Chapter 2. 운영체제 구성
운영체제의 핵심 요구사항 중 하나는 여러 활동을 동시에 지원하는 것이다. 예를 들어 1장에서 본 fork와 exec 시스템 콜을 사용하면 컴파일러와 텍스트 편집기를 각각 프로세스로 시작할 수 있다. 운영체제는 CPU와 메모리 같은 자원을 이 프로세스들 사이에서 시분할해야 한다. 또한 운영체제는 프로세스들 사이의 격리도 마련해야 한다. 한 프로세스에 버그가 있어 오동작하더라도, 관련 없는 다른 프로세스에 영향을 주어서는 안 된다. 하지만 완전한 격리는 지나치게 강하다. 프로세스들이 의도적으로 상호작용할 수 있어야 하기 때문이다. 파이프라인이 그 예다. 따라서 운영체제는 세 가지 요구사항, 즉 다중화, 격리, 상호작용을 충족해야 한다.
이 장은 운영체제가 이 세 요구사항을 달성하기 위해 어떻게 구성되는지 개괄한다. 이를 달성하는 방법은 많지만, 이 책은 많은 Unix 운영체제가 사용하는 모놀리식 커널 중심의 주류 설계에 초점을 맞춘다. 또한 이 장은 xv6에서 격리의 단위인 xv6 프로세스를 개괄한다.
xv6는 멀티코어[1] RISC-V 마이크로프로세서에서 실행되며, 저수준 기능의 상당 부분, 예를 들어 프로세스 구현은 RISC-V에 특화되어 있다. RISC-V는 64비트 CPU이고, xv6는 "LP64" C로 작성되어 있다. 이는 C 프로그래밍 언어에서 long(L)과 포인터(P)는 64비트이지만 int는 32비트라는 뜻이다. 이 책은 독자가 어떤 아키텍처에서든 약간의 기계 수준 프로그래밍을 해 보았다고 가정하며, RISC-V에 특화된 개념은 필요할 때 소개한다. 사용자 수준 ISA 문서 [2]와 privileged architecture 문서 [3]가 완전한 명세다. "The RISC-V Reader: An Open Architecture Atlas" [15]도 참고할 수 있다.
완전한 컴퓨터에서 CPU 주변에는 보조 하드웨어가 있으며, 그중 많은 부분은 I/O 인터페이스 형태다. xv6는 qemu의 -machine virt 옵션이 시뮬레이션하는 보조 하드웨어를 대상으로 작성되어 있다. 여기에는 RAM, 부트 코드를 담은 ROM, 사용자의 키보드/화면에 연결되는 직렬 연결, 저장을 위한 디스크가 포함된다.
이 책에서 "멀티코어"는 메모리를 공유하지만 병렬로 실행되며 각자 고유한 레지스터 집합을 가진 여러 CPU를 뜻한다. 이 책은 때때로 multiprocessor라는 용어를 multi-core의 동의어로 쓰지만, multiprocessor는 여러 개의 별도 프로세서 칩을 가진 컴퓨터를 더 구체적으로 가리킬 수도 있다. 본문으로 돌아가기 ↑
2.1 물리 자원 추상화하기
운영체제를 처음 접할 때 떠올릴 수 있는 첫 질문은 왜 운영체제가 필요한가 하는 것이다. 달리 말하면 그림 1.2의 시스템 콜들을 응용 프로그램이 링크하는 라이브러리로 구현할 수도 있다. 이 방식에서는 각 응용 프로그램이 자기 필요에 맞춘 라이브러리를 가질 수도 있다. 응용 프로그램은 하드웨어 자원과 직접 상호작용하고, 그 응용 프로그램에 가장 좋은 방식으로, 예를 들어 높거나 예측 가능한 성능을 얻기 위해, 그 자원을 사용할 수 있다. 임베디드 장치나 실시간 시스템을 위한 일부 운영체제는 이런 방식으로 구성된다.
이 라이브러리 접근법의 단점은 둘 이상의 응용 프로그램이 실행될 때 응용 프로그램들이 잘 행동해야 한다는 점이다. 예를 들어 각 응용 프로그램은 다른 응용 프로그램이 실행될 수 있도록 주기적으로 CPU를 양보해야 한다. 이런 협력적 시분할 방식은 모든 응용 프로그램이 서로를 신뢰하고 버그가 없다면 괜찮을 수 있다. 하지만 응용 프로그램들이 서로를 신뢰하지 않고 버그도 있는 경우가 더 일반적이므로, 협력적 방식이 제공하는 것보다 더 강한 격리가 필요한 경우가 많다.
강한 격리를 달성하려면 응용 프로그램이 민감한 하드웨어 자원에 직접 접근하지 못하게 하고, 대신 그 자원을 서비스로 추상화하는 것이 도움이 된다. 예를 들어 Unix 응용 프로그램은 디스크를 직접 읽고 쓰는 대신 파일 시스템의 open, read, write, close 시스템 콜을 통해서만 저장소와 상호작용한다. 이는 응용 프로그램에 경로명이라는 편의를 제공하고, 인터페이스를 제공하는 운영체제가 디스크를 관리할 수 있게 한다. 격리가 문제가 아니더라도, 의도적으로 상호작용하는 프로그램들, 또는 단지 서로 방해하지 않고 싶은 프로그램들은 디스크를 직접 사용하는 것보다 파일 시스템이라는 추상화가 더 편리하다는 것을 알게 될 가능성이 높다.
마찬가지로 Unix는 필요할 때 레지스터 상태를 저장하고 복원하면서 하드웨어 CPU를 프로세스들 사이에서 투명하게 전환한다. 따라서 응용 프로그램은 시분할을 의식할 필요가 없다. 이 투명성 덕분에 일부 응용 프로그램이 무한 루프에 빠져 있더라도 운영체제는 CPU를 공유할 수 있다.
또 다른 예로, Unix 프로세스는 물리 메모리와 직접 상호작용하는 대신 exec를 사용하여 자기 메모리 이미지를 구성한다. 이를 통해 운영체제는 프로세스를 메모리의 어디에 배치할지 결정할 수 있다. 메모리가 부족하면 운영체제는 프로세스 데이터의 일부를 디스크에 저장할 수도 있다. exec는 실행 가능한 프로그램 이미지를 저장하기 위한 파일 시스템이라는 편의도 사용자에게 제공한다.
Unix 프로세스들 사이의 많은 상호작용은 파일 디스크립터를 통해 일어난다. 파일 디스크립터는 많은 세부사항, 예를 들어 파이프나 파일의 데이터가 어디에 저장되는지를 추상화할 뿐 아니라, 상호작용을 단순하게 만드는 방식으로 정의되어 있다. 예를 들어 파이프라인의 한 응용 프로그램이 종료되거나 실패하면, 커널은 파이프라인의 다음 프로세스에 자동으로 파일 끝 신호를 생성한다.
그림 1.2의 시스템 콜 인터페이스는 프로그래머의 편의와 강한 격리 가능성을 모두 제공하도록 신중하게 설계되어 있다. Unix 인터페이스가 자원을 추상화하는 유일한 방법은 아니지만, 좋은 방법임이 입증되었다.
2.2 사용자 모드, supervisor 모드, 시스템 콜
강한 격리에는 응용 프로그램과 운영체제 사이의 단단한 경계가 필요하다. 응용 프로그램에 버그가 있거나 악의적이더라도, 응용 프로그램이 운영체제나 다른 프로그램의 동작을 방해하도록 허용되어서는 안 된다. 강한 격리를 달성하려면 운영체제는 응용 프로그램이 운영체제의 자료구조와 명령어를 수정하거나 심지어 읽을 수도 없게 해야 하며, 다른 프로세스의 메모리에 접근할 수도 없게 해야 한다.
CPU는 강한 격리를 위한 하드웨어 지원을 제공한다. 예를 들어 RISC-V에는 코드가 할 수 있는 일을 제한하는 세 가지 권한 수준, 즉 machine mode, supervisor mode, user mode가 있다. machine mode에서 실행되는 명령어는 완전한 권한을 가진다. CPU는 machine mode에서 시작한다. machine mode는 주로 부팅 중 컴퓨터를 설정하기 위한 것이다. xv6는 machine mode에서 잠깐 실행된 뒤 supervisor mode로 전환한다.
supervisor mode에서는 CPU가 특권 명령어를 실행할 수 있다. 예를 들어 인터럽트를 활성화하거나 비활성화하고, 페이지 테이블 주소를 담은 레지스터를 읽거나 쓸 수 있다. user mode의 응용 프로그램이 특권 명령어를 실행하려고 하면 CPU는 그 명령어를 실행하지 않고 supervisor mode의 특별한 코드로 "트랩"한다. 그 코드는 응용 프로그램을 종료할 수 있다. 1장의 그림 1.1은 이 구성을 보여준다. 응용 프로그램은 숫자를 더하는 것 같은 사용자 모드 명령어만 실행할 수 있으며, 사용자 공간에서 실행된다고 말한다. 반면 supervisor mode의 소프트웨어는 특권 명령어도 실행할 수 있으며, 커널 공간에서 실행된다고 말한다. 커널 공간, 또는 supervisor mode에서 실행되는 소프트웨어를 커널이라고 부른다.
응용 프로그램은 read 같은 시스템 콜을 통해 커널과 상호작용한다. 응용 프로그램은 커널 함수를 직접 호출하거나 커널 메모리에 접근할 수 없다. RISC-V는 시스템 콜을 위한 ecall 명령어를 제공한다. 이 명령어는 CPU를 user mode에서 supervisor mode로 전환하고, 커널이 지정한 진입점으로 점프한다. CPU가 supervisor mode로 전환된 뒤 커널은 시스템 콜의 인자를 검증할 수 있다. 예를 들어 시스템 콜에 전달된 주소가 응용 프로그램 메모리의 일부인지 확인한다. 또한 응용 프로그램이 요청한 작업을 수행할 수 있는지 결정할 수 있다. 예를 들어 응용 프로그램이 지정한 파일에 쓸 권한이 있는지 확인한 뒤, 요청을 거부하거나 실행한다. supervisor mode로 전환할 때의 진입점을 커널이 통제하는 것은 중요하다. 응용 프로그램이 커널 진입점을 결정할 수 있다면, 악의적인 응용 프로그램은 예를 들어 인자 검증을 건너뛰는 지점으로 커널에 진입할 수 있다.
2.3 커널 구성
핵심 설계 질문은 운영체제의 어느 부분이 supervisor mode에서 실행되어야 하는가이다. 한 가지 가능성은 운영체제 전체가 커널 안에 있어서 모든 시스템 콜 구현이 supervisor mode에서 실행되는 것이다. 이 구성을 모놀리식 커널이라고 부른다.
모놀리식 구성에서는 운영체제 전체가 supervisor mode에서 실행되는 하나의 프로그램으로 이루어진다. 이 구성이 편리한 이유 중 하나는 운영체제 설계자가 코드를 supervisor 권한이 필요한 부분과 필요하지 않은 부분으로 나누지 않아도 된다는 점이다. 또한 운영체제의 서로 다른 부분들이 하나의 프로그램에 속해 있으므로 쉽게 협력할 수 있다. 예를 들어 모놀리식 커널은 파일 시스템과 가상 메모리 시스템이 디스크 블록 캐시를 공유하게 할 수 있다.
단점은 모놀리식 커널이 크고 복잡해지는 경향이 있다는 것이다. 그 결과 어떤 개발자도 코드의 서로 다른 부분 사이의 모든 상호작용을 이해하지 못하게 될 수 있고, 이는 버그의 좋은 재료가 된다. 커널의 버그는 특히 골칫거리다. 전체 컴퓨터를 크래시시킬 수 있고, 많은 응용 프로그램을 오동작하게 만들 수 있으며, 전체 컴퓨터를 보안 공격에 취약하게 만들 수도 있기 때문이다.

