메인 콘텐츠로 건너뛰기
page thumbnail

x86 아키텍처를 위한 자기 참조 페이지 테이블, 왜 쓸까?

64비트 x86-64가 등장하면서 가상 주소 공간은 말 그대로 ‘우주급’으로 커졌습니다. 그 덕에 프로세스마다 어마어마한 메모리를 쓸 수 있게 됐지만, 운영체제 입장에서는 골칫거리 하나가 더 생겼습니다. 바로 이 거대한 가상 주소 공간을 관리하기 위한 페이지 테이블이 너무 크고, 다루기 귀찮아졌다는 점입니다.

이 글에서는 이 문제를 우아하게 해결하는 기법인 자기 참조(Self-referencing) 페이지 테이블을 x86(32비트, 64비트 모두) 관점에서 쉽게 풀어봅니다.

전체 흐름은 이렇게 갑니다.

  1. x86-64의 다단계 페이지 테이블 구조가 왜 복잡한지

  2. 자기 참조 페이지 테이블이 무엇이고, 어떻게 동작하는지

  3. 이 기법을 실제로 활용하는 두 가지 방식과 장단점

  4. 다른 아키텍처와 운영체제에서의 적용 가능성, 그리고 실전에서의 의미

운영체제 공부를 하거나 직접 커널을 짜보는 분이라면, 코드 구조를 단순하게 만들어 줄 꽤 매력적인 도구가 될 수 있습니다.


x86-64 페이지 테이블 구조, 무엇이 그렇게 복잡한가

먼저 “왜 이런 꼼수를 써야 하지?”부터 짚고 넘어가야 합니다. 답은 간단합니다. 페이지 테이블을 다루는 것 자체가 점점 더 힘들어졌기 때문입니다.

x86-64의 64비트 주소 공간은 이론적으로 2⁶⁴지만, 실제로는 그보다 작게 쓰더라도 엄청나게 큽니다.1 이 큰 가상 주소 공간을 곧이곧대로 일대일로 매핑하려면 페이지 테이블이 기하급수적으로 커지기 때문에, x86-64는 다단계 페이지 테이블 구조를 사용합니다.

대표적인 4단계 구조(AMD64 기준)를 보면:

  • PML4 (또는 PGD): 최상위 루트 테이블

  • PDPT

  • PD

  • PT: 실제 페이지 프레임을 가리키는 최하위 테이블

가상 주소는 대략 이런 식으로 나뉩니다.

  • 상위 비트: PML4 인덱스

  • 그 아래: PDPT 인덱스

  • 그 아래: PD 인덱스

  • 그 아래: PT 인덱스

  • 마지막 비트들: 페이지 안에서의 오프셋

MMU는 이 인덱스들을 차례대로 따라 내려가면서 페이지 테이블 워크(page walk)를 수행해 최종 물리 주소를 찾습니다.2

문제는 여기서부터입니다.

  1. 모든 레벨이 메모리를 잡아먹는다
    사용할 수 있는 가상 주소 범위가 클수록, 잠재적으로 필요한 페이지 테이블 엔트리도 많아집니다. 많이 쓰지 않는 영역 때문에도 페이지 테이블 페이지들이 할당될 수 있고, 결국 페이지 테이블 자체가 메모리 잡아먹는 괴물이 될 수 있습니다.

  2. 운영체제 코드에서 페이지 테이블을 직접 다루기 불편하다
    커널은 종종 “이 가상 주소를 담당하는 페이지 테이블 엔트리는 어디 있지?” 같은 작업을 해야 합니다. 예를 들어:

    • 새 페이지를 매핑할 때

    • 페이지를 해제할 때

    • 권한 비트를 수정할 때(COW, NX 등)3

    그때마다 페이지 테이블의 각 레벨이 어느 물리 주소에 있는지를 알아야 합니다. 즉, 페이지 테이블을 가리키는 물리 주소를 기억해두고, 필요할 때 마다 이를 가상 주소로 매핑해서 접근해야 합니다.

    물리 주소와 가상 주소를 동시에 관리하다 보면 코드가 금방 난장판이 됩니다. 특히 커널이 여러 주소 공간(프로세스별 page table, IOMMU용 page table 등)을 관리하기 시작하면 더 심해집니다.4

  3. TLB, 페이지 폴트와 맞물리면서 디버깅도 힘들어진다
    TLB 미스가 발생하면 하드웨어가 페이지 테이블 워크를 하고, 페이지 테이블에 잘못된 항목이 있거나 누락되면 페이지 폴트가 터집니다.2
    “지금 이 시점의 페이지 테이블 상태를 눈으로 확인하고 싶다”라고 해도, 손쉽게 테이블 전체를 가상 주소로 들여다보는 방법이 없다면 매번 임시 매핑을 만들고 지우는 귀찮은 작업을 해야 합니다.

