리눅스/리눅스 시스템 프로그래밍

큰숲백과, 나무를 보지 말고 큰 숲을 보라.
Senouis (토론 | 기여)님의 2023년 7월 1일 (토) 18:39 판 (→‎리눅스의 Inter Process Communication: 소켓은 별도의 2단계 문단으로)

이 문서는 리눅스에서 C언어를 활용해 커널을 수정하거나 커널에 붙을 시스템 소프트웨어를 제작하는 데에 필요한 지식을 담습니다. 리누스 토르발스가 C++의 도입을 아직 반대하고 있기 때문에, 6.X 버전까지도 리눅스 커널 소스 코드는 C언어에 CPU 아키텍처별 어셈블러 코드가 섞인 구조를 하고 있으며, 극히 일부에 Rust 등의 타 언어가 쓰일 뿐입니다. 따라서 ISO C99 기준으로 된 C언어 지식이 있어야 프로그래밍이 가능합니다.

Linux 커널 업데이트 기록

~2.4 버전까지

2.4 버전 이전까지의 리눅스는 레거시 버전으로, 현재는 사용하는 운영체제가 사실상 없다.

2.4 버전에 와서야 리눅스의 모든 기초 틀이 잡혔다. 다만 그 이후로도 더 나은 성능과 시스템 관리를 위해 커널을 신기술을 개발하고 도입하며 계속 확장되었으나, 오래된 아키텍처의 CPU를 사용하는 레거시 PC들은 최신 커널의 무거움을 감당하지 못하므로 2.4버전은 최소한의 관리를 받으며 현역 리눅스 커널의 버전 한계치를 형성한다. 현재도 2.4.X 버전 아니면 2.6.X 버전을 쓰는 오래된 기계 제어용 컴퓨터들이 간혹 목격된다.

~4.X 버전까지

2.4.X까지 썼었던 O(n) 시간 복잡도를 가지는 단일 큐 내 epoch 값을 순서대로 살피는 순회 알고리즘 기반 프로세싱 스케줄링을 2.5 버전부터 버리고 여러 Runqueue를 가지는 O(1) 시간 복잡도의 새로운 스케줄링 알고리즘이 도입되었으나, 2.6에서 Red-Black 트리 자료구조를 사용하는 새로운 프로세싱 알고리즘인 Completely Fair Scheduler로 교체되었다.

3.7.X 버전은 i386(인텔 80386)이 지원되는 마지막 커널로, 그 이후에는 펜티엄 시기에 도입된 확장 명령어들이 있는 x86 계통 CPU의 컴파일만 지원한다. 그리고 그 외에도 현재 사실상 사용처가 없는 20세기의 레거시 CPU 아키텍처들에 대한 지원들이 점점 중단되었다.

현재 현역으로 돌아가는 PC 시스템은 4.X 버전의 커널을 써야 제대로 사용 가능하다. 가령 AMD라이젠 CPU의 경우 4.11 이상 버전의 커널을 써야 제대로 된 구동이 가능하며, 5.20 버전 이상 커널을 쓰는 것이 권장된다.

5.0 이후

현재 시장에서 단종되지 않은 최신 CPU 제품들은 5.0 이후의 Linux 커널을 써야 한다.

6.0 버전부터 코어 X나 라이젠 스레드리퍼 같은 HEDT~워크스테이션에 대한 추가 스케쥴링 최적화가 진행되었다. 6.1 이상이면 성능 저하가 없는 수준으로, 여기에 인텔 최신 CPU에서 사용된 하이브리드 프로세서 설계 기법에 대응하는 Nest 알고리즘도 도입된다. 그 외에 80486마저 지원이 중단되었다(이제 FPU가 CPU 칩 내에 기본적으로 내장되었다고 가정하며, 32비트 x86 시기 생긴 SIMD 명령어나 NX 비트 등의 확장 명령어가 거의 다 있어야 함).

리눅스의 시스템 콜과 시스템 라이브러리의 기본

리눅스는 모놀리식 커널이라 API와 시스템 콜까지 전부 커널이 처리한다. 리눅스의 시스템 호출(System Call)은 커널 모드에서 작동하는 운영체제의 기능을 유저 모드의 프로그램이 가져오기 위해 커널 메모리 영역에 접근하는 절차다. 시스템 콜은 다음 절차를 따라 이루어진다.

  1. 응용 프로그램이 Wrapper 함수를 호출하면 래퍼함수가 시스템 호출에 필요한 정보(현재의 레지스터 값, 프로그램 상태 등)을 레지스터나 스택에 넣어 백업한다.
  2. 어떤 시스텡 호출 기능을 사용할 것인지 레지스터에 넣은 후 트랩을 호출한다. x86 계통 CPU의 경우 EAX 레지스터에 호출 기능 번호를 넣고 int 0x80로 트랩 벡터가 가리키는 코드를 실행한다.
  3. 커널은 트랩 호출을 감지하면 커널 모드로 전환해 system_call() 함수로 커널 스택에 레지스터 값 등을 백업하고 호출의 유효성을 확인하며 시스템 콜의 서비스 루틴 테이블(sys_call_table 커널 변수)에서 요청한 기능을 찾아 실행한다.
  4. 기능 처리가 끝나면 system_call()은 백업한 값들을 레지스터에 복원하고 유저 모드로 CPU 권한 상태를 되돌린 다음 처리 결과를 유저 모드 프로그램에 반환한다.

시스템 콜은 모드 스위칭이 발생하므로 유저 프로그램 입장에서 처리 시간(오버헤드)이 오래 걸리는 편이다. 따라서 이미 가지고 있는 데이터를 시스템 콜로 또 부르는 것은 낭비에 가까우므로 단순히 현재 프로세스의 ID를 가져오는 작업이라도 과도하게 사용하는 것을 지양해야 한다.

