프로젝트
약 19분 분량 학습 프로젝트/토이 커널

유저모드에서 프로세스까지

OSCKernelRISC-Vxv6QEMU
목차

0. 들어가며

1편에서 커널의 골격을 세웠습니다. 부팅, UART 출력, 트랩/타이머, 키보드+셸, 페이지 할당기, Sv39 페이징이 그것입니다.
그런데 거기까진 모든 코드가 커널 권한(S-mode)에서 돕니다.
운영체제를 운영체제답게 만드는 건 “권한이 낮은 유저 프로그램이 커널에 일을 부탁한다”는 경계입니다.

이 글이 세우는 건 그 경계, 그리고 그 위에 올라가는 프로세스입니다.
하나의 질문으로 묶을 수 있습니다. 여러 실행 흐름을 같은 CPU 위에서 안전하게, 격리한 채, 강제로 번갈아 돌리려면 무엇이 필요한가?
이 질문을 세 조각으로 쪼개 차례로 답합니다.

  1. 유저모드 + 시스템콜: U-mode로 내려가서 ecall로 커널을 부른다(권한 경계)
  2. 프로세스 + 선점형 스케줄러: 컨텍스트 스위치로 여러 흐름을 강제로 번갈아 돌린다(시간 분할)
  3. 유저 프로세스: 프로세스마다 페이지 테이블을 줘서 주소공간을 격리한다(공간 분할)

마지막 두 절은 이걸 만들며 디버깅에서 배운 두 가지, 공유 CSR과 SUM 비트입니다.
솔직히 이 두 버그가 이번 단계에서 제일 많이 배운 부분이라, 별도 절로 떼어 정리했습니다.

참고서는 여전히 xv6(MIT 6.S081). 다만 트램폴린/프로세스별 트랩프레임 같은 정석 구조는 한 번에 다 넣지 않고, 동작하는 가장 단순한 길로 먼저 갔습니다(아래에서 그 대가도 만납니다).

1. 권한 경계가 풀어야 하는 진짜 문제

“유저 프로그램은 커널만큼의 권한을 가져선 안 된다. 그러면서도 화면 출력 같은 진짜 일은 해야 한다.”

말은 단순한데, 이 둘은 정면으로 충돌합니다.
지금까지 우리 커널은 전부 S-mode에서 돌았습니다. 셸도, 타이머도, 페이지 할당기도 그랬습니다.
S-mode는 사실상 못 하는 게 없습니다. CSR(제어·상태 레지스터, 즉 satp·sstatus처럼 CPU 설정·상태를 담는 특수 레지스터)을 마음껏 읽고 쓰고, 장치 메모리(UART·PLIC)에 직접 접근하고, 페이지 테이블도 갈아끼웁니다.

문제는 “프로그램”을 이 권한으로 돌리면 곤란하다는 것입니다.

// 만약 유저 프로그램이 S-mode 권한으로 돈다면?
*(volatile uint64 *)0x80200000 = 0; // 커널 텍스트를 0으로 — 커널 자살
w_satp(0); // 페이징 끄기 — 격리 붕괴

유저가 짠 프로그램에 버그가 하나 있어서 엉뚱한 주소에 쓰기라도 하면, 커널 자체가 무너집니다.
그래서 RISC-V는 권한 레벨을 M(머신) > S(슈퍼바이저) > U(유저) 셋으로 나누고, OS는 유저 프로그램을 권한이 가장 낮은 U-mode에 가둡니다.

CPU가 보장해야 하는 경계는 세 가지입니다:

  1. U-mode는 CSR을 못 만진다satp·sstatus 같은 시스템 상태를 못 건드림
  2. U-mode는 장치 메모리를 못 만진다 → UART·PLIC 직접 접근 금지
  3. U-mode는 PTE_U가 없는 페이지(=커널 페이지)에 접근만 해도 폴트 → 커널 메모리 격리

그럼 화면 출력 같은 진짜 일은 어떻게 할까요? 커널에 부탁합니다.
이게 시스템콜입니다.
비유하자면 U-mode 프로그램은 손님이고 커널은 주방입니다.
손님이 직접 주방에 들어가 불을 만질 순 없으니, 주문서를 적어 종업원에게 건네는 셈입니다.
RISC-V에서 그 “주문”이 ecall(environment call) 명령입니다.

ecall은 어떻게 권한을 넘기나

유저가 “부탁”을 어떻게 전달하고, CPU는 어떻게 S-mode로 올라가나?

유저가 커널에 뭘 부탁하려면, 약속된 자리에 인자를 놓아야 합니다.
우리는 xv6처럼 a7에 콜 번호, a0부터 인자를 두기로 했습니다.

li a7, 1 # a7 = SYS_putchar (콜 번호)
li a0, 85 # a0 = 'U' (인자)
ecall # "커널아, 이 글자 좀 찍어줘"

