[1000줄 OS 구현하기] Boot

2025. 1. 21. 01:13CS/OS

 

 

Boot | OS in 1,000 Lines

 

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

 


1. Booting

 

보통 컴퓨터를 부팅하면 BIOS나 UEFI가 하드웨어를 초기화하고 OS를 로드한다.

 

QEME virt machine에서는 OpenSBI가 BIOS나 UEFI 역할을 수행한다.

 

 

종류 long form 설명 특징
BIOS Basic Input/Output System 컴퓨터 부팅 시 가장 먼저 실행되는 펌웨어 하드웨어 초기화, 운영체제 로드, 오래된 PC에서 주로 사용
UEFI Unified Extensible Firmware Interface BIOS를 대체하는 현대적인 펌웨어 인터페이스 빠른 부팅 속도, 더 많은 기능 제공, 현대 PC에서 주로 사용
OpenSBI Open Supervisor Binary Interface RISC-V 아키텍처용 펌웨어 BIOS/UEFI와 같은 역할, QEMU 가상 머신에서 사용, 하드웨어 초기화와 OS 로딩 담당

 


2. SBI

 

SBI (Supervisor Binary Interface)는 RISC-V 하드웨어와 효율적으로 상호작용할 수 있도록 하는 API다.

 

펌웨어가 OS에 제공하는 기능들을 정의한다.

 

SBI 사양은 GitHub에 공개되어 있으며, 다음과 같은 유용한 기능들을 제공한다:

  • 디버그 콘솔(예: 시리얼 포트)에 문자 표시
  • 재부팅/종료
  • 타이머 설정

 

OpenSBI는 대표적인 SBI 구현체로, QEMU에서 기본적으로 시작되어 하드웨어 초기화를 수행하고 커널을 부팅한다.

 


3. OpenSBI

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

# QEMU file path
QEMU=qemu-system-riscv32

# Start QEMU
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot
  • #!/bin/bash : 이 스크립트가 bash 셸로 실행되어야 함을 지정하는 셔뱅(shebang) 라인
  • set -xue ****: 스크립트 실행 옵션 설정
    • x : 실행되는 명령어를 출력
    • u : 정의되지 않은 변수 사용 시 에러 발생
    • e : 명령어 실행 실패 시 즉시 스크립트 종료
  • QEMU=qemu-system-riscv32 : RISC-V 32비트 아키텍처용 QEMU 시스템 에뮬레이터 경로를 변수로 저장
  • $QEMU -machine virt : QEMU 가상 머신 타입을 virt로 지정
  • -bios default : 기본 BIOS(OpenSBI)를 사용
  • `-nographic` : GUI 없이 실행
  • -serial mon:stdio : 시리얼 출력을 현재 터미널로 리다이렉트
  • --no-reboot : 재부팅 없이 종료

 

./run.sh

각종 정보를 출력한다.

 

아무 키를 눌러도 반응이 없을 것.

 

이는 QEMU의 표준 입출력이 가상 머신의 시리얼 포트와 연결되어 있어서, 키보드로 입력한 문자들이 OpenSBI로 전송되기는 하지만, 이 입력을 실제로 처리하는 프로그램이 없기 때문.

 

Ctrl+A를 누르고 C를 누르면 QEMU 디버깅 콘솔로 전환할 수 있다.

 

나가기는 q.

 

QEMU 8.0.2 monitor - type 'help' for more information
(qemu) q

 


4. Linker script

 

링커 스크립트는 실행 파일의 메모리 레이아웃을 정의하는 파일입니다.

 

링커 스크립트는 프로그램의 메모리 구조를 정교하게 제어하기 위해 필요하다.

 

특히 운영체제 커널과 같이 하드웨어와 직접 상호작용하는 저수준 프로그래밍에서는 메모리 주소와 레이아웃이 매우 중요하다.

 

커널은 특정 메모리 주소에서 실행되어야 하고, 하드웨어 접근을 위해 정확한 메모리 매핑이 필요하기 때문에 링커 스크립트를 통한 세밀한 메모리 제어가 필수적이다.

 

출처 https://embed-avr.tistory.com/86

