1. 内核启动入口分析:从BIOS/UEFI到GRUB

说实话,很多做嵌入式Linux开发的朋友,一上来就盯着start_kernel()看。但我要说,内核的第一行代码执行前,已经发生了太多关键的事情。不理解这些,你调试启动问题时会非常痛苦。

我个人习惯把x86架构的启动过程分成三个阶段:固件阶段(BIOS/UEFI)、引导阶段(GRUB)、内核入口阶段。今天我们先聊前两个阶段,看看内核的第一行代码执行前,CPU到底在忙什么。

1.1 按下电源键的那一刻

你按下电源键,CPU复位,所有寄存器回到默认值。这时候CPU处于实模式(Real Mode),地址线只有20位有效,最大寻址1MB。嗯,这里要注意:CS:IP = 0xF000:0xFFF0,这个地址映射到BIOS ROM的入口。

关键事实:CPU复位后第一条指令地址是0xFFFFFFF0(对于现代CPU),但为了兼容性,这个地址被映射到BIOS ROM区域。BIOS/UEFI从这里开始执行。

我在项目中遇到过一个问题:某款工控主板在低温环境下启动失败,抓了半天发现是BIOS自检阶段内存初始化时序不对。你看,连BIOS阶段都可能出问题,更别说后面的内核了。

1.2 BIOS vs UEFI:到底有什么区别?

说白了,BIOS是爷爷辈的技术,UEFI是后来居上的方案。我建议你至少了解以下几点区别:

特性 BIOS (Legacy) UEFI
启动模式 实模式,16位 保护模式/64位
分区表 MBR(主引导记录) GPT(GUID分区表)
引导加载器位置 MBR(512字节) EFI系统分区(ESP)
可扩展性 差,只能从磁盘启动 好,支持网络启动、驱动加载
安全启动 不支持 支持(Secure Boot)

你想想看,BIOS那512字节的MBR能干什么?只能放一段很小的引导代码,然后跳转到下一个阶段。而UEFI可以直接加载完整的引导管理器,甚至能在启动过程中加载驱动——这就是为什么现代系统启动时能看到图形界面。

1.3 GRUB到底做了什么?

GRUB(GRand Unified Bootloader)是目前Linux最常用的引导加载器。它的核心任务就三个:

  1. 加载内核镜像到内存
  2. 设置启动参数(如root分区、initrd路径)
  3. 切换到保护模式(或长模式),然后跳转到内核入口

我曾经调试过一个启动问题:GRUB加载内核后直接重启,没有任何错误信息。后来发现是initrd镜像太大,超出了GRUB能加载的内存范围。嗯,这种问题在嵌入式设备上特别常见。

调试技巧:在GRUB菜单按'e'编辑启动项,可以手动修改内核参数。按'c'进入GRUB命令行,用lscat等命令检查文件系统。这些在排查启动问题时非常有用。

1.4 内核入口前的内存布局

当GRUB把控制权交给内核时,内存已经不再是刚开机时的样子了。我习惯用下面这张图来理解:

内核入口前x86内存布局(典型) 0xFFFFFFFF 0x00000000 BIOS/UEFI ROM 区域 0xFFFF0000 - 0xFFFFFFFF(典型) 内核镜像(由GRUB加载) 包含:setup代码 + 压缩内核 + 解压代码 入口点:startup_32(保护模式)或 startup_64(长模式) initrd/initramfs 临时根文件系统,包含必要的驱动和启动脚本 低端内存 / 实模式数据 中断向量表、BIOS数据区、GRUB参数块 0x00000000 - 0x00100000(典型) 内核入口

这张图是我自己总结的,每次调试启动问题我都会先确认内核被加载到了哪个地址。你想想看,如果GRUB把内核加载到了错误的位置,那后面的解压和跳转全都会乱套。

1.5 从实模式到保护模式的切换

