Linux启动过程分析之BootLoader

Published: 日 08 八月 2021

In Kernel.

启动过程总览

一个BIOS+MBR组合的启动过程如下图:

1.BIOS:在x86平台,系统加电后,CPU会恢复到0xfffffff0开始执行,这一般是BIOS的内容,BIOS的主要作用是检测各种硬件并对其进行初始化(如填充各种寄存器),它分为Probe,Training和Enum三个阶段,注意初始时内存不可使用,BIOS是直接在ROM(如NorFlash)上执行的,当Training完成后才可使用,在完成开机自检后,它就能以中断向量的方式为后续的过程提供各种硬件信息(ACPI/Mem/Disk/SMBios等),在完成自检后它根据检测的硬件信息与配置加载bootloader的代码到0000:7C00处并跳转到该处执行,在之后bootloader及kernel依然会再使用BIOS提供的中断调用,由于这部分基本不会被定制,因此下面会跳过这部分。

注:1.尽管CPU重置后会处于实模式,但此时A20处于Enable状态,其CS的隐藏部分基址又为0xFFFF0000,因此实际为Big Real Mode,此时第一条指令为CS.BASE+IP=0xFFFF0000+0xFFF0=0xFFFFFFF0,硬连线使此处映射为BIOS空间,但一般它只包含一条远跳转指令,使其跳转到0xFFFF0进入真正的实模式。

2.远跳转是为了清空流水线,这样状态变换才能生效,这种操作会经常见到。

2.MBR:可以认为是一种记录磁盘分区的方式,此时可用GPT与其对应,也可以认为是磁盘的第一个扇区,这时它分三部分,前446字节存放代码(如bootloader的前半部分代码),随后64字节存放分区表,最后两字节存放MBR有效标志,如下图:

3.Bootloader:它用于加载kernel,本文主要分析它。

4.Kernel与initrd:kernel是系统的核心,它用于资源的管理与调度,initrd可以认为是一个虚拟磁盘,它将作为初始跟文件系统被使用。其实它是一个历史叫法,它以前是memdisk,后来发现他压根没必要做成虚拟磁盘,直接ramfs不香么,后来就有了initramfs,现在只是习惯这样叫它。这部分将在下一篇详细介绍。

5.Init系统:kernel启动的第一个用户进程,如systemd,openrc等,他们的文件名一般叫做init。

Bootloader

一般情况下是不需要分析Bootloader,但是有的产品会对它做定制导致设备模拟时启动过程异常,或者kernel不是标准格式难以分析,在这时就需要分析Bootloader的逻辑了。 首先说说Bootloader是干嘛的,它的最主要的作用就是把kernel加载到内存并运行它,但是现在它的功能越来越强大了,下面说说它的核心功能:

  1. 加载内核,这里分两步---如何找到它以及如何解析它:BIOS是不可能支持所有文件系统的,事实上它甚至不支持文件系统,所以它只能按磁盘驱动的接口去访问文件,如CHS或更新的LBA,总的还是按扇区号定位,这就要求kernel必须存放在连续的扇区,现在它由Bootloader来做了,比如Grub2它就支持各种阉割版的文件系统(如fat/ext3/btrfs等),这时只需要bootloader的引导部分连续存放并使用如LBA的方式加载,它加载完文件系统功能后就可以直接访问文件系统里的文件或配置。至于解析kernel,kernel可以按多种格式存放,如elf/pe/a.out,它们的存放形式和被加载到内存中的形式很可能是不一致的,这时就需要bootloader去解析它的格式并正确的将其加载到内存。
  2. 为内核传递参数:用户可以根据需要,在启动时为内核提供多种参数,如使用root=指定根目录位置,quiet指定不输出详细信息,noaslr不启用内核地址随机化等

而其它就是一些非核心功能了,如选择启动项与链式启动可以用来进入救援模式或启动其它不支持的系统,密码与加密功能能用于对boot分区加密或kernel签名校验,还有一些支持图形化界面不过这些都不重要啦,我们只关注核心功能~

正式内容前先看看在实模式时,它的物理地址布局情况,如下图:

注意这片区域并不是都映射到了DRAM上,可能直接映射到ROM或者设备寄存器上,其中BDA的结构部分规范化如下:

而EBDA的格式并没有规范化。当切换到保护模式里面很多内存就可被回收重用,但是为了应对以后可能的BIOS调用,以及SMM/ACPI等功能也会继续使用这部分内存,因此即使切换到保护模式部分区域的内存并不会被回收。大于1M的区域是没有定义用途的,这部分内存布局可以使用BIOS提供的内存探测调用来获取,比如使用最有名的E820调用,一种常见的情况如下:

这里同样留意下之后的区域也不是完全映射到DRAM上的,它有大片的区域被预留来映射设备空间。

Grub2

见到最多的就是Grub2了,它是在旧版的Grub(grub legacy)基础上重构得到的,与旧版差别很大,通常它由放置在两个位置的部分组成:位于第一个分区之前的引导核心部分和位于boot分区配置与扩展模块,下面会详细介绍这两部分。

MBR与MBRGAP

如上所述MBR只有446字节可存放代码,肯定不够存放Grub2核心代码,基本所有Bootloader在MBR里实现的功能都是加载剩余部分代码,剩余部分代码可存放于boot分区,由于boot分区有文件系统,用户可以直接操作里面的文件,因此极易改变Grub2在扇区上的位置(如碎片整理),这就会导致系统无法启动。另外也可存放于MBR GAP,所谓MBR GAP是指MBR到第一个分区之间的区域,一般分区会按照2K或2M等大小对齐,于是中间会有一些扇区是未使用的,它们刚好可以用于存放Grub2的部分代码,存放在这里的代码不属于任何分区用户无法直接看到,因此不容易被破坏,如下图: image.png 这是一种Grub2前半部分的布局方式,BOOT.IMG的作用是加载CORE.IMG,而CORE.IMG是由多个部分组成:

  1. DISKBOOT.IMG: 磁盘驱动用于加载剩余部分
  2. XX_DECOMPRESSOR.IMG: 解压缩代码,由图可见其后面是被被压缩的数据,这些数据需要由它解压
  3. KERNEL.IMG: Grub2的kernel,包含Grub2的基本功能,若下一阶段配置错误或文件丢失,依然能依靠它执行最后的救援操作
  4. MODULES:Grub2是模块化的,除了最基本的功能其他功能都被编译进了单个模块,通常它们存放于/boot/grub/{arch}/目录下,以.mod结尾,linux下为是ELF格式,用户也可以直接把它们放置在上图所示位置,Grub2启动时会优先从这里面加载模块
  5. CONFIG:Grub2支持配置文件,它通常位于/boot/grub/grub.cfg,它也可以被放置在上图所示位置,之后启动时会先加载此处的配置
  6. PUBKEYS:Grub2可对kernel等进行签名校验,签名的公钥位于该位置

正常情况下我们是不需要分析这片区域,但是若发现之后的数据是非常规格式,如找不到它的kernel在哪个位置,怎么存放的,不知道它的kernel是什么格式,是否被加密,亦或者还没有到加载kernel就启动失败了,此时就必须要分析这里了,此处自顶向下先描述文件布局格式再分析几个关键部分的实现细节。

CORE生成过程

MBR直接是单个镜像不多说,CORE.IMG是grub_mkimage.c将多个镜像组合而成的,它会根据用户指定目标或自动探测而使用一种target description,本文分析的是i386-pc

static const struct grub_install_image_target_desc image_targets[] =
  {
    {
      .dirname = "i386-pc",
      .names = { "i386-pc", NULL },
      .voidp_sizeof = 4, // 指针大小为4
      .bigendian = 0, // 小端序
      .id = IMAGE_I386_PC, 
      .flags = PLATFORM_FLAGS_DECOMPRESSORS,// 这里的标志只有一个表示使用压缩
      .total_module_size = TARGET_NO_FIELD, // 接下来几个值表示这些大小被存放在哪个位置
      .decompressor_compressed_size = GRUB_DECOMPRESSOR_I386_PC_COMPRESSED_SIZE,
      .decompressor_uncompressed_size = GRUB_DECOMPRESSOR_I386_PC_UNCOMPRESSED_SIZE,
      .decompressor_uncompressed_addr = TARGET_NO_FIELD,
      .section_align = 1,
      .vaddr_offset = 0,
      .link_addr = GRUB_KERNEL_I386_PC_LINK_ADDR,
      .default_compression = GRUB_COMPRESSION_LZMA // 默认压缩算法,用于压缩kernel与modules
    },
    ...
    }

进入它的核心逻辑:

