一些 clang 生成的程序集在实模式下不工作(.COM,微型内存模型)

some clang-generated assembly not working in real mode (.COM, tiny memory model)

首先,这是 Custom memory allocator for real-mode DOS .COM (freestanding) — how to debug? 的后续。但要让它独立,这里是背景:

clang(还有 gcc)有一个 -m16 开关,所以 i386 指令集的 long 指令以“16 位”实模式 执行为前缀。这可以被利用来使用 GNU 链接器创建 DOS .COM 32 位实模式可执行文件,如 this blog post. (of course still limited to the tiny memory model, means everything in one 64KB segment) Wanting to play with this, I created a minimal runtime 中所述,它似乎工作得很好。

然后我尝试用这个运行时构建我最近创建的 curses-based game,但是,它崩溃了。我遇到的第一件事是经典的 heisenbug:打印有问题的错误值使其正确。我找到了一个解决方法,只是为了面对下一次崩溃。所以我首先想到的是我的自定义 malloc() 实现,请参阅另一个问题。但到目前为止还没有人发现它有什么真正的问题,我决定再看看我的 heisenbug。它体现在以下代码片段中(请注意,在为其他平台编译时这完美地工作):

typedef struct
{
    Item it;    /* this is an enum value ... */
    Food *f;    /* and this is an opaque pointer */
} Slot;

typedef struct board
{
    Screen *screen;
    int w, h;
    Slot slots[1];    /* 1 element for C89 compatibility */
} Board;

[... *snip* ...]

    size = sizeof(Board) + (size_t)(w*h-1) * sizeof(Slot);
    self = malloc(size);
    memset(self, 0, size);

sizeof(Slot) 是 8(clangi386 架构),sizeof(Board) 是 20,wh 是维度游戏板,在 DOS 80 和 24 中的 运行 的情况下(因为为 title/status 栏预留了一行)。为了调试这里发生的事情,我让我的 malloc() 输出它的参数,它被调用时值为 12 (sizeof(board) + (-1) * sizeof(Slot)?)

打印出 wh 显示了正确的值,仍然 malloc() 得到了 12。打印出 size 显示了正确计算的大小,这一次,malloc() 也得到了正确的值。所以,经典的heisenbug.

我找到的解决方法如下所示:

    size = sizeof(Board);
    for (int i = 0; i < w*h-1; ++i) size += sizeof(Slot);

很奇怪,这个有效。下一个合乎逻辑的步骤:比较生成的程序集。在这里我必须承认我对 x86 完全陌生,我唯一的组装经验是使用旧的 6502。所以,在下面的片段中,我将添加我的假设和想法作为评论,请在这里纠正我。

首先是"broken"原版(wh%esi%edi):

    movl    %esi, %eax
    imull   %edi, %eax           # ok, calculate the product w*h
    leal    12(,%eax,8), %eax    # multiply by 8 (sizeof(Slot)) and add
                                 # 12 as an offset. Looks good because
                                 # 12 = sizeof(Board) - sizeof(Slot)...
    movzwl  %ax, %ebp            # just use 16bit because my size_t for
                                 # realmode is "unsigned short"
    movl    %ebp, (%esp)
    calll   malloc

现在,对我来说,这看起来不错,但我的 malloc() 看到 12,如前所述。循环的解决方法编译为以下程序集:

    movl    %edi, %ecx
    imull   %esi, %ecx             # ok, w*h again.
    leal    -1(%ecx), %edx         # edx = ecx-1? loop-end condition?
    movw    , %ax               # sizeof(Board)
    testl   %edx, %edx             # I guess that sets just some flags in
                                   # order to check whether (w*h-1) is <= 0?
    jle .LBB0_5
    leal    65548(,%ecx,8), %eax   # This seems to be the loop body
                                   # condensed to a single instruction.
                                   # 65548 = 65536 (0x10000) + 12. So
                                   # there is our offset of 12 again (for 
                                   # 16bit). The rest is the same ...
.LBB0_5:
    movzwl  %ax, %ebp              # use bottom 16 bits
    movl    %ebp, (%esp)
    calll   malloc

如前所述,第二个变体按预期工作。毕竟这么长的文字,我的问题很简单……为什么?我在这里遗漏了实模式的一些特别之处吗?

供参考:this commit 包含两个代码版本。只需键入 make -f libdos.mk 以获得具有解决方法的版本(稍后崩溃)。要编译导致错误的代码,请先从 libdos.mk 中的 CFLAGS 中删除 -DDOSREAL

更新: 鉴于评论,我尝试自己更深入地调试它。使用dosbox的调试器有点麻烦,但我终于在这个错误的位置解决了它。所以,下面的汇编代码 intended by clang:

    movl    %esi, %eax
    imull   %edi, %eax
    leal    12(,%eax,8), %eax
    movzwl  %ax, %ebp
    movl    %ebp, (%esp)
    calll   malloc

结果如下(注意 dosbox 反汇编程序使用的英特尔语法):

