[1000줄 OS 구현하기] Hello World!

2025. 2. 3. 00:48CS/OS

 

 

Hello World! | OS in 1,000 Lines

 

operating-system-in-1000-lines.vercel.app

  • 한글 번역이 생겼습니다!

 


1. SBI로 출력

 

SBI를 OS의 API 정도로 소개했었다.

 

SBI의 function을 호출하기 위해선 ecall 명령어를 사용한다.

 


가. kernel.c

#include "kernel.h"

typedef unsigned char uint8_t;
typedef unsigned int uint32_t;
typedef uint32_t size_t;

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

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;
        register long a7 __asm__("a7") = eid;

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

수정되거나 추가한 코드만 살펴보면…

 

#include "kernel.h"

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;
    register long a7 __asm__("a7") = eid;

    __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 kernel_main(void) {
    const char *s = "\n\nHello World!\n";
    for (int i = 0; s[i] != '\0'; i++) {
        putchar(s[i]);
    }

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

 

  • register long a0 __asm__("a0") = arg0;
    : C 변수 a0를 RISC-V의 a0 레지스터에 직접 매핑
  • fid : Function ID - SBI 호출할 특정 기능을 식별하는 번호
  • eid : Extension ID - SBI 확장 기능을 식별하는 번호 (예: 1은 Console Putchar)

 

SBI 호출 시 Function ID (fid)와 Extension ID (eid)는 SBI 사양에서 미리 정의된 번호들.

 

이러한 ID들은 RISC-V SBI 표준에 의해 미리 정의되어 있어서 개발자가 임의로 정하는 것이 아닙니다.

 

1번 즉 Console Putchar는 인자로 받은 문자를 디버그 콘솔에 출력해 주는 함수다.

 

    __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};
  • ecall 명령어를 실행하여 SBI 호출
  • a0a1 레지스터의 출력을 C 변수에 저장
  • a0 ~ a7 레지스터의 값을 입력으로 전달
  • memory메모리를 수정할 수 있음을 컴파일러에 알림
  • 실행 후 a0(error)a1(value) 값을 sbiret 구조체로 반환합니다. (C 언어 compound literal 문법 - 쉽게 말해 익명 구조체)

 

ecall 명령어를 실행하면, CPU 실행 모드가 커널 모드(S-Mode)에서 OpenSBI 모드(M-Mode)로 전환되어 OpenSBI 처리 루틴이 동작한다.

 

처리가 끈나면 다시 커널 모드(S-Mode)로 돌아온다.

 

애플리케이션이 커널에 시스템 콜을 호출할 때도 ecall이 사용된다.

 

상위 권한 레벨로의 함수 호출 역할.

 

void putchar(char ch) {
    sbi_call(ch, 0, 0, 0, 0, 0, 0, 1 /* Console Putchar */);
}

void kernel_main(void) {
    const char *s = "\n\nHello World!\n";
    for (int i = 0; s[i] != '\0'; i++) {
        putchar(s[i]);
    }

    for (;;) {
        __asm__ __volatile__("wfi");
    }
}
  • putchar
    : SBI 호출을 통해 단일 문자를 콘솔에 출력.
    : Console Putchar (Extension ID 1)을 사용하여 구현.

 

"Hello World!" 문자열을 한 글자씩 순회하면서 putchar를 호출하여 출력.

 

무한 루프에서 wfi (wait for interrupt) 명령어를 실행하여 시스템을 대기 상태로 유지.

 


나. kernel.h

#pragma once

struct sbiret {
    long error;
    long value;
};

kernel.h에 반환하는 구조체를 정의한다.

 

  • #pragma once
    : 헤더 파일의 중복 포함을 방지하는 전처리기 지시문.
    : 한 소스 파일에서 동일한 헤더 파일이 여러 번 포함되는 것을 방지.
    : 컴파일 시간을 단축하고 중복 정의로 인한 오류를 예방.

 


다. 실행

./run.sh
...

Hello World!

 

