为什么非 0xAA55 结尾的软盘镜像 QEMU 无法启动

最近我在调试一段保护模式相关的代码时发现如果生成的二进制文件大小不等于 512 字节、末尾两字节也非 0xAA55 时,无法当作软盘来用 QEMU 来启动,却有些虚拟机(如 VMware Workstation)中又可以。为了简化演示代码,我用下面这段代码做示例,启动后屏幕打印 shellcodes.org:

        ;; filename: boot.asm
org 07c00h

        mov ax, cs
        mov ds, ax
        mov es, ax

hello_entry:
        mov bp, msg
        mov cx, 14              ; 显示字符长度
        mov ah, 13h            ; 在 Teletype 模式下显示字符串
        mov al, 01h            ; 光标位置不变
        mov bh, 0              ; 显示页,图形模式下 BH 必须为 0
        mov bl, 0fh            ; 文字颜色,0F 为白色
        mov dh, 0              ; 显示行
        mov dl, 0               ; 显示列
        int 10h
        ret

msg: db "shellcodes.org"
jmp $

编译、并用 QEMU 启动:

$ nasm boot.asm -o boot.img
$ qemu-system-i386 -fda boot.img

输出如下:

SeaBIOS (version 1.13.0-2.fc32)


iPXE (http://ipxe.org) 00:03.0 CA00 PCI2.10 PnP PMM+07F91A50+07ED1A50 CA00



Booting from Hard Disk...
Boot failed: could not read the boot disk

Booting from Floppy...
Boot failed: not a bootable disk

如上,并未如期输出;但换成 VMware Workstation 启动又可以成功打印出 shellcodes.org。

我最先怀疑的是代码兼容等等问题,于是启用 QEMU 的远程调试:

qemu-system-i386 -s -S -fda boot.img

接着用 GDB 连接,并在 0x7c00 地址处下断点:

target remote :1234
break *0x7c00
continue

BIOS 会将引导扇区的数据加载到 0x7c00,并从 0x7c00 开始执行,所以断点打在这里。但发现根本没执行到 0x7c00,说明 BIOS 根本没从软盘中读取数据。

我怀疑原因是 QEMU 的 BIOS 在加载镜像时发现不是 0xAA55 结尾,认为数据不合法所致,所以写了个脚本自动把镜像填充到 512 字节,并以 0xAA55 结尾:

# filename: to_floppy.py

import sys


if __name__ == '__main__':
    try:
        inputfile = sys.argv[1]
    except IndexError:
        raise SystemExit(f"usage: {__file__} bin_file")

    with open(inputfile, "rb") as f:
        data = f.read()
        data_len = len(data)

        if data_len > 512:
            raise SystemExit("超出512字节")

        if data_len == 512 and data[-2:] == b'\x55\xaa':
            raise SystemExit(f"{inputfile}已是可启动镜像")

        zero_data = b'\x00' * (510 - data_len)
        data += zero_data
        data += b'\x55\xaa'

        with open(f"new_{inputfile}", "wb") as f:
            f.write(data)
            print(f"output to new_{inputfile}")

执行脚本,并检查输出的新文件格式:

$ python3 to_floppy.py boot.img
output to new_boot.img
$ hexdump new_boot.img
0000000 c88c d88e c08e 1bbd b97c 000e 13b4 01b0
0000010 00b7 0fb3 00b6 00b2 10cd 73c3 6568 6c6c
0000020 6f63 6564 2e73 726f eb67 00fe 0000 0000
0000030 0000 0000 0000 0000 0000 0000 0000 0000
*
00001f0 0000 0000 0000 0000 0000 0000 0000 aa55
0000200
$ stat new_boot.img
  文件:new_boot.img
  大小:512
...

接着启动新镜像:

qemu-system-i386 -fda new_boot.img

如期输出 shellcodes.org。

到这里,有些人会疑惑为什么不直接就在代码后面加两句:

times 510-($-$$) db 0 ; 用 0 填充剩余字节
dw 0xaa55       ; 最后两个字节,加起来刚好 512 字节

这样 NASM 编译出来的格式就刚好合法。但网上很多保护模式的代码示例都不会有这两句,并在 VirtualBox、Bochs 上都能正常运行,而我想知道为什么 QEMU 上就不行。

QEMU 的 BIOS 用的是开源的 SeaBIOS,于是我下载了 SeaBIOS 的源码,并在 src/boot.c 中找到了从软盘启动的源码,加注释整理如下:

// Boot from a disk (either floppy or harddrive)
static void
boot_disk(u8 bootdrv, int checksig)
{
  u16 bootseg = 0x07c0;

  /* 以下代码调用 int 13 中断,从软盘中取指定位置的数据
     扇区大小为 512 字节,所以这里从软盘中读取了 512 字节的数据
  */
  struct bregs br;
  memset(&br, 0, sizeof(br));
  br.flags = F_IF;
  br.dl = bootdrv;
  br.es = bootseg;
  br.ah = 2; /* AH 寄存器为 02H 表示读取扇区 */
  br.al = 1; /* AL 寄存器表示读取的扇区数,这里为 1 */
  br.cl = 1; /* CL 寄存器低 5 位为读取的起始扇区号,CL 6、7 位 表示磁道号,值为 1 表示读取 0 磁道 1 扇区的数据*/
  /* DH 寄存器指定扇面号,因为上面调用了 memset,br 所有成员都初始为 0,因此 DH 默认为 0,表示第 0 面 */
  call16_int(0x13, &br);

  if (br.flags & F_CF) {
    printf("Boot failed: could not read the boot disk\n\n");
    return;
  }

  if (checksig) {
    struct mbr_s *mbr = (void*)0;
    /* 这里检查末尾是否为 0xaa55,不是的话则启动失败 */
    if (GET_FARVAR(bootseg, mbr->signature) != MBR_SIGNATURE) {
      printf("Boot failed: not a bootable disk\n\n");
      return;
    }
  }

  tpm_add_bcv(bootdrv, MAKE_FLATPTR(bootseg, 0), 512);

  /* Canonicalize bootseg:bootip */
  u16 bootip = (bootseg & 0x0fff) << 4;
  bootseg &= 0xf000;

  call_boot_entry(SEGOFF(bootseg, bootip), bootdrv);
}

过程很简单,通过 0x13 中断从软盘的 0 扇面、0 磁道、1 扇区读取 512 个字节的数据放到地址 0x07c0 中,然后判断引导扇区末尾两字节是否为 0xaa55,最后执行引导扇区的代码。

代码中 MBR_SIGNATURE 宏对应的值就是 0xaa55,定义在 src/disk.h 中:

/* filename: src/disk.h */

#define MBR_SIGNATURE 0xaa55

这里就证实了 BIOS 启动时确实会验证格式。不过注意验证是发生在 if (checksig) 这个判断分支里的,而 checksig 是传递给 boot_disk 的参数,说明可能存在某个外传参数可以关闭验证过程,于是为继续追踪 boot_disk 的调用,在 do_boot 函数中找到调用,并传递了 CheckFloppySig 变量:

/* filename: boot.c */

static void
do_boot(int seq_nr)
{
    if (! CONFIG_BOOT)
        panic("Boot support not compiled in.\n");

    if (seq_nr >= BEVCount)
        boot_fail();

    struct bev_s *ie = &BEV[seq_nr];
    switch (ie->type) {

    case IPL_TYPE_FLOPPY:
        printf("Booting from Floppy...\n");
        boot_disk(0x00, CheckFloppySig); /* 注意这里 CheckFloppySig 是可控变量*/
        break;
    }
    ...
}

跟踪 CheckFloppySig,默认值为 1:

static int CheckFloppySig = 1;

也就是 BIOS 默认会做格式检查。在 boot_init 函数中找到修改 CheckFloppySig 条件:

/* filename: boot.c */

void
boot_init(void)
{
    if (! CONFIG_BOOT)
        return;

    if (CONFIG_QEMU) {
        if (rtc_read(CMOS_BIOS_BOOTFLAG1) & 1)
            CheckFloppySig = 0;
        ...
    }
    ...
}

在读取到 CMOS_BIOS_BOOTFLAG1 这个标志时可以关闭格式检测,CMOS_BIOS_BOOTFLAG1 宏的定义如下:

/* filename: hw/rtc.h */

#define CMOS_BIOS_BOOTFLAG1      0x38

也就是传递了 0x38 给 SeaBIOS 就能关闭检查了,于是再下载一份 QEMU 源码来翻,看怎么控制参数的,在 vl.c 中找到这段逻辑:

/* filename: vl.c */

...

int fd_bootchk = 1;

...

int main(int argc, char **argv, char **envp)
{
  ...
  for(;;) {
    ...

      switch(popt->index) {
        ...
      case QEMU_OPTION_no_fd_bootchk:
        fd_bootchk = 0;
        break;
      }
    ...
  }
  ...
}

fd_bootchk 会通过调用 set_boot_dev 传递给 SeaBIOS:

/* filename: hw/i386/pc.c */

static void set_boot_dev(ISADevice *s, const char *boot_device, Error **errp)
{
  ...
    /* 给 SeaBIOS 传递 0x38 */
  rtc_set_memory(s, 0x38, (bds[2] << 4) | (fd_bootchk ? 0x0 : 0x1));
  ...
}

而 fd_bootchk 对应的 QEMU 启动参数就是 no-fd-bootchk,定义在 qemu-options.hx 中:

DEF("no-fd-bootchk", 0, QEMU_OPTION_no_fd_bootchk,
    "-no-fd-bootchk  disable boot signature checking for floppy disks\n",
    QEMU_ARCH_I386)

因此对于“不合格”的镜像,启动时带上 no-fd-bootchk 参数即可:

qemu-system-i386 -fda boot.img -no-fd-bootchk

到这里,我所有的疑问都解除了。