Linux 内核格式与启动协议¶
背景¶
之前在各种场合遇到过各种 Linux 内核的文件名或格式,例如:
- vmlinux
- vmlinuz
- uImage
- bzImage
- uImage
即使是同样的文件名,格式可能也是不一样的,相应的启动协议也可能不一样。这篇博客尝试结合 Linux,各种 Bootloader(QEMU,EDK-II,U-Boot,OpenSBI)的代码来研究不同的 Linux 二进制格式以及启动协议。
amd64/x86_64¶
首先是常用的 amd64 架构,找几个系统,查看 /boot
目录下的 kernel 文件的类型:
/boot/vmlinuz-6.1.0-12-amd64: Linux kernel x86 boot executable bzImage, version 6.1.0-12-amd64 (debian-kernel@lists.debian.org) #1 SMP PREEMPT_DYNAMIC Debian 6.1.52-1 (2023-09-07), RO-rootFS, swap_dev 0X7, Normal VGA
/boot/vmlinuz-6.2.16-14-pve: Linux kernel x86 boot executable bzImage, version 6.2.16-14-pve (build@proxmox) #1 SMP PREEMPT_DYNAMIC PMX 6.2.16-14 (2023-09-19T08:17Z), RO-rootFS, swap_dev 0XC, Normal VGA
看了几个系统,发现文件名都是 vmlinuz
开头,文件类型都是 Linux kernel x86 boot executable bzImage
。
Linux/x86 Boot Protocol¶
Linux 在 x86 下定义了一套 Linux/x86 Boot Protocol,它规定了 bootloader 在启动 Linux 的时候,需要做哪些事情,传递哪些参数,以什么形式传递参数,那么 Linux 就可以在这给基础上启动起来。
首先,Boot Protocol 定义了 Linux 内核的格式,使得 Bootloader 可以得到关于 Linux 内核的一些信息。这个格式定义在 The Real-Mode Kernel Header,是一个巨大的结构体,对应的代码如下:
.section ".header", "a"
.globl sentinel
sentinel: .byte 0xff, 0xff /* Used to detect broken loaders */
.globl hdr
hdr:
setup_sects: .byte 0 /* Filled in by build.c */
root_flags: .word ROOT_RDONLY
syssize: .long 0 /* Filled in by build.c */
ram_size: .word 0 /* Obsolete */
vid_mode: .word SVGA_MODE
root_dev: .word 0 /* Filled in by build.c */
boot_flag: .word 0xAA55
后面还有很长,都是这个结构体里的字段。其中也可以看到熟悉的 0xAA55
,熟悉的启动分区结尾。这片数据通过 linker script 来放置到从文件开始偏移 0x1f1 处:
. = 495;
.header : { *(.header) }
.entrytext : { *(.entrytext) }
.inittext : { *(.inittext) }
.initdata : { *(.initdata) }
__end_init = .;
这里 495=0x1ef
,再加上两个 sentinel
字节,最终 hdr
就会落在 0x1f1 地址处。前面看到的 file
命令输出里的各个字段,其实也是从这里读来的:
/boot/vmlinuz-6.1.0-12-amd64: Linux kernel x86 boot executable bzImage, version 6.1.0-12-amd64 (debian-kernel@lists.debian.org) #1 SMP PREEMPT_DYNAMIC Debian 6.1.52-1 (2023-09-07), RO-rootFS, swap_dev 0X7, Normal VGA
# Linux kernel boot images, from Albert Cahalan <acahalan@cs.uml.edu>
# and others such as Axel Kohlmeyer <akohlmey@rincewind.chemie.uni-ulm.de>
# and Nicolas Lichtmaier <nick@debian.org>
# All known start with: b8 c0 07 8e d8 b8 00 90 8e c0 b9 00 01 29 f6 29
# Linux kernel boot images (i386 arch) (Wolfram Kleff)
# URL: https://www.kernel.org/doc/Documentation/x86/boot.txt
514 string HdrS Linux kernel
!:strength + 55
# often no extension like in linux, vmlinuz, bzimage or memdisk but sometimes
# Acronis Recovery kernel64.dat and Plop Boot Manager plpbtrom.bin
# DamnSmallLinux 1.5 damnsmll.lnx
!:ext /dat/bin/lnx
>510 leshort 0xAA55 x86 boot executable
>>518 leshort >0x1ff
>>>529 byte 0 zImage,
>>>529 byte 1 bzImage,
>>>526 lelong >0
>>>>(526.s+0x200) string >\0 version %s,
>>498 leshort 1 RO-rootFS,
>>498 leshort 0 RW-rootFS,
>>508 leshort >0 root_dev %#X,
>>502 leshort >0 swap_dev %#X,
>>504 leshort >0 RAMdisksize %u KB,
>>506 leshort 0xFFFF Normal VGA
>>506 leshort 0xFFFE Extended VGA
>>506 leshort 0xFFFD Prompt for Videomode
>>506 leshort >0 Video mode %d
这里的判断和 Linux 源码的对应关系如下:
514 string HdrS Linux kernel
.ascii "HdrS" # header signature
>510 leshort 0xAA55 x86 boot executable
boot_flag: .word 0xAA55
>>>529 byte 0 zImage,
>>>529 byte 1 bzImage,
loadflags:
.byte LOADED_HIGH # The kernel is to be loaded high
>>>>(526.s+0x200) string >\0 version %s,
.word kernel_version-512 # pointing to kernel version string
# above section of header is compatible
# with loadlin-1.5 (header v1.5). Don't
# change it.
>>498 leshort 1 RO-rootFS,
>>498 leshort 0 RW-rootFS,
root_flags: .word ROOT_RDONLY
>>508 leshort >0 root_dev %#X,
root_dev: .word 0 /* Filled in by build.c */
>>502 leshort >0 swap_dev %#X,
syssize: .long 0 /* Filled in by build.c */
>>504 leshort >0 RAMdisksize %u KB,
ram_size: .word 0 /* Obsolete */
>>506 leshort 0xFFFF Normal VGA
>>506 leshort 0xFFFE Extended VGA
>>506 leshort 0xFFFD Prompt for Videomode
>>506 leshort >0 Video mode %d
vid_mode: .word SVGA_MODE
在此基础上,Bootloader 初始化 struct boot_params
并传给 Linux 内核:
/* The so-called "zeropage" */
struct boot_params {
struct screen_info screen_info; /* 0x000 */
struct apm_bios_info apm_bios_info; /* 0x040 */
__u8 _pad2[4]; /* 0x054 */
__u64 tboot_addr; /* 0x058 */
struct ist_info ist_info; /* 0x060 */
__u64 acpi_rsdp_addr; /* 0x070 */
/* omitted */
/*
* The sentinel is set to a nonzero value (0xff) in header.S.
*
* A bootloader is supposed to only take setup_header and put
* it into a clean boot_params buffer. If it turns out that
* it is clumsy or too generous with the buffer, it most
* probably will pick up the sentinel variable too. The fact
* that this variable then is still 0xff will let kernel
* know that some variables in boot_params are invalid and
* kernel should zero out certain portions of boot_params.
*/
__u8 sentinel; /* 0x1ef */
__u8 _pad6[1]; /* 0x1f0 */
struct setup_header hdr; /* setup header */ /* 0x1f1 */
__u8 _pad7[0x290-0x1f1-sizeof(struct setup_header)];
__u32 edd_mbr_sig_buffer[EDD_MBR_SIG_MAX]; /* 0x290 */
struct boot_e820_entry e820_table[E820_MAX_ENTRIES_ZEROPAGE]; /* 0x2d0 */
/* omitted */
}
接着 Kernel 就会启动,根据 struct boot_params
的各个字段进行初始化。
EFI boot stub¶
如果用 xxd
查看文件开头,会发现它具有 PE 和 COFF 头部:
00000000: 4d5a ea07 00c0 078c c88e d88e c08e d031 MZ.............1
00000010: e4fb fcbe 4000 ac20 c074 09b4 0ebb 0700 ....@.. .t......
00000020: cd10 ebf2 31c0 cd16 cd19 eaf0 ff00 f000 ....1...........
00000030: 0000 0000 0000 0000 cd23 8281 8200 0000 .........#......
00000040: 5573 6520 6120 626f 6f74 206c 6f61 6465 Use a boot loade
00000050: 722e 0d0a 0a52 656d 6f76 6520 6469 736b r....Remove disk
00000060: 2061 6e64 2070 7265 7373 2061 6e79 206b and press any k
00000070: 6579 2074 6f20 7265 626f 6f74 2e2e 2e0d ey to reboot....
00000080: 0a00 5045 0000 6486 0400 0000 0000 0000 ..PE..d.........
00000090: 0000 0100 0000 a000 0602 0b02 0214 00fe ................
000000a0: a504 0000 0000 0000 0000 9c07 cf00 0002 ................
这样做的目的是让 UEFI 认为 vmlinux 也是一个合法的 UEFI 程序,而 UEFI 的程序格式正是 PE,这种做法就是 EFI boot stub,生成一个满足 UEFI 要求的头部。在 Linux 源码 arch/x86/boot/header.S中,使用汇编来构造出一个 MS-DOS Stub:
.section ".bstext", "ax"
.global bootsect_start
bootsect_start:
#ifdef CONFIG_EFI_STUB
# "MZ", MS-DOS header
.word MZ_MAGIC
#endif
首先是经典的 MZ
,也就是 MS-DOS Stub 的 magic。MS-DOS Stub 在偏移 0x3c
的地方,记录了到 PE 头部的偏移:
#ifdef CONFIG_EFI_STUB
.org 0x38
#
# Offset to the PE header.
#
.long LINUX_PE_MAGIC
.long pe_header
#endif /* CONFIG_EFI_STUB */
后面才是实际的 COFF 头部,在汇编中填写 COFF 头部的各个字段:
#ifdef CONFIG_EFI_STUB
pe_header:
.long PE_MAGIC
coff_header:
#ifdef CONFIG_X86_32
.set image_file_add_flags, IMAGE_FILE_32BIT_MACHINE
.set pe_opt_magic, PE_OPT_MAGIC_PE32
.word IMAGE_FILE_MACHINE_I386
#else
.set image_file_add_flags, 0
.set pe_opt_magic, PE_OPT_MAGIC_PE32PLUS
.word IMAGE_FILE_MACHINE_AMD64
#endif
.word section_count # nr_sections
.long 0 # TimeDateStamp
.long 0 # PointerToSymbolTable
.long 1 # NumberOfSymbols
.word section_table - optional_header # SizeOfOptionalHeader
.word IMAGE_FILE_EXECUTABLE_IMAGE | \
image_file_add_flags | \
IMAGE_FILE_DEBUG_STRIPPED | \
IMAGE_FILE_LINE_NUMS_STRIPPED # Characteristics
后面还有很多内容,这里没有完整贴出来。里面比较重要的是 AddressOfEntryPoint,也就是 PE 程序的入口。UEFI 在执行 PE 程序的时候,会按照下面的函数签名调用:
typedef
EFI_STATUS
(EFIAPI *EFI_IMAGE_ENTRY_POINT) (
IN EFI_HANDLE ImageHandle,
IN EFI_SYSTEM_TABLE *SystemTable
);
所以 Linux 也按照这个签名实现了一个函数:
/*
* Because the x86 boot code expects to be passed a boot_params we
* need to create one ourselves (usually the bootloader would create
* one for us).
*/
efi_status_t __efiapi efi_pe_entry(efi_handle_t handle,
efi_system_table_t *sys_table_arg);
那么当从 UEFI 执行 vmlinuz 的时候,UEFI 就会从 COFF 头部找到 efi_pe_entry
函数的地址,传入两个参数,然后调用它。这个函数负责的是,模仿 Bootloader 的行为,填写 struct boot_param
,然后跳转到真正的内核 entrypoint。
内核压缩¶
Linux 内核还经常会以压缩的形式存在,压缩的算法可能采用 gzip 等等。压缩的同时,也会放一份没有被压缩的汇编代码,用来解压。为了区分内核是否经过压缩,通常约定 vmlinux
表示没有经过压缩,vmlinuz
表示经过压缩。
Image/zImage/bzImage/uImage¶
有时候还会看到另一种术语:Image 添加一个前缀,如:
- Image:未经过压缩的 Linux 内核
- zImage:经过压缩的 Linux 内核
- bzImage:big zImage,而不是 bzip Image,是 zImage 的后续格式
bzImage 和 zImage 从 Boot Protocol 来看,加载的地址不同:
is_bzImage = (protocol >= 0x0200) && (loadflags & 0x01);
load_address = is_bzImage ? 0x100000 : 0x10000;
这样解决了 Image/zImage 的大小上限问题(地址范围 0x10000-0x90000,512KB),所以基本只能见到 bzImage 了。
而 uImage 其实是 U-Boot 的启动镜像格式,在 Linux 内核外面套了一层自己的格式。但现在 U-Boot 设计了新的格式:U-Boot FIT Image。由于 x86 上一般不会用 U-Boot,这里就不涉及了。
在编译内核过程中,这些文件关系是:
- vmlinux: 首先链接出来的 ELF
- arch/x86/boot/compressed/vmlinux.bin: 从 vmlinux objcopy 得到的 ELF 文件,去掉了 .comment section
- arch/x86/boot/compressed/vmlinux.bin.gz: 对 arch/x86/boot/compressed/vmlinux.bin 进行压缩
- arch/x86/boot/compressed/vmlinux: 把 arch/x86/boot/compressed/vmlinux.bin.gz(通过 arch/x86/boot/compressed/piggy.S 使用 .incbin 引入)和解压缩程序打包在一起,成为一个新的 vmlinux
- arch/x86/boot/vmlinux.bin: 从 arch/x86/boot/compressed/vmlinux objcopy 得到的二进制文件,去掉了 .note 和 .comment section
- arch/x86/boot/bzImage: 把 arch/x86/boot/vmlinux.bin 组装成最终的格式
这个过程中执行的命令,可以从目录下对应的 .cmd
文件里找到:
$ cat arch/x86/boot/compressed/.vmlinux.bin.cmd
cmd_arch/x86/boot/compressed/vmlinux.bin := objcopy -R .comment -S vmlinux arch/x86/boot/compressed/vmlinux.bin
$ cat arch/x86/boot/compressed/.vmlinux.bin.gz.cmd
cmd_arch/x86/boot/compressed/vmlinux.bin.gz := cat arch/x86/boot/compressed/vmlinux.bin arch/x86/boot/compressed/vmlinux.relocs | gzip -n -f -9 > arch/x86/boot/compressed/vmlinux.bin.gz
$ cat arch/x86/boot/compressed/.vmlinux.cmd
cmd_arch/x86/boot/compressed/vmlinux := ld -m elf_x86_64 --no-ld-generated-unwind-info -pie --no-dynamic-linker --orphan-handling=warn -z noexecstack --no-warn-rwx-segments -T arch/x86/boot/compressed/vmlinux.lds arch/x86/boot/compressed/kernel_info.o arch/x86/boot/compressed/head_64.o arch/x86/boot/compressed/misc.o arch/x86/boot/compressed/string.o arch/x86/boot/compressed/cmdline.o arch/x86/boot/compressed/error.o arch/x86/boot/compressed/piggy.o arch/x86/boot/compressed/cpuflags.o arch/x86/boot/compressed/early_serial_console.o arch/x86/boot/compressed/kaslr.o arch/x86/boot/compressed/ident_map_64.o arch/x86/boot/compressed/idt_64.o arch/x86/boot/compressed/idt_handlers_64.o arch/x86/boot/compressed/mem_encrypt.o arch/x86/boot/compressed/pgtable_64.o arch/x86/boot/compressed/sev.o arch/x86/boot/compressed/acpi.o arch/x86/boot/compressed/tdx.o arch/x86/boot/compressed/tdcall.o arch/x86/boot/compressed/efi_thunk_64.o arch/x86/boot/compressed/efi.o drivers/firmware/efi/libstub/lib.a -o arch/x86/boot/compressed/vmlinux
$ cat arch/x86/boot/.vmlinux.bin.cmd
cmd_arch/x86/boot/vmlinux.bin := objcopy -O binary -R .note -R .comment -S arch/x86/boot/compressed/vmlinux arch/x86/boot/vmlinux.bin
$ cat arch/x86/boot/.bzImage.cmd
cmd_arch/x86/boot/bzImage := arch/x86/boot/tools/build arch/x86/boot/setup.bin arch/x86/boot/vmlinux.bin arch/x86/boot/zoffset.h arch/x86/boot/bzImage
最后就由 installkernel
命令把 bzImage
复制到 /boot
下,并改名为 vmlinuz
。
对于这一过程的完整描述,推荐阅读 老司机带你探索内核编译系统,写的比较详细。
Unified Kernel Image¶
Unified Kernel Image 也是一种比较新的格式,它把启动时候需要的一些文件(Linux 内核,微码,initramfs 等等),都放在一个文件里,这样方便 Secure Boot,只需要对一个大文件进行签名即可。
riscv64¶
RISC-V 现在通常有两套固件标准,一套是 SBI(Supervisor Binary Interface),另一套是 UEFI。
SBI¶
SBI 是 M 态程序提供给 S 态程序的一套接口。SBI 一个的常见实现就是 OpenSBI,当 OpenSBI 加载 Linux 的时候,做了如下约定:
- a0: hart id
- a1: dtb 地址
代码如下:
void __noreturn sbi_hsm_hart_start_finish(struct sbi_scratch *scratch,
u32 hartid)
{
unsigned long next_arg1;
unsigned long next_addr;
unsigned long next_mode;
struct sbi_hsm_data *hdata = sbi_scratch_offset_ptr(scratch,
hart_data_offset);
if (!__sbi_hsm_hart_change_state(hdata, SBI_HSM_STATE_START_PENDING,
SBI_HSM_STATE_STARTED))
sbi_hart_hang();
next_arg1 = scratch->next_arg1;
next_addr = scratch->next_addr;
next_mode = scratch->next_mode;
hsm_start_ticket_release(hdata);
sbi_hart_switch_mode(hartid, next_arg1, next_addr, next_mode, false);
}
这里 sbi_hart_switch_mode
前两个参数就是最终要传给 Linux 的参数,第一个参数 hartid
通过 a0
寄存器传递,第二个参数 next_arg1
通过 a1
寄存器传递。
当 Linux 启动的时候,就会从 dtb 中获取系统的各项信息。这样的设计接口比较简单,只需要传两个寄存器,但是很多东西就要放到 dtb 里面去传了,例如 initrd 的地址,cmdline 等等。无论是 bootloader 还是 Linux,都需要附带 dtb 解析和修改的代码,不像 x86 那样,只需要传一个固定结构的结构体即可。
QEMU 支持直接加载 Kernel,也就是说 QEMU 也要负责实现上面的 Boot Protocol:
void kvm_riscv_reset_vcpu(RISCVCPU *cpu)
{
CPURISCVState *env = &cpu->env;
if (!kvm_enabled()) {
return;
}
env->pc = cpu->env.kernel_addr;
env->gpr[10] = kvm_arch_vcpu_id(CPU(cpu)); /* a0 */
env->gpr[11] = cpu->env.fdt_addr; /* a1 */
env->satp = 0;
}
UEFI¶
RISC-V 也支持用 UEFI 启动,它的做法和 x86 类似,也是做一个 EFI Boot Stub。稍微不一样的是,通过构造,EFI boot stub 可以保证它在直接当成 RISC-V 程序执行的时候,也可以正常工作:
__HEAD
ENTRY(_start)
/*
* Image header expected by Linux boot-loaders. The image header data
* structure is described in asm/image.h.
* Do not modify it without modifying the structure and all bootloaders
* that expects this header format!!
*/
#ifdef CONFIG_EFI
/*
* This instruction decodes to "MZ" ASCII required by UEFI.
*/
c.li s4,-13
j _start_kernel
#else
/* jump to start kernel */
j _start_kernel
/* reserved */
.word 0
#endif
在 RISC-V 下,PE 头部的 MZ
可以被解析成合法的指令,在它后面跳转到实际的 Kernel 入口,这样即使 Bootloader 没有实现 UEFI,例如 OpenSBI,跳转到 Kernel 第一条指令开始执行,也可以正常进入到 _start_kernel
当中。
和 x86 类似,EFI boot stub 的实际 entry point 是一个单独的函数,而不是原来的 _start_kernel
:
/*
* EFI entry point for the generic EFI stub used by ARM, arm64, RISC-V and
* LoongArch. This is the entrypoint that is described in the PE/COFF header
* of the core kernel.
*/
efi_status_t __efiapi efi_pe_entry(efi_handle_t handle,
efi_system_table_t *systab);
这个函数的接口和前面 amd64 UEFI 是一样的,因为是 UEFI 标准规定的。它做的事情是,从 UEFI 获取系统信息,构造出一个 dtb,然后从 UEFI 中获取 hart id(见后),然后再跳转到实际的 _start_kernel
,传递的参数和前面 SBI 时是一样的:
void __noreturn efi_enter_kernel(unsigned long entrypoint, unsigned long fdt,
unsigned long fdt_size)
{
unsigned long kernel_entry = entrypoint + stext_offset();
jump_kernel_func jump_kernel = (jump_kernel_func)kernel_entry;
/*
* Jump to real kernel here with following constraints.
* 1. MMU should be disabled.
* 2. a0 should contain hartid
* 3. a1 should DT address
*/
csr_write(CSR_SATP, 0);
jump_kernel(hartid, fdt);
}
为了让 EFI boot stub 可以获取到 boot hart id,还设计了 RISCV_EFI_BOOT_PROTOCOL
,使得 EFI boot stub 可以获取 boot hart id:
static efi_status_t get_boot_hartid_from_efi(void)
{
efi_guid_t boot_protocol_guid = RISCV_EFI_BOOT_PROTOCOL_GUID;
struct riscv_efi_boot_protocol *boot_protocol;
efi_status_t status;
status = efi_bs_call(locate_protocol, &boot_protocol_guid, NULL,
(void **)&boot_protocol);
if (status != EFI_SUCCESS)
return status;
return efi_call_proto(boot_protocol, get_boot_hartid, &hartid);
}
这个函数就会在 UEFI 固件中实现,例如 edk2:
/**
Get the boot hartid
@param This Protocol instance structure
@param BootHartId Pointer to the Boot Hart ID variable
@retval EFI_SUCCESS If BootHartId is returned
@retval EFI_INVALID_PARAMETER Either "BootHartId" is NULL or "This" is not
a valid RISCV_EFI_BOOT_PROTOCOL instance.
**/
EFI_STATUS
EFIAPI
RiscvGetBootHartId (
IN RISCV_EFI_BOOT_PROTOCOL *This,
OUT UINTN *BootHartId
)
{
if ((This != &gRiscvBootProtocol) || (BootHartId == NULL)) {
return EFI_INVALID_PARAMETER;
}
*BootHartId = mBootHartId;
return EFI_SUCCESS;
}
以及 U-Boot:
/**
* efi_riscv_get_boot_hartid() - return boot hart ID
* @this: RISCV_EFI_BOOT_PROTOCOL instance
* @boot_hartid: caller allocated memory to return boot hart id
* Return: status code
*/
static efi_status_t EFIAPI
efi_riscv_get_boot_hartid(struct riscv_efi_boot_protocol *this,
efi_uintn_t *boot_hartid)
{
EFI_ENTRY("%p, %p", this, boot_hartid);
if (this != &riscv_efi_boot_prot || !boot_hartid)
return EFI_EXIT(EFI_INVALID_PARAMETER);
*boot_hartid = gd->arch.boot_hart;
return EFI_EXIT(EFI_SUCCESS);
}
struct riscv_efi_boot_protocol riscv_efi_boot_prot = {
.revision = RISCV_EFI_BOOT_PROTOCOL_REVISION,
.get_boot_hartid = efi_riscv_get_boot_hartid
};