2017年12月31日 星期日

uefi os loader (0) - write uefi program without edk2

今天是 20171231, 寫篇文章紀錄一下 2017 的最後一天, 很久沒寫電腦技術文章, 我找到了寫 uefi bootloader 的火苗, 先這麼開始吧!

2018 倒數計時
開發 uefi os loader 很困難 (或者應該說是陌生, 知道了其實並沒那麼難), 和 legacy bios os loader 相比之下, 有以下的問題要解決:
  1. 在 uefi 環境, x86 cpu 處於何種模式
  2. 取得 memory map, 如何解讀 memroy map 的資訊
  3. 如何載入 os kernel (在不自己寫 sata 的驅動程式之下)
  4. 如何離開 uefi 環境
  5. 離開 uefi 環境, x86 cpu 處於何種模式
  6. 離開 uefi 環境之後, 如何秀字
  7. 如何跳到 os kernel 執行
這些問題有些和 uefi 相關, 有些是 loader 本身的相關知識, 好像真的比開發 legacy bios os loader 難了不只一點。

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:
• UniprocessorIn long mode, in 64-bit modePaging enabled128 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  0
uefi 挑 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:
  1. 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.
  2. libgnuefi.a: A library containing a single function (_relocate) that is used by the CRT0.
  3. elf_x86_64_efi.lds: A linker script used to link UEFI applications.
  4. efi.h and other headers: Convenience headers that provide structures, typedefs, and constants improve readability when accessing the System Table and other UEFI resources.
  5. libefi.a: A library containing convenience functions like CRC computation, string length calculation, and easy text printing.
  6. efilib.h: Header for libefi.a.
要打造這環境很麻煩, 需要自己編譯 toolchain (好像已經不用這麼做了), 因為 uefi 執行檔格式是 pe, 在 linux 預設的 gcc, 只能造出 elf 執行檔, 這太繁瑣了, 我不是用這個方式。很單純就是用系統的 toolchain 即可。

只要以下的檔案, 就可以開發 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 編譯/連結相關的參數。
只要有了這幾個檔案, 就可以用我熟悉的 gcc + makefile 來開發 uefi 程式。

grub2 也是用類似的方法

grub-core/kern/efi/mm.c
grub_efi_get_memory_map()
{
  ...
  b = grub_efi_system_table->boot_services;
  ...
}

一個就是 system table, 一個就是 boot service, 咦! 好像是廢話。

