函数调用约定规则
函数调用之间需要约定,就和我写这篇这个文档遵守markdown的语法一样,x86可以参考:
摘自内核的头文件:
arch/x86/include/asm/calling.h
x86 function call convention, 64-bit
:
arguments [caller-clobbered] | callee-saved | extra caller-saved [callee-clobbered] | return |
---|---|---|---|
rdi rsi rdx rcx r8-9 | rbx rbp [*] r12-15 | r10-11 | rax, rdx [**] |
x86 function calling convention, 32-bit
:
arguments [caller-clobbered] | callee-saved | extra caller-saved [callee-clobbered] | return |
---|---|---|---|
eax edx ecx | ebx edi esi ebp [*] | eax, edx [**] |
第一个问题我们常说的caller-saved
和callee-saved
究竟在说什么,第二个问题函数调用到底是怎么回事,第三个是内联汇编可以怎么写
caller和callee
假如我们写了一个下面形式的程序,其中funb称为调用者caller
,funa称为被调用者callee
int funa(int x, int y, int z)
{
return x+y+z;
}
int funb(void)
{
funa(x, y, z);
}
传参和caller-saved,callee-clobbered
函数调用传参有两种形式,一种是栈上传参,一种是寄存器传参。
假如我要传递三个参数,我会首先在栈底分配参数空间:8*3=0x18,然后第一个参数放在rsp+0,第二个参数放在rsp+8,第三个参数放在rsp+0x10位置;callee拿到rsp的地址,从rsp+0取第一个参数,rsp+8取第二个参数,rsp+0x10取第三个参数。
寄存器传参就更简单了,caller把三个参数依次放在rdi,rsi,rdx上,你也不要去栈上load到寄存器上了,我已经给你放好了,你直接用吧。
通过栈传参是计算机早期的一种形式,那时候寄存器太少,就算放到寄存器上还是需要在栈上来回倒腾,不如直接放在栈上。现在计算机基本都是有32个寄存器,直接放在寄存器上速度更快,所以栈传参基本上都废弃了。
caller-saved
和callee-clobbered
是在说同一件事情,函数调用中通过设置rdi rsi rdx rcx r8-9
来传递参数,之后跳转到callee,规定:这些寄存器的值调用者需要caller保存
。
寄存器传参的使命跳转到callee之后已经结束了,callee可以自由地使用这些寄存器,而callee非常可能修改寄存器的值。caller如果不想这些寄存器中存放的数据丢失,那么需要自己主动保存寄存器中的数据。
每个函数都有自己的定义,通过定义就知道自己接收到那些参数,它可以选择使用,也可以选择不使用,但是它可以使用全部的普通寄存器
,callee函数除了知道自己需要的参数放在哪些寄存器中,它不知道也不关心caller中对于寄存器的使用情况,所以caller把自己的数据保存在栈上是非常明智的。
函数调用
我们在写main函数原型的时候,标准写法是int main(int argc, char **argv,char **auxv)
,老师教我们的写法是void main(void)
,但是为什么不同的写法程序为什么没有崩溃呢?
经典的动态链接程序的启动顺序是:动态解释器
->start
->__libc_start_main
->generic_start_main
->main
(generic_start_main一些早期的glibc中是没有的).
下面是glibc中的代码片段:
glibc/csu/libc-start.c
STATIC int LIBC_START_MAIN (int (*main) (int, char **, char **
MAIN_AUXVEC_DECL)...)
{
result = main (argc, argv, __environ MAIN_AUXVEC_PARAM);
exit (result);
}
我们现在通过一个简单的main来验证是不是上面的代码片段,静态链接并反汇编看一下它的调用过程:
#gcc test.c --static -o test
int main(int argc, char **argv, char **auxv)
{
printf("argc:%d argv:%p auxv:%p\n", argc, argv, auxv);
}
objdump -d test:
generic_start_main:
400c1f: 48 8b 15 1a ba 2c 00 mov 0x2cba1a(%rip),%rdx # 6cc640 <__environ>
400c26: 48 8b 74 24 10 mov 0x10(%rsp),%rsi
400c2b: 8b 7c 24 0c mov 0xc(%rsp),%edi edi是rdi的32bit寄存器
400c2f: 48 8b 44 24 18 mov 0x18(%rsp),%rax
400c34: ff d0 callq *%rax
#./test
argc:1 argv:0x7ffe5ca49488 auxv:0x7ffe5ca49498
我们对比c代码和汇编代码,汇编代码中是将前三个值放到rdi,rsi,rdx
中,下面一条call指令直接跳转到main,这个和C片段中是完全对应上了,我知道了有下面的结论。
函数调用的规则可以分成两部分:
- 1.根据callee的生命,caller在调用它时设置参数到寄存器中
- 2.callee实现中可以用也可以不用,这是它的自由
特别是后面的规则,有更多的要求:callee中实现的参数的类型要匹配,并且不能多与callee原型中的参数个数
。
假如声明函数原型为:int fun(int x, char **y)
,但是实现中使用int func(int *x, char **y)
,caller中设置的x=2
,callee却要访问*x=*2
,这个地址是非法的,之后会收到SIGSEGV信号可能会导致程序异常退出;也不能实现为int fun(int x, char **y, char *c)
,你如果要访问*c
,caller中没设置第三个寄存器,是个随机值,你访问一个随机地址这个就是个野指针,程序出现异常是必然的。
内联汇编
内联汇编是gcc的扩展,gcc文档6.45 How to Use Inline Assembly Language in C Code
相关章节详细描述了一般规则和所有支持arch下的特有语法。内联汇编允许我们在C语言片段中嵌入汇编语言,类似于java提供的JNI(java native interface),python提供的C extension。在内核中arch相关的代码:启动,中断处理,系统调用等都是汇编写的,汇编在某些场景下(C语言搞不定)还是必需品。
内联汇编的一般形式如下,但是每个arch的内联汇编都是不同的,首先是寄存器名称都不一样,当然你也可以给他们取相同的别名,但是他们实际的调用规则都不同,强用别名真的会走火入魔的。其次他们的寄存器使用时写法不同,除了通用选项还有一些自己的选项。就算同样相似的内联汇编代码,但是gcc编译出的二进制却完全不同,这是因为gcc arch的后端实现和寄存器使用方法有非常大的区别,只是大家都批了同一张皮,都叫内联汇编而已。
asm ( assembler template
: output operands (optional)
: input operands (optional)
: list of clobbered registers(optional)
);
相关细节继续看gcc文档6.45 How to Use Inline Assembly Language in C Code
,或者自行google,我就不在献丑了
内联汇编操作寄存器
通过上面我们知道函数调用之间是有规则的,这是靠gcc编译器来强制遵循的,不过C语言中有强大的函数指针,也可以绕过这个限制还不会有任何编译报警:
#gcc test1.c -c -o test1.o
int funa(int* a, int b){return 0;}
int fun(void)
{
int (*f)(int ) = (int (*)(int ))funa;
f(1);
}
通过反汇编我们再一次确认了上面的结论
#objdump -d test1.o
0000000000000000 <funa>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 89 7d f8 mov %rdi,-0x8(%rbp)
8: 89 75 f4 mov %esi,-0xc(%rbp)
b: b8 00 00 00 00 mov $0x0,%eax
10: 5d pop %rbp
11: c3 retq
0000000000000012 <fun>:
12: 55 push %rbp
13: 48 89 e5 mov %rsp,%rbp
16: 48 83 ec 10 sub $0x10,%rsp
1a: 48 c7 45 f8 00 00 00 movq $0x0,-0x8(%rbp)
21: 00
22: 48 8b 45 f8 mov -0x8(%rbp),%rax
26: bf 01 00 00 00 mov $0x1,%edi
2b: ff d0 callq *%rax
2d: 90 nop
2e: c9 leaveq
2f: c3 retq
函数调用规则是纯C语言函数之间需要遵守的规则,汇编语言不需要遵守,它如果遵守了说明他是个乖宝宝。
这里有个小小的问题:代码是什么?跳转又是什么?
我刚开始学C语言的时候从来没有思考过这个问题。代码就是一块指令的集合,就如上面的fun
函数,就是从上向下顺序执行,直到跳转指令。跳转是什么?跳转就是它的字面意思,PC指针调到另外一个位置开始取指令顺序执行。函数指针的作用是什么?当我跳转到这个函数指针的时候我应该怎么去设置我的寄存器,获取返回值,除此之外它就是个地址,让pc指针跳转到这个地址,类似于mov func %pc
。
我们使用内联汇编来桥接C和汇编的时候,这个时候需要遵守函数调用约定,下面是一个我在做arm64下内核hook的时候遇到的一个场景,参考另外一篇文档:linux内核态hook模块。下面我会用一些伪代码来描述上下文,我hook了内核的do_mount参数,而且原始的架构做成了这样
do_mount->hook控制块代码 //do_mount第一条指令修改成:jmp hook控制块代码
hook控制块代码{ //hook控制块代码是汇编代码手工组成的
call hook_do_mount
call origin_do_mount
}
现在需要传递给origin_do_mount新的参数,那么我可以采用如下形式的内联汇编,它充当的角色就是caller设置寄存器的动作。
void hook_do_mount(const char *dev_name, const char __user *dir_name,
const char *type_page, unsigned long flags, void *data_page)
asm volatile(
"mov x0, %0\n"
"mov x1, %1\n"
"mov x2, %2\n"
"mov x3, %3\n"
"mov x4, %4"::"r"(dev_name), "r"(dir_name), "r"(type_page), "r"(flags), "r"(data_page));
}
纯粹的汇编代码不需要遵守约定,只要自己的上下文逻辑能够不互相冲突,不破坏寄存器的内容。但是当汇编代码增多的时候就需要进行分段,约定好调用关系,这样就慢慢演化除了函数调用规则,把不同汇编代码封装成功能函数,互相约定接口,有一天C语言告诉你不用写汇编了,我给你封装好了,我们就从石器时代来到了青铜器时代。生产工具的进化并不代表我们不需要强健的身体,C也不能完全取代汇编,每一门生存下来的语言都有他自己的舞台
总结
1.函数调用约定到底约定了什么
2.代码是什么?函数跳转又是什么?
2.内联汇编规定了C和汇编的接口,你可以很灵活的使用它,它只是约定了怎么使用C程序的参数,又是怎么在汇编中设置C程序的参数的。如果你不需要使用C的参数,你完全不用管,在内联汇编中可以灵活的使用寄存器