그림 2.1: 파일 시스템 서버를 가진 마이크로커널
마이크로커널은 커널 안의 버그 발생 가능성을 줄이는 것을 목표로 한다. 아이디어는 커널 자체에는 절대적으로 최소한의 기능만 넣는 것이다. 그렇게 하면 supervisor mode에서 실행되는 코드가 적어지고, 커널을 이해하고 정확성을 분석하기 쉬워진다. 운영체제의 대부분은 사용자 수준 서버 프로세스로 실행된다. 예를 들어 파일 시스템 코드는 supervisor mode가 아니라 user mode의 서버 프로세스로 실행된다.
그림 2.1은 이 마이크로커널 설계를 보여준다. 그림에서 파일 시스템은 사용자 수준 서버 프로세스로 실행된다. 응용 프로그램이 파일 서버와 상호작용할 수 있도록 커널은 한 user-mode 프로세스에서 다른 user-mode 프로세스로 메시지를 보내는 프로세스 간 통신 메커니즘을 제공한다. 예를 들어 셸 같은 응용 프로그램이 파일을 읽거나 쓰고 싶다면 파일 서버에 메시지를 보내고 응답을 기다린다.
마이크로커널에서 커널 인터페이스는 응용 프로그램 시작, 메시지 전송, 장치 하드웨어 접근 등을 위한 몇 가지 저수준 함수로 구성된다. 대부분의 운영체제가 사용자 수준 서버에 있으므로, 이 구성에서는 커널이 상대적으로 단순해질 수 있다.
현실 세계에서는 모놀리식 커널과 마이크로커널 모두 널리 쓰인다. 많은 Unix 커널은 모놀리식이다. 예를 들어 Linux는 모놀리식 커널을 가지지만, 일부 운영체제 기능은 사용자 수준 서버로 실행된다. 창 시스템이 그 예다. Linux는 운영체제를 많이 사용하는 응용 프로그램에 높은 성능을 제공하는데, 이는 부분적으로 커널의 하위 시스템들이 긴밀하게 통합될 수 있기 때문이다.
Minix, L4, QNX 같은 운영체제는 서버를 가진 마이크로커널로 구성되어 있으며, 임베디드 환경에 널리 배포되었다. L4의 변형인 seL4는 메모리 안전성과 다른 보안 속성에 대해 검증될 만큼 작다 [8].
운영체제 개발자들 사이에서는 어떤 구성이 더 나은지에 대해 많은 논쟁이 있지만, 어느 한쪽을 결정적으로 뒷받침하는 증거는 없다. 게다가 이는 "더 낫다"가 무엇을 의미하는지에 크게 달려 있다. 더 빠른 성능, 더 작은 코드 크기, 커널의 신뢰성, 사용자 수준 서비스를 포함한 전체 운영체제의 신뢰성 등이 서로 다른 기준이 될 수 있다.
어떤 구성이 더 나은가라는 질문보다 더 중요할 수 있는 현실적인 고려사항도 있다. 어떤 운영체제는 마이크로커널을 가지지만 성능상의 이유로 일부 사용자 수준 서비스를 커널 공간에서 실행한다. 어떤 운영체제는 처음부터 그렇게 시작했기 때문에 모놀리식 커널을 가지고 있으며, 기존 운영체제를 순수한 마이크로커널 구성에 맞게 다시 작성할 유인이 거의 없다. 새 기능이 그런 재작성보다 더 중요할 수 있기 때문이다.
이 책의 관점에서 마이크로커널 운영체제와 모놀리식 운영체제는 많은 핵심 아이디어를 공유한다. 둘 다 시스템 콜을 구현하고, 페이지 테이블을 사용하며, 인터럽트를 처리하고, 프로세스를 지원하며, 동시성 제어를 위해 락을 사용하고, 파일 시스템을 구현한다. 이 책은 이러한 핵심 아이디어에 초점을 맞춘다.
xv6는 대부분의 Unix 운영체제처럼 모놀리식 커널로 구현되어 있다. 따라서 xv6 커널 인터페이스는 운영체제 인터페이스에 대응하며, 커널이 완전한 운영체제를 구현한다. xv6는 많은 서비스를 제공하지 않기 때문에 그 커널은 일부 마이크로커널보다 작지만, 개념적으로 xv6는 모놀리식이다.
2.4 코드: xv6 구성
xv6 커널 소스는 kernel/ 하위 디렉터리에 있다. 그림 2.2는 파일들을 커널의 주요 책임 영역으로 나누어 나열한다. 여기에는 시스템 시작, 즉 부팅, 프로세스 생성과 제어, 트랩, 즉 인터럽트와 시스템 콜 처리, 메모리 할당과 가상 주소 구성, 장치 제어, 파일 시스템 관리가 포함된다.
그림 2.2: xv6 커널 소스 파일
| 영역 | 파일 | 설명 |
|---|---|---|
| Boot | entry.S | 가장 첫 부트 명령어. |
| Boot | main.c | 다른 모듈의 초기화를 제어한다. |
| Boot | start.c | 초기 machine-mode 부트 코드. |
| Processes | exec.c | exec() 시스템 콜. |
| Processes | proc.c | 프로세스와 스케줄링. |
| Processes | swtch.S | 스레드 전환. |
| Processes | sysproc.c | 프로세스 관련 시스템 콜. |
| Traps | kernelvec.S | 커널 코드에서 발생한 트랩 처리. |
| Traps | trampoline.S | 사용자 코드에서 발생한 트랩 처리. |
| Traps | trap.c | 트랩과 인터럽트를 처리하고 복귀하는 C 코드. |
| Traps | syscall.c | 시스템 콜을 처리 함수로 디스패치한다. |
| Memory | vm.c | 페이지 테이블과 주소 공간을 관리한다. |
| Memory | kalloc.c | 물리 페이지 할당자. |
| Devices | console.c | 사용자 키보드와 화면에 연결한다. |
| Devices | plic.c | RISC-V 인터럽트 컨트롤러. |
| Devices | printf.c | 콘솔로의 형식화된 출력. |
| Devices | uart.c | 직렬 포트 콘솔 장치 드라이버. |
| Devices | virtio_disk.c | 디스크 장치 드라이버. |
| FS | bio.c | 파일 시스템을 위한 디스크 블록 캐시. |
| FS | file.c | 파일 디스크립터 지원. |
| FS | fs.c | 파일 시스템. |
| FS | log.c | 파일 시스템 로깅과 크래시 복구. |
| FS | sysfile.c | 파일 관련 시스템 콜. |
| FS | pipe.c | 파이프. |
| Misc | sleeplock.c | CPU를 양보하는 락. |
| Misc | spinlock.c | CPU를 양보하지 않는 락. |
| Misc | string.c | C 문자열과 바이트 배열 라이브러리. |
2.5 프로세스 개요
xv6에서, 다른 Unix 운영체제에서처럼, 격리의 단위는 프로세스다. 프로세스 추상화는 한 프로세스가 다른 프로세스의 메모리, CPU, 파일 디스크립터 등을 망가뜨리거나 엿보지 못하게 한다. 또한 프로세스가 커널 자체를 망가뜨리지 못하게 하여, 프로세스가 커널의 격리 메커니즘을 무력화할 수 없게 한다. 커널은 프로세스 추상화를 조심스럽게 구현해야 한다. 버그가 있거나 악의적인 응용 프로그램이 커널이나 하드웨어를 속여 나쁜 일을, 예를 들어 격리를 우회하는 일을 하게 만들 수 있기 때문이다. 커널이 프로세스를 구현하는 데 사용하는 메커니즘에는 user/supervisor mode 플래그, 주소 공간, 스레드 시분할이 포함된다.
격리를 강제하는 데 도움을 주기 위해 프로세스 추상화는 프로그램에 자기만의 사적인 기계를 가진 듯한 환상을 제공한다. 프로세스는 프로그램에 다른 프로세스가 읽거나 쓸 수 없는 사적인 메모리 시스템, 즉 주소 공간처럼 보이는 것을 제공한다. 또한 프로세스는 프로그램에 자기 명령어를 실행할 자기만의 CPU처럼 보이는 것도 제공한다.
xv6는 각 프로세스에 자기 주소 공간을 제공하기 위해 하드웨어로 구현되는 페이지 테이블을 사용한다. RISC-V 페이지 테이블은 가상 주소, 즉 RISC-V 명령어가 조작하는 주소를 물리 주소, 즉 CPU가 주 메모리에 보내는 주소로 변환하거나 "매핑"한다.
xv6는 각 프로세스마다 별도의 페이지 테이블을 유지하며, 이 페이지 테이블이 그 프로세스의 주소 공간을 정의한다. 그림 2.3이 보여주듯이 주소 공간에는 가상 주소 0에서 시작하는 프로세스의 사용자 메모리가 포함된다. 명령어가 먼저 오고, 그다음 전역 변수, 그다음 스택, 마지막으로 프로세스가 필요에 따라 확장할 수 있는 malloc용 "힙" 영역이 온다. 프로세스 주소 공간의 최대 크기를 제한하는 요인은 여러 가지다. RISC-V의 포인터는 64비트 폭이고, 하드웨어는 페이지 테이블에서 가상 주소를 찾을 때 낮은 39비트만 사용하며, xv6는 그 39비트 중 38비트만 사용한다. 따라서 최대 주소는 2^38 - 1 = 0x3fffffffff이고, 이것이 MAXVA(0899)다.
주소 공간의 맨 위에는 xv6가 trampoline 페이지(4096바이트)와 trapframe 페이지를 둔다. xv6는 이 두 페이지를 커널로 들어가고 다시 나오는 전환에 사용한다. trampoline 페이지에는 커널로 들어가고 나오는 코드가 있으며, trapframe은 커널이 프로세스의 사용자 레지스터를 저장하는 곳이다. 4장에서 이를 설명한다.