ecall을 U-mode에서 실행하면 CPU가 트랩을 일으킵니다.
1편에서 본 그 트랩 메커니즘 그대로입니다. stvec이 가리키는 핸들러(kernelvec)로 점프하고, S-mode로 올라갑니다.
다만 이번엔 scause8(Environment call from U-mode) 로 들어옵니다.
그래서 1편에서 만든 트랩 핸들러에 갈래 하나만 더 달면 끝입니다.

// trap.c — kerneltrap()
} else if (cause == SCAUSE_U_ECALL) {
// 유저 프로그램이 ecall로 커널에 '부탁'한 것 = 시스템콜.
syscall(f);
f->sepc += 4; // ecall(4바이트) '다음' 명령으로 복귀(프레임의 sepc 수정)
}

여기서 f->sepc += 4가 중요합니다.
sepc트랩이 발생한 명령의 PC가 들어갑니다. ecall 같은 예외는 그 명령 자신의 주소가, 인터럽트는 다음에 재개할 주소가 담깁니다. ecall은 예외라 ecall 자기 자신의 주소가 들어 있습니다.
그대로 복귀하면 같은 ecall을 또 실행해서 무한 루프에 빠집니다.
ecall은 (압축 형식이 없어) 항상 4바이트라, sepc에 4를 더해 그 다음 명령으로 복귀시킵니다.

흔한 오해 정정: 왜 w_sepc()로 CSR을 직접 안 고치고 f->sepc(프레임 사본)를 고칠까요? “어차피 같은 값 아니냐” 싶지만 아닙니다. CSR sepc는 코어에 하나뿐이라 트랩이 인터리빙되면 덮여버립니다. 트랩 프레임 안의 사본을 고쳐야 그 트랩만의 복귀 상태로 안전하게 남습니다. 이 정확한 이유가 5절의 첫 번째 디버깅 이야기입니다.

콜 번호로 분기하는 디스패처는 user.c에 있습니다.
실제 코드는 시스템콜이 16개나 되지만(fork/exec/read/mmap/tcp…), 골격만 보면 이렇습니다.

// user.c — 시스템콜 디스패치 (커널 측)
void syscall(struct regframe *f) {
switch (f->a7) { // a7 = 콜 번호로 분기
case SYS_putchar:
uart_putc((char)f->a0); // a0 = 유저가 부탁한 글자
f->a0 = 0; // 반환값을 프레임의 a0에 써둔다
break;
// SYS_fork / SYS_exec / SYS_read / ...
}
}

눈여겨볼 건 반환값을 f->a0에 쓴다는 점입니다.
f는 트랩 프레임, 즉 트랩에 진입할 때 유저의 레지스터를 통째로 저장해 둔 사본입니다.
여기 a0을 고쳐두면, 트랩에서 복귀할 때 그 값이 유저의 a0 레지스터로 복원됩니다.
유저 입장에선 “ecall 했더니 a0에 결과가 들어와 있다”, 딱 함수 호출처럼 보이는 셈입니다.

처음 U-mode로 내려보내기: sret 한 방

부탁을 받는 길은 만들었으니, 이제 유저 프로그램을 U-mode로 처음 내려보내는 길이 필요합니다.
이건 의외로 단순합니다. sret 명령 하나면 됩니다.

sret은 원래 “트랩에서 복귀” 명령입니다.
CPU는 sret을 보면 sstatus.SPP 비트를 보고 “트랩 직전에 어느 모드였지?”를 판단해, 그 모드로 돌아갑니다.
그래서 우리가 마치 U-mode에서 트랩이 났던 것처럼 상태를 꾸며두고 sret하면, CPU는 그대로 U-mode로 “복귀”합니다.
실제로는 한 번도 U-mode에 있던 적이 없는데 말입니다.

우리 코드에선 enter_user()가 그 일을 합니다.

// proc.c — 유저 프로세스가 처음 실행될 때 거치는 래퍼
static void enter_user(void) {
release(&pt_lock); // 스케줄러가 잡은 락을 놓는다(첫 실행)
uint64 s = r_sstatus();
s &= ~SSTATUS_SPP; // SPP=0 → sret 시 U-mode로 떨어진다
s |= SSTATUS_SPIE; // U-mode에서 인터럽트 enable(= 선점 가능)
s |= SSTATUS_SUM; // 커널이 유저 페이지 접근 가능(6절에서 다시 만남)
w_sstatus(s);
w_sepc(USERVA); // 복귀 주소 = 유저 진입점
// sp를 유저 스택 top으로 잡고 sret. 돌아오지 않는다.
asm volatile("mv sp, %0\n sret\n" :: "r"((uint64)USERSTACKTOP));
}

SPP=0이 “직전이 U-mode였다”는 거짓말이고, SPIE가 “U-mode에선 인터럽트를 켜라”(이게 있어야 타이머가 유저를 선점합니다. 엄밀히는 SPIE복귀 후 SIE로 복원될 값이라, sret 순간 SIE ← SPIE로 인터럽트가 켜집니다), sepc=USERVA가 “여기로 복귀하라”입니다.
sret을 때리면 CPU는 권한을 U-mode로 낮추고 USERVA로 점프합니다. 유저 프로그램의 첫 명령이 거기 있습니다.

