美文网首页
带你弄懂 call 指令调用方式

带你弄懂 call 指令调用方式

作者: 微微笑的蜗牛 | 来源:发表于2020-12-06 09:21 被阅读0次

    当我们使用高级语言调用一个函数 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
    
    1. nasm -o call_1.bin call_1.S 将其编译,生成机器码。

    2. 使用 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 的机器码是 ffd0call 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],对应指令为 ff161109addr = 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
    

    下面我们来对反汇编的代码进行分析,看其地址是否跟该调用方式一致。

    1. 首先我们看 mov ax,0x1234 这一行,它是函数的起始行,其文件偏移是 0x15。由于我们设置了 vstart=0x900,那么函数的地址为 0x900 + 0x15 = 0x915

    2. 第一行指令 mov word [0x911],0x915。这行指令的含义比较清楚,就是将数值 0x915 放入 0x911 这个地址中,因此 0x911 的内容就是 0x915

    3. 在调用第一个 call 指令 call [0x911] 时,它指向的地址是 0x911,而 0x911 中的内容恰恰是 0x915,也就是函数地址。因此,内存寻址这种方式验证通过~

    4. 第三行指令 mov ax,0x915,给 ax 赋值 0x915,即函数地址。

    5. 在调用第二个 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 指令的几种调用方式,举出具体代码示例说明其使用方式,并查看了相应机器码。然后通过反汇编或者直接查看机器码的方式,进一步验证了原理与实际指令布局的契合。

    相关文章

      网友评论

          本文标题:带你弄懂 call 指令调用方式

          本文链接:https://www.haomeiwen.com/subject/gnxowktx.html