从引导加载程序到内核
如果您一直在阅读我之前的博客文章,那么您可以看到,一段时间以来,我已经开始参与低级编程。我已经写了一些关于x86_64
Linux 汇编编程的文章,同时,我也开始深入研究Linux内核源代码。
我非常有兴趣了解低级别的东西是如何工作的,程序在我的计算机上运行的方式,它们在内存中的位置,内核如何管理进程和内存,网络堆栈如何在低级别工作以及许多其他的东西。所以,我已经决定编写另一系列关于x86_64架构的Linux内核的帖子。
请注意,我不是一个专业的内核黑客,我不会在工作中为内核编写代码。这只是一个爱好。我只是喜欢低级别的东西,看看这些东西是如何工作的我很有意思。
所有帖子也可以通过github repo访问,如果您发现我的英语或帖子内容有问题,请随时发送拉取请求。
请注意,这不是官方文档,只是学习和分享知识。
所需知识
- 理解C代码
- 理解汇编代码(AT&T语法)
无论如何,如果你刚刚开始学习这些工具,我会尝试在这个和以下的帖子中解释一些部分。好吧,这是简单介绍的结束,现在我们可以开始深入研究Linux内核和低级内容。
我在3.18
Linux内核开始编写本书时,自那时起许多事情都可能发生了变化。如果有变化,我会相应更新帖子。
魔力按钮,接下来会发生什么?
虽然这是关于Linux内核的一系列帖子,但我们不会直接从内核代码开始 - 至少在本段中没有。只要按下笔记本电脑或台式电脑上的神奇电源按钮,它就会开始工作。主板向电源设备发送信号。收到信号后,电源为计算机提供适当的电量。一旦主板收到电源良好信号,它就会尝试启动CPU。CPU会重置其寄存器中的所有剩余数据,并为每个数据设置预定义值。
的80386 CPU和更新的CPU的计算机复位后定义在CPU寄存器以下预定义的数据:
IP 0xfff0
CS selector 0xf000
CS base 0xffff0000
处理器开始以实模式工作。让我们稍微备份并尝试在此模式下理解内存分段。所有x86兼容处理器都支持实模式,从8086 CPU一直到现代Intel 64位CPU。该8086
处理器具有一个20位的地址总线,这意味着它可以与一个工作0-0xFFFFF
或1 megabyte
地址空间。但它只有16-bit
寄存器,其最大地址为2^16 - 1
或0xffff
(64千字节)。
内存分段用于利用可用的所有地址空间。所有内存都分为小的固定大小的65536
字节段(64 KB)。由于我们无法64 KB
用16位寄存器寻址上述存储器,因此设计了另一种方法。
地址由两部分组成:段选择器,它具有基址和与该基址的偏移量。在实模式中,段选择器的关联基址是Segment Selector * 16
。因此,要在内存中获取物理地址,我们需要将段选择器部分乘以16
并向其添加偏移量:
PhysicalAddress = Segment Selector * 16 + Offset
例如,如果CS:IP
是0x2000:0x0010
,那么相应的物理地址将是:
>>> hex((0x2000 << 4) + 0x0010)
'0x20010'
但是,如果我们采用最大的段选择器和偏移量0xffff:0xffff
,那么结果地址将是:
>>> hex((0xffff << 4) + 0xffff)
'0x10ffef'
这是65520
超过第一兆字节的字节数。由于只有一兆字节是在实模式下访问,0x10ffef
成为0x00ffef
与A20线禁用。
好的,现在我们对这种模式下的实模式和内存寻址有一点了解。让我们回过头来重新讨论寄存器值。
该CS
寄存器由两个部分组成:在可见光段选择,和隐藏的基地址。虽然基址通常是通过将段选择器值乘以16形成的,但在硬件复位期间,CS寄存器中的段选择器被加载0xf000
并且基址被加载0xffff0000
; 处理器使用此特殊基址直到CS
更改为止。
通过将基址添加到EIP寄存器中的值来形成起始地址:
>>> 0xffff0000 + 0xfff0
'0xfffffff0'
我们得到0xfffffff0
,比4GB低16个字节。这一点称为复位向量。这是CPU期望在复位后找到第一条执行指令的内存位置。它包含一条指向BIOS入口点的jump(jmp
)指令。例如,如果我们查看coreboot源代码(src/cpu/x86/16bit/reset16.inc
),我们将看到:
.section ".reset", "ax", %progbits
.code16
.globl _start
_start:
.byte 0xe9
.int _start16bit - ( . + 2 )
...
在这里,我们可以看到jmp
指令操作码,它是0xe9
,以及它的目标地址_start16bit - ( . + 2)
。
我们还可以看到该reset
节是16
字节,并且编译为从0xfffffff0
address(src/cpu/x86/16bit/reset16.ld
)开始:
SECTIONS {
/* Trigger an error if I have an unuseable start address */
_bogus = ASSERT(_start16bit >= 0xffff0000, "_start16bit too low. Please report.");
_ROMTOP = 0xfffffff0;
. = _ROMTOP;
.reset . : {
*(.reset);
. = 15;
BYTE(0x00);
}
}
现在BIOS启动了; 在初始化和检查硬件之后,BIOS需要找到可引导的设备。引导顺序存储在BIOS配置中,控制BIOS尝试从哪些设备引导。尝试从硬盘驱动器启动时,BIOS会尝试查找引导扇区。在使用MBR分区布局划分的硬盘驱动器上,引导扇区存储在第446
一个扇区的第一个字节中,其中每个扇区都是512
字节。第一个扇区的最后两个字节是0x55
和0xaa
,它指示BIOS该设备是可引导的。
例如:
;
; Note: this example is written in Intel Assembly syntax
;
[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)。
你会看见:
简单的bootloader只打印`!`在这个例子中,我们可以看到代码将以16-bit
实模式执行,并将从0x7c00
内存开始。启动后,它调用0x10中断,只打印!
符号; 它填补了剩余510
的字节用零,并用两个魔法字节完成0xaa
和0x55
。
您可以使用该objdump
实用程序查看此二进制转储:
nasm -f bin boot.nasm
objdump -D -b binary -mi386 -Maddr16,data16,intel boot
真实世界的引导扇区有代码用于继续引导过程和分区表而不是一堆0和感叹号:)从这一点开始,BIOS将控制权移交给引导加载程序。
注意:如上所述,CPU处于实模式; 在实模式下,计算内存中的物理地址如下:
PhysicalAddress = Segment Selector * 16 + Offset
就像上面解释的那样。我们只有16位通用寄存器; 16位寄存器的最大值是0xffff
,所以如果我们取最大值,结果将是:
>>> hex((0xffff * 16) + 0xffff)
'0x10ffef'
在哪里0x10ffef
等于1MB + 64KB - 16b
。的8086处理器(这与实模式第一处理器),与此相反,具有20位地址线。由于2^20 = 1048576
是1MB,这意味着实际可用内存为1MB。
一般来说,实模式的内存映射如下:
0x00000000 - 0x000003FF - Real Mode Interrupt Vector Table
0x00000400 - 0x000004FF - BIOS Data Area
0x00000500 - 0x00007BFF - Unused
0x00007C00 - 0x00007DFF - Our Bootloader
0x00007E00 - 0x0009FFFF - Unused
0x000A0000 - 0x000BFFFF - Video RAM (VRAM) Memory
0x000B0000 - 0x000B7777 - Monochrome Video Memory
0x000B8000 - 0x000BFFFF - Color Video Memory
0x000C0000 - 0x000C7FFF - Video ROM BIOS
0x000C8000 - 0x000EFFFF - BIOS Shadow Area
0x000F0000 - 0x000FFFFF - System BIOS
在这篇文章的开头,我写道,CPU执行的第一条指令位于地址0xFFFFFFF0
,远大于0xFFFFF
(1MB)。CPU如何在实模式下访问该地址?答案在coreboot文档中:
0xFFFE_0000 - 0xFFFF_FFFF: 128 kilobyte ROM mapped into address space
在执行开始时,BIOS不在RAM中,而是在ROM中。
引导程序
有许多可以启动Linux的引导加载程序,例如GRUB 2和syslinux。Linux内核有一个Boot协议,它规定了引导加载程序实现Linux支持的要求。此示例将描述GRUB 2。
从以前开始,既然BIOS
已经选择了引导设备并将控制转移到引导扇区代码,则从boot.img开始执行。由于可用空间有限,此代码非常简单,并且包含一个指针,用于跳转到GRUB 2核心图像的位置。核心映像以diskboot.img开头,它通常存储在第一个分区之前未使用空间中第一个扇区之后。上面的代码将核心映像的其余部分加载到内存中,其中包含GRUB 2的内核和用于处理文件系统的驱动程序。加载核心映像的其余部分后,它将执行grub_main函数。
该grub_main
函数初始化控制台,获取模块的基地址,设置根设备,加载/解析grub配置文件,加载模块等。在执行结束时,该grub_main
函数将grub移动到正常模式。该grub_normal_execute
功能(来自grub-core/normal/main.c
源代码文件)完成最后的准备工作并显示一个菜单来选择操作系统。当我们选择其中一个grub菜单项时,该grub_menu_execute_entry
函数运行,执行grub boot
命令并启动所选的操作系统。
正如我们可以在内核引导协议中读到的那样,引导加载程序必须读取并填充内核设置头的某些字段,这些字段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
引导加载程序必须使用从命令行接收或在引导期间计算的值来填充此标头和其余标头(仅write
在Linux引导协议中标记为类型,例如在此示例中)。(我们现在不会对内核设置头的所有字段进行完整的描述和解释,但是当我们讨论内核如何使用它们时我们将这样做;您可以在引导协议中找到所有字段的描述。)
正如我们在内核启动协议中看到的那样,在加载内核后,内存将按如下方式映射:
| 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 + sizeof(KernelBootSector) + 1
X
加载的内核引导扇区的地址在哪里。就我而言,X
是0x10000
,我们可以在内存转储看到:
引导加载程序现在已将Linux内核加载到内存中,填充了头字段,然后跳转到相应的内存地址。我们现在可以直接转到内核设置代码。
内核设置阶段的开始
最后,我们在内核中!从技术上讲,内核还没有运行; 首先,内核设置部分必须配置诸如解压缩器和一些与内存管理相关的东西之类的东西,仅举几例。完成所有这些操作后,内核设置部分将解压缩实际内核并跳转到它。设置部分的执行从_start符号处的arch / x86 / boot / header.S开始。
乍一看可能看起来有点奇怪,因为之前有几条指令。很久以前,Linux内核曾经拥有自己的bootloader。但是,现在,如果你跑,例如,
qemu-system-x86_64 vmlinuz-3.18-generic
然后你会看到:
在qemu尝试vmlinuz实际上,文件header.S
以幻数MZ(参见上图)开头,显示错误消息,然后是PE标头:
#ifdef CONFIG_EFI_STUB
# "MZ", MS-DOS header
.byte 0x4d
.byte 0x5a
#endif
...
...
...
pe_header:
.ascii "PE"
.word 0
它需要这个来加载具有UEFI支持的操作系统。我们现在不会研究它的内部工作,并将在接下来的章节中介绍它。
实际的内核设置入口点是:
// header.S line 292
.globl _start
_start:
引导程序(grub2和其他)知道这一点(在偏移量0x200
处MZ
)并直接跳转到它,尽管事实是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:
//
// rest of the header
//
在这里,我们可以看到跳转到该点的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 = 0x1000
cs = 0x1020
跳转到之后start_of_setup
,内核需要执行以下操作:
- 确保所有段寄存器值相等
- 如果需要,设置正确的堆栈
- 设置bss
- 跳转到arch / x86 / boot / main.c中的C代码
我们来看看实现。
对齐段寄存器
首先,内核确保ds
和es
段寄存器指向同一地址。接下来,它使用cld
指令清除方向标志:
movw %ds, %ax
movw %ax, %es
cld
正如我之前写的,grub2
加载内核初始化代码地址0x10000
默认情况下,并cs
在0x1020
因为执行不从文件的开头开始,而是从这里跳:
_start:
.byte 0xeb
.byte start_of_setup-1f
它是512
从4d 5a偏移的字节。我们还需要对齐cs
从0x1020
到0x1000
,以及所有其他段寄存器。之后,我们设置堆栈:
pushw %ds
pushw $6f
lretw
它将值推ds
送到堆栈,然后是6标签的地址并执行lretw
指令。当lretw
调用指令时,它将标签的地址加载6
到指令指针寄存器中并加载cs
值为ds
。之后,ds
和cs
将具有相同的价值观。
堆栈设置
几乎所有的设置代码都是为实模式下的C语言环境做准备。接下来的步骤是检查ss
寄存器值,并作出正确的堆栈,如果ss
是错误的:
movw %ss, %dx
cmpw %ax, %dx
movw %sp, %dx
je 2f
这可能会导致3种不同的情况:
-
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
(最大段大小为64 KB之前的4字节对齐地址)放入dx
。如果它不为零,我们继续使用sp
引导加载程序给定的值(在我的情况下为0xf7f4)。在此之后,我们将值的值ax
存入ss
,它存储正确的段地址0x1000
并设置正确sp
。我们现在有一个正确的堆栈:
- 在第二种情况中,(
ss
!=ds
)。首先,我们将_end的值(设置代码结束的地址)放入dx
并loadflags
使用testb
指令检查头字段,以查看是否可以使用堆。loadflags是一个位掩码头,定义如下:
#define LOADED_HIGH (1<<0)
#define QUIET_FLAG (1<<5)
#define KEEP_SEGMENTS (1<<6)
#define CAN_USE_HEAP (1<<7)
并且,正如我们可以在引导协议中读到的:
Field name: loadflags
This field is a bitmask.
Bit 7 (write): CAN_USE_HEAP
Set this bit to 1 to indicate that the value entered in the
heap_end_ptr is valid. If this field is clear, some setup code
functionality will be disabled.
如果该CAN_USE_HEAP
位置位,我们将其heap_end_ptr
放入dx
(指向_end
)并向其添加STACK_SIZE
(最小堆栈大小,1024
字节)。在此之后,如果dx
没有携带(它将不被携带dx = _end + 1024
),跳转到标签2
(如前面的情况)并进行正确的堆栈。
[图片上传失败...(image-25000d-1555591232488)]
- 如果
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
地址(+3 - 对齐到4个字节)cx
。的eax
寄存器清零(使用xor
指令),和BSS部分的大小(cx
- di
)被计算并投入cx
。然后,cx
除以4('字'的大小),并stosl
重复使用指令,将eax
(零)的值存储到指向的地址di
,自动增加di
4,重复直到cx
达到零。这段代码的实际效果是零,通过在内存中的所有单词写入__bss_start
到_end
:
跳到主要
这就是全部 - 我们有堆栈和BSS,所以我们可以跳转到main()
C函数:
calll main
该main()
函数位于arch / x86 / boot / main.c中。您可以在下一部分中了解它的作用。
结论
这是关于Linux内核内容的第一部分的结尾。如果您有任何问题或建议,可以在Twitter上拨打我的电子邮箱,然后给我发电子邮件,或者只是创建一个问题。在接下来的部分,我们首先看到的C代码,在Linux内核设置执行,内存例程,如实施memset
,memcpy
,earlyprintk
,早落实控制台和初始化,以及更多。
- image
网友评论