애플리케이션
이전 챕터까지 구현한 페이지 테이블 덕분에, 이제 커널과 애플리케이션을 독립된 가상 주소 공간에 배치할 수 있다. 이번 챕터에서는 커널 위에서 동작할 첫 번째 애플리케이션을 준비한다. 목표는 세 가지다.
- 애플리케이션용 링커 스크립트(
user.ld) 작성 - 유저랜드 라이브러리(
user.c) 구현 - 빌드 파이프라인을 통해 애플리케이션 바이너리를 커널 이미지 안에 내장
링커 스크립트 (user.ld)
애플리케이션은 커널과 별도의 링커 스크립트를 사용한다.
ENTRY(start)
SECTIONS {
. = 0x1000000;
.text :{
KEEP(*(.text.start));
*(.text .text.*);
}
.rodata : ALIGN(4) {
*(.rodata .rodata.*);
}
.data : ALIGN(4) {
*(.data .data.*);
}
.bss : ALIGN(4) {
*(.bss .bss.* .sbss .sbss.*);
. = ALIGN(16);
. += 64 * 1024; /* 64KB */
__stack_top = .;
ASSERT(. < 0x1800000, "too large executable");
}
}커널 링커 스크립트(kernel.ld)와 구조는 동일하지만, 두 가지가 다르다.
베이스 주소: 커널은 0x80200000에서 시작하지만, 애플리케이션은 0x1000000에서 시작한다. 이전 챕터에서 페이지 테이블로 두 주소 공간을 분리했기 때문에 같은 가상 주소 범위를 써도 충돌하지 않지만, 여기서는 명확하게 다른 범위를 사용한다.
스택을 .bss 안에 배치: 커널은 .bss 바깥에 스택을 정의하지만, 애플리케이션은 .bss 안에 넣는다. 이유는 빌드 파이프라인에서 objcopy -O binary로 ELF를 순수 바이너리로 변환할 때, 실제 바이트가 없는 섹션(.bss)은 출력에서 생략되기 때문이다. 스택이 .bss 바깥에 있으면 바이너리에서 통째로 사라진다. 스택을 .bss 안에 넣고 나중에 --set-section-flags .bss=alloc,contents로 강제로 포함시키는 방식을 쓴다.
ASSERT는 링크 시점에 조건을 검사하는 지시어로, 실행 파일이 0x1800000을 넘으면 링크를 실패시킨다. 컴파일이 성공해도 실행 파일이 너무 커지는 실수를 빌드 타임에 잡을 수 있다.
유저랜드 라이브러리 (user.c, user.h)
애플리케이션마다 공통으로 필요한 기반 코드를 user.c로 분리한다. 리눅스로 치면 crt0.o와 libc 일부에 해당한다.
#include "user.h"
extern char __stack_top[];
__attribute__((noreturn)) void exit(void) {
for (;;);
}
void putchar(char c) {
/* TODO */
}
__attribute__((section(".text.start")))
__attribute__((naked))
void start(void) {
__asm__ __volatile__(
"mv sp, %[stack_top] \n"
"call main \n"
"call exit \n"
:: [stack_top] "r" (__stack_top)
);
}start 함수는 커널의 boot 함수와 역할이 동일하다. .text.start 섹션에 배치되어 링커 스크립트의 KEEP(*(.text.start))에 의해 실행 파일 맨 앞에 고정되고, 스택 포인터를 __stack_top으로 설정한 뒤 main을 호출한다. main이 반환하면 exit를 호출한다.
exit는 __attribute__((noreturn))컴파일러에게 '이 함수는 절대 반환하지 않는다'고 알려주는 힌트. 실행 동작을 바꾸는 게 아니라, noreturn 이후 코드에 대한 unreachable 경고 제거와 컴파일러 최적화(함수 호출 후 에필로그 생략 등)를 위한 것이다.로 선언되어 있고, 현재 구현은 무한 루프다. 아직 시스템 콜이 없어서 커널에 “프로세스 종료"를 알릴 방법 자체가 없기 때문에 임시 구현으로 남겨둔 것이다. 챕터 13~14에서 유저 모드와 시스템 콜이 구현되면 제대로 된 exit로 교체된다.
putchar도 마찬가지로 아직 구현이 없다. common.c의 printf가 putchar를 참조하기 때문에 링크 오류를 막기 위해 선언만 해둔다.
.bss 초기화 코드를 넣지 않은 이유는, 커널의 alloc_pages가 이미 물리 메모리를 0으로 채워주기 때문이다. 실제 OS들도 같은 이유(다른 프로세스가 사용하던 민감 정보 유출 방지)로 새 프로세스에 할당하는 메모리를 0으로 초기화한다.
#pragma once
#include "common.h"
__attribute__((noreturn)) void exit(void);
void putchar(char ch);첫 번째 애플리케이션 (shell.c)
user.c는 모든 애플리케이션에 공통인 기반 코드고, shell.c는 이 애플리케이션만의 로직을 담는다. 아직 문자 출력 방법이 없으므로 단순 무한 루프로 시작한다.
#include "user.h"
void main(void) {
for (;;);
}빌드 파이프라인 (run.sh)
애플리케이션은 커널과 별도로 빌드한 뒤, 최종적으로 커널 이미지 안에 내장(embed)된다. 전체 흐름은 다음과 같다.
shell.c ─┐
user.c ─┼───▶ shell.elf ───▶ shell.bin ───▶ shell.bin.o ─┐
common.c ─┘ |
kernel.c ────────────────────────────────────────────────────┴─▶ kernel.elfrun.sh
#!/bin/bash
set -xue
QEMU=qemu-system-riscv32
# clang 경로와 컴파일 옵션
CC=/usr/bin/clang # Ubuntu 등 환경에 따라 경로 조정: CC=clang
CFLAGS="-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32-unknown-elf -fuse-ld=lld -fno-stack-protector -ffreestanding -nostdlib"
# 커널 빌드
$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
#!/bin/bash
set -xue
QEMU=qemu-system-riscv32
OBJCOPY=/usr/bin/llvm-objcopy
# clang 경로와 컴파일 옵션
CC=/usr/bin/clang # Ubuntu 등 환경에 따라 경로 조정: CC=clang
CFLAGS="-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32-unknown-elf -fuse-ld=lld -fno-stack-protector -ffreestanding -nostdlib"
# 애플리케이션 컴파일 & 링크
$CC $CFLAGS -Wl,-Tuser.ld -Wl,-Map=shell.map -o shell.elf shell.c user.c common.c
# ELF -> 순수 바이너리
$OBJCOPY --set-section-flags .bss=alloc,contents -O binary shell.elf shell.bin
# 바이너리 -> 링크 가능한 오브젝트
$OBJCOPY -Ibinary -Oelf32-littleriscv shell.bin shell.bin.o
# 커널 빌드
$CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \
kernel.c common.c shell.bin.o
# QEMU 실행
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
-kernel kernel.elf
처음 $CC 명령은 커널 빌드와 비슷한데 C 파일들을 컴파일하고, user.ld 링커 스크립트를 사용해 링킹한다.
첫 번째 $OBJCOPY 명령은 ELF 형식의 실행 파일(shell.elf)을 실제 메모리 내용만 포함하는 바이너리(shell.bin)로 변환한다. 우리는 단순히 이 바이너리 파일을 메모리에 로드해 애플리케이션을 실행할 것이다. 일반적인 OS에서는 ELF 같은 형식을 사용해, 메모리 매핑 정보와 실제 메모리 내용을 분리해서 다루지만, 여기서는 단순화를 위해 바이너리만 다룰 것이다.
두 번째 $OBJCOPY 명령은 이 바이너리(shell.bin)를 C 언어에 임베드할 수 있는 오브젝트(shell.bin.o)로 변환합니다. 이 파일 안에 어떤 심볼이 들어있는지 llvm-nm 명령으로 확인해보면
$ llvm-nm shell.bin.o
000102d0 D _binary_shell_bin_end
000102d0 A _binary_shell_bin_size
00000000 D _binary_shell_bin_start_binary_라는 접두사 뒤에 파일 이름이 오고, 그 다음에 start, end, size가 붙는다. 이 심볼들은 각각 바이너리 내용의 시작, 끝, 크기를 의미한다._binary_shell_bin_size에는 파일 크기가 들어있는데 주소값으로 파일 크기가 들어있다. llvm-nm으로 확인하면:
$ llvm-nm shell.bin.o | grep _binary_shell_bin_size
000102d0 A _binary_shell_bin_size
$ ls -al shell.bin
-rwxr-xr-x 1 leejw leejw 66256 Mar 31 20:13 shell.bin
$ python3 -c 'print(0x102d0)'
66256llvm-nm 출력의 첫 번째 열은 심볼의 주소를 나타냅니다. 여기서 102d0(16진수)는 실제 파일 크기와 일치합니다. A(두 번째 열)는 이 심볼이 링커에 의해 주소가 재배치되지 않는 ‘절대(Absolute)’ 심볼이라는 뜻입니다. 즉, 파일 크기를 ‘주소’ 형태로 박아놓은 것입니다.
char _binary_shell_bin_size[] 같은 식으로 정의하면, 일반 포인터처럼 보일 수 있지만 실제로는 그 값이 ‘파일 크기’를 담은 주소로 간주되어, 캐스팅하면 파일 크기를 얻게 됩니다.
마지막으로, 커널 컴파일 시 shell.bin.o를 함께 링크하면, 첫 번째 애플리케이션의 실행 파일이 커널 이미지 내부에 임베드됩니다.
디스어셈블 확인
llvm-objdump -d shell.elf로 확인하면 start 함수가 정확히 0x1000000에 배치된 것을 볼 수 있다.
$ llvm-objdump -d shell.elf
01000000 <start>:
1000000: 37 05 01 01 lui a0, 4112
1000004: 13 05 05 26 addi a0, a0, 608
1000008: 2a 81 mv sp, a0
100000a: 19 20 jal 0x1000010 <main>
100000c: 29 20 jal 0x1000016 <exit>
01000010 <main>:
1000010: 01 a0 j 0x1000010 <main>
01000016 <exit>:
1000016: 01 a0 j 0x1000016 <exit>.text.start 섹션이 실행 파일 맨 앞에 오고, main과 exit 모두 현재는 자기 자신으로 점프하는 무한 루프 한 줄짜리 코드임을 확인할 수 있다.
유저 모드
위에서 shell.bin을 커널 이미지 안에 내장했다. 이번 챕터에서는 이 바이너리를 실제로 메모리에 올리고 실행한다. 목표는 두 가지다.
create_process를 수정해 바이너리를 페이지 단위로 복사하고 유저 주소 공간에 매핑user_entry에서sret으로 U-Mode 전환
실행 파일을 메모리에 올리기
먼저 애플리케이션의 베이스 주소를 상수로 정의한다. user.ld에서 설정한 0x1000000과 반드시 일치해야 한다.
#define USER_BASE 0x1000000 // user.ld의 베이스 주소와 일치해야 함
다음으로 create_process를 수정한다. 기존에는 커널 함수 주소(pc)만 받았지만, 이제 바이너리 포인터(image)와 크기(image_size)를 추가로 받는다.
kernel.c
struct process *create_process(uint32_t pc) {
...
*--sp = (uint32_t) pc; // ra
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;
proc->page_table = page_table;
return proc;
}
struct process *create_process(const void *image, size_t image_size) {
...
*--sp = (uint32_t) user_entry; // ra (변경!)
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);
// 유저 페이지 매핑
for (uint32_t off = 0; off < image_size; off += PAGE_SIZE) {
paddr_t page = alloc_pages(1);
size_t remaining = image_size - off;
size_t copy_size = PAGE_SIZE <= remaining ? PAGE_SIZE : remaining;
memcpy((void *) page, image + off, copy_size);
map_page(page_table, USER_BASE + off, page, PAGE_U | PAGE_R | PAGE_W | PAGE_X);
}
proc->pid = i + 1;
proc->state = PROC_RUNNABLE;
proc->sp = (uint32_t) sp;
proc->page_table = page_table;
return proc;
}
바이너리를 직접 매핑하지 않고 새 물리 페이지에 복사 후 매핑한다. 만약 직접 매핑하면 같은 바이너리로 만든 여러 프로세스가 동일한 물리 페이지를 공유하게 되어 메모리 격리가 깨진다.
페이지 매핑 시 PAGE_U 플래그를 추가한다. 이 비트가 없으면 U-Mode에서 해당 페이지에 접근할 때 Page Fault가 발생한다. 커널 페이지에는 PAGE_U를 붙이지 않으므로, 애플리케이션이 커널 메모리에 접근하는 것을 하드웨어 수준에서 차단할 수 있다.
마지막으로 ra에 넣는 값을 pc에서 user_entry로 바꿨다. 첫 컨텍스트 스위치 시 user_entry로 진입해 U-Mode 전환을 거친 뒤 애플리케이션이 실행된다.
kernel_main에서는 다음과 같이 shell 프로세스를 생성한다.
kernel.c
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
WRITE_CSR(stvec, (uint32_t) kernel_entry);
idle_proc = create_process((uint32_t) NULL);
idle_proc->pid = 0;
current_proc = idle_proc;
proc_a = create_process((uint32_t) proc_a_entry);
proc_b = create_process((uint32_t) proc_b_entry);
yield();
PANIC("switched to idle process");
}
extern char _binary_shell_bin_start[], _binary_shell_bin_size[];
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
WRITE_CSR(stvec, (uint32_t) kernel_entry);
idle_proc = create_process(NULL, 0);
idle_proc->pid = 0;
current_proc = idle_proc;
create_process(_binary_shell_bin_start, (size_t) _binary_shell_bin_size);
yield();
PANIC("switched to idle process");
}
QEMU 모니터의 물리 주소 shell 이미지가 정상적으로 매핑 되었는지 확인하기
info mem으로 매핑을 확인할 수 있다.(qemu) info mem
vaddr paddr size attr
-------- ---------------- -------- -------
01000000 0000000080265000 00001000 rwxu---
01001000 0000000080267000 00010000 rwxu---0x80265000이 가상 주소 0x1000000 (USER_BASE)에 매핑되어 있는 것을 확인할 수 있다. 이제 이 물리 주소의 내용을 살펴보기 위해 xp 명령어를 사용해보면 다음과 같이 나온다.(qemu) xp /10i 0x80265000
0x80265000: 01010537 lui a0,16842752
0x80265004: 2d050513 addi a0,a0,720
0x80265008: 812a mv sp,a0
0x8026500a: 00000097 auipc ra,0 # 0x8026500a
0x8026500e: 012080e7 jalr ra,ra,18
0x80265012: 00000097 auipc ra,0 # 0x80265012
0x80265016: 00e080e7 jalr ra,ra,14
0x8026501a: 0000 illegal
0x8026501c: a001 j 0 # 0x8026501c
0x8026501e: 0000 illegal auipc는 Add Upper Immediate to PC라는 뜻으로, 현재 pc값에 즉시값을 더해서 레지스터에 저장하는 명령어이다. auipc ra, 0은 ra = pc + 0뜻이다. auipc ra, 0 + jalr ra, ra, x를 그냥 jal ra, x라고 생각하면 된다.shell.elf의 역어셈블 결과와 비교하면 실제로 일치함을 확인할 수 있다.$ llvm-objdump -d shell.elf | head -n21
shell.elf: file format elf32-littleriscv
Disassembly of section .text:
01000000 <start>:
1000000: 37 05 01 01 lui a0, 4112
1000004: 13 05 05 2d addi a0, a0, 720
1000008: 2a 81 mv sp, a0
100000a: 97 00 00 00 auipc ra, 0
100000e: e7 80 20 01 jalr 18(ra)
1000012: 97 00 00 00 auipc ra, 0
1000016: e7 80 e0 00 jalr 14(ra)
100001a: 00 00 unimp
0100001c <main>:
100001c: 01 a0 j 0x100001c <main>
100001e: 00 00 unimp
01000020 <exit>:
1000020: 01 a0 j 0x1000020 <exit>
U-Mode로 전환하기
#define SSTATUS_SPIE (1 << 5)__attribute__((naked)) void user_entry(void) {
__asm__ __volatile__(
"csrw sepc, %[sepc] \n"
"csrw sstatus, %[sstatus] \n"
"sret \n"
:
: [sepc] "r" (USER_BASE),
[sstatus] "r" (SSTATUS_SPIE)
);
}sret는 원래 예외 핸들러에서 복귀할 때 쓰는 명령어다. RISC-V 스펙상 sstatus의 SPP 비트가 0이면 sret 실행 시 U-Mode로 전환하면서 sepc에 설정한 주소로 점프한다. S-Mode에서 U-Mode로 내려가는 방법이 sret 하나뿐이기 때문에, 실제 트랩이 없어도 두 CSR만 원하는 값으로 설정한 뒤 sret를 호출하여 U-Mode로 전환할 수 있다.
sepc:sret가 점프할 주소.USER_BASE(0x1000000)으로 설정하면shell.bin의start함수부터 실행된다.sstatus의SPIE비트: U-Mode 진입 시 인터럽트 활성화 여부. 이 튜토리얼에서는 인터럽트를 사용하지 않지만 명시적으로 설정한다.
__attribute__((naked))가 반드시 필요하다. 컴파일러가 프롤로그/에필로그를 생성하면 sret 이전에 불필요한 스택 조작이 끼어들어 오동작한다.
전체 실행 흐름
kernel_main
└─ create_process(shell_bin, size) // 바이너리 복사 & 페이지 매핑
└─ yield()
└─ switch_context() // 컨텍스트 스위치
└─ user_entry() // ra에 저장된 주소로 점프
└─ sret // U-Mode 전환 + 0x1000000으로 점프
└─ start() // shell 실행 시작U-Mode 실행 확인
기존의 shell.c는 무한 루프만 돌았기 때문에 U-Mode가 정확히 동작하는지 확인하기 위해서 shell.c에서 커널 메모리에 쓰기를 시도해보자.
void main(void) {
*((volatile int *) 0x80200000) = 0x1234; // 커널 메모리에 쓰기 시도
for (;;);
}0x80200000은 커널 페이지로, 페이지 테이블에 PAGE_U 비트가 없다.
PANIC: kernel.c:146: unexpected trap scause=0000000f, stval=80200000, sepc=01000026실행하면 scause = 0xf = 15(Store/AMO page fault)가 발생한다. U-Mode에서 PAGE_U가 없는 페이지에 접근하면 하드웨어가 차단하는 것이다. llvm-addr2line으로 sepc 주소를 확인하면 정확히 해당 라인을 가리킨다.
$ llvm-addr2line-14 -e shell.elf 01000026
shell.c:4