printf的一个异常现象引发的对x86-64体系下可变参数传参的探究

测试环境

1
2
3
4
5
clang version 3.8.1-24 (tags/RELEASE_381/final)
Target: x86_64-pc-linux-gnu
Thread model: posix

Linux version 4.9.0-deepin13-amd64 (yangbo@deepin.com) (gcc version 6.3.0 20170321 (Debian 6.3.0-11) ) #1 SMP PREEMPT Deepin 4.9.57-1 (2017-10-19)

奇异现象复现

  • 代码

    1
    2
    3
    4
    5
    6
    7

    #include <stdio.h>
    int main()
    {
    double a = 6.0;
    printf("%lx\n" , a);
    }
  • 执行结果

  • 这段代码用的运行结果是随机的,无规律的,这是非常奇怪的

先说原因

  • printf因为使用的格式化字符串是”%lx”所以从通用目的寄存器读取可变参数,但是 a 因为是double类型,所以放在xmm0寄存器。

分析

  • 先看glibc-2.26中stdio-common/printf.c的源码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    int
    __printf (const char *format, ...)
    {
    va_list arg;
    int done;

    va_start (arg, format);
    done = vfprintf (stdout, format, arg);
    va_end (arg);

    return done;
    }
  • 可以看到,使用的是stdarg的机制实现可变参数传参。

  • 如果可变参数完全使用栈帧传递,那么结果不可能是随机的。那么只可能是使用寄存器传参

  • 复习一下CSAPP第三章

  • 可以看到,浮点参数的传参使用的是SIMD寄存器,而整形使用的是通用目的寄存器

  • 那么猜测,这应该是问题所在。printf因为使用的格式化字符串是”%lx”所以从通用目的寄存器读取可变参数,但是 a 因为是double类型,所以放在xmm0寄存器。

GDB调试

  • 使用 clang -S d.c && clang d.s -g命令编译上面那段问题代码。这样我们就可以在gdb里针对汇编指令设置断点

  • main函数部分汇编代码

    1
    2
    3
    4
    5
    6
    7
    8
    subq	$16, %rsp
    movabsq $.L.str, %rdi # .L.str就是"%lx\n"
    movsd .LCPI0_0, %xmm0
    # 字面量的浮点放在内存,.LCPI0_0引用的就是 double 类型的 6.0
    movsd %xmm0, -8(%rbp)
    movsd -8(%rbp), %xmm0
    movb $1, %al
    callq printf
  • 可以看到,double a 确实放在了xmm0,

  • 用GDB在 callq printf 处设置断点(注意,运行到断点处,callq printf指令还没有执行),检查用于传参的前四个通用目的寄存器

    (红框内是前四个传参的通用目的寄存器)

  • 执行gdb 的next指令 ,运行callq printf这条指令,检查输出

  • 可以看到,与rsi寄存器的内容一样。可以初步确认,因为格式字符串是”%lx”,所以printf在通用目的寄存器读取可变参数

  • 手动修改汇编代码,在callq printf之前加上一条movq $16, %rsi(注意,此处是十进制,而printf使用的格式字符串是”%lx”,所以程序输出的是十六进制)

    1
    2
    3
    4
    5
    6
    7
    movabsq	$.L.str, %rdi
    movsd .LCPI0_0, %xmm0 # xmm0 = mem[0],zero
    movsd %xmm0, -8(%rbp)
    movsd -8(%rbp), %xmm0 # xmm0 = mem[0],zero
    movb $1, %al
    movq $16, %rsi # 这一条就是加上去的
    callq printf
  • 运行,结果是

  • 符合预期,与rsi寄存器的东西一样

  • 分析结果得到证实

探究过程出现的一些问题

  • 在不合时宜的时刻检查寄存器的值
    • 执行完callq printf后才检查xmm0、xmm1的内容,企图找到double a
    • 执行完callq printf后才检查rdi、rsi的值。
  • 因为printf函数会使用这些寄存器,所以这样检查必然是不行的

关于vc++的一些补充

  • Visual Studio 2015 的 Varargs文档

    如果参数是通过 vararg(例如省略号参数)传递的,则基本上会应用正常的参数传递过程,包括溢出第五个及后续参数。 此外,被调用方的责任是转储采用其地址的参数。仅处理浮点值时,如果被调用方要使用整数寄存器中的值,整数寄存器和浮点寄存器才会同时包含浮点值

    if parameters are passed via varargs (for example, ellipsis arguments), then essentially the normal parameter passing applies including spilling the fifth and subsequent arguments. It is again the callee’s responsibility to dump arguments that have their address taken. For floating-point values only, both the integer and the floating-point register will contain the float value in case the callee expects the value in the integer registers.

  • 按照我的理解,加粗部分应该是说,如果实参里有integer也有float-point,那么我们在整形寄存器也可以读取到对应序号的浮点寄存器的值,比如test(3, 2.0, 1),那么2.0既存在于RDX,也存在于XMM1, 1既存在于R8,也存在于xmm2。这样,我们使用stdarg的va_arg(ap, long long)读取第二个参数2.0时,就不会出错。如果是gcc,就会出错,因为gcc并不会把浮点放在整形寄存器。

  • 这应该是微软为了兼容以前的老代码,以前可变参都是放在栈上,所以改变va_arg的第二个实参type也不会读错,只会形成强制类型转换。(由于手头没有vc++的编译器,只能借助跟师兄的远程合作来探究,所以这里只有部分猜测被证实,读者可以自己测试一下是否在对应序号的整形寄存器和浮点寄存器存在相同的内容)