linux 也有類似的程式:
boot/compressed/eboot.c
#define BOOT_SERVICES(bits)                                             \
static void setup_boot_services##bits(struct efi_config *c)             \
{                                                                       \
        efi_system_table_##bits##_t *table;                             \
        efi_boot_services_##bits##_t *bt;                               \
                                                                        \
        table = (typeof(table))sys_table;                               \
                                                                        \
        c->text_output = table->con_out; 

先來看看 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 的寫法。

efi_main.c
 1 #include "efi.h"
 2 #include "efi_api.h"
 3 
 4 static efi_guid_t gEfiSimpleFileSystemProtocolGuid = EFI_GUID(0x964E5B22, 0x6459, 0x11D2,  0x8E, 0x39, 0x00, 0xA0, 0xC9, 0x69, 0x72, 0x3B);
 5 
 6 efi_status_t efi_main(efi_handle_t image, struct efi_system_table *sys_table)
 7 {
 8   sys_table->con_out->output_string(sys_table->con_out, L"002 hello efi by gcc\n\r");
 9   return 0;
10 }

和經典程式 hello world 不同的是 (一般都被 c library 處理完畢, 程式員感覺不到他們的存在), 還需要初始化 uefi 的部份, crt0_x86_64_efi.S 就是在做這件事情。

這邊牽扯到 x64 的 2 種 function call convention, 參考《X86调用约定

注意到有 2 種不同的方式:
  1. 微软x64调用约定使用RCX, RDX, R8, R9这四个寄存器传递头四个整型或指针变量(从左到右)
  2. 此约定主要在Solaris,GNU/Linux,FreeBSD和其他非微软OS上使用。头六个整型参数放在寄存器RDI, RSI, RDX, RCX, R8和R9上。
很不幸這裡需要用到這兩種 function call convention, 所以雖然使用 gcc 在 linux 開發, 還是得搞懂 ms 的那套規則。因為 uefi 是使用 ms function call convention。雖然不太爽, 但也沒辦法。

_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, 不過我不知道怎麼造出這些 symbol, 所以就沒特別介紹這個, 這個範例程式沒處理 .dynamic section 的內容也不影響執行。list 1 為這個範例的 .dynamic section 內容。

我已經理解怎麼造出這些需要 relocation 的 symbol 了, 請參考: [code] 自己移動自己 - relocation

list 1. readelf -d my.elf
 1 descent@debian64:uefi_prog$ readelf -d my.elf
 2
 3 Dynamic section at offset 0x205000 contains 7 entries:
 4   Tag        Type                         Name/Value
 5  0x0000000000000004 (HASH)               0x0
 6  0x000000006ffffef5 (GNU_HASH)           0x8000
 7  0x0000000000000005 (STRTAB)             0x7000
 8  0x0000000000000006 (SYMTAB)             0x6000
 9  0x000000000000000a (STRSZ)              19 (bytes)
10  0x000000000000000b (SYMENT)             24 (bytes)
11  0x0000000000000000 (NULL)               0x0

crt0_x86_64_efi.S
 1 /*
 2  * crt0-efi-x86_64.S - x86_64 EFI startup code.
 3  * Copyright (C) 1999 Hewlett-Packard Co.
 4  * Contributed by David Mosberger <davidm@hpl.hp.com>.
 5  * Copyright (C) 2005 Intel Co.
 6  * Contributed by Fenghua Yu <fenghua.yu@intel.com>.
 7  *
 8  * All rights reserved.
 9  * SPDX-License-Identifier: BSD-3-Clause
10  */
11  .text
12  .align 4
13 
14  .globl _start
15 _start:
16  subq $8, %rsp
17  pushq %rcx
18  pushq %rdx
19 
20 0:
21 
22  popq %rsi
23  popq %rdi
24 
25  call efi_main
26  addq $8, %rsp
27 
28 .exit:
29  ret
30 
31  /*
32   * hand-craft a dummy .reloc section so EFI knows it's a relocatable
33   * executable:
34   */
35  .data
36 dummy: .long 0
37 
38 #define IMAGE_REL_ABSOLUTE 0
39  .section .reloc, "a"
40 label1:
41  .long dummy-label1    /* Page RVA */
42  .long 10     /* Block Size (2*4+2) */
43  .word (IMAGE_REL_ABSOLUTE << 12) +  0  /* reloc for dummy */

efi.ld 比較特別的事情算是 L7 的 .hash section, 這是給 dynamic section 用的, 而且一定要排在第一個, 詳請請參考 elf64 format 文件。神奇的是, 不需要指定 link 位址, 因為這個 uefi 程式要作到不管被載入到那個位址, 都可以正確執行, 這個 linker script 會從 0 開始計算位址。

README.gnuefi
.hash (and/or .gnu.hash)
        Collects the ELF .hash info (this section _must_ be the first
        section in order to build a shared object file; the section is
        not actually loaded or used at runtime).

efi.ld
 1 OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64", "elf64-x86-64")
 2 OUTPUT_ARCH(i386:x86-64)
 3 ENTRY(_start)
 4 SECTIONS
 5 {
 6  image_base = .;
 7  .hash : { *(.hash) }
 8  . = ALIGN(4096);
 9  .eh_frame : {
10   *(.eh_frame)
11  }
12  . = ALIGN(4096);
13  .text : {
14   *(.text)
15   *(.text.*)
16   *(.gnu.linkonce.t.*)
17  }
18  . = ALIGN(4096);
19  .reloc : {
20   *(.reloc)
21  }
22  . = ALIGN(4096);
23  .data : {
24   *(.rodata*)
25   *(.got.plt)
26   *(.got)
27   *(.data*)
28   *(.sdata)
29   *(.sbss)
30   *(.scommon)
31   *(.dynbss)
32   *(.bss)
33   *(COMMON)
34   *(.rel.local)
35   . = ALIGN(8);
36   *(SORT(.u_boot_list*));
37   . = ALIGN(8);
38   *(.dtb*);
39  }
40  . = ALIGN(4096);
41  .dynamic : { *(.dynamic) }
42  . = ALIGN(4096);
43  .rela : {
44   *(.rela.data*)
45   *(.rela.got)
46   *(.rela.stab)
47  }
48  . = ALIGN(4096);
49  .dynsym : { *(.dynsym) }
50  . = ALIGN(4096);
51  .dynstr : { *(.dynstr) }
52  . = ALIGN(4096);
53  .ignored.reloc : {
54   *(.rela.reloc)
55   *(.eh_frame)
56   *(.note.GNU-stack)
57  }
58  .comment 0 : { *(.comment) }
59 }

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 以下程式碼有關:

list 2 lea + rip
lea image_base(%rip), %rdi
lea _DYNAMIC(%rip), %rsi

lea + rip + symbol 的這個用法很特別, 只存在 x86-64 模式下。
ref: 从机器码理解RIP 相对寻址

有點難懂, 直接用 objdump 來說明 lea + rip 指令。

list 3 lea + rip
1 0000000000002000 <_start>:
2     2000:       48 83 ec 08             sub    $0x8,%rsp
3     2004:       51                      push   %rcx
4     2005:       52                      push   %rdx
5     2006:       48 8d 3d f3 df ff ff    lea    -0x200d(%rip),%rdi       # 0 <image_base>
6     200d:       48 8d 35 ec 2f 00 00    lea    0x2fec(%rip),%rsi        # 5000 <_DYNAMIC>
7     2014:       59                      pop    %rcx

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  0x6999ced    
image_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
 1 efi_main.efi: efi_main.elf
 2  objcopy  -j .text -j .sdata -j .data -j .dynamic -j .dynsym -j .rel -j .rela -j .reloc --target=efi-app-x86_64 $< $@
 3 
 4 efi_main.elf: efi_main.o crt0_x86_64_efi.o
 5  ld -Bsymbolic -Bsymbolic-functions -shared --no-undefined -o $@ -T efi.ld $^
 6 
 7 efi_main.o: efi_main.c efi.h types.h efi_api.h
 8  gcc -nostdinc -I. -D__KERNEL__ -D__UBOOT__ -Wall -Wstrict-prototypes -Wno-format-security -fno-builtin -ffreestanding -fshort-wchar -Os -fno-stack-protector -fno-delete-null-pointer-checks -g -fstack-usage -Wno-format-nonliteral -Werror=date-time -fno-strict-aliasing -fomit-frame-pointer -fno-toplevel-reorder -fno-dwarf2-cfi-asm -D__I386__ -ffunction-sections -fvisibility=hidden -pipe -fpic -fshort-wchar -c $<
 9 
10 crt0_x86_64_efi.o: crt0_x86_64_efi.S
11  gcc -nostdinc  -D__UBOOT__ -D__ASSEMBLY__ -g -fno-strict-aliasing -fomit-frame-pointer -fno-toplevel-reorder -fno-dwarf2-cfi-asm -D__I386__ -ffunction-sections -fvisibility=hidden -pipe -fpic -fshort-wchar   -c crt0_x86_64_efi.S
12 
13 clean:
14  rm -rf *.o *.efi *.elf

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。

在 qemu 上執行是沒有問題的
 1 UEFI Interactive Shell v2.1
 2 EDK II
 3 UEFI v2.60 (EDK II, 0x00010000)
 4 Mapping table
 5       FS0: Alias(s):FP0a:;BLK0:
 6           PciRoot(0x0)/Pci(0x1,0x0)/Floppy(0x0)
 7      BLK2: Alias(s):
 8           PciRoot(0x0)/Pci(0x1,0x1)/Ata(0x0)
 9      BLK1: Alias(s):
10           PciRoot(0x0)/Pci(0x1,0x0)/Floppy(0x1)
11 Press ESC in 2 seconds to skip startup.nsh or any other key to continue.
12 Shell> fs0:
13 FS0:\> efi_main.efi
14 002 hello efi by gcc

當然在真實機器上也是可以正常執行的。

在真實機器上測試

接下來我便要用這樣的開發方式, 開發 uefi os loader。

ref: uefi document:
  • UEFI_Spec_2_7.pdf
  • PI_Spec_1_6.pdf
ref: linux-5.1.14/arch/x86/boot/compressed/eboot.c
efi_main()
{
        setup_graphics(boot_params);
        setup_efi_pci(boot_params);
        setup_quirks(boot_params);
}

沒有留言:

張貼留言

使用 google 的 reCAPTCHA 驗證碼, 總算可以輕鬆留言了。

我實在受不了 spam 了, 又不想讓大家的眼睛花掉, 只好放棄匿名留言。這是沒辦法中的辦法了。留言的朋友需要有 google 帳號。