C语言在函数调用时,栈是如何变化的?

Posted 裸机思维

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C语言在函数调用时,栈是如何变化的?相关的知识,希望对你有一定的参考价值。


为什么会写篇栈变化的文章?做系统分析的话你肯定遇到过一些crash, oops等棘手问题,一般大家都会用 gdb, objdump 或者 addr2line等工具分析 pc 位置来定位出错的地方。但是这些分析工具背后的本质原理就不见得理解深刻了,而且有的时候面对一系列 backtrace 或者 stack 日志处于懵逼的状态。


今天和大家一起看下面对 crash 日志的时候,如何利用 stack 来分析其变化的来龙去脉。


Arm指令集介绍

崇尚简单粗暴的介绍方式,我们直接来看各个寄存器的大体用法,详细用法可百度,不,谷歌。
1.    r0-r3 用作传入函数参数,传出函数返回值。在子程序调用之间,可以将 r0-r3 用于任何用途。被调用函数在返回之前不必恢复 r0-r3。---如果调用函数需要再次使用 r0-r3 的内容,则它必须保留这些内容。
2.    r4-r11 被用来存放函数的局部变量。如果被调用函数使用了这些寄存器,它在返回之前必须恢复这些寄存器的值。r11 是栈帧指针 fp。
3.    r12 是内部调用暂时寄存器 ip它在过程链接胶合代码(例如,交互操作胶合代码)中用于此角色。在过程调用之间,可以将它用于任何用途。被调用函数在返回之前不必恢复 r12。
4.    寄存器 r13 是栈指针 sp。它不能用于任何其它用途。sp 中存放的值在退出被调用函数时必须与进入时的值相同。
5.    寄存器 r14 是链接寄存器 lr如果您保存了返回地址,则可以在调用之间将 r14 用于其它用途,程序返回时要恢复
6.    寄存器 r15 是程序计数器 pc它不能用于任何其它用途。

演示代码

假如现在你已经掌握了 arm 指令的用法,即便没有掌握也没关系,“书到用时回头翻”。这里以一段简单的 c 语言为例:
#include <stdio.h>
int m = 8;int fun(int a,int b){ int c = 0; c = a + b; return c;}int main(){ int i = 4; int j = 5; m = fun(i, j); return 0;}

编译一下,然后反汇编:

