0. 前言

我们在学习 C/C++ 时,第一个接触的函数往往是 main。我们都知道它的标准签名是:

int main(int argc, char *argv[])

或者在 Qt 等框架中看到它被用来初始化 QApplication
但你是否思考过以下问题:

  1. 我们在 Shell 命令行里输入的只是“字符”,为什么到了 main 里就变成了“指针数组”?
  2. 为什么内核需要把这些参数“Copy”一遍?不能直接用 Shell 里的地址吗?
  3. 程序刚启动时,CPU怎么知道从哪里开始执行?又是怎么找到这些参数地址的?

本文将从操作系统底层视角(Shell 解析、Exec 系统调用、虚拟内存布局、ELF 文件结构)带你彻底搞懂这个过程。


1. main 函数的标准形态

首先快速回顾一下基础。
在 Linux/Unix 环境下:

  • argc (Argument Count): 参数总数(包含程序名本身)。
  • argv (Argument Vector):指向字符串的指针数组。

场景举例:假设你的程序叫 app,你想传入两个参数 helloworld。命令行输入:

./app hello world

此时程序内部收到的数据:

  • argc = 3
  • argv 布局:
    argv[0] -> “./app”
    argv[1] -> “hello”
    argv[2] ->“world”
    argv[3] -> NULL (标准结尾)

2. 第一阶段:Shell 的“切分”与“统计”

当你按下回车键时,程序并没立即运行,此时还是 Shell 进程 在工作。

  1. 读取输入:Shell 拿到字符串 “./app hello world”。
  2. 分词 (Tokenization):Shell按照空格切割字符串。
  3. 计算:此时 Shell 就算出了参数个数为 3。
  4. 发起调用:Shell 构造一个参数数组,然后调用系统函数execve(或 exec 系列):
// 伪代码
execve("./app", args, env); 

关键点:此时参数还在 Shell 进程的内存里。


3. 第二阶段:内核的“乾坤大挪移”这是最核心的魔法环节。

execve 陷入内核后,内核并没有直接把 Shell 里的指针传给新程序,而是进行了一次深拷贝。
1. 为什么要 Copy?(虚拟内存隔离)
很多同学会问:“为什么不能直接把 Shell 里的地址传给新程序,让它自己去读?
原因在于:进程隔离。

  • Linux 中每个进程都有独立的虚拟地址空间。
  • Shell 里的地址 0x1000 可能存的是数据,而新程序里的 0x1000可能还是未分配区域。如果新程序直接访问 Shell 的内存地址,会触发 段错误 (Segmentation Fault)或读到乱码。

内核的做法:内核必须充当“搬运工”,把参数的具体**内容(字符串值)从 Shell的内存读取出来,搬运到新进程的栈(Stack)**空间里。

2. 为什么存在栈(Stack)里?
内核将参数存放在新进程的用户栈顶。

  • 生命周期main 也是函数,参数放在栈上符合 C 语言函数调用标准。
  • 动态性:命令行参数长度不固定,栈天然支持自顶向下的动态增长。

4. 第三阶段:内存布局与“地址之谜”

当参数被搬运到新进程的栈里后,内存布局(高地址 -> 低地址)大致如下:在这里插入图片描述

这就是 argv 的真相main 函数里的 argv,本质上就是一个指向这段栈内存区域的指针。


5. 第四阶段:程序是怎么“跑”起来的?

这里有两个终极问题:

1.代码入口在哪?
2.代码怎么知道栈在哪?

1.寻找入口 (ELF Header)

你的 ./app 文件是一个 ELF (Executable and Linkable Format) 文件。它的头部(Header)记录了程序的 Entry Point Address(入口地址)。内核读取这个地址,将CPU 的 PC 寄存器 强行指向这里。
2. 传递栈地址 (SP 寄存器)
内核在跳转之前,会把 SP 寄存器(栈指针) 设置为指向刚才构建好的参数数据的起始位置。
实际上,C 程序的真正入口并不是 main,而是 _start(由编译器插入的汇编代码)。
_start 的工作流程如下:
1.读取 SP:从 SP 指向的位置弹出 argc。
2.计算 argv:SP 现在的指向就是 argv 数组的起始地址。
3.调用 main:将argc 和 argv 放入对应寄存器(或压栈),执行 call main。


6. 实验验证

我们可以写一段代码,验证“命令行参数确实存在于栈上”这一理论。

#include <stdio.h>

int main(int argc, char *argv[]) {
    int local_var = 0; // 局部变量,一定分配在栈上

    printf("局部变量地址 (Stack): %p\n", &local_var);
    
    if (argc > 0) {
        // argv[0] 保存的是字符串的地址,看看它是不是也在栈附近
        printf("argv[0] 指向的地址: %p\n", argv[0]); 
        printf("argv 数组本身的地址: %p\n", &argv);
    }

    return 0;
}

运行结果(示例):

局部变量地址 (Stack): 0x7ffd5e396abc
argv[0] 指向的地址: 0x7ffd5e396d88

你会发现,argv 指向的字符串地址,和局部变量的地址非常接近(都在 0x7ffd… 区域),这就实锤了参数是存储在栈上的!


7. 总结

从输入命令到 main 函数执行,经历了以下核心步骤:
1.Shell 切割字符串,计算数量。
2.Kernel 跨越进程边界,将参数字符串的内容Copy 到新进程的栈顶。
3.Kernel 读取 ELF 头,找到程序入口地址。
4.Kernel 设置 SP 寄存器指向栈顶参数。
5.启动代码 (_start) 从栈上拿到 argc/argv,最终传递给 main
理解了这个过程,不仅能让你更好地掌握 C 语言,在调试嵌入式系统(如 i.MX6ULL)的启动脚本或解决栈溢出问题时,也会更加得心应手。


建议:如果这篇文章对你有帮助,欢迎点赞收藏!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