Post

应用视角的操作系统

应用视角的操作系统

关于 _start

在 C 语言中,真正的程序入口不是 main,而是 _start

当操作系统加载一个可执行文件时,它寻找的入口点并不是 main,而是一个叫做 _start 的符号。 在正常情况下,编译器会默默地在你的代码外面包上一层 C 标准库(libc)的代码。也就是 _start 函数先执行,它负责准备好运行环境(比如初始化栈、解析命令行参数 argc 和 argv 等),然后再由它去调用你的 main 函数。

截屏2026-03-07 23.50.35

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)”之上的语言,而汇编是直接和操作系统交互的

_startC 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

也就是:

  1. 从栈顶取一个值
  2. 把它当作 下一条指令地址

假设有一个函数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

我们再来看看,程序启动的时候,栈里有什么:

截屏2026-03-07 23.51.47

这个时候栈顶存放的可能是命令行参数的数量(argc,比如数字 1), 灾难就发生了:ret 指令无情地把 1 塞进了程序计数器。CPU 傻乎乎地跑到内存地址 0x00000001 去找指令执行,而这个地址属于操作系统的受保护禁区或根本未映射的内存,于是直接被操作系统一脚踢死——Segmentation fault。

用一张图总结:

截屏2026-03-07 23.53.20

而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()会进行系统调用,结束程序!

截屏2026-03-07 23.59.04

printf → syscall

截屏2026-03-07 23.56.33

至此,我们也就知道了: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
This post is licensed under CC BY 4.0 by the author.

Trending Tags