call/jump 附近的表并不总是在引导加载程序中工作

Near call/jump tables don't always work in a bootloader

一般问题

我一直在开发一个简单的引导加载程序,但在某些环境中遇到了一个问题,在这些环境中,此类指令不起作用:

mov si, call_tbl      ; SI=Call table pointer
call [call_tbl]       ; Call print_char using near indirect absolute call
                      ; via memory operand
call [ds:call_tbl]    ; Call print_char using near indirect absolute call
                      ; via memory operand w/segment override
call near [si]        ; Call print_char using near indirect absolute call
                      ; via register

其中每一个都恰好涉及间接靠近 CALL to absolute memory offsets. I have discovered that I have issues if I use similar JMP 表。相对的调用和跳转似乎不受影响。像这样的代码有效:

call print_char 

我采纳了 Whosebug 上讨论编写引导加载程序的注意事项的海报提出的建议。特别是我看到这个 回答 General Bootloader Tips。第一个提示是:

  1. When the BIOS jumps to your code you can't rely on CS,DS,ES,SS,SP registers having valid or expected values. They should be set up appropriately when your bootloader starts. You can only be guaranteed that your bootloader will be loaded and run from physical address 0x07c00 and that the boot drive number is loaded into the DL register.

采纳了大家的意见,我没有依赖CS,我搭建了一个栈,设置DS就合适了对于我使用的 ORG(原点偏移量)。我创建了一个 Minimal Complete Verifiable 示例来演示该问题。我使用 NASM 构建了它,但它似乎不是 NASM 特有的问题。


最小示例

测试代码如下:

[ORG 0x7c00]
[Bits 16]

section .text
main:
    xor ax, ax
    mov ds, ax            ; DS=0x0000 since OFFSET=0x7c00
    cli                   ; Turn off interrupts for potentially buggy 8088
    mov ss, ax
    mov sp, 0x7c00        ; SS:SP = Stack just below 0x7c00
    sti                   ; Turn interrupts back on

    mov si, call_tbl      ; SI=Call table pointer
    mov al, [char_arr]    ; First char to print 'B' (beginning)
    call print_char       ; Call print_char directly (relative jump)

    mov al, [char_arr+1]  ; Character to print 'M' (middle)
    call [call_tbl]       ; Call print_char using near indirect absolute call
                          ; via memory operand
    call [ds:call_tbl]    ; Call print_char using near indirect absolute call
                          ; via memory operand w/segment override
    call near [si]        ; Call print_char using near indirect absolute call
                          ; via register

    mov al, [char_arr+2]  ; Third char to print 'E' (end)
    call print_char       ; Call print_char directly (relative jump)

end:
    cli
.endloop:
    hlt                   ; Halt processor
    jmp .endloop

print_char:
    mov ah, 0x0e    ; Write CHAR/Attrib as TTY
    mov bx, 0x00    ; Page 0
    int 0x10
    retn

; Near call address table with one entry
call_tbl: dw print_char

; Simple array of characters
char_arr: db 'BME'

; Bootsector padding
times 510-($-$$) db 0
dw 0xAA55

我构建了一个 ISO 映像和一个 1.44MB 的软盘映像用于测试目的。我使用的是 Debian Jessie 环境,但大多数 Linux 发行版都是类似的:

nasm -f bin boot.asm -o boot.bin
dd if=/dev/zero of=floppy.img bs=1024 count=1440
dd if=boot.bin of=floppy.img conv=notrunc

mkdir iso    
cp floppy.img iso/
genisoimage -quiet -V 'MYBOOT' -input-charset iso8859-1 -o myos.iso -b floppy.img -hide floppy.img iso

我最终得到一个名为 floppy.img 的软盘映像和一个名为 myos.iso.

ISO 映像

预期与实际结果

在大多数情况下,此代码都有效,但在许多环境中却无效。当它工作时,它只是在显示器上打印:

BMMME

我使用带有相对偏移量的典型 CALL 打印出 B,它似乎工作正常。在某些环境中,当我 运行 我刚得到的代码时:

B

然后它似乎停止做任何事情。它似乎正确地打印出了 B 但随后发生了意想不到的事情。

似乎可行的环境:

未按预期工作的环境:

我发现有趣的是 Bochs(2.6 版)在使用 ISO 的 Debian Jessie 上无法正常工作。当我使用相同版本的软盘启动时,它按预期工作。

在所有情况下,ISO 和软盘映像似乎都已加载并启动 运行ning,因为在 ALL 情况下它至少能够在显示器上打印出 B


我的问题

问题

你的问题的答案隐藏在你的问题中,只是不明显。你引用了我的 :

  1. When the BIOS jumps to your code you can't rely on CS,DS,ES,SS,SP registers having valid or expected values. They should be set up appropriately when your bootloader starts. You can only be guaranteed that your bootloader will be loaded and run from physical address 0x00007c00 and that the boot drive number is loaded into the DL register.

