카테고리 없음

[9주차] 리눅스 시스템 프로그래밍

2026. 5. 24. 12:31

VEDA 주간 학습 정리

[9주차] 리눅스 시스템 프로그래밍

이번 주 학습을 한 문장으로 정리하면: 시스템 콜이 커널로 들어가는 원리부터 파일 I/O, 프로세스, 시그널, IPC까지 리눅스 프로그램이 OS와 만나는 지점을 정리한 주간

작성일: 2026.05.24

주차 이동

◀ 저번주차: [8주차] 리눅스 개발도구 정리

▶ 다음주차: 작성 후 링크 연결 예정

이번 주 학습 목표

  • 사용자 모드와 커널 모드가 분리되는 이유와 시스템 콜 호출 흐름 이해하기
  • 파일시스템, 아이노드, 파일 디스크립터, 저수준·고수준 입출력의 차이 정리하기
  • fork(), exec() 계열의 동작 방식 복기하기
  • 시그널을 안전하게 다루기 위한 sigaction()과 비동기 신호 안전성 이해하기
  • 프로세스 간 통신을 위한 IPC 방식과 특징 비교하기

1. 리눅스 시스템 프로그래밍 전체 지도

이번 주 필기노트는 리눅스 응용프로그램이 커널과 어떻게 연결되는지를 큰 흐름으로 보여준다. 단순히 함수 이름을 외우는 것이 아니라, 어떤 작업이 사용자 공간에서 끝나고 어떤 작업부터 커널의 도움이 필요한지 구분하는 것이 핵심이었다.

영역 핵심 개념 복기 포인트
시스템 콜 사용자 모드에서 커널 서비스를 요청하는 통로 트랩, 시스템 콜 테이블, 모드 전환 비용
파일 I/O 파일 디스크립터와 커널 오픈 파일 테이블 저수준 I/O와 고수준 I/O의 버퍼링 차이
프로세스 fork(), wait(), exec() 복사, 종료 수거, 실행 이미지 교체
비동기 제어 시그널과 핸들러 언제든 끼어드는 이벤트를 안전하게 처리하는 방법
프로세스 간 통신 IPC 독립된 주소 공간을 가진 프로세스들이 데이터를 주고받는 방법

2. 시스템 콜과 커널 모드

리눅스 시스템 프로그래밍의 출발점은 사용자 프로그램이 직접 하드웨어를 만지지 못한다는 사실이다. 일반 프로그램은 사용자 모드에서 실행되고, 파일 읽기나 프로세스 생성처럼 위험하거나 중요한 작업은 시스템 콜을 통해 커널에게 대리 수행을 요청한다.

[ 사용자 공간 ]
응용프로그램 -> C 라이브러리 open()
                  |
                  | 트랩 / 소프트웨어 인터럽트
                  v
[ 커널 공간 ]
트랩 핸들러 -> 시스템 콜 테이블 -> sys_open() 실행 -> 결과 반환
구분 시스템 콜 라이브러리 함수
실행 위치 커널 모드 진입 대부분 사용자 모드에서 처리
호출 비용 모드 전환 때문에 상대적으로 큼 일반 함수 호출에 가까워 비교적 작음
예시 open(), read(), write(), fork() printf(), fopen(), fwrite()
매뉴얼 man 2 함수명 man 3 함수명

복기 메모

시스템 콜은 강력하지만 공짜가 아니다. 유저 모드와 커널 모드 사이를 오가는 비용이 있기 때문에, 고수준 라이브러리는 내부 버퍼를 사용해 시스템 콜 횟수를 줄이도록 설계되어 있다.

3. 환경변수 조회와 시간 측정

환경변수는 커널에 매번 시스템 콜을 날려 가져오는 값이라기보다, 프로세스가 실행될 때 물려받은 환경 영역을 getenv()로 읽는 방식이다. 반면 현재 시간과 실행 시간 측정은 time(), gettimeofday(), times() 같은 시스템 프로그래밍 API를 사용해 확인한다.

환경변수 불러오기

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    char *home = getenv("HOME");
    char *path = getenv("PATH");

    if (home == NULL) {
        printf("HOME 환경변수를 찾을 수 없습니다.\n");
    } else {
        printf("HOME = %s\n", home);
    }

    if (path != NULL) {
        printf("PATH = %s\n", path);
    }

    return 0;
}

getenv()는 환경변수 이름을 문자열로 넘기면 해당 값을 문자열 포인터로 돌려준다. 값이 없으면 NULL이 반환되므로, 바로 출력하기보다 존재 여부를 먼저 확인하는 습관이 필요하다. putenv()로 환경변수를 바꿀 수도 있지만, 그 변경은 현재 프로세스와 이후 생성되는 자식 프로세스에만 적용된다.