grub_install_generate_image (const char *dir, const char *prefix,
                 FILE *out, const char *outname, char *mods[],
                 char *memdisk_path, char **pubkey_paths,
                 size_t npubkeys, char *config_path,
                 const struct grub_install_image_target_desc *image_target,
                 int note, grub_compression_t comp, const char *dtb_path,
                 const char *sbat_path, int disable_shim_lock)
{
    ...
    // 设置压缩算法,此处为lzma
  if (comp == GRUB_COMPRESSION_AUTO)
    comp = image_target->default_compression;
  if (image_target->id == IMAGE_I386_PC
      || image_target->id == IMAGE_I386_PC_PXE
      || image_target->id == IMAGE_I386_PC_ELTORITO)
    comp = GRUB_COMPRESSION_LZMA;

    // 根据指定的要嵌入的列表与moddep.lst文件生成最终要嵌入的文件的路径列表
    // moddep.lst记录了每个模块的依赖关系,此处会把指定模块及其依赖全部加入列表
  path_list = grub_util_resolve_dependencies (dir, "moddep.lst", mods);
    // 获取kernel.img的路径
  kernel_path = grub_util_get_path (dir, "kernel.img");
    /* 根据指针大小选择module info结构体,它在kernel.img之后用于描述要嵌入的文件的总的信息,此处为
    struct grub_module_info32
    {
        grub_uint32_t magic;  // magic为gmim 0x676d696d
        grub_uint32_t offset; // 后续文件的起始偏移
        grub_uint32_t size;  // 所有嵌入文件的大小
    };
    */
  if (image_target->voidp_sizeof == 8)
    total_module_size = sizeof (struct grub_module_info64);
  else
    total_module_size = sizeof (struct grub_module_info32);

接下来会计算要嵌入的所有文件的大小信息,每个被嵌入的文件有个头部,描述该文件类型与大小:

    /* enum  {
          OBJ_TYPE_ELF, // 可加载模块
          OBJ_TYPE_MEMDISK,  
          OBJ_TYPE_CONFIG,  // 最先被解析的配置文件,只能指定一个
          OBJ_TYPE_PREFIX,  // prefix路径,描述boot分区路径
          OBJ_TYPE_PUBKEY,  // 公钥,用于文件签名校验
          OBJ_TYPE_DTB,  // 设备树文件
          OBJ_TYPE_DISABLE_SHIM_LOCK
        };
    struct grub_module_header
    {
      grub_uint32_t type; // 文件类型,如上
      grub_uint32_t size;  // 文件大小
    };
    */
  for (p = path_list; p; p = p->next) // 所有可加载模块的大小,其他类似被省略
    total_module_size += (ALIGN_ADDR (grub_util_get_image_size (p->name))
              + sizeof (struct grub_module_header));
  {
    size_t i;
    for (i = 0; i < npubkeys; i++)
      {/*...*/;}
  }

  if (memdisk_path){/*...*/;}

  if (dtb_path){/*...*/;}

  if (sbat_path != NULL && image_target->id != IMAGE_EFI)
    grub_util_error (_(".sbat section can be embedded into EFI images only"));

  if (disable_shim_lock)
    total_module_size += sizeof (struct grub_module_header);

  if (config_path){/*...*/;}

  if (prefix){/*...*/;}

  grub_util_info ("the total module size is 0x%" GRUB_HOST_PRIxLONG_LONG,
          (unsigned long long) total_module_size);

接下来它会先解析kernel的节去生成内存布局信息,之后再生成module info:

  /* 此处生成COREIMG的内存布局信息,它由util/grub-mkimagexx.c实现,该文件很大,但是由于此处生成的是IMAGE_I386_PC,
     它不属于可重定位的项,因此实际运行的代码并不多,此处不再分析。*/
  if (image_target->voidp_sizeof == 4)
    kernel_img = grub_mkimage_load_image32 (kernel_path, total_module_size,
                        &layout, image_target);
  else
    kernel_img = grub_mkimage_load_image64 (kernel_path, total_module_size,
                        &layout, image_target);

    // 为嵌入文件生成module info头,记录所有文件的大小等信息
  if (image_target->voidp_sizeof == 8)
    { /* ...  */ }
  else
    {
      /* Fill in the grub_module_info structure.  */
      struct grub_module_info32 *modinfo;
      if (image_target->flags & PLATFORM_FLAGS_MODULES_BEFORE_KERNEL)
    modinfo = (struct grub_module_info32 *) kernel_img;
      else
    modinfo = (struct grub_module_info32 *) (kernel_img + layout.kernel_size);
      modinfo->magic = grub_host_to_target32 (GRUB_MODULE_MAGIC);
      modinfo->offset = grub_host_to_target_addr (sizeof (struct grub_module_info32));
      modinfo->size = grub_host_to_target_addr (total_module_size);
      if (image_target->flags & PLATFORM_FLAGS_MODULES_BEFORE_KERNEL)
    offset = sizeof (struct grub_module_info32);
      else
    offset = layout.kernel_size + sizeof (struct grub_module_info32);
    }

接下来分别将可加载模块,pubkey,memdisk等文件加载并连接到kernel尾,之后将它们整体压缩:

/* 如下是可加载模块的处理过程,其他文件过程类似,被省略... */
for (p = path_list; p; p = p->next)
    {
      struct grub_module_header *header;
      size_t mod_size;

      mod_size = ALIGN_ADDR (grub_util_get_image_size (p->name));

      header = (struct grub_module_header *) (kernel_img + offset);
      header->type = grub_host_to_target32 (OBJ_TYPE_ELF);
      header->size = grub_host_to_target32 (mod_size + sizeof (*header));
      offset += sizeof (*header);

      grub_util_load_image (p->name, kernel_img + offset);
      offset += mod_size;
    }

  {
    size_t i;
    for (i = 0; i < npubkeys; i++)
      {...}
  }

  if (memdisk_path){...}

  if (dtb_path){...}

  if (disable_shim_lock){...}

  if (config_path){...}

  if (prefix){...}

  // 对整体进行压缩
  compress_kernel (image_target, kernel_img, layout.kernel_size + total_module_size,
           &core_img, &core_size, comp);
  free (kernel_img);

压缩完添加上lzma_decompress.img与diskboot.img镜像,并填充镜像相应位置:

  // 根据压缩算法选择对应的解压镜像,此处为lzma_decompress.img
  if (image_target->flags & PLATFORM_FLAGS_DECOMPRESSORS)
    {
      switch (comp)
    {
    case GRUB_COMPRESSION_LZMA:
      name = "lzma_decompress.img";
      break;
        ...
    }

      decompress_path = grub_util_get_path (dir, name);
      decompress_size = grub_util_get_image_size (decompress_path);
      decompress_img = grub_util_read_image (decompress_path);
        // 判断解压后的文件是否超限
      if ((image_target->id == IMAGE_I386_PC
       || image_target->id == IMAGE_I386_PC_PXE
       || image_target->id == IMAGE_I386_PC_ELTORITO)
      && decompress_size > GRUB_KERNEL_I386_PC_LINK_ADDR - 0x8200)
    grub_util_error ("%s", _("Decompressor is too big"));

        /* 若decompressor_compressed_size域存在,将压缩后大小写入该域,此处存在为
        lzma_decompress.img的0x08处 */
      if (image_target->decompressor_compressed_size != TARGET_NO_FIELD)
    *((grub_uint32_t *) (decompress_img
                 + image_target->decompressor_compressed_size))
      = grub_host_to_target32 (core_size);
        /* 若decompressor_uncompressed_size域存在,将原始大小写入该域,此处存在为
        lzma_decompress.img的0x0c处 */
      if (image_target->decompressor_uncompressed_size != TARGET_NO_FIELD)
    *((grub_uint32_t *) (decompress_img
                 + image_target->decompressor_uncompressed_size))
      = grub_host_to_target32 (layout.kernel_size + total_module_size);

      full_size = core_size + decompress_size;
      full_img = xmalloc (full_size);
      memcpy (full_img, decompress_img, decompress_size);
      memcpy (full_img + decompress_size, core_img, core_size);
      core_img = full_img;
      core_size = full_size;
    }
    // 判断文件大小是否超过限制
  switch (image_target->id)
    {
    case IMAGE_I386_PC:
    case IMAGE_I386_PC_PXE:
    case IMAGE_I386_PC_ELTORITO:
    if (GRUB_KERNEL_I386_PC_LINK_ADDR + core_size > 0x78000
        || (core_size > (0xffff << GRUB_DISK_SECTOR_BITS))
        || (layout.kernel_size + layout.bss_size
        + GRUB_KERNEL_I386_PC_LINK_ADDR > 0x68000))
      grub_util_error (_("core image is too big (0x%x > 0x%x)"),
               GRUB_KERNEL_I386_PC_LINK_ADDR + (unsigned) core_size,
               0x78000);
    ...
    }
    // 
  switch (image_target->id)
    {
    case IMAGE_I386_PC:
    case IMAGE_I386_PC_PXE:
    case IMAGE_I386_PC_ELTORITO:
      {
    unsigned num;
    char *boot_path, *boot_img;
    size_t boot_size;
    // 计算CORE.IMG与DECOMPRESSOR所占扇区数
    num = ((core_size + GRUB_DISK_SECTOR_SIZE - 1) >> GRUB_DISK_SECTOR_BITS);
    if (image_target->id == IMAGE_I386_PC_PXE){/*...*/}
    if (image_target->id == IMAGE_I386_PC_ELTORITO){/*...*/}

    boot_path = grub_util_get_path (dir, "diskboot.img");
    boot_size = grub_util_get_image_size (boot_path);
    if (boot_size != GRUB_DISK_SECTOR_SIZE)
      grub_util_error (_("diskboot.img size must be %u bytes"),
               GRUB_DISK_SECTOR_SIZE);
    // 加载DISKBOOT
    boot_img = grub_util_read_image (boot_path);

    {/* 将CORE.IMG与DECOMPRESSOR所占扇区写入DISKBOOT的尾部 */
      struct grub_pc_bios_boot_blocklist *block;
      block = (struct grub_pc_bios_boot_blocklist *) (boot_img
                              + GRUB_DISK_SECTOR_SIZE
                              - sizeof (*block));
      block->len = grub_host_to_target16 (num);
    }

    grub_util_write_image (boot_img, boot_size, out, outname);
      }
      break;
        ...
    }
    // 写入磁盘
  grub_util_write_image (core_img, core_size, out, outname);
}