출처 https://ourembeddeds.github.io/blog/2020/09/21/arm7m-startup-ldscript/

 

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)
    : 프로그램의 진입점을 boot 함수로 지정
  • SECTIONS { ... }
    : 각 섹션의 배치는 SECTIONS 블록 내에서 정의됩니다.
  • . = 0x80200000;
    : 커널의 기본 주소를 0x80200000으로 설정
    : . 기호는 현재 주소를 나타냅니다.
    : 데이터가 배치될 때마다 자동으로 증가.
  • .text : : 코드 섹션 시작
    • KEEP(*(.text.boot)): .text.boot 섹션을 항상 시작 부분에 배치. 중요한 부팅 코드가 링커의 최적화 과정에서 실수로 제거되거나 재배치되는 것을 방지.
    • *(.text .text.*): .text 섹션과 .text.로 시작하는 모든 섹션을 배치
  • .rodata : ALIGN(4)
    : 읽기 전용 데이터 섹션을 4바이트 경계에 맞춰 배치
  • .data : ALIGN(4)
    : 읽기/쓰기 가능한 데이터 섹션을 4바이트 경계에 배치
  • .bss : ALIGN(4)
    : 0으로 초기화된 읽기/쓰기 데이터 섹션을 4바이트 경계에 배치
    • __bss__bss_end 심볼을 정의하여 섹션의 시작과 끝 주소 저장
    • C 언어에서는 extern char symbol_name을 사용하여 정의된 심볼을 참조할 수 있음
  • . = ALIGN(4);
    : 현재 주소를 4바이트 경계에 맞춤. 메모리 정렬을 통해 접근 효율성을 높임
  • . += 128 * 1024; /* 128KB */
    : 현재 주소에서 128KB 크기의 커널 스택 공간 확보
  • __stack_top = .
    : 스택의 최상단 주소를 __stack_top 심볼에 저장

 

 

중요하게 짚고 갈 포인트는 다음과 같다.

  • 커널의 진입점은 boot 함수입니다.
  • base address는 0x80200000입니다.
  • .text.boot 섹션은 항상 맨 앞에 배치됩니다.
  • 각 섹션은 .text, .rodata, .data, .bss 순서로 배치됩니다.
  • 커널 스택은 .bss 섹션 뒤에 위치하며, 크기는 128KB입니다.

 

.text, .rodata, .data, .bss 섹션들은 각각 특정한 역할을 가진 데이터 영역:

섹션 설명
.text 프로그램의 코드가 저장되는 영역입니다.
.rodata 읽기 전용인 상수 데이터가 저장되는 영역입니다.
.data 읽기/쓰기가 가능한 데이터가 저장되는 영역입니다.
.bss 초기값이 0인 읽기/쓰기 데이터가 저장되는 영역입니다.

 


5. Minimal kernel

 

이제 커널을 만들어 보자.

 

kernel.c라는 이름의 C언어 소스코드를 작성한다:

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

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

 

1) The kernel entry point

커널은 링커 스크립트에서 진입점으로 지정된 boot 함수에서부터 실행된다.

 

이 함수에서는 스택 포인터(sp)를 링커 스크립트에 정의된 __stack_top 주소로 설정한다.

 

그런 다음 kernel_main 함수로 점프한다.

 

2) boot function attributes

boot 함수에는 두 가지 special attributes이 있습니다.

 

  • __attribute__((naked))
    : 속성은 컴파일러가 return 명령어와 같은 불필요한 코드를 함수 본문 전후에 생성하지 않도록 지시
    : 인라인 어셈블리 코드가 정확히 함수 본문이 되도록 보장합니다.
  • __attribute__((section(".text.boot")))
    : 링커 스크립트에서 함수의 배치를 제어.
    : OpenSBI는 무조건 0x80200000 주소로 점프하기 때문에, boot 함수를 0x80200000 주소에 배치.

 

3) extern char to get linker script symbols

파일 시작 부분에서는 링커 스크립트에 정의된 각 심볼을 extern char를 사용하여 선언한다.

 

여기서는 심볼의 주소만 필요하기 때문에 char 타입을 사용하는 것이 특별히 중요하지는 않다.

 

extern char __bss;로 선언할 수도 있지만, __bss 단독으로는 ".bss 섹션의 시작 주소" 대신 ".bss 섹션의 0번째 바이트 값"을 의미한다.

 

