当我们使用高级语言调用一个函数 func()
时,在编译为汇编代码后,实际上是调用了 call 指令。伪代码如下:
call func
默认的 call 调用是 near 近调用。聪明的你可能想到,既然有近调用,那么肯定有远调用了。今天我们就来说说 call 在 x86 的 16 位 实模式
下的几种调用方式。
开门见山,先列出 call 调用的 4 种方式:
- 相对近调用
- 间接绝对近调用
- 直接绝对远调用
- 间接绝对远调用
可以看到,上面的几种调用方式种有几组反义词,间接/直接,近/远,绝对/相对。字面上都很好理解,肯定也都是跟地址相关的,那么具体到调用层面,是怎么处理的呢?我们一一来讲解。
在讲述之前,我们需要明确一个概念。在实模式中,CS 寄存器中存放的是段基址,IP 寄存器中存放的是段内偏移量,内存物理地址 = 段基址 + 段内偏移
。
相对近调用
- 近调用:指调用的函数在同一个段中,无需跨段,即 cs 不变。
- 相对:指待调用函数地址相对于下一条指令(即 call 的下一条),计算出一个偏移量。也就是说这个偏移量不是真正的段内偏移,而是相对位置偏移量。
指令格式如下,默认为 near,因此 near 可以省略。
call near 立即数地址
call 中的立即数地址也就是对应着相对位置偏移量
。所以要想获取真正的偏移,还需经过一番计算。
由于 x86
是小端字节序
,即 高位在高地址,低位在低地址
。它对应的机器码是 e8llhh
,大小为 3 字节,其中 e8
代表相对近调用。ll 表示立即数的低位,hh 表示立即数的高位。
假设立即数为 0x1234,34 在低位,12 在低位。机器码如下:
e83412
假设知道了相对偏移量,那么被调用函数实际段内偏移地址计算方式如下:
被调用函数实际段内偏移 = 下一条指令地址 + 相对偏移量
下一条指令地址 = 当前指令地址 + 当前指令长度
// 最终结果
被调用函数实际段内偏移 = 当前指令地址 + 当前指令长度 + 相对偏移量
由于是相对近调用,编译器需要算出相对偏移量,根据上述公式,可得出:
相对偏移量 = 被调用函数实际段内偏移(也就是函数地址) - (当前指令地址 + 当前指令长度)
举个栗子,假设被调用函数的地址为 0x12,call 指令的地址为 0x3,那么相对偏移量为 0x12 - 0x3 - 3 = 0x6
。
0x6 填充为 2 字节表示:0x0006
,后两位 06
是低位,前两位 00
是高位,因此机器码可表示为:e80600
,从左往右地址增大。
实例
为了让大家理解得更清晰,我们通过一个例子讲解下。call_1.S 的代码如下,每行添加了相应注释。
;近调用 call_proc
call near near_proc
; $ 表示当前行,即不断循环
jmp $
;定义变量 addr,初始值为 0x4
addr dd 4
;定义 near_proc 函数
near_proc:
;将 0x1234 放入 ax 寄存器
mov ax, 0x1234
;返回
ret
-
nasm -o call_1.bin call_1.S
将其编译,生成机器码。 -
使用
xxd
来逐字节查看 call_1.bin 中的内容。如何使用xxd
查看字节,可参看 辅助工具 。输入如下命令:
// 0 - 起始字节 // 13 - 查看的字节长度 ./xxd.sh call_1.bin 0 13
输出如下:
00000000: E8 06 00 EB FE 04 00 00 00 B8 34 12 C3 ..........4..
上面我们说到,这种方式的机器码为 e8llhh
。一眼可以看出,第一个字节就是 e8
,后面的 0x0006
就是立即数。但是如何验证它所调用函数的地址,是通过我们提到的公式计算得到的呢?
我们将生成的机器码反汇编一下,ndisasm call_1.bin
,输出如下结果:
00000000 E80600 call 0x9
00000003 EBFE jmp short 0x3
00000005 0400 add al,0x0
00000007 0000 add [bx+si],al
00000009 B83412 mov ax,0x1234
0000000C C3 ret
- 第一列是文件偏移,当没有设置编址基址时可认为它是地址。编址基址在第二种调用方式种会提到。
- 第二列是机器码指令。
- 第三列是汇编代码。
从上可以看到,第一条指令为 E80600
,对应汇编代码 call 0x9
,也就是说调用函数的地址是 0x9
。它的相对偏移量为 0x0006
,我们再根据 被调用函数实际段内偏移 = 当前指令地址 + 当前指令长度 + 相对偏移量
这个公式计算出实际偏移量,看是否能跟反汇编中的结果匹配。
// 当前指令地址 0,指令长度 3,相对偏移 0x6
实际偏移 = 0 + 3 + 0x6 = 0x9
得到实际偏移为 0x9
,计算出的偏移量跟反汇编得到的偏移是吻合的,验证通过~
再看一下这条指令,可以得知 mov ax
的操作码是 b8
。这里提到它是让大家先有个印象,因为后面会用到。
00000009 B83412 mov ax,0x1234
间接绝对近调用
- 间接:顾名思义,即不能直接使用的数据。需要通过寄存器寻址或者是内存寻址,从寄存器/内存地址中获取地址。
- 绝对:即为真实段内偏移,无需再次计算。
指令格式可分为寄存器
和内存地址
两种方式:
// 通过寄存器
call ax
// 通过地址
call [addr]
-
call [addr]
,内存寻址。它的操作码为ff16
,整条指令的机器码是ff16+16位内存地址
。 -
call 寄存器
,寄存器寻址。随着寄存器不同,操作码也不一样。比如call ax
的机器码是ffd0
,call cx
的机器码是ffd1
。
这种方式比相对近调用要简单一些,从寄存器/内存中获取地址即可。
实例
下面我们用一个栗子来讲解一下。call_2.S
代码如下,每句代码都添加了相应注释。
;自定义 section,名字为 call_test,告诉汇编器从 0x900 开始编址,之后地址逐个+1
section call_test vstart=0x900
;将 near_proc 函数地址写入 addr 变量中,word 表示 2 字节,即将 near_proc 地址以 2 字节表示。
mov word [addr], near_proc
;调用 near_proc
call [addr]
;将 near_proc 的地址放入 ax 寄存器
mov ax, near_proc
;调用 near_proc
call ax
; $ 代表当前指令地址,这句表示跳转到当前指令,即不断循环
jmp $
;定义变量 addr,4 字节,初始值为 0x4
addr dd 4
;定义 near_proc 函数
near_proc:
;将 0x1234 放入 ax 寄存器
mov ax, 0x1234
同样,先将其编译为机器码,然后使用 xxd 查看字节,内容如下:
00000000: C7 06 11 09 15 09 FF 16 11 09 B8 15 09 FF D0 EB ................
00000010: FE 04 00 00 00 B8 34 12 C3 ......4..
稍微瞄一眼,我们可以看到有两个 ff
的指令,这也对应着汇编代码的两次 call 调用。
- 一次是
call [addr]
,对应指令为ff161109
,addr = 0x0911
; - 另一次是
call ax
,对应指令为ffd0
。
同样我们将其反汇编,得到如下结果:
00000000 C70611091509 mov word [0x911],0x915
00000006 FF161109 call [0x911]
0000000A B81509 mov ax,0x915
0000000D FFD0 call ax
0000000F EBFE jmp short 0xf
00000011 0400 add al,0x0
00000013 0000 add [bx+si],al
00000015 B83412 mov ax,0x1234
00000018 C3 ret
下面我们来对反汇编的代码进行分析,看其地址是否跟该调用方式一致。
-
首先我们看
mov ax,0x1234
这一行,它是函数的起始行,其文件偏移是0x15
。由于我们设置了vstart=0x900
,那么函数的地址为0x900 + 0x15 = 0x915
。 -
第一行指令
mov word [0x911],0x915
。这行指令的含义比较清楚,就是将数值0x915
放入0x911
这个地址中,因此0x911
的内容就是0x915
。 -
在调用第一个 call 指令
call [0x911]
时,它指向的地址是 0x911,而 0x911 中的内容恰恰是 0x915,也就是函数地址。因此,内存寻址这种方式验证通过~ -
第三行指令
mov ax,0x915
,给 ax 赋值 0x915,即函数地址。 -
在调用第二个 call 指令
call ax
时,同样会调用到该函数。因此,寄存器寻址也验证通过~
直接绝对远调用
- 直接:表示操作数是立即数,直接可使用。
- 源调用:表示跨段访问,也就是 cs 和 ip 都需要改变。
指令格式如下,far 可加可不加,但返回必须用 retf,表示远返回。
// 段基址和段内偏移都是立即数
call far 段基址: 段内偏移
操作码是 0x9a
,整条指令格式为 0x9a + 段内偏移(2 字节) + 段基址(2 字节)
。
由于 cs 和 ip 都需要改变,因此在调用函数时,cs 和 ip 均要压栈,以便函数返回时恢复。
实例
call_3.S 代码如下:
;从 0x900 开始编址
section call_test vstart=0x900
;直接绝对远调用 far_proc,0表示段基址,far_proc 表示偏移地址。far 可加可不加,call far 0:far_proc 也可以。
call 0:far_proc
;死循环
jmp $
;函数定义
far_proc:
mov ax, 0x1234
;配合远调用使用
retf
先将其编译为机器码,然后使用 xxd 查看字节。结果如下所示:
00000000: 9A 07 09 00 00 EB FE B8 34 12 CB ........4..
很明显,我们可以看出第一个字节为 9a
,表示直接远调用。紧跟着的 2 字节为段内偏移 0x0907
,而后跟着的 2 字节为段基址 0x0000
。所以其实际地址为 0000: 0907 = 0x907
。
这次机器码比较简短,我们不用反汇编的方式,直接通过机器码来看。
函数定义是 mov ax, 0x1234
,上面提到它所对应的指令为 B83412
。这条指令的文件偏移为 7,可以自己数一下,9A 对应 0,B8 对应 7。同样由于编址基址的原因,加上 0x900,那么其实际地址为 0x907,验证通过~。
间接绝对远调用
再一次出现「间接」,这里我们应该能猜到它的含义。也就是说段基址和段内偏移需要从寄存器/内存中取出。
不过它只支持内存寻址,不使用寄存器寻址的原因可能是一下要用两个寄存器,太浪费资源。
指令格式如下所示,注意需要添加 far
,否则会跟第二种调用方式混淆。
call far [addr]
操作码是 ff1e
,后面跟着内存地址 addr
。它表示从 addr
地址中取出数据,低两个字节是段内偏移,高两个字节是段基址。
实例
cal_4.S 代码如下:
;从 0x900 开始编址
section call_test vstart=0x900
;间接远调用
call far [addr]
;死循环
jmp $
;定义 addr 变量,大小为 4 字节。低 2 字节为段内偏移,即 far_proc 地址;高 2 字节为段基址,0。
addr dw far_proc, 0
far_proc:
mov ax, 0x1234
retf
先将其编译为机器码,然后使用 xxd 查看字节。结果如下所示:
00000000: FF 1E 06 09 EB FE 0A 09 00 00 B8 34 12 CB ...........4..
其中 ff1e0906
,表示间接远调用,0x0906
为内存地址,相对编址基址 0x900
距离为 6,我们要从 0x0906
中取出 4 字节数据。那如何取呢?
第一个字节 FF 对应 0,从前往后数到 6,即对应着 0A
,再取出 4 字节,内容为 0x0000090a
,这就是函数实际地址。根据内容可计算出段基址为 0,段内偏移为 0x090a
。
同样,根据上一种方式的套路,B83412
对应的文件偏移为 10,也就是 0xa
。再加上编址基址 0x900
,即为 0x90a
,同样验证通过~。
写在最后
这篇文章中,我们介绍了 call 指令的几种调用方式,举出具体代码示例说明其使用方式,并查看了相应机器码。然后通过反汇编或者直接查看机器码的方式,进一步验证了原理与实际指令布局的契合。
网友评论