$ arm-linux-gnueabi-gcc main.c -o main 
$ arm-linux-gnueabi-objdump -D -D main
00010400 <fun>: 10400: e52db004 push {fp} ; (str fp, [sp, #-4]!) 10404: e28db000 add fp, sp, #0 10408: e24dd014 sub sp, sp, #20 1040c: e50b0010 str r0, [fp, #-16] 10410: e50b1014 str r1, [fp, #-20] ; 0xffffffec 10414: e3a03000 mov r3, #0 10418: e50b3008 str r3, [fp, #-8] 1041c: e51b2010 ldr r2, [fp, #-16] 10420: e51b3014 ldr r3, [fp, #-20] ; 0xffffffec 10424: e0823003 add r3, r2, r3 10428: e50b3008 str r3, [fp, #-8] 1042c: e51b3008 ldr r3, [fp, #-8] 10430: e1a00003 mov r0, r3 10434: e24bd000 sub sp, fp, #0 10438: e49db004 pop {fp} ; (ldr fp, [sp], #4) 1043c: e12fff1e bx lr
00010440 <main>: 10440: e92d4800 push {fp, lr} 10444: e28db004 add fp, sp, #4 10448: e24dd008 sub sp, sp, #8 1044c: e3a03004 mov r3, #4 10450: e50b300c str r3, [fp, #-12] 10454: e3a03005 mov r3, #5 10458: e50b3008 str r3, [fp, #-8] 1045c: e51b1008 ldr r1, [fp, #-8] 10460: e51b000c ldr r0, [fp, #-12] 10464: ebffffe5 bl 10400 <fun> 10468: e1a02000 mov r2, r0 1046c: e59f3010 ldr r3, [pc, #16] ; 10484 <main+0x44> 10470: e5832000 str r2, [r3] 10474: e3a03000 mov r3, #0 10478: e1a00003 mov r0, r3 1047c: e24bd004 sub sp, fp, #4 10480: e8bd8800 pop {fp, pc} 10484: 00021024 andeq r1, r2, r4, lsr #32




【裸机思维】转载旁注:

注意:认真从上面的汇编代码观察,你会发现:

  • 在调用函数时,编译器使用 r0和r1来传递头两个形参;

  • 在从函数返回时,编译器使用 r0 来保存函数的返回值。


这一过程是符合AAPCS(Arm 架构程序调用规约)的要求的。实际上,AAPCS规定:

  • 函数的前4个形参,如果每一个的尺寸都小于等于32bit,则每一个形参单独占用一个通用寄存器;这前四个形参固定通过 r0 ~ r3 来传递;

  • 超出4个形参的部分,使用stack来传递;

  • 函数的返回值如果其尺寸小于等于32bit,则使用r0来传递;



我们来看上面汇编的节选:

ldr     r1, [fp, #-8]     ;把实参赋值给 r1ldr     r0, [fp, #-12]    ;把实参赋值给 r0bl      10400 <fun>       ; 调用目标函数(函数会通过r0和r1获取参数)mov     r2, r0            ;函数会把返回值保存在r0中,这里是读取返回值到r2


后面的图解其实更接近C语言函数调用的理论模型——Arm的AAPCS实际上是C语言理论模型的一种本地化优化——不同硬件架构可能会有不同的本地化优化,但万变不离其宗这也是本文最精华的部分




图解栈的变化过程

如何能让读者接受吸收的更快,我一直觉得按照学习效率来讲的话顺序应该是视频,图文,文字。反正我是比较喜欢视频类的教学。这里给大家画下栈变化的过程是什么样子的。这里的图是结合上面的代码来画的,希望有助于读者的理解。

1.程序在内存分布区域


C语言在函数调用时,栈是如何变化的?



2.全局变量m赋值



C语言在函数调用时,栈是如何变化的?

3.保存进入main之前的栈底, fp-sp之间是当前函数栈


C语言在函数调用时,栈是如何变化的?



4.函数main的栈已经准备好了


C语言在函数调用时,栈是如何变化的?


5.i入栈


C语言在函数调用时,栈是如何变化的?


6.j入栈


C语言在函数调用时,栈是如何变化的?



7.准备函数fun的调用, 形参反向入栈 先形参b入栈


C语言在函数调用时,栈是如何变化的?


8.形参a入栈


C语言在函数调用时,栈是如何变化的?



C语言在函数调用时,栈是如何变化的?



C语言在函数调用时,栈是如何变化的?



C语言在函数调用时,栈是如何变化的?


12.pc指针跳转fun代码


C语言在函数调用时,栈是如何变化的?


13.c入栈


C语言在函数调用时,栈是如何变化的?


14.可以看到函数fun的数据 形参a,b 在上一层函数的栈中. 一部分在自己的栈上. 此步取值到加法器中进行加法运算,再赋值给c


C语言在函数调用时,栈是如何变化的?


15.c赋给返回值,填入上面的留空位置


C语言在函数调用时,栈是如何变化的?


16.栈底恢复上一层


C语言在函数调用时,栈是如何变化的?


17.lr赋值给pc, 实现了跳转


C语言在函数调用时,栈是如何变化的?


18.返回值赋值给全局变量m


C语言在函数调用时,栈是如何变化的?


19.前面函数调用的形参已经无用,回滚sp


C语言在函数调用时,栈是如何变化的?


20.函数返回,清理main的栈空间


C语言在函数调用时,栈是如何变化的?


总结

这么多图有没有看花?相信到这里你已经了解了栈背后的来龙去脉,下一篇我们一起根据实际的 stack 错误案例剖析错误的可能性。



C语言在函数调用时,栈是如何变化的?

添加极客助手微信,加入技术交流群

以上是关于C语言在函数调用时,栈是如何变化的?的主要内容,如果未能解决你的问题,请参考以下文章

函数调用时栈是如何变化的?

x86上为啥C语言调用一个函数要先把参数压栈,之后才是返回地址

在C语言中,如何给函数分配内存?

栈溢出

VBS 环境下如何调用EXCEL内置函数

C函数调用过程原理及函数栈帧分析(转)