汇编语言一发入魂 0x07 - 过程调用
前两篇文章中我们学习了如何控制屏幕光标,如何从硬盘读取数据。这种常用的功能我们希望将它封装成过程调用,类似于高级语言中的函数,这样当我们控制光标或者从硬盘读取数据时就不需要每次都写大段的重复代码了。
在CPU中,执行的指令通过cs:ip来确定。过程调用实际上就是通过call或lcall指令来修改ip或cs:ip来达到跳转到另一段指令中执行的目的。
call指令通过修改ip来实现过程调用,因为只修改ip,所以被调例程与原例程在同一个代码段内,也称为近调用。处理器在执行call指令时先将call后面的第一条指令的偏移地址压栈,再通过操作数计算出新的ip替换当前ip。
lcall指令通过修改cs:ip来实现过程调用,因为同时修改cs和ip,所以被调例程与原例程不在同一个代码段内,也称为远调用。处理器在执行lcall指令时先将cs、ip依次压栈,再用指令中给出的段地址代替cs原有的内容,用指令中给出的偏移地址代替ip原有的内容。
从子例程返回到原例程使用ret或lret指令。ret指令用栈中的数据修改ip的内容,实现近转移;lret用栈中的数据修改cs:ip,实现远转移。CPU执行ret指令时相当于执行pop ip,执行lret指令时相当于执行pop ip、pop cs。
下面我们通过一些简单的例子来学习一下如何使用这些指令。
示例一
代码
.code16
movw $0x7c00, %sp
callw put_char_A
jmp .
put_char_A:
movw $0xb800, %ax
movw %ax, %es
movw $'A' | 0x0a00, %es:0
retw
.org 510
.word 0xAA55
解释
第3行设置堆栈栈顶指针。因为call指令和ret指令的执行依赖于堆栈。
第5行调用了我们在第9行定义的子例程。
第13行使用ret指令跳回原来的执行流程。
编译、反编译
$ as --32 boota.s -o boota.o
$ objcopy -O binary -j .text boota.o boota.bin
$ objdump -D -b binary -m i386 -Mi8086,suffix boota.bin
boota.bin: 文件格式 binary
Disassembly of section .data:
00000000 <.data>:
0: bc 00 7c movw $0x7c00,%sp
3: e8 02 00 callw 0x8
6: eb fe jmp 0x6
8: b8 00 b8 movw $0xb800,%ax
b: 8e c0 movw %ax,%es
d: 26 c7 06 00 00 41 0a movw $0xa41,%es:0x0
14: c3 retw
...
1fd: 00 55 aa addb %dl,-0x56(%di)
第12行编译后的指令是e8 02 00,其中e8是操作码,02 00是操作数,转换成正常顺序即00 02。编译器在计算这个操作数的时候先使用标号的汇编地址(该例中为8)减去本指令的汇编地址(该例中为3),再减去3,作为机器指令的操作数。即8 - 3 - 3 = 2。同样,指令在执行时,CPU先用ip当前的值加上指令中的操作数,再加上3,得到偏移地址。然后将call指令之后的第一条指令的地址压入栈中,再使用刚才计算得到的ip替换当前ip,从而完成跳转。因为此时栈中压入的是call后的第一条指令的偏移地址,所以当子例程通过ret返回时,会使用这个地址替换ip。从而使调用例程继续执行后续指令。
调试
启动虚拟机:
$ qemu-system-i386 boota.bin -S -s
在另一个终端启动gdb(配合.gdbinit):
$ gdb -q
Breakpoint 1, 0x00007c00 in ?? ()
1: x/i $cs*16+$pc
=> 0x7c00: mov $0x7c00,%sp
(gdb)
向后执行一条指令:
(gdb) si
0x00007c03 in ?? ()
1: x/i $cs*16+$pc
=> 0x7c03: call 0x7c08
(gdb)
可以看到这里计算出来的地址是0x7c08,当前指令的地址0x7c03,加操作数2,再加3,得到0x7c08。继续执行并查看寄存器内容:
(gdb) si
0x00007c08 in ?? ()
1: x/i $cs*16+$pc
=> 0x7c08: mov $0xb800,%ax
(gdb) info registers
eax 0xaa55 43605
ecx 0x0 0
edx 0x80 128
ebx 0x0 0
esp 0x7bfe 0x7bfe
ebp 0x0 0x0
esi 0x0 0
edi 0x0 0
eip 0x7c08 0x7c08
eflags 0x202 [ IF ]
cs 0x0 0
ss 0x0 0
ds 0x0 0
es 0x0 0
fs 0x0 0
gs 0x0 0
此时ip的内容为0x7c08,sp的内容为0x7bfe。sp初始时我们设置成了0x7c00,在执行call指令时处理器会将call后面一条指令的偏移地址压栈,所以sp的值变成了0x7bfe。我们来查看一下栈中的内容:
(gdb) x/1xh 0x7bfe
0x7bfe: 0x7c06
(gdb)
0x7c06正好是后面jmp指令的偏移地址。稍后ret指令执行时会将这个偏移地址从栈中弹出到ip,来跳回到原来的执行流程。
向后执行3条指令:
(gdb) si 3
0x00007c14 in ?? ()
1: x/i $cs*16+$pc
=> 0x7c14: ret
(gdb)
此时屏幕左上角会打印出字符'A',常规操作就不贴图了。观察上面的输出,下一条要执行的便是ret指令,查看一下此时的寄存器内容:
(gdb) info registers
eax 0xb800 47104
ecx 0x0 0
edx 0x80 128
ebx 0x0 0
esp 0x7bfe 0x7bfe
ebp 0x0 0x0
esi 0x0 0
edi 0x0 0
eip 0x7c14 0x7c14
eflags 0x202 [ IF ]
cs 0x0 0
ss 0x0 0
ds 0x0 0
es 0xb800 47104
fs 0x0 0
gs 0x0 0
ip是0x7c14,要跳转到的偏移地址还保存在0x7bfe处。执行ret指令,观察结果:
(gdb) si
0x00007c06 in ?? ()
1: x/i $cs*16+$pc
=> 0x7c06: jmp 0x7c06
(gdb) info registers
eax 0xb800 47104
ecx 0x0 0
edx 0x80 128
ebx 0x0 0
esp 0x7c00 0x7c00
ebp 0x0 0x0
esi 0x0 0
edi 0x0 0
eip 0x7c06 0x7c06
eflags 0x202 [ IF ]
cs 0x0 0
ss 0x0 0
ds 0x0 0
es 0xb800 47104
fs 0x0 0
gs 0x0 0
看到了吗?ip的值已经是0x7c06了,下一条要执行的指令也如我们所愿是jmp了。
call指令的操作数还可以在寄存器或内存中,例如callw *%cx或callw *procedure_address。需要注意的是正如你看到的,寄存器或内存地址前需要加一个*,就好像指针一样。具体的代码戳这里。
下面来看一个lcall的例子。
示例二
代码
.code16
movw $0x7c00, %sp
lcallw $0x07d0, $0
jmp .
.org 0x100
put_char_A:
movw $0xb800, %ax
movw %ax, %es
movw $'A' | 0x0a00, %es:0
lretw
.org 510
.word 0xAA55
解释
第5行lcall指令的格式为lcall $section, $offset。0x07d0是远调用的代码段地址,0是段内偏移。
第9行使用伪指令.org将位置计数器移动到了0x100处。因为主引导记录是被加载到0x7c00处的,所以标号put_char_A在程序执行时的实际物理地址是0x7c00 + 0x100 = 0x7d00,对应段地址0x07d0,段内偏移0。
第14行使用lret指令将栈中保存的段内偏移和段地址依次弹出到ip、cs,恢复原来的执行流程。
调试
启动虚拟机:
$ qemu-system-i386 bootla.bin -S -s
启动gdb:
$ gdb -q
Breakpoint 1, 0x00007c00 in ?? ()
1: x/i $cs*16+$pc
=> 0x7c00: mov $0x7c00,%sp
(gdb)
向后执行两条指令,此时已经进入到了子例程,查看寄存器状态:
(gdb) si 2
0x00000000 in ?? ()
1: x/i $cs*16+$pc
0x7d00: mov $0xb800,%ax
(gdb) info registers
eax 0xaa55 43605
ecx 0x0 0
edx 0x80 128
ebx 0x0 0
esp 0x7bfc 0x7bfc
ebp 0x0 0x0
esi 0x0 0
edi 0x0 0
eip 0x0 0x0
eflags 0x202 [ IF ]
cs 0x7d0 2000
ss 0x0 0
ds 0x0 0
es 0x0 0
fs 0x0 0
gs 0x0 0
此时已经跳转到了段0x7d0,段内偏移0x0处了。sp也因为cs:ip压入栈中变成了0x7bfc,查看栈中的内容:
(gdb) x/2xh 0x7bfc
0x7bfc: 0x7c08 0x0000
(gdb)
低地址处是ip 0x7c08,高地址处是cs 0x0000。向后执行4条指令并查看寄存器内容:
(gdb) si 4
0x00007c08 in ?? ()
1: x/i $cs*16+$pc
=> 0x7c08: jmp 0x7c08
(gdb) info registers
eax 0xb800 47104
ecx 0x0 0
edx 0x80 128
ebx 0x0 0
esp 0x7c00 0x7c00
ebp 0x0 0x0
esi 0x0 0
edi 0x0 0
eip 0x7c08 0x7c08
eflags 0x202 [ IF ]
cs 0x0 0
ss 0x0 0
ds 0x0 0
es 0xb800 47104
fs 0x0 0
gs 0x0 0
可以看到在lret指令执行后,cs恢复成了0x0,ip恢复成了0x7c08。sp因为ip和cs的出栈恢复了初始值0x7c00。
lcall的操作数也可以在内存中,例如lcallw *procedure_address。具体的代码戳这里
