SBI에게 “hello"라고 말하기
이전 글에서 SBI는 OS와 펌웨어가 소통하기 위한 인터페이스라고 했다. SBI 명령어를 호출하려면 ecall을 사용하면 된다.ecall은 RISC-V에서 현재 권한보다 높은 권한을 가진 소프트웨어에게 요청을하는 명령어다. OS 개발에서는 M-mode에게 ecall을 하는 것이라고 보면된다.
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]
);
}
#include "kernel.h"
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[];
struct sbiret sbi_call(long arg0, long arg1, long arg2, long arg3, long arg4,
long arg5, long fid, long eid) {
register long a0 __asm__("a0") = arg0;
register long a1 __asm__("a1") = arg1;
register long a2 __asm__("a2") = arg2;
register long a3 __asm__("a3") = arg3;
register long a4 __asm__("a4") = arg4;
register long a5 __asm__("a5") = arg5;
register long a6 __asm__("a6") = fid; // Function ID
register long a7 __asm__("a7") = eid; // Extension ID
__asm__ __volatile__("ecall"
: "=r"(a0), "=r"(a1)
: "r"(a0), "r"(a1), "r"(a2), "r"(a3), "r"(a4), "r"(a5),
"r"(a6), "r"(a7)
: "memory");
return (struct sbiret){.error = a0, .value = a1};
}
void putchar(char ch) {
sbi_call(ch, 0, 0, 0, 0, 0, 0, 1); // Console Putchar
}
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);
const char *s = "\n\nHello World!\n";
for (int i = 0; s[i] != '\0'; i++) {
putchar(s[i]);
}
for (;;) {
__asm__ __volatile__("wfi");
}
}
__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]
);
}
추가된 내용을 위주로 보자면, 다음과 같다.
kernel.h추가,kernel.h에는sbiret이라는 구조체가 정의되어있다. 아래 코드와 같다.#pragma once는 헤더파일을 중복해서 포함하는 것을 방지하는 전처리기 지시문
#pragma once
struct sbiret {
long error;
long value;
};sbi_call함수는 함수 인자(a0,a1등)를 register에 배치시키고,ecall명령어를 실행하고 반환하는 함수이다.register long a0 __asm__("a0") = arg0;은arg0값을a0register에 넣으라는 문법이다.
로컬 변수에 대해 레지스터를 지정하는 문법이라고 보면 된다.__asm__ __volatile__("ecall", ...)은a0-7까지의 레지스터를 입력으로 주고,ecall을 실행하여 반환값을 각각a0,a1에 저장하라는 뜻이다.memoryclobber 인자는 ecall 실행 전후로 메모리가 변경될 수 있으니, 컴파일러가 레지스터에 캐싱해둔 메모리 값을 신뢰하지 말라는 지시어다.memoryclobber는 컴파일러에게 다음과 같은 동작을 지시한다.- 쓰기 방향: ecall 실행 전에, 레지스터에만 들고 있고 아직 메모리에 쓰지 않은 값을 메모리에 flush한다.
- 읽기 방향: ecall 실행 후에, 레지스터에 캐싱해둔 메모리 값을 버리고 메모리에서 다시 읽는다.
putchar('A')에 대해 살펴보자면 다음과 같다.
| 매개변수 | 값 | 레지스터 | 의미 |
|---|---|---|---|
| arg0 | 'A' (= 65) | a0 | 출력할 문자 |
| arg1~arg5 | 0 | a1~a5 | 사용 안 함 |
| fid | 0 | a6 | 함수 ID (Console Putchar는 0) |
| eid | 1 | a7 | 확장 ID (0x01 = Console Putchar 확장) |
SBI 스펙에서 Console Putchar는 EID가 0x01이고, 이 확장에 함수가 하나뿐이라 FID는 0이다. 이때 필요한 인자는 출력할 문자 하나(a0)뿐이라 나머지는 전부 0으로 채운 것이다.
실제 putchar('A')의 실행흐름은 다음과 같다.
putchar('A')sbi_call('A', 0, 0, 0, 0, 0, 0, 1)- 레지스터에 값 세팅 (a0=‘A’, a7=1, 나머지 0)
- ecall 실행
- CPU가 M-Mode로 전환
- OpenSBI가 a7=1을 보고 “Console Putchar구나”
- a0에 있는 ‘A’를 UARTUniversal Asynchronous Receiver/Transmitter로 데이터를 한 비트씩 순서대로 주고받는 직렬 장치로 전송
- 터미널에 ‘A’ 표시
__asm__ __volatile__("wfi");는 wait for interupt의 약자로, CPU에게 인터럽트가 올 때 까지 쉬라고 말하는 RISC-V 명령어이다.
위 코드를 실행해보면, 커널에서 Hello, World!가 출력되는 것을 볼 수 있다.
printf 함수
문자 하나를 출력하는 putchar함수를 사용하여 printf 함수를 만들어보자. 표준 C에서는 많은 기능이 있지만, %d, %x, %s만 지원하는 간단한 기능만 존재하는 걸로 만들어보자.
또한 printf는 U-mode 프로그램에도 쓰이므로, 커널과 유저랜드에서 공유할 common.c와 common.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를 구현했으니 커널에서 사용해보자.
#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도 빌드 대상에 추가해줘야한다.
$CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \
kernel.c common.c그러면 다음과 같이 잘 출력되는 것을 볼 수 있다.
./run.sh
Hello World!
1 + 2 = 3, 1234abcdC 표준 라이브러리
이번에는 string.h나 stdint.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, ...);
#pragma once typedef int bool; typedef unsigned char uint8_t; typedef unsigned short uint16_t; typedef unsigned int uint32_t; typedef unsigned long long uint64_t; typedef uint32_t size_t; typedef uint32_t paddr_t; typedef uint32_t vaddr_t; #define true 1 #define false 0 #define NULL ((void *) 0) #define align_up(value, align) __builtin_align_up(value, align) #define is_aligned(value, align) __builtin_is_aligned(value, align) #define offsetof(type, member) __builtin_offsetof(type, member) #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 *memset(void *buf, char c, size_t n); void *memcpy(void *dst, const void *src, size_t n); char *strcpy(char *dst, const char *src); int strcmp(const char *s1, const char *s2); void printf(const char *fmt, ...);
paddr_t: 물리 메모리 주소vaddr_t: 가상 메모리 주소align_up(value, align):value를align의 배수로 맞춰서 올림하는 함수. 이때,align은 2의 거듭제곱.is_aligned(value, align):value가align의 배수인지 체크하는 함수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 매크로가 그 역할을 한다.
#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을 한번 사용해보자.
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!