시스템 콜이나 glib 같은 시스템 라이브러리를 쓸 때에는 에러 처리를 해주는 것이 좋다. 가령 open()으로 시스템 호출을 한 경우 리턴값이 -1인 경우(파일 열기 실패)에 프로그램이나 프로그램 내 기능을 종료하는 식의 에러 처리가 필요하다(반대로 이미 열린 파일을 close()로 닫을 때에도 시스템 상황에 따라 안 닫힐 수 있으니 예외 처리를 해줘야 한다). 이때 <errno.h> 헤더를 써서 그 안에 선언된 에러 코드(errno 전역 변수)로 어떤 에러가 나왔는지 자세한 정보를 얻을 수 있다(에러를 확인한 다음이나 초기화 과정에서 errno 변수는 0으로 초기화해두자). 간혹 시스템 라이브러리 중 시스템 콜이 실패했음에도 0을 반환하는 고약한 Wrapper 함수가 있는 경우가 있으므로, 라이브러리마다 매뉴얼을 살펴서 errno 역시 확인해야 하는지 주의해야 한다. "lib/error_functions.h" 내의 errMsg()나 errExit(), erroExitEN() 및 기타 등등의 함수로 예외처리를 더 간단하게 할 수도 있다.

시스템 소프트웨어 제작시 getopt()로 소프트웨어에 넘겨지는 UNIX식 파라미터 파싱이 가능하다. 또 시스템이 기본적으로 사용하는 호출 규약을 컴파일 때 -D_POSIX_C_SOURCE 컴파일 옵션 등으로 세팅할 수 있다(플랫폼에 따른 이식성을 정의한다)

Linux의 프로세스와 프로세스 스케쥴링 관련 기능

리눅스는 기본적으로 프로세스 단위로만 프로그램을 구동하여 1프로그램 n 프로세스 형태의 멀티프로세싱만 지원했다(한 프로세스 내 데이터를 공유하는 스레드 개념이 없었다). 지금이야 경량 프로세스니 POSIX 스레드(pthread)니 하여 멀티스레딩을 하는 방법이 잘 지원되지만 예전에는 프로세스 만들 때마다 프로세스 정보를 싹 다 복사하느라 효율이 생각만큼 좋지 않았다.

프로세스 관리용 자료구조

리눅스는 2.6.23부터 기본적으로 특수한 휴리스틱으로 실행 시간을 결정하지 않는 Completely Fair Scheduler를 사용한다. CPU에서 실행할 프로세스를 교체할 때 task_struct 구조체 안의 cfs_rq RB Tree 루트부터 가장 왼쪽의 프로세스(task_struct 내 sched_entity의 원소이기도 한 프로세스 엔티티)를 무조건 트리에서 뽑아내며, 뽑아낼 때 RB-Tree의 구조를 해치지 않게 구조를 재배열한다(트리(자료구조) 참조).

프로세스가 한번 CPU에 들어가면 CPU에서 실제로 균등하게 나뉜 실행 시간에 우선순위 값을 곱한 가상 실행 시간이 vruntime 값에 더해진다. vruntime이 한 번 실행에 크게 증가할 수록 스케줄링 트리 왼쪽으로 배치되는 일이 점점 적어지며 스케줄링 트리가 전체적으로 평탄한 상태를 유지하는데, 그렇다고 vruntime 증가가 끝난 프로세스를 다시 삽입할 때 RB Tree의 자동 평탄화 특성에 따라 삽입 후 정렬 작업을 하기에 과도하게 Scheduling Tree가 기울어져 실행 시간을 받지 못하는 프로세스가 생기는 일이 발생하지 않기에 결국 CPU 실행 시간을 잡지 못하는 프로세스가 발생하는 일은 없다. vruntime 자체는 모든 우선순위의 프로세스들에서 항상 증가하므로 한 번 실행될 때 vruntime이 적게 증가하는 프로세스가 더 자주 실행된다(=균등히 나뉜 CPU 실행시간을 더 자주 잡아 결과적으로 높은 우선순위로 실행되어 요청에 대한 반응성이 좋아진다).

이 CFS 정책은 내부적으로 SCHED_NORMAL이라고 부르며, 이 외에 우선 순위별로 별도의 단위 실행 시간과 Deadline을 정하는 SCHED_DEADLINE 같은 스케줄링 정책도 있다.

PID(Process ID)는 프로세스마다 매겨진 고유 번호를 담은 16비트 정수다. /proc/sys/kernel/pid_max 파일을 건드리지 않으면 이게 기본값이며 건드려도 (2의 22승 - 1) 개가 최대치다. 그리고 설정 가능한 프로세스 수의 최소치는 init 프로세스(PID 1)를 포함한 시스템 프로세스와 데몬이 사용하는 PID 범위의 크기인 300보다 커야 한다.

unistd.h의 getpid()로 PID를 받을 수 있지만 그걸로는 프로세스 간 종속 관계를 알 수 없고 getppid()를 추가로 호출해야 한다.

리눅스 프로세스는 코드 영역(section)인 .text, 초기화된 데이터(전역 변수와 static 변수) 영역, 초기화 안 된 데이터 영역인 .bss, stack 영역, 자유로운 메모리 할당 가능 영역인 heap 영역으로 나뉜다.

리눅스 프로그램은 ABI(Application Binary Interface)라 하여 스택 영역에 함수의 인자와 반환값이 저장되는 구조를 따른다. 하지만 최적화(컴파일 단계에서 fastcall를 사용하기에 레지스터에 파라미터를 직접 밀어넣는 경우, 쓰지 않는 변수 제거 작업 등)로 인해 모든 파라미터와 리턴값을 스택 영역에서 찾을 수 없는 경우도 있다.

프로세스의 생성과 소멸