[kernel] entering user mode (U-mode)...
Hello from user mode! (printed by the kernel, requested via ecall)

U-mode로 떨어진 프로그램이 ecall로 부탁하고, 커널이 받아서 처리한 것입니다.

이 한 장이면 권한 경계를 넘나드는 한 번의 왕복이 한눈에 들어옵니다.

유저모드 printf가 ecall로 S-mode에 진입해 kernelvec·syscall 디스패치·uart_putc를 거쳐 sret로 다시 유저모드로 복귀하는 권한 경계 왕복

별것 아닌 한 줄처럼 보이지만, 권한 경계를 처음 넘은 순간이라 의미가 큽니다.
지금까지 한 덩어리였던 “커널”이 비로소 “커널”과 “그 위에서 도는 프로그램”으로 갈라진 것입니다.

권한 경계는 “막는 것”이자 “넘겨주는 것”이다. U-mode가 못 하게 막는 장치(폴트)와, 합법적으로 넘어가게 해주는 통로(ecall/sret)는 한 쌍입니다.

2. 시간 분할이 풀어야 하는 진짜 문제

하나의 프로그램만 도는 건 아직 OS가 아닙니다.

“CPU 코어는 하나(혹은 몇 개)뿐인데, 실행 흐름은 여럿이다. 어떻게 동시에 도는 것처럼 보이게 하나?”

운영체제다움은 여러 실행 흐름을 번갈아 돌리는 데서 옵니다. 셸이 입력을 기다리는 동안 다른 프로그램이 계산을 돌리고, 그게 또 멈추면 세 번째가 끼어듭니다.
비결은 빠르게 번갈아 태우는 것입니다.
그런데 흐름을 번갈아 태우려면 두 가지를 풀어야 합니다:

  1. 멈췄다 정확히 그 자리에서 다시 시작할 수 있어야 함 → 컨텍스트 스위치(이 절)
  2. 양보를 안 하는 프로그램도 강제로 빼앗을 수 있어야 함 → 선점(아래)

실행 흐름의 “상태”는 어디에 있나

흐름을 멈췄다 재개하려면 무엇을 저장해야 하나?

실행 흐름의 “현재 상태”는 결국 레지스터들에 다 들어 있습니다. 지금 어느 함수의 어디까지 왔는지(ra), 스택은 어디인지(sp), 계산 중이던 값들(s0~s11) 말입니다.
그래서 컨텍스트 스위치는 지금 흐름의 레지스터를 어딘가 저장하고, 다른 흐름의 레지스터를 복원하는 게 전부입니다.
저장해 둔 레지스터 묶음을 우리는 struct context라 부르고, 프로세스마다 하나씩 갖고 있습니다.

그런데 레지스터가 31개나 되는데, 전부 저장할 필요는 없습니다.
swtch평범한 C 함수처럼 호출됩니다.
C 호출 규약(calling convention)상, 함수를 호출하면 a0~a7이나 t0~t6 같은 caller-saved 레지스터는 호출한 쪽이 이미 알아서 스택에 대피시킵니다.
그러니 swtch가 따로 챙길 건 callee-saved 레지스터(s0~s11) + ra(복귀 주소) + sp(스택 포인터), 딱 14개입니다.
한 가지 짚고 갈 게 있습니다. rasp는 호출 규약상 엄밀히는 caller-saved인데, 왜 swtch가 직접 저장할까요?
swtch는 보통 함수처럼 자기를 부른 쪽 으로 돌아가는 게 아니라 다른 실행 흐름으로 돌아가기 때문입니다.
ra(어디로 돌아갈지)와 sp(어느 스택인지)가 바로 그 “흐름의 정체” 라, 이 둘을 저장·복원하는 게 흐름 전환의 본질입니다. 그래서 일반 함수의 caller-saved 규칙과 무관하게 swtch만큼은 직접 챙깁니다.

# swtch.S — void swtch(struct context *old, struct context *new)
# a0 = old(저장할 곳), a1 = new(복원할 곳)
swtch:
sd ra, 0(a0) # 현재 흐름의 ra/sp/s0~s11을 old에 저장
sd sp, 8(a0)
sd s0, 16(a0)
sd s1, 24(a0)
# ... s2 ~ s11 (offset 32 ~ 104) ...
sd s11,104(a0)
ld ra, 0(a1) # 새 흐름의 값을 new에서 복원
ld sp, 8(a1)
ld s0, 16(a1)
# ... s1 ~ s11 ...
ld s11,104(a1)
ret # ra(=새 흐름의 복귀 주소)로 점프 → 갈아탔다

