Skip to content

유저 모드

이전 장에서 작성한 애플리케이션을 실행해보겠습니다.

실행 파일 추출하기

ELF와 같은 실행 파일 형식에서는 로드 주소가 파일 헤더(ELF의 경우 프로그램 헤더)에 저장됩니다. 하지만, 우리의 애플리케이션 실행 이미지는 원시 바이너리(raw binary)이기 때문에, 다음과 같이 고정된 값으로 준비해주어야 합니다:

kernel.h
c
// 애플리케이션 이미지의 기본 가상 주소입니다. 이는 `user.ld`에 정의된 시작 주소와 일치해야 합니다.
#define USER_BASE 0x1000000

다음으로, shell.bin.o에 포함된 원시 바이너리를 사용하기 위해 심볼들을 정의합니다:

kernel.c
c
extern char _binary_shell_bin_start[], _binary_shell_bin_size[];

또한, 애플리케이션을 시작하기 위해 create_process 함수를 다음과 같이 수정합니다:

kernel.c
c
void user_entry(void) {
    PANIC("not yet implemented");
}

struct process *create_process(const void *image, size_t image_size) {
    /* omitted */
    *--sp = 0;                      // s3
    *--sp = 0;                      // s2
    *--sp = 0;                      // s1
    *--sp = 0;                      // s0
    *--sp = (uint32_t) user_entry;  // ra (changed!)

    uint32_t *page_table = (uint32_t *) alloc_pages(1);

    // Map kernel pages.
    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);

    // Map user pages.
    for (uint32_t off = 0; off < image_size; off += PAGE_SIZE) {
        paddr_t page = alloc_pages(1);

        // Handle the case where the data to be copied is smaller than the
        // page size.
        size_t remaining = image_size - off;
        size_t copy_size = PAGE_SIZE <= remaining ? PAGE_SIZE : remaining;

        // Fill and map the page.
        memcpy((void *) page, image + off, copy_size);
        map_page(page_table, USER_BASE + off, page,
                 PAGE_U | PAGE_R | PAGE_W | PAGE_X);
    }

create_process 함수를 실행 이미지의 포인터(image)와 이미지 크기(image_size)를 인자로 받도록 수정했습니다. 이 함수는 지정된 크기만큼 실행 이미지를 페이지 단위로 복사하여 프로세스의 페이지 테이블에 매핑합니다. 또한, 첫 번째 컨텍스트 스위치 시 점프할 주소를 user_entry로 설정합니다. 현재는 이 함수를 빈 함수로 유지합니다.

WARNING

실행 이미지를 복사하지 않고 직접 매핑하면, 동일한 애플리케이션의 프로세스들이 동일한 물리 페이지를 공유하게 됩니다. 이는 메모리 격리를 파괴합니다!

마지막으로, create_process 함수를 호출하는 부분을 수정하여 사용자 프로세스를 생성하도록 합니다:

kernel.c
c
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_proc = create_process(NULL, 0); // updated!
    idle_proc->pid = 0; // idle
    current_proc = idle_proc;

    // new!
    create_process(_binary_shell_bin_start, (size_t) _binary_shell_bin_size);

    yield();
    PANIC("switched to idle process");
}

이제 QEMU 모니터를 통해 실행 이미지가 예상대로 매핑되었는지 확인해봅시다:

(qemu) info mem
vaddr    paddr            size     attr
-------- ---------------- -------- -------
01000000 0000000080265000 00001000 rwxu---
01001000 0000000080267000 00010000 rwxu---

물리 주소 0x80265000이 가상 주소 0x1000000 (USER_BASE)에 매핑되어 있는 것을 확인할 수 있습니다. 이제 이 물리 주소의 내용을 살펴봅시다. 물리 메모리의 내용을 표시하려면 xp 명령어를 사용합니다:

(qemu) xp /32b 0x80265000
0000000080265000: 0x37 0x05 0x01 0x01 0x13 0x05 0x05 0x26
0000000080265008: 0x2a 0x81 0x19 0x20 0x29 0x20 0x00 0x00
0000000080265010: 0x01 0xa0 0x00 0x00 0x82 0x80 0x01 0xa0
0000000080265018: 0x09 0xca 0xaa 0x86 0x7d 0x16 0x13 0x87

일부 데이터가 있는 것 같습니다. shell.bin의 내용을 확인하여 실제로 일치하는지 검증해봅시다:

$ hexdump -C shell.bin | head
00000000  37 05 01 01 13 05 05 26  2a 81 19 20 29 20 00 00  |7......&*.. ) ..|
00000010  01 a0 00 00 82 80 01 a0  09 ca aa 86 7d 16 13 87  |............}...|
00000020  16 00 23 80 b6 00 ba 86  75 fa 82 80 01 ce aa 86  |..#.....u.......|
00000030  03 87 05 00 7d 16 85 05  93 87 16 00 23 80 e6 00  |....}.......#...|
00000040  be 86 7d f6 82 80 03 c6  05 00 aa 86 01 ce 85 05  |..}.............|
00000050  2a 87 23 00 c7 00 03 c6  05 00 93 06 17 00 85 05  |*.#.............|
00000060  36 87 65 fa 23 80 06 00  82 80 03 46 05 00 15 c2  |6.e.#......F....|
00000070  05 05 83 c6 05 00 33 37  d0 00 93 77 f6 0f bd 8e  |......37...w....|
00000080  93 b6 16 00 f9 8e 91 c6  03 46 05 00 85 05 05 05  |.........F......|
00000090  6d f2 03 c5 05 00 93 75  f6 0f 33 85 a5 40 82 80  |m......u..3..@..|

