应用视角的操作系统
关于 _start
在 C 语言中,真正的程序入口不是 main,而是 _start。
当操作系统加载一个可执行文件时,它寻找的入口点并不是 main,而是一个叫做 _start 的符号。 在正常情况下,编译器会默默地在你的代码外面包上一层 C 标准库(libc)的代码。也就是 _start 函数先执行,它负责准备好运行环境(比如初始化栈、解析命令行参数 argc 和 argv 等),然后再由它去调用你的 main 函数。
1
2
3
4
5
6
7
8
9
_start (在 CRT startup object 中)
↓
初始化运行环境
↓
调用 main()
↓
main 返回
↓
exit()
如果我自己定义_start,就会覆盖系统默认的_start(注:要关闭默认startup files,否则会出现multiple definition of _start的报错,因为crt1.o 也定义了 _start)
假设我在linux里写一个这样的程序:
1
2
3
4
5
6
7
#include <stdio.h>
#include <unistd.h>
void _start() {
printf("hello world!");
_exit(0);
}
因为printf()并不是一个裸函数,它依赖:
- libc runtime 初始化
- stdio 初始化(stdout 等)
- TLS
- libc 内部全局状态
而这些都是 _start 里的启动代码负责初始化的。
所以,当我gcc hello.c -nostartfiles的时候,这时候实际上做了编译和链接两件事,这时候不会报错,因为libc 仍然被链接,而printf的符号也能被找到
但是./a.out会报segment fault或者没有输出,因为printf 依赖 libc 运行时初始化,而这个初始化是在:__libc_start_main中完成的
C 语言本质上只是运行在一层“运行时系统(runtime)”之上的语言,而汇编是直接和操作系统交互的
_start是C runtime(CRT) 提供的
再考虑这样的代码:
1
2
3
4
5
#include <stdio.h>
int main() {
printf("hello world");
}
我在terminal里执行:gcc -c hello.c
这一步只会执行compile,也就是:
hello.c → hello.o
hello.o只是一个目标文件,包含:
_main函数- 对
_printf的 引用(没有printf的实现!!!)
而ld hello.o则会试图构造一个完整的程序,但是链接输入里并没有printf,所以报错:
1
2
Undefined symbols for architecture arm64:
"_printf"
ret
我们再来看这个程序:
1
2
3
void _start() {
while(1);
}
这个程序是可以正常编译、调试的,但是如果执行./a.out,它并不会正常返回,而是报错!
在计算机系统的底层,CPU 只是一个“无情的执行指令的机器”。它没有“程序结束了”这个高级概念,它只会机械地看程序计数器(PC / RIP 寄存器)指向哪里,就去哪里取指令执行。
也就是循环往复地进行fetch → decode → execute,只要PC里有地址,它就会继续跑。
当我们在普通的 C 函数里写 return 时,底层对应的汇编指令是 ret。ret 指令的微观操作非常机械:把当前栈顶(Stack Pointer 指向的内存空间)里的那串数字弹出来,塞进 PC(程序计数器)里,然后 CPU 就跳转到这个地址去接着执行。
ret指令的行为可以用两条伪代码描述:
1
2
pop RIP
jump RIP
也就是:
- 从栈顶取一个值
- 把它当作 下一条指令地址
假设有一个函数foo(),编译器会生成类似:
call foo
call指令做了两件事:
push return_address
jump foo
也就是把return_address去push到栈的顶端,接着jump到foo,去实际执行它,在函数调用结束的时候,会进行ret指令,也就是:
pop return_address → RIP
普通函数调用时:栈顶正好存着当初调用它时的“返回地址”,所以 ret 能完美回家。 在我们的极简 _start 里,这是程序的最开端!换而言之,_start 没有调用者!_start 不是通过 call 进入的,而是 操作系统直接把 PC 设置为程序入口地址!
程序执行的起点是:
1
2
3
4
5
6
7
操作系统
↓
加载程序
↓
设置寄存器
↓
RIP = 程序入口 (_start)
这里是没有call _start的!所以栈顶没有return_address!
我们再来看看,程序启动的时候,栈里有什么:
这个时候栈顶存放的可能是命令行参数的数量(argc,比如数字 1), 灾难就发生了:ret 指令无情地把 1 塞进了程序计数器。CPU 傻乎乎地跑到内存地址 0x00000001 去找指令执行,而这个地址属于操作系统的受保护禁区或根本未映射的内存,于是直接被操作系统一脚踢死——Segmentation fault。
用一张图总结:
而main函数可以return的原因也就很明显了,实际上,程序的执行流程是:
1
2
3
4
5
6
7
_start
↓
__libc_start_main
↓
main
↓
exit
_start会:
1
2
_start:
__libc_start_main(main, argc, argv, ...)
而在__libc_start_main内部,会有类似于这样的事情:
1
2
int ret = main(argc, argv, envp);
exit(ret);
所以:main函数return的时候,实际上会:
1
2
3
4
5
main
↓ ret
__libc_start_main
↓
exit()
main 的 ret 有合法返回地址,返回__libc_start_main 内部代码,接着exit()会进行系统调用,结束程序!
printf → syscall
至此,我们也就知道了:gcc的正常流程是预处理 → 编译 → 汇编 → 链接
现在,我们可以看看,printf("hello world")是怎么一步步剥离成一个完全没有libc的程序的,也就是:
1
C程序 → libc → syscall → kernel
第一步,对于上面的程序,我们输入gcc hello.c,实际上会有这样的调用链:
1
2
3
4
5
6
7
8
9
10
11
main
↓
应用程序
↓
printf (libc stdio)
↓
write (libc syscall wrapper)
↓
syscall instruction
↓
kernel
即:printf 不是系统调用,它只是 libc 的高级封装。
注意:Linux 真正的 API 是 syscall,而不是 libc。
第二步:
1
2
3
4
5
#include <unistd.h>
int main() {
write(1, "hello world\n", 12);
}
这一步实际上有这样的调用链:
1
2
3
4
5
6
7
main
↓
write
↓
syscall
↓
kernel
这说明了,write 既是 libc 的函数,也是系统调用的薄封装;而 printf 是建立在 write 之上的高级库函数。
第三步:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void _start() {
const char msg[] = "hello world\n";
asm(
"mov $1, %%rax\n" // syscall: write
"mov $1, %%rdi\n" // fd = stdout
"mov %0, %%rsi\n" // buffer
"mov $12, %%rdx\n" // size
"syscall\n"
:
: "r"(msg)
);
asm(
"mov $60, %%rax\n" // syscall: exit
"xor %%rdi, %%rdi\n"
"syscall\n"
);
}
编译:
1
gcc -nostdlib hello.c
就变成了:
1
2
3
4
5
_start
↓
syscall
↓
kernel
最后,写成汇编:
global _start
section .data
msg db "hello world",10
len equ $-msg
section .text
_start:
mov rax,1
mov rdi,1
mov rsi,msg
mov rdx,len
syscall
mov rax,60
xor rdi,rdi
syscall
编译:
1
2
nasm -f elf64 hello.asm
ld hello.o
程序结构:
1
2
3
4
5
6
7
ELF入口
↓
_start
↓
syscall
↓
kernel
现在我们就知道了完整的抽象层级,每一层都在封装下一层:
1
2
3
4
5
6
7
8
9
10
11
高级语言
↓
C程序
↓
libc
↓
syscall
↓
kernel
↓
hardware