따라서 []를 추가하여 __bss가 주소를 반환하도록 하고 실수를 방지하는 것이 좋다.

 

4) .bss section initialization

kernel_main 함수에서는 먼저 memset 함수를 사용하여 .bss 섹션을 0으로 초기화한다.

 

일부 부트로더는 .bss 섹션을 인식하고 0으로 초기화하기도 하지만, 만일의 경우를 대비해 수동으로 초기화한다.

 

마지막으로 함수는 무한 루프에 진입하고 커널이 종료된다.

 


6. 실행 및 디버깅

 

가. 실행

#!/bin/bash
set -xue

QEMU=qemu-system-riscv32

# Path to clang and compiler flags
# mac Users:
# CC=/opt/homebrew/opt/llvm/bin/clang  
# Ubuntu users: 
CC=clang
CFLAGS="-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32 -ffreestanding -nostdlib"

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

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

OpenSBI에서 kernel.c를 컴파일하고 이를 실행하도록 run.sh를 수정한다.

 

# Path to clang and compiler flags
# mac Users:
# CC=/opt/homebrew/opt/llvm/bin/clang  
# Ubuntu users: 
CC=clang
CFLAGS="-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32 -ffreestanding -nostdlib"

# Build the kernel
$CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf kernel.c
  • CC : clang 컴파일러 위치.
  • CFLAGS : clang 컴파일 옵션.
  • -Wl,-Tkernel.ld
    : clang 명령어는 C 컴파일을 수행하고 내부적으로 링커를 실행
    : -Wl,: 이는 뒤따르는 옵션을 C 컴파일러가 아닌 링커에게 전달하라는 의미
    : -Tkernel.ld: kernel.ld라는 링커 스크립트를 사용하도록 지정

 

옵션 설명
-std=c11 C11 표준 사용
-O2 효율적인 기계어 코드 생성을 위한 최적화 활성화
-g3 최대량의 디버그 정보 생성
-Wall 주요 경고 메시지 활성화
-Wextra 추가 경고 메시지 활성화
--target=riscv32 32비트 RISC-V 아키텍처용 컴파일
-ffreestanding 호스트 환경(개발 환경)의 표준 라이브러리 사용하지 않음
-nostdlib 표준 라이브러리 링크하지 않음
-Wl,-Tkernel.ld 링커 스크립트 지정
-Wl,-Map=kernel.map 맵 파일(링커 할당 결과) 출력

 

# Start QEMU
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot -kernel kernel.elf
  • -kernel kernel.elf : 컴파일된 커널 이미지 파일을 QEMU에 로드하도록 지정

 


나. 디버깅

run.sh를 실행하면 커널이 무한 루프에 진입한다.

 

커널이 제대로 실행되고 있는지 확인할 방법이 없다.

 

이럴 때 QEMU의 디버깅 기능을 사용한다.

 

CPU 레지스터에 대한 자세한 정보를 얻으려면 QEMU 모니터를 열고 info registers 명령어를 실행한다.

(qemu) info registers

CPU#0
 V      =   0
 pc       80200048
 mhartid  00000000
 mstatus  80006080
 mstatush 00000000
 hstatus  00000000
 vsstatus 00000000
 mip      00000000
 mie      00000008
 mideleg  00001666
 hideleg  00000000
 medeleg  00f0b509
 hedeleg  00000000
 mtvec    80000530
 stvec    80200000
 vstvec   00000000
 mepc     80200000
 sepc     00000000
 vsepc    00000000
 mcause   00000003
 scause   00000000
 vscause  00000000
 mtval    00000000
 stval    00000000
 htval    00000000
 mtval2   00000000
 mscratch 80033000
 sscratch 00000000
 satp     00000000
 x0/zero  00000000 x1/ra    8000a084 x2/sp    8022004c x3/gp    00000000
 x4/tp    80033000 x5/t0    00000001 x6/t1    00000002 x7/t2    00000000
 x8/s0    80032f50 x9/s1    00000001 x10/a0   8020004c x11/a1   8020004c
 x12/a2   00000000 x13/a3   00000019 x14/a4   00000000 x15/a5   00000001
 x16/a6   00000001 x17/a7   00000005 x18/s2   80200000 x19/s3   00000000
 x20/s4   87e00000 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   000000c8 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 80200014는 현재 실행 중인 명령어의 주소를 나타내는 프로그램 카운터입니다.

 

