0112、安全开发篇(二):调用栈与函数高级
返回:C/C++ 进阶提升目录 · 安全开发总目录
这一章从“函数能调用”推进到“函数到底怎么调用”。后面学逆向、调试、栈回溯、崩溃分析、Hook 参数读取时,调用栈和调用约定会反复出现。
2.0 汇编指令速览
这节先把常见汇编指令当作调试语言来认识,不急着背全。最重要的是能在反汇编窗口里看懂大概:谁把数据放进寄存器,谁压栈,谁跳转,谁调用函数,谁返回。
| 指令 | 含义 | 复盘重点 |
|---|---|---|
mov |
数据传送 | 看清源操作数和目的操作数 |
push / pop |
压栈 / 出栈 | 观察参数、返回地址、寄存器保存 |
call |
调用函数 | 会压入返回地址并跳到目标函数 |
ret |
函数返回 | 从栈中取回返回地址 |
add / sub |
加减运算 | 常用于调整栈顶和整数计算 |
cmp / jcc |
比较和条件跳转 | 对应 if、循环和分支 |
2.1 函数调用堆栈
函数调用时,栈里保存了返回地址、参数、局部变量和部分寄存器现场。调试时如果能看懂栈,就能知道“现在从哪里来、准备回哪里去、参数大概是什么”。
| 栈中内容 | 作用 |
|---|---|
| 返回地址 | 当前函数执行完后回到调用者的位置 |
| 参数 | 调用者传给被调函数的数据 |
| 局部变量 | 函数内部临时使用的数据 |
| 保存寄存器 | 保证调用前后的上下文一致 |
最小复盘代码:
1 |
|
复盘时在 add 里打断点,看参数、局部变量和返回地址,尝试把源码、反汇编和栈窗口对上。
2.2 函数调用约定
调用约定规定参数怎么传、谁清理栈、返回值放在哪里。x86 下常见 cdecl、stdcall、fastcall;x64 下通常使用统一调用约定,前几个参数走寄存器。
| 调用约定 | 参数传递 | 栈清理 | 复盘点 |
|---|---|---|---|
cdecl |
多数参数经栈传递 | 调用者清理 | 支持可变参数 |
stdcall |
多数参数经栈传递 | 被调用者清理 | WinAPI 中常见 |
fastcall |
部分参数走寄存器 | 约定决定 | 参数读取要结合寄存器 |
| x64 | 前几个参数走寄存器 | 调用者维护栈空间 | 注意栈对齐和 shadow space |
安全开发里看 Hook、回调和函数指针时,调用约定错了很容易参数错位、栈不平衡或直接崩溃。
2.3 函数堆栈平衡
堆栈平衡指函数调用前后栈顶能回到合理位置。栈不平衡常见原因包括调用约定不匹配、参数个数错误、函数指针类型写错、手写汇编保存/恢复不完整。
| 问题 | 表现 | 复盘方式 |
|---|---|---|
| 参数个数不对 | 返回后崩溃或变量异常 | 对比函数声明和调用点 |
| 调用约定不对 | ret 后栈顶错位 |
看调用者/被调者谁在清栈 |
| 保存现场不完整 | 调用后寄存器值异常 | 检查保存和恢复是否成对 |
2.4 宏函数
宏函数是预处理阶段的文本替换,不是真正函数。它没有类型检查,也不会生成函数调用栈,但写得好可以减少重复,写得差会制造非常隐蔽的 bug。
1 |
|
复盘重点:宏参数要加括号,宏整体也要加括号;复杂逻辑尽量用函数或内联函数。
2.5 数组参数与返回
数组作为函数参数时会退化为指针,所以函数里拿不到原数组长度。安全开发里这点非常重要:只传数组指针、不传长度,后面很容易出现越界。
1 |
|
| 写法 | 风险 |
|---|---|
只传 arr |
函数不知道边界 |
传 arr + count |
更容易写遍历区间 |
| 传数组引用/模板 | C++ 中可以保留长度信息 |
2.6 结构体参数样式
结构体可以按值传递,也可以按指针传递。按值会复制整个结构体;按指针效率高,但要注意空指针、生命周期和是否允许修改。
1 |
|
复盘时优先使用 const Student * 表达“只读引用”,能减少误修改。
2.7 可变参数
可变参数函数的典型例子是 printf。它灵活,但也危险:调用者传入的格式字符串必须和后续参数类型严格对应。
1 |
|
安全联系:格式化字符串和可变参数不匹配,是 C 语言里非常经典的风险来源。
2.8 printf 实现原理解析
printf 的核心不是“打印”,而是根据格式字符串逐个解析参数。格式字符串相当于一份说明书,告诉函数后面应该读几个参数、按什么类型解释。
| 格式 | 解释方式 | 常见问题 |
|---|---|---|
%d |
按整数读取 | 传错类型会错读 |
%f |
按浮点读取 | 默认提升规则要注意 |
%s |
按字符串地址读取 | 空指针或非法地址可能崩溃 |
%p |
按指针输出 | 调试有用,对外输出要谨慎 |
2.9 变量内存布局
变量放在哪里,决定了它的生命周期和访问方式。安全开发里要把“变量名”翻译成“内存区域 + 地址 + 大小 + 生命周期”。
| 区域 | 内容 | 生命周期 |
|---|---|---|
| 栈区 | 局部变量、函数参数 | 函数调用期间 |
| 堆区 | 手动申请的动态内存 | 申请后到释放前 |
| 全局/静态区 | 全局变量、静态变量 | 程序运行期间 |
| 只读区 | 字符串常量、常量数据 | 程序运行期间 |
| 代码区 | 机器指令 | 程序运行期间 |
本章最小复盘代码
1 |
|
学习检查清单
| 检查项 | 状态 |
|---|---|
| 能在调试器里看懂一次函数调用的栈变化 | 待复盘 |
| 能说明调用约定和栈平衡为什么重要 | 待复盘 |
| 能写出带长度参数的数组处理函数 | 待复盘 |
| 能解释结构体按值传递和指针传递的区别 | 待复盘 |
| 能说明可变参数和格式化字符串的安全风险 | 待复盘 |