0193:2839  6689F0              mov  eax,esi
0193:283C  660FAFC7            imul eax,edi
0193:2840  668D060C00          lea  eax,[000C]             ds:[000C]=0000F000
0193:2845  660FB7E8            movzx ebp,ax                                    
0193:2849  6766892C24          mov  [esp],ebp              ss:[FFB2]=00007B5C
0193:284E  66E8401D0000        call 4594 ($+1d40)

认为这条lea指令看起来很可疑,确实,在它之后,错误的值在ax中。因此,我尝试使用 .code16 将相同的汇编源代码提供给 GNU 汇编程序,结果如下(通过 objdump 进行反汇编,我认为这并不完全正确,因为它可能会误解大小前缀字节) :

00000000 <.text>:
   0:   66 89 f0                mov    %si,%ax
   3:   66 0f af c7             imul   %di,%ax
   7:   67 66 8d 04             lea    (%si),%ax
   b:   c5 0c 00                lds    (%eax,%eax,1),%ecx
   e:   00 00                   add    %al,(%eax)
  10:   66 0f b7 e8             movzww %ax,%bp
  14:   67 66 89 2c             mov    %bp,(%si)

唯一的区别是这个 lea 指令。这里它以 67 开头,意思是 16 位实模式下的 "address is 32bit"。我的猜测是,这个 实际上需要的,因为 lea 是为了对地址进行操作,而优化器只是 "abused" 在这里进行数据计算。我的假设是否正确?如果是这样,这可能是 clang-m16 内部汇编器中的错误吗?也许有人可以解释 clang 发出的 668D060C00 来自哪里以及 可能 是什么意思? 66 表示 "data is 32bit" 并且 8D 可能是操作码本身 --- 但其余的呢?

您的 objdump 输出是伪造的。看起来它是在假设 32 位地址和操作数大小而不是 16 位的情况下进行反汇编。因此它认为 lea 比它结束得更快,并将一些地址字节反汇编为 lds / add。然后奇迹般地恢复同步,并看到一个 movzww 零从 16b 延伸到 16b...非常有趣。

我倾向于相信你的 DOSBOX 反汇编输出。它完美地解释了您观察到的行为(malloc 总是使用 arg 12 调用)。你是对的,罪魁祸首是

lea   eax,[000C]   ;  eax = 0x0C = 12.  Intel/MASM/NASM syntax
leal  12, %eax     #or AT&T syntax:

它看起来像是组装你的 DOSBOX 二进制文件的错误(clang -m16 我想你说过),因为它组装了 leal 12(,%eax,8), %eax

leal  12(,%eax,8), %eax  # AT&T
lea   eax, [12 + eax*8]  ; Intel/MASM/NASM syntax

我可能会深入研究一些指令编码 tables / 文档,并弄清楚 lea 应该如何 被组装成机器代码。它应该与 32 位模式编码相同,但带有 67 66 前缀(分别为地址大小和操作数大小)。 (不,这些前缀的顺序无关紧要,66 67 也可以。)

您的 DOSBOX 和 objdump 输出甚至没有相同的二进制文件,所以是的,它们确实不同。 (objdump 误解了前面指令中的操作数大小前缀,但直到 LEA 才影响 insn 长度。)

您的 GNU as .code16 二进制文件有 67 66 8D 04 C5,然后是 32 位 0x0000000C 位移(小端)。这是带有两个前缀的 LEA。我认为这是 16 位模式 leal 12(,%eax,8), %eax 的正确编码。

您的 DOSBOX 反汇编程序只有 66 8D 06,具有 16 位 0x0C 绝对地址。 (缺少 32 位地址大小前缀,并使用不同的寻址模式。)我不是 x86 二进制专家;我以前没有遇到过反汇编程序/指令编码的问题。 (而且我通常只看 64 位 asm。)所以我必须查找不同寻址模式的编码。

我的 x86 指令来源是 Intel 的 Intel® 64 和 IA-32 架构 软件开发人员手册 第 2 卷(2A、2B 和 2C):指令集参考,A-Z。 (链接自 https://whosebug.com/tags/x86/info,顺便说一句。)

它说:(第 2.1.1 节)

The operand-size override prefix allows a program to switch between 16- and 32-bit operand sizes. Either size can be the default; use of the prefix selects the non-default size.

这很简单,一切都与普通的 32 位保护模式几乎相同,除了 16 位操作数大小是默认值。

LEA insn 描述有一个 table 描述了 16、32 和 64 位地址(67H 前缀)和操作数大小(66H 前缀)的各种组合所发生的情况。在所有情况下,当大小不匹配时,它会截断或零扩展结果,但它是 Intel insn ref 手册,因此它必须单独布置每个案例。 (这有助于更复杂的指令行为。)

是的,"abusing" lea 通过在非地址数据上使用它是一种常见且有用的优化。您可以对 2 个寄存器进行非破坏性加法,将结果放在第 3 个寄存器中。同时添加一个常量,并将其中一个输入缩放 2、4 或 8。因此它可以执行最多需要 4 条其他指令的操作。 (mov / shl / add r,r / add r,i)。此外,它不会影响标志,如果您想为另一次跳跃或特别是 cmov.

保留标志,这是一个奖励