【免费下载链接】linux-insides-zh
Linux 内核揭秘
导读
本篇文章以hust-open-atom-club/linux-insides-zh仓库中Booting/章节为主线,为你完整梳理 Linux 内核从你按下电源键那一刻起,到内核镜像最终加载到内存为止的全部启动阶段。你将掌握:CPU 复位向量与实模式寻址的来龙去脉、GRUB 等引导程序如何把控制权交给内核、内核早期setup code的堆栈/BSS/堆初始化、视频模式设置、从实模式经保护模式到 64 位长模式的切换,以及内核解压与 KASLR 地址随机化的实现细节。读完本章,你不仅能看懂arch/x86/boot/下的早期启动代码,还能把每个启动阶段与具体寄存器、数据结构和内核源码位置一一对应起来。
阅读方法与前置知识
本章假设你熟悉基础计算机架构,对C语言和x86_64汇编语法(特别是 AT&T 语法)略有了解。你不需要成为内核专家,但能读懂短代码片段、识别硬件术语会极大提升阅读收益——因为引导过程的绝大多数细节都以寄存器操作、内存地址和 BIOS 中断的形式呈现。
建议的阅读方法是:第一次按顺序通读(从第一部分依次读到第六部分),之后在需要将某个符号或寄存器映射到启动序列中的具体位置时,再把对应章节作为参考点逐步回顾。若想在阅读时对照真实代码,最好在本地拥有一份 Linux 内核源码,可使用 git 克隆获取:
git clone git@github.com:torvalds/linux.git章节内各篇文章提到的关键源码(如arch/x86/boot/header.S、arch/x86/boot/main.c、arch/x86/boot/video.c、arch/x86/boot/pm.c、arch/x86/boot/compressed/head_64.S等)均可在克隆后的源码树中直接定位研读。
术语表:读懂引导章节的必备词汇
在阅读本章乃至后续其他章节时,你会频繁遇到以下专业术语,建议先通读一遍建立印象:
CS、DS、SS、CR0、CR3、CR4、EFER——代表 x86 的段寄存器和控制寄存器。CS(代码段)、DS(数据段)、SS(堆栈段)在实模式与保护模式下扮演完全不同的角色;CR0的PE位控制保护模式的开启,CR3保存页表基址,CR4承载各类特性开关,EFER(Extended Feature Enable Register)则与 64 位长模式的使能直接相关。0x...——表示十六进制值,内核启动代码中几乎所有地址与寄存器值都以此形式出现。entry_*和startup_*——早期引导符号的常见前缀,例如startup_32、startup_64分别对应 32 位与 64 位入口点。setup code——Linux 内核的早期部分,负责执行将内核代码本身加载到内存之前的准备工作。decompressor——setup code中负责把压缩后的内核映像解压到内存的那部分代码。
你将学到什么
本章明确给出了三个核心学习目标,后续六个部分全部围绕它们展开:
- 处理器如何从固件(BIOS/UEFI)和引导加载程序(如 GRUB 2)一步步到达内核入口点;
- x86_64 处理器的不同运行模式(实模式、保护模式、长模式)及其切换机制;
- 内核本身启动之前的早期
setup code如何被加载到内存并开始工作。
阅读顺序与六大部分一览
本章共六个部分,按启动的时间线严格递进:
| 部分 | 主题 | 对应文件 |
|---|---|---|
| 第一部分 | 从引导程序到内核 | Booting/linux-bootstrap-1.md |
| 第二部分 | 在内核设置代码的第一步 | Booting/linux-bootstrap-2.md |
| 第三部分 | 视频模式初始化和保护模式切换 | Booting/linux-bootstrap-3.md |
| 第四部分 | 切换 64 位模式 | Booting/linux-bootstrap-4.md |
| 第五部分 | 内核解压 | Booting/linux-bootstrap-5.md |
| 第六部分 | 内核地址随机化 | Booting/linux-bootstrap-6.md |
下面逐部分展开核心内容。
第一部分:从引导程序到内核
按下电源键之后发生了什么
尽管这是一系列关于 Linux 内核的文章,第一部分并不从内核代码开始,而是从硬件视角出发。按下电源开关后,主板向电源发送信号,电源回送"电源备妥信号"(Power Good Signal),主板随即尝试启动 CPU。CPU 复位所有寄存器并设置预定义值。自 80386 起,CPU 复位后寄存器被设置为:
IP 0xfff0 CS selector 0xf000 CS base 0xffff0000处理器由此开始在实模式下工作。要理解实模式,必须先掌握 8086 时代就确立的段式内存管理:8086 只有 20 位寻址总线(可访问 1MB 地址空间)和 16 位寄存器(最大寻址0xffff,即 64KB)。为突破寄存器宽度限制,实模式将地址拆为"段基址 + 偏移"两部分,物理地址计算公式为:
PhysicalAddress = Segment * 16 + Offset例如CS:IP = 0x2000:0x0010时,物理地址为(0x2000 << 4) + 0x0010 = 0x20010。而 16 位能表达的最大组合0xffff:0xffff计算得到0x10ffef,超出 1MB 达 65519 字节——这正是A20 地址线存在的原因:在 A20 被禁用时,0x10ffef会回卷为0x00ffef。
复位向量与 BIOS 启动
结合复位后的CS基址与IP值,逻辑地址为0xffff0000:0xfff0,最终物理地址是0xfffffff0——位于 4GB - 16 字节处,即著名的复位向量(Reset Vector)。这是 CPU 复位后期望执行的第一条指令所在地址,它包含一条jump指令,通常跳向 BIOS 入口点。文章以 coreboot 源码为例展示了.reset段中的跳转指令(opcode0xe9):
.section ".reset", "ax", %progbits .code16 .globl _start _start: .byte 0xe9 .int _start16bit - ( . + 2 )在实模式下 CPU 只能访问 1MB 地址空间,那 CPU 如何访问到0xfffffff0这样的高位地址?答案来自 coreboot 的内存映射文档:0xFFFE_0000 - 0xFFFF_FFFF这段 128KB 的 ROM 被映射进地址空间,因此复位向量处执行的第一条指令来自ROM 而非 RAM。
BIOS 寻找可引导设备与 MBR
BIOS 完成初始化和硬件检查后,会按配置顺序尝试从不同设备寻找引导程序。对硬盘而言,BIOS 寻找引导扇区:MBR 分区下引导扇区位于第一个扇区(512 字节)的头 446 字节,且扇区末尾必须是魔术字节0x55与0xaa,BIOS 据此判定设备可引导。第一部分给出了一个完整的极简引导扇区示例:
[BITS 16] [ORG 0x7c00] boot: mov al, '!' mov ah, 0x0e mov bh, 0x00 mov bl, 0x07 int 0x10 jmp $ times 510-($-$$) db 0 db 0x55 db 0xaa用 NASM 汇编并用 QEMU 直接运行:
nasm -f bin boot.nasm && qemu-system-x86_64 boot该二进制以0x7c00为起始(BIOS 约定把引导扇区加载到0x7c00),通过int 0x10打印!字符,剩余 510 字节用 0 填充,最后以0xaa、0x55收尾。运行后 QEMU 屏幕显示如下:
还可以用objdump查看其二进制转储:
nasm -f bin boot.nasm objdump -D -b binary -mi386 -Maddr16,data16,intel boot第一部分还给出了实模式下 1MB 地址空间的经典分配表,值得牢记:0x00000000-0x000003FF是实模式中断向量表,0x00007C00-0x00007DFF是引导加载程序区,0x000A0000-0x000BFFFF是视频内存(VRAM),0x000F0000-0x000FFFFF是系统 BIOS 等。
GRUB 2 与内核启动协议
现实世界中可选的引导程序很多(GRUB 2、syslinux 等),Linux 内核通过Boot Protocol规定引导程序应如何实现。章节以 GRUB 2 为例:BIOS 把控制权交给引导扇区中的boot.img(仅占一个扇区,只做必要初始化),随后跳转到 core image(如diskboot.img),后者把整个 core image(含 GRUB 2 内核代码与文件系统驱动)载入内存,最终调用grub_main:初始化控制台、计算模块基地址、设置 root 设备、读取 grub 配置文件、加载模块,最后进入 normal 模式由grub_normal_execute显示操作系统菜单;用户选择系统后,grub_menu_execute_entry调用boot命令引导所选操作系统。
按 boot protocol 要求,引导程序必须填充kernel setup header(位于 setup code 偏移0x01f1处),其定义始于arch/x86/boot/header.S:
.globl hdr hdr: setup_sects: .byte 0 root_flags: .word ROOT_RDONLY syssize: .long 0 ram_size: .word 0 vid_mode: .word SVGA_MODE root_dev: .word 0 boot_flag: .word 0xAA55引导程序必须填充 boot protocol 中标记为write的字段(如type_of_loader),这些信息可能来自命令行或由计算得到。内核被引导入内存后,内存使用布局如下:
| Protected-mode kernel | 100000 +------------------------+ | I/O memory hole | 0A0000 +------------------------+ | Reserved for BIOS | Leave as much as possible unused ~ ~ | Command line | (Can also be below the X+10000 mark) X+10000 +------------------------+ | Stack/heap | For use by the kernel real-mode code. X+08000 +------------------------+ | Kernel setup | The kernel real-mode code. | Kernel boot sector | The kernel legacy boot sector. X +------------------------+ | Boot loader | <- Boot sector entry point 0x7C00 001000 +------------------------+ | Reserved for MBR/BIOS | 000800 +------------------------+ | Typically used by MBR | 000600 +------------------------+ | BIOS use only | 000000 +------------------------+其中X是内核 boot sector 被加载进内存的位置。示例环境里X = 0x10000,可通过内存 dump 验证:
内核设置代码的入口_start
内核设置代码的起点是arch/x86/boot/header.S中的_start函数。_start之前的代码其实是内核自带的"引导程序"——早期内核确实可以如此启动,但现代内核中这部分代码已不再启动内核,只输出错误信息。直接用qemu-system-x86_64 vmlinuz-...启动时即可看到该错误:
qemu-system-x86_64 vmlinuz-3.18-generic为了兼容 UEFI 硬件,header.S开头定义了 DOS MZ 魔术数字与 PE 头。真正的内核代码从_start开始——其他引导程序(GRUB 2 等)知道_start位于 MZ 头之后偏移0x200字节处,因此会跳过前面位于.bstext段的代码直接跳转至此:
.globl _start _start: .byte 0xeb .byte start_of_setup-1f 1: // // rest of the header //_start首条指令是短跳转(opcode0xeb),跳到start_of_setup标号(位于.entrytext段,其中第一条指令才是内核执行开始后的第一条指令)。GRUB 2 跳转到_start时设置:
state.gs = state.fs = state.es = state.ds = state.ss = segment; state.cs = segment + 0x20;当内核被加载到0x10000时,fs = es = ds = ss = 0x1000、cs = 0x1020,第一条指令地址为0x10200(正好是0x10000起偏移0x200处)。从start_of_setup开始的代码需要完成四件事:将所有段寄存器值统一、设置堆栈、设置 BSS(静态变量区)、跳转到main.c。
段寄存器设置
先让ds与es指向相同地址并用cld清方向标志,然后用一个经典技巧统一cs:
pushw %ds pushw $6f lretwlretw会把标号6的地址装入ip、把ds的值装入cs,从而让cs与其他段寄存器一致。
设置堆栈
接下来检查ss寄存器,处理三种情况(ss == 0x10000、ss != 0x10000且CAN_USE_HEAP置位、ss != 0x10000且CAN_USE_HEAP未置位)。核心代码将sp4 字节对齐,若为 0 则设为0xfffc(堆栈向下增长,64KB 段的最后一个 4 字节地址),然后把0x10000写入ss:
2: andw $~3, %dx jnz 3f movw $0xfffc, %dx 3: movw %ax, %ss movzwl %dx, %esp stiloadflags字段的Bit 7即CAN_USE_HEAP标志(置位表示heap_end_ptr值有效)。loadflags的全部标志位包括:
#define LOADED_HIGH (1<<0) #define QUIET_FLAG (1<<5) #define KEEP_SEGMENTS (1<<6) #define CAN_USE_HEAP (1<<7)若CAN_USE_HEAP置位,则把heap_end_ptr加上最小堆栈大小STACK_SIZE(512 字节)得到堆栈顶;未置位则只加STACK_SIZE。三种情形下堆栈布局分别如 stack1.png、stack2.png、minimal_stack.png 所示。
BSS 段设置
执行 C 代码前还要校验 magic 签名并清零 BSS 段。签名校验失败则跳入setup_bad:
cmpl $0x5a5aaa55, setup_sig jne setup_bad随后将 BSS 段全部清零:
movw $__bss_start, %di movw $_end+3, %cx xorl %eax, %eax subw %di, %cx shrw $2, %cx rep; stosl即把__bss_start放入di、_end + 3(4 字节对齐)放入cx、eax清零,计算差值得到 BSS 大小,除以 4 后以rep; stosl按 4 字节写零,最终效果见 bss.png。至此即可call main进入第一个 C 语言函数(定义于arch/x86/boot/main.c)。
第二部分:内核设置代码的第一步
第二部分从main()函数开始,主线是:把启动参数拷贝到 zeropage、初始化控制台、初始化堆、检查 CPU、侦测内存、初始化键盘、查询系统参数。
将启动参数拷贝到 boot_params
main()首先调用copy_boot_params(void),把内核设置信息拷贝到boot_params结构(定义于arch/x86/include/uapi/asm/bootparam.h)的struct setup_header hdr字段。该函数完成两件事:把header.S中定义的hdr结构拷贝到boot_params.hdr;若内核通过旧命令行协议运行,则更新命令行指针。注意这里的memcpy不是 C 库函数,而是定义在arch/x86/boot/copy.S中的汇编实现,采用fastcall调用规则(参数经ax、dx、cx寄存器传递):
GLOBAL(memcpy) pushw %si pushw %di movw %ax, %di ;move &boot_param.hdr to di movw %dx, %si ;move &hdr to si pushw %cx ;push cx ( sizeof(hdr) ) shrw $2, %cx rep; movsl ;copy based on 4 bytes popw %cx andw $3, %cx ;cx = cx % 4 rep; movsb ;copy based on one byte popw %di popw %si retl ENDPROC(memcpy)调用memcpy(&boot_params.hdr, &hdr, sizeof hdr)时,ax指向boot_params.hdr、dx指向hdr、cx为结构大小。实现先以 4 字节为单位复制,剩余不足 4 字节的部分再按字节复制。GLOBAL宏(定义于arch/x86/include/asm/linkage.h)给代码段分配全局可见的名字标签,ENDPROC宏(定义于include/linux/linkage.h)标记汇编函数结束,便于静态分析工具识别。
控制台初始化
console_init(定义于arch/x86/boot/early_serial_console.c)解析命令行是否包含earlyprintk选项并初始化串口,可能的取值包括:
serial,0x3f8,115200serial,ttyS0,115200ttyS0,115200
若命令行含debug选项,会出现输出early console in setup code。puts(定义于arch/x86/boot/tty.c)逐字节调用putchar:
void __attribute__((section(".inittext"))) putchar(int ch) { if (ch == '\n') putchar('\r'); bios_putchar(ch); if (early_serial_base != 0) serial_putchar(ch); }putchar会把\n扩展为\r\n,先经bios_putchar用int 0x10(AH = 0x0e)输出到显示器,若串口已初始化再输出到串口。bios_putchar使用initregs(内部先memset清零biosregs结构)填充寄存器。memset同样是copy.S中的汇编实现,用imull $0x01010101把单字节值扩展为 4 字节重复模式,再配合rep; stosl高效写内存。.inittext段定义在setup.ld链接脚本中。
堆初始化
init_heap先检查loadflags的CAN_USE_HEAP标志,随后计算堆栈结束地址与堆结束地址:
char *stack_end; if (boot_params.hdr.loadflags & CAN_USE_HEAP) { asm("leal %P1(%%esp),%0" : "=r" (stack_end) : "i" (-STACK_SIZE)); } //heap_end = heap_end_ptr + 512 heap_end = (char *)((size_t)boot_params.hdr.heap_end_ptr + 0x200);即stack_end = esp - STACK_SIZE、heap_end = heap_end_ptr + 0x200。若heap_end大于stack_end,则用stack_end接管——因为在大多数系统中全局堆与堆栈相邻但增长方向相反。堆初始化完成后即可使用GET_HEAP等操作。
检查 CPU 类型
validate_cpu(arch/x86/boot/cpu.c)调用check_cpu(arch/x86/boot/cpucheck.c)获取 CPU 级别并与预设最低级别比较:
check_cpu(&cpu_level, &req_level, &err_flags); if (cpu_level < req_level) { printf("This kernel requires an %s CPU, ", cpu_name(req_level)); printf("but only detected an %s CPU.\n", cpu_name(cpu_level)); return -1; }check_cpu还会检查 CPU 标志(若为 64 位 CPU 则设置 long mode)、检查制造商并据此设置不同的 CPU 选项(例如 AMD CPU 若不支持SSE+SSE2则禁止之)。
内存分布侦测
detect_memory(arch/x86/boot/memory.c)通过0xe820(获取全部内存分配)、0xe801和0x88(获取临近内存大小)三类 BIOS 接口侦测内存分布。以detect_memory_e820为例,它填充biosregs结构:
initregs(&ireg); ireg.ax = 0xe820; ireg.cx = sizeof buf; ireg.edx = SMAP; // 0x534d4150 ireg.di = (size_t)&buf;其中ax固定为0xe820,cx是缓冲区大小,edx必须是魔术数字SMAP(0x534d4150),es:di是缓冲区地址,ebx初始为 0。随后循环调用int 0x15,把返回的ebx写回寄存器继续下一次调用,直到返回的 eflags 含X86_EFLAGS_CF为止。收集到的信息写入e820entry数组,每个元素含内存段起始地址、大小与类型(reserved、usable 等)。这些内容可在dmesg输出中看到:
[ 0.000000] e820: BIOS-provided physical RAM map: [ 0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009fbff] usable [ 0.000000] BIOS-e820: [mem 0x000000000009fc00-0x000000000009ffff] reserved [ 0.000000] BIOS-e820: [mem 0x00000000000f0000-0x00000000000fffff] reserved [ 0.000000] BIOS-e820: [mem 0x0000000000100000-0x000000003ffdffff] usable键盘初始化与系统参数查询
keyboard_init()先通过int 0x16(AH = 0x02)获取键盘状态并存入boot_params.kbd_status,再以AX = 0x0305设置按键重复频率。系统参数查询则包括query_mca(arch/x86/boot/mca.c):设置AH = 0xc0调用int 0x15,若 CF 被置位则无 MCA(Micro Channel Architecture);否则ES:BX指向系统信息表,函数把表内容拷贝到boot_params.sys_desc_table。query_ist、query_apm_bios等查询将在后续章节继续介绍。
保护模式与段描述符
在深入后续部分前,必须理解保护模式的内存管理。实模式 20 位地址线在保护模式中被替换为 32 位,可访问多达 4GB 地址空间,并引入内存分页能力。保护模式下,每个内存段的大小和起始位置由"段描述符"描述,所有段描述符存放于全局描述符表(GDT),GDT 地址保存在 48 位寄存器GDTR中(16 位表大小 + 32 位表基址),通过lgdt指令加载。段描述符为 64 位结构,核心字段包括:
- Limit(20 位)——分存于 0-15 与 16-19 位,配合
G位决定段长度:G=0时按字节增长(最大 1MB),G=1时按 4KB 增长(最大 4GB);段长度 = 粒度 × (LIMIT + 1)。 - Base(32 位)——分存于 0-15、32-39、56-63 位,定义段基址。
- Type/Attribute(40-47 位)——
S位(第 44 位)区分系统段与代码/数据段;第 43 位区分数据段(E扩展方向、W可写、A已访问)与代码段(C一致、R可读、A已访问)。数据段全部可读,代码段永远不可写;C=1时低优先级代码可访问该代码段。 - DPL(2 位)——段优先级(0-3)。
- P 标志(bit 47)——段是否存在于内存,
P=0时访问会报错。 - L 标志(bit 53)——仅对代码段有意义,
L=1表示该段运行于 64 位模式。 - D/B 标志(bit 54)——对可执行代码段称
D(默认操作数/地址长度 32 位或 16 位),对堆栈段称B(32 位esp或 16 位sp栈指针),对下扩数据段则决定堆栈段上界限(0xFFFFFFFF或0xFFFF)。
保护模式下段寄存器保存的是一种称为段选择子的 16 位结构:Index(GDT 中段描述符索引)、TI(选 GDT 还是 LDT)、RPL(请求者优先级)。CPU 寻址步骤为:装入段选择子 → 按选择子从 GDT 取段描述符放入段寄存器隐藏部分 → 非下扩段下物理地址 = 描述符基址 + 偏移。线性地址的形成过程见 linear_address.png。从实模式进入保护模式的标准操作是:禁止中断 →lgdt装载 GDT → 置CR0.PE = 1→ 跳转执行保护模式代码。
第三部分:视频模式初始化和保护模式切换
内核数据类型与堆操作 API
第三部分从set_video函数(arch/x86/boot/video.c)开始,它会读取boot_params.hdr.vid_mode。在此之前,先认识内核常用数据类型(建议阅读内核代码时牢记):
| Type | char | short | int | long | u8 | u16 | u32 | u64 |
|---|---|---|---|---|---|---|---|---|
| Size | 1 | 2 | 4 | 8 | 1 | 2 | 4 | 8 |
内核在boot.h中提供了一套堆操作 API:
RESET_HEAP()——((void *)( HEAP = _end )),把堆头重置到_end标号(内核初始化时堆与栈共享内存空间)。GET_HEAP(type, n)——((type *)__get_heap(sizeof(type),__alignof__(type),(n))),按类型大小与对齐要求分配内存。__get_heap(s, a, n)——先按对齐要求调整HEAP,把当前HEAP存入tmp,再让HEAP += s*n预留空间并返回tmp。heap_free(n)——判断heap_end - HEAP >= n是否成立。
设置显示模式
set_video首先调用RESET_HEAP(),然后调用store_mode_params把显示模式参数写入boot_params.screen_info(结构定义于include/uapi/linux/screen_info.h)。其内部流程为:store_cursor_position通过int 0x10(AH = 0x3)查询光标行列并存入screen_info.orig_x/orig_y;store_video_mode保存当前显示模式到orig_video_mode;根据显示模式设置video_segment(单色文本内存段0xB000或彩色文本内存段0xB800);再通过set_fs(0)配合rdfs16(0x485)读取字体大小存入orig_video_points,并从0x44a、0x484读取行列信息存入orig_video_cols/orig_video_lines。
随后save_screen把当前屏幕信息(屏幕大小、光标位置、字符数据)保存到saved_screen结构({ int x, y; int curx, cury; u16 *data; }),并检查堆空间是否足够:
if (!heap_free(saved.x*saved.y*sizeof(u16)+512)) return;probe_cards(0)(arch/x86/boot/video-mode.c)遍历所有显卡驱动:
for (card = video_cards; card < video_cards_end; card++) { if (card->unsafe == unsafe) { if (card->probe) card->nmodes = card->probe(); else card->nmodes = 0; } }video_cards符号来自arch/x86/boot/setup.ld中定义的.videocards段,其中存放所有以__videocard宏(即struct card_info __attribute__((used,section(".videocards"))))定义的显卡结构,例如:
static __videocard video_vga = { .card_name = "VGA", .probe = vga_probe, .set_mode = vga_set_mode, };card_info结构含card_name、set_mode、probe、modes、nmodes、unsafe、xmode_first、xmode_n字段。set_video随后进入主循环:若vid_mode为ASK_VGA则弹出菜单让用户选择,再调用set_mode设置模式,失败则提示Undefined video mode number并再次询问:
for (;;) { if (mode == ASK_VGA) mode = mode_menu(); if (!set_mode(mode)) break; printf("Undefined video mode number: %x\n", mode); mode = ASK_VGA; }set_mode→raw_set_mode遍历card_info调用对应set_mode(如vga_set_mode,内部按VIDEO_80x25、VIDEO_8POINT、VIDEO_80x43等模式调用vga_set_***函数,每个函数最终都是调用int 0x10设置显示模式)。设置成功后写回boot_params.hdr.vid_mode,vesa_store_edid把 EDID 数据写入内存,do_restore还原此前保存的屏幕信息。
关于vid_mode的传入方式,boot protocol 提供了vga=命令行选项:
vga=<mode> <mode> here is either an integer (in C notation, either decimal, octal, or hexadecimal) or one of the strings "normal" (meaning 0xFFFF), "ext" (meaning 0xFFFE) or "ask" (meaning 0xFFFD). This value should be entered into the vid_mode field, as it is used by the kernel before the command line is parsed.即可以把vga=ask(或0xFFFD)写入 grub 或引导程序配置文件,内核启动时便会显示交互式菜单供用户挑选显示模式:
进入保护模式前的最后准备
go_to_protected_mode(arch/x86/boot/pm.c)完成最后的准备工作。首先realmode_switch_hook:若存在realmode_swtchhook 则用lcallw远调用之(用于 DOS 等宿主环境),否则直接禁止中断与 NMI:
static void realmode_switch_hook(void) { if (boot_params.hdr.realmode_swtch) { asm volatile("lcallw *%0" : : "m" (boot_params.hdr.realmode_swtch) : "eax", "ebx", "ecx", "edx"); } else { asm volatile("cli"); outb(0x80, 0x70); /* Disable NMI */ io_delay(); } }io_delay通过对 I/O 端口0x80写入任意字节获得约 1ms 延时。
随后enable_a20(arch/x86/boot/a20.c)激活 A20 地址线:先用a20_test检测是否已激活——把FS设为0x0000、GS设为0xffff,在A20_TEST_ADDR写入并读取GS:A20_TEST_ADDR+0x10判断是否回卷;未激活则尝试多种方法(包括 BIOSint 0x15等)。若最终失败会调用die(arch/x86/boot/header.S中hlt+ 死循环)结束运行。
接着reset_coprocessor通过向 I/O 端口0xf0、0xf1写 0 复位数字协处理器;mask_all_interrupts屏蔽从中断控制器(PIC)的所有中断和主 PIC 上除 IRQ2(级联中断)以外的全部中断:
outb(0xff, 0xa1); /* Mask all interrupts on the secondary PIC */ outb(0xfb, 0x21); /* Mask all but cascade on the primary PIC */设置 IDT 与 GDT,进入保护模式
setup_idt用lidtl装载一个空 IDT(长度 0),使所有中断调用暂时为空:
static void setup_idt(void) { static const struct gdt_ptr null_idt = {0, 0}; asm volatile("lidtl %0" : : "m" (null_idt)); }gdt_ptr是__attribute__((packed))的 48 位结构(16 位长度 + 32 位指针)。setup_gdt则装载启动用 GDT:
static const u64 boot_gdt[] __attribute__((aligned(16))) = { [GDT_ENTRY_BOOT_CS] = GDT_ENTRY(0xc09b, 0, 0xfffff), [GDT_ENTRY_BOOT_DS] = GDT_ENTRY(0xc093, 0, 0xfffff), [GDT_ENTRY_BOOT_TSS] = GDT_ENTRY(0x0089, 4096, 103), };这里定义了代码段、数据段和 TSS 段的描述符;由于尚未设置任何中断调用,TSS 实际不会被使用,其存在只是为了满足 Intel 处理器进入保护模式的要求。数组以 16 字节对齐(__attribute__((aligned(16))))。准备工作全部完成后,内核通过jmpl *%eax跳转到 32 位入口点(arch/x86/boot/pmjump.S),正式进入保护模式。
第四部分:切换到 64 位模式
第四部分(Booting/linux-bootstrap-4.md)承接保护模式,展示了在保护模式中的最初几步:确认 CPU 是否支持长模式(Long Mode)、SSE 与分页,初始化页表,最后完成向 64 位长模式的切换。本部分包含大量汇编代码,阅读前最好备一本汇编参考书。
入口点为arch/x86/boot/pmjump.S的jmpl *%eax,eax寄存器保存 32 位入口点地址。按照 x86 Linux 内核引导协议:"When using bzImage, the protected-mode kernel was relocated to 0x100000"(使用 bzImage 时,保护模式内核被重定位至0x100000)。示例中寄存器状态验证了这一点:
eax 0x100000 1048576 ecx 0x0 0 edx 0x0 0 ebx 0x0 0 esp 0x1ff5c 0x1ff5c ebp 0x0 0x0 esi 0x14470 83056 edi 0x0 0 eip 0x100000 0x100000 eflags 0x46 [ PF ZF ]本部分随后验证 CPU 能力(长模式、SSE、分页支持)、初始化页表,并最终跳转到 64 位入口点startup_64。切换完成后,代码段寄存器加载__KERNEL_CS并执行lret完成远跳转:
pushl $__KERNEL_CS leal startup_64(%ebp), %eax ... pushl %eax ... lret第五部分:内核解压
第五部分(Booting/linux-bootstrap-5.md)涵盖跳进内核代码的最后步骤:解压前准备、重定位与直接内核解压。我们在 64 位入口点startup_64(源文件arch/x86/boot/compressed/head_64.S)开始。进入startup_64后先建立数据段:
.code64 .org 0x200 ENTRY(startup_64) xorl %eax, %eax movl %eax, %ds movl %eax, %es movl %eax, %ss movl %eax, %fs movl %eax, %gs随后进行解压前的准备工作(如初始化解压器环境、处理重定位)并调用extract_kernel完成对压缩内核映像的直接解压,把真正的内核代码展开到指定物理地址。
第六部分:内核地址随机化(KASLR)
第六部分(Booting/linux-bootstrap-6.md)讲述引导过程的高级部分——内核加载地址随机化。Linux 内核的入口点是init/main.c的start_kernel函数,它在LOAD_PHYSICAL_ADDR地址开始执行,该地址依赖CONFIG_PHYSICAL_START配置选项,默认0x1000000:
config PHYSICAL_START hex "Physical address where the kernel is loaded" if (EXPERT || CRASH_DUMP) default "0x1000000" ---help--- This gives the physical address where the kernel is loaded.该选项可在内核配置时修改,但也可让加载地址取随机值——为此需启用CONFIG_RANDOMIZE_BASE。启用后,内核镜像解压和加载的物理地址会被随机化,这是出于**地址空间布局随机化(ASLR)**的安全考量。
随机化的最早步骤之一是初始化恒等映射页表(identity mapped,虚拟地址与物理地址相同):若引导加载程序使用 16 位或 32 位引导协议,此时已存在页表;但若解压器选择页表覆盖范围之外的内存区域,就需要建立新页表。随机化从choose_random_location函数开始:
void choose_random_location(unsigned long input, unsigned long input_size, unsigned long *output, unsigned long output_size, unsigned long *virt_addr)该函数负责在解压前挑选随机的物理(与虚拟)加载地址,为start_kernel的最终运行位置奠定安全基础。
内核版本说明
按照本仓库章节 README 的标注,本章对应Linux kernel v6.19。需要注意的是,章节内各篇译文源自较早的上游版本(文中多处引用的源码路径与行号对应v3.18时代的内核源码树),阅读时如遇到与现行内核源码的细微差异,应以最新内核源码树为准,并欢迎向仓库提交修正。整个书籍的目录结构可查看 SUMMARY.md,书中其余章节(如初始化、中断、系统调用、内存管理等)同样配有各自的技术主题与源码讲解。
小结与下一步
至此,一条完整的引导链已经清晰:电源复位 → 实模式复位向量 → BIOS 自检与 MBR 引导扇区 → GRUB 2 按 boot protocol 填充 setup header → 内核_start/start_of_setup统一段寄存器、设置堆栈与 BSS →main()拷贝启动参数、初始化控制台与堆、校验 CPU、侦测内存、初始化键盘 → 视频模式设置(vga=选项)→ 禁止中断与 NMI、激活 A20、屏蔽 PIC、设置空 IDT 与 boot GDT → 进入保护模式 → 校验长模式能力、初始化页表 → 切换 64 位长模式 → 解压准备与重定位 →extract_kernel解压 → KASLR 随机化加载地址 →start_kernel。每一个环节都对应着 Booting/ 章节中可逐行研读的源码与讲解,是理解后续初始化、中断与内存管理章节的坚实起点。
【免费下载链接】linux-insides-zh
Linux 内核揭秘
相关推荐
按下电源键那一刻发生了什么?Linux 内核揭秘(linux-insides-zh)引导章节 6 大阶段完整解读
按下电源键那一刻发生了什么?Linux 内核揭秘(linux insides zh)引导章节 6 大阶段完整解读 按下电源键的那一刻,Linux 内核引导过程就
Linux内核引导过程解析:从开机到内核加载(基于0xAX/linux-insides项目)
Linux内核引导过程解析:从开机到内核加载(基于0xAX/linux insides项目) 引言:计算机启动的魔法时刻 当我们按下计算机的电源按钮时,一系列精
文档教程操作系统新手如何入门 Linux 内核源码?Linux 内核揭秘(linux-insides-zh)3 步阅读法,从引导流程到系统调用
新手如何入门 Linux 内核源码?Linux 内核揭秘(linux insides zh)3 步阅读法,从引导流程到系统调用 刚接触 Linux 内核源码 时
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考