31337 h4x0r 有一回对我说道,“你读过 x86 平台的底层代码么?”我略略点一点头。他说,“读过底层,……我便考你一考。x86 的电脑上,怎样重启的?”我想,脚本小子一样的人,也配考我么?便回过脸去,不再理会。

31337 h4x0r 等了许久,很恳切的说道,“不会重启电脑罢?……我教给你,记着!这些方法应该记着。将来做黑客的时候,开发要用。”我暗想我和黑客的等级还很远呢,而且我们编程序也不是用来重启机器;又好笑,又不耐烦,懒懒的答他道,“谁要你教,不就是 ACPI 去写一个 ResetReg 么?”

31337 h4x0r 显出极高兴的样子,将两个指头的长指甲敲着键盘,点头说,“对呀对呀!……x86 有六样重启法,你知道么?”我愈不耐烦了,努着嘴走远。

31337 h4x0r 刚启动了 Emacs,想在编辑器上打字,见我毫不热心,便又叹一口气,显出极惋惜的样子。

Linux 内核支持全部六种方法……但为什么要支持六种?

有一些重启方法可能存在硬件实现问题,例如 8042 键盘控制器的方法,是支持最广泛,但也是电脑硬件 bug 最可能发生的方法。尽管 Linux 内核会逐一尝试每一种方法,很可能在尝试其中一种后导致硬件卡死。由于这些机器多半只在 Windows 上测试过,因此直到 2010 年左右,在 Linux 上重新启动机器时无法断电依然是比较常见的问题。此时,用户就需要逐一尝试这些重启方法,找到一种能用的,并向上游报告,上游将机器的型号和需要的重启方法加入表中。在现在出产的新机器上,这种问题已经非常罕见。如果你日后遇到了重启的问题,希望你能想起这篇文章,并对你有所帮助。

没错,六种方法重启,而且每种都不一定能工作,重启时需要逐一尝试,必然时候还需要查表,这就是充满历史遗产的 x86 世界的现状。

方法一:8042 键盘控制器的错误使用方法

8042 键盘控制器控制 x86 计算机上的 PS/2 键盘鼠标,但这大概也是 x86 计算机上能力最强大的硬件设备,因为它名义上是键盘控制器,但由于 IBM 工程师的脑洞,实际上还拥有控制 CPU 和内存的功能。历史上,8042 与 CPU 的 Reset 信号相连,还与内存总线上的一个逻辑门相连。

向 8042 键盘控制器(端口 0x64)写入 0xFE. 这会触发 CPU 的重置信号,并重启计算机。

ioperm(0x64, 1, 1);
outb(0xfe, 0x64);

在 Linux 上,可以使用内核参数 reboot=kbd,让 Linux 内核使用这种方式实现重新启动的功能。

方法二:向 PCI 的 0xCF9 端口发送重置信号

Intel 的 ICH/PCH 南桥芯片同时负责部分电源工作。向 PCI 的 0xCF9 端口发送重置信号,可以要求南桥芯片重启计算机。

ioperm(0xcf9, 1, 1);
uint8_t reboot_code = 0x06                 /* 热启动,冷启动 0x0E */
uint8_t cf9 = inb(0xcf9) & ~reboot_code;
outb(cf9|2, 0xcf9);                                    /* Request hard reset */
usleep(50);
outb(cf9|reboot_code, 0xcf9);            /* Actually do the reset */

在 Linux 上,可以使用内核参数 reboot=pci,让 Linux 内核使用这种方式实现重新启动的功能。

方法三:让 CPU 产生三重异常

如果代码触发了 CPU 的一个异常,并触发了事先注册的异常处理代码,但是异常处理代码本身也触发了异常,这就叫做双重异常。但这个异常处理代码本身也注册了异常处理代码,而且在执行这个异常处理代码的异常处理代码时又触发了异常,就产生了三重异常。从 80286 开始,三重异常会导致 CPU 进入 SHUTDOWN 状态,主板电路会重新启动系统。

例如:

load_idt(&no_idt);              /* 清空中断描述表 */
__asm__ __volatile__("int3");   /* 触发中断 */
/* 此时发生中断,由于 IDT 为空,无论是中断的异常处理代码还是双重异常处理代码都不存在,触发了三重异常
/* CPU 进入 SHUTDOWN 状态,主板重置 */

在 Linux 上,可以使用内核参数 reboot=triple,让 Linux 内核使用这种方式实现重新启动的功能。

尽管这个特性就在 80286 的手册附录里,而且 IBM 的工程师看到了而且还实现了必要的电路,然而 IBM 的工程师完全没有意识到这可以用来在切换到实模式时重置 CPU,而是将键盘控制器的一个端口接到 CPU 的 Reset 信号线上来进行重置 CPU 实现模式切换……