마법은 마지막 ret입니다.
ret은 “ra가 가리키는 곳으로 돌아가라”는 명령인데, 방금 ra새 흐름의 값으로 덮어썼습니다.
그래서 swtch라는 함수를 호출했는데, 정작 return은 전혀 다른 실행 흐름에서 나옵니다.
A가 swtch를 부르면 함수가 “안 돌아오고”, 한참 뒤 B가 양보하며 swtch를 부르는 순간 비로소 A의 swtch가 return합니다.
한 함수 호출의 입구와 출구가 서로 다른 스레드에 걸쳐 있는 셈입니다.

스케줄러와 yield의 핑퐁

스케줄러는 단순합니다.
프로세스 테이블을 돌며 실행 가능한(RUNNABLE) 프로세스를 찾으면, RUNNING으로 바꾸고 swtch로 그 안에 진입합니다.
그 프로세스가 양보(yield)하면 swtch가 return하면서 스케줄러로 돌아오고, 다음 프로세스를 찾아 또 진입합니다(라운드로빈).

// proc.c — 스케줄러 (각 코어가 무한 루프)
void scheduler(void) {
int id = r_tp(); // 이 코어 번호
for (;;) {
intr_on(); // 이 코어가 인터럽트를 받을 수 있게
acquire(&pt_lock);
for (int i = 0; i < NPROC; i++) {
struct proc *p = &proctable[i];
if (p->state == RUNNABLE) {
p->state = RUNNING;
cpu_proc[id] = p;
switch_satp(p->pagetable); // 주소공간 전환(3절)
swtch(&cpu_sched[id], &p->context); // p 실행 → 양보 시 복귀
switch_satp(kernel_pt()); // 복귀 후 커널 주소공간으로
cpu_proc[id] = 0;
}
}
release(&pt_lock);
}
}

반대편엔 yield()가 있습니다. 프로세스가 CPU를 스케줄러에 돌려주는 함수입니다.
스케줄러의 swtchyieldswtch서로의 ra로 핑퐁하면서 두 흐름이 번갈아 깨어나는 구조입니다.

// proc.c — 현재 proc이 CPU를 스케줄러에 양보
void yield(void) {
int id = r_tp();
struct proc *p = cpu_proc[id];
if (!p) return;
acquire(&pt_lock);
if (p->state == RUNNING)
p->state = RUNNABLE; // 다시 스케줄될 수 있게
swtch(&p->context, &cpu_sched[id]); // 스케줄러로 → 나중에 여기로 복귀
release(&pt_lock);
}

pt_lockswtch를 가로질러 들고 다니는 게 보일 겁니다. 스케줄러가 락을 든 채로 프로세스에 진입하고, 프로세스는 첫 실행 시 thread_start/enter_user에서 그 락을 놓습니다(release(&pt_lock)). 일종의 바통 넘기기입니다. 멀티코어(NCPU)에서 프로세스 테이블이 동시에 망가지지 않게 지키는 장치인데, 자세한 건 멀티코어 편에서 다룹니다.

협력형이 풀지 못하는 경우: 선점이 필요한 이유

여기까지는 프로세스가 자발적으로 yield를 불러야 전환됩니다(협력형, cooperative).
그런데 협력형은 한 가지 경우를 못 풉니다. 바로 양보를 안 하는 프로그램입니다.
무한 루프를 도는 프로그램은 영영 양보를 안 하니, 그 순간 다른 모든 흐름이 굶습니다.
그래서 진짜 OS는 양보를 안 해도 강제로 빼앗는 선점(preemption)을 합니다.
열쇠는 타이머 인터럽트입니다.

구분협조적 양보(cooperative)타이머 선점(preemptive)
전환 계기프로세스가 직접 yield() 호출타이머 인터럽트가 대신 yield()
악성/버그 루프영영 양보 안 함 → 전체 정지다음 틱에 강제로 빼앗김
응답성양보 지점 사이엔 응답 불가틱 주기(우리는 0.1초)로 보장
구현 비용작음(yield만)타이머 핸들러에 한 줄

1편에서 만든 주기 타이머에 딱 한 줄만 더하면 선점형이 됩니다.
타이머가 울릴 때마다, 지금 프로세스가 돌고 있으면 대신 yield()를 불러주는 것입니다.

// trap.c — 타이머 인터럽트 처리
ticks++;
w_stimecmp(r_time() + TIMER_INTERVAL); // 다음 타이머 예약
if (current_proc())
yield(); // 실행 중인 스레드를 스케줄러에 강제 양보(= 선점)

프로세스 입장에선 자기가 잘 돌다가 어느 순간 멈췄고, 한참 뒤 아무 일 없었다는 듯 그 자리에서 재개됩니다.
그 “한참” 사이에 다른 프로세스가 타이머 몫만큼 돌았던 것입니다. 본인은 전혀 모릅니다.
이게 멀티태스킹의 환상입니다.

셸도 다시 살렸습니다.
셸은 UART 인터럽트로 동작하니 어느 프로세스가 돌든 키 입력에 응답합니다.
그리고 두 스레드가 진짜로 번갈아 도는지 눈으로 보려고 ps 명령을 추가했습니다. 각 스레드가 스핀하며 카운터를 올리는데, 타이머가 둘을 번갈아 선점하면 두 카운터가 함께 늘어납니다.