요약하면, 페이지 테이블은 엄청 중요한데, 정작 운영체제 입장에서 다루기는 꽤 성가신 존재입니다.

여기서 등장하는 게 자기 참조 페이지 테이블입니다.


자기 참조 페이지 테이블이란? (셀프 레퍼런스 한 줄의 마법)

핵심 아이디어는 놀라울 정도로 단순합니다.

“페이지 테이블 안에서, 루트 테이블(PML4/PGD)이 자기 자신을 가리키게 만들자.”

이 한 줄짜리 꼼수가 만들어내는 효과는 생각보다 강력합니다.

한 칸을 비워두고, 거기에 ‘나 자신’을 넣는다

x86-64의 루트 페이지 테이블(PML4)은 512개의 엔트리를 가집니다. 이 중 하나(보통 상위 몇 개 중 하나)를 골라 특별한 용도로 씁니다.

  • 그 엔트리의 물리 주소 필드에 현재 PML4의 물리 주소를 다시 써 넣습니다.

  • 그리고 필요한 플래그(Present, Read/Write, kernel-only 등)를 설정합니다.

이렇게 하면, 그 엔트리를 통해 접근 가능한 가상 주소 범위 안에 모든 페이지 테이블들이 체계적으로 펼쳐지게 됩니다.

예를 들어, PML4의 마지막 엔트리를 자기 자신으로 설정했다고 가정해 봅시다.
그러면 다음과 같은 “마법 주소 공간”이 생깁니다.

  • 특정 상위 비트 조합 → “페이지 테이블들이 모여 있는 영역”

  • 그 안에서 하위 인덱스를 바꾸면:

    • 어떤 값은 PML4의 특정 엔트리를 가리키고

    • 어떤 값은 PDPT의 특정 엔트리

    • 어떤 값은 PD의 특정 엔트리

    • 어떤 값은 PT의 특정 엔트리를 가리키게 됩니다.

즉, 가상 주소만 잘 계산하면, 현재 주소 공간을 위한 모든 페이지 테이블 엔트리를 ‘일반 메모리처럼’ 읽고 쓸 수 있게 되는 것입니다.

이 기법은 OSDev 커뮤니티에서는 흔히 Fractal / Recursive Page Mapping 같은 이름으로도 불립니다. Hacker News에서 언급된 프로젝트도 결국 이런 아이디어를 x86에 깔끔하게 적용하고 논문 수준으로 정리한 사례라고 볼 수 있습니다.5

왜 이렇게 편해지는가?

자기 참조 페이지 테이블을 쓰면 다음과 같은 이점이 생깁니다.

첫째, 물리 주소를 신경 안 써도 된다

이전에는 “이 가상 주소의 PDE/PTE는 물리 메모리 어디에 있지?”를 알아내고, 그 물리 페이지를 다시 커널 가상 주소에 매핑한 뒤 접근해야 했습니다.

이제는 고정된 공식 하나만 알면 됩니다.

“페이지 테이블 전용 가상 주소 영역의 상위 비트는 셀프 레퍼런스 인덱스,
나머지 인덱스는 (PML4, PDPT, PD, PT) 인덱스를 그대로 박는다.”

그러면 그 주소는 자동으로 원하는 페이지 테이블 엔트리의 가상 주소가 됩니다.
물리 주소를 의식할 필요가 없습니다.

둘째, 모든 레벨의 테이블에 일관된 방식으로 접근 가능

32비트든 64비트든, PML4든 PGD든, PD 든 PT든 상관없이 같은 패턴으로 주소를 계산합니다.
덕분에 코드베이스를 “x86 공통 코드 + 약간의 아키텍처별 상수” 수준으로 정리할 수 있습니다.

셋째, 디버깅과 인트로스펙션이 훨씬 쉬워진다

커널 디버깅을 하다 보면 “지금 이 프로세스의 페이지 테이블 전체를 메모리 덤프로 보고 싶다”는 순간이 많습니다.
자기 참조를 걸어두면, 디버거에서 그 특수 영역을 그대로 메모리로 읽어오기만 하면 됩니다.

페이지 폴트 핸들러 안에서 바로 자신의 페이지 테이블을 들여다보고, 문제를 고치는 것도 가능합니다.


