Putting the “You” in CPU

판본
일반 · 한 장 요약 · PDF (영어 원문)

0장: 들어가며

저는 컴퓨터로 많은 일을 해 왔지만, 제 지식에는 늘 빈틈이 하나 있었습니다. 컴퓨터에서 프로그램을 실행하면 정확히 무슨 일이 일어나는 것일까요? 저는 이 빈틈에 대해 생각해 보았습니다. 필요한 저수준 지식은 대부분 갖추고 있었지만, 모든 것을 하나로 엮어 내기가 어려웠습니다. 프로그램은 정말로 CPU에서 직접 실행되는 것일까요, 아니면 다른 무언가가 일어나고 있는 것일까요? 시스템 호출을 사용해 본 적은 있지만, 시스템 호출은 어떻게 동작하는 것일까요? 시스템 호출이란 정말 무엇일까요? 여러 프로그램은 어떻게 동시에 실행되는 것일까요?

대충 휘갈겨 그린 디지털 그림입니다. 머리가 긴 누군가가 바이너리를 집어삼키는 컴퓨터를 내려다보며 어리둥절해하고 있습니다. 그러다 갑자기 좋은 생각이 떠오릅니다! 그리고 구부정한 자세로 데스크톱 컴퓨터 앞에 앉아 조사를 시작합니다.

결국 참지 못하고 알아낼 수 있는 것은 최대한 알아내기 시작했습니다. 대학에 다니지 않는다면 시스템을 폭넓게 다루는 자료가 많지 않기 때문에, 저는 품질도 제각각이고 때로는 서로 모순되는 정보를 담은 수많은 자료를 샅샅이 뒤져야 했습니다. 몇 주간의 조사와 40쪽에 가까운 노트 끝에, 이제는 컴퓨터가 부팅부터 프로그램 실행까지 어떻게 동작하는지 훨씬 더 잘 이해하게 되었다고 생각합니다. 제가 배운 내용을 설명해 주는 제대로 된 글 하나가 그토록 간절했기에, 제가 갖고 싶었던 바로 그 글을 지금 쓰고 있습니다.

그리고 이런 말이 있지 않습니까… 무언가를 다른 사람에게 설명할 수 있어야만 그것을 진정으로 이해한 것이라고 말입니다.

바쁘십니까? 이런 내용은 이미 알고 있다고 생각하십니까?

3장을 읽어 보십시오. 분명 새로운 것을 배우시리라 장담합니다. 물론 여러분이 리누스 토르발스 본인쯤 되는 분이 아니라면 말입니다.

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장: 시간을 쪼개 봅시다

여러분이 운영체제를 만들고 있고, 사용자가 여러 프로그램을 동시에 실행할 수 있게 하고 싶다고 가정해 봅시다. 하지만 여러분에게는 멋진 멀티코어 프로세서가 없으므로, CPU는 한 번에 명령어 하나만 실행할 수 있습니다!

다행히 여러분은 아주 똑똑한 운영체제 개발자입니다. 프로세스들이 CPU를 번갈아 사용하게 하면 병렬 처리를 흉내 낼 수 있다는 사실을 알아냅니다. 프로세스들을 차례로 돌아가며 각각 명령어를 몇 개씩 실행하면, 어느 한 프로세스가 CPU를 독차지하지 않고도 모든 프로세스가 반응성을 유지할 수 있습니다.

그런데 프로세스를 전환하려면 프로그램 코드로부터 제어권을 어떻게 되찾아 와야 할까요? 조금 조사해 보니, 대부분의 컴퓨터에는 타이머 칩이 달려 있다는 사실을 알게 됩니다. 일정 시간이 지나면 OS 인터럽트 핸들러로 전환이 일어나도록 타이머 칩을 프로그램할 수 있습니다.

하드웨어 인터럽트

앞에서 소프트웨어 인터럽트가 유저랜드 프로그램에서 운영체제로 제어권을 넘기는 데 어떻게 쓰이는지 이야기했습니다. 이것을 “소프트웨어” 인터럽트라고 부르는 이유는 프로그램이 자발적으로 일으키기 때문입니다. 즉, 일반적인 페치-실행 사이클에서 프로세서가 실행하는 기계어 코드가 프로세서에게 제어권을 커널로 넘기라고 지시합니다.

하드웨어 인터럽트가 정상적인 실행을 어떻게 중단시키는지 보여 주는 그림입니다. 위쪽에는 키 하나가 강조 표시된 키보드 그림이 있고, 번개 모양이 오른쪽의 CPU를 향해 그려져 있습니다. 아래쪽에는 "프로그램 코드"라고 표시된 이진수, 비슷한 번개 모양, 그리고 "커널 코드"라고 표시된 또 다른 이진수가 있습니다. 번개 모양에는 "인터럽트가 컨텍스트 전환을 일으킴"이라고 표시되어 있습니다.

운영체제 스케줄러는 PIT 같은 타이머 칩을 사용해 멀티태스킹을 위한 하드웨어 인터럽트를 일으킵니다:

  1. 프로그램 코드로 점프하기 전에, 운영체제는 일정 시간이 지나면 인터럽트를 일으키도록 타이머 칩을 설정합니다.
  2. 운영체제는 사용자 모드로 전환하고 프로그램의 다음 명령어로 점프합니다.
  3. 타이머가 만료되면 하드웨어 인터럽트가 발생해 커널 모드로 전환되고 운영체제 코드로 점프합니다.
  4. 이제 운영체제는 프로그램이 멈춘 지점을 저장하고, 다른 프로그램을 불러온 뒤, 이 과정을 반복할 수 있습니다.

이것을 선점형 멀티태스킹이라고 하며, 프로세스를 중단시키는 것을 선점이라고 합니다. 예를 들어 여러분이 같은 컴퓨터에서 브라우저로 이 글을 읽으면서 음악을 듣고 있다면, 여러분의 컴퓨터는 아마 1초에 수천 번씩 바로 이 사이클을 반복하고 있을 것입니다.

타임슬라이스 계산

타임슬라이스란 운영체제 스케줄러가 프로세스를 선점하기 전까지 그 프로세스가 실행되도록 허용하는 시간입니다. 타임슬라이스를 정하는 가장 간단한 방법은 모든 프로세스에 10 ms 정도의 같은 타임슬라이스를 주고 작업을 순서대로 돌아가며 실행하는 것입니다. 이를 고정 타임슬라이스 라운드 로빈 스케줄링이라고 합니다.

여담: 재미있는 전문 용어 이야기!

타임슬라이스를 흔히 “퀀텀(quantum)“이라고도 부른다는 사실을 알고 계셨습니까? 이제 아셨으니 기술 좋아하는 친구들에게 자랑하실 수 있습니다. 이 글에서 한 문장 걸러 한 번씩 퀀텀이라는 말을 쓰지 않은 것만으로도 저는 칭찬을 듬뿍 받아야 마땅하다고 생각합니다.

타임슬라이스 용어 이야기가 나온 김에 덧붙이면, Linux 커널 개발자들은 고정 주파수 타이머 틱을 세는 데 지피(jiffy)라는 시간 단위를 사용합니다. jiffy는 여러 용도 가운데 특히 타임슬라이스의 길이를 재는 데 쓰입니다. Linux의 지피 주파수는 보통 1000 Hz이지만, 커널을 컴파일할 때 설정할 수 있습니다.

고정 타임슬라이스 스케줄링을 조금 개선하는 방법은 프로세스가 응답하기까지 걸리는 이상적인 최대 시간인 목표 지연 시간(target latency)을 정하는 것입니다. 목표 지연 시간이란 프로세스 수가 적당하다고 가정할 때, 프로세스가 선점된 뒤 실행을 다시 시작하기까지 걸리는 시간입니다. 이것은 머릿속에 그려 보기가 꽤 어렵습니다! 걱정하지 마십시오. 곧 다이어그램이 나옵니다.

타임슬라이스는 목표 지연 시간을 전체 태스크 수로 나누어 계산합니다. 이 방식은 프로세스 수가 적을 때 불필요한 태스크 전환을 없애 주므로 고정 타임슬라이스 스케줄링보다 낫습니다. 목표 지연 시간이 15ms이고 프로세스가 10개라면 각 프로세스는 15/10, 즉 1.5ms씩 실행됩니다. 프로세스가 3개뿐이라면 각 프로세스는 목표 지연 시간을 지키면서도 5ms라는 더 긴 타임슬라이스를 받습니다.

프로세스를 전환하려면 현재 프로그램의 전체 상태를 저장하고 다른 프로그램의 상태를 복원해야 하므로 계산 비용이 큽니다. 타임슬라이스가 일정 수준보다 작아지면 프로세스가 너무 자주 전환되어 성능 문제가 생길 수 있습니다. 그래서 타임슬라이스 길이에 하한(최소 세분도)을 두는 것이 일반적입니다. 따라서 최소 세분도가 적용될 만큼 프로세스가 많아지면 목표 지연 시간을 넘기게 됩니다.

이 글을 쓰는 시점에서 Linux의 스케줄러는 6 ms의 목표 지연 시간과 0.75 ms의 최소 단위(minimum granularity)를 사용합니다.

"단순한 동적 타임슬라이스 라운드 로빈 스케줄링(Naive Dynamic Timeslice Round-Robin Scheduling)"이라는 제목의 다이어그램입니다. 서로 다른 프로세스 3개가 반복되는 주기 안에서 실행 시간을 할당받는 모습을 시간 순서대로 보여 줍니다. 각 프로세스의 실행 블록 사이에는 "커널 스케줄러"라고 표시된 훨씬 짧은 블록이 있습니다. 각 프로그램 실행 블록의 길이에는 "타임슬라이스(2ms)"라고 표시되어 있습니다. 프로세스 1이 실행을 시작한 시점부터 프로세스 1이 다시 실행을 시작하는 시점까지의 구간은 프로세스 2와 3의 실행 시간을 포함하며, "목표 지연 시간(6ms)"이라고 표시되어 있습니다.

이런 기본적인 타임슬라이스 계산을 쓰는 라운드 로빈 스케줄링은 오늘날 대부분의 컴퓨터가 쓰는 방식과 비슷합니다. 그래도 여전히 다소 단순한 방식입니다. 대부분의 운영체제는 프로세스 우선순위와 마감 시한까지 고려하는 더 복잡한 스케줄러를 사용합니다. 2007년부터 Linux는 Completely Fair Scheduler라는 스케줄러를 사용해 왔습니다. CFS는 태스크의 우선순위를 정하고 CPU 시간을 나누기 위해 매우 정교한 컴퓨터 과학 기법을 여러 가지 사용합니다.

운영체제는 프로세스를 선점할 때마다 새 프로그램의 메모리 환경을 포함해 저장해 둔 실행 컨텍스트를 불러와야 합니다. 이 작업은 CPU에 다른 페이지 테이블, 즉 “가상” 주소를 물리 주소로 매핑하는 표를 사용하라고 지시해서 이루어집니다. 이 페이지 테이블은 프로그램이 서로의 메모리에 접근하지 못하게 막는 시스템이기도 합니다. 이 주제는 이 글의 5장과 6장에서 깊이 파고들겠습니다.

참고 #1: 커널 선점 가능성

지금까지는 유저랜드 프로세스의 선점과 스케줄링에 대해서만 이야기했습니다. 커널 코드가 시스템 호출을 처리하거나 드라이버 코드를 실행하는 데 너무 오래 걸리면 프로그램이 버벅거리는 것처럼 느껴질 수 있습니다.

Linux를 포함한 현대의 커널은 선점형 커널입니다. 이는 커널 코드 자체도 유저랜드 프로세스와 마찬가지로 인터럽트되고 스케줄링될 수 있도록 프로그래밍되어 있다는 뜻입니다.

커널 같은 것을 직접 작성하는 것이 아니라면 그리 중요한 내용은 아니지만, 제가 읽은 글마다 거의 빠짐없이 이 내용을 언급해서 저도 다루기로 했습니다! 더 알아 두어서 나쁠 일은 거의 없습니다.

참고 #2: 역사 수업

클래식 Mac OS와 NT보다 훨씬 이전의 Windows 버전 같은 옛 운영체제는 선점형 멀티태스킹의 전신에 해당하는 방식을 사용했습니다. 운영체제가 프로그램을 언제 선점할지 결정하는 대신 프로그램이 스스로 운영체제에 제어권을 양보하는 방식이었습니다. 프로그램은 소프트웨어 인터럽트를 발생시켜 “이제 다른 프로그램을 실행해도 됩니다”라고 알렸습니다. 이렇게 명시적으로 양보해야만 운영체제가 제어권을 되찾아 다음으로 스케줄링된 프로세스로 전환할 수 있었습니다.

이를 협력형 멀티태스킹이라고 합니다. 이 방식에는 몇 가지 큰 결함이 있습니다. 악의적이거나 그저 잘못 설계된 프로그램 하나가 운영체제 전체를 쉽게 멈추게 할 수 있고, 실시간 작업이나 시간에 민감한 작업의 시간적 일관성을 보장하기가 거의 불가능합니다. 이런 이유로 기술 업계는 오래전에 선점형 멀티태스킹으로 전환했고 다시는 돌아가지 않았습니다.

