
https://operating-system-in-1000-lines.vercel.app/ko/11-page-table
페이지 테이블 | OS in 1,000 Lines
operating-system-in-1000-lines.vercel.app
프로그램은 자신에게 할당된 메모리 자원이 있다. 이 메모리는 실제 물리적 메모리가 아닌, 가상의 메모리 자원이다. 그럼 왜 가상메모리를 사용하는 이유와, 어떻게 사용되는지에 대해 알아보자.
먼저, 가상메모리를 사용하는 이유는 다음과 같다. (with Gemini)
1. 프로세스 간의 완벽한 격리 (보안과 안정성)
가상 메모리가 없다면 모든 프로세스가 실제 물리 메모리(RAM) 주소를 직접 사용해야 해.
- 문제: 프로세스 A가 코딩 실수로 엉뚱한 주소에 값을 쓰면, 옆에 있던 프로세스 B의 데이터나 심지어 커널의 코드를 덮어씌울 수 있어. 이건 곧 시스템 전체의 붕괴로 이어지지.
- 해결: 가상 메모리를 쓰면 각 프로세스는 자신만의 독립된 주소 공간을 가져. 프로세스 A가 자기 세상의 0x1000번지를 아무리 수정해도, 실제 RAM에서는 A에게 할당된 구역만 바뀌므로 다른 프로세스는 절대 건드릴 수 없어.
2. 메모리 파편화(Fragmentation) 해결
프로그램을 실행하고 종료하다 보면 RAM 곳곳에 빈 공간이 생겨. 이를 파편화라고 해.
- 문제: RAM에 총 100MB의 여유가 있어도, 10MB씩 10군데로 흩어져 있다면 연속된 50MB짜리 프로그램을 실행할 수 없게 돼.
- 해결: 가상 메모리는 '흩어진 물리 메모리를 연속된 것처럼' 보여줘. 페이지 테이블이 흩어진 10MB 조각들을 가상 주소상에서는 0x0000부터 0x5000까지 쭉 이어진 것처럼 매핑해주기 때문에, 큰 프로그램도 제약 없이 실행할 수 있어.
3. 실제 RAM보다 큰 메모리 사용 (스와핑)
가상 메모리라는 이름 그대로, 물리적인 RAM의 용량에 얽매이지 않게 해줘.
- 원리: 지금 당장 쓰지 않는 메모리 페이지는 하드디스크나 SSD(Swap 영역)로 잠시 옮겨두고, 필요할 때만 다시 RAM으로 불러와.
- 효과: 사용자는 8GB RAM을 가진 컴퓨터에서도 16GB, 32GB짜리 거대한 프로그램을 돌릴 수 있게 되는 거지. (물론 속도는 조금 느려지겠지만!)
4. 프로그래밍의 단순화
개발자가 코드를 짤 때 "내 프로그램이 실제 RAM의 몇 번지에 올라갈까?"를 고민할 필요가 없어. 모든 프로그램은 항상 같은 주소(예: 0x80000000)에서 시작한다고 가정하고 빌드하면 돼. 실제 배치는 운영체제가 페이지 테이블로 알아서 처리해주니까.
결국, 메모리 영역의 보호, 메모리 파편화 해결, 더 큰 메모리 사용, 마지막으로 프로그래밍의 단순화라는 장점이 있기 때문에 가상메모리를 사용한다. 그럼 어떤 원리로 가상메모리가 사용되는지도 알아보자.
1단계: 페이지 테이블(장부) 만들기
커널은 먼저 메모리 어딘가에 가상 주소와 물리 주소를 짝지어놓은 리스트를 만들어. 이걸 페이지 테이블(Page Table)이라고 불러.
- 페이지(Page): 가상 메모리를 나누는 단위 (보통 4KB).
- 프레임(Frame): 물리 메모리를 나누는 단위 (똑같이 4KB).
- PTE (Page Table Entry): 장부의 한 줄이야. 여기에는 "가상 주소 몇 번은 물리 주소 몇 번이다"라는 정보와 함께, "이 구역은 읽기 전용이다" 혹은 "사용자 모드에서 접근 가능하다" 같은 권한 설정 비트가 들어있어.
2단계: 번역기(MMU) 활성화
장부를 다 만들었다면, 이제 CPU에게 "지금부터 이 장부를 보고 주소를 번역해라!"라고 명령을 내려야 해.
- satp 레지스터 (RISC-V 기준): 여기에 우리가 만든 페이지 테이블의 물리 주소를 써넣어.
- 활성화: 이 레지스터에 값을 넣는 순간 CPU 내부의 MMU(Memory Management Unit)라는 하드웨어 번역기가 가동돼.
- 번역 과정: 이제 프로그램이 0x1000 주소에 접근하려고 하면, MMU가 자동으로 satp가 가리키는 장부를 뒤져서 실제 RAM 주소인 0x80001000으로 화살표를 돌려버리는 거지.
3단계: 다단계 페이지 테이블 (공간 절약의 기술)
그런데 4GB 전체를 1대1로 기록하려면 장부 자체가 너무 커지는 문제가 생겨. 그래서 RISC-V 같은 현대적인 CPU는 '다단계 페이지 테이블' 방식을 써.
- 원리: 마치 책의 '목차'와 같은 구조야.
- 1단계 테이블(목차)을 보고 대략적인 범위를 찾은 뒤, 2단계 테이블(상세 내용)로 가서 정확한 물리 주소를 찾는 방식이지.
- 이렇게 하면 실제로 사용하지 않는 가상 주소 범위에 대해서는 상세 장부(2단계)를 만들지 않아도 되어서 메모리를 엄청나게 아낄 수 있어.
전체적인 작동 흐름 (요약)
- 커널: 빈 메모리에 페이지 테이블을 생성하고 매핑 정보를 채운다.
- 커널: satp 레지스터에 페이지 테이블의 주소를 설정한다.
- CPU(MMU): 프로그램이 내는 모든 가상 주소를 가로채서 페이지 테이블을 뒤진다.
- CPU(MMU): 실제 물리 주소를 찾아내어 RAM에 데이터를 읽거나 쓴다.
- 예외 발생: 만약 장부에 없는 주소거나 권한이 없는 행동(읽기 전용에 쓰기 등)을 하면, MMU가 Page Fault 예외를 일으켜 커널(handle_trap)에게 알린다.
결론적으로 가상 메모리는 커널이 만든 지도(Page Table) 위에서 하드웨어 번역기(MMU)가 실시간으로 길을 찾아주는 원리로 작동해.
가이드에서는 RSIC-V의 페이징 메커니즘 중 Sv32 를 사용한다. 2단계의 페이지 테이블로 구성된 방식을 사용하는데, 32비트 가상 주소는 1단계 페이지 테이블 인덱스( VPN[1] ), 2단계 인덱스( VPN[0] ), 그리고 페이지 오프셋 ( offset ) 으로 나뉜다.
아래 링크에서는 가상주소가 VPN[1], VPN[0], 그리고 offset 이 어떻게 구성되는지 계산해주는 app이다.
https://riscv-sv32-virtual-address.vercel.app/
v0 App
riscv-sv32-virtual-address.vercel.app
그리고 RISC-V 의 가상 메모리에 관해 설명된 좋은 블로그 링크이다.
https://medium.com/@viveksgt/understanding-risc-v-virtual-memory-part-0-0bc2ce3318cf
자, 그럼 Sv32 에 대해 좀 더 자세히 살펴보자.
먼저, Sv32 의 핵심 구조는 두 단계로 나뉘어진 페이지 테이블이다.
- L1 (root) 페이지 테이블은 가상 주소 공간을 4MB 단위로 관리한다.
- L0 페이지 테이블은 4KB 단위로 매핑하여 관리한다.
두 테이블의 개념은 MMU(Memory Management Unit)에 의해 아래와 같이 세 부분으로 해석된다.
- VPN[1] (10bits): L1 페이지 테이블에서 몇 번째 항목을 볼지 결정하는 인덱스
- VPN[0] (10bits): L0 페이지 테이블에서 몇 번째 항목을 볼지 결정하는 인덱스
- Page Offset (12bits): 최종적으로 찾은 4KB 페이지 안에서 정확히 몇 번째 바이트에 있는지 나타냄

