1장:

이 글을 쓰면서 몇 번이고 거듭 놀랐던 한 가지는 컴퓨터가 얼마나 단순한지였습니다. 지금도 실제보다 더 많은 복잡성이나 추상화가 있을 거라고 예상하며 지레 겁먹지 않기가 어렵습니다! 계속 읽기 전에 머릿속에 새겨 두어야 할 것이 하나 있다면, 단순해 보이는 모든 것은 실제로도 그만큼 단순하다는 사실입니다. 이 단순함은 무척 아름답고, 때로는 정말, 정말 저주스럽습니다.

컴퓨터가 가장 근본적인 수준에서 어떻게 동작하는지, 그 기초부터 시작해 봅시다.

컴퓨터는 어떻게 설계되어 있는가

컴퓨터의 중앙 처리 장치(CPU)는 모든 연산을 담당합니다. CPU는 그야말로 우두머리입니다. 수리수리 마수리 그 자체입니다. CPU는 컴퓨터를 켜자마자 쉬지 않고 돌아가기 시작하여, 명령어를 하나, 또 하나, 또 하나 실행합니다.

최초로 대량 생산된 CPU는 Intel 4004로, 60년대 후반 이탈리아의 물리학자이자 엔지니어인 페데리코 파긴(Federico Faggin)이 설계했습니다. 이 CPU는 오늘날 우리가 쓰는 64비트 시스템이 아닌 4비트 아키텍처였고 현대 프로세서보다 훨씬 덜 복잡했지만, 그 단순함의 상당 부분은 지금도 여전히 남아 있습니다.

CPU가 실행하는 “명령어”는 그저 바이너리 데이터일 뿐입니다. 어떤 명령어를 실행하는지 나타내는 한두 바이트(옵코드)가 오고, 그 뒤에 명령어 실행에 필요한 데이터가 이어집니다. 우리가 기계어라고 부르는 것은 이러한 바이너리 명령어를 줄줄이 늘어놓은 것에 지나지 않습니다. 어셈블리는 기계어를 날것의 비트보다 사람이 더 쉽게 읽고 쓸 수 있게 해 주는 유용한 문법이며, 항상 CPU가 읽을 수 있는 바이너리로 컴파일됩니다.

기계어가 어셈블리로 변환되고 다시 기계어로 되돌아가는 과정을 보여 주는 도표입니다. 양방향 화살표가 세 가지 예시를 연결합니다. 기계어(2진수) 다음에는 3바이트의 2진수가, 기계어(16진수) 다음에는 그 3바이트를 16진수로 변환한 값(0x83, 0xC3, 0x0A)이, 어셈블리 다음에는 "add ebx, 10"이 표시되어 있습니다. 어셈블리와 기계어는 색상으로 구분되어 있어, 기계어의 각 바이트가 어셈블리의 한 단어로 변환된다는 점을 분명히 알 수 있습니다.

여담으로, 명령어가 위 예시처럼 항상 기계어와 1:1로 대응되어 표현되는 것은 아닙니다. 예를 들어 add eax, 512는 05 00 02 00 00으로 변환됩니다.

첫 번째 바이트(05)는 특별히 EAX 레지스터에 32비트 숫자를 더하는 것을 나타내는 옵코드입니다. 나머지 바이트는 리틀 엔디언 바이트 순서로 표현한 512(0x200)입니다.

Defuse Security는 어셈블리와 기계어 사이의 변환을 이것저것 시험해 볼 수 있는 유용한 도구를 만들었습니다.

RAM은 컴퓨터의 주 메모리 저장소로, 컴퓨터에서 실행되는 프로그램이 사용하는 모든 데이터를 저장하는 커다란 다목적 공간입니다. 여기에는 프로그램 코드 자체뿐 아니라 운영체제 핵심부의 코드도 포함됩니다. CPU는 항상 RAM에서 직접 기계어를 읽으며, RAM에 올라가 있지 않은 코드는 실행할 수 없습니다.

CPU는 다음 명령어를 가져올 RAM 위치를 가리키는 명령어 포인터를 저장합니다. CPU는 각 명령어를 실행한 뒤 포인터를 옮기고 이를 반복합니다. 이것이 바로 페치-실행 사이클입니다.