您的代码正确设置了 DS,并设置了自己的堆栈(SS,以及 SP).你没有盲目复制CSDS,而是你所做的是依赖CS预期值 (0x0000)。在我解释我的意思之前,我想提请你注意最近的 我给出了 ORG 指令(或任何指定的原点)链接器)与 BIOS 使用的 segment:offset 对一起工作以跳转到物理地址 0x07c00.

答案详细说明了如何将 CS 复制到 DS 引用内存地址(例如变量)时会导致问题。我在总结中说:

Don't assume CS is a value we expect, and don't blindly copy CS to DS . Set DS explicitly.

关键是不要假设 CS 是我们期望的值。所以你的下一个问题可能是 - 我似乎没有使用 CS 是吗?答案是肯定的。通常,当您使用典型的 CALLJMP 指令时,它看起来像这样:

call print_char
jmp somewhereelse

在16中bit-code这两个都是相对跳跃。这意味着您在内存中向前或向后跳转,但作为相对于 JMPCALL 之后的指令的偏移量。您的代码在段中的位置无关紧要,因为它是您当前所在位置的 plus/minus 位移。 CS 的当前值实际上与相对跳跃无关,因此它们应该按预期工作。

您的说明示例似乎并不总是正常工作,包括:

call [call_tbl]       ; Call print_char using near indirect absolute call
                      ; via memory operand
call [ds:call_tbl]    ; Call print_char using near indirect absolute call
                      ; via memory operand w/segment override
call near [si]        ; Call print_char using near indirect absolute call
                      ; via register

所有这些都有一个共同点。 CALLed 或 JMPed 的地址是 ABSOLUTE,而不是相对地址。标签的偏移量将受到 ORG(代码的原点)的影响。如果我们查看您的代码的反汇编,我们将看到:

objdump -mi8086 -Mintel -D -b binary boot.bin --adjust-vma 0x7c00
boot.bin:     file format binary

Disassembly of section .data:

00007c00 <.data>:
    7c00:   31 c0                   xor    ax,ax
    7c02:   8e d8                   mov    ds,ax
    7c04:   fa                      cli
    7c05:   8e d0                   mov    ss,ax
    7c07:   bc 00 7c                mov    sp,0x7c00
    7c0a:   fb                      sti
    7c0b:   be 34 7c                mov    si,0x7c34
    7c0e:   a0 36 7c                mov    al,ds:0x7c36
    7c11:   e8 18 00                call   0x7c2c              ; Relative call works
    7c14:   a0 37 7c                mov    al,ds:0x7c37
    7c17:   ff 16 34 7c             call   WORD PTR ds:0x7c34  ; Near/Indirect/Absolute call
    7c1b:   3e ff 16 34 7c          call   WORD PTR ds:0x7c34  ; Near/Indirect/Absolute call
    7c20:   ff 14                   call   WORD PTR [si]       ; Near/Indirect/Absolute call
    7c22:   a0 38 7c                mov    al,ds:0x7c38
    7c25:   e8 04 00                call   0x7c2c              ; Relative call works
    7c28:   fa                      cli
    7c29:   f4                      hlt
    7c2a:   eb fd                   jmp    0x7c29
    7c2c:   b4 0e                   mov    ah,0xe              ; Beginning of print_char
    7c2e:   bb 00 00                mov    bx,0x0              ; function
    7c31:   cd 10                   int    0x10
    7c33:   c3                      ret
    7c34:   2c 7c                   sub    al,0x7c             ; 0x7c2c offset of print_char
                                                               ; Only entry in call_tbl
    7c36:   42                      inc    dx                  ; 0x42 = ASCII 'B'
    7c37:   4d                      dec    bp                  ; 0x4D = ASCII 'M'
    7c38:   45                      inc    bp                  ; 0x45 = ASCII 'E'
    ...
    7dfd:   00 55 aa                add    BYTE PTR [di-0x56],dl

我在 CALL 语句所在的位置手动添加了一些注释,包括有效的相关语句和可能无效的 near/indirect/absolute 语句。我还确定了 print_char 函数的位置,以及它在 call_tbl.

中的位置

从代码后的数据区域我们确实看到 call_tbl 位于 0x7c34,它包含一个 2 字节的绝对偏移量 0x7c2c。这都是正确的,但是当您使用绝对 2 字节偏移量时,它被假定为在当前 CS 中。如果您已经阅读了这个 (我之前引用过)关于当错误的 DS 和偏移量用于引用变量时会发生什么,您现在可能会意识到这可能适用到 JMPs CALLs 使用涉及 NEAR 2 字节绝对值的绝对偏移量。

举个例子,让我们以这个并不总是有效的调用为例:

call [call_tbl] 

call_tbl 从 DS:[call_tbl] 加载。当我们启动引导加载程序时,我们正确地将 DS 设置为 0x0000,这样就可以从内存地址 0x0000:0x7c34 正确检索值 0x7c2c。然后处理器将设置 IP=0x7c2c 但它假设它是相对于当前设置的 CS。由于我们不能假设 CS 是预期值,因此处理器可能会 CALL 或 JMP 到错误的位置。这完全取决于 CS:IP BIOS 用来跳转到我们的引导加载程序的内容(可能会有所不同)。

