前面几章完成了从 MBR 到 Loader 的引导链,进入保护模式并开启了分页。现在要做最后一步:把编译好的内核 ELF 文件从磁盘读入内存,解析它的段布局,把代码和数据放到正确的地址,然后跳进去执行。
为什么是 ELF
ELF(Executable and Linkable Format)是 Unix/Linux 的标准可执行文件格式。选择它而不是更简单的 flat binary 或 a.out,是因为内核的内存布局有特殊需求:代码段要可执行不可写,数据段要可写不可执行,BSS 段要在运行时清零但不占磁盘空间。a.out 格式把所有段捆在一起,无法表达这种细粒度的权限控制;flat binary 更是连段的概念都没有。ELF 的程序头表(Program Header Table)天然支持”每段独立地址、独立权限、独立大小”,这正是内核加载器需要的。
ELF 还有一项间接优势:工具链成熟。GCC 生成 ELF、LD 链接 ELF、OBJDUMP 反汇编 ELF,整个开发流程不需要额外的格式转换步骤。当内核跑起来之后,符号表和调试信息也留在 ELF 的节区头表(Section Header Table)中,方便用 GDB 做源码级调试。
程序头表面向运行时加载,节区头表面向链接和调试。内核加载器只读程序头表就够了,节区头表在运行时可以完全忽略。这也是 ELF 把两种视图分开设计的工程考量:加载器不需要理解链接器的全部细节。
ELF 文件格式
ELF 文件总体结构
ELF 文件由四部分组成,从文件开头到结尾依次为:
- ELF Header:文件开头,描述整个文件
- Program Header Table:描述可加载段(对运行时重要)
- Sections:包含 .text 代码段、.data 数据段等实际内容
- Section Header Table:描述各个节区(对链接时重要)
对于加载内核而言,主要关注 ELF 头部和程序头表,它们包含了运行时需要的所有信息。节区头表是给链接器和调试器用的,加载器不需要碰它。
ELF 头部(ELF Header)
ELF 头部位于文件开头,描述了整个文件的基本信息:这个文件是不是 ELF、是 32 位还是 64 位、入口点在哪里、程序头表从文件的哪个偏移开始。32 位系统的 ELF 头部占 52 字节,64 位占 64 字节。
ELF 头部数据结构
// 32 位 ELF 头部#define EI_NIDENT 16
typedef struct { unsigned char e_ident[EI_NIDENT]; // ELF 标识信息 uint16_t e_type; // 文件类型 uint16_t e_machine; // 目标架构 uint32_t e_version; // ELF 版本 uint32_t e_entry; // 程序入口点地址 uint32_t e_phoff; // 程序头表偏移 uint32_t e_shoff; // 节区头表偏移 uint32_t e_flags; // 处理器特定标志 uint16_t e_ehsize; // ELF 头部大小 uint16_t e_phentsize; // 程序头表条目大小 uint16_t e_phnum; // 程序头表条目数量 uint16_t e_shentsize; // 节区头表条目大小 uint16_t e_shnum; // 节区头表条目数量 uint16_t e_shstrndx; // 节区名称字符串表索引} Elf32_Ehdr;
// 64 位 ELF 头部typedef struct { unsigned char e_ident[EI_NIDENT]; // ELF 标识信息 uint16_t e_type; // 文件类型 uint16_t e_machine; // 目标架构 uint32_t e_version; // ELF 版本 uint64_t e_entry; // 程序入口点地址 uint64_t e_phoff; // 程序头表偏移 uint64_t e_shoff; // 节区头表偏移 uint32_t e_flags; // 处理器特定标志 uint16_t e_ehsize; // ELF 头部大小 uint16_t e_phentsize; // 程序头表条目大小 uint16_t e_phnum; // 程序头表条目数量 uint16_t e_shentsize; // 节区头表条目大小 uint16_t e_shnum; // 节区头表条目数量 uint16_t e_shstrndx; // 节区名称字符串表索引} Elf64_Ehdr;加载器首先要验证 e_ident 数组:前 4 字节是魔数 0x7F 'E' 'L' 'F',用来快速判断文件是否为 ELF 格式;第 5 字节标识 32 位(ELFCLASS32 = 1)或 64 位(ELFCLASS64 = 2);第 6 字节标识字节序,小端(ELFDATA2LSB = 1)或大端(ELFDATA2MSB = 2)。x86 是小端架构,所以加载器必须校验这一字节,否则后续所有多字节数据都会按错误的字节序解读。
// e_ident 索引定义#define EI_MAG0 0 // 魔数第一个字节:0x7F#define EI_MAG1 1 // 魔数第二个字节:'E'#define EI_MAG2 2 // 魔数第三个字节:'L'#define EI_MAG3 3 // 魔数第四个字节:'F'#define EI_CLASS 4 // 文件类别(32 位 / 64 位)#define EI_DATA 5 // 数据编码(大端 / 小端)#define EI_VERSION 6 // ELF 版本#define EI_OSABI 7 // 操作系统 / ABI 标识#define EI_ABIVERSION 8 // ABI 版本#define EI_PAD 9 // 填充字节起始位置e_type 区分 ELF 文件的用途:ET_EXEC(2) 是可执行文件,ET_REL(1) 是可重定位目标文件(.o),ET_DYN(3) 是共享库(.so),ET_CORE(4) 是核心转储。内核文件通常是 ET_EXEC 类型,它有固定的加载地址,不需要动态链接器帮忙选择基地址。
e_machine 标识目标架构。i386 对应 EM_386(3),x86-64 对应 EM_X86_64(62),ARM 对应 EM_ARM(40)。加载器校验这个字段是为了防止在错误的 CPU 上加载不兼容的二进制文件,在裸机环境下没有操作系统替你做这件事,执行一条不支持的指令只会直接 triple fault。
#define ET_EXEC 2 // 可执行文件#define EM_386 3 // Intel 80386#define EM_X86_64 62 // AMD x86-64程序头表(Program Header Table)
程序头表描述了 ELF 文件中的段(Segment),每个段在加载时会被映射到内存中。程序头表是一个数组,ELF 头部的 e_phoff 字段给出了这个数组在文件中的偏移,e_phnum 给出了元素个数,e_phentsize 给出了每个元素的大小。
程序头数据结构
// 32 位程序头typedef struct { uint32_t p_type; // 段类型 uint32_t p_offset; // 段在文件中的偏移 uint32_t p_vaddr; // 段在内存中的虚拟地址 uint32_t p_paddr; // 段的物理地址 uint32_t p_filesz; // 段在文件中的大小 uint32_t p_memsz; // 段在内存中的大小 uint32_t p_flags; // 段的标志 uint32_t p_align; // 段的对齐方式} Elf32_Phdr;
// 64 位程序头typedef struct { uint32_t p_type; // 段类型 uint32_t p_flags; // 段的标志 uint64_t p_offset; // 段在文件中的偏移 uint64_t p_vaddr; // 段在内存中的虚拟地址 uint64_t p_paddr; // 段的物理地址 uint64_t p_filesz; // 段在文件中的大小 uint64_t p_memsz; // 段在内存中的大小 uint64_t p_align; // 段的对齐方式} Elf64_Phdr;注意 32 位和 64 位程序头中 p_flags 的位置不同:32 位放在最后,64 位紧挨着 p_type。这是 64 位 ELF 规范刻意调整的,把 p_flags 移到前面可以让结构体中 64 位字段自然对齐,减少填充字节。
p_vaddr 和 p_paddr 同时存在是历史遗留。在启用分页的系统中,CPU 用 p_vaddr(虚拟地址)访问内存,p_paddr 几乎没用。但在没有 MMU 的嵌入式系统上,或者在内核还没开启分页的启动阶段,加载器需要知道物理地址才能把数据放到正确的位置。所以我们的汇编加载器用 p_paddr 作为复制目标地址。
#define PT_NULL 0 // 未使用#define PT_LOAD 1 // 可加载段(必须加载到内存)#define PT_DYNAMIC 2 // 动态链接信息#define PT_INTERP 3 // 解释器路径#define PT_NOTE 4 // 注释信息#define PT_SHLIB 5 // 保留#define PT_PHDR 6 // 程序头表自身#define PT_GNU_STACK 0x6474e551 // GNU 栈属性加载内核时只关心 PT_LOAD(1) 类型的段。其他段类型服务于动态链接和元数据,内核是静态链接的,不需要它们。
p_flags 控制段的权限:PF_X(0x1) 可执行、PF_W(0x2) 可写、PF_R(0x4) 可读。这些标志最终会写入页表项的 R/W 和 NX 位。代码段通常是 PF_R | PF_X(0x5),数据段是 PF_R | PF_W(0x6)。如果给代码段也加上可写权限,看似省事,但一旦内核有缓冲区溢出漏洞,攻击者就能直接修改内核代码,所以加载器在设置页表时必须尊重 p_flags。
#define PF_X 0x1 // 可执行#define PF_W 0x2 // 可写#define PF_R 0x4 // 可读节区头表(Section Header Table)
节区头表主要用于链接阶段,描述了 ELF 文件中的各个节区(Section)。虽然在运行时加载不是必需的,但对于理解和调试内核非常有用。
// 32 位节区头typedef struct { uint32_t sh_name; // 节区名称(在字符串表中的索引) uint32_t sh_type; // 节区类型 uint32_t sh_flags; // 节区标志 uint32_t sh_addr; // 节区在内存中的地址 uint32_t sh_offset; // 节区在文件中的偏移 uint32_t sh_size; // 节区大小 uint32_t sh_link; // 链接到另一个节区的索引 uint32_t sh_info; // 附加信息 uint32_t sh_addralign; // 对齐方式 uint32_t sh_entsize; // 固定大小条目的表项大小} Elf32_Shdr;常见的节区类型:
#define SHT_NULL 0 // 无效节区#define SHT_PROGBITS 1 // 程序定义的内容(如代码、数据)#define SHT_SYMTAB 2 // 符号表#define SHT_STRTAB 3 // 字符串表#define SHT_RELA 4 // 重定位表(带加数)#define SHT_HASH 5 // 符号哈希表#define SHT_DYNAMIC 6 // 动态链接信息#define SHT_NOTE 7 // 注释#define SHT_NOBITS 8 // 无内容(如 BSS 段)ELF 解析代码示例
下面是一段教学示意代码,展示 ELF 加载的典型逻辑:验证魔数、遍历程序头、复制 PT_LOAD 段、处理 BSS。注意,本项目的真实加载器 do_load_kernel 为了精简省略了魔数校验和 BSS 处理,完整实现见后文”完整的 Loader 代码”一节。
; -----------------------------------------------------------------------; 教学示意:ELF 内核加载器(典型实现,非本项目真实代码); NASM syntax, 32-bit protected mode; -----------------------------------------------------------------------
%include "../common/bootmacros.inc"
; ELF 常量定义ELF_MAGIC equ 0x464C457F ; "\x7FELF"ELFCLASS32 equ 1ELFDATA2LSB equ 1ET_EXEC equ 2EM_386 equ 3PT_LOAD equ 1PF_X equ 0x1PF_W equ 0x2PF_R equ 0x4
; ELF 头部偏移E_IDENT equ 0E_TYPE equ 16E_MACHINE equ 18E_VERSION equ 20E_ENTRY equ 24E_PHOFF equ 28E_PHENTSIZE equ 42E_PHNUM equ 44
; 程序头偏移P_TYPE equ 0P_OFFSET equ 4P_VADDR equ 8P_PADDR equ 12P_FILESZ equ 16P_MEMSZ equ 20P_FLAGS equ 24P_ALIGN equ 28
; -----------------------------------------------------------------------; load_elf_kernel(教学示意); 输入: esi = ELF 文件在内存中的起始地址; 输出: eax = 内核入口地址,加载成功返回入口点,失败返回 0; -----------------------------------------------------------------------load_elf_kernel: push ebx push ecx push edx push edi push ebp
; 保存 ELF 起始地址 mov ebp, esi
; 验证 ELF 魔数 mov eax, [esi + E_IDENT] cmp eax, ELF_MAGIC jne .invalid_elf
; 验证类别(32位) cmp byte [esi + E_IDENT + 4], ELFCLASS32 jne .invalid_elf
; 验证字节序(小端) cmp byte [esi + E_IDENT + 5], ELFDATA2LSB jne .invalid_elf
; 验证文件类型(可执行) mov ax, [esi + E_TYPE] cmp ax, ET_EXEC jne .invalid_elf
; 验证架构(i386) mov ax, [esi + E_MACHINE] cmp ax, EM_386 jne .invalid_elf
; 获取程序头表信息 mov ebx, [esi + E_PHOFF] ; 程序头表偏移 add ebx, esi ; 程序头表地址 mov cx, [esi + E_PHNUM] ; 程序头数量 mov dx, [esi + E_PHENTSIZE] ; 程序头条目大小
.phdr_loop: test cx, cx jz .load_done
; 检查段类型 mov eax, [ebx + P_TYPE] cmp eax, PT_LOAD jne .next_phdr
; 这是一个可加载段,需要复制到内存 push ecx push ebx
; 获取源地址(文件中的偏移 + ELF 起始) mov esi, [ebx + P_OFFSET] add esi, ebp
; 获取目标地址 mov edi, [ebx + P_PADDR]
; 获取复制大小 mov ecx, [ebx + P_FILESZ]
; 复制段内容 cld rep movsb
; 如果内存大小 > 文件大小,需要填充 0(BSS) mov ecx, [ebx + P_MEMSZ] sub ecx, [ebx + P_FILESZ] jle .no_bss
xor eax, eax rep stosb
.no_bss: pop ebx pop ecx
.next_phdr: ; 移动到下一个程序头 add ebx, edx dec cx jmp .phdr_loop
.load_done: ; 返回入口点地址 mov eax, [ebp + E_ENTRY] jmp .exit
.invalid_elf: xor eax, eax ; 返回 0 表示失败
.exit: pop ebp pop edi pop edx pop ecx pop ebx ret这段代码遵循 OSDev 社区的典型做法:先校验 ELF 魔数、类别、字节序、文件类型、目标架构,再遍历程序头表,对每个 PT_LOAD 段用 rep movsb 复制到 p_paddr 指定的物理地址,最后处理 BSS(p_memsz - p_filesz 部分清零)。本项目真实的 do_load_kernel 与之有三处差异:一是不做任何格式校验,因为内核是自己编译的;二是段复制调用 memcpy 而非内联 rep movsb;三是不处理 BSS,依赖链接脚本和内核自身约定。
从引导程序到内核的跳转过程
内核加载流程
完整的内核加载流程包括以下步骤:
完整的 Loader 代码
下面是真实的 Loader 代码,节选自 05.hello-kernel/loader.S,展示了进入保护模式、开启分页、加载 ELF 内核并跳转到内核入口的完整流程:
[bits 16]org 0x8000
%include "../common/bootmacros.inc"
_start: jmp loader_start
; 定义 GDTDEFINE_STANDARD_GDT
loader_start: cli xor ax, ax mov ds, ax mov es, ax mov fs, ax mov gs, ax
SET_STACK_AT_MBR
call cls mov bx, MSG_REAL_MODE call print_bios
; 启用A20地址线 in al, 0x92 or al, 0000_0010b out 0x92, al
; 加载GDT并进入保护模式 lgdt [gdt_descriptor] mov eax, cr0 or eax, 1 mov cr0, eax
; 远跳刷新CS jmp SELECTOR_CODE32:init_pm
; ------------------------------; BIOS文本输出; ------------------------------print_bios: pusha.loop: mov al, [bx] test al, al jz .done mov ah, 0x0e int 0x10 inc bx jmp .loop.done: popa ret
cls: mov ax, 0x0600 mov bx, 0x0700 mov cx, 0 mov dx, 184Fh int 0x10 mov ah, 0x02 xor dx, dx xor bh, bh int 0x10 ret
MSG_REAL_MODE db "Started in 16-bit real mode (BIOS)",0MSG_PROT_MODE db "Now in 32-bit protected mode (direct video)",0MSG_ENABLE_PAGE db "Now Enable Page",0
; ------------------------------; 保护模式初始化; ------------------------------[bits 32]
%include "../common/lib/pm_utils.S"
init_pm: mov ax, SELECTOR_DATA32 mov ds, ax mov es, ax mov fs, ax mov ss, ax mov ax, SELECTOR_VIDEO mov gs, ax
SET_STACK_AT_PM
call clear_screen
push 0x0F push MSG_PROT_MODE call println add esp, 8
call setup_page
push 0x0F push MSG_ENABLE_PAGE call println add esp, 8
call init_kernel
cli.hang: hlt jmp .hang
; ------------------------------; 内存分页; ------------------------------setup_page: ; 清除页目录的内存空间 push PAGE_SIZE ; n push 0 ; val = 0 push PAGE_DIR_PHYISCAL_ADDR ; dest call memset add esp, 12.create_pde: ; first pde mov eax, PAGE_DIR_PHYISCAL_ADDR - PAGE_SIZE or eax, PTE_USER | PTE_WRITE | PTE_PRESENT mov [PAGE_DIR_PHYISCAL_ADDR + 0], eax mov [PAGE_DIR_PHYISCAL_ADDR + 768 * 4], eax
; the second pde - we use this 4MB virtual space for all kernel page tales :) mov eax, PAGE_DIR_PHYISCAL_ADDR or eax, PTE_USER | PTE_WRITE | PTE_PRESENT mov [PAGE_DIR_PHYISCAL_ADDR + 769 * 4], eax
; other kernel pde mov eax, PAGE_DIR_PHYISCAL_ADDR + PAGE_SIZE or eax, PTE_USER | PTE_WRITE | PTE_PRESENT mov ecx, 254 mov edx, PAGE_DIR_PHYISCAL_ADDR + 770 * 4.create_kernel_pde: mov [edx], eax add eax, PAGE_SIZE add edx, 4 loop .create_kernel_pde
; create the first page table, for 1MB low memory mov eax, 0 or eax, PTE_USER | PTE_WRITE | PTE_PRESENT mov ecx, 256 mov edx, PAGE_DIR_PHYISCAL_ADDR - PAGE_SIZE.create_pte: mov [edx], eax add eax, PAGE_SIZE add edx, 4 loop .create_pte
call enable_page retsetup_page 的页表布局和上一章讲的恒等映射不一样,它服务的是”内核跑在高地址”这个目标。页目录在 PAGE_DIR_PHYISCAL_ADDR(即 0x101000),它前面一页 0x100000 放第一个页表。PDE[0] 和 PDE[768] 都指向这个页表,前者映射低 4MB 用于过渡,后者把低 1MB 恒等映射到 0xC0000000 附近。PDE[769] 指向页目录自身所在页,这一段 4MB 虚拟空间留作所有内核页表的存放区。PDE[770] 到 PDE[1023] 共 254 项依次指向后续物理页,是内核代码数据区的映射空间。第一个页表只填 256 项,对应低 1MB 物理内存。
页表项标志用的是 PTE_USER | PTE_WRITE | PTE_PRESENT = 0x7,不是只有 R/W 和 Present 的 0x3,因为这套页表把用户态可访问位也设上了,方便后续从内核跳到用户态时不用再改页表。
enable_page 完成三件事:调整 GDT 中视频段描述符的基址到高地址、把 GDT 和栈都搬到 KERNEL_SPACE_START(0xC0000000)以上、设置 CR3 并置 CR0.PG 位。置位后立刻 lgdt 重新加载 GDT,因为开启分页后旧的 GDT 地址可能不再有效。这段代码省略了,完整实现见 loader.S。
; ------------------------------; 内核初始化(节选); ------------------------------init_kernel: call allocate_pages_for_kernel call load_hd_kernel_image call do_load_kernel
; init floating point unit before entering the kernel finit
; move stack to 0xF0000000 mov esp, KERNEL_STACK_TOP - 16 mov ebp, esp
; let's jump to kernel entry :) jmp eax retinit_kernel 是加载内核的总入口。它先调用 allocate_pages_for_kernel 为内核镜像、内核代码段、内核栈建立页表映射,然后 load_hd_kernel_image 把磁盘上的 kernel.bin 读到 KERNEL_BIN_LOAD_VIRTUAL_ADDR(0xFFF00000),最后 do_load_kernel 解析 ELF 并把段复制到正确的虚拟地址。跳转前用 finit 初始化 FPU,把栈挪到 KERNEL_STACK_TOP(0xF0000000),然后 jmp eax 跳到 ELF 入口点。
allocate_pages_for_kernel: ; allocate pages to load kernel bin image mov ecx, KERNEL_BIN_MAX_SIZE shr ecx, 12 push ecx ; arg 3: num of pages to load kernel bin image push KERNEL_BIN_LOAD_PHYSICAL_ADDR ; arg 2: physical addr push KERNEL_BIN_LOAD_VIRTUAL_ADDR ; arg 1: virtual addr call set_pages_mapping add esp, 12
; allocate pages to place kernel sections mov ecx, KERNEL_MEM_MAX_SIZE shr ecx, 12 push ecx ; arg 3: num of pages to place kernel push KERNEL_PHYSICAL_ADDR_START ; arg 2: physical addr push KERNEL_VIRTUAL_ADDR_START ; arg 1: virtual addr call set_pages_mapping add esp, 12
; allocate one page for kernel stack mov ecx, 2 push ecx ; arg 3: num of pages push KERNEL_STACK_PHYSICAL_ADDR ; arg 2: physical addr push KERNEL_STACK_TOP - PAGE_SIZE ; arg 1: virtual addr call set_pages_mapping add esp, 12
ret
load_hd_kernel_image: push KERNEL_BIN_LOAD_VIRTUAL_ADDR ; buffer push KERNEL_SECTORS ; sector count push KERNEL_START_SECTOR ; LBA call read_disk add esp, 12 ret这里能看到几个关键常量来自 bootmacros.inc:KERNEL_BIN_MAX_SIZE 是 1MB,所以 KERNEL_SECTORS 是 2048 扇区;KERNEL_BIN_LOAD_VIRTUAL_ADDR 是 0xFFF00000,对应的物理地址 KERNEL_BIN_LOAD_PHYSICAL_ADDR 在 32MB 物理内存的末尾;KERNEL_START_SECTOR 是 9,对应 Makefile 里 kernel_OFFSET := 9。read_disk 是 pm_utils.S 提供的 ATA PIO 读取函数,参数顺序是 LBA、扇区数、缓冲区地址。
; load kernel sections to memory; return the kernel entry point addressdo_load_kernel: xor eax, eax xor ebx, ebx xor ecx, ecx xor edx, edx
; 获取程序头表偏移 (e_phoff) mov ebx, [KERNEL_BIN_LOAD_VIRTUAL_ADDR + ELF_E_PHOFF] add ebx, KERNEL_BIN_LOAD_VIRTUAL_ADDR ; EBX = 程序头表的实际地址
; 获取程序头数量 (e_phnum) mov cx, [KERNEL_BIN_LOAD_VIRTUAL_ADDR + ELF_E_PHNUM]
; 获取每个程序头大小 (e_phentsize) movzx edx, word [KERNEL_BIN_LOAD_VIRTUAL_ADDR + ELF_E_PHENTSIZE]
.load_each_segment: ; 加载 PT_LOAD 类型的段 mov eax, [ebx + PH_TYPE] cmp eax, 1 ; PT_LOAD ? jne .next_program_header
; 调用 memcpy(p_vaddr, file_base + p_offset, p_filesz) push dword [ebx + PH_FILESZ] ; 第三个参数:复制字节数 mov eax, [ebx + PH_OFFSET] ; 读取段在 ELF 文件内的偏移 add eax, KERNEL_BIN_LOAD_VIRTUAL_ADDR ; 源地址 = 文件起始 + 偏移 push eax ; 第二个参数:源地址 push dword [ebx + PH_VADDR] ; 第一个参数:目标虚拟地址
call memcpy add esp, 12 ; 清理参数栈
.next_program_header: add ebx, edx ; 指向下一个程序头 loop .load_each_segment ; CX-- 并判断是否结束
; 返回 ELF 文件入口点 (e_entry) mov eax, [KERNEL_BIN_LOAD_VIRTUAL_ADDR + ELF_E_ENTRY] retdo_load_kernel 是真实的 ELF 加载逻辑,和前面那段示意代码有明显区别。它不验证 ELF 魔数,不检查 e_type 和 e_machine,因为这套引导链只加载自己编译的内核,格式总是对的,省掉了校验开销。判断 PT_LOAD 用立即数 cmp eax, 1,而不是定义 PT_LOAD equ 1 再比较,这是为了少一条 equ 指令,但可读性稍差。
段复制调用的是 pm_utils.S 里的 memcpy,参数顺序是 dest, src, n,所以压栈顺序是 n、src、dest(cdecl 从右到左)。源地址用 KERNEL_BIN_LOAD_VIRTUAL_ADDR + p_offset,目标地址直接用 p_vaddr,因为分页已经把内核虚拟地址映射好了。注意它不处理 BSS:p_memsz > p_filesz 的部分不会被清零。这是因为 link.ld 里 .bss 用 ALIGN(4096) 对齐,且 end 符号标记了镜像末尾,BSS 的清零留给内核自己或者根本不需要(这个最小内核没有 BSS 变量)。返回值是 e_entry,即链接脚本 ENTRY(main) 指定的入口地址。
ELF_E_PHOFF、ELF_E_PHNUM、ELF_E_PHENTSIZE、PH_TYPE、PH_OFFSET、PH_VADDR、PH_FILESZ、ELF_E_ENTRY 这些偏移常量都定义在 bootmacros.inc 里,不是 loader.S 局部的 equ。真实代码里没有定义 ELF_MAGIC 和 PT_LOAD 这两个宏,因为 do_load_kernel 不做魔数校验,PT_LOAD 直接写成立即数 1。
内核入口点与初始化流程
最小内核示例
当 Loader 成功加载并跳转到内核入口点后,内核开始执行。下面是一个最小化的 C 语言内核示例:
// kernel.c — 最小内核#define VGA_ADDR ((volatile unsigned short *)0xC00B8000)#define VGA_WIDTH 80#define VGA_HEIGHT 25#define WHITE_ON_BLACK 0x0F
void print(const char *s){ volatile unsigned short *vga = VGA_ADDR; while (*s) { *vga++ = (WHITE_ON_BLACK << 8) | *s++; }}
void clear_screen(){ volatile unsigned short *vga = VGA_ADDR; unsigned short blank = (WHITE_ON_BLACK << 8) | ' ';
for (int i = 0; i < VGA_WIDTH * VGA_HEIGHT; i++) vga[i] = blank;}
void main(){ __asm__ volatile("movl $0x1, %eax"); clear_screen(); print("Hello OS"); while (1) ;}这个内核极简到只有三个函数:clear_screen 清空屏幕,print 向 VGA 文本缓冲区写字符串,main 是入口点。注意几个细节:VGA 地址用的是 0xC00B8000 而不是物理地址 0xB8000,因为 Loader 已经开启了分页,内核运行在 3GB 以上的高地址空间,VGA 缓冲区被映射到了 0xC0000000 之上。main 一开始的 movl $0x1, %eax 是个占位,方便在 QEMU 调试时通过寄存器值确认内核入口确实被执行到了。main 没有 void 参数列表写成 main() 而非 main(void),是 freestanding 环境下的常见写法,Loader 不传参数,也不期待返回值,函数末尾用 while (1); 死循环挂住 CPU。
内核链接脚本
为了让内核代码正确加载到指定地址,需要编写链接脚本:
/* link.ld - 内核链接脚本 */
ENTRY(main)
SECTIONS{ .text 0xC0800000: { code = .; _code = .; __code = .; *(.text) }
.data ALIGN(4096): { data = .; _data = .; __data = .; *(.data) *(.rodata) }
.bss ALIGN(4096): { bss = .; _bss = .; __bss = .; *(.bss) . = ALIGN(4096); }
end = .; _end = .; __end = .;}这个链接脚本有几个关键点。入口点是 main,与 kernel.c 里的函数名对应。.text 段的起始地址是 0xC0800000,这是内核在虚拟地址空间中的加载位置:0xC0000000 是内核空间起点,前 8MB 给页目录和页表预留,内核从 8MB 偏移处开始。这里没有 multiboot 段,因为这套引导链是 MBR→Loader 自己加载内核,不走 GRUB 的 multiboot 协议。
.data 和 .bss 用 ALIGN(4096) 按 4KB 页边界对齐,这样每个段恰好落在页表项的边界上,方便 Loader 给不同段设置不同的页表权限(代码页不可写、数据页可写)。code/_code/__code 这组符号是给 C 代码或调试器用的别名,带不同数量下划线是为了兼容不同工具链的符号查找约定。end 符号标记内核镜像的末尾,BSS 段在运行时清零的范围就是从 __bss 到 end。
构建系统
下面是用于编译内核的 Makefile:
# 05.hello-kernel/Makefile
CHAPTER := 05.hello-kernelMODULES := mbr loader kernel
mbr_SRC := mbr.Smbr_OFFSET := 0
loader_SRC := loader.Sloader_OFFSET := 1
kernel_SRC := kernel.ckernel_OFFSET := 9kernel_LDFILE := link.ld
include ../Makefile.inc这个 Makefile 本身很薄,真正的构建逻辑在 ../Makefile.inc 里。每个模块用 <mod>_SRC 声明源文件、<mod>_OFFSET 声明它在磁盘镜像中的起始扇区、kernel_LDFILE 声明内核的链接脚本。MODULES := mbr loader kernel 定义构建顺序,公共 Makefile 会为每个模块生成对应的 .bin,然后用 dd 把它们按 OFFSET 写入磁盘镜像。
注意几个关键点:MBR 在 0 扇区(引导扇区),Loader 在 1 扇区,kernel 在 9 扇区。kernel 用 kernel_LDFILE := link.ld 指定链接脚本,而 mbr 和 loader 没有这个变量,因为它们是 -f bin 直接生成的 flat binary,不走 ELF 链接。CHAPTER 变量决定构建输出目录,所有产物都放在 ../build/05.hello-kernel/ 下。
实模式到保护模式的切换
虽然在第 3 章已经介绍过实模式到保护模式的切换,但这里从内核加载的角度再次总结关键步骤:
切换步骤
; 1. 禁用中断cli
; 2. 启用 A20 地址线(方法一:使用 FAST A20)in al, 0x92or al, 2out 0x92, al
; 3. 加载 GDTlgdt [gdt_descriptor]
; 4. 设置 CR0.PE 位mov eax, cr0or eax, 1mov cr0, eax
; 5. 远跳转刷新流水线jmp SELECTOR_CODE32:init_pm
; 6. 在保护模式中初始化段寄存器[bits 32]init_pm: mov ax, SELECTOR_DATA32 mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax
; 设置栈 SET_STACK_AT_PM关键注意事项
在从实模式切换到保护模式时,有几个关键点需要注意:
第一,必须先加载 GDT 再设置 CR0.PE 位,否则 CPU 无法正确加载段描述符。
第二,设置 PE 位后必须立即执行远跳转,刷新流水线中的 16 位指令预取队列。
第三,进入保护模式后要尽快初始化所有段寄存器,避免使用实模式下的段值。
第四,在开启分页之前,需要确保页表已正确设置,且代码段和数据段在页表中有正确的映射。
; 完整的模式切换和分页开启流程(教学示意)switch_to_protected_mode_with_paging: ; 1. 禁用中断 cli
; 2. 启用 A20 call enable_a20
; 3. 加载临时 GDT(在低内存) lgdt [gdt_descriptor]
; 4. 开启保护模式 mov eax, cr0 or al, 1 mov cr0, eax
; 5. 远跳转 jmp SELECTOR_CODE32:protected_mode_init
protected_mode_init: ; 6. 初始化段寄存器 mov ax, SELECTOR_DATA32 mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax SET_STACK_AT_PM
; 7. 设置页表(真实实现见 loader.S 的 setup_page) call setup_page
; 8. 加载 CR3(在 setup_page -> enable_page 内完成) ; mov eax, PAGE_DIR_PHYISCAL_ADDR ; mov cr3, eax
; 9. 开启分页(在 enable_page 内完成) ; mov eax, cr0 ; or eax, CR0_PG ; mov cr0, eax
; 10. 加载最终 GDT(在高内存,在 enable_page 内完成) ; lgdt [gdt_descriptor]
ret真实代码里步骤 8 到 10 都封装在 enable_page 函数中,由 setup_page 在页表填好后调用,不是分开写的。这里拆开注释只是为了对照流程。注意常量名是 PAGE_DIR_PHYISCAL_ADDR(真实代码里的拼写,保留原样)和 CR0_PG,都来自 bootmacros.inc。
至此,操作系统启动的全部基础工作已经完成。接下来,内核将开始真正的系统初始化:设置中断处理、初始化内存管理、加载驱动程序、启动调度器,最终为用户提供服务。
参考
- ELF Format Specification - ELF 格式官方规范
- OSDev Wiki - ELF - ELF 开发指南
- Intel 64 and IA-32 Architectures Software Developer’s Manual - x86 架构权威参考
- JamesM’s kernel development tutorials - 内核开发教程
- The little book about OS development - 操作系统开发入门
- OSDev Wiki - 操作系统开发百科全书
- 南京大学:操作系统设计与实现 - 优质中文课程
- 《操作系统真象还原》— 郑钢,从零实现简易操作系统
- 《Linux 内核设计与实现》— Robert Love,深入 Linux 内核
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






