[CS300 #110] 페이징 — 고정 크기 조각으로 나눈 가상 메모리
컴퓨터공학 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 가 등장한다.
확인 문제
- 4KB 페이지에서 가상 주소의 하위 몇 비트가 오프셋인가? 그 이유는?
- 페이징이 외부 단편화를 없애는 이유와, 대신 생기는 단편화의 종류는?
- 다단계 페이지 테이블이 메모리를 아끼는 원리는? 대가는 무엇인가?
- TLB 가 없으면 4단계 페이지 테이블에서 메모리 접근 한 번에 몇 번의 메모리 읽기가 필요한가?
- minor page fault 와 major page fault 의 차이는?
풀이
- 12비트다. 4096 = 2^12 이므로 페이지 안의 위치를 나타내는 데 12비트가 필요하다.
- 모든 조각의 크기가 같아 아무 빈 프레임에나 넣을 수 있기 때문이다. 대신 마지막 페이지가 다 차지 않는 내부 단편화가 생긴다.
- 실제로 쓰는 주소 영역에 대한 하위 테이블만 만들기 때문이다. 대가는 변환 시 여러 단계를 차례로 읽어야 해서 느려진다는 것이다.
- 페이지 테이블 4번 + 실제 데이터 1번, 총 5번이다.
- minor 는 디스크 I/O 없이 처리되는 폴트(새 페이지 할당, 이미 페이지 캐시에 있는 페이지 매핑)이고, major 는 스왑이나 파일에서 디스크를 읽어야 하는 폴트다.
더 읽을거리 (References)
- OSTEP, Paging: Introduction (PDF), Paging: Faster Translations (TLBs) (PDF), Paging: Smaller Tables (PDF)
- Linux kernel documentation, Page Tables
- Linux kernel documentation, Concepts overview (Memory Management)
- Python 문서, mmap, resource