동작 과정

  1. 커널에서 ecall 명령어를 실행 → CPU는 OpenSBI가 부팅 시점에 설정해 둔 M-Mode 트랩 핸들러(mtvec 레지스터)로 점프
  2. 레지스터를 저장한 뒤, C로 작성된 트랩 핸들러가 호출
  3. eid에 따라, 해당 SBI 기능을 처리하는 함수가 실행
  4. 8250 UART용 디바이스 드라이버가 문자를 QEMU에 전송
  5. QEMU의 8250 UART 에뮬레이션이 이 문자를 받아서 표준 출력
  6. 터미널 에뮬레이터가 문자를 화면에 표시

 

Console Putchar 함수를 부르는 것은 OpenSBI에 구현된 디바이스 드라이버를 호출하는 것!

 

8250 UART chip

 

 


2. printf 함수

 

%d(10진수), %x(16진수), %s(문자열)만 지원하는 단순한 버전의 아주 원시적인 printf를 구현해보자.

 

포맷 문자열과 값을 받아서 출력하면 된다.

 


가. common.c

 

printf는 추후 유저 모드 프로그램에서도 활용할 수 있게 커널과 유저에서 공유할 common.c라는 파일에 작성한다.

 

  • 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);
                    if (value < 0) {
                        putchar('-');
                        value = -value;
                    }

                    int divisor = 1;
                    while (value / divisor > 9)
                        divisor *= 10;

                    while (divisor > 0) {
                        putchar('0' + value / divisor);
                        value %= divisor;
                        divisor /= 10;
                    }

                    break;
                }
                case 'x': { // Print an integer in hexadecimal.
                    int value = va_arg(vargs, int);
                    for (int i = 7; i >= 0; i--) {
                        int nibble = (value >> (i * 4)) & 0xf;
                        putchar("0123456789abcdef"[nibble]);
                    }
                }
            }
        } else {
            putchar(*fmt);
        }

        fmt++;
    }

end:
    va_end(vargs);
}

포맷 문자열을 한 글자씩 순회하면서 %를 만나면 전달받은 값을 대신 출력하고 이외의 문자는 그대로 출력.

 

그런데 va_list, va_start, va_arg, va_end는 뭘까?


나. 가변 인자 함수

 

  • 가변 인자 함수
    : Variadic Function
    : 인자의 개수가 고정되지 않은 함수를 말함. (예, printf)

 

가변 인자 함수는 최소한 하나의 고정 매개변수를 가져야 하며, 이후에 ... 으로 가변 인자를 표시.

void example(int count, ...) {
    va_list args;
    va_start(args, count);  // count가 마지막 고정 인자

    for (int i = 0; i < count; i++) {
        int value = va_arg(args, int);  // 각 인자를 int 타입으로 가져옴
        // value 처리
    }

    va_end(args);
}

가변 인자를 처리하는 과정:

  1. va_list로 가변 인자 목록을 저장할 변수를 선언
  2. va_start로 가변 인자 처리 시작 (마지막 고정 인자를 기준점으로 사용)
  3. va_arg로 각 가변 인자를 순차적으로 접근
  4. va_end로 가변 인자 처리 종료

 

 

가변 인자 매크로들 설명 문법
va_list 가변 인자들의 목록을 저장하는 타입. printf와 같이 인자의 개수가 고정되지 않은 함수에서 인자들을 처리하기 위해 사용. va_list ap;
va_start 가변 인자 처리를 시작하기 위해 va_list를 초기화. 마지막 고정 매개변수를 기준으로 가변 인자의 시작 위치를 설정. va_start(ap, last_arg);
va_arg va_list에서 다음 인자를 가져오는 매크로. 인자의 타입을 지정하면 해당 타입으로 값을 반환. va_arg(ap, type);
va_end 가변 인자 처리를 종료하고 va_list를 정리. 메모리 누수를 방지하기 위해 반드시 호출해야 함. va_end(ap);

 

 

va_list, va_start, va_arg, va_end와 같은 것들은 원래 <stdarg.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, ...);

__builtin_으로 시작하는 식별자들은 컴파일러에서 제공하는 빌트인 기능이다.

 

복잡한 것은 컴파일러가 알아서 해주므로, 매크로를 정의만 해두면 알아서 돌아간다.

 


다. kernel.c와 run.sh 수정

#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");
    }
}

kernel.ckernel_main을 수정한다.

 

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

run.shcommon.c를 추가하여 링크한다.

 

$ ./run.sh

Hello World!
1 + 2 = 3, 1234abcd

printf 해금!