리눅스는 기본적으로 한 프로세스에서 프로세스를 추가로 만든다면 부모가 되는 자기 자신의 정보(열린 파일 디스크립터들이나 메모리 페이지 정보 등)를 복사하는 식으로 프로세스를 생성한다. 그래서 한 프로그램이 다른 프로그램을 실행해야 할 경우에는 fork() 후에 추가로 exec() 함수를 호출해서 프로세스가 실행하는 프로그램을 바꾸어야 한다.

  • fork() : 자식 프로세스를 부모 프로세스를 복사하는 식으로 만들고 그 PID를 반환. fork 이후 부모와 자식 중 어느 것이 실행될지는 정해지지 않는다.
    • vfork() : exec()을 실행할 때까지 메모리 정보를 자식에게 복사하지 않고 fork()
    • clone(func, childstack, flags, ...) : 프로세스가 아닌 스레드를 만든다. 따라서 호출할 때 프로그램 내의 특정 함수를 파라미터까지 넘겨 실행하도록 한다.
  • exec() :
  • execve(pathname, argv, envp) : 프로세스가 새 프로그램을 실행하도록 프로그램 정보를 교체
    • execle, execlp, execvp, execv, execl 등의 파생 함수가 있다.
  • exit(status) : 종료 핸들러를 호출하고 stdio 버퍼에 남은 내용을 출력하며 _exit() 호출. 종료 핸들러는 atexit(handlerfunc)나 glibc의 on_exit(handlerfunc, args)으로 정의
    • _exit(status) : 프로세스의 진정한 정상 종료 함수. 파일 디스크립터 정리, 세마포어나 IPC 기능 전부 해제, 메모리 매핑 해제 등을 수행하고 프로세서 그룹 내 자식들에게 SIGHUP과 SIGCONT 전송
  • wait(&status) : 자식 프로세스가 종료될 때까지 부모 프로세스를 정지. waitpid(pid, status, options)로 특정 자식만 지정하여 기다릴 수 있다.

셸 명령어를 호출해 실행해야 할 경우에는 system(command)가 별도로 존재한다.

프로세스 스케줄링 정책과 우선순위

경량 프로세스(Lightweight Process)

데몬과 그 외 프로세스 관련 기능

데몬은 숨겨진 특권 프로세스로, 일반적으로 시스템이 종료될 때까지 실행되는 것을 상정하고 만드는 백그라운드 프로세스이다. 보통 이름이 d로 끝나는 암묵의 규칙이 있다. 그래서 데몬은 일반 프로세스보다 다소 높은 수준의 권한을 가지고 실행되는 '준' 시스템 프로세스이기에 부모 프로세스로부터의 fork()로 데몬을 생성할 경우 부모와의 모든 연결고리를 끊고 시스템 프로세스처럼 세팅(현재 작업 디렉토리를 루트 디렉토리(/)로 이동이라던가, 부모로부터 받았으나 쓰지 않는 모든 파일 디스크립터를 닫는다던가...)해야 한다. 그리고 becomeDaemon(flags)를 호출하면 데몬이 만들어진다.

데몬이 시스템에 미치는 영향이 일반 프로세스보다 크기에, 메모리 누수같은 문제가 더 치명적이므로 버그에 더욱 주의해야 한다.

Linux의 메모리 관리 관련 기능

리눅스는 32비트 컴퓨팅 CPU용 운영체제 시절부터 CPU의 MMU 구조와는 별도로 돌아가는 4단계 페이징 기반 가상 메모리 주소 체계를 가지고 설계되었다. 물론 지금은 64비트 컴퓨팅에 대응하여 5번째 단계도 도입되었다.

32비트 x86 CPU용 리눅스 기준 기준 가상 메모리 영역에서 0xC0000000 16진수 주소 이상은 커널이 예약했기에 그 아래 3GB의 유저 영역만 프로그램이 자유롭게 쓸 수 있다. 보통 유저 프로그램의 메모리 영역의 맨 앞에 사전에 프로그램에 정의된 코드와 전역 변수 등의 초기화 값이 들어있으며, 나머지 영역은 앞에서는 할당된 Heap 메모리 영역이, 0xC0000000 이전의 뒷부분에서는 스택 메모리 영역이 서로를 향해 메모리를 확보하면서 자라나는 구조로 프로세스 데이터 배치가 되어 있다.

흔히 아는 stdlib.h의 malloc과 free는 힙 영역을 확보하거나 해제하는 함수이며, 배열처럼 인덱싱으로 크기를 선언 가능한 calloc과 영역 재할당용 realloc도 있다. 리눅스는 프리 리스트로 프로세스가 할당한 메모리 영역을 자동으로 관리하기에 같은 메모리 영역에 free를 두 번 선언하면 에러를 낸다. 그래서 다시 사용할 것 같은 메모리 영역은 free() 후 NULL로 막으라 하는 것이고, 멀티프로세싱/멀티스레딩까지 고려하면 atomic하게 메모리 영역 해제 작업을 진행하게 코딩해야 하는 것이다.

그 외에 리눅스는 스택 영역을 강제로 확보하는 alloca와 2의 거듭제곱 크기로 강제로 정렬하여 메모리 영역을 확보하는 memalign이 있긴 한데 쓸 일이 그닥 많지 않다.

버디 블록 알고리즘

리눅스의 Paging

참조의 지역성(루프 구조의 코드로 인해 특정 코드가 자주 반복하며 실행되는 시간적 지역성과 순차적 데이터 프로세싱에 의해 프로그램에 필요한 데이터를 실시간으로 어느 정도 예측 가능하다는 공간적 지역성 모두 포괄) 때문에 프로그램의 모든 정보를 단번에 메모리에 올릴 필요가 없으며, 페이징을 활용해 적절하게 메모리를 단위로 분할해(x86의 경우는 4KB) 지금 필요한 코드와 데이터만 RAM에 올리고 나머지는 보조 기억 장치의 스왑 영역(Swap area)에 밀어넣는 식으로 RAM 용량을 아낄 수 있다.

이에 따라 Swap을 사용하는 등의 문제로 Page 정보를 담은 페이지 테이블(Page Table)에 페이지를 찾아 접근했는데 실제로 데이터가 없는 경우가 있다. 이를 Page Fault라고 하며 Page Fault가 일어난 프로세스는 기본적으로 실행을 정지하고 보조 기억 장치에 밀어넣었던 RAM 데이터를 다시 가져오게 된다. 물론 이 과정에서 성능 저하가 반드시 발생하지만 RAM 용량이 보조 기억 장치의 용량을 능가하지 못해 2020년에도 평범한 사용자의 PC에 있는 램의 용량이 막 64GB에 도달한 시점에서 이를 초과하는 대용량 그래픽 데이터 등은 어쩔 수 없이 페이지 교체 과정으로 SSD 등의 보조 기억 장치에서 불러와야 한다.

리눅스의 파일 및 디바이스 I/O

