11、main函数详解:argc与argv的传递机制,main函数的返回值,程序启动与退出流程

说实话,很多C语言开发者写了几年代码,对main函数的理解还停留在「程序入口」这个层面。但如果你深入嵌入式底层,或者做系统级开发,你会发现main函数远不止这么简单。它背后藏着操作系统加载程序、传递参数、回收资源的一整套机制。

今天我就带你把这层窗户纸捅破。

11.1 main函数的两种标准签名

先看最基础的东西。C标准规定了main函数有两种合法形式:

// 形式一:不带参数
int main(void)

// 形式二:带命令行参数
int main(int argc, char *argv[])

注意,我这里写的是 int main(void),不是 void main()。很多教材和编译器允许 void main(),但C标准明确说——main函数的返回值类型必须是int。我在项目中见过有人用 void main(),结果移植到某个严格遵循标准的编译器上直接报错。嗯,这种坑踩过一次就记住了。

11.2 argc与argv的传递机制

argc和argv是怎么传进来的?说白了,是操作系统帮你干的活。

当你执行 ./myprogram hello world 时,shell(命令行解释器)会做这几件事:

  1. 解析命令行字符串,按空格拆分成若干段
  2. 统计段数,得到argc(参数个数)
  3. 把每段字符串的地址存到一个指针数组里,得到argv
  4. 调用execve系统函数,把argc和argv传给新进程

然后C运行时库(CRT)拿到这两个值,在调用main函数之前把它们压到栈上,或者通过寄存器传递(取决于架构和ABI)。

来看一个具体的例子:

#include <stdio.h>

int main(int argc, char *argv[])
{
    printf("参数个数: %d\n", argc);
    for (int i = 0; i < argc; i++) {
        printf("argv[%d] = %s\n", i, argv[i]);
    }
    return 0;
}

如果你执行 ./test a b c,输出会是:

参数个数: 4
argv[0] = ./test
argv[1] = a
argv[2] = b
argv[3] = c

这里有个细节很多人不知道:argv[argc] 一定是一个NULL指针。这是C标准规定的,用来标记参数列表的结尾。所以你也可以这样遍历:

for (char **p = argv; *p != NULL; p++) {
    printf("%s\n", *p);
}

我个人习惯用这种方式,感觉更贴近指针的本质。

小技巧:在嵌入式系统中,argc和argv通常由启动代码(startup code)提供。如果你在裸机环境下写程序,可以自己伪造argc和argv,甚至让main函数根本不接收它们——只要你的链接脚本和启动代码配合好就行。

11.3 main函数的返回值

main函数的返回值是给谁看的?给操作系统看的。更准确地说,是给调用你的那个进程(通常是shell)看的。

按照惯例:

  • 返回0表示程序正常退出
  • 返回非0表示程序异常退出

但具体什么值代表什么含义,不同系统有不同约定。在Linux中,你可以用 echo $? 查看上一个程序的返回值:

$ ./myprogram
$ echo $?
0

我曾经在写一个自动化测试脚本时,发现某个工具总是返回1,但明明执行成功了。查了半天才发现,那个工具的作者把返回值的含义搞反了——成功返回1,失败返回0。这导致我的脚本逻辑全乱套。所以,请一定遵守约定:0表示成功,非0表示失败

另外,main函数的返回值范围是0到255(在大多数系统上)。如果你返回一个大于255的值,实际被截断成低8位。比如 return 256,shell拿到的其实是0。

注意:在嵌入式系统中,main函数可能永远不会返回。比如单片机程序,main函数里通常是一个死循环。这种情况下,main函数的返回值其实没有意义,但编译器可能会警告。你可以用 while(1); 或者 for(;;); 来避免返回。

11.4 程序启动与退出流程

很多人以为程序从main函数开始,到main函数结束。其实不是。在main函数之前和之后,还有一堆事情要做。

我画了一张图,帮你理清整个流程:

程序启动与退出完整流程 1. 操作系统加载程序 2. CRT启动代码执行 3. 初始化全局/静态变量 4. 调用 main(argc, argv) 5. main函数体执行 6. 清理资源,返回退出码 用户代码 范围 CRT = C Runtime Library(C运行时库)

具体来说,流程是这样的:

11.4.1 启动阶段(main之前)

  1. 操作系统加载程序:内核把可执行文件从磁盘读到内存,建立进程地址空间
  2. CRT启动代码执行:这是编译器自动插入的一段汇编代码,负责:
    • 初始化栈指针
    • 初始化全局变量和静态变量(BSS段清零,数据段赋值)
    • 调用构造函数(C++中)
    • 准备argc和argv
    • 调用main函数

我记得在调试一个STM32项目时,发现全局变量初始值不对。查到最后,原来是启动代码里BSS段清零的地址范围算错了。从那以后,我每次移植新平台,第一件事就是检查启动代码。

11.4.2 退出阶段(main之后)

当main函数执行 return 语句时,控制权并没有直接还给操作系统,而是先回到CRT的退出代码中:

  1. CRT拿到main的返回值
  2. 调用 exit() 函数,它会:
    • 调用所有通过 atexit() 注册的退出处理函数
    • 刷新所有缓冲区(如stdout的缓冲区)
    • 关闭所有打开的文件描述符
    • 释放动态分配的内存(其实这一步通常由操作系统回收)
  3. 最后调用 _exit() 系统调用,通知操作系统进程结束

这里有个容易忽略的点:如果你在main函数里调用了 exit(),它和 return 的效果基本一样。但如果你调用了 _exit()_Exit(),则会直接终止进程,不执行任何清理工作。我在写一个守护进程时用过 _exit(),因为子进程不需要刷新缓冲区,直接退出更快。

核心要点:
  • main函数的返回值是给操作系统看的,0表示成功,非0表示失败
  • argc和argv由操作系统通过CRT传递,argv[argc] == NULL
  • 程序启动和退出都有CRT参与,main函数只是中间一环
  • 嵌入式系统中main函数可能永不返回,需要特殊处理

11.5 一个完整的例子

最后,我写一个综合示例,把今天讲的东西串起来:

#include <stdio.h>
#include <stdlib.h>

void cleanup(void) {
    printf("清理函数被调用\n");
}

int main(int argc, char *argv[])
{
    // 注册退出处理函数
    atexit(cleanup);

    printf("程序启动,参数个数: %d\n", argc);

    // 检查命令行参数
    if (argc < 2) {
        fprintf(stderr, "用法: %s <名字>\n", argv[0]);
        return 1;  // 返回非0表示错误
    }

    printf("你好, %s!\n", argv[1]);

    // 模拟一些工作
    printf("程序执行完毕,准备退出...\n");

    return 0;  // 返回0表示成功
}

运行效果:

$ ./greet 小明
程序启动,参数个数: 2
你好, 小明!
程序执行完毕,准备退出...
清理函数被调用

注意看,cleanup 函数是在main返回之后才被调用的。这就是CRT在背后做的工作。

好了,关于main函数的argc/argv传递机制、返回值含义、以及程序启动退出的完整流程,就讲到这里。这些东西看似基础,但理解透了,你写出来的程序会更健壮,排查问题也会更有方向。


公众号:蓝海资料掘金营,微信deep3321