函数调用的底层机制

程序员通勤双肩包,下单送全年 CSDN 会员 防泼水面料电脑包,保护笔记本电脑,背负轻便,下单再赠 Coding Plan,看技术干货、查文档、下载源码,一站式搞定学习需求 阅读详情
关键词: 函数调用,calling convention, C语言实现

函数调用的底层机制

作者: apf::detrox

这是一篇介绍C语言中的函数调用是如何用实现的文章。写给那些对C语言各种行为的底层实现感兴趣人的入门级文章。如果你是C语言或者汇编、底层技术的老鸟或是对这个问题不感兴趣,那么这篇文章只会耽误您的时间,您大可不必阅读他。当然如果前辈们愿意为我指出不足,我将十分感谢您的指导,并对耽误您宝贵的时间致歉。 好了,废话少说!要研究这个问题,让我们先打开VC++吧。最好是6.0的,:-P。(什么你没有VC++,倒!....赶快装一个!@#$,要快!) 首先,让我们在VC++里建立一个Win32 Console Application项目,并建立主文件fun.c。并输入以下内容。

int fun(int a, int b) {
   a = 0x4455;
   b = 0x6677;
   return a + b;
}
int main() {
    fun(0x8899,0x1100);
    return 0;
}
之后,最关键的是在项目设置里关闭优化功能。也就是把Project->Setting->C/C++->Optimizations选为Disabled。编译器的优化在分析底层实现时大多数情况不太受欢迎。 按键盘上的F10键,进入单步调试模式(Step Over)。看到你的main函数左侧有个黄色的小箭头了吗?那个就是程序即将执行的语句。按Alt + 8。打开反编译窗口,看到汇编语句了吗?是不是想这个样子
==> 00401078   push        1100h
    0040107D   push        8899h
    00401082   call        @ILT+5(fun) (0040100a)
    00401087   add         esp,8
看到两个PUSH指令了吗?再看看后面的数字,不正是我们要传递的参数吗。奇怪阿?我们明明是先传递的0x8899怎么反倒先push 1100h呢?呵呵,这个现象就叫Calling conversion。究竟是何方神圣,我在后面会详细的给你解释的。先别着急。随后的Call指令的作用就是开始调用函数了。 接下来关掉反汇编窗口,在源代码窗口按F11(Step Into)进入函数体。当看到那个黄色的小箭头指向函数名的时候再调出反汇编窗口(Alt+8)。你会看到类似下面的代码:
1:    int fun(int a, int b) {
00401000   push        ebp
00401001   mov         ebp,esp
00401003   sub         esp,40h
00401006   push        ebx
00401007   push        esi
00401008   push        edi
00401009   lea         edi,[ebp-40h]
0040100C   mov         ecx,10h
00401011   mov         eax,0CCCCCCCCh
00401016   rep stos    dword ptr [edi]
2:       a = 0x4455;
00401018   mov         dword ptr [ebp+8],4455h
3:       b = 0x6677;
0040101F   mov         dword ptr [ebp+0Ch],6677h
4:       return a + b;
00401026   mov         eax,dword ptr [ebp+8]
00401029   add         eax,dword ptr [ebp+0Ch]
5:    }
0040102C   pop         edi
0040102D   pop         esi
0040102E   pop         ebx
0040102F   mov         esp,ebp
00401031   pop         ebp
00401032   ret
VC++就是好,还在难懂的汇编语句前加入了C语言的源代码。不过同时也有不少我们不需要的代码。因此,你只需要关心红色的部分就可以了。 奇怪阿?不是参数都用push传递了吗?怎么没看到被pop出来?问题其实是这样,当你调用Call进入函数的时候Call背着你做了一件事。call把它下一条语句的地址push进了堆栈。(旁人: 什么!这是为什么?)原因很简单,因为函数调用完了,要用ret返回。而ret怎么知道返回哪里呢?对了, ret指令pop了call指令push给他的地址(搞清楚这个关系哦),然后返回到了这个地址。call和ret配合的如此绝妙,一个PUSH一个POP肯定不会让堆栈不平衡的(老外叫no stack unwinding)。现在明白了,如果你来个pop eax,那eax里面是什么?当然是ret要用的返回地址了。好啦,你要是pop eax就等于抢了ret要用的东西了。不论曾程序流程和道德标准上你做的都不对 :-P。 可是怎么在函数体里使用参数呢?问题其实并不难,既然参数在堆栈里我们就可以使用esp(堆栈指针)来访问了。不过,我相信你也想到了。esp是个经常变化的值。一旦,函数里出现pop或push他就会变化。这样很不容易定位参数的于内存中的位置。因此,我们需要一个不会变化的东西作为访问参数的基准。看看函数体的开头部分:
00401000   push        ebp
00401001   mov         ebp,esp
先用push ebp保存了原来ebp的值再把esp的值给ebp。原来ebp就是用来做基准的。也难怪他被称为ebp(Base Pointer)。很自然ret返回前的pop ebp就是恢复原来ebp的数值喽。当然一定要恢复,因为函数里也可以调用函数嘛。每个函数都用ebp,自然要保证使用完后完璧归赵了。现在当函数执行到 mov ebp, esp后堆栈应该变成这个样子了。
/-------------------/  Higher Address
 | 参数2:  0x1100h |  
 +-----------------+
 | 参数1:  0x8899h |
 +-----------------+
 |   函数返回地址  | 
 |    0x00401087   |
 +-----------------+
 |       ebp       |
/-------------------/   Lower Address <== stack pointer 
& ebp all point to here, now
  
由于我们在VC++上使用的int类型是一个32位类型,ebp和函数返回值也是32位的。因此每个量要占去4个字节。另外还需要注意堆栈的扩展方向是高地址到低地址。有了这些指示。我们就可以分析出,第一个参数的地址是ebp + 08h,第二个参数就是ebp + 0ch。看看反汇编的代码:
2:       a = 0x4455;
00401018   mov         dword ptr [ebp+8],4455h
3:       b = 0x6677;
0040101F   mov         dword ptr [ebp+0Ch],6677h
与我们的计算吻合。之后呢:
00401031   pop         ebp
00401032   ret
将ebp原来的数值完璧归赵,调用ret指令,ret指令pop出返回地址,之后返回到调用函数的call指令的下一条语句。ret之后,堆栈应该变成这个样子了
/-------------------/  Higher Address
 | 参数2:  0x1100h |  
 +-----------------+
 | 参数1:  0x8899h |
/-------------------/   Lower Address  <== stack pointer
哈哈,问题出现了,再函数返回后堆栈出现了不平衡的情况(Stack Unwinding)。怎么办呢?好办啊,直接 pop cx pop cx 把堆栈平衡过来就好了。幸好我们只有两个参数,要是有20个的话,那就要有20个pop cx。不说影响美观,程序效率也会很低。所以VC++使用了这个办法解决问题:
00401082   call        @ILT+5(fun) (0040100a)
00401087   add         esp,8
看红色的语句,直接将esp的值加8,让堆栈变成
/-------------------/  Higher Address <== stack pointer
 | 参数2:  0x1100h |  
 +-----------------+
 | 参数1:  0x8899h |
/-------------------/   Lower Address 
通过改变esp从根本上解决了Stack unwinding。(push,pop指令本质上不就是通过改变esp来实现堆栈平衡的吗) 现在,明白了函数如何传递参数,如何调用,如何返回。下一个问题就是看看函数如何传递返回值了。相信你早就注意到了
4:       return a + b;
00401026   mov         eax,dword ptr [ebp+8]
00401029   add         eax,dword ptr [ebp+0Ch]
可见,函数正式用eax寄存器来保存返回值的。如果你想使用函数的返回值,那么一定要在函数一返回就把eax寄存器的值读出来。至于为什么不用ebx,ecx...,这个虽然没有规定,但是习惯上大家都是用eax的。而且windows程序中也明确指出了,函数的返回值必须放入eax内。 OK,现在来解决什么是calling conversion这个历史遗留问题。如果认真思考过,你一定想函数的参数为什么偏用堆栈转递呢,寄存器不也可以传递吗?而且很快阿。参数的传递顺序不一定要是由后到前的,从前到后传递也不会出现任何问题啊?再有为什么一定要等到函数返回了再处理堆栈平衡的问题呢,能否在函数返回前就让堆栈平衡呢? 所有上述提议都是绝对可行的,而他们之间不同的组合就造就了函数不同的调用方法。也就是你常看到或听到的stdcall,pascal,fastcall,WINAPI,cdecl等等。这些不同的处理函数调用方式就叫做calling convention。 默认情况下C语言使用的是cdecl方式,也就是上面提到的。参数由右到左进栈,调用函数者处理堆栈平衡。如果你在我们刚才的程序中fun函数前加入__stdcall,再来用上面的方法分析一下。
8:        fun(0x8899,0x1100);
00401058   push        1100h  ; <== 参数仍然是由右到左传递的
0040105D   push        8899h   
00401062   call        fun (00401000)
;<== 这里没有了 add esp, 08h
1:    int __stdcall fun(int a, int b) {
00401000   push        ebp
00401001   mov         ebp,esp
00401003   sub         esp,40h
00401006   push        ebx
00401007   push        esi
00401008   push        edi
00401009   lea         edi,[ebp-40h]
0040100C   mov         ecx,10h
00401011   mov         eax,0CCCCCCCCh
00401016   rep stos    dword ptr [edi]
2:       a = 0x4455;
00401018   mov         dword ptr [ebp+8],4455h
3:       b = 0x6677;
0040101F   mov         dword ptr [ebp+0Ch],6677h
4:       return a + b;
00401026   mov         eax,dword ptr [ebp+8]
00401029   add         eax,dword ptr [ebp+0Ch]
5:    }
0040102C   pop         edi
0040102D   pop         esi
0040102E   pop         ebx
0040102F   mov         esp,ebp
00401031   pop         ebp
00401032   ret         8; <== ret 取出返回地址后,
                       ; 给esp加上 8。看!堆栈平衡在函数内完成了。
                       ; ret指令这个语法设计就是专门用来实现函数
                       ; 内完成堆栈平衡的
于是得出结论,stdcall是由右到左传递参数,被调用函数恢复堆栈的calling convention. 其他几种calling convention的修饰关键词分别是__pascal,__fastcall, WINAPI(这个要包含windows.h才可以用)。 现在,你可以用上面说的方法自己分析一下他们各自的特点了。
深入解析C++函数调用底层机制:从栈帧到ABI的完整生命周期 函数调用是程序执行的核心机制,它定义了代码块间的协作方式。其底层原理涉及栈内存管理和调用约定两大关键技术:栈为每次调用提供独立的栈帧空间,用于存储返回地址、局部变量和部分参数;调用约定则规定了参数传递、寄存器使用和栈清理等规则,确保调用方与被调用方正确协作。理解这些机制对于优化性能、调试复杂问题至关重要,特别是在处理递归、多线程或跨语言交互。本文通过一个实际栈溢出案例,结合x64 System V ABI标准,详细拆解了参数通过RDI、RSI等寄存器传递,以及栈对齐、帧指针等工程实践细节,帮助开发者从CP 阅读详情

相关推荐

C语言函数调用底层机制

C语言函数调用底层机制C语言函数调用底层机制C语言函数调用底层机制C语言函数调用底层机制C语言函数调用底层机制C语言函数调用底层机制

C语言函数调用底层实现

最近在阅读大名鼎鼎的《深入理解计算机系统》,读到第三章,介绍了函数的底层实现。对底层的实现有了一些了解。 为了理解,我就用书上的例子,如果在中途有出现的术语,我会就近解释。 1. 背景 全文将会围绕下面两个函数来介绍所有的实现机制,这两个函数是: 第一个,主调用函数(它去调用另外一个函数 proc) long call_proc() { long x1 = 1; in...

tuijiangmeng87的博客 6398

浅谈函数调用函数调用底层实现逻辑】

函数调用底层逻辑实现以及汇编相关的知识。

Ethereal的博客 1609

C函数的调用过程原理和栈分析

  在编程中,相信每个人对函数都不陌生,那么你真正理解函数的调用过程吗?当一个c函数被调用,一个栈帧(stack frame)是如何被建立,又如何被消除的。本文主要就是来解决这些问题的,不同的操作系统和编译器可能有所不同,本文主要介绍在linux下的gcc编译器。 栈帧   我们先来看一下,一个典型的栈帧的样子:       首先介绍一下这里面非两个重要的指针:ebp和esp;  ...

编程随笔与杂谈 6940

函数的调用在底层的过程

计算机是用来跑程序的,因此理解了程序的运行对学习计算机有很大的好处,在这里我对程序的运行做一个简要的总结。     如下是计算机的总体构造,cpu、内存、硬盘直接通过总线连接在一起,以此来传输数据和控制性。这里省略掉了cpu的内部寄存器,后面会详细说明。    我们的程序一开始是以存储在硬盘的文件里的,如果我这个候通

Thomas Young的专栏 2187

C语言函数调用底层实现原理

前言 C语言程序执行实质上的函数的连续调用。 运行程序,系统通过程序入口调用main函数,在main函数中又不断调用其它函数。 程序的每个进程都包括一个调用栈结构(Call Stack)。 调用栈的作用: 传递函数参数 保存返回地址 临保存寄存器原有值(保存现场) 寄存器分配 寄存器指CPU中可以进行高速运算的缓冲区。用于存放程序执行中用到的数据和指令。 Intel 32位结构寄存器(IA32)包含8个通用寄存器,每个寄存器4个字节(32位)。 通用寄存器按照AT&T语法,寄存器名以**%e

返璞归真的博客 2977

函數調用機制底層

package main import ( “fmt” ) func test(n1 int){ n1 = n1+1 fmt.Println("test()n1=",n1)//輸出結果 } func getSum(n1 int,n2 int)int{ sum:= n1+n2 fmt.Println(“getSum sum =”,sum)//30 //儅函數有return語句,就是將結果返回給調用...

qq_43187004的博客 92

C语言函数调用底层原理:栈帧结构与参数传递机制

从参数传递的细节到栈帧的生命周期管理,函数调用的每个环节都体现了C语言底层资源的高效利用和精准控制,是理解计算机系统运行机制的重要窗口。从底层角度,传递的是变量的内存地址,该地址被压入栈中,被调用函数通过地址操作内存,实现对调用函数变量的修改。5. 帧指针(EBP)与栈指针(ESP):在x86架构中,帧指针EBP指向当前栈帧的底部,栈指针ESP指向当前栈帧的顶部。在调用printValues函数,参数3、2、1会按照从右到左的顺序依次压入栈中,然后printf函数根据格式字符串和栈中的参数进行输出。

2501_91652765的博客 656

c语言上面想调用下面的函数,C语言函数调用底层机制.doc

C语言函数调用底层机制这是一篇介绍C语言中的函数调用是如何用实现的文章。写给那些对C语言各种行为的底层实现感兴趣人的入门级文章。如果你是C语言或者汇编底层技术的老鸟或是对这个问题不感兴趣,那么这篇文章只会耽误您的间,您大可不必阅读他。当然如果前辈们愿意为我指出不足,我将十分感谢您的指导,并对耽误您宝贵的间致歉。好了,废话少说!要研究这个问题,让我们先打开VC++吧。最好是6.0的,:-P。...

weixin_39617252的博客 563

深入理解二叉树遍历的底层机制函数调用栈与递归回溯

本文通过分析函数调用栈的运行机制,深入揭示了二叉树递归遍历的本质原理。文章指出,前序、中序、后序遍历的区别仅在于节点访问机的不同,而它们共享完全相同的探索路径。关键点包括:1)函数调用栈的入栈出栈过程自动实现了递归的"深入"和"回溯";2)中序遍历的"左根右"顺序源于函数调用的阻塞性质;3)三种遍历方式的代码差异仅在于visit(node)在函数体中的位置。理解调用栈机制,就能看透递归遍历的底层逻辑,摆脱对"黑箱魔法"的困惑。

大四在找工作,如果公司有需要的可以联系我13086825462@163.com 1199

浅谈C++中函数调用底层机制

先来简单说一说栈指针和帧指针。 栈指针就是用来存储栈顶的地址,帧指针是用来存储函数刚被调用候的地址。 举个例子: int add(int a,int b){   int c=a+b;   return c; } 这是被调函数。 int x=add( 5,7); 这是主调函数的一个语句。 接下来是主调函数的反汇编代码: 8048459:  movl   $0x7,0x4(%e

CSDN_LYY的专栏 2401
上一篇: 跨进程API Hook
下一篇: ACM POJ 1050(To The Max) Solution Summary
detrox
博客等级 码龄24年 10粉丝 34原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值