← 블로그

· WIL, 크래프톤 정글

정글 돌아보기 1: Pintos project

오랫만에 작성하는 글이다. 이전 글을 봤더니 핀토스를 이겨낸다고 썼던데 지금 와서 돌아보면 진거 같다.

기억나는것과 커밋 기록을 더듬어 적어보려고 한다.


PintoS 프로젝트 여정 요약

크래프톤 정글에서 약 5주간(2025.11.06 ~ 2025.12.10) PintoS(KAIST 64-bit) 프로젝트를 진행했다. 운영체제라는 것을 밑바닥부터 만져본 건 처음이었고, 솔직히 5주 내내 '내가 이걸 할 수 있나' 싶었다. 근데 어찌저찌 해냈다. 그 과정을 여기에 남긴다.


타임라인 개요

기간단계핵심 키워드
11.06 ~ 11.12P1: ThreadsAlarm Clock, Priority Scheduling, Donation, MLFQS
11.13 ~ 11.24P2: User ProgramsELF 로딩, Argument Passing, System Call, 유저/커널 모드
11.25 ~ 12.10P3: Virtual MemoryPage 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 명령 사용 (32bit int 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, 모드 전환 메커니즘

트러블슈팅에서 얻은 교훈

  1. 소프트웨어 상태와 하드웨어 상태의 불일치를 항상 의심하라 — page->frame != NULL이어도 PML4 매핑이 없을 수 있다. 한쪽만 보면 속는다.
  2. 멀티프로세스 환경에서 공유 자원은 반드시 락으로 보호하라 — 파일 시스템 Race Condition은 한 번 당하면 잊을 수가 없다.
  3. 재귀적 락 획득을 피하라 — 함수 호출 체인에서 락의 범위를 명확히 설계해야 한다. 안 그러면 assertion에서 터진다.
  4. 유저 버퍼 검증 시 스택 확장 가능성을 고려하라 — 아직 매핑 안 된 주소라고 다 불법은 아니다. 스택은 자란다.