3장: 프로그램을 실행하는 방법

지금까지 CPU가 실행 파일에서 불러온 기계어 코드를 어떻게 실행하는지, 링 기반 보안이 무엇인지, 그리고 시스템 호출이 어떻게 동작하는지 살펴보았습니다. 이번 절에서는 Linux 커널을 깊이 파고들어 애초에 프로그램이 어떻게 로드되고 실행되는지 알아보겠습니다.

구체적으로는 x86-64 위의 Linux를 살펴볼 것입니다. 왜일까요?

우리가 배울 내용의 대부분은 다른 운영체제와 아키텍처가 여러 구체적인 면에서 다르더라도 그대로 잘 일반화될 것입니다.

Exec 시스템 호출의 기본 동작

exec 시스템 호출을 보여 주는 순서도입니다. 왼쪽에는 "사용자 공간(user space)"이라는 이름이 붙은 순서도 항목 묶음이 있고, 오른쪽에는 "커널 공간(kernel space)"이라는 이름이 붙은 묶음이 있습니다. 사용자 공간 묶음에서 시작하면, 사용자가 터미널에서 ./file.bin을 실행하고, 이어서 execve("./file.bin", ...) 시스템 호출이 실행됩니다. 이어서 SYSCALL(시스템 호출) 명령어가 실행되고, 이는 커널 공간 그룹의 첫 번째 항목인 "바이너리 로드 및 설정"으로 이어지며, 이 항목은 다시 "binfmt 시도"로 이어집니다. 해당 binfmt가 지원되면 (현재 프로세스를 대체하여) 새 프로세스를 시작합니다. 지원되지 않으면 binfmt를 다시 시도합니다.

매우 중요한 시스템 호출인 execve부터 시작합시다. 이 시스템 호출은 프로그램을 로드하고, 성공하면 현재 프로세스를 그 프로그램으로 대체합니다. 다른 시스템 호출(execlp, execvpe 등)도 몇 가지 있지만, 모두 다양한 방식으로 execve 위에 계층을 이루고 있습니다.

여담: execveat

execve는 사실 몇 가지 설정 옵션과 함께 프로그램을 실행하는 더 일반적인 시스템 호출인 execveat 위에 구축되어 있습니다. 간단히 하기 위해 주로 execve를 다루겠습니다. 유일한 차이는 execve가 execveat에 몇 가지 기본값을 제공한다는 점입니다.

ve가 무엇을 뜻하는지 궁금하십니까? v는 매개변수 하나가 인자의 벡터(리스트)(argv)라는 뜻이고, e는 또 다른 매개변수가 환경 변수의 벡터(envp)라는 뜻입니다. 다른 여러 exec 시스템 호출은 서로 다른 호출 시그니처를 나타내기 위해 각기 다른 접미사를 가지고 있습니다. execveat의 at은 그냥 “at”이라는 뜻인데, execve를 실행할 위치를 지정하기 때문입니다.

execve의 호출 시그니처는 다음과 같습니다:

int execve(const char *filename, char *const argv[], char *const envp[]);

재미있는 사실이 있습니다! 프로그램의 첫 번째 인자가 프로그램 이름이라는 관례를 알고 계십니까? 그것은 순전히 관례일 뿐이며, 실제로 execve 시스템 호출 자체가 설정하는 것이 아닙니다! 첫 번째 인자는 프로그램 이름과 전혀 관계가 없더라도 argv 인자의 첫 번째 항목으로 execve에 전달된 값이 무엇이든 그 값이 됩니다.

흥미롭게도 execve에는 argv[0]이 프로그램 이름이라고 가정하는 코드가 실제로 일부 있습니다. 이에 대해서는 나중에 인터프리터 방식의 스크립트 언어를 다룰 때 더 자세히 이야기하겠습니다.

0단계: 정의

시스템 호출이 어떻게 동작하는지는 이미 알고 있지만, 실제 코드 예시는 아직 한 번도 본 적이 없습니다! 내부적으로 execve가 어떻게 정의되어 있는지 Linux 커널의 소스 코드를 살펴봅시다.

fs/exec.c
SYSCALL_DEFINE3(execve,
		const char __user *, filename,
		const char __user *const __user *, argv,
		const char __user *const __user *, envp)
{
	return do_execve(getname(filename), argv, envp);
}

SYSCALL_DEFINE3는 인자가 3개인 시스템 호출의 코드를 정의하기 위한 매크로입니다.

저는 왜 매크로 이름에 인자 개수(arity)가 하드코딩되어 있는지 궁금해서 이리저리 검색해 보았고, 이것이 어떤 보안 취약점을 고치기 위한 우회책이었다는 사실을 알게 되었습니다.

파일 이름 인자는 getname() 함수로 전달되며, 이 함수는 문자열을 사용자 공간에서 커널 공간으로 복사하고 사용량 추적과 관련된 작업도 몇 가지 수행합니다. 이 함수는 include/linux/fs.h에 정의된 filename 구조체를 반환합니다. 이 구조체는 사용자 공간에 있는 원래 문자열을 가리키는 포인터와 함께, 커널 공간으로 복사된 값을 가리키는 새 포인터도 저장합니다:

include/linux/fs.h
struct filename {
	const char		*name;	/* pointer to actual string */
	const __user char	*uptr;	/* original userland pointer */
	int			refcnt;
	struct audit_names	*aname;
	const char		iname[];
};

그다음 execve 시스템 호출은 do_execve() 함수를 호출합니다. 이 함수는 다시 몇 가지 기본값과 함께 do_execveat_common()를 호출합니다. 앞서 언급한 execveat 시스템 호출 역시 do_execveat_common()를 호출하지만, 사용자가 제공한 옵션을 더 많이 전달합니다.

아래 코드 조각에 do_execve와 do_execveat의 정의를 모두 포함했습니다:

fs/exec.c
static int do_execve(struct filename *filename,
	const char __user *const __user *__argv,
	const char __user *const __user *__envp)
{
	struct user_arg_ptr argv = { .ptr.native = __argv };
	struct user_arg_ptr envp = { .ptr.native = __envp };
	return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}

static int do_execveat(int fd, struct filename *filename,
		const char __user *const __user *__argv,
		const char __user *const __user *__envp,
		int flags)
{
	struct user_arg_ptr argv = { .ptr.native = __argv };
	struct user_arg_ptr envp = { .ptr.native = __envp };

	return do_execveat_common(fd, filename, argv, envp, flags);
}

[띄어쓰기는 원문 그대로임]

execveat에서는 파일 디스크립터(어떤 리소스를 가리키는 일종의 ID)가 시스템 호출에 전달된 다음 do_execveat_common으로 전달됩니다. 이 값은 프로그램을 실행할 때 기준이 되는 디렉터리를 지정합니다.

execve에서는 파일 디스크립터 인자로 특별한 값인 AT_FDCWD가 사용됩니다. 이것은 Linux 커널에서 공유되는 상수로, 경로명을 현재 작업 디렉터리 기준의 상대 경로로 해석하라고 함수에 알려 줍니다. 파일 디스크립터를 받는 함수에는 보통 다음과 같은 수동 검사가 들어 있습니다if (fd == AT_FDCWD) { /* special codepath */ }.

1단계: 준비

이제 프로그램 실행을 처리하는 핵심 함수인 do_execveat_common에 도달했습니다. 이 함수가 무엇을 하는지 더 큰 그림으로 보기 위해, 코드를 들여다보던 것에서 잠시 한 발 물러서 보겠습니다.

do_execveat_common의 첫 번째 주요 작업은 linux_binprm이라는 구조체를 준비하는 것입니다. 구조체 정의 전체를 옮겨 싣지는 않겠지만, 짚고 넘어가야 할 중요한 필드가 몇 가지 있습니다:

(오늘 알게 된 사실: binprm은 binary program의 약자입니다.)

이 버퍼 buf를 좀 더 자세히 살펴봅시다:

linux_binprm @ include/linux/binfmts.h
	char buf[BINPRM_BUF_SIZE];

보시다시피 버퍼의 길이는 상수 BINPRM_BUF_SIZE로 정의되어 있습니다. 코드베이스에서 이 문자열을 검색하면 include/uapi/linux/binfmts.h에서 그 정의를 찾을 수 있습니다:

include/uapi/linux/binfmts.h
/* sizeof(linux_binprm->buf) */
#define BINPRM_BUF_SIZE 256

즉, 커널은 실행되는 파일의 앞부분 256바이트를 이 메모리 버퍼에 불러옵니다.

여담: UAPI란 무엇입니까?

위 코드의 경로에 /uapi/가 들어 있다는 점을 눈치채셨을 것입니다. 왜 이 길이는 linux_binprm 구조체와 같은 파일인 include/linux/binfmts.h에 정의되어 있지 않을까요?

UAPI는 “userspace API(사용자 공간 API)“의 약자입니다. 여기서는 누군가가 버퍼의 길이를 커널의 공개 API에 포함해야 한다고 결정했다는 뜻입니다. 이론상 UAPI에 속한 모든 것은 유저랜드에 노출되고, UAPI가 아닌 모든 것은 커널 코드 내부에서만 쓰입니다.

원래 커널 코드와 사용자 공간 코드는 한데 뒤섞인 채로 공존했습니다. 2012년에 유지보수성을 높이려는 시도의 일환으로 UAPI 코드가 별도의 디렉터리로 리팩터링되었습니다.

2단계: Binfmt

커널이 다음으로 하는 주요 작업은 여러 “binfmt”(바이너리 형식) 핸들러를 차례로 순회하는 것입니다. 이 핸들러들은 fs/binfmt_elf.c나 fs/binfmt_flat.c 같은 파일에 정의되어 있습니다. 커널 모듈도 자체 binfmt 핸들러를 이 풀에 추가할 수 있습니다.

각 핸들러는 load_binary() 함수를 노출하는데, 이 함수는 linux_binprm 구조체를 받아 해당 핸들러가 프로그램의 형식을 이해할 수 있는지 확인합니다.

이 과정에서는 흔히 버퍼에서 매직 넘버를 찾거나, (역시 버퍼에 있는) 프로그램의 시작 부분을 디코딩해 보거나, 파일 확장자를 확인하거나, 이들을 함께 수행합니다. 핸들러가 해당 형식을 지원하면 프로그램을 실행할 수 있도록 준비한 뒤 성공 코드를 반환합니다. 그렇지 않으면 일찍 종료하고 오류 코드를 반환합니다. 커널은 성공하는 binfmt가 나올 때까지 각 binfmt의 load_binary() 함수를 차례로 시도합니다. 때로는 이 과정이 재귀적으로 실행되기도 합니다. 예를 들어 스크립트에 인터프리터가 지정되어 있고 그 인터프리터 자체도 스크립트라면, 계층 구조는 binfmt_script > binfmt_script > binfmt_elf가 될 수 있습니다(여기서 ELF는 체인의 마지막에 있는 실행 파일 형식입니다).

형식 집중 탐구: 스크립트

Linux가 지원하는 여러 형식 중에서 제가 가장 먼저 따로 이야기하고 싶은 것은 binfmt_script입니다.

셔뱅을 읽거나 써 본 적이 있으십니까? 일부 스크립트의 맨 앞에서 인터프리터의 경로를 지정하는 그 줄 말입니다.

#!/bin/bash

저는 늘 셔뱅을 셸이 처리한다고만 생각했지만, 그렇지 않습니다! 셔뱅은 사실 커널의 기능이며, 스크립트는 다른 모든 프로그램과 똑같은 시스템 호출로 실행됩니다. 컴퓨터는 정말 멋집니다.

fs/binfmt_script.c가 파일이 셔뱅으로 시작하는지 어떻게 확인하는지 살펴보십시오:

load_script @ fs/binfmt_script.c
	/* Not ours to exec if we don't start with "#!". */
	if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
		return -ENOEXEC;

파일이 실제로 셔뱅으로 시작하면, binfmt 핸들러는 이어서 인터프리터 경로와 그 경로 뒤에 공백으로 구분된 인자들을 읽습니다. 이 과정은 줄바꿈 문자나 버퍼의 끝을 만나면 멈춥니다.

여기에는 흥미롭고도 기묘한 점이 두 가지 있습니다.

먼저, 파일의 처음 256바이트로 채워진 linux_binprm의 버퍼를 기억하십니까? 이 버퍼는 실행 파일 형식을 감지하는 데 쓰이지만, binfmt_script에서 셔뱅을 읽어 들이는 곳도 바로 이 버퍼입니다.

조사하는 동안 저는 이 버퍼의 길이가 128바이트라고 설명한 글을 읽었습니다. 그런데 그 글이 게시된 후 어느 시점에 길이가 두 배인 256바이트로 늘어났습니다! 이유가 궁금해서 Linux 소스 코드에서 BINPRM_BUF_SIZE가 정의된 줄의 Git blame(특정 코드 줄을 수정한 모든 사람의 기록)을 확인해 보았습니다. 그랬더니 놀랍게도…

