fork, ELF 로더, 그리고 파일시스템
목차
0. 들어가며
2편에서 유저 프로세스까지 왔습니다. U-mode, 시스템콜, 선점형 스케줄러, 프로세스별 주소공간까지요.
이번 글은 그 위에 유닉스 프로세스 모델의 나머지와, 완전히 새로운 축인 저장장치를 붙입니다.
세 가지 기능을 올리는데, 셋 다 결국 “무엇을 복사하고, 무엇을 공유하고, 무엇을 그대로 두느냐”라는 같은 질문을 풉니다.
- fork()로 주소공간을 복제한다. 무엇을 복사하고 무엇을 공유할까?
- ELF 로더로 따로 컴파일한 진짜 프로그램을 적재한다. 파일의 어느 바이트를 메모리 어디에 둘까?
- 파일시스템으로 virtio-blk 디스크(virtio = 가상머신용 표준 가상 장치 규약)를 읽어
ls/cat을 제공한다. 헐벗은 블록 배열을 어떻게 “파일”로 약속할까?
3번은 한 번에 안 됐습니다.
virtio 드라이버를 디버깅하며 만난 volatile 한 줄이 이번 글의 하이라이트입니다.
이 글은 그 세 질문을 ACID 시리즈와 같은 결, 즉 문제 먼저, 경우 나열, 비교 표, 오해 정정 순서로 실제 커널 코드에 기대어 풀어 봅니다.
1. fork()가 풀어야 하는 진짜 문제
“프로세스가 자신을 복제한다.” 한 번 호출했는데 둘이 되고, 부모에겐 자식 pid를, 자식에겐 0을 반환한다.
말은 단순하지만 구현은 까다롭습니다.
유닉스에서 새 프로그램을 띄우는 방식이 “fork로 나를 복제한 뒤, 그 복제본만 새 프로그램으로 갈아끼운다(exec)“라서, “복제”와 “교체”를 두 단계로 쪼개 둔 것입니다. 그 사이에 자식의 환경을 손볼 틈이 생깁니다(파일 리다이렉트, 권한 낮추기 등).
겉보기에 fork는 마술 같습니다.
한 번 호출했는데 두 곳에서 리턴합니다. 부모 쪽에서 한 번, 자식 쪽에서 한 번입니다.
구현 관점에서 fork가 진짜로 답해야 하는 질문은 딱 둘입니다:
- 자식의 주소공간을 어떻게 만드는가? 부모 메모리의 무엇을 복사하고 무엇을 공유할 것인가?
- 자식이 어떻게 “fork에서 0을 받고 돌아온” 상태로 깨어나는가? 그것도 새 복귀 코드 없이.
이 두 질문을 1편의 페이징과 2편의 트랩 프레임을 그대로 재활용해 풉니다.
경우 나누기: 페이지마다 복사 정책이 다르다
자식의 주소공간을 만들 때, 부모의 모든 페이지를 같은 정책으로 다루면 안 됩니다.
페이지의 성격(읽기전용이냐, 사적으로 고쳐야 하느냐)에 따라 셋으로 갈립니다.
코드 페이지: 공유한다
둘이 같은 물리 페이지를 가리켜도 안전한가?
유저 코드는 R|X(읽기·실행)라 누가 덮어쓸 일이 없습니다.
그래서 복사하지 않고 공유합니다. 같은 물리 페이지를 부모와 자식이 같이 가리킵니다.
실제 코드에선 참조 카운트만 하나 올립니다(kref_inc). fork가 가벼워지고 메모리도 아낍니다.
일반적으로 읽기 전용으로 매핑된 페이지라면 코드뿐 아니라 .rodata(읽기 전용 데이터)도 같은 식으로 공유할 수 있습니다. 우리 구현은 한 장짜리 코드 페이지만 써서, 결과적으로 코드와 읽기전용 데이터가 한 페이지에 담겨 함께 공유됩니다.
스택 페이지: 사적으로 복사한다
자식만 고쳐 써야 하는 데이터를 공유하면?
스택은 공유하면 안 됩니다.
바로 그 위에 얹힌 트랩 프레임에서 자식 것만 a0을 0으로 바꿔야 하는데, 공유하면 부모 프레임까지 망가집니다.
그래서 kalloc()으로 새 페이지를 받아 부모 스택을 통째로 copybytes로 복사합니다.
트랩 프레임: 복사된 스택 안에서 두 필드만 수정한다
복사하면 복귀 지점도 따라온다. 그럼 무엇이 달라야 하나?
2편에서 본 것처럼, 유저가 ecall로 시스템콜을 부르면 커널은 그 순간의 레지스터 전부(=트랩 프레임, struct regframe)를 유저 스택 위에 저장합니다.
이 프레임 안에 “어디로 돌아갈지(sepc)“와 “무슨 값을 들고 돌아갈지(a0)“가 들어 있습니다.
부모 스택을 통째로 복사하면 자식 스택에도 같은 가상주소에 같은 프레임이 들어가니, 자식이 할 일은 그 프레임에서 a0만 0으로 바꾸는 것뿐입니다.
나머지(돌아갈 주소, 스택 포인터, 모든 변수)는 부모와 글자 하나 안 틀리기 때문입니다.
| 구분 | 복사 정책 | 이유 | 실제 코드 |
|---|---|---|---|
| 코드 페이지 | 공유 (전체 복사 안 함) | R|X 읽기전용 → 충돌 없음 | kref_inc(parent->ucode) |
| 스택 페이지 | 사적 복사 | 자식 프레임만 고쳐야 함 | copybytes(ustack, parent->ustack, PGSIZE) |
| 트랩 프레임 | 복사본 안에서 2필드 수정 | ”fork에서 0 받고 복귀” 흉내 | cf->a0=0; cf->sepc=f->sepc+4 |
비유하면, 게임 세이브 슬롯을 통째로 복사해 두 번째 슬롯을 만든 다음 그 사본에서 캐릭터 이름 한 글자만 바꾸는 셈입니다. 나머지 진행 상황은 완벽히 같으니, 어느 슬롯에서 이어 해도 바로 그 지점부터입니다.
우리 코드는 어떻게
실제 proc.c의 proc_fork() 핵심입니다.
위 세 경우가 코드에 그대로 드러납니다.
// proc.c — proc_fork(struct regframe *f) 핵심// f = 부모의 트랩 프레임 포인터(유저 스택 위, ecall 시점 상태)
// 코드 페이지: 복사 대신 공유(읽기전용이라 안전) — fork가 가벼워진다kref_inc(parent->ucode);child->ucode = parent->ucode;
// 스택 페이지: 사적 복사(트랩 프레임을 자식만 고쳐 써야 하므로)char *ustack = kalloc();copybytes(ustack, parent->ustack, PGSIZE);child->ustack = ustack;child->pagetable = proc_pagetable((uint64)parent->ucode, (uint64)ustack);
// 복사된 자식 스택 안의 트랩 프레임을 손본다.// 부모 프레임은 유저 VA (uint64)f 에 있고, 물리적으론 복사본 ustack 안// 같은 오프셋에 들어 있다.uint64 off = (uint64)f - USERSTACK;struct regframe *cf = (struct regframe *)(ustack + off);cf->a0 = 0; // 자식의 fork() 반환값 = 0cf->sepc = f->sepc + 4; // ecall 다음 명령부터(부모와 같은 지점)
// 자식의 "첫 실행 진입점"을 forkret으로, 프레임 VA를 기억시킨다child->context.ra = (uint64)forkret;child->context.sp = (uint64)USERSTACKTOP; // forkret이 잠깐 쓸 스택child->tf_va = (uint64)f; // 트랩 프레임 VA(자식에서도 동일)여기서 off = (uint64)f - USERSTACK이 핵심입니다.
부모 프레임이 유저 가상주소 f에 있다면, 스택 페이지 시작(USERSTACK)으로부터의 거리(off)는 부모와 자식이 같습니다.
그래서 자식의 물리 복사본(ustack)에서 같은 off만큼 들어가면 자식 프레임의 정확한 위치를 짚고, 거기서 a0과 sepc만 고치는 것입니다.
흔한 오해 정정:
sepc + 4를 “그냥 +4”로 외우면 안 됩니다. 이건 “ecall명령 다음으로 돌아가라”는 뜻이고,ecall은 (압축 형식이 없어) RISC-V에서 항상 4바이트 명령이라 4를 더하는 것입니다. 빼먹으면 자식이 복귀하자마자 같은ecall을 다시 만나 영원히fork만 재호출합니다. 부모는 왜 안 그럴까요? 부모의sepc+4는 트랩 진입·복귀 공통 경로가 이미 처리해 줍니다. 자식은 그 경로를 타기 전에 프레임을 직접 만들어 넣는 것이라, 여기서 손수 더해 줘야 합니다.
자식이 처음 스케줄되면 context.ra를 따라 forkret으로 들어갑니다.
// proc.c — fork된 자식의 첫 실행 진입점void forkret(void) { release(&pt_lock); // 스케줄러가 잡아준 락을 놓는다 struct proc *p = cpu_proc[r_tp()]; trapret_from(p->tf_va); // sp=프레임 VA로 잡고 트랩 복귀(안 돌아옴)}forkret은 기억해 둔 tf_va(프레임 VA)로 sp를 잡고, 2편에서 만든 트랩 복귀 공통 경로(trapret_from)를 그대로 탑니다.
이 공통 경로는 프레임에서 레지스터를 복원하고 sret으로 U-mode에 떨어뜨리는 코드입니다. 부모가 시스템콜에서 돌아갈 때 타는 길과 똑같습니다.
즉 자식은 “부모가 시스템콜에서 복귀하는 길”을 그대로 따라가되, 프레임 안 a0만 0이라 반환값만 달라지는 것입니다.
fork를 위해 새 복귀 코드를 짤 필요가 전혀 없었던 것입니다. 위에서 던진 두 번째 질문의 답입니다.
한 장으로 정리
- 부모의
ecall→ 트랩 프레임이 유저 스택에 저장됨(2편의 메커니즘) - 자식 = 스택 페이지 복사(+ 코드 페이지 공유) → 같은 VA에 같은 프레임
- 자식 프레임에서
a0=0,sepc+=4만 수정 - 자식 첫 실행 →
forkret→tf_va로sp잡고 → 공통 트랩 복귀(trapret_from) - 결과: 부모와 같은 지점에서 깨어나되, fork 반환값만 0
[parent] fork() returned a child; we are two now. [child] hello -- I was created by fork().hobby> ps spinK (kernel pid=0): ticks=7945564 userP (user pid=1): ticks=13 ← 부모 userP+ (user pid=2): ticks=10 ← fork로 태어난 자식하나였던 유저 프로세스가 둘이 되어(userP → userP+userP+), 커널 스레드(spinK)와 함께 셋이 선점 스케줄됩니다.
자식 이름 끝의 +는 proc_fork가 부모 이름에 붙여 준 표식입니다.
2. ELF 로더가 풀어야 하는 진짜 문제
“따로 컴파일한 진짜 프로그램을 적재한다.” 파일의 어느 바이트를, 메모리 어디에, 어떻게 펼쳐 놓을 것인가?
지금까지(2편) 유저 프로그램은 커널 소스 안에 인라인 어셈블리로 박혀 있었습니다.
부팅용 데모로는 충분했지만 한계가 뻔합니다. 프로그램을 바꿀 때마다 커널을 다시 빌드해야 하고, C로 짠 진짜 프로그램을 못 올립니다.
그래서 이번엔 유저 프로그램을 따로 컴파일한 진짜 ELF 바이너리(ELF = Executable and Linkable Format, 리눅스 등에서 쓰는 실행파일 표준 포맷)로 바꿉니다.
riscv64-elf-gcc로 user/init.c를 독립 컴파일하면 ELF 파일이 나오는데, 이걸 .incbin으로 커널 이미지에 데이터로 임베드해 두고(initcode[]), 부팅 때 커널이 파싱해서 적재합니다.
나중(이 글 후반)엔 같은 로더로 디스크에 있는 프로그램도 적재하게 되니, 로더는 exec의 심장입니다.
배경 개념: ELF라는 적재 지도
ELF 헤더: 지도책의 표지
“이 바이트 덩어리가 진짜 실행 파일인가? 진입점은 어디인가?”
ELF(Executable and Linkable Format)는 리눅스·BSD가 쓰는 표준 실행 파일 포맷입니다.
핵심만 보면 “헤더 + 세그먼트들의 목록”입니다. “이 바이트 덩어리를 메모리 어디에 어떻게 펼쳐 놓아라”라는 지도책입니다.
ELF 맨 앞 ELF 헤더에 매직 넘버(0x7F 'E' 'L' 'F'), 진입점(e_entry), 그리고 “프로그램 헤더가 어디 있는지(e_phoff)·몇 개인지(e_phnum)“가 적혀 있습니다.
프로그램 헤더: 한 줄짜리 적재 지시
“이 세그먼트를 파일 어디서 가져와, 메모리 어디에, 얼마나 펼치라는 건가?”
각 프로그램 헤더(PT_LOAD 항목)가 한 줄의 적재 지시입니다.
p_offset: 파일의 이 위치에서p_filesz: 이만큼 바이트를p_vaddr: 이 가상주소에 둬라p_memsz: 단, 메모리에선 이만큼 차지하게 하라(나머지는 0으로)
마지막 항목이 .bss(초기값 0인 전역변수) 처리입니다.
파일에는 0을 굳이 저장 안 하니까(p_filesz < p_memsz), 차이만큼 메모리를 0으로 채워 주면 됩니다.
우리 코드는 어떻게
실제 elf.c의 load_elf()입니다.
먼저 매직 넘버로 진짜 ELF인지 확인하고, 프로그램 헤더를 한 줄씩 훑어 PT_LOAD만 적재합니다.
// elf.c — PT_LOAD 세그먼트 적재int load_elf(const char *img, char *codepage, uint64 *entry) { const struct elf64_ehdr *eh = (const struct elf64_ehdr *)img; // 매직 넘버 0x7F 'E' 'L' 'F' 확인 (생략) *entry = eh->e_entry; // 진입점 주소
for (int i = 0; i < eh->e_phnum; i++) { // 프로그램 헤더 개수만큼 const struct elf64_phdr *ph = (const struct elf64_phdr *)(img + eh->e_phoff + (uint64)i * eh->e_phentsize); if (ph->p_type != PT_LOAD) continue; // 적재 대상만
uint64 off = ph->p_vaddr - USERVA; // 코드 페이지 안에서의 오프셋 if (off + ph->p_memsz > PGSIZE) { // 우린 1페이지로 제한 uart_puts("[elf] segment too big (>1 page)\n"); return -1; } copyb(codepage + off, img + ph->p_offset, ph->p_filesz); // p_memsz > p_filesz 부분(.bss)은 codepage가 미리 0이라 자동 처리 } return 0;}여기서 off = ph->p_vaddr - USERVA 한 줄이 핵심입니다.
유저 프로그램을 USERVA(0x1000)에 링크했으니, p_vaddr에서 USERVA를 빼면 “한 장짜리 코드 페이지 안에서의 위치”가 나옵니다.
그래서 우리는 페이지 테이블을 거치지 않고 그냥 식별 매핑된 커널 메모리(codepage)에 직접 펼쳐 두고, 나중에 그 물리 페이지를 유저의 USERVA에 매핑하면 끝입니다.
흔한 오해 정정: “
.bss는 로더가 0으로 채운다”고 흔히 생각하지만, 이 로더엔.bss를 따로 0으로 미는 코드가 없습니다. 호출하는 쪽(make_user_proc)이kalloc()직후zero(code, PGSIZE)로 페이지를 미리 0으로 밀어 두기 때문에,p_filesz만큼만 복사하면 그 뒤(.bss)는 저절로 0입니다. “미리 0으로 깔아 두고 필요한 만큼만 덮어쓴다”는 1편 demand paging에서도 본 패턴입니다.
// proc.c — make_user_proc(): 로더를 부르는 쪽char *code = kalloc();zero(code, PGSIZE); // .bss 대비 미리 0uint64 entry;if (load_elf(initcode, code, &entry) != 0) // 임베드된 ELF를 파싱·적재 return 0;if (entry != USERVA) // _start는 USERVA에 링크돼 있어야 uart_puts("[warn] elf entry != USERVA\n");함정 / 주의
- 한 페이지 제한: 지금 로더는 세그먼트가 한 페이지(4KB)를 넘으면 거절합니다(
off + p_memsz > PGSIZE). 그 대신 주소 배치가 1편부터 써온 “코드 1페이지 + 스택 1페이지”와 정확히 맞아떨어져, 1절의 fork(스택 복사)·페이징과 마찰이 없습니다. 더 큰 프로그램은 나중 편에서 멀티 페이지로 확장합니다. - 링크 주소 고정:
_start가 반드시USERVA에 링크돼 있어야off계산이 맞습니다. 그래서 위에서entry != USERVA를 경고로 잡아 둔 것입니다.
이제 user/init.c는 커널과 무관한 평범한 C 프로그램입니다.
시스템콜 래퍼만 있으면 fork·print·exit를 부를 수 있습니다.
void _start(void) { long pid = sys_fork(); // 자신을 복제 sys_print(pid); // 부모/자식 메시지 for (int i = 0; i < 25; i++) { sys_tick(); /* busy wait */ } sys_exit();}3. 파일시스템이 풀어야 하는 진짜 문제
“헐벗은 블록 배열에서 파일을 읽는다.” 512바이트 블록이 쭉 늘어선 배열을, 어떻게
ls/cat이 되는 “파일”로 약속할 것인가?
마지막은 완전히 새로운 축, 저장장치입니다.
지금까지는 모든 게 RAM 안에서만 살았는데, 이제 전원을 꺼도 남는 디스크를 붙이고 거기서 파일을 읽습니다.
이번 글에선 가장 단순한 형태인 읽기 전용 파일시스템으로 시작합니다.
(파일을 만들고 지우는 쓰기, 크래시에도 안 깨지는 저널링은 다음 편 주제입니다. 이번 글에서 다룰 트랜잭션은 ACID 시리즈의 영역이고, 여기 커널에선 “디스크에서 읽어 보여 주기”에만 집중합니다.)
이 문제는 두 층으로 갈라야 풀립니다.
- 아래층(블록 드라이버): virtio-blk 디스크에서 “512바이트 블록 하나”를 읽고 쓰는 법
- 위층(파일시스템): 그 블록들 위에 “파일”이라는 개념을 올려
ls/cat을 제공하는 법
3-1. 디스크 레이아웃: 블록을 어떻게 약속할까
디스크는 그냥 512바이트짜리 블록(섹터)이 쭉 늘어선 배열입니다.
이 헐벗은 블록 배열에 “0번 블록엔 뭐가, 1번 블록엔 뭐가 있다”는 약속을 씌운 게 파일시스템입니다.
우리 약속은 아주 단순합니다.
블록 0 슈퍼블록 (magic + 파일 수 + 전체 블록 수)블록 1 디렉터리 (파일 8개분: 이름, 크기, 시작 블록)블록 2.. 파일 데이터 (각 파일은 시작 블록부터 연속 배치)- 슈퍼블록(블록 0)은 표지입니다. 매직 넘버로 “이게 우리 파일시스템이 맞는지” 확인하고, 파일이 몇 개인지 적어 둡니다.
- 디렉터리(블록 1)는 목차입니다. 파일마다
{이름, 크기, 시작 블록}한 줄(fs_dirent)입니다. 한 블록(512B)에 64바이트짜리 항목 8개가 들어가니, 최대 8개 파일입니다. - 그 뒤로 파일 데이터가 연속 블록으로 놓입니다.
이 레이아웃은 fsformat.h 한 파일에 정의돼 있고, 커널과 호스트 도구가 같이 인클루드합니다.
그래서 호스트(맥)에서 tools/mkfs로 디스크 이미지를 만들 때와 커널이 그걸 읽을 때가 글자 하나 안 어긋납니다.
// fsformat.h — 커널과 mkfs가 공유하는 온디스크 포맷struct fs_dirent { // 64바이트 → 한 블록(512)에 8개 char name[NAMELEN]; // 파일명 (NAMELEN=56) unsigned int size; // 파일 크기(바이트) unsigned int start; // 시작 블록 번호};3-2. 블록 드라이버: virtio로 디스크와 대화하기
이제 “블록 하나 읽어와”를 실제로 어떻게 하느냐입니다.
디스크에 직접 명령을 쓰는 것이 아닙니다.
왜 PIO 대신 virtio인가
“장치 레지스터를 직접 두드리는 PIO와, 공유 메모리 링으로 대화하는 virtio는 무엇이 다른가?”
QEMU의 가상 디스크는 virtio 규약을 따르는데, virtio는 “게스트(우리 커널)와 호스트(디바이스)가 공유 메모리 위의 링으로 대화하는” 표준입니다.
실제 하드웨어를 흉내 내는 대신 가상화에 최적화된 인터페이스입니다. PIO든 MMIO든 CPU 입장에선 똑같이 load/store지만, virtio는 데이터를 공유 메모리에 두고 MMIO는 “알림”에만 쓰기 때문에, 매 워드마다 레지스터로 옮기는 PIO보다 레지스터 접근 횟수가 크게 줄어 한 요청을 가볍게 끝냅니다.
| 구분 | PIO(레지스터 직접 제어) | virtio(가상 디바이스) |
|---|---|---|
| 데이터 전달 | 레지스터를 통해 한 워드씩 | 공유 메모리 버퍼를 디바이스가 DMA(직접 메모리 접근) |
| 한 요청당 트랩/MMIO | 많음(바이트·워드마다) | 적음(디스크립터 1체인 + 알림 1번) |
| 완료 통지 | 폴링/인터럽트 | used 링 갱신 → 폴링 또는 인터럽트 |
| 우리 구현 | 없음 | 3-디스크립터 체인 + 폴링 |
비유: 식당 주문표 시스템
- 게스트가 메모리에 주문서를 적는다. 이게 디스크립터 체인으로,
[요청 헤더] → [데이터 버퍼] → [상태 바이트]세 칸이next로 엮인 것이다. - 게스트가 “주문 넣었음”을 available 링에 끼워 넣는다(주문 대기열).
- MMIO 레지스터를 한 번 톡 쳐서(
QUEUE_NOTIFY) “주문 들어갔어요”라고 종을 울린다. - 디바이스가 처리하고, 다 되면 used 링(완료 대기열)에 “n번 주문 나왔습니다”를 올린다.
- 게스트는 used 링이 갱신될 때까지 폴링한다(인터럽트 없이 계속 들여다봄).
세 칸 디스크립터의 역할은 이렇습니다. [0]엔 “몇 번 섹터를 읽어라”는 헤더, [1]엔 데이터가 담길 버퍼(디바이스가 여기에 쓰므로 WRITE 플래그), [2]엔 디바이스가 성공/실패를 적을 1바이트 상태칸입니다.
// virtio.c — virtio_disk_rw(): 한 블록 읽기/쓰기breq.type = write ? VIRTIO_BLK_T_OUT : VIRTIO_BLK_T_IN;breq.sector = block; // 읽을 섹터 번호
desc[0].addr = (uint64)&breq; desc[0].len = sizeof(breq); // [0] 요청 헤더desc[0].flags = VRING_DESC_F_NEXT; desc[0].next = 1; // → 다음은 [1]
desc[1].addr = (uint64)buf; desc[1].len = BSIZE; // [1] 데이터 버퍼desc[1].flags = (write ? 0 : VRING_DESC_F_WRITE) | VRING_DESC_F_NEXT;desc[1].next = 2; // 디바이스가 여기 씀
bstatus = 0xff;desc[2].addr = (uint64)&bstatus; desc[2].len = 1; // [2] 상태 바이트desc[2].flags = VRING_DESC_F_WRITE; desc[2].next = 0; // 디바이스가 결과 기록
avail->ring[avail->idx % NUM] = 0; // available 링에 체인 헤드(0)를 넣고mb(); // 메모리 배리어: 위 쓰기를 먼저 보이게avail->idx += 1;mb();R32(MMIO_QUEUE_NOTIFY) = 0; // 디바이스에 알림(종 울리기)
while (used->idx == used_seen) // used 링 갱신까지 폴링 ;used_seen = used->idx;return (bstatus == 0) ? 0 : -1; // 디바이스가 0을 적었으면 성공mb()(메모리 배리어, fence)는 “디스크립터를 다 적은 게 디바이스 눈에 보인 다음에 알림이 가도록” 쓰기 순서를 강제합니다. CPU와 컴파일러가 메모리 접근 순서를 재배치하지 못하게 막는 것입니다. 종을 울렸는데 주문서가 아직 안 적혀 있으면 안 되기 때문입니다.
호스트에서 mkfs로 만든 디스크 이미지를 QEMU에 붙이고 커널을 켜면, 부팅 로그에 드라이버와 마운트가 뜨고 ls/cat이 동작합니다.
[ok] virtio-blk disk ready[ok] filesystem mounted: 2 fileshobby> ls motd.txt (162 bytes) readme.txt (240 bytes)hobby> cat motd.txtWelcome to kernel-hobby (C / RISC-V).This text lives on a virtio-blk disk and was read by the kernel'sown filesystem driver -- not baked into the kernel image.3-3. 파일시스템: 블록을 파일로
위층은 의외로 짧습니다.
드라이버가 “블록 하나 읽기”를 주니, 파일시스템은 디렉터리에서 이름을 찾아 → 시작 블록부터 크기만큼 블록을 이어 읽기만 하면 됩니다.
// fs.c — fs_read(): 이름으로 파일을 찾아 buf에 읽어 들인다for (int i = 0; i < nfiles; i++) { if (!streq(dir[i].name, name)) continue; // 디렉터리에서 이름 매칭 uint32 size = dir[i].size; uint32 blk = dir[i].start, off = 0, remaining = size; while (remaining > 0) { // 시작 블록부터 연속 읽기 virtio_disk_rw(blk, fsbuf, 0); // 블록 하나 → fsbuf uint32 n = remaining > BSIZE ? BSIZE : remaining; for (uint32 j = 0; j < n; j++) buf[off + j] = fsbuf[j]; off += n; remaining -= n; blk++; } return (int)size;}ls는 디렉터리 항목을 그냥 쭉 출력하고(fs_ls), cat은 위와 똑같이 블록을 이어 읽어 UART로 흘려보냅니다(fs_cat).
파일이 연속 블록으로 놓여 있어서 “시작 블록 + 크기”만으로 전부 읽을 수 있습니다. 단순한 만큼 빠릅니다.
흔한 오해 정정: 디스크 I/O 버퍼(
fsbuf)를static으로 둔 건 DMA 정확성 때문입니다. virtio는 게스트가 준 주소를 물리주소로 그대로 DMA에 쓰는데, 커널.bss(식별 매핑이라 va==pa)에 있는static버퍼라야 그 주소가 진짜 물리주소와 일치합니다. 스택 위 지역 변수를 넘기면 주소가 어긋나 디바이스가 엉뚱한 데를 읽습니다. 1절 fork의 “공유 vs 사적 복사”가 가상주소 차원의 약속이었다면, 여기선 물리주소 차원에서 같은 주의가 필요한 셈입니다.
이 텍스트는 진짜로 디스크 이미지 안에 있고, 커널이 제 드라이버로 읽어 출력한 것입니다.
커널 이미지에 박혀 있는 것이 아닙니다.
3-4. 디버깅: 왜 멈췄을까
이 단계는 세 번 막혔습니다.
전부 교과서적인 함정이었는데, 직접 밟아 보니 책에서 한 줄로 넘어가던 게 왜 중요한지 몸으로 알 수 있었습니다.
1) virtio MMIO 매핑: 첫 접근에 페이지 폴트
1편에서 켠 페이징은 커널이 쓰는 영역만 식별 매핑해 뒀습니다.
그런데 virtio 레지스터(0x10001000)는 그 목록에 없어서, 드라이버가 첫 레지스터를 읽는 순간 페이지 폴트로 죽었습니다.
해결은 1편의 kvmmap에 UART·PLIC 옆으로 “이 MMIO 영역도 va==pa로 매핑하라”는 한 줄을 추가하는 것이었습니다.
2) 모던 vs 레거시: 기능 협상의 함정
QEMU virtio-mmio는 기본이 레거시(version 1)인데, 제 드라이버는 모던(version 2) 규약으로 짰습니다.
그래서 QEMU에 -global virtio-mmio.force-legacy=false를 줘서 모던으로 강제했습니다.
게다가 모던에선 드라이버가 디바이스와 기능 협상을 해야 큐가 살아납니다.
특히 VIRTIO_F_VERSION_1(기능 비트 32, 즉 하위 32비트가 아니라 상위 워드에 있습니다)을 반드시 수락해야 합니다.
그래서 코드가 기능 비트를 하위/상위 두 워드로 나눠 읽고 씁니다.
// virtio.c — 기능 협상: 하위 워드에선 안 쓸 기능을 끄고,// 상위 워드에선 VERSION_1(비트 32)을 수락R32(MMIO_DEVICE_FEAT_SEL) = 1; // 상위 워드 선택(비트 32~)uint32 feat_hi = R32(MMIO_DEVICE_FEAT); // 비트 0 = VERSION_1R32(MMIO_DRIVER_FEAT_SEL) = 1;R32(MMIO_DRIVER_FEAT) = feat_hi; // 그대로 수락(VERSION_1 포함)3) volatile: 이게 제일 좋았습니다
디바이스는 요청을 분명히 성공시켰습니다. used->idx가 1로 올라가고 상태 바이트도 0(성공)으로 바뀐 걸 디버거로 확인했습니다.
그런데 폴링 루프 while (used->idx == used_seen) ;가 영원히 안 끝났습니다.
원인은 컴파일러였습니다.
used가 volatile이 아니면, 컴파일러는 “이 루프 안에서 used->idx를 바꾸는 코드가 없네?”라고 보고 값을 레지스터에 한 번만 읽어 캐시해 버립니다.
그래서 디바이스가 메모리의 used->idx를 바꿔도, 루프는 레지스터에 갇힌 옛값만 비교하며 무한히 돕니다.
volatile을 붙이면 “이 메모리는 내가 모르는 사이에 변할 수 있으니 매번 메모리에서 다시 읽어라”가 됩니다.
static volatile struct virtq_used *used; // 디바이스가 비동기 갱신 → volatile하드웨어가 비동기로 갱신하는 메모리(MMIO·DMA 링)는 반드시
volatile로 읽는다. 다만volatile은 컴파일러가 접근을 생략하지 못하게 할 뿐, 접근들 사이의 순서까지 보장하지는 않습니다. 순서가 필요한 곳(위의 디스크립터 → 알림)은mb()같은 메모리 배리어를 따로 써야 합니다. 책에서 한 줄로 넘어가던 규칙인데, 한 시간 헤매고 나니 왜 그런지 몸으로 알 수 있었습니다.
정리
이번 글의 세 기능은 결국 “무엇을 복사하고, 무엇을 공유하고, 무엇을 그대로 두느냐”라는 한 질문의 세 변주였습니다.
- fork: 코드 페이지는 공유(
kref_inc), 스택은 사적 복사(copybytes), 트랩 프레임은 복사본 안에서 두 필드(a0=0,sepc+=4)만 수정한다. 새 복귀 코드 없이 2편의trapret_from을 그대로 재활용해 “fork에서 0 받고 복귀한” 자식을 만든다. - ELF 로더: 프로그램 헤더(
p_offset/p_filesz/p_vaddr/p_memsz)를 한 줄짜리 적재 지시로 읽고,off = p_vaddr - USERVA로 한 페이지 안에 펼친다..bss는 미리 0으로 깐 페이지 덕에 저절로 처리된다. - 파일시스템: 슈퍼블록/디렉터리/연속 데이터라는 단순 약속(
fsformat.h) 위에서, 3-디스크립터 virtio 체인으로 블록을 읽어ls/cat을 올린다. DMA 정확성 때문에 버퍼는 식별 매핑된static이어야 한다.
여기까지 오면서 운영체제의 다섯 축이 전부 동작합니다.
- CPU: 트랩, 인터럽트, 선점형 스케줄링
- 메모리: Sv39 페이징, 프로세스별 주소공간 격리
- 프로세스: fork, ELF 적재, 생명주기
- 저장장치: virtio-blk 디스크, 파일시스템
- 인터페이스: 인터랙티브 커널 셸
부팅해서 명령을 치고, 여러 프로그램이 격리된 채 동시에 돌고, 디스크에서 파일을 읽습니다. 작지만 진짜 운영체제가 됐습니다.
xv6를 참고서 삼아 바닥부터 C로 짜며, “OS가 어떻게 도는가”를 손으로 만져 이해하는 게 목표였는데, 그 목표는 이룬 것 같습니다.
다음으로 더 간다면 유저공간 셸(셸 자체를 디스크의 프로그램으로 내리고 파일에서 exec), 쓰기 가능한 파일시스템과 크래시에도 안 깨지는 저널링(트랜잭션·원자성), 런타임 exec() 시스템콜이 남아 있습니다.
하지만 한 호흡은 여기서.
참고 (1차 자료 우선)
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.