x86 32비트·64비트 모두에서 쓰는 방법 (두 가지 구현 스타일)

재미있는 점은 이 기법이 32비트, 64비트 x86 모두에서 쓸 수 있다는 것입니다. 구조가 조금 다를 뿐, 아이디어는 동일합니다.

32비트에서는 전통적으로 PGD–PMD–PTE 구조(보통 2~3단계)에서 가장 상위 디렉터리의 한 엔트리를 자기 자신으로 돌려 버립니다.
64비트에서는 PML4–PDPT–PD–PT 구조에서 PML4 엔트리 하나를 셀프 레퍼런스로 씁니다.1

이렇게 자기 참조를 만들어 놓으면, 페이지 테이블을 조작하는 코드는 아키텍처에 거의 구애받지 않는 공통 패턴을 따르게 됩니다.

실제로 페이지 테이블 엔트리를 조작하는 방식은 크게 두 가지로 나눌 수 있습니다.

1) 트리 탐색을 직접 사용하는 방식

이 방식은 말 그대로 페이지 테이블 트리를 커널 코드가 직접 탐색하는 방법입니다.

  1. 목표 가상 주소를 받는다.

  2. 그 주소의 각 인덱스(PML4, PDPT, PD, PT)를 계산한다.

  3. “페이지 테이블용 고정 가상 영역”의 베이스 주소에 인덱스를 조합해서:

    • 해당 가상 주소에 있는 페이지 테이블 엔트리를 읽고/쓴다.

장점은 예측 가능성과 단순함입니다.

  • “이 가상 주소의 PTE는 항상 이 공식을 따른 주소에 있다” → 디버깅이 쉽다.

  • 동기적으로 바로 값을 읽고 수정할 수 있다.

  • TLB flush, shootdown 등도 코드 상에서 명시적으로 처리하기 좋다.

운영체제의 메모리 관리 코드 대부분은 이 스타일에 더 가깝습니다.
교육용 OS(예: eduOS)나 자체 커널 실험에서도 이해하기가 쉽고, 코드 흐름이 “페이지 테이블은 그냥 또 다른 배열”처럼 느껴지는 장점이 있습니다.

2) 페이지 폴트 핸들러를 적극 활용하는 방식

조금 더 “트릭”에 가까운 접근은 페이지 폴트를 일부러 유도해서 페이지 테이블 관리에 활용하는 방법입니다.

아이디어는 이렇습니다.

  • 어떤 가상 주소를 당장 매핑하지 않는다.

  • 해당 주소를 처음 접근하면 페이지 폴트가 발생한다.

  • 페이지 폴트 핸들러에서:

    • 이것이 “합법적이지만 아직 준비되지 않은 페이지”인지 판단

    • 필요하다면 새 페이지 프레임을 할당하고

    • 페이지 테이블 엔트리를 설정한 뒤 재시도

이런 패턴을 페이지 테이블 관리에도 응용할 수 있습니다.
예를 들어 “특정 범위의 가상 주소를 페이지 테이블용 특수 영역으로 예약해 두고, 실제로 접근할 때마다 그때그때 필요한 페이지 테이블 페이지를 할당”하는 식입니다.

장점은:

  • 페이지 테이블 페이지를 지연 할당(lazy allocation) 할 수 있어, 메모리 사용을 조금 더 아낄 수 있습니다.

  • 주소 공간 구조를 깔끔하게 유지할 수 있습니다.

단점은:

  • 페이지 폴트 핸들러가 복잡해지고, 성능 상의 오버헤드도 생깁니다.

  • 커널 코드의 타이밍이나 락 구조가 더 까다로워집니다.

대부분의 일반적인 OS 구현에서는 첫 번째 방식(직접 트리 탐색 + 자기 참조)을 기본으로 하고, 두 번째 방식은 부분적으로만 섞어 쓰거나, 특정 상황에서만 사용합니다.


다른 아키텍처에서도 통하는 개념, 그리고 윈도우 NT의 힌트

이 아이디어는 x86에만 국한된 것이 아닙니다.
필수 조건은 두 가지뿐입니다.

  1. 페이지 테이블이 계층적인 구조일 것

  2. 루트 테이블의 엔트리 하나를 任意 물리 주소(자기 자신)로 설정할 수 있을 것

예를 들어 DEC Alpha 아키텍처의 메모리 관리 설명을 보면, 비슷한 형태의 자기 참조/재귀 매핑 기법을 사용하는 것을 권장하는 부분이 있습니다.6
ARM, MIPS 계열에서도 MMU가 비슷하게 동작한다면 원리는 그대로 적용할 수 있습니다.