Visual Studio Code 편집기의 Git blame 창을 찍은 스크린샷입니다. git blame에는 "#define BINPRM_BUF_SIZE 128" 줄이 256으로 바뀐 내역이 나타나 있습니다. 커밋 작성자는 Oleg Nesterov이며, 본문은 다음과 같습니다. "exec: BINPRM_BUF_SIZE를 256으로 늘림. 대기업 고객은 네트워크 파일 시스템에서 애플리케이션을 실행하는 경우가 많은데, 이런 환경에서는 IT 부서가 정한 프로젝트 볼륨 구조 때문에 경로가 128자보다 길어질 수 있습니다. 이 값을 다음 2의 거듭제곱으로 올리면 여전히 512b slab에 들어가면서도, 가장 극단적인 경우를 제외한 모든 경우에 이 문제가 해결됩니다." 이 커밋에는 Linus Torvalds를 비롯한 여러 사람이 서명(sign-off)했습니다.

컴퓨터는 정말 멋집니다!

셔뱅은 커널이 처리하며 파일 전체를 불러오는 대신 buf에서 가져오기 때문에, 항상 buf의 길이만큼 잘립니다. 보아하니 4년 전 누군가가 커널이 128자를 넘는 경로를 잘라 버리는 데 짜증이 났고, 그 해결책으로 버퍼 크기를 두 배로 늘려 잘리는 지점을 두 배로 늘렸던 것입니다! 오늘날 여러분의 Linux 머신에서도 셔뱅 줄이 256자를 넘으면 256자 이후의 모든 내용은 완전히 사라집니다.

셔뱅(shebang) 잘림을 나타낸 다이어그램입니다. file.bin이라는 파일에서 가져온 큰 바이트 배열입니다. 처음 256바이트는 강조 표시되어 "buf에 로드됨"이라는 레이블이 붙어 있고, 나머지 바이트는 반투명하게 표시되어 "무시됨, 256바이트 초과"라는 레이블이 붙어 있습니다.

이 때문에 버그가 생겼다고 상상해 보십시오. 코드가 망가지는 근본 원인을 찾아내려 애쓰는 모습을 상상해 보십시오. 문제가 Linux 커널 깊숙한 곳에 있다는 사실을 알게 되었을 때의 기분을 상상해 보십시오. 경로의 일부가 알 수 없이 사라졌다는 사실을 발견하게 될, 어느 거대 기업의 다음 IT 담당자에게 애도를 표합니다.

두 번째 기묘한 점: argv[0]이 프로그램 이름이라는 것은 관례일 뿐이며, 호출자는 exec 시스템 호출에 원하는 어떤 argv든 넘길 수 있고 그 값은 아무런 검사 없이 그대로 전달된다는 것을 기억하십니까?

공교롭게도 binfmt_script는 argv[0]이 프로그램 이름이라고 가정하는 곳 중 하나입니다. 이 핸들러는 항상 argv[0]을 제거한 다음, argv의 맨 앞에 다음 항목들을 추가합니다:

예시: 인자 수정

예시 execve 호출을 살펴봅시다:

// Arguments: filename, argv, envp
execve("./script", [ "A", "B", "C" ], []);

이 가상의 script 파일은 첫 줄에 다음과 같은 셔뱅이 있습니다:

script
#!/usr/bin/node --experimental-module

최종적으로 Node 인터프리터에 전달되는 수정된 argv는 다음과 같습니다:

[ "/usr/bin/node", "--experimental-module", "./script", "B", "C" ]

argv를 갱신한 후, 핸들러는 linux_binprm.interp를 인터프리터 경로(이 경우에는 Node 바이너리)로 설정하여 파일의 실행 준비를 마칩니다. 마지막으로, 프로그램의 실행 준비에 성공했음을 나타내기 위해 0을 반환합니다.

형식 집중 탐구: 기타 인터프리터

또 하나의 흥미로운 핸들러는 binfmt_misc입니다. 이 핸들러는 /proc/sys/fs/binfmt_misc/에 특수 파일 시스템을 마운트하여, 유저랜드 설정을 통해 몇 가지 제한된 형식을 추가할 수 있게 해 줍니다. 프로그램은 이 디렉터리의 파일에 특별한 형식으로 쓰기를 수행하여 자신만의 핸들러를 추가할 수 있습니다. 각 설정 항목은 다음을 지정합니다.

이 binfmt_misc 시스템은 Java 설치에서 자주 사용되며, 클래스 파일은 0xCAFEBABE 매직 바이트로, JAR 파일은 확장자로 감지하도록 설정됩니다. 제 시스템에는 Python 바이트코드를 .pyc 확장자로 감지하여 적절한 핸들러에 넘겨주는 핸들러가 설정되어 있습니다.

이는 프로그램 설치 프로그램이 높은 권한으로 동작하는 커널 코드를 작성하지 않고도 자신만의 형식에 대한 지원을 추가할 수 있게 해 주는 꽤 멋진 방법입니다.

결국에는 (Linkin Park의 노래 말고)

exec 시스템 호출은 항상 다음 두 경로 중 하나로 끝납니다.

유닉스 계열 시스템을 사용해 본 적이 있다면, 터미널에서 실행한 셸 스크립트에 셔뱅 줄이나 .sh 확장자가 없어도 여전히 실행된다는 것을 눈치챘을 수도 있습니다. Windows가 아닌 터미널이 가까이 있다면 지금 바로 시험해 볼 수 있습니다.

셸 세션
$ echo "echo hello" > ./file
$ chmod +x ./file
$ ./file
hello

(chmod +x는 운영체제에 해당 파일이 실행 파일임을 알려 줍니다. 이렇게 하지 않으면 파일을 실행할 수 없습니다.)

그렇다면 이 셸 스크립트는 왜 셸 스크립트로 실행되는 것입니까? 커널의 형식 핸들러에는 식별할 만한 표시가 전혀 없는 셸 스크립트를 감지할 확실한 방법이 없을 텐데 말입니다!

알고 보니 이 동작은 커널의 일부가 아닙니다. 실은 여러분의 셸이 실패 상황을 처리하는 흔한 방식입니다.

셸로 파일을 실행했는데 exec 시스템 호출이 실패하면, 대부분의 셸은 파일 이름을 첫 번째 인자로 하여 셸을 실행함으로써 그 파일을 셸 스크립트로 다시 실행하려고 시도합니다. Bash는 보통 자기 자신을 이 인터프리터로 사용하지만, ZSH는 sh가 가리키는 것을 사용하며, 이는 보통 본 셸입니다.

이 동작이 이렇게 흔한 이유는 Unix 시스템 간에 코드를 이식할 수 있도록 만들어진 오래된 표준인 POSIX에 명시되어 있기 때문입니다. 대부분의 도구나 운영체제가 POSIX를 엄격하게 따르지는 않지만, 그 관례 중 상당수는 여전히 공유되고 있습니다.

[exec 시스템 호출이] [ENOEXEC] 오류에 해당하는 오류로 실패하면, 셸은 명령 이름을 첫 번째 피연산자로 하여 셸을 호출한 것과 동등한 명령을 실행해야 하며, 나머지 인자는 모두 새 셸에 전달됩니다. 실행 파일이 텍스트 파일이 아니면 셸은 이 명령 실행을 건너뛸 수 있습니다. 이 경우 셸은 오류 메시지를 출력하고 종료 상태 126을 반환해야 합니다.

출처: 셸 명령 언어(Shell Command Language), POSIX.1-2017

컴퓨터는 정말 멋집니다!

4장: 엘프 군주 되기

이제 우리는 execve를 꽤 철저하게 이해했습니다. 대부분의 경로 끝에서 커널은 실행할 기계어 코드가 담긴 최종 프로그램에 도달하게 됩니다. 일반적으로 실제로 코드로 점프하기 전에 준비 과정이 필요합니다. 예를 들어 프로그램의 여러 부분을 메모리의 올바른 위치에 적재해야 합니다. 프로그램마다 용도별로 필요한 메모리의 양이 다르므로, 프로그램을 실행할 수 있게 준비하는 방법을 명시하는 표준 파일 형식이 존재합니다. Linux는 이러한 형식을 많이 지원하지만, 단연 가장 흔한 형식은 ELF(executable and linkable format, 실행 파일 및 링크 가능 형식)입니다.

종이에 마커로 그린 그림입니다. 마법사 엘프가 한 손에는 누(gnu)의 머리를, 다른 손에는 Linux 펭귄을 들고 명상하는 모습이 그려져 있습니다. 엘프는 "음, 사실 Linux는 커널일 뿐이고, 운영체제는..."이라고 말하다가 말끝을 흐립니다. 그림에는 빨간 마커로 다음과 같은 설명이 적혀 있습니다: "You've heard of elf on a shelf! 이제 기대하십시오... GNU/리눅스 위의 엘프(elf on a GNU/Linux)를!" 그림에는 "Nicky"라는 서명이 있습니다.

(귀여운 그림을 그려 주신 Nicky Case님께 감사드립니다.)

여담: 엘프는 어디에나 있을까요?

Linux에서 앱이나 명령줄 프로그램을 실행한다면, 그것은 십중팔구 ELF 바이너리입니다. 하지만 macOS에서는 Mach-O가 사실상의 표준 형식입니다. Mach-O는 ELF와 똑같은 일을 모두 하지만 구조가 다릅니다. Windows에서 .exe 실행 파일은 Portable Executable 형식을 사용하는데, 이 역시 같은 개념을 가진 다른 형식입니다.

Linux 커널에서 ELF 바이너리는 binfmt_elf 핸들러가 처리하며, 이 핸들러는 다른 많은 핸들러보다 복잡하고 수천 줄의 코드로 이루어져 있습니다. 이 핸들러는 ELF 파일에서 특정 세부 정보를 파싱해 내고, 그 정보를 이용해 프로세스를 메모리에 적재하고 실행하는 일을 담당합니다.

명령줄 쿵푸를 좀 부려서 binfmt 핸들러들을 줄 수 기준으로 정렬해 보았습니다:

셸 세션
$ wc -l binfmt_* | sort -nr | sed 1d
    2181 binfmt_elf.c
    1658 binfmt_elf_fdpic.c
     944 binfmt_flat.c
     836 binfmt_misc.c
     158 binfmt_script.c
      64 binfmt_elf_test.c

파일 구조

binfmt_elf가 ELF 파일을 어떻게 실행하는지 더 깊이 살펴보기 전에, 먼저 파일 형식 자체를 살펴봅시다. ELF 파일은 일반적으로 네 부분으로 구성됩니다:

ELF 파일 구조의 개요를 보여 주는 다이어그램으로, 네 개의 섹션이 차례로 이어집니다. 섹션 1, ELF 헤더: 바이너리에 관한 기본 정보와 PHT 및 SHT의 위치입니다. 섹션 2, 프로그램 헤더 테이블(PHT): ELF 파일의 데이터를 메모리의 어디에 어떻게 로드할지 기술합니다. 섹션 3, 섹션 헤더 테이블(SHT): 디버깅을 돕는 선택적인 데이터 "지도"입니다. 섹션 4, 데이터: 바이너리의 모든 데이터입니다. PHT와 SHT는 이 섹션 내부를 가리킵니다.

ELF 헤더

모든 ELF 파일에는 ELF 헤더가 있습니다. ELF 헤더는 다음과 같은 바이너리의 기본 정보를 전달하는 매우 중요한 역할을 합니다:

ELF 헤더는 항상 파일의 맨 앞에 있습니다. ELF 헤더는 파일 내 어디에든 있을 수 있는 프로그램 헤더 테이블과 섹션 헤더의 위치를 명시합니다. 그리고 이 테이블들은 다시 파일의 다른 곳에 저장된 데이터를 가리킵니다.

프로그램 헤더 테이블

프로그램 헤더 테이블은 런타임에 바이너리를 적재하고 실행하는 방법에 대한 구체적인 세부 정보를 담은 일련의 항목입니다. 각 항목에는 어떤 세부 정보를 명시하는지 나타내는 유형 필드가 있습니다. 예를 들어 PT_LOAD는 메모리에 적재해야 할 데이터가 담겨 있음을 뜻하지만, PT_NOTE는 해당 세그먼트에 반드시 어딘가에 적재할 필요는 없는 정보성 텍스트가 담겨 있음을 뜻합니다.

흔히 쓰이는 프로그램 헤더 타입 네 가지를 보여 주는 표입니다. 타입 1, PT_LOAD: 메모리에 로드할 데이터입니다. 타입 2, PT_NOTE: 저작권 고지, 버전 정보 등과 같은 자유 형식 텍스트입니다. 타입 3, PT_DYNAMIC: 동적 링킹에 관한 정보입니다. 타입 4, PT_INTERP: "ELF 인터프리터"의 위치를 가리키는 경로입니다.

각 항목은 해당 데이터가 파일 안의 어디에 있는지, 그리고 경우에 따라서는 그 데이터를 메모리에 어떻게 적재할지에 대한 정보를 지정합니다.

