전자공학을 전공할 때 부터 필자는 버킷 리스트에 하나가 추가되었다.
‘언젠가는 트랜지스터를 사용해서 나만의 컴퓨터를 직접 설계해야지. 그리고 거기에 나만의 OS를 올려야지. 그러고나서는 나만의 언어로 나만의 시스템을 만들어봐야지.’
물론 당시에는 컴퓨터 분야에 큰 지식이 없었기 때문에, 그냥 마음가짐으로 끝나버리긴 했다.(사실 책을 봐도 잘 이해가 안됐다.)
그러다 문뜩 왠지 지금이라면 가능할것같다는 생각이 들어 한번 정리해보려고 한다. 물론 바닥부터 혼자 상상하는건 아니고, 기존에 누군가 해놓은 레퍼런스를 따라가면서 조금씩 커스터마이징을 해볼 계획이다.
1. 환경 구축#
아무래도 내 물리 파티션에 개발중인 데이터를 그대로 덮어씌우면서 작업할 순 없다. 하지만 우리에게는 가상화라는 굉장히 유용한 도구가 있다. 이 프로젝트는 가상화 도구를 사용해서 진행할 것이다.
우선은 다음 1000줄로 OS만들기 글을 먼저 참고해보려고 한다.
링크: https://operating-system-in-1000-lines.vercel.app/en/
1
2
3
4
5
6
7
| # install tools
brew install llvm lld qemu
# set enviroment vars
$ export PATH="$PATH:$(brew --prefix)/opt/llvm/bin"
$ which llvm-objcopy
/opt/homebrew/opt/llvm/bin/llvm-objcopy
|
llvm, lld, qemu를 설치해주고 환경변수에 추가해준다. llvm은 컴파일러 lld는 링커, qemu는 가상화 도구다
필자는 맥북을 쓰고있긴하지만 하이퍼바이저도 있기 때문에 우분투에서 한번 진행을 해보겠다.
1
2
3
4
5
| #instamm tools
sudo apt update && sudo apt install -y clang llvm lld qemu-system-riscv32 curl
# download opensbi
curl -LO https://github.com/qemu/qemu/raw/v8.0.4/pc-bios/opensbi-riscv32-generic-fw_dynamic.bin
|
2. 커널 부팅하기#
컴퓨터는 어떻게 부팅될까? 필자가 디지털 포렌식 강의를 할때도 상당히 중요하게 다루는 주제다. 과연 우리 컴퓨터는 어떤 원리로, 어떤 로직으로 부팅이 시작되고 OS 커널을 메모리에 로드하는 걸꺼? 부팀은 다음 순서로 이루어진다
- POST(Power On Self Test)
- MBR or GPT 해석 후 부팅가능 파티션으로 이동
- 부팅 가능 파티션에서 부트 코드 실행
- OS 로드
POST과정과 MBR을 로드하는 것 까지는 Basic Input Output System(BIOS) 또는 Unified Extensible Firmware Interface(UEFI)의 역할이다.
자세한 내용은 다음 글을 참고해보자 MBR과 GPT in NTFS 파일 시스템
다만 벌써부터 MBR영역을 정의하는 어셈블리 코드나, 부트로더까지 가기는 좀 이르기때문에 먼저 커널 코드를 만들어보자.
2-1. Linker Script 작성하기#
Linker Script는 실행가능(Executable)한 파일의 메모리 레이아웃을 정의한다.
kernel.ld 파일을 만들고 다음 코드를 입력하자. ld는 bare-metal 시스템에 커널이나 부트로더를 빌드할때 사용하는 스크립트다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| /* kernel.ld */
/* ELF 바이너리의 시작 지점으로 boot 심볼(함수)를 지정 */
ENTRY(boot)
SECTIONS {
/* 현재 위치를 0x80200000 으로 설정 */
/* 이후 섹션은 이 주소를 기준으로 오프셋이 계산 */
/* 0x80200000 주소는 커널이나 부트로더가 적재되는 주소로 자주 사용 */
/* 1024 * 1024 *2 = 2,097,152 = 0x20000 즉 2MB 의 여유 */
. = 0x80200000;
/* 코드 영역 */
.text :{
KEEP(*(.text.boot));
/* .text와 .text.로 시작하는 모든 섹션을 해당 위치에 배치 */
*(.text .text.*);
}
/* 읽기 전용 데이터 영역, 상수 or const */
/* 4bytes 정렬 */
.rodata : ALIGN(4) {
*(.rodata .rodata.*);
}
/* 읽기 쓰기 가능한 데이터 영역 */
.data : ALIGN(4) {
*(.data .data.*);
}
/* 읽기 쓰기 가능한 전역 변수 영역 */
/* 시작주소 __bss, 끝 주소 */
/* . 은 현재 주소를 의미 __xxx 는 심볼 표시 */
/* 심볼은 c에서 extern 으로 참조 가능 */
.bss : ALIGN(4) {
__bss = .;
*(.bss .bss.* .sbss .sbss.*);
__bss_end = .;
}
/* 스택 공간 확보 */
/* 커서 정렬 후 4bytes 정렬, 128kb 공간 확보 */
/* __stack_top 심볼은 스택 포인터 초기화용으로 호출 가능 */
. = ALIGN(4);
. += 128 * 1024; /* 128KB */
__stack_top = .;
}
|
2-2. kernel.c 파일 작성하기#
kernel.c 파일을 생성하고 다음 코드를 작성하자. 주석은 지워도 상관없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| // kernel.c
typedef unsigned char uint8_t;
typedef unsigned int uint32_t;
typedef uint32_t size_t;
// 링커스크립트 ld 에서 정의되는 메모리 주소 심볼
extern char __bss[], __bss_end[], __stack_top[];
// 메모리 초기화 함수
void *memset(void *buf, char c, size_t n) {
uint8_t *p = (uint8_t *) buf;
while (n--)
*p++ = c;
return buf;
}
// 커널 시작점
// bss 영역 메모리 초기화
// 추후 장치 초기화, 메모리 맵, 인터럽트 등 구성 가능능
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
for (;;);
}
// GNU 확장 인라인 어셈블리 문법
// __asm__ __volatile__ (
// "assembly instructions"
// : output_operands
// : input_operands
// : clobbered_registers
// );
// __attribute__((naked)): 컴파일러가 인라인 어셈블리 외에 다른 코드를 생성 x
// 또 프롤로그/에필로그 없이 컴파일.
// 즉, push ra, save s0~s11, ret 같은 함수 호출 시 자동 삽입되는 코드가 생략
// section(".text.boot"):
// boot 함수를 링커 스크립트의 .text.boot에 위치하도록 명시
// mv sp ,%[stack_top]: 스택 포인터를 __stack_top으로 설정
// j kernel_main: kernel_main으로 jump
// : 출력값 x
// : [stack_top] "r" (__stack_top)
// "r" 일반 목적 레지스터에 담아서 전달하라는 의미
// __stack_top(ld에 선언된 심볼) 값을 임의 레지스터에 입력
// stack_top 이라 alias 를 붙임 %[stack_top] 로 사용
__attribute__((section(".text.boot")))
__attribute__((naked))
void boot(void) {
__asm__ __volatile__(
"mv sp, %[stack_top]\n" // Set the stack pointer
"j kernel_main\n" // Jump to the kernel main function
:
: [stack_top] "r" (__stack_top) // 입력 피연산자
);
}
|
2-3. run.sh 수정#
자 그럼 이제 run.sh 파일을 수정하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| #!/bin/bash
set -xue
QEMU=qemu-system-riscv32
# clang 경로와 컴파일 옵션
# 현재 필자는 clang이 환경변수에 등록돼있으므로 컴파일로 위치는 clang으로 충분하다
# 만약 동작하지 않는다면 which clang 명령어로 정확한 위치를 확인해보자.
CC=clang
CFLAGS="-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32-unknown-elf -fno-stack-protector -ffreestanding -nostdlib"
# 커널 빌드
$CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf kernel.c
# QEMU 실행
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot -kernel kernel.elf
|
빌드 구문이 추가되었다. 빌드 옵션에 대한 상세 설명은 다음과 같다.
옵션 | 설명 |
---|
-std=c11 | C11 표준을 사용하여 컴파일 |
-O2 | 효율적인 기계어 생성을 위한 최적화 활성화 |
-g3 | 최대한의 디버그 정보를 생성 |
-Wall | 주요 경고 메시지 활성화 |
-Wextra | 추가 경고 메시지 활성화 |
--target=riscv32-unknown-elf | 32비트 RISC-V 대상 타겟으로 컴파일 |
-ffreestanding | 호스트 환경의 표준 라이브러리를 사용하지 않음 (독립 환경 구성) |
-fno-stack-protector | 불필요한 스택 보호 기능 비활성화 (스택 직접 조작 시 유용) |
-nostdlib | 표준 라이브러리를 링크하지 않음 |
-Wl,-Tkernel.ld | 지정한 kernel.ld 링커 스크립트 사용 |
-Wl,-Map=kernel.map | 링커 메모리 배치 결과를 kernel.map 파일로 출력 |
2-4. 실행해보기#
./run.sh 명령으로 생성한 셸 스크립트 파일을 실행하면, 커널이 빌드되면서 실행된다.
만약 실행 권한이 없다면 chmod +x runsh 명령으로 실행 권한을 부여해주자.
실행하면 다음 결과를 확인할 수 있다.