BOOT.IMG

它是由grub-core/boot/i386/pc/boot.S生成的,该文件的开始是一些宏定义,我们只需要关注scratch的定义:

    .macro scratch

    /* scratch space */
mode:
    .byte   0                           ; 存储磁盘操作模式,如LBA
disk_address_packet:  ; 用于存放LBA操作的DAP
sectors:
    .long   0
heads:
    .long   0
cylinders:
    .word   0
sector_start:
    .byte   0
head_start:
    .byte   0
cylinder_start:
    .word   0
    /* more space... */
    .endm

该宏定义的结构和之前提到的LBA有关,由于在这个阶段我们只能使用很低级的磁盘操作方式(即CHS与LBA),而LBA是有Extend Disk Drive(EDD)实现的,抽象程度更高使用更简单,因此会首先尝试使用它操作磁盘,因为现在基本都是支持EDD所以下文也只介绍它,使用它先使用INT13中断,它是BIOS提供的磁盘(本文磁盘的磁盘是一种很飘忽不定的东西,没准它是软盘,没准它是洗衣机?)相关的中断,令AH=0x41即可检查是否支持EDD,若支持可令AH=0x42读指定扇区,此时会涉及到一个叫DAP(Disk Address Packet)的结构,它用于描述从那个扇区开始读多少个扇区的数据到哪个位置,如下图: image.png 跳过宏定义,是代码起始点:

    .text
    .code16             ;; 定义生成16位的代码

.globl _start, start;
_start:
start:
    /*
     * _start is loaded at 0x7c00 and is jumped to with CS:IP 0:0x7c00
     */
    jmp LOCAL(after_BPB)            ;; 直接跳转到after_BPB这个标签处
    nop /* do I care about this ??? */

#ifdef HYBRID_BOOT
    ....
#else
    .org GRUB_BOOT_MACHINE_BPB_START        ;; GRUB_BOOT_MACHINE_BPB_START=0x3
    .org 4
#endif
#ifdef HYBRID_BOOT
    ...
#else
    scratch                     ;; DAP会展开到此处
#endif

    .org GRUB_BOOT_MACHINE_BPB_END          ;; GRUB_BOOT_MACHINE_BPB_END=0x5a

LOCAL(kernel_address):
    .word   GRUB_BOOT_MACHINE_KERNEL_ADDR   ;; GRUB_BOOT_MACHINE_KERNEL_ADDR=0x8000

#ifndef HYBRID_BOOT
    .org GRUB_BOOT_MACHINE_KERNEL_SECTOR ;; GRUB_BOOT_MACHINE_KERNEL_SECTOR=0x5c
LOCAL(kernel_sector):
    .long   1                                                           ;; kernel的起始扇区低位
LOCAL(kernel_sector_high):
    .long   0                                                           ;; kernel的起始扇区高位
#endif

    .org GRUB_BOOT_MACHINE_BOOT_DRIVE       ;; GRUB_BOOT_MACHINE_BOOT_DRIVE=0x64
boot_drive:
    .byte 0xff  /* the disk to load kernel from */
            /* 0xff means use the boot drive */

如上,它的开始直接是一条JMP指令,之后是一些数据区,比如DAP会存放在此处,还有一个容易让人误解的BPB(BIOS Parameter Block)也存放于此,注意它并不是BIOS的参数,它实际只在VBR(Volume Boot Record)上有效,用于描述某些文件系统(FAT/NTFS等)所需的元信息,因此我们可以忽略它。注意它里面有些是.org指令,可理解为对齐。

LOCAL(after_BPB):

/* general setup */
    cli     ;; 关闭外部中断

        /*
         * 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).
         */
    .org GRUB_BOOT_MACHINE_DRIVE_CHECK
boot_drive_check:
        jmp     3f  /* grub-setup may overwrite this jump */
        testb   $0x80, %dl
        jz      2f
3:
    /* Ignore %dl different from 0-0x0f and 0x80-0x8f.  */
    testb   $0x70, %dl  ;; 判断dl(dl用于存放boot disk号)是否正确,若不合法将其改为0x80
    jz      1f
2:  
        movb    $0x80, %dl
1:
    ljmp    $0, $real_start ;; 有些有问题的BIOS会把段地址设置为0x07C0,此处修改为0x0000

real_start:

    /* 将ds和ss设置为0*/
    xorw    %ax, %ax
    movw    %ax, %ds
    movw    %ax, %ss

    /* 设置栈位置为0x2000 */
    movw    $GRUB_BOOT_MACHINE_STACK_SEG, %sp ;; GRUB_BOOT_MACHINE_STACK_SEG=0x2000

    sti     ;; 启用外部中断

    /* 若boot_drive有效(非0xff)就将其赋值给dl */
    movb   boot_drive, %al      
    cmpb    $0xff, %al
    je  1f
    movb    %al, %dl
1:
    /* save drive reference first thing! */
    pushw   %dx

    /* MSG是一个宏,它通过BIOS中断去输出字符串 */
    MSG(notification_string)

    /* 将DAP的地址赋值给si */
    movw    $disk_address_packet, %si

    /* 检查是否支持LBA */
    movb    $0x41, %ah      ;; 令AH=0x41,在int13中表示检测是否支持EDD
    movw    $0x55aa, %bx    
    int $0x13

    /*
     *  %dl may have been clobbered by INT 13, AH=41H.
     *  This happens, for example, with AST BIOS 1.04.
     */
    popw    %dx
    pushw   %dx

    /* use CHS if fails */
    jc  LOCAL(chs_mode)
    cmpw    $0xaa55, %bx
    jne LOCAL(chs_mode)

    andw    $1, %cx
    jz  LOCAL(chs_mode)

如上它首先处理BootDrive,接下来它也总是用DL存储改值,后续很多磁盘调用隐含的会使用它。接着设置DS与SS段寄存器为0x00,接着设置了SP建立了栈,将DAP的地址赋给SI,然后使用int13检查是否支持LBA,此处认为它支持,于是到如下代码:

LOCAL(lba_mode):
    xorw    %ax, %ax
    movw    %ax, 4(%si)     ;; si为DAP的地址,此处偏移4表示目的buffer的offset,设为0

    incw    %ax
    movb    %al, -1(%si)    ;; 设置磁盘操作模式为1(非0)

    /* the blocks */
    movw    %ax, 2(%si)     ;; 要读的扇区数为1

    movw    $0x0010, (%si)  ;; 设置DAP的大小

    /* 设置要读的扇区位置,一般为默认值1 */
    movl    LOCAL(kernel_sector), %ebx  ;; 注意实模式类似于8086但它其实是支持使用32位寄存器的
    movl    %ebx, 8(%si)
    movl    LOCAL(kernel_sector_high), %ebx
    movl    %ebx, 12(%si)

    /* 目标buffer的segment,设为0x7000,由于offset为0,即写到0x70000这个位置 */
    movw    $GRUB_BOOT_MACHINE_BUFFER_SEG, 6(%si) 

    /* EDD读操作 */
    movb    $0x42, %ah
    int $0x13

    jc  LOCAL(chs_mode)

    movw    $GRUB_BOOT_MACHINE_BUFFER_SEG, %bx   ;; 把buffer seg(0x7000)给bx
    jmp LOCAL(copy_buffer)

如上,它使用LBA读取将kernel的第一个扇区读入0x70000这个位置,注意这里的kernel为上图的CORE.IMG,它第一个扇区一般为DISKBOOT.IMG,它一般位于磁盘的第二个扇区,除非用户在安装时指定其他位置,接着如下:

LOCAL(copy_buffer):
    /*
     * We need to save %cx and %si because the startup code in
     * kernel uses them without initializing them.
     */
    pusha               ;; 保存AX CX DX BX SP BP SI DI
    pushw   %ds     
    /*将ds:si 所指内容复制到 es:di处,共0x200字节(一个扇区) */
    movw    $0x100, %cx
    movw    %bx, %ds
    xorw    %si, %si
    movw    $GRUB_BOOT_MACHINE_KERNEL_ADDR, %di ;; ds:si -> 0x7000:0x0000
    movw    %si, %es                                                        ;; es:di -> 0x0000:0x8000

    cld

    rep
    movsw

    popw    %ds
    popa

    /* boot kernel */
    jmp *(LOCAL(kernel_address))

如上,它将buffer的内容复制到了0x8000这个位置,并跳转到该位置,即将控制权交给了DISKBOOT.IMG。

DISKBOOT.IMG

其实此处还可选CDBOOT/PXEBOOT等,不过最常见的还是DISKBOOT,它是由grub-core/boot/i386/pc/diskboot.S生成的,首先看看它的尾部:

    .org 0x200 - GRUB_BOOT_MACHINE_LIST_SIZE ;; GRUB_BOOT_MACHINE_LIST_SIZE=0x0c
LOCAL(firstlist):   
blocklist_default_start:
    /* 后续的扇区起始位置 */
    .long 2, 0
blocklist_default_len:
    /* 将要读取的扇区长度,由mkimage填充 */
    .word 0
blocklist_default_seg:
    /* 后续数据将会被加载的段位置,由于DISKBOOT已占用一个扇区(0x200),所以此处加了0x20*/
    .word (GRUB_BOOT_MACHINE_KERNEL_SEG + 0x20)

在上面mkimage时已经提到,它在连接完XX_DECOMPRESSOR与COMPRESSED_CODE后会将其所需扇区数等写入DISKBOOT,即如上区域。现在开始看看它的开头:

    .file   "diskboot.S"
    .text
    .code16
    .globl  start, _start
start:
_start:

    pushw   %dx

    pushw   %si
    MSG(notification_string)
    popw    %si

    /* 将firstlist(即上面提到的结构)的地址存入DI */
    movw    $LOCAL(firstlist), %di

    /* 要读数据的起始扇区存于EBP */
    movl    (%di), %ebp

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

    /* 若没有要读的数据直接跳转到bootit */
    cmpw    $0, 8(%di)

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

LOCAL(setup_sectors):
    /* 看是否支持LBA,此处直接使用之前保存的标志位即可 */
    cmpb    $0, -1(%si)

    je  LOCAL(chs_mode)

    /* 保存要读的扇区起始位置 */
    movl    (%di), %ebx
    movl    4(%di), %ecx

    /* Phoenix EDD 一次最多只能读0x7f,此处对它进行判断 */
    xorl    %eax, %eax
    movb    $0x7f, %al

    cmpw    %ax, 8(%di) 

    /* 若总的扇区数大于0x7f则ax为0x7f */
    jg  1f

    /* 否则为总的扇区数 */
    movw    8(%di), %ax

1:              ;; 此时ax里存的是此次要读的扇区数
    /* 总的要读的扇区数-=本次要读的 */
    subw    %ax, 8(%di)

    /* 将记录的扇区起始位置后移ax个扇区,注意,原始值在之前已经被保存在EBX和ECX了 */
    addl    %eax, (%di)
    adcl    $0, 4(%di)

    /* set up disk address packet */

    /* 设置DAP大小为0x10 */
    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 */
    movw    $GRUB_BOOT_MACHINE_BUFFER_SEG, 6(%si)

    /* save %ax from destruction! */
    pushw   %ax

    /* the offset of buffer address */
    movw    $0, 4(%si)

    movb    $0x42, %ah
    int $0x13

    jc  LOCAL(read_error)

    movw    $GRUB_BOOT_MACHINE_BUFFER_SEG, %bx
    jmp LOCAL(copy_buffer)
  ......
  LOCAL(copy_buffer):

    /* 将目的段地址放入ES,初始时0x820,在DISKBOOT的后面 */
    movw    10(%di), %es    

    /* 恢复AX,它里面存放的是上一次读的扇区数 */
    popw    %ax

    /* AX左移5,AX是扇区数,左移9表示字节数,由于段要左移4,所以此处左移5) */
    shlw    $5, %ax 
    addw    %ax, 10(%di)    /* 将左移5的AX加上目标BUFFER段上,表示下次读时的段偏移 */

    /* save addressing regs */
    pusha
    pushw   %ds

    /* 设置复制的次数,由于是MOVSW每次两字节,上面已经左移5了,所以此次在左移3 */
    shlw    $3, %ax
    movw    %ax, %cx

    xorw    %di, %di    /* zero offset of destination addresses */
    xorw    %si, %si    /* zero offset of source addresses */
    movw    %bx, %ds    /* restore the source segment */

    cld     /* 正向复制 */

    rep     /* sets a repeat */
    movsw       /* this runs the actual copy */

    popw    %ds
    MSG(notification_step)
    popa

    /* 检查是否完成了读取,若未完成,跳转到setup_sectors继续读取 */
    cmpw    $0, 8(%di)
    jne LOCAL(setup_sectors)

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

    /* 若已完成,跳转到bootloop */
    jmp LOCAL(bootloop)

如上,代码量挺多其实就是将CORE.IMG后续的内容读入到内存中,之后跳转到bootloop,代码如下:

LOCAL(bootloop):
    /* 检查是否还有扇区要读取 */
    cmpw    $0, 8(%di)

    /* 若没有跳转到bootit */
    je  LOCAL(bootit)
LOCAL(bootit):
    /* print a newline */
    MSG(notification_done)
    popw    %dx /* 确保DL里存的还是boot drive */
    ljmp    $0, $(GRUB_BOOT_MACHINE_KERNEL_ADDR + 0x200) ;; 跳转到新加载的数据处

在将CORE.IMG的所有数据读到内存后,跳转到新读入的区域,即XX_DECOMPRESSOR.IMG,它一般是LZMA的。

LZMA_DECOMPRESSOR.IMG

它由grub-core/boot/i386/pc/startup_raw.S生成,首先依然看代码:

    .code16 ;; 当前依然是实模式,生成16位代码

    .globl  start, _start
start:
_start:
LOCAL (base):
#ifdef __APPLE__
    ...
#else
    ljmp $0, $ABS(LOCAL (codestart)) ;; 它的开始是一个跳转
#endif

    .org GRUB_DECOMPRESSOR_MACHINE_COMPRESSED_SIZE ;; 压缩后的大小 offset=0x08
LOCAL(compressed_size):
    .long 0
    .org GRUB_DECOMPRESSOR_MACHINE_UNCOMPRESSED_SIZE ;; 未压缩的大小 offset=0x0c
LOCAL(uncompressed_size):
    .long 0

    .org GRUB_KERNEL_I386_PC_REED_SOLOMON_REDUNDANCY  ;; RS冗余 offset=0x10
reed_solomon_redundancy:
    .long   0
    .org GRUB_KERNEL_I386_PC_NO_REED_SOLOMON_LENGTH  ;; GRUB_KERNEL_I386_PC_NO_REED_SOLOMON_LENGTH=0x14
    .short  (LOCAL(reed_solomon_part) - _start)

/*
 *  This is the area for all of the special variables.
 */
    .org GRUB_DECOMPRESSOR_I386_PC_BOOT_DEVICE  ;; GRUB_DECOMPRESSOR_I386_PC_BOOT_DEVICE=0x18
LOCAL(boot_dev):
    .byte   0xFF, 0xFF, 0xFF
LOCAL(boot_drive):
    .byte   0x00

/* the real mode code continues... */
LOCAL (codestart):
    cli     /* 关中断 */

    /* 将数据段和栈段设置为0 */
    xorw    %ax, %ax
    movw    %ax, %ds
    movw    %ax, %ss
    movw    %ax, %es

    /* 设置实模式的栈 */
    movl    $GRUB_MEMORY_MACHINE_REAL_STACK, %ebp ;; 0x2000-0x10
    movl    %ebp, %esp

    sti     /* we're safe again */

    /* save the boot drive */
    movb    %dl, LOCAL(boot_drive)

    /* reset disk system (%ah = 0) */
    int $0x13

    /* 过渡到保护模式 */
    calll   real_to_prot

这里面定义的一些数据在之前mkimage已经提到过了,之前看代码它做的所有操作都是为了进入保护模式,这时的代码在grub-core/kern/i386/realmode.S。切换到保护模式,有两件事要做:设置新的全局描述符表与中断描述符表。由于保护模式与实模式使用的逻辑地址转换方式不同,保护模式不再是段寄存器左移4位作为基地址,而是将其作为索引去全局描述符表或本地描述符表中获取对应基址,因此需要先建立GDT表,如下:

    .p2align    5   /* force 32-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
/* this is the GDT descriptor */
gdtdesc:
    .word   0x27            /* limit */
    .long   gdt         /* addr */