시간 측정하기

#include <stdio.h>
#include <sys/time.h>
#include <sys/times.h>
#include <time.h>
#include <unistd.h>

int main(void) {
    struct timeval start, end;
    struct tms cpu_time;
    long ticks = sysconf(_SC_CLK_TCK);
    time_t now = time(NULL);

    printf("현재 Epoch 시간: %ld초\n", now);

    gettimeofday(&start, NULL);

    for (volatile long i = 0; i < 100000000; i++) {
        // 측정할 작업
    }

    gettimeofday(&end, NULL);
    times(&cpu_time);

    double elapsed =
        (end.tv_sec - start.tv_sec) +
        (end.tv_usec - start.tv_usec) / 1000000.0;

    printf("실제 경과 시간: %.6f초\n", elapsed);
    printf("User CPU 시간: %.6f초\n", (double)cpu_time.tms_utime / ticks);
    printf("System CPU 시간: %.6f초\n", (double)cpu_time.tms_stime / ticks);

    return 0;
}

시간 측정 복기 메모

time()은 초 단위 현재 시각을 볼 때 사용하고, gettimeofday()는 코드 앞뒤의 실제 경과 시간을 마이크로초 단위로 비교할 때 사용한다. times()는 벽시계 시간이 아니라 프로세스가 User mode와 System mode에서 CPU를 얼마나 사용했는지 확인할 때 사용하며, 클럭 틱 값은 sysconf(_SC_CLK_TCK)로 초 단위로 변환한다.

4. 파일시스템과 파일 입출력

리눅스는 파일을 이름 자체로 관리하는 것이 아니라, 아이노드와 데이터 블록을 통해 관리한다. 특히 디렉토리는 파일명과 아이노드 번호의 매핑 테이블을 담고 있는 특별한 파일이라는 점이 중요했다.

ext4 파일시스템의 큰 구조

부트 블록 -> 수퍼 블록 -> 아이노드 블록 -> 데이터 블록
             파일시스템 정보   파일 메타데이터     실제 파일 내용
개념 역할 주의할 점
아이노드 파일 크기, 권한, 소유자, 시간, 데이터 블록 주소 저장 파일명은 아이노드가 아니라 디렉토리 데이터에 저장된다
stat() 파일 메타데이터 조회 심볼릭 링크가 가리키는 원본 파일을 따라간다
lstat() 링크 자체의 메타데이터 조회 심볼릭 링크 파일 자체를 확인할 때 사용한다
파일 디스크립터 프로세스가 열린 파일을 가리키는 정수 인덱스 0, 1, 2는 표준 입력, 표준 출력, 표준 에러로 예약된다

저수준 I/O와 고수준 I/O

저수준 입출력은 open(), read(), write(), lseek()처럼 시스템 콜을 직접 사용한다. 반면 고수준 입출력은 fopen(), fread(), fprintf()처럼 C 라이브러리 버퍼를 거쳐 동작한다.

lseek(fd, 10L, SEEK_SET);  // 파일 시작 기준 10바이트 이동
read(fd, buf, 10);

lseek(fd, 10L, SEEK_CUR);  // 현재 위치 기준 10바이트 이동
read(fd, buf, 10);

5. 터미널 제어와 파일 디스크립터 복사

터미널도 리눅스에서는 파일처럼 다뤄진다. 기본 캐노니컬 모드에서는 엔터를 눌러 한 줄이 완성되어야 프로그램이 입력을 받지만, RAW 모드에서는 키 하나가 들어오는 즉시 프로그램으로 전달된다. 패스워드 입력처럼 화면에 입력을 보이면 안 되는 경우에는 ICANONECHO 플래그를 끄는 흐름을 사용한다.

tcgetattr(STDIN_FILENO, &oldt);
newt = oldt;
newt.c_lflag &= ~(ICANON | ECHO);
newt.c_cc[VMIN] = 1;
newt.c_cc[VTIME] = 0;
tcsetattr(STDIN_FILENO, TCSANOW, &newt);

ch = getchar();

tcsetattr(STDIN_FILENO, TCSANOW, &oldt);

dup()dup2()는 파일 디스크립터를 복사한다. 특히 dup2(fd, STDOUT_FILENO)는 표준 출력 FD 1번을 파일로 바꿔, printf() 결과가 화면이 아니라 파일에 기록되도록 만들 수 있다.

fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
close(fd);

printf("이 내용은 파일에 저장됩니다.\n");

6. 프로세스 생성, 종료, 교체

프로세스는 실행 중인 프로그램이며, 코드, 데이터, 힙, 스택으로 구성된 독립 주소 공간을 가진다. 프로세스끼리는 서로의 메모리를 마음대로 침범할 수 없고, 커널은 각 프로세스의 실행 문맥을 따로 관리한다.

