大家好,欢迎来到IT知识分享网。
一、
汇编语言 –寄存器-指令集-寻址_寄存器的汇编语言有哪些-CSDN博客
二、原文链接:https://www.jianshu.com/p/55726f7e355a
当我们使用高级语言调用一个函数 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],对应指令为ff,addr = 0x0911; - 另一次是
call ax,对应指令为ffd0。
同样我们将其反汇编,得到如下结果:
00000000 C mov word [0x911],0x915 00000006 FF 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 指令的几种调用方式,举出具体代码示例说明其使用方式,并查看了相应机器码。然后通过反汇编或者直接查看机器码的方式,进一步验证了原理与实际指令布局的契合。
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://haidsoft.com/120033.html