[전문가를 위한 C] Chapter 10. 유닉스의 역사와 아키텍처

YUSHIN KIM·2026년 9월 6일

전문가를 위한 C

목록 보기
10/15

Chapter 10. 유닉스의 역사와 아키텍처

10.1 유닉스의 역사

10.1.1 멀틱스와 유닉스

멀틱스 운영체제 이후 유닉스 운영체제가 개발되었고, 멀틱스의 철학이 승계되었다. 다음은 두 운영체제에 대한 비교이다.

  • 멀틱스와 유닉스 모두 내부 구조로 링 모양의 아키텍처를 따른다: 커널 링과 셀 링이 대표적인 공통 요소이다.
  • 멀틱스는 작동하기 위해 비싼 자원과 머신이 필요했다
  • 멀틱스는 설계가 복잡했다

멀틱스의 혁신을 단순화한 결과가 유닉스이다. 유닉스의 첫 번째 버전은 순수 어셈블리어만으로 작성되었고, 1973년에 최초로 C를 사용한 유닉스 버전 4가 출시되었다.


10.1.2 BCPL과 B

BCPL은 컴파일러를 작성할 목적으로 고안된 프로그래밍 언어이다. C 언어 전 있었던 B 언어는 자료형이 없는(typeless) 언어였기 때문에 바이트가 아닌 워드 단위로만 작업이 가능해 시스템 프로그래밍에 불리했다. 따라서 NB(New B) 언어 개발 시까지 B 언어는 계속 수정되었고 이로부터 구조체가 유래되었다.


10.1.3 C로 향하는 길

다음은 B의 단점으로, C 개발의 계기가 되었다.

  • B는 메모리에서만 워드를 사용할 수 있었다: 당시 가용한 하드웨어는 워드 기반 체계(scheme)로 메모리를 다뤘다.
  • B는 단일 자료형 언어였다: 모든 변수가 워드로 관리되었다.
  • 자료형이 없다는 것은 문자열 조작 알고리즘 같은 멀티바이트(multibyte) 지향 알고리즘이 B에서 효율적으로 작성될 수 없음을 의미한다: 워드는 정수나 문자열 같은 멀티바이트 자료형을 다루기에 비효율적이다.
  • B는 부동소수점 연산을 지원하지 않았다: 당시 하드웨어는 부동소수점 연산을 지원하기 시작했으나 프로그래밍 언어가 지원하지 않았다.
  • 프로그램이 메모리에서 특정 바이트 또는 한 바이트 범위에 접근 시 워드 인덱스 계산에 더 많은 연산이 필요했다: 메모리의 워드만을 다룰 수 있기에 바이트 연산이 비효율적이었다.

워드 기반으로 설계된 B 언어는 New B로 수정되었으며, 이것이 C라고 불린다.


10.2 유닉스 아키텍처

10.2.1 철학

  • 유닉스는 일반적인 최종 사용자(end user)가 아닌 개발자를 위해 설계 및 개발되었다.
  • 유닉스 시스템은 작고 단순한 여러 프로그램으로 구성된다.
  • 복잡한 일은 작고 단순한 프로그램을 연쇄적으로 실행해 수행할 수 있다.
  • 각각의 작고 단순한 프로그램은 출력 결과를 다른 프로그램의 입력으로 전달할 수 있어야 하며, 이 체인은 지속되어야 한다.
  • 유닉스는 매우 문자(text) 지향적이다.
  • 유닉스는 완벽함보다 간결함을 선택하도록 제안한다.
  • 어떤 유닉스 호환 운영체제에 대해 작성된 프로그램은 다른 유닉스 시스템에서도 쉽게 사용할 수 있어야 한다.

10.2.2 유닉스 링

링 구조의 가장 안쪽에는 하드웨어(hardware)가 존재한다. 이를 커널(kernel)이 감싸고 있으며, 하드웨어에 대한 래퍼(wrapper)의 역할을 한다. 커널은 시스템의 전체 자원을 이용할 수 있는 가장 높은 권한을 가진다.

셸(shell)은 사용자 응용프로그램과 커널 간의 인터페이스 역할을 한다. 이는 여러 작은 프로그램으로 구성되어 커널에 대한 서비스를 제공한다. 라이브러리 모음도 포함하며, 모두 C로 작성되어 있다. 단일 유닉스 규격(SUS: Simple Unix Specification)에 있는 라이브러리를 기반으로 표준 인터페이스를 반드시 노출해야 한다.

