페이징(Paging)이란
지금까지 만든 OS는 모든 프로세스가 동일한 물리 메모리를 공유한다. 즉 프로세스 A가 프로세스 B의 스택 주소를 알면 그냥 읽고 쓸 수 있다. 이는 보안 측면에서 치명적인 문제다.
이를 해결하기 위해 프로그램이 사용하는 주소(가상 주소)와 실제 RAM 주소(물리 주소)를 분리한다. 각 프로세스는 자신만의 가상 주소 공간을 가지며, 같은 가상 주소라도 프로세스마다 다른 물리 주소로 매핑될 수 있다.
프로세스 A: 가상 0x1000 → 물리 0x8000
프로세스 B: 가상 0x1000 → 물리 0x9000이 매핑 정보를 저장하는 테이블이 페이지 테이블(Page Table) 이고, 이 메커니즘 전체를 페이징(Paging) 이라고 한다. 변환은 CPU 내부의 MMUMemory Management Unit. 가상 주소를 물리 주소로 변환하는 하드웨어. CPU가 메모리에 접근할 때마다 자동으로 동작한다.가 자동으로 수행한다.
메모리를 주소 하나하나 단위로 매핑하면 테이블이 너무 커지기 때문에, 4KB(= 4096바이트) 단위의 덩어리(페이지) 로 나누어 관리한다. 메모리 할당에서 alloc_pages를 페이지 단위로 구현한 것도 이 구조와 맞닿아 있다.
os01에서 다뤘던 Page Fault 시나리오
를 떠올려보자. lw s0, 0(s1) 실행 시 MMU가 페이지 테이블을 조회하다가 V=0인 엔트리를 만난 것이 바로 이 변환 실패였다. 이 글에서는 그 페이지 테이블을 직접 만들어 각 프로세스의 메모리를 하드웨어 수준에서 격리한다.
Sv32 가상 주소 구조
이 책에서는 RISC-V의 페이징 방식 중 하나인 Sv32를 사용한다. 32비트 가상 주소를 다음과 같이 3개의 필드로 쪼갠다.
[31:22] VPN[1] (10bit) — 1단계 페이지 테이블 인덱스
[21:12] VPN[0] (10bit) — 2단계 페이지 테이블 인덱스
[11:0] offset (12bit) — 페이지 내 오프셋 (4KB 범위)MMU는 가상 주소를 받으면 VPN[1] → VPN[0] 순서로 2단계 테이블 워크를 수행해 최종 물리 주소를 얻는다.
32비트 주소공간을 4KB 페이지로 나누면 페이지가 총 2²⁰ = 약 100만 개다. 이걸 1단계 테이블 하나에 전부 넣으면 4MB짜리 테이블이 프로세스마다 항상 필요하다. 2단계로 쪼개면 실제로 사용하는 영역에 해당하는 2단계 테이블만 동적으로 할당하면 된다. 이게 왜 1단계가 아니라 2단계로 쪼개는가?
map_page 코드에서 1단계 엔트리가 없을 때만 alloc_pages(1)을 호출하는 이유다.
PTE(Page Table Entry) 구조
Sv32의 PTE는 32비트이며 다음과 같이 구성된다.
[31:10] PPN (Physical Page Number) — 22비트
[9:8] RSW (소프트웨어 예약)
[7] D (Dirty: CPU가 이 페이지에 쓴 적 있음)
[6] A (Accessed: CPU가 이 페이지를 읽거나 실행한 적 있음)
[5] G (Global)
[4] U (User 모드 접근 가능)
[3] X (Execute 가능)
[2] W (Write 가능)
[1] R (Read 가능)
[0] V (Valid: 이 엔트리가 유효함)
중요한 규칙이 하나 있다. R=0, W=0, X=0인 엔트리는 “다음 단계 테이블을 가리키는 포인터” 로 해석되고, R/W/X 중 하나라도 1이면 실제 물리 페이지로의 매핑(리프 엔트리) 이다. map_page 코드에서 1단계 엔트리에 PAGE_V만 세트하고 R/W/X를 넣지 않는 이유가 이것이다.

