[Linux] 리눅스 파일 실습

mommers·2026년 2월 2일

Linux

목록 보기
31/59


파일과 디렉토리 정보

1. 파일 I/O 방식 비교: System Call vs Standard Library

리눅스에서 파일을 다루는 방법은 크게 커널을 직접 호출(System Call)하거나, C 표준 라이브러리(Wrapper)를 사용하는 것으로 나뉩니다.

구분System Call (저수준)Standard Library (고수준)
함수open, read, write, closefopen, fread, fwrite, fclose
식별자File Descriptor (int fd)*File Stream (FILE fp)
버퍼링없음 (Unbuffered, 직접 커널 요청)있음 (Buffered, 성능 최적화)
이식성리눅스/유닉스 계열 종속모든 C 언어 지원 OS 호환
헤더<fcntl.h>, <unistd.h><stdio.h>

2. 주요 System Call 및 플래그 (open)

  • <fcntl.h>, <unistd.h> 추가 해야함

함수 원형

int open(const char *pathname, int flags, mode_t mode);
ssize_t write(int fd, const void *buf, size_t count);
ssize_t read(int fd, void *buf, size_t count);

주요 Flags -> fcntl.h 에 포함

O_RDONLY (읽기), O_WRONLY (쓰기), O_RDWR (읽기/쓰기)

  • O_CREAT: 파일 없으면 생성 (생성 시 mode 권한 필수).
  • O_EXCL: O_CREAT와 함께 사용. 파일이 이미 있으면 에러(덮어쓰기 방지).
  • O_TRUNC: 파일이 이미 있으면 내용을 싹 지우고 크기를 0으로 만듦.
  • O_APPEND: 파일 끝에 내용 추가.

권한 설정 (Permission) & umask

0664: User(RW), Group(RW), Other(R).
코드에서 0664를 줘도, 실제 파일 권한은 0664 & ~umask로 결정됨.

  • 일반 유저 umask: 보통 0002 → 결과 0664

  • Root(sudo) umask: 보통 0022 → 결과 0644. (예제에서 sudo 실행 시 권한이 달라진 이유)

    Umask의미최종 권한 (파일/디렉터리)사용처
    0000아무것도 안 뺌 (완전 개방)666 / 777개발/테스트용 (위험)
    0002Other의 쓰기만 뺌664 / 775일반 사용자 기본값 (같은 그룹끼리 협업 용이)
    0022Group, Other의 쓰기 뺌644 / 755Root / 서버 기본값 (나만 수정 가능)
    0077나 빼고 전부 차단600 / 700개인 키, 보안 설정 파일 (.ssh 등)

3. 코드 예제 (Struct & Binary I/O)

로깅, 프로세스 잠금, 논블로킹, 리다이렉션을 통해 open 플래그와 주요 시스템 콜 활용법을 정리했습니다.


1. 로그 파일 기록 (Atomic Append)

서버나 데몬에서 로그를 남길 때, 여러 프로세스가 동시에 써도 내용이 덮어쓰기되거나 꼬이지 않게 하는 것이 핵심입니다.

O_APPEND : 커널 레벨에서 쓰기 직전, 파일 포인터를 무조건 파일의 끝으로 이동시킴 (Atomic Operation). 경쟁 상태(Race Condition) 방지.

#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

