2017年12月31日 星期日

write uefi program without edk2

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

uefi 程式需要使用 edk2, 要完成一個 uefi hello 程式, 得照其規定來開發, 但是 edk2 實在太麻煩了, 有沒有 gcc + makefile 就可以搞定的方法呢?

很幸運的, 答案是肯定的。
2018 倒數計時
也許你聽過 gnu efi, 做的也是類似的事情。

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, 咦! 好像是廢話。

先來看看 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 就是在做這件事情, 這邊很抱歉, 我不知道為什麼要這樣, 如果有人能告知我, 非常謝謝。

[ 4] .reloc            PROGBITS         0000000000003000  00203000
       000000000000000a  0000000000000000   A       0     0     1

這時候最終的 elf 檔案就會有 .reloc section, 這樣 uefi 才會正常載入/執行這個 efi application。

最後還有一段 relocation 的程式碼, 為了簡化, 我拿掉了, 這是在這個 uefi 程式被載入時, 會自己處理被標記為 DT_RELA, DT_RELASZ, DT_RELAENT 的 symbol, 不過我不知道怎麼造出這些 symbol, 所以就沒特別介紹這個。

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 比較特別的事情算是 L8 的 .hash section, 這是給 dynamic section 用的, 詳請請參考 elf64 format 文件。神奇的是, 不需要指定 link 位址, 因為這個 uefi 程式要作到不管被載入到那個位址, 都可以正確執行。

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, 很神奇吧!

L5 的 ld -shared 會插入 _DYNAMIC symbol, 和 crt0_x86_64_efi.S 以下程式碼有關:

lea image_base(%rip), %rdi
lea _DYNAMIC(%rip), %rsi

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), 而且也不太會注意其和「位址無關」到底是什麼意思?

字面上的解釋, 就是無論載入到 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

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

在真實機器上測試

沒有留言:

張貼留言

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

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