BIOS 对 0x0000:0x7c00 处的引导加载程序执行相当于 FAR JMP 的情况下,CS 将设置为 0x0000,IP 将设置为 0x7c00。当我们遇到 call [call_tbl] 时,它会解析为 CALL 到 CS:IP=0x0000:0x7c2c 。这是物理地址 (0x0000<<4)+0x7c2c=0x07c2c,实际上是 print_char 函数在内存中物理开始的位置。

一些 BIOS 对我们在 0x07c0:0x0000 的引导加载程序执行相当于 FAR JMP 的操作,CS 将设置为 0x07c0和 IP 到 0x0000。这也映射到物理地址 (0x07c0<<4)+0=0x07c00 。当我们遇到 call [call_tbl] 时,它会解析为 CALL 到 CS:IP=0x07c0 :0x7c2c 。这是物理地址(0x07c0<<4)+0x7c2e=0x0f82c。这显然是错误的,因为 print_char 函数位于物理地址 0x07c2c,而不是 0x0f82c。

CS 设置不正确会导致 JMPCALL 指令出现问题 Near/Absolute寻址。以及任何使用 CS: 段覆盖的内存操作数。在这个

中可以找到在实模式中断处理程序中使用 CS: 覆盖的示例

解决方案

因为已经表明我们不能依赖在 BIOS 跳转到我们的代码时设置的 CS 我们可以设置 CS 我们自己。要设置 CS,我们可以对我们自己的代码执行 FAR JMP,这将设置 [=90=CS:IP 到对我们正在使用的 ORG(代码和数据的起点)有意义的值。如果我们使用 ORG 0x7c00:

jmp 0x0000:$+5

$+5 表示使用比当前程序计数器高 5 的偏移量。 far jmp 的长度为 5 个字节,因此这具有远跳转到 jmp 之后的指令的效果。它也可以这样编码:

    jmp 0x0000:farjmp
farjmp:

当这些指令中的任何一个完成时,CS 将被设置为 0x0000 并且 IP 将被设置为下一条指令的偏移量.他们对我们来说关键是 CS 将是 0x0000。当与 0x7c00 的 ORG 配对时,它将正确解析绝对地址,以便它们在物理上 运行 在 CPU 上正常工作。 0x0000:0x7c00=(0x0000<<4)+0x7c00=物理地址0x07c00.

当然如果我们使用ORG 0x0000那么我们需要设置CS为0x07c0。这是因为 (0x07c0<<4)+0x0000=0x07c00。所以我们可以这样编码 far jmp:

jmp 0x07c0:$+5

CS将被设置为0x07c0,IP将被设置为下一条指令的偏移量。

这一切的最终结果是我们将CS设置为我们想要的段,而不是依赖于一个我们无法保证的值,当BIOS完成跳转到我们的代码。


不同环境下的问题

正如我们所见,CS 可能很重要。大多数 BIOS,无论是在模拟器、虚拟机还是真实硬件中,都相当于远跳到 0x0000:0x7c00,在这些环境中,您的引导加载程序可以正常工作。从 CD 启动时,某些环境(如较旧的 AMI Bioses 和 Bochs 2.6 正在使用 CS:IP[ 启动我们的引导加载程序=159=] = 0x07c0:0x0000。正如在那些环境中所讨论的那样 near/absolute CALLs 和 JMPs 将从错误的内存位置继续执行并导致我们的引导加载程序运行不正确。

那么 Bochs 为软盘映像而不是 ISO 映像呢?这是 Bochs 早期版本的一个特点。当从软盘引导时,虚拟 BIOS 跳转到 0x0000:0x7c00,当它从 ISO 映像引导时,使用 0x07c0:0x0000。这解释了为什么它的工作方式不同。这种奇怪的行为显然是由于对特别提到段 0x07c0 的 El Torito 规范之一的字面解释而产生的。 Boch 的较新版本的虚拟 BIOS 被修改为对两者都使用 0x0000:0x7c00。


这是否意味着某些 BIOS 存在 Bug?

这个问题的答案是主观的。在 IBM 的 PC-DOS(2.1 之前)的第一个版本中,引导加载程序假定 BIOS 跳转到 0x0000:0x7c00,但这并没有明确定义。一些 BIOS 制造商在 80 年代开始使用 0x07c0:0x0000 并破坏了一些早期版本的 DOS。当发现这一点时,引导加载程序被修改为表现良好,不会对使用 segment:offset 对到达物理地址 0x07c00 做出任何假设。当时人们可能认为这是一个错误,但基于 20 位 segment:offset 对引入的歧义。

自 80 年代中期以来,我认为任何假定 CS 为特定值的新引导加载程序都被错误编码。