header.S 文件:Linux内核的入口

1 阅读9分钟

概述

arch/x86/boot/header.S 是Linux内核启动过程中的关键文件,它定义了内核镜像的头部结构和早期启动代码。这个文件就像内核的"身份证"和"启动说明书",告诉引导加载器如何正确加载和启动内核。

第一阶段:引导加载器解析内核头部

1.1 引导加载器如何找到并解析 header.S

启动流程中 header.S 的位置:

BIOS/UEFI → 引导加载器(GRUB) → header.S头部解析 → 跳转到_start → 执行start_of_setup

1.2 GRUB 解析过程

GRUB 加载过程:

// grub-core/loader/i386/linux.c
grub_linux_boot() {
    // 读取内核头部
    grub_file_read(file, &lh, sizeof(lh));
    
    // 检查启动标志
    if (lh.boot_flag != grub_cpu_to_le16_compile_time(0xAA55))
        return grub_error(GRUB_ERR_BAD_OS, "invalid magic number");
    
    // 检查头部签名
    if (lh.header != grub_cpu_to_le32_compile_time(0x53726448)) /* "HdrS" */
        return grub_error(GRUB_ERR_BAD_OS, "no setup signature found");
}

1.3 内核头部结构(引导加载器读取的部分)

// 传统启动头部 - 引导加载器首先读取这部分
.section ".header", "a"
.globl sentinel
sentinel: .byte 0xff, 0xff        /* 哨兵值,检测损坏的引导器 */

.globl hdr
hdr:
  .byte setup_sects - 1 /* 设置代码扇区数 */
root_flags: .word ROOT_RDONLY /* 根文件系统标志 */
syssize: .long ZO__edata / 16 /* 系统大小 */
ram_size: .word 0   /* 已废弃的内存大小字段 */
vid_mode: .word SVGA_MODE  /* 视频模式 */
root_dev: .word 0   /* 根设备号 */
boot_flag: .word 0xAA55  /* 启动标志,经典魔数 */

这是内核的"传统身份证",包含了启动所需的基本信息:

  • sentinel: 0xff, 0xff:哨兵值,用来检测损坏的引导加载器,就像"防伪标记"
  • setup_sects - 1:设置代码的扇区数,告诉引导器"我的设置代码有多长"
  • boot_flag: 0xAA55:启动标志,这是经典的"魔数",就像"合法启动的印章"

1.4 内核入口点定义

# offset 512, entry point - 偏移512字节处,内核的真正入口点
.globl	_start
_start:
		.byte	0xeb		# short (2-byte) jump - 2字节短跳转
		.byte	start_of_setup-1f	# 跳转到真正的设置代码
1:

# Part 2 of the header, from the old setup.S
		.ascii	"HdrS"		# header signature - 头部签名
		.word	0x020f		# header version number - 头部版本号

这是内核的"真正入口":

  • _start:这是引导加载器跳转到的第一个地址,就像"大门"
  • 短跳转指令:0xeb 是一个2字节的短跳转,跳到真正的设置代码
  • "HdrS":头部签名,告诉引导器"这确实是Linux内核"
  • 版本号:确保兼容性,就像"协议版本号"

1.5 启动参数配置区域

type_of_loader:	.byte	0		# 引导器类型
loadflags:	.byte	LOADED_HIGH	# 加载标志,表示"请把我加载到高地址"
setup_move_size: .word  0x8000		# 如果设置代码不在0x90000,需要移动的大小
code32_start:	.long	0x100000	# 32位代码起始地址,默认1MB处
ramdisk_image:	.long	0		# 内存盘地址,就像"临时仓库的位置"
ramdisk_size:	.long	0		# 内存盘大小,就像"临时仓库的容量"
cmd_line_ptr:	.long	0		# 命令行参数指针,就像"启动时的特殊指令"

这部分是"启动配置参数",就像搬家时的详细说明,告诉引导器如何正确设置内核的运行环境。

第二阶段:引导加载器跳转到内核

2.1 引导加载器的跳转操作

# 引导加载器(如GRUB)执行类似操作:
jmp $KERNEL_CS, $_start    # 跳转到header.S的_start标签

2.2 _start 标签的执行

_start:
    # 必须用字节形式,避免汇编器生成3字节跳转
    .byte   0xeb        # 2字节短跳转指令
    .byte   start_of_setup-1f   # 跳转偏移量,像"门口的指路牌"
1:
# 这里实际执行:jmp start_of_setup