섹션 헤더 테이블

섹션 헤더 테이블은 섹션에 대한 정보를 담은 일련의 항목입니다. 이 섹션 정보는 ELF 파일 안의 데이터를 그려 놓은 지도와 같습니다. 이 정보 덕분에 디버거 같은 프로그램은 데이터의 각 부분이 어떤 용도로 쓰이는지 쉽게 파악할 수 있습니다.

섬, 강, 야자수, 나침반 방위표가 그려진 오래된 보물 지도입니다. 일부 섬에는 ".text", ".data", ".shstrtab", ".bss" 같은 ELF 섹션 이름이 적혀 있습니다. 그림에는 "섹션 헤더 테이블은 바이너리 데이터를 위한 지도와 같습니다."라는 설명이 달려 있습니다.

예를 들어, 프로그램 헤더 테이블은 넓은 범위의 데이터를 한꺼번에 메모리에 적재하도록 지정할 수 있습니다. 그 하나의 PT_LOAD 블록 안에 코드와 전역 변수가 모두 들어 있을 수도 있습니다! 프로그램을 실행하는 데에는 이 둘을 따로 지정해야 할 이유가 없습니다. CPU는 그저 엔트리 포인트에서 시작해 한 단계씩 나아가며, 프로그램이 요청하는 때와 위치에서 데이터에 접근할 뿐입니다. 하지만 프로그램을 분석하는 디버거 같은 소프트웨어는 각 영역이 정확히 어디서 시작하고 어디서 끝나는지 알아야 합니다. 그렇지 않으면 “hello”라고 적힌 텍스트를 코드로 해석하려 들 수도 있습니다(그리고 그것은 올바른 코드가 아니므로 폭발해 버립니다). 이 정보는 섹션 헤더 테이블에 저장됩니다.

섹션 헤더 테이블은 보통 포함되어 있지만, 사실은 선택 사항입니다. ELF 파일은 섹션 헤더 테이블을 완전히 제거해도 아무 문제 없이 실행되며, 자기 코드가 하는 일을 숨기고 싶은 개발자는 해석하기 더 어렵게 만들려고 때때로 ELF 바이너리에서 섹션 헤더 테이블을 일부러 제거하거나 망가뜨리기도 합니다.

각 섹션에는 이름과 타입, 그리고 섹션을 어떻게 사용하고 해석할지 지정하는 몇 가지 플래그가 있습니다. 표준 이름은 관례상 보통 점으로 시작합니다. 가장 흔한 섹션은 다음과 같습니다.

데이터

프로그램 헤더 테이블과 섹션 헤더 테이블의 항목은 메모리에 로드하기 위해서든, 프로그램 코드의 위치를 지정하기 위해서든, 단순히 섹션에 이름을 붙이기 위해서든 모두 ELF 파일 안의 데이터 블록을 가리킵니다. 이 모든 다양한 데이터 조각은 ELF 파일의 데이터 섹션에 들어 있습니다.

ELF 파일의 여러 부분이 데이터 블록 안의 위치를 어떻게 참조하는지 보여 주는 도식입니다. 끝으로 갈수록 흐려지는 연속된 데이터 모음이 그려져 있으며, 그 안에는 ELF 인터프리터 경로, 섹션 제목 ".rodata", 문자열 "Hello, world!"처럼 분명히 알아볼 수 있는 것들이 들어 있습니다. 데이터 블록 위에는 몇 가지 ELF 섹션 예시가 떠 있고, 화살표가 각 섹션의 데이터를 가리킵니다. 예를 들어, PHT 엔트리 예시와 SHT 엔트리 예시의 데이터 섹션은 둘 다 같은 "Hello, world!" 텍스트를 가리킵니다. SHT 엔트리의 레이블도 데이터 블록에 저장되어 있습니다.

링킹에 대한 간단한 설명

다시 binfmt_elf 코드로 돌아가 봅시다. 커널은 프로그램 헤더 테이블에서 두 가지 타입의 항목에 관심을 둡니다.

PT_LOAD 세그먼트는 .text 섹션과 .data 섹션 같은 모든 프로그램 데이터가 메모리의 어디에 로드되어야 하는지를 지정합니다. 커널은 ELF 파일에서 이 항목들을 읽어 데이터를 메모리에 로드하며, 이를 통해 CPU가 프로그램을 실행할 수 있게 됩니다.

커널이 관심을 두는 또 다른 타입의 프로그램 헤더 테이블 항목은 PT_INTERP이며, 이는 “동적 링킹 런타임”을 지정합니다.

동적 링킹이 무엇인지 이야기하기 전에, 먼저 “링킹” 전반에 대해 이야기해 봅시다. 프로그래머는 보통 재사용 가능한 코드로 이루어진 라이브러리 위에 프로그램을 만듭니다 — 앞에서 이야기한 libc가 그 예입니다. 소스 코드를 실행 파일 바이너리로 바꿀 때, 링커라는 프로그램이 라이브러리 코드를 찾아 바이너리에 복사함으로써 이러한 참조를 모두 해결합니다. 이 과정을 정적 링킹이라고 하며, 이는 외부 코드가 배포되는 파일에 직접 포함된다는 뜻입니다.

하지만 어떤 라이브러리는 매우 흔하게 쓰입니다. libc는 시스템 호출을 통해 운영체제와 상호작용하기 위한 표준 인터페이스이기 때문에, 세상의 거의 모든 프로그램이 libc를 사용한다는 것을 알 수 있습니다. 컴퓨터에 있는 모든 프로그램마다 libc의 복사본을 따로 포함시키는 것은 공간을 끔찍하게 낭비하는 일입니다. 또한 라이브러리를 사용하는 각 프로그램이 업데이트되기를 기다릴 필요 없이 라이브러리의 버그를 한 곳에서 고칠 수 있다면 좋을 것입니다. 동적 링킹은 이러한 문제에 대한 해결책입니다.

정적으로 링크된 프로그램이 bar라는 라이브러리의 foo 함수를 필요로 한다면, 그 프로그램에는 foo 전체의 복사본이 포함됩니다. 하지만 동적으로 링크된 경우에는 “라이브러리 bar의 foo가 필요합니다”라는 참조만 포함됩니다. 프로그램이 실행될 때 bar가 컴퓨터에 설치되어 있다면, foo 함수의 기계어 코드를 필요할 때 메모리에 로드할 수 있습니다. 컴퓨터에 설치된 bar 라이브러리가 업데이트되면, 프로그램 자체를 전혀 변경하지 않아도 다음에 프로그램을 실행할 때 새 코드가 로드됩니다.

정적 링킹과 동적 링킹의 차이를 보여 주는 다이어그램입니다. 왼쪽에는 정적 링킹이 나타나 있으며, "foo"라는 코드의 내용이 두 프로그램에 각각 따로 복사되는 모습을 보여 줍니다. 여기에는 라이브러리 함수가 빌드 시점에 개발자의 컴퓨터에서 각 바이너리로 복사된다는 설명이 함께 적혀 있습니다. 오른쪽에는 동적 링킹이 나타나 있습니다. 각 프로그램에는 "foo" 함수의 이름이 들어 있고, 화살표가 프로그램 바깥으로 뻗어 나와 사용자의 컴퓨터에 있는 foo 프로그램을 가리킵니다. 여기에는 바이너리가 라이브러리 함수의 이름을 참조하며, 이 함수들은 런타임에 사용자의 컴퓨터에서 로드된다는 설명이 함께 적혀 있습니다.

실제 환경에서의 동적 링킹

Linux에서 bar와 같이 동적으로 링크할 수 있는 라이브러리는 보통 .so(Shared Object) 확장자를 가진 파일로 패키징됩니다. 이러한 .so 파일도 프로그램과 마찬가지로 ELF 파일입니다 — ELF 헤더에 해당 파일이 실행 파일인지 라이브러리인지를 지정하는 필드가 있다는 것을 기억하실 것입니다. 또한 공유 오브젝트는 섹션 헤더 테이블에 .dynsym 섹션을 가지고 있으며, 여기에는 파일에서 어떤 심볼을 내보내고 있어 동적으로 링크할 수 있는지에 대한 정보가 담겨 있습니다.

Windows에서는 bar 같은 라이브러리가 .dll(dynamic link library) 파일로 패키징됩니다. macOS는 .dylib(dynamically linked library) 확장자를 사용합니다. macOS 앱이나 Windows .exe 파일과 마찬가지로, 이 파일들은 ELF 파일과 형식이 조금 다르지만 개념과 기법은 같습니다.

두 가지 링킹 방식의 흥미로운 차이점은, 정적 링킹에서는 라이브러리 중 실제로 사용되는 부분만 실행 파일에 포함되고 따라서 그 부분만 메모리에 로드된다는 것입니다. 동적 링킹에서는 라이브러리 전체가 메모리에 로드됩니다. 처음에는 덜 효율적으로 들릴 수 있지만, 실제로는 라이브러리를 메모리에 한 번만 로드한 뒤 그 코드를 여러 프로세스가 공유하게 함으로써 현대 운영체제가 더 많은 공간을 절약할 수 있게 해 줍니다. 라이브러리는 프로그램마다 서로 다른 상태가 필요하므로 코드만 공유할 수 있지만, 그래도 절약되는 양은 RAM 수십에서 수백 메가바이트에 이를 수 있습니다.

실행

ELF 파일을 실행하는 커널 이야기로 다시 돌아가 봅시다. 실행하려는 바이너리가 동적으로 링크되어 있다면, 운영체제는 바이너리의 코드로 곧바로 점프할 수 없습니다. 코드 일부가 빠져 있기 때문입니다. 동적으로 링크된 프로그램에는 필요한 라이브러리 함수에 대한 참조만 들어 있다는 점을 기억하십시오!

바이너리를 실행하려면 운영체제는 어떤 라이브러리가 필요한지 파악하고, 그것들을 로드하고, 이름으로 된 포인터를 모두 실제 점프 명령어로 바꾼 다음에야 실제 프로그램 코드를 시작해야 합니다. 이는 ELF 형식과 깊이 얽혀 동작하는 매우 복잡한 코드이므로, 보통 커널의 일부가 아니라 독립된 프로그램으로 존재합니다. ELF 파일은 사용하려는 프로그램의 경로(보통 /lib64/ld-linux-x86-64.so.2와 같은 형태)를 프로그램 헤더 테이블의 PT_INTERP 항목에 지정합니다.

ELF 헤더를 읽고 프로그램 헤더 테이블을 훑어본 뒤, 커널은 새 프로그램을 위한 메모리 구조를 설정할 수 있습니다. 커널은 먼저 모든 PT_LOAD 세그먼트를 메모리에 로드하여 프로그램의 정적 데이터, BSS 공간, 기계어 코드를 채웁니다. 프로그램이 동적으로 링크되어 있다면 커널은 ELF 인터프리터(PT_INTERP)를 실행해야 하므로, 인터프리터의 데이터, BSS, 코드도 메모리에 로드합니다.

이제 커널은 유저랜드로 돌아갈 때 CPU가 복원할 명령어 포인터를 설정해야 합니다. 실행 파일이 동적으로 링크되어 있다면, 커널은 명령어 포인터를 메모리에 있는 ELF 인터프리터 코드의 시작 지점으로 설정합니다. 그렇지 않다면 커널은 명령어 포인터를 실행 파일의 시작 지점으로 설정합니다.

커널은 이제 시스템 호출에서 돌아갈 준비가 거의 되었습니다(아직 execve 안에 있다는 점을 기억하십시오). 커널은 프로그램이 시작할 때 읽을 수 있도록 argc, argv, 환경 변수를 스택에 푸시합니다.

이제 레지스터를 비웁니다. 커널은 시스템 호출을 처리하기 전에, 사용자 공간으로 다시 전환할 때 복원할 수 있도록 레지스터의 현재 값을 스택에 저장합니다. 커널은 사용자 공간으로 돌아가기 전에 스택의 이 부분을 0으로 채웁니다.

마침내 시스템 호출이 끝나고 커널은 유저랜드로 돌아갑니다. 커널은 이제 0으로 채워진 레지스터를 복원하고, 저장된 명령어 포인터로 점프합니다. 이제 그 명령어 포인터가 새 프로그램(또는 ELF 인터프리터)의 시작 지점이 되며, 현재 프로세스는 교체되었습니다!

5장: 컴퓨터 속의 번역가

지금까지 메모리를 읽고 쓰는 것에 대해 이야기할 때마다 설명이 다소 모호했습니다. 예를 들어 ELF 파일은 데이터를 적재할 특정 메모리 주소를 지정하는데, 그렇다면 서로 다른 프로세스가 충돌하는 메모리를 사용하려 할 때 문제가 생기지 않는 이유는 무엇입니까? 왜 각 프로세스는 서로 다른 메모리 환경을 가진 것처럼 보이는 것입니까?

