本文发自 http://www.binss.me/blog/boot-process-of-linux-grub/,转载请注明出处。

Linux启动流程 中,我较为概要地分析了计算机从启动到 Linux kernel 启动,执行第一个进程的流程。

当时是 17 年 2 月份,正是 GiantVM 项目的关键时期,因此在大致了解流程后,就重新投入到项目的开发中。没有进一步分析其中的细节。

直到最近折腾黑群晖,遵循着 nasyun 社区的老骥伏枥大佬的教程完成对群晖系统的硬盘引导后,始终对其引导方式不甚了解。因此翻到当初做实验剩下的一些材料和笔记,进行学习和整理,形成本文。

第一条指令

一般来说,在接通电源时,主板就已经部分通电了。其处于 Standby 模式,检测是否需要开机。当用户按下电源开关(开机键)后,电源开始进入正常工作模式,它会给主板上电,按照时序供应 5V 和 12V 电源,然后输出 Power_OK 信号,通知主板可以正式工作。最后,主板会向 CPU 的 reset 引脚发送信号,于是CPU将清除所有寄存器中的数据并加载为预设值,根据 Intel SDM vol3 Table 9-1,寄存器值为:

EIP          0000FFF0H
CS Selector  F000H
CS base      FFFF0000H
CS Limit     FFFFH

通过 gdb 对 QEMU-KVM 中的虚拟机进行调试,刚启动时寄存器状况如下:

(gdb) i r
eax            0x0  0
ecx            0x0  0
edx            0x406f1  263921
ebx            0x0  0
esp            0x0  0x0 <irq_stack_union>
ebp            0x0  0x0 <irq_stack_union>
esi            0x0  0
edi            0x0  0
eip            0xfff0 0xfff0
eflags         0x2  [ ]
cs             0xf000 61440
ss             0x0  0
ds             0x0  0
es             0x0  0
fs             0x0  0
gs             0x0  0

可以发现此时 CS 为 0xf000 , IP 为 0xfff0 。根据 real mode 下地址的计算方法,此时 CS base 应该等于 0xf000 << 4 = 0xf0000 。但为何 Intel SDM 中说它是 0xffff0000 呢?根据 Intel SDM vol3 9.1.4 First Instruction Executed :

The address FFFFFFF0H is beyond the 1-MByte addressable range of the processor while in real-address mode. The processor is initialized to this starting address as follows. The CS register has two parts: the visible segment selector part and the hidden base address part. In real-address mode, the base address is normally formed by shifting the 16-bit segment selector value 4 bits to the left to produce a 20-bit base address. However, during a hardware reset, the segment selector in the CS register is loaded with F000H and the base address is loaded with FFFF0000H. The starting address is thus formed by adding the base address to the value in the EIP register (that is, FFFF0000 + FFF0H = FFFFFFF0H).

即段寄存器有一个隐藏的base address部分(不属于寄存器,而是位于一片非一致性cache中),在 reset 的时候被设置成 0xffff0000 。因此我们需要使用的是这个隐藏的 base address 而不是我们计算得到的 0f0000。

因此实际上执行的第一条指令的地址为 0xffff0000 + 0xfff0 = 0xfffffff0

查看这个地址,一般都是跳转:

(gdb) set architecture i8086
(gdb) x/i 0xfffffff0
   0xfffffff0:  ljmp   $0xf000,$0xe05b

但有趣的是查看 0xffff0(0xf0000 + 0xffff) 也是同样的内容:

(gdb) x/i 0xffff0
   0xffff0: ljmp   $0xf000,$0xe05b

根据一些资料,这是一项称为 Shadow RAM 的技术在作怪,它将BIOS ROM中的内容映射到RAM中,以提升访问速度。因此 0xf0000 - 0xfffff 和 0xffff0000 - 0xffffffff 映射的是同一片ROM,自然存在 0xfffffff0 的指令就和存在 0xffff0 的指令相同了。

第二条指令

ljmp $0xf000,$0xe05b 是一条长跳转指令,目标地址为 0xf000:0xe05b 。

根据 Intel SDM:

The first time the CS register is loaded with a new value after a hardware reset, the processor will follow the normal rule for address translation in real-address mode (that is, [CS base address = CS segment selector * 16]). To insure that the base address in the CS register remains unchanged until the EPROM based software-initialization code is completed, the code must not contain a far jump or far call or allow an interrupt to occur (which would cause the CS selector value to be changed).

于是此时 cs 被加载为 0xf000 ,此后 base address 将遵循 real mode 下的基地址计算方法,等于 0xf000 << 4 = 0xf0000 。

根据 IBM PC compatible PC 规定的内存布局:

+------------------+  <- 0xFFFFFFFF (4GB)
|      32-bit      |
|  memory mapped   |
|     devices      |
|                  |
/\/\/\/\/\/\/\/\/\/\

/\/\/\/\/\/\/\/\/\/\
|                  |
|      Unused      |
|                  |
+------------------+  <- depends on amount of RAM
|                  |
|                  |
| Extended Memory  |
|                  |
|                  |
+------------------+  <- 0x00100000 (1MB)
|     BIOS ROM     |
+------------------+  <- 0x000F0000 (960KB)
|  16-bit devices, |
|  expansion ROMs  |
+------------------+  <- 0x000C0000 (768KB)
|   VGA Display    |
+------------------+  <- 0x000A0000 (640KB)
|                  |
|    Low Memory    |
|                  |
+------------------+  <- 0x00000000

可以发现起始阶段的内存地址空间主要在于前 1MB,其中 Low Memory (0-640KB) 为起始阶段能够使用的内存。0x000a0000 - 0x000fffff 被作为硬件设备的映射区域,如视频显示buffer、设备固件等。以 0x100000(1MB) 为界是因为在 8086 CPU 只支持 1MB 的寻址,而在随后的 80286 中突破了该限制,但为了保持兼容性,前 1MB 依然保持该内存布局。

其中用于映射BIOS ROM的区间为 0x000f0000 - 0x000fffff 。BIOS会存放在该区域底部,一直持续到 0xfffff 。由于不同的BIOS具有不同的长度,需要准确定位到 BIOS 的起始地址是很困难的。因此规定在 0xffff0 处放一条跳转指令,跳转到BIOS的起始处。

在这里 0xf000:0xe05b 就是 BIOS 的起始地址。下一步:

(gdb) si
[f000:e05b]    0xfe05b: cmpl   $0x0,%cs:0x70b8
0x0000e05b in nmi_print_seq ()

(gdb) i r cs eip
cs             0xf000   61440
eip            0xe05b   0xe05b <nmi_print_seq+219>

可以看到来到了 0xf000:0xe05b ,gdb显示的 0x0000e05b in nmi_print_seq () 是错的,它没有考虑到 cs selector。

接下来查看 0xf000 << 4 + 0xe05b = 0xfe05b 附近的代码:

(gdb) x/160i 0xfe05b
   0xfe05b: cmpl   $0x0,%cs:0x70b8
   0xfe062: jne    0xfd408
   0xfe066: xor    %dx,%dx
   0xfe068: mov    %dx,%ss
   0xfe06a: mov    $0x7000,%esp
   0xfe070: mov    $0xf2d7e,%edx
   0xfe076: jmp    0xfd28b
   ...

跳转到 0xfd28b ,其中

   0xfd28b: cli
   0xfd28c: cld

前者保证此后的流程不会被中断,后者保证了内存将从低地址到高地址增长。后续还有一些和硬件设备通信的指令:

   0xfd290: mov    $0x8f,%eax
   0xfd296: out    %al,$0x70
   0xfd298: in     $0x71,%al
   0xfd29a: in     $0x92,%al
   0xfd29c: or     $0x2,%al
   0xfd29e: out    %al,$0x92

这里将 al 中存放的值 0x8f 写入到 0x70 端口,然后从 0x71 端口读入值到 al 中,再从 0x92 端口读值到 al ,将其 bit 1 设置为 1 后重新写回 0x92。

根据 http://bochs.sourceforge.net/techspec/PORTS.LST (找不到QEMU的,用BOCHS的应该差不多),0x70为CMOS RAM index register port,0x71为 CMOS RAM data port。由于 0x8f = 1000 1111,因此这里的行为是在禁用 NMI 的同时设置 index 为 1111。根据规范要求,在对 0x70 进行写后必须跟着对 0x71 的操作,因此这里只能跟了一个dummy read。我们不关心它的值,因此读到的值被下一条指令的结果覆盖。

接下来将 0x92(PS/2 system control port A) 设置 bit 1为1,表示通过键盘控制器开启 Fast A20 Gate,为之后进入保护模式做准备。

接着是设置 GDT(Global Descriptor Table)和IDT(Interrupt Descriptor Table):

   0xfd2a3: lidtw  %cs:0x70a8
   0xfd2a9: lgdtw  %cs:0x7064

这里将 0xf000:0x70a8 中的内容作为 IDT 的 base 和 limit 设置到 IDTR 中,并将 0xf000:0x7064 作为 GDT 的 base 和 limit 设置到 GDTR 中。

