Linux内核启动。 第一部分

从引导程序到内核

如果您阅读了以前的文章 ,您将了解我对底层编程的新兴趣。 我写了几篇关于x86_64 Linux的汇编程序编程的文章,同时开始深入研究Linux内核的源代码。

我非常想了解低级事物的工作原理:程序如何在计算机上运行,​​它们如何位于内存中,内核如何管理进程和内存,网络堆栈在低级如何工作等等。 因此,我决定写另一系列有关x86_64体系结构 Linux内核的文章。

请注意,我不是专业的内核开发人员,也不在工作时编写内核代码。 这只是一个爱好。 我只喜欢低级的东西,深入研究它们很有趣。 因此,如果您发现任何混乱或疑问/评论出现,请通过Twitter通过邮件与我联系,或者只是创建票证 。 我将不胜感激。

所有文章都发布在GitHub存储库中 ,如果我的英语或文章内容有问题,请立即发送拉取请求。

请注意,这不是官方文档,而只是培训和知识共享。

必修知识

  • 了解C代码
  • 了解汇编代码(AT&T语法)

无论如何,如果您刚刚开始学习此类工具,我将尝试在本文章及其后续文章中进行解释。 好的,随着介绍的完成,是时候深入探讨Linux内核和底层知识了。

我从Linux 3.18内核时代开始写这本书,此后发生了很多变化。 如果有更改,我将相应地更新文章。

魔术电源按钮,下一步是什么?


尽管这些是有关Linux内核的文章,但至少在本节中,我们还没有涉及到它。 按下膝上型计算机或台式计算机上的魔术电源按钮后,它将立即开始工作。 主板将信号发送到电源 。 收到信号后,它将为计算机提供必要的电量。 主板收到“电源正常”信号后 ,便立即尝试启动CPU。 他将所有剩余的数据转储到寄存器中,并为每个寄存器设置预定义的值。

重新启动后,处理器80386和更高版本的CPU寄存器中应具有以下值:

  IP 0xfff0
 CS选择器0xf000
 CS基数0xffff0000 

处理器开始在实模式下工作。 让我们回到过去,尝试了解此模式下的内存分段 。 所有与x86兼容的处理器都支持实模式:从8086到现代的64位Intel处理器。 8086处理器使用20位地址总线,也就是说,它可以使用0-0xFFFFF1 的地址空间工作。 但是它只有16位寄存器,最大地址为2^16-10xffff (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 ) ... 

在这里,我们看到操作码( opcodejmp ,即0xe9 ,目的地址_start16bit - ( . + 2)

我们还看到reset部分是16个字节,它编译后从地址0xfffff0src/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尝试查找引导扇区。 在MBR分区磁盘上,引导扇区存储在第一个扇区的前446个字节中,其中每个扇区为512个字节。 第一个扇区的最后两个字节是0x550xaa 。 它们显示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字节,并以两个魔术字节0xaa0x55 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' 

其中0x10ffef1 + 64 - 168086处理器 (第一个具有实模式的处理器)具有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 2syslinux 。 内核具有一个引导协议,该协议定义了用于实现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是内核引导扇区的地址。 在我们的例子中, X0x10000 ,如内存转储所示:



引导程序将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支持的操作系统。 我们将在以下各章中考虑其设备。

安装内核的实际入口点:

 // header.S line 292 .globl _start _start: 

引导程序(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: // // rest of the header // 

在这里,我们看到操作代码jmp0xeb ),该代码转到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内核应执行以下操作:


让我们看看这是如何实现的。

段案例对齐


首先,内核检查dses段寄存器是否指向相同的地址。 然后使用cld清除方向标志:

  movw %ds, %ax movw %ax, %es cld 

如我先前所写,默认情况下,grub2加载内核安装代码为0x10000cs加载为0x10200 ,因为执行不是从文件开头开始,而是从此处的过渡开始:

 _start: .byte 0xeb .byte start_of_setup-1f 

这是4d 5a512字节偏移量。 像所有其他段寄存器一样,还必须将cs0x10200对齐到0x10000 。 之后,安装堆栈:

  pushw %ds pushw $6f lretw 

该指令将ds值压入堆栈,然后是标签6的地址和lretw指令,后者将标签6的地址加载到命令计数器的寄存器中,并向cs加载值ds 。 之后, dscs将具有相同的值。

堆叠设定


几乎所有这些代码都是在实模式下准备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 , .

参考文献


Source: https://habr.com/ru/post/zh-CN428664/


All Articles