从引导程序到内核如果您阅读了以前的
文章 ,您将了解我对底层编程的新兴趣。 我写了几篇关于
x86_64
Linux的汇编程序编程的文章,同时开始深入研究Linux内核的源代码。
我非常想了解低级事物的工作原理:程序如何在计算机上运行,它们如何位于内存中,内核如何管理进程和内存,网络堆栈在低级如何工作等等。 因此,我决定写另一系列有关
x86_64体系结构 Linux内核的文章。
请注意,我不是专业的内核开发人员,也不在工作时编写内核代码。 这只是一个爱好。 我只喜欢低级的东西,深入研究它们很有趣。 因此,如果您发现任何混乱或疑问/评论出现,请
通过Twitter通过
邮件与我联系,或者只是创建
票证 。 我将不胜感激。
所有文章都发布在
GitHub存储库中 ,如果我的英语或文章内容有问题,请立即发送拉取请求。
请注意,这不是官方文档,而只是培训和知识共享。必修知识无论如何,如果您刚刚开始学习此类工具,我将尝试在本文章及其后续文章中进行解释。 好的,随着介绍的完成,是时候深入探讨Linux内核和底层知识了。
我从Linux 3.18内核时代开始写这本书,此后发生了很多变化。 如果有更改,我将相应地更新文章。
魔术电源按钮,下一步是什么?
尽管这些是有关Linux内核的文章,但至少在本节中,我们还没有涉及到它。 按下膝上型计算机或台式计算机上的魔术电源按钮后,它将立即开始工作。 主板将信号发送到
电源 。 收到信号后,它将为计算机提供必要的电量。 主板收到
“电源正常”信号后 ,便立即尝试启动CPU。 他将所有剩余的数据转储到寄存器中,并为每个寄存器设置预定义的值。
重新启动后,处理器
80386和更高版本的CPU寄存器中应具有以下值:
IP 0xfff0
CS选择器0xf000
CS基数0xffff0000
处理器开始在
实模式下工作。 让我们回到过去,尝试了解此模式下
的内存分段 。 所有与x86兼容的处理器都支持实模式:从
8086到现代的64位Intel处理器。 8086处理器使用20位地址总线,也就是说,它可以使用
0-0xFFFFF
或
1
的地址空间工作。 但是它只有16位寄存器,最大地址为
2^16-1
或
0xffff
(64 KB)。
需要使用
内存分段来使用整个可用地址空间。 所有内存均分为固定大小为
65536
字节(64 KB)的小段。 由于使用16位寄存器,我们无法访问64 KB以上的内存,因此开发了另一种方法。
地址由两部分组成:1)具有基地址的段选择器; 2)从基址偏移。 在实模式下,段
* 16
的基地址为
* 16
。 因此,要获取内存中的物理地址,您需要将段选择器的一部分乘以16并向其添加偏移量:
= * 16 +
例如,如果
CS:IP
寄存器的值为
0x2000:0x0010
,则相应的物理地址将如下所示:
>>> hex((0x2000 << 4) + 0x0010) '0x20010'
但是,如果使用最大段的选择器和偏移量
0xffff:0xffff
,则会得到地址:
>>> hex((0xffff << 4) + 0xffff) '0x10ffef'
即第一个兆字节后的
65520
个字节。 由于在实模式下只有1兆字节可用,
0x10ffef
在禁用
A20线路的情况下
0x10ffef
变为
0x00ffef
。
好了,现在我们对实模式和此模式下的内存寻址有所了解。 让我们回到复位后寄存器值的讨论。
CS
寄存器由两部分组成:可见的段选择器和隐藏的基地址。 尽管基地址通常是通过在硬件复位期间将段选择器的值乘以16形成的,但CS寄存器中的段选择器为
0xf000
,基地址为
0xffff0000
。 处理器使用此特殊基地址,直到CS更改为止。
通过将基地址添加到EIP寄存器中的值来形成起始地址:
>>> 0xffff0000 + 0xfff0 '0xfffffff0'
我们得到
0xfffffff0
,这是4 GB以下的16个字节。 这一点称为
复位向量 。 这是内存中CPU复位后等待第一条指令执行的位置:跳转操作(
jmp ),通常指示BIOS入口点。 例如,如果您查看
coreboot的源代码(
src/cpu/x86/16bit/reset16.inc
),我们将看到:
.section ".reset", "ax", %progbits .code16 .globl _start _start: .byte 0xe9 .int _start16bit - ( . + 2 ) ...
在这里,我们看到操作码(
opcode )
jmp
,即
0xe9
,目的地址
_start16bit - ( . + 2)
。
我们还看到
reset
部分是16个字节,它编译后从地址
0xfffff0
(
src/cpu/x86/16bit/reset16.ld
)运行:
SECTIONS { _bogus = ASSERT(_start16bit >= 0xffff0000, "_start16bit too low. Please report."); _ROMTOP = 0xfffffff0; . = _ROMTOP; .reset . : { *(.reset); . = 15; BYTE(0x00); } }
BIOS现在启动; 初始化并检查BIOS硬件后,您需要找到引导设备。 引导顺序保存在BIOS配置中。 尝试从硬盘驱动器引导时,BIOS尝试查找引导扇区。 在
MBR分区磁盘上,引导扇区存储在第一个扇区的前446个字节中,其中每个扇区为512个字节。 第一个扇区的最后两个字节是
0x55
和
0xaa
。 它们显示BIOS它是引导设备。
例如:
; ; : Intel x86 ; [BITS 16] boot: mov al, '!' mov ah, 0x0e mov bh, 0x00 mov bl, 0x07 int 0x10 jmp $ times 510-($-$$) db 0 db 0x55 db 0xaa
我们收集并运行:
nasm -f bin boot.nasm && qemu-system-x86_64 boot
QEMU收到一条命令,以使用我们刚刚创建的
boot
二进制文件作为磁盘映像。 由于上面生成的二进制文件满足引导扇区的要求(从
0x7c00
开始并以魔术序列结束),因此QEMU将二进制文件视为磁盘映像的主引导记录(MBR)。
您将看到:

在此示例中,我们看到代码以16位实模式运行,并从内存中的地址
0x7c00
开始。 启动后,它会导致一个
0x10中断,该中断只会打印一个字符
!
; 用零填充剩余的510字节,并以两个魔术字节
0xaa
和
0x55
0xaa
。
您可以使用
objdump
实用程序查看二进制转储:
nasm -f bin boot.nasm
objdump -D -b binary -mi386 -Maddr16,data16,intel boot
当然,在实际的引导扇区中,有代码可以继续引导过程和分区表,而不是一堆零和一个感叹号:)。 从这一刻起,BIOS将控制权转移到引导加载程序。
注意 :如上所述,CPU处于实模式。 内存中物理地址的计算如下:
= * 16 +
我们只有16位通用寄存器,而16位寄存器的最大值是
0xffff
,因此,最大值将是:
>>> hex((0xffff * 16) + 0xffff) '0x10ffef'
其中
0x10ffef
是
1 + 64 - 16
。
8086处理器 (第一个具有实模式的处理器)具有20位地址线。 由于
2^20 = 1048576
,因此实际可用内存为1 MB。
通常,实模式存储器寻址如下:
0x00000000-0x000003FF-实模式的中断向量表
0x00000400-0x000004FF-BIOS数据区域
0x00000500-0x00007BFF-未使用
0x00007C00-0x00007DFF-我们的引导程序
0x00007E00-0x0009FFFF-未使用
0x000A0000-0x000BFFFF-视频RAM(VRAM)
0x000B0000-0x000B7777-单色视频存储器
0x000B8000-0x000BFFFF-彩色模式视频存储器
0x000C0000-0x000C7FFF-视频ROM BIOS
0x000C8000-0x000EFFFF-阴影区域(BIOS阴影)
0x000F0000-0x000FFFFF-系统BIOS
在本文开头写道,处理器的第一条指令位于
0xFFFFFFF0
,这比
0xFFFFF
(1 MB)大得多。 CPU如何在实模式下访问该地址? 在
coreboot文档中回答:
0xFFFE_0000 - 0xFFFF_FFFF: 128 ROM
在执行开始时,BIOS不在RAM中,而在ROM中。
引导程序
Linux内核可以使用不同的引导加载程序进行加载,例如
GRUB 2和
syslinux 。 内核具有一个引导协议,该协议定义了用于实现Linux支持的引导程序要求。 在此示例中,我们正在使用GRUB 2。
继续引导过程,BIOS选择了引导设备并将控制权转移到引导扇区,执行从
boot.img开始。 由于大小有限,这是一个非常简单的代码。 它包含一个指向主GRUB 2映像的指针,它以
diskboot.img开头,通常存储在第一个扇区之后,第一个分区之前的未使用空间中。 上面的代码将包含GRUB 2内核和用于处理文件系统的驱动程序的其余映像加载到内存中。 之后,
执行grub_main函数。
grub_main
函数初始化控制台,返回模块的基址,设置根设备,加载/解析grub配置文件,加载模块,等等。 在执行结束时,它将使grub进入普通模式。
grub_normal_execute
函数(来自
grub-core/normal/main.c
源文件)完成了最后的准备工作并显示了用于选择操作系统的菜单。 当我们选择grub菜单项之一时,将
grub_menu_execute_entry
函数,该函数执行grub
boot
命令并加载所选的OS。
如内核引导协议中所示,引导加载程序必须读取并填写内核安装标头的某些字段,该字段从内核安装代码的偏移量
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
引导加载程序应使用从命令行接收或在引导时计算的值来填充此标头和其他标头(在本示例中,在Linux引导协议中仅标记为类型
write
)。 现在,我们将不讨论所有标题字段的描述和解释。 稍后我们将讨论内核如何使用它们。 有关所有字段的说明,请参见
下载协议 。
如您在内核启动协议中所看到的,内存将显示如下:
| 受保护的内核模式
100000 + ------------------------ +
| I / O映射|
0A0000 + ------------------------ +
| 储备金 对于BIOS | 尽可能地自由
~~
| 命令行| (也可能低于X + 10000)
X + 10000 + ------------------------ +
| 堆/堆| 使用真实的内核模式代码
X + 08000 + ------------------------ +
| 内核安装| 内核实模式代码
| 内核引导扇区| 旧版内核启动扇区
X + ------------------------ +
| 装载机 <-入口点0x7C00引导扇区
001000 + ------------------------ +
| 储备金 适用于MBR / BIOS |
000800 + ------------------------ +
| 通常使用 MBR |
000600 + ------------------------ +
| 二手的 仅BIOS |
000000 + ------------------------ +
因此,当加载程序将控制权转移到内核时,它以地址开头:
X + sizeof (KernelBootSector) + 1
其中
X
是内核引导扇区的地址。 在我们的例子中,
X
是
0x10000
,如内存转储所示:

引导程序将Linux内核移至内存中,填充标头字段,然后移至相应的内存地址。 现在我们可以直接进入内核安装代码。
内核安装阶段开始
最后,我们是核心! 尽管从技术上讲它尚未运行。 首先,内核安装部分需要配置一些东西,包括一个解压缩器和一些带有内存管理的东西。 完成所有这些步骤后,她将解压缩真正的核心并继续进行。 安装以带有
_start字符的
arch / x86 / boot / header.S开始。
乍一看,这似乎有些奇怪,因为在他面前有几条指令。 但是很久以前,Linux内核拥有自己的引导程序。 现在,例如,如果您运行
qemu-system-x86_64 vmlinuz-3.18-generic
您将看到:

实际上,
header.S
文件以魔术数字
MZ (请参见上面的转储的屏幕截图),错误消息的文本和
PE标头开头:
#ifdef CONFIG_EFI_STUB # "MZ", MS-DOS header .byte 0x4d .byte 0x5a #endif ... ... ... pe_header: .ascii "PE" .word 0
需要加载带有
UEFI支持的操作系统。 我们将在以下各章中考虑其设备。
安装内核的实际入口点:
引导程序(grub2等)知道这一点(从
MZ
偏移
0x200
)并直接转到该点,尽管
header.S
从
.bstext
部分开始,错误消息的文本位于该部分:
// // arch/x86/boot/setup.ld // . = 0; // current position .bstext : { *(.bstext) } // put .bstext section to position 0 .bsdata : { *(.bsdata) }
内核安装入口点:
.globl _start _start: .byte 0xeb .byte start_of_setup-1f 1:
在这里,我们看到操作代码
jmp
(
0xeb
),该代码转到
start_of_setup-1f
点。 例如,在
Nf
表示法中,
2f
表示本地标签
2:
在我们的示例中,这是标签
1
,它在转换后立即出现,并且包含其余的设置标头。 在安装标题之后,我们立即看到
.entrytext
部分,该部分以
start_of_setup
标签开头。
这是实际执行的第一个代码(当然,不是前面的跳转指令)。 在部分内核安装收到来自加载程序的控制后,第一条
jmp
指令位于从实际内核模式开始的偏移量
0x200
处,即在前512个字节之后。 在Linux内核启动协议和grub2源代码中都可以看到:
segment = grub_linux_real_target >> 4; state.gs = state.fs = state.es = state.ds = state.ss = segment; state.cs = segment + 0x20;
在我们的例子中,内核在地址
0x10000
引导。 这意味着开始内核安装后,段寄存器将具有以下值:
gs = fs = es = ds = ss = 0x10000
cs = 0x10200
转到
start_of_setup
内核应执行以下操作:
让我们看看这是如何实现的。
段案例对齐
首先,内核检查
ds
和
es
段寄存器是否指向相同的地址。 然后使用
cld
清除方向标志:
movw %ds, %ax movw %ax, %es cld
如我先前所写,默认情况下,grub2加载内核安装代码为
0x10000
,
cs
加载为
0x10200
,因为执行不是从文件开头开始,而是从此处的过渡开始:
_start: .byte 0xeb .byte start_of_setup-1f
这是
4d 5a的
512
字节偏移量。 像所有其他段寄存器一样,还必须将
cs
从
0x10200
对齐到
0x10000
。 之后,安装堆栈:
pushw %ds pushw $6f lretw
该指令将
ds
值压入堆栈,然后是标签
6的地址和
lretw
指令,后者将标签
6
的地址加载到
命令计数器的寄存器中,并向
cs
加载值
ds
。 之后,
ds
和
cs
将具有相同的值。
堆叠设定
几乎所有这些代码都是在实模式下准备C环境的过程的一部分。 下一步是检查
ss
寄存器值,如果
ss
值不正确,则创建正确的堆栈:
movw %ss, %dx cmpw %ax, %dx movw %sp, %dx je 2f
这可以触发三种不同的情况:
ss
有效值为0x1000
(与cs
以外的所有其他寄存器一样)ss
值无效,并且设置了CAN_USE_HEAP
标志(请参见下文)ss
值无效,并且未设置CAN_USE_HEAP
标志(请参见下文)
按顺序考虑所有方案:
ss
有效值( 0x1000
)。 在这种情况下,我们转到标签2:
2: andw $~3, %dx jnz 3f movw $0xfffc, %dx 3: movw %ax, %ss movzwl %dx, %esp sti
在这里,我们将
dx
寄存器(其中包含引导加载程序指示的
sp
值)的对齐方式设置为
4
个字节,并检查是否为零。 如果为零,则我们将值
0xfffc
dx
(最大段大小为64 KB之前的
4
字节对齐地址)放入。 如果不等于零,那么我们将继续使用引导加载程序指定的
sp
值(在本例中为
0xf7f4
)。 然后将
ax
值放入
ss
,这将保存正确的段地址
0x1000
并设置正确的
sp
。 现在我们有了正确的堆栈:

- 在第二种情况下,
ss != ds
。 首先,我们将值_end (安装代码末尾的地址)放在dx
并使用testb
指令检查报头字段loadflags
,以检查是否可以使用堆。 loadflags是一个位掩码标头,定义如下:
#define LOADED_HIGH (1<<0) #define QUIET_FLAG (1<<5) #define KEEP_SEGMENTS (1<<6) #define CAN_USE_HEAP (1<<7)
并按照启动协议中的指示:
: loadflags
.
7 (): CAN_USE_HEAP
1, ,
heap_end_ptr . ,
.
如果将
CAN_USE_HEAP
位置1,则在
dx
我们设置值
heap_end_ptr
(指向
_end
)并向其添加
STACK_SIZE
(最小堆栈大小为
1024
字节)。 之后,转到标签
2
(与前面的情况一样)并进行正确的堆叠。

- 如果未设置
CAN_USE_HEAP
使用从_end
到_end + STACK_SIZE
的最小堆栈:

BSS设置
在继续使用主要的C代码之前,还需要执行两个步骤:这是设置
BSS区域并验证“魔术”签名。 首先进行签名验证:
cmpl $0x5a5aaa55, setup_sig jne setup_bad
该指令只是将
setup_sig与幻数0x5a5aaa55进行比较。 如果它们不相等,则会报告致命错误。
如果幻数匹配,并且我们有一组正确的段寄存器和堆栈,那么剩下的就是在进行C代码之前配置BSS部分。
BSS部分用于存储静态分配的未初始化数据。 Linux仔细检查此存储区是否已重置:
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字节的+3)被移至
cx
。 清除
eax
寄存器(使用
xor
指令),计算bss分区的大小(
cx-di
),并将其放置在
cx
。 然后,将
cx
分为四部分(“字”的大小),并
stosl
使用
stosl
指令,将值
(零)存储在指向
di
的地址中,自动将
di
增加4,并重复此操作直到
达到零为止。 这段代码的最终结果是,从
__bss_start
到
_end
将零写入内存中的所有单词:

前往主要
就是这样:我们有一个堆栈和BSS,因此您可以转到
main()
C函数:
calll main
main()
函数位于
arch / x86 / boot / main.c中。 下一部分我们将讨论她。
结论
这是有关Linux内核设备的第一部分的结尾。 ,
,
. C, Linux, ,
memset
,
memcpy
,
earlyprintk
, .
参考文献