
리눅스에서 파일을 다루는 방법은 크게 커널을 직접 호출(System Call)하거나, C 표준 라이브러리(Wrapper)를 사용하는 것으로 나뉩니다.
| 구분 | System Call (저수준) | Standard Library (고수준) |
|---|---|---|
| 함수 | open, read, write, close | fopen, fread, fwrite, fclose |
| 식별자 | File Descriptor (int fd) | *File Stream (FILE fp) |
| 버퍼링 | 없음 (Unbuffered, 직접 커널 요청) | 있음 (Buffered, 성능 최적화) |
| 이식성 | 리눅스/유닉스 계열 종속 | 모든 C 언어 지원 OS 호환 |
| 헤더 | <fcntl.h>, <unistd.h> | <stdio.h> |
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);
O_RDONLY (읽기), O_WRONLY (쓰기), O_RDWR (읽기/쓰기)
O_CREAT: 파일 없으면 생성 (생성 시 mode 권한 필수).O_EXCL: O_CREAT와 함께 사용. 파일이 이미 있으면 에러(덮어쓰기 방지).O_TRUNC: 파일이 이미 있으면 내용을 싹 지우고 크기를 0으로 만듦.O_APPEND: 파일 끝에 내용 추가.0664: User(RW), Group(RW), Other(R).
코드에서 0664를 줘도, 실제 파일 권한은 0664 & ~umask로 결정됨.
일반 유저 umask: 보통 0002 → 결과 0664
Root(sudo) umask: 보통 0022 → 결과 0644. (예제에서 sudo 실행 시 권한이 달라진 이유)
| Umask | 의미 | 최종 권한 (파일/디렉터리) | 사용처 |
|---|---|---|---|
| 0000 | 아무것도 안 뺌 (완전 개방) | 666 / 777 | 개발/테스트용 (위험) |
| 0002 | Other의 쓰기만 뺌 | 664 / 775 | 일반 사용자 기본값 (같은 그룹끼리 협업 용이) |
| 0022 | Group, Other의 쓰기 뺌 | 644 / 755 | Root / 서버 기본값 (나만 수정 가능) |
| 0077 | 나 빼고 전부 차단 | 600 / 700 | 개인 키, 보안 설정 파일 (.ssh 등) |
로깅, 프로세스 잠금, 논블로킹, 리다이렉션을 통해 open 플래그와 주요 시스템 콜 활용법을 정리했습니다.
서버나 데몬에서 로그를 남길 때, 여러 프로세스가 동시에 써도 내용이 덮어쓰기되거나 꼬이지 않게 하는 것이 핵심입니다.
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
write() 시스템 콜은 printf처럼 포맷팅(%d, %s) 기능이 없습니다. 오직 "바이트 배열"만 처리합니다.
따라서, sprintf 계열 함수를 사용하여 [시간 + 메시지]를 하나의 문자열 버퍼로 합친 뒤, 그 버퍼를 write() 해야 합니다.
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);
}
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에 포맷팅 문자열을 바로 쏘는 함수.프로그램이 중복 실행되는 것을 막기 위해 PID 파일을 생성합니다. 파일 존재 여부 확인과 생성이 동시에(Atomic) 이루어져야 합니다.
O_EXCLO_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;
}
gcc single_proc.c -o single_proc
/var/run에 파일을 써야 하므로 sudo가 필수입니다.
Bash
# 터미널 A
sudo ./single_proc
결과:프로그램 시작됨. (PID: 1234) 가 뜨고 멈춰있음.
새 터미널을 열거나, 기존 터미널에서 백그라운드로 실행 후 재시도합니다.
Bash
# 터미널 B (또는 새 창)
sudo ./single_proc
결과:[Error] 프로그램이 이미 실행 중입니다! (PID 파일 존재) 라고 뜨고 즉시 종료됨.
이 방식의 단점(파일이 남는 문제)을 확인해 봅니다.
sudo ./single_proc
결과: 프로세스가 없는데도 [Error] ... 가 뜨며 실행이 안 됨. (좀비 파일 문제)
sudo rm /var/run/myapp.pid
지워줘야 다시 실행됩니다. (그래서 flock 방식을 더 추천함).
"잠금 파일(Lock File)을 만들고 flock() 함수로 침(Flag)을 발라라."
가장 확실하고 안전한 방법은 파일 잠금(File Locking) 기능을 사용하는 것입니다.
flock() 사용운영체제 차원에서 파일에 "사용 중" 표시를 남기는 방식입니다.
가장 큰 장점은 프로그램이 비정상 종료(Crash, Kill)되어도 OS가 자동으로 잠금을 해제해줍니다. (좀비 잠금 파일이 남지 않음).
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;
}
flock이 성공하고 파일을 붙잡음.flock을 시도했으나, 이미 첫 번째 놈이 잡고 있어서 EWOULDBLOCK 에러 발생 → 즉시 종료.fd를 회수하면서 잠금도 자동으로 풀림.O_EXCL):키보드 입력, 파이프, 소켓 등에서 데이터가 없으면 기다리지 않고 다른 일을 하러 가야 할 때 사용합니다. (이벤트 루프 방식).
O_NONBLOCKread 호출 시 데이터가 없으면 대기(Block)하지 않고 즉시 1 리턴하며 errno를 EAGAIN으로 설정.#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;
}
정확히 말하면 "키보드 그 자체(H/W)"는 아니지만, "프로세스와 연결된 터미널 창(S/W)"을 의미합니다.
하지만 결과적으로 "키보드 입력을 받아오는 통로" 역할을 합니다.
/dev/tty의 정체: "마법의 거울 (Alias)"pts/0)에서 실행하면 → /dev/tty는 /dev/pts/0을 가리킵니다.pts/1)에서 실행하면 → /dev/tty는 /dev/pts/1을 가리킵니다./dev/tty)물리적 키보드가 /dev/tty까지 도달하는 과정입니다.
/dev/input/eventX (Raw 데이터 처리)./dev/tty: 애플리케이션이 여기서 read()를 하면 버퍼에 담긴 키보드 글자를 가져옴.stdin(0번) 대신 /dev/tty를 썼나요?보통 stdin도 키보드와 연결되어 있지만, 리다이렉션될 경우 키보드가 아니게 됩니다.
scanf / read(0):./app < input.txtinput.txt) 내용을 읽어버립니다.open("/dev/tty"):./app < input.txt/dev/tty는 여전히 사용자의 키보드/터미널을 가리킵니다.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;
}
"커널 내부의 '실제 파일 객체'는 공유되고, '참조 카운트(Reference Count)'가 0이 될 때까지 사라지지 않기 때문입니다."
dup2는 파일 내용을 복사하는 것이 아니라, "같은 파일을 가리키는 화살표(포인터) 하나를 더 만드는 것"입니다.
리눅스 커널은 열린 파일을 관리할 때 참조 카운트(Reference Count) 방식을 사용합니다.
open() 실행 시:struct file 객체가 생성됩니다.old_fd가 이 객체를 가리킵니다.dup2(old_fd, new_fd) 실행 시:new_fd도 old_fd가 가리키던 그 객체를 똑같이 가리키게 됩니다.close(old_fd) 실행 시:old_fd와 파일 객체의 연결을 끊습니다.new_fd를 통해 여전히 접근 가능합니다.안 닫아도 동작은 하지만, 닫는 것이 좋은 습관입니다.
dup2를 통해 stdout(1번)으로 복제했다면, 원본인 fd(3번)는 더 이상 필요 없으므로 닫아서 번호를 반환하는 것이 시스템 리소스 관리에 유리합니다.관계가 있긴 하지만 dup2는 Inode보다 한 단계 위인 '오픈 파일 테이블'을 공유하게 만듦
엄밀히 말하면 dup2는 Inode를 직접 건드리는 것이 아니라, 중간 다리 역할인 'Open File Description(파일 구조체)'을 공유합니다. 물론 결과적으로는 같은 Inode를 가리키게 됩니다.
이 관계를 3단계 구조로 이해하는 것이 핵심입니다.
| 단계 | 명칭 | 역할 | dup2 시 동작 |
|---|---|---|---|
| Level 1 | FD Table (프로세스 전용) | 사용자가 쓰는 번호표 (int fd) | 새 번호표 발급 (예: 1번이 3번 복제) |
| Level 2 | Open File Table (커널 전역) | 현재 읽는 위치(Offset), 모드(R/W), 참조 카운트 | 기존 것을 공유함 (새로 안 만듦!) |
| Level 3 | Inode Table (파일 시스템) | 실제 파일 메타데이터, 데이터 블록 위치 | 같은 놈을 가리킴 |
dup2의 비밀: "Level 2를 공유한다"dup2(old_fd, new_fd)를 실행하면 다음과 같은 일이 벌어집니다.
old_fd: 3, new_fd: 4)를 가집니다.struct file)를 동시에 가리킵니다.fd 3으로 읽어서 오프셋이 이동하면, fd 4도 이동된 위치부터 읽습니다.open()을 두 번 한 경우 vs dup2()가장 많이 혼동하는 부분입니다.
dup2 (복제)fd1 = open(...), fd2 = dup(fd1)fd1에서 읽으면 fd2의 읽기 위치도 바뀜.open (독립적 열기)fd1 = open(...), fd2 = open(...) (같은 파일)fd1이 파일을 읽든 말든, fd2는 자기만의 위치(0)에서 시작함."원본 FD를 닫아도 되는 이유"를 Inode 관점에서 다시 설명하면:
dup2로 인해 Level 2 객체를 붙잡고 있는 녀석(참조 카운트)이 2명이 되었기 때문에,
원본 FD가 close 되어 하나가 떠나도, 복제된 FD가 Level 2 객체(와 그 아래 연결된 Inode)를 꽉 잡고 있어서 안전한 것입니다.