跳至主要內容

汇编语言一发入魂 0x07 - 过程调用

未央大约 7 分钟汇编语言过程调用

前两篇文章中我们学习了如何控制屏幕光标,如何从硬盘读取数据。这种常用的功能我们希望将它封装成过程调用,类似于高级语言中的函数,这样当我们控制光标或者从硬盘读取数据时就不需要每次都写大段的重复代码了。

CPU中,执行的指令通过cs:ip来确定。过程调用实际上就是通过calllcall指令来修改ipcs:ip来达到跳转到另一段指令中执行的目的。

call指令通过修改ip来实现过程调用,因为只修改ip,所以被调例程与原例程在同一个代码段内,也称为近调用。处理器在执行call指令时先将call后面的第一条指令的偏移地址压栈,再通过操作数计算出新的ip替换当前ip

lcall指令通过修改cs:ip来实现过程调用,因为同时修改csip,所以被调例程与原例程不在同一个代码段内,也称为远调用。处理器在执行lcall指令时先将cs、ip依次压栈,再用指令中给出的段地址代替cs原有的内容,用指令中给出的偏移地址代替ip原有的内容。

从子例程返回到原例程使用retlret指令。ret指令用栈中的数据修改ip的内容,实现近转移;lret用栈中的数据修改cs:ip,实现远转移。CPU执行ret指令时相当于执行pop ip,执行lret指令时相当于执行pop ippop 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(配合.gdbinitopen in new window):

$ 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的内容为0x7c08sp的内容为0x7bfesp初始时我们设置成了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

ip0x7c14,要跳转到的偏移地址还保存在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 *%cxcallw *procedure_address。需要注意的是正如你看到的,寄存器或内存地址前需要加一个*,就好像指针一样。具体的代码戳这里open in new window

下面来看一个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

解释

5lcall指令的格式为lcall $section, $offset0x07d0是远调用的代码段地址,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恢复成了0x0ip恢复成了0x7c08sp因为ipcs的出栈恢复了初始值0x7c00

lcall的操作数也可以在内存中,例如lcallw *procedure_address。具体的代码戳这里open in new window