운영체제 측면에서도, 이 기법은 생각보다 오래전부터 사용되어 온 것으로 보입니다.

  • OS 개발자 커뮤니티에서는 “Windows NT 계열이 오래 전부터 자기 참조 페이지 테이블을 사용해 왔다”는 이야기가 자주 나옵니다.5

  • 실제로 관련 논문이나 문서 중에는 “2010년경의 레퍼런스 하나에서 NT 커널이 유사한 방법을 쓴다는 언급이 있다”고 적힌 경우도 있습니다.

  • Hacker News에서도 “이거 새로울 것 없다, 윈도우는 이미 오래 썼다”는 댓글이 달려 있죠.5

리눅스는 전통적으로 고정 커널 매핑 구간을 활용해 페이지 테이블을 접근하는 방식을 많이 써 왔고, 아키텍처별로 구현이 조금씩 다릅니다. 하지만 요지는 동일합니다.
어떤 방식이든 페이지 테이블을 자기 자신의 주소 공간 안에 안정적으로 노출시키는 구조를 만들어 두면, 커널 코드가 훨씬 정리됩니다.

재미있는 부분은, 이런 기법이 커널 개발자나 시스템 프로그래머 사이에서는 꽤 잘 알려져 있지만, 교과서나 기본 OS 강의에서는 잘 다루지 않는다는 점입니다.
그래서 최근에 나온 학부 졸업 논문에서 이 주제를 다시 체계적으로 정리하니, “이 정도를 논문으로?”라는 반응과 함께 “그래도 정리해두면 좋지”라는 의견이 함께 나오는 상황이 벌어지기도 했습니다.5


마무리: 커널 코드가 단순해질수록 디버깅이 행복해진다

지금까지 x86 아키텍처에서의 자기 참조 페이지 테이블(Self-referencing / Recursive page tables) 개념을 살펴봤습니다.

핵심 포인트만 다시 정리해보면:

  1. 64비트 x86-64에서 가상 주소 공간과 다단계 페이지 테이블 구조가 커지면서, 페이지 테이블 자체를 다루는 비용과 복잡성이 눈에 띄게 증가했습니다.12

  2. 루트 테이블(PML4/PGD)의 엔트리 하나를 자기 자신을 가리키도록 설정하면,

    • 모든 페이지 테이블을 포함하는 “특수 가상 주소 영역”이 생기고

    • 이 영역을 통해 페이지 테이블 엔트리를 일반 메모리처럼 다룰 수 있습니다.

  3. 이 기법은 x86 32비트와 64비트 모두에 적용 가능하며, 페이지 테이블을 조작하는 코드를 아키텍처 중립적인 형태로 단순화합니다.

  4. 구현 방식은 크게:

    • 트리 탐색을 직접 활용해 정해진 공식으로 PTE 주소를 계산하는 방법

    • 페이지 폴트 핸들러를 이용해 지연 할당과 자동 매핑을 해주는 방법
      두 가지가 있고, 대부분의 OS는 첫 번째를 기본으로 사용합니다.

  5. Alpha 같은 다른 아키텍처에도 응용 가능하고, Windows NT 등 상용 OS에서도 비슷한 방식이 오래전부터 쓰였다는 정황이 있습니다.56

운영체제 코드를 짜보면 느끼겠지만, 복잡한 일을 여러 군데서 반복하는 것만큼 버그를 잘 부르는 구조도 없습니다.
자기 참조 페이지 테이블은 “페이지 테이블이라는 복잡한 구조를, 커널 입장에서 훨씬 단순한 API처럼 보이게 만드는 작은 트릭”입니다.

직접 OS를 만들어보고 싶다면, 다음과 같은 연습을 해보는 것도 좋습니다.

  • 32비트 x86용 간단한 커널을 만든 뒤, PGD 엔트리 하나를 자기 자신으로 매핑해 보기

  • 그 영역을 통해 “이 가상 주소의 PTE를 읽어오는 함수”를 만들어 보고

  • 기존의 물리 주소 기반 관리 코드와 비교해, 얼마나 코드가 단순해지는지 체감해 보기

한 번 이 편리함을 맛보고 나면, 페이지 테이블을 예전 방식으로 다시 다루고 싶지 않을 수도 있습니다.


참고

2Page table

3Memory paging

4Memory management unit

5Self-referencing Page Tables for the x86-Architecture | Hacker News

6Memory management unit — Examples (DEC Alpha)

이 노트는 요약·비평·학습 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.