리눅스의 모든 I/O는 파일 디스크립터로 그 상태를 표시하는 파일 입출력을 통해 이루어진다. 비단 사용자의 데이터뿐만 아니라 운영체제가 인식한 장치의 정보와 상태 역시 파일로 저장하여 읽고 쓰면서 장치를 제어하기도 한다. 심지어 /dev/tty에 파일을 복사해 파일 내용을 바로 터미널로 넘길 수 있다! fcntl.h 헤더 파일에 기능이 정의되어 있어 sys/stat.h과 fcntl.h를 C 코드에 포함하는 것으로 시스템 라이브러리 제작시 파일을 읽고 쓸 수 있게 한다.

  • fd = open("path/of/file", flags , mode); - 파일을 flag와 mode 세팅에 따라 열어서 그 파일 디스크립터 번호를 반환한다.
    • flag는 다음 중 하나를 선택한다
      • O_RDONLY
      • O_WRONLY
      • O_RDWR: O_RDONLY | O_WRONLY는 에러를 내기에 이것을 대신 써야 한다.
      • O_CLOEXEC: 실행시 닫기
      • O_CREAT: 파일 신규 생성
      • O_DIRECT: 파일 입출력에 버퍼 캐시를 쓰지 않는다.
      • O_DIRECTORY: 디렉토리 전용 I/O
      • O_NONBLOCK: 같은 파일에 여러 프로세스가 접근한다. 보통 프로세스간 통신을 파일 입출력을 활용해 시행할 때 쓴다.
      • O_LARGEFILE: 32비트 시스템에서 큰 파일을 쓸 때 열며, 64비트 시스템에서는 딱히 쓰지 않는다.
      • O_TRUNC: 기존 파일의 길이를 0으로 설정한다. 보통 파일 데이터를 완전히 비울 때 사용한다.
      • O_APPEND: 파일 끝에 신규 데이터를 붙이는 편집을 할 것이다.
      • 그 외에 O_EXCL, O_NOATIME, O_NOCTTY, O_NOFOLLOW, O_SYNC, O_ASYNC, O_DSYNC 등이 있다.
  • nread = read(fd, buffer, count); - 파일 디스크립터를 buffer에 count 만큼 읽어서 현재 읽는 파일 내 위치 포인터인 파일 오프셋을 옮긴 후 성공한 바이트 수를 반환한다.
  • nwrite = write(fd, buffer, count); - 파일 디스크립터에 buffer의 내용을 count만큼 쓰고 쓴 횟수를 기록한다.
  • status = close(fd); - 파일을 닫는다.

그 외에 일부 장치는 ioctl(fd, request,...)을 써서 위 기능으로 커버되지 않는 특수한 동작을 제어할 수 있게 한 경우가 있다. 파일 내 현재 읽고 쓰는 위치를 가리키는 파일 오프셋을 read로 변경되기 때문에 이미 읽은 내용을 다시 읽으려면 lseek(fd, offset, shence)로 새 파일 오프셋을 받아와야 한다. 이때 shence는 파일 오프셋을 옮길 위치를 정하는 기준점으로, 다음 옵션이 있다.

  • SEEK_SET: 파일의 시작 기준
  • SEEK_CUR: 현재 오프셋 기준
  • SEEK_END: 파일의 끝 기준

여담으로 파일 오프셋을 파일 끝을 초과한 채로 파일에 쓸 수 있는데, 이때 발생하는 빈 공간을 파일 구멍(file hole)이라고 한다. 파일 구멍이 디스크 블록보다 크면 파일을 쓰고 닫을 때 디스크에서는 순수하게 파일 구멍만 차지하는 블록은 쓰기 작업을 하지 않는다.

pread/pwrite(지정된 오프셋에서 읽고 쓰기), readv/writev(여러 버퍼에 파일 데이터를 나누어 읽고 합쳐 쓰기), preadv/pwritev(앞의 두 기능을 동시 수행) 등의 부수 기능이 있다.

파일 디스크립터의 종류

리눅스 커널 기반 시스템은 프로세스와 시스템에서 각각 할당한 파일 디스크립터와 파일 시스템을 위한 inode 테이블을 가진다. 프로세스가 할당하는 파일 디스크립터는 순수히 파일 I/O에 대해 프로세스 입장에서 필요한 정보(닫는 메서드와 디스크립터 ID)만 담당하고 inode 테이블은 한 파일 시스템이 관리하는 파일들의 정보(읽고 쓰는 권한과 종류, 파일 크기와 타임스탬프 등의 속성)만 관리하지만 시스템이 할당하는 파일 디스크립터는 프로세스별 파일 디스크립터와 inode 테이블을 매칭하기 위해 프로세스가 파일을 열고 작업하기 위해 요청한 모든 정보(open file description table)를 담는다. 프로세스별 파일 디스크립터 -> Open File Description Table -> inode 테이블 순으로 하드웨어에 가깝다.

모든 운영체제 정보를 파일로 노출하는 리눅스답게 /dev/fd에서 파일 디스크립터 전체 목록을 볼 수 있다.

리눅스의 Inter Process Communication

시그널

시그널은 보통 운영체제가 응용 소프트웨어에 보내는 이벤트이다. 프로세스가 자력으로도 보낼 수 있지만 흔한 경우는 아니다. 많은 시그널 중 다음과 같은 종류가 프로세스에게 자주 보내진다.

  • SIGABRT: 프로세스가 abort()를 호출한 경우에 덤프가 된다는 신호
  • AIALRM: alarm() 또는 setitimer()로 설정한 타이머가 만료된 경우
  • SIGBUS: mmap()을 호출하는 식으로 메모리 매핑을 하려는 시도가 실패한 경우
  • SIGCHLD, SIGCLD: 멀티스레딩 환경에서 자식 프로세스의 실행 상태 변화(종료 및 재개)
  • SIGCOND: 프로세스 재개 신호
  • SIGFPE: 프로세스가 0으로 나누기를 했을 경우
  • SIGINT: bash 셸에서 Ctrl C로 프로세스 강제 인터럽트를 걸었을 경우. 별도의 조치를 취하지 않았을 때 프로세스의 시그널 대응은 자신의 강제 종료이다.
  • SIGPIPE: IPC 과정에서 닫힌 Pipe에 쓰려고 할 경우
  • SIGQUIT: 인터럽트가 아닌 강제 종료. 프로세스 덤프가 생성된다. Ctrl \을 눌러 수동으로 보낼 수 있다.
  • SIGSTOP: 인터럽트가 아닌 강제 종료. 프로세스 덤프도 생성되지 않는다.
  • SIGTERM: 정상적인 프로세스 종료 신호
  • SIGTRAP: 시스템의 디버깅용 트랩 신호
  • SIGTSTP: 셸에서 실행중인 프로세스(포그라운드 프로세스) 종료 신호. Ctrl Z로 보내는 신호가 이거다.
  • SIGUSR1, SIGUSR2: 프로그래머가 직접 정의하는 시그널