또한, 우리는 정확히 어떻게 여기까지 오게 된 것입니까? execve가 현재 프로세스를 새 프로그램으로 교체하는 시스템 호출이라는 것은 이해했지만, 이것만으로는 여러 프로세스가 어떻게 시작될 수 있는지 설명되지 않습니다. 맨 처음 프로그램이 어떻게 실행되는지는 더더욱 설명되지 않습니다. 과연 어떤 닭(프로세스)이 다른 모든 달걀(다른 프로세스)을 낳는(생성하는) 것입니까?

우리의 여정도 끝을 향해 가고 있습니다. 이 두 가지 질문에 대한 답을 얻고 나면, 컴퓨터가 부팅에서부터 지금 여러분이 사용하고 있는 소프트웨어를 실행하기까지 어떤 과정을 거쳤는지 거의 완전히 이해하게 될 것입니다.

메모리는 가짜입니다

자, 그럼 메모리 이야기를 해 봅시다. 알고 보면 CPU가 메모리 주소를 읽거나 쓸 때, 실제로는 물리 메모리(RAM)의 해당 위치를 가리키는 것이 아닙니다. 그보다는 가상 메모리 공간의 한 위치를 가리키는 것입니다.

CPU는 메모리 관리 장치(MMU)라는 칩과 통신합니다. MMU는 가상 메모리의 위치를 RAM의 위치로 번역해 주는 사전을 가진 번역가처럼 동작합니다. CPU가 메모리 주소 0xfffaf54834067fe2에서 읽으라는 명령어를 받으면, MMU에 그 주소를 번역해 달라고 요청합니다. MMU는 사전에서 그 주소를 찾아보고, 대응하는 물리 주소가 0x53a4b64a90179fe2라는 것을 알아낸 뒤 그 숫자를 CPU에 돌려보냅니다. 그러면 CPU는 RAM의 해당 주소에서 읽을 수 있습니다.

웃고 있는 CPU와 MMU가 대화를 나누는 그림입니다. MMU는 키가 큰 칩으로, 도서관 사서 안경을 쓰고 "사전: 포인터 0x0000부터 0xffff까지"라고 적힌 커다란 책을 들고 있습니다. CPU가 MMU에게 긴 메모리 주소를 변환해 달라고 요청합니다. MMU는 잠시 생각한 뒤, 다른 포인터로 대답합니다.

컴퓨터가 처음 부팅될 때는 메모리 접근이 물리 RAM으로 곧바로 이루어집니다. 시작 직후 운영체제는 번역 사전을 만들고 CPU에 MMU를 사용하기 시작하라고 지시합니다.

이 사전은 사실 페이지 테이블이라고 부르며, 모든 메모리 접근을 번역하는 이 시스템을 페이징이라고 부릅니다. 페이지 테이블의 항목은 페이지라고 부르며, 각 항목은 가상 메모리의 특정 덩어리가 RAM에 어떻게 매핑되는지를 나타냅니다. 이 덩어리는 항상 고정된 크기이며, 프로세서 아키텍처마다 페이지 크기가 다릅니다. x86-64의 기본 페이지 크기는 4 KiB로, 각 페이지가 4,096바이트 길이의 메모리 블록에 대한 매핑을 지정한다는 뜻입니다.

다시 말해, 4 KiB 페이지에서는 주소의 하위 12비트가 MMU 번역 전후에 항상 동일합니다. 12비트인 이유는 번역 후 얻게 되는 4,096바이트 페이지를 인덱싱하는 데 필요한 비트 수가 12이기 때문입니다.

x86-64는 운영체제가 2 MiB 또는 4 GiB의 더 큰 페이지를 활성화하는 것도 허용합니다. 이렇게 하면 주소 번역 속도가 향상될 수 있지만, 메모리 단편화와 낭비가 늘어납니다. 페이지 크기가 클수록 주소 중 MMU가 번역하는 부분은 작아집니다.

4 KiB 페이징을 사용할 때 메모리 주소를 분해한 모습입니다. 하위 12비트는 페이지 내부를 인덱싱하고, 나머지 비트는 MMU가 변환하여 페이지의 시작 주소가 됩니다.

페이지 테이블 자체는 그저 RAM에 존재합니다. 페이지 테이블에는 수백만 개의 항목이 들어갈 수 있지만, 각 항목의 크기는 몇 바이트 정도에 불과하므로 페이지 테이블이 공간을 그리 많이 차지하지 않습니다.

부팅 시 페이징을 활성화하기 위해 커널은 먼저 RAM에 페이지 테이블을 구성합니다. 그런 다음 페이지 테이블 시작 지점의 물리 주소를 페이지 테이블 베이스 레지스터(PTBR)라는 레지스터에 저장합니다. 마지막으로 커널은 페이징을 활성화하여 모든 메모리 접근이 MMU를 거쳐 변환되도록 합니다. x86-64에서는 제어 레지스터 3(CR3)의 상위 20비트가 PTBR 역할을 합니다. 페이징(Paging)을 뜻하는 PG라는 이름이 붙은 CR0의 31번 비트를 1로 설정하면 페이징이 활성화됩니다.

페이징 시스템의 마법은 컴퓨터가 실행되는 동안에도 페이지 테이블을 수정할 수 있다는 점입니다. 바로 이 방식으로 각 프로세스가 자신만의 격리된 메모리 공간을 가질 수 있습니다. 운영체제가 한 프로세스에서 다른 프로세스로 컨텍스트를 전환할 때 중요한 작업 중 하나는 가상 메모리 공간을 물리 메모리의 다른 영역으로 다시 매핑하는 것입니다. 프로세스가 두 개 있다고 가정해 봅시다. 프로세스 A는 자신의 코드와 데이터(아마도 ELF 파일에서 로드되었을 것입니다!)를 0x0000000000400000에 둘 수 있고, 프로세스 B도 정확히 같은 주소에서 자신의 코드와 데이터에 접근할 수 있습니다. 두 프로세스는 실제로 그 주소 범위를 두고 다투는 것이 아니므로, 심지어 같은 프로그램의 인스턴스일 수도 있습니다! 프로세스 A의 데이터는 물리 메모리에서 프로세스 B와 멀리 떨어진 어딘가에 있으며, 커널이 해당 프로세스로 전환할 때 0x0000000000400000에 매핑합니다.

서로 다른 두 프로세스가 의인화된 데스크톱 컴퓨터를 그린 촌스러운 클립아트 이미지에게 같은 메모리 주소를 변환해 달라고 요청하는 모습을 보여 주는 도식입니다. 의인화된 컴퓨터는 연속된 물리 메모리 띠 안에서 서로 다른 구역을 골라 각 프로세스에 응답합니다.

여담: 저주받은 ELF 이야기

특정 상황에서 binfmt_elf는 메모리의 첫 번째 페이지를 0으로 매핑해야 합니다. ELF를 최초로 지원한 1988년의 운영체제인 UNIX System V Release 4.0(SVr4)용으로 작성된 일부 프로그램은 널 포인터를 읽을 수 있다는 점에 의존합니다. 그리고 어째서인지 아직도 그 동작에 의존하는 프로그램이 있습니다.

이 기능을 구현한 Linux 커널 개발자는 조금 불만스러웠던 것 같습니다:

“왜 이런 짓을 하냐고요??? 글쎄요, SVr4는 페이지 0을 읽기 전용으로 매핑하고, 일부 애플리케이션이 이 동작에 ‘의존’합니다. 우리에게는 이 애플리케이션들을 다시 컴파일할 힘이 없으므로 SVr4의 동작을 에뮬레이션합니다. 하아.”

하아.

페이징을 통한 보안

메모리 페이징으로 가능해진 프로세스 격리는 코드 작성의 편의성을 높여 주지만(프로세스는 메모리를 사용하기 위해 다른 프로세스를 의식할 필요가 없습니다), 동시에 일정 수준의 보안도 제공합니다. 프로세스는 다른 프로세스의 메모리에 접근할 수 없습니다. 이것으로 이 글의 처음에 던졌던 질문 중 하나에 절반쯤 답할 수 있습니다:

프로그램이 CPU에서 직접 실행되고 CPU가 RAM에 직접 접근할 수 있다면, 왜 코드는 다른 프로세스의 메모리나, 맙소사, 커널의 메모리에 접근할 수 없습니까?

기억나십니까? 아주 오래전 일처럼 느껴집니다…

그렇다면 커널 메모리는 어떻습니까? 우선, 커널은 당연히 실행 중인 모든 프로세스와 페이지 테이블 자체까지 관리하기 위해 자체적으로 많은 데이터를 저장해야 합니다. 하드웨어 인터럽트, 소프트웨어 인터럽트 또는 시스템 호출이 발생하여 CPU가 커널 모드로 들어갈 때마다 커널 코드는 어떻게든 그 메모리에 접근해야 합니다.

Linux의 해법은 가상 메모리 공간의 상위 절반을 항상 커널에 할당하는 것이며, 그래서 Linux를 상위 절반 커널(higher half kernel)이라고 부릅니다. Windows도 비슷한 기법을 사용하지만, macOS는… 조금 더 복잡해서 관련 자료를 읽다가 뇌가 귀로 흘러나오는 줄 알았습니다. ~(++)~

가상 메모리 공간을 하나의 띠로 나타낸 다이어그램입니다. 왼쪽 절반에는 사용자 공간(user space)이라는 레이블이 붙어 있으며, 실행 중인 프로그램을 위한 메모리입니다. 오른쪽 절반에는 커널 공간(kernel space)이라는 레이블이 붙어 있으며, 커널과 관련된 모든 것을 위한 고정된 영역입니다. 두 구간을 나누는 중간 지점에는 메모리 주소 0x8000000000000000이 표시되어 있습니다.

하지만 유저랜드 프로세스가 커널 메모리를 읽거나 쓸 수 있다면 보안상 끔찍한 일이 될 것이므로, 페이징은 두 번째 보안 계층을 제공합니다. 각 페이지에는 권한 플래그가 지정되어야 합니다. 한 플래그는 해당 영역이 쓰기 가능한지, 아니면 읽기만 가능한지를 결정합니다. 또 다른 플래그는 커널 모드에서만 해당 영역의 메모리에 접근할 수 있다고 CPU에 알려 줍니다. 이 후자의 플래그는 상위 절반의 커널 공간 전체를 보호하는 데 사용됩니다. 사실 커널 메모리 공간 전체가 사용자 공간 프로그램의 가상 메모리 매핑에 들어 있으며, 프로그램에는 거기에 접근할 권한이 없을 뿐입니다.

페이지 테이블 항목의 권한을 나타낸 표입니다. Present: true입니다. 읽기/쓰기: 읽기 전용입니다. 사용자/커널: 모든 모드입니다. Dirty: false입니다. Accessed: true입니다. 나머지는 생략합니다.

사실 페이지 테이블 자체도 커널 메모리 공간 안에 들어 있습니다! 타이머 칩이 프로세스 전환을 위해 하드웨어 인터럽트를 발생시키면, CPU는 권한 수준을 커널 모드로 전환하고 Linux 커널 코드로 점프합니다. 커널 모드(Intel 링 0)에 있으면 CPU는 커널이 보호하는 메모리 영역에 접근할 수 있습니다. 그러면 커널은 (메모리 상위 절반 어딘가에 있는) 페이지 테이블에 기록하여 새 프로세스를 위해 가상 메모리의 하위 절반을 다시 매핑할 수 있습니다. 커널이 새 프로세스로 전환하고 CPU가 사용자 모드에 진입하면, CPU는 더 이상 커널 메모리에 전혀 접근할 수 없습니다.

거의 모든 메모리 접근은 MMU를 거칩니다. 인터럽트 디스크립터 테이블의 핸들러 포인터는 어떻습니까? 그것들 역시 커널의 가상 메모리 공간에 있는 주소를 가리킵니다.

계층적 페이징과 기타 최적화

64비트 시스템의 메모리 주소는 64비트 길이이며, 이는 64비트 가상 메모리 공간의 크기가 무려 16엑스비바이트에 달한다는 뜻입니다. 이는 엄청나게 큰 크기로, 현재 존재하거나 가까운 미래에 존재할 어떤 컴퓨터보다도 훨씬 큽니다. 제가 아는 한, 역대 컴퓨터 중 가장 많은 RAM을 갖춘 것은 1.5페타바이트가 넘는 RAM을 탑재한 Blue Waters 슈퍼컴퓨터였습니다. 그래도 16 EiB의 0.01%에도 미치지 못합니다.

가상 메모리 공간의 4 KiB 구역마다 페이지 테이블 항목이 하나씩 필요하다면, 4,503,599,627,370,496개의 페이지 테이블 항목이 필요합니다. 페이지 테이블 항목이 8바이트 길이라면, 페이지 테이블 하나를 저장하는 데만 32페비바이트의 RAM이 필요합니다. 이 역시 컴퓨터 한 대에 탑재된 RAM의 세계 기록보다 크다는 점을 알아차리셨을 것입니다.

여담: 단위가 왜 이렇게 이상합니까?

