애플리케이션

이전 챕터까지 구현한 페이지 테이블 덕분에, 이제 커널과 애플리케이션을 독립된 가상 주소 공간에 배치할 수 있다. 이번 챕터에서는 커널 위에서 동작할 첫 번째 애플리케이션을 준비한다. 목표는 세 가지다.

  1. 애플리케이션용 링커 스크립트(user.ld) 작성
  2. 유저랜드 라이브러리(user.c) 구현
  3. 빌드 파이프라인을 통해 애플리케이션 바이너리를 커널 이미지 안에 내장

링커 스크립트 (user.ld)

애플리케이션은 커널과 별도의 링커 스크립트를 사용한다.

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 일부에 해당한다.

user.c
#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.cprintfputchar를 참조하기 때문에 링크 오류를 막기 위해 선언만 해둔다.

.bss 초기화 코드를 넣지 않은 이유는, 커널의 alloc_pages가 이미 물리 메모리를 0으로 채워주기 때문이다. 실제 OS들도 같은 이유(다른 프로세스가 사용하던 민감 정보 유출 방지)로 새 프로세스에 할당하는 메모리를 0으로 초기화한다.

user.h
#pragma once
#include "common.h"

__attribute__((noreturn)) void exit(void);
void putchar(char ch);

첫 번째 애플리케이션 (shell.c)

user.c는 모든 애플리케이션에 공통인 기반 코드고, shell.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.elf
run.sh

처음 $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)'
66256

llvm-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 섹션이 실행 파일 맨 앞에 오고, mainexit 모두 현재는 자기 자신으로 점프하는 무한 루프 한 줄짜리 코드임을 확인할 수 있다.

유저 모드

위에서 shell.bin을 커널 이미지 안에 내장했다. 이번 챕터에서는 이 바이너리를 실제로 메모리에 올리고 실행한다. 목표는 두 가지다.

  1. create_process를 수정해 바이너리를 페이지 단위로 복사하고 유저 주소 공간에 매핑
  2. user_entry에서 sret으로 U-Mode 전환

실행 파일을 메모리에 올리기

먼저 애플리케이션의 베이스 주소를 상수로 정의한다. user.ld에서 설정한 0x1000000과 반드시 일치해야 한다.

kernel.h
#define USER_BASE 0x1000000  // user.ld의 베이스 주소와 일치해야 함

다음으로 create_process를 수정한다. 기존에는 커널 함수 주소(pc)만 받았지만, 이제 바이너리 포인터(image)와 크기(image_size)를 추가로 받는다.

kernel.c

바이너리를 직접 매핑하지 않고 새 물리 페이지에 복사 후 매핑한다. 만약 직접 매핑하면 같은 바이너리로 만든 여러 프로세스가 동일한 물리 페이지를 공유하게 되어 메모리 격리가 깨진다.

페이지 매핑 시 PAGE_U 플래그를 추가한다. 이 비트가 없으면 U-Mode에서 해당 페이지에 접근할 때 Page Fault가 발생한다. 커널 페이지에는 PAGE_U를 붙이지 않으므로, 애플리케이션이 커널 메모리에 접근하는 것을 하드웨어 수준에서 차단할 수 있다.

마지막으로 ra에 넣는 값을 pc에서 user_entry로 바꿨다. 첫 컨텍스트 스위치 시 user_entry로 진입해 U-Mode 전환을 거친 뒤 애플리케이션이 실행된다.

kernel_main에서는 다음과 같이 shell 프로세스를 생성한다.

kernel.c

shell 이미지가 정상적으로 매핑 되었는지 확인하기

QEMU 모니터의 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, 0ra = 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로 전환하기

kernel.h
#define SSTATUS_SPIE (1 << 5)
kernel.c
__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.binstart 함수부터 실행된다.
  • sstatusSPIE 비트: 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에서 커널 메모리에 쓰기를 시도해보자.

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

참고 자료