페치-실행 사이클을 보여 주는 다이어그램입니다. 텍스트 말풍선이 두 개 있습니다. 첫 번째 말풍선에는 "인출(Fetch)"이라는 이름이 붙어 있으며 "현재 명령어 포인터가 가리키는 메모리에서 명령어를 읽습니다."라는 텍스트가 적혀 있습니다. 두 번째 말풍선의 제목은 "실행(Execute)"이며 "명령어를 실행한 다음 명령어 포인터를 옮깁니다."라는 텍스트가 적혀 있습니다. 인출 말풍선에서 실행 말풍선으로 화살표가 이어지고 실행 말풍선에서 다시 인출 말풍선으로 화살표가 돌아가므로, 이 과정이 반복된다는 것을 알 수 있습니다.

명령어를 실행하고 나면 포인터는 RAM에서 그 명령어 바로 다음 위치로 이동하여 이제 다음 명령어를 가리키게 됩니다. 코드가 실행되는 이유가 바로 이것입니다! 명령어 포인터는 그저 계속해서 앞으로 나아가며, 기계어를 메모리에 저장된 순서대로 실행합니다. 어떤 명령어는 명령어 포인터에게 대신 다른 곳으로 점프하라고 하거나, 특정 조건에 따라 서로 다른 곳으로 점프하라고 지시할 수 있습니다. 이 덕분에 재사용 가능한 코드와 조건부 로직이 가능해집니다.

이 명령어 포인터는 레지스터에 저장됩니다. 레지스터는 CPU가 매우 빠르게 읽고 쓸 수 있는 작은 저장 공간입니다. CPU 아키텍처마다 정해진 레지스터 집합이 있으며, 이 레지스터들은 연산 중에 임시 값을 저장하는 일부터 프로세서를 설정하는 일까지 온갖 용도로 쓰입니다.

앞의 다이어그램에 나온 ebx처럼 일부 레지스터는 기계어 코드에서 직접 접근할 수 있습니다.

다른 레지스터는 CPU 내부에서만 사용되지만, 특수한 명령어를 통해 값을 갱신하거나 읽을 수 있는 경우가 많습니다. 명령어 포인터가 그 예입니다. 명령어 포인터는 직접 읽을 수는 없지만, 점프 명령어 등으로 갱신할 수 있습니다.

프로세서는 단순합니다

처음 질문으로 돌아가 봅시다. 컴퓨터에서 실행 파일을 실행하면 무슨 일이 일어납니까? 먼저 실행을 준비하느라 온갖 마법 같은 일이 벌어지지만(이 과정은 모두 나중에 차근차근 살펴보겠습니다), 그 과정이 끝나면 어딘가의 파일에 기계어 코드가 남습니다. 운영체제는 이 코드를 RAM에 올리고, CPU에 명령어 포인터를 RAM의 해당 위치로 점프하라고 지시합니다. CPU는 평소처럼 페치-실행 사이클을 계속 수행하므로, 프로그램이 실행되기 시작합니다!

(저는 이 대목에서 머리가 멍해졌습니다. 진지하게 말해서, 지금 이 글을 읽는 데 쓰고 있는 프로그램도 바로 이렇게 실행되고 있습니다! 여러분의 CPU는 브라우저의 명령어를 RAM에서 차례대로 가져와 직접 실행하고 있으며, 그 명령어들이 이 글을 화면에 그려 내고 있습니다.)

RAM에 있는 일련의 기계어 코드 바이트를 나타낸 도식입니다. "Instruction Pointer"(명령어 포인터)라고 적힌 화살표가 강조 표시된 바이트를 가리키고 있으며, 명령어 포인터가 RAM에서 앞으로 이동하는 방식을 나타내는 화살표들이 있습니다.

알고 보면 CPU의 세계관은 아주 단순합니다. CPU는 현재 명령어 포인터와 약간의 내부 상태만 볼 뿐입니다. 프로세스는 전적으로 운영체제가 만든 추상화이며, CPU가 본래 이해하거나 추적하는 대상이 아닙니다.

*손을 휘저으며* 프로세스는 컴퓨터를 더 팔려고 OS 개발자들이 빅 바이트가 지어낸 추상화입니다

저에게는 이 사실이 답보다 더 많은 질문을 불러일으킵니다.

  1. CPU가 멀티프로세싱을 모르고 그저 명령어를 순서대로 실행할 뿐이라면, 왜 CPU는 실행 중인 프로그램 안에 갇혀 버리지 않습니까? 어떻게 여러 프로그램이 동시에 실행될 수 있습니까?
  2. 프로그램이 CPU에서 직접 실행되고 CPU가 RAM에 직접 접근할 수 있다면, 왜 코드는 다른 프로세스의 메모리나, 맙소사, 커널의 메모리에 접근하지 못합니까?
  3. 말이 나온 김에, 모든 프로세스가 아무 명령어나 실행해서 컴퓨터에 무슨 짓이든 하지 못하게 막는 메커니즘은 무엇입니까? 그리고 도대체 시스템 호출이란 게 뭡니까?