hobby> ps
threads (preemptively scheduled):
spinA: ticks=34888638
spinB: ticks=31213595
hobby> ps (1.2초 후)
spinA: ticks=64687087 +30M
spinB: ticks=61305716 +30M

두 카운터가 1.2초 동안 각각 ~30M씩 늘었습니다.
둘 다 비슷하게 자란다는 게 핵심입니다. 타이머가 공평하게 번갈아 태우고 있다는 증거입니다.
선점형 멀티태스킹이 도는 것입니다.

선점은 “프로그램을 못 믿는다”는 설계 결정이다. 협력형은 모든 프로그램이 착하게 양보한다고 믿습니다. 선점형은 그 신뢰를 타이머라는 외부 강제력으로 대체합니다. 그래야 한 악성 루프가 시스템 전체를 인질로 잡지 못합니다.

3. 공간 분할이 풀어야 하는 진짜 문제

2절의 “프로세스”는 사실 커널 스레드였습니다. S-mode에서 돌고, 페이지 테이블도 다 같이 공유했습니다.

“여러 프로그램이 같은 주소공간을 보면, 한쪽이 다른 쪽 메모리를 마음껏 덮어쓸 수 있다. 어떻게 막나?”

같은 주소공간을 공유하면 서로의 메모리를 망가뜨릴 수 있습니다.
이건 진짜 프로세스가 아닙니다.
진짜 프로세스의 핵심은 격리입니다.
1편 5절에서 가상메모리를 만들 때 약속했던 “프로세스마다 다른 번역 표”가 바로 여기서 실현됩니다.
프로세스 A의 0x1000과 B의 0x1000이 서로 다른 물리 페이지로 번역되면, 둘은 같은 주소를 써도 안 부딪히고 서로를 망가뜨릴 수도 없습니다.
그러려면 프로세스마다 페이지 테이블을 따로 줘야 합니다.

페이지 테이블을 어떻게 구성하나: proc_pagetable

유저 페이지만 넣으면 될까? 아니면 커널도 같이 넣어야 하나?

각 프로세스의 페이지 테이블은 두 부분으로 이뤄집니다.
커널 영역(식별 매핑) + 그 프로세스만의 유저 코드·스택(PTE_U 달림)입니다.

// vm.c — 프로세스별 페이지 테이블
pagetable_t proc_pagetable(uint64 ucode_pa, uint64 ustack_pa) {
pagetable_t pt = (pagetable_t)kalloc();
memset(pt, 0, PGSIZE);
map_kernel(pt); // 커널을 이 테이블에도 식별 매핑
mappages(pt, USERVA, PGSIZE, ucode_pa, PTE_R|PTE_X|PTE_U); // 코드: 읽기·실행, 유저 접근
mappages(pt, USERSTACK, PGSIZE, ustack_pa, PTE_R|PTE_W|PTE_U); // 스택: 읽기·쓰기, 유저 접근
return pt;
}

코드 페이지는 R|X(읽기·실행, 쓰기 금지), 스택은 R|W(읽기·쓰기, 실행 금지)입니다. 1편에서 본 권한 비트가 여기서 보호 역할을 합니다.
그리고 둘 다 PTE_U가 붙어 있습니다.
이게 있어야 U-mode 프로그램이 이 페이지에 접근할 수 있습니다(커널 페이지엔 PTE_U가 없으니 유저가 건드리면 폴트가 납니다. 1절의 경계 (3)이 바로 이것입니다).

왜 커널까지 매핑할까요? U-mode에서 트랩(ecall·타이머)이 나는 순간엔 satp여전히 그 프로세스 테이블을 가리킵니다.
그런데 트랩 핸들러(kernelvec)도 결국 메모리에 있는 코드라, 그 주소가 지금 페이지 테이블에 매핑돼 있어야 실행됩니다.
그래서 모든 프로세스 테이블에 커널을 같은 주소(식별 매핑)로 복제해 둡니다. 그러면 satp을 바꾸지 않고도 핸들러가 그대로 돌아갑니다.

여기엔 트레이드오프가 있습니다:

구분커널 전체를 모든 테이블에 복제(우리)트램폴린 한 페이지만(xv6 정석)
매핑하는 양커널 텍스트·데이터 전부진입 코드 한 장 + 트랩프레임 한 장
satp 교체 시점트랩 진입 후 핸들러 안에서 천천히트램폴린에서 즉시
구현 복잡도단순(map_kernel 한 줄)복잡(별도 주소공간 전환 로직)
메모리 비용테이블마다 커널 매핑이 들어가 더 씀최소
보안커널이 유저 테이블에서도 보임커널은 별도 주소공간

우린 학습용이라 “커널을 통째로 복제”하는 더 단순한 길을 택했습니다.
대신 페이지 테이블마다 커널 매핑이 들어가니 메모리를 좀 더 쓰고, 6절에서 그 단순화의 또 다른 대가(SUM 비트)를 만납니다.

