
리눅스는 모든 자원을 파일처럼 관리하는 것이 특징이다.
이번 글에서는 리눅스 프로그래밍의 기본 개념과 파일 입출력에 관해 다룬다.
이전에 했던 내용과 중복되는 개념도 있지만 그만큼 잘 알아두면 도움이 될 것이다.
리눅스 시스템 프로그래밍의 정의
리눅스 운영체제(OS)가 제공하는 시스템 콜과 POSIX API를 사용해 user space에서 시스템 자원을 직접 제어하는 프로그래밍
시스템 콜 : 사용자 프로그램이 커널 기능(파일 열기, 프로세스 생성 등)을 요청하는 인터페이스
사용자 공간에서 커널 자원 접근 → open/read/write, fork/exec, socket 등
학습 환경
| 환경 | OS | 용도 |
|---|---|---|
| LSP @ Ubuntu 20.04 @ Vbox @ Windows | Ubuntu (x86_64) | 가상화로 데스크톱에서 리눅스 프로그래밍 연습 |
| LSP @ Raspbian @ RPi | Raspbian (ARM) | 라즈베리파이 실물 하드웨어 + GPIO 제어 실습 |
리눅스 디바이스 프로그래밍
| 구분 | 리눅스 시스템 프로그래밍 (LSP) | 리눅스 디바이스 프로그래밍 |
|---|---|---|
| 위치 | 사용자 공간 (User Space) | 커널 공간 (Kernel Space) |
| 목적 | OS API로 시스템 자원 제어 | 새 디바이스 드라이버 개발 |
| 예제 | open("/sys/class/gpio/export") | GPIO 드라이버 module_init() |
| 컴파일 | gcc app.c -o app | make modules + insmod |
| 실행 | sudo ./app | dmesg로 커널 로그 확인 |
| 대표 API | fork, socket, pthread | platform_driver, file_operations |
아키텍처의 차이
ARM/PPC(임베디드, 라즈베리파이) 등 PC가 아닌 곳에서 Linux를 사용하는 것을 일반적으로 임베디드 리눅스라 지칭함
임베디드 리눅스 필요 요소
Bootloader (U-Boot)
전원이 켜지면 가장 먼저 실행되는 코드
하드웨어 초기화(GPU 끌기, 메모리 설정) 후 커널 로드
커널 이미지
make → vmlinux (실행파일, 10MB+ 매우 큼)
make Image → 압축된 커널 (vmlinux 압축)
make zImage → ARM용 최종 압축 이미지 (라즈베리파이 부팅용, kernel.org에서 다운로드 가능)
Device Tree
CPU가 하드웨어의 메모리, GPIO, UART 위치를 알 수 있도록 바이너리 파일, 커널과 함께 부팅
Root Filesystem(ext4)
/ (루트) 디렉터리를 사용하는 파일 시스템
Mount
커널이 파일시스템을 메모리에 올리고 / 경로로 연결하는 과정
라즈베리파이에서는 SD카드의 2번째 파티션(ext4)을 /로 마운트
System Call
사용자 프로그램(Process)이 파일이나 네트워크 등 커널의 자원을 요청할 때 발생
User Space와 Kernel Space의 경계에서 일어남
ARM(라즈베리파이)의 System Call
SVC (Supervisor Call) 명령어
사용자 모드에서 SVC #0 실행 → 커널 모드로 전환 → 시스템 호출 번호 확인 → 작업 수행 → 결과 반환
고수준 입출력
FILE 구조체를 사용하고, User Space에서 동작
내부적으로 버퍼링과 오류 처리 등을 하므로 편리하지만 속도가 느
C의 경우 fopen / fread / fwrite / fclose
FILE *fp : 사용자 공간에 있는 구조체(버퍼, 파일 위치, 상태 정보 포함)
fread: 내부 버퍼에서 먼저 읽고, 부족하면 read() 시스템 호출
저수준 입출력
정수 파일 디스크립터(fd)를 사용하고, Kernel Space에서 동작
직접 커널 호출(ARM의 SVC)을 하므로 빠르고, 디바이스 드라이버 개발에 필수
C의 경우 open / read / write / close
int fd : 커널이 할당한 파일 디스크립터(0:stdin, 1:stdout, 2:stderr)
open : ARM SVC 호출 → 커널 파일시스템 접근 → fd 반환
Everything is a file
Unix에서는 모든 자원을 파일처럼 open/read/write/close 의 매커니즘으로 동일하게 처리함
하드웨어의 제어가 C 파일 I/O처럼 편리하게 이루어짐
하드디스크
예: /dev/hda1, /dev/hda2
디렉토리
키보드, 모니터
예: /dev/stdin, /dev/stdout
네트워크 카드, 사운드 카드
시스템 정보(kernel)
각 프로세스 또한 디렉토리로 접근 가능
예: /proc , cpuinfo
드라이버 정보(device driver)
예: /sys
파일의 종류
| 종류 | 표시 | 설명 |
|---|---|---|
| 일반 파일 | - | 데이터 저장 (텍스트, 바이너리, 실행파일) |
| 디렉토리 | d | 파일/디렉토리 목록 |
| 심볼릭 링크 | l | 다른 파일/디렉토리 가리키는 텍스트 경로 |
| 하드 링크 | - | 동일 inode 공유 (같은 데이터 여러 이름) |
| 디바이스 | b/c | 하드웨어 |
| 소켓 | s | 네트워크/IPC 통신 |
| 파이프 | p | 프로세스 간 데이터 전달 |
파일 열고 사용하는 순서
open → read/write → close
File open system call
파일의 존재 여부 확인
파일이 존재하지 않을 경우 생성 여부 결정
파일에 대한 권한 확인
open() 시스템 콜을 이용한 파일 열기
파일을 오픈하거나 생성할 때 사용하는 시스템 콜
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int open(const char *pathname, int oflag, mode_t mode)
파일 오픈 플래그
| 플래그 | 의미 | 라즈베리파이 예시 |
|---|---|---|
O_RDONLY | 읽기 전용 | /proc/cpuinfo |
O_WRONLY | 쓰기 전용 | /sys/class/gpio/gpio17/value |
O_RDWR | 읽기+쓰기 | /home/pi/data.txt |
O_CREAT | 없으면 생성 (mode 필요) | 로그 파일 |
O_TRUNC | 열 때 내용 삭제 (크기 0) | 새 로그 시작 |
O_APPEND | 끝에 추가 | 로그 파일 |
파일 모드
O_CREAT 사용할 때 세 번째 인자로 파일 모드(8진수)를 통해 권한 지정 가능
open("new.txt", O_RDWR | O_CREAT, 0666); // rw-rw-rw- (umask 적용 후 0644)
open("gpio.txt", O_WRONLY | O_CREAT, 0600); // rw------- (root만)
파일 디스크립터 (fd)
커널이 프로세스별로 할당하는 정수 값
open 을 처음 호출하면 3번부터 255번 사이의 가장 낮은 빈 번호를 할당
File read system call read()
파일 디스크립터(fd)에서 지정된 바이트 수만큼 데이터를 사용자 버퍼로 복사하는 함수
파일로부터 데이터를 읽기 위한 시스템 콜 함수
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
fd : open() 반환값으로 받은 파일 디스크립터 번호 (stdin=0, 파일=3 등)
buf : 읽은 데이터를 저장할 사용자 공간 버퍼
count : 요청 바이트 수
| 반환값 | 의미 |
|---|---|
>0 | 실제 읽은 바이트 수 (count 이하) |
0 | EOF (파일 끝, 파이프 종료) |
-1 | 오류, errno 확인 |
예제
memset : 버퍼를 초기화할 때 사용하면 쓰레기값을 방지
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main()
{
int fd = open("/home/pi/data.txt", O_RDONLY);
if (fd == -1)
{
perror("open");
return 1;
}
char buf[256];
memset(buf, 0, sizeof(buf)); // 버퍼 초기화
ssize_t n = read(fd, buf, sizeof(buf)-1); // 최대 255바이트 읽기
if (n == -1)
{
perror("read"); // EAGAIN, EINTR 등
}
else if (n == 0)
{
printf("파일 끝 (EOF)\n");
}
else
{
buf[n] = '\0'; // 문자열 종료
printf("읽음 %zd 바이트: %s\n", n, buf);
}
close(fd);
return 0;
}
Non-blocking 읽기 옵션 (O_NONBLOCK)
기본 (blocking) : 데이터가 들어오지 않으면 무한 대기
read(fd, buf, N); // N바이트 올 때까지 블록
Non-blocking 설정 : 데이터가 없으면 바로 반환
#include <fcntl.h>
int flags = fcntl(fd, F_GETFL); // 현재 플래그 가져오기
fcntl(fd, F_SETFL, flags | O_NONBLOCK); // O_NONBLOCK 추가
ssize_t n = read(fd, buf, 100);
if (n == -1 && errno == EAGAIN) {
printf("데이터 없음, 바로 반환\n"); // non-blocking 특성
}
File write system call write()
버퍼에 있는 데이터를 특정 파일에 기록해 달라고 커널에 요청하는 역할
write()를 사용하려면 파일이 쓰기 가능한 모드(O_WRONLY, O_RDWR 등)로 열려야 함
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
fd : open() 으로 얻은 파일 디스크립터 번호로, 작업을 할 파일을 가리킴
count : 버퍼에서 요청한 작업 바이트 크기
buf : 파일에 쓸 데이터가 들어 있는 메모리 주소
| 반환값 | 의미 |
|---|---|
>0 | 실제로 쓴 바이트 수, 항상 count와 같다는 보장은 없음 |
0 | count가 0이면 실질적인 쓰기 작업 없이 0을 반환 |
-1 | 오류, errno 확인 |
read()와 write()의 buf 차이
read(int fd, void *buf, size_t count)
커널이 파일에서 사용자 버퍼로 데이터를 복사하므로, buf는 수정 가능한 포인터
write(int fd, const void *buf, size_t count)
커널이 사용자 버퍼 → 파일/디바이스 방향으로 읽기만 하므로, buf는 읽기 전용(const)으로 선언
File close system call close()
해당 파일 디스크립터를 더 이상 사용하지 않으므로, 커널의 자원을 회수하라는 의미의 시스템 콜
#include <unistd.h>
int close(int fd);
| 반환값 | 의미 |
|---|---|
0 | 정상 종료, 해당 fd에 연결된 커널 자원 해제됨 |
-1 | 실패, errno 확인 |
파일 탐색
lseek() 시스템 콜로, 현재 fd에서 읽기/쓰기가 진행될 파일 위치(pos)를 옮기는 것
파일 위치 값만 갱신하고 실제로 데이터 입출력을 하는 등의 다른 동작은 일어나지 않음
성공하면 이동 후의 파일 위치(pos)를 반환하고, 실패 시에는 -1 반
#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
fd : open() 으로 얻은 파일 디스크립터
offset : 이동하고 싶은 거리를 바이트 단위로 나타내며 양수/음수 모두 가능
whence : 이동의 기준점을 지정하는 상수
whence(origin)에 따른 동작
| 상수 | 기준점 의미 | 동작 예시 설명 |
|---|---|---|
SEEK_SET | 파일의 시작(0 바이트 위치) 기준 | 시작에서 offset만큼 떨어진 위치로 이동 |
SEEK_CUR | 현재 파일 위치(pos) 기준 | 지금 위치에서 offset만큼 앞/뒤로 이동 |
SEEK_END | 파일의 끝(EOF) 기준 | 파일 끝에서 offset만큼 앞/뒤로 이동 |
Multiplexed I/O(입출력 다중화)
하나의 프로세스 또는 스레드가 여러 파일 디스크립터를 동시에 감시하고, 입출력할 준비가 된 fd를 골라서 처리하는 방법
입출력 다중화의 대표적인 방식
동기성을 유지하면서 프로세스/스레드 사용
각 fd에 전담 프로세스나 스레드를 하나씩 붙이는 방식
메인 프로세스가 open()으로 fd를 만들고 나면 새 연결/파일마다 fork()로 프로세스를 생성하거나 pthread_create() 로 스레드를 생성
각 프로세스/스레드는 맡은 fd에 대해 동기적인 blocking I/O(read(), write())만 사용
select()를 사용
단일 프로세스에서 여러 개의 fd를 관리하면서 커널에 준비된 fd를 알려달라고 요청하는 방식
여러 개의 파일을 파일 디스크립터 배열(fd_set)에 넣고 관리
적은 스레드로 많은 fd를 효율적으로 처리할 수 있음
select()를 사용한 입출력 다중화
#include <sys/select.h>
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
nfds : 감시할 fd 중 가장 큰 값 + 1
readfds : 읽기 가능 여부를 감시할 fd 집합(예: 수신 대기 소켓, stdin 등)
writefds : 쓰기 가능 여부를 감시할 fd 집합(예: 송신 버퍼 여유 확인)
exceptfds : 예외(에러, out-of-band) 감시용 집합(주로 소켓)
timeout : 대기 시간으로, NULL이면 준비될 때까지 무한대기, 0이면 즉시 복귀, 값 지정 시 해당 시간만 대기
FD 관리를 위한 fd_set 과 FD 매크로
fd_set : 여러 개의 fd를 배열처럼 관리하기 위해 커널이나 라이브러리가 제공하는 비트 집합
내부적으로 최대 1024개 정도의 fd를 비트 단위로 표현하며, 배열같은 느낌의 비트마스크 구조
fd_set set;
FD_ZERO(&set); // fd 집합을 0으로 초기화
FD_SET(fd, &set); // fd 추가, fd의 비트를 1로 설정
FD_CLR(fd, &set); // fd 제거, fd의 비트를 0으로 설정
FD_ISSET(fd, &set); // fd가 집합에 포함되어 있는지 확인
select를 사용한 입출력 다중화 흐름
FD_ZERO()로 집합 초기화FD_SET()으로 감시할 fd들을 집합에 추가select() 호출로 이벤트(읽기/쓰기 가능) 발생까지 대기FD_ISSET()으로 어떤 fd가 준비됐는지 확인하고 해당 fd에 대해 read()/write() 수행파일 권한과 소유권
리눅스는 다중 사용자 OS로, 모든 파일에 소유자(owner), 그룹(group), 기타(other)에 대한 권한이 존재함
권한은 읽기(r=4), 쓰기(w=2), 실행(x=1) 세 가지를 조합하여 보통 8진수 3자리로 표현
기본 생성 권한에서 umask를 뺀 값을 사용 (예: 0644 → rw-r--r-- )
일반 파일뿐만 아니라 디렉터리, 디바이스, 프로세스 정보, 드라이버 인터페이스도 모두 파일로 표현되고 권한을 가짐
시스템 자원에 대한 접근 제어도 파일 권한으로 통합 관리
파일의 소유권 변경하기
chmod / chmod() : 권한 변경
chown / chown() : 소유자 변경
fchown() : 열린 fd로 소유자 변경
lchown() : 심볼릭 링크 자체의 소유자 변경
umask와 기본 생성 권한
새 파일의 기본 권한은 파일 0666, 디렉터리 0777에서 umask 값을 뺀 값으로 설정됨
umask 명령어 / umask() 함수를 사용하여 파일을 생성할 때 적용할 마스크 값을 설정 가능
파일의 종류와 권한, 모드 확인
stat() 시스템 콜을 사용하면 파일의 종류, 권한, 크기, 소유자, 시간 정보를 확인할 수 있음
파일에 대한 정보를 stat 구조체 형태로 돌려줌
#include <sys/stat.h>
int stat(const char *pathname, struct stat *buf); // 경로로 조회
int fstat(int fd, struct stat *buf); // fd로 조회
int lstat(const char *pathname, struct stat *buf); // 링크 자체 조회
struct stat 주요 정보
mode_t st_mode : 파일 종류 + 권한 비트 (S_IFREG, S_IRUSR 등)
uid_t st_uid, gid_t st_gid : 소유자/그룹 ID
off_t st_size : 파일 크기
time_t st_mtime : 마지막 수정 시간 등
inode와 파일 복사 비교
일반 복사(cp) : 새 inode를 생성하며, 복사 시점의 데이터만 같은 완전히 독립된 파일
링크 : 기존 inoe를 재사용하여 디스크 공간을 절약하고 데이터의 일관성을 유지함
# 테스트 (라즈베리파이 SD카드)
echo "test" > origin.txt
cp origin.txt copy.txt # 새 inode 생성
ln origin.txt hard.txt # hard link (같은 inode)
ln -s origin.txt sym.txt # symbolic link (별도 inode)
ls -li *.txt
# 123456 -rw-r--r-- origin.txt
# 789012 -rw-r--r-- copy.txt ← 다른 inode
# 123456 -rw-r--r-- hard.txt ← 같은 inode
# 345678 lrwxrwxrwx sym.txt ← 별도 inode (l=링크)
Hard Link
같은 inode를 공유하며, 디렉토리 엔트리(덴트리)에 inode 번호만 추가됨
원본을 삭제해도 inode의 링크 카운트가 1 이상이면 데이터가 유지됨
같은 파일 시스템 내에서만 사용 가능하며 디렉토리에는 사용 불가
하나의 파일을 여러 디렉토리에서 공유하고 싶을 때 유용하고, 백업/중복 방지 역할 가능
Symbolic Link (Soft Link)
별도의 inode를 가지며, 원본 파일의 경로 문자열만 저장
장치 번호와 inode 번호를 함께 가지고 있어 디렉토리 링크, 다른 장치나 파일 시스템 간에도 링크 가능
원본 삭제 시에는 파일이 깨짐
표준 입출력 파일 디스크립터
| fd 번호 | 이름 | 기본 연결 |
|---|---|---|
| 0 | stdin | 키보드 (/dev/stdin) |
| 1 | stdout | 모니터 (/dev/stdout) |
| 2 | stderr | 모니터 (/dev/stderr) |
시스템 프로그래밍 관점에서 Redirection 구현
셸의 > 나 2> 리다이렉션은 dup2() 로 구현 가능하고 백그라운드 명령 실행 시 자주 사용됨
newfd 는 oldfd 의 복사본처럼 동작하여 같은 파일을 가리킴
이때 파일 디스크립터 flag까지 복사하지는 않고, newfd가 이미 열려 있으면 자동으로 close() 후 재할당
#include <unistd.h>
int dup2(int oldfd, int newfd);
oldfd : 복사할 원본 fd (예 : 새로 연 파일의 fd=3)
newfd : 덮어쓸 대상 fd (예 : stdout=1)
파일 디스크립터 테이블(FDT)
각 프로세스는 0~255 크기의 FDT 배열을 가지며, stdin, stdout, stderr을 기본적으로 열어 둠
파일 열기에 성공하면 FDT에서 3번부터 순서대로 할당
fork() 후에는 자식도 동일한 FDT를 복사하여 갖게되며, dup2 로 독립 조작
[0: /dev/stdin, 1: /dev/stdout, 2: /dev/stderr, 3: log.txt, 4: gpio, ...]
버퍼
입출력 성능을 높이기 위해 데이터를 모아 두었다가 한 번에 장치로 보내거나 받아들이는 메모리 공간
버퍼의 크기는 대체로 1024바이트
write() 를 1바이트씩 2048번 쓰는 것보다 1024바이트씩 2번 호출하는 편이 시스템 콜 횟수가 줄어 효율적
입출력 버퍼
고수준 입출력 함수를 사용하면 먼저 버퍼에 내용이 쌓이고 바로 입출력이 일어나지 않음
버퍼가 일정 크기 이상 차거나 줄바꿈 문자 입력/종료 시 실제로 입출력이 일어나는 형태
I/O 스케줄러
커널 내부에서 버퍼에 쌓인 데이터를 언제 내보낼지 결정하는 레이어
stdin/stdout, 디스크 · 드라이버와 I/O를 수행할 때 실질적인 타이밍을 조절함
fflush() 로 버퍼 비우기
#include <stdio.h>
int fflush(FILE *stream);
출력 버퍼에 쌓인 데이터를 즉시 목적지로 전송하고 버퍼를 비우는 역할
실시간 로그 확인, 사용자에게 즉시 입력 받기 등 버퍼가 채워질 때까지 기다릴 수 없는 경우 유용함
예시
fflush(stdout); : 표준 출력 버퍼를 강제로 비움 → 지금까지 printf()한 내용이 즉시 화면/리다이렉션된 파일로 출력
fflush(fp); : 파일 스트림 fp에 대한 출력 버퍼를 디스크로 출력
fflush(NULL); : 모든 출력 스트림을 한 번에 플러시
현재 디렉토리
chdir() : 작업 디렉토리 변경 함수로, 셸에서 사용하던 cd 와 유사함
getcwd() : 현재 경로를 얻는 함수
디렉토리 삭제, 이동, 접근
mkdir() / rmdir() : 디렉토리 생성/삭제 함수
rename() : 디렉토리 이동
opendir() / readdir() / closedir() : 디렉토리 내용 접근
dirent 구조체
readdir() 이 반환하는 구조체
디렉토리 안의 각 엔트리(파일/디렉토리)를 나타냄
#include <dirent.h>
struct dirent {
ino_t d_ino; // inode 번호
off_t d_off; // 디렉토리 내 offset
unsigned short d_reclen; // 레코드 길이
unsigned char d_type; // 타입(DT_REG, DT_DIR 등 시스템마다 다를 수 있음)
char d_name[]; // 항목 이름 (널 종료 문자열)
};
유닉스의 시간
Epoch(1970-01-01 00:00:00 UTC)부터 지금까지 흐른 초 단위 정수로 시간을 표현
time() 함수를 사용하면 이 값을 time_t 형 정수로 얻을 수 있음
#include <time.h>
time_t time(time_t *timep);
tm 구조체
연도, 월, 일 등을 분리해서 처리할 때 사용하면 편리한 구조체
struct tm {
int tm_sec; // 초 [0, 60]
int tm_min; // 분 [0, 59]
int tm_hour; // 시 [0, 23]
int tm_mday; // 일 [1, 31]
int tm_mon; // 월 [0, 11] (0=1월)
int tm_year; // 1900년부터의 년수
int tm_wday; // 요일 [0, 6] (0=일요일)
int tm_yday; // 연중 일수 [0, 365]
int tm_isdst; // 서머타임 여부
};
gmtime()
struct tm *gmtime(const time_t *timep);
time_t 를 UTC 기준의 tm 구조체로 변환
localtime()
struct tm *localtime(const time_t *timep);
time_t 를 로컬 타임존 기준의 tm 구조체로 변환
시스템의 시간대 설정을 반영
cal 명령어
터미널에서 cal 을 입력하면 현재 혹은 지정한 연도/월의 달력을 출력
파일은 다루어도 다루어도 끝이 없다는 생각이 들었다.
이제 개념 정리는 끝내버려!