x64汇编学习(1)-- call printf(逐行解释)

Last Updated: 2023-10-02 14:19:01 Monday

-- TOC --

测试用C代码如下:

#include <stdio.h>

int main(){
    int a = 0;
    int b = 1;
    float c = 2.3;
    double d = 4.5;
    char e = 'e';
    short f = 2;
    printf("%d %d %.2f %0.2f %c %d\n",
             a, b, c, d, e, f);
    return 7;
}

gcc 12.2,默认生成的汇编如下:(通过Compiler Explorer,它过滤掉了一些汇编器指令)

.LC2:   # ASCII string, 自动补'\0'
        .string "%d %d %.2f %0.2f %c %d\n"
main:
        # prologue
        push    rbp
        mov     rbp, rsp
        # expand stack frame with 32 bytes
        # call main前,已经push了返回地址令入栈,
        # 前面的push rbp后,rsp已经16bytes对齐,
        # 再sub rsp, 32, 依然保持16bytes对齐!
        sub     rsp, 32
        # int a = 0
        mov     DWORD PTR [rbp-4], 0
        # int b = 1
        mov     DWORD PTR [rbp-8], 1
        # 通过rip相对寻址得到.LC0的地址,
        # 用DWORD PTR的方式读取这个地址,
        # 将此地址开始的4bytes存入xmm0低32bit,
        # xmm0高96bit被清零。(当movss的source为mem时)
        movss   xmm0, DWORD PTR .LC0[rip]
        # float c = 2.3
        movss   DWORD PTR [rbp-12], xmm0
        # 将此地址开始的8bytes存入xmm0第64bit,
        # xmm0高64bit被清零。(当movsd的source为mem时)
        movsd   xmm0, QWORD PTR .LC1[rip]
        # double d = 4.5
        # [rbp-24]是为了8bytes对齐
        movsd   QWORD PTR [rbp-24], xmm0
        # 将字母e的ASCII编码101存入[rbp-25]的位置
        mov     BYTE PTR [rbp-25], 101
        # 将立即数2存入[rbp-28]的位置
        # [rbp-28]是为了2bytes对齐
        mov     WORD PTR [rbp-28], 2
        # 将[rbp-28]地址开始的WORD,
        # 以signed扩展方式,存入edi寄存器
        movsx   edi, WORD PTR [rbp-28]
        # 将[rbp-25]地址开始的BYTE,
        # 以signed扩展的方式,存入ecx寄存器
        # printf的第6个参数,char e
        movsx   ecx, BYTE PTR [rbp-25]
        # pakced xor,对xmm2寄存器清0
        pxor    xmm2, xmm2
        # 将[rbp-12]地址开始的DWORD,4bytes,
        # 转换成double,8bytes,存入xmm2寄存器低64位,
        # xmm2高64位不受影响。
        cvtss2sd        xmm2, DWORD PTR [rbp-12]
        # 将xmm2寄存器内容,存入rsi寄存器
        movq    rsi, xmm2
        # 将[rbp-24]地址开始的QWORD,8bytes,
        # 存入xmm0寄存器
        movsd   xmm0, QWORD PTR [rbp-24]
        # 将[rbp-8]地址开始的DWORD,4bytes,
        # 存入edx寄存器
        # printf的第3个参数,b
        mov     edx, DWORD PTR [rbp-8]
        # 将[rbp-4]地址开始的DWORD,4bytes,
        # 存入eax寄存器
        mov     eax, DWORD PTR [rbp-4]
        # copy edi寄存器到r8d,即r8的低32bit
        # printf的第7个参数,short f
        mov     r8d, edi
        # copy xmm0寄存器到xmm1寄存器
        # printf的第5个参数,浮点数d
        # move 16-byte aligned packed double-precision float
        # 如果是mem操作数,必须16字节对齐
        movapd  xmm1, xmm0
        # copy rsi寄存器到xmm0的低64位,
        # printf的第4个参数,浮点数c,
        # movq是一条SSE2指令,不是ATT格式。
        movq    xmm0, rsi
        # copy eax to esi
        # printf的第2个参数,a
        mov     esi, eax
        # copy .LC2's address to edi
        # memory model is FLAT
        # printf的第1个参数
        mov     edi, OFFSET FLAT:.LC2
        # copy 2 to eax,
        # printf是variadic function,2表示参数中有2个浮点数
        mov     eax, 2
        # 将下一条指令地址压栈,
        # 跳转到printf的地址,在printf的最后使用ret弹出此地址
        call    printf
        # return 7
        mov     eax, 7
        # shorthand epilogue,等价于下面两条指令:
        # mov rsp, rbp
        # pop rbp
        # 这行代码,恢复了rsp和rbp的值
        leave
        # pop return address, return to that address
        ret
.LC0:
        # float 2.3所占的4bytes的unsigned int数值
        .long   1075000115
.LC1:
        # double 4.5所占的8bytes的两次unsigned int解析的数值
        .long   0
        .long   1074921472

操作浮点数及xmm寄存器的指令,均来自SSE1或2指令集。

汇编器指令

更多汇编器指令,参考:x86-64汇编基础

关于两个浮点数

#include <stdio.h>

int main(int argc, char **argv) {
    float fv = 2.3;
    double fd = 4.5;
    printf("%u\n", *((unsigned int*)&fv));
    printf("%u\n", *((unsigned int*)&fd));
    printf("%u\n", *((unsigned int*)&fd+1));
    return 0;
}

输出:

1075000115
0
1074921472

参数传递符合Linux Calling Convention

call printf时的传参,严格按照Linux Calling Convention进行。

rip寄存器相对寻址

这是x64新增的寻址方式,rip寄存器永远指向下一条指令的地址,自动被CPU更新,在x64下,可以read。本文测试代码中,取浮点数常量的两条汇编指令如下:

