SBI에게 “hello"라고 말하기

이전 글에서 SBI는 OS와 펌웨어가 소통하기 위한 인터페이스라고 했다. SBI 명령어를 호출하려면 ecall을 사용하면 된다.
ecall은 RISC-V에서 현재 권한보다 높은 권한을 가진 소프트웨어에게 요청을하는 명령어다. OS 개발에서는 M-mode에게 ecall을 하는 것이라고 보면된다.

kernel.c

추가된 내용을 위주로 보자면, 다음과 같다.

  • kernel.h 추가, kernel.h에는 sbiret이라는 구조체가 정의되어있다. 아래 코드와 같다.
    • #pragma once는 헤더파일을 중복해서 포함하는 것을 방지하는 전처리기 지시문
kernel.h
#pragma once

struct sbiret {
    long error;
    long value;
};
  • sbi_call함수는 함수 인자(a0, a1 등)를 register에 배치시키고, ecall명령어를 실행하고 반환하는 함수이다.

    • register long a0 __asm__("a0") = arg0;arg0값을 a0 register에 넣으라는 문법이다.
      로컬 변수에 대해 레지스터를 지정하는 문법이라고 보면 된다.
    • __asm__ __volatile__("ecall", ...)a0-7까지의 레지스터를 입력으로 주고, ecall을 실행하여 반환값을 각각 a0,a1에 저장하라는 뜻이다.
    • memory clobber 인자는 ecall 실행 전후로 메모리가 변경될 수 있으니, 컴파일러가 레지스터에 캐싱해둔 메모리 값을 신뢰하지 말라는 지시어다. memory clobber는 컴파일러에게 다음과 같은 동작을 지시한다.
      • 쓰기 방향: ecall 실행 전에, 레지스터에만 들고 있고 아직 메모리에 쓰지 않은 값을 메모리에 flush한다.
      • 읽기 방향: ecall 실행 후에, 레지스터에 캐싱해둔 메모리 값을 버리고 메모리에서 다시 읽는다.
  • putchar('A')에 대해 살펴보자면 다음과 같다.

매개변수레지스터의미
arg0'A' (= 65)a0출력할 문자
arg1~arg50a1~a5사용 안 함
fid0a6함수 ID (Console Putchar는 0)
eid1a7확장 ID (0x01 = Console Putchar 확장)

SBI 스펙에서 Console Putchar는 EID가 0x01이고, 이 확장에 함수가 하나뿐이라 FID는 0이다. 이때 필요한 인자는 출력할 문자 하나(a0)뿐이라 나머지는 전부 0으로 채운 것이다.
실제 putchar('A')의 실행흐름은 다음과 같다.

  1. putchar('A')
  2. sbi_call('A', 0, 0, 0, 0, 0, 0, 1)
  3. 레지스터에 값 세팅 (a0=‘A’, a7=1, 나머지 0)
  4. ecall 실행
  5. CPU가 M-Mode로 전환
  6. OpenSBI가 a7=1을 보고 “Console Putchar구나”
  7. a0에 있는 ‘A’를 UARTUniversal Asynchronous Receiver/Transmitter로 데이터를 한 비트씩 순서대로 주고받는 직렬 장치로 전송
  8. 터미널에 ‘A’ 표시
  • __asm__ __volatile__("wfi");는 wait for interupt의 약자로, CPU에게 인터럽트가 올 때 까지 쉬라고 말하는 RISC-V 명령어이다.

위 코드를 실행해보면, 커널에서 Hello, World!가 출력되는 것을 볼 수 있다.

printf 함수

문자 하나를 출력하는 putchar함수를 사용하여 printf 함수를 만들어보자. 표준 C에서는 많은 기능이 있지만, %d, %x, %s만 지원하는 간단한 기능만 존재하는 걸로 만들어보자. 또한 printf는 U-mode 프로그램에도 쓰이므로, 커널과 유저랜드에서 공유할 common.ccommon.h에 작성해보자.

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
#include "common.h"

