linux函数调用过程中的寄存器

函数调用约定规则

函数调用之间需要约定,就和我写这篇这个文档遵守markdown的语法一样,x86可以参考:
摘自内核的头文件:
arch/x86/include/asm/calling.h
x86 function call convention, 64-bit:

arguments [caller-clobbered]callee-savedextra caller-saved [callee-clobbered]return
rdi rsi rdx rcx r8-9rbx rbp [*] r12-15r10-11rax, rdx [**]

x86 function calling convention, 32-bit:

arguments [caller-clobbered]callee-savedextra caller-saved [callee-clobbered]return
eax edx ecxebx edi esi ebp [*]eax, edx [**]

第一个问题我们常说的caller-savedcallee-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-savedcallee-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的参数,你完全不用管,在内联汇编中可以灵活的使用寄存器

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值