这是x86启动过程中最微妙的一步。GRUB在跳转到内核之前,必须完成:

  • 关闭中断(CLI指令)
  • 加载GDT(全局描述符表)
  • 设置CR0寄存器的保护模式位(PE位)
  • 远跳转(far jump)刷新指令流水线

注意:如果GDT设置错误,或者段描述符的权限位不对,CPU会立即触发异常。我曾经见过一个案例:某定制内核在切换模式时崩溃,最后发现是GRUB版本太旧,生成的GDT不兼容新CPU。

内核的setup代码(arch/x86/boot/header.S)会检查当前CPU模式。如果GRUB已经切到了保护模式,内核就直接接管;如果还在实模式,内核自己再切一次。嗯,这里有个细节:内核的setup代码本身是16位实模式代码,它运行在0x7C00附近,然后才跳转到32位入口。

1.6 内核的第一行代码到底在哪?

很多人以为start_kernel()是第一行。其实不是。真正的第一行代码在arch/x86/boot/header.S中,具体是:

// arch/x86/boot/header.S (简化)
_start:
    .byte  0xeb          // 短跳转指令
    .byte  start_of_setup-1f  // 跳转到setup代码
    // ... 后面是内核头部信息(boot_params结构)
    
start_of_setup:
    // 这里才是真正的入口
    movw    %ds, %ax
    movw    %ax, %ss
    movw    $__BOOT_STACK, %sp  // 设置栈指针
    // ... 初始化段寄存器、检查CPU特性等

这段代码运行在实模式下,主要做几件事:

  1. 设置栈指针(很重要,否则call指令会乱跳)
  2. 检查CPU是否支持保护模式(CPUID指令)
  3. 加载GDT并切换到保护模式
  4. 跳转到32位入口(startup_32)

我个人习惯在调试时,用QEMU配合GDB在0x7C00处设断点,然后单步跟踪这段setup代码。你会发现很多有趣的事情——比如BIOS可能会修改某些内存区域,或者GRUB传递的参数格式不对。

调试命令参考:

# 使用QEMU + GDB调试内核启动
qemu-system-x86_64 -kernel bzImage -s -S
# 在另一个终端
gdb vmlinux
(gdb) target remote :1234
(gdb) hbreak *0x7C00
(gdb) continue

这样你就可以看到内核的第一条指令是如何执行的。

1.7 避坑指南:我踩过的几个坑

做嵌入式Linux这些年,我在启动阶段踩过不少坑。分享几个典型的:

  • 坑1:GRUB找不到内核 —— 分区表格式不对。BIOS模式需要MBR,UEFI需要GPT。我曾经把UEFI主板设成了Legacy模式,结果GRUB死活不认GPT分区。
  • 坑2:内核解压失败 —— 内存不足。有些嵌入式设备只有128MB内存,如果initrd太大,解压时直接OOM。解决方案是用更小的initramfs,或者用gzip而不是xz压缩。
  • 坑3:Secure Boot导致无法启动 —— 内核签名不对。UEFI的Secure Boot会检查引导加载器和内核的签名。我建议在开发阶段先关掉Secure Boot,等稳定了再配置签名。

嗯,这些坑说起来简单,但排查起来真的很费时间。所以我建议你在做新板子的bring-up时,先确保BIOS/UEFI阶段没问题,再往下走。

1.8 小结

从按下电源键到内核的第一行代码,中间经历了:CPU复位 → BIOS/UEFI自检 → 加载GRUB → GRUB加载内核 → 切换到保护模式 → 跳转到setup代码。每一步都可能出问题,每一步都需要你理解底层原理。

我个人觉得,理解启动流程最好的方法就是动手调试。用QEMU模拟一个环境,从BIOS阶段开始单步跟踪,看看每条指令做了什么。你会发现很多书本上没写的东西。

下一章我们会深入内核的setup代码,看看它是如何从实模式切换到保护模式,然后跳转到startup_32的。到时候我会分享一些具体的调试案例。


公众号:蓝海资料掘金营,微信deep3321