부트로더와 커널과 디버깅
2025년 2월 26일
커널 #
UEFI 펌웨어가 실행시켜주는 EFI 어플리케이션 까지는 구현이 완료됐다. 이 어플리케이션이 OS 부트로더라고 불리고, OS라고 부를 수 있는 커널을 로드하는 역할을 하게된다.
커널 코드와 빌드 #
KernelMain은 그냥 어셈코드로 hlt 명령을 호출하도록 구현되어 있다. hlt는 CPU를 인터럽트가 발생할때까지 멈추는 어셈블리 명령이다.
extern "C" 키워드는 C 함수라는 것을 컴파일러에게 알려주는 키워드인데, 기본적으로 c++은 오버로딩 오버라이딩이 가능하기 때문에 컴파일하면 함수 이름에 파라미터까지 포함되는 네임 맹글링을 수행한다.
c++ 에서 작성한 코드를 c로 호출하기 위해서는 맹글링된 이름을 정확히 적어서 호출해야 하는데, 그것을 방지하기 위해 extern “C” 를 사용한다.
1extern "C" void KernelMain() {
2 while (1) __asm__("hlt");
3}
컴파일 #
1$ clang++ -O2 -Wall -g --target=x86_64-elf -ffreestanding -mno-red-zone -fno-exceptions -fno-rtti -std=c++17 -c main.cpp
- -O2: 최적화 레벨2로 설정. 숫자가 오를수록 공격적인 최적화가 수행되고 안정성이 떨어짐
- -Wall: 일반 경고 모두 출력
- -g: 디버그 정보(DWARF 심볼) 포함해서 컴파일. 최적화 옵션을 줘도 유지되지만, 최적화에 의해 변수가 줄어들 수 있다.
- –target=x86_64-elf: 운영체제 독립적인 ELF 바이너리를 생성. x86_64-linux-gnu 옵션을 주면 일반적인 리눅스에 의존적인 바이너리가 돼서 glibc 같은 런타임 라이브러리를 사용한다.
- -ffreestanding: 프리스탠딩(OS가 없는)환경 용으로 컴파일한다. 운영체제의 도움을 받지 않기 때문에 new, delete, printf 등 이미 구현된 표준 라이브러리를 사용할 수 없다.
1extern "C" void kernel_main() { 2 // 하드웨어에 직접 접근해야 함 3 char *video_memory = (char *)0xb8000; 4 video_memory[0] = 'A'; 5} - -mno-red-zone: 인터럽트가 발생하면 스택에 기존 데이터를 푸시하고 인터럽트를 처리한뒤 복구하게 되는데, 스택을 푸시하는 순간 레드존이 깨지게된다.
유저모드 앱에서 인터럽트가 발생하면 커널모드로 이동 후 커널스택을 이용해서 레드존이 안전하지만, 커널에서는 자신의 스택을 사용하기 때문에 레드존이 깨져서 레드존 옵션을 꺼야한다.- Red Zone: 함수 호출 시 rsp 하위의 128 바이트를 안전하게 쓸 수 있다는 규약으로 sub rsp 명령을 최적화하는 기능이다. 컴파일러가 레드존이 가능한 최적화 대상을 선택해 적용한다.
- -fno-exceptions: 예외처리 문법(try, catch) 사용금지 옵션. 예외처리는 스택을 unwind 하면서 핸들러를 찾는데, 이 과정에서 메모리가 조작되어 문제가 발생할 수 있다.
- -fno-rtti: RTTI(런타임 타입 정보) 사용 금지. dynamic_cast, typeid 같은 기능 제거. RTTI는 내부적으로 동적메모리를 사용할 수 있기 때문에 커널에서는 사용할 수 없다.
- RTTI: 객체의 실제 타입을 런타임에 확인할 수 있도록 하는 기능. vtable의 맨 앞 또는 특정위치에 type_info 객체가 저장되고 이 객체를 참조해서 타입을 알아온다.
- dynamic_cast: 안전한 다운캐스팅.
- typeid: 객체의 실제 타입을 확인.
- -std=c++17: c++17을 표준으로 컴파일
링크 #
1$ ld.lld --entry KernelMain -z norelro --image-base 0x100000 --static -o kernel.elf main.o
- –entry: 진입점 함수를 지정할 수 있다.
- -z norelro: RELRO 비활성화. 커널에서는 동적 라이브러리를 사용하지 않기 때문에 GOT를 사용하지 않는다.
- RELRO: Read-Only Relocations는 ELF에서 GOT를 읽기 전용으로 만들어 GOT Overwrite 공격을 방지하는 기능.
- –image-base: ELF 실행파일을 배치할 주소 지정. 일반적으로 사용하는 주소이다.
- –static: 동적링커가 유저모드에서 실행되는 프로그램이라 동적 링크가 불가능하기 때문에 정적링크로 빌드한다.
커널 로드하기 #
커널 이미지 크기만큼 메모리 할당 #
파일 시스템에서 빌드한 커널 이미지를 읽고 메모리를 할당한다.
1// 루트 파일시스템에서 커널 이미지를 Open 한다.
2 EFI_FILE_PROTOCOL* kernel_file;
3 root_dir->Open(
4 root_dir, &kernel_file, L"\\kernel.elf",
5 EFI_FILE_MODE_READ, 0);
6
7// 커널 이미지 파일에 대한 정보 획득.
8// 파일 크기를 알아야 얼마나 메모리를 할당할지 알 수 있기 때문
9// GetInfo 로 얻는 파일 정보는 EFI_FILE_INFO 와 파일명이 나란히 저장된다.
10 UINTN file_info_size = sizeof(EFI_FILE_INFO) + sizeof(CHAR16) * 12;
11 UINT8 file_info_buffer[file_info_size];
12 kernel_file->GetInfo(
13 kernel_file, &gEfiFileInfoGuid,
14 &file_info_size, file_info_buffer);
15
16 EFI_FILE_INFO* file_info = (EFI_FILE_INFO*)file_info_buffer;
17 UINTN kernel_file_size = file_info->FileSize;
18
19// 커널 베이스 주소부터 커널 이미지 파일의 크기를 페이지 단위 크기로 메모리를 할당한다.
20// 링크할 때 커널의 베이스주소가 0x100000 부터 시작되도록 바이너리가 만들어져있기 때문에
21// 무조건 베이스주소가 맞도록 할당해야한다. (AllocateAddress 방식 사용)
22 EFI_PHYSICAL_ADDRESS kernel_base_addr = 0x100000;
23 gBS->AllocatePages(
24 AllocateAddress, EfiLoaderData,
25// 최소한 커널 사이즈만큼 할당해야 하기 때문에 페이지 단위로 올림 처리
26 (kernel_file_size + 0xfff) / 0x1000, &kernel_base_addr);
27 kernel_file->Read(kernel_file, &kernel_file_size, (VOID*)kernel_base_addr);
28 Print(L"Kernel: 0x%0lx (%lu bytes)\n", kernel_base_addr, kernel_file_size);
EFI_FILE_INFO 는 sizeof 를 통해 구조체의 사이즈를 가져왔지만 파일명에 대한 버퍼를 따로 사이즈만큼 늘린것을 볼 수 있다.
C에서 구조체에 가변길이 배열을 넣기 위해 자주 사용되는 기법으로, 가변길이 배열은 멤버의 크기가 0으로 계산되기 때문에 따로 필요한만큼 추가로 늘려야한다.
1typedef struct {
2 UINT64 Size, FileSize, PhysicalSize;
3 EFI_TIME CreateTime, LastAccessTime, ModificationTime;
4 UINT64 Attribute;
5 CHAR16 FileName[];
6} EFI_FILE_INFO;
UEFI 부트서비스 중지 #
부트서비스는 UEFI 펌웨어 부팅 중 DXE 단계에서 활성화되며 초기화된다.
커널 실행 전에 부트로더에서 여러 부트서비스 API를 호출하며 메모리 상태를 변경했고, 커널이 로드되면 메모리를 커널에서 관리해야 하기 때문에 현재의 메모리 상태를 map_key를 통해 명확히 하고 부트서비스를 종료해서 더이상의 메모리 변경이 없는 상태로 만들어야 한다.
1 EFI_STATUS status;
2// 메모리 상태가 변경되면 map_key가 변경된다.
3// 부트서비스 종료 시 현재 메모리 상태와 알고있는 map_key가 같은지 확인한다.
4// 다르면 실패한다.
5// 부트로더(UEFI 앱)가 실행되는 동안 여러 gBS 객체를 통해 부트서비스 API를 호출하게 되는데,
6// 메모리 상태를 계속 변경시키기 때문에 보통은 업데이트 되지않는 경우가 없어 실패한다.
7 status = gBS->ExitBootServices(image_handle, memmap.map_key);
8 if (EFI_ERROR(status)) {
9// 실패하면 현재 메모리 상태에 맞는 맵으로 업데이트한다.
10 status = GetMemoryMap(&memmap);
11 if (EFI_ERROR(status)) {
12 Print(L"failed to get memory map: %r\n", status);
13 while (1);
14 }
15// 가져온 메모리맵 키를 사용하여 다시 종료를 시도한다.
16// 여기에서 실패하면 중대한 에러이다.
17 status = gBS->ExitBootServices(image_handle, memmap.map_key);
18 if (EFI_ERROR(status)) {
19 Print(L"Could not exit boot service: %r\n", status);
20 while (1);
21 }
22 }
커널 실행 #
커널은 kernel.elf 파일로 빌드되어 있고 ELF 파일 헤더의 +24(0x18) 오프셋에 entry point의 주소가 담겨있다. 101120
1 UINT64 entry_addr = *(UINT64*)(kernel_base_addr + 24);
2
3 typedef void EntryPointType(void);
4 EntryPointType* entry_point = (EntryPointType*)entry_addr;
5 entry_point();
커널 담고 실행해보기 #
부트로더에서 작성한 커널 경로에 커널 파일을 위치시켜야 한다.
부트이미지를 만들때 그냥 루트경로에 kernel.elf를 담으면 된다.
1/kernel.elf
2/EFI/BOOT/BOOTX64.EFI
실행 결과 #
문제점과 디버깅 #
문제점을 발견한건 커널에서 픽셀을 그리다가 발견했다.
커널에서 전달받은 프레임버퍼에 값을 썼는데, 표시가 안되는 문제가 있었고 부트로더가 정상적으로 register를 찍어봤을때 hlt가 아닌 이상한 위치에 멈춰있는것을 보고 문제가 발생했다고 판단했다.
원래는 커널이 0x100000 베이스주소로 로드되고 hlt 명령을 실행하면서 KernelMain 에서 멈춰있어야 했다.
QEMU를 gdb로 디버깅하기 #
qemu 실행할 때 -gdb tcp::1234 -S 옵션을 추가하면 디버거를 기다리고 continue를 실행시킬 때 까지 QEMU가 멈춰있게 된다.
gdb를 실행하고 (gdb) target remote localhost:1234 명령으로 연결할 수 있으며 continue 를 실행시키면 qemu가 실행되기 때문에 브레이크포인트를 잘 걸고 디버깅을 하면 될것이다.
발생한 문제 #
코드상으로 부트로더는 ELF의 0x18 오프셋에 있는 entry_point 값을 읽어 함수 포인터로 보고 실행시키는 것이 전부이다.
0x18 번지에 있는 값도 정상이고, 실제로 그 값이 KernelMain 을 가리킨 다는 것을 확인할 수 있다.
여기에서부터 잘못됐다. 0x100000 에는 ELF 헤더가 정상적으로 들어가 있는걸 볼 수 있지만, 실제 그 위치로 점프하면 이상한 명령어밖에 없다.
UEFI 부트로더 디버깅 방법 #
qemu 에서 실행되는 uefi 부트로더를 디버깅하기 위해서 가장 중요한건 부트로더의 메인 함수를 찾는 것인데, UEFI 펌웨어에서 LoadImage() → StartImage() 과정을 거쳐 부트로더를 직접 로드 & 실행하기 때문에 부트로더의 베이스주소를 알아야 한다는 점이다.
- 부트로더에서 Print를 찍어서 확인
- Ovmf 부터 디버깅하면서 Ovmf.map 파일로 DxeCore.efi 심볼을 로드하고 LoadImage 함수에서 EntryPoint 찾기
- UEFI Shell 에서 직접 실행하여 EntryPoint 찾기 방법
1. Print로 부트로더의 base 주소 출력하기 #
가장 쉬운 방법이다. 그냥 부트로더에서 로드된 주소를 직접 출력하는 방법이다.
디버그 심볼 #
빌드를 하고나면 빌드 폴더에 Loader.debug 와 Loader.efi 가 보이는데, Loader.debug 에만 심볼이 남아있는것을 볼 수 있다.
두 파일의 코드영역을 살펴봐도 심볼이 없을 뿐 코드의 위치는 동일하기 때문에 심볼은 debug 파일을 사용하면 되는 것을 알 수 있다.
하지만 UEFI의 부트로더는 PE 포맷으로 구현되어야 하는데, debug 파일은 ELF인 것을 알 수 있고 부트로더로 사용할 수 없기 때문에 qemu 에는 무조건 efi 파일을 사용해야한다.
1. 커널에서 로드된 주소 출력하기 #
OpenProtocol로 LoadedImage를 가져올 수 있고 참조해서 ImageBase를 알아올 수 있다.
1EFI_STATUS EFIAPI UefiMain(
2 EFI_HANDLE image_handle,
3 EFI_SYSTEM_TABLE* system_table) {
4 EFI_STATUS Status;
5 EFI_LOADED_IMAGE_PROTOCOL *LoadedImage = NULL;
6 Status = gBS->OpenProtocol(
7 image_handle,
8 &gEfiLoadedImageProtocolGuid,
9 (VOID **) &LoadedImage,
10 image_handle,
11 NULL,
12 EFI_OPEN_PROTOCOL_GET_PROTOCOL
13 );
14 if (EFI_ERROR(Status)) {
15 Print(L"OpenProtocol (LoadedImage) Failed: %r\n", Status);
16 return Status;
17 }
18 Print(L"Base = %p\n", LoadedImage->ImageBase);
2. text 섹션과 data 섹션의 오프셋 확인 #
심볼을 로드하기 전에 objdump -h 로 .text 섹션과 .data 섹션의 오프셋을 알아와야 한다.
3. 디버그심볼 로드 #
Loader.efi의 이미지 베이스는 0x3E257000 임을 알 수 있었다. 그리고 text 섹션은 0x240, data 섹션은 0x4200 오프셋인 것도 확인했다.
심볼을 로드하기 전에 file 명령으로 연결된 파일을 제거하고, add-symbol-file 명령으로 베이스주소에 오프셋을 맞춰준다.
1(gdb) target remote :1234
2(gdb) file
3(gdb) add-symbol-file build/Loader.debug 0x3E257240 -s .data 0x3E25b200
4# 커널도 마찬가지로 로드하면 양쪽 다 볼 수 있다.
5# (gdb) add-symbol-file build/kernel.elf 0x103de0 -s .data 0x1136f0
6(gdb) disassemble UefiMain
위의 이미지에서 볼 수 있듯이 UefiMain 의 주소가 정확히 ImageBase + 0x4eb 인 것을 확인할 수 있고 아직은 LoadImage가 호출되기 이전이라 0으로 채워져 있는 영역이다.
그냥 bp UefiMain 걸고 continue 하면 함수를 볼 수 있게 된다.
1(gdb) x/10gx UefiMain
20x3e2574eb <UefiMain>: 0x0000000000000000 0x0000000000000000
30x3e2574fb <UefiMain+16>: 0x0000000000000000 0x0000000000000000
40x3e25750b <UefiMain+32>: 0x0000000000000000 0x0000000000000000
5
6(gdb) b UefiMain
7Breakpoint 1 at 0x3e2574eb: file /home/kdh/min-os/devenv/edk2/MinLoaderPkg/Main.c, line 172.
8
9(gdb) continue
정확히 UefiMain에서 멈췄다.
4. 디버깅! #
debug 심볼 파일에 소스코드도 포함되어 있기 때문에 list 명령으로 c 소스코드를 볼 수 있고, breakpoint도 소스코드 라인 기준으로 걸 수 있다
rbx 값은 0x1011b0 으로 원하는 값이 잘 들어왔는데 실행할때 멈추는 것을 확인할 수 있었다.
원인을 알게됐다. 맨 위에서 캡쳐했듯 커널의 entry_point인 0x1011b0 위치에는 아무런 값이 없었다. 실제로 파일을 확인해도 이미지 베이스를 제외한 0x11b0 오프셋에는 아무런 값이 없다.
지금 내 코드에서는 그냥 파일을 그대로 읽어서 메모리에 복사하고 entrypoint 주소를 직접 실행하고 있다.
파일의 오프셋과 실제 메모리에 로드되는 오프셋은 다르고, 커널을 그대로 복사하는게 아니라 오프셋에 맞게 로드해줘야 한다는 점을 간과했다.
아마 이걸 Read 가 아닌 다른 함수로 메모리에 복사하거나 elf 를 로드하는 함수를 직접 만들어주거나 elf를 빌드할때 파일 오프셋을 그대로 쓰는 옵션이 있지 않을까 생각이 든다.
ELF 커널을 로드하기 #
https://github.com/ajxs/uefi-elf-bootloader/
ELF 파일 은 여기에 정리해뒀다.
커널 로드 과정 #
1. 임시 버퍼에 kernel.elf 파일 읽어오기 #
1// 파일 정보를 가져오는 것 까지는 같다.
2 EFI_FILE_PROTOCOL* kernel_file;
3 root_dir->Open(
4 root_dir, &kernel_file, L"\\kernel.elf",
5 EFI_FILE_MODE_READ, 0);
6 _IfErrorHalt(L"open file '\\kernel.elf' Failed", status);
7
8 UINTN file_info_size = sizeof(EFI_FILE_INFO) + sizeof(CHAR16) * 12;
9 UINT8 file_info_buffer[file_info_size];
10 kernel_file->GetInfo(
11 kernel_file, &gEfiFileInfoGuid,
12 &file_info_size, file_info_buffer);
13 _IfErrorHalt(L"get file information Failed", status);
14
15 EFI_FILE_INFO* file_info = (EFI_FILE_INFO*)file_info_buffer;
16 UINTN kernel_file_size = file_info->FileSize;
17
18 VOID* kernel_buffer;
19 status = gBS->AllocatePool(EfiLoaderData, kernel_file_size, &kernel_buffer);
20 _IfErrorHalt(L"allocate pool Failed", status);
21 status = kernel_file->Read(kernel_file, &kernel_file_size, kernel_buffer);
22 _IfErrorHalt(L"Read from kernel_file Failed..", status);
23
24 EFI_FILE_INFO* file_info = (EFI_FILE_INFO*)file_info_buffer;
25 UINTN kernel_file_size = file_info->FileSize;
26
27// AllocatePool 로 파일을 읽어올 임시 버퍼를 할당받는다. (바이트단위)
28 VOID* kernel_buffer;
29 status = gBS->AllocatePool(EfiLoaderData, kernel_file_size, &kernel_buffer);
30 _IfErrorHalt(L"allocate pool Failed", status);
31 status = kernel_file->Read(kernel_file, &kernel_file_size, kernel_buffer);
32 _IfErrorHalt(L"Read from kernel_file Failed..", status);
2. ELF 헤더를 읽어서 필요한 메모리 공간을 찾아온다. #
파일일 때보다 메모리 공간에 로드될 때 각 섹션의 범위를 벗어날 수 있기 때문에(Align이나 .bss 영역 때문) 필요한 영역의 주소 범위를 정확히 알아오는 작업이 필요하다
1// 전체 Program Header를 확인해서 로드의 시작 주소, 끝 주소를 찾아오는 함수
2void CalcLoadAddressRange(Elf64_Ehdr* ehdr, UINT64* first, UINT64* last) {
3 Elf64_Phdr* phdr = (Elf64_Phdr*)((UINT64)ehdr + ehdr->e_phoff);
4 *first = MAX_UINT64;
5 *last = 0;
6// Program Header의 수 만큼 반복
7 for (Elf64_Half i = 0; i < ehdr->e_phnum; ++i) {
8// PT_LOAD 만 메모리에 로드하면 된다.
9 if (phdr[i].p_type != PT_LOAD) continue;
10// Program Header가 가상메모리 주소 기준으로 정렬되지 않았을 수 있다.
11 *first = MIN(*first, phdr[i].p_vaddr);
12 *last = MAX(*last, phdr[i].p_vaddr + phdr[i].p_memsz);
13 }
14}
15// ---
16
17// 커널버퍼를 ELF Header 구조체로 읽을 수 있도록 Elf64_Ehdr* 타입에 포인터 저장
18// ELF64 Header 구조체 포맷을 미리 정의해야한다. elf.hpp에 작성
19 Elf64_Ehdr* kernel_ehdr = (Elf64_Ehdr*)kernel_buffer;
20 UINT64 kernel_first_addr, kernel_last_addr;
21 CalcLoadAddressRange(kernel_ehdr, &kernel_first_addr, &kernel_last_addr);
22 Print(L"Kernel: 0x%0lx - 0x%0lx\n", kernel_first_addr, kernel_last_addr);
3. 계산한 메모리 범위에 해당하는 페이지를 할당받고 커널 로드 #
1// 커널을 섹션 단위로 로드하는 코드
2void CopyLoadSegments(Elf64_Ehdr* ehdr) {
3 Elf64_Phdr* phdr = (Elf64_Phdr*)((UINT64)ehdr + ehdr->e_phoff);
4// Program Header의 수 만큼 반복
5 for (Elf64_Half i = 0; i < ehdr->e_phnum; ++i) {
6// PT_LOAD 만 로드하면 된다.
7 if (phdr[i].p_type != PT_LOAD) continue;
8// 각 세그먼트마다 파일의 오프셋 위치를 가상주소에 filesz 만큼 복사(로드)한다.
9 UINT64 segm_in_file = (UINT64)ehdr + phdr[i].p_offset;
10 CopyMem((VOID*)phdr[i].p_vaddr, (VOID*)segm_in_file, phdr[i].p_filesz);
11// 메모리상의 사이즈보다 파일상 섹션 사이즈가 작으면 나머지는 0으로 채운다.
12// .bss 영역의 경우엔 초기화되지 않은 전역변수가 담겨져있고,
13// 개발자가 초기화하지 않았지만 무조건 0인 이유는 여기에 있다.
14 UINTN remain_bytes = phdr[i].p_memsz - phdr[i].p_filesz;
15 SetMem((VOID*)(phdr[i].p_vaddr + phdr[i].p_filesz), remain_bytes, 0);
16 }
17}
18// ---
19
20// 페이지 수 계산 후 커널 시작주소(이미지베이스)에 할당
21 UINTN num_pages = (kernel_last_addr - kernel_first_addr + 0xfff) / 0x1000;
22 status = gBS->AllocatePages(AllocateAddress, EfiLoaderData,
23 num_pages, &kernel_first_addr);
24 _IfErrorHalt(L"AllocatePages Failed!", status);
25
26 CopyLoadSegments(kernel_ehdr);
27// 커널을 이미지베이스에 잘 로드했으니 임시버퍼는 해제한다.
28 status = gBS->FreePool(kernel_buffer);
29 _IfErrorHalt(L"FreePool Failed", status);
4. ELF Header에서 entry_point를 찾고 실행 #
1// 엔트리포인트 위치 계산(0x18 오프셋) 후 실행한다.
2 UINT64 entry_addr = *(UINT64*)(kernel_first_addr + 24);
3 typedef void EntryPointType(UINT64, UINT64);
4 EntryPointType* entry_point = (EntryPointType*)entry_addr;
5 entry_point(gop->Mode->FrameBufferBase, gop->Mode->FrameBufferSize);
정상적으로 커널까지 실행되어 그림이 그려진 것까지 확인된다.
현재 커널 코드는 while (1) __asm__("hlt"); 에서 멈춰놨기 때문에 0x101311 주소에서 hlt로 무한루프 도는 것을 볼 수 있다.