gcc -c main.c 没有报错,不代表已经能得到一个可执行程序。如果 main.c 调用了一个只有声明、没有提供定义的函数,问题可能直到链接时才出现。
这篇从一个小程序开始,亲手把每个中间文件生成出来。原文曾以“C语言重拾”为开头,却残留了不相关的默认模板;这里重新整理为独立的 C 入门实验,不把模板当作学习经历。
实验使用 Linux 下的 GCC,已在 WSL 的 Arch Linux、GCC 16.1.1 上验证。Windows 可以在已安装 GCC 的 WSL 中运行;下面的文件都放在一个单独的实验目录,不需要管理员权限,也不改系统配置。
1. 先准备三个文件
建立并进入一个空目录,例如 mkdir c-build-demo 后执行 cd c-build-demo。将以下代码分别保存为对应文件。
greet.h:告诉调用者函数怎么调用。
#ifndef GREET_H
#define GREET_H
#define APP_NAME "C build demo"
void greet(const char *name);
#endif
main.c:使用头文件中的宏和函数声明。
#include "greet.h"
int main(void)
{
greet(APP_NAME);
return 7;
}
greet.c:提供函数真正执行的代码,也就是定义。
#include <stdio.h>
#include "greet.h"
void greet(const char *name)
{
printf("Hello, %s\n", name);
}
先把完整程序跑通:
gcc -std=c11 -Wall -Wextra -Werror main.c greet.c -o demo
./demo
status=$?
printf 'exit status: %s\n' "$status"
屏幕会出现:
Hello, C build demo
exit status: 7
Hello 是程序打印的文本,7 是 main 返回给运行环境的退出状态。它们不是一回事。本例故意返回7用于观察:程序执行到了最后,但在 shell 的约定里,非零退出状态仍表示非成功状态,不应把它用于“成功”的自动化判定。
2. 一条 gcc 命令背后有四个阶段
可以把过程画成这条链:
main.c + greet.h
|
| 预处理:处理 #include、宏和条件编译
v
main.i
|
| 编译:产生汇编代码
v
main.s
|
| 汇编:产生目标文件
v
main.o ----- greet.o
\ /
\ / 链接:结合目标文件与所需库
demo
这里“编译”有两种常见说法:日常说“编译程序”,往往泛指整条构建链;讨论具体阶段时,则指从预处理后的 C 代码到汇编代码的转换。先分清语境,才不会把“编译没错”和“整个构建成功”混在一起。
GCC 的 -E、-S、-c 分别控制停在哪个阶段。注意:-S main.c 仍会先预处理,-c main.c 仍会先预处理、编译、汇编,不是跳过前面的工作。GCC 阶段控制选项
| 命令 | 停止位置 | 产物 | 还不能说明什么 |
|---|---|---|---|
gcc -E main.c -o main.i | 预处理后 | 展开后的文本 | C 语义正确、能链接 |
gcc -S main.c -o main.s | 编译后 | 汇编文本 | 所需函数都能找到定义 |
gcc -c main.c -o main.o | 汇编后 | 目标文件 | 已经是可直接运行的程序 |
gcc main.o greet.o -o demo | 链接后 | 可执行文件 | 运行结果符合业务预期 |
目标文件不是“装着 C 源码的文本”。它包含机器代码以及链接所需的符号、重定位等信息;通常不能像 ./demo 那样直接运行。
3. 分阶段生成,亲眼看中间结果
gcc -std=c11 -E main.c -o main.i
gcc -std=c11 -S main.c -o main.s
gcc -std=c11 -Wall -Wextra -Werror -c main.c -o main.o
gcc -std=c11 -Wall -Wextra -Werror -c greet.c -o greet.o
打开 main.i,能看到函数声明和展开后的调用:
greet("C build demo");
APP_NAME 已替换成字符串,但 greet 的函数体并不会因为包含 greet.h 自动出现:头文件里只有声明。
main.s 的具体指令会因 CPU 架构、编译器版本及优化选项不同而变化。不需要背这段汇编,先确认它是编译器生成的汇编文本,而不是最终可执行文件即可。
还可以用 nm 看目标文件里的符号:
nm main.o
nm greet.o
在本例的普通目标文件中,main.o 会把 greet 标为 U,表示它在这个文件中尚未定义;greet.o 则提供该函数。具体地址不必相同。U 不等于程序一定有错:它可能由其他目标文件或库在后续步骤中提供。GNU nm 符号说明
4. 故意漏掉一个目标文件,复现链接失败
仅链接 main.o:
gcc main.o -o missing-demo
status=$?
printf 'link status: %s\n' "$status"
这一步会失败,错误信息中包含对 greet 的未定义引用,常见措辞是 undefined reference to 'greet'。不同工具链的标点、路径、诊断格式可能不同,因此自动验证不能要求整段错误文本逐字相等。
为什么之前 gcc -c main.c 能通过?编译器看到了 void greet(const char *name);,知道函数接收什么参数、返回什么类型,可以生成调用代码。链接时才需要把调用和实际实现接起来,而当前命令没有提供 greet.o。
修复不是删除函数声明,也不是关闭警告,而是把定义所在的目标文件交给链接器:
gcc main.o greet.o -o demo
./demo
status=$?
printf 'exit status: %s\n' "$status"
如果出现类似问题,可以先问三件事:函数有没有定义?定义所在的文件有没有参与构建?声明与定义的名称、签名是否一致?不要一看到错误就去改 #include;“找不到头文件”和“链接找不到函数”不是同一个阶段。
5. 验证时不能只看有没有生成文件
这个实验应检查的是一组事实,而不是“命令似乎运行过”:
| 检查点 | 期望 |
|---|---|
| 分阶段命令 | 全部返回0,生成非空 .i、.s、.o |
| 预处理结果 | 宏展开为字符串 |
仅链接 main.o | 非零退出,诊断提及 greet |
| 链接两个目标文件 | 返回0 |
运行 demo | 输出准确,同时退出状态为7 |
其中“故意失败”的链接命令返回非零,才算这个测试点成立;不能给整段脚本简单加上 set -e 后就不管它,否则会在期望的失败处提前结束。运行返回7的程序也一样,需要单独捕获状态。
再做一步变化:把 APP_NAME 换成另一段文本,重新构建后观察输出。头文件没有独立变成可执行文件,却通过预处理影响了使用它的源码。这也说明,真实项目里改了头文件,为什么相关源文件常常需要重新编译。
这次真正要记住的不是三个扩展名,而是:声明让调用可以被编译,定义让调用在链接时有着落;链接成功之后,还要验证运行结果。 排查错误先定位阶段,会比盲目修改源码更有方向。
1818



被折叠的 条评论
为什么被折叠?