第三阶段:start_of_setup 早期环境初始化

3.1 start_of_setup 开始执行

.section ".entrytext", "ax"
start_of_setup:				# 内核真正开始执行的第一段代码

这是内核真正开始执行的第一段代码,就像"搬进新房子后的第一件事"。

3.2 段寄存器设置

# Force %es = %ds			# 强制ES=DS,确保段寄存器一致
movw	%ds, %ax		# 让ES和DS指向同一个地方
movw	%ax, %es		# 就像"确保所有房间钥匙都能用"
cld				# 清除方向标志,设置默认工作方向

段寄存器设置:

  1. movw %ds, %ax; movw %ax, %es:让ES和DS指向同一个地方,确保所有房间钥匙都能用
  2. cld:清除方向标志,确保字符串操作是正向的,设置默认的工作方向

3.3 栈空间检查和设置

# 栈空间检查和设置,没有栈就无法调用函数
movw	%ss, %dx		# 检查栈段是否有效
cmpw	%ax, %dx	# %ds == %ss? 检查SS是否等于DS
movw	%sp, %dx		# 就像"检查临时工作台是否可用"
je	2f		# -> assume %sp is reasonably set

# Invalid %ss, make up a new stack  栈无效时重新计算栈指针
movw	$_end, %dx		# 从代码结束处开始
testb	$CAN_USE_HEAP, loadflags # 检查是否可以使用堆
jz	1f
movw	heap_end_ptr, %dx	# 如果可以使用堆,就用heap_end_ptr
1:	addw	$STACK_SIZE, %dx	# 加上栈大小,给栈留出空间
jnc	2f
xorw	%dx, %dx	# Prevent wraparound 防止地址回绕

2:	# Now %dx should point to the end of our stack space
andw	$~3, %dx	# dword align 4字节对齐
jnz	3f
movw	$0xfffc, %dx	# Make sure we're not zero 确保不为零
3:	movw	%ax, %ss		# 设置栈段
movzwl	%dx, %esp	# 设置栈指针
sti			# 开启中断

栈空间检查和设置: 这是最关键的部分,因为没有栈就无法调用函数:

  1. 检查栈段:检查SS是否等于DS,就像"检查临时工作台是否可用"
  2. 栈无效时的处理:如果栈段无效,就重新计算栈指针
  3. 栈对齐:确保栈是4字节对齐的,就像"工作台要摆放整齐"
  4. 设置栈:最后设置SS和ESP,开启中断

3.4 CS段规范化

# CS段规范化,让CS和DS保持一致
pushw	%ds		# 用远返回技巧规范化CS段
pushw	$6f		# 相当于跳转到DS:6f
lretw			# 让CS和DS保持一致,统一门牌号格式
6:

这部分是"地址规范化":

  • 由于历史原因,CS可能比DS高0x20,这里用一个巧妙的技巧
  • 相当于跳转到DS:6f,让CS和DS保持一致
  • 就像"把所有房间的门牌号统一格式"

3.5 签名验证

# Check signature at end of setup  检查设置代码末尾的签名
cmpl	$0x5a5aaa55, setup_sig	# 确保代码完整,检查包裹是否损坏
jne	setup_bad		# 签名错误就跳转到错误处理

签名检查:

  • 检查设置代码末尾的签名,确保代码完整
  • 就像"检查包裹是否完整,有没有损坏"

3.6 BSS段清零

# Zero the bss				# BSS段清零,清理所有空房间
movw	$__bss_start, %di	# 设置目标地址为BSS段开始
movw	$_end+3, %cx		# 计算要清零的长度
xorl	%eax, %eax		# EAX清零,用作填充值
subw	%di, %cx		# 计算实际长度
shrw	$2, %cx			# 除以4,按双字处理
rep stosl			# 重复存储,把BSS段全部清零

BSS段清零: 这就像"搬进新房子后,把所有空房间都打扫干净"。BSS段存放未初始化的全局变量,必须清零。

第四阶段:跳转到C语言代码

4.1 调用main函数

# Jump to C code (should not return)	# 跳转到C代码,交给C语言继续
calll	main			# 调用main函数,进入C语言世界

4.2 start_of_setup 到 main.c 的完整调用路径

完整调用链:

引导加载器 → _start → start_of_setup → calll main → arch/x86/boot/main.c:main()

calll main 指令的执行过程:

  • 压栈:将返回地址(下一条指令地址)压入栈
  • 跳转:将EIP设置为main函数的地址
  • 继续执行:从main函数第一条指令开始执行

