2018 倒數計時 |
- 在 uefi 環境, x86 cpu 處於何種模式
- 取得 memory map, 如何解讀 memroy map 的資訊
- 如何載入 os kernel (在不自己寫 sata 的驅動程式之下)
- 如何離開 uefi 環境
- 離開 uefi 環境, x86 cpu 處於何種模式
- 離開 uefi 環境之後, 如何秀字
- 如何跳到 os kernel 執行
https://github.com/naoki9911/xv6_uefi/ https://github.com/naoki9911/edk2/tree/f6392fdaa72cf3f54d43a00b26a74ce0e1184027/xv6_bootloader
提供了一個完整 uefi loader 的程式碼, 可以參考, 這個 loader 做了我上面提到的那些事情, 最後載入 xv6 kernel。程式碼還算容易, 應該難不倒你, xv6_bootloader 使用的是 edk2, edk2 用法中文資訊比較少。
由於一直找不到第一手資料 (中文世界通常指英文資料), 只好看第 0 手資料, 很希望我寫的這系列可以被當成第一手資料。
x86 cpu 開機處於真實模式, 你好奇在 uefi 環境時, cpu 處於什麼模式嗎?
根據 UEFI_Spec_2_7.pdf 2.3.4, uefi 開機之後在呼叫 ExitBootServices() 之前, x64 cpu 處於以下模式:
2.3.4 x64 Platforms All functions are called with the C language calling convention. See Section 2.3.4.2 for more detail. During boot services time the processor is in the following execution mode: • Uniprocessor • In long mode, in 64-bit mode • Paging enabled • 128 KiB, or more, of available stack space • The stack must be 16-byte aligned. Stack may be marked as non-executable in identity mapped page tables. • CR0.EM must be zero • CR0.TS must be zero• Direction flag in EFLAGs clear • 4 KiB, or more, of available stack space • The stack must be 16-byte aligned以上是簡短摘錄, 詳細請參閱 2.3.4, cpu 已經進入 long mode, 使用 page, 一核心, 所以 gdt 已經被設定了, 在 qemu 中, 是以下的內容:
gdt: 47, addr: 07ED6F18 0: 0 0 8: CF9200 FFFF 10: CF9F00 FFFF 18: CF9300 FFFF 20: CF9A00 FFFF 28: 0 0 30: CF9300 FFFF 38: AF9A00 FFFF 40: 0 0uefi 挑 0x38 為 cs selector。
UEFI_Spec_2_7.pdf 2.1.3 有大略提到 UEFI OS loader, 可參考一下知道個大概 (當然只看這節的內容是寫不出來 UEFI OS loader 的)。uefi os loader 先提到這; 再來介紹不使用 edk2 來開發 uefi 程式的方法。
uefi 程式一般使用 edk2, 要完成一個 uefi hello 程式, 得照其規定來開發, 但是 edk2 實在太麻煩了, 有沒有 gcc + makefile 就可以搞定的方法呢?
很幸運的, 答案是肯定的。
也許你聽過 gnu efi, 做的也是類似的事情, 一年之後我才知道, 本篇提到的作法就是參考 gnu efi。
README.gnuefi 這份文件也部份提到一些 efi 的 runtime 細節, 補上一些我從 source code 看不出來的細節。
GNU-EFI includes:
- crt0-efi-x86_64.o: A CRT0 (C runtime initialization code) that provides an entry point that UEFI firmware will call when launching the application, which will in turn call the "efi_main" function that the developer writes.
- libgnuefi.a: A library containing a single function (_relocate) that is used by the CRT0.
- elf_x86_64_efi.lds: A linker script used to link UEFI applications.
- efi.h and other headers: Convenience headers that provide structures, typedefs, and constants improve readability when accessing the System Table and other UEFI resources.
- libefi.a: A library containing convenience functions like CRC computation, string length calculation, and easy text printing.
- efilib.h: Header for libefi.a.
只要以下的檔案, 就可以開發 uefi 程式。這篇文章說明的是 x86_64 的用法, x86 32bit mode 就不介紹了。
- efi_api.h efi.h types.h - efi 相關的 struct, function 定義, 看起來是從 edk2 參考而來。
- efi_main.c - 使用 efi_main 的程式進入點。
- crt0_x86_64_efi.S - efi 程式的初始化程式碼。
- efi.ld - linker script
- makefile - gcc/ld 編譯/連結相關的參數。
grub2 也是用類似的方法
一個就是 system table, 一個就是 boot service, 咦! 好像是廢話。
linux 也有類似的程式:
先來看看 efi_main.c, 很簡單, 呼叫 efi_main 來完成一個 uefi 程式, 裡頭用到 efi_system_table 裡頭的 efi_simple_text_output_protocol 的 output_string function, 可以用他來輸出字串到螢幕。
output_string 需要接受一個 efi_simple_text_output_protocol 和 u16 的 c-style 字串, 這對於我在 linux 開發的人來說很不習慣, 在 linux 下, 以 utf8 為主, 印出英文字串時, 只要使用 u8 c-sytle 字串就可以, 不過 uefi 只提供這個, 也只能照辦。所以一個普通字串就需要 prefix L, 這是 efi_main.c L8 為什麼需要 prefix L 的原因。
這個部份相當於經典程式 hello world 的寫法。
和經典程式 hello world 不同的是 (一般都被 c library 處理完畢, 程式員感覺不到他們的存在), 還需要初始化 uefi 的部份, crt0_x86_64_efi.S 就是在做這件事情。
這邊牽扯到 x64 的 2 種 function call convention, 參考《X86调用约定》
注意到有 2 種不同的方式:
- 微软x64调用约定使用RCX, RDX, R8, R9这四个寄存器传递头四个整型或指针变量(从左到右)
- 此约定主要在Solaris,GNU/Linux,FreeBSD和其他非微软OS上使用。头六个整型参数放在寄存器RDI, RSI, RDX, RCX, R8和R9上。
_start 被 uefi bios 載入時, 使用 ms function call convention, crt0_x86_64_efi.S L17 的 %rcx 是 efi_handle_t (image), L18 是 efi_system_table (sys_table), 而 L25 call efi_main 時, 是使用 linux function call convention, 所以得把 %rdi 的值設定為 image, %rsi 設定為 sys_table。L17, 18, 22, 23 就是在做這些事情。之後進入了 efi_main 之後, 才可以正常使用 sys_table。
來看一下 efi api 的宣告, get_memory_map 是 uefi 提供 API, EFIAPI 是 #define EFIAPI __attribute__((ms_abi)), 代表要以 ms function call convention 來呼叫 uefi API。
efi_status_t (EFIAPI *get_memory_map)(efi_uintn_t *memory_map_size, struct efi_mem_desc *desc, efi_uintn_t *key, efi_uintn_t *desc_size, u32 *desc_version);還沒完, 我們還需要 relocatable executable, L35 ~ L43 就是在做這件事情, 這邊很抱歉, 我不知道為什麼要這樣, 如果有人能告知我, 非常謝謝。
猜測是 pe 格式需要有 .reloc section, 這樣 uefi loader 才會去載入這個 uefi 執行檔。
ref: PE文件学习笔记 (四): 重定位表(Relocation Table)解析
[ 4] .reloc PROGBITS 0000000000003000 00203000 000000000000000a 0000000000000000 A 0 0 1
這時候最終的 elf 檔案就會有 .reloc section, 這樣 uefi 才會正常載入/執行這個 efi application。
而 elf 的 relocation 方式就用 elf 的 relocation 相關 section 來處理。最後還有一段 relocation 的程式碼, 這就是在做 elf relocation, 為了簡化, 我拿掉了, 這是在這個 uefi 程式被載入時, 會自己處理 .dynamic section 的內容, 標記為 DT_RELA, DT_RELASZ, DT_RELAENT 的 symbol,
我已經理解怎麼造出這些需要 relocation 的 symbol 了, 請參考: [code] 自己移動自己 - relocation
efi.ld 比較特別的事情算是 L7 的 .hash section, 這是給 dynamic section 用的, 而且一定要排在第一個, 詳請請參考 elf64 format 文件。神奇的是, 不需要指定 link 位址, 因為這個 uefi 程式要作到不管被載入到那個位址, 都可以正確執行, 這個 linker script 會從 0 開始計算位址。
makefile 紀錄所需要的編譯/連結參數。關鍵是 L2 的 objcopy, 前面有提到, uefi 是 pe 格式, 但 linux 系統的 gcc 只會輸出 elf 格式, 怎麼辦呢? objcopy 會把 elf 轉成 pe 的格式, 仔細看 --target=efi-app-x86_64, 很神奇吧!
makefile L5 的 ld -shared 會插入 _DYNAMIC symbol, 和 crt0_x86_64_efi.S 以下程式碼有關:
lea + rip + symbol 的這個用法很特別, 只存在 x86-64 模式下。
ref: 从机器码理解RIP 相对寻址
有點難懂, 直接用 objdump 來說明 lea + rip 指令。
image_base 是 0, 而 list 3 L5 rip (L6 的位址) 是 200d + (-0x200d) 剛好是 0, 就是 image_base 的值。 list 3 L6 rip 是 0x2014 (L7 的位址) + (0x2fec) 剛好是 0x5000, 就是 _DYNAMIC 的值。 和一般 lea 的用法不太一樣。lea 在對 rip 暫存器和非 rip 暫存器似乎有 2 種不同的執行方式。
u.elf: file format elf64-x86-64 Disassembly of section .hash: 0000000000000000 \<image_base\>: 0: 03 00 add (%rax),%eax 2: 00 00 add %al,(%rax) 4: 07 (bad) 5: 00 00 add %al,(%rax) 7: 00 04 00 add %al,(%rax,%rax,1) a: 00 00 add %al,(%rax) c: 02 00 add (%rax),%al e: 00 00 add %al,(%rax) 10: 01 00 add %eax,(%rax) 12: 00 00 add %al,(%rax) 14: 00 00 add %al,(%rax) 16: 00 00 add %al,(%rax) 18: 05 00 00 00 00 add $0x0,%eax 31 0x6999039 push %rdx 32 0x699903a lea -0x2041(%rip),%rcx # 0x6997000 33 0x6999041 lea 0x2fb8(%rip),%rdx # 0x699c000 34 0x6999048 callq 0x6999cedimage_base 在被載入時才能得知其位址, 無法在 link timer 看出來, 所以我用了 gdb 來觀察真實的載入位址, L32 的 0x6997000 就是在計算 image_base 被載入的位址是 0x6997000, 透過 0x6999041 + (-0x2041) 計算得知。image_base 就是這個 uefi 程式的開頭位址, 為什麼要知道這個呢? 因為需要這個值來做 relocation, 而可以這麼做是因為這個 uefi 程式的 link 位址從 0 開始計算。
而在編譯成 shared object 時, lea 的這種語法只能搭配 rip, 是的, uefi 的編譯選項很類似 share object, 所以 uefi 程式其實是一個 share object, 然後 uefi shell 直接載入並執行這個 share object。
若 _start 被載入到 0x2000, list 3 L5 rdi 就是 0; 若 _start 被載入到 0x3000, list 3 L5 rdi 就是 0x1000 (0x300d + (-0x200d)), rdi 就是要紀錄這個 uefi (share object) 被載入到那一個位址, 後面的 _relocate function 就會用這個值來調整 relocateion 的相關修正。
而 _DYNAMIC 是 dynamic section 的位址, _relocate function 需要讀取這個 section 才知道需不需要做 relocation 的調整。
makefile L5 -shared, L8/11 -fpic 是重點, 一定要這 2 個 option, 編譯出來的程式才能正常被 uefi 載入/執行。-fpic 表示我們要造出和位址無關的程式碼, 這觀念不難, 但平常我們不太容易練習到, 沾上邊的有 .so, 不過大部分程式員都是寫應用程式, 我認為很少人會去寫動態函式庫 (.so), 而且也不太會注意其和「位址無關」到底是什麼意思? 所以一個 uefi 執行檔其實是一個 share object, uefi loader 載入/執行的其實是 .so, 而不是一個真正的執行檔, 真是神奇。
字面上的解釋, 就是無論載入到 0x100, 0x200, 這個 efi application 都要正常執行。基本上平常的開發環境, 很難體會這點。
你需要寫一個 loader 和一個「與位址無關的程式」, 然後用這個 loader 去載入他才會有深刻的印象。
扯太多了, 總之, 這樣就可以靠 gcc + makefile 完成以 efi_main 為進入點的 uefi application。
當然在真實機器上也是可以正常執行的。
在真實機器上測試 |
接下來我便要用這樣的開發方式, 開發 uefi os loader。
ref:
- gnu efi soure code
- Dynamic Section
- 如何解讀 dynamic section
- debug uefi application by qemu/gdb
- Programming for EFI: Creating a "Hello, World" Program
- UEFI_Spec_2_7.pdf
- PI_Spec_1_6.pdf
efi_main() { setup_graphics(boot_params); setup_efi_pci(boot_params); setup_quirks(boot_params); }
沒有留言:
張貼留言
使用 google 的 reCAPTCHA 驗證碼, 總算可以輕鬆留言了。
我實在受不了 spam 了, 又不想讓大家的眼睛花掉, 只好放棄匿名留言。這是沒辦法中的辦法了。留言的朋友需要有 google 帳號。