2025. 1. 21. 01:13ㆍCS/OS
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 |