内核启动过程

作者: Android高级开发 | 来源:发表于2019-04-18 20:47 被阅读8次

从引导加载程序到内核

如果您一直在阅读我之前的博客文章,那么您可以看到,一段时间以来,我已经开始参与低级编程。我已经写了一些关于x86_64Linux 汇编编程的文章,同时,我也开始深入研究Linux内核源代码。

我非常有兴趣了解低级别的东西是如何工作的,程序在我的计算机上运行的方式,它们在内存中的位置,内核如何管理进程和内存,网络堆栈如何在低级别工作以及许多其他的东西。所以,我已经决定编写另一系列关于x86_64架构的Linux内核的帖子。

请注意,我不是一个专业的内核黑客,我不会在工作中为内核编写代码。这只是一个爱好。我只是喜欢低级别的东西,看看这些东西是如何工作的我很有意思。

所有帖子也可以通过github repo访问,如果您发现我的英语或帖子内容有问题,请随时发送拉取请求。

请注意,这不是官方文档,只是学习和分享知识。

所需知识

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

无论如何,如果你刚刚开始学习这些工具,我会尝试在这个和以下的帖子中解释一些部分。好吧,这是简单介绍的结束,现在我们可以开始深入研究Linux内核和低级内容。

我在3.18Linux内核开始编写本书时,自那时起许多事情都可能发生了变化。如果有变化,我会相应更新帖子。

魔力按钮,接下来会发生什么?

虽然这是关于Linux内核的一系列帖子,但我们不会直接从内核代码开始 - 至少在本段中没有。只要按下笔记本电脑或台式电脑上的神奇电源按钮,它就会开始工作。主板向电源设备发送信号。收到信号后,电源为计算机提供适当的电量。一旦主板收到电源良好信号,它就会尝试启动CPU。CPU会重置其寄存器中的所有剩余数据,并为每个数据设置预定义值。

80386 CPU和更新的CPU的计算机复位后定义在CPU寄存器以下预定义的数据:

IP          0xfff0
CS selector 0xf000
CS base     0xffff0000

处理器开始以实模式工作。让我们稍微备份并尝试在此模式下理解内存分段。所有x86兼容处理器都支持实模式,从8086 CPU一直到现代Intel 64位CPU。该8086处理器具有一个20位的地址总线,这意味着它可以与一个工作0-0xFFFFF1 megabyte地址空间。但它只有16-bit寄存器,其最大地址为2^16 - 10xffff(64千字节)。

内存分段用于利用可用的所有地址空间。所有内存都分为小的固定大小的65536字节段(64 KB)。由于我们无法64 KB用16位寄存器寻址上述存储器,因此设计了另一种方法。

地址由两部分组成:段选择器,它具有基址和与该基址的偏移量。在实模式中,段选择器的关联基址是Segment Selector * 16。因此,要在内存中获取物理地址,我们需要将段选择器部分乘以16并向其添加偏移量:

PhysicalAddress = Segment Selector * 16 + Offset

例如,如果CS:IP0x2000:0x0010,那么相应的物理地址将是:

>>> hex((0x2000 << 4) + 0x0010)
'0x20010'

但是,如果我们采用最大的段选择器和偏移量0xffff:0xffff,那么结果地址将是:

>>> hex((0xffff << 4) + 0xffff)
'0x10ffef'

这是65520超过第一兆字节的字节数。由于只有一兆字节是在实模式下访问,0x10ffef成为0x00ffefA20线禁用。

好的,现在我们对这种模式下的实模式和内存寻址有一点了解。让我们回过头来重新讨论寄存器值。

CS寄存器由两个部分组成:在可见光段选择,和隐藏的基地址。虽然基址通常是通过将段选择器值乘以16形成的,但在硬件复位期间,CS寄存器中的段选择器被加载0xf000并且基址被加载0xffff0000; 处理器使用此特殊基址直到CS更改为止。

通过将基址添加到EIP寄存器中的值来形成起始地址:

>>> 0xffff0000 + 0xfff0
'0xfffffff0'

