OSTEP 독학 일지 - H.1

Jaeyeol Lee @kodingwarrior@hackers.pub
update: 2025-08-05
본 글은 Operating Systems : Three Easy Pieces라는 교재를 독학하는 여정을 기록으로 남기기 위한 시리즈 글입니다.
2025-08-05 09:30
이제 OSTEP 독학을 본격적으로 시작하는 첫 걸음이다. 시작이 반이라고 하지 않았던가? 시작을 하려면 어떻게 해야 하는가? 먼저 xv6 커널을 띄우기 위해, 헬로월드 부터 찍고 보는 것이다. 헬로월드를 찍기 위해서 필요한 준비물들을 하나 둘 살펴보자.
근데... 어떻게 세팅하지...?
먼저 xv6를 어떻게 띄울지는 이 문서(https://github.com/remzi-arpacidusseau/ostep-projects/blob/master/INSTALL-xv6.md) 에서 설명하고 있는데, 스텝 바이 스텝으로 따라가보자. 막상, 문서를 봤더니 macOS 기준으로 어떤 것을 설치하면 되는지에 대한 설명은 있는데, 리눅스 환경에서 어떻게 설치하면 되는지에 대한 내용은 전혀 보이지가 않는다. xv6 소스코드에 대한 언급도 RISC-V 기반은 아니어서 좀 더 찾아봐야 한다.
결국, 내가 실습하기 위해서 준비가 되어야 하는 것들이 아래와 같은 것들인데, 내가 지금 쓰고 있는 환경인 Ubuntu에서 어떻게 세팅하는 것인지 알아봐야 한다.
- RISC-V 기반의 xv6 소스코드 : https://github.com/mit-pdos/xv6-riscv
- RISC-V 툴체인 : xv6 소스코드를 RISC-V 기반의 인스트럭션으로 컴파일할 수 있도록 하기 위한 64비트 RISC-V GNU 툴체인
- QEMU : xv6 소스코드를 x86_64 프로세서 혹은 애플 실리콘 프로세서 위에서 굴릴 수 있으려면, QEMU(Quick EMUlator)라는 오픈소스 머신 에뮬레이터가 필요하다.
- 그리고, 꾸준히 실습하겠다는 마음가짐
다행히도, 서울대학교의 학부 수업에서 사용되는 실습 과제 리포지토리(https://github.com/snu-csl/os-pa1/)에 어떻게 하면 개발 환경을 세팅할 수 있는지 친절하게 설명이 잘 되어 있다.
(Ubuntu 기준) QEMU 세팅하기
먼저, 빌드하는데 필수요소가 되는 패키지들을 설치한다.
$ sudo apt-get install git build-essential
xv6 소스코드를 클론떠서 실습하려면 당연히, git은 당연히 설치가 되어야 한다. 그렇다면 build-essential은 왜 필요한가? build-essential 패키지에는 GCC(Gnu Compiler Collection), G++, Make, dpkg-dev 같은 것들이 포함되어 있기 때문이다. C 기반으로 짜여있는 운영체제 소스코드를 실습하는 과정에 있어서, GCC 컴파일러는 물론이고 Make는 필수다.
그리고, RISC-V GNU 툴체인을 설치한다.
$ sudo apt-get install gcc-riscv64-linux-gnu
마지막으로 QEMU를 설치한다.
$ sudo apt-get install qemu-system-misc
이걸로 개발환경 세팅하는건 준비가 끝났다. 그리고 git clone으로 xv6-riscv 리포지토리를 다운로드 받고, Makefile 파일에서 명시가 되어 있는대로 make qemu
명령을 실행하면 일단은 Hello World를 띄울 수는 있게 되는 셈이다.
2025-08-06 01:30
Hello World는 끝났다. Hello World도 끝났겠다, 소스코드를 하나하나 다 까보고 있는데, 이럴수가.. 어떻게 보면 당연한 소리겠지만, 외부 라이브러리를 끌어다 쓰지 않는다. stdmem.h, stdio.h 등 우리가 통상적으로 C 언어를 학습할 때 가져다 쓰는 표준 헤더 파일을 가져다 쓰는 것이 아니라 가져다 쓰는 모든 것들이 같은 프로젝트 안에서 하나하나 자체 구현되어 있다. 운영체제를 밑바닥에서 구축하는 실습 과제이니 어떻게 보면 당연할 수도 있겠다. 덕분에 소스코드 하나하나를 음미하는 맛은 있을 것 같다.
암튼, 이왕 할거면 재밌게 실습하자는 의미에서, 부트시퀀스가 일어나는동안 릭롤링 아스키아트가 화면에 출력되도록 main.c
파일을 커스터마이징했다.
이제 모든 준비가 끝났다. 헬로월드를 띄웠으니, 과제로 넘어가보자.
H.1 Intro Project
이젠 진짜 시작이라고 볼 수 있는 H.1 Intro Project 스텝에 도전할 차례다. 과제에 대한 설명은 여기에 있다.
2025-08-06 23:30
H.1 과제에서 해야하는 일은 나만의 시스템 콜(int getreadcount(void)
)를 정의하는 것이다. getreadcount
함수를 정의하는 것 자체의 요구사항은 어렵지 않다. 커널이 부팅된 이후로 유저 프로세스에서 read()
시스템 콜이 몇번 호출되었는지만 반환하면 된다. 간단하게 접근해보자면, 이렇게 접근할 수 있을 것이다.
- 어떤 카운터 변수(
readcount
)를 정의한다. read()
시스템 콜이 호출될 때마다 카운터 변수의 값이 1씩 증가하게 한다 (ex.readcount += 1
)getreadcount()
함수를 호출했을 때,readcount
변수에 들어있는 값을 출력한다.
접근만 생각했을때는 굉장히 간단하다. 근데, 어떻게 시스템 콜을 정의할 지랑, 시스템 콜을 올바르게 정의했는지를 어떻게 테스트하는지가 관건이다.
심층 분석
먼저, 시스템 콜을 어떻게 정의하는지를 살펴보자.
2025-08-08 08:50
OSTEP에 따르면, Chapter 5(Mechanism: Limited Direct Execution)에서 시스템 콜(System Call)에 대해서 설명하고 있다. 적당히 요약하자면,
시스템 콜은 유저 프로그램에서 파일시스템 접근/프로세스 생성/메모리 확보 등을 할 수 있도록 커널에서 functionality를 제공해주는 것이다.
시스템 콜을 실행하기 위해서, 프로그램은 반드시 trap 이라는 특별한 instruction을 실행해야 한다. 이 instruction은 kernel의 trap table로 점프하며 kernel mode로 privilege가 바뀌게 된다. kernel mode에서 privileged operation을 실행하고 나면, OS에서는 다시 return-from-trap이라는 instruction을 실행하여 user mode로 다시 복귀한다.
2025-08-13 20:30
어떤 시스템 콜을 사용할 수 있는지는 kernel/syscall.c, kernel/syscall.h에 정의되어 있다. user/user.h에서는 유저 레벨에서 커널에 접근할 수 있도록 어떤 시스템 콜을 호출할 수 있는지 나열되어 있고, kernel/sysfile.c에 각 시스템 콜이 어떻게 세부적으로 구현되어 있는지 볼 수 있다. 그리고, user/usys.pl 스크립트에서는 각 시스템 콜을 호출하는 어셈블리 코드를 자동 생성하고 있다.
각각의 코드들이 어떻게 유기적으로 연결되는지 살펴보자. write
시스템 콜을 예시로 들어보겠다
유저 모드에서의 trap
유저 모드에서 내가 write()
같은 함수를 부르면, 사실은 **usys.S
**에 있는 아주 짧은 어셈블리 코드가 실행된다. 이 코드는 해당 시스템 콜 번호를 a7
레지스터에 넣고, ecall
을 호출한다. 이 순간, CPU는 유저 모드 → 커널 모드로 전환되고, 트랩 벡터에 등록된 진입점으로 점프한다.
# user/usys.S
...
.global write
write:
li a7, SYS_write
ecall
ret
...
커널 모드로 진입(usertrap)
커널 쪽에서는 trap.c
의 usertrap()
함수가 호출된다. 여기서 "이건 시스템 콜 트랩이구나"를 인식하면 syscall()
함수를 호출해서 실제 시스템 콜 처리 경로로 넘겨준다.
// user/trap.c
void
usertrap(void)
{
...
syscall();
...
usertrapret();
}
시스템 콜 호출
kernel/syscall.h에는 각각의 시스템 콜에 대한 system call number라는 것이 assign(ex. fork는 1번, close는 21번)이 되어 있고, kernel/syscall.c에는 시스템 콜 테이블을 각각의 system call number로 할당된 시스템 콜을 모아놓은 배열로서 syscalls
배열로 정의하고 있다.
// kernel/syscall.h
#define SYS_write 16
// kernel/syscall.c
static uint64 (*syscalls[])(void) = {
...
[SYS_write] sys_write,
};
syscall(void)
함수는 현재 프로세스의 trapframe
에서 **a7
값(=시스템 콜 번호)**을 읽고, 그 번호에 맞는 함수를 syscalls[]
배열에서 찾아 호출한다. 그 결과값은 다시 trapframe->a0
에 저장되는데, 이 값이 유저 모드로 돌아갔을 때 C 함수의 반환값이 된다.
// kernel/syscall.c
void
syscall(void)
{
int num;
struct proc *p = myproc();
num = p->trapframe->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
// Use num to lookup the system call function for num, call it,
// and store its return value in p->trapframe->a0
p->trapframe->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
그리고, 다시 user/usys.S 코드를 보면, SYS_write
라는 시스템 콜 번호를 a7 레지스터에 적재해놓고 ecall 명령어를 실행하면, 시스템 콜 테이블에 등록된 시스템 콜(sys_write
)을 호출하는 부분을 확인할 수 있는데, 이 함수가 어디에 정의가 되어있는지 확인을 하고 싶다면... kernel/sysfile.c에서 sys_write
가 어떻게 정의되어 있는지 확인할 수 있다.
// kernel/sysfile.c
uint64
sys_write(void)
{
struct file *f;
int n;
uint64 p;
argaddr(1, &p);
argint(2, &n);
if(argfd(0, 0, &f) < 0)
return -1;
return filewrite(f, p, n);
}
유저 모드로 복귀
시스템 콜이 끝나면 usertrapret()
이 실행돼서 커널 모드 → 유저 모드로 복귀한다. 트램폴린 코드를 거쳐 sret
명령으로 돌아가면, 유저 모드의 어셈블리 stub(usys.S
)이 반환값을 그대로 받아서 내가 호출했던 write()
함수 호출 결과로 돌려준다.
// user/trap.c
void
usertrap(void)
{
...
syscall();
...
usertrapret();
}
해답
여기서 내가 할 수 있는 action item은 이렇게 볼 수 있다.
- kernel/syscall.h, kernel/syscall.c 에서 시스템 콜과 시스템 콜 번호를 짝지어주기
- kernel/sysfile.c 에서
getreadcount
시스템콜 구현하고,read
시스템 콜을 호출할 때마다readcount
변수가 증가하도록 하기 - user/user.h파일을 수정해서, 유저 모드에서
getreadcount
시스템콜을 호출할 수 있도록 빈칸 뚫어주기 - user/usys.pl 스크립트에서 생성하는 어셈블리 코드(user/usys.S)에
getreadcount
시스템 콜에 대한 stub 정의하기
먼저, user/usys.pl 파일에서는 getreadcount
시스템 콜에 대한 stub를 생성하는 코드를 추가한다.
# user/usys.pl
...
entry("getreadcount");
# user/usys.S
...
.global getreadcount
getreadcount:
li a7, SYS_getreadcount
ecall
ret
...
그리고 user/user.h 에서는 getreadcount
시스템콜을 유저모드에서 호출할 수 있도록 선언하고
// user/user.h
uint64 getreadcount(void)
systcall.h에서는 getreadcount
시스템 콜의 번호를 정의한다.
// kernel/syscall.h
#define SYS_getreadcount 22
syscall.c에서는 syscalls
배열에 시스템 콜 번호(SYS_getreadcount
)와 getreadcount
시스템 콜을 짝지어 준다.
// kernel/syscall.c
extern u64t getreadcount(void);
...
static uint64 (*syscalls[])(void) = {
...
[SYS_write] sys_write,
};
그리고 sysfile.c에서는 전역 범위에서 사용되는 readcount
변수를 정의 후, read
함수가 정의된 부분에 readcount
변수가 증가하는 코드를 집어넣고, getreadcount
함수를 정의하면 된다.
// kernel/sysfile.c
static u64t readcount;
...
uint64
sys_read(void)
{
struct file *f;
int n;
uint64 p;
readcount += 1;
argaddr(1, &p);
argint(2, &n);
if(argfd(0, 0, &f) < 0)
return -1;
return fileread(f, p, n);
}
...
uint64
sys_getreadcount(void)
{
return readcount;
}
추가적인 고민 : getreadcount의 원자성 보장하기
2025-08-13 23:00
여기에 문제가 하나 있다. read
를 할 때마다 readcount
라는 값이 원자적으로 증가한다는 것을 어떻게 보장할 수 있을까? 정답은 readcount
변수의 선언부에 _Atomic
지시어를 추가하는 것이다. _Atomic
지시어로 선언된 변수는 이에 대한 증감연산자로 인한 연산이 원자적이라는 것을 보장한다. _Atomic
타입 지시어에 대한 설명은 여기를 참고해보자
그렇다면, 실제로 원자적인지 아닌지는 어떻게 프로그램으로 테스트할 수 있을까? 우리가 해볼 수 있는 것은 프로세스를 여러개 fork 띄우고, 거기다가 read를 시행하는 횟수를 엄청 늘리는 것이다. 엄청 큰 파일을 읽는다고 하더라도 그걸 재현하는데에는 무리가 있으니, 그냥 무의미한 read 시스템콜을 계속 호출하게 두는 것이다.
따라서, 실험용 프로그램은 이렇게 짤 수 있을 것이다. 이제는 시스템 콜을 올바르게 정의했는지를 테스트할 수 있다.
// h1_readcount.c
#include "kernel/types.h"
#include "kernel/fcntl.h"
#include "kernel/stat.h"
#include "user/user.h"
int
main(int argc, char *argv[])
{
int nproc = 4;
int loops = 10000;
if(argc == 3){
nproc = atoi(argv[1]);
loops = atoi(argv[2]);
}
uint64 before = getreadcount();
for(int i = 0; i < nproc; i++){
if(fork() == 0){
char dummy;
for(int j = 0; j < loops; j++){
read(0, &dummy, 0);
}
exit(0);
}
}
for(int i = 0; i < nproc; i++)
wait(0);
uint64 after = getreadcount();
uint64 delta = after - before;
uint64 expected = (uint64)nproc * (uint64)loops;
printf("before=%lu after=%lu delta=%lu expected=%lu\n",
before, after, delta, expected);
if(delta == expected)
printf("PASS: atomic increments\n");
else
printf("FAIL: lost increments\n");
exit(0);
}
_Atomic
지시어를 추가하지 않은 버전의 실행결과
$ h1_readcount 10 10000
before=22 after=100021 delta=99999 expected=100000
FAIL: lost increments
$ h1_readcount 10 10000
before=100043 after=200042 delta=99999 expected=100000
FAIL: lost increments
$ h1_readcount 10 10000
before=200064 after=300063 delta=99999 expected=100000
FAIL: lost increments
_Atomic
지시어를 추가한 버전의 실행결과
$ h1_readcount 100 1000
before=22 after=61022 delta=61000 expected=100000
FAIL: lost increments
$ h1_readcount 10 10000
before=61044 after=161044 delta=100000 expected=100000
PASS: atomic increments
$ h1_readcount 10 10000
before=161066 after=261066 delta=100000 expected=100000
PASS: atomic increments
$ h1_readcount 10 10000
before=261088 after=361088 delta=100000 expected=100000
PASS: atomic increments
$ h1_readcount 10 10000
before=361110 after=461110 delta=100000 expected=100000
따라서, readcount
변수 선언부에 _Atomic
지시어를 추가하는 것으로, 여러 프로세스에서 read 시스템콜을 호출하더라도 readcount
가 원자적으로 증가하는 것을 보장할 수 있었고, getreadcount
시스템 콜에서도 정확히 read 시스템 콜을 호출한 수만큼 값이 반환되는 것을 확인할 수 있었다.
정리
_Atomic
지시어를 사용해 다중 프로세스 환경에서도 readcount 증가의 원자성을 확보했다.- xv6의 시스템 콜 정의 구조(syscall.h, syscall.c, usys.pl ...)를 직접 손보며 전체 흐름을 익혔다.
위의 삽질의 흔적들은 https://github.com/malkoG/xv6-riscv/tree/h1-getreadcount 여기에서 구경이 가능하다.
Reference
- _Atomic 타입 지시어에 대한 설명 : https://en.cppreference.com/w/c/language/atomic.html
- RISC-V 어셈블리에서 시스템 콜을 호출할때 시스템 콜 번호를 왜 a7 레지스터에 적재하는지에 대해 설명되어 있다. https://man7.org/linux/man-pages/man2/syscall.2.html
- RISC-V 어셈블리 코드를 해석하는 데 있어서 도움이 되었던 Cheatsheet : https://projectf.io/posts/riscv-cheat-sheet/