用汇编语言编写计算两整数之和的程序(下)
通过前面的学习(用汇编语言编写计算两整数之和的程序(上)),我们知道了,虽然同属于低级语言,但汇编语言的程序需要先转换成机器语言的程序才能由CPU解释执行。那“计算1+2”这段代码对应着怎样的机器语言的代码呢?
查看汇编语言对应的机器语言
在汇编语言的开发调试工具SASM中,可以通过GDB命令来查看汇编语言对应的机器语言的代码。
点击工具栏中的“调试”按钮(图标是右下角有一只蓝色甲虫的绿色三角形)就会进入调试模式。此时,SASM窗口底部的窗格中会出现一行绿色的文字“正在调试...”。同时,最底部还会出现一个名为“GDB 命令:”的输入框。
另外,进入调试模式后,SASM会插入一行代码mov ebp, esp。这行代码后面的注释;for correct debugging(为了正确的调试)说明这行代码仅用于辅助调试,并不会影响程序的运行,可以忽略,如下图所示。
我们依次输入如下两条GDB 命令
set disassembly-flavor inteldisassemble /r
按下回车键后,就会看到在底部的窗格中输出了大量信息:
> set disassembly-flavor intel
> disassemble /r
Dump of assembler code for function main:
=> 0x00401390 <+0>: 89 e5 mov ebp,esp
0x00401392 <+2>: a1 00 20 40 00 mov eax,ds:0x402000
0x00401397 <+7>: 03 05 04 20 40 00 add eax,DWORD PTR ds:0x402004
0x0040139d <+13>: a3 08 20 40 00 mov ds:0x402008,eax
0x004013a2 <+18>: e8 02 00 00 00 call 0x4013a9 <main+25>
(略)
输出信息中中间部分的十六进制数就是机器语言的代码,比如
0x00401390 <+0>: 89 e5 mov ebp,esp
这行中间部分的89 e5就是mov ebp,esp对应的机器语言的代码。为什么89似乎就对应mov?为什么这条mov指令有两个操作数,似乎应该对应两个十六进制数才对,却只对应了一个e5?这些问题只能通过查询CPU的指令手册才能得知。
mov ebp,esp对应的机器语言的代码
我们打开X86 Opcode and Instruction Reference,搜索mov。
可以看到同一个mov指令,却对应了88~8C等多个十六进制数(这样的十六进制数称为Primary Opcode)。那mov ebp, esp中的mov到底对应哪个十六进制数呢?这就取决于操作数的类型了。操作数ebp和esp都是32位的寄存器,匹配r/m16/32 r16/32这个模式(第1个操作数是寄存器或16/32位的内存地址,第2个操作数是16/32位寄存器),所以这一行代码中的mov对应89。
【TODO 为什么不是8B?还得考虑指令的格式】
那e5又是如何对应ebp, esp的呢?这又涉及到称为ModR/M byte(Wiki: ModR/M)的指令编码方式了。
+-------+---+---+---+---+---+---+---+---+
| Bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
+-------+---+---+---+---+---+---+---+---+
| Usage | MOD | REG | R/M |
+-------+---+---+---+---+---+---+---+---+
| e5 | 1 | 1 | 1 | 0 | 0 | 1 | 0 | 1 |
+-------+---+---+---+---+---+---+---+---+
如图所示,0xe5 = 1110 0101,从左往右数,前两位的二进制数11是MOD,MOD=11表示该指令的两个操作数都是寄存器。之后的连续三位二进制数100对应寄存器esp,最后的三位二进制数101对应寄存器ebp(参考modrm_byte_32)。这样一来,一个十六进制数就可以对应两个操作数了。
mov eax, [A]对应的机器语言代码
我们再来看下一行汇编语言的代码mov eax, [A]和对应的机器语言的十六进制数
a1 00 20 40 00 mov eax,ds:0x402000
先在X86 Opcode and Instruction Reference中搜索a1,可以看到
A1 MOV eAX moffs16/32
a1对应的mov指令的两个操作数分别是eax寄存器和16/32位的内存地址的偏移量(offset)。结合后面的ds:0x402000来看,00 20 40 00就是内存地址偏移量(采用小端序),这里的ds是数据段data segment的缩写。另外,这个内存地址就是标签A对应的内存地址,可以通过GDB命令p &A验证这一点。
add eax, [B]对应的机器语言代码
最后再来看一下add指令对应的机器语言代码。
03 05 04 20 40 00 add eax,DWORD PTR ds:0x402004
查询手册可知
03 ADD r16/32 r/m16/32
add指令对应03,后续的05又是ModR/M byte,表示该指令的第一个操作数是寄存器eax(REG=000),第二个操作数是内存地址(R/M=101)。
+-------+---+---+---+---+---+---+---+---+
| Bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
+-------+---+---+---+---+---+---+---+---+
| Usage | MOD | REG | R/M |
+-------+---+---+---+---+---+---+---+---+
| 05 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 |
+-------+---+---+---+---+---+---+---+---+
05之后的四个字节04 20 40 00是标签B对应的内存地址。
现在诸位应该能深切地感受到为什么要发明汇编语言了吧,因为使用机器语言编程时不得不记忆毫无规律的数字,实在是太不方便了。
上一篇文章的结尾处提出了一个问题:
在高级语言中,计算两整数之和可以只用两个变量
a += b,但在汇编语言中,为什么不能写成add [A], [B]呢?
其实答案很简单,因为没有两个操作数都是内存地址的、加法的机器语言指令。汇编语言的指令和机器语言的指令是一一对应的,如果某种机器语言的指令不存在,自然就不存在与之对应的汇编语言的指令。或者说,如果找不到对应的机器语言的指令,这样的汇编语言的指令就是无效的。
在SASM中查看寄存器和内存存储单元中的数据
SASM的“寄存器组”窗格和“内存”窗格可用于分别查看寄存器和内存存储单元中的数据。这两个窗格都只有在调试模式下才会出现。先点击工具栏中的“调试”按钮,然后通过分别点击“调试”菜单中的“显示寄存器组”和“显示内存”这两个菜单项就可以打开这两个窗格。
如图所示,“寄存器组”窗格显示在窗口的右侧,里面列出了各个寄存器中的数据。“内存”窗格显示在窗口上方。
点击“内存”窗格中的“添加变量...”,然后输入A并按下回车键,就可以看到在“值”这一列出现了数字“1”,这正是我们在数据段中定义的、存储在A标签中的整数。“内存”窗格不仅可以监视定义在数据段中的标签的值,还可以查看 main标签对应的内存地址。继续点击“添加变量...”,然后输入“main”,再按下回车键后就能看到main标签对应的地址了。
使用SASM逐行调试代码
下面我们在SASM中逐行调试这段程序。调试时重点关注两点:
- eax寄存器和内存存储空间中的数据
- eip寄存器的值
点击工具栏中的“调试”按钮后,代码编辑区域的左侧会出现一个绿色的箭头。箭头此时指向了mov ebp, esp这条指令。在调试程序时,要时刻关注绿色箭头的位置。绿色箭头指向哪条指令,哪条指令就是即将执行的指令。
mov ebp, esp这条指令是SASM自动加入的,仅用于辅助调试,并不会影响程序的流程。我们可以直接点击工具栏中的“跳过”按钮,忽略这条指令。
为了便于还没有运行SASM的读者阅读下面的内容,我将汇编语言的代码和对应的机器语言的代码整理到了下面的表格中
| 汇编语言的指令 | 指令的内存地址 | 与入口指令的偏移量 | 机器语言的指令 | 反汇编后的指令 |
|---|---|---|---|---|
mov eax, [A] | 0x401392 | +2 | a1 00 20 40 00 | mov eax,ds:0x402000 |
add eax, [B] | 0x401397 | +7 | 03 05 04 20 40 00 | add eax,DWORD PTR ds:0x402004 |
mov [ANS], eax | 0x40139d | +13 | a3 08 20 40 00 | mov ds:0x402008, eax |
执行mov eax, [A]
此时,绿色的箭头应该跳过了;write your code here这行注释,指向了mov eax, [A]这条指令。这条指令一旦执行,就会将存储在A标签中的整数1(0x1)复制到CPU的eax寄存器中。那现在eax寄存器中的值是什么呢?
有几种方式可以查看在eax寄存器中的值:
- 查看SASM中的“寄存器组”窗格
- GDB命令:
info registers eax
另外,这条指令的地址“0x401392”,而此时,eip寄存器的值也是“0x401392”,这似乎说明eip寄存器的值应该就是即将执行的指令的地址。
这里还有一个小问题:mov eax, [A]与mov eax,ds:0x402000有什么关联呢?这两条指令其实是同一条指令。前面讲过,A只不过是一个贴在内存存储空间上的标签,本质上还是数字形式的内存地址,CPU在解释执行指令时只会使用内存地址。这里的0x402000就是 A 标签对应的地址,ds 是数据段(.data section)的缩写,用于说明A标签对应的是位于数据段中的内存地址。
执行add eax, [B]
继续点击“跳过”按钮,eip寄存器的值又变为了“0x401397”,通过对比 GDB 输出的信息,这个地址正好是add eax, [B]这条指令的地址,而0x402004是B标签对应的内存地址。我们注意到这一次点击“跳过”按钮使eip寄存器的值从0x401392变为了0x401397,增加了5,这说明刚刚执行的mov指令对应的机器语言代码应该占5 字节,而a1 00 20 40 00恰好是5字节。
该指令的作用是将存储在B标签中的整数2累加到CPU的eax寄存器中。这样一来,eax寄存器的值就应该由0x1变为0x3了。再次点击“跳过”按钮后,不出所料,eax寄存器的值果然又变成了 0x3。
执行mov [ANS], eax
现在,绿色的箭头又指向了mov [ANS], eax这条指令。eip寄存器的值又变成了0x40139d,这正是这条指令的地址。
该指令能够将eax寄存器中的两数之和0x3存储到ANS标签中。为了验证这条指令的作用,我们先把ANS也添加到内存窗格,ANS当前的值是0(0x0),点击“跳过”按钮后,就会发现它的值真的也变成 3(0x3)了。
将eax寄存器清零后程序退出
再次点击“跳过”按钮,绿色的箭头现在指向了xor eax, eax,这条指令的作用是将eax寄存器的值清零。因为0表示程序正常退出。点击“跳过”按钮后,eax寄存器的值的确清零了。
绿色的箭头终于到达了最后一条指令。一旦执行完ret这条指令,程序就会退出。再次点击“跳过”按钮,随着程序的退出,窗口的最下方出现了一行绿色的文字“调试完成。”
通过反复观察对比eip寄存器中的内存地址,我们可以得出如下结论:eip寄存器中存储着即将执行的指令的地址。每执行完一条指令,eip寄存器的值都会自动更新为下一条即将执行的指令的地址。而这正是程序中的指令得以按顺序执行的机制。
读到这里,想必大家都对在执行“计算1+2”时,计算机内部都发生了什么有所了解了吧。