디스어셈블러(llvm-objdump)를 사용하여 해당 코드의 정확한 위치를 확인해 보겠습니다:

tiredi@tiredi-Standard-PC-i440FX-PIIX-1996:~/Desktop/MyOS$ llvm-objdump -d kernel.elf

kernel.elf:     file format elf32-littleriscv

Disassembly of section .text:

80200000 <boot>:
80200000: 37 05 22 80   lui     a0, 0x80220
80200004: 13 05 c5 04   addi    a0, a0, 0x4c
80200008: 2a 81         mv      sp, a0
8020000a: 6f 00 a0 01   j       0x80200024 <kernel_main>

8020000e <memset>:
8020000e: 11 ca         beqz    a2, 0x80200022 <memset+0x14>
80200010: 2a 96         add     a2, a2, a0
80200012: aa 86         mv      a3, a0
80200014: 13 87 16 00   addi    a4, a3, 0x1
80200018: 23 80 b6 00   sb      a1, 0x0(a3)
8020001c: ba 86         mv      a3, a4
8020001e: e3 1b c7 fe   bne     a4, a2, 0x80200014 <memset+0x6>
80200022: 82 80         ret

80200024 <kernel_main>:
80200024: 37 05 20 80   lui     a0, 0x80200
80200028: 13 05 c5 04   addi    a0, a0, 0x4c
8020002c: b7 05 20 80   lui     a1, 0x80200
80200030: 93 85 c5 04   addi    a1, a1, 0x4c
80200034: 33 86 a5 40   sub     a2, a1, a0
80200038: 01 ca         beqz    a2, 0x80200048 <kernel_main+0x24>
8020003a: 13 06 15 00   addi    a2, a0, 0x1
8020003e: 23 00 05 00   sb      zero, 0x0(a0)
80200042: 32 85         mv      a0, a2
80200044: e3 1b b6 fe   bne     a2, a1, 0x8020003a <kernel_main+

pc 80200014는 현재 실행 중인 명령어가 j 0x80200010라는 것을 의미.

 

이를 통해 QEMU가 kernel_main 함수에 정상적으로 도달했음을 확인할 수 있다.

 

여기에 더해 스택 포인터(sp 레지스터)가 링커 스크립트에 정의된 __stack_top 값으로 올바르게 설정되었는지 확인해 보자.

 

일전에 x2 레지스터가 스택 포인터를 뜻한다고 했다.

 

레지스터 덤프를 보면 x2/sp 8022004c를 확인할 수 있다.

 

링커가 __stack_top을 어디에 배치했는지 확인하기 위해 kernel.map 파일을 살펴본다:

     VMA      LMA     Size Align Out     In      Symbol
       0        0 80200000     1 . = 0x80200000
80200000 80200000       16     4 .text
...
8020004c 8020004c        0     4 .bss
8020004c 8020004c        0     1         __bss = .
8020004c 8020004c        0     1         __bss_end = .
8020004c 8020004c        0     1 . = ALIGN ( 4 )
8020004c 8020004c    20000     1 . += 128 * 1024
8022004c 8022004c        0     1 __stack_top = .

8022004c로 일치하는 것을 알 수 있다.

 

tiredi@tiredi-Standard-PC-i440FX-PIIX-1996:~/Desktop/MyOS$ llvm-nm kernel.elf
8020004c B __bss
8020004c B __bss_end
8022004c B __stack_top
80200000 T boot
80200024 T kernel_main
8020000e T memset

대신에 llvm-nm을 사용해서 함수 및 변수의 주소를 알아낼 수도 있다.

 

참고로 동작하는 동안 info registers의 결과는 계속 변경될 것이다.

 

QEMU 모니터에서 stop 명령어를 사용하면 에뮬레이션을 일시적으로 중지할 수 있다:

(qemu) stop             ← 프로세스 중지
(qemu) info registers   ← 중지된 시점의 상태 확인
(qemu) cont             ← 프로세스 재개

종료는 Ctrl+a -> x 또는 q.

 


'CS > OS' 카테고리의 다른 글

[1000줄 OS 구현하기] RISC-V Assembly  (0) 2025.01.17
[1000줄 OS 구현하기] 시작하기  (0) 2025.01.17