그럼 페이지 테이블이 어떻게 구성되어있는지 아래 표로 확인해보자.
| Virtual Address | VPN[1] (10 bits) | VPN[0] (10 bits) | Offset (12 bits) |
| 0x1000_0000 | 0x040 | 0x000 | 0x000 |
| 0x1000_1000 | 0x040 | 0x001 | 0x000 |
| 0x1000_f000 | 0x040 | 0x00f | 0x000 |
| 0x2000_f0ab | 0x080 | 0x00f | 0x0ab |
| 0x2000_f012 | 0x080 | 0x00f | 0x012 |
| 0x2000_f034 | 0x080 | 0x00f | 0x034 |
| 0x20f0_f034 | 0x083 | 0x30f | 0x034 |
위 표에서 가상메모리 0x1000_0000 의 물리주소를 계산하는 과정은 다음과 같다.
먼저 CPU L1 (root) 페이지 테이블을 검색한 후, 0x40 (십진수 64) 번째 항목 PTE를 읽는다.
(PTE란, Page Table Entry의 약자로 페이지 테이블의 한 줄 (항목) 을 의미)
이 항목에는 L0 페이지 테이블의 시작 물리주소가 저장되어 있다. CPU는 L0 페이지의 시작 물리 주소에 접근한다. 예를 들어, 그 주소가 0x80100 이라고 가정하면, CPU 는 해당 주소로 접근하게 된다.
접근한 0x80100의 L0 페이지 테이블로 접근한 뒤, VPN[0] 의 값 0x000 번째 항목 PTE를 읽는다.여기에 실제 데이터가 담긴 물리 페이지의 정보가 들어있다. 만약 이 주소가 0x80200 이라고 한다면, CPU는 다음과 같이 계산한다.
최종 물리 주소 = 0x80200 + 0x000 (offset) = 0x80200000
(여기서 offset은 주소값에서 더해지는게 아니라, 붙여지는 개념이다)
그럼 이제 페이지 테이블부터 구성해보자.
// kernel.h
#define SATP_SV32 (1u << 31) // satp 레지스터에서 "Sv32 모드 페이지 활성화" 비트
#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 레지스터에 대한 정보는 다음과 같다.

