这一部分从入口函数和函数参数开始。

第一篇已经会写 main、普通函数和指针了;第二篇要往下追一层:程序真正从哪里进来,main 的参数是谁传的,函数调用时栈里发生了什么,为什么在 IDA 和调试器里能回溯到入口和调用链。

1.0 汇编眼中的入口函数参数

平时写 C 程序,常把 main 当成程序入口。

1
2
3
4
int main(void)
{
return 0;
}

但从操作系统和运行库角度看,程序并不是一上来就直接执行这几行源码。通常会先经过运行库启动代码,完成一些初始化,然后再调用用户写的 main。

入口层次

层次 说明
系统入口 操作系统加载程序后进入的启动位置
运行库入口 完成 C/C++ 运行环境初始化
用户入口 调用用户写的 main 或 WinMain
函数返回 main 返回值最终会被传给运行库和系统

从汇编角度看,函数参数不是凭空出现的,它们会按照调用约定,通过寄存器或栈传给被调用函数。

现在先建立直觉:源码里的 main(argc, argv) 是用户能看到的入口;真正的进程入口还要再往前追到运行库和系统加载流程。

1.1 Main 函数参数解析

main 常见写法有两种。

1
2
3
4
int main(void)
{
return 0;
}
1
2
3
4
int main(int argc, char *argv[])
{
return 0;
}

argc 表示命令行参数个数,argv 保存每个参数字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main(int argc, char *argv[])
{
printf("argc = %d\n", argc);

for (int i = 0; i < argc; i++)
{
printf("argv[%d] = %s\n", i, argv[i]);
}

return 0;
}

如果程序名是 demo.exe,命令行这样运行:

1
demo.exe input.txt 8080

常见结果可以理解为:

下标 内容
argv[0] 程序路径或程序名
argv[1] input.txt
argv[2] 8080

参数解析习惯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
if (argc != 3)
{
printf("usage: demo <file> <port>\n");
return 1;
}

const char *file_path = argv[1];
int port = atoi(argv[2]);

printf("file = %s\n", file_path);
printf("port = %d\n", port);

return 0;
}

命令行工具一定要先检查参数个数,再读取 argv[i]。否则参数不够时,很容易访问不存在的位置。

1.2 Main 函数调用堆栈

函数调用会形成调用链。调试器里的“调用堆栈”可以告诉我们当前函数是从哪里一路调用过来的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

void func_c(void)
{
printf("func_c\n");
}

void func_b(void)
{
func_c();
}

void func_a(void)
{
func_b();
}

int main(void)
{
func_a();
return 0;
}

执行到 func_c 内部时,调用链大致是:

1
main -> func_a -> func_b -> func_c

调试时可以在 func_c 里下断点,然后查看调用堆栈窗口。

调用堆栈里能看什么

信息 作用
当前函数 程序现在停在哪里
上层调用者 当前函数是谁调用的
返回位置 函数执行完要回到哪里
参数和局部变量 当前栈帧中的上下文数据

后面分析崩溃、定位漏洞、看异常调用路径时,调用堆栈非常重要。

1.3 Main 函数定位回溯:IDA

IDA 里看到的入口通常不是源码里的 main,而是编译后程序的入口点和运行库初始化逻辑。

回溯思路

  1. 找到程序入口点。
  2. 识别运行库初始化代码。
  3. 追踪对用户函数的调用。
  4. 结合字符串、交叉引用、函数参数判断真正的 main。

常用观察点

观察点 说明
start / entry point 程序入口附近
字符串引用 printf、菜单文本、错误提示
调用关系 谁调用了疑似业务函数
参数形态 是否存在 argc、argv 一类参数
返回值处理 main 返回后如何退出程序

IDA 回溯 main 时不要只凭函数名。不同编译器、运行库和优化选项下,函数名可能丢失或变化,更可靠的是看调用关系和代码行为。

1.4 Main 函数定位回溯:DBG

调试器里定位 main 更偏动态观察:让程序跑起来,再通过断点、单步、调用堆栈和模块入口去追。

常见动态思路

  1. 从入口点开始单步。
  2. 在常见 API 或输出函数附近下断点。
  3. 通过字符串定位业务代码。
  4. 观察调用堆栈,向上回溯调用者。
  5. 找到稳定出现的用户逻辑入口。

适合练习的小程序

1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main(int argc, char *argv[])
{
printf("hello debug\n");
printf("argc = %d\n", argc);

return 0;
}

可以在 printf 处下断点,再从调用堆栈向上看是谁调用到了这里。

1.5 函数声明及定义规则

函数声明告诉编译器“这个函数存在,参数和返回值长什么样”;函数定义提供真正实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int add(int left, int right); // 声明

int main(void)
{
int result = add(3, 5);
printf("result = %d\n", result);
return 0;
}

int add(int left, int right) // 定义
{
return left + right;
}

声明和定义区别

概念 作用
函数声明 告诉编译器函数接口
函数定义 提供函数体
函数调用 使用函数
返回值类型 规定函数返回什么
参数列表 规定调用者要传什么

函数声明和定义的返回值、函数名、参数类型要保持一致,否则编译或链接时可能出问题。

1.6 函数分文件编写

项目稍微变大后,所有代码都堆在一个 .c 文件里会很乱。更常见的写法是把声明放到头文件,把实现放到源文件。

文件结构示例

1
2
3
4
project/
main.c
calc.h
calc.c