메모리에 관한 질문은 따로 한 절을 할애할 만한 주제라서 5장에서 다룹니다. 요약하자면, 대부분의 메모리 접근은 실제로 전체 주소 공간을 다시 매핑하는 간접 계층을 거칩니다. 당분간은 프로그램이 모든 RAM에 직접 접근할 수 있고 컴퓨터는 한 번에 하나의 프로세스만 실행할 수 있다고 가정하겠습니다. 이 두 가정은 때가 되면 모두 풀어 설명하겠습니다.

이제 첫 번째 토끼굴로 뛰어들어 시스템 호출과 보안 링으로 가득한 세계로 들어갈 차례입니다.

여담: 그런데 커널이란 무엇입니까?

macOS, Windows, Linux 같은 컴퓨터의 운영체제는 컴퓨터에서 실행되며 모든 기본적인 것들이 작동하게 해 주는 소프트웨어의 모음입니다. “기본적인 것들”은 정말 포괄적인 말이고, “운영체제”도 마찬가지입니다. 누구에게 묻느냐에 따라 컴퓨터에 기본으로 딸려 오는 앱, 글꼴, 아이콘 같은 것까지 여기에 포함될 수 있습니다.

반면 커널은 운영체제의 핵심입니다. 컴퓨터를 부팅하면 명령어 포인터는 어딘가에 있는 한 프로그램에서 시작합니다. 그 프로그램이 바로 커널입니다. 커널은 컴퓨터의 메모리, 주변 장치 및 기타 자원에 거의 완전하게 접근할 수 있으며, 컴퓨터에 설치된 소프트웨어(유저랜드 프로그램이라고 합니다)를 실행하는 일을 담당합니다. 커널이 어떻게 이러한 접근 권한을 갖는지, 그리고 유저랜드 프로그램은 어째서 갖지 못하는지를 이 글 전반에 걸쳐 알아보겠습니다.

Linux는 커널일 뿐이며, 실제로 사용하려면 셸이나 디스플레이 서버 같은 유저랜드 소프트웨어가 많이 필요합니다. macOS의 커널은 XNU라고 불리며 유닉스 계열이고, 최신 Windows 커널은 NT 커널이라고 불립니다.

모든 것을 지배하는 두 개의 링

프로세서가 어떤 모드(권한 수준 또는 링이라고도 합니다)에 있는지에 따라 프로세서가 할 수 있는 일이 정해집니다. 현대 아키텍처에는 최소한 두 가지 선택지가 있습니다. 바로 커널/슈퍼바이저 모드와 사용자 모드입니다. 아키텍처가 두 개보다 많은 모드를 지원할 수도 있지만, 요즘 흔히 쓰이는 것은 커널 모드와 사용자 모드뿐입니다.

커널 모드에서는 무엇이든 가능합니다. CPU는 지원되는 모든 명령어를 실행하고 모든 메모리에 접근할 수 있습니다. 사용자 모드에서는 명령어의 일부만 허용되고, I/O 및 메모리 접근이 제한되며, 많은 CPU 설정이 잠깁니다. 일반적으로 커널과 드라이버는 커널 모드에서 실행되고, 애플리케이션은 사용자 모드에서 실행됩니다.

프로세서는 커널 모드에서 시작합니다. 커널은 프로그램을 실행하기 전에 사용자 모드로의 전환을 시작합니다.

사용자 모드와 커널 모드의 보호 방식 차이를 보여 주는 가짜 iMessage 스크린숏 두 장입니다. 첫 번째 스크린숏에는 커널 모드라는 이름이 붙어 있으며, 오른쪽에서 "이 보호된 메모리를 읽어!"라고 하자 왼쪽에서 "여기 있어, 자기야 :)"라고 답합니다. 두 번째 스크린숏에는 사용자 모드라는 이름이 붙어 있으며, 오른쪽에서 "이 보호된 메모리를 읽어!"라고 하자 왼쪽에서 "안 돼! 세그멘테이션 폴트야!"라고 답합니다.

