SBI(Supervisor Binary Interface)

RISC-V 아키텍처에서 SBI는 M-mode의 펌웨어와 S-mode의 OS 사이의 표준 인터페이스로, OS가 하드웨어에 독립적으로 일관된 방식으로 시스템 리소스에 접근하고 제어할 수 있도록 지원한다.
정리하자면 SBI는 다음과 같은 일을 한다.

  • M-mode 전용 레지스터에 접근할 수 있는 인터페이스 제공
  • Supervisor(OS)와 SEESupervisor Execution Environment로, SBI 구현체인 OpenSBI가 돌아가는 하드웨어. 여기서는 QEMU라고 생각하면 된다.를 깔끔하게 분리
  • 하나의 OS 이미지로 서로 다른 SEE 위에서 실행 가능하게 해줌 alt text 해당 그림에서 좀 더 이해를 돕자면, SEE에 펌웨어(OpenSBI)가 M-mode로 동작하고, 해당 펌웨어와 OS가 소통하기 위한 인터페이스가 SBI라고 보면 된다.
    OpenSBI와 SBI가 헷갈릴 수 있는데, 정확히 설명하자면, SBI는 인터페이스, 통신 규약이고 그걸 구현해놓은 펌웨어가 OpenSBI라고 보면된다.

OpenSBI 부팅하기

OpenSBI는 SBI 인터페이스를 제공하는 것 뿐만아니라, 부트스트랩 기능도 제공한다. QEMU 위에서 OpenSBI를 부팅해보자. 아래와 같이 run.sh를 작성하자

touch run.sh
chmod +x run.sh
run.sh
#!/bin/bash
set -xue

# QEMU 실행 파일 경로
QEMU=qemu-system-riscv32

# QEMU 실행
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot

-bios default 관련해서, 나는 OpenSBI bin파일을 다운했는데 default로 설정해도 되나? 라는 의문이 생겨서 찾아보니, -bios 옵션은 공식 문서상 -bios <file> 형식만 기술되어 있는데, 코드 를 보면, user가 -bios 옵션을 안쓰거나, -bios default 옵션을 사용하였을 때는 QEMU가 현재 디렉토리 및 데이터 디렉토리(/usr/share/qemu/ 등)에서 OpenSBI binary file을 직접 찾는다고 한다. 그래서 사실상 없는거랑 똑같은..
그럼 왜 공식문서에는 -bios file 옵션 밖에 안 적어놨니..
아무튼, 저렇게 작성하고 ./run.sh를 실행해보면 다음과 같이 터미널에 뜬다.

OpenSBI v1.2
   ____                    _____ ____ _____
  / __ \                  / ____|  _ \_   _|
 | |  | |_ __   ___ _ __ | (___ | |_) || |
 | |  | | '_ \ / _ \ '_ \ \___ \|  _ < | |
 | |__| | |_) |  __/ | | |____) | |_) || |_
  \____/| .__/ \___|_| |_|_____/|____/_____|
        | |
        |_|

Platform Name             : riscv-virtio,qemu
Platform Features         : medeleg
...

이때, Ctrl+A를 누른 후 C를 눌러 QEMU 디버그 콘솔(QEMU 모니터)로 전환한다. 모니터에서 q 명령으로 QEMU를 종료할 수 있다.
또한 Ctrl+A를 누른 후 H를 누르면, 다음과 같은 단축키 기능이 있다. 아래의 C-a는 Ctrl+A를 뜻한다.

C-a h    도움말 표시
C-a x    에뮬레이터 종료
C-a s    디스크 데이터를 파일에 저장(-snapshot 사용 시)
C-a t    콘솔 타임스탬프 토글
C-a b    break(매직 sysrq)
C-a c    콘솔과 모니터 간 전환
C-a C-a  C-a를 전송

링커 스크립트

링커 스크립트는 해당 실행 파일이 어느 메모리에 배치될지 정의하는 파일이다.링커는 이 정보를 기반으로 함수와 변수가 배치될 메모리 주소를 결정한다.
kernel.c 파일을 컴파일하고, 링커가 링커스크립트를 보고 커널 바이너리 파일을 생성하게 되는데, 그 바이너리가 QEMU에서 돌아가면서 kernel이 시작되는 것이다.