사용자 응용프로그램은 유닉스 철학의 이식성의 원리 때문에 시스템 호출(system call)을 통해 커널에 직접 접근하기보단 셸 링이 제공하는 API와 도구를 사용해야 한다.

바깥 링이 서비스를 사용할 수 있도록 내부 링은 인터페이스를 제공해야 한다.


10.3 사용자 응용프로그램에 대한 셸 인터페이스

모든 유닉스 시스템은 셸 링에서 같은 응용프로그램 프로그래밍 인터페이스(API: Application Programming Interface)를 노출한다. 표준 인터페이스만을 사용하는 C 소스 코드는 모든 유닉스 시스템에서 빌드 및 실행할 수 있다.

유닉스 표준과 완전히 호환 가능하도록 만들어진 시스템을 유닉스 호환 시스템이라 하며, 대표적인 예로 BSD가 있다. 반면 유닉스 표준을 부분적으로 따르는 시스템은 유닉스 계열 시스템이라 하며, 대표적인 예로 리눅스가 있다.

셸 링이 제공하는 다양한 API는 SUS 표준 아래 수집된다. 다음은 SUS v4에 있는 API의 목록이다.

  • 시스템 인터페이스: 모든 C 프로그램에서 사용할 수 있는 모든 함수의 목록이다. SUS v4에는 1191개의 함수가 있다.
  • 헤더 인터페이스: SUS v4와 호환 가능한 유닉스 시스템에서 사용할 수 있는 헤더 파일 목록이다. SUS v4에는 모든 C 프로그램에 접근할 수 있는 82개의 헤더 파일이 있다.
  • 유틸리티 인터페이스: SUS v4와 호환되는 유닉스 시스템에서 사용할 수 있는 유틸리티 프로그램 또는 커맨드 라인 프로그램의 목록이다. SUS v4는 160개의 유틸리티 프로그램으로 구성된다.
  • 스크립팅 인터페이스: 셸 스크립트 작성 시 사용되는 언어이다. 셸 스크립팅 언어(shell scripting language) 또는 셸 명령어(shell command language)라고 한다.
  • XCURSES 인터페이스: 그래픽 엔진이 필요하지 않은 인터페이스로, 텍스트 기반의 GUI에서 C 프로그램과 사용자가 상호작용하도록 돕는다.

SUS는 시스템에서 헤더를 어디에서 사용할 수 있고, 어디에 위치해 있는지를 설명할 뿐이다. 표준에 따르면 이는 /usr/include 또는 /usr/local/include에 있어야 한다.

만약 시스템 인터페이스와 헤더 인터페이스를 각 유닉스 버전(Unix flavor)마다 상이한 구현과 합치면 C 표준 라이브러리 또는 libc가 된다. 유닉스 시스템에서 개발되는 모든 C 프로그램은 더 하위에 있는 커널이나 하드웨어와 통신하기 위해 libc를 사용한다.

유닉스 호환 시스템은 SUS 표준을 전적으로 준수하지만 유닉스 계열 시스템은 부분적으로만 준수한다. 그러므로 이론적으로 유닉스 호환 시스템을 위해 개발된 프로그램은 다른 유닉스 호환 시스템에 이식할 수 있어야 하지만, 유닉스 계열 시스템에는 이식되지 않을 수도 있다.

리눅스 탄생 이후 여러 유닉스 계열 운영체제가 개발되었고, 이는 SUS 표준의 부분집합에 특정 이름을 부여하는 토대가 되었다. 이것들을 이식 가능한 운영체제 인터페이스(POSIX: Portable Operating System Interface)라고 한다. POSIX 호환성은 표준 셸 링에 대한 것이고, 커널에 대한 것은 아니다.

SUS와 POSIX 표준은 모두 인터페이스에 대한 규칙이다. 각 유닉스 시스템은 이에 대한 자체적인 구현이 있고, 셸 링에 속하는 libc 라이브러리에 위치한다. 즉, 유닉스 시스템에서 셸 링은 표준 방식으로 제공해야 하는 libc 구현을 포함하여 요청을 커널 링이 처리하도록 전달한다.


10.4 셸 링에 대한 커널 인터페이스

사용자 응용프로그램은 셸 루틴을 실행하기 위해 libc 라이브러리와 연결되거나 기존 유틸리티 프로그램을 실행할 수 있어야 한다. 기존 유틸리티 프로그램도 자체적으로 libc 라이브러리를 사용한다. 그리고 유닉스 계열의 운영체제를 설계할 수 있으려면 컴파일 파이프라인과 링크 메커니즘이 필요하다. 이때 C의 모든 특성이 기여한다.

