컴퓨터공학 300 주제 시리즈의 110번째 글이다. 전체 지도는 여기.

한 줄 요약

페이징은 가상 주소 공간과 물리 메모리를 같은 크기의 조각(페이지와 프레임)으로 나누고, 페이지 테이블로 둘을 대응시켜 주소를 변환하는 메모리 관리 방식이며, TLB 가 그 변환을 빠르게 만든다.

왜 필요한가

프로세스마다 “0번지부터 시작하는 나만의 메모리”를 주고 싶다. 가장 단순한 방법은 프로세스를 물리 메모리의 연속된 구간에 통째로 올리는 것이다. 그러면 곧 문제가 생긴다.

  • 외부 단편화: 프로세스가 오고 가면서 빈 공간이 여기저기 조각난다. 합치면 충분한데 연속된 공간이 없어 새 프로세스를 못 올린다.
  • 크기 변경이 어렵다: 힙이 커지려는데 바로 뒤에 다른 프로세스가 있다.
  • 필요 없는 부분까지 올려야 한다: 거대한 프로그램의 대부분은 실행 중 한 번도 쓰이지 않는다.

페이징은 메모리를 작은 고정 크기 조각으로 나눠 아무 빈 프레임에나 흩어 놓는다. 연속성이 필요 없으니 외부 단편화가 사라지고, 필요한 페이지만 올리는 요구 페이징도 가능해진다. 현대의 범용 CPU 와 운영체제는 모두 페이징을 쓴다.

핵심 개념

페이지, 프레임, 오프셋

  • 페이지(page): 가상 주소 공간의 고정 크기 조각. x86-64 와 대부분의 ARM 리눅스에서 기본 4KB 다.
  • 프레임(frame): 물리 메모리의 같은 크기 조각.
  • 가상 주소는 가상 페이지 번호(VPN) 와 오프셋으로 나뉜다. 4KB = 2^12 이므로 하위 12비트가 오프셋이다.
가상 주소 (32비트 예)
+----------------------+--------------+
|   VPN (상위 20비트)   | offset (12)  |
+----------------------+--------------+
           |                   |
     페이지 테이블              |
           v                   v
+----------------------+--------------+
|   PFN (프레임 번호)    | offset (12)  |   물리 주소
+----------------------+--------------+

오프셋은 그대로 두고 페이지 번호만 프레임 번호로 바꾼다. 이것이 주소 변환의 전부다.

페이지 테이블 항목(PTE)

비트 의미
Present(Valid) 이 페이지가 지금 물리 메모리에 있는가
R/W 쓰기 허용 여부
U/S 사용자 모드 접근 허용 여부
Accessed 최근에 접근되었는가(페이지 교체에 사용)
Dirty 수정되었는가(내보낼 때 디스크에 써야 하는가)
NX 실행 금지(데이터 영역에서 코드 실행 차단)

Present 가 꺼진 페이지에 접근하면 CPU 는 페이지 폴트 예외를 일으키고, 커널이 원인을 판단한다. 처음 쓰는 익명 페이지라면 빈 프레임을 할당하고(minor fault), 스왑이나 파일에서 읽어 와야 하면 디스크 I/O 를 한다(major fault). 권한이 없는 접근이면 프로세스에 SIGSEGV 를 보낸다.

다단계 페이지 테이블

64비트 주소 공간을 4KB 페이지로 나누어 평평한 표 하나로 만들면 표가 터무니없이 커진다. 대부분이 비어 있는 주소 공간을 위해 거대한 표를 둘 수는 없다. 그래서 표를 트리로 만든다. 실제로 쓰는 영역에 대한 하위 표만 만든다.

x86-64 리눅스는 보통 4단계(48비트 가상 주소)를 쓰고, 지원하는 CPU 에서는 5단계(57비트)도 쓸 수 있다.

48비트 가상 주소 = [PGD 9][PUD 9][PMD 9][PTE 9][offset 12]

CR3 -> PGD -> PUD -> PMD -> PTE -> 물리 프레임

각 단계가 9비트(512개 항목, 8바이트씩 = 정확히 4KB 한 페이지)다. 대가는 변환 한 번에 메모리를 최대 4번 더 읽어야 한다는 것이다.

TLB — 변환 결과 캐시

매 메모리 접근마다 페이지 테이블을 걷는다면 너무 느리다. CPU 안의 작은 연관 캐시 TLB(Translation Lookaside Buffer) 가 최근의 VPN→PFN 변환을 기억한다. 프로그램은 지역성이 있어서 대부분의 접근이 TLB 에서 바로 끝난다.

  • TLB 미스 → 하드웨어(x86)나 소프트웨어가 페이지 테이블을 걸어 채운다.
  • 프로세스가 바뀌면 TLB 내용이 무의미해진다. PCID/ASID 태그로 전체 무효화를 피한다.
  • 페이지가 크면 TLB 항목 하나가 더 넓은 범위를 덮는다. 그래서 거대 페이지(huge page)(x86-64 의 2MB, 1GB)가 큰 메모리를 쓰는 DB 나 JVM 의 성능을 올리기도 한다.

페이징이 주는 덤

  • 공유: 여러 프로세스의 페이지 테이블이 같은 프레임을 가리키면 공유 라이브러리를 메모리에 한 번만 올린다.
  • Copy-on-Write: fork 후 부모와 자식이 프레임을 공유하다가 쓰는 순간에만 복사한다.
  • 보호: 페이지 단위로 읽기/쓰기/실행 권한을 다르게 준다.
  • 내부 단편화: 대신 마지막 페이지의 남는 부분은 낭비된다. 평균 페이지 크기의 절반 정도다.

직접 해 보기