주소공간까지 함께 전환: switch_satp

페이지 테이블을 따로 줬으면, 프로세스를 바꿀 때 어느 테이블을 쓸지는 언제 바꾸나?

그게 satp 레지스터입니다(1편에서 페이징을 켰던 그 레지스터).
스케줄러가 프로세스에 진입하기 직전에 satp을 그 프로세스 테이블로 바꾸고, 양보받아 돌아오면 다시 커널 테이블로 되돌립니다.
satp을 바꾸는 순간부터 같은 가상주소라도 전혀 다른 물리 페이지로 번역됩니다. 이게 프로세스마다 독립된 주소공간이 생기는 원리입니다.

// proc.c — scheduler() 안
switch_satp(p->pagetable); // 이 프로세스 주소공간으로 (satp 교체 + TLB flush)
swtch(&cpu_sched[id], &p->context); // 실행 → 양보 시 복귀
switch_satp(kernel_pt()); // 복귀 후 커널 주소공간으로
vm.c
void switch_satp(pagetable_t pt) {
w_satp(MAKE_SATP(pt)); // satp에 새 페이지 테이블의 물리주소
sfence_vma(); // TLB(옛 번역 캐시) flush
}

sfence_vma()를 잊으면 안 됩니다. 주소 번역을 통째로 갈아끼웠으니, CPU가 캐시해 둔 옛 번역(TLB)을 비워야 새 테이블이 진짜로 적용됩니다.

커널 스레드와 유저 프로세스를 같은 swtch로

여기서 우리 설계의 깔끔한 점이 드러납니다.
커널 스레드든 유저 프로세스든 똑같은 swtch로 진입합니다.
차이는 단 하나, 첫 진입점(context.ra) 뿐입니다.

// proc.c — make_thread(): 커널 스레드
p->context.ra = (uint64)thread_start; // 첫 swtch는 그냥 함수로 진입
// proc.c — make_user_proc(): 유저 프로세스
p->context.ra = (uint64)enter_user; // 첫 swtch는 U-mode로 떨어지는 함수로

스케줄러가 처음 이 프로세스에 swtch로 들어가면, retcontext.ra로 점프합니다.
커널 스레드면 thread_start(락 놓고 본체 함수 호출), 유저 프로세스면 enter_user(1절의 그 sret으로 U-mode 진입)로 가는 것입니다.
한쪽은 그냥 S-mode 함수로 계속 돌고, 다른 쪽은 U-mode로 떨어져 유저 프로그램이 됩니다. 같은 메커니즘에 진입점만 다른 것입니다.

구분커널 스레드유저 프로세스
실행 모드S-modeU-mode
페이지 테이블커널 것 공유자체(격리된 주소공간)
유저 코드/스택(PTE_U)없음있음
첫 진입점(context.ra)thread_startenter_user(sret)
전환 메커니즘swtchswtch (동일)
주소공간 전환불필요(커널 공유)switch_satp 필요

데모는 커널 스레드 1개 + 유저 프로세스 1개를 함께 스케줄합니다.
유저 프로세스는 인사 한 번 하고, 틱을 40번 세며 잠깐 일하다가, SYS_exit으로 종료됩니다.

hobby> ps
threads (preemptively scheduled):
spinK: ticks=22453788 ← 커널 스레드(S-mode)
userP: ticks=9 ← 유저 프로세스(U-mode, 자체 페이지 테이블)
hobby> ps (1.5초 후)
spinK: ticks=59812260
userP: ticks=23 ← 카운터 증가 중
hobby> ps (4초 후)
spinK: ticks=215041281
← userP가 SYS_exit으로 종료 → 사라짐

격리된 주소공간을 가진 유저 프로세스가, 커널 스레드와 함께, 선점적으로 스케줄되고, 일을 마치면 깔끔히 사라집니다.
작은 OS의 골격이 섰습니다.

격리는 “다른 번역 표”가 전부다. 같은 가상주소가 프로세스마다 다른 물리 페이지로 가니까, 서로의 존재조차 모르게 됩니다. 거창한 보호 장치가 아니라 페이지 테이블 한 장의 차이입니다.

4. 트랩 프레임을 어디에 둘 것인가

3절은 한 번에 되지 않았습니다.
유저 프로세스가 인사만 출력하고 멈춰버렸습니다(폴트도 없이).
원인을 쫓다 보니, 처음엔 그냥 넘긴 트랩 프레임 설계가 문제였습니다.
이 절과 다음 절은 그 두 버그를 통해 트랩·CSR·권한이 어떻게 얽히는지 정리합니다.

먼저 트랩 프레임이 뭔지부터.

트랩 프레임에 무엇을 저장해야 하나

트랩이 나면 유저(또는 직전 흐름)의 레지스터를 어디에, 무엇까지 저장해야 안전한가?

처음 트랩 진입점은 일반 레지스터 31개만 스택에 저장했습니다.
복귀 PC인 sepc와 모드 비트인 sstatusCSR이니 알아서 잘 있겠지 하고 안 건드렸습니다.
그런데 여기에 함정이 있습니다. sepc/sstatus는 코어마다 딱 한 개씩 있는 레지스터입니다.

