在没有原型的文件中调用的函数在ARM和x86-64上产生不同的结果
我们有3个文件: main.c
, lib.h
和lib.c
:
main.c中:
#include #include /* #include "lib.h" */ int main(void) { printf("sizeof unsigned long long: %zu\n", sizeof(unsigned long long)); printf("sizeof int: %zu\n", sizeof(int)); unsigned long long slot = 0; int pon_off = 1; lib_fn(slot, pon_off); return EXIT_SUCCESS; }
lib.h:
void lib_fn(unsigned slot, int pon_off);
lib.c:
#include #include void lib_fn(unsigned slot, int pon_off) { printf("slot: %d\n", slot); printf("pon_off: %d\n", pon_off); return; }
编译:
gcc -O2 -Wall -Wextra main.c lib.c
在ARM上运行:
$ ./a.out sizeof unsigned long long: 8 sizeof int: 4 slot: 0 pon_off: 0
在x86-64上运行:
$ ./a.out sizeof unsigned long long: 8 sizeof int: 4 slot: 0 pon_off: 1
如您所见, pon_off
在ARM上为0,在x86-64上为1。 我猜它与参数大小有关,因为lib_fn()
有两个整数, lib_fn()
8个字节,一个long long
8个字节。
-
为什么
pon_off
在ARM和x86-64上的打印方式不同? -
它与调用约定有关吗?
它与调用约定有关吗?
是的,它与调用约定/ ABI有关。
在x86-64上,函数参数的“自然”宽度是64位,而较窄的整数args仍然使用整个“槽”。 ( 寄存器中的前6个整数/指针args和前8个FP args(SysV)或前4个args(Windows),然后堆栈)。
在ARM上,寄存器宽度(和堆栈上的“arg slot”最小宽度)是32位,64位整数args需要两个寄存器。
在32位x86( gcc -m32
)上,您将看到与32位ARM相同的行为。 在AArch64上,您会看到与x86-64相同的行为,因为它们的调用约定都是“正常”,并且不会将单独的窄args打包到单个寄存器中。 ( x86-64 System V确实将结构成员打包成最多2个寄存器,而不是每个成员使用单独的寄存器!)
具有等于寄存器大小的最小“arg slot”宽度几乎是通用的,无论args是在寄存器中还是在堆栈中传递。 这不一定是int
的宽度,但是: AVR(8位RISC微控制器)有16位int
,它有两个寄存器,但char
/ uint8_t
args可以在一个寄存器中传递。
根据原型中的类型,使用原型,更宽/更窄的类型将转换为被调用者期望的类型。 显然一切都有效。
没有原型,调用中表达式的类型决定了arg的传递方式。 unsigned long long slot
在ARM的调用约定中占用前2个arg传递寄存器,其中lib_fn
期望找到它的2个整数args。
(声称在没有原型的情况下将所有内容都转换为int
的答案是错误的。没有原型等效于int lib_fn(...);
但是printf
仍然可以使用double
和int64_t
。请注意,当传递给a时, float
会隐式转换为double
。 variadic函数,就像较窄的整数类型被上转换为int
,这就是为什么%f
是double
的格式,并且没有float
格式,与scanf传递指针不同。这就是C的设计方式;这里有没有理由。但无论如何,C要求更广泛的类型能够按原样传递给可变函数,并且所有调用约定都可以容纳。)
顺便说一句, 其他破坏是可能的 :某些实现对可变参数(因而是非原型)使用不同于正常函数的调用约定。
例如,在Windows上,您可以将一些编译器设置为默认为_stdcall
调用约定 ,其中被调用者从堆栈中弹出args。 (即在弹出返回地址后使用ret 8
来做esp+=8
)但显然这个调用约定不适用于可变参数函数,所以默认不适用于它们,并且它们会使用_cdecl
或者调用者负责清理堆栈args,因为只有调用者才知道他们传递了多少个args。 希望在这种模式下,编译器至少会警告隐式声明函数的错误,因为错误会导致崩溃(堆栈指向调用后的错误位置)。
让我们来看看这个案例的asm
有关读取编译器asm输出的介绍,请参阅如何从GCC / clang组件输出中删除“noise”? ,特别是Matt Godbolt的CppCon2017演讲“我的编译器最近为我做了什么? 解开编译器的盖子“ 。
为了使asm尽可能简单,我删除了打印并将代码放在一个返回void的函数中。 (这允许尾部调用优化 ,您跳转到函数并返回给调用者。)编译器输出中的唯一指令是arg设置并跳转到lib_fn。
#ifdef USE_PROTO void lib_fn(unsigned slot, int pon_off); #endif void foo(void) { unsigned long long slot = 0; int pon_off = 1; lib_fn(slot, pon_off); }
请参阅Godbolt编译器资源管理器中的source + asm ,适用于ARM,x86-64和x86-32( -m32
)以及gcc 6.3 。 (我实际上复制了foo
lib_fn
命名了lib_fn
因此在一个版本的调用者中没有原型,而不是为每个架构设置2个单独的编译器窗口。在更复杂的情况下,这将是方便的,因为你可以在编译器窗格之间进行区分) 。
对于x86-64,输出基本上与原型相同/不同。 如果没有,调用者必须将al
( 使用xor eax,eax
使整个RAX归零 )来指示此可变函数调用在XMM寄存器中没有传递FP args。 (在Windows调用约定中,您不会这样做,因为Windows约定针对可变参数函数进行了优化,并且以牺牲正常函数为代价来实现它们的简单性。)
对于ARM:
foo: @ no prototype mov r2, #1 @ pon_off mov r0, #0 @ slot low half mov r1, #0 @ slot high half b lib_fn_noproto bar: @ with proto, u long long is converted to unsigned according to C rules, like the callee expects mov r1, #1 mov r0, #0 b lib_fn
lib_fn期望R0中的slot
和R1中的pon_off
。
打破x86-64
如果你使用unsigned __int128
你在x86-64上会遇到同样的问题。
lib_fn_noproto((unsigned __int128)slot, pon_off);
编译为:
mov edx, 1 # pon_off = EDX = 1 xor edi, edi # low half of slot = RDI = 0 xor esi, esi # high half of slot = RSI = 0 xor eax, eax # number of xmm register args = 0 jmp lib_fn_noproto
它打破了调用约定的方式与32位ARM 完全相同 ,64位arg采用前2个插槽。
这是因为x64-86和ARM如何将参数传递给函数(正如Peter Cordes在他的评论中提到的那样)。
请比较ARM和x64-86上生成的程序集:
- 在ARM上,unsigned long long存储在2个寄存器中,int在1中,在x86上存储在64位寄存器中。
- 在ARM上,函数在获取参数时,为每个参数读取单个寄存器,使第一个变量的高低部分分成2个参数。 最后传递的第二个参数是省略的。 在x64-86上,它仍然从这两个64位寄存器获取值。
旁注:在x64-86上,只有少数启动函数参数由寄存器传递,如果有更多,则下一个参数存储在堆栈中。
如果没有函数原型并且使用了隐式声明,则编译器假定所有参数都是int
类型。
看起来int在arm和x64-86 architecutre上有所不同。
请注意,修饰符%d
只能与int参数一起使用,对于无符号的使用%u
这就是为什么有警告给你。