오랫만에 작성하는 글이다. 이전 글을 봤더니 핀토스를 이겨낸다고 썼던데 지금 와서 돌아보면 진거 같다.
기억나는것과 커밋 기록을 더듬어 적어보려고 한다.
PintoS 프로젝트 여정 요약
크래프톤 정글에서 약 5주간(2025.11.06 ~ 2025.12.10) PintoS(KAIST 64-bit) 프로젝트를 진행했다. 운영체제라는 것을 밑바닥부터 만져본 건 처음이었고, 솔직히 5주 내내 '내가 이걸 할 수 있나' 싶었다. 근데 어찌저찌 해냈다. 그 과정을 여기에 남긴다.
타임라인 개요
| 기간 | 단계 | 핵심 키워드 |
|---|---|---|
| 11.06 ~ 11.12 | P1: Threads | Alarm Clock, Priority Scheduling, Donation, MLFQS |
| 11.13 ~ 11.24 | P2: User Programs | ELF 로딩, Argument Passing, System Call, 유저/커널 모드 |
| 11.25 ~ 12.10 | P3: Virtual Memory | Page Fault, Lazy Loading, Stack Growth, Memory Mapped Files |
Phase 1: Threads (스레드)
공부한 내용
Pintos의 기존 스레드 시스템을 뜯어보는 것부터 시작했다. 생성, 완료, 스케줄러, 동기화 기본 요소 — 전부 코드로 읽었다. 바쁜 대기(Busy Waiting)가 왜 나쁜지, 우선순위 스케줄링은 왜 필요한지, 우선순위 기부(Priority Donation)는 또 뭔지. 그리고 4.4BSD MLFQS 이론과 고정소수점 연산까지.
처음엔 이게 다 뭔 소린가 싶었는데, 코드를 직접 치다 보니까 이해가 됐다. 역시 손이 먼저 움직여야 머리가 따라온다.
구현 과정
1) Alarm Clock — Busy Wait 제거
원래 timer_sleep()은 while 루프 안에서 thread_yield()를 미친 듯이 호출하며 CPU를 갈아넣고 있었다.
이걸 sleep_list를 만들어서 wakeup_tick 기준으로 정렬해놓고, thread_block()으로 재워버리는 방식으로 바꿨다.
📂
devices/timer.c
// timer_sleep: ticks만큼 재우는 함수 (timer.c:100)
void timer_sleep(int64_t ticks) {
int64_t start = timer_ticks();
int64_t wake_up_time = start + ticks;
ASSERT(intr_get_level() == INTR_ON);
enum intr_level old_level = intr_disable();
struct thread *cur = thread_current();
cur->wakeup_tick = wake_up_time;
list_insert_ordered(&sleep_list, &cur->elem, wakeup_tick_less, NULL);
thread_block();
intr_set_level(old_level);
}그리고 timer_interrupt()가 호출될 때마다 check_wakeup()으로 sleep_list 앞쪽을 확인한다.
시간이 됐으면 깨워주고, 아직이면 그냥 넘어간다. 정렬돼 있으니까 앞에서부터만 보면 된다.
// check_wakeup: 매 틱마다 잠든 스레드 깨우기 (timer.c:116)
static void check_wakeup(void) {
int64_t now = timer_ticks();
while (!list_empty(&sleep_list)) {
struct thread *head = list_entry(list_front(&sleep_list), struct thread, elem);
if (now < head->wakeup_tick)
break;
list_pop_front(&sleep_list);
head->wakeup_tick = 0;
thread_unblock(head);
}
}2) Priority Scheduling — 우선순위 기반 스케줄링 + 기부
라운드 로빈은 공평하긴 한데, 급한 놈이 먼저 가야 할 때가 있다.
ready_list를 우선순위 내림차순으로 정렬하고, 새 스레드가 생기거나 깨어날 때 선점(Preemption)이 일어나도록 했다.
근데 진짜 골치 아픈 건 Priority Donation이었다.
우선순위 역전(Priority Inversion) — 높은 놈이 낮은 놈한테 잡혀서 못 움직이는 상황.
이걸 해결하려면 lock_acquire 시점에 Holder한테 우선순위를 기부해야 한다.
📂
threads/synch.c
// lock_acquire: 기부하고 잠들기 (synch.c:193)
void lock_acquire(struct lock *lock) {
ASSERT(lock != NULL);
ASSERT(!intr_context());
ASSERT(!lock_held_by_current_thread(lock));
struct thread *cur = thread_current();
if (lock->holder != NULL) {
cur->waiting_lock = lock;
int priority = thread_get_priority();
donate_priority(lock->holder, priority);
sema_down(&lock->semaphore);
thread_current()->waiting_lock = NULL;
} else {
sema_down(&lock->semaphore);
}
lock->holder = thread_current();
list_push_back(&thread_current()->locks_hold, &lock->lock_elem);
}중첩 기부(Nested Donation)는 H → M → L 체인으로 전파되는 구조다. holder가 또 다른 락에 막혀있으면 그 holder한테도 재귀적으로 기부해준다.
// donate_priority: 중첩 기부 포함 (synch.c:411)
void donate_priority(struct thread *holder, int priority_to_donate) {
if (holder == NULL || priority_to_donate <= holder->priority)
return;
holder->priority = priority_to_donate;
switch (holder->status) {
case THREAD_READY:
sort_readylist();
preempt_priority();
break;
case THREAD_BLOCKED:
// 웨이터리스트에 속해있으면 holder의 holder에게 다시 기부
if (holder->waiting_lock != NULL) {
struct thread *next_holder = holder->waiting_lock->holder;
if (next_holder != NULL)
donate_priority(next_holder, priority_to_donate);
list_sort(&holder->waiting_lock->semaphore.waiters, priority_greater, NULL);
}
break;
default:
break;
}
}lock_release 때는 기부받은 우선순위를 원래대로 돌려놓고, 아직 들고 있는 다른 락들의 웨이터 중 가장 높은 우선순위로 재조정한다.
// lock_release: 기부 원복 및 잔여 기부값 재계산 (synch.c:248)
void lock_release(struct lock *lock) {
ASSERT(lock != NULL);
ASSERT(lock_held_by_current_thread(lock));
struct thread *cur = thread_current();
list_remove(&lock->lock_elem);
cur->priority = cur->original_priority; // 원래 우선순위로 복구
if (!list_empty(&cur->locks_hold)) {
struct list_elem *max_elem = list_max(&cur->locks_hold, lock_priority_lesser, NULL);
const struct lock *max_lock = list_entry(max_elem, struct lock, lock_elem);
if (!list_empty(&max_lock->semaphore.waiters)) {
const struct thread *max_thread = list_entry(
list_front(&max_lock->semaphore.waiters), struct thread, elem);
donate_priority(cur, max_thread->priority);
}
}
lock->holder = NULL;
sema_up(&lock->semaphore);
}3) MLFQS — 동적 우선순위 스케줄러
고정 우선순위의 문제는 기아 현상(Starvation)이다. 낮은 놈은 영원히 못 돌 수도 있다.
MLFQS는 recent_cpu, load_avg, nice 세 가지 지표로 우선순위를 동적으로 재계산한다.
먼저 고정소수점 연산 매크로부터 만들어야 했다. 정수만 쓰는 Pintos에서 소수점 연산을 하려면 17.14 포맷으로 직접 구현해야 한다.
📂
threads/thread.c
// 고정소수점 연산 매크로 (thread.c:28)
#define FIXED (14)
#define MUL(x, y) ((((int64_t)(x)) * y) >> FIXED)
#define DIV(x, y) ((int64_t)(x) << FIXED) / (y)
#define TOINT(x) (x >= 0) ? ((x + (FIXED >> 1)) >> FIXED) : ((x - (FIXED / 2)) / FIXED)
#define TOINT_ZERO(x) ((x) >> FIXED)
#define TOFIX(x) (x << FIXED)우선순위 재계산 공식은 이렇다: priority = PRI_MAX - (recent_cpu / 4) - (nice * 2)
// cal_priority: 우선순위 재계산 (thread.c:421)
void cal_priority(struct thread *t) {
t->priority = PRI_MAX - TOINT_ZERO(t->recent_cpu / 4) - t->nice * 2;
if (t->priority > PRI_MAX) t->priority = PRI_MAX;
if (t->priority < PRI_MIN) t->priority = PRI_MIN;
}그리고 mlfqs_on_tick()이 타이머 인터럽트마다 호출되면서 모든 걸 관리한다.
매 틱마다 현재 스레드의 recent_cpu를 올리고, 4틱마다 전체 우선순위 재계산, 100틱(1초)마다 load_avg와 recent_cpu 갱신.
// mlfqs_on_tick: 매 틱/4틱/100틱 주기 갱신 (thread.c:462)
void mlfqs_on_tick(void) {
if (thread_current() != idle_thread)
thread_current()->recent_cpu = thread_current()->recent_cpu + TOFIX(1);
if (timer_ticks() % 4 == 0) {
update_priority_all();
list_sort(&ready_list, priority_greater, NULL);
}
if (timer_ticks() % 100 == 0) {
update_load_avg();
update_recent_cpu_all();
}
}// update_load_avg: 1초마다 시스템 부하 갱신 (thread.c:408)
void update_load_avg(void) {
int ready_threads = list_size(&ready_list);
if (thread_current() != idle_thread)
ready_threads += 1;
// load_avg = (59/60) * load_avg + (1/60) * ready_threads
int term1 = MUL(TOFIX(59) / 60, load_avg);
int term2 = TOFIX(1) / 60 * ready_threads;
load_avg = term1 + term2;
}통과 테스트
- Alarm:
alarm-single,alarm-multiple,alarm-simultaneous,alarm-priority,alarm-zero,alarm-negative - Priority:
priority-fifo,priority-preempt,priority-change,priority-sema,priority-condvar - Donation:
donate-one,donate-multiple,donate-multiple2,donate-nest,donate-chain,donate-sema,donate-lower - MLFQS:
mlfqs-load-1,mlfqs-load-60,mlfqs-load-avg,mlfqs-recent-1,mlfqs-fair-2,mlfqs-fair-20,mlfqs-nice-2,mlfqs-nice-10
올패스 뜨는 순간의 쾌감은 진짜 말로 표현이 안 됐다.
Phase 2: User Programs (유저 프로그램)
공부한 내용
코치님 발제(11.13)를 기점으로 OS의 유저 프로그램 실행 메커니즘을 깊이 파고들었다. 여기서부터 난이도가 확 올라갔다. 커널이 유저 프로그램을 어떻게 올리고, 어떻게 실행하고, 어떻게 통제하는지 — 그 전체 흐름을 이해해야 했다.
핵심 학습 주제들
1. 유저 모드 vs 커널 모드
- CPU 권한 수준(Ring 0/3, CPL), 메모리 접근 권한(U/S 비트)
- 실행 스택 분리(유저 스택 ↔ 커널 스택, TSS 자동 전환)
- 모드 전환 조건: syscall/interrupt/exception → 커널, iret → 유저
2. ELF(Executable and Linkable Format)
- ELF Header에서 magic number, entry point(
e_entry), PHDR 정보 읽기 - Program Header Table의
PT_LOAD세그먼트 처리 - 페이지 단위 메모리 매핑 → 파일 읽기 → BSS 영역 제로 채움
- 스택은 ELF와 무관하게 OS가 직접 구성
3. 레지스터와 메모리 구조
- RIP/EIP(실행 위치), RSP/ESP(스택 포인터), RAX(syscall 번호/리턴값)
- CR2(page fault 주소), CR3(페이지 디렉토리 물리 주소)
- 유저 가상 주소 공간 구조, pagedir/pml4 기반 메모리 보호
4. 유저 스택 초기화
- 유저 가상 주소 최상단에서 아래로 성장
- 문자열 push → 문자열 주소 push → argv[] push → argc push → fake return address
- 16-byte alignment 필수 (x86-64)
5. 시스템 콜 메커니즘
- x86-64
syscall명령 사용 (32bitint 0x30과 차이) - ABI: RAX=번호, RDI/RSI/RDX/R10/R8/R9=인자, RAX=리턴값
- CPU가 자동으로 RCX←RIP, R11←RFLAGS 저장 후 커널 진입
- 유저 포인터 검증 필수:
is_user_vaddr()+pml4_get_page(), 실패 시exit(-1)
6. 인터럽트 처리 흐름
- CPU 자동 저장(RIP, CS, RFLAGS, SS, RSP) →
intr_frame구성 - IDT(Interrupt Descriptor Table) 기반 핸들러 점프
- Gate Descriptor의 DPL=3 설정이 유저 모드에서 syscall 가능 조건
iretq로 유저 모드 복귀
7. hlt 명령어
- idle thread에서 CPU 낭비 방지용
- Ring 0에서만 실행 가능,
EFLAGS.IF=1상태에서 외부 인터럽트로 wake-up
구현 과정
Argument Passing
커맨드라인 문자열을 파싱해서 유저 스택에 쌓는 작업이다. 개념은 단순한데, 포인터 연산에서 한 끗 틀리면 바로 세그폴트가 나서 신경을 좀 썼다.
📂
userprog/process.c
// Argument Passing: 커맨드라인 파싱 후 유저 스택 구성 (process.c:549)
char *argv[64];
int argc = 0;
// program_name부터 시작해서 나머지 인자들 파싱
argv[argc++] = program_name;
for (char *token = strtok_r(NULL, " ", &save_ptr); token != NULL;
token = strtok_r(NULL, " ", &save_ptr)) {
if (argc >= 64) break;
argv[argc++] = token;
}
intptr_t cur_rsp = (uintptr_t)if_->rsp;
void *argv_ptr[65];
// 문자열을 스택에 역순으로 push
for (int i = argc - 1; i >= 0; i--) {
size_t cur_len = strlen(argv[i]) + 1;
cur_rsp -= cur_len;
memcpy((void *)cur_rsp, argv[i], cur_len);
argv_ptr[i] = (void *)cur_rsp;
}
argv_ptr[argc] = NULL;
// 8-byte alignment
cur_rsp = cur_rsp & ~0x7;
// 포인터 배열 push
for (int i = argc; i >= 0; i--) {
cur_rsp -= sizeof(void *);
*(void **)cur_rsp = argv_ptr[i];
}
if_->R.rsi = cur_rsp;
// fake return address
cur_rsp -= sizeof(void *);
memset((void *)cur_rsp, 0, sizeof(void *));
if_->rsp = cur_rsp;
if_->R.rdi = argc;System Call Handler
syscall_handler()는 RAX에 들어있는 번호로 switch-case 분기한다.
각 인자는 x86-64 ABI에 따라 RDI, RSI, RDX, R10, R8, R9 순서로 들어온다.
📂
userprog/syscall.c
// syscall_handler: RAX 기반 분기 (syscall.c:61)
void syscall_handler(struct intr_frame *f) {
#ifdef VM
thread_current()->user_rsp = f->rsp;
#endif
switch (f->R.rax) {
case SYS_HALT: s_halt(); break;
case SYS_EXIT: s_exit(f->R.rdi); break;
case SYS_FORK: f->R.rax = s_fork((const char *)f->R.rdi, f); break;
case SYS_EXEC: f->R.rax = s_exec((const char *)f->R.rdi); break;
case SYS_WAIT: f->R.rax = s_wait(f->R.rdi); break;
case SYS_CREATE: f->R.rax = s_create((const char *)f->R.rdi, f->R.rsi); break;
case SYS_REMOVE: f->R.rax = s_remove((const char *)f->R.rdi); break;
case SYS_OPEN: f->R.rax = s_open((const char *)f->R.rdi); break;
case SYS_READ: f->R.rax = s_read(f->R.rdi, (void *)f->R.rsi, f->R.rdx); break;
case SYS_WRITE: f->R.rax = s_write(f->R.rdi, (const void *)f->R.rsi, f->R.rdx); break;
case SYS_MMAP: f->R.rax = (uint64_t)sys_mmap((void *)f->R.rdi, f->R.rsi,
f->R.rdx, f->R.r10, f->R.r8); break;
case SYS_MUNMAP: do_munmap((void *)f->R.rdi); break;
// ... seek, tell, close, dup2 등
default: thread_exit(); break;
}
}유저 메모리 접근 검증
유저가 넘겨주는 포인터를 그냥 믿으면 안 된다. 커널이 죽는다.
s_check_access()로 단일 주소를, s_check_buffer()로 버퍼 범위를 페이지 단위로 검증한다.
// s_check_access: 유저 포인터 검증 (syscall.c:327)
static void s_check_access(const char *file) {
if (file == NULL || !is_user_vaddr(file))
s_exit(-1);
#ifdef VM
void *page_va = pg_round_down((void *)file);
struct page *page = spt_find_page(&thread_current()->spt, page_va);
if (page == NULL) {
if (!vm_claim_page(page_va))
s_exit(-1);
}
#else
if (!pml4_get_page(thread_current()->pml4, file))
s_exit(-1);
#endif
}Phase 3: Virtual Memory (가상 메모리)
여기서부터 진짜 지옥이 시작됐다. Page Fault, Lazy Loading, Stack Growth, Memory Mapped Files — 하나하나가 만만치 않았는데, 이것들이 서로 얽혀서 돌아가야 한다는 게 진짜 문제였다.
핵심 구현
Page Fault Handler
페이지 폴트가 터지면 vm_try_handle_fault()가 호출된다.
SPT에서 페이지를 찾아보고, 있으면 claim해주고, 없으면 스택 확장인지 확인한다.
📂
vm/vm.c
// vm_try_handle_fault: 페이지 폴트 디스패처 (vm.c:343)
bool vm_try_handle_fault(struct intr_frame *f, void *addr, bool user,
bool write, bool not_present) {
struct supplemental_page_table *spt = &thread_current()->spt;
if (is_kernel_vaddr(addr))
return false;
void *page_addr = pg_round_down(addr);
struct page *page = spt_find_page(spt, page_addr);
if (page != NULL) {
if (write && !page->writable)
return false;
return vm_do_claim_page(page);
}
/* 스택 확장 조건 확인 */
uintptr_t stack_ptr = user ? f->rsp : thread_current()->user_rsp;
if ((uintptr_t)addr >= stack_ptr - 8 && (uintptr_t)addr <= USER_STACK &&
(uintptr_t)addr >= USER_STACK - (1 << 20)) {
vm_stack_growth(page_addr);
page = spt_find_page(spt, page_addr);
if (page == NULL) return false;
return vm_do_claim_page(page);
}
return false;
}Memory Mapped Files
파일을 메모리에 매핑하는 do_mmap()은 파일을 file_reopen()으로 복제한 뒤, 페이지 단위로 쪼개서 SPT에 등록한다.
실제 로딩은 나중에 page fault가 터졌을 때 lazy_load_file()이 담당한다.
📂
vm/file.c
// do_mmap: 파일을 메모리에 매핑 (file.c:145)
void *do_mmap(void *addr, size_t length, int writable,
struct file *file, off_t offset) {
lock_acquire(&filesys_lock);
struct file *mfile = file_reopen(file);
off_t f_len = file_length(file);
lock_release(&filesys_lock);
if (mfile == NULL) return NULL;
void *start_addr = addr;
size_t read_bytes = f_len < (off_t)length ? f_len : length;
size_t zero_bytes = (size_t)pg_round_up((void *)length) - read_bytes;
while (read_bytes > 0 || zero_bytes > 0) {
size_t page_read_bytes = read_bytes < PGSIZE ? read_bytes : PGSIZE;
size_t page_zero_bytes = PGSIZE - page_read_bytes;
struct file_page *aux = malloc(sizeof(struct file_page));
aux->file = mfile;
aux->ofs = offset;
aux->read_bytes = page_read_bytes;
aux->zero_bytes = page_zero_bytes;
if (!vm_alloc_page_with_initializer(VM_FILE, addr, writable,
file_backed_initializer, aux)) {
free(aux);
goto error;
}
read_bytes -= page_read_bytes;
zero_bytes -= page_zero_bytes;
addr = (uint8_t *)addr + PGSIZE;
offset += PGSIZE;
}
return start_addr;
// ... error handling 생략
}// lazy_load_file: 실제 페이지 폴트 시 파일 로딩 (file.c:119)
static bool lazy_load_file(struct page *page, struct file_page *aux) {
if (page->frame == NULL) return false;
void *kva = page->frame->kva;
lock_acquire(&filesys_lock);
file_seek(aux->file, aux->ofs);
off_t bytes_read = file_read(aux->file, kva, aux->read_bytes);
lock_release(&filesys_lock);
if (bytes_read != (off_t)aux->read_bytes) return false;
memset(kva + bytes_read, 0, aux->zero_bytes);
return true;
}트러블슈팅
이 단계에서 가장 많은 트러블슈팅을 경험했다. 세그폴트, 무한 루프, assertion 실패 — 매일매일이 전쟁이었다.
트러블슈팅 1: 고아 프레임(Widowed Frame) 문제
증상: mmap된 메모리 접근 시 무한 Page Fault → exit(-1)
원인: vm_do_claim_page()에서 page->frame != NULL이면 이미 로드된 것으로 판단하고 true 반환. 그러나 실제 하드웨어 페이지 테이블(PML4)에는 매핑이 빠져있는 상태 발생.
소프트웨어가 "나 프레임 있어"라고 하는데, 하드웨어는 "몰?루" 하고 있었던 거다.
해결: 프레임이 있어도 PML4 매핑을 한 번 더 검증하는 로직을 추가했다.
📂
vm/vm.c
// vm_do_claim_page: 고아 프레임 수정 버전 (vm.c:408)
static bool vm_do_claim_page(struct page *page) {
if (page->frame != NULL) {
// 프레임은 있는데 PML4 매핑이 빠져있을 수 있다
struct thread *t = thread_current();
void *kva = page->frame->kva;
if (pml4_get_page(t->pml4, page->va) == NULL) {
if (!pml4_set_page(t->pml4, page->va, kva, page->writable))
return false;
}
return true;
}
struct frame *frame = vm_get_frame();
if (frame == NULL) return false;
frame->page = page;
page->frame = frame;
struct thread *t = thread_current();
if (!pml4_set_page(t->pml4, page->va, frame->kva, page->writable)) {
palloc_free_page(frame->kva);
free(frame);
page->frame = NULL;
return false;
}
return swap_in(page, frame->kva);
}트러블슈팅 2: Page Merge 테스트 — Race Condition & Recursive Locking
page-merge-par, page-merge-stk, page-merge-mm 테스트 모두 실패.
세 가지 이슈가 동시에 터져서 머리가 아팠다.
Issue 1: Race Condition — open failed
load()함수에서filesys_open호출 시filesys_lock미획득process_exit의 파일 닫기와 충돌하여 파일 시스템 구조체 손상- 해결:
load,process_exit,process_cleanup,s_create,s_remove모두filesys_lock으로 보호
Issue 2: Recursive Locking — !lock_held_by_current_thread assertion
sys_mmap에서filesys_lock획득 후do_mmap호출 → 내부에서file_reopen시 다시 락 획득 시도- Pintos의 락은 재귀적 획득 미지원
- 해결:
sys_mmap에서file_length조회 시만 락 사용 후 해제,do_mmap이 자체적으로 락 관리
Issue 3: Stack Growth 누락 — child-qsort: exit(-1)
child-qsort가 스택에 큰 버퍼 할당 후read()호출- 아직 물리 프레임에 매핑되지 않은 스택 주소를
s_check_buffer가 "유효하지 않음"으로 판단 - 해결:
s_check_buffer에 스택 확장 감지 로직 추가
📂
userprog/syscall.c
// s_check_buffer: 스택 확장 감지 포함 버퍼 검증 (syscall.c:352)
static void s_check_buffer(const void *buffer, unsigned length, bool writable) {
if (buffer == NULL) s_exit(-1);
const char *start = (const char *)buffer;
const char *end = start + length - 1;
for (void *p = pg_round_down(start); p <= pg_round_down(end); p += PGSIZE) {
if (length == 0) break;
struct thread *curr = thread_current();
#ifdef VM
struct page *page = spt_find_page(&curr->spt, p);
if (page == NULL) {
// 스택 확장 시도
uintptr_t rsp = curr->user_rsp;
if ((uintptr_t)p >= rsp - 8 && (uintptr_t)p <= USER_STACK &&
(uintptr_t)p >= USER_STACK - (1 << 20)) {
if (vm_alloc_page(VM_ANON | VM_MARKER_0, p, true)) {
if (vm_claim_page(p))
continue; // 스택 확장 성공
}
}
s_exit(-1);
}
if (writable && !page->writable) s_exit(-1);
if (page->frame == NULL) {
if (!vm_claim_page(p)) s_exit(-1);
}
#else
s_check_access(p);
#endif
}
}주요 학습 포인트 정리
OS 핵심 개념
| 개념 | 배운 것 |
|---|---|
| 스레드 스케줄링 | 라운드 로빈 → 우선순위 → MLFQS 진화 과정 |
| 동기화 | 세마포어, 락, 조건 변수, 우선순위 기부 |
| 메모리 보호 | 페이징, U/S 비트, PML4, CR3 |
| 프로세스 실행 | ELF 로딩, Argument Passing, 유저 스택 구성 |
| 시스템 콜 | x86-64 syscall ABI, 인자 전달, 유저 포인터 검증 |
| 가상 메모리 | Lazy Loading, Stack Growth, Memory Mapped Files |
| 인터럽트 | IDT, intr_frame, TSS, 모드 전환 메커니즘 |
트러블슈팅에서 얻은 교훈
- 소프트웨어 상태와 하드웨어 상태의 불일치를 항상 의심하라 —
page->frame != NULL이어도 PML4 매핑이 없을 수 있다. 한쪽만 보면 속는다. - 멀티프로세스 환경에서 공유 자원은 반드시 락으로 보호하라 — 파일 시스템 Race Condition은 한 번 당하면 잊을 수가 없다.
- 재귀적 락 획득을 피하라 — 함수 호출 체인에서 락의 범위를 명확히 설계해야 한다. 안 그러면 assertion에서 터진다.
- 유저 버퍼 검증 시 스택 확장 가능성을 고려하라 — 아직 매핑 안 된 주소라고 다 불법은 아니다. 스택은 자란다.