흔하지 않고 정말 보기 흉하다는 것은 알지만, 저는 이진 바이트 크기 단위(2의 거듭제곱)와 미터법 단위(10의 거듭제곱)를 명확히 구분하는 것이 중요하다고 생각합니다. 킬로바이트(kB)는 1,000바이트를 뜻하는 SI 단위입니다. 키비바이트(KiB)는 1,024바이트를 뜻하는, IEC가 권장하는 단위입니다. 컴퓨터는 이진 시스템이므로, CPU와 메모리 주소에서 바이트 수는 보통 2의 거듭제곱입니다. KB(더 나쁘게는 kB)를 1,024라는 의미로 쓰면 더 모호해질 것입니다.

가능한 가상 메모리 공간 전체에 대해 연속된 페이지 테이블 항목을 두는 것은 불가능하기 때문에(적어도 대단히 비현실적이기 때문에), CPU 아키텍처는 계층적 페이징을 구현합니다. 계층적 페이징 시스템에는 단위가 점점 더 잘게 나뉘는 여러 단계의 페이지 테이블이 있습니다. 최상위 단계의 항목은 큰 메모리 블록을 담당하며 더 작은 블록의 페이지 테이블을 가리키고, 이렇게 해서 트리 구조를 이룹니다. 4 KiB 또는 페이지 크기가 얼마이든 그 크기의 블록에 대한 개별 항목이 트리의 잎이 됩니다.

x86-64는 전통적으로 4단계 계층적 페이징을 사용합니다. 이 체계에서 각 페이지 테이블 항목은 그 항목이 들어 있는 테이블의 시작 위치에 주소의 일부를 오프셋으로 더해 찾습니다. 이 일부는 최상위 비트부터 시작합니다. 이 비트들이 접두사 역할을 하므로 해당 항목은 그 비트들로 시작하는 모든 주소를 담당합니다. 항목은 해당 메모리 블록의 하위 트리를 담고 있는 다음 단계 테이블의 시작 위치를 가리키며, 이 하위 트리들은 다시 그다음 비트 묶음으로 인덱싱됩니다.

x86-64의 4단계 페이징 설계자들은 페이지 테이블 공간을 절약하기 위해 모든 가상 포인터의 상위 16비트를 무시하기로 하기도 했습니다. 48비트로는 128 TiB의 가상 주소 공간을 얻을 수 있으며, 이 정도면 충분히 크다고 판단했습니다. (64비트를 모두 사용하면 16 EiB가 되는데, 이는 꽤 많은 양입니다.)

처음 16비트를 건너뛰므로, 페이지 테이블의 첫 번째 단계를 인덱싱하는 “최상위 비트”는 실제로 63번 비트가 아니라 47번 비트부터 시작합니다. 이는 또한 이 장 앞부분의 상위 절반 커널 다이어그램이 엄밀히 말해 부정확했다는 뜻이기도 합니다. 커널 공간의 시작 주소는 64비트보다 작은 주소 공간의 중간 지점으로 표시했어야 합니다.

x86-64의 4단계 페이징을 나타낸 크고 상세한 풀컬러 다이어그램입니다. 네 단계의 페이지 테이블을 보여 주며, 각 단계에서 "접두사" 역할을 하는 비트를 강조하여 표시합니다. 또한 접두사 비트의 값을 테이블의 기준 주소에 더하는 방식으로 이 비트들이 테이블을 인덱싱하는 과정도 보여 줍니다. 각 테이블의 엔트리는 다음 테이블의 시작 지점을 가리킵니다. 단, 마지막 단계인 레벨 1 테이블의 엔트리는 RAM에 있는 4 KiB 블록의 시작 지점을 가리킵니다. MMU는 그 주소에 최하위 12비트를 더하여 최종 물리 주소를 구합니다. 레벨 4 테이블은 하나이고, 레벨 3 테이블은 n제곱 개이며, 아래 단계도 같은 방식으로 이어집니다.

계층적 페이징은 트리의 어느 단계에서든 다음 항목을 가리키는 포인터가 null(0x0)일 수 있으므로 공간 문제를 해결합니다. 이 덕분에 페이지 테이블의 하위 트리 전체를 생략할 수 있으며, 이는 가상 메모리 공간에서 매핑되지 않은 영역이 RAM의 공간을 전혀 차지하지 않는다는 뜻입니다. CPU는 트리의 상위 단계에서 빈 항목을 발견하는 즉시 오류를 낼 수 있으므로, 매핑되지 않은 메모리 주소에 대한 조회는 빠르게 실패할 수 있습니다. 페이지 테이블 항목에는 존재 플래그도 있어서, 주소가 유효해 보이더라도 해당 항목을 사용할 수 없다고 표시하는 데 쓸 수 있습니다.

계층적 페이징의 또 다른 장점은 가상 메모리 공간의 큰 부분을 효율적으로 교체할 수 있다는 것입니다. 가상 메모리의 넓은 영역이 어떤 프로세스에서는 물리 메모리의 한 영역에, 다른 프로세스에서는 또 다른 영역에 매핑될 수 있습니다. 커널은 두 매핑을 모두 메모리에 저장해 두고, 프로세스를 전환할 때 트리 최상위 단계의 포인터만 갱신하면 됩니다. 전체 메모리 공간 매핑이 평평한 항목 배열로 저장되어 있다면 커널은 많은 항목을 갱신해야 할 것입니다. 이는 느릴 뿐 아니라, 여전히 각 프로세스의 메모리 매핑을 별도로 추적해야 합니다.

제가 x86-64가 “전통적으로” 4단계 페이징을 사용한다고 말한 이유는 최근 프로세서들이 5단계 페이징을 구현하기 때문입니다. 5단계 페이징은 간접 참조 단계를 하나 더 추가하고 주소 지정 비트를 9개 더 늘려, 57비트 주소로 주소 공간을 128 PiB까지 확장합니다. 5단계 페이징은 2017년부터 이를 지원한 Linux를 비롯해 최신 Windows 10 및 11 서버 버전 등의 운영체제에서 지원됩니다.

여담: 물리 주소 공간의 한계

운영체제가 가상 주소에 64비트를 모두 사용하지 않는 것처럼, 프로세서도 64비트 물리 주소 전체를 사용하지 않습니다. 4단계 페이징이 표준이던 시절에 x86-64 CPU는 46비트를 넘게 사용하지 않았으며, 이는 물리 주소 공간이 64 TiB로 제한되었다는 뜻입니다. 5단계 페이징에서는 지원 범위가 52비트로 확장되어 4 PiB의 물리 주소 공간을 지원합니다.

운영체제 수준에서는 가상 주소 공간이 물리 주소 공간보다 큰 것이 유리합니다. Linus Torvalds가 말했듯이, “[가상 주소 공간은] 더 커야 합니다. 최소한 두 배는 되어야 하는데, 솔직히 그것도 빠듯하고, 열 배 이상이면 훨씬 낫습니다. 이걸 이해하지 못하는 사람은 멍청이입니다. 논의는 여기서 끝입니다.”

스와핑과 요구 페이징

메모리 접근은 몇 가지 이유로 실패할 수 있습니다. 주소가 범위를 벗어났을 수도 있고, 페이지 테이블에 매핑되지 않았을 수도 있으며, 존재하지 않는 것으로 표시된 항목을 가지고 있을 수도 있습니다. 이 중 어떤 경우든 MMU는 페이지 폴트라는 하드웨어 인터럽트를 발생시켜 커널이 문제를 처리하게 합니다.

어떤 경우에는 읽기가 정말로 유효하지 않거나 금지된 것입니다. 이런 경우 커널은 아마 세그멘테이션 폴트 오류와 함께 프로그램을 종료할 것입니다.

셸 세션
$ ./program
Segmentation fault (core dumped)
$

여담: 세그폴트의 존재론

“세그멘테이션 폴트”는 맥락에 따라 다른 의미를 가집니다. MMU는 권한 없이 메모리를 읽을 때 “세그멘테이션 폴트”라는 하드웨어 인터럽트를 발생시키지만, “세그멘테이션 폴트”는 운영체제가 모든 종류의 불법적인 메모리 접근을 이유로 실행 중인 프로그램을 종료하기 위해 보낼 수 있는 시그널의 이름이기도 합니다.

다른 경우에는 메모리 접근이 의도적으로 실패하도록 하여, 운영체제가 메모리를 채운 뒤 CPU에 제어권을 다시 넘겨 재시도하게 할 수 있습니다. 예를 들어, 운영체제는 디스크에 있는 파일을 실제로 RAM에 적재하지 않은 채 가상 메모리에 매핑해 두었다가, 해당 주소가 요청되어 페이지 폴트가 발생하면 그때 물리 메모리에 적재할 수 있습니다. 이를 요구 페이징(demand paging)이라고 합니다.

하드웨어 인터럽트로 요구 페이징(demand paging)을 구현하는 방식을 보여 주는 세 칸짜리 만화 형식의 도식입니다. 1번 칸: CPU가 MMU와 대화를 나누고 있습니다. CPU가 "0xfff 읽기"라고 말하자 MMU는 어리둥절한 표정을 짓고, 이어서 "페이지 폴트!"라고 적힌 번개를 CPU에 보냅니다. 패널 2에는 "페이지 폴트 핸들러"라는 제목이 붙어 있으며 테두리가 지그재그 모양입니다. 이 칸은 CPU가 데이터를 RAM에 적재한 뒤 인터럽트에서 복귀하는 모습을 보여 줍니다. 마지막 3번 칸에서는 다시 CPU와 MMU가 대화를 나눕니다. MMU는 속으로 "오, 이제 페이지가 있네."라고 생각합니다. 그리고 CPU의 원래 요청에 "여기 요청하신 메모리입니다!"라고 답합니다. CPU는 고맙다고 말합니다.

우선, 이 덕분에 디스크의 파일 전체를 가상 메모리에 지연 매핑하는 mmap 같은 시스템 호출이 존재할 수 있습니다. 유출된 Facebook 언어 모델의 런타임인 LLaMa.cpp를 알고 계신다면, 최근 Justine Tunney가 모든 로딩 로직이 mmap을 사용하도록 만들어 이를 크게 최적화했다는 소식도 들으셨을 것입니다. (그녀에 대해 처음 들어 보셨다면 그녀의 작업물을 살펴보십시오! Cosmopolitan Libc와 APE는 정말 멋지며, 이 글을 재미있게 읽고 계신다면 흥미롭게 느끼실 것입니다.)

보아하니 이 변경에 Justine이 관여한 것을 두고 많은 논란이 있는 듯합니다. 인터넷의 낯선 사용자들에게 욕을 먹지 않으려고 짚어 두는 것뿐입니다. 고백하자면 저는 그 논란의 대부분을 읽어 보지 않았으며, Justine의 작업물이 멋지다고 했던 제 말은 여전히 전적으로 사실입니다.

프로그램과 그 라이브러리를 실행할 때, 커널은 실제로 아무것도 메모리에 적재하지 않습니다. 커널은 해당 파일의 mmap을 생성할 뿐이며, CPU가 코드를 실행하려 하면 즉시 페이지 폴트가 발생하고 커널이 그 페이지를 실제 메모리 블록으로 교체합니다.

요구 페이징은 여러분이 아마 “스와핑(swapping)” 또는 “페이징(paging)“이라는 이름으로 접해 보셨을 기법도 가능하게 합니다. 운영체제는 메모리 페이지를 디스크에 기록한 뒤 물리 메모리에서는 제거하되, present 플래그를 0으로 설정한 채 가상 메모리에는 남겨 둠으로써 물리 메모리를 확보할 수 있습니다. 그 가상 메모리를 읽으려 하면, 운영체제는 디스크에서 RAM으로 메모리를 복원하고 present 플래그를 다시 1로 설정할 수 있습니다. 운영체제는 디스크에서 적재되는 메모리를 위한 공간을 마련하기 위해 RAM의 다른 영역을 스왑해야 할 수도 있습니다. 디스크 읽기와 쓰기는 느리기 때문에, 운영체제는 효율적인 페이지 교체 알고리즘을 사용하여 스와핑이 최대한 적게 일어나도록 합니다.

흥미로운 꼼수 하나는 페이지 테이블의 물리 메모리 포인터를 사용하여 물리 저장 장치 내 파일의 위치를 저장하는 것입니다. MMU는 present 플래그가 0인 것을 보는 즉시 페이지 폴트를 일으키므로, 그 값이 유효하지 않은 메모리 주소라는 것은 문제가 되지 않습니다. 모든 경우에 실용적인 방법은 아니지만, 생각해 보면 재미있습니다.

6장: 포크와 소에 대해 이야기해 봅시다

마지막 질문입니다. 우리는 어떻게 여기까지 왔습니까? 최초의 프로세스는 어디에서 오는 것입니까?

이 글도 거의 끝나갑니다. 이제 마지막 구간에 들어섰습니다. 곧 홈런을 칠 참입니다. 더 푸른 초원으로 나아가는 중입니다. 그 밖에도 온갖 끔찍한 관용구가 있지만, 모두 여러분이 6장 분량 하나만 더 읽으면 밖에 나가 풀을 만지거나, CPU 아키텍처에 관한 15,000단어짜리 글을 읽지 않을 때 하는 무언가를 하러 갈 수 있다는 뜻입니다.