시그널을 수동으로 보내려면 signal.h의 kill(pid, signalvalue)를 호출하면 된다(자기 자신에게 보내면 raise(sinalvalue)도 가능하다). 그리고 시그널이 왔을 때 프로그래머가 프로세스의 동작을 제어할 수 있는 경우 동일 헤더의 signal(sig, handlerfunc)으로 동작을 제어할 수 있다. 핸들러 호출까지 프로세스를 멈추려면 pause()를 호출한다.

여담으로 시그널은 미룰 수 없다(메세지 큐에 쌓는 방식으로 구현된 게 아니다). 그리고 한 프로세스 내 스레드(경량 프로세스 등) 여러 개가 동시에 시그널을 호출하면 작동하는 시그널 핸들러가 호출해도 데드락이나 공유된 메모리 데이터 오염이 일어나지 않는 시스템 라이브러리 함수를 재진입 가능 함수로 분류한다.

파이프

파이프(Pipe)는 파일 I/O 기반 단방향 IPC 기능이다.

만약 프로세스가 PIPE_BUF의 크기만큼 데이터를 쓰면 쓸 때 Atomic한 동작이 보장된다.

unistd.h의 pipe(filedes[2])를 부르면 filedes[1]에 쓰고 filedes[0]에서 읽을 수 있다. 자식 프로세스가 부모 프로세스의 파일 디스크립터를 받는 리눅스에서 파이프를 연결한 뒤 각 프로세스에서 사용하지 않는 디스크립터를 닫으면 단방향 통신이 완성된다[1] 파이프는 마저 남은 열린 파일 디스크립터마저 닫으면 자동 소멸된다.

FIFO

네임드 파이프(Named Pipe)라고도 불리는 FIFO는 sys/stat.h의 mkfifo(pathname, modevalue)로 만들며 똑같이 파일 I/O(read(), write() 등)를 사용하나 권한 설정 등을 통해 그냥 파이프에 비해 여러 프로세스가 공유하기 쉽다는 특징이 있다. 다만 데이터를 읽으려는 프로세스가 쓰려는 프로세스보다 먼저 파일을 열어야 한다. 열 때 open()에 O_NONBLOCK 플래그를 줘서 여러 파일이 동시에 FIFO 파일을 읽거나 쓰게 할 수 있다(Non-Blocking R/W).

메세지 큐

리눅스의 소켓(Socket)

리눅스의 자원 공유

리눅스의 세마포어

모니터

Read-Copy Update

리눅스의 사용자 관리

  • 계정 정보가 든 패스워드 파일 위치: /etc/passwd <- 로그인 명과 패스워드 필드, 사용자 ID 및 그룹 ID, 그 외의 계정 정보(홈 디렉터리와 로그인 셸 등)가 있으며 패스워드 필드가 비어있으면 무조건 패스워드 없이 로그인 가능하다.
    • Shadow 패스워드 파일 위치: /etc/shadow <- 이것은 패스워드 정보를 passwd 파일을 읽어로 손쉽게 탈취하는 어이없는 문제를 막기 위해 고안되었다.
  • 시스템 내 사용자 그룹들에 관한 정보: /etc/group

사용자와 사용자 그룹

pwd.h에 패스워드 구조체를 가져올 수 있는 getpwnam과 getpwuid가 있다.

grp.h에 있는 getgrnam와 getgrgid로 그룹 파일 레코드를 읽는다.

유닉스 시스템에서 패스워드는 unistd.h의 crypt 함수로 세팅 가능하다. 이는 리눅스도 기본적으로 따른다.

리눅스의 타이머와 시간대, 틱(Tick) 관련 기능

sys/time.h 안에 존재

  • gettimeofday(tv, NULL) : time_t 변수에 1070년 1월 1일 자정(UTC) 기준 몇 초와 몇 마이크로초가 흘렀는지 tv에 담는다.
  • ctime(timep) : timep에 time_t형 정수값을 집어넣으면 표준 포맷의 시간 문자열을 반환한다.
  • gmtime(timep), localtime(timep) : 날짜와 시, 분, 초 같은 현실 시간 단위들로 분해한다(gm은 UTC 00:00 기준, localtime은 현지 시간)
    • mktime(*timeptr): localtime의 반대 작업을 한다(time_t 값을 반환)
  • asctime(timeptr): tm 구조체를 표준 포맷 시간 문자열로 표현

시간대 정의

/usr/share/zoneinfo 디렉토리에 각 시간대 별 시간이 나타난다

로케일

각 나라별 날짜/시간 표기를 setlocale(category, localestr)로 세팅

jiffy