libc 또는 셸 링의 함수는 커널 기능을 사용하기 위해 시스템 호출(system call) 메커니즘을 사용한다.

코드 박스 10-1 [예제 10-1] 셸 링에 포함된 sleep 함수를 호출

#include <unistd.h>

int main(int argc, char** argv) {
    sleep(1);
    return 0;
}

unistd.h 헤더 파일, sleep 함수 모두 SUS가 노출한 인터페이스에 속한다.

셀 박스 10-1 [예제 10-1]이 불러온 시스템 호출을 추적하기 위해 strace를 사용하는 [예제 10-1]을 빌드하고 실행하기

$ gcc 10_1.c -lc -o 10_1.out
$ strace ./10_1.out
...

유닉스 계열 시스템에서는 truss, 리눅스 시스템에서는 strace를 사용하여 시스템 호출을 관찰할 수 있다.

셀 박스 10-2 [예제 10-1]에서 불러온 시스템 호출을 보여주는 strace의 출력 결과

$ strace ./10_1.out
execve("./10_1.out", ["./10_1.out"], 0x7fffee3a2010 /* 84 vars */) = 0
brk(NULL)                               = 0x58669b0e3000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x705110405000
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (그런 파일이나 디렉터리가 없습니다)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=71195, ...}) = 0
mmap(NULL, 71195, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7051103f3000
close(3)                                = 0
openat(AT_FDCWD, "/usr/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0 \250\2\0\0\0\0\0"..., 832) = 832
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 840, 64) = 840
fstat(3, {st_mode=S_IFREG|0755, st_size=2186512, ...}) = 0
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 840, 64) = 840
mmap(NULL, 2231696, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x705110000000
mmap(0x705110028000, 1671168, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = 0x705110028000
mmap(0x7051101c0000, 319488, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1c0000) = 0x7051101c0000
mmap(0x70511020e000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x20d000) = 0x70511020e000
mmap(0x705110214000, 52624, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x705110214000
close(3)                                = 0
mmap(NULL, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7051103f0000
arch_prctl(ARCH_SET_FS, 0x7051103f0740) = 0
set_tid_address(0x7051103f0d68)         = 13190
set_robust_list(0x7051103f0a20, 24)     = 0
rseq(0x7051103f0680, 0x21, 0, 0x53053053) = 0
mprotect(0x70511020e000, 16384, PROT_READ) = 0
mprotect(0x586665327000, 4096, PROT_READ) = 0
mprotect(0x70511044a000, 8192, PROT_READ) = 0
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
getrandom("\x29\x05\x8d\xc1\x49\xe3\xe7\x08", 8, GRND_NONBLOCK) = 8
munmap(0x7051103f3000, 71195)           = 0
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=1, tv_nsec=0}, 0x7ffe0d602000) = 0
exit_group(0)                           = ?
+++ exited with 0 +++

sleep 함수 호출이 clock_nanosleep 함수 호출로 변환되었음을 확인할 수 있다.

셀 박스 10-3 nanosleep 시스템 호출에 대해 할당된 매뉴얼 페이지

$ man nanosleep
nanosleep(2)                    System Calls Manual                   nanosleep(2)

NAME
       nanosleep - high-resolution sleep

LIBRARY
       Standard C library (libc, -lc)

SYNOPSIS
       #include <time.h>

       int nanosleep(const struct timespec *duration,
                     struct timespec *_Nullable rem);

   Feature Test Macro Requirements for glibc (see feature_test_macros(7)):

       nanosleep():
           _POSIX_C_SOURCE >= 199309L

...

다음으로 libc에서 실제로 시스템 호출을 불러오는 지점을 찾아보자.

셀 박스 10-4 FreeBSD 프로젝트를 복제하고 특정 커밋으로 이동하기

$ git clone https://github.com/freebsd/freebsd
...
$ cd freebsd
$ git reset --hard bf78455d496
...

셀 박스 10-5 FreeBSD libc 파일에 있는 nanosleep 시스템 호출 관련 엔트리 찾기

$ cd lib/libc
$ grep sys_nanosleep . -R
./include/libc_private.h:int		__sys_nanosleep(const struct timespec *, struct timespec *);
./sys/Symbol.map:	__sys_nanosleep;
./sys/interposing_table.c:	SLOT(nanosleep, __sys_nanosleep),
./sys/nanosleep.c:__weak_reference(__sys_nanosleep, __nanosleep);

lib/libc/sys/interposing_table.c 파일에 따르면 nanosleep 함수는 __sys_nanosleep 함수와 연결되어 있다. FreeBSD 규칙(convention)에 따라 __sys로 시작하는 함수는 시스템 호출 함수이다.

lib/libc/include/libc_private.h 파일은 시스템 호출 주변의 래퍼 함수에 필요한 비공개 및 내부 함수 선언을 포함한다.


10.5 커널

커널 링은 시스템 호출을 통해 커널의 기능을 제공하는 것이 주요 목적이다. 다음은 리눅스 같은 모놀리식 커널(Monolthic kernel)의 커널 프로세스와 사용자 프로세스를 비교한 것이다.

  • 커널 프로세스는 첫 번째로 로드 및 실행된다. 사용자 프로세스가 스폰되기 전 커널 프로세스가 로드되고 실행되어야 한다.
  • 커널 프로세스는 하나여야 한다. 하지만 사용자 프로세스는 동시에 여러 개가 작업 중일 수 있다.
  • 커널 프로세스는 부트 로더(boot loader)에 의해 메인 메모리로 커널 이미지를 복제해 생성된다. 하지만 사용자 프로세스는 exec, fork 시스템 호출을 이용해 생성된다.
  • 커널 프로세스는 시스템 호출을 다루고 실행한다. 하지만 사용자 프로세스는 커널 프로세스가 시스템을 호출하기를 기다린다. 즉, 사용자 프로세스는 시스템 호출에 관한 실행 흐름을 커널 프로세스에 위임한다.
  • 커널 프로세스는 물리 메모리 및 특권 모드(privileged mode)에 있는 모든 연결된 하드웨어를 본다. 하지만 사용자 프로세스는 가상 메모리를 본다.

운영체제의 런타임에는 커널 프로세스 모드(커널 랜드, 커널 공간), 사용자 프로세스 모드(유저 랜드, 사용자 공간) 두 개의 실행 모드가 존재한다.

다음은 유닉스 커널이 갖는 권한의 목록이다.

  • 프로세스 관리
  • 프로세스 간 통신(IPC: Inter-Process Communication)
  • 스케줄링
  • 메모리 관리
  • 시스템 시작
  • 장치 관리


10.6 하드웨어

연결된 모든 하드웨어는 유닉스 시스템에 연결된 장치로 간주된다. 장치는 필수(mandatory) 장치, 그리고 주변(peripheral) 장치로 구분된다. CPU와 메인 메모리는 필수 장치, 하드디스크 드라이브나 네트워크 어댑터, 마우스, 모니터 등 나머지 모든 하드웨어는 주변 장치이다.

유닉스 커널은 필수 장치를 완벽하게 은닉하며 사용자 공간에서는 접근이 허용되지 않는다. 하지만 주변 장치는 장치 파일이라는 메커니즘을 통해 노출된다. 이는 /dev 경로에서 확인할 수 있다.

셀 박스 10-6 리눅스 머신의 /dev 내용 목록

 ls -l /dev
total 0
crw-r--r--  1 root   root     10,   235 Sep  6 20:27 autofs
drwxr-xr-x  2 root   root           700 Sep  6 20:58 block
crw-------  1 root   root     10,   234 Sep  6 20:27 btrfs-control
drwxr-xr-x  3 root   root            60 Sep  6 20:27 bus
drwxr-xr-x  2 root   root          4600 Sep  6 20:27 char
crw-------  1 root   tty       5,     1 Sep  6 20:27 console
lrwxrwxrwx  1 root   root            11 Sep  6 20:27 core -> /proc/kcore
drwxr-xr-x 14 root   root           280 Sep  6 20:27 cpu
crw-------  1 root   root     10,   260 Sep  6 20:27 cpu_dma_latency
crw-------  1 root   root     10,   261 Sep  6 20:27 cpu_wakeup_latency
crw-------  1 root   root     10,   203 Sep  6 20:27 cuse
drwxr-xr-x 11 root   root           220 Sep  6 20:27 disk
drwxr-xr-x  2 root   root            60 Sep  6 20:27 dma_heap
drwxr-xr-x  3 root   root           100 Sep  6 20:27 dri
...

유닉스에서 하드웨어 장치에 대한 추상화는 가상 장치를 가질 수 있게 하기 때문에, 위 결과는 모두 물리 장치가 아니다. 예를 들어 가상 네트워크 어댑터 장치는 네트워크 패킷에 추가적인 작업을 수행함으로써 VPN 메커니즘의 일부가 될 수 있다.

profile
안녕하세요

0개의 댓글