실제 아키텍처에서 프로세서 모드가 어떻게 드러나는지 예를 들어 보겠습니다. x86-64에서는 현재 권한 수준(CPL)을 cs(코드 세그먼트)라는 레지스터에서 읽을 수 있습니다. 구체적으로 말하면, CPL은 cs 레지스터의 최하위 비트 두 개에 담겨 있습니다. 이 두 비트로 x86-64에서 가능한 네 가지 링을 모두 나타낼 수 있습니다. 링 0은 커널 모드이고 링 3은 사용자 모드입니다. 링 1과 링 2는 드라이버를 실행하기 위해 설계되었지만, 오래된 소수의 특수한 운영체제에서만 사용됩니다. 예를 들어 CPL 비트가 11이라면 CPU는 링 3, 즉 사용자 모드에서 실행 중입니다.

시스템 호출이란 대체 무엇입니까?

프로그램은 컴퓨터에 대한 완전한 접근 권한을 맡길 만큼 신뢰할 수 없기 때문에 사용자 모드에서 실행됩니다. 사용자 모드는 컴퓨터 대부분에 대한 접근을 막아 제 역할을 다합니다. 하지만 프로그램은 어떻게든 I/O에 접근하고, 메모리를 할당하고, 운영체제와 상호작용할 수 있어야 합니다! 이를 위해 사용자 모드에서 실행되는 소프트웨어는 운영체제 커널에 도움을 요청해야 합니다. 그러면 운영체제는 자체 보안 보호 장치를 구현하여 프로그램이 악의적인 일을 하지 못하도록 막을 수 있습니다.

운영체제와 상호작용하는 코드를 작성해 본 적이 있다면 open, read, fork, exit 같은 함수가 아마 낯익을 것입니다. 추상화 계층을 몇 단계 내려가 보면, 이 함수들은 모두 시스템 호출을 사용해 운영체제에 도움을 요청합니다. 시스템 호출은 프로그램이 사용자 공간에서 커널 공간으로의 전환을 시작하여 프로그램의 코드에서 운영체제 코드로 넘어갈 수 있게 해 주는 특수한 절차입니다.

사용자 공간에서 커널 공간으로의 제어 이전은 소프트웨어 인터럽트라는 프로세서 기능을 사용해 이루어집니다.

  1. 부팅 과정에서 운영체제는 인터럽트 벡터 테이블(IVT, x86-64에서는 인터럽트 디스크립터 테이블이라고 합니다)이라는 테이블을 RAM에 저장하고 CPU에 등록합니다. IVT는 인터럽트 번호를 핸들러 코드 포인터에 대응시킵니다.
"Interrupt Vector Table"(인터럽트 벡터 테이블)이라는 캡션이 붙은 표 이미지입니다. 번호 기호가 붙은 첫 번째 열에는 01부터 04까지 이어지는 일련의 숫자가 있습니다. 이에 대응하는 두 번째 열에는 "Handler Address"(핸들러 주소)라는 이름이 붙어 있으며, 항목마다 무작위의 8바이트 길이 16진수가 하나씩 들어 있습니다. 표 하단에는 "So on and such forth..."(기타 등등...)라는 텍스트가 있습니다.
  1. 그러면 유저랜드 프로그램은 INT 같은 명령어를 사용할 수 있습니다. 이 명령어는 프로세서에게 IVT에서 주어진 인터럽트 번호를 찾고, 커널 모드로 전환한 다음, 명령어 포인터를 IVT에 저장된 메모리 주소로 점프시키라고 지시합니다.

이 커널 코드가 끝나면 IRET 같은 명령어를 사용하여 CPU에게 사용자 모드로 다시 전환하고 명령어 포인터를 인터럽트가 발생했던 시점의 위치로 되돌리라고 지시합니다.

(궁금하셨다면 말씀드리자면, Linux에서 시스템 호출에 사용되는 인터럽트 ID는 0x80입니다. Linux 시스템 호출 목록은 Michael Kerrisk의 온라인 맨페이지 디렉터리에서 보실 수 있습니다.)

래퍼 API: 인터럽트 추상화하기

지금까지 시스템 호출에 대해 알게 된 내용은 다음과 같습니다.

프로그램은 시스템 호출을 발생시킬 때 운영체제에 데이터를 전달해야 합니다. 운영체제는 어떤 시스템 호출을 실행할지와 함께, 예를 들어 어떤 파일 이름을 열지처럼 그 시스템 호출 자체에 필요한 데이터도 알아야 합니다. 이 데이터를 전달하는 방식은 운영체제와 아키텍처에 따라 다르지만, 보통은 인터럽트를 발생시키기 전에 특정 레지스터나 스택에 데이터를 넣는 방식으로 이루어집니다.