这个方法的成功率几乎是最高的,但大多数操作系统(包括 Linux 内核)不到万不得已,不会使用。虽然原则上这是为了正确进行电源管理,然而事实上的原因是,三重异常往往意味着操作系统出现了严重的故障,例如将内存中负责处理 Swap 的代码本身也移动到了 Swap 里,会立刻导致整个系统重启,数据荡然无存,开发者本来对三重异常感到恐惧,然而现在却要自己主动触发一个,实在是不能接受。此外,开发者也都认为触发个三重异常让 CPU 被迫重置实在是太残忍了,不忍心下手……因此,这种方法只会被开发者当作最终的手段。

方法四:切入实模式,跳转到 BIOS 代码重启

禁用各种东西,将 CPU 从保护模式切换回实模式,然后 ljmp $0xffff, $0x0000 跳转到 BIOS 的重置代码。 至于 BIOS 怎么重启,谁知道呢?不过只要 BIOS 实现正确,就可以工作(仅限于 32 位计算机)。

例如:

movl %cr0, %eax
andl $0x00000011, %eax
orl  $0x60000000, %eax
movl %eax, %cr0
movl %eax, %cr3
movl %cr0, %ebx
andl $0x60000000, %ebx
jz   f
invd
f:   andb $0x10, al
movl %eax,    %cr0
ljmp $0xffff, $0x0000

在 Linux 上,可以使用内核参数 reboot=bios,让 Linux 内核使用这种方式实现重新启动的功能。

方法五:通过 ACPI 重新启动

ACPI 是现代 x86 计算机必不可少的标准电源管理接口,自然也提供了重新启动计算机的功能。

在支持 ACPI 的机器固件提供的「固定 ACPI 描述表」(Fixed ACPI Description Table, FADT)中,保存有 reset_registerreset_value 两个数值,告知操作系统重启的方法。

rr = &acpi_gbl_FADT.reset_register;      /* 获取重启寄存器   */
reset_value = acpi_gbl_FADT.reset_value; /* 获取重启魔法数值 */

/* The reset register can only exist in I/O, Memory or PCI config space
 * on a device on bus 0. */

/* 在 PCI、内存和 IO 地址中寻找重启寄存器 */
switch (rr->space_id) {

/* 如果寄存器在 PCI,就写 PCI */
case ACPI_ADR_SPACE_PCI_CONFIG:
    /* The reset register can only live on bus 0. */
    bus0 = pci_find_bus(0, 0);
    if (!bus0)
        return;
    /* Form PCI device/function pair. */
    devfn = PCI_DEVFN((rr->address >> 32) & 0xffff,
              (rr->address >> 16) & 0xffff);
    printk(KERN_DEBUG "Resetting with ACPI PCI RESET_REG.");
    /* Write the value that resets us. */
    pci_bus_write_config_byte(bus0, devfn,
            (rr->address & 0xffff), reset_value);
    break;

/* 如果是内存或者 I/O 地址,直接用 writeb() / outb() 写内存或 I/O */
/* acpi_reset() 调用的是 acpi_os_write_port(), acpi_hw_write() */
/* 但只是多了一些检查,最后依然调用的是 writeb() / outb() */
case ACPI_ADR_SPACE_SYSTEM_MEMORY:
case ACPI_ADR_SPACE_SYSTEM_IO:
    printk(KERN_DEBUG "ACPI MEMORY or I/O RESET_REG.\n");
    acpi_reset();
    break;
}

写入数值后触发硬件重启。 在 Linux 上,可以使用内核参数 reboot=acpi,让 Linux 内核使用这种方式实现重新启动的功能。

方法六:通过 EFI/UEFI 重启

EFI/UEFI 是更现代计算机固件的接口标准,其中包括 UEFI 启动服务和 UEFI 运行服务,它们提供了一组标准的函数调用,提供一些和硬件相关的服务。好比系统调用为应用程序提供服务,UEFI 服务则为操作系统提供服务。在操作系统启动后,会运行 ExitBootServices 禁用 UEFI 启动服务,但是 UEFI 运行服务的代码会一直处于内存中,而且随时可以被系统调用执行。

efi_mode = EFI_RESET_WARM;
/* 或 efi_mode = EFI_RESET_COLD; */

/* reset_system 是 efi 结构体中的函数指针,其原型是:*/
/* void efi_reset_system_t (int reset_type, efi_status_t status, unsigned long data_size, efi_char16_t *data); */
/* 系统在通过 EFI 启动时,正确的内存地址会被初始化 */
efi.reset_system(efi_mode, EFI_SUCCESS, 0, NULL);

由于 EFI 的高度复杂性,可以直接阅读 EFI 相关文献。 在 Linux 上,可以使用内核参数 reboot=efi,让 Linux 内核使用这种方式实现重新启动的功能。