주소 변환을 흉내 내고, 요구 페이징으로 페이지 폴트가 실제로 언제 일어나는지 센다.

import mmap, resource

PAGE = mmap.PAGESIZE
print("페이지 크기:", PAGE, "바이트")

# 1) 주소 변환 흉내: 32비트 주소, 4KB 페이지 -> 상위 20비트 VPN, 하위 12비트 offset
page_table = {0x00400: 0x1A2B3, 0x00401: 0x0F00D}   # VPN -> PFN (나머지는 없음)
def translate(vaddr):
    vpn, off = vaddr >> 12, vaddr & 0xFFF
    if vpn not in page_table:
        return f"0x{vaddr:08x} -> page fault (VPN 0x{vpn:05x} 매핑 없음)"
    return f"0x{vaddr:08x} -> VPN 0x{vpn:05x} + off 0x{off:03x} -> 물리 0x{(page_table[vpn] << 12) | off:08x}"
for va in (0x00400123, 0x00401FFF, 0x00402000):
    print(translate(va))

# 2) 요구 페이징: 64MB 를 매핑해도 만지기 전까지는 물리 페이지가 없다
def minflt():
    return resource.getrusage(resource.RUSAGE_SELF).ru_minflt
SIZE = 64 * 1024 * 1024
m = mmap.mmap(-1, SIZE)                 # 익명 매핑
f0 = minflt()
print("매핑 직후 증가한 page fault:", minflt() - f0)
for off in range(0, SIZE, PAGE):        # 페이지마다 1바이트씩 쓴다
    m[off] = 1
print("모든 페이지를 만진 뒤     :", minflt() - f0, "(페이지 수 =", SIZE // PAGE, ")")
for off in range(0, SIZE, PAGE):
    m[off] = 2
print("두 번째로 만진 뒤 추가분  :", minflt() - f0 - SIZE // PAGE)
페이지 크기: 4096 바이트
0x00400123 -> VPN 0x00400 + off 0x123 -> 물리 0x1a2b3123
0x00401fff -> VPN 0x00401 + off 0xfff -> 물리 0x0f00dfff
0x00402000 -> page fault (VPN 0x00402 매핑 없음)
매핑 직후 증가한 page fault: 0
모든 페이지를 만진 뒤     : 16384 (페이지 수 = 16384 )
두 번째로 만진 뒤 추가분  : 0

64MB 를 mmap 한 순간에는 페이지 폴트가 하나도 없다. 커널은 주소 범위만 예약했다. 페이지마다 처음 쓸 때 minor fault 가 정확히 한 번씩, 16384번 일어나고, 그 뒤에는 이미 매핑되어 있으므로 추가 폴트가 없다. 투명 거대 페이지(THP)가 켜진 시스템에서는 2MB 단위로 할당되어 폴트 수가 훨씬 적게 나올 수 있다.

현업에서는

  • VSZ 와 RSS: ps 의 VSZ 는 예약한 가상 주소 크기, RSS 는 실제로 물리 메모리에 올라온 페이지 크기다. 위 실험처럼 VSZ 가 커도 만지지 않으면 RSS 는 늘지 않는다. 쿠버네티스 메모리 limit 과 OOM 판단은 가상 크기가 아니라 cgroup 이 센 실제 사용량 기준이다.
  • major fault 는 지연의 신호: ps -o min_flt,maj_flt 나 /proc/PID/stat 으로 볼 수 있다. major fault 가 늘면 디스크에서 페이지를 읽고 있다는 뜻이고, 응답 지연으로 바로 이어진다.
  • 거대 페이지 설정: PostgreSQL 의 huge_pages, JVM 의 대형 페이지 옵션은 TLB 미스를 줄이려는 설정이다. 반면 투명 거대 페이지는 일부 워크로드에서 지연 스파이크를 일으킬 수 있어 DB 벤더가 끄라고 권하기도 하므로 문서를 확인하고 측정해서 정한다.
  • 오버커밋: 리눅스는 기본적으로 실제 메모리보다 많은 가상 메모리 할당을 허용한다(vm.overcommit_memory). 요구 페이징 덕분에 가능하지만, 모두가 실제로 쓰기 시작하면 OOM killer 가 등장한다.

확인 문제

  1. 4KB 페이지에서 가상 주소의 하위 몇 비트가 오프셋인가? 그 이유는?
  2. 페이징이 외부 단편화를 없애는 이유와, 대신 생기는 단편화의 종류는?
  3. 다단계 페이지 테이블이 메모리를 아끼는 원리는? 대가는 무엇인가?
  4. TLB 가 없으면 4단계 페이지 테이블에서 메모리 접근 한 번에 몇 번의 메모리 읽기가 필요한가?
  5. minor page fault 와 major page fault 의 차이는?

풀이

  1. 12비트다. 4096 = 2^12 이므로 페이지 안의 위치를 나타내는 데 12비트가 필요하다.
  2. 모든 조각의 크기가 같아 아무 빈 프레임에나 넣을 수 있기 때문이다. 대신 마지막 페이지가 다 차지 않는 내부 단편화가 생긴다.
  3. 실제로 쓰는 주소 영역에 대한 하위 테이블만 만들기 때문이다. 대가는 변환 시 여러 단계를 차례로 읽어야 해서 느려진다는 것이다.
  4. 페이지 테이블 4번 + 실제 데이터 1번, 총 5번이다.
  5. minor 는 디스크 I/O 없이 처리되는 폴트(새 페이지 할당, 이미 페이지 캐시에 있는 페이지 매핑)이고, major 는 스왑이나 파일에서 디스크를 읽어야 하는 폴트다.

더 읽을거리 (References)