4.3 main函数开始执行

// 文件:arch/x86/boot/main.c
void main(void)
{
    // 复制启动参数
    copy_boot_params();

    // 初始化控制台
    console_init();

    // 检测内存
    detect_memory();

    // 设置键盘
    keyboard_init();

    // 检查CPU特性
    validate_cpu();

    // 设置视频模式
    set_video();

    // 进入保护模式
    go_to_protected_mode();  // 这个函数不会返回
}

4.4 为什么能直接调用C函数?

在调用 main() 之前,start_of_setup已经完成了所有必要的准备工作:

  1. 栈空间就绪:有了栈,C函数才能正常调用
  2. 段寄存器统一:确保数据访问的一致性
  3. BSS段清零:C语言的全局未初始化变量存放在BSS段,必须清零
  4. 中断开启:允许系统响应硬件中断

第五阶段:错误处理路径

5.1 签名验证失败的处理

# Setup corrupt somehow...		# 错误处理:如果签名检查失败
setup_bad:
movl	$setup_corrupt, %eax	# 显示错误信息
calll	puts			# "No setup signature found..."
# Fall through...		# 继续执行die函数

.globl	die
.type	die, @function
die:					# 死循环停机,发现问题就报错并停止
hlt				# 停机指令
jmp	die			# 无限循环,不能继续危险操作

错误处理:

  • 如果签名检查失败,就跳到setup_bad
  • 显示错误信息"No setup signature found..."
  • 最后进入die函数:无限循环停机
  • 就像"如果发现问题就报错并停止,不能继续危险的操作"

执行流程总览图

image.png

关键时间节点总结

  1. 头部解析阶段:引导加载器读取并验证内核头部
  2. 入口跳转阶段:从_start跳转到start_of_setup
  3. 环境初始化阶段:设置段寄存器、栈空间、清零BSS
  4. 验证检查阶段:确保代码完整性
  5. 语言切换阶段:从汇编代码跳转到C语言代码
  6. 后续初始化阶段:在main.c中进行硬件检测和模式切换

这个完整的执行顺序展示了header.S如何承担起Linux内核启动过程中的关键桥梁作用,确保系统能够从引导加载器平滑过渡到内核主体代码的执行。

重要技术细节

内存布局和调用栈

调用前的内存状态:

栈内存布局 (从高地址到低地址):
┌─────────────────┐ ← ESP指向这里
│                 │   (栈顶,空闲空间)
│   栈空间        │
└─────────────────┘ ← 栈底

代码内存布局:
┌─────────────────┐ 0x90000
│  引导加载器数据  │
├─────────────────┤ 0x90200
│  header.S       │ ← start_of_setup在这里
├─────────────────┤ 0x90xxx
│  main.c         │ ← main函数在这里
└─────────────────┘

执行 calll main 时的栈变化:

调用前栈状态:
┌─────────────────┐ ← ESP
│                 │
└─────────────────┘

执行 calll main 后:
┌─────────────────┐ ← ESP (新位置)
│  返回地址       │ ← 指向calll main的下一条指令
├─────────────────┤
│                 │
└─────────────────┘

多启动方式支持

UEFI启动支持:

#ifdef CONFIG_EFI_STUB
    # "MZ", MS-DOS header		# PE格式的"MZ"签名
    .word	IMAGE_DOS_SIGNATURE
    .org	0x38
    .long	LINUX_PE_MAGIC
    .long	pe_header
#endif

这部分是为了让Linux内核能在UEFI系统上启动:

  • PE格式的"MZ"签名,像护照上的国徽
  • 告诉UEFI"详细信息在这里"
  • 让Linux内核既能在传统BIOS上启动,也能在现代UEFI上启动

总结

header.S文件虽然代码量不大,但在Linux启动过程中发挥着至关重要的作用。它按照严格的执行顺序完成以下任务:

  1. 身份验证:通过多重签名证明内核的合法性
  2. 参数协商:在引导器和内核之间建立通信协议
  3. 环境准备:为后续C代码建立最基本的运行环境
  4. 平滑过渡:实现从汇编到C语言的无缝切换

这种设计让Linux内核能够在各种不同的硬件平台和启动环境中稳定运行,体现了Linux系统优秀的工程设计和架构思想。理解header.S的执行流程,有助于深入掌握操作系统的启动机制,为系统级编程和内核开发打下基础。