movss   xmm0, DWORD PTR .LC0[rip]
...
movsd   xmm0, QWORD PTR .LC1[rip]
...

编译成object文件后,查看这两条指令,变为:

movss  xmm0,DWORD PTR [rip+0x0]  # 1e <main+0x1e>
...
movsd  xmm0,QWORD PTR [rip+0x0]  # 2b <main+0x2b>
...

所以,汇编指令写成.LC0[rip].LC1[rip],只是告诉汇编器,用rip相对寻址的方式,计算到两个Label的地址。

编译后显示[rip+0x0],显然这里需要重定位。后面的注释表示执行到这条语句的时候,rip寄存器的值,即下一条指令的虚拟地址。

编译成可执行二进制文件后,这两条指令变成:

movss  xmm0,DWORD PTR [rip+0xee8]        # 40202c <__dso_handle+0x24>
...
movsd  xmm0,QWORD PTR [rip+0xedf]        # 402030 <__dso_handle+0x28>
...

地址和字长,__dso_handle是可执行文件中的一个符号,它的地址可用readelf -s test查看,value列就是各符号的虚拟地址。(如果是object文件,符号的value值一般表示在各自section中的offset,或size)

此时,所有地址都确定了。注释中是虚拟地址。这个区域属于.rodata:

$ readelf -S test
...
[16] .rodata           PROGBITS         0000000000402000  00002000
       0000000000000038  0000000000000000   A       0     0     8
...

查看这个区域:

$ readelf -x16 test

Hex dump of section '.rodata':
  0x00402000 01000200 00000000 00000000 00000000 ................
  0x00402010 25642025 6420252e 32662025 302e3266 %d %d %.2f %0.2f
  0x00402020 20256320 25640a00 25750a00 33331340  %c %d..%u..33.@
  0x00402030 00000000 00001240                   .......@

0x40202c地址开始的4bytes为:33331340

0x402030地址开始的8bytes为:00000000 00001240

它们就是两个浮点数的值。把上面的那段代码稍微修改一下:

#include <stdio.h>

int main(int argc, char **argv) {
    float fv = 2.3;
    double fd = 4.5;
    printf("%x\n", *((unsigned int*)&fv));
    printf("%x\n", *((unsigned int*)&fd));
    printf("%x\n", *((unsigned int*)&fd+1));
    return 0;
}

输出:

40133333
0
40120000

代码中的浮点数常量,与字符串常量一样,进入.rodata,而不是立即数。

OFFSET FLAT

表示应该把其后跟着的符号虚拟地址(而不是内容)作为操作数。FLAT表示内存模型,简单地理解,就是取.LC2的虚拟地址。(早期还有分段内存模型)

mov     edi, OFFSET FLAT:.LC2

只是地址。但为什么只用edi?

这段描述来自微软官网:Operations that output to a 32-bit subregister are automatically zero-extended to the entire 64-bit register. Operations that output to 8-bit or 16-bit subregisters are not zero-extended (this is compatible x86 behavior). 操作32位的寄存器,其高32位自动做0扩展,而且具有减小code size的好处。

gcc编译器默认是non-PIE模式,这个模式会将所有static code/data放在低2GiB的虚拟地址空间内,在64位CPU下,用32位的地址表示也足够。64 bit Linux uses the small memory model by default, which puts all code and static data below the 2GB address limit. This makes sure that you can use 32-bit absolute addresses.

这行代码编译后,最终会成为这样:

mov    edi,0x402010

0x402010地址就是.rodata中的那段字符串的开始位置,如上。

如果采用-fPIE编译选项,这行代码会变成:

lea     rax, .LC2[rip]
mov     rdi, rax

采用rip寄存器相对寻址,而且使用64位的rdi。

-O1下的汇编

.LC2:
        .string "%d %d %.2f %0.2f %c %d\n"
main:
        # call main已经将下一行指令的地址压栈了,
        # 这里只需要在expand 8bytes,
        # 就16bytes对齐了。
        sub     rsp, 8
        mov     r8d, 2
        mov     ecx, 101
        movsd   xmm1, QWORD PTR .LC0[rip]
        movsd   xmm0, QWORD PTR .LC1[rip]
        mov     edx, 1
        mov     esi, 0
        mov     edi, OFFSET FLAT:.LC2
        mov     eax, 2
        call    printf
        mov     eax, 7
        add     rsp, 8
        ret
.LC0:
        .long   0
        .long   1074921472
.LC1:
        .long   1610612736
        .long   1073899110

-O1优化后,只剩下直接给printf传参的代码了,全部使用立即数,无需寄存器在内存和stack(也是内存)之间中转数据。

修改rsp的两行代码,是为了保证rsp能够16字节对齐。这是Linux ABI的规定。如果没有这两行,直接Segmentation Fault。

-O2和-O3下的汇编

.LC2:
        .string "%d %d %.2f %0.2f %c %d\n"
main:
        sub     rsp, 8
        mov     ecx, 101
        mov     edx, 1
        xor     esi, esi
        mov     r8d, 2
        mov     edi, OFFSET FLAT:.LC2
        mov     eax, 2
        movsd   xmm1, QWORD PTR .LC0[rip]
        movsd   xmm0, QWORD PTR .LC1[rip]
        call    printf
        mov     eax, 7
        add     rsp, 8
        ret
.LC0:
        .long   0
        .long   1074921472
.LC1:
        .long   1610612736
        .long   1073899110

对esi寄存器的操作发生了变化,由mov变成了xor,只应该是应为mov的值是0导致的。

本文链接:https://cs.pynote.net/hd/asm/202302081/

-- EOF --

-- MORE --