리눅스/리눅스 시스템 프로그래밍
이 문서는 리눅스에서 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)은 커널 모드에서 작동하는 운영체제의 기능을 유저 모드의 프로그램이 가져오기 위해 커널 메모리 영역에 접근하는 절차다. 시스템 콜은 다음 절차를 따라 이루어진다.
- 응용 프로그램이 Wrapper 함수를 호출하면 래퍼함수가 시스템 호출에 필요한 정보(현재의 레지스터 값, 프로그램 상태 등)을 레지스터나 스택에 넣어 백업한다.
- 어떤 시스텡 호출 기능을 사용할 것인지 레지스터에 넣은 후 트랩을 호출한다. x86 계통 CPU의 경우 EAX 레지스터에 호출 기능 번호를 넣고 int 0x80로 트랩 벡터가 가리키는 코드를 실행한다.
- 커널은 트랩 호출을 감지하면 커널 모드로 전환해 system_call() 함수로 커널 스택에 레지스터 값 등을 백업하고 호출의 유효성을 확인하며 시스템 콜의 서비스 루틴 테이블(sys_call_table 커널 변수)에서 요청한 기능을 찾아 실행한다.
- 기능 처리가 끝나면 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가 우측으로 기울어져 실행 시간을 받지 못하는 프로세스가 생기는 일은 없다(Starvation 없음). 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)가 별도로 존재한다.
프로세스 스케줄링 정책과 우선순위[편집 | 원본 편집]
리눅스의 프로세스 스케줄링 정책은 다음 상수들을 세팅함으로 이루어진다.
- SCHED_RR : 우선 실행 레벨이 있는 라운드 로빈(실행한 프로세스를 실행 큐 맨 뒤에 넣어서 큐 내의 모든 프로세스가 돌아가면서 균등한 시간 동안 실행된다) 정책을 쓴다.
- SCHED_FIFO : First in, First Out(실행한 프로세스를 실행 큐 맨 뒤에 넣어서 큐 내의 모든 프로세스가 돌아가면서 실행되나 프로세스마다 sched.h의 sched_yield()로 수동으로 실행을 포기해야 다음 프로세스가 실행될 수 있다) 정책을 쓴다.
- SCHED_OTHER 우선 실행 레벨이 없이 공평한 라운드 로빈 정책을 쓴다.
- SCHED_BATCH : 자주 wake(프로세스 실행 재개)된다면 덜 실행하게 된다.
- SCHED_IDLE : nice 값을 무시하다시피 하여 SCHED_OTHER를 실행한다.
- SCHED_NORMAL : Completely Fair Scheduler(CFS)를 사용한다.
- SCHED_DEADLINE : 실행 시간의 데드라인을 프로세스 별로 정하고 데드라인 시간이 짧은 것부터 실행한다.
프로세스의 스케줄링 정책과 우선순위는 sched.h의 sched_getscheduler(pid)와 sched_getparam(pid, prioritystruct)로 가져오고, sched_setscheduler(pid, policy, prioritystruct)으로 둘 다 세팅하거나 sched_setparam(pid, prioritystruct)으로 우선순위를 세팅할 수 있다.
리눅스의 프로세스 우선순위는 nice라는 가중치의 형태로 수동 조절이 가능한 경우가 있다.
- getpriority(which, id) : which로 선택한 프로세스 선택 기준(PRIO_PROCESS, PRIO_PGRP, PRIO_USER)에 따라 id 값에 해당하는 프로세스(들)의 nice를 정수 값으로 반환한다.
- setpriority(which, id, priority) : 선택한 프로세스 범위 기준에 priority 정수 값을 nice로 적용한다.
PREEMPT_RT 옵션을 주고 컴파일한 리눅스 커널은 커널의 기본적인 프로세스 선점(Preemption) 기능이 비활성화되어 프로세스든, 커널 단계든 수동으로 스케줄링 정책을 짜야 하며, 인터럽트나 Priority Inversion이 되는 뮤텍스에 의존하는 스케줄링을 할 수 없다.
멀티코어 시스템에서는 CPU 친화도라는, 특정 CPU를 지정하여 프로세스를 실행하도록 유도할 수 있는 기능이 있다. sched_setaffinity(pid, len, cpu_set)으로 세팅할 수 있으며 pid가 0이면 호출한 프로세스를 대상으로 친화도를 조절한다. sched_getaffinity(jpid, len, cpu_set)으로 친화도 값을 가져올 수 있으며, CPU_ZERO(set) 및 CPU_SET(cpu, set), CPU_CLR(cpu, set), CPUISSET(cpu, set) 등의 매크로로 cpu set 친화도 구조체를 변경할 수 있다.
경량 프로세스(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이 있긴 한데 쓸 일이 그닥 많지 않다.
한편 리눅스는 물리 메모리의 영역별 특성을 Zone 개념으로 정의한다.
- ZONE_NORMAL: 평범하게 페이지가 할당된 물리 메모리 영역
- ZONE_MOVABLE: 평범한 페이지가 할당되었긴 한데, 할당된 물리 메모리 주소가 바뀔 수 있다.
- ZONE_DMA, ZONE_DMA32: 16bit/32bit 데이터 프로세싱 CPU가 주력이던 시절 16bit/32bit addressing DMA로 RAM <->장치 간 데이터 전송 방식을 사용하는 장치들을 사용하기 위해 커널의 lowmem 부분을 이곳으로 정의할 수 있다. 일종의 레거시 호환 영역.
- ZONE_HIGHMEM: CPU의 접근 가능 주소 범위를 벗어난 영역. PAE 등의 특별한 접근 기능을 사용하기 위해 동원한다. 32bit x86 시절에 주력으로 쓰였다.
버디 블록 알고리즘[편집 | 원본 편집]
버디 블록 알고리즘은 특정한 가상 메모리 영역을 단위 메모리 크기에 따라 여러 레벨의 분할 구조를 가지게 하여 프로그램의 효율적인 메모리 사용을 가능하게 한다.
가령 16MB의 램이 있다면 level 0는 1MB, level 1은 2MB, level 2는 4MB, level 3은 8MB, level 4는 16MB를 지정하여 9MB의 메모리 영역을 프로세스가 요청하면 level 4부터 level 0까지 X -> 8MB -> X -> X -> 1MB 순으로 할당할 크기의 단위를 2배씩 내리며(즉 반으로 가르며) 할당 예정 크기를 정교하게 조절한다. 프로세스가 할당받은 메모리 영역을 해제할 때에는 반대 순서로 작은 것부터 운영체제가 해제하고 옆 블록과 합쳐서 상위 레벨로 올릴 수 있는지 확인하면 된다. 이렇게 한 블록을 나누어 버디 블록들을 생성하거나 반대로 인접 블록들을 합쳐 상위 레벨 블록으로 올리기 때문에 Buddy 블록 알고리즘이라고 하는 것이다.
리눅스의 Paging[편집 | 원본 편집]
참조의 지역성(루프 구조의 코드로 인해 특정 코드가 자주 반복하며 실행되는 시간적 지역성과 순차적 데이터 프로세싱에 의해 프로그램에 필요한 데이터를 실시간으로 어느 정도 예측 가능하다는 공간적 지역성 모두 포괄) 때문에 프로그램의 모든 정보를 단번에 메모리에 올릴 필요가 없으며, 페이징을 활용해 적절하게 메모리를 단위로 분할해(x86의 경우는 4KB) 지금 필요한 코드와 데이터만 RAM에 올리고 나머지는 보조 기억 장치의 스왑 영역(Swap area)에 밀어넣는 식으로 RAM 용량을 아낄 수 있다.
이에 따라 Swap을 사용하는 등의 문제로 Page 정보를 담은 페이지 테이블(Page Table)에 페이지를 찾아 접근했는데 실제로 데이터가 없는 경우가 있다. 이를 Page Fault라고 하며 Page Fault가 일어난 프로세스는 기본적으로 실행을 정지하고 보조 기억 장치에 밀어넣었던 RAM 데이터를 다시 가져오게 된다. 물론 이 과정에서 성능 저하가 반드시 발생하지만 RAM 용량이 보조 기억 장치의 용량을 능가하지 못해 2020년에도 평범한 사용자의 PC에 있는 램의 용량이 막 16GB에 도달한 시점에서 이를 초과하는 대용량 그래픽 데이터 등은 어쩔 수 없이 페이지 교체 과정으로 SSD 등의 보조 기억 장치에서 불러와야 한다.
적은 크기의 연속적 영역을 가지는 커널 단계 메모리 할당은 kmalloc와 kfree을 사용하며, 큰 규모의 비연속적 메모리 페이지는 vmalloc - 메모리 크기 기준 -과 alloc_pages - 페이지 수 기준 - 로 할당, kvfree로 해제한다.
메모리 매핑[편집 | 원본 편집]
RAM의 가상 메모리 공간에 특정 영역을 지정하는 것을 메모리 매핑이라고 한다. 메모리 매핑으로 프로세스 별 데이터 공유를 파일 I/O로 경유하게 하여 IPC를 구현하거나, 메모리에 읽고 쓰는 기능으로 파일에 읽고 쓰는 기능을 구현할 수도 있다. 공유/비공개 세팅과 파일 기반/익명 매핑 세팅이 가능하다.
sys/mman.h의 mmap(addr, length, prot, flags, fd, offset) 기능을 활용하여 메모리 매핑을 생성한다. prot는 PROT_NONE(접근 금지), PROT_READ(읽기 가능), PROT_WRITE(수정 가능), PROT_EXEC(실행 가능) 등을 OR 조합으로 설정한다. MAP_SHARED, MAP_PRIVATE를 제외한 flags는 OR 가능하다(MAP_PRIVATE과 MAP_SHARED도 OR이 된다). 파일 매핑의 경우 열은 파일 디스크립터를 fd에 넣고 offset에 파일 포인터를 넣어 메모리 주소 포인팅을 하게 되고 익명 매핑에서 fd와 offset은 무시된다.
주의해야 할 것은 파일을 매핑할 경우 매핑 영역보다 파일을 크게 한 경우 매핑되지 않은 파일 영역에 접근하면 SIGSEGV 시그널로 프로그램이 강제로 꺼지게 된다. 그리고 프로세스가 파일 매핑이 안 된 RAM 영역에 접근하면 SIGBUS 시그널이 나오는데, 이것도 기본 동작은 프로세스 강제 종료라 주의해야 한다. 또 너무 큰 메모리 매핑은 스왑 영역마저 낭비할 정도로 심각한 성능 저하를 보이며, Out Of Memory 에러로 커널이 강제로 프로세스를 종료할 수 있다.
메모리 매핑 해제는 munmap(addr, length)로 가능하며, mremap(oldaddr, oldsize, newsize, flags, ...)으로 리매핑이 가능하다. 파일 매핑의 경우 매핑된 영역과 파일 간의 동기화를 msync(addr, length, flags)로 바로 호출할 수 있다. remap_file_pages(addr, size, prot, pgoff, flags)로 연속적이지 않은 메모리 영역을 운영체제의 메모리 페이징 구현을 이용해 할당할 수 있다.
메모리 매핑 이외에도 mprotect(addr, length, prot)처럼 운영체제 가상 메모리 보호 세팅을 변경하거나, mlock(addr, length) 및 munlock(addr, length)으로 보조 기억 장치로의 메모리 스왑을 막고 풀 수 있다. 메모리 페이지가 스왑되었는지는 mincore(addr, length, resultarr)로 확인하며 madvise(addr, length, advicevalue)로 커널에 프로세스의 사용패턴을 보내면 커널이 알아서 메모리 사용을 최적화해주는 등의 신기한 기능이 있다.
리눅스의 파일 및 디바이스 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 등이 있다.
- flag는 다음 중 하나를 선택한다
- 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에서 파일 디스크립터 전체 목록을 볼 수 있다.
Memory-mapped I/O[편집 | 원본 편집]
파일을 통하지 않고 장치 입출력을 하는 예외는 Memory-mapped I/O이다. 그러나 그렇다고 메모리의 물리 주소에 직접 쓰는 것은 보통 페이지 폴트 예외만 일으킬 뿐이다. 따라서 ioremap 함수를 이용해 장치 I/O를 위한 물리 메모리와 가상 메모리 매핑을 시행하여야 한다. 캐시 메모리 캐싱이 필요 없으면 ioremap_nocache를 쓰고 I/O 가상 메모리 매핑을 해제할 때에는 iounmap을 쓴다.
mmap은 그냥 가상 메모리 영역을 물리 주소에 매핑할 뿐이므로 장치 I/O에는 커널 영역/유저 영역 구분까지 고려한 ioremap을 사용하는 것이 더 유리하다
리눅스의 Inter Process Communication[편집 | 원본 편집]
시그널[편집 | 원본 편집]
시그널은 보통 운영체제가 응용 소프트웨어에 보내는 이벤트이다. 프로세스가 자력으로도 보낼 수 있지만 흔한 경우는 아니다. 많은 시그널 중 다음과 같은 종류가 프로세스에게 자주 보내진다.
- SIGABRT: 프로세스가 abort()를 호출한 경우에 덤프가 된다는 신호
- SIGALRM: 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).
SystemV 공유 메모리[편집 | 원본 편집]
- shmget: 공유 메모리 영역 할당
- shmctl: 공유 메모리 영역 해제
- shmat: 프로세스에 공유 메모리 체결
- shmdt: 프로세스에서 공유 메모리 분리(독립)
리눅스의 소켓(Socket)[편집 | 원본 편집]
리눅스는 유닉스 소켓을 지원한다.
sys/un.h와 sys/socket.h를 같이 가져오면 보통의 소켓 통신을 할 수 있다.socket(AF_UNIX, SOCK_STREAM, 0) 같은 함수로 리눅스에서 소켓을 만든 다음 bind(sfd, addr, size)로 소켓 결속을 할 수 있다. 이 소켓은 셸이나 파일 탐색기에서 파일 형태로 송출되나(=디렉토리가 있음) 소켓을 open()으로 열 수 없으며, 중복된 이름이나 상대 경로명으로 소켓을 결속할 수 없다. 다만 소켓의 해제는 unlink나 remove로 한다.
소켓 결속 뒤에는 클라이언트의 연결 요청을 accept=(sfd, NULL, NULL)로 가져와 read/write 함수로 처리한 후 close로 요청을 닫는다.
netinet/in.h에 정의된 sockaddr_in으로 bind의 IPv4 address를 지정한다. IPv6은 sockaddr_in6이 따로 있다. 보통 IPv4와 IPv6을 아우르는 inet_pton()과 inet_ntop()로 십진수 표기를 이진수로 바꾸거나 그 반대를 수행할 수 있다.
recv(sfd, buffer, datalen, flags)과 send(sfd, buffer, datalen, flags) 등으로 read/write로 못하는 소켓 I/O도 가능하다.
TCP/IP[편집 | 원본 편집]
리눅스로 TCP/IP 네트워크 통신을 할 때에는 /etc/services 파일에서 잘 알려진 포트들(HTTP 등이 쓰는 핵심 포트)의 서비스명을 찾을 수 있다.
sys/socket.h 등을 가져오고 getaddrinfo(hoststr, servicestr, hintstruct, resultstruct)으로 호스트와 서비스명을 가져온 다음 freeaddrinf0(resultstruct)로 메모리를 해제하는 방식으로 IP 통신 정보가 구성된 구조체를 만들 수 있다. 반대로 호스트명과 서비스명을 IP 통신 정보 구조체로부터 가져오려면 getnameinfo(socketaddr, addrlen, hoststr, hoststrlen, servicestr, servicestrlen, flags)로 가져온다.
TCP 통신 절차는 다음과 같다.
- 서버가 listen()을 호출해서 소켓을 열고 accept로 요청 받기를 시작한다.
- 클라이언트가 connect()로 연결하고 서버로 SYN 세그먼트를 전송하면 서버가 SYN과 ACK를 모두 세팅한 세그먼트를 답신으로 보내 데이터 전달 순서(ISN 시작 번호)를 서로 맞춘다.
- 클라이언트가 ACK 세그먼트를 보내면 연결이 완료된다.
- 이제 한 쪽이 데이터가 든 세그먼트를 보내고 반대쪽이 보낸 ACK를 받는다.
- 연결을 끝낼때 한쪽이 소켓에 close()를 해서 FIN 세그먼트를 전송하면 다른 쪽이 ACK를 보내고 소켓 close()를 한 다음 그 다른 쪽이 ACK 세그먼트를 보내어 연결을 종료한다.
리눅스의 자원 공유[편집 | 원본 편집]
SystemV 세마포어[편집 | 원본 편집]
유닉스 운영체제의 일종이었던 SystemV 운영체제의 세마포어 방식을 리눅스도 지원한다
- semget: 세마포어를 만든다. IPC_CREAT는 신규 세마포어를 만들며, IPC_EXCL과 함께 써서 이전에 이 세마포어를 만든 적 없다는 것을 확인할 수 있다.
- semctl: 세마포어의 특성 초기화를 한다. SETVAL이나 SETALL 오퍼레이션을 넘겨 하나의 프로세스에만 초기화를 수행하게 된다. IPC_STAT 오퍼레이션을 세팅하면 세마포어의 상태를 알 수 있다.
- semctl에 IPC_RMID 오퍼레이션을 세팅하면 세마포어가 삭제된다.
- sembuf형 구조체로 세마포어 세팅을 하게 되는데 sem_flag 멤버에 IPC_NOWAIT을 세팅하면 논블로킹 세마포어(세마포어가 잠긴 상태에서 추가 자원 접근이 발생하면 스케줄링 없이 즉시 에러를 뱉고 프로그램 종료)가 되며, SEM_UNDO가 되면 세마포어 삭제를 잊어도 시스템이 알아서 세마포어 반환 처리를 한다.
- semop: 세마포어 값을 수정한다. 값을 내리거나 올릴 수 있으며 0으로 내리면 관련된 자원의 사용이 잠긴다.
모니터[편집 | 원본 편집]
세마포어에 조건 동기를 결합한 기능이다. 그에 따라 두 개의 큐를 사용한다.
Read-Copy Update[편집 | 원본 편집]
공유하고 있으나 변경할 경우 동기화 문제가 우려될 때 먼저 변경할 데이터를 읽어서(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 소켓[편집 | 원본 편집]
sys/socket.h에서 관련 함수를 찾을 수 있다.
버클리 소켓 규격에 따르면 socket(소켓 생성), bind(소켓 설정, IP 어드레스나 포트 등을 설정한다), listen(서버에서 포트에서 들어오는 데이터 수신 시작), connect(클라이언트에서 연결 요청 보냄) 및 accept(클라이언트로부터의 연결 요청 수락), send 및 recv(데이터 송수신), close(연결 닫기), shutdown(소켓 삭제) 순으로 소켓 통신 사이클이 돈다.
그 외에 socketpair(), getsockname(), getpeername(), sendto(), recvfrom(), sendmsg(), recvmsg(), getsockopt(), setsockopt(), getaddrinfo(), getnameinfo() 등의 기능이 있다.
TCP 통신을 위한 옵션[편집 | 원본 편집]
getsockopt로 가져오고 setsockopt로 세팅 가능한 TCP 통신 옵션은 다음과 같다.
- TCP_CONGESTION: TCP 혼잡 방지 알고리즘을 세팅한다.
- TCP_CORK: Partial Frame을 보내지 않는다.
- TCP_INFO: 소켓 정보를 얻는다.
- TCP_KEEPCNT, TCP_KEEPIDLE, TCP_KEEPINTVL : 커넥션 시간 관련 세팅들
- TCP_DEFER_ACCEPT: Accept 요청을 소켓에 데이터가 들어왔을 때에만 한다. 시스템 소모 전력을 좀 아낄 수 있으나 반응이 좀 느리다.
- TCP_LINGER2: 버려지는 FIN_WAIT2 state의 소켓이 삭제되기까지 걸리는 시간을 세팅한다. 타 시스템에 채용된 경우가 많지 않아서 이식성이 떨어지는 옵션이다.
- TCP_NODELAY: Nagle 알고리즘에 따른 효율적 패킷 전송 방식을 쓰지 않는다.
- TCP_MAXSEG: 외부로 나가는 TCP 세그먼트 최대 크기를 정한다.
- TCP_USER_TIMEOUT: 타임아웃까지 걸리는 시간을 세팅한다.
- TCP_WINDOW_CLAMP: 데이터그램의 전송 크기 윈도우를 이 값으로 제한한다.
- TCP_FASTOPEN: RFC 7413에 정의된 소켓의 Fast Open을 서버에서 사용한다.
- TCP_FASTOPEN_CONNECT: 클라이언트에서 Fast Open을 수행한다.
POSIX IPC[편집 | 원본 편집]
다음은 POSIX 메세지 큐 API다.
- mq_open: 새로운 메시지 큐를 생성하거나 기존 큐를 열림 처리하고, 디스크립터를 반환한다
- mq_send: 데이터를 큐에 쓴다
- mq_receive: 큐에서 메시지를 읽는다
- mq_close: 이전에 열어 놓은 메시지 큐를 닫는다.
- mq_unlink: 메시지 큐를 삭제한다.
- mq_notify: 메시지 큐에 데이터가 들어왔음을 메시지 큐를 사용하는 프로세스들에게 알린다.
- mq_getattr, mq_setattr: 메시지 큐의 특성을 읽거나 설정한다.
그 외의 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)
POSIX 세마포어[편집 | 원본 편집]
semaphore.h에서...
- sem_init: 세마포어 생성
- sem_wait, sem_trywait, sem_timedwait: 세마포어 획득(카운터 감소)
- sem_post: 세마포어 반환(카운터 증가)
- sem_getvalue: 세마포어 상태를 가져온다
- sem_unlink: 세마포어 삭제
리눅스 디바이스 드라이버 개발 과정[편집 | 원본 편집]
리눅스의 디바이스 드라이버는 크게 문자 드라이버(키보드/마우스처럼 버퍼 없이 신호를 들어오는 대로 바로 처리해야 함), 블록 드라이버(대부분의 보조 기억 장치의 드라이버가 이 종류로 버퍼가 있어서 상황에 따라 뒤에 오는 신호를 앞선 신호보다 먼저 처리할 수 있다), 네트워크 디바이스가 있다.
각 디바이스의 그룹, 종류 번호(Major와 Minor가 있다)를 알고 있다면 디바이스의 드라이버를 작성하자. 그 전에 디바이스 드라이버의 적재 관련 리눅스 쉘 명령어들로 다음이 있다.
- mknod: 디바이스 드라이버 파일을 생성한다. 리눅스는 모든 I/O를 파일의 open/close 및 read/write로 처리한다는 것을 이미 설명했으므로 이 파일은 반드시 있어야 한다.
- insmod: 실행 중인 커널에 디바이스 드라이버 모듈을 적재한다.
- rmmod: 실행 중인 커널에서 특정 디바이스 드라이버 모듈을 빼낸다.
- lsmod: 현재 적재된 모듈 리스트를 확인한다.
그럼 이제 디바이스 드라이버 프로그램의 구조를 보자.
- 디바이스 드라이버가 파일 형태로 존재하므로 프로그램이 디바이스 드라이버에 접근했을 때 드라이버 파일에 할 수 있는 동작의 목록인 file_operation 구조체형의 변수를 정의하고 그 안의 open/release/read/write/ioctl 등을 구현해야 한다(close 대신 release가 대응한다).
- 그 다음 드라이버 자체의 초기화를 수행할 init 기능의 함수와 드라이버 모듈 제거에 대응할 exit 함수를 만든다(이때 각각 register_chrdev와 unregister_chrdev를 두 함수에 반드시 각각 써줘야 한다).
- 마지막으로 전술한 두 함수의 함수 포인터를 모듈 초기화 매크로인 module_init와 모듈 해제 매크로인 module_exit 매크로에 인자로 넘겨주면 디바이스 드라이버 소스 코드 제작 끝!
그 다음은 디바이스 드라이버 제작 시 주의할 것들이다.
- 변수의 데이터형에서 bit 크기를 반드시 명시해야한다. 가령 정수형의 경우 u64나 s32 같이 비트 수가 명시되어야 하고 int나 unsigned int를 쓰는 것은 권장하지 않는다. 이는 ABI에 따라 시스템의 데이터 프로세싱 비트 수가 다를 수 있기 때문에 비트 수가 명시되지 않은 데이터형이 타 시스템으로의 디바이스 드라이버 포팅 과정에 심각한 문제가 될 수 있다.
- 구조체 등의 큰 데이터를 4바이트/8바이트 등으로 정렬하는 습관을 기르자. 네트워크 입출력이나 IDE 같은 데에선 거의 필수다. __attribute__ ((packed))라고 데이터 형 앞에 명시하거나 get_unaligned/put_unaligned 매크로 등을 활용하는 것이 좋다.
- volatile 접두어를 써서 예상치 못한 컴파일러 최적화를 막아야 할 수 있다. 주로 멀티프로세싱 상황에서 쓰는 공유 메모리나 Memory-mapped I/O용 변수 등에 대해 컴파일러가 최적화 중에 쓰지 않는 변수로 착각해 없애버리는 등의 문제가 발생한다.
- 여러 디바이스 드라이버끼리 데이터를 공유할 때에는 /dev 디렉토리 아래에 파일을 만들고 데이터를 거기 읽고 써서 공유하는 방법이 권장된다.
- 인터럽트 처리가 꽤 난해하다. Hard IRQ, Soft IRQ, Tasklet, Workqueue로 디바이스의 인터럽트 신호에 대응하는 처리 루틴들의 스케줄링 방법들이 나뉘는데, 대체로 Tasklet과 Workqueue로 루틴을 설정하는 것이 권장된다. Hard IRQ는 디바이스 드라이버를 위해 존재하는 게 아닌 수준으로 서드 파티 드라이버에선 쓰이지 않고 빈번하고 빨리 처리 가능한 루틴만 Soft IRQ로 넘기고 루틴의 휴면 상태가 가능한 고성능 장치의 경우는 Workqueue에 루틴을 밀어넣으며 그 외의 급하지 않은 처리 루틴들은 Tasklet로 후순위 처리를 하게 한다.
- 그리고 인터럽트 신호를 공유하는 경우가 매우 많기에, open과 release 과정에서 인터럽트 루틴 등록과 해제를 각각 수행하는 것이 강력히 권장된다. 인터럽트 신호 공유는 request_irq 함수의 flags 파라미터로 사용 여부를 설정할 수 있으며, 같이 파라미터로 넘긴 dev_id 파라미터에 자기가 할당받은 인터럽트 신호에 매칭된 디바이스 ID가 저장되어 나중에 release를 할 때 free_irq에 irq 번호와 넘겨서 인터럽트 루틴 해제를 할 수 있다.