이게 왜 문제냐면, 트랩 처리 도중에 또 전환이 일어나기 때문입니다.
프로세스 A가 트랩에 들어와 처리하던 중에 타이머가 울려 yield로 B에 넘어가고, B가 또 트랩(ecall이든 타이머든)을 일으키면, 그 하나뿐인 sepc가 B의 값으로 덮입니다.
나중에 A로 돌아와 sret하면, A는 자기 복귀 주소가 아니라 B가 남긴 주소로 점프합니다. 엉뚱한 곳으로 날아가 페이지 폴트가 납니다.

재밌는 건 커널 스레드만 있던 2절에선 이게 안 터졌다는 점입니다.
스레드들이 전부 같은 코드(spinA/spinB는 사실상 같은 함수)를 돌아서, 복귀 PC가 누구 값으로 덮이든 우연히 맞아떨어졌습니다.
순전히 운이었습니다.
그런데 U-mode 유저 프로세스(복귀 주소 = 유저 코드)와 S-mode 커널 스레드(복귀 주소 = 커널 코드)가 섞이는 순간, 서로의 sepc를 덮으며 바로 터졌습니다.

해결: CSR도 프레임에 저장한다

해결은 일반 레지스터처럼 sepc/sstatus도 트랩 프레임에 저장/복원하는 것입니다. 그래야 트랩마다 자기 복귀 상태를 독립적으로 갖습니다.
실제 kernelvec.S를 보면, 일반 레지스터 31개를 오프셋 0~232에 저장한 뒤, 그 두 CSR을 240·248에 이어 저장합니다(프레임 총 256바이트).

# kernelvec.S — 일반 레지스터(0~232)를 저장한 직후
csrr t0, sepc
sd t0, 240(sp) # 복귀 PC도 프레임에
csrr t0, sstatus
sd t0, 248(sp) # (트랩마다 독립 → 여러 프로세스 인터리빙 안전)
csrr tp, sscratch # 덤: tp(코어 번호)도 복구 — 유저가 tp를 덮었을 수 있어서

복귀할 때(trapret)는 반대로 프레임의 240·248을 읽어 sepc/sstatus로 되돌린 뒤 sret합니다.
이렇게 하니 1절에서 w_sepc()가 아니라 f->sepc += 4 프레임을 고쳤던 이유도 분명해집니다. CSR을 직접 만지면 인터리빙에 휩쓸리지만, 프레임 사본을 고치면 그 트랩만의 복귀 상태로 안전하게 남습니다.
이게 xv6 트랩프레임의 핵심입니다.

트랩 프레임을 어디에 둘까: 우리의 선택과 대가

여기서 한 가지 더 짚고 갈 게 있습니다. 트랩 프레임 자체를 어느 스택에 쌓느냐는 별개의 결정입니다.

구분별도 커널 스택에(xv6 정석)유저 스택 위에(우리)
트랩 시 스택 교체sscratch로 유저↔커널 스택 스왑스왑 없음, 유저 스택 그대로
핸들러가 만지는 메모리커널 페이지(PTE_U 없음)유저 페이지(PTE_U 있음)
SUM 비트 필요?불필요필요(6절의 버그 원인)
구현 복잡도높음(스택 스왑 로직)낮음

우린 단순화를 위해 유저 스택 위에서 트랩을 처리하는 길을 택했습니다.
그게 6절에서 만날 두 번째 버그의 씨앗입니다. 단순함의 대가는 다음 절에서 청구됩니다.

트랩 프레임은 “트랩마다 독립적인 복귀 상태”여야 한다. 코어에 하나뿐인 CSR을 믿으면 인터리빙에 무너집니다. 프레임이라는 사본에 복귀 상태를 박제해 둬야 여러 프로세스가 트랩을 겹쳐도 안전합니다.

5. SUM 비트: 커널이 유저 페이지를 만질 권한

4절을 고쳤는데 그래도 멈췄습니다.
이번엔 더 고약한, 출력도 폴트 메시지도 없는 완전한 hang이었습니다.

“1절에서 ‘커널 페이지는 유저가 못 만진다’를 봤다. 그럼 반대로, 커널이 유저 페이지를 만질 땐 어떻게 되나?”

계측하려고 커널 스레드가 주기적으로 점(.)을 찍게 했더니 점조차 안 찍혔습니다. 스케줄러가 유저 프로세스 안에서 영영 못 빠져나오고 있었던 것입니다.

왜 hang이 났나

범인은 SUM(Supervisor User Memory access) 비트였습니다.
4절에서 정한 대로, 저는 유저 프로세스의 트랩도 유저 스택 위에서 처리했습니다. 트랩 프레임을 별도 커널 스택이 아니라 그냥 유저 스택에 쌓은 것입니다.
그런데 보안상 기본적으로 S-mode는 PTE_U 달린 유저 페이지를 못 만집니다(커널이 실수로 유저 메모리를 건드리는 걸 막는 장치입니다. 1절 경계의 대칭입니다).
S-mode가 유저 페이지에 접근하려면 sstatus.SUM=1이 필요합니다.