void write_log(const char *msg) {
    // O_APPEND: 쓰기 시 무조건 파일 끝에 붙임 (동시성 보장)
    // 0644: 소유자 RW, 그룹/기타 R
    int fd = open("system.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
    if (fd == -1) {
        perror("log open fail");
        return;
    }
    
    // 실제로는 timestamp 등을 붙여서 write함
    write(fd, msg, strlen(msg));
    write(fd, "\n", 1);
    
    close(fd);
}

int main() {
    write_log("[INFO] System Started");
    write_log("[WARN] Memory usage high");
    return 0;
}

file_log.c에 대한 로그 system.log가 생성된 것을 확인할 수 있고, ls -al 명령어를 통해 system.log가775 -> 644 로 권한이 바뀐 것을 확인할 수 있다.


위(Raspi), 아래(wsl) 환경에서 file file_log, file file_log_wsl을 비교해보면

// 라즈베리파이5
pi@pi-222:~/project/linux_system $ file file_log
file_log: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, BuildID[sha1]=1753207b9ae31e16b7b3de9a38ad1135a42b880f, for GNU/Linux 3.7.0, with debug_info, not stripped

// wsl
mo@DESKTOP-HMRIDMH:~/project/linux_system$ file file_log_wsl 
file_log_wsl: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=b116cbfe4854e154f043b34b8980028414f406df, for GNU/Linux 3.2.0, not stripped

timestamp 붙여서 write함

write() 시스템 콜은 printf처럼 포맷팅(%d, %s) 기능이 없습니다. 오직 "바이트 배열"만 처리합니다.

따라서, sprintf 계열 함수를 사용하여 [시간 + 메시지]를 하나의 문자열 버퍼로 합친 뒤, 그 버퍼를 write() 해야 합니다.

방법 1: snprintf + write (표준적인 방식)

메모리 버퍼에 내용을 미리 완성한 뒤 파일에 씁니다. 가장 일반적입니다.

#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <time.h>   // 시간 관련 헤더

void write_log_with_time(const char *msg) {
    int fd = open("system.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
    if (fd == -1) return;

    // 1. 현재 시간 구하기
    time_t now = time(NULL);
    struct tm t;
    localtime_r(&now, &t); // Thread-safe한 버전 사용 권장

    // 2. 시간 포맷팅 (예: 2026-01-31 14:00:00)
    char time_str[64];
    strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S", &t);

    // 3. 최종 로그 문자열 만들기 (시간 + 메시지 + 개행)
    char log_buf[1024];
    // snprintf: 버퍼 오버플로우 방지 (안전함)
    int len = snprintf(log_buf, sizeof(log_buf), "[%s] %s\n", time_str, msg);

    // 4. 한번에 쓰기 (Atomic write 효과)
    if (len > 0) {
        write(fd, log_buf, len);
    }
    
    close(fd);
}

방법 2: dprintf 사용 (Linux/POSIX 전용 꿀팁)

리눅스 시스템 프로그래밍에서는 dprintf (Descriptor printf)를 지원합니다. fd에 직접 printf 포맷을 쏠 수 있어 코드가 훨씬 간결해집니다.

#define _POSIX_C_SOURCE 200809L // dprintf 사용을 위한 매크로
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <time.h>

void write_log_simple(const char *msg) {
    int fd = open("system.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
    if (fd == -1) return;

    time_t now = time(NULL);
    struct tm t;
    localtime_r(&now, &t);

    char time_str[64];
    strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S", &t);

    // ★ 핵심: write() 대신 printf처럼 사용 가능
    dprintf(fd, "[%s] %s\n", time_str, msg);

    close(fd);
}

핵심 함수 요약

  • time(): 현재 유닉스 타임스탬프(초 단위) 가져오기.
  • localtime_r(): 타임스탬프를 년/월/일 구조체(struct tm)로 변환. (_r이 붙어야 멀티스레드에서 안전).
  • strftime(): 구조체를 예쁜 문자열("2026-01-31")로 변환.
  • dprintf(): fd에 포맷팅 문자열을 바로 쏘는 함수.

2. 단일 인스턴스 실행 보장 (PID Lock File)

프로그램이 중복 실행되는 것을 막기 위해 PID 파일을 생성합니다. 파일 존재 여부 확인과 생성이 동시에(Atomic) 이루어져야 합니다.

  • 핵심 플래그: O_EXCL
    • O_CREAT와 함께 사용 시, "파일이 없으면 만들고, 있으면 즉시 에러(EEXIST) 리턴"함.
    • if (access) { open } 방식의 경쟁 상태(TOCTOU) 보안 취약점을 해결.
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h> // dprintf 사용시 필요할 수 있음

#define PID_FILE "/var/run/myapp.pid"

int main() {
    // 1. PID 파일 열기 (O_EXCL: 이미 있으면 실패)
    int fd = open(PID_FILE, O_WRONLY | O_CREAT | O_EXCL, 0644);
    
    if (fd == -1) {
        if (errno == EEXIST) {
            fprintf(stderr, "[Error] 프로그램이 이미 실행 중입니다! (PID 파일 존재)\\n");
            exit(1);
        }
        perror("open failed"); // 권한 문제 등 다른 에러
        exit(1);
    }

    // 2. 내 PID 기록
    dprintf(fd, "%d\\n", getpid());
    
    // 3. 실행 상태 유지 (테스트를 위해 무한 대기)
    printf("프로그램 시작됨. (PID: %d)\\n", getpid());
    printf("종료하려면 'kill %d' 또는 Ctrl+C를 누르세요.\\n", getpid());
    
    while(1) {
        sleep(10);
    }
    
    // (참고) 강제 종료 시 이 부분은 실행 안 됨 -> 파일이 남음 (단점)
    close(fd);
    return 0;
}

테스트 시나리오 (터미널 2개 사용)

gcc single_proc.c -o single_proc

① 첫 번째 실행 (성공 케이스)

/var/run에 파일을 써야 하므로 sudo가 필수입니다.

Bash

# 터미널 A
sudo ./single_proc
결과:프로그램 시작됨. (PID: 1234) 가 뜨고 멈춰있음.

② 두 번째 실행 (중복 방지 확인)

새 터미널을 열거나, 기존 터미널에서 백그라운드로 실행 후 재시도합니다.

Bash

# 터미널 B (또는 새 창)
sudo ./single_proc

결과:[Error] 프로그램이 이미 실행 중입니다! (PID 파일 존재) 라고 뜨고 즉시 종료됨.

정리 및 파일 확인

이 방식의 단점(파일이 남는 문제)을 확인해 봅니다.

  1. 실행 중인 프로세스 강제 종료 (Ctrl+C).
  2. 다시 실행 시도:Bash
sudo ./single_proc

결과: 프로세스가 없는데도 [Error] ... 가 뜨며 실행이 안 됨. (좀비 파일 문제)

  1. 수동 해결:Bash

sudo rm /var/run/myapp.pid
지워줘야 다시 실행됩니다. (그래서 flock 방식을 더 추천함).


flock()함수

"잠금 파일(Lock File)을 만들고 flock() 함수로 침(Flag)을 발라라."

가장 확실하고 안전한 방법은 파일 잠금(File Locking) 기능을 사용하는 것입니다.

추천 방법: flock() 사용

운영체제 차원에서 파일에 "사용 중" 표시를 남기는 방식입니다.
가장 큰 장점은 프로그램이 비정상 종료(Crash, Kill)되어도 OS가 자동으로 잠금을 해제해줍니다. (좀비 잠금 파일이 남지 않음).

코드 구현 (C/Linux)

C

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/file.h> // flock을 위해 필요
#include <errno.h>

#define LOCK_FILE "/var/run/myapp.lock" // 보통 /var/run 또는 /tmp 사용

int main() {
    // 1. 잠금 파일 열기 (없으면 생성)
    int fd = open(LOCK_FILE, O_RDWR | O_CREAT, 0666);
    if (fd < 0) {
        perror("Open lock file failed");
        exit(1);
    }

    // 2. 잠금 시도 (핵심!)
    // LOCK_EX: 배타적 잠금 (나만 쓸 거야)
    // LOCK_NB: Non-Blocking (이미 잠겨있으면 기다리지 말고 바로 에러 리턴해)
    if (flock(fd, LOCK_EX | LOCK_NB) == -1) {
        if (errno == EWOULDBLOCK) {
            fprintf(stderr, ">>> 프로그램이 이미 실행 중입니다! <<<\n");
            exit(1); // 중복 실행이므로 종료
        } else {
            perror("Lock failed");
            exit(1);
        }
    }

    // 3. 성공 시: 이후 정상 로직 수행
    printf("프로그램 시작... (PID: %d)\n", getpid());
    
    // 프로그램이 도는 동안 fd를 닫으면 안 됨!
    while(1) {
        sleep(10); 
    }

    return 0;
}

동작 원리

  1. 첫 번째 실행: flock이 성공하고 파일을 붙잡음.
  2. 두 번째 실행: flock을 시도했으나, 이미 첫 번째 놈이 잡고 있어서 EWOULDBLOCK 에러 발생 → 즉시 종료.
  3. 종료 시: 프로세스가 죽으면 OS가 fd를 회수하면서 잠금도 자동으로 풀림.

비추천 방법 (예전 방식)

  • PID 파일 존재 여부 체크 (O_EXCL):
    • 파일이 있으면 실행 불가로 판단.
    • 치명적 단점: 프로그램이 버그로 뻗어버리면 파일이 그대로 남아서, 재부팅 전까지 다시 실행 못하는 상황 발생 (수동으로 지워줘야 함).

3. 논블로킹 읽기 (Non-blocking I/O)

키보드 입력, 파이프, 소켓 등에서 데이터가 없으면 기다리지 않고 다른 일을 하러 가야 할 때 사용합니다. (이벤트 루프 방식).

  • 핵심 플래그: O_NONBLOCK
    • read 호출 시 데이터가 없으면 대기(Block)하지 않고 즉시 1 리턴하며 errnoEAGAIN으로 설정.
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>

int main() {
    char buf[128];
    // 표준 입력(0)을 논블로킹 모드로 다시 열기 (또는 fcntl 사용 가능)
    // 예제 편의상 FIFO나 디바이스 파일이라 가정
    int fd = open("/dev/tty", O_RDONLY | O_NONBLOCK); 

    while (1) {
        ssize_t ret = read(fd, buf, sizeof(buf));
        
        if (ret > 0) {
            printf("Read data: %.*s\n", (int)ret, buf);
        } else if (ret == -1) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // 데이터가 없음. 에러가 아님.
                printf("No data... doing other tasks...\n");
                sleep(1); // 다른 작업 시뮬레이션
                continue;
            }
            perror("read error");
            break;
        }
    }
    close(fd);
    return 0;
}

/dev/tty

정확히 말하면 "키보드 그 자체(H/W)"는 아니지만, "프로세스와 연결된 터미널 창(S/W)"을 의미합니다.
하지만 결과적으로 "키보드 입력을 받아오는 통로" 역할을 합니다.

1. /dev/tty의 정체: "마법의 거울 (Alias)"

  • 정의: 현재 프로세스를 제어하고 있는 터미널(Controlling Terminal)을 가리키는 특수 파일(Alias)입니다.
  • 동작:
    • 내가 터미널 1(pts/0)에서 실행하면 → /dev/tty/dev/pts/0을 가리킵니다.
    • 내가 터미널 2(pts/1)에서 실행하면 → /dev/tty/dev/pts/1을 가리킵니다.
  • 결과: 어느 창에서 실행하든 상관없이 "지금 사용자가 보고 있는 그 화면과 키보드"에 연결해 줍니다.

2. 데이터 흐름 (키보드 → /dev/tty)

물리적 키보드가 /dev/tty까지 도달하는 과정입니다.

  1. Hardware: 키보드 누름.
  2. Kernel Driver: /dev/input/eventX (Raw 데이터 처리).
  3. Terminal Emulator: (예: Putty, VSCode 터미널) 입력을 받아 텍스트로 변환.
  4. TTY Driver: 입력된 문자를 버퍼에 담음.
  5. /dev/tty: 애플리케이션이 여기서 read()를 하면 버퍼에 담긴 키보드 글자를 가져옴.

3. 왜 stdin(0번) 대신 /dev/tty를 썼나요?

보통 stdin도 키보드와 연결되어 있지만, 리다이렉션될 경우 키보드가 아니게 됩니다.

  • scanf / read(0):
    • 명령어: ./app < input.txt
    • 결과: 키보드가 아니라 파일(input.txt) 내용을 읽어버립니다.
  • open("/dev/tty"):
    • 명령어: ./app < input.txt
    • 결과: 표준 입력은 파일로 바뀌었어도, /dev/tty는 여전히 사용자의 키보드/터미널을 가리킵니다.
    • 용도: "비밀번호 입력"이나 "계속하시겠습니까?(Y/n)" 처럼 반드시 사람의 직접 입력을 받아야 할 때 사용합니다.

4. 표준 출력 리다이렉션 (Daemon Log)

printf로 찍는 모든 내용을 터미널이 아닌 특정 파일로 돌릴 때 사용합니다. (데몬 프로세스 만들 때 필수).

  • 핵심 시스템 콜: dup2(old_fd, new_fd)
    • new_fd(예: 1번 stdout)를 닫고, old_fd(로그 파일)를 복제하여 덮어씌움.
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    int fd = open("daemon.out", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    // 화면(1)으로 나갈 출력을 파일(fd)로 바꿔치기
    // 이제 1번 fd는 파일(fd)을 가리킴
    if (dup2(fd, STDOUT_FILENO) == -1) {
        perror("dup2");
        return 1;
    }
    
    // 원본 fd는 필요 없으니 닫음 (복제본이 1번에 살아있음)
    close(fd);

    // printf는 stdout(1번)에 쓰지만, 실제로는 파일에 저장됨
    printf("This message goes to the file, not the screen.\n");
    printf("Redirect success!\n");

    return 0;
}

dup2(old_fd, new_fd)했을 떄 원본 fd를 닫아도 되는 이유?

"커널 내부의 '실제 파일 객체'는 공유되고, '참조 카운트(Reference Count)'가 0이 될 때까지 사라지지 않기 때문입니다."

dup2는 파일 내용을 복사하는 것이 아니라, "같은 파일을 가리키는 화살표(포인터) 하나를 더 만드는 것"입니다.

1. 동작 원리 (Reference Counting)

리눅스 커널은 열린 파일을 관리할 때 참조 카운트(Reference Count) 방식을 사용합니다.

  1. open() 실행 시:
    • 커널에 struct file 객체가 생성됩니다.
    • old_fd가 이 객체를 가리킵니다.
    • 참조 카운트 = 1
  2. dup2(old_fd, new_fd) 실행 시:
    • new_fdold_fd가 가리키던 그 객체를 똑같이 가리키게 됩니다.
    • 커널은 "이 파일을 보는 녀석이 둘이네?" 하고 카운트를 올립니다.
    • 참조 카운트 = 2
  3. close(old_fd) 실행 시:
    • old_fd와 파일 객체의 연결을 끊습니다.
    • 참조 카운트를 1 내립니다.
    • 참조 카운트 = 1
    • 결과: 카운트가 0이 아니므로 파일 객체는 소멸되지 않고 살아있습니다. new_fd를 통해 여전히 접근 가능합니다.

2. 비유: TV 리모컨

  • 파일: 거실에 있는 TV.
  • old_fd: 원래 가지고 있던 리모컨 A.
  • dup2: 리모컨 A를 복제해서 리모컨 B(new_fd)를 만듦.
  • close(old_fd): 리모컨 A를 버림.
  • 결과: 리모컨 A를 버렸다고 해서 TV가 꺼지거나 리모컨 B가 작동을 안 하는 것은 아닙니다.

3. 왜 굳이 닫나요? (리소스 관리)

안 닫아도 동작은 하지만, 닫는 것이 좋은 습관입니다.

  • 낭비 방지: 프로세스가 가질 수 있는 fd의 개수(기본 1024개)는 제한되어 있습니다.
  • 깔끔함: dup2를 통해 stdout(1번)으로 복제했다면, 원본인 fd(3번)는 더 이상 필요 없으므로 닫아서 번호를 반환하는 것이 시스템 리소스 관리에 유리합니다.

1. inode == dup2 ?

관계가 있긴 하지만 dup2는 Inode보다 한 단계 위인 '오픈 파일 테이블'을 공유하게 만듦

엄밀히 말하면 dup2Inode를 직접 건드리는 것이 아니라, 중간 다리 역할인 'Open File Description(파일 구조체)'을 공유합니다. 물론 결과적으로는 같은 Inode를 가리키게 됩니다.
이 관계를 3단계 구조로 이해하는 것이 핵심입니다.

단계명칭역할dup2 시 동작
Level 1FD Table (프로세스 전용)사용자가 쓰는 번호표 (int fd)새 번호표 발급 (예: 1번이 3번 복제)
Level 2Open File Table (커널 전역)현재 읽는 위치(Offset), 모드(R/W), 참조 카운트기존 것을 공유함 (새로 안 만듦!)
Level 3Inode Table (파일 시스템)실제 파일 메타데이터, 데이터 블록 위치같은 놈을 가리킴

2. dup2의 비밀: "Level 2를 공유한다"

dup2(old_fd, new_fd)를 실행하면 다음과 같은 일이 벌어집니다.

  1. Level 1 (FD): 서로 다른 번호 (old_fd: 3, new_fd: 4)를 가집니다.
  2. Level 2 (Open File): 두 FD가 하나의 구조체(struct file)를 동시에 가리킵니다.
    • 중요: 따라서 파일 오프셋(Offset)도 공유됩니다.
    • fd 3으로 읽어서 오프셋이 이동하면, fd 4도 이동된 위치부터 읽습니다.
  3. Level 3 (Inode): 당연히 같은 Inode를 봅니다.

3. 비교: open()을 두 번 한 경우 vs dup2()

가장 많이 혼동하는 부분입니다.

  • Case A: dup2 (복제)
    • 상황: fd1 = open(...), fd2 = dup(fd1)
    • 관계: FD는 다르지만 Level 2(오프셋)를 공유.
    • Inode: 같음.
    • 특징: fd1에서 읽으면 fd2의 읽기 위치도 바뀜.
  • Case B: open (독립적 열기)
    • 상황: fd1 = open(...), fd2 = open(...) (같은 파일)
    • 관계: FD 다르고, Level 2(오프셋)도 서로 다름 (독립적).
    • Inode: 같음 (물리적 파일은 하나니까).
    • 특징: fd1이 파일을 읽든 말든, fd2는 자기만의 위치(0)에서 시작함.

4. 결론

"원본 FD를 닫아도 되는 이유"를 Inode 관점에서 다시 설명하면:

dup2로 인해 Level 2 객체를 붙잡고 있는 녀석(참조 카운트)이 2명이 되었기 때문에,
원본 FD가 close 되어 하나가 떠나도, 복제된 FD가 Level 2 객체(와 그 아래 연결된 Inode)를 꽉 잡고 있어서 안전한 것입니다.

profile
임베디드 개발자가 되기 위해 공부중입니다!

0개의 댓글