이제 RISC-V Sv32 시스템에서 가상 주소와 물리 주소를 연결해주는 페이지 매핑(page mapping) 로직을 구현해보자.
kernel.c
void map_page(uint32_t *table1, uint32_t vaddr, paddr_t paddr, uint32_t flags)
{
// 가상 메모리와 물리 메모리는 4KB(PAGE_SIZE) 로 관리
// 주소의 하위 12비트는 Offset으로 사용, 매핑할 주소의 하위 12비트가 0이 아니면 오작동
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; // 상위 10비트 추출, 1단계 테이블 인덱스로 사용
if((table[vpn1] & PAGE_V) == 0) // 2단계 테이블이 아직 없음
{
uint32_t pt_paddr = alloc_pages(1); // 2단계 테이블로 쓸 새로운 페이지 할당
table1[vpn1] = ((pt_paddr / PAGE_SIZE) << 10) | PAGE_V; // 새로운 2단계 페이지의 주소를 1단계 테이블에 등록
// 주소를 PAGE_SIZE로 나누는 이유는 PTE에 주소 전체가 아닌 PPN (페이지 번호)를 넣어야 하기 때문
}
// 2단계 페이지 테이블 엔트리에 물리 페이지 번호와 플래그 설정
uint32_t vpn0 = (vaddr >> 12) & 0x3FF;
uint32_t *table0 = (uint32_t *)((table1[vpn1] >> 10) * PAGE_SIZE); // 2단계 테이블 주소 계산
table0[vpn0] = ((paddr / PAGE_SIZE) << 10) | flags | PAGE_V; // 최종 물리 주소 매핑
}
만약 2단계 테이블이 없을 경우, 새로운 메모리를 할당받아 테이블을 생성한다. 그리고 그 주소를 1단계 테이블에 등록하는데, 이때 주소를 PAGE_SIZE로 나누는 이유는 PTE에 주소 전체가 아닌 Physical Page Number(PPN) 을 넣기 때문이다.
물리 주소에서 의미 없는 하위 12비트(0들)를 떼어내고 남은 상위 20비트를 PPN 이라고 부른다.
(참고로 pt_paddr / PAGE_SIZE 는 pa_paddr >> 12 와 동일)
- 물리주소: 0x80201000
- PPN: 0x80201 (뒤에 000 을 버림)
이 PPN만 저장하면 PTE 20bits 공간 중에서 20bits만 쓰고도 물리 주소를 가리킬 수 있게 된다. 그럼 남은 12bits 공간에 우리가 원하는 PAGE_V, PAGE_R, PAGE_W 와 같은 플래그들을 넣을 수 있다.
다음은 VPN[0] 추출 및 최종 물리 주소 매핑이다. vpn0는 가상 메모리 주소의 중간 10비트를 추출해 2단계 테이블의 인덱스로 사용한다. 그리고 table0는 1단계 테이블에 저장해뒀던 PPN을 다시 주소로 복원 (* PAGE_SIZE) 해서 2단계 테이블의 시작 위치를 찾아간다.
마지막으로 최종 매핑 단계에서는 2단계 테이블의 해당 칸에 연결하고 싶은 실제 물리 주소(paddr)의 PPN과 flags, 그리고 유효하하다는 표시 PAGE_V 를 같이 써넣는다.
커널 메모리 영역을 매핑하기 위해 먼저 커널 링커 스크립트를 수정한다.
// kernel.ld
ENTRY(boot)
SECTIONS {
. = 0x80200000;
__kernel_base = .;
그 다음, 프로세스 구조체에 테이블을 가리키는 포인터를 추가하는데, 이 포인터가 1단계 페이지 테이블을 가리킨다.
// kernel.h
struct process {
int pid;
int state;
vaddr_t sp;
uint32_t *page_table; // 새롭게 추가
uint8_t stack[8192];
};
프로세스를 새롭게 만드는 create_process 함수에서 커널 페이지들을 매핑해준다.
// kernel.c
struct process* create_process(uint32_t pc){
// 생략
// Map 커널 페이지
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 = tapge_table; // 추가
return proc;
}
이제 문맥 교환 (context switching) 시에 프로세스의 페이지 테이블을 스위칭한다.
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)])
);
switch_context(&prev->sp, &next->sp);
}
먼저 satp 레지스터에 1단계 페이지 테이블 주소를 지정하면 페이지 테이블을 전환할 수 있다. SATP_SV32 값은 보통 0x80000000과 같은 상수로, "이제부터 Sv32 가상 메모리 모드를 켜겠다" 라고 알리는 신호다.
그리고 satp에는 페이지 테이블의 전체 주소가 아니라 PPN을 넣어야하기 때문에, PAGE_SIZE로 나눈 값을 전달한다.
결국, 명령어 "csrw satp, %[satp]\n" 가 실행되면 CPU가 바라보는 메모리 지도(page table)이 새로운 프로세스의 지도로 바뀌게 된다.
sfence.vma 명령어는 지금까지 쌓인 TLB 캐시를 모두 지우는 명령어이다. 페이지 테이블을 교체할 때 반드시 이 과정을 거쳐야 시스템이 꼬이지 않는다.
마지막으로 sscratch 설정 , "csrw sscratch, %[sscratch]\n" 은 트랩(예외) 처리를 위한 준비 작업이다. 이 프로세스가 실행되다가 시스템 콜이나 인터럽트가 발생해서 커널 엔트리(kernel_entry, 정의한 예외처리)로 들어오면, csrrw sp, sscratch, sp 명령어를 통해 이 값을 꺼내서 커널 스택으로 즉시 전환하게 된다.
./run.sh 로 실행해보자.

