新手阶段学 C 语言的时候,我印象最深的一行代码不是 printf("hello world"),反而是程序末尾那行 return 0;。当时班里讨论得很凶:明明程序已经跑完了,这行代码删掉也没见报错,教材里却每段代码都写,到底是不是多此一举?
这个问题看起来很初级,但背后牵扯的东西一点都不初级:程序退出后谁在收尾、操作系统怎么判断程序成功还是失败、shell 脚本里的 $? 到底是什么、甚至 CI 构建时为什么明明能编译却报"链接不到 main 函数"……这些坑很多写了几年项目的人都没完全理清。
这篇我不打算讲标准条文念经,就按我实际写代码和排查问题的顺序,把 return 0; 从头到尾拆开聊一遍。适合刚学 C/C++ 的新手,也适合写了不少年代码但没认真想过"程序退出状态"的开发者。
1. return 0 的真相:不是写给编译器看的,是写给操作系统看的
1.1 一个最简单的程序退出后发生了什么
先做个最简单的实验。你在 Linux 或 macOS 终端里写下这段代码,编译运行:
c复制#include <stdio.h>
int main(void) {
printf("hello\n");
return 0;
}
编译运行的时候你看到的只是屏幕上的 hello,然后命令行提示符重新出现。好像 return 0; 什么都没干。但如果你在 shell 里多敲一条命令,事情就变了:
bash复制echo $?
终端会打印一个数字:0。
这个 $? 是上一个命令的退出状态码。也就是说,当 main 函数执行到 return 0; 时,程序并没有"原地消失",而是把这个 0 通过操作系统的进程退出机制交还给了调用者——通常就是 shell。
如果这时候你突然好奇,把 return 0; 改成 return 42;,重新编译运行,再执行 echo $?,得到的就是 42。
到这里你应该理解了:return 0; 不是写给编译器看的。编译器根本不在乎你返回什么,它只检查类型对不对。这行代码是写给操作系统看的,告诉它"我这个进程是正常结束的"。0 是行业通用的"成功"约定,非 0 则表示出错了。
1.2 返回值是谁在接收,怎么接收
进程退出时,返回值不会凭空消失。在 Linux 上,exit 系统调用会把这个整数返回给内核,内核把状态记录在进程控制块里。父进程(也就是 shell 或者其他启动这个程序的进程)通过 wait / waitpid 系列系统调用回收子进程时,就能拿到这个退出状态。
这里有个容易混淆的地方:shell 里 $? 拿到的不是完整的返回值,而是退出状态的低 8 位。如果你在 main 里写 return 300;,编译不会报错,但实际 $? 拿到的是 44(因为 300 对 256 取余)。所以别把 main 的返回值当成一个 32 位整数随意用。
真实项目里经常遇到一种情况:程序自己的业务逻辑没跑完,但 main 已经返回了。比如某个工具函数里调了 exit(1),有些新手会以为 exit 和 return 差不多,实际上差别巨大:
return是函数返回,只有 main 里的 return 才会触发进程退出;exit是标准库函数,调用后立即开始进程终止流程,并且不管你在哪个函数里调,整个进程都会退出。
所以如果某个库函数内部调用了 exit(0),你在 main 里费劲包了一堆错误处理代码全都不生效,因为进程已经"死了"。
1.3 为什么偏偏是 0,其他数字不行吗
约定俗成的东西总有历史原因。Unix 传统里,进程退出状态 0 表示无错误,非 0 表示有错误,不同的非 0 值可以区分错误类型。最典型的就是 grep:找到匹配返回 0,没找到返回 1,命令本身出错返回 2。shell 脚本里直接靠返回值判断执行结果:
bash复制if grep -q "hello" file.txt; then
echo "found"
else
echo "not found"
fi
能这么写,就是因为 grep 严格遵守退出码约定。如果你的程序不遵守这个约定,它在命令行里还没什么,一旦被别人写进脚本,就是一个隐蔽的定时炸弹。
Windows 下的情况稍微有点不同,WinMain 的返回值约定和 C 的 main 不完全一致,但 main 返回 0 的语义是通用的。Visual Studio 的调试器里 0 也表示"正常退出"。所以 return 0; 不是某个编译器的强制要求,而是整个操作系统生态的通用语言。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 光写 void main 不是省事,是在给自己埋坑
2.1 main 的三种合法形态到底怎么选
很多人以为 main 可以随便写,反正编译器不报错。实际上 C 标准里 main 只有两种标准形态:
c复制int main(void)
int main(int argc, char *argv[])
C++ 标准还额外允许 int main(int argc, char *argv[], char *envp[]),不过第三个参数在不同平台表现有差异,非必要不碰。
C99 和 C11 标准文本里说,main 的返回类型必须是 int。void main 在任何一份 C/C++ 标准里都不是合法的。它之所以能跑,纯粹是编译器宽容:很多编译器看到 void main 就按非标准扩展处理,甚至自动生成一个返回 0 的收尾代码。
那 void main 到底有什么问题?最直接的问题就是:你想在 main 里 return -1; 上报错误时,根本返回不了。有些编译器还会帮你隐式补 return 0;,但你没法控制返回值,程序无论出了什么事都以"成功"状态退出。
我之前接手过一个内部命令行工具,原作者写的是 void main,程序执行出错只是打印了一行错误信息就退出了。外部调度脚本根据退出码判断任务是否成功,结果哪怕程序内部已经报错,脚本那边看到的始终是 0,继续往后续任务跑,最后数据整个错乱。排查到最后,罪魁祸首就是这个 void main。
2.2 不同编译器对 void main 的纵容程度
不要以为编译器不报错就是标准允许。拿 GCC 测试一下:
c复制void main() {
}
GCC 在默认情况下可能只给个 warning,不会中断编译。但如果你开了 -Werror,警告升级为错误,编译直接失败。也就是说,同一种写法换个编译选项就挂了。
你在网上搜"void main 为什么不推荐",会看到很多社区里吵了几十年的帖子。老谭的 C 语言教材里大量使用 void main,影响了一代中国程序员,导致很多人根深蒂固地认为这是规范写法。但如果你去读 C89、C99 原文,从来就没有 void main 的位置。
我自己的习惯是:不管项目多简单,一律写 int main。即便某些嵌入式平台上 main 的启动方式特殊,也是平台提供的非标准入口,而不是靠 void main 来适配。真要深究,可以去看编译器生成的汇编代码:int main 和 void main 在返回时的处理路径完全不一样,前者会保证把 eax 寄存器设成返回值,后者只负责恢复栈就跳回启动代码。
2.3 argc/argv 到底怎么用,什么时候必须用
聊到 main 的参数,就必须把 argc 和 argv 说清楚:
argc(argument count)是命令行参数个数,包括程序名本身;argv(argument vector)是参数字符串数组,argv[0]通常是程序名,argv[1]开始才是真正的参数。
新手一开始不太理解为什么要这两个东西。举个例子:你写一个批量重命名工具,用户执行 renamer *.txt newname,程序里就得靠 argv 拿到文件名,而不是硬编码在源码里。
c复制#include <stdio.h>
int main(int argc, char *argv[]) {
if (argc < 3) {
fprintf(stderr, "Usage: %s <src> <dst>\n", argv[0]);
return 2;
}
printf("rename %s to %s\n", argv[1], argv[2]);
return 0;
}
这里注意两点。第一,argc < 3 时返回 2,这个 2 是给调用者看的,表示"参数错误";第二,argv[0] 可能为 NULL,极端情况下(比如 exec 族函数调用时传空参数列表)要防御一下,但正常命令行下不会。
main 函数参数和 return 0 表面上看没直接关系,但实际是联动的:你想让脚本区分"参数错了"和"执行成功",就得在 main 里对不同情况 return 不同的非零值。没有返回值,参数处理得再完美,脚本也只能两眼一抹黑。
3. 不写 return 0 会发生什么:隐式返回与未定义行为的边界
3.1 C99/C++ 标准里的隐式 return 0 是怎么回事
这里必须澄清一个经常被误解的点。在 C89 标准里,如果 main 执行到了末尾还没碰到 return,行为是未定义的。到了 C99 以及之后的 C 标准,才新增了一个特殊规则:main 函数如果执行到右花括号都没 return,等同于 return 0;。
C++ 标准也有类似条款:main 末尾缺省 return 时,返回值是 0。
这个规则非常特殊,它只对 main 生效。普通函数如果漏写 return,在 C++ 里同样是未定义行为,在 C 里则取决于调用者怎么用返回值。所以你不能把"main 可以漏写 return"推导成"所有函数都能漏写"。
但就算标准规定了隐式返回 0,我依然建议你显式写 return 0;。原因有两层:
- 代码可读性。一个没写 return 的 main,阅读者还得心里默念"哦,C99 标准对 main 有特殊规则"才能判断行为。显式写出来,意图一目了然。
- 兼容性。你的代码可能被迁移到 C89 编译器下编译,或者被某些老旧的嵌入式编译器编译,那条"隐式 return 0"的规则可能根本不生效。
3.2 隐式返回只对 main 生效,别扩展到其他函数
我在代码评审里经常看到这样的写法:
c复制int add(int a, int b) {
int sum = a + b;
}
写的人说"我记得不写 return 也行的"。不对,这条规则只属于 main。add 函数缺 return,行为是未定义的。实际运行时,函数返回时寄存器里残留什么,调用者就拿什么,可能刚好是 sum 的值,也可能是一段乱码。这种代码比 void main 还危险,因为它看起来能跑,但换个编译优化级别就炸了。
3.3 隐藏在隐式 return 背后的未定义行为风险
很多人觉得未定义行为是"反正能跑就别较真"。但 main 返回值的未定义行为,可能表现为非常诡异的现象:
比如你在一个函数里写:
c复制int foo(void) {
// 没有 return
}
然后 main 里这样用:
c复制int main(void) {
int result = foo();
if (result == 0) {
printf("ok\n");
} else {
printf("fail\n");
}
return 0;
}
result 可能是任何值。有时候是 0,有时候不是,看运气。遇到这种情况,新手第一反应是"编译器出 bug 了",实际上是你自己写了未定义行为。
再往深一层说,现代编译器在开启 -O2 优化时,会根据未定义行为做激进假设。你写了一个没返回值的函数,编译器可能直接优化掉后续代码,导致程序行为完全无法预测。所以别跟编译器玩"它可能比较善良"的游戏。
4. return 值没按预期传到系统:shell、脚本、CI 里的真实翻车现场
4.1 shell 里怎么用 $? 检查返回值
写完程序后如果只在 IDE 里跑,你会觉得 return 0 无所谓。但一旦程序被脚本调用,返回值就变成核心信息了。
在 Bash 里,$? 只能拿到上一条命令的退出码,而且任何命令执行都会刷新它。所以你需要马上取值:
bash复制./my_program
status=$?
echo "exit code is $status"
if [ $status -eq 0 ]; then
echo "success"
fi
$? 本身是 shell 的特殊变量,每次命令执行后它都会被覆盖。如果你先执行别的命令再去取,取到的就不是程序退出的值了。
4.2 反着的返回值:为什么会有些程序 0 表示失败
多数人的直觉是"0 表示成功、非 0 表示失败",但实际并不是所有工具都按这个直觉走。因为 shell 的 if 语句判断的是"命令退出码是否为 0",所以很多命令行工具遵循 0 为成功。可也有些领域约定不同:
git diff:有差异返回 0,没有差异返回 1,这和你直觉上的"没有差异才是成功"刚好相反;diff工具:文件一致返回 0,文件不同返回 1,有错误返回 2;test/[命令:条件为真返回 0,为假返回 1。
所以当你写一个通用程序被别的工具调用时,返回码语义必须写清楚。如果不确定调用方的约定,最简单的方式就是遵循标准约定:0 成功,非 0 失败,不同的非 0 值表示不同错误类型。
4.3 CI/脚本里 return 值传递断层的排查思路
我在 CI 里排查过很多次"程序明明失败了,流水线却显示成功"的问题。典型的链路是:
code复制shell 脚本 -> 调用可执行程序 -> 程序 return -1
-> 脚本没有检查 $? -> 脚本最后一条命令是 echo "done"
-> 脚本退出码变成 0 -> CI 认为成功
问题出在脚本最后一行的 echo。shell 脚本的退出码就是最后一个执行命令的退出码,如果你前面调用了程序但没把它的退出码透传出去,脚本自己最后成功退出,所有错误信息都会被吞掉。
正确的写法是:
bash复制./my_program
status=$?
if [ $status -ne 0 ]; then
echo "program failed with $status"
exit $status
fi
或者直接:
bash复制./my_program
如果脚本里没有后续命令,那脚本的退出码就自动等于 ./my_program 的退出码。就怕中间夹了别的命令。另一个常见写法是 set -e,让脚本在任意命令失败时立即退出,但对某些命令不生效。经验是:不要依赖 set -e 兜底,自己在关键步骤做退出码透传。
既然这里提到脚本透传,就多说一句:main 里如果 return -1;,shell $? 会显示 255。因为退出状态码是无符号 8 位整数,-1 被转换成 255。所以写返回值时最好用你期望的 0~255 之间的值,别写负数,不然脚本那边看到的和你 main 里理解的可能不是同一个数。
5. 构建环节和 return 0 纠缠不清:CMake 链接不到 main 函数的原因链
5.1 链接器到底在找什么符号
先抛一个问题:return 0; 和"链接不到 main 函数"有什么关系?
表面上看,一个是运行时行为,一个是构建期错误。但它们有一个交点:链接器在生成可执行文件时,必须找到程序入口点。在大多数 C/C++ 运行时环境里,真正的入口是 _start,它做一些初始化后调用 main。所以你的目标文件里必须存在一个叫 main 的符号(C 里不带修饰)。
如果你写了一个库文件(.a 或 .so),没有 main 是正常的;但如果你要生成可执行文件,链接器找不到 main 就会报类似这样的错误:
code复制/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o: 在函数‘_start’中:
(.text+0x24):对‘main’未定义的引用
collect2: error: ld returned 1 exit status
这时候第一反应不是去检查 main 函数写没写,而是检查链接命令里有没有把包含 main 的目标文件放进来。很多人栽在 CMake 写法上。
5.2 main 函数链接不到的常见原因
CMake 里最常见的情况是这样的:你写了一个 main.cpp,但 add_library 而不是 add_executable 把它打成库,链接器当然找不到 main;或者你用了 add_executable(main src/main.cpp),但 main.cpp 文件路径写错,导致目标文件里压根没有 main 符号。
我遇到过好几次更隐蔽的情况:项目里有多个子目录,每个子目录的 CMakeLists.txt 里都写了 add_executable,但源文件名重复。比如两个子目录都有 main.cpp,构建时其中一个目标文件覆盖了另一个,链接器找到的 source 里没有 main 或者有多个 main,表现为"重复定义"或"未定义引用"。
还有一个常见问题:把 main 写在了被 if 排除的源码分支里。比如:
cmake复制set(SOURCES util.cpp)
if(WIN32)
list(APPEND SOURCES win_main.cpp)
else()
list(APPEND SOURCES linux_main.cpp)
endif()
add_executable(my_app ${SOURCES})
如果某个平台分支的源文件名拼错了,或者条件没进入任何分支,就会导致没有任何一个文件提供 main 符号。
定位这类问题最有效的办法是直接看链接命令。CMake 里开启:
bash复制cmake --build build --verbose
或者先 cmake -DCMAKE_VERBOSE_MAKEFILE=ON ..,看实际的 g++/clang 命令里有没有把包含 main 的 .cpp 文件路径传进去。大多数时候问题不是编译器,而是构建脚本的源文件组织逻辑。
5.3 通过 return 0 定位运行时问题的实践
链接问题解决了,程序能跑了,return 0 又会在另一种情况下发挥作用:定位"程序为什么启动后立刻退出但不报错"。
我调试过一个服务,启动后进程马上消失,日志只打了半行。第一反应是 main 前面的某个初始化函数出错。但当时在 main 里已经有 return 0; 兜底,所以进程退出码是 0,系统觉得它"正常结束"了,不会生成 core dump,也看不出异常。
后来我把 main 改成分阶段返回:
c复制int main(void) {
if (init_log() != 0) {
return 10;
}
if (init_config() != 0) {
return 11;
}
if (start_server() != 0) {
return 12;
}
return 0;
}
然后写个脚本不断打印退出码:
bash复制./my_server
echo $?
退出码 11 直接指向 init_config 这一步。这就是 return 0 的价值:它让程序的状态显式地暴露给外界,而不是让进程默默消失。
所以我的建议是:main 里的 return 值最好形成一个短暂的"错误码表",比如 0 成功、1 运行时错误、2 参数错误,甚至用 10、11、12 分段标识,少用一长串 if-else 只返回同一个 -1。这样新手排查时能少走很多弯路。
另外补充一个经验:main 里 return 0; 和调用 exit(0); 在绝大多数情况下等价,但如果全局对象有析构函数,return 会先触发栈上局部对象的析构,再触发全局对象析构,然后由运行时调用 exit;如果你在 main 中间直接调 exit(0),栈上局部对象的析构函数不会执行。C++ 新手容易在这里踩坑,尤其是写 RAII 风格的锁或临时文件管理器时,return 比 exit 安全得多。
还有一点,main 返回后,static 对象的析构顺序和构造顺序相反,全局对象的析构顺序则跨编译单元不定,C++17 之后 inline 变量的出现让这个规则更复杂。但至少有一点是确定的:你能用 return 0; 让流程自然走完析构,就不要用 exit 提前掐断。这算是我在 C++ 服务端项目里最常提醒新人的一条。
如果你是用 CMake 组织的项目,可以在 CMakeLists 里给 main 加一个返回码检测的测试,比如 add_test 里执行编译出的可执行文件并断言 PASS_REGULAR_EXPRESSION 或者检查退出码。这样每次改完构建都能自动确认 main 还在正常工作,而不是等到 CI 跑挂了才回头查。
单独说一个真实项目里的教训:当时有一个监控脚本每天凌晨跑,程序偶尔报错但总显示成功。查了半天发现程序 main 的最后一行是 return 0;,但中间某个线程崩了,pthread_create 出错后主线程没检测返回值,继续往下走,最后正常 return 0。进程退出码是 0,崩溃信息全部丢失。后来在关键调用后加上返回值检查,只要 pthread_create 或 pthread_join 返回非 0,main 就返回错误码。从那以后,我再也没有忽略过这类系统调用的返回值。
把这个场景再延伸一下:不只是 pthread_create,包括 fopen、malloc、socket 这些常见调用的返回值,如果不在代码里检查,那么 main 里的 return 0 就变成了"虚假的成功"。return 0 不保证你的程序执行成功,它只保证你的程序在退出时告诉系统自己成功。真正让 return 0 有意义的,是前面所有函数都正确处理了错误分支,并且这些错误分支最终都能反映到 main 的返回值上。
所以如果你问"main 函数中 return 0 是多此一举吗",我的答案很明确:不是。它可能迟到,可能被滥用,但它绝不是一句空话。没有它,shell 脚本没法判断题跑没跑成,CI 流水线没法判断任务是否真正成功,调试者没法快速定位启动失败的阶段。它就像程序的最后一句遗言:告诉世界自己到底是怎么离开的。
写代码时我总会跟同事提一个很笨但很实用的建议:每个程序的 main 函数不要只写一个孤零零的 return 0,至少把启动流程拆成几个阶段,每个阶段的失败给不同错误码。这样一来,运维、测试、甚至未来的自己,都能靠一行 echo $? 快速锁定问题。