LOCAL(realidt):
    .word 0x400
    .long 0

可见它其实使用的平坦内存模型,逻辑地址和物理地址是一一对应的,可以认为接下来访问的依然是物理地址,没有转换地址的过程。而对于中断,在实模式它使用的是中断向量表IVT,它位于前0x200字节,那里面是BIOS提供的中断服务例程的地址,在保护模式它下不再使用这个地址的中断,而是由IDTR指向的IDT里的中断描述符所含地址,因此在保护模式也需要重新设置它,它的定义如下:

protidt:
    .word 0
    .long 0

可见此时它并没有中断服务例程可用。随后即可执行下面的代码进入保护模式:

real_to_prot:
    .code16
    cli ;; 关中断

    /* 将数据段置为0,用于加载GDT */
    xorw    %ax, %ax
    movw    %ax, %ds

    lgdtl   gdtdesc  ;; 加载GDT

    /* 通过设置CR0的PE标志开启保护模式,注意此时还未真正进入保护模式 */
    movl    %cr0, %eax
    orl $GRUB_MEMORY_CPU_CR0_PE_ON, %eax
    movl    %eax, %cr0

    /* 需要使用长跳转加载CS才会真的进入保护模式,注意这里CS赋值为8,右移3为才是GDT里的索引 */
    ljmpl   $GRUB_MEMORY_MACHINE_PROT_MODE_CSEG, $protcseg ;; GRUB_MEMORY_MACHINE_PROT_MODE_CSEG=8

    .code32
protcseg:
    /* 将其他段选择子都设置为0x10,即GDT里的第二项-->数据段 */
    movw    $GRUB_MEMORY_MACHINE_PROT_MODE_DSEG, %ax ;; GRUB_MEMORY_MACHINE_PROT_MODE_DSEG=0x10
    movw    %ax, %ds
    movw    %ax, %es
    movw    %ax, %fs
    movw    %ax, %gs
    movw    %ax, %ss

    /* 将ESP(存放的是实模式的地址)的数据放入GRUB_MEMORY_MACHINE_REAL_STACK */
    movl    (%esp), %eax
    movl    %eax, GRUB_MEMORY_MACHINE_REAL_STACK ;; GRUB_MEMORY_MACHINE_REAL_STACK=(0x2000 - 0x10)

    /* 重新设置一个保护模式使用的栈 */
    movl    protstack, %eax
    movl    %eax, %esp
    movl    %eax, %ebp

    /* 将实模式的返回地址放到保护模式的栈顶 */
    movl    GRUB_MEMORY_MACHINE_REAL_STACK, %eax
    movl    %eax, (%esp)

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

    sidt LOCAL(realidt)  ;; 注意这些保存的信息,它之后在将控制权交给内核前会再次切换回实模式
    lidt protidt            ;; 加载新的IDT

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

注意分段一直存在无法关闭,而分页是进入保护模式后可选的开启,因此此时可以不使用分页,也就不用建立页表。

调用返回后,接下来就是保护模式下的代码了:

    .code32         ;; 开始编译32为的代码

    cld                 ;;  重置方向
    call    grub_gate_a20       ;; 开启A20,这样就可以访问超过1M的内存了

    movl    LOCAL(compressed_size), %edx        ;; 将压缩后的大小放入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       ;; RS码数据量
    leal    LOCAL(reed_solomon_part), %eax  ;; RS码数据其实
    cld
    call    EXT_C (grub_reed_solomon_recover) ;; 执行RS算法 (eax,edx,ecx)
    jmp post_reed_solomon           ;; 

它先激活了A20门,之后调用RS算法对后续部分进行校验与纠错,注意这里是汇编代码,编译器不会像C一样为它使用某种调用约定,因此传参方式由代码编写者自定义。这之后跳转到post_reed_solomon:

post_reed_solomon:

#ifdef ENABLE_LZMA
    movl    $GRUB_MEMORY_MACHINE_DECOMPRESSION_ADDR, %edi ;; 被解压的地址,默认0x100000
#ifdef __APPLE__
    movl    $decompressor_end, %esi
#else
    movl    $LOCAL(decompressor_end), %esi                      ;; ESI后续数据压缩数据开始的位置
#endif
    pushl   %edi
    movl    LOCAL (uncompressed_size), %ecx                     ;; ECX保存解压后大小
    leal    (%edi, %ecx), %ebx
    /* Don't remove this push: it's an argument.  */
    push    %ecx
    call    _LzmaDecodeA    ;; ebx=stat,esi=indata,edi=outdata,ecx=outsize
    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                   ;;  跳转到下一个IMG-> kernel继续执行

#ifdef ENABLE_LZMA
#include "lzma_decode.S"            ;; 嵌入的解压代码
#endif

    .p2align 4

#ifdef __APPLE__
        ...
#else
    .bss
LOCAL(decompressor_end):        ;; 标志XX_DECOMPRESSOR结束,也是后续被压缩数据的起始位置
#endif

如上它调用_LzmaDecodeA将后续的数据解压,并将该段定义的一些函数地址,保存的实模式中断描述表位置放入寄存器,之后跳转到解压后的首地址,即kernel.img处,由于之前每次跳转后都不会再使用上一个段的代码和数据了,所以没有传递本段的数据,而XX_DECOMPRESSOR里定义的模式切换相关的代码和数据会被之后的KERNEL.IMG再次使用到,因此这里把它们放入寄存器传递了过去。

KERNEL.IMG

现在进入它的核心部分了,它从grub-core/kern/i386/pc/startup.S开始:

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

  /* 这里它先将上一个IMG传过来的地址保存当前IMG的对应位置 */
    movl    %ecx, (LOCAL(real_to_prot_addr) - _start) (%esi) ;; esi为当前镜像(IMG)起始位置
    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
    movl    $(_edata - _start), %ecx
#endif
    movl    $(_start), %edi ;; 这里开始复制操作,
    rep
    movsb

    movl    $LOCAL (cont), %esi
    jmp *%esi

在之后它为跳转到C语言代码做最后的准备:

LOCAL(cont):

#ifdef __APPLE__
    ...
#else
    /* clean out the bss */
    movl    $BSS_START_SYMBOL, %edi  ;; BSS的开始地址

    /* compute the bss length */
    movl    $END_SYMBOL, %ecx
#endif
    subl    %edi, %ecx                              ;; BSS的长度

    /* clean out */
    xorl    %eax, %eax
    cld
    rep
    stosb                                                       ;; 将BSS清零

    movl    %edx, EXT_C(grub_boot_device)

    /* 调用grub_main */
    call EXT_C(grub_main)

  ...
  /* 之前可见在保护模式它没有中断可用一直是关中断的,若要调用BIOS中断它的实现是先退回
     实模式再调用,实现在int.S,这里不再分析 */
  #include "../int.S"                           ;; BIOS中断调用实现
  ...

提一下,在保护模式和实模式都可以运行C语言代码,都可以使用call指令,使用call指令要求更宽松,只要设置好栈就可以使用,而要运行C语言的代码,不仅要设置好栈还要初始化BSS,C的核心输出可以分为三部分,代码段,已初始化数据段和BSS段,前两者都会保存于文件并在运行时加载到内存中,BSS不在文件中占用空间,需要在内存中根据所声明大小分配空间并清零。如上它初始化了BSS后调用了grub_main,它就是定义grub-core/kern/main.c的函数,由C编写:

/* The main routine.  */
void __attribute__ ((noreturn))
grub_main (void)
{
  /* 做一些初始化,主要是内存与console的初始化,前面已经激活了A20门,但是还没有使用1M以上的内存,
  上面已经提到超过1M的物理地址并不一定都是可用的,需要先检测,这里它使用E820等方式获取了所有可用的内存。*/
  grub_machine_init ();

  grub_boot_time ("After machine init.");

  /* Hello.  */
  grub_setcolorstate (GRUB_TERM_COLOR_HIGHLIGHT);
  grub_printf ("Welcome to GRUB!\n\n");
  grub_setcolorstate (GRUB_TERM_COLOR_STANDARD);

  /* 初始化文件签名校验,grub使用的类似VFS,并提供了很多文件过滤器,如此处会注册GRUB_FILE_FILTER_VERIFY,
  在grub打开文件时,会一次检测并执行每一个过滤器,过滤器根据打开文件所使用的type字段判断是否执行过滤操作,
  如此处它检测是否跳过签名校验,是否是文件签名等,之后使用注册的签名校验过滤器链验证签名,如gpg使用
  grub_verifier_register将自己注册到签名验证链中,之后它就会使用CORE.IMG里的公钥验证签名。
  */
  grub_verifiers_init ();

  /* 将CORE.IMG里的配置文件加载到内存 */
  grub_load_config ();

  grub_boot_time ("Before loading embedded modules.");

  /* Load pre-loaded modules and free the space.  */
  grub_register_exported_symbols ();
  /* 加载所有CORE.IMG里嵌入的模块 */
#ifdef GRUB_LINKER_HAVE_INIT
  grub_arch_dl_init_linker ();
#endif  
  grub_load_modules ();

  grub_boot_time ("After loading embedded modules.");

  /* 将CORE.IMG里保存的prefix解析为root和prefix环境变量  */
  grub_set_prefix_and_root ();
  grub_env_export ("root");
  grub_env_export ("prefix");

  /* 所有KERNEL.IMG之后嵌入的模块都被使用了,此时可以释放这片区域  */
  reclaim_module_space ();

  grub_boot_time ("After reclaiming module space.");

  /* 注册set unset ls insmod 这几条核心命令*/
  grub_register_core_commands ();

  grub_boot_time ("Before execution of embedded config.");

  /* 若在CORE.IMG里有配置文件则先执行它 */
  if (load_config)
    grub_parser_execute (load_config);

  grub_boot_time ("After execution of embedded config. Attempt to go to normal mode");

  /* 进入normal模式,如其名正常情况下会进入这个模式,它是通过加载normal.mod及所以来的模块完成的,
  加载成功后可以执行所有核心命令,但是在如/boot分区文件被删除等情况导致加载失败时,就无法进入该模式,
  这也就意味着本次启动失败了*/
  grub_load_normal_mode ();

  /* 在无法进入normal模式时,会进入救援模式,它不依赖外部模块,提供能最小化的维护功能,若
  问题不是很严重(如prefix变了)时,还可以通过它通过之前注册的几条命令修复并启动kernel。*/
  grub_rescue_run ();
}

grub_main功能如上,由于这部分是C代码所以不再仔细分析,在需要看某个功能时直接定位即可,只是要注意它会加载后续boot分区所使用文件系统的模块,另外这里面提到的扩展模块会在下一节分析。

Boot分区

分区总览

这里的Boot分区并不特指/boot分区,它可以是任意在执行grub-install时所指定的目录所在分区,如/分区,该分区将会被写入MBRGAP里所以之后不能随意修改,本文一致认为它是/boot分区吧。一般有了分区就会有文件系统,boot分区一般会选择fat/ext2/ext3作为文件系统,于是用户就可以很方便的操作里面的文件了,如将kernel放置于该分区,将配置文件置于该分区,如下为我本地的boot分区:

root@bm:/boot# mount
...
/dev/sda2 on /boot type ext4 (rw,relatime)

root@bm:/boot# tree
.
├── config-5.4.0-77-generic             # 编译kernel的配置文件
├── config-5.4.0-80-generic
├── grub
│   ├── fonts
│   │   └── unicode.pf2                     # 字体文件
│   ├── gfxblacklist.txt
│   ├── grub.cfg                                    # 配置文件
│   ├── grubenv                                     # 环境变量
│   ├── i386-pc                                     # 架构相关的可加载模块与被写入MBR/MBRGAP的镜像文件
│   │   ├── blocklist.mod
│   │   ├── boot.img
│   │   ├── ......
│   └── unicode.pf2
├── initrd.img -> initrd.img-5.4.0-80-generic
├── initrd.img-5.4.0-77-generic     # initrd文件
├── initrd.img-5.4.0-80-generic
├── initrd.img.old -> initrd.img-5.4.0-77-generic
├── lost+found
├── System.map-5.4.0-77-generic     # 内核函数的符号信息
├── System.map-5.4.0-80-generic
├── vmlinuz -> vmlinuz-5.4.0-80-generic
├── vmlinuz-5.4.0-77-generic            # 被压缩的内核
├── vmlinuz-5.4.0-80-generic
└── vmlinuz.old -> vmlinuz-5.4.0-77-generic

其实Grub2的核心大多在上一节所述区域,boot分区是放一些扩展模块或用户配置文件,kernel/initrd的地方,先简单描述grub的配置:

# 若存在grubenv就加载它里面的环境变量
if [ -s $prefix/grubenv ]; then
  set have_grubenv=true
  load_env
fi

# 使用insmod可以加载可扩展模块,它将在prefix所指向目录寻找,此处定义加载所有显示模块
function load_video {
  if [ x$feature_all_video_module = xy ]; then
    insmod all_video
  else
    insmod efi_gop
    insmod efi_uga
    insmod ieee1275_fb
    insmod vbe
    insmod vga
    insmod video_bochs
    insmod video_cirrus
  fi
}

# 设置串口配置,这样就可以把输入输出指向串口,在输出较多/刷新很快时很有用
serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1
terminal_input console serial
terminal_output console serial

# 设置菜单显示时间,它将会在启动时停留在Grub2界面,在这段时间内可选择对应启动项或者执行Grub命令
if [ "${recordfail}" = 1 ] ; then
  set timeout=30
else
  if [ x$feature_timeout_style = xy ] ; then
    set timeout_style=menu
    set timeout=5
  else
    set timeout=5
  fi
fi
# 设置启动项,还可以设置子菜单不列了
menuentry 'Ubuntu' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-simple' {
    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             # 如后面kernel位于其他文件系统上,就需要在此处加载指定模块
  # search指令用于搜索并设置root路径,之后像linux/initrd命令都是以该路径作为根目录
    if [ x$feature_platform_search_hint = xy ]; then
      search --no-floppy --fs-uuid --set=root  5ba34c3d-bd14-451d-a7d8-09a64009e3f1
    else
      search --no-floppy --fs-uuid --set=root 5ba34c3d-bd14-451d-a7d8-09a64009e3f1
    fi
  # 加载kernel,后面指定的是kernel的启动参数,注意不要把此处的root与上面的search或直接指定的root搞混了
  linux /boot/vmlinuz-4.15.0-142-generic root=UUID=5ba34c3d-bd14-451d-a7d8-09a64009e3f1 ro net.ifnames=0 biosdevname=0 console=ttyS0,115200 console=tty0 panic=5 intel_idle.max_cstate=1 intel_pstate=disable  crashkernel=1800M-64G:160M,64G-:512M
    initrd  /boot/initrd.img-4.15.0-142-generic # 加载initrd,此处可接多个initrd文件
}
# 一般不建议直接编辑上面的文件,而是创建如下文件并修改配置
if [ -f  ${config_directory}/custom.cfg ]; then
  source ${config_directory}/custom.cfg
elif [ -z "${config_directory}" -a -f  $prefix/custom.cfg ]; then
  source $prefix/custom.cfg;
fi

其他未提及的命令详见手册,接下来是另一个重要的linux.mod模块的分析,如上所述它们分别用于加载kernel和initrd,所以先介绍下linux启动协议

linux启动协议

linux启动协议用于bootloader和kernel之间传递信息,kernel的头部包含启动协议的数据,bootloader通过读取该结构来获取kernel信息并加载它,之后再把一些加载信息写回该结构,它在kernel中定义在arch/x86/boot/header.S里,C表示如下:

struct setup_header {
    __u8    setup_sects;  // 实模式代码扇区数
    __u16   root_flags;     // root是否只读,现在用启动参数ro/rw控制
    __u32   syssize;        // 保护模式的代码数
    __u16   ram_size;       // 过时
#define RAMDISK_IMAGE_START_MASK    0x07FF
#define RAMDISK_PROMPT_FLAG     0x8000
#define RAMDISK_LOAD_FLAG       0x4000
    __u16   vid_mode;   // video模式
    __u16   root_dev;   // root设备,现在用启动参数root=控制
    __u16   boot_flag;  // 0xAA55
    __u16   jump;       // 一条相对跳转指令,跳转到实模式入口
    __u32   header;     // 'HdrS'
    __u16   version;    // 协议版本
    __u32   realmode_swtch; // 没看懂,在特殊情况下的hook,一般不会用-_-
    __u16   start_sys;  // 过时
    __u16   kernel_version; // kernel版本
    __u8    type_of_loader; // bootloader的类型和版本
    __u8    loadflags;  // 一些加载标志,如加载位置,是否输出信息/使用堆等
#define LOADED_HIGH (1<<0)  // 0x100000 or 0x10000
#define QUIET_FLAG  (1<<5)  
#define KEEP_SEGMENTS   (1<<6)
#define CAN_USE_HEAP    (1<<7)
    __u16   setup_move_size;    // 过时
    __u32   code32_start;   // kernel加载地址(保护模式入口地址)
    __u32   ramdisk_image;  // ramdisk/ramfs的地址
    __u32   ramdisk_size;   
    __u32   bootsect_kludge;    // 过时
    __u16   heap_end_ptr;   // setup的堆结束地址-0x200
    __u8    ext_loader_ver; 
    __u8    ext_loader_type;    
    __u32   cmd_line_ptr;       // 命令行字符串的指针
    __u32   initrd_addr_max;    // initrd能占用的最高地址
    __u32   kernel_alignment;   // 可重定位内核的对齐大小
    __u8    relocatable_kernel; // 是否可重定位
    __u8    _pad2[3];   
    __u32   cmdline_size;   // 最大命令行参数长度
    __u32   hardware_subarch;   // 和半虚拟化相关
    __u64   hardware_subarch_data; 
    __u32   payload_offset;     // 实际payload的偏移,如压缩后的kernel或未压缩数据偏移
    __u32   payload_length; 
    __u64   setup_data; //额外的启动数据
} __attribute__((packed));