kernel.ld
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 = .;
}

해당 파일에 대해 설명하자면,

  • ENTRY()는 지시어를 사용하여 boot 함수를 시작점으로 지정
  • SECTION{}섹션섹션이란 같은 종류의 데이터를 묶어 놓은 영역으로 .text :{} 이런식으로 되어있다. 배치 규칙을 정의하는 블록
  • .을 단독으로 사용하면 위치 카운터 로, 현재 메모리 주소를 뜻한다.
  • base address는 0x80200000으로 설정(0x80000000-0x80200000에는 OpenSBI가 있음)
  • .text, .rodata, .data, .bss와 같은 이름은 컴파일러가 자동으로 분류해서 넣어주지만, 다른 것들은 C 파일에서 __attribute__((section(".무언가")))로 지정해줘야 링커가 링커 스크립트에 있는 section으로 할당해준다.
  • KEEPKEEP 지시어는 링크할 때 --gc-sections을 넣어서 가비지 컬렉션 기능이 동작할 때, 아무도 참조하지 않는 코드를 삭제하지 않고 최종 결과물에 강제로 포함시키는 지시어을 사용하여 .text.bootboot함수를 찾는게 아니라, 나중에 작성하게 될 kernel.c에서 __attribute__((section('.text.boot')))를 줘야 알 수 있다. 섹션이 무조건 최종 바이너리에 포함되도록 보장
  • *은 와일드카드로, 예를 들어, *(.text .text.*)가 있으면, 가장 바깥의 *()모든 오브젝트 파일의라는 뜻이고, 안쪽의 .text .text.모든 .text 섹션과 모든 .text.someting 섹션이라는 뜻이다.
  • ALIGN(4)현재 주소를 4의 배수로 올림한 값이다.
  • = 할당 연산자를 통해서 symbol에 값을 할당할 수 있다. 또한 이는 C에서 extern을 사용하여 참조할 수 있다.
  • 산술, 논리, 비교 연산은 C언어와 문법이 같고, 우선순위도 같다.
  • 그 외의 링커 스크립트는 GNU ld script 공식문서 를 보면 잘 알 수 있다.

위 내용을 잘 이해했다면, 마지막 3줄이 의미하는 바를 알 수 있을 것이다. 무슨 뜻일까?

스택의 max size를 128KB로 설정하고, __stack_top이라는 심볼을 설정하는 뜻의 코드이다.
참고로 K, M도 각각 1024, 1024*1024의 값으로 사용가능하다. 공식문서 Constants

최소화된 커널

이번에는 실제 커널을 작성해보자.

kernel.c
typedef unsigned char uint8_t; // 1 byte
typedef unsigned int uint32_t; // 4 byte
typedef uint32_t size_t; // memory size

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;
}

void kernel_main(void) {
    memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);

    for (;;);
}

__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]
    );
}

대부분 위의 내용이나 이전 챕터의 글을 보면 알 수 있기 때문에 나오지 않은 것만 적어보자면

  • 주소 값을 왜 char[]로 선언?

    • symbol값을 extern으로 참조하여 주소값을 가져올때는 두가지 방식, 포인터로 선언하는 방식과 배열로 선언하는 방식이 있는데, 차이는 포인터로 선언하는 방식은 &를 빼먹으면 안되는 것 말고는 없다.
  • __attribute__((naked))의 의미:

    • 컴파일러가 함수 앞뒤에 자동으로 추가하는 프롤로그/에필로그일반적으로 C언어에서 함수를 컴파일하면 컴파일러가 자동으로 앞 뒤에 코드를 삽입하게 되는데, 프롤로그(함수 시작)에는 현재 레지스터 값을 스택에 저장하고 함수가 쓸 스택 공간을 확보하는 코드를 삽입하고, 에필로그(함수 끝)에는 저장해뒀던 레지스터 값을 복원하고, 호출한 함수로 돌아가는 코드를 삽입한다. 코드를 생성하지 않게 한다. boot함수가 실행하는 시점에 stack이 아직 없어서 에필로그/프롤로그에 stack을 사용하면 크래시가 나기 때문에 사용한다.
  • : [stack_top]이 뭐지?

    • 인라인 어셈블리 안에서 __stack_topstack_top의 이름으로도 참조할 수 있게 설정하는 명령이다. 그냥 안쓰고 %0으로 참조해도 된다.