문제는 4절에서 sstatus를 트랩마다 저장/복원하게 만든 것과 충돌했다는 점입니다.
SUM도 sstatus의 한 비트니까, 이제 프로세스마다 SUM 값이 달라집니다.
커널 스레드는 SUM=0으로 돌다 멈췄는데, 스케줄러가 유저 프로세스의 트랩 핸들러(유저 스택에서 실행 중)를 재개하는 순간 그 SUM=0 상태로 깨어납니다.
연쇄는 이렇습니다:

  1. 유저 스택 접근 → SUM=0이라 폴트
  2. 그 폴트를 처리하려고 트랩 진입 → 또 유저 스택을 건드림 → 또 폴트
  3. 출력 한 줄 못 내고 무한 재폴트 → 즉 hang

해결: SUM을 항상 1로 고정

해결은 SUM을 부팅 때 켜고 항상 1로 유지하는 것입니다.
trap_init()에서 한 번 켜두고, U-mode로 내려가는 enter_user/proc_execsstatus 세팅에도 SUM을 늘 포함시켜서, 어떤 트랩이 깨어나도 SUM=1이 보장되게 했습니다.

// trap.c — trap_init()에서 한 번만
w_sstatus(r_sstatus() | SSTATUS_SUM); // SUM=1 고정: 커널이 항상 유저 페이지 접근 가능
// proc.c — enter_user() / proc_exec()의 sstatus 세팅에도 항상 포함
s |= SSTATUS_SUM;

이건 학습 커널다운 단순화입니다.
xv6는 트랩을 별도 커널 스택에서 처리하고 sscratch로 유저/커널 스택을 교체해서, 애초에 트랩 핸들러가 유저 페이지를 만질 일이 없게 더 정석으로 풉니다(4절 표의 왼쪽 열).
우린 “유저 스택에서 트랩 처리 + SUM 항상 1”이라는 지름길을 택했고, 그 대가로 이 버그를 만난 것입니다.

흔한 오해 정정: SUM=1로 고정하는 건 “그냥 켜두면 편하니까” 가 아닙니다. 그건 제가 4절에서 트랩을 유저 스택 위에서 처리하기로 한 결정에서 강제로 따라 나오는 결과입니다. xv6처럼 별도 커널 스택을 쓰면 SUM은 평소 0이어야 맞습니다. 커널이 유저 메모리를 실수로 건드리는 걸 잡아주기 때문입니다. SUM을 어떻게 다룰지는 트랩 프레임을 어디 두느냐에 종속됩니다.

두 버그 다 수정 자체는 두어 줄이지만, 여기까지 오며 트랩 ↔ CSR ↔ 권한이 어떻게 얽히는지 손으로 만져보며 이해하게 됐습니다.
책으로 읽을 땐 그냥 넘겼던 부분입니다.

6. 정리

이번 글은 “여러 실행 흐름을 같은 CPU 위에서 안전하게, 격리한 채, 강제로 번갈아 돌리려면 무엇이 필요한가”를 세 분할로 풀었습니다:

  • 권한 분할: U-mode가 CSR·장치·커널 페이지를 못 만지게 막고(폴트), ecall/sret으로 합법적 통로를 연다. 막는 장치와 넘겨주는 통로는 한 쌍.
  • 시간 분할: swtch(14레지스터)로 흐름을 멈췄다 재개하고, 타이머 인터럽트로 선점해 양보 안 하는 프로그램도 빼앗는다. 협력형의 신뢰를 외부 강제력으로 대체.
  • 공간 분할: 프로세스마다 페이지 테이블(proc_pagetable)을 줘 같은 가상주소가 다른 물리 페이지로 가게 한다. 격리는 “다른 번역 표” 한 장의 차이.

그리고 이걸 만들며 만난 두 디버깅이 설계의 핵심을 드러냈습니다:

  • 트랩 프레임: 코어에 하나뿐인 sepc/sstatus(CSR)를 프레임에 저장해야(240·248, 256B) 트랩 인터리빙에 안전하다. 그래서 w_sepc()가 아니라 f->sepc += 4.
  • SUM 비트: 트랩을 유저 스택에서 처리하기로 한 단순화가 SUM=1 고정을 강제한다. CSR을 어떻게 다룰지는 설계 결정에 종속된다.

이제 여러 프로그램이 각자 격리된 주소공간에서, 선점적으로, 생성·종료되는 작은 운영체제가 됐습니다.
다음은 진짜 프로그램을 다루는 쪽입니다. fork/exec + ELF 로더(지금은 임베드된 프로그램), 그리고 파일시스템으로 갑니다.

코드: github.com/dj258255/kernel-hobby
다음 글: fork/exec + 파일시스템 (예정)

참고 (1차 자료 우선)

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.