本节解释 Linux 内核编译过程中采取的步骤以及每个阶段产生的输出。构建过程取决于架构,因此我想强调我们只考虑构建 Linux/x86 内核。
当用户输入 'make zImage' 或 'make bzImage' 时,生成的引导内核镜像分别存储为 arch/i386/boot/zImage
或 arch/i386/boot/bzImage
。以下是镜像的构建方式
vmlinux
中,这是一个静态链接的、未剥离的 ELF 32 位 LSB 80386 可执行文件。System.map
由 nm vmlinux 生成,不相关或不重要的符号被过滤掉。arch/i386/boot
。bootsect.S
在预处理时,根据目标是 bzImage 还是 zImage,可以选择是否使用 -D__BIG_KERNEL__,分别生成 bbootsect.s
或 bootsect.s
。bbootsect.s
被汇编,然后转换为名为 bbootsect
的“原始二进制”形式(或 bootsect.s
被汇编并原始二进制转换为 bootsect
用于 zImage)。setup.S
(setup.S
包括 video.S
) 被预处理为用于 bzImage 的 bsetup.s
或用于 zImage 的 setup.s
。与引导扇区代码的方式相同,区别在于 bzImage 存在 -D__BIG_KERNEL__。然后,结果被转换为名为 bsetup
的“原始二进制”形式。arch/i386/boot/compressed
,并将 /usr/src/linux/vmlinux
转换为原始二进制格式的 $tmppiggy(临时文件名),删除 .note
和 .comment
ELF 节。piggy.o
。head.S
和 misc.c
(仍在 arch/i386/boot/compressed
目录中)编译成 ELF 目标文件 head.o
和 misc.o
。head.o
、misc.o
和 piggy.o
链接到 bvmlinux
中(或 zImage 的 vmlinux
,不要将其与 /usr/src/linux/vmlinux
混淆!)。请注意 vmlinux
使用的 -Ttext 0x1000 和 bvmlinux
使用的 -Ttext 0x100000 之间的区别,即对于 bzImage,压缩加载器被加载到高地址。bvmlinux
转换为“原始二进制” bvmlinux.out
,删除 .note
和 .comment
ELF 节。arch/i386/boot
目录,并使用程序 tools/build,将 bbootsect
、bsetup
和 compressed/bvmlinux.out
连接到 bzImage
中(对于 zImage
,删除上面多余的 'b')。这会将重要的变量(如 setup_sects
和 root_dev
)写入引导扇区的末尾。0x4000 字节 >= 512 + setup_sects * 512 + 引导扇区/设置运行时堆栈的空间
稍后我们将看到此限制的来源。
在此步骤生成的 bzImage 大小的上限约为 2.5M,用于使用 LILO 引导,对于从软盘或 CD-ROM(El-Torito 仿真模式)引导原始镜像,上限为 0xFFFF 段(0xFFFF0 = 1048560 字节)。
请注意,虽然 tools/build 验证了引导扇区、内核镜像和设置大小下限的大小,但它不检查所述设置大小的 *上限*。因此,只需在 setup.S
的末尾添加一些大的 ".space",就很容易构建一个损坏的内核。
启动过程的详细信息特定于架构,因此我们将重点关注 IBM PC/IA32 架构。由于旧的设计和向后兼容性,PC 固件以老式的方式引导操作系统。此过程可以分为以下六个逻辑阶段
用于引导 Linux 内核的引导扇区可以是
arch/i386/boot/bootsect.S
),我们在这里详细考虑 Linux 引导扇区。前几行初始化用于段值的便捷宏
29 SETUPSECS = 4 /* default nr of setup-sectors */
30 BOOTSEG = 0x07C0 /* original address of boot-sector */
31 INITSEG = DEF_INITSEG /* we move boot here - out of the way */
32 SETUPSEG = DEF_SETUPSEG /* setup starts here */
33 SYSSEG = DEF_SYSSEG /* system loaded at 0x10000 (65536) */
34 SYSSIZE = DEF_SYSSIZE /* system size: # of 16-byte clicks */
(左侧的数字是 bootsect.S 文件的行号)DEF_INITSEG
、DEF_SETUPSEG
、DEF_SYSSEG
和 DEF_SYSSIZE
的值取自 include/asm/boot.h
/* Don't touch these, unless you really know what you're doing. */
#define DEF_INITSEG 0x9000
#define DEF_SYSSEG 0x1000
#define DEF_SETUPSEG 0x9020
#define DEF_SYSSIZE 0x7F00
现在,让我们考虑 bootsect.S
的实际代码
54 movw $BOOTSEG, %ax
55 movw %ax, %ds
56 movw $INITSEG, %ax
57 movw %ax, %es
58 movw $256, %cx
59 subw %si, %si
60 subw %di, %di
61 cld
62 rep
63 movsw
64 ljmp $INITSEG, $go
65 # bde - changed 0xff00 to 0x4000 to use debugger at 0x6400 up (bde). We
66 # wouldn't have to worry about this if we checked the top of memory. Also
67 # my BIOS can be configured to put the wini drive tables in high memory
68 # instead of in the vector table. The old stack might have clobbered the
69 # drive table.
70 go: movw $0x4000-12, %di # 0x4000 is an arbitrary value >=
71 # length of bootsect + length of
72 # setup + room for stack;
73 # 12 is disk parm size.
74 movw %ax, %ds # ax and es already contain INITSEG
75 movw %ax, %ss
76 movw %di, %sp # put stack at INITSEG:0x4000-12.
第 54-63 行将引导扇区代码从地址 0x7C00 移动到 0x90000。这是通过以下方式实现的
此代码不使用 rep movsd
是有意的(提示 - .code16)。
第 64 行跳转到引导扇区新副本中的标签 go:
,即段 0x9000 中。接下来的三个指令(第 64-76 行)在 $INITSEG:0x4000-0xC 处准备堆栈,即 %ss = $INITSEG (0x9000) 和 %sp = 0x3FF4 (0x4000-0xC)。这就是我们之前提到的设置大小限制的来源(请参阅构建 Linux 内核镜像)。
第 77-103 行修补第一个磁盘的磁盘参数表,以允许多扇区读取
77 # Many BIOS's default disk parameter tables will not recognise
78 # multi-sector reads beyond the maximum sector number specified
79 # in the default diskette parameter tables - this may mean 7
80 # sectors in some cases.
81 #
82 # Since single sector reads are slow and out of the question,
83 # we must take care of this by creating new parameter tables
84 # (for the first disk) in RAM. We will set the maximum sector
85 # count to 36 - the most we will encounter on an ED 2.88.
86 #
87 # High doesn't hurt. Low does.
88 #
89 # Segments are as follows: ds = es = ss = cs - INITSEG, fs = 0,
90 # and gs is unused.
91 movw %cx, %fs # set fs to 0
92 movw $0x78, %bx # fs:bx is parameter table address
93 pushw %ds
94 ldsw %fs:(%bx), %si # ds:si is source
95 movb $6, %cl # copy 12 bytes
96 pushw %di # di = 0x4000-12.
97 rep # don't need cld -> done on line 66
98 movsw
99 popw %di
100 popw %ds
101 movb $36, 0x4(%di) # patch sector count
102 movw %di, %fs:(%bx)
103 movw %es, %fs:2(%bx)
软盘控制器使用 BIOS 服务 int 0x13 功能 0(重置 FDC)重置,设置扇区紧跟在引导扇区之后加载,即物理地址 0x90200 ($INITSEG:0x200),再次使用 BIOS 服务 int 0x13,功能 2(读取扇区)。这发生在第 107-124 行期间
107 load_setup:
108 xorb %ah, %ah # reset FDC
109 xorb %dl, %dl
110 int $0x13
111 xorw %dx, %dx # drive 0, head 0
112 movb $0x02, %cl # sector 2, track 0
113 movw $0x0200, %bx # address = 512, in INITSEG
114 movb $0x02, %ah # service 2, "read sector(s)"
115 movb setup_sects, %al # (assume all on head 0, track 0)
116 int $0x13 # read it
117 jnc ok_load_setup # ok - continue
118 pushw %ax # dump error code
119 call print_nl
120 movw %sp, %bp
121 call print_hex
122 popw %ax
123 jmp load_setup
124 ok_load_setup:
如果由于某种原因加载失败(坏软盘或有人在操作过程中拔出了软盘),我们会转储错误代码并在无限循环中重试。摆脱它的唯一方法是重启机器,除非重试成功,但通常不会(如果出现问题,只会变得更糟)。如果成功加载 setup_sects 扇区的设置代码,我们跳转到标签 ok_load_setup:
。
然后我们继续在物理地址 0x10000 处加载压缩内核镜像。这样做是为了保留低内存 (0-64K) 中的固件数据区域。加载内核后,我们跳转到 $SETUPSEG:0 (arch/i386/boot/setup.S
)。一旦不再需要数据(例如,不再调用 BIOS),它将被从 0x10000 移动到 0x1000(物理地址,当然)的整个(压缩)内核镜像覆盖。这是由 setup.S
完成的,它为保护模式设置并跳转到 0x1000,这是压缩内核的头部,即 arch/386/boot/compressed/{head.S,misc.c}
。这设置了堆栈并调用 decompress_kernel()
,它将内核解压缩到地址 0x100000 并跳转到它。
请注意,旧的引导加载程序(旧版本的 LILO)只能加载设置的前 4 个扇区,这就是为什么设置中有代码在需要时加载其余部分的原因。此外,设置中的代码必须处理加载程序类型/版本与 zImage/bzImage 的各种组合,因此非常复杂。
让我们检查一下引导扇区代码中的 kludge,它允许加载一个大的内核,也称为“bzImage”。设置扇区像往常一样加载在 0x90200 处,但内核一次加载 64K 的块,使用一个特殊的辅助例程,该例程调用 BIOS 将数据从低内存移动到高内存。此辅助例程在 bootsect.S
中由 bootsect_kludge
引用,并在 setup.S
中定义为 bootsect_helper
。setup.S
中的 bootsect_kludge
标签包含设置段的值和 bootsect_helper
代码在其中的偏移量,以便引导扇区可以使用 lcall
指令跳转到它(段间跳转)。它在 setup.S
中的原因很简单,因为 bootsect.S 中没有更多空间了(这严格来说是不正确的 - 在 bootsect.S
中大约有 4 个备用字节和至少 1 个备用字节,但这显然是不够的)。此例程使用 BIOS 服务 int 0x15 (ax=0x8700) 移动到高内存,并将 %es 重置为始终指向 0x10000。这确保了 bootsect.S
中的代码在从磁盘复制数据时不会耗尽低内存。
与裸机 Linux 引导扇区相比,使用专用引导加载程序 (LILO) 有几个优点
LILO 做的最后一件事是跳转到 setup.S
,然后事情像往常一样进行。
通过“高级初始化”,我们考虑任何与引导程序不直接相关的东西,即使执行此操作的部分代码是用汇编编写的,即 arch/i386/kernel/head.S
,它是未压缩内核的头部。执行以下步骤
start_kernel()
,所有其他 CPU 调用 arch/i386/kernel/smpboot.c:initialize_secondary()
,如果 ready=1,它只是重新加载 esp/eip 并且不返回。init/main.c:start_kernel()
是用 C 编写的,它执行以下操作
kmem_cache_init()
,初始化 slab 分配器的大部分。mem_init()
,它计算 max_mapnr
、totalram_pages
和 high_memory
并打印出 "Memory: ..." 行。kmem_cache_sizes_init()
,完成 slab 分配器初始化。fork_init()
,创建 uid_cache
,根据可用内存量初始化 max_threads
,并将 init_task
的 RLIMIT_NPROC
配置为 max_threads/2
。init()
,如果通过 "init=" 引导参数提供 execute_command,则执行它,否则尝试按顺序执行 /sbin/init、/etc/init、/bin/init、/bin/sh;如果所有这些都失败,则以“建议”使用 "init=" 参数而崩溃。这里需要注意的重要一点是,init()
内核线程调用 do_basic_setup()
,而 do_basic_setup()
又调用 do_initcalls()
,后者遍历通过 __initcall
或 module_init()
宏注册的函数列表并调用它们。这些函数要么彼此不依赖,要么它们的依赖关系已通过 Makefiles 中的链接顺序手动修复。这意味着,根据目录在树中的位置和 Makefiles 的结构,调用初始化函数的顺序可能会更改。有时,这很重要,因为您可以想象两个子系统 A 和 B,其中 B 依赖于 A 完成的一些初始化。如果 A 是静态编译的,而 B 是一个模块,那么保证在 A 准备好所有必要的环境后调用 B 的入口点。如果 A 是一个模块,那么 B 也必然是一个模块,因此没有问题。但是,如果 A 和 B 都静态链接到内核中怎么办?调用它们的顺序取决于内核镜像的 .initcall.init
ELF 节中的相对入口点偏移量。Rogier Wolff 建议引入一个分层“优先级”基础设施,模块可以通过该基础设施让链接器知道它们应该以什么(相对)顺序链接,但到目前为止,还没有可用的补丁以足够优雅的方式实现这一点以被内核接受。因此,请确保您的链接顺序正确。如果在上面的示例中,A 和 B 在静态编译一次时工作正常,那么它们将始终工作,前提是它们在同一个 Makefile 中按顺序列出。如果它们不起作用,请更改其目标文件的列出顺序。
另一个值得注意的事情是 Linux 通过传递 "init=" 引导命令行来执行“替代 init 程序”的能力。这对于从意外覆盖的 /sbin/init 中恢复或手动调试初始化 (rc) 脚本和 /etc/inittab
非常有用,一次执行一个脚本。
在 SMP 上,BP 经历引导扇区、设置等的正常序列,直到到达 start_kernel()
,然后到达 smp_init()
,特别是 src/i386/kernel/smpboot.c:smp_boot_cpus()
。smp_boot_cpus()
循环遍历每个 apicid(直到 NR_CPUS
),并在其上调用 do_boot_cpu()
。do_boot_cpu()
所做的是为目标 CPU 创建(即 fork_by_hand
)一个空闲任务,并在 Intel MP 规范(0x467/0x469)定义的众所周知的位置写入 trampoline.S
中找到的 trampoline 代码的 EIP。然后,它向目标 CPU 生成 STARTUP IPI,这使得该 AP 执行 trampoline.S
中的代码。
引导 CPU 在低内存中为每个 CPU 创建 trampoline 代码的副本。AP 代码在其自己的代码中写入一个魔数,BP 验证该魔数以确保 AP 正在执行 trampoline 代码。Intel MP 规范强制要求 trampoline 代码必须在低内存中。
trampoline 代码只是将 %bx 寄存器设置为 1,进入保护模式并跳转到 startup_32,这是 arch/i386/kernel/head.S
的主要入口点。
现在,AP 开始执行 head.S
并发现它不是 BP,它跳过清除 BSS 的代码,然后进入 initialize_secondary()
,后者只是为该 CPU 进入空闲任务 - 回想一下 init_tasks[cpu]
已经由 BP 执行 do_boot_cpu(cpu)
初始化。
请注意,init_task 可以共享,但每个空闲线程都必须有自己的 TSS。这就是为什么 init_tss[NR_CPUS]
是一个数组。
当操作系统初始化自身时,大多数代码和数据结构永远不再需要。大多数操作系统(BSD、FreeBSD 等)无法处理这些不需要的信息,从而浪费了宝贵的物理内核内存。他们使用的借口(参见 McKusick 的 4.4BSD 书)是“相关代码分散在各个子系统中,因此释放它不可行”。当然,Linux 不能使用这样的借口,因为在 Linux 下“如果某件事在原则上是可能的,那么它已经实现或有人正在努力实现它”。
所以,正如我之前所说,Linux 内核只能编译为 ELF 二进制文件,现在我们找到了原因(或原因之一)。与丢弃初始化代码/数据相关的原因是 Linux 提供了两个要使用的宏
__init
- 用于初始化代码__initdata
- 用于数据这些评估为 gcc 属性说明符(也称为“gcc 魔术”),如 include/linux/init.h
中定义的那样
#ifndef MODULE
#define __init __attribute__ ((__section__ (".text.init")))
#define __initdata __attribute__ ((__section__ (".data.init")))
#else
#define __init
#define __initdata
#endif
这意味着如果代码静态编译到内核中(即未定义 MODULE),则它被放置在特殊的 ELF 节 .text.init
中,该节在 arch/i386/vmlinux.lds
的链接器映射中声明。否则(即,如果它是一个模块),宏评估为无。
在引导期间发生的情况是,“init”内核线程(函数 init/main.c:init()
)调用特定于架构的函数 free_initmem()
,后者释放地址 __init_begin
和 __init_end
之间的所有页面。
在典型的系统(我的工作站)上,这导致释放大约 260K 的内存。
通过 module_init()
注册的函数放置在 .initcall.init
中,在静态情况下也会释放它。Linux 的当前趋势是在设计子系统(不一定是模块)时,从设计的早期阶段就提供 init/exit 入口点,以便将来可以在需要时将所讨论的子系统模块化。pipefs 就是一个例子,请参阅 fs/pipe.c
。即使给定的子系统永远不会成为模块,例如 bdflush(请参阅 fs/buffer.c
),对它的初始化函数使用 module_init()
宏仍然很好且整洁,前提是函数被调用的确切时间无关紧要。
还有两个以类似方式工作的宏,称为 __exit
和 __exitdata
,但它们与模块支持的联系更直接,因此将在后面的章节中进行解释。
让我们回顾一下在引导期间传递给内核的命令行发生了什么
arch/i386/kernel/head.S
将其前 2k 复制到零页。arch/i386/kernel/setup.c:parse_mem_cmdline()
(由 setup_arch()
调用,而 setup_arch()
本身由 start_kernel()
调用)从零页复制 256 字节到 saved_command_line
,后者由 /proc/cmdline
显示。如果存在 "mem=" 选项,则此例程也会处理 "mem=" 选项,并对 VM 参数进行适当的调整。parse_options()
(由 start_kernel()
调用)中返回到命令行,它处理一些“内核内”参数(当前为 "init=" 以及 init 的环境/参数)并将每个单词传递给 checksetup()
。checksetup()
遍历 ELF 节 .setup.init
中的代码并调用每个函数,如果单词匹配,则将单词传递给它。请注意,使用通过 __setup()
注册的函数返回的 0 值,可以将相同的 "variable=value" 传递给多个函数,其中 "value" 对一个函数无效,而对另一个函数有效。Jeff Garzik 评论说:“这样做的人会被打屁股 :)” 为什么?因为这显然是 ld-order 特定的,即以一种顺序链接的内核将在函数 B 之前调用函数 A,而另一种顺序链接的内核将以相反的顺序调用,结果取决于顺序。那么,我们如何编写处理引导命令行的代码呢?我们使用 include/linux/init.h
中定义的 __setup()
宏
/*
* Used for kernel command line parameter setup
*/
struct kernel_param {
const char *str;
int (*setup_func)(char *);
};
extern struct kernel_param __setup_start, __setup_end;
#ifndef MODULE
#define __setup(str, fn) \
static char __setup_str_##fn[] __initdata = str; \
static struct kernel_param __setup_##fn __initsetup = \
{ __setup_str_##fn, fn }
#else
#define __setup(str,func) /* nothing */
endif
因此,您通常会在您的代码中像这样使用它(取自真实驱动程序 BusLogic HBA drivers/scsi/BusLogic.c
的代码)
static int __init
BusLogic_Setup(char *str)
{
int ints[3];
(void)get_options(str, ARRAY_SIZE(ints), ints);
if (ints[0] != 0) {
BusLogic_Error("BusLogic: Obsolete Command Line Entry "
"Format Ignored\n", NULL);
return 0;
}
if (str == NULL || *str == '\0')
return 0;
return BusLogic_ParseDriverOptions(str);
}
__setup("BusLogic=", BusLogic_Setup);
请注意,__setup()
对模块没有任何作用,因此希望处理引导命令行并且可以是模块或静态链接的代码必须在其模块初始化例程中手动调用其解析函数。这也意味着可以编写在编译为模块时处理参数的代码,但在静态编译时则不处理参数,反之亦然。