소프트웨어 클록(CPU 내 프로세스의 단위 실행 시간

CPU 시간

times(tmsbuf)는 과거 임의의 시점 이래의 클록 틱의 수를 반환

clock()은 호출 프로세스가 사용한 전체 CPU 시간을 CLOCKS_PER_SEC 단위(보통 백만 jiffy)로 나눈 값

두 함수 모두 사용자 CPU 시간(유저 모드 소프트웨어 시간)과 시스템 CPU 시간(커널 모드 소프트웨어 시간) 같은 CPU 시간을 구분하여 구조체로 보낸다.

타이머

sys/timer.h에는 setitimer(which, new_value_structptr, old_value_structptr)와 getitimer(which, curr_value_structptr)가 있어 전자로 타이머를 하나 만들고 후자로 만료까지 남은 시간을 확인할 수 있다. 이 타이머는 기본적으로 만료시 프로세스를 종료하며, 다른 작업을 하려면 별도의 핸들러를 등록해주어야 한다. which는 ITIMER_REAL(현실 시간대로 세는 타이머, 시그널 SIGALRM 송출), ITIMER_VIRTUAL(프로세스 가상 시간, 즉 사용자 모드 CPU 시간을 세는 타이머로 프로세스 스케줄러의 vruntime과 관련됨, 시그널 SIGVTALRM 송출), ITIMER_PROF(프로파일링 전용 시그널을 만료시 보내며 커널 모드 CPU 시간까지 포함한 시간을 센다) 중 하나로 세팅한다. getitimer의 curr_value_structptr.it_value에는 남은 시간이 표시된다.

unistd.h의 alarm(seconds)로 SIGALRM을 송출하는 더 간단한 타이머를 만들 수 있다. 이건 ITIMER_REAL이 설정된 setitimer와 동작이 충돌한다. 또 해당 헤더의 sleep(seconds)로 강제로 프로세스 작동을 지연시킬 수 있다.

리눅스의 기본 파일 시스템

리눅스는 ext 계통의 파일 시스템을 주로 사용한다. 하지만 다른 파일 시스템도 지원하며, VFS(Virtual File System)를 통해 이들 파일시스템을 포괄하여 파일 I/O를 수행할 수 있는 체계를 응용 프로그램에게 제공한다. open, read, write, lssek, close, truncate, tat, mount, umount, mmap, mkdir, link, unlink, symlink, rename 같은 파일 I/O 메서드가 다 VFS 위에서 돌아간다.

파일 시스템 기본 개요

리눅스의 ext2 파티션은 부트블록, 슈퍼블록, 인덱스 노드 테이블(약칭 i-node 테이블 또는 i-list), 그리고 데이터 블록으로 구성된다. 부트 블록을 제외하면 모든 블록이 탐색에 최적화되도록 여러 블록 그룹으로 분할되는 경우가 있다.

  • 부트 블록: 말 그대로 부팅에 필요한 프로그램 데이터가 있는 블록이다. OS가 없는 파티션은 이 부분이 비어 있어 사용되지 않는다.
  • 슈퍼 블록: 파티션 정보가 들어있는 메타데이터 블록. 인덱스 노드의 크기, 파티션 내 논리 블록의 크기, 논리 블록 기준 파일 시스템의 크기 등이 적혀 있다.
  • i-node 테이블: 각 파일과 디렉토리 모두 i노드 테이블에 그 위치가 적혀 있다. 한 파일 또는 한 디렉토리마다 하나의 i-node가 있으며 파티션 내 파일을 탐색할 때 이 부분에서 파일이 있는 데이터 블록의 위치와 갯수를 찾는다.

i-node 내에는 파일 정보 외에 크기가 15인 블록 주소 포인터 리스트가 있으며 파일 데이터가 들어있는 데이터 블록이 i-node의 주소 리스트에 직접 매핑될 수도 있지만, 파일이 15개 블록 주소 안에 못 넣을 경우 마지막 3개 블록에 파일 데이터가 아닌 다른 블록의 주소값 리스트가 들어있는 '포인터 블록'이 매핑된다. 이 체계를 간접 포인터(indirect pointer)라고 한다. 모든 블록은 32비트 정수 주소(4바이트 주소)를 가지므로, 포인터 블록에는 (블록의 크기) / (4바이트) 개의 주소들이 들어있다. 가령 파일 시스템의 블록 크기가 4096바이트인 경우 간접 포인터 체계에 의해 한 포인터 블록에는 1024개의 블록 주소가 들어 있다.

ext2의 간접 포인터 체계에서 i-node의 13번째 블록은 1단계 간접 포인터, 14번째 블록은 2단계, 15번째 블록은 3단계로 지정되어 있다. 따라서 ext2는 블록 크기가 최대값이 4096바이트일 때 한 파일의 최대 크기가 12 × 4096 + 1024 × 4096 + 1024 × 1024 × 4096 + 1024 × 1024 × 1024 × 4096 바이트이며 이는 4.004테라바이트 정도 된다.

저널링

ext2의 단점은 갑작스러운 파일 I/O 중단으로 인한 파일 크래시가 의심될 경우(예: 커널 패닉, 시스템 전원 공급 중단으로 인한 강제 종료 등) 무조건 i-node를 다시 구성해야 한다는 점이다(e2fsck 프로그램이 이걸 담당했으며 시스템 크래시로 컴퓨터가 꺼졌을 때 다음 부팅 때 무조건 작동하여 사용자들을 긴 부팅 시간으로 고통받게 했다)

그래서 리눅스 버전 2.4.15부터 기본 채택된 ext3에서는 저널링이라 하여 파일의 변경 요청 기록을 먼저 기록한 뒤에 파일 변경을 하는 체계를 도입하였다. 이러면 파일의 생성이나 수정 요청이 먼저 디스크에 기록된 다음 실행되기 때문에 중간에 갑자기 파일 I/O가 중단되어도 재부팅 시에 재개하여 시스템 가용성과 일관성을 높일 수 있다. ext2 <-> ext3의 차이는 저널링 시스템 유무가 큰 비중을 차지하기에 저널링 체계의 첨삭 기능 추가로 파일 시스템의 상호 변환을 할 수 있다.

파티션 마운트

디스크 내 파티션은 sys/mount.h 헤더 내의 mount(sourcedev, targetdir, fstype, mountflags, data)로 인식하고 umount()로 인식을 해제한다. 아니면 UNIX 셸dp mount/umount 커맨드를 보낼 수도 있다. 전자의 방법은 mount(2)/umount(2), 후자의 경우는 mount(8)/umount(8)이라고도 불린다.

/proc/mounts 파일에 현재 마운트된 파티션들을 찾을 수 있다. 또 mount, umount이라는 특수한 UNIX 셸 커맨드로 /etc/mtab에 파일 시스템의 추가 정보가 담긴 마운팅 정보가 생성된다(다만 마운트/언마운트 과정에 문제가 생기면 /etc/mtab의 무결성이 파괴된다). 덤으로 mount, umount 셸 명령어는 /etc/fstab에 슈퍼유저 권한으로 접근 가능한 추가 마운팅 정보도 제공한다.

/proc/mounts 파일은 마운트된 디바이스 이름, 마운트 지점, 파일 시스템 포맷, 마운트 플래그 옵션(읽기/쓰기 허용 여부)이 공백으로 구분되어 기록되며, 그 뒤에는 /etc/fstab에서만 0이 아닌 값으로 나타나는 dump 옵션, fsck 검사 옵션이 포함된다.

sys/statvfs.h 헤더 내의 statvfs(path, statvfsbuf)로 파티션 정보가 담긴 statvfs 구조체를 가져올 수 있다.

파일 정보와 디렉토리 관련

sys/stat.h 내의 stat(pathname, statbuf)로 파일 정보가 담긴 stat 구조체를 가져올 수 있다. 심볼릭 링크(바로가기 파일) 그 자체의 정보를 가져오는 lstat(pathname, statbuf)과 이미 열린 파일의 정보를 가져오는 fstat(fd, statbuf)도 있다.

리눅스의 파일은 파일 소유자, 파일 소유자가 속한 그룹의 모든 사용자, 그 외의 사용자에게 읽기/쓰기/실행 권한을 따로 부여할 수 있다(bash 셸의 chmod 명령어로 변경하는 그거 맞다). 디렉토리도 일종의 파일로 간주되나, 권한을 부여할 때 읽기는 ls 명령어 등으로 디렉토리 내부 파일 목록을 볼 권한, 쓰기는 파일 생성/제거나 이동에 관한 권한, 실행은 파일을 열고 닫는 권한으로 대체된다(디렉토리 내 프로그램의 실행 권한은 검색 권한이라는 별도의 용어가 있다).

sys/stat.h에 chmod(pathname, modevalue)와 fchmod(fd, modevalue)가 있어 각각 일반 권한 변경 기능, 열린 파일 권한 변경 기능이 있다. 그 외에 리눅스는 ACL이라는 확장된 권한 부여 체계가 있긴 한데, 기능을 구현한 구현물이 워낙 다이나믹(=표준 없음)하여 여기서 다루지 않는다.

i-node 정보는 ioctl(fd, FLAG, attributeptr)로 접근, 변경 가능하다.

디렉토리는 특별한 파일로 취급되어 그 안에 다른 파일의 이름과 파일의 i-node 번호 리스트가 들어있다. 놀랍게도 ext의 i-node에는 파일 이름이 없어서 디렉토리 파일을 참조해야 하는데, 이로 인해 한 디렉토리나 여러 디렉토리에서 같은 파일에 대해 서로 다른 이름을 저장할 수 있게 한다(하드 링크). 디렉토리가 아닌 파일에 다른 파일을 가리키도록 별도의 이름을 부여하는 심볼릭 링크(바로 가기)와는 다르니 주의하자. 덤으로 심볼릭 링크를 줄줄이 꿰는 식의 참조 양산은 표준이 최대 8개 길이로 제약되어 있다.

하드 링크는 unistd.h의 link(oldpathname, newpathname)나 unlink(pathname)으로 생성/삭제한다. 파일 자체를 건드리는 rename(oldpathname, newpathname)과는 또 다르다!

심볼릭 링크는 symlink(targetpath, linkingpath)로 생성하며, unlink(pathname)으로 하드 링크처럼 삭제한다. 심볼릭 링크가 가리키는 파일이 아닌 링크 그 자체를 읽으려면 readlink(path, buf, size)로 평범한 파일 read()하듯이 읽으면 된다.

디렉토리 생성은 sys/stat.h의 mkdir(path, modevalue), 디렉토리 삭제는 unistd.h의 rmdir(path)로 할 수 있다. 파일이건 디렉토리건 상관 없이 지우는 remove(path)도 있으며, 디렉토리를 파일로서 그 자체를 읽고 쓰는 작업은 opendir(path)로 DIR 값 생성 -> readdir(DIR) 반복 호출 순으로 할 수 있고, rewinddir(DIR)로 현재 읽은 파일 리스트 위치를 처음으로 돌리거나 closedir(DIR)로 디렉토리 읽기 작업을 끝낸다.

팁으로 unistd.h를 포함한 뒤 현재 프로그램이 작업 중인 디렉토리를 getcwd(resultbuf, size)를 사용해 resultbuf로 빼낼 수 있으며, 프로그램이 작업 중인 디렉토리를 옮기려면 chdir(pathname)으로 변경할 수 있다.

리눅스용 라이브러리 바이너리 제작

리눅스의 기본 C 라이브러리인 glib(GNU C 라이브러리) 등의 라이브러리로는 부족한 기능이 있을 경우 직접 라이브러리 제작이 가능하다.

GCC로 프로그램을 컴파일 할 때 프로그램 내 함수 호출 규약이 3가지 있다. 보통 _cdecl, _stdcall, _fastcall로 호출한다.

  • cdecl: 함수를 호출하기 전 프로그램의 스택 영역에 파라미터와 리턴값 보관 장소를 마련하고 함수를 호출한다. printf 등 가변 인자를 사용하는 함수의 제어는 들쭉날쭉한 파라미터 리스트 길이의 크기를 알기 쉬운 cdecl 방식이 압도적으로 간편하다는 사실을 알 수 있다. 호출한 함수의 함수 종료 후 리턴 지점을 기록하는 메모리 주소 시작지점(호출 연결 정보의 위치)이 곧 가변 인자 리스트의 끝 바로 다음이므로 다른 건 무시하더라도 가변 인자 하나 때문에 운영체제/시스템 라이브러리 개발시 함수에 _cdecl을 선언하는 경우가 많다.
  • stdcall: 함수를 호출한 후에 프로그램의 스택 영역에 파라미터와 리턴값 보관 장소를 마련한다. 함수 호출이 잦은 코드는 stdcall을 써서 메모리 영역을 줄일 수 있다.
  • fastcall: 레지스터에 최초 몇 개의 파라미터를 밀어넣고 나머지를 스택에 넣는다. 스택에 넣은 파라미터는 cdecl처럼, 스택에 넣지 않고 레지스터로 바로 보낸 초반부 파라미터는 함수 호출 후 stdcall처럼 메모리 영역을 차지하는데, 보통의 프로그램은 함수 호출 시 많아야 3~4개의 리턴값 포함 파라미터를 쓰기에 성능은 stdcall과 cdecl의 중간 정도의 성능이 제일 안 좋을 때이며, 빠를 때에는 stdcall을 소폭 능가한다(메인 메모리까지 가야 스택 영역의 파라미터를 알 수 있기에 호출 타이밍에 CPU 내에 일부 파라미터를 먼저 보낸 fastcall이 이상적 함수 호출 상황에서 제일 빠른 것). AMD64 ISA를 사용하는 CPU는 늘어난 레지스터 수를 바탕으로 이게 함수 호출법의 기본값으로 정해져서 _fastcall 접두어를 함수 앞에 붙이는 건 AMD64 타깃으로 컴파일할 때 무시된다.

C 프로그램은 프로그램 시작시 Bash 등의 쉘로부터 넘겨받은 프로그램 파라미터 리스트의 처음 원소(argv[0])에 프로그램 실행명이 들어있다. 물론 심볼릭 링크 이름이 들어있는 경우도 있어서 프로그램 명칭을 가져오는 완벽한 방법은 아니나 상당히 도움이 되는 정보이다. 리눅스의 경우 더 정확한 방법으로 /proc/(프로세스 PID)/cmdline 파일에 대한 I/O로 특정 프로세스의 명령행 인자 전체를 null값(0)으로 구분하며 읽을 수 있다.[2]

환경 변수는 프로그램을 실행할 때 사전에 정해진 시스템 정보로 셸 프로세스가 동작하는 동안 'export ENV_NAME = /path/to/env' 셸 명령어를 실행하면 /path/to/env를 가리키는 환경변수 ENV_NAME이 생성되는 식으로 생성할 수 있다. printenv 셸 명령어로 현재의 환경 변수 목록을 확인할 수 있다. 리눅스 C 프로그램에서는 extern char **environ;로 사전에 정의된 환경 변수 리스트를 가져오거나 아예 main 함수에 세번째 인자로 문자열의 리스트를 선언하면 환경 변수 리스트가 거기 담기는 형태로 환경변수를 가져올 수 있다. 아니면 getenv("ENV_NAME")로 ENV_NAME이란 환경변수의 문자열 값을 가져오는 등의 작업을 하거나 putenv("ENV_NAME=value") 및 setenv("ENV_NAME", "value", overwrite) 형태로 환경변수를 수정하며 unsetenv("ENV_NAME")으로 제거하도록 SUS v3에 명시되어 있다(clearenv()는 아예 리스트를 통째로 비우기에 사용이 어렵다).

<setjmp.h> 헤더파일에는 longjmp(env, setjmp(env))처럼 goto 기능이 정의되어 있는데, CPU의 분기 명령어를 직접 실행하는 함수라 코드를 난장판으로 만들 위험이 있다. 어셈블리 수준의 정밀한 조건 분기가 필요한 아주 특수한 상황이 아니면 사용을 하지 않는 것이 좋다. 어차피 int i = setjmp(env); 같은 건 스택 정보 저장에 부적절한 구문이라 C99에서 컴파일러가 막고 경고하기도 하며 잘못 짜면 프로그램의 런타임 에러나 보안 취약점을 만들게 된다.

리눅스와 POSIX

POSIX 스레드

pthread.h에 있다. 다음은 자주 쓰이는 기능들이다.

  • pthread_create(thread, thread_attr, func, arg) : thread 시작
  • pthread_exit(returnvalue) : thread 종료(thread의 main이 되는 함수에서 return하는 것과 동일한 종료 과정을 거친다)
  • pthread_equal(thread1, thread2) : 스레드 ID 일치 여부
  • pthread_join(thread, returnval) : 실행 중인 스레드가 종료될 때까지 기다린다.
  • pthread_detach(thread) : 스레드를 분리하여 시스템이 알아서 종료하도록 함
  • pthread_cancel(thread) : 스레드를 취소한다(비정상 종료)
  • pthread_mutex_lock(mutex) : 스레드의 뮤텍스를 잠그고 다른 스레드가 특정 자원을 쓰려고 하면 쓰지 못하게 태스크 스케줄링으로 중단되게 할 수 있다.
  • pthread_mutex_unlock(mutex) : 스레드의 뮤텍스를 풀어 다른 스레드가 자원에 접근하게 한다.
  • pthread_cond_signal(cond) : 컨디션 시그널로 기다리는 스레드를 하나 깨운다.
  • pthread_cond_signal(cond) : 컨디션 시그널로 기다리는 스레드를 모두 깨운다.
  • pthread_cond_wait(cond, mutex) : 컨디션 시그널과 뮤텍스를 매칭해 해당 뮤텍스와 관련된 현재 스레드를 컨디션 시그널이 올 때까지 기다리게 한다.

그 외에 뮤텍스와 컨디션 시그널 변수를 동적 초기화하는 pthread_mutex_init, pthread_cond_init와 핸들러의 재진입 가능을 보장하는 단발성 초기화 메서드인 pthread_once 등이 있다.

POSIX 소켓

POSIX IPC

그 외의 POSIX 규격

POSIX 타이머

  • time.h의 clock_gettimer(clockid, tp)와 clock_settime(clockid, tp), clock_getcpuclockid(pid, clockid), clock_nanosleep(clockid, flags, request_tp, remain_tp)
    • pthread.h까지 포함하면 쓸 수 있는 pthread_getcpuclockid(thread, clockid)
  • timer_create(clockid, evp, timerid), timer_settime(timerid, flags, itp_value, itp_old_value), timer_gettime(timerid, itp_curr_value), timer_delete(timerid), timer_getoverrun(timerid)
    • timer_fd_create(clockid, flags), timerfd_settime(fd, flags, itp_new_value, itp_old_value), timerfd_gettime(fd, itp_curr_value)

참고할 만한 자료

  1. 만일 파일 디스크립터를 전부 열어둔 상태로 내버려두면 부모와 자식이 같은 내용을 볼 수 있는데, 어느 프로세스에서 데이터를 보낸 것인지 모를 뿐더러 한쪽이 파이프를 읽는 도중 반대쪽이 쓰기를 하면 파이프가 깨지기에 이걸 양방향 IPC로 볼 수 없다.
  2. 여담으로, sysconf(_SC_ARG_MAX)로 프로그램 파라미터 리스트의 최대 원소 수를 구할 수 있는데, 4KB보다 큰 값으로 정해져 있다.