根据定义,IDTR 和 GDTR 都为 48bit 的寄存器,其中低 16 bit 为 limit(size),高 32bit 为 base(起始地址)。打印出来如下:

(gdb) x/6b 0xf7064
0xf7064:    0x37    0x00    0x70    0x70    0x0f    0x00
(gdb) x/6b 0xf70a8
0xf70a8:    0x00    0x00    0xae    0x70    0x0f    0x00

然后设置 cr0 的 bit 0 为 1,正式让系统进入保护模式。

   0xfd2af: mov    %cr0,%ecx
   0xfd2b2: and    $0x1fffffff,%ecx
   0xfd2b9: or     $0x1,%ecx
   0xfd2bd: mov    %ecx,%cr0
   0xfd2c0: ljmpl  $0x8,$0xfd2c8

最后通过长跳转改变 cs,开始执行保护模式代码。切换模式后,首先初始化各段寄存器为 0x10 :

   0xfd2c8: mov    $0x10,%ecx
   0xfd2cd: mov    %ecx,%ds
   0xfd2cf: mov    %ecx,%es
   0xfd2d1: mov    %ecx,%ss
   0xfd2d3: mov    %ecx,%fs
   0xfd2d5: mov    %ecx,%gs
   0xfd2d7: jmp    *%edx

跳转到 %edx ,即 0xf2d7e ......

经过一系列调用后(其实是没看懂接下来的汇编),BIOS完成了 激活VGA、检查插入到内存数、初始化PCI总线和其他设备等初始化操作,比如在 VGA 初始化完成后会在屏幕上输出 "Starting SeaBIOS"。

