「Hello World」真的从 main 开始吗一个颠覆认知的问题初学 C 语言时老师告诉你程序从main函数开始执行。这句话对吗对但只对了一半。如果你写过 Python应该知道它的代码是从第一行开始逐行解释执行的。那么 C 语言呢编译型语言的入口在哪里今天就让我们从 Hello World 出发看看main到底是不是程序的第一行代码。你以为的#includestdio.hintmain(){printf(Hello World!\n);return0;}编译运行输出Hello World!看起来一切正常main就是起点。编译命令如下-o参数指定输出文件名hello.c是源码文件$ gcc-ohello hello.c $ ./hello Hello World!实际情况第一步查看 ELF 文件入口C 程序编译后生成的是 ELFLinux或 PEWindows格式的可执行文件。ELF 头中记录了一个Entry point address入口地址让我们看看它指向谁。首先确保hello程序已经编译好然后使用readelf工具来查看它的 ELF 头。readelf是 Linux 上专门用来读取 ELF 格式文件信息的工具-hheader参数表示显示 ELF 文件头$ readelf-hhello|grepEntry真实结果Entry point address: 0x1080再看看0x1080处是什么函数。这次用objdump工具它是 Linux 上的反汇编器-ddisassemble参数表示将机器码反汇编成汇编指令。grep -A 20表示匹配到_start:后显示后续 20 行$ objdump-dhello|grep-A20_start:真实结果0000000000001080 _start: 1080: f3 0f 1e fa endbr64 1084: 31 ed xor %ebp,%ebp 1086: 49 89 d1 mov %rdx,%r9 1089: 5e pop %rsi 108a: 48 89 e2 mov %rsp,%rdx 108d: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp 1091: 50 push %rax 1092: 54 push %rsp 1093: 45 31 c0 xor %r8d,%r8d 1096: 31 c9 xor %ecx,%ecx 1098: 48 8d 3d fe 00 00 00 lea 0xfe(%rip),%rdi # 119d main 109f: ff 15 33 2f 00 00 call *0x2f33(%rip) # 3fd8 __libc_start_mainGLIBC_2.34 10a5: f4 hlt入口函数叫_start不是main再看符号表确认。readelf -ssymbols显示可执行文件的符号表里面记录了所有函数名、变量名及其地址。用grep -E过滤出我们关心的几个符号$ readelf-shello|grep-Emain|_start|__libc真实结果26: 0000000000001080 38 FUNC GLOBAL DEFAULT 16 _start 31: 000000000000119d 60 FUNC GLOBAL DEFAULT 16 main 18: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main_start在地址0x1080main在地址0x119d__libc_start_main来自 glibcUND undefined需要动态链接第二步真正的启动流程ELF 入口 → _start → __libc_start_main → 初始化 libc → 调用全局构造器 → main → exit → 全局析构器_start做了 5 件事清空%ebp标记最外层栈帧取出argc和argv内核已经把它们压到栈上了对齐栈SSE 指令需要 16 字节对齐调用__libc_start_main把main的地址传给它hlt指令如果__libc_start_main返回直接停机__libc_start_main是 glibc 的初始化函数它负责初始化线程子系统注册atexit()处理函数调用所有全局构造函数__attribute__((constructor))**调用main(argc, argv)以main的返回值调用exit()第三步用代码验证把以下代码保存为demo_init.c#includestdio.h__attribute__((constructor))voidbefore_main(){printf([constructor] 我在 main 之前执行\n);}__attribute__((destructor))voidafter_main(){printf([destructor] 我在 main 之后执行\n);}intmain(){printf([main] Hello World!\n);printf([main] main 函数地址: %p\n,main);return0;}编译并运行$ gcc-odemo_init demo_init.c $ ./demo_init这里gcc -o demo_init demo_init.c的作用是调用 GCC 编译器读取demo_init.c源码经过预处理、编译、汇编、链接四个阶段生成名为demo_init的可执行文件-o指定输出文件名。./demo_init是执行当前目录下的demo_init程序。运行结果[constructor] 我在 main 之前执行 [main] Hello World! [main] main 函数地址: 0x58ce5055c19d [destructor] 我在 main 之后执行constructor在main之前打印destructor在main之后打印。程序的生命线比 main 更长。第四步甩开 libc直接从 _start 运行如果我们 **不用 **CRTC Runtime直接从_start开始呢void_start(){// 直接用内联汇编调用 write 系统调用__asm__volatile(mov $1, %%rax\n// write 系统调用号mov $1, %%rdi\n// fd stdoutlea (%0), %%rsi\n// bufmov $19, %%rdx\n// countsyscall\n::r(Hello from _start!\n):rax,rdi,rsi,rdx);// 用 exit 系统调用退出不依赖 libc__asm__volatile(mov $60, %%rax\n// exit 系统调用号xor %%rdi, %%rdi\nsyscall\n:::rax,rdi);}要编译这个特殊的程序不能用普通的gcc命令需要加上-nostartfiles参数。这个参数告诉 GCC“不要链接默认的 CRT 启动代码我自己提供_start”。同时因为没链接 libc我们也用不了printf——所以代码里直接用了系统调用syscall指令写入终端$ gcc-nostartfiles-onostart nostart.c $ ./nostart编译命令分解-nostartfiles意思是不使用标准启动文件即不链接 CRT-o nostart指定输出文件名nostart.c是源文件。执行./nostart后输出Hello from _start! $ echo $? 0不需要 main不需要 printf不需要 libc直接在 _start 里用系统调用输出echo $?用来查看上一条命令的退出码这里返回 0 表示程序正常退出。再看看这个程序的汇编同样用objdump -d反汇编$ objdump-dnostart|grep-A20_start:0000000000001000 _start: 1000: f3 0f 1e fa endbr64 1004: 55 push %rbp 1005: 48 89 e5 mov %rsp,%rbp 1008: 48 8d 05 f1 0f 00 00 lea 0xff1(%rip),%rax # 2000 _start0x1000 100f: 48 89 45 f8 mov %rax,-0x8(%rbp) 1013: 48 8b 4d f8 mov -0x8(%rbp),%rcx 1017: 48 c7 c0 01 00 00 00 mov $0x1,%rax 101e: 48 c7 c7 01 00 00 00 mov $0x1,%rdi 1025: 48 8d 31 lea (%rcx),%rsi 1028: 48 c7 c2 13 00 00 00 mov $0x13,%rdx 102f: 0f 05 syscall 1031: 48 c7 c0 3c 00 00 00 mov $0x3c,%rax 1038: 48 31 ff xor %rdi,%rdi 103b: 0f 05 syscall可以看到没有调用__libc_start_main没有printf包装直接就是加载字符串地址到%rsi设置%rax 1write系统调用号执行syscall陷入内核设置%rax 60exit系统调用号再次syscall这才是最底层的 Hello World第五步查看进程内存映射让我们更直观地看看程序各部分在内存中的位置#includestdio.h#includestdlib.hintglobal_data42;intglobal_bss;constintglobal_rodata100;voidfunc(){staticintstatic_var0;printf( 静态变量地址: %p\n,static_var);}intmain(){intlocal_var0;int*heap_varmalloc(sizeof(int));printf(main 函数地址: %p\n,main);printf(func 函数地址: %p\n,func);printf(字符串常量地址: %p\n,Hello);printf(已初始化全局变量: %p\n,global_data);printf(未初始化全局变量: %p\n,global_bss);printf(只读全局变量: %p\n,global_rodata);func();printf(局部变量地址: %p\n,local_var);printf(堆上变量地址: %p\n,heap_var);free(heap_var);return0;}运行结果main 函数地址: 0x5636523ec272 func 函数地址: 0x5636523ec249 字符串常量地址: 0x5636523ed070 已初始化全局变量: 0x5636523ef010 未初始化全局变量: 0x5636523ef018 只读全局变量: 0x5636523ed004 静态变量地址: 0x5636523ef01c 局部变量地址: 0x7ffd3b507114 堆上变量地址: 0x56367e13b2a0地址分布一目了然区域地址范围低/高代码段 (main, func)0x56...低地址只读数据 (.rodata)0x56...低地址全局变量 (.data/.bss)0x56...低地址堆 (malloc)0x56...低地址栈 (局部变量)0x7ff...高地址栈在高地址0x7ff区域代码和数据在低地址0x56区域。这就是经典的内存布局——栈从高地址向低地址生长堆从低地址向高地址生长。核心启示main不是程序的真实入口——它是被__libc_start_main调用的。真正的入口是_start。_start是汇编写的由编译器GCC自动注入到每个程序中。它负责设置运行环境然后调用main。全局构造函数/析构函数在main之前/之后执行这意味着很多初始化工作如 C 的全局对象构造在main前已经完成了。没有 libc 也能运行——通过直接系统调用几行汇编就能实现完整的 Hello World。这让你明白libc 只是一层方便调用系统服务的封装罢了。思考题尝试把这个 Hello World 编译成静态链接版本-static然后对比_start的汇编代码。看看静态链接时_start和动态链接时有什么不同为什么