당장은 쉘과 아무런 상호작용을 할 수 없겠지만, ctrl+a, c를 순차적으로 입력하면 qemu 쉘이 떨어진다.
qemu 명령 키는 다음을 참고해보자.
키 조합 | 설명 |
---|
C-a h | 도움말 출력 (print this help) |
C-a x | 에뮬레이터 종료 (exit emulator) |
C-a s | 디스크 데이터를 파일로 저장 (스냅샷 모드에서) |
C-a t | 콘솔 타임스탬프 토글 |
C-a b | break 신호 전송 (magic sysrq) |
C-a c | 콘솔과 모니터 사이 전환 |
C-a C-a | C-a 문자 자체를 전송 |
링커 스크립트에서 ENTRT(boot)라 정의했기 때문에 boot 함수에서 부터 시작된다.
boot 함수는 다음과 같이 정의되어있다. 스택의 끝 주소(=시작점, 스택은 높은 주소에서 낮은 주소로)를 sp(stack pointer)에 대입한다.
그리고는 kernel_main 함수로 점프한다.
빌드 후 확인해보면 다음 결과를 확인할 수 있다.

원본 결과와는 약간다른데…
1
2
3
4
5
6
7
8
9
10
11
| 원본 결과
kernel.elf: file format elf32-littleriscv
Disassembly of section .text:
80200000 <boot>:
80200000: 37 05 22 80 lui a0, 0x80220
80200004: 13 05 c5 04 addi a0, a0, 0x4c
80200008: 2a 81 mv sp, a0
8020000a: 6f 00 a0 01 j 0x80200024 <kernel_main>
|
boot 함수에서 a0 레지스터에 0x80220(000) 값을 할당하고, 0x4c 값을 더하고 있다.
그리고 이 값을 sp(스택포인터)로 지정하고 kernel main 함수로 점프한다.
(0x4c는 왜더하는 걸까..) 뭔가 컴파일러 최적화 과정에서 차이가 있는것 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| 내 결과
80200024 <kernel_main>:
80200024: 37 05 20 80 lui a0, 0x80200
80200028: 13 05 c5 04 addi a0, a0, 0x4c
8020002c: b7 05 20 80 lui a1, 0x80200
80200030: 93 85 c5 04 addi a1, a1, 0x4c
80200034: 33 86 a5 40 sub a2, a1, a0
80200038: 01 ca beqz a2, 0x80200048 <kernel_main+0x24>
8020003a: 13 06 15 00 addi a2, a0, 0x1
8020003e: 23 00 05 00 sb zero, 0x0(a0)
80200042: 32 85 mv a0, a2
80200044: e3 1b b6 fe bne a2, a1, 0x8020003a <kernel_main+0x16>
80200048: 01 a0 j 0x80200048 <kernel_main+0x24>
|
kernel main 함수에서 a1과 a0을 빼서 a2에 저장한 뒤 bnqz 계산후 0이면 메인으로 다시 점프뛰는데 까지가 memset 구문인것 같다.
아마 실제 값이 0이므로 memset 함수 위치를 call 하는 구문 보다는 바로 jump 하도록 inlne 함수가 구현된것 같다.
그리고 마지막에는 j 0x80200048 <kernel_main+0x24> 로 제자리 무한루프를 도는 것으로 보아 맞게 디스어셈블 된것 같기는 하다.
다음 명령으로도 주소값을 확인해볼 수 있다.


3. 문자열 출력해보기#
실행 경로에 opensbi-riscv32-generic-fw_dynamic.bin 파일이 존재해야 한다
3-1. kernel.c 에 문자열 출력함수 추가#
kernel.c 파일을 다음과 같이 수정해주자
//new 주석이 달린 코드는 새로 추가된 코드들이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
#include "kernel.h"
typedef unsigned char uint8_t;
typedef unsigned int uint32_t;
typedef uint32_t size_t;
// new
// Supervisor Binary Interface(SBI)를 통해 ecall 요청 코드
// fid: Function ID Extension 내의 세부 기능 식별
// eid: extension ID 어떤 SBI Extension을 호출할지
// eid 1 : Console I/O 확장 기능, fid 0 : 문자 1개 출력 함수
struct sbiret sbi_call(long arg0, long arg1, long arg2, long arg3, long arg4,
long arg5, long fid, long eid) {
register long a0 __asm__("a0") = arg0;
register long a1 __asm__("a1") = arg1;
register long a2 __asm__("a2") = arg2;
register long a3 __asm__("a3") = arg3;
register long a4 __asm__("a4") = arg4;
register long a5 __asm__("a5") = arg5;
register long a6 __asm__("a6") = fid;
register long a7 __asm__("a7") = eid;
// ecall 명령어, SBI 함수 호출
// ecall: RISC-V 에서 System Call을 트리거
// "=r"(a0), "=r"(a1) 을 출력하는데, c에서 리턴하고있음.
// : "memory": 메모리를 클로버, 메모리에 영향을 미침을 알림
// 컴파일러에게 알려주는 역할
__asm__ __volatile__("ecall"
: "=r"(a0), "=r"(a1)
: "r"(a0), "r"(a1), "r"(a2), "r"(a3), "r"(a4), "r"(a5),
"r"(a6), "r"(a7)
: "memory");
return (struct sbiret){.error = a0, .value = a1};
}
// new
void putchar(char ch) {
// fid 0, eid 1 호출
sbi_call(ch, 0, 0, 0, 0, 0, 0, 1 /* Console Putchar */);
}
void *memset(void *buf, char c, size_t n) {
uint8_t *p = (uint8_t *) buf;
while (n--)
*p++ = c;
return buf;
}
void kernel_main(void) {
// new
const char *s = "\n\nHello World!\n";
for (int i = 0; s[i] != '\0'; i++) {
putchar(s[i]);
}
// new, cpu를 wfi 상태로 대기
for (;;) {
__asm__ __volatile__("wfi");
}
}
__attribute__((section(".text.boot")))
__attribute__((naked))
void boot(void) {
__asm__ __volatile__(
"mv sp, %[stack_top]\n" // Set the stack pointer
"j kernel_main\n" // Jump to the kernel main function
:
: [stack_top] "r" (__stack_top) // Pass the stack top address as %[stack_top]
);
}
|
3-2. kernel.h 파일 생성#
또 kernel.h 파일도 생성해주자
1
2
3
4
5
6
7
| //kernel.h
#pragma once
struct sbiret {
long error;
long value;
};
|
./run.sh로 실행하면 hello world가 출력되는 것을 볼 수 있다!