在 BIOS legacy 引导中,BIOS 会搜索那些 sector 0 (MBR) 的 byte 510-511 为 55 aa (Magic Signature) 的存储设备,该标识表示该设备是可引导(bootable)设备。随后 BIOS 将该设备的 MBR 读入到内存。由于历史遗留问题(IBM PC 5150 ROM BIOS 率先把第一个扇区加载到 0x7c00,之后为了兼容都这样做了,具体可参考 https://www.glamenv-septzen.net/en/view/6 ),这个地址为 0x00:0x7c00 。扇区长度为 512 byte,因此这段地址为 0x0000:0x7c00 - 0x0000:0x7dff ,布局如下:

                +-------------------------+   <- 0x7c00
   446 bytes    | Bootloader             |
                |                         |
                |                         |
                |                         |
                +-------------------------+
   64 bytes     | Partition Table         |
                |                         |
                +-------------------------+
   2 bytes      | Magic Signature (55 aa) |
                +-------------------------+

随后 BIOS 将跳转到该地址,CPU 开始执行上面的 Bootloader 代码。我们下个断点:

(gdb) hb *0x7c00
Hardware assisted breakpoint 1 at 0x7c00
(gdb) c
Continuing.
The target architecture is assumed to be i8086
[   0:7c00] => 0x7c00 <exception_stacks+15360>: jmp    0x7c65 <exception_stacks+15461>

Breakpoint 1, 0x00007c00 in exception_stacks ()

可以发现 CPU 在 BIOS 中进入了保护模式后,在执行 bootloader 前又回到了实模式,个人猜测是为了在 BIOS 代码中能利用保护模式的特性,然后为了兼容又跳回实模式。

Bootloader

在 Linux 中最常见的 bootloader 莫过于 GRUB2 了,它是 GRUB 的第二个版本,除了引导系统启动外,还具有很多高级特性。由于 MBR Bootloader 区域只有 446 bytes,不可能装下 GRUB 的所有代码。因此将引导流程拆分成多个阶段,并将第一阶段要执行的 boot.img 放到 MBR 中。

boot.img

为了能够进行调试,我们需要编译带有符号信息的 GRUB ,然后进行加载。先查看当前的 GRUB 版本:

$ grub-install -V
grub-install (GRUB) 2.02~beta2-36ubuntu3.9

然后搜索一下版本:

$ apt-cache madison grub2
     grub2 | 2.02~beta2-36ubuntu11.3 | http://us.archive.ubuntu.com/ubuntu yakkety-updates/universe amd64 Packages
     grub2 | 2.02~beta2-36ubuntu11 | http://us.archive.ubuntu.com/ubuntu yakkety/universe amd64 Packages
     grub2 | 2.02~beta2-36ubuntu3 | http://us.archive.ubuntu.com/ubuntu xenial/main Sources
     grub2 | 2.02~beta2-36ubuntu3.11 | http://us.archive.ubuntu.com/ubuntu xenial-updates/main Sources

发现没有一模一样的版本,但和 upstream 版本号相同,只是 ubuntu 版本略有不同,应该无伤大雅,直接下载即可:

$ apt-get source grub2

如果失败,请取消掉 /etc/apt/sources.list 中对 deb-src 行对注释,然后 sudo apt-get update。下载完后会发现当前目录下多了 grub2-2.02~beta2 文件夹,请确保安装了依赖后,进行编译:

$ sudo apt-get install git bison libopts25 libselinux1-dev autogen m4 autoconf help2man libopts25-dev flex libfont-freetype-perl automake autotools-dev libfreetype6-dev texinfo ia32_libs build_essential
$ cd grub2-2.02~beta2
$ debuild -us -uc -b

其中 us 和 uc 表示不签名 , b 表示只构建 binary。

编译完成后,找到 boot_image-boot.o 文件,它是 boot.img 的带符号信息和代码版本,将其拷贝出来:

$ cp ~/work/grub/grub2-2.02~beta2/obj/grub-pc/grub-core/boot/i386/pc/boot_image-boot.o ~/work/grub/grub_dump

因为代码段默认从 0x0 开始,而 BIOS 实际上会将 MBR 加载到 0x7c00,因此我们将其代码段重新链接到 0x7c00 :

$ ld -Ttext=0x7c00 -m elf_i386 boot_image-boot.o -o boot.image

然后就可以在gdb中加载代码目录和文件、方便地进行单步调试了:

...
(gdb) directory /home/binss/work/grub/grub2-2.02~beta2/obj/grub-pc/grub-core/
(gdb) file /home/binss/work/grub/grub_dump/boot.image
(gdb) hb *0x7c00
(gdb) c
Continuing.
[   0:7c00] => 0x7c00 <start>:  jmp    0x7c65 <L_after_BPB>

Thread 1 hit Breakpoint 1, start () at ../../../grub-core/boot/i386/pc/boot.S:134
134     jmp LOCAL(after_BPB)
(gdb) ni
[   0:7c65] => 0x7c65 <L_after_BPB>:    cli
204             cli             /* we're not safe here! */

具体的流程符合 grub-core/boot/i386/pc/boot.S 中的定义,直接看代码:

    /* 0x7c00 在此,直接跳转到后面的 LOCAL(after_BPB) 标签,因此后续地址用于保存数据 */
    jmp LOCAL(after_BPB)
    nop /* do I care about this ??? */

    /*
     * This space is for the BIOS parameter block!!!!  Don't change
     * the first jump, nor start the code anywhere but right after
     * this area.
     */

    /* 保存 BIOS 参数块 BPB ,共 86 byte(0x7c04-0x7c59)
     * 1 byte(0x7c04) 保存探测到的读取 mode
     * 16 byte(0x7c05-0x7c14) 对于LBA,保存DAP;对于CHS,保存CHS参数
     */
    . = _start + GRUB_BOOT_MACHINE_BPB_START
    . = _start + 4
...
    . = _start + GRUB_BOOT_MACHINE_BPB_END
    /*
     * End of BIOS parameter block.
     */

    /* 0x7c5a 保存 grub kernel 的地址,经过调试发现值为 0x8000 */
kernel_address:
    .word   GRUB_BOOT_MACHINE_KERNEL_ADDR

#ifndef HYBRID_BOOT
    /* 0x7c5c 保存 GRUB 内核起始扇区 LBA 地址,先低 4 byte,后高 4 byte */
    /* 可以发现 GRUB 内核的默认起始扇区为 1,跟在 MBR 后面 */
    . = _start + GRUB_BOOT_MACHINE_KERNEL_SECTOR
kernel_sector:
    .long   1
kernel_sector_high:
    .long   0
#endif

    /* 0x7c64 保存 GRUB 内核的驱动器号 */
    . = _start + GRUB_BOOT_MACHINE_BOOT_DRIVE
boot_drive:
    .byte 0xff  /* the disk to load kernel from */
            /* 0xff means use the boot drive */

/* 0x7c65 boot.S 主逻辑 */
LOCAL(after_BPB):
    /* 禁止中断,防止存在 dl 中的驱动器号因为收到中断后执行中断处理程序,导致被覆盖 */
    cli   /* we're not safe here! */

        /*
         * This is a workaround for buggy BIOSes which don't pass boot
         * drive correctly. If GRUB is installed into a HDD, check if
         * DL is masked correctly. If not, assume that the BIOS passed
         * a bogus value and set DL to 0x80, since this is the only
         * possible boot drive. If GRUB is installed into a floppy,
         * this does nothing (only jump).
         */
    . = _start + GRUB_BOOT_MACHINE_DRIVE_CHECK
    /* 修正驱动器号 */
boot_drive_check:
        jmp     3f  /* grub-setup may overwrite this jump */  /* 该行会被覆盖为nop,不会执行 */
        /* BIOS 会将当前引导的驱动器号存在 dl 中,0x80 为 1st HDD
         * 某些 BIOS 传递的驱动器号有误,因此这里和 0x80 作与
         * 如果驱动器号小于0x80,则结果为 0,需要强制设置为 0x80
         */
        testb   $0x80, %dl
        jz      2f
3:
    /* Ignore %dl different from 0-0x0f and 0x80-0x8f.  */
    /* 将 0x80-0x8f 的驱动器号统一设置为 0x80 */
    testb   $0x70, %dl
    jz      1f
2:
        movb    $0x80, %dl
1:
    /*
     * ljmp to the next instruction because some bogus BIOSes
     * jump to 07C0:0000 instead of 0000:7C00.
     */
    /* 某些有 bug 的 BIOS 会跳转到 07C0:0000 而不是 0000:7C00
     * 这里用长跳转保证了 segmemt 为 0 */
    ljmp  $0, $real_start

real_start:

    /* set up %ds and %ss as offset from 0 */
    /* 清空 ds 和 ss */
    xorw  %ax, %ax
    movw  %ax, %ds
    movw  %ax, %ss

    /* set up the REAL stack */
    /* 设置栈指针,经调试为 0x2000 */
    movw  $GRUB_BOOT_MACHINE_STACK_SEG, %sp

    sti   /* we're safe again */ /* 开启中断 */

    /*
     *  Check if we have a forced disk reference here
     */
    /* 将 boot_drive 地址(0x7c64)的值存入 al ,默认为 0xff */
    movb   boot_drive, %al
    /* 如果确实是 0xff,则使用 dl 中的驱动器号 */
    cmpb  $0xff, %al
    je  1f
    /* 否则使用 boot_drive 地址(0x7c64)的值作为驱动器号,设置到 dl 中 */
    movb  %al, %dl
1:
    /* save drive reference first thing! */
    /* 将设置好的驱动器号压栈保存起来,避免因为后续操作导致 dx 变更 */
    pushw %dx

    /* 如果非 slient,在屏幕上打印 notification_string ,即 "GRUB"
     * 此时已经恢复了中断,可以调用 BIOS 例程
     */
#if defined(QUIET_BOOT) && !defined(HYBRID_BOOT)
    /* is either shift key held down? */
    movw  $(GRUB_MEMORY_MACHINE_BIOS_DATA_AREA_ADDR + 0x17), %bx
    testb $3, (%bx)
    jz  2f
#endif

    /* print a notification message on the screen */
    MSG(notification_string)

MSG定义如下:

#define MSG(x)  movw $x, %si; call LOCAL(message)
LOCAL(probe_values):
    .byte 36, 18, 15, 9, 0

其实就是调用 BIOS 例程在屏幕上打印 message。

2:
    /* set %si to the disk address packet */
    /* 设置 si 为 BPB 的第二个byte (0x7c05) */
    movw  $disk_address_packet, %si

    /* check if LBA is supported */
    /* 调用 BIOS 例程探测是否支持 LBA
     * 要求 ah 为 0x41 ,bx 为 0x55aa, dl 为驱动器号(如0x80)
     */
    movb  $0x41, %ah
    movw  $0x55aa, %bx
    int $0x13

    /*
     *  %dl may have been clobbered by INT 13, AH=41H.
     *  This happens, for example, with AST BIOS 1.04.
     */
    /* 从栈中恢复 dl,避免被 BIOS 例程的结果所覆盖 */
    popw  %dx
    pushw %dx

    /* use CHS if fails */
    /* 如果 cf=1 ,表示不存在,退化到 chs_mode */
    jc  LOCAL(chs_mode)
    /* 如果 bx 不是 0xaa55,表示调用失败,退化到 chs_mode */
    cmpw  $0xaa55, %bx
    jne LOCAL(chs_mode)

    /* 如果 cx 的 bit 0 没置位(表示非 device access using the packet structure),退化到 chs_mode */
    andw  $1, %cx
    jz  LOCAL(chs_mode)

CHS(Cylinder-Head-Sector) 和 LBA(Logical Block Addressing) 是扇区的编址方式。前者直接使用 (c,h,s) 来表示一个扇区的位置。扇区号(s)从1开始,柱面号(c)和磁头(h)从 0 开始,因此CHS编址的起始地址为 (0, 0, 1),只能寻址约 8 GB (255, 1024, 64) = 255 * 1024 * 64 sector。后者的 (c, h, s) 表示的位置为 (c * 硬盘中的磁头数目 + h) * 每条磁道上可以划分的最大的扇区数 + (s - 1),其中扇区号从 0 开始。相比来说,LBA 编址能够表示更大的范围,是更为现代的编址方法。

因此在上文代码判断是否支持 LBA 编址,如果支持,采用 LBA mode 进行拷贝:

lba_mode:
    /* 准备 disk address packet(DAP, 磁盘地址数据包),用于从磁盘拷贝sector数据到内存中
     * DAP长16byte,格式为
     *       0 size
     *       1 unused,为0
     *       2-3 要读的sector数
     *       4-7 目标内存的起始地址(segment:offset),注意是小端是offset在前
     *       8-f 要读的起始sector号
     * 不需要chs,可以直接逾越1024柱面的限制
     */
    xorw  %ax, %ax
    /* 填充 DAP,其起始地址为 si(0x7c05) */
    /* 设置目标内存的起始地址 offset 为 0 */
    movw  %ax, 4(%si)

    /* ax++ == 1 */
    incw  %ax
    /* set the mode to non-zero */
    /* 将 mode=1 设置到 0x7c04,表示是 LBA mode,用于后续的 diskboot.S 中判断编址模式 */
    movb  %al, -1(%si)

    /* the blocks */
    /* 设置要读取的 sector 数为1 */
    movw  %ax, 2(%si)

    /* the size and the reserved byte */
    /* size,DAP 长度为 16byte */
    movw  $0x0010, (%si)

    /* the absolute address */
    /* 设置起始 sector 号为 1(第二个扇区) */
    movl  kernel_sector, %ebx
    movl  %ebx, 8(%si)
    movl  kernel_sector_high, %ebx
    movl  %ebx, 12(%si)

    /* the segment of buffer address */
    // 设置缓冲区起始地址的 segment 为 GRUB_BOOT_MACHINE_BUFFER_SEG,一般为 0x7000
    movw  $GRUB_BOOT_MACHINE_BUFFER_SEG, 6(%si)

    /* 此时状态
     * (gdb) x/16b 0x7c05
     * 0x7c05 <sectors>: 0x10  0x00  0x01  0x00  0x00  0x00  0x00  0x70
     * 0x7c0d <cylinders>: 0x01  0x00  0x00  0x00  0x00  0x00  0x00  0x00
     */
    /*
     * BIOS call "INT 0x13 Function 0x42" to read sectors from disk into memory
     *  Call with %ah = 0x42
     *      %dl = drive number
     *      %ds:%si = segment:offset of disk address packet
     *  Return:
     *      %al = 0x0 on success; err code on failure
     */
    /* 调用 BIOS 例程进行扩展读从 disk 上读取数据到缓冲区
     * 要求 ah 为 0x42 , dl 为驱动器号 (如0x80),ds:si 指向 DAP
     * 注意此时 ds 为 0x0 ,si 为 0x7c05
     */
    movb  $0x42, %ah
    int $0x13

    /* LBA read is not supported, so fallback to CHS.  */
    /* cf=1,表示调用出错,退化到 chs_mode */
    jc  LOCAL(chs_mode)
    /* 成功则跳转到 copy_buffer */
    movw  $GRUB_BOOT_MACHINE_BUFFER_SEG, %bx
    jmp LOCAL(copy_buffer)

如果不支持 LBA mode 或在 LBA mode 的执行流程中出错,会退化到 CHS mode:

LOCAL(chs_mode):
    /*
     *  Determine the hard disk geometry from the BIOS!
     *  We do this first, so that LS-120 IDE floppies work correctly.
     */
    /* 设置 ah=0x8,dl 为驱动器编号,调用 BIOS 例程获取驱动器的 CHS 参数 */
    /* 如果调用成功,则 ah 被设置为0,
     * dh 用于表示逻辑磁头最大索引值(H)
     * cx 用于表示逻辑柱面索引最大值(C) 和 逻辑扇区最大数(S)
       具体来说是:C 使用 ch 表示低8位,使用 cl 的高两位表示高2位
                 S 使用 cl 的低6位
     */
    movb    $8, %ah
    int $0x13
    jnc LOCAL(final_init)

    /* 如果调用失败,则如果驱动器号小于 0x80,则尝试探测软盘 */
    popw    %dx
    /*
     *  The call failed, so maybe use the floppy probe instead.
     */
    testb   %dl, %dl
    jnb LOCAL(floppy_probe)

    /* 如果大于 0x80,则硬盘错误,进入错误处理流程 */
    /* Nope, we definitely have a hard disk, and we're screwed. */
    ERR(hd_probe_error_string)

LOCAL(final_init):
    /* set the mode to zero */
    /* 将 dh 拷贝到 eax 的低 2 byte,其它为 0,因此此时 ah 为 0 */
    movzbl  %dh, %eax
    /* 将 mode=0 设置到 0x7c04,表示是 CHS mode,用于后续的 diskboot.S 中判断编址模式 */
    movb    %ah, -1(%si)

    /* 保存CHS参数,格式为:
     *       0-4 S
     *       4-7 H
     *       8-9 C
     */
    /* save number of heads */
    /* eax 此时保存了逻辑磁头最大索引值(H)
     * H 加一后保存到 BPB */
    incw    %ax
    movl    %eax, 4(%si)

    /* C 使用 ch 表示低 8 位,使用 cl 的高两位表示高 2 位 */
    /* 将 cl 的拷贝到 dx 的低 2byte */
    movzbw  %cl, %dx
    shlw    $2, %dx

    /* 将低8位拷到al,左移后的高8位拷到ah,从而ax存的就是C */
    movb    %ch, %al
    movb    %dh, %ah

    /* save number of cylinders */
    /* C 加一后保存到 BPB */
    incw    %ax
    movw    %ax, 8(%si)

    /* S 使用 cl 的低 6 位,cl 之前被拷贝到 dl 中 */
    movzbw  %dl, %ax
    shrb    $2, %al

    /* save number of sectors */
    /* 将 S 保存到 BPB */
    movl    %eax, (%si)

setup_sectors:
    /* 判断需要读取的扇区 */
    /* load logical sector start (top half) */
    /* kernel_sector 和 kernel_sector_high 保存的是GRUB内核起始扇区LBA地址
     * 需要转换成 CHS
     * 首先将高4字节地址加载到eax
     */
    movl    kernel_sector_high, %eax

    /* 如果高 4 字节不为 0,肯定超过 CHS 寻址范围,报错 */
    orl %eax, %eax
    jnz LOCAL(geometry_error)

    /* load logical sector start (bottom half) */
    /* 再将低 4 字节地址加载到 eax */
    movl    kernel_sector, %eax

    /* zero %edx */
    xorl    %edx, %edx

    /* divide by number of sectors */
    /* edx:eax / (%si) = 起始扇区号 / S(逻辑扇区最大数)
     * 商为起始磁头号,存在 eax
     * 余数为 CHS 起始扇区号,存在 edx
     */
    divl    (%si)

    /* save sector start */
    /* 将 CHS 起始扇区号存到 cl */
    movb    %dl, %cl

    xorw    %dx, %dx    /* zero %edx */
    /* edx:eax / 4(%si) = 起始磁头号 / H(逻辑磁头最大索引值)
     * 商为 CHS 起始柱面号,存在 eax
     * 余数为 CHS 起始磁头号,存在 edx
     */
    divl    4(%si)      /* divide by number of heads */

    /* do we need too many cylinders? */
    /* 如果 CHS 起始柱面号越界(大于等于 C),跳转到错误处理
     * 无需检查 CHS 起始磁头号是否越界,因为余数(CHS 起始磁头号)小于除数(H)
     */
    cmpw    8(%si), %ax
    jge LOCAL(geometry_error)

    /* normalize sector start (1-based) */
    /* CHS 中扇区从 1 开始 */
    incb    %cl
    /* 此时起始柱面(C)存在ax中,起始磁头(H)存在 dx 中,起始扇区(S)存在 cl 中 */

    /* low bits of cylinder start */
    /* 将 C 的 0-7bit 拷贝到 ch */
    movb    %al, %ch

    /* high bits of cylinder start */
    /* 将C的 8-9bit 拷贝到 cl 的 6-7bit
    xorb    %al, %al
    shrw    $2, %ax
    orb %al, %cl

    /* save head start */
    /* 将 H 存到 al */
    movb    %dl, %al

    /* restore %dl */
    /* 恢复 dl 为驱动器号 */
    popw    %dx

    /* head start */
    /* 将 H 存到 dh */
    movb    %al, %dh

    /* 之所以拷来拷去是为了满足 INT 0x13 func 0x2 的参数规范
     * 用于读取扇区
     */
/*
 * BIOS call "INT 0x13 Function 0x2" to read sectors from disk into memory
 *  Call with   %ah = 0x2
 *          %al = number of sectors
 *          %ch = cylinder
 *          %cl = sector (bits 6-7 are high bits of "cylinder")
 *          %dh = head
 *          %dl = drive (0x80 for hard disk, 0x0 for floppy disk)
 *          %es:%bx = segment:offset of buffer
 *  Return:
 *          %al = 0x0 on success; err code on failure
 */
    /* 设置缓冲区起始地址的 segment 为 GRUB_BOOT_MACHINE_BUFFER_SEG,一般为 0x7000 */
    movw    $GRUB_BOOT_MACHINE_BUFFER_SEG, %bx
    movw    %bx, %es    /* load %es segment with disk buffer */

    /* 缓冲区起始地址的 offset 为 0 */
    xorw    %bx, %bx    /* %bx = 0, put it at 0 in the segment */
    movw    $0x0201, %ax    /* function 2 */
    int $0x13

    /* 如果读取出错,跳转到错误处理,否则设置 bx 为缓冲区地址的 segment */
    jc  LOCAL(read_error)

    movw    %es, %bx

在 LBA mode 下,我们是直接借助起始扇区地址,将其包装成 DAP 后通过 INT 0x13, func 0x42 将对应扇区读到缓冲区中。

而在 CHS mode 下就比较麻烦了,要先通过 INT 0x13 func 0x08 获取 CHS 参数,然后把起始扇区地址转换成 CHS,然后再通过 INT 0x13, func 0x02 将对应扇区读到缓冲区中。

接下来将读取缓冲区的内容拷贝到目标位置 :

LOCAL(copy_buffer):
  /*
   * We need to save %cx and %si because the startup code in
   * kernel uses them without initializing them.
   */
  /* 将通用寄存器全部压栈 */
  pusha
  pushw %ds

  movw  $0x100, %cx
  movw  %bx, %ds
  xorw  %si, %si
  movw  $GRUB_BOOT_MACHINE_KERNEL_ADDR, %di
  movw  %si, %es

  cld
  /* rep movsw %ds:(%si),%es:(%di)
   * 从 ds:si(0x7000:0x0) 拷贝到 es:di(0x0:0x8000) ,每次拷一个word(2byte) 直到 cx 为0
   * 因此一共拷了 512 byte
   */
  rep
  movsw

  /* 恢复 ds 和通用寄存器 */
  popw  %ds
  popa

因此 boot.S 干的事就是读取第二个扇区的内容,并将它拷贝到 0:0x8000。

最后恢复寄存器,跳转到 kernel_address ,即 0x8000 :

  /* boot kernel */
  jmp *(kernel_address)

kernel_address:
  .word GRUB_BOOT_MACHINE_KERNEL_ADDR

core.img

根据 GRUB 的流程,在执行完 boot.img 后将开始执行存储在 MBR 后续 62 个扇区中的 core.img 。

core.img 主要由几部分组成。分别为 diskboot.img ,lzma_decompress.img ,kernel.img 和 需要加载 module 的 img。

由于 core.img 从第二个扇区开始,而 diskboot.img 位于 core.img 最前面,长度为一个扇区,因此上述 boot.img 逻辑中拷贝的其实是 diskboot.img。

diskboot.img

diskboot.img,它由 grub-core/boot/i386/pc/diskboot.S 编译而成,我们看它的代码:

    .globl  start, _start
start:
_start:
    /*
     * _start is loaded at 0x2000 and is jumped to with
     * CS:IP 0:0x2000 in kernel.
     */

    /*
     * we continue to use the stack for boot.img and assume that
     * some registers are set to correct values. See boot.S
     * for more information.
     */

    /* save drive reference first thing! */
    /* 位于 0:0x8000 */
    /* 将存在 dx 中的驱动器号压栈 */
    pushw   %dx

    /* 调用 LOCAL(check_silent) 检查 shift 键是否被按下,如果是,跳转到 LOCAL(x)
     * 这里为 LOCAL(after_notification_string) ,相当于跳过了接下来的几行
     */
    SILENT(after_notification_string)

    /* print a notification message on the screen */
    /* 在屏幕上输出 loading 字样 */
    pushw   %si
    MSG(notification_string)
    popw    %si

LOCAL(after_notification_string):
    /* this sets up for the first run through "bootloop" */
    /* 将 LOCAL(firstlist) 这个 label 的地址放到 di 中 */
    /* 长度为 12 byte,包含三个 label:
        blocklist_default_start(8byte) GRUB 内核的起始扇区,在 grub-install 时指定,决定 GRUB 内核起始扇区的 LBA 地址,默认为2
        blocklist_default_len(2byte) GRUB 内核的扇区数,在 grub-mkimage 时设置为实际长度
        blocklist_default_seg(2byte) 跳转到 GRUB 内核时的默认 segment,默认为 0x820
    */
    movw    $LOCAL(firstlist), %di

    /* save the sector number of the second sector in %ebp */
    /* 将 firstlist 的前 8 byte(blocklist_default_start) 保存到存到 ebp */
    movl    (%di), %ebp

        /* this is the loop for reading the rest of the kernel in */
LOCAL(bootloop):

    /* check the number of sectors to read */
    /* 判断 blocklist_default_len 是否是 0,如果是,不用拷了,直接跳转到 LOCAL(bootit) 直接启动 */
    cmpw    $0, 8(%di)

    /* if zero, go to the start function */
    je  LOCAL(bootit)

diskboot 延续使用 boot 阶段的栈(SS:SP=0x0000:0x2000)。包括:

  • DL: 引导驱动器编号
  • si: DAP起始地址
  • ds: 数据段
  • ss: 堆栈段

diskboot 将根据 blocklist_default_len ,决定是要拷贝 GRUB 内核还是直接启动。如果需要拷贝,则根据读取 mode 进行对应的读取。

以 LBA mode 为例:

        /* this is the loop for reading the rest of the kernel in */
LOCAL(bootloop):

    /* check the number of sectors to read */
    cmpw    $0, 8(%di)

    /* if zero, go to the start function */
    je  LOCAL(bootit)

LOCAL(setup_sectors):
    /* check if we use LBA or CHS */
    /* boot.S 在 DAP 的前一个 byte 设置了读取 mode (如movb  %al, -1(%si)) */
    cmpb    $0, -1(%si)

    /* use CHS if zero, LBA otherwise */
    je  LOCAL(chs_mode)

    /* LBA mode */
    /* load logical sector start */
    /* 加载起始扇区地址到 ebx 和 ecx 中,一个放 4 byte */
    movl    (%di), %ebx
    movl    4(%di), %ecx

    /* the maximum is limited to 0x7f because of Phoenix EDD */
    /* 将最大读取扇区数设置到 al 中 */
    xorl    %eax, %eax
    movb    $0x7f, %al

    /* how many do we really want to read? */
    /* 如果要读的扇区数大于 0x7f */
    cmpw    %ax, 8(%di) /* compare against total number of sectors */

    /* which is greater? */
    jg  1f

    /* if less than, set to total */
    /* 如果要读取的扇区数小于等于 0x7f,表示此次可以加载完成,将剩余数量存到 ax 中 */
    movw    8(%di), %ax

    /* 循环 */
1:
    /* subtract from total */
    /* 一次读 ax 个扇区,更新此次读完后还剩多少没读 */
    subw    %ax, 8(%di)

    /* add into logical sector start */
    /* 更新起始地址 */
    addl    %eax, (%di)
    /* 更新高4byte,需要考虑进位 */
    adcl    $0, 4(%di)

    /* set up disk address packet */
    /* 设置 DAP,然后调用 int 0x13 进行读取,过程同 boot.S */

    /* the size and the reserved byte */
    movw    $0x0010, (%si)

    /* the number of sectors */
    movw    %ax, 2(%si)

    /* the absolute address */
    movl    %ebx, 8(%si)
    movl    %ecx, 12(%si)

    /* the segment of buffer address */
    /* 设置缓冲区的起始 segment 为 GRUB_BOOT_MACHINE_BUFFER_SEG ,一般为 0x7000 */
    movw    $GRUB_BOOT_MACHINE_BUFFER_SEG, 6(%si)

    /* save %ax from destruction! */
    /* 将读取的扇区数压栈 */
    pushw   %ax

    /* the offset of buffer address */
    /* 设置目标缓冲区的 offset 为0 */
    /* 因此指定了读取的 buffer 为 0x7000:0 */
    movw    $0, 4(%si)

/*
 * BIOS call "INT 0x13 Function 0x42" to read sectors from disk into memory
 *  Call with   %ah = 0x42
 *          %dl = drive number
 *          %ds:%si = segment:offset of disk address packet
 *  Return:
 *          %al = 0x0 on success; err code on failure
 */

    movb    $0x42, %ah
    int $0x13

    jc  LOCAL(read_error)

    movw    $GRUB_BOOT_MACHINE_BUFFER_SEG, %bx
    jmp LOCAL(copy_buffer)

而对于 CHS mode ,流程类似 boot.S 中的 CHS mode ,这里就不进行分析了。无论是 LBA 还是 CHS ,最终都会执行到 LOCAL(copy_buffer) 进行拷贝:

LOCAL(copy_buffer):

    /* load addresses for copy from disk buffer to destination */
    /* 加载目的 segemt(blocklist_default_seg) 到 es */
    movw    10(%di), %es    /* load destination segment */

    /* restore %ax */
    /* 弹出先前压栈的此次要读取的扇区数 */
    popw    %ax

    /*
     * ax 存放的是要读取的扇区数,转换为 byte 需要 << 9,因为一个扇区大小为 512 byte=2^9
     * ax << 5 ,相当于 ax << 9 >> 4 (段地址等于 base >> 4)
     * 然后可以直接加到当前扇区的起始段地址上,得到下次加载扇区的起始段地址
     */
    /* determine the next possible destination address (presuming
        512 byte sectors!) */
    shlw    $5, %ax     /* shift %ax five bits to the left */
    addw    %ax, 10(%di)    /* add the corrected value to the destination
                   address for next time */

    /* save addressing regs */
    pusha
    pushw   %ds

    /* get the copy length */
    /* ax << 3,加上先前的 5 位,一共左移了 8 位,512/(2^8) = 2,因此表示要读取的 word 数(1 word = 2 byte) */
    shlw    $3, %ax
    movw    %ax, %cx

    /* 由于是以扇区为单位进行拷贝,offset 必定为 0,清空之 */
    xorw    %di, %di    /* zero offset of destination addresses */
    xorw    %si, %si    /* zero offset of source addresses */
    /* 设置目标内存的segment号 */
    movw    %bx, %ds    /* restore the source segment */

    cld     /* sets the copy direction to forward */

    /* perform copy */
    /* 开始拷贝,每次拷一个 word(2 byte) 直到 cx 为0 */
    rep     /* sets a repeat */
    movsw       /* this runs the actual copy */

    /* restore addressing regs and print a dot with correct DS
       (MSG modifies SI, which is saved, and unused AX and BX) */
    popw    %ds
    /* 如果非 slient,打印 "." 字符,提醒用户程序正在运行 */
    SILENT(after_notification_step)
    MSG(notification_step)

LOCAL(after_notification_step):
    popa

    /* check if finished with this dataset */
    /* 如果还有扇区未拷贝,跳转回 LOCAL(bootloop) */
    cmpw    $0, 8(%di)
    jne LOCAL(setup_sectors)

    /* update position to load from */
    subw    $GRUB_BOOT_MACHINE_LIST_SIZE, %di

    /* jump to bootloop */
    jmp LOCAL(bootloop)

相当于将磁盘中 GRUB 内核镜像(core.img)通过缓冲区 0x7000:0x0 拷贝到 blocklist_default_seg:0x(0x820:0x)。即 0x8200 。

所有扇区都拷贝完成后,跳转到 LOCAL(bootit) :

LOCAL(bootit):
    SILENT(after_notification_done)
    /* print a newline */
    MSG(notification_done)

LOCAL(after_notification_done):
    popw    %dx /* this makes sure %dl is our "boot" drive */
    ljmp    $0, $(GRUB_BOOT_MACHINE_KERNEL_ADDR + 0x200)

如果非 slient,则在上述的 "." 后面打印 "\r\n" 换行表示拷贝结束,然后跳转到 $(GRUB_BOOT_MACHINE_KERNEL_ADDR + 0x200) ,即 0x8000 + 0x200 = 0x8200 ,正是我们刚刚把 GRUB 内核拷贝到的起始地址处。

至此 diskboot.img 完成了将 GRUB 内核从磁盘中拷到内存中的任务,最后跳转到 GRUB 内核在内存中的起始地址 0x8200 。

lzma_decompress.img

为了节省空间,kernel.img 实际上是被压缩过的。而 lzma_decompress.img 正是负责解压 kernel.img 。

我们生成的带调试信息的文件:

$ cp grub/obj/grub-pc/grub-core/boot/i386/pc/lzma_decompress_image-startup_raw.o /home/binss/work/grub/grub_dump/
$ ld -Ttext=0x8200 -m elf_i386 lzma_decompress_image-startup_raw.o -o lzma_decompress.img

然后调试之:

(gdb) directory /home/binss/work/grub/grub2-2.02~beta2/grub-core/boot/i386/pc/
(gdb) file /home/binss/work/grub/grub_dump/lzma_decompress.img
(gdb) hb *0x8200
Hardware assisted breakpoint 1 at 0x8200: file startup_raw.S, line 46.
(gdb) c
Continuing.
[   0:8200] => 0x8200 <start>:  ljmp   $0x0,$0x821c

Thread 1 hit Breakpoint 1, start () at startup_raw.S:46
46      ljmp $0, $ABS(LOCAL (codestart))

注意此时的相对路径直接是 startup_raw.S ,因此 directory 需要加载到最后一级目录。

因此 lzma_decompress.img 对应的代码为 grub-core/boot/i386/pc/startup_raw.S :

start:
_start:
LOCAL (base):
    /*
     *  Guarantee that "main" is loaded at 0x0:0x8200.
     */
#ifdef __APPLE__
    ljmp $0, $(ABS(LOCAL (codestart)) - 0x10000)
#else
    ljmp $0, $ABS(LOCAL (codestart))
#endif

...
/* the real mode code continues... */
LOCAL (codestart):
    cli     /* we're not safe here! */ /* 接下来要切到保护模式,关中断 */

    /* 设置实模式下的段寄存器为 0 */
    /* set up %ds, %ss, and %es */
    xorw    %ax, %ax
    movw    %ax, %ds
    movw    %ax, %ss
    movw    %ax, %es

    /* set up the real mode/BIOS stack */
    /* 设置栈为 (0x2000 - 0x10) */
    movl    $GRUB_MEMORY_MACHINE_REAL_STACK, %ebp
    movl    %ebp, %esp

    sti     /* we're safe again */

    /* save the boot drive */
    /* 将驱动器号存到 LOCAL(boot_drive) 中 */
    ADDR32  movb    %dl, LOCAL(boot_drive)

    /* reset disk system (%ah = 0) */
    /* int 0x13 func 0x0 为 Reset Disk Drive */
    int $0x13

    /* transition to protected mode */
    /* 调用函数 real_to_prot */
    DATA32  call real_to_prot

startup_raw.S 从 LOCAL (codestart) 开始执行。在设置好寄存器后,对驱动器进行 reset,然后调用 real_to_prot ,其定义在 grub-core/kern/i386/realmode.S :

real_to_prot:
    .code16
    cli

    /* load the GDT register */
    xorw    %ax, %ax
    movw    %ax, %ds
    DATA32  ADDR32  lgdt    gdtdesc

    /* turn on protected mode */
    movl    %cr0, %eax
    orl $GRUB_MEMORY_CPU_CR0_PE_ON, %eax
    movl    %eax, %cr0

    /* jump to relocation, flush prefetch queue, and reload %cs */
    DATA32  ljmp    $GRUB_MEMORY_MACHINE_PROT_MODE_CSEG, $protcseg

这段代码负责从实模式切换到保护模式。首先通过 lgdt 加载全局描述符表 GDT ,其描述符为:

gdtdesc:
    .word   0x27            /* limit */
    .long   gdt         /* addr */

内容为:

    .p2align    5   /* force 4-byte alignment */
gdt:
    .word   0, 0
    .byte   0, 0, 0, 0

    /* -- code segment --
     * base = 0x00000000, limit = 0xFFFFF (4 KiB Granularity), present
     * type = 32bit code execute/read, DPL = 0
     */
    .word   0xFFFF, 0
    .byte   0, 0x9A, 0xCF, 0

    /* -- data segment --
     * base = 0x00000000, limit 0xFFFFF (4 KiB Granularity), present
     * type = 32 bit data read/write, DPL = 0
     */
    .word   0xFFFF, 0
    .byte   0, 0x92, 0xCF, 0

    /* -- 16 bit real mode CS --
     * base = 0x00000000, limit 0x0FFFF (1 B Granularity), present
     * type = 16 bit code execute/read only/conforming, DPL = 0
     */
    .word   0xFFFF, 0
    .byte   0, 0x9E, 0, 0

    /* -- 16 bit real mode DS --
     * base = 0x00000000, limit 0x0FFFF (1 B Granularity), present
     * type = 16 bit data read/write, DPL = 0
     */
    .word   0xFFFF, 0
    .byte   0, 0x92, 0, 0


    .p2align 5

可以发现 GDT 含4个表项,分别指向保护模式代码段、保护模式数据段、实模式下的代码段、实模式下的数据段。

在设置好 GDT 后,直接设置 cr0 的 bit 0(PE) 切换到保护模式。最后一个长跳转跳转到保护模式的代码:

    .code32
protcseg:
    /* reload other segment registers */
    movw    $GRUB_MEMORY_MACHINE_PROT_MODE_DSEG, %ax
    movw    %ax, %ds
    movw    %ax, %es
    movw    %ax, %fs
    movw    %ax, %gs
    movw    %ax, %ss

    /* put the return address in a known safe location */
    movl    (%esp), %eax
    movl    %eax, GRUB_MEMORY_MACHINE_REAL_STACK

    /* get protected mode stack */
    movl    protstack, %eax
    movl    %eax, %esp
    movl    %eax, %ebp

    /* get return address onto the right stack */
    movl    GRUB_MEMORY_MACHINE_REAL_STACK, %eax
    movl    %eax, (%esp)

    /* zero %eax */
    xorl    %eax, %eax

    /* 存储实模式的中断描述符表寄存器(含limit和addr)到LOCAL(realidt) */
    sidt LOCAL(realidt)
    /* 从protidt加载保护模式的中断描述符表到寄存器中(含limit和addr) */
    lidt protidt

    /* return on the old (or initialized) stack! */
    ret

由于刚从实模式切换而来,需要把各个段寄存器都设置为 0x10 (include/grub/i386/memory_raw.h)。随后保存实模式的中断描述符表地址,加载保护模式的中断描述符表。

至此完成了到保护模式的切换,回到 startup_raw.S :

    .code32

    /* 安全,重新开中断 */
    cld
    call    grub_gate_a20

    movl    LOCAL(compressed_size), %edx
#ifdef __APPLE__
    addl    $decompressor_end, %edx
    subl    $(LOCAL(reed_solomon_part)), %edx
#else
    addl    $(LOCAL(decompressor_end) - LOCAL(reed_solomon_part)), %edx
#endif
    movl    reed_solomon_redundancy, %ecx
    leal    LOCAL(reed_solomon_part), %eax
    cld
    call    EXT_C (grub_reed_solomon_recover)
    jmp post_reed_solomon

调用 grub_gate_a20 开启 a20 ,包括以下流程:

  1. 调用 gate_a20_check_state 检查当前 A20 是否启用 (通过 0x80 的 Manufacturing Diagnostics port 把值写出,如果 ebx 地址的值不变则为 a20 为关闭)
  2. 如果未启用,则跳转到 gate_a20_try_bios
  3. gate_a20_try_bios 调用 prot_to_real 切到实模式,调用 BIOS 例程 int 0x15 func 0x2401 开启 A20,然后调用 real_to_prot 切回保护模式
  4. 再次调用 gate_a20_check_state 检查当前 A20 是否启用
  5. 如果未启用,跳转到 gate_a20_try_system_control_port_a ,尝试使用修改 0x92 端口 bit 1 值的方法开启 A20
  6. 再次调用 gate_a20_check_state 检查当前 A20 是否启用
  7. 如果未启用,跳转到 gate_a20_try_keyboard_controller ,尝试通过向 0x64 端口写入 0xd1(开启对 804x 键盘控制器的写,接下来向 0x60 写入的 byte 将被写到 804x),再向 0x60 端口写入 0xdf 的方法开启 A20
  8. 再次调用 gate_a20_check_state 检查当前 A20 是否启用
  9. 如果未启用,跳转回 gate_a20_try_bios ,即 step 3 进行重试

A20 开启后,我们就能够使用第20条及以上的地址线,从而能够使用/访问 1MB 以上的地址。

接下来是调用 grub_reed_solomon_recover ,通过 Reed Solomon 算法(见 https://en.wikipedia.org/wiki/Reed%E2%80%93Solomon_error_correction)来对 GRUB 受保护代码进行检查和修复。其定义在 ./grub-core/lib/reed_solomon.c

void REED_SOLOMON_ATTRIBUTE
grub_reed_solomon_recover (void *ptr_, grub_size_t s, grub_size_t rs)

参数依次为 LOCAL(reed_solomon_part) , LOCAL(compressed_size) + $(LOCAL(decompressor_end) - LOCAL(reed_solomon_part)) , reed_solomon_redundancy 的值。表示保护 lzma_decompress.img 中 从 LOCAL(reed_solomon_part) 到 lzma_decompress.img 结尾的 LOCAL(decompressor_end) 再到 kernel.img 结尾 (kernel.img 大小存储在 LOCAL(compressed_size) ) 部分的代码,这部分代码到目前为止还未执行。用于校验和修复数据的冗余数据从 reed_solomon_redundancy 开始。

这样做的目的是为了避免其他软件篡改了 GRUB 内核而导致引导崩溃,因此在此进行校验和恢复。校验通过后,跳转到 post_reed_solomon ,进入受保护的代码区,负责对 kernel.img 进行解压:

post_reed_solomon:

#ifdef ENABLE_LZMA
    /* GRUB_MEMORY_MACHINE_DECOMPRESSION_ADDR = 0x100000 */
    movl    $GRUB_MEMORY_MACHINE_DECOMPRESSION_ADDR, %edi
#ifdef __APPLE__
    movl    $decompressor_end, %esi
#else
    movl    $LOCAL(decompressor_end), %esi
#endif
    pushl   %edi
    movl    LOCAL (uncompressed_size), %ecx
    /* GRUB_MEMORY_MACHINE_DECOMPRESSION_ADDR + uncompressed_size = kernel.img 解压后的结束地址 */
    leal    (%edi, %ecx), %ebx
    /* Don't remove this push: it's an argument.  */
    push    %ecx
    call    _LzmaDecodeA
    pop %ecx
    /* _LzmaDecodeA clears DF, so no need to run cld */
    popl    %esi
#endif

    movl    LOCAL(boot_dev), %edx
    movl    $prot_to_real, %edi
    movl    $real_to_prot, %ecx
    movl    $LOCAL(realidt), %eax
    jmp *%esi

#ifdef ENABLE_LZMA
#include "lzma_decode.S"
#endif

    .p2align 4

#ifdef __APPLE__
    .zerofill __DATA, __aa_before_bss, decompressor_end, 10, 0
#else
    .bss
LOCAL(decompressor_end):
#endif

在确定了 kernel.img 解压后应该放到哪里(0x100000 - 0x100000 + uncompressed_size) 后,调用 _LzmaDecodeA 进行解压,其定义在 grub-core/boot/i386/pc/lzma_decode.S ,它的代码就跟在 jmp *%esi 后面,负责将跟在 LOCAL(decompressor_end) 后面的 kernel.img 解压到 0x100000 。

最后将常用函数地址保存到寄存器中,然后跳转到 esi(0x100000) 开始执行解压出来的代码。

kernel.img

kernel.img 是 GRUB 的主要逻辑。我们再次重施故伎,产生的带调试信息的文件:

$ cp grub/obj/grub-pc/grub-core/kernel.exec /home/binss/work/grub/grub_dump/
$ ld -m elf_i386 kernel.exec -o kernel.image

然后加载之:

(gdb) directory /home/binss/work/grub/grub2-2.02~beta2/obj/grub-pc/grub-core/
(gdb) file /home/binss/work/grub/grub_dump/kernel.image

发现首先执行的是 grub-core/kern/i386/pc/startup.S :

start:
_start:
__start:
#ifdef __APPLE__
LOCAL(start):
#endif
    .code32

    /* 保存 startup_raw.S 传递过来的函数地址 */
    movl    %ecx, (LOCAL(real_to_prot_addr) - _start) (%esi)
    movl    %edi, (LOCAL(prot_to_real_addr) - _start) (%esi)
    movl    %eax, (EXT_C(grub_realidt) - _start) (%esi)

    /* copy back the decompressed part (except the modules) */
#ifdef __APPLE__
    movl    $EXT_C(_edata), %ecx
    subl    $LOCAL(start), %ecx
#else
    /* 将解压后的kernel.img的 代码段-数据段(0x100000 - 0x106ca0) 拷贝到 0x9000 */
    movl    $(_edata - _start), %ecx
#endif
    /* $(_start) == 0x9000 */
    movl    $(_start), %edi
    rep
    movsb

    /* LOCAL (cont) == 0x9025 */
    movl    $LOCAL (cont), %esi
    /* 跳转到 0x9025 开始执行 */
    jmp *%esi

这里的实现非常 tricky 。把 kernel.img 解压后的代码段和数据段从 0x100000 拷到 0x9000 ,而这些被拷贝的代码恰巧是当前正在执行的代码。在拷贝完成后,跳转到 LOCAL (cont) 而不是 0x9000 ,避免了这段拷贝逻辑被重复执行。

直到这里,gdb 才恢复正常,能够显示当前执行的源代码而不是反汇编出来的,因为根据 grub-core/Makefile.core.def ,kernel.img 是链接到 0x9000 的:

kernel = {
  name = kernel;
  ...
  i386_pc_ldflags          = '$(TARGET_IMG_LDFLAGS)';
  i386_pc_ldflags          = '$(TARGET_IMG_BASE_LDOPT),0x9000';
  ...
};

继续看 startup.S :

LOCAL(cont):

#ifdef __APPLE__
    /* clean out the bss */
    movl    $EXT_C(_edata), %edi

    /* compute the bss length */
    movl    $GRUB_MEMORY_MACHINE_SCRATCH_ADDR, %ecx
#else
    /* clean out the bss */
    /* 将 BSS 段的起始地址存到 edi */
    movl    $BSS_START_SYMBOL, %edi

    /* compute the bss length */
    /* 将 BSS段的结束地址存到 ecx */
    movl    $END_SYMBOL, %ecx
#endif
    /* 计算 BSS 段长度 ecx = ecx - edi ,作为循环次数 */
    subl    %edi, %ecx

    /* clean out */
    xorl    %eax, %eax
    cld
    /* 将 BSS 段都设置为eax (清0) */
    rep
    stosb

    /* 将 edx 保存的 LOCAL(boot_dev) 地址设置到 EXT_C(grub_boot_device) */
    movl    %edx, EXT_C(grub_boot_device)

    /*
     *  Call the start of main body of C code.
     */
    call EXT_C(grub_main)

其清空了 BSS 段,这样接下来要执行的 C 代码才能存放未初始化的全局变量和静态变量。最后调用 grub_main ,进入 C 代码,其位于 grub-core/kern/main.c :

grub_main 主要流程如下:

grub_main
=> grub_machine_init => grub_console_init       初始化控制台
                     => grub_mm_init_region     初始化内存区域
                     => grub_tsc_init           初始化 TSC,用于计时
=> grub_setcolorstate + grub_printf             打印欢迎信息
=> grub_load_config                             加载各模块的配置文件
=> grub_load_modules                            加载 /boot/grub/i386-pc/*.mod
=> grub_set_prefix_and_root                     设置环境变量 prefix(GRUB目录,默认为 /boot/grub ) 和 root(根设备)
=> grub_register_core_commands                  注册 set / unset / ls / insmod 这四个命令供用户使用
=> grub_parser_execute                          如果各模块有配置文件被加载,则进行解析
=> grub_load_normal_mode => grub_dl_load ("normal")   加载 normal.mod 模块
                         => grub_command_execute ("normal", 0, 0)   运行 normal 命令

在加载 normal.mod 模块时,会执行 GRUB_MOD_INIT(normal) ,其设置了一堆环境变量,注册了几个命令,其中关键是 normal 命令:

grub_register_command ("normal", grub_cmd_normal,
             0, N_("Enter normal mode."));

因此在 grub_main 函数的最后的 grub_load_normal_mode 运行 normal 命令时会调用 grub_cmd_normal

grub_cmd_normal
=> config = grub_xasprintf ("%s/grub.cfg", prefix)                                              拼接出 grub.cfg 所在路径
=> grub_enter_normal_mode => grub_normal_execute => read_config_file                            读取 grub.cfg,解析执行,并得到所有菜单项
                                                 => grub_show_menu => show_menu => run_menu     显示菜单
                                                                                => grub_menu_get_entry 根据菜单索引,得到对应菜单项
                                                                                => grub_menu_execute_entry 运行菜单项

read_config_file 会读取 grub.cfg,它可以看作是 shell 脚本的扩展,一般包含以下内容:

  1. 通过 set 来设置 GRUB 的环境变量
  2. 通过 function 定义的函数,一般会在该文件中被调用
  3. 通过 insmod 加载必要的模块
  4. 通过 menuentry 定义的引导菜单,每一个语句定义一个菜单项
run_menu
=> grub_menu_get_timeout            从环境变量中得到超时时间
=> getkeystatus                     检测用户按键状态,如果按下了 shift 则修改超时时间为 -1 ,表示不会超时
=> grub_getkey_noblock              获取用户当前按键,进行相应处理
=> get_entry_index_by_hotkey        如果用户选择了对应的菜单项,则根据按键计算出对应的菜单索引
=> print_countdown                  如果开启倒计时,则不断更新倒计时时间
=> 如果超时,返回默认菜单(default)的索引
grub_menu_execute_entry
=> 如果菜单项有子菜单,显示之
=> grub_script_execute_new_scope     执行菜单项对应的script

在我的 grub.cfg 文件中,第一条为 menuentry 如下:

    menuentry 'Ubuntu, with Linux 4.8.0-59-generic' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-4.8.0-59-generic-advanced-e2ece054-3cda-43be-8b41-306c02ea6f26' {
        recordfail
        load_video
        gfxmode $linux_gfx_mode
        insmod gzio
        if [ x$grub_platform = xxen ]; then insmod xzio; insmod lzopio; fi
        insmod part_msdos
        insmod ext2
        set root='hd0,msdos1'
        if [ x$feature_platform_search_hint = xy ]; then
          search --no-floppy --fs-uuid --set=root --hint-bios=hd0,msdos1 --hint-efi=hd0,msdos1 --hint-baremetal=ahci0,msdos1  c71891fc-93ff-495f-9ca9-5711d13126ee
        else
          search --no-floppy --fs-uuid --set=root c71891fc-93ff-495f-9ca9-5711d13126ee
        fi
        echo    'Loading Linux 4.8.0-59-generic ...'
        linux   /vmlinuz-4.8.0-59-generic root=/dev/mapper/giantvm--vg-root ro  strict-devmem=0
        echo    'Loading initial ramdisk ...'
        initrd  /initrd.img-4.8.0-59-generic
    }

其通过 linux 指令加载 kernel 镜像 vmlinuz-4.8.0-59-generic 。随后通过 initrd 加载位于相同目录下的 initrd.img-4.8.0-59-generic 。

在先前加载各模块时,linux.mod 被加载,其在 GRUB_MOD_INIT (linux) 中注册了 linux 和 initrd 命令,回调函数分别为 grub_cmd_linux 和 grub_cmd_initrd 。

grub_cmd_linux

grub_cmd_linux 定义在 grub-core/loader/i386/linux.c 。它会根据参数传入的路径打开并读取 linux kernel 的镜像,即 vmlinuz-xxx 。

读取分为三步:

  1. 读取 linux_kernel_header
  2. 读取 linux_kernel_params (跳过前面 linux_kernel_header 大小)
  3. 读取保护模式镜像 (vmlinux.bin)

linux_kernel_header 是 LINUX/x86 BOOT PROTOCOL 所定义的格式,根据 kernel 文档 Documentation/x86/boot.txt,字段如下:

Offset  Proto   Name        Meaning
/Size

01F1/1  ALL(1   setup_sects The size of the setup in sectors
01F2/2  ALL root_flags  If set, the root is mounted readonly
01F4/4  2.04+(2 syssize     The size of the 32-bit code in 16-byte paras
01F8/2  ALL ram_size    DO NOT USE - for bootsect.S use only
01FA/2  ALL vid_mode    Video mode control
01FC/2  ALL root_dev    Default root device number
01FE/2  ALL boot_flag   0xAA55 magic number
0200/2  2.00+   jump        Jump instruction
0202/4  2.00+   header      Magic signature "HdrS"
0206/2  2.00+   version     Boot protocol version supported
0208/4  2.00+   realmode_swtch  Boot loader hook (see below)
020C/2  2.00+   start_sys_seg   The load-low segment (0x1000) (obsolete)
020E/2  2.00+   kernel_version  Pointer to kernel version string
0210/1  2.00+   type_of_loader  Boot loader identifier
0211/1  2.00+   loadflags   Boot protocol option flags
0212/2  2.00+   setup_move_size Move to high memory size (used with hooks)
0214/4  2.00+   code32_start    Boot loader hook (see below)
0218/4  2.00+   ramdisk_image   initrd load address (set by boot loader)
021C/4  2.00+   ramdisk_size    initrd size (set by boot loader)
...

GRUB 根据规范可以解析获得内核镜像的相关信息。比如从 linux_kernel_header.setup_sects 得到了实模式镜像的扇区数,乘以 512(<< GRUB_DISK_SECTOR_BITS)得到实模式镜像的大小。再比如从 linux_kernel_header.init_size 得到了保护模式内核镜像的大小 。于是调用 allocate_pages 为保护模式镜像申请内存空间 prot_mode_mem ,起始地址 preferred_address 默认为 GRUB_LINUX_BZIMAGE_ADDR ,即 0x100000。还记得吗? 0x100000 先前用于存放解压的 grub 代码,如今 grub 代码,也就是现在正在执行的代码,已经被挪到了 0x9000 开始的内存中,所以可以复用。

接下来结合命令行参数,将读取的 linux_kernel_params 填充完整:

/* code32_start = 0x1000000 + 0x100000 - 0x100000 = 0x1000000 */
linux_params.code32_start = prot_mode_target + lh.code32_start - GRUB_LINUX_BZIMAGE_ADDR;
linux_params.kernel_alignment = (1 << align);
linux_params.ps_mouse = linux_params.padding10 =  0;
linux_params.type_of_loader = GRUB_LINUX_BOOT_LOADER_TYPE;
linux_params.cl_magic = GRUB_LINUX_CL_MAGIC;
linux_params.cl_offset = 0x1000;

linux_params.ramdisk_image = 0;
linux_params.ramdisk_size = 0;

linux_params.heap_end_ptr = GRUB_LINUX_HEAP_END_OFFSET;
linux_params.loadflags |= GRUB_LINUX_FLAG_CAN_USE_HEAP;
...

如果一切顺利没出错,则会调用 grub_loader_set (grub_linux_boot, grub_linux_unload, 0) ,将 grub_loader_boot_func 设置为 grub_linux_boot ,同时将 grub_loader_unload_func 设置为 grub_linux_unload。

grub_cmd_initrd

grub_cmd_initrd 同样定义在 grub-core/loader/i386/linux.c 。其负责读取参数指定的 initrd.img ,然后将它的内存地址和大小设置到 linux_kernel_params 中:

linux_params.ramdisk_image = initrd_mem_target;
linux_params.ramdisk_size = size;
linux_params.root_dev = 0x0100; /* XXX */

这样 kernel 启动后,就知道去哪里加载 initrd 了。

grub_linux_boot

在 grub.cfg 中的菜单项脚本执行完成后, run_menu => grub_menu_execute_entry => grub_script_execute_new_scope 返回上级,执行 boot 命令:

  if (grub_errno == GRUB_ERR_NONE && grub_loader_is_loaded ())
    /* Implicit execution of boot, only if something is loaded.  */
    grub_command_execute ("boot", 0, 0);

和 linux.mod 一样,boot.mod 在加载时注册了 boot 命令的回调函数,于是会执行 grub_cmd_boot => grub_loader_boot => grub_loader_boot_func (grub_linux_boot)

=> 修正用于实模式的内存大小,实际大小为 0x6000,起始物理地址地址 prot_mode_mem 为 0x8a000 。虚拟地址 real_mode_mem 为 0x7ffcb8b0
=> 将各参数都存到 grub_linux_boot_ctx 中
=> 设置 grub_relocator32_state ,用于描述保护模式下的寄存器状态。比如ip为 code32_start(0x100000)
=> grub_relocator32_boot (relocator, state, 0)

经过一番 relocate 后,跳转到 kernel 的实模式代码进行执行。

总结

以下是机器从启动到执行 Linux kernel 前所涉及的代码段地址及功能:

  1. 0xfffffff0 :跳转指令
  2. 0xfe05b - :执行BIOS代码,加载选中设备的boot sector
  3. 0x7c00 - :GRUB boot.img 代码,负责读取 diskboot.img 到指定地址
  4. 0x8000 - :GRUB diskboot.img 代码,负责读取 GRUB 内核代码,包括 lzma_decompress.img 和 kernel.img
  5. 0x8200 - :GRUB lzma_decompress.img 代码,负责从实模式切换到保护模式,设置 GDT,开启A20,通过 Reed Solomon 算法对 GRUB 受保护代码进行检查和修复,解压 kernel.img 代码
  6. 0x100000 - :GRUB kernel.img 代码,将自身代码拷贝到 0x9000
  7. 0x9000 - :GRUB kernel.img 代码,负责执行 GRUB 的主要逻辑。它解析 grub.cfg,根据用户选择加载对应的 kernel,然后跳转到 kernel 进行执行。

此时形成的内存布局如下:

         | 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   +------------------------+

GRUB 将 vmlinuz 分成两部分,分别加载到内存地址空间的 X 和 0x100000 处。此后,Linux kernel 开始执行。有机会的话我们将在后续的文章中进行分析。

参考

https://pdos.csail.mit.edu/6.828/

http://jameeeees.github.io/2017/03/04/MIT-JOS-lab1-exercise2/

https://en.wikipedia.org/wiki/GNU_GRUB

https://www.gnu.org/software/grub/manual/html_node/Images.html

https://www.zhihu.com/question/22364502

http://ytliu.info/blog/2016/03/14/linuxnei-cun-chu-shi-hua-assembly/

http://ytliu.info/blog/2016/03/15/linuxnei-cun-chu-shi-hua-c/

http://blog.csdn.net/richardysteven/article/details/52629731#t16

https://0xax.gitbooks.io/linux-insides/content/Booting/linux-bootstrap-1.html

http://lukeluo.blogspot.jp/2013/07/grub-how-to-7-debugging.html