kernel在加载时被分为两个部分,实模式部分与保护模式部分,后者根据image格式加载到0x10000(4K)或是0x100000(1M)处,具体细节会在下一篇描述。

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

总的来说,kernel的前两个扇区包含了启动协议的数据,它开始于0x1f1,之前部分可以存储代码,新版的kernel此处的代码只会输出提示信息并等待重启,Grub2定义了struct linux_kernel_paramsstruct linux_xx_kernel_header结构用于和kernel对应。

linux.mod模块

它位于grub-core/loader/i386/linux.c,实现了linuxinitrd这两条命令,入口点是GRUB_MOD_INIT(其实所有模块都是),如下:

GRUB_MOD_INIT(linux)
{
    // 注册两条命令
  cmd_linux = grub_register_command ("linux", grub_cmd_linux,
                     0, N_("Load Linux."));
  cmd_initrd = grub_register_command ("initrd", grub_cmd_initrd,
                      0, N_("Load initrd."));
  my_mod = mod;
}

先看linux命令:

static grub_err_t
grub_cmd_linux (grub_command_t cmd __attribute__ ((unused)),
        int argc, char *argv[])
{
  grub_file_t file = 0;
  struct linux_i386_kernel_header lh;
  grub_uint8_t setup_sects;
  grub_size_t real_size, prot_size, prot_file_size;
  grub_ssize_t len;
  int i;
  grub_size_t align, min_align;
  int relocatable;
  grub_uint64_t preferred_address = GRUB_LINUX_BZIMAGE_ADDR;

  grub_dl_ref (my_mod);
  // 打开文件并读取头部
  file = grub_file_open (argv[0], GRUB_FILE_TYPE_LINUX_KERNEL);
  grub_file_read (file, &lh, sizeof (lh));

  // 检查kernel的boot_flag
  assert(lh.boot_flag == grub_cpu_to_le16_compile_time (0xaa55));
  // 检查实模式(setup)所使用的扇区数
  assert(lh.setup_sects <= GRUB_LINUX_MAX_SETUP_SECTS);

  /* 检查头部魔数与版本,若低了需使用linux16去启动  */
  assert(lh.header == grub_cpu_to_le32_compile_time (GRUB_LINUX_I386_MAGIC_SIGNATURE)
      && grub_le_to_cpu16 (lh.version) >= 0x0203);
  /* 检查加载标志,非BigKernel(zImage)需用linux16启动*/
  assert (lh.loadflags & GRUB_LINUX_FLAG_BIG_KERNEL);

  /* 设置内核启动 命令行参数的最大长度*/
  if (grub_le_to_cpu16 (lh.version) >= 0x0206)
    maximal_cmdline_size = grub_le_to_cpu32 (lh.cmdline_size) + 1;
  else
    maximal_cmdline_size = 256;

  if (maximal_cmdline_size < 128)
    maximal_cmdline_size = 128;

  setup_sects = lh.setup_sects;

  /* 若实模式段的扇区数,若为0则修改为4  */
  if (! setup_sects)
    setup_sects = GRUB_LINUX_DEFAULT_SETUP_SECTS;

  /* 获取实模式和保护模式部分的大小 */
  real_size = setup_sects << GRUB_DISK_SECTOR_BITS;
  prot_file_size = grub_file_size (file) - real_size - GRUB_DISK_SECTOR_SIZE;

  /* 获取内核的对齐大小及是否可以重定位*/
  if (grub_le_to_cpu16 (lh.version) >= 0x205
      && lh.kernel_alignment != 0
      && ((lh.kernel_alignment - 1) & lh.kernel_alignment) == 0)
    {
      for (align = 0; align < 32; align++)
    if (grub_le_to_cpu32 (lh.kernel_alignment) & (1 << align))
      break;
      relocatable = lh.relocatable;
    }
  else
    {
      align = 0;
      relocatable = 0;
    }
  /* 获取压缩后的kernel的大小,详见下一篇 */
  if (grub_le_to_cpu16 (lh.version) >= 0x020a)
    {
      min_align = lh.min_alignment;
      prot_size = grub_le_to_cpu32 (lh.init_size);
      prot_init_space = page_align (prot_size);
      if (relocatable)
    preferred_address = grub_le_to_cpu64 (lh.pref_address);
    }
  else
    {
      min_align = align;
      prot_size = prot_file_size;
      /* Usually, the compression ratio is about 50%.  */
      prot_init_space = page_align (prot_size) * 3;
    }
  /* 分配保护模式所需空间*/
  if (allocate_pages (prot_size, &align,
              min_align, relocatable,
              preferred_address))
    goto fail;

  grub_memset (&linux_params, 0, sizeof (linux_params));

  /* 启动协议的JUMP指令为两字节的相对跳转,跳转到代码起始位置(头部的结束),因此这样计算出代码入口点的偏移 */
  len = 0x202 + *((char *) &lh.jump + 1);

  /* 启动协议部分不应超过edd_mbr_sig_buffer,因为后者已经不属于kernel的启动协议部分了 */
  if (len > (char *) &linux_params.edd_mbr_sig_buffer - (char *) &linux_params) {
    grub_error (GRUB_ERR_BAD_OS, "Linux setup header too big");
    goto fail;
  }
  /* kernel的启动协议部分拷贝给linux_params */
  grub_memcpy (&linux_params.setup_sects, &lh.setup_sects, len - 0x1F1);

  /* 由于已经读取了一部分,所以需要减去,接着读完剩下的头部,注意,这里只读取了头部,
    setup的代码部分并没有被读取,实际上这部分代码它最终也没有被加载到内存
    */
  len -= sizeof(lh);
  if ((len > 0) &&
      (grub_file_read (file, (char *) &linux_params + sizeof (lh), len) != len))
    {      goto fail;  }
  /* 设置保护模式代码的起始位置,bootloader类型等信息 */
  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.padding11 = 0;
  linux_params.type_of_loader = GRUB_LINUX_BOOT_LOADER_TYPE;

  /* These two are used (instead of cmd_line_ptr) by older versions of Linux,
     and otherwise ignored.  */
  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;

  /* These are not needed to be precise, because Linux uses these values
     only to raise an error when the decompression code cannot find good
     space.  */
  linux_params.ext_mem = ((32 * 0x100000) >> 10);
  linux_params.alt_mem = ((32 * 0x100000) >> 10);

  /* Ignored by Linux.  */
  linux_params.video_page = 0;

  /* Only used when `video_mode == 0x7', otherwise ignored.  */
  linux_params.video_ega_bx = 0;

  linux_params.font_size = 16; /* XXX */

  /* The other parameters are filled when booting.  */

  grub_file_seek (file, real_size + GRUB_DISK_SECTOR_SIZE);

  grub_dprintf ("linux", "bzImage, setup=0x%x, size=0x%x\n",
        (unsigned) real_size, (unsigned) prot_size);

  /* 开始解析kernel的参数,有些参数会解析并写入启动协议头,其他的会原样传给kernel */
  linux_mem_size = 0;
  for (i = 1; i < argc; i++)
#ifdef GRUB_MACHINE_PCBIOS
    .../* 设置显示模式*/
#endif /* GRUB_MACHINE_PCBIOS */
    /* 解析kernel使用的内存大小 */
    if (grub_memcmp (argv[i], "mem=", 4) == 0)
      { ....}
    else if (grub_memcmp (argv[i], "quiet", sizeof ("quiet") - 1) == 0)
      { /* 不输出详细的启动信息 */
    linux_params.loadflags |= GRUB_LINUX_FLAG_QUIET;
      }

  /* 创建命令行参数,它做的是把传给kernel的参数  */
  linux_cmdline = grub_zalloc (maximal_cmdline_size + 1);
  if (!linux_cmdline)
    goto fail;
  grub_memcpy (linux_cmdline, LINUX_IMAGE, sizeof (LINUX_IMAGE));
  {
    grub_err_t err;
    err = grub_create_loader_cmdline (argc, argv,
                      linux_cmdline
                      + sizeof (LINUX_IMAGE) - 1,
                      maximal_cmdline_size
                      - (sizeof (LINUX_IMAGE) - 1),
                      GRUB_VERIFY_KERNEL_CMDLINE);
  }
  /* 把保护模式的内存加载到prot_mode_mem */
  len = prot_file_size;
  assert(grub_file_read (file, prot_mode_mem, len) == len && !grub_errno)

  /* 加载kernel无误时注册boot函数,之后执行boot时会调用该函数*/
  if (grub_errno == GRUB_ERR_NONE)
    {
      grub_loader_set (grub_linux_boot, grub_linux_unload,
               0 /* set noreturn=0 in order to avoid grub_console_fini() */);
      loaded = 1;
    }

  return grub_errno;
}

