资讯动态

C语言学习/复习8----函数栈帧(汇编指令、寄存器、内存模型)

发布时间:2026/8/6 17:56:45 来源:尧图企业网站定制
0、函数栈帧0.1概念CPU 执行函数时操作系统会在栈内存调用栈Call Stack上开辟一块块独立空间每一次调用一个函数就新建一块专属内存区域这块区域就叫函数栈帧。 栈遵循先进后出先调用的函数后销毁后调用的函数执行完立刻销毁。0.2、栈帧核心作用每个函数在运行期间需要独立的工作区栈帧就是这个工作间负责存放当前函数运行需要的全部临时数据函数局部变量、临时变量函数入参传入的参数函数返回地址执行完回到上层调用函数的哪一行代码保存调用者寄存器环境、ebp 栈基址、esp 栈指针等底层寄存器信息函数内部临时运算产生的中间数据。一、函数栈帧的创建与销毁1.1函数的局部变量/参数/调用基于 32 位 x86 架构ebp/esp 维护栈帧每一次函数调用都会在进程栈空间开辟一块独立内存区域称为函数栈帧局部变量、形参、返回地址、旧 ebp 等全部存放在对应栈帧里。EBP栈基址指针当前栈帧的基准底部整个栈帧以 ebp 为参照物寻址只要 ebp 不变就能稳定找到本函数内所有变量、参数。ESP栈顶指针指向当前栈最顶端函数内压栈、开辟局部空间时esp 不断向下移动函数退出时 esp 回收空间。每一个栈帧都有自己独立的 ebp层层串联构成完整调用栈。吃透栈帧创建ebp 绑定、esp 开栈与栈帧销毁ebp 复位、esp 回收就能彻底理解局部变量、传参、函数调用、返回的全部底层原理这就是 C 语言程序运行的栈层面底层核心逻辑。案例11、局部变量是怎么创建的局部变量创建依托当前函数栈帧整体流程调用函数时CPU 为当前函数开辟专属栈帧ebp锁定栈帧的起始基准位置向下移动esp栈向低地址增长在栈帧内部划分出对应大小的栈内存用来存放局部变量编译器根据变量类型分配字节空间int占 4 字节执行初始化赋值指令把字面量写入刚刚分配的栈内存。以Add里的int z 0举例Add函数栈帧搭建完成后在本栈帧内ebp-4的位置预留 4 字节栈空间将数值0写入该内存地址完成局部变量创建 初始化 main 函数里的a、b、c同理它们属于 main 函数栈帧内的局部变量。2、为什么未初始化的局部变量值是随机垃圾值栈是复用内存函数执行完毕销毁栈帧时操作系统并不会主动清空栈内存里的数据只是单纯抬高esp回收栈空间这块内存里依旧残留着上一个函数调用遗留的旧数据局部变量只分配栈地址不初始化时不会主动清零内存读取到的就是栈里残留的历史脏数据表现为随机值全局变量 / 静态变量存放在数据段程序加载时系统会默认清零所以不会有随机脏值。3、函数是怎么传参的传参顺序是怎样的1经典 cdecl 调用约定C 语言默认从右向左压栈传参调用Add(a,b)即Add(10,20)执行顺序先把最右侧实参b20压入栈再把左侧实参a10压入栈紧接着压入函数返回地址执行完 Add 后回到 main 中调用处继续执行的指令地址跳转到Add函数内部执行栈帧构建逻辑。整体传参底层逻辑实参的值存入调用者main的栈中被调用函数Add通过自身栈帧的ebp偏移量读取栈上的实参以此拿到参数数据。不同调用约定规则不同stdcall同样右往左压栈由被调用方平衡栈fastcall优先用寄存器传参剩余参数压栈。4、形参和实参是什么关系实参调用函数时传入的实际数据本例 main 里的a10、b20存放在 main 函数的栈空间实参生命周期不受被调用函数影响形参Add的int x、int y本质是 Add 函数栈帧中通过ebp偏移访问栈上实参数据。形参只有函数被调用时才创建函数执行结束形参直接销毁二者只在函数调用瞬间发生数据传递绑定函数执行完毕之后不再有关系。在C/C中二者数据传递绑定关系分为: 1.值传递逻辑把实参的值复制一份赋值给形参形参、实参是两块完全独立的内存空间; 函数内部修改形参仅仅修改副本完全影响不到外面的实参。 2.地址传递指针传参:实参把自身的内存地址拷贝一份赋值给指针类型的形参形参指针和实参指针存着同一个地址解引用后可顺着地址找到原始实参的内存就能修改外面实参的值。 3. C引用传递形参本质是实参的别名二者共用同一块内存地址没有拷贝、没有独立空间形参就是实参本身对形参做任何修改等价于直接修改实参。简单总结实参是源头数据形参是被调函数访问这份数据的接口。5、函数调用完整底层执行流程以main调用Add(a,b)完整拆解阶段 1调用方 main 准备工作计算实参a10、b20按照从右往左顺序依次压入栈call Add指令执行自动把下一条指令地址返回地址压入栈CPU 跳转到Add函数入口地址开始执行。阶段 2被调函数 Add 搭建专属栈帧核心push ebp把 main 函数的栈基址 ebp 压栈保存mov ebp, esp将当前栈顶 esp 赋值给 ebpebp 固定为 Add 栈帧的基准底部sub esp, N向下拓展 esp开辟出 Add 栈帧空间用来存放局部变量z、临时计算数据 至此 Add 函数栈帧正式创建完成局部变量、形参都可以通过 ebp 固定偏移精准寻址。阶段 3函数内部业务执行读取栈上形参xebp8、yebp12计算xy存入局部变量z(ebp-4)。6、函数调用结束后如何返回、销毁栈帧第一步保存返回值C 语言约定整数类型返回值默认放入通用寄存器eax本例将zxy的结果存入eax。第二步销毁当前 Add 栈帧回溯栈mov esp, ebp直接把 esp 重置为 ebp一次性回收本栈帧所有局部变量占用的栈空间pop ebp取出之前压栈保存的 main 的 ebp恢复 main 函数的栈基址ret指令弹出栈中预先保存的返回地址CPU 跳回 main 函数调用 Add 的下一行代码第三步调用方 main 平衡栈cdecl 约定下由调用方 main 执行add esp, 8清理栈上之前压入的两个 int 参数共8字节栈完全恢复到调用 Add 之前的状态整个调用流程彻底收尾。7、函数栈帧的大小变化普通 C/C 无 VLA变长数组、无 alloca在当前栈帧上动态分配字节单个函数栈帧大小固定编译期确定有VLA、alloca、手动改 esp汇编代码手动增减栈顶指针人为改变当前栈帧大小栈帧运行时动态可变不同函数之间栈帧本身大小本来就不一样函数执行过程中常规代码不会中途收缩 / 扩张自身栈帧。整个进程的调用栈是一整块操作系统划定的固定大小内存Windows 默认 1MB、Linux 默认 8MB 左右所有栈帧都挤在这一块内存里。 单个栈帧再小无数个栈帧堆叠起来也会耗尽整块栈内存所以要注意递归的使用防止栈溢出。二、知识点补充2.1、函数调用约定Cdeclcdecl是C 语言默认的函数调用约定全称C declaration是x86 32 位环境下最经典的调用规范绝大多数 C 编译器GCC、MSVC不加特殊修饰时默认采用。1. 参数入栈顺序从右往左依次压入堆栈示例func(a, b, c)压栈顺序c → b → a2. 堆栈平衡清理者调用方Caller清理栈函数执行完毕后由调用函数的代码负责平衡堆栈把入栈的参数从栈中弹出、恢复栈指针 ESP。 这是cdecl最关键特征支持可变参数函数printf、scanf的根本原因被调用方不知道参数个数没法自行清理栈只能调用方事后修正栈。3. 名字修饰符号命名MSVC函数名前加下划线void test()→_testGCCLinux基本不加额外下划线直接原名为什么需要对函数名进行修饰C 语言本身不支持函数重载、类、命名空间同一作用域不能出现同名函数它的名字修饰是为了解决 3 个基础问题区分编译期不同类型符号全局函数、全局变量、编译器内部预留符号做隔离避免不小心重名适配平台 ABI、调用约定不同调用约定cdecl/stdcall/fastcall需要通过名字附加标记区分比如 MSVC 的 stdcall 还会写成_test4标注参数栈占用字节数链接器能校验调用方和被调用方参数栈是否匹配链接器识别外部符号编译生成.obj/.o目标文件时把源码标识符翻译成链接器能识别的字符串符号链接器依靠这个字符串找到跨文件调用的函数地址。 C 因为重载、类、命名空间、模板等语法同一个简单函数名可以对应多个不同函数链接器无法分辨链接器只认一串字符串符号必须把「类名 命名空间 函数名 参数类型 const 属性 模板参数」全部编码进最终符号名这套编码规则就叫Name Mangling名字改编 反向把改编后的符号还原成 C 源码签名叫Demangling解改编。4. 寄存器使用通用寄存器EAX/ECX/EDX属于易损毁寄存器被调用函数可以随意修改 需要保留的数据调用方要提前压栈保存。5. 返回值传递整数、指针类型的返回值通过放入EAX寄存器返回浮点旧 x87 浮点栈大型结构体调用方提前在栈上分配临时内存把地址隐式作为第一个参数传入。6.优点缺点天然支持可变参数printf只能用 cdeclC 默认、唯一适配可变参数的经典调用约定。C 语言原生默认兼容性最好。每次调用结束调用方都要额外代码平衡栈代码体积略大频繁调用时重复栈调整效率略低于stdcall。和MSVC 32 位 x86Windows下的常见调用约定简单对比调用约定清栈方参数传递方式是否支持可变参数MSVC 命名修饰额外关键特征__cdecl调用方 Caller参数从右向左全部压栈✅ 支持_函数名全局函数默认this 只能手动压栈传入__stdcall被调用方 Callee参数从右向左全部压栈❌ 不支持_函数名参数总字节数Win32 API 默认__fastcall被调用方 Callee前两个参数放入 ECX、EDX剩余参数右往左压栈❌ 基本不支持函数名参数总字节数尽量用寄存器提速__thiscall被调用方 Calleethis指针固定放 ECX 寄存器其余参数右往左压栈❌ 不支持经过 C 名字改编 mangling形如?xxxYAX...不会是_xxxxx这类简单修饰C 非静态类成员函数默认专属约定7.手动指定 cdecl默认本身就是 cdecl日常 C 开发基本不用手动写只有场景需要强制锁定调用约定时才用逆向工程、汇编交互、手写汇编调用 C 函数不同编译器编译的库互相链接保证栈平衡、参数入栈顺序一致和 Windows 动态链接库 DLL 对接、兼容老式二进制接口区分stdcall/fastcall等其他调用约定。8.C下的注意事项x64 环境下基本废弃64 位环境Windows x64 / Linux AMD64彻底没有 cdecl /stdcall/thiscall 这套区分 整套架构换了全新统一调用规范WindowsMicrosoft x64 调用约定Linux/macOSSystem V AMD64 ABI 不管 C 还是 C全都不区分老式 32 位那几种约定。项目Microsoft x64 ABIWindowsSystem V AMD64 ABILinux/macOS前 N 个整数参数寄存器依次rcx, rdx, r8, r9仅 4 个依次rdi, rsi, rdx, rcx, r8, r96 个前 N 个浮点参数寄存器xmm0, xmm1, xmm2, xmm34 个xmm0~xmm78 个栈预留影子空间Home Space强制要求调用者在栈上为前 4 个寄存器参数预留 32 字节影子区被调用方可以覆盖读写无影子空间栈仅用来传递溢出参数栈布局规则调用前栈必须 16 字节对齐call 指令压入返回地址后栈变为 8 字节被调用函数开头通常 sub rsp, xxx 补齐 16 对齐进入函数时rsp必须天然保持 16 字节对齐call 压入返回地址后 rsp%168函数内分配栈时要保证重新对齐红区Red Zone无红区rsp 下方 128 字节红区函数可临时使用中断 / 信号不会践踏这片内存无需修改 rsp调用者保存易失寄存器rax, rcx, rdx, r8, r9, r10, r11所有 xmm 寄存器被调用方使用必须自行压栈保存rax, rcx, rdx, rsi, rdi, r8~r11全部 xmm调用者要跨函数保留数据需手动保存被调用者保存非易失rbx, rbp, rsi, rdi, r12~r15函数修改前必须入栈保存返回前恢复rbx, rbp, r12~r15修改必须保存恢复rsi/rdi是传参寄存器属于易失返回值整数 / 指针rax浮点xmm0大于 8 字节小结构体调用方隐式传 rcx 作为输出指针整数 / 指针rax浮点xmm0超大结构体调用方栈传隐藏指针参数入栈顺序寄存器放不下的多余参数从右往左压栈栈底先放最右侧参数寄存器用完后多余参数同样从右往左入栈栈清理责任常规 cdecl 风格调用者清理栈微软无 stdcall统一调用者平衡栈默认调用者清理栈C 全局普通函数默认沿用 cdecl类成员函数另有thiscall。在32 位 x86 WindowsMSVC全局普通 C 函数默认就是 __cdecl行为和 C 的cdecl完全一致只是名字修饰name mangling不一样C 只是简单加下划线_fooC 要支持重载、命名空间、类会做名字改编mangling符号名会变得很长调用约定规则本身没变只是函数符号名被编译器重编码了。如下图所示extern C 的 C 函数调用约定依旧默认 cdecl即关闭 C mangling采用 C 风格简单下划线命名和纯 C 二进制互通。类成员函数不用 cdecl默认 thiscall。隐藏参数this指针传递方式特殊MSVC32 默认__thiscall不属于 cdecl成员函数不能直接当成普通 cdecl 函数指针调用。为什么单独搞一个 thiscall这是专门为成员函数量身定做主要为了提升效率用寄存器传递高频使用的this减少栈操作效率更高。手动指定其他调用约定时可以显式改成 stdcall、fastcall 等覆盖默认 cdecl如 void __stdcall bar();C 里可变参数依然依赖 cdecl像下面这种可变参数函数只能用 cdeclstdcall、thiscall 都不行原因还是被调用方不知道参数总大小只能调用者调用结束后修正栈指针这是 cdecl 的核心特性。2.2、C/C进程的内存模型C 编译后在 Linux 下最终生成 ELF 可执行文件操作系统加载 ELF 时就是严格按照下图地址分区映射到进程虚拟地址空间C 语言、C、Go 等编译型原生进程都遵循这套布局。栈用于存储函数调用时的局部变量、函数参数、临时变量、引用以及返回地址寄存器信息。当函数调用完成后分配给这个函数的栈空间会被释放。本文所讲的函数栈帧即在这堆是用于动态内存分配的区域当使用newC或者mallocC分配内存vector 动态扩容内存、容器底层数组分配的内存块就位于堆区。我们需要手动释放这些内存否则可能导致内存泄漏。全局/静态存储区全局变量和静态变量都存放在全局/静态存储区。未初始化的和初始化的、全局 const 常量分别放在上面图中的bss 和 data 段。代码段存放程序的二进制代码它是只读的以防止程序在运行过程中被意外修改。代码中的函数编译为二进制后函数的逻辑就存放在代码段这段区域也有可能包含一些只读的常数变量例如字符串常量类成员函数、全局函数、模板实例化代码、虚函数表等。内存映射区域共享库动态链接库.so的内存映射区域的内存映射区域该区域本质是操作系统打通磁盘共享库与进程虚拟内存的桥梁操作系统借助mmapLinux将磁盘上的共享库 ELF 文件分段映射到进程虚拟地址空间的专属区域一般位于进程堆区与栈区之间的mmap 映射段依托虚拟内存与写时复制机制在保证进程隔离性的前提下实现动态库全局共享是现代操作系统动态链接的底层基础。这片区域不只是放共享库文件映射系统动态链接器把 C 依赖的.so共享库libc、libstdc、pthread 等映射到这里把普通文件、数据库文件映射进进程地址空间也在这里匿名映射new/malloc申请超大内存时glibc 不会去扩充下方的堆而是直接调用mmap在这片区域开辟独立匿名内存共享内存、自定义缓冲区也分配在此 这片区域和堆是互相独立的地址空间不会和堆争抢连续地址。补充说明进程虚拟地址不是物理内存 图里所有区块都是虚拟地址空间内核会按需映射到物理内存进程自身只感知这套连续虚拟地址不用关心实际物理内存位置。堆向上、栈向下的设计意义 堆往高地址扩张、栈往低地址收缩中间预留大片 mmap 映射区防止堆持续变大和栈增长互相冲撞覆盖保障地址空间安全。三、总结本文拖了三四年本来只想写写C的函数栈帧是什么但中间又学了数据结构、C、Linux操作系统、计算机组成等知识于是又补充了点内容只学了C语言的初学者可能会对文中提到的诸多名词不理解那是因为涉及了其它学科的知识不只是语言方面的内容了。但没关系我尽量作了知识点补充或许看着有点超前、断裂不过也可以作为一种启发提醒你可以用AI等工具去补充学习、提前学习相关知识。本文从内存的视角试图讲解C/C程序是如何调用函数的。这自然而然涉及到一些数据结构、一些汇编指令、一些寄存器、一些操作系统的知识。文中的进程地址空间、C的一些特性等都只是冰山一角后续感兴趣还可以去看看一下知识C对象、类、继承、多态、原子操作、互斥锁、信号量、memory_order并发编程进程、线程、生产者消费者、无锁队列Linux操作系统内核、系统调用、文件系统、常用命令....................................此处不再赘述、能力也不允许了........作者后面还要面临考公二战、专业知识的复习、就业项目的练习等等总之就是“生存与发展”或许有机会再补充新知识发表新文章吧不知道又是拖几年呢...............

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价