실행해보기

run.sh스크립트에 커널 빌드 명령과 -kernel kernel.elf.elf는 kernel.c를 clang이 컴파일하고, 링커(lld)가 링커 스크립트(kernel.ld)를 기반으로 메모리 배치를 결정하여 만든 최종 실행 파일이다. 옵션을 추가해 보자.

run.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

# QEMU 실행
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
    -kernel kernel.elf

CFLAGS에서 지정한 옵션의 의미는 다음과 같다.

옵션설명
-std=c11C11 표준 사용
-O2최적화 레벨 2 설정
-g3최대한의 디버그 정보 생성
-Wall핵심 경고 활성화
-Wextra추가 경고 활성화
--target=riscv32-unknown-elf32비트 RISC-V 대상 아키텍처로 컴파일
-ffreestanding컴파일 단계에서 호스트 표준 라이브러리를 전제하지 않음
-fuse-ld=lldLLVM 링커 (ld.lld) 사용
-fno-stack-protector스택 보호 기능 비활성화 ( #31 참고)
-nostdlib링크 단계에서 표준 라이브러리를 연결하지 않음
-Wl,-Tkernel.ld링커 스크립트(kernel.ld) 지정
-Wl,-Map=kernel.map맵 파일(kernel.map) 생성 (링킹 결과와 섹션 배치를 확인할 수 있음)

-f기능 은 해당 기능을 켬, -fno기능은 해당 기능을 끔, -W는 Warning 관련, -O는 최적화 관련, -f 기능 관련 접두라고 생각하면 된다.
-Wl, 옵션은 링커에게 해당 옵션은 전달이라는 뜻이다.

커널 디버깅

run.sh를 실행하면 kernel_main에서 무한 루프가 돌아간다. 이 때 QEMU의 디버그 기능을 사용하여 정보를 알 수 있다.

QEMU 모니터에서 info registers 명령어를 실행하면 다음과 같이 CPU 레지스터 정보를 알 수 있다.

info registers 실행 결과
QEMU 6.2.0 monitor - type 'help' for more information
(qemu) info registers
 pc       80200050
 mhartid  00000000
 mstatus  80006080
 mstatush 00000000
 mip      00000000
 mie      00000008
 mideleg  00000222
 medeleg  0000b109
 mtvec    80000420
 stvec    80200000
 mepc     80200000
 sepc     00000000
 mcause   00000002
 scause   00000000
 mtval    00000000
 stval    00000000
 mscratch 80033000
 sscratch 00000000
 satp     00000000
 x0/zero  00000000 x1/ra    8000a084 x2/sp    80220054 x3/gp    00000000
 x4/tp    80033000 x5/t0    00000001 x6/t1    00000000 x7/t2    00000000
 x8/s0    80032f50 x9/s1    00000001 x10/a0   80200054 x11/a1   80200054
 x12/a2   00000000 x13/a3   00000019 x14/a4   4014112d x15/a5   00000001
 x16/a6   00000008 x17/a7   00000002 x18/s2   80200000 x19/s3   00000000
 x20/s4   87000000 x21/s5   00000000 x22/s6   80006800 x23/s7   8001c020
 x24/s8   00002000 x25/s9   8002b4e4 x26/s10  00000000 x27/s11  00000000
 x28/t3   616d6569 x29/t4   8001a5a1 x30/t5   000000fc x31/t6   00000000
 f0/ft0   ffffffff00000000 f1/ft1   ffffffff00000000 f2/ft2   ffffffff00000000 f3/ft3   ffffffff00000000
 f4/ft4   ffffffff00000000 f5/ft5   ffffffff00000000 f6/ft6   ffffffff00000000 f7/ft7   ffffffff00000000
 f8/fs0   ffffffff00000000 f9/fs1   ffffffff00000000 f10/fa0  ffffffff00000000 f11/fa1  ffffffff00000000
 f12/fa2  ffffffff00000000 f13/fa3  ffffffff00000000 f14/fa4  ffffffff00000000 f15/fa5  ffffffff00000000
 f16/fa6  ffffffff00000000 f17/fa7  ffffffff00000000 f18/fs2  ffffffff00000000 f19/fs3  ffffffff00000000
 f20/fs4  ffffffff00000000 f21/fs5  ffffffff00000000 f22/fs6  ffffffff00000000 f23/fs7  ffffffff00000000
 f24/fs8  ffffffff00000000 f25/fs9  ffffffff00000000 f26/fs10 ffffffff00000000 f27/fs11 ffffffff00000000
 f28/ft8  ffffffff00000000 f29/ft9  ffffffff00000000 f30/ft10 ffffffff00000000 f31/ft11 ffffffff00000000

pc 80200050 을 보면 알 수 있겠지만, 현재 0x80200050 주소의 명령어가 실행되고 있음을 알 수 있다.

llvm-objdump -d kernel.elf로 어떤 명령어가 있는지 확인해볼 수 있다.

llvm-objdump -d kernel.elf 실행 결과
llvm-objdump -d kernel.elf

kernel.elf:     file format elf32-littleriscv

Disassembly of section .text:

80200000 <boot>:
80200000: 37 05 22 80   lui     a0, 524832
80200004: 13 05 45 05   addi    a0, a0, 84
80200008: 2a 81         mv      sp, a0
8020000a: 6f 00 a0 01   j       0x80200024 <kernel_main>
8020000e: 00 00         unimp

80200010 <memset>:
80200010: 09 ca         beqz    a2, 0x80200022 <memset+0x12>
80200012: aa 86         mv      a3, a0
80200014: 7d 16         addi    a2, a2, -1
80200016: 13 87 16 00   addi    a4, a3, 1
8020001a: 23 80 b6 00   sb      a1, 0(a3)
8020001e: ba 86         mv      a3, a4
80200020: 75 fa         bnez    a2, 0x80200014 <memset+0x4>
80200022: 82 80         ret

80200024 <kernel_main>:
80200024: 37 05 20 80   lui     a0, 524800
80200028: 13 05 45 05   addi    a0, a0, 84
8020002c: b7 05 20 80   lui     a1, 524800
80200030: 93 85 45 05   addi    a1, a1, 84
80200034: 33 86 a5 40   sub     a2, a1, a0
80200038: 01 ce         beqz    a2, 0x80200050 <kernel_main+0x2c>
8020003a: b3 05 b5 40   sub     a1, a0, a1
8020003e: 2e 86         mv      a2, a1
80200040: 93 06 15 00   addi    a3, a0, 1
80200044: 85 05         addi    a1, a1, 1
80200046: 23 00 05 00   sb      zero, 0(a0)
8020004a: 36 85         mv      a0, a3
8020004c: e3 f9 c5 fe   bgeu    a1, a2, 0x8020003e <kernel_main+0x1a>
80200050: 01 a0         j       0x80200050 <kernel_main+0x2c>

아까 pc가 0x80200050 에 있다고 했는데 위의 llvm 결과에서 0x80200050에 어떤 명령어가 있는지를 보면, j 0x80200050 명령어가 있다. 즉 kernel_main의 무한루프 부분이 진행되고 있음을 알 수 있는 부분이다.

또한 스택 포인터가 정말로 링커 스크립트에서 정의한 __stack_top의 주소로 설정되었는지 확인해볼 수 있다.
info registers의 결과 중 x2/sp 80220054로 나와있는데, 아래의 kernel.map을 보면 80220054 80220054 0 1 __stack_top = .라고 동일하게 나와있음을 알 수 있다.

kernel.map
     VMA      LMA     Size Align Out     In      Symbol
       0        0 80200000     1 . = 0x80200000
80200000 80200000       52     4 .text
80200000 80200000        e     2         /tmp/kernel-0bd4f2.o:(.text.boot)
80200000 80200000        0     1                 
80200000 80200000        0     1                 
80200000 80200000        0     1                 
80200000 80200000        0     1                 
80200000 80200000        e     1                 boot
8020000e 8020000e        0     1                 
8020000e 8020000e        0     1                 
80200010 80200010       42     4         /tmp/kernel-0bd4f2.o:(.text)
80200010 80200010        0     1                 
80200010 80200010        0     1                 
80200010 80200010        0     1                 
80200010 80200010        0     1                 
80200010 80200010       14     1                 memset
80200012 80200012        0     1                 
80200014 80200014        0     1                 
80200016 80200016        0     1                 
8020001a 8020001a        0     1                 
8020001e 8020001e        0     1                 
80200020 80200020        0     1                 
80200022 80200022        0     1                 
80200024 80200024        0     1                 
80200024 80200024        0     1                 
80200024 80200024        0     1                 
80200024 80200024        0     1                 
80200024 80200024        0     1                 
80200024 80200024        0     1                 
80200024 80200024       2e     1                 kernel_main
8020003a 8020003a        0     1                 
80200040 80200040        0     1                 
80200044 80200044        0     1                 
80200046 80200046        0     1                 
8020004a 8020004a        0     1                 
8020004c 8020004c        0     1                 
80200050 80200050        0     1                 
80200050 80200050        0     1                 
80200052 80200052        0     1                 
80200052 80200052        0     1                 
80200054 80200054        0     4 .bss
80200054 80200054        0     1         __bss = .
80200054 80200054        0     1         __bss_end = .
80200054 80200054        0     1 . = ALIGN ( 4 )
80200054 80200054    20000     1 . += 128 * 1024
80220054 80220054        0     1 __stack_top = .
       0        0       65     1 .debug_loclists
       0        0       65     1         /tmp/kernel-0bd4f2.o:(.debug_loclists)
       c        c        0     1                 
       0        0       e1     1 .debug_abbrev
       0        0       e1     1         /tmp/kernel-0bd4f2.o:(.debug_abbrev)
       0        0       dd     1 .debug_info
       0        0       dd     1         /tmp/kernel-0bd4f2.o:(.debug_info)
       0        0       17     1 .debug_rnglists
       0        0       17     1         /tmp/kernel-0bd4f2.o:(.debug_rnglists)
       c        c        0     1                 
       0        0       48     1 .debug_str_offsets
       0        0       48     1         /tmp/kernel-0bd4f2.o:(.debug_str_offsets)
       8        8        0     1                 
       0        0       b3     1 .debug_str
       0        0       b3     1         <internal>:(.debug_str)
       0        0       14     1 .debug_addr
       0        0       14     1         /tmp/kernel-0bd4f2.o:(.debug_addr)
       8        8        0     1                 
       0        0       42     1 .comment
       0        0       42     1         <internal>:(.comment)
       0        0       2b     1 .riscv.attributes
       0        0       2b     1         /tmp/kernel-0bd4f2.o:(.riscv.attributes)
       0        0       44     4 .debug_frame
       0        0       44     4         /tmp/kernel-0bd4f2.o:(.debug_frame)
       0        0        0     1                 
       0        0       f4     1 .debug_line
       0        0       f4     1         /tmp/kernel-0bd4f2.o:(.debug_line)
       0        0        0     1                 .Lline_table_start0
       0        0       32     1 .debug_line_str
       0        0       32     1         <internal>:(.debug_line_str)
       0        0      3f0     4 .symtab
       0        0      3f0     4         <internal>:(.symtab)
       0        0       ce     1 .shstrtab
       0        0       ce     1         <internal>:(.shstrtab)
       0        0       52     1 .strtab
       0        0       52     1         <internal>:(.strtab)

또는 llvm-nm kernel.elf 명령어를 통해서 확인할 수 있다.

llvm-nm kernel.elf
00000000 N .Lline_table_start0
80200054 B __bss
80200054 B __bss_end
80220054 B __stack_top
80200000 T boot
80200024 T kernel_main
80200010 T memset

이때, N은 non-alloc 섹션의 심볼, B는 BSS영역의 심볼, T는 text 영역의 심볼이고, 대문자는 글로벌 심볼, 소문자는 로컬 심볼을 뜻한다.

참고 자료