Sv32 스펙상 물리 주소는 최대 34비트(PPN 22비트 + offset 12비트)까지 표현 가능하다. 즉 PTE의 PPN 22비트를 전부 활용하면 16GB 물리 메모리를 지원할 수 있다. 그런데 이 튜토리얼에서는 32비트 물리 주소를 PAGE_SIZE(4096)로 나누면 PPN은 최대 20비트다. PTE의 PPN 필드 22비트 중 상위 2비트는 이 튜토리얼에서 항상 0이 된다. 이건 버그가 아니라 의도적인 단순화다. QEMU virt 머신의 물리 메모리가 전부 32비트 범위(PPN이 22비트인데 paddr_t가 32비트면 상위 2비트는?
paddr_t가 uint32_t(32비트)로 정의되어 있다.typedef uint32_t paddr_t;PTE [31:10] PPN 22비트:
[ 00 | 실제 사용되는 PPN 20비트 ]
↑
이 2비트는 항상 00x80200000 ~ 0x84221000) 안에 있으므로 실행상 문제가 없다. 실제로 34비트 물리 주소를 쓰는 하드웨어를 지원하려면 paddr_t를 uint64_t로 바꿔야 한다.
페이지 테이블 구현
매크로 정의
#define SATP_SV32 (1u << 31)
#define PAGE_V (1 << 0) // "Valid" 비트
#define PAGE_R (1 << 1) // 읽기 가능
#define PAGE_W (1 << 2) // 쓰기 가능
#define PAGE_X (1 << 3) // 실행 가능
#define PAGE_U (1 << 4) // 사용자 모드 접근 가능
SATP_SV32는 satpSupervisor Address Translation and Protection 레지스터. Sv32 활성화 비트(bit 31), ASID(bit 30:22), 1단계 페이지 테이블의 물리 페이지 번호(bit 21:0)로 구성된다. 레지스터에 Sv32 페이징 활성화를 알리는 비트다.
map_page 함수
map_page의 역할을 한 문장으로 표현하면, “VPN을 인덱스로 삼아 해당 PTE에 PPN을 기록함으로써, MMU가 나중에 가상 주소를 물리 주소로 변환할 수 있게 준비하는 함수” 다.
MMU는 가상 주소에서 VPN을 추출해 페이지 테이블을 조회하고, 거기서 PPN을 꺼내 offset을 붙여 물리 주소를 만든다. map_page는 그 조회 결과가 올바르게 나오도록 테이블을 미리 채워두는 것이다.
MMU 동작 (하드웨어): VPN → 페이지 테이블 조회 → PPN → PPN + offset = 물리 주소
map_page 역할 (우리): 페이지 테이블을 미리 채워둠void map_page(uint32_t *table1, uint32_t vaddr, paddr_t paddr, uint32_t flags) {
if (!is_aligned(vaddr, PAGE_SIZE))
PANIC("unaligned vaddr %x", vaddr);
if (!is_aligned(paddr, PAGE_SIZE))
PANIC("unaligned paddr %x", paddr);
uint32_t vpn1 = (vaddr >> 22) & 0x3ff; // vaddr에서 VPN[1] 추출 (1단계 인덱스)
if ((table1[vpn1] & PAGE_V) == 0) {
// 이 VPN[1] 범위의 2단계 테이블이 아직 없으면 새로 할당
uint32_t pt_paddr = alloc_pages(1);
table1[vpn1] = ((pt_paddr / PAGE_SIZE) << 10) | PAGE_V; // 2단계 테이블 주소를 PPN으로 저장
}
uint32_t vpn0 = (vaddr >> 12) & 0x3ff; // vaddr에서 VPN[0] 추출 (2단계 인덱스)
uint32_t *table0 = (uint32_t *) ((table1[vpn1] >> 10) * PAGE_SIZE); // 2단계 테이블 주소 복원
table0[vpn0] = ((paddr / PAGE_SIZE) << 10) | flags | PAGE_V; // VPN[0]번째 PTE에 PPN 기록
}코드를 단계별로 뜯어보면,
VPN 추출
가상 주소는 [VPN[1]][VPN[0]][offset] 구조다. >> 22로 상위 10비트(VPN[1])를, >> 12로 그다음 10비트(VPN[0])를 추출한다. & 0x3ff는 10비트짜리 마스크(0b11_1111_1111)로, 시프트 후 불필요한 상위 비트를 제거한다.
vaddr >> 22 & 0x3ff → VPN[1] (1단계 테이블 인덱스)
vaddr >> 12 & 0x3ff → VPN[0] (2단계 테이블 인덱스)1단계 PTE 처리
table1[vpn1]이 V=0이면 이 VPN[1] 범위에 대한 2단계 테이블이 아직 없다는 뜻이다. alloc_pages(1)로 4KB를 할당해 2단계 테이블 공간을 만들고, 그 주소를 PPN으로 변환해 1단계 PTE에 기록한다.
물리 주소를 PPN으로 변환하는 방법은 간단하다. 물리 주소는 [PPN][offset] 구조인데, 우리가 만든 페이지는 항상 4KB 정렬이므로 / PAGE_SIZE(= >> 12)로 하위 12비트를 버리면 PPN만 남는 것이 보장된다.
물리 주소 0x80254123
[PPN = 0x80254][offset = 0x123]
/ PAGE_SIZE → PPN = 0x80254이 PPN을 PTE의 [31:10] 필드에 넣으려면 << 10 시프트가 필요하다.
0x80254 << 10 → PTE [31:10]에 올바르게 위치
| PAGE_V → V=1, R=W=X=0 → 포인터 엔트리 (2단계 테이블을 가리킴)2단계 테이블 주소 복원
table1[vpn1] >> 10으로 PTE에서 PPN을 꺼내고, * PAGE_SIZE로 물리 주소를 복원한다. 저장할 때 / PAGE_SIZE로 줄였던 것을 그대로 되돌리는 것이다.
table1[vpn1] >> 10 → PPN 추출
× PAGE_SIZE → 물리 주소 복원 (2단계 테이블이 실제로 있는 곳)
(uint32_t *) → 배열로 사용하기 위해 포인터 캐스팅2단계 PTE에 실제 매핑 기록
table0[vpn0]가 진짜 목적이다. 여기에 paddr의 PPN과 권한 플래그를 기록하면, MMU가 VPN[0]으로 조회했을 때 올바른 물리 페이지를 찾을 수 있다.
paddr / PAGE_SIZE → 물리 페이지의 PPN
<< 10 | flags | PAGE_V → 리프 PTE 완성 (R/W/X 중 하나 이상이 1)map_page가 하는 일은 결국 이것이다. offset은 MMU가 변환 없이 그대로 사용하므로 map_page에서 다룰 필요가 없다. 물리 주소 vs 물리 페이지 번호 혼동이 페이지 테이블에서 가장 흔한 버그 원인이니 주의가 필요하다.
커널 메모리 영역 매핑 (Identity Mapping)
왜 커널도 매핑해야 하는가
페이징을 켜는 순간(csrw satp 실행 직후), CPU는 다음 명령어를 가져올 때도 MMU를 통해 가상 주소를 번역한다. 만약 현재 실행 중인 커널 코드가 페이지 테이블에 없다면, csrw satp 직후 즉시 Instruction Page Fault가 발생한다.
그래서 커널 영역은 가상 주소 == 물리 주소인 identity mapping으로 설정한다. 페이징을 켜기 전과 후에 동일한 주소로 코드가 실행되므로 끊김이 없다.
링커 스크립트 수정
kernel.ld
ENTRY(boot)
SECTIONS {
. = 0x80200000;
ENTRY(boot)
SECTIONS {
. = 0x80200000;
__kernel_base = .;
링커 스크립트에서 심볼에 __kernel_base를 . = 0x80200000 뒤에 정의해야 하는 이유.(위치 카운터)를 할당하면, 그 시점의 위치 카운터 값이 심볼에 저장된다. . = 0x80200000 줄이 먼저 실행되어 위치 카운터를 0x80200000으로 설정한 뒤에 __kernel_base = .가 실행되어야 __kernel_base가 0x80200000이 된다. 순서가 바뀌면 위치 카운터가 아직 0이므로 __kernel_base = 0이 되어버린다.
struct process에 page_table 추가
kernel.h
struct process {
int pid;
int state;
vaddr_t sp;
uint8_t stack[8192];
};
struct process {
int pid;
int state;
vaddr_t sp;
uint32_t *page_table;
uint8_t stack[8192];
};
create_process에서 커널 페이지 매핑
kernel.c
proc->pid = i + 1;
proc->state = PROC_RUNNABLE;
proc->sp = (uint32_t) sp;
return proc;
uint32_t *page_table = (uint32_t *) alloc_pages(1);
for (paddr_t paddr = (paddr_t) __kernel_base; paddr < (paddr_t) __free_ram_end; paddr += PAGE_SIZE)
map_page(page_table, paddr, paddr, PAGE_R | PAGE_W | PAGE_X);
proc->pid = i + 1;
proc->state = PROC_RUNNABLE;
proc->sp = (uint32_t) sp;
proc->page_table = page_table;
return proc;
__kernel_base부터 __free_ram_end까지 커버한다. 이렇게 하면 .text같은 정적 영역뿐만 아니라 alloc_pages로 동적 할당된 영역(페이지 테이블 자체 포함)도 커널이 접근할 수 있다.
컨텍스트 스위칭 시 페이지 테이블 전환
프로세스를 바꿀 때 페이지 테이블도 함께 교체해야 한다.
kernel.c
void yield(void) {
// 생략
__asm__ __volatile__(
"csrw sscratch, %[sscratch]\n"
:
: [sscratch] "r" ((uint32_t) &next->stack[sizeof(next->stack)])
);
// 컨텍스트 스위칭
struct process *prev = current_proc;
current_proc = next;
switch_context(&prev->sp, &next->sp);
}
void yield(void) {
// 생략
__asm__ __volatile__(
"sfence.vma\n"
"csrw satp, %[satp]\n"
"sfence.vma\n"
"csrw sscratch, %[sscratch]\n"
:
// 끝에 꼭 콤마가 있어야 함!
: [satp] "r" (SATP_SV32 | ((uint32_t) next->page_table / PAGE_SIZE)),
[sscratch] "r" ((uint32_t) &next->stack[sizeof(next->stack)])
);
// 컨텍스트 스위칭
struct process *prev = current_proc;
current_proc = next;
switch_context(&prev->sp, &next->sp);
}
satp에는 물리 주소가 아니라 page_table / PAGE_SIZE로 구한 물리 페이지 번호를 SATP_SV32와 OR해서 넣는다. sfence.vma는 TLBTranslation Lookaside Buffer. 최근 가상→물리 주소 변환 결과를 캐싱하는 하드웨어. satp를 바꿔도 TLB가 이전 매핑을 들고 있으면 잘못된 주소로 접근하게 된다.를 flush하는 명령어로, satp 변경 전후에 반드시 삽입한다.
실행해보기
./run.sh
starting process A
Astarting process B
BABABABABABABABABABABABABAB...출력은 이전 글와 완전히 동일하다. 페이지 테이블이 제대로 설정되었는지는 QEMU 모니터에서 확인할 수 있다.
(qemu) info mem
vaddr paddr size attr
-------- ---------------- -------- -------
80200000 0000000080200000 00001000 rwx--ad
80201000 0000000080201000 00001000 rwx----
80202000 0000000080202000 00001000 rwx--a-
80203000 0000000080203000 00001000 rwx----
80204000 0000000080204000 00001000 rwx--ad
80205000 0000000080205000 00001000 rwx----
80206000 0000000080206000 00001000 rwx--ad
80207000 0000000080207000 00009000 rwx----
80210000 0000000080210000 00001000 rwx--ad
80211000 0000000080211000 0001f000 rwx----
80230000 0000000080230000 00001000 rwx--ad
80231000 0000000080231000 04000000 rwx----
...vaddr == paddr로 identity mapping이 되어 있고, attr에 r, w, x가 설정된 것을 확인할 수 있다.
매핑은 있는데 즉시 Page Fault가 날 때페이징 디버깅 팁
info mem에 아무것도 안 나올 때satp에 SATP_SV32 비트를 빠뜨렸을 가능성이 크다. (qemu) info mem이 No translation or protection을 출력하면 페이징 자체가 꺼져 있는 상태다.satp에 물리 페이지 번호 대신 물리 주소를 그대로 넣은 경우다. PAGE_SIZE로 나누는 것을 빠뜨리면 satp가 가리키는 테이블 주소가 32비트 범위를 훌쩍 넘어버려서 매핑이 전부 깨진다.-d unimp,guest_errors,int,cpu_reset -D qemu.log 옵션을 run.sh에 추가하면 QEMU 로그에서 어떤 주소에서 어떤 예외가 발생했는지 확인할 수 있다.__kernel_base 값이 0으로 나올 때
링커 스크립트에서 . = 0x80200000 앞에 __kernel_base = .를 정의한 경우다. 순서를 바꿔야 한다.