void putchar(char ch);

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;
                    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);
}
  • ...과 variable args: C언어에서 가변인자를 사용하려면 stdarg.h를 사용해야하는데 이걸 사용할 수 없으므로, 컴파일러에서 제공하는 __built_in_을 매핑하여 사용한다.
    • va_list vargs: 다음에 꺼낼 인자의 레지스터 위치를 가리키는 포인터
    • va_start(vargs, fmt): vargs가 fmt(마지막 고정인자) 다음부터 가변인자가 시작된다는 것을 알려주는 역할
    • va_arg(vargs, 타입): 해당 함수를 호출할 때마다 vargs가 가리키는 값을 꺼낸다. 타입을 지정하는 이유는 가변인자는 컴파일 타임에 타입 정보가 없어서 어디까지 읽어야할지를 알려주는 것.
    • va_end(vargs): 끝이라고 적는 것인데, RISC-V에서는 내부적으로 no-op라고 함.
  • case \0: 포맷 문자열이 %로 끝난 경우라서 %만 출력하고 종료한다. “hi %“와 같은 케이스.
  • case \%: %% 처리로, %자체 문자를 출력하고 싶을때 사용하는 경우인데, 똑같이 %만 출력한다.
  • case s: 문자열 가변인자를 받아서 출력한다.
  • case d: 정수 가변인자를 받아서 출력하는데, unsigned로 바꾸는 이유는 INT_MIN과 같은 수는 int 범위를 벗어나기 때문. 예를들어 magnitude = 123;이라면 “1”, “2”, “3"의 순서로 출력되어야 하기 때문에, 높은 자릿수부터 출력하는 코드인 것을 알 수 있다.
  • case x: 16진수는 2진수 비트를 4개 합쳐서 하나의 문자로 만들 수 있고, unsigned는 32비트로 그러한 문가 총 8개다. 그래서 32비트를 16진수로 출력하고자 할때, 8개로 쪼개서 4개의 이진수를 하나로 읽으면 되는 것이다. unsigned nibble = (value >> (i * 4)) & 0xf;는 4비트씩 차례대로 읽기 위한 비트연산이다.

printf를 구현했으니 커널에서 사용해보자.

kernel.c
#include "kernel.h"
#include "common.h"

void kernel_main(void) {
    printf("\n\nHello %s\n", "World!");
    printf("1 + 2 = %d, %x\n", 1 + 2, 0x1234abcd);

    for (;;) {
        __asm__ __volatile__("wfi");
    }
}

그리고 common.c도 빌드 대상에 추가해줘야한다.

run.sh
$CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \
    kernel.c common.c

그러면 다음과 같이 잘 출력되는 것을 볼 수 있다.

./run.sh

Hello World!
1 + 2 = 3, 1234abcd

C 표준 라이브러리

이번에는 string.hstdint.h와 같은 표준라이브러리가 제공하는 함수나 타입을 정의해보자.

common.h
  • paddr_t: 물리 메모리 주소
  • vaddr_t: 가상 메모리 주소
  • align_up(value, align): valuealign의 배수로 맞춰서 올림하는 함수. 이때, align은 2의 거듭제곱.
  • is_aligned(value, align): valuealign의 배수인지 체크하는 함수
  • offsetof(type, member): 구조체 내에서 특정 member가 시작되는 위치를 바이트 단위로 반환

메모리 조작 함수인 memcpy, memset와 문자열 조작함수인 strcpy, strcmp 함수를 만들어보자.

common.c
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;
}
  • strcmp에서 unsigned char로 캐스팅하는 이유는 signed일 경우 128 이상의 값에서 음수로 잘못 해석될 수 있기 때문이다.

커널 패닉

커널 패닉이란 커널에서 복구 불가능한 오류가 발생했을 때 시스템을 멈추는 메커니즘이다. 윈도우의 블루스크린이 커널 패닉의 일종이다.

다음 PANIC 매크로가 그 역할을 한다.

kernel.h
#define PANIC(fmt, ...)                                                        \
    do {                                                                       \
        printf("PANIC: %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__);  \
        while (1) {}                                                           \
    } while (0)
  • do while(0) 구조: while(0)이므로 한번만 실행된다. 그럼에도 쓰는 이유는 다른 if문과의 중복을 방지하기 위해서다. 자세한 이유는 이 글 을 참고하면 될 것 같다.
  • ##__VA_ARGS__: 가변 인자 매크로를 설정할 때 사용되는 기능이다. ##이 앞에 붙는 이유는, 가변인자가 empty일 때, 불필요한 ,를 없애주기 때문이다.

예시로 PANIC을 한번 사용해보자.

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

    PANIC("booted!");
    printf("unreachable here!\n");
}

QEMU에서 실행했을 때, unreachable here!\n은 출력되지 않고, 파일명과 줄 번호와 함께, booted!가 출력되는 것을 볼 수 있다.

./run.sh

PANIC: kernel.c:37: booted!

참고 자료