Linux 内核的逐一尝试

Linux 内核中,默认使用一个无限循环的 switch {} 语句逐一执行这些重启方法,如果成功了,那么机器就会重启,否则就会切换到下一种方法。如果硬件有已知问题,Linux 内核会查表,并根据机器型号选择合适的方式,并可能会进行一些 workaround。最后,有专门支持的特定计算机硬件会使用硬件自身的方法重启。

for (;;) {
    switch (reboot_type) {
    case BOOT_ACPI:
        // ...
        reboot_type = BOOT_KBD;
        break;

    case BOOT_KBD:
        /* 代码 */
        if (attempt == 0 && orig_reboot_type == BOOT_ACPI) {
            attempt = 1;
            reboot_type = BOOT_ACPI;
        } else {
            reboot_type = BOOT_EFI;
        }
        break;

    case BOOT_EFI:
        /* 代码 */
        reboot_type = BOOT_BIOS;
        break;

    case BOOT_BIOS:
        /* 代码 */
        reboot_type = BOOT_CF9_SAFE;
        break;

    case BOOT_CF9_FORCE:
        port_cf9_safe = true;
        /* Fall through */
    case BOOT_CF9_SAFE:
        /* 代码 */
        reboot_type = BOOT_TRIPLE;
        break;

    case BOOT_TRIPLE:
        /* 代码 */
        /* We're probably dead after this, but... */
        reboot_type = BOOT_KBD;
        break;
    }
}

为什么用键盘控制器能重启电脑?

随着个人计算机和半导体技术突飞猛进的发展,到了 1984 年,IBM 发表了具有划时代意义的 IBM PC/AT 计算机(即 IBM 5170)。PC/AT 搭载了强大的 80268 CPU,支持的内存也从 20 位的 1 MiB 变成了 24 位的 16 MiB。这一扩展地址可就惨了。在旧的 8086 上,尝试访问大于 1 MiB 的内存,会溢出回到内存的开头,后来有程序员也发现了这个现象,并利用它优化程序。但在 80286 上,由于大于 1 MiB 的内存确实存在,因此不会溢出也不会回到内存开头……本来 80286 默认使用的是实模式(而不是新的保护模式),就是为了完美兼容现有的 8086 程序的,但由于 Intel 并没有意识到这个问题,因此导致大量现有的程序停止工作。

IBM 的工程师为了解决这个兼容问题,他们在主板的内存地址总线的信号线上装了一个逻辑门,作为信号开关。当关闭时,就会一直产生信号 0,相当于关闭了超过 1 MiB 的内存,同时由于电路的特点,也能让这个溢出魔法重新恢复正常。但是,在哪里控制这个开关呢?PC AT 计算机使用 8042 芯片控制键盘,工程师看到芯片上正好有多余的端口,于是脑洞大开,用这个键盘芯片来控制 1 MiB 内存的开启。这就是著名的 A20。至今,让键盘控制器开启 A20 依然是进入保护模式不可或缺的一步。

同时,由于很多时候还需要在系统里兼容旧程序,有时还需要进入新的保护模式后,再切换回旧的实模式,但 80268 进行这个模式切换时,必须进行 CPU 重置。于是,IBM 的工程师把 8042 键盘控制器上另一个端口,连接到了CPU 的 Reset 信号线上……直到今天,x86 计算机上依然需要兼容 AT 机的 8042,因此,8042 键盘控制器能用来重启几乎一切 x86 电脑。

后来发现,键盘控制器的反应速度实在是太慢了,如果需要频繁 A20 切换会有性能问题,因为 IBM 当初的这个坑爹设计,日后系统开发者和 Intel 还花费了很大的代价解决。到了今天,x86 处理器里有一个专门模拟 A20 的 I/O 口……

如果说 8086 芯片是通往 x86 大坑的一把洛阳铲,那么这台 IBM 电脑则是造就了 x86 体系无数大坑的挖掘机。觉得 AT 很陌生?那在 AT 后面加个「X」呢?此外,你垃圾键盘右上角的 3 个刺眼的指示灯,Windows 用户不知道有什么用的 SysRq 按键,以及主板里一没电就掉时间日期,还时不时漏液损坏主板的纽扣电池,还有那经常出现诡异问题不能正常引导系统的 MBR,全都是 AT 的设计。更别说那个电源适配器负载一低就没有 +12V 输出,于是 IBM 在低端机器上安装了个 50 W 的功率电阻用来费电,幸好这一点上我们没有保持兼容……无论怎么说,这开启了一个时代,你现在的计算机很可能依然或多或少的兼容 AT。我们所说的「IBM 兼容机」指的就是 PC/AT 兼容。