그림 2.3: 프로세스 가상 주소 공간의 배치
xv6 커널은 각 프로세스에 대해 많은 상태 조각을 유지하며, 이를 struct proc(2034)에 모은다. 프로세스의 가장 중요한 커널 상태 조각은 페이지 테이블, 커널 스택, 실행 상태다. 이 책은 proc 구조체의 원소를 가리키기 위해 p->xxx 표기법을 사용한다. 예를 들어 p->pagetable은 프로세스의 페이지 테이블을 가리키는 포인터다.
이 시점에서 struct proc을 정의하는 kernel/proc.h를 읽어 보라. xv6 코드는 이 책보다 여러분이 이해해야 할 대상으로서 더 중요하다. 코드를 우선하고, 코드를 명확히 하는 데 필요할 때 이 책을 참고해야 한다. 일부 코드의 목적은 처음에는 분명하지 않을 수 있지만, 더 읽고 코드를 검색하다 보면 도움이 될 것이다. 자유롭게 코드를 탐색하고 수정해 보라.
각 프로세스는 프로세스를 실행하는 데 필요한 상태를 담는 제어 스레드, 줄여서 스레드를 가진다. 어떤 순간이든 스레드는 CPU에서 실행 중이거나, 중단되어 있을 수 있다. 중단되어 있다는 것은 실행 중은 아니지만 미래에 실행을 재개할 수 있다는 뜻이다. CPU를 프로세스들 사이에서 전환하기 위해 커널은 그 CPU에서 현재 실행 중인 스레드를 중단하고 그 상태를 저장한 뒤, 이전에 중단되어 있던 다른 프로세스의 스레드 상태를 복원한다. 스레드 상태의 상당 부분, 즉 지역 변수와 함수 호출 반환 주소는 스레드의 스택에 저장된다. 각 프로세스에는 두 개의 스택이 있다. 사용자 스택과 커널 스택(p->kstack)이다. 프로세스가 사용자 명령어를 실행할 때는 사용자 스택만 사용되고 커널 스택은 비어 있다. 프로세스가 시스템 콜이나 인터럽트 때문에 커널에 진입하면, 커널 코드는 그 프로세스의 커널 스택에서 실행된다. 프로세스가 커널 안에 있는 동안 사용자 스택에는 여전히 저장된 데이터가 들어 있지만 활발히 사용되지는 않는다. 프로세스의 스레드는 사용자 스택과 커널 스택을 번갈아 적극적으로 사용한다. 커널 스택은 별도로 존재하고 사용자 코드로부터 보호되므로, 프로세스가 자기 사용자 스택을 망가뜨렸더라도 커널은 실행될 수 있다.
프로세스의 사용자 코드는 RISC-V ecall 명령어를 실행하여 시스템 콜을 만들 수 있다. 이 명령어는 supervisor mode로 전환하고 프로그램 카운터를 커널이 정의한 진입점으로 바꾼다. 진입점의 코드는 프로세스의 커널 스택으로 전환한 뒤 시스템 콜을 구현하는 커널 명령어를 실행한다. 시스템 콜이 완료되면 커널은 sret 명령어를 실행하여 사용자 공간으로 돌아간다. sret은 user mode로 전환하고 시스템 콜 명령어 바로 다음의 사용자 명령어 실행을 재개한다. 프로세스의 스레드는 I/O를 기다리기 위해 커널 안에서 "block"될 수 있으며, I/O가 끝나면 멈췄던 곳에서 재개될 수 있다.
p->state는 프로세스가 할당되었는지, 실행 준비가 되었는지, 현재 CPU에서 실행 중인지, I/O를 기다리는 중인지, 종료 중인지를 나타낸다.
p->pagetable은 RISC-V 하드웨어가 기대하는 형식의 프로세스 페이지 테이블을 담는다. xv6는 프로세스가 사용자 공간에서 실행될 때 페이징 하드웨어가 그 프로세스의 p->pagetable을 사용하게 한다. 프로세스의 페이지 테이블은 프로세스의 메모리를 저장하기 위해 할당된 물리 페이지의 주소 기록으로도 쓰인다.
요약하면 프로세스는 두 가지 설계 아이디어를 묶는다. 하나는 프로세스에 자기만의 메모리가 있다는 환상을 주는 주소 공간이고, 다른 하나는 프로세스에 자기만의 CPU가 있다는 환상을 주는 스레드다. xv6에서 프로세스는 하나의 주소 공간과 하나의 스레드로 구성된다. 실제 운영체제에서 프로세스는 여러 CPU를 활용하기 위해 둘 이상의 스레드를 가질 수 있다.
2.6 코드: xv6 시작, 첫 프로세스와 시스템 콜
xv6를 더 구체적으로 만들기 위해 커널이 어떻게 시작하고 첫 프로세스를 실행하는지 개괄하겠다. 이후 장들은 이 개괄에 등장하는 메커니즘을 더 자세히 설명한다. kernel/entry.S, kernel/start.c, kernel/main.c, user/init.c를 읽어 보라.
RISC-V 컴퓨터에 전원이 들어오면 컴퓨터는 스스로를 초기화하고 읽기 전용 메모리에 저장된 부트 로더를 실행한다. 부트 로더는 xv6 커널을 물리 주소 0x80000000의 메모리로 복사한다. 커널을 0x0이 아니라 0x80000000에 배치하는 이유는 주소 범위 0x0:0x80000000에 I/O 장치들이 있기 때문이다.
그다음 부트 로더는 _entry(1006)에서 시작하는 xv6로 점프한다. RISC-V는 페이징 하드웨어가 비활성화된 상태로 시작한다. 가상 주소는 물리 주소에 직접 매핑된다. _entry의 명령어들은 xv6가 C 코드를 실행할 수 있도록 스택을 설정한다. xv6는 start.c 파일에서 이 스택을 위한 공간 stack0를 선언한다(1060). RISC-V에서 스택은 아래쪽으로 자라기 때문에, _entry의 코드는 스택 포인터 레지스터 sp에 스택의 맨 위인 stack0+4096 주소를 적재한다. 이제 커널에 스택이 있으므로 _entry는 start(1064)의 C 코드로 호출한다.
start 함수는 CPU가 machine mode에서만 허용하는 몇 가지 설정을 수행한다. 가장 중요한 것은 클록 칩을 프로그래밍하여 타이머 인터럽트를 생성하게 하는 것이다. 그런 다음 start는 RISC-V mret 명령어를 사용하여 supervisor mode로 전환하고 main(1160)으로 점프한다. mret에는 약간의 설정이 필요하다. start는 mstatus 레지스터에서 이전 권한 모드를 supervisor로 설정하고, main의 주소를 mepc 레지스터에 써서 목적지 주소로 설정하며, 페이지 테이블 레지스터 satp에 0을 써서 supervisor mode에서의 가상 주소 변환을 비활성화하고, 모든 인터럽트와 예외를 supervisor mode로 위임한다.
main(1160)이 여러 장치와 하위 시스템을 초기화한 뒤, userinit(2327)을 호출하여 첫 프로세스를 만든다. 새로 생성된 모든 프로세스는 커널 안의 forkret(2653)에서 실행을 시작한다. 첫 프로세스라는 특별한 경우에 forkret은 사용자 프로그램 /init을 적재하기 위해 kexec를 호출한다.
kexec를 호출한 뒤 forkret은 /init 프로세스의 사용자 공간으로 돌아간다. init(7764)은 필요하면 새 콘솔 장치 파일을 만들고, 이를 파일 디스크립터 0, 1, 2로 연다. 그런 다음 콘솔에서 셸을 시작한다. 이제 시스템이 올라왔다.
2.7 보안 모델
운영체제가 버그가 있거나 악의적인 코드를 어떻게 다루는지 궁금할 수 있다. 악의적인 행위에 대응하는 것이 단순한 실수로 인한 버그를 처리하는 것보다 훨씬 더 어렵기 때문에, 운영체제 설계에서는 주로 악의적인 행위에 대한 보안을 제공하는 데 초점을 맞추는 것이 합리적이다. 다음은 운영체제 설계에서 일반적으로 사용하는 보안 가정과 목표에 대한 개략적인 설명이다.
운영체제는 프로세스의 사용자 수준(user-level) 코드가 커널이나 다른 프로세스를 망가뜨리기 위해 최선을 다할 것이라고 가정해야 한다. 사용자 코드는 자신에게 허용되지 않은 주소 공간의 포인터를 역참조(dereference)하려고 할 수 있다. 사용자 코드용이 아닌 명령어를 실행하려고 시도할 수도 있다. RISC-V 제어 레지스터(control register)를 읽거나 쓰려고 할 수도 있으며, 장치 하드웨어에 직접 접근하려고 할 수도 있다. 또한 시스템 호출(system call)에 교묘한 값을 전달하여 커널을 충돌시키거나 잘못된 동작을 하도록 속이려 할 수도 있다.
커널의 목표는 각 사용자 프로세스를 제한하여 다음만 가능하게 하는 것이다.
- 자신의 사용자 메모리에만 접근할 수 있다.
- 32개의 범용 RISC-V 레지스터만 사용할 수 있다.
- 시스템 호출이 의도적으로 허용한 방식으로만 커널과 다른 프로세스에 영향을 줄 수 있다.
커널은 이 외의 모든 행위를 막아야 한다. 이러한 요구사항은 일반적으로 커널 설계에서 절대적으로 지켜져야 하는 조건으로 간주된다.
반면 커널 자체의 코드에 대한 기대는 다르다. 커널 코드는 선의(good intentions)를 가진 신중한 프로그래머가 작성했으며, 버그가 없고 악의적인 코드도 포함되어 있지 않다고 가정한다. 이러한 가정은 우리가 커널 코드를 분석하는 방식에 영향을 준다. 예를 들어 커널 내부 함수들(예: 스핀락(spin lock)) 중에는 커널 코드가 잘못 사용하면 심각한 문제를 일으킬 수 있는 것들이 많다. 하지만 우리는 커널이 자기 자신의 함수들을 올바르게 사용한다고 가정한다.
하드웨어 수준에서는 RISC-V CPU, RAM, 디스크 등의 장치가 문서에 명시된 대로 동작하며 하드웨어 버그는 없다고 가정한다.
하지만 현실은 그렇게 단순하지 않다.
악의적인 사용자 프로그램이 디스크 공간, CPU 시간, 프로세스 테이블 슬롯 등의 커널이 보호하는 자원을 과도하게 소비하여 시스템을 사용할 수 없게 만드는 것을 완전히 막기는 어렵다. 또한 100% 버그 없는 커널 코드나 버그 없는 하드웨어를 만드는 것은 일반적으로 불가능하다. 악의적인 사용자가 커널이나 하드웨어의 버그를 알고 있다면 이를 악용할 수 있다.
실제로 Linux와 같이 성숙하고 널리 사용되는 커널에서도 이전에는 알려지지 않았던 취약점이 지속적으로 발견된다 [1].
마지막으로 사용자 코드와 커널 코드의 구분이 항상 명확한 것도 아니다. 일부 특권(privileged)을 가진 사용자 수준 프로세스는 운영체제의 핵심 서비스를 제공하며 사실상 운영체제의 일부처럼 동작한다. 또한 일부 운영체제에서는 특권 사용자 코드가 커널 내부에 새로운 코드를 삽입할 수 있다. Linux의 로더블 커널 모듈(loadable kernel module)이나 eBPF가 그 예이다.
커널 버그에 대한 부분적인 방어 수단으로 xv6 코드는 불일치(inconsistency)나 복구 불가능한 오류를 검사하는 코드를 포함하고 있으며, 이런 상황이 발생하면 panic()을 호출하여 시스템을 패닉 상태로 만든다.
panic() 함수는 오류 메시지를 출력한 후 시스템을 정지시킨다.
패닉은 바람직한 상황은 아니지만, 잘못된 상태로 계속 실행하는 것보다는 낫다. 일반적으로 패닉은 커널 버그 때문에 발생한다. 예를 들어 커널 데이터가 손상되었거나, 존재하지 않는 메모리를 참조하는 것과 같은 불법적인 동작을 수행하려고 할 때 발생한다.
이러한 상황에서는 일관성이 깨진 상태로 실행을 계속하는 것보다 panic()을 통해 시스템을 정지시키는 것이 더 안전하다.
커널 개발자는 패닉이 발생하면 근본 원인이 되는 코드 버그를 찾아 수정해야 한다.
2.8 실제 운영체제(Real world)
대부분의 운영체제는 프로세스(process) 개념을 채택하고 있으며, 대부분의 프로세스는 xv6의 프로세스와 유사한 형태를 가진다.
하지만 현대 운영체제는 하나의 프로세스 안에 여러 개의 스레드(thread)를 지원한다. 이를 통해 하나의 프로세스가 여러 CPU를 동시에 활용할 수 있게 된다.
프로세스 내에서 여러 스레드를 지원하려면 xv6에는 없는 상당한 양의 메커니즘이 필요하다. 또한 어떤 프로세스의 자원을 스레드들이 공유할지 제어하기 위해 인터페이스 변경도 필요하다.
예를 들어 Linux의 clone 시스템 호출은 fork의 변형으로, 프로세스의 어떤 요소를 스레드끼리 공유할지 세밀하게 제어할 수 있다.
2.9 연습 문제(Exercises)
- 사용 가능한(free) 메모리의 양을 반환하는 시스템 호출을 xv6에 추가하라.