mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3975 字
11 分钟
加载内核
2024-03-26

前面几章完成了从 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 做源码级调试。

Note

程序头表面向运行时加载,节区头表面向链接和调试。内核加载器只读程序头表就够了,节区头表在运行时可以完全忽略。这也是 ELF 把两种视图分开设计的工程考量:加载器不需要理解链接器的全部细节。

ELF 文件格式#

ELF 文件总体结构#

ELF 文件由四部分组成,从文件开头到结尾依次为:

  1. ELF Header:文件开头,描述整个文件
  2. Program Header Table:描述可加载段(对运行时重要)
  3. Sections:包含 .text 代码段、.data 数据段等实际内容
  4. 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_vaddrp_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 1
ELFDATA2LSB equ 1
ET_EXEC equ 2
EM_386 equ 3
PT_LOAD equ 1
PF_X equ 0x1
PF_W equ 0x2
PF_R equ 0x4
; ELF 头部偏移
E_IDENT equ 0
E_TYPE equ 16
E_MACHINE equ 18
E_VERSION equ 20
E_ENTRY equ 24
E_PHOFF equ 28
E_PHENTSIZE equ 42
E_PHNUM equ 44
; 程序头偏移
P_TYPE equ 0
P_OFFSET equ 4
P_VADDR equ 8
P_PADDR equ 12
P_FILESZ equ 16
P_MEMSZ equ 20
P_FLAGS equ 24
P_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,依赖链接脚本和内核自身约定。

从引导程序到内核的跳转过程#

内核加载流程#

完整的内核加载流程包括以下步骤:

1
Loader 从磁盘读取内核 ELF 文件到内存缓冲区
2
解析 ELF 头部,验证文件格式
3
遍历程序头表,加载所有 PT_LOAD 类型的段
4
处理 BSS 段(清零未初始化数据区域)
5
获取内核入口点地址
6
设置内核运行环境(栈、GDT、IDT 等)
7
跳转到内核入口点执行
graph TD A[Loader 启动] --> B[进入保护模式] B --> C[开启分页] C --> D[读取内核 ELF 到缓冲区] D --> E{验证 ELF 格式} E -->|失败| F[报错并停止] E -->|成功| G[遍历程序头表] G --> H{PT_LOAD 段?} H -->|是| I[复制段到目标内存] I --> J[处理 BSS] J --> H H -->|否| K[下一个程序头] K --> H H -->|遍历完成| L[获取入口点] L --> M[设置内核环境] M --> N[跳转到内核入口] N --> O[内核开始执行]

完整的 Loader 代码#

下面是真实的 Loader 代码,节选自 05.hello-kernel/loader.S,展示了进入保护模式、开启分页、加载 ELF 内核并跳转到内核入口的完整流程:

[bits 16]
org 0x8000
%include "../common/bootmacros.inc"
_start:
jmp loader_start
; 定义 GDT
DEFINE_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)",0
MSG_PROT_MODE db "Now in 32-bit protected mode (direct video)",0
MSG_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
ret

setup_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_START0xC0000000)以上、设置 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
ret

init_kernel 是加载内核的总入口。它先调用 allocate_pages_for_kernel 为内核镜像、内核代码段、内核栈建立页表映射,然后 load_hd_kernel_image 把磁盘上的 kernel.bin 读到 KERNEL_BIN_LOAD_VIRTUAL_ADDR0xFFF00000),最后 do_load_kernel 解析 ELF 并把段复制到正确的虚拟地址。跳转前用 finit 初始化 FPU,把栈挪到 KERNEL_STACK_TOP0xF0000000),然后 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.incKERNEL_BIN_MAX_SIZE 是 1MB,所以 KERNEL_SECTORS 是 2048 扇区;KERNEL_BIN_LOAD_VIRTUAL_ADDR0xFFF00000,对应的物理地址 KERNEL_BIN_LOAD_PHYSICAL_ADDR 在 32MB 物理内存的末尾;KERNEL_START_SECTOR 是 9,对应 Makefile 里 kernel_OFFSET := 9read_diskpm_utils.S 提供的 ATA PIO 读取函数,参数顺序是 LBA、扇区数、缓冲区地址。

; load kernel sections to memory
; return the kernel entry point address
do_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]
ret

do_load_kernel 是真实的 ELF 加载逻辑,和前面那段示意代码有明显区别。它不验证 ELF 魔数,不检查 e_typee_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 里 .bssALIGN(4096) 对齐,且 end 符号标记了镜像末尾,BSS 的清零留给内核自己或者根本不需要(这个最小内核没有 BSS 变量)。返回值是 e_entry,即链接脚本 ENTRY(main) 指定的入口地址。

ELF_E_PHOFFELF_E_PHNUMELF_E_PHENTSIZEPH_TYPEPH_OFFSETPH_VADDRPH_FILESZELF_E_ENTRY 这些偏移常量都定义在 bootmacros.inc 里,不是 loader.S 局部的 equ。真实代码里没有定义 ELF_MAGICPT_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.bssALIGN(4096) 按 4KB 页边界对齐,这样每个段恰好落在页表项的边界上,方便 Loader 给不同段设置不同的页表权限(代码页不可写、数据页可写)。code/_code/__code 这组符号是给 C 代码或调试器用的别名,带不同数量下划线是为了兼容不同工具链的符号查找约定。end 符号标记内核镜像的末尾,BSS 段在运行时清零的范围就是从 __bssend

构建系统#

下面是用于编译内核的 Makefile:

# 05.hello-kernel/Makefile
CHAPTER := 05.hello-kernel
MODULES := mbr loader kernel
mbr_SRC := mbr.S
mbr_OFFSET := 0
loader_SRC := loader.S
loader_OFFSET := 1
kernel_SRC := kernel.c
kernel_OFFSET := 9
kernel_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, 0x92
or al, 2
out 0x92, al
; 3. 加载 GDT
lgdt [gdt_descriptor]
; 4. 设置 CR0.PE 位
mov eax, cr0
or eax, 1
mov 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

至此,操作系统启动的全部基础工作已经完成。接下来,内核将开始真正的系统初始化:设置中断处理、初始化内存管理、加载驱动程序、启动调度器,最终为用户提供服务。

参考#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

加载内核
https://blog.souloss.cn/posts/os/loading-the-kernel/
作者
Souloss
发布于
2024-03-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时