기기마다 시스템 호출을 부르는 방식이 다르므로, 프로그래머가 프로그램마다 시스템 호출을 직접 구현하는 것은 지극히 비현실적입니다. 또한 그렇게 되면 운영체제는 예전 방식을 사용하도록 작성된 모든 프로그램이 망가질까 봐 인터럽트 처리 방식을 바꿀 수 없게 됩니다. 마지막으로, 요즘은 보통 프로그램을 순수 어셈블리로 작성하지 않습니다 — 파일을 읽거나 메모리를 할당하고 싶을 때마다 프로그래머에게 어셈블리 수준까지 내려가라고 기대할 수는 없습니다.

"시스템 호출은 아키텍처마다 다르게 구현됩니다."라는 설명이 붙은 그림입니다. 왼쪽에는 웃고 있는 CPU가 바이너리를 받아 file.txt라는 파일 이름을 내뱉고 있습니다. 오른쪽에 따로 떨어져 있는 다른 CPU는 같은 바이너리 데이터를 받지만 당황하고 속이 메스꺼운 표정을 짓고 있습니다.

그래서 운영체제는 이러한 인터럽트 위에 추상화 계층을 제공합니다. 필요한 어셈블리 명령어를 감싸는 재사용 가능한 고수준 라이브러리 함수는 유닉스 계열 시스템에서는 libc가, Windows에서는 ntdll.dll이라는 라이브러리의 일부가 제공합니다. 이러한 라이브러리 함수를 호출하는 것 자체는 커널 모드로의 전환을 일으키지 않으며, 그저 일반적인 함수 호출일 뿐입니다. 라이브러리 내부에서는 어셈블리 코드가 실제로 커널에 제어권을 넘기며, 이 코드는 이를 감싸는 라이브러리 서브루틴보다 훨씬 더 플랫폼에 의존적입니다.

유닉스 계열 시스템에서 실행되는 C에서 exit(1)을 호출하면, 그 함수는 내부적으로 시스템 호출의 옵코드와 인자를 알맞은 레지스터/스택/기타 등등에 넣은 뒤 인터럽트를 발생시키는 기계어 코드를 실행합니다. 컴퓨터는 정말 멋집니다!

속도가 필요합니다 / CISC스럽게 가 봅시다

x86-64 같은 많은 CISC 아키텍처에는 시스템 호출 패러다임이 널리 쓰이면서 만들어진, 시스템 호출 전용으로 설계된 명령어가 있습니다.

Intel과 AMD는 x86-64에서 그다지 잘 협력하지 못했습니다. 그래서 x86-64에는 최적화된 시스템 호출 명령어 세트가 실제로 두 가지나 있습니다. SYSCALL과 SYSENTER는 INT 0x80 같은 명령어의 최적화된 대안입니다. 이에 대응하는 반환 명령어인 SYSRET과 SYSEXIT는 사용자 공간으로 빠르게 돌아가 프로그램 코드를 재개하도록 설계되었습니다.

(AMD와 Intel 프로세서는 이 명령어들에 대한 호환성이 조금씩 다릅니다. 64비트 프로그램에는 일반적으로 SYSCALL이 가장 좋은 선택이고, 32비트 프로그램에서는 SYSENTER가 더 잘 지원됩니다.)

그 설계 방식을 대표하듯, RISC 아키텍처에는 이런 특수 명령어가 없는 경향이 있습니다. Apple Silicon의 기반이 되는 RISC 아키텍처인 AArch64는 시스템 호출과 소프트웨어 인터럽트 모두에 인터럽트 명령어 하나만 사용합니다. Mac 사용자들도 잘 지내고 있다고 생각합니다 :)


휴, 내용이 정말 많았습니다! 간단히 정리해 봅시다.

앞서 던졌던 첫 번째 질문에 어떻게 답할 수 있을지 알아봅시다.

CPU가 둘 이상의 프로세스를 추적하지 않고 그저 명령어를 하나씩 차례로 실행할 뿐이라면, 왜 실행 중인 프로그램 안에 갇혀 버리지 않을까요? 어떻게 여러 프로그램이 동시에 실행될 수 있을까요?

친애하는 친구여, 이 질문의 답은 콜드플레이가 그토록 인기 있는 이유에 대한 답이기도 합니다… 바로 클록(clocks)입니다! (뭐, 엄밀히 말하면 타이머입니다. 그냥 이 농담을 억지로라도 끼워 넣고 싶었을 뿐입니다.)

2장으로 계속: 시간을 쪼개 봅시다