16진수로만 보면 이해하기 어렵습니다. 기계어를 역어셈블하여 예상한 명령어들과 일치하는지 확인해봅시다:

(qemu) xp /8i 0x80265000
0x80265000:  01010537          lui                     a0,16842752
0x80265004:  26050513          addi                    a0,a0,608
0x80265008:  812a              mv                      sp,a0
0x8026500a:  2019              jal                     ra,6                    # 0x80265010
0x8026500c:  2029              jal                     ra,10                   # 0x80265016
0x8026500e:  0000              illegal
0x80265010:  a001              j                       0                       # 0x80265010
0x80265012:  0000              illegal

이 코드는 초기 스택 포인터 값을 계산/설정하고, 두 개의 다른 함수를 호출합니다. 이를 shell.elf의 역어셈블 결과와 비교하면 실제로 일치함을 확인할 수 있습니다:

$ llvm-objdump -d shell.elf | head -n20

shell.elf:      file format elf32-littleriscv

Disassembly of section .text:

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>
 100000e: 00 00         unimp

01000010 <main>:
 1000010: 01 a0         j       0x1000010 <main>
 1000012: 00 00         unimp

사용자 모드로 전환하기

애플리케이션을 실행하기 위해, 우리는 user mode 또는 RISC-V에서는 U-Mode 라 불리는 CPU 모드를 사용합니다. U-Mode로 전환하는 것은 의외로 간단합니다. 방법은 다음과 같습니다:

kernel.h
c
#define SSTATUS_SPIE (1 << 5)
kernel.c
c
// ↓ __attribute__((naked)) is very important!
__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)
    );
}

S-Mode에서 U-Mode로의 전환은 sret 명령어를 사용하여 이루어집니다. 다만, 모드를 변경하기 전에 두 개의 CSR에 값을 기록합니다:

  • sepc 레지스터: U-Mode로 전환 시 실행할 프로그램 카운터를 설정합니다. 즉, sret 명령어가 점프할 위치입니다.
  • sstatus 레지스터의 SPIE 비트: 이 비트를 설정하면 U-Mode로 진입할 때 하드웨어 인터럽트가 활성화되며, stvec 레지스터에 설정된 핸들러가 호출됩니다.

TIP

이 책에서는 하드웨어 인터럽트를 사용하지 않고 폴링을 사용하기 때문에 SPIE 비트를 설정할 필요는 없습니다. 하지만 인터럽트를 묵살하기보다는 명확하게 설정하는 것이 좋습니다.

사용자 모드 실행하기

이제 실행해봅시다! 다만, shell.c가 단순히 무한 루프를 돌기 때문에 화면상에서 제대로 동작하는지 확인하기 어렵습니다. 대신, QEMU 모니터를 통해 확인해봅시다:

(qemu) info registers

CPU#0
 V      =   0
 pc       01000010

CPU가 계속해서 0x1000010 주소를 실행하고 있는 것 같습니다. 제대로 작동하는 것처럼 보이지만, 뭔가 아쉬운 점이 있습니다. 그래서 U-Mode에서만 나타나는 동작을 관찰해보기로 합시다. shell.c에 한 줄을 추가합니다:

shell.c
c
#include "user.h"

void main(void) {
    *((volatile int *) 0x80200000) = 0x1234; // new!
    for (;;);
}

0x80200000은 커널에서 사용하는 메모리 영역으로, 페이지 테이블에 매핑되어 있습니다. 그러나 이 주소는 페이지 테이블 항목에서 U 비트가 설정되지 않은 커널 페이지이므로 예외(페이지 폴트)가 발생해야 하며, 커널이 패닉 상태에 빠져야 합니다. 한번 실행해봅시다:

$ ./run.sh

PANIC: kernel.c:71: unexpected trap scause=0000000f, stval=80200000, sepc=0100001a

15번째 예외(scause = 0xf = 15)는 "Store/AMO 페이지 폴트"에 해당합니다. 예상했던 예외가 발생한 것 같습니다! 또한, sepc에 저장된 프로그램 카운터는 우리가 shell.c에 추가한 라인을 가리키고 있습니다:

$ llvm-addr2line -e shell.elf 0x100001a
/Users/seiya/dev/os-from-scratch/shell.c:4

축하합니다! 첫 번째 애플리케이션을 성공적으로 실행했습니다! 사용자 모드를 구현하는 것이 이렇게 간단하다는 것이 놀랍지 않나요? 커널은 애플리케이션과 매우 유사하며, 단지 몇 가지 추가 권한만 가지고 있을 뿐입니다.