High Memory
Stack  - 지역변수, 매개변수, 리턴 주소
Heap   - malloc 등 동적 할당 영역
Data   - 전역변수, static 변수
Code   - 실행할 바이너리 코드
Low Memory
함수 역할 핵심 포인트
fork() 부모 프로세스의 복사본 생성 한 번 호출하지만 부모와 자식에서 각각 리턴한다
COW Copy-on-Write 최적화 쓰기 연산이 발생한 페이지만 실제 복사한다
wait() 종료된 자식의 상태 수거 부모가 수거하지 않으면 좀비 프로세스가 남는다
exec() 현재 프로세스 이미지를 새 실행 파일로 교체 프로그램은 바뀌지만 PID는 유지된다
system() 쉘을 실행해 문자열 명령어를 수행 내부적으로 자식 프로세스를 만들고 /bin/sh -c 형태로 명령을 실행하므로 간단하지만 제어와 보안 측면에서는 주의가 필요하다

좀비 프로세스 복기

자식 프로세스가 종료되면 실행 메모리는 해제되지만 PID와 종료 상태는 커널 프로세스 테이블에 남는다. 부모가 wait() 또는 waitpid()로 이를 수거해야 완전히 정리된다.

7. 시그널과 비동기 제어

시그널은 커널이나 다른 프로세스가 어떤 이벤트를 비동기적으로 알리는 소프트웨어 인터럽트다. Ctrl+C로 발생하는 SIGINT, 강제 종료인 SIGKILL, 일반 종료 요청인 SIGTERM처럼 상황별로 의미가 다르다.

시그널 발생 상황 특징
SIGINT Ctrl+C 사용자 중단 요청
SIGQUIT Ctrl+\ 종료와 함께 core dump 생성 가능
SIGKILL 즉시 강제 종료 잡거나 무시하거나 블록할 수 없다
SIGTERM 일반 종료 요청 프로그램이 정리 후 종료할 기회를 가질 수 있다

신뢰성 있는 시그널 처리를 위해서는 오래된 signal()보다 sigaction()을 사용하는 것이 좋다. 핸들러 실행 중 차단할 시그널 집합과 SA_RESTART 같은 옵션을 명확히 지정할 수 있기 때문이다.

void my_handler(int signo) {
    write(STDERR_FILENO, "SIGINT 수신 완료!\n", 22);
}

struct sigaction act;
act.sa_handler = my_handler;
sigfillset(&act.sa_mask);
act.sa_flags = SA_RESTART;
sigaction(SIGINT, &act, NULL);

핸들러 작성 주의점

시그널은 코드 중간에 갑자기 끼어들 수 있으므로 핸들러 안에서는 printf(), malloc(), free()처럼 내부 락이나 전역 상태를 사용하는 함수를 피해야 한다. 안전한 write()를 쓰거나 volatile sig_atomic_t 플래그만 바꾸고 메인 루프에서 처리하는 설계가 좋다.

8. IPC: 프로세스 사이의 통신

프로세스는 독립된 주소 공간을 가지므로 서로의 메모리를 직접 읽고 쓸 수 없다. 그래서 프로세스 간 데이터 교환에는 커널이 제공하는 IPC 통로가 필요하다.

IPC 방식 통신 방향 정리 포인트
PIPE 단방향 부모-자식 관계에서 단순한 데이터 전달에 적합하다
메시지 큐 양방향 구조체 메시지 단위로 주고받고 타입별 선별 수신이 가능하다
공유 메모리 양방향 커널 복사를 거치지 않아 가장 빠르지만 동기화가 반드시 필요하다
세마포어 통신 자체보다는 제어 공유 자원에 동시에 접근하지 못하도록 막는 동기화 수단이다

마무리

이번 주는 리눅스 시스템 프로그래밍의 큰 줄기를 한 번에 정리한 시간이었다. 시스템 콜을 통해 커널 기능을 호출하는 방식, 파일이 아이노드와 FD로 관리되는 방식, 프로세스가 생성되고 종료되는 방식이 서로 따로 떨어진 주제가 아니라 하나의 운영체제 흐름으로 이어진다는 점이 중요했다.

특히 뒤로 갈수록 시그널과 IPC처럼 비동기 이벤트와 프로세스 간 데이터 교환을 다루기 때문에 “누가 언제 어떤 자원에 접근하는가”를 계속 확인해야 했다. 복기할 때는 함수 이름보다도 모드 전환, 주소 공간 분리, 커널이 제공하는 통신 통로라는 관점으로 다시 읽으면 훨씬 잘 정리될 것 같다.