4. printf 기능 구현하기#
여러분들 c언어 처음 배울때를 생각해보자. 아마도 hello world 출력으로 시작했을 것이다
1
2
3
4
5
6
| #include<stdio.h>
int main()
{
printf("hello world!");
return 0
}
|
여담으로 stdio.h를 스트디오, 스튜디오 쩜 에이치 라고 말하는 친구들이 있는데 그러지말자..
stdio.h는 standard input outout의 줄임말이다. 에스티디아이오가 맞다
아무튼 우리는 표준 출력함수를 구현해서 커널에서 호출해볼 것이다.
4-1. kernel.c, common.c, common.h 코드 작성#
common.h 파일을 다음과 같이 작성하자
__builtin_으로 시작하는 식별자들은 컴파일러(예: clang)에서 제공하는 빌트인 기능이다. 내부 처리는 컴파일러가 알아서 해주므로, 우리는 단순히 이러한 매크로를 정의만 해두면 된다.
또 printf 함수를 선언했다. 전형적인 C 스타일이다
1
2
3
4
5
6
7
8
9
| //common.h
#pragma once
#define va_list __builtin_va_list
#define va_start __builtin_va_start
#define va_end __builtin_va_end
#define va_arg __builtin_va_arg
void printf(const char *fmt, ...);
|
common.c 파일에 printf 함수를 구현한다. printf는 kernerland 뿐만 아니라 userland 에서도 쓰일 기능이므로 별도 파일로 분리했다.
printf는 워낙 유명한 함수기때문에 따로 설명은 필요없을 것이다. 다만 표준 라이브러리에서 제공한는 printf 함수보다는 기능이 많이 빈약하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
| // common.c
#include "common.h"
void printf(const char *fmt, ...) {
va_list vargs;
va_start(vargs, fmt);
while (*fmt) {
if (*fmt == '%') {
fmt++; // Skip '%'
switch (*fmt) { // Read the next character
case '\0': // '%' at the end of the format string
putchar('%');
goto end;
case '%': // Print '%'
putchar('%');
break;
case 's': { // Print a NULL-terminated string.
const char *s = va_arg(vargs, const char *);
while (*s) {
putchar(*s);
s++;
}
break;
}
case 'd': { // Print an integer in decimal.
int value = va_arg(vargs, int);
unsigned magnitude = value; // https://github.com/nuta/operating-system-in-1000-lines/issues/64
if (value < 0) {
putchar('-');
magnitude = -magnitude;
}
unsigned divisor = 1;
while (magnitude / divisor > 9)
divisor *= 10;
while (divisor > 0) {
putchar('0' + magnitude / divisor);
magnitude %= divisor;
divisor /= 10;
}
break;
}
case 'x': { // Print an integer in hexadecimal.
unsigned value = va_arg(vargs, unsigned);
for (int i = 7; i >= 0; i--) {
unsigned nibble = (value >> (i * 4)) & 0xf;
putchar("0123456789abcdef"[nibble]);
}
}
}
} else {
putchar(*fmt);
}
fmt++;
}
end:
va_end(vargs);
}
|
printf 함수를 정의했으므로 kernel_main 함수에서 호출해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // kernel.c
#include "kernel.h"
#include "common.h"
// ... 중간 생략
void kernel_main(void) {
//new
printf("\nHello %s\n", "World!\n");
printf("1 + 2 = %d, %x \n", 1+2, 0x1234abcd);
for (;;) {
__asm__ __volatile__("wfi");
}
}
// .. 뒤 생략
|
4-2. 빌드 명령 수정#
실행 전 kernel.c 파일에서 common.c 파일에 작성된 printf 함수를 호출하고 있으므로 컴파일 옵션에서 common.c를 빌드 대상에 포함해주어야 한다.
run.sh 파일을 다음과같이 수정해주자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
#!/bin/bash
set -xue
QEMU=qemu-system-riscv32
# clang 경로와 컴파일 옵션
CC=clang # Ubuntu 등 환경에 따라 경로 조정: CC=clang
CFLAGS="-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32-unknown-elf -fno-stack-protector -ffreestanding -nostdlib"
# 커널 빌드, common.c 추가
$CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf kernel.c common.c
# QEMU 실행
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot -kernel kernel.elf
|
4-3. printf 함수 실행 결과#
./run.sh 를 실행하면