execve가 현재 프로세스를 대체하여 새 프로그램을 시작한다면, 새 프로그램을 새 프로세스에서 따로 시작하려면 어떻게 해야 합니까? 컴퓨터에서 여러 가지 일을 하고 싶다면 이는 꽤 중요한 능력입니다. 앱을 더블 클릭해 실행하면 그 앱은 따로 열리고, 이전에 사용하던 프로그램은 계속 실행됩니다.

답은 또 다른 시스템 호출인 fork이며, 모든 멀티프로세싱의 근간이 되는 시스템 호출입니다. fork는 사실 꽤 단순합니다. 현재 프로세스와 그 메모리를 복제하고, 저장된 명령어 포인터는 원래 위치에 그대로 둔 다음, 두 프로세스가 평소처럼 계속 진행하도록 합니다. 아무런 개입이 없으면 두 프로그램은 서로 독립적으로 계속 실행되며, 모든 연산이 두 배가 됩니다.

새로 실행되는 프로세스를 “자식”이라 하고, 원래 fork를 호출한 프로세스를 “부모”라고 합니다. 프로세스는 fork를 여러 번 호출할 수 있으므로 자식을 여러 개 가질 수 있습니다. 각 자식에는 1부터 시작하는 프로세스 ID(PID)가 번호로 매겨집니다.

같은 코드를 아무 생각 없이 두 배로 실행하는 것은 별 쓸모가 없으므로, fork는 부모와 자식에서 서로 다른 값을 반환합니다. 부모에서는 새 자식 프로세스의 PID를 반환하고, 자식에서는 0을 반환합니다. 이 덕분에 새 프로세스에서 다른 작업을 할 수 있어 포크가 실제로 쓸모 있게 됩니다.

main.c
pid_t pid = fork();

// Code continues from this point as usual, but now across
// two "identical" processes.
//
// Identical... except for the PID returned from fork!
//
// This is the only indicator to either program that they
// are not one of a kind.

if (pid == 0) {
	// We're in the child.
	// Do some computation and feed results to the parent!
} else {
	// We're in the parent.
	// Probably continue whatever we were doing before.
}

프로세스 포크는 이해하기가 조금 어려울 수 있습니다. 지금부터는 여러분이 이해했다고 가정하겠습니다. 아직 이해하지 못했다면 꽤 괜찮은 설명이 있는 이 끔찍하게 생긴 웹사이트를 확인해 보십시오.

어쨌든 유닉스 프로그램은 fork를 호출한 다음 자식 프로세스에서 곧바로 execve를 실행하는 방식으로 새 프로그램을 시작합니다. 이를 fork-exec 패턴이라고 합니다. 프로그램을 실행하면 컴퓨터는 다음과 비슷한 코드를 실행합니다.

launcher.c
pid_t pid = fork();

if (pid == 0) {
	// Immediately replace the child process with the new program.
	execve(...);
}

// Since we got here, the process didn't get replaced. We're in the parent!
// Helpfully, we also now have the PID of the new child process in the PID
// variable, if we ever need to kill it.

// Parent program continues here...

음메에에에!

프로세스의 메모리를 복제해 놓고 다른 프로그램을 불러올 때 곧바로 전부 버리는 것은 조금 비효율적으로 들린다는 점을 눈치채셨을지도 모릅니다. 다행히도 우리에게는 MMU가 있습니다. 느린 부분은 페이지 테이블 복제가 아니라 물리 메모리의 데이터 복제이므로, 우리는 RAM을 아예 복제하지 않습니다. 대신 새 프로세스를 위해 기존 프로세스의 페이지 테이블 사본을 만들고, 매핑은 그대로 같은 물리 메모리를 가리키도록 둡니다.

하지만 자식 프로세스는 부모로부터 독립되고 격리되어야 합니다! 자식이 부모의 메모리에 쓰거나 그 반대가 되어서는 안 됩니다!

COW(copy on write) 페이지를 소개합니다. COW 페이지를 사용하면 두 프로세스는 메모리에 쓰기를 시도하지 않는 한 같은 물리 주소에서 읽습니다. 둘 중 하나가 메모리에 쓰려고 하는 순간 해당 페이지가 RAM에 복사됩니다. COW 페이지 덕분에 두 프로세스는 전체 메모리 공간을 미리 복제하는 비용 없이도 메모리 격리를 누릴 수 있습니다. fork-exec 패턴이 효율적인 이유가 바로 이것입니다. 새 바이너리를 로드하기 전까지 이전 프로세스의 메모리에는 아무것도 쓰이지 않으므로 메모리를 복사할 필요가 전혀 없습니다.

다른 여러 재미있는 기능처럼 COW도 페이징 꼼수와 하드웨어 인터럽트 처리로 구현됩니다. fork는 부모를 복제한 뒤 두 프로세스의 모든 페이지를 읽기 전용으로 표시합니다. 프로그램이 메모리에 쓰면 해당 메모리가 읽기 전용이기 때문에 쓰기가 실패합니다. 그러면 세그폴트(하드웨어 인터럽트 쪽의 세그폴트)가 발생하고, 이를 커널이 처리합니다. 커널은 메모리를 복제하고, 쓰기가 가능하도록 페이지를 갱신한 다음, 인터럽트에서 복귀하여 쓰기를 다시 시도합니다.

A: 똑똑!
B: 누구십니까?
A: 끼어드는 소입니다.
B: 끼어드는 소 누 —
A: 음메에에에에!

태초에 (창세기 1장 1절 아님)

여러분 컴퓨터의 모든 프로세스는 부모 프로그램이 fork-exec한 것입니다. 단 하나, init 프로세스만 예외입니다. init 프로세스는 커널이 직접 수동으로 설정합니다. init 프로세스는 가장 먼저 실행되는 유저랜드 프로그램이자 시스템 종료 시 가장 마지막에 종료되는 프로그램입니다.

순식간에 화면이 까맣게 변하는 멋진 광경을 보고 싶으십니까? macOS나 Linux를 사용 중이라면 작업을 저장하고 터미널을 연 뒤 init 프로세스(PID 1)를 종료해 보십시오:

셸 세션
$ sudo kill 1

저자 주: 안타깝게도 init 프로세스에 관한 지식은 macOS나 Linux 같은 유닉스 계열 시스템에만 적용됩니다. 지금부터 배우는 내용 대부분은 커널 아키텍처가 매우 다른 Windows를 이해하는 데에는 적용되지 않습니다.

execve 절에서와 마찬가지로 이 점을 분명히 짚고 넘어갑니다 — NT 커널에 대해서도 글 한 편을 통째로 쓸 수 있지만 꾹 참고 있습니다. (적어도 지금은 말입니다.)

init 프로세스는 운영체제를 구성하는 모든 프로그램과 서비스를 생성하는 역할을 합니다. 그중 다수는 다시 자신만의 서비스와 프로그램을 생성합니다.

프로세스 트리입니다. 루트 노드에는 "init"이라는 레이블이 붙어 있습니다. 자식 노드에는 모두 레이블이 없지만, init 프로세스가 생성한 것으로 보입니다.

init 프로세스를 종료하면 그 자식들과 자식들의 자식들까지 모두 종료되어 OS 환경이 꺼집니다.

다시 커널로

3장에서 Linux 커널 코드를 살펴보는 일이 무척 재미있었으니, 이번에도 조금 더 살펴보겠습니다! 이번에는 커널이 init 프로세스를 어떻게 시작하는지 살펴보는 것으로 시작하겠습니다.

컴퓨터는 다음과 같은 순서로 부팅됩니다:

  1. 메인보드에는 연결된 디스크에서 부트로더라는 프로그램을 찾는 작은 소프트웨어가 함께 탑재되어 있습니다. 이 소프트웨어는 부트로더를 하나 골라 그 기계어 코드를 RAM에 적재한 뒤 실행합니다.

    아직은 운영체제가 실행 중인 세계에 들어선 것이 아니라는 점을 기억하십시오. 운영체제 커널이 init 프로세스를 시작하기 전까지는 멀티프로세싱도 시스템 호출도 사실상 존재하지 않습니다. init 이전의 맥락에서 프로그램을 “실행”한다는 것은 돌아올 것을 기대하지 않고 RAM에 있는 그 프로그램의 기계어 코드로 곧바로 점프한다는 뜻입니다.

  2. 부트로더는 커널을 찾아 RAM에 적재하고 실행하는 일을 담당합니다. GRUB 같은 일부 부트로더는 설정을 바꿀 수 있거나 여러 운영체제 중 하나를 선택할 수 있게 해 줍니다. BootX와 Windows Boot Manager는 각각 macOS와 Windows에 내장된 부트로더입니다.

  3. 이제 커널이 실행되어 인터럽트 핸들러 설정, 드라이버 적재, 초기 메모리 매핑 생성 등 방대한 초기화 작업을 시작합니다. 마지막으로 커널은 권한 수준을 사용자 모드로 전환하고 init 프로그램을 시작합니다.

  4. 드디어 운영체제의 유저랜드에 들어왔습니다! init 프로그램은 init 스크립트를 실행하고, 서비스를 시작하고, 셸/UI 같은 프로그램을 실행하기 시작합니다.

Linux 초기화하기

리눅스에서 3단계(커널 초기화)의 대부분은 init/main.c의 start_kernel 함수에서 이루어집니다. 이 함수는 다른 여러 init 함수를 호출하는 코드가 200줄을 넘기 때문에 이 글에 전체 내용을 싣지는 않겠지만, 한번 훑어보시기를 권합니다! start_kernel의 끝에서는 arch_call_rest_init라는 함수가 호출됩니다:

start_kernel @ init/main.c
	/* Do the rest non-__init'ed, we're now alive */
	arch_call_rest_init();

non-__init’ed란 무슨 뜻입니까?

start_kernel 함수는 asmlinkage __visible void __init __no_sanitize_address start_kernel(void)로 정의되어 있습니다. __visible, __init, __no_sanitize_address 같은 이상한 키워드는 모두 Linux 커널에서 함수에 여러 코드나 동작을 덧붙이는 데 쓰이는 C 전처리기 매크로입니다.

여기서 __init은 단지 공간을 절약하기 위해, 부팅 과정이 끝나는 즉시 해당 함수와 그 데이터를 메모리에서 해제하도록 커널에 지시하는 매크로입니다.

이것은 어떻게 동작하는 것입니까? 너무 세세한 부분까지 들어가지 않고 말하자면, Linux 커널 자체도 ELF 파일로 패키징되어 있습니다. __init 매크로는 __section(".init.text")로 확장되는데, 이는 코드를 일반적인 .text 섹션 대신 .init.text라는 섹션에 배치하도록 하는 컴파일러 지시문입니다. __section(".init.data")로 확장되는 __initdata처럼, 데이터와 상수를 특별한 init 섹션에 배치할 수 있게 해 주는 다른 매크로들도 있습니다.

arch_call_rest_init는 단순한 래퍼 함수에 불과합니다:

init/main.c
void __init __weak arch_call_rest_init(void)
{
	rest_init();
}

주석에 “do the rest non-__init’ed”라고 적힌 것은 rest_init가 __init 매크로로 정의되지 않았기 때문입니다. 즉, init 메모리를 정리할 때 이 함수는 해제되지 않습니다:

init/main.c
noinline void __ref rest_init(void)
{

이제 rest_init는 init 프로세스를 위한 스레드를 생성합니다:

rest_init @ init/main.c
	/*
	 * We need to spawn init first so that it obtains pid 1, however
	 * the init task will end up wanting to create kthreads, which, if
	 * we schedule it before we create kthreadd, will OOPS.
	 */
	pid = user_mode_thread(kernel_init, NULL, CLONE_FS);

user_mode_thread에 전달되는 kernel_init 매개변수는 몇 가지 초기화 작업을 마친 뒤 유효한 init 프로그램을 찾아 실행하는 함수입니다. 이 과정은 몇 가지 기본 설정 작업으로 시작합니다. free_initmem이 호출되는 부분을 제외하면 대부분은 건너뛰겠습니다. 바로 여기서 커널이 .init 섹션을 해제합니다!

kernel_init @ init/main.c
	free_initmem();

이제 커널은 실행할 적절한 init 프로그램을 찾을 수 있습니다:

kernel_init @ init/main.c
	/*
	 * We try each of these until one succeeds.
	 *
	 * The Bourne shell can be used instead of init if we are
	 * trying to recover a really broken machine.
	 */
	if (execute_command) {
		ret = run_init_process(execute_command);
		if (!ret)
			return 0;
		panic("Requested init %s failed (error %d).",
		      execute_command, ret);
	}

	if (CONFIG_DEFAULT_INIT[0] != '\0') {
		ret = run_init_process(CONFIG_DEFAULT_INIT);
		if (ret)
			pr_err("Default init %s failed (error %d)\n",
			       CONFIG_DEFAULT_INIT, ret);
		else
			return 0;
	}

	if (!try_to_run_init_process("/sbin/init") ||
	    !try_to_run_init_process("/etc/init") ||
	    !try_to_run_init_process("/bin/init") ||
	    !try_to_run_init_process("/bin/sh"))
		return 0;

	panic("No working init found.  Try passing init= option to kernel. "
	      "See Linux Documentation/admin-guide/init.rst for guidance.");

Linux에서 init 프로그램은 거의 항상 /sbin/init에 있거나 그곳으로 심볼릭 링크되어 있습니다. 흔히 쓰이는 init으로는 systemd(웹사이트가 유난히 훌륭합니다), OpenRC, runit 등이 있습니다. kernel_init은 다른 것을 전혀 찾지 못하면 기본값으로 /bin/sh를 사용하며, /bin/sh마저 찾지 못한다면 무언가가 끔찍하게 잘못된 것입니다.

MacOS에도 init 프로그램이 있습니다! 이름은 launchd이며 /sbin/launchd에 있습니다. 터미널에서 실행해 보십시오. 커널이 아니라고 혼날 것입니다.

이 시점부터는 부팅 과정의 4단계에 해당합니다: init 프로세스가 유저랜드에서 실행되며 fork-exec 패턴을 사용해 다양한 프로그램을 실행하기 시작합니다.

Fork 메모리 매핑

프로세스를 fork할 때 Linux 커널이 메모리의 하위 절반을 어떻게 다시 매핑하는지 궁금해서 조금 뒤져 보았습니다. 프로세스 fork 코드의 대부분은 kernel/fork.c에 들어 있는 것으로 보입니다. 고맙게도 그 파일의 앞부분이 어디를 봐야 하는지 알려 주었습니다:

kernel/fork.c
/*
 *  'fork.c' contains the help-routines for the 'fork' system call
 * (see also entry.S and others).
 * Fork is rather simple, once you get the hang of it, but the memory
 * management can be a bitch. See 'mm/memory.c': 'copy_page_range()'
 */

이 copy_page_range 함수는 메모리 매핑에 관한 정보를 받아 페이지 테이블을 복사하는 것으로 보입니다. 이 함수가 호출하는 함수들을 빠르게 훑어보면, 페이지를 읽기 전용으로 설정해 COW 페이지로 만드는 곳도 바로 여기입니다. 이 작업을 해야 하는지는 is_cow_mapping이라는 함수를 호출해 확인합니다.

is_cow_mapping은 include/linux/mm.h에 정의되어 있으며, 메모리 매핑에 해당 메모리가 쓰기 가능하고 프로세스 간에 공유되지 않음을 나타내는 플래그가 있으면 true를 반환합니다. 공유 메모리는 애초에 공유되도록 설계되었으므로 COW를 적용할 필요가 없습니다. 약간 이해하기 어려운 비트마스킹을 감상해 보십시오:

include/linux/mm.h
static inline bool is_cow_mapping(vm_flags_t flags)
{
	return (flags & (VM_SHARED | VM_MAYWRITE)) == VM_MAYWRITE;
}

kernel/fork.c로 돌아가서 copy_page_range를 Command-F로 간단히 검색해 보면 dup_mmap 함수에서 한 번 호출된다는 것을 알 수 있습니다… 이 함수는 다시 dup_mm에서 호출되고… 그것은 copy_mm에서 호출되며… 그것은 마지막으로 거대한 copy_process 함수에서 호출됩니다! copy_process는 fork 함수의 핵심이자, 어떤 의미에서는 Unix 시스템이 프로그램을 실행하는 방식의 중심입니다. 부팅 시 첫 번째 프로세스를 위해 만들어진 템플릿을 언제나 복사하고 수정하는 방식이기 때문입니다.

요약하자면…

그래서… 프로그램은 어떻게 실행됩니까?

가장 낮은 수준에서 보면 프로세서는 멍청합니다. 프로세서는 메모리를 가리키는 포인터를 하나 가지고 있으며, 다른 곳으로 점프하라는 명령어를 만나지 않는 한 명령어를 차례대로 실행합니다.

점프 명령어 외에도 하드웨어 인터럽트와 소프트웨어 인터럽트가 미리 정해진 위치로 점프하여 실행 순서를 끊을 수 있으며, 그 위치에서 다시 어디로 점프할지 정할 수 있습니다. 프로세서 코어는 여러 프로그램을 동시에 실행할 수 없지만, 타이머로 인터럽트를 반복해서 발생시키고 커널 코드가 서로 다른 코드 포인터 사이를 전환하도록 하면 이를 흉내 낼 수 있습니다.

프로그램은 자신이 일관되고 격리된 단위로 실행되고 있다고 속아서 믿습니다. 사용자 모드에서는 시스템 자원에 직접 접근할 수 없고, 메모리 공간은 페이징으로 격리되며, 시스템 호출은 실제 실행 환경을 자세히 알지 못해도 일반적인 I/O에 접근할 수 있도록 설계되어 있습니다. 시스템 호출은 CPU에 커널 코드를 실행해 달라고 요청하는 명령어이며, 그 커널 코드의 위치는 시작 시 커널이 설정합니다.

그런데… 프로그램은 어떻게 실행됩니까?

컴퓨터가 부팅된 후 커널은 init 프로세스를 실행합니다. 이것은 더 높은 추상화 수준에서 실행되는 첫 번째 프로그램이며, 이 수준에서는 기계어 코드가 구체적인 시스템 세부 사항을 많이 신경 쓸 필요가 없습니다. init 프로그램은 컴퓨터의 그래픽 환경을 렌더링하고 다른 소프트웨어의 실행을 담당하는 프로그램들을 실행합니다.

프로그램을 실행하기 위해 init은 fork 시스템 호출로 자기 자신을 복제합니다. 모든 메모리 페이지가 COW이므로 물리 RAM 안에서 메모리를 복사할 필요가 없어 이 복제는 효율적입니다. Linux에서는 copy_process 함수가 바로 이 일을 합니다.

두 프로세스는 각각 자신이 fork된 프로세스인지 확인합니다. fork된 프로세스라면 exec 시스템 호출을 사용해 현재 프로세스를 새 프로그램으로 교체해 달라고 커널에 요청합니다.

새 프로그램은 ELF 파일일 가능성이 높으며, 커널은 이 파일을 파싱하여 프로그램을 어떻게 로드할지, 그리고 새 가상 메모리 매핑 안에서 코드와 데이터를 어디에 배치할지에 관한 정보를 찾습니다. 프로그램이 동적으로 링크되어 있다면 커널은 ELF 인터프리터도 준비할 수 있습니다.

그런 다음 커널은 프로그램의 가상 메모리 매핑을 로드하고 프로그램이 실행되는 상태로 유저랜드에 복귀할 수 있는데, 이는 실제로는 CPU의 명령어 포인터를 가상 메모리에 있는 새 프로그램 코드의 시작 지점으로 설정한다는 뜻입니다.

7장: 에필로그

축하합니다! 이제 우리는 CPU 속에 “여러분”을 확실히 새겨 넣었습니다. 즐거운 시간이 되셨기를 바랍니다.

여러분이 방금 얻은 모든 지식이 실재하며 지금도 실제로 작동하고 있다는 점을 다시 한번 강조하며 여러분을 떠나보내겠습니다. 다음에 컴퓨터가 어떻게 여러 앱을 동시에 실행하고 있는지 생각하게 된다면, 타이머 칩과 하드웨어 인터럽트를 떠올리시기 바랍니다. 어떤 멋진 프로그래밍 언어로 프로그램을 작성하다가 링커 오류를 만나면, 그 링커가 무엇을 하려는 것인지 생각해 보시기 바랍니다.

이 글에 담긴 내용에 대해 질문(또는 정정할 사항)이 있으시면 lexi@hackclub.com으로 이메일을 보내시거나 GitHub에 이슈나 PR을 올려 주십시오.

끝.

… 하지만 잠깐, 아직 더 있습니다!

보너스: C 개념 번역하기

직접 저수준 프로그래밍을 해 보셨다면 아마 스택과 힙이 무엇인지 알고 계실 것이고, malloc도 사용해 보셨을 것입니다. 하지만 그것들이 어떻게 구현되어 있는지는 깊이 생각해 보지 않으셨을 수도 있습니다!

먼저, 스레드의 스택은 가상 메모리의 높은 주소 어딘가에 매핑된 고정 크기의 메모리입니다. 대부분의(전부는 아니지만) 아키텍처에서 스택 포인터는 스택 메모리의 맨 위에서 시작하며, 스택이 커질수록 아래쪽으로 이동합니다. 매핑된 스택 공간 전체에 대해 물리 메모리를 미리 할당하지는 않습니다. 대신 요구 페이징을 사용하여 스택 프레임에 도달할 때마다 메모리를 지연 할당합니다.

malloc 같은 힙 할당 함수가 시스템 호출이 아니라는 사실을 들으면 놀라실 수도 있습니다. 대신 힙 메모리 관리는 libc 구현이 제공합니다! malloc, free 등은 복잡한 프로시저이며, libc는 메모리 매핑의 세부 사항을 직접 추적합니다. 내부적으로 유저랜드 힙 할당자는 mmap(파일 외의 것도 매핑할 수 있습니다)과 sbrk를 비롯한 시스템 호출을 사용합니다.

보너스: 소소한 이야기

이 내용들을 넣을 만한 적당한 자리를 찾지 못했지만 재미있다고 생각해서 여기에 실었습니다.

대부분의 Linux 사용자는 아마도 커널에서 페이지 테이블이 어떻게 표현되는지 상상하는 데 시간을 거의 쓰지 않을 만큼 충분히 흥미로운 삶을 살고 있을 것입니다.

Jonathan Corbet, LWN

하드웨어 인터럽트를 다르게 시각화한 그림입니다.

나뭇가지 위의 작은 새를 그린 4컷 밈 만화로, 말풍선에는 어셈블리 명령어가 적혀 있습니다. 두 번째 컷에서는 화면 밖에서 또 다른 말풍선이 나타나 "안녕, 나야 키보드!"라고 외칩니다. 세 번째 컷에서는 외침의 주인공인 커다란 까마귀가 화면 안에 모습을 드러내며, 이번에는 "중요한 메시지가 있어!"라고 외칩니다. 마지막 컷에서는 시큰둥한 표정의 작은 새가 클로즈업됩니다. 화면 밖 까마귀에게서 나온 또 다른 말풍선에는 E라는 글자 하나만 달랑 적혀 있습니다.

참고로 일부 시스템 호출은 커널 공간으로 점프하는 대신 vDSO라는 기법을 사용합니다. 이에 대해 이야기할 시간은 없었지만, 꽤 흥미로운 내용이니 한번 찾아 읽어 보시기를 권합니다.

마지막으로, 유닉스 편향이라는 지적에 대해 말씀드리겠습니다. 실행과 관련된 내용 중 상당 부분이 유닉스에 매우 특화되어 있다는 점은 저도 정말 미안하게 생각합니다. macOS나 Linux 사용자라면 문제없지만, CPU 아키텍처에 관한 내용은 모두 같더라도 Windows가 프로그램을 실행하거나 시스템 호출을 처리하는 방식을 이해하는 데에는 그다지 도움이 되지 않을 것입니다. 언젠가 Windows 세계를 다루는 글도 꼭 써 보고 싶습니다.

감사의 말

이 글을 쓰는 동안 GPT-3.5, GPT-4와 꽤 많은 대화를 나눴습니다. 거짓말을 많이 했고 정보도 대부분 쓸모없었지만, 문제를 풀어 나갈 때는 가끔 큰 도움이 되었습니다. LLM의 한계를 알고 LLM이 하는 모든 말을 극도로 의심한다면, LLM의 도움은 전체적으로 보아 이득이 될 수 있습니다. 그렇긴 하지만 LLM은 글쓰기에는 형편없습니다. LLM에게 글을 대신 쓰게 하지 마십시오.

무엇보다도 제 글을 교정해 주고, 격려해 주고, 함께 브레인스토밍해 준 모든 분께 감사드립니다. 특히 Ani, B, Ben, Caleb, Kara, polypixeldev, Pradyun, Spencer, Nicky(4장의 멋진 엘프를 그려 주었습니다), 그리고 사랑하는 부모님께 감사드립니다.

컴퓨터를 좋아하는 십 대인데 아직 Hack Club Slack에 들어오지 않았다면 지금 바로 가입하십시오. 제 생각과 진행 상황을 나눌 멋진 사람들의 커뮤니티가 없었다면 저는 이 글을 쓰지 못했을 것입니다. 십 대가 아니라면 저희가 계속 멋진 일을 할 수 있도록 후원해 주십시오.

이 글에 실린 그저 그런 그림은 모두 Figma로 그렸습니다. 편집에는 Obsidian을 썼고, 린팅에는 가끔 Vale를 썼습니다. 이 글의 Markdown 원본은 GitHub에 공개되어 있으며 앞으로의 사소한 지적도 언제든 환영합니다. 모든 그림은 Figma 커뮤니티 페이지에 공개되어 있습니다.

사랑스럽게 애원하는 표정을 짓고 있는 CPU입니다.