아래는 페이지 테이블의 내용을 확인하는 내용이다.
페이지 테이블 내용 확인하기
우선 0x80000000 주변 가상 주소들이 어떻게 매핑되는지 살펴봅시다. 잘 설정됐다면 (가상 주소) == (물리 주소) 형태로 매핑되어 있어야 합니다.
QEMU 8.0.2 monitor - type 'help' for more information
(qemu) stop
(qemu) info registers
...
satp 80080253
...
satp 레지스터 값이 0x80080253 으로 설정된 것을 볼 수 있습니다. RISC-V의 Sv32 사양에 따르면, (0x80080253 & 0x3fffff) * 4096 = 0x80253000 이 값이 1단계 페이지 테이블의 물리 시작 주소임을 의미합니다.
이제 1단계 페이지 테이블 내용을 확인해봅시다. 0x80000000 가상 주소에 해당하는 1단계 인덱스는 0x80000000 >> 22 = 512이므로, 1단계 테이블의 512번째 엔트리를 보고 싶습니다. 각 엔트리는 4바이트이므로, 512 * 4를 더한 위치를 살펴봅니다:
(qemu) xp /x 0x80253000+512*4
0000000080253800: 0x20095001
첫 번째 열은 물리 주소를 표시하고, 이후로 16진수 값이 나옵니다. (/x 옵션은 16진수 표시를 의미). /1024x 같은 식으로 개수를 지정하면 더 많은 엔트리를 덤프할 수 있습니다.
TIP
x 명령어(예: x /x 0x...)는 가상 주소 기준으로 메모리를 확인하는 명령어이고, xp 명령어는 물리 주소 기준으로 확인하는 명령어입니다. 커널 공간은 가상 주소와 물리 주소가 동일하지만, 사용자 공간처럼 가상 주소와 물리 주소가 다른 곳을 볼 땐 xp를 사용해야 올바르게 확인할 수 있습니다.
스펙에 따르면, 2단계 페이지 테이블은 (0x20095001 >> 10) * 4096 = 0x80254000 에 위치합니다. 2단계 테이블(1024개 엔트리)을 전부 덤프해봅시다:
(qemu) xp /1024x 0x80254000
0000000080254000: 0x00000000 0x00000000 0x00000000 0x00000000
0000000080254010: 0x00000000 0x00000000 0x00000000 0x00000000
0000000080254020: 0x00000000 0x00000000 0x00000000 0x00000000
0000000080254030: 0x00000000 0x00000000 0x00000000 0x00000000
...
00000000802547f0: 0x00000000 0x00000000 0x00000000 0x00000000
0000000080254800: 0x2008004f 0x2008040f 0x2008080f 0x20080c0f
0000000080254810: 0x2008100f 0x2008140f 0x2008180f 0x20081c0f
0000000080254820: 0x2008200f 0x2008240f 0x2008280f 0x20082c0f
0000000080254830: 0x2008300f 0x2008340f 0x2008380f 0x20083c0f
0000000080254840: 0x200840cf 0x2008440f 0x2008484f 0x20084c0f
0000000080254850: 0x200850cf 0x2008540f 0x200858cf 0x20085c0f
0000000080254860: 0x2008600f 0x2008640f 0x2008680f 0x20086c0f
0000000080254870: 0x2008700f 0x2008740f 0x2008780f 0x20087c0f
0000000080254880: 0x200880cf 0x2008840f 0x2008880f 0x20088c0f
...
처음 부분은 전부 0으로 채워져 있고, 512번째 엔트리(254800 부근)부터 값이 채워져 있음을 볼 수 있습니다. 이는 __kernel_base가 0x80200000 이고, VPN[1]이 0x200 부근이기 때문입니다.
직접 메모리 덤프를 보는 대신, QEMU에는 현재 페이지 테이블 매핑 정보를 사람이 보기 좋게 출력해주는 info mem 명령어가 있습니다. 매핑이 제대로 되었는지 최종적으로 확인하고 싶다면 이 명령어를 이용하면 됩니다.
(qemu) info mem
vaddr paddr size attr
-------- ---------------- -------- -------
80200000 0000000080200000 00001000 rwx--a-
80201000 0000000080201000 0000f000 rwx----
80210000 0000000080210000 00001000 rwx--ad
80211000 0000000080211000 00001000 rwx----
80212000 0000000080212000 00001000 rwx--a-
80213000 0000000080213000 00001000 rwx----
80214000 0000000080214000 00001000 rwx--ad
80215000 0000000080215000 00001000 rwx----
80216000 0000000080216000 00001000 rwx--ad
80217000 0000000080217000 00009000 rwx----
80220000 0000000080220000 00001000 rwx--ad
80221000 0000000080221000 0001f000 rwx----
80240000 0000000080240000 00001000 rwx--ad
80241000 0000000080241000 001bf000 rwx----
80400000 0000000080400000 00400000 rwx----
80800000 0000000080800000 00400000 rwx----
80c00000 0000000080c00000 00400000 rwx----
81000000 0000000081000000 00400000 rwx----
81400000 0000000081400000 00400000 rwx----
81800000 0000000081800000 00400000 rwx----
81c00000 0000000081c00000 00400000 rwx----
82000000 0000000082000000 00400000 rwx----
82400000 0000000082400000 00400000 rwx----
82800000 0000000082800000 00400000 rwx----
82c00000 0000000082c00000 00400000 rwx----
83000000 0000000083000000 00400000 rwx----
83400000 0000000083400000 00400000 rwx----
83800000 0000000083800000 00400000 rwx----
83c00000 0000000083c00000 00400000 rwx----
84000000 0000000084000000 00241000 rwx----
표의 각 열은 가상 주소, 물리 주소, 크기(16진수), 그리고 속성 정보를 나타냅니다.
속성은 r(읽기 가능), w(쓰기 가능), x(실행 가능), a(accessed: CPU가 페이지를 읽거나 실행함), d(dirty: CPU가 페이지에 기록함)로 구성됩니다. a와 d는 OS가 “이 페이지가 실제로 사용되었는지(읽혔는지), 쓰여졌는지” 추적할 때 유용합니다.
TIP
처음 접하는 사람에게는 페이지 테이블 디버깅이 쉽지 않습니다. 원하는 대로 동작하지 않으면, 아래 "부록: 페이징 디버깅" 섹션을 참고해보세요.
부록: 페이징 디버깅
페이지 테이블 설정은 실수하기 쉽고, 에러가 잘 드러나지 않을 때가 많습니다. 여기서는 흔히 발생하는 페이징 오류와, 이를 디버깅하는 방법을 살펴봅니다.
페이징 모드 비트 설정을 빼먹었을 때
예를 들어, satp 레지스터에 모드를 설정하는 것을 깜빡했다고 합시다:
// kernel.c
__asm__ __volatile__(
"sfence.vma\n"
"csrw satp, %[satp]\n"
"sfence.vma\n"
:
: [satp] "r" (((uint32_t) next->page_table / PAGE_SIZE)) // Missing SATP_SV32!
);
이 경우, OS를 실행해도 전과 달라진 점 없이 정상 작동하는 것처럼 보입니다. 실제로는 페이징이 꺼져 있어서 모든 주소가 여전히 물리 주소처럼 동작하기 때문입니다.
이를 디버깅하기 위해 QEMU 모니터에서 info mem 명령어를 써보면:
(qemu) info mem
No translation or protection
처럼 전혀 페이지 테이블 매핑 정보가 나오지 않습니다.
물리 페이지 번호 대신 물리 주소를 지정했을 때
이번엔 다음처럼 페이지 테이블을 설정할 때 "물리 주소"를 그대로 써버려야 하는데, 실수로 "물리 페이지 번호" 대신 물리 주소를 그대로 써버렸다고 가정합시다:
// kernel.c
__asm__ __volatile__(
"sfence.vma\n"
"csrw satp, %[satp]\n"
"sfence.vma\n"
:
: [satp] "r" (SATP_SV32 | ((uint32_t) next->page_table)) // Forgot to shift!
);
이 경우 info mem 명령어를 써보면 매핑이 전혀 잡히지 않은 것을 볼 수 있습니다:
$ ./run.sh
QEMU 8.0.2 monitor - type 'help' for more information
(qemu) stop
(qemu) info mem
vaddr paddr size attr
-------- ---------------- -------- -------
디버깅을 위해 레지스터도 살펴봅시다:
(qemu) info registers
CPU#0
V = 0
pc 80200188
...
scause 0000000c
...
pc가 0x80200188이고, scause가 0000000c (Instruction page fault)이라는 것을 확인할 수 있습니다. llvm-addr2line으로 0x80200188의 위치를 확인해 보면 예외 핸들러의 시작점임을 알 수 있습니다.
QEMU 로그(-d unimp,guest_errors,int,cpu_reset -D qemu.log)를 열어보면:
run.sh
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
-d unimp,guest_errors,int,cpu_reset -D qemu.log \ # new!
-kernel kernel.elf
Invalid read at addr 0x253000800, size 4, region '(null)', reason: rejected
riscv_cpu_do_interrupt: hart:0, async:0, cause:0000000c, epc:0x80200580, tval:0x80200580, desc=exec_page_fault
Invalid read at addr 0x253000800, size 4, region '(null)', reason: rejected
riscv_cpu_do_interrupt: hart:0, async:0, cause:0000000c, epc:0x80200188, tval:0x80200188, desc=exec_page_fault
Invalid read at addr 0x253000800, size 4, region '(null)', reason: rejected
riscv_cpu_do_interrupt: hart:0, async:0, cause:0000000c, epc:0x80200188, tval:0x80200188, desc=exec_page_fault
이 로그로부터 몇 가지를 알 수 있습니다:
- epc(예외 발생 PC)가 0x80200580인 점을 보면, satp를 설정하자마자 곧바로 페이지 폴트가 발생했다는 것을 의미합니다.
- 이후 예외(page fault)가 0x80200188에서 반복적으로 일어나는데, 이 위치는 예외 핸들러의 시작점입니다. 결국 예외를 처리하려고 해도, 예외 핸들러 코드조차 페이지 폴트가 발생하는 악순환이 이어집니다.
- info registers에서 satp가 0x80253000 같은 값으로 설정되었고, Sv32에 따르면 (0x80253000 & 0x3fffff) * 4096 = 0x253000000처럼 주소가 32비트 범위를 초과해버리는 비정상적인 값이 되어 매핑 자체가 엉켜 버렸습니다.
정리하자면, QEMU 로그나 레지스터 덤프, 메모리 덤프 등을 통해 무엇이 문제인지 추적할 수 있지만, 결국 가장 중요한 것은 "스펙을 꼼꼼히 읽고 정확히 구현하는 것" 입니다. 페이징에서는 사소한 실수로도 큰 오류가 발생하기 쉽습니다.
'os' 카테고리의 다른 글
| OS in 1,000 Lines #16 (User mode) (0) | 2026.01.01 |
|---|---|
| OS in 1,000 Lines #15 (Application) (0) | 2026.01.01 |
| OS in 1,000 Lines #13 (스케쥴러) (0) | 2025.12.29 |
| OS in 1,000 Lines #12 (문맥교환) (0) | 2025.12.29 |
| OS in 1,000 Lines #11 (메모리 할당) (0) | 2025.12.29 |