축하한다 여러분은 커널에서 호출 가능한 printf 함수를 구현했다!
5. 표준 함수 구현#
이번 장에서는 C 표준 함수들을 구현한다. 모든 C 표준을 구현할 수는 없겠지만, 메모리 & 문자열 조작 함수들을 구현해본다.
5-1. common.h 파일 수정#
먼저 common.h 파일에 다음을 코드를 작성하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| //common.h
typedef int bool;
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long uint64_t;
typedef uint32_t size_t;
typedef uint32_t paddr_t;
typedef uint32_t vaddr_t;
#define true 1
#define false 0
#define NULL ((void *) 0)
#define align_up(value, align) __builtin_align_up(value, align)
#define is_aligned(value, align) __builtin_is_aligned(value, align)
#define offsetof(type, member) __builtin_offsetof(type, member)
#define va_list __builtin_va_list
#define va_start __builtin_va_start
#define va_end __builtin_va_end
#define va_arg __builtin_va_arg
void *memset(void *buf, char c, size_t n);
void *memcpy(void *dst, const void *src, size_t n);
char *strcpy(char *dst, const char *src);
int strcmp(const char *s1, const char *s2);
void printf(const char *fmt, ...);
|
5-2. common.c 파일 수정#
함수 이름을 보면 알겠지만, memset 함수는 메모리 초기화, memcpy는 메모리 복사, strcpy는 문자열 복사, strcmp는 문자열 비교 함수다.
common.c 파일에 다음 함수들을 추가하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| // 메모리 복사
void *memcpy(void *dst, const void *src, size_t n) {
uint8_t *d = (uint8_t *) dst;
const uint8_t *s = (const uint8_t *) src;
while (n--)
*d++ = *s++;
return dst;
}
// 메모리 초기화
void *memset(void *buf, char c, size_t n) {
uint8_t *p = (uint8_t *) buf;
while (n--)
*p++ = c;
return buf;
}
// 문자열 복사
char *strcpy(char *dst, const char *src) {
char *d = dst;
while (*src)
*d++ = *src++;
*d = '\0';
return dst;
}
// 문자열 비교
int strcmp(const char *s1, const char *s2) {
while (*s1 && *s2) {
if (*s1 != *s2)
break;
s1++;
s2++;
}
return *(unsigned char *)s1 - *(unsigned char *)s2;
}
|
이제 여러분은 메모리 와 문자열을 핸들링할 수 있게되었다!
6. 커널 패닉#
운영체제를 사용하다 보면 종종 커널 패닉을 마주한다. 여러분은 윈도우 95, 98, me 등 MS-DOS 기반의 운영 체제를 사용 해 보았는가? 필자의 첫 OS는 Windows 98 이었다.
Windows 98은 심심하면 블루스크린을 띄우곤 했다. 심지어 빌게이츠 발표에서도 블루스크린을 띄워 망신을 당하기도 했다.(지금도 윈도우 환경에서 docker를 사용하다 보면 블루스크린을 띄우곤 한다) 윈도우 운영체제가 내뱉는 블루 스크린이 바로 커널 패닉이다. 메모리 충돌이나 커널에 문제가 생길경우 커널은 동작을 멈추고 커널 패닉을 띄운다.
쉽게 생각해보면 커널 패닉은 운영체제 단에서 수행하는 예외처리다. 커널 패닉 기능을 구현해보자.
6-1. kernel.h 파일 수정#
kernel.h 파일에 커널 패닉 매크로를 작성한다.
1
2
3
4
5
6
7
8
| //kernel.h
// 커널 패닉 발생 시 파일 명과 라인을 출력
#define PANIC(fmt, ...) \
do { \
printf("PANIC: %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__); \
while (1) {} \
} while (0)
|
6-2. kernel.c 파일 수정#
kernel.c 파일의 kernel_main 함수 끝에 다음 구문을 추가하고 실행해보자
1
2
3
4
5
6
7
8
9
10
11
| // kernel.c
// ... 중간 생략
void kernel_main(void) {
...
PANIC("booted!");
printf("unreachable here!\n");
}
// ... 뒤도 생략
|
실행해보면 PANIC이라는 매크로 함수로 진입하는 것을 볼 수 있다.

7. 예외 처리#
앞 장에서 커널 패닉을 구현했기때문에 이번 장에서는 당연히 예외 처리를 다루어야 한다. 커널 패닉과 예외처리는 실과 바늘같은 사이다.
예외(Exception)은 프로그래밍에서 꽤나 중요한 요소다. 적절한 예외처리를 해주지 않는다면, 프로그램이 중간에 뻗어버릴 것이다. 실력 좋은 프로그래머는 버그없는 프로그램을 만드는 것이 아니라, 예외 처리를 잘 하는 것이다 라는 말도 있다.
운영체제(커널) 단에서도 물론 예외가 발생한다. 이런 경우는 보통 잘못된 메모리 주소 참조(page fault)와 이어지기 때문에 치명적인 버그를 일으킬 수 있다. 따라서 오류가 있는 상태에서 오동작하지 않도록 미리 예외를 구현해놓아야 한다.
7-1. 예외 처리 과정#
RISC-V에서 예외는 다음과 같은 단계를 거쳐 처리된다.
CPU는 medeleg 레지스터를 확인하여 어떤 모드(운영 모드)에서 예외를 처리할지 결정한다.
여기서는 OpenSBI가 이미 U-Mode와 S-Mode 예외를 S-Mode 핸들러에서 처리하도록 설정해두었다.
CPU는 예외가 발생한 시점의 상태(각종 레지스터 값)를 여러 CSR(제어/상태 레지스터)들에 저장한다.(아래 표 참조).
stvec 레지스터에 저장된 값이 프로그램 카운터로 설정되면서, 커널의 예외 핸들러로 점프한다.
예외 핸들러는 일반 레지스터(프로그램 상태)를 별도로 저장한 뒤, 예외를 처리한다.
처리 후, 저장해둔 실행 상태를 복원하고 sret 명령어를 실행해 예외가 발생했던 지점으로 돌아가 프로그램을 재개한다다.
2번 단계에서 업데이트되는 주요 CSR은 아래와 같습니다. 커널은 이 정보들을 기반으로 예외를 처리한다:
예외처리용 레지스터#
레지스터 | 내용 |
---|
scause | 예외 유형. 커널은 이를 읽어 어떤 종류의 예외인지 판단합니다. |
stval | 예외에 대한 부가 정보 (예: 문제를 일으킨 메모리 주소). 예외 종류에 따라 다르게 사용됩니다. |
sepc | 예외가 발생했을 때의 프로그램 카운터(PC) 값. |
sstatus | 예외가 발생했을 때의 운영 모드(U-Mode/S-Mode 등) 및 기타 상태 정보. |
7-2. kernel.c 파일에 커널 예외 처리 코드 추가#
kernel.c 파일에 다음 코드를 추가하자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
| // kernel.c
__attribute__((naked))
__attribute__((aligned(4)))
void kernel_entry(void) {
__asm__ __volatile__(
// sscratch에 현재 스택포인터(sp) 저장
"csrw sscratch, sp\n"
// aligned(4)이므로 -4 * 31만큼 스택 포인터 위치 변경
// 스택은 높은 주소에서 낮은주소로 이동하기때문에 -
"addi sp, sp, -4 * 31\n"
// sw: store word, 32 bits(4 bytes) 값을 메모리에 저장
"sw ra, 4 * 0(sp)\n"
"sw gp, 4 * 1(sp)\n"
"sw tp, 4 * 2(sp)\n"
"sw t0, 4 * 3(sp)\n"
"sw t1, 4 * 4(sp)\n"
"sw t2, 4 * 5(sp)\n"
"sw t3, 4 * 6(sp)\n"
"sw t4, 4 * 7(sp)\n"
"sw t5, 4 * 8(sp)\n"
"sw t6, 4 * 9(sp)\n"
"sw a0, 4 * 10(sp)\n"
"sw a1, 4 * 11(sp)\n"
"sw a2, 4 * 12(sp)\n"
"sw a3, 4 * 13(sp)\n"
"sw a4, 4 * 14(sp)\n"
"sw a5, 4 * 15(sp)\n"
"sw a6, 4 * 16(sp)\n"
"sw a7, 4 * 17(sp)\n"
"sw s0, 4 * 18(sp)\n"
"sw s1, 4 * 19(sp)\n"
"sw s2, 4 * 20(sp)\n"
"sw s3, 4 * 21(sp)\n"
"sw s4, 4 * 22(sp)\n"
"sw s5, 4 * 23(sp)\n"
"sw s6, 4 * 24(sp)\n"
"sw s7, 4 * 25(sp)\n"
"sw s8, 4 * 26(sp)\n"
"sw s9, 4 * 27(sp)\n"
"sw s10, 4 * 28(sp)\n"
"sw s11, 4 * 29(sp)\n"
// csrr: Control and Status Register: 특정 CSR 값을 읽는 명령
// a0에 ssaratch 값 저장, 여기서는 스택포인터 값을 a0에 저장
// sscratch 에는 -4 * 31 하기 전 원래 sp 값이 저장되어 있음
"csrr a0, sscratch\n"
// ap 의 값을 다시 메모리에 저장
"sw a0, 4 * 30(sp)\n"
// 현재 sp ( sp_original - 4*31 ) 을 a0에 저장하고 handle_trap 함수에 전달
"mv a0, sp\n"
// handle_trap 함수 호출
"call handle_trap\n"
// 아마 load word, handle_trap 처리가 끝다고 메모리에 저장된 값 로드
"lw ra, 4 * 0(sp)\n"
"lw gp, 4 * 1(sp)\n"
"lw tp, 4 * 2(sp)\n"
"lw t0, 4 * 3(sp)\n"
"lw t1, 4 * 4(sp)\n"
"lw t2, 4 * 5(sp)\n"
"lw t3, 4 * 6(sp)\n"
"lw t4, 4 * 7(sp)\n"
"lw t5, 4 * 8(sp)\n"
"lw t6, 4 * 9(sp)\n"
"lw a0, 4 * 10(sp)\n"
"lw a1, 4 * 11(sp)\n"
"lw a2, 4 * 12(sp)\n"
"lw a3, 4 * 13(sp)\n"
"lw a4, 4 * 14(sp)\n"
"lw a5, 4 * 15(sp)\n"
"lw a6, 4 * 16(sp)\n"
"lw a7, 4 * 17(sp)\n"
"lw s0, 4 * 18(sp)\n"
"lw s1, 4 * 19(sp)\n"
"lw s2, 4 * 20(sp)\n"
"lw s3, 4 * 21(sp)\n"
"lw s4, 4 * 22(sp)\n"
"lw s5, 4 * 23(sp)\n"
"lw s6, 4 * 24(sp)\n"
"lw s7, 4 * 25(sp)\n"
"lw s8, 4 * 26(sp)\n"
"lw s9, 4 * 27(sp)\n"
"lw s10, 4 * 28(sp)\n"
"lw s11, 4 * 29(sp)\n"
// sp(스택 포인터)에 원래 주소값 저장
"lw sp, 4 * 30(sp)\n"
// return
"sret\n"
);
}
|
중간에 handle_trap 함수를 호출하므로 handle_trap 함수도 작성해주자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // kernel.c
// handle_trap 함수 구현체
void handle_trap(struct trap_frame *f) {
// scause, stval, sepc는 모두 레지스터
// scause는 trap (예외 or 인터럽트)의 원인 코드를 담고 있음
uint32_t scause = READ_CSR(scause);
// stval에는 trap 당시의 메모리 주소가 저장됨
uint32_t stval = READ_CSR(stval);
// sepc는는 trap 당시의 porgram counter 가 저장됨
uint32_t user_pc = READ_CSR(sepc);
// 즉 결론적으로 커널 패닉 메시지로 원인코드, 메모리주소, pc를 출력함
PANIC("unexpected trap scause=%x, stval=%x, sepc=%x\n", scause, stval, user_pc);
}
|
7-3. kernel.h 파일에 예외 처리 관련 코드 추가#
kernel.h 파일에 다음 코드들을 추가해주자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| // kernel.h
struct trap_frame {
uint32_t ra;
uint32_t gp;
uint32_t tp;
uint32_t t0;
uint32_t t1;
uint32_t t2;
uint32_t t3;
uint32_t t4;
uint32_t t5;
uint32_t t6;
uint32_t a0;
uint32_t a1;
uint32_t a2;
uint32_t a3;
uint32_t a4;
uint32_t a5;
uint32_t a6;
uint32_t a7;
uint32_t s0;
uint32_t s1;
uint32_t s2;
uint32_t s3;
uint32_t s4;
uint32_t s5;
uint32_t s6;
uint32_t s7;
uint32_t s8;
uint32_t s9;
uint32_t s10;
uint32_t s11;
uint32_t sp;
} __attribute__((packed));
// csrr: Control and Status Register Read, reg 값을 읽어 __tmp로 리턴
// 전처리에서 #연산자를 통해 입력문자열을 동적으로 처리 #reg
#define READ_CSR(reg) \
({ \
unsigned long __tmp; \
__asm__ __volatile__("csrr %0, " #reg : "=r"(__tmp)); \
__tmp; \
})
// csrr: Control and Status Register Write, 입력 값을 reg에 할당
// 이 코드 실행 결과는 다음 어셈블리와 같다
// li a0, 0xdeadbeef
// csrw sepc, a0
// __tmp는 임의의 일반 레지스터에 할당한다는 의미
// __asm__은 인라인 어셈블리 명령 다음 문자열을 assembly로 그대로 컴파일러에 삽임
// __volatilie__은 최적화 방지 플래그, 컴파일러가 자동으로 최적화하지 않도록함
// :: "r"(__tmp)
// 이건 GCC 인라인 어셈블리 제약 조건 부분입니다.
// : 앞이 없음 → 출력 없음
// :: → 입력만 있음
// "r" → 레지스터에 담아달라는 뜻 (즉, __tmp는 일반 레지스터에 값을 할당)
// 즉, 컴파일러가 __tmp 값을 적절한 레지스터 (예: a0, t0)에 자동 배치해주고,
// 어셈블리 안에서는 %0으로 그 레지스터를 참조하게 됨.
#define WRITE_CSR(reg, value) \
do { \
uint32_t __tmp = (value); \
__asm__ __volatile__("csrw " #reg ", %0" ::"r"(__tmp)); \
} while (0)
|
7-4. Kernel 예외 처리 & Panic 실행!#
그런 다음 kernel.c 파일의 kernel_main 함수에 다음 코드를 추가해주자
1
2
3
4
5
6
7
8
9
10
11
| //kernel.c
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
// new
// stvec를 설정 후
// stvec: trap 발생 시 어디로 점프할지 지정하는 코드, 위에서 정의한 kernel_entry 함수로 점프한다.
WRITE_CSR(stvec, (uint32_t) kernel_entry); // new
// 커널 패닉을 일으키는 코드, unimp 명령의 고의 에러를 일으킨다.
__asm__ __volatile__("unimp"); // new
}
|
다음과 같이 커널패닉 메시지를 내뱉으면 성공이다!

spec 주소로 해당 오류를 추적해볼 수 있다. 다음 명령을 실행해보자
llvm-addr2line -e kernel.elf 802000d6

오류가 발생한 명령줄 라인을 볼 수 있다. kernel.c 파일의 96번째 라인으로 가보자

정확하게 고의 에러를 일으킨 asm volatile(“unimp”); // new 코드를 볼 수 있다.
8. 메모리 할당#
메모리 핸들링은 운영체제가 수행하는 역할 중 가장 중요한 작업이 아닐까 생각한다. 물론 프로세스 스케쥴링도 중요하지만, 옜날 멀티프로세싱을 지원하지 않던 컴퓨터에서도 메모리 관리 기능은 반드시 존재했다.
현재 운영체제에 들어서는 이 메모리 공간의 효율적 관리와 다른 프로세스의 메모리공간을 침범하지 않기 위한 다양한 로직들이 적용되어 있다.
8-1. 링커 스크립트 수정하기#
1장에서 작성했던 링커 스크립트를 조금 수정해보자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
ENTRY(boot)
SECTIONS {
. = 0x80200000;
.text :{
KEEP(*(.text.boot));
*(.text .text.*);
}
.rodata : ALIGN(4) {
*(.rodata .rodata.*);
}
.data : ALIGN(4) {
*(.data .data.*);
}
.bss : ALIGN(4) {
__bss = .;
*(.bss .bss.* .sbss .sbss.*);
__bss_end = .;
}
. = ALIGN(4);
. += 128 * 1024; /* 128KB */
__stack_top = .;
/* new */
. = ALIGN(4096);
__free_ram = .;
. += 64 * 1024 * 1024; /* 64MB */
__free_ram_end = .;
}
|
새로 추가된 코드는 메모리를 64MB 위치부터 커서를 4KB 크기(페이지 크기)로 맞춰준다.
__free_ram = .; 로 심볼에 현재 위치를 저장하고 64MB 이동 후 __free_ram_end = .;로 해당 위치를 저장해준다.
이렇게 하드코딩된 주소 대신 링커 스크립트에서 정의함으로써, 커널의 정적 데이터와 겹치지 않도록 링커가 위치를 자동으로 조정할 수 있다.
8-2. 메모리 할당 알고리즘#
운영체제 수업시간에 메모리 할당 알고리즘에 대해 공부해보았을 것이다.(사실 잘 기억이안난다.. 컴퓨터를 잘 모를때라)
최초 적합, 최적 적합 등의 알고리즘이 있었던 것으로 기억하는데, 본 포스팅에서는 원본 글을 따라서 그냥 페이지 단위로 메모리를 할당해보겠다.
kernel.c 파일에 다음 코드를 추가해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // kernel.c
extern char __free_ram[], __free_ram_end[];
paddr_t alloc_pages(uint32_t n) {
static paddr_t next_paddr = (paddr_t) __free_ram;
paddr_t paddr = next_paddr;
next_paddr += n * PAGE_SIZE;
if (next_paddr > (paddr_t) __free_ram_end)
PANIC("out of memory");
memset((void *) paddr, 0, n * PAGE_SIZE);
return paddr;
}
|
해당 코드는 페이지 개수를 입력으로 받아 페이지 수 * 페이지 크기 만큼의 공간을 할당하고 그 값을 0으로 초기화한다.
현재 코드에는 메모리 해제 기능이 없지만, 이건 나중에 추가해보도록 하자
PAGE_SIZE 변수가 정의되어 있지 않기 때문에 common.h 파일에 다음과 같이 매크로를 이용해 선언해준다.
1
2
| //common.h
#define PAGE_SIZE 4096
|
메모리 할당 함수를 정의 했으므로 kernel_main 에서 테스트 코드를 작성해보자
1
2
3
4
5
6
7
8
9
10
11
| // kernel.c
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
paddr_t paddr0 = alloc_pages(2);
paddr_t paddr1 = alloc_pages(1);
printf("alloc_pages test: paddr0=%x\n", paddr0);
printf("alloc_pages test: paddr1=%x\n", paddr1);
PANIC("booted!");
}
|
코드를 추가하고 실행하면

alloc_pages test: paddr0=80221000
80221000 B __free_ram
정상 동작하는것을 확인할 수 있다.
9. 프로세스#
필자가 수업을 할때도 종종 하곤 하는 질문이다. 프로세스가 과연 뭘까? 프로그램과 프로세스의 차이는 뭘까?
여러분은 설명할 수 있는가? 정확한 정의는 아니겠지만, 필자는 프로세스와 프로그램을 이렇게 설명하고 싶다.
프로그램은 보조 저장장치에 저장되어 실행을 기다리고 있는 실행 가능한 상태의 코드 & 데이터들 이다. 프로세스는 이 프로그램이 메모리 공간을 할당받고, 메모리에 적재 후 실행가능한 상태로 만든 실행 단위를 의미한다.
이전 메모리 챕터에서 운영체제는 다양한 기능들을 하지만, 가장 중요한 역할은 메모리 핸들링과 프로세스 스케쥴링 이라고 했다.
이번 포스팅에서는 프로세스에 대해서 다줘보자.
9-1. PCB 정의#
윈도우든 유닉스든 리눅스든 모든 프로세스는 Process Control Block(PCB) 라는 영역을 가지고 있다.
해당 영역은 프로세스만의 고유한 정보를 기록하여 커널이 이를 관리하고 또 userland에서 커널에 system call을 통해 요청 시 해당 정보를 반환하는 등 용도로 사용된다.
kernel.h 파일에 PCB 영역을 다음과 같이 정의해보자
1
2
3
4
5
6
7
8
9
10
11
12
| //kernel.h
#define PROCS_MAX 8 // 최대 프로세스 개수
#define PROC_UNUSED 0 // 사용되지 않는 프로세스 구조체
#define PROC_RUNNABLE 1 // 실행 가능한(runnable) 프로세스
struct process {
int pid; // 프로세스 ID
int state; // 프로세스 상태: PROC_UNUSED 또는 PROC_RUNNABLE
vaddr_t sp; // 스택 포인터
uint8_t stack[8192]; // 커널 스택
};
|
9-2. Context Switching 정의#
우리는 멀티 프로세싱의 환경에 살고있다. 여러분들은 유튜브로 음악을 들으면서 문서 작업을 하거나 게임을 하는 등 동시에 여러가지 작업을 수행할 수 있다.
만약 운영체제가 이런 멀티 프로세싱을 지원하지 않는다면 어떨까? 한번에 단 한가지 작업밖에는 할 수 없을 것이다.
멀티 프로세싱을 근본적으로는 굉장히 단순무식한 방법을 사용한다. CPU의 연산 속도가 굉장히 빠른 것을 이용해 아주 짧은 시간마다 프로세스를 전환하는 것이다.
이 때 프로세스를 전환할때 수행되는 작업이 바로 context switching 이다.
kernal.c 파일에 다음 코드를 추가해보자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| __attribute__((naked)) void switch_context(uint32_t *prev_sp,
uint32_t *next_sp) {
__asm__ __volatile__(
// 현재 프로세스의 스택에 callee-saved 레지스터를 저장
"addi sp, sp, -13 * 4\n" // 13개(4바이트씩) 레지스터 공간 확보
"sw ra, 0 * 4(sp)\n" // callee-saved 레지스터만 저장
"sw s0, 1 * 4(sp)\n"
"sw s1, 2 * 4(sp)\n"
"sw s2, 3 * 4(sp)\n"
"sw s3, 4 * 4(sp)\n"
"sw s4, 5 * 4(sp)\n"
"sw s5, 6 * 4(sp)\n"
"sw s6, 7 * 4(sp)\n"
"sw s7, 8 * 4(sp)\n"
"sw s8, 9 * 4(sp)\n"
"sw s9, 10 * 4(sp)\n"
"sw s10, 11 * 4(sp)\n"
"sw s11, 12 * 4(sp)\n"
// 스택 포인터 교체
"sw sp, (a0)\n" // *prev_sp = sp
"lw sp, (a1)\n" // sp를 다음 프로세스의 값으로 변경
// 다음 프로세스 스택에서 callee-saved 레지스터 복원
"lw ra, 0 * 4(sp)\n"
"lw s0, 1 * 4(sp)\n"
"lw s1, 2 * 4(sp)\n"
"lw s2, 3 * 4(sp)\n"
"lw s3, 4 * 4(sp)\n"
"lw s4, 5 * 4(sp)\n"
"lw s5, 6 * 4(sp)\n"
"lw s6, 7 * 4(sp)\n"
"lw s7, 8 * 4(sp)\n"
"lw s8, 9 * 4(sp)\n"
"lw s9, 10 * 4(sp)\n"
"lw s10, 11 * 4(sp)\n"
"lw s11, 12 * 4(sp)\n"
"addi sp, sp, 13 * 4\n"
"ret\n"
);
}
|
코드 내용은 별것 없다. 스택 영역에 현재 레지스터 값을 저장하고 다음 프로세스의 레지스터 값을 불러오는 것이다.
attribute((naked))는 컴파일러가 이 함수에서 인라인 어셈블리 asm 이외에 다른 코드를 생성하지 않도록 해 준다.
9-3. 프로세스 생성 함수 작성#
자 이제 프로세스를 생성하는 함수를 작성해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| //kernel.c
struct process procs[PROCS_MAX]; // 모든 프로세스 제어 구조체 배열
struct process *create_process(uint32_t pc) {
// 미사용(UNUSED) 상태의 프로세스 구조체 찾기
struct process *proc = NULL;
int i;
for (i = 0; i < PROCS_MAX; i++) {
if (procs[i].state == PROC_UNUSED) {
proc = &procs[i];
break;
}
}
if (!proc)
PANIC("no free process slots");
// 첫 컨텍스트 스위치 시, switch_context에서 이 값들을 복원함
// 스택 프레임 구성
// stack은 높은 주소에서 낮은 주소로 자라므로 --
uint32_t *sp = (uint32_t *) &proc->stack[sizeof(proc->stack)];
*--sp = 0; // s11
*--sp = 0; // s10
*--sp = 0; // s9
*--sp = 0; // s8
*--sp = 0; // s7
*--sp = 0; // s6
*--sp = 0; // s5
*--sp = 0; // s4
*--sp = 0; // s3
*--sp = 0; // s2
*--sp = 0; // s1
*--sp = 0; // s0
// 현재 Program Counter(PC) 저장, 프로세스 실행 시 jump 할 주소
*--sp = (uint32_t) pc; // ra (처음 실행 시 점프할 주소)
// 커널 스택에 callee-saved 레지스터 공간을 미리 준비
// process 구조체 필드 초기화
proc->pid = i + 1;
proc->state = PROC_RUNNABLE;
proc->sp = (uint32_t) sp;
return proc;
}
|
프로세스 구조체 배열에 빈 공간이 있으면 새로운 프로세스를 생성한다.
자 준비는 끝났다. 이제 프로세스 두 개를 실행해보자. kernel.c 파일에 다음 코드를 작성해주자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| // kernel.c
// delay에 원본 글보다 0을 하나 추가했는데, 만약 너무 느리다면 수치르 좀 줄여주자
void delay(void) {
for (int i = 0; i < 300000000; i++)
__asm__ __volatile__("nop"); // do nothing
}
struct process *proc_a;
struct process *proc_b;
void proc_a_entry(void) {
printf("starting process A\n");
while (1) {
putchar('A');
switch_context(&proc_a->sp, &proc_b->sp);
delay();
}
}
void proc_b_entry(void) {
printf("starting process B\n");
while (1) {
putchar('B');
switch_context(&proc_b->sp, &proc_a->sp);
delay();
}
}
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
// trap 발생 시 jump 할 위치 지정
WRITE_CSR(stvec, (uint32_t) kernel_entry);
// 함수의 시작 주소를 인자로 전달한다.
proc_a = create_process((uint32_t) proc_a_entry);
proc_b = create_process((uint32_t) proc_b_entry);
proc_a_entry();
PANIC("unreachable here!");
}
|
딜레이를 주지 않으면 문자열이 화면에 너무 빠르게 찍히기 때문에 delay를 주었다. 여기까지 진행한 후 한번 실행해보자.

프로세스가 매우 빠르게 switch 되면서 콘솔에 ABABABABABAB 문자열을 작성하는 것을 확인할 수 있다.
위의 예제에서는 A, B 프로세스에서 매번 서로다른 프로세스를 직접 호출하도록 코드를 작성했다. 하지만 프로세스가 많아진다면?
다음에 어느 프로세스가 실행될 것인지 매번 결정해서 지정할 수 있을까?
10. 프로세스 스케쥴링#
따라서 이제 우리는 프로세스를 스케쥴링 해줄 수 있는 스케쥴러 기능을 작성해야한다.
10-1. 프로세스 스케쥴링 함수 yield#
kernel.c 파일에 다음 코드를 추가하자. yield 함수는 프로세스 스케쥴링 함수이며 프로세스별로 균일한 시간을 보장하는 Round Robin 알고리즘으로 동작한다.
멀티 프로세싱을 지원하면서 프로세스 별로 각자의 스택 영역을 갖기 때문에 우리는 컨텍스트 스위칭 시 각 프로세스의 스택을 가리키도록 수정해주어야 한다.
[sscratch] “r” ((uint32_t) &next->stack[sizeof(next->stack)]) 은 일반 레지스터에에 스택 최상단 주소를 저장하라는 명령이다.
그 다음 csrw sscratch, %[sscratch]\n" 명령을 통해 일반 레지스터에 저장된 값을 sscratch 레지스터에 저장하라는 의미가 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| // kernel.c
struct process *current_proc; // 현재 실행 중인 프로세스
struct process *idle_proc; // Idle 프로세스
void yield(void) {
// 실행 가능한 프로세스를 탐색
struct process *next = idle_proc;
for (int i = 0; i < PROCS_MAX; i++) {
// Round Robin(RR) 스케쥴링
struct process *proc = &procs[(current_proc->pid + i) % PROCS_MAX];
if (proc->state == PROC_RUNNABLE && proc->pid > 0) {
next = proc;
break;
}
}
// 현재 프로세스 말고는 실행 가능한 프로세스가 없으면, 그냥 리턴
if (next == current_proc)
return;
// 컨텍스트 스위칭
struct process *prev = current_proc;
current_proc = next;
// context switch 시 stack 변경
__asm__ __volatile__(
"csrw sscratch, %[sscratch]\n"
:
: [sscratch] "r" ((uint32_t) &next->stack[sizeof(next->stack)])
);
switch_context(&prev->sp, &next->sp);
}
|
10-2.예외 핸들러 kernel_entry 함수 수정#
그럼 다음 예외 핸들러 kernel_entry 함수를 다음과 같이 수정해주자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
void kernel_entry(void) {
__asm__ __volatile__(
//fix
"csrrw sp, sscratch, sp\n"
"addi sp, sp, -4 * 31\n"
"sw ra, 4 * 0(sp)\n"
// ... 중간 생략
"sw s11, 4 * 29(sp)\n"
"csrr a0, sscratch\n"
"sw a0, 4 * 30(sp)\n"
// NEW
// Reset the kernel stack.
"addi a0, sp, 4 * 31\n"
"csrw sscratch, a0\n"
"mv a0, sp\n"
"call handle_trap\n"
// ~ continue
}
|
첫 번째 어셈블리 명령 “csrrw sp, sscratch, sp\n” 에서 csrrw 명령은 read, write를 동시에 수행하도록 한다.
sscratch 에 있는 값을 sp에 쓰고, sp에 있는 값을 다시 sscratch에 쓴다.
즉 기존 sp는 sscratch에 가고, sscratch 에 있던 값은 sp로 가는 것이다. 사실상 교환이라 봐도 된다.
코드로 표현하자면 가 되는 것이다.
1
2
3
| temp = sp;
sp = sscratch;
sscratch = temp;
|
결과적으로 sp는 현재 실행 중인 프로세스의 커널 스택이 된다.
10-3. PID 0 Idle Process 생성 후 실행#
이후 kernel_main 함수를 다음과 같이 수정해보자. 핵심은 0번 PID를 가진 idle process를 생성해주는 것이다. 따라서 운영체제가 부팅될 때 최초 idle 프로세스가 0번으로 생성되고, 우리는 여기서부터 시작할 수 있다. 어디서 많이 본 구조가 아닌가? (리눅스를 생각해보자)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
printf("\n\n");
WRITE_CSR(stvec, (uint32_t) kernel_entry);
// idle 프로세스를 0번으로 실행
idle_proc = create_process((uint32_t) NULL);
idle_proc->pid = 0; // idle
current_proc = idle_proc;
proc_a = create_process((uint32_t) proc_a_entry);
proc_b = create_process((uint32_t) proc_b_entry);
// 프로세스 생성 후 yield 호출로 프로세스 스케쥴링 시작
yield();
PANIC("switched to idle process");
}
|
마지막으로 proc_a_entry 함수와 proc_b_entry 함수를 다음과 같이 수정해주자
앞의 코드에서는 a, b 프로세스가 직접 컨텍스트 스위칭을 수행했지만 이제는 스케쥴링 함수 yield가 수행한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| void proc_a_entry(void) {
printf("starting process A\n");
while (1) {
putchar('A');
// 원본 글에는 delay가 없지만 너무 빨라서 추가해줬다.
delay();
yield();
}
}
void proc_b_entry(void) {
printf("starting process B\n");
while (1) {
putchar('B');
// 원본 글에는 delay가 없지만 너무 빨라서 추가해줬다.
delay();
yield();
}
}
|
이번 포스팅의 내용은 다소 어려울 수 있다.
코드를 천천히 읽어보고 이해 해보자.
결과는 다음과 같다.

11. 페이지 테이블#
현대 컴퓨터 운영체제에서 프로그램이 메모리에 접근할 때, CPU는 지정된 **가상 주소(virtual address)**를 **물리 주소(physical address)**로 변환해야 한다. 이 변환에 필요한 매핑 정보는 **페이지 테이블(page table)**에 저장되어 있다.
운영체제가 가상 주소 방식을 사용하는 이유는 다음과 같다:
- 프로세스마다 독립된 메모리 공간을 제공할 수 있다.
- 프로세스 간 메모리 침범을 방지하여 시스템의 안정성과 보안을 확보할 수 있다.
- 메모리 할당 및 해제를 운영체제가 유연하게 관리할 수 있다.
가상 주소를 사용하면 모든 프로세스는 0번 주소부터 시작하는 독립적인 주소 공간을 갖게 된다.
즉, 실제 물리 주소를 직접 계산하거나 신경 쓸 필요 없이 각 프로세스는 자신만의 일관된 주소 체계를 사용할 수 있다.
따라서 운영체제는 어떤 프로세스가 어느 가상 주소 영역을 사용하는지를 **페이지 테이블을 통해 추적하고 변환(offset 및 물리 매핑 정보 관리)**힌다.
또한, 페이지 테이블을 교체함으로써 동일한 가상 주소가 프로세스마다 서로 다른 물리 주소를 가리키도록 설정할 수 있다.
이를 통해 프로세스 간 메모리 공간을 격리하고, 사용자 영역과 커널 영역을 명확히 구분하여 시스템 보안을 강화할 수 있다.
이 장에서는 하드웨어 기반 메모리 격리 메커니즘을 직접 구현해본다.
메모리에 접근할 때, CPU는 VPN[1]과 VPN[0]을 계산하여 해당하는 페이지 테이블 엔트리를 찾은 뒤, 거기에 저장된 물리 기본 주소(base physical address)에 offset을 더해 최종 물리 주소를 얻는다.
11-1. 가상 주소 계산하기#
다음 표는 Sv32(32-bit RISC-V 가상 주소 체계) 에서 가상 주소(Virtual Address)를 페이지 테이블 인덱스(VPN)와 오프셋(Offest)으로 나누는 예시를 보여준다.
Virtual Address | VPN[1] (10 bits) | VPN[0] (10 bits) | Offset (12 bits) |
---|
0x1000_0000 | 0x040 | 0x000 | 0x000 |
0x1000_1000 | 0x040 | 0x001 | 0x000 |
0x1000_f000 | 0x040 | 0x00f | 0x000 |
0x2000_f0ab | 0x080 | 0x00f | 0x0ab |
0x2000_f012 | 0x080 | 0x00f | 0x012 |
0x2000_f034 | 0x080 | 0x00f | 0x034 |
0x20f0_f034 | 0x083 | 0x30f | 0x034 |
32bit 시스템이므로 메모리 대역폭도 32bit다. 즉
0 ~ 1111 1111 1111 1111 1111 1111 1111 1111 만큼의 주소를 가질 수 있는 것이다.
제일 먼저 0x1000_0000 주소를 2진수로 바꿔보자.
0x1000_0000 = 0001 0000 00 / 00 0000 0000 / 0000 0000 0000 이다.
상위 10 bit는 VPN[1], 그 다음 10 bit는 VPN[0] 이다. 나머지 12bit는 offset이 된다.
따라서
VPN[1] = 0001 0000 00 = 0x40
VPN[0] = 00 0000 0000 = 0x00
Offset = 0000 0000 0000 0000 = 0x00
이며 위 표와 일치함을 알 수 있다. 마지막 0x20f0_f034를 계산해보자.
0x1000_0000 = 0010 0000 11 / 11 0000 1111 / 0000 0011 0100
VPN[1] = 0010 0000 11 = 0x83
VPN[0] = 11 0000 1111 = 0x30F
Offset = 0000 0011 0100 = 0x34
로 역시 위 표와 일치함을 알 수 있다.
자 이제 이러한 계산 방법을 어떻게 적용하는지를 실제 코드를 통해 알아보자.
- 참고로 32bit 운영체제는 이론상 메모리를 최대 4GB 까지 지원할 수 있다.
$2^{32}=4,294,967,296 \text{ bytes} = 4 \text{GB}$
윈도 XP를 생각해보라 :P 그럼 64bit 운영체제는 메모리를 얼마까지 지원가능할까?
11-2. 페이지 테이블 구성하기#
Sv32 방식의 페이지 테이블을 구성해 보자. 먼저 매크로들을 정의한다. SATP_SV32는 satp 레지스터에서 “Sv32 모드 페이징 활성화"를 나타내는 비트이며, PAGE_*들은 페이지 테이블 엔트리에 설정할 플래그를 의미한다.
1
2
3
4
5
6
7
| // kernel.h
#define SATP_SV32 (1u << 31)
#define PAGE_V (1 << 0) // "Valid" 비트 (엔트리가 유효함을 의미)
#define PAGE_R (1 << 1) // 읽기 가능
#define PAGE_W (1 << 2) // 쓰기 가능
#define PAGE_X (1 << 3) // 실행 가능
#define PAGE_U (1 << 4) // 사용자 모드 접근 가능
|
11-3. 페이지 매핑하기(페이지 할당 알고리즘)#
kernel.c 파일에 다음 map_page 함수를 추가하자
map_page 함수는 1단계 페이지 테이블(table1), 가상 주소(vaddr), 물리 주소(paddr), 그리고 페이지 테이블 엔트리 플래그(flags)를 인자로 받는다.:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| // kernel.c
void map_page(uint32_t *table1, uint32_t vaddr, paddr_t paddr, uint32_t flags) {
// vaddr이 페이지 단위로 정렬되지 않으면 에러
if (!is_aligned(vaddr, PAGE_SIZE))
PANIC("unaligned vaddr %x", vaddr);
// paddr도 페이지 단위로 정렬되지 않으면 에러
if (!is_aligned(paddr, PAGE_SIZE))
PANIC("unaligned paddr %x", paddr);
// 1단계 인덱스 계산
// 상위 10개 bits 를 추출하기 위해 비트 연산으로 22자리를 밀어주고 있다.
// 또 정확한 계산을 위해 0x3ff와 and 연산을 해 준다
// 0x3ff = 0011 1111 1111
uint32_t vpn1 = (vaddr >> 22) & 0x3ff;
// PAGE_V는 매크로 정의에 의해 1이다. 따라서 table1[vpn1] 이 0 인경우
// 다시말해 1단계 페이지 엔트리가 유효하지 않은 경우
if ((table1[vpn1] & PAGE_V) == 0) {
// 2단계 페이지 메모리를 allocation하고 위치를 table1에 저장한다.
// 중요한것은 실제 물리 메모리를 기록하는게 아니라 페이지 넘버를 기록한다.
uint32_t pt_paddr = alloc_pages(1);
table1[vpn1] = ((pt_paddr / PAGE_SIZE) << 10) | PAGE_V;
}
// 2단계 인덱스 추출
uint32_t vpn0 = (vaddr >> 12) & 0x3ff;
// 2단계 테이블 포인터 구성
// table[vpn1] >> 10 만 하는 이유는, table1[vpn1] 에 기록된 정보 중
// 상위 22bit가 Physical Page Number를 가리키기 때문
uint32_t *table0 = (uint32_t *) ((table1[vpn1] >> 10) * PAGE_SIZE);
// 물리 메모리 공간의 페이지 인덱스를 table0에 기록
// 위와 마찬가지로 상위 22bit 가 PPN을 가리키도록 << 10 후 기록
table0[vpn0] = ((paddr / PAGE_SIZE) << 10) | flags | PAGE_V;
}
|
이 함수는 2단계 페이지 테이블이 없으면 할당한 뒤, 2단계 페이지 테이블의 페이지 테이블 엔트리를 채운다.
paddr를 PAGE_SIZE로 나누는 이유는 엔트리에 물리 주소 자체가 아니라 “물리 페이지 번호(physical page number)“를 저장해야 하기 때문이이다. 헷갈리지 않도록 주의하자.
우선 가장 먼저 알아야 할 것은 실제 물리 메모리 주소를 찾아가기 위해 2개의 테이블을 운용하고 있다는 것이다.
서로 독립된 각 프로세스들은 모두 table1, table0을 가지고 있다.
중요한것은 table1은 단 프로세스마다 단 하나만 존재 하고 table1 배열 인덱스 내부 값은 table0 의 메모리 주소를 가리키고 있다는 것이다. 따라서 table1의 인덱스 개수만큼 table0이 존재한다.
32bit 운영체제에서 table1은 1024개의 엔트리를 가지고, 따라서 이는 1024개의 table0이 존재한다는 의미와 같다.
또한 table0도 각각 1024개의 페이지 엔트리를 가지고, 각 페이지의 크기는 4kb이다.
따라서 프로세스가 활용할 수 있는 메모리공간은
$ 1024 \cdot 1024 \cdot 4 \text{kb} = 4 \text{GB} $ 가 된다.
Virtual Address (32bit)
┌───────┬───────┬───────┐
│ 10 bits (vpn1) │ 10 bits (vpn0) │ 12 bits (offset) │
└───────┴───────┴───────┘
Page Table Entry (32bit)
┌─────────┐
│ table1 (page dir) │ ← 가상 주소, 상위 10비트로 인덱싱
├─────────┤
│ table1[vpn1] = PTE │ ← table0 (2단계 페이지 테이블)의 물리 주소 + 플래그
└─────────┘
$ ↓ $
┌─────────┐
│ table0 (page tbl) │ ← 가상 주소, 중간 10비트로 인덱싱
├─────────┤
│ table0[vpn0] = PTE │ ← 최종 물리 페이지 주소 + 접근 플래그
└─────────┘
Page Table Entry Structure (32bit)
┌────────────────┬──────┐
│ 22 bits Physical Page Number (PPN) │ 10 bits flags │
└────────────────┴──────┘
계산 순서는 다음과 같다.
- 가상 메모리 주소 vaddr이 주어짐
- vaddr의 최상의 10비트(VPN1) 추출, vpn1 = vaddr » 22
- table1[VPN1] 으로 해당하는 table0의 물리 주소를 찾는다.
- vaddr의 가운데 10비트(VPN0) 추출 vaddr » 12
- table0[VPN0] 에 실제 물리 메모리 주소의 PPN((paddr / PAGE_SIZE) « 10)을 기록
설명만 읽는것보다 여러분이 직접 페이지 테이블을 그려보면서 이해하는 것이 빠를 것이다.
11-4. 커널 메모리 영역 매핑#
페이지 테이블은 사용자 공간(애플리케이션)을 위해서만이 아니라, 커널을 위해서도 설정해주어야 한다.
여기서는 커널 메모리 매핑을 “커널의 가상 주소 == 물리 주소"로 동일하게 설정한다. 이렇게 하면 페이징을 활성화해도 동일한 코드가 문제없이 동작할 수 있다.
먼저, 커널 링커 스크립트를 수정해 커널이 사용하는 시작 주소(__kernel_base)를 정의한다:
1
2
3
4
5
6
| ENTRY(boot)
SECTIONS {
. = 0x80200000;
/* new */
__kernel_base = .;
|
__kernel_base 는 . = 0x80200000 줄 뒤에 정의해야 한다. 순서가 바뀌면 __kernel_base 값이 0이 된다.
다음으로, 프로세스 구조체에 페이지 테이블을 가리키는 포인터를 추가한다. 이 포인터는 1단계 페이지 테이블을 가리킨다.
1
2
3
4
5
6
7
8
9
10
11
| // kernel.h
struct process {
int pid;
int state;
vaddr_t sp;
// new
uint32_t *page_table;
uint8_t stack[8192];
};
|
마지막으로, create_process 함수에서 커널 페이지들을 매핑해 준다. 커널 페이지는 __kernel_base 부터 __free_ram_end 까지를 커버한다. 이렇게 하면 커널이 .text 같은 정적 할당 영역과 alloc_pages로 관리되는 동적 할당 영역도 모두 접근할 수 있게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // kernel.c
extern char __kernel_base[];
struct process *create_process(uint32_t pc) {
// 생략
// Map kernel pages.
uint32_t *page_table = (uint32_t *) alloc_pages(1);
for (paddr_t paddr = (paddr_t) __kernel_base;
paddr < (paddr_t) __free_ram_end; paddr += PAGE_SIZE)
map_page(page_table, paddr, paddr, PAGE_R | PAGE_W | PAGE_X);
proc->pid = i + 1;
proc->state = PROC_RUNNABLE;
proc->sp = (uint32_t) sp;
// new
proc->page_table = page_table;
return proc;
}
|
11-5. 페이지 테이블 전환#
컨텍스트 스위칭(context switching) 시에 프로세스의 페이지 테이블을 스위칭해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| void yield(void) {
/* 생략 */
__asm__ __volatile__(
"sfence.vma\n"
"csrw satp, %[satp]\n"
"sfence.vma\n"
"csrw sscratch, %[sscratch]\n"
:
// new, 끝에 꼭 콤마가 있어야 함!
: [satp] "r" (SATP_SV32 | ((uint32_t) next->page_table / PAGE_SIZE)),
[sscratch] "r" ((uint32_t) &next->stack[sizeof(next->stack)])
);
switch_context(&prev->sp, &next->sp);
}
|
satp 레지스터에 1단계 페이지 테이블 주소를 지정하면 페이지 테이블을 전환할 수 있다. 이때도 “물리 주소"가 아닌 “물리 페이지 번호"를 지정해야 하므로 PAGE_SIZE로 나누어준다.
sfence.vma 명령어들은 페이징 구조가 바뀌었을 때, CPU가 내부적으로 캐싱해둔 페이지 테이블(TLB)을 무효화하고, 페이지 테이블 변경이 올바르게 완료되었음을 보장하는 역할을 한다.
11-6. 페이징 테스트 하기#
Reference#
https://operating-system-in-1000-lines.vercel.app/en/