其中initrd的实现较简单,它必须在执行完linux命令后才能执行,它会把加载的initrd信息写回给linux_params,如下:

static grub_err_t
grub_cmd_initrd (grub_command_t cmd __attribute__ ((unused)),
         int argc, char *argv[])
{
  grub_size_t size = 0, aligned_size = 0;
  grub_addr_t addr_min, addr_max;
  grub_addr_t addr;
  grub_err_t err;
  struct grub_linux_initrd_context initrd_ctx = { 0, 0, 0 };

  /* 它根据每个参数获取每个initrd文件的大小 */
  if (grub_initrd_init (argc, argv, &initrd_ctx))
    goto fail;
  /* 获取加载所需的内存,即上面获取到的内存总和 */
  size = grub_get_initrd_size (&initrd_ctx);
  aligned_size = ALIGN_UP (size, 4096);

  /* 选择一个最佳加载位置  */
  if (grub_le_to_cpu16 (linux_params.version) >= 0x0203)
    {
      addr_max = grub_cpu_to_le32 (linux_params.initrd_addr_max);

      /* XXX in reality, Linux specifies a bogus value, so
     it is necessary to make sure that ADDR_MAX does not exceed
     0x3fffffff.  */
      if (addr_max > GRUB_LINUX_INITRD_MAX_ADDRESS)
    addr_max = GRUB_LINUX_INITRD_MAX_ADDRESS;
    }
  else
    addr_max = GRUB_LINUX_INITRD_MAX_ADDRESS;

  if (linux_mem_size != 0 && linux_mem_size < addr_max)
    addr_max = linux_mem_size;

  /* Linux 2.3.xx has a bug in the memory range check, so avoid
     the last page.
     Linux 2.2.xx has a bug in the memory range check, which is
     worse than that of Linux 2.3.xx, so avoid the last 64kb.  */
  addr_max -= 0x10000;

  addr_min = (grub_addr_t) prot_mode_target + prot_init_space;

  /* Put the initrd as high as possible, 4KiB aligned.  */
  addr = (addr_max - aligned_size) & ~0xFFF;

  {
    // 分配空间
    grub_relocator_chunk_t ch;
    err = grub_relocator_alloc_chunk_align (relocator, &ch,
                        addr_min, addr, aligned_size,
                        0x1000,
                        GRUB_RELOCATOR_PREFERENCE_HIGH,
                        1);

    initrd_mem = get_virtual_current_address (ch);
    initrd_mem_target = get_physical_target_address (ch);
  }
  // 将所有initrd加载到内存,它们将被连续存放
  if (grub_initrd_load (&initrd_ctx, argv, initrd_mem))
    goto fail;

  /* 将加载的地址与大小写回linux_params */
  linux_params.ramdisk_image = initrd_mem_target;
  linux_params.ramdisk_size = size;
  linux_params.root_dev = 0x0100; /* XXX */
}

一般执行完这两条命令后,就会执行boot命令,boot函数已经加载kernel时被注册了,它会在最后的操作后跳转到保护模式的入口地址开始执行,自此Bootloader的工作完成啦。

注:1. 现在一般都是使用linux与initrd命令,如上它是直接以保护模式入口点作为内核入口点,在有的系统会使用linux16与initrd16,它们的代码位于grub-core/loader/i386/pc/linux.c,在这里它们会加载实模式代码并从实模式代码启动kernel,具体差别会在下篇分析。

2.另外可能会用到的是luks模块,使用它时可以直接将boot分区也加密,若遇到boot分区被加密,特别是使用了luks加密,可先看看是不是它做的,此处不再分析。

总结

现在对Grub2运行过程有深入理解了,可以回顾一下在遇到问题的一些调试点,首先是0x7c00这个点是Grub2的入口点,接下来是0x8000是DiskBoot的入口点,再之后的0x100000为kernel的入口点等。

在做分析时,可根据实际情况判断应该从哪个地址开始分析,如读文件失败则从MBR开始分析,对于这一类分析,需要时刻修改镜像加载的基址,如使用IDA时,在Edit->Segments->RebaseProgram里修改。而想读linux内核则最好从linux.mod开始分析,分析模块时它们可能使用的是嵌入CORE.IMG的内核,此时可以先用dd命令将MBRGAP取出来,再根据上面描述的步骤定位到压缩数据,解压获取到modules。

对于嵌入MBRGAP的模块,需先确定压缩数据起始位置与大小,压缩数据是RAW格式,需要自行添加头部才能使用解压工具解压,以lzma格式为例,需要为它添加头部,它使用的压缩属性为\x5d\x00\x00\x01\x00之后接64位的长度,其实这个长度不是必要滴。解压后可通过\x6d\x69\x67\x0c\x00\x00来定位kernel的结束位置,另外也可使用binwalk来提取模块,当然也可以用它提取压缩数据,但是需要使用-Z等参数,效果很差。

另外,要学习可以自己编译一个Grub2,方法直接下载源码运行autogen.sh,之后就是configure+make一条龙了,如果报错了可以先试试再configure时加-Wno-error选项,若依然报警告错误那就是有的编译目标没有用我们指定的参数,这时可直接修改Makefile,编译安装后即可调试,每个镜像的image版是有符号的,可以加载它进行调试,使用qemu调试如:

qemu-system-x84_64 -s -S -nographic -hda vi.qcow2

之后即可使用gdb或lldb附加上去:

# gdb
target remote :1234
# lldb
platform select remote-gdb-server
process connect connect://127.0.0.1:1234
breakpoint set -a 0x7c00

在使用qemu时像int指令会进入内部,可以用脚本实现单步步过(例如如下脚本,不过这里注册的指令最好只在步过int时用):

class NextInstructionAddress(gdb.Command):
        # 来源于网络,但是因为某些原因不写出处了
    def __init__(self):
        super().__init__(
            'nia',
            gdb.COMMAND_BREAKPOINTS,
            gdb.COMPLETE_NONE,
            False
        )
    def invoke(self, arg, from_tty):
        frame = gdb.selected_frame()
        arch = frame.architecture()
        pc = gdb.selected_frame().pc()
        length = arch.disassemble(pc)[0]['length']
        gdb.Breakpoint('*' + str(pc + length), temporary = True)
        gdb.execute('continue')
NextInstructionAddress()

另外还容易出现的问题是在GDB无法正确切换模式,此时重连即可。

其他bootloader

在工作中还遇到了如下bootloader这里简单记录,以后有必要再仔细分析:

  1. Grub Legacy:哎这代码真的不忍直视,也是在某产品里用到需要调试它,看源码看得头秃,不建议学习了,只说说它经典的三阶段:

    • stage1:该部分为512字节,即mbr存入第一个扇区,由于大小限制,它不支持文件系统,唯一作用是加载剩余部分。
    • stage1.5:该部分为可选部分,它由单一的不完全文件系统驱动组成,且一般位于mbr gap里,它的作用是从文件系统里读取stage2。
    • stage2:它是grub的核心,支持各种功能,并最终加载启动内核或者进行链式加载,它一般位于分区里,当不存在stage1.5时,它的位置不能改变(如碎片整理可能导致系统无法启动),此时由stage1直接加载,当存在stage1.5时,它的位置可变,并作为一个文件系统里的普通文件被加载。
  2. Redboot:这是ecos的一个部分,但是可以单独拿出来用,某产品用的就是它,它的编译脚本为tcl,功能,windows编译需要做点修改:

static void cygwin_conv_to_win32_path(const char *posix, char * win32)
{
    /* Get the size */
    ssize_t size = cygwin_conv_path( CCP_POSIX_TO_WIN_A, posix, NULL, 0);
    cygwin_conv_path( CCP_POSIX_TO_WIN_A, posix, win32, size);
}

static void cygwin_conv_to_posix_path (const char *win32, char * posix)
{
    /* Get the size */
    ssize_t size = cygwin_conv_path( CCP_WIN_A_TO_POSIX, win32, NULL, 0);
    cygwin_conv_path(  CCP_WIN_A_TO_POSIX , win32, posix, size);
}

Linux可以直接使用这种方式安装,它会下载交叉编译工具,用这些工具可以自己编译源码,否则需要自己编译工具链:

wget --passive-ftp ftp://ecos.sourceware.org/pub/ecos/ecos-install.tcl
sh ecos-install.tcl
  1. LILO:遇到了没分析
  2. SysLinux:遇到了没分析

参考

  1. ntel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3 (3A, 3B, 3C & 3D): System Programming Guide
  2. 【我所认知的BIOS】- CPU的第一条指令 -- lightseed

  3. [Linux启动流程:从启动到 GRUB. -- binss