calc.h:

1
2
3
4
5
6
7
#ifndef CALC_H
#define CALC_H

int add(int left, int right);
int sub(int left, int right);

#endif

calc.c:

1
2
3
4
5
6
7
8
9
10
11
#include "calc.h"

int add(int left, int right)
{
return left + right;
}

int sub(int left, int right)
{
return left - right;
}

main.c:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
#include "calc.h"

int main(void)
{
printf("%d\n", add(3, 5));
printf("%d\n", sub(8, 2));
return 0;
}

头文件保护

1
2
3
4
5
6
#ifndef CALC_H
#define CALC_H

// declarations

#endif

头文件保护可以避免同一个头文件被重复包含时引发重复定义相关问题。

1.7 函数参数传递:值传递

值传递会把实参的值复制一份给形参。函数内部修改形参,不会改变外部变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

void change_value(int x)
{
x = 100;
}

int main(void)
{
int value = 10;

change_value(value);

printf("value = %d\n", value);
return 0;
}

输出仍然是 10。

值传递适合什么

场景 说明
只读取参数 函数不需要修改外部变量
小型基础类型 int、char、double 等
防止误改 函数拿到的是副本

值传递更安全,但如果传递大型结构体,复制成本可能比较高。

1.8 函数参数传递:地址传递

地址传递把变量地址传给函数,函数可以通过指针修改外部变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

void change_value(int *p)
{
if (p == NULL)
{
return;
}

*p = 100;
}

int main(void)
{
int value = 10;

change_value(&value);

printf("value = %d\n", value);
return 0;
}

输出变成 100。

地址传递适合什么

场景 说明
修改外部变量 通过指针写回结果
传递数组 数组传参本质上会退化成指针
传递大型结构体 避免复制大量数据
返回多个结果 通过多个指针参数写回

指针参数要判空

1
2
3
4
5
6
7
8
9
void set_score(int *score)
{
if (score == NULL)
{
return;
}

*score = 90;
}

安全开发里,外部传入的指针都要先考虑是否有效。不要一上来就解引用。

1.9 寄存器与线程栈区

函数调用不只发生在源码层面。底层执行时,参数、局部变量、返回地址、寄存器保存等内容都会和栈有关。

先记住几个概念

概念 说明
寄存器 CPU 内部高速存储位置
栈区 每个线程都有自己的栈,用于函数调用和局部变量
栈帧 一次函数调用形成的一段栈空间
返回地址 函数执行完后要回到的位置
局部变量 常见情况下保存在当前函数栈帧中

一个简单调用过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int add(int a, int b)
{
int result = a + b;
return result;
}

int main(void)
{
int value = add(3, 5);
printf("%d\n", value);
return 0;
}

从调试角度可以观察:

  1. 调用 add 前参数如何准备。
  2. 进入 add 后局部变量在哪里。
  3. return result 后返回值如何交给调用者。
  4. 函数返回后栈如何恢复。

不同架构和调用约定下,参数可能通过寄存器传,也可能通过栈传。现在不要急着背死规则,先用调试器观察:参数在哪里,返回值在哪里,返回地址在哪里。

本章最小复盘代码

这段代码把命令行参数、分函数、值传递、地址传递和调用堆栈观察放到一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <stdio.h>
#include <stdlib.h>

int add(int left, int right)
{
int result = left + right;
return result;
}

void set_value(int *target, int value)
{
if (target == NULL)
{
return;
}

*target = value;
}

int main(int argc, char *argv[])
{
if (argc != 3)
{
printf("usage: demo <left> <right>\n");
return 1;
}

int left = atoi(argv[1]);
int right = atoi(argv[2]);

int sum = add(left, right);
int saved = 0;

set_value(&saved, sum);

printf("sum = %d\n", sum);
printf("saved = %d\n", saved);

return 0;
}

复盘时可以在 add、set_value、main 里分别下断点,观察调用堆栈、参数和局部变量。

常见错误整理

错误 示例 问题
不检查 argc 直接访问 argv[2] 参数不够时越界
混淆声明和定义 只有声明没有实现 链接错误
头文件重复包含 没有 include guard 可能重复定义或编译混乱
以为值传递能改外部变量 change_value(value) 改的是副本
指针参数不判空 直接 *p = 1 传入 NULL 时崩溃
返回局部变量地址 return &local; 函数返回后地址失效
调试时只看源码 不看调用堆栈 很难定位真实调用路径
逆向时只找函数名 符号可能被去掉 要结合调用关系和行为判断

学习检查清单

检查项 状态
能解释用户写的 main 和真实程序入口的区别 待复盘
能写出带 argc、argv 的 main 函数 待复盘
能检查命令行参数个数再读取参数 待复盘
能用调用堆栈观察函数调用链 待复盘
能在 IDA 中根据入口、字符串和调用关系回溯疑似 main 待复盘
能在调试器中通过断点和调用堆栈定位用户逻辑入口 待复盘
能区分函数声明、定义和调用 待复盘
能把函数拆到 .h 和 .c 文件中 待复盘
能解释值传递为什么不能修改外部变量 待复盘
能通过地址传递修改外部变量 待复盘
能理解寄存器、线程栈、栈帧和返回地址的基本关系 待复盘

这一部分的核心是:函数调用不是一句源码那么简单。参数、返回值、调用者、被调用者、栈帧和返回地址一起构成了程序真实运行时的路径。