我们得到0xfffffff0,比4GB低16个字节。这一点称为复位向量。这是CPU期望在复位后找到第一条执行指令的内存位置。它包含一条指向BIOS入口点的jumpjmp)指令。例如,如果我们查看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字节,并且编译为从0xfffffff0address(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字节。第一个扇区的最后两个字节是0x550xaa,它指示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的字节用零,并用两个魔法字节完成0xaa0x55

您可以使用该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 2syslinux。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加载的内核引导扇区的地址在哪里。就我而言,X0x10000,我们可以在内存转储看到:

内核第一个地址

引导加载程序现在已将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和其他)知道这一点(在偏移量0x200MZ)并直接跳转到它,尽管事实是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指令操作码(0xebstart_of_setup-1f。例如,在Nf表示法中,2f指的是本地标签2:; 在我们的例子中,它是1跳转后立即出现的标签,它包含设置标题的其余部分。在设置标题之后,我们看到.entrytextstart_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,内核需要执行以下操作:

我们来看看实现。

对齐段寄存器

首先,内核确保dses段寄存器指向同一地址。接下来,它使用cld指令清除方向标志:

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

正如我之前写的,grub2加载内核初始化代码地址0x10000默认情况下,并cs0x1020因为执行不从文件的开头开始,而是从这里跳:

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

它是5124d 5a偏移的字节。我们还需要对齐cs0x10200x1000,以及所有其他段寄存器。之后,我们设置堆栈:

    pushw   %ds
    pushw   $6f
    lretw

它将值推ds送到堆栈,然后是6标签的地址并执行lretw指令。当lretw调用指令时,它将标签的地址加载6指令指针寄存器中并加载cs值为ds。之后,dscs将具有相同的价值观。

堆栈设置

几乎所有的设置代码都是为实模式下的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的值(设置代码结束的地址)放入dxloadflags使用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,自动增加di4,重复直到cx达到零。这段代码的实际效果是零,通过在内存中的所有单词写入__bss_start_end

BSS

跳到主要

这就是全部 - 我们有堆栈和BSS,所以我们可以跳转到main()C函数:

    calll main

main()函数位于arch / x86 / boot / main.c中。您可以在下一部分中了解它的作用。

结论

这是关于Linux内核内容的第一部分的结尾。如果您有任何问题或建议,可以在Twitter上拨打我的电子邮箱然后给发电子邮件,或者只是创建一个问题。在接下来的部分,我们首先看到的C代码,在Linux内核设置执行,内存例程,如实施memsetmemcpyearlyprintk,早落实控制台和初始化,以及更多。

  • image
image

+qq群457848807:。获取以上高清技术思维图,以及相关技术的免费视频学习资料

相关文章

  • Linux 内核初始化

    《Linux 内核分析》 MOOC 课程实验 分析 Linux 内核的启动过程 1.计算机的启动过程 我们日常使用...

  • 内核启动过程

    从引导加载程序到内核 如果您一直在阅读我之前的博客文章,那么您可以看到,一段时间以来,我已经开始参与低级编程。我已...

  • Android开机启动过程

    Android系统启动过程 BootLoader与Linux内核启动 init进程 zygote进程 system...

  • Camera源码解读-1 CameraService启动

    Android系统启动过程 Android系统启动包括两大步骤:1、Linux内核启动,2、Android架构启动...

  • FrameWork启动过程

    1.linux的启动过程最后,内核将读取init.rc文件,并启动该文件中的各种服务程序,android系统内核也...

  • 跟踪分析Linux内核的启动过程

    网易云课堂《Linux内核分析》作业 实验目的: 跟踪分析一个简单的Linux内核启动过程,理解操作系统是怎么启动...

  • Android Linux杂记 -2-启动过程

    Linux的开机启动过程可以分为4个步骤:启动驱动器、启动内核、初始化过程、启动用户登录界面。每个步骤中所执行的操...

  • Android 进阶解密摘要

    参照《Android 进阶解密》做的摘要。 Android 系统启动过程 init 进程启动 Linux 内核加载...

  • Linux启动过程

    Linux启动过程: 按下电源àbios自检à系统引导,即加载内核à启动执行内核à初始化系统à登陆界面; Bios...

  • linux基础笔记

    Linux系统启动过程 Linux系统的启动过程可以分为5个阶段: 内核的引导。 运行 init。 系统初始化。 ...

网友评论

    本文标题:内核启动过程

    本文链接:https://www.haomeiwen.com/subject/tkdxgqtx.html