操作系统随笔
这年夏天,你被流放了——不是流放到了高新区,而是流放到了一个叫做 Alpine Linux 的孤岛上。
除了孤独之外,这个岛上一点也不少,除了遍地的系统调用,像 open()、read()、fork() 以外,还有一些你好像从来没有见过的东西,比如 busybox、apk、/etc/inittab……
我是谁(whoami)?我在干什么(ps)?等等,我从哪里来?我要到哪里去?
看着面前一望无际的大海,你陷入了无边的沉思……
一个不用 stdio 的 A+B 问题
用系统调用实现输入输出
musl 和 glibc 都是 C 标准库的实现,前者更轻量级,Alpine Linux 内置的 C 标准库就是 musl。标准库,顾名思义就是提供了 printf()、scanf()、malloc() 等一系列函数的库。
但是,实际上我们不需要使用 stdio 库来完成 A+B 这个简单的任务,我们完全可以直接使用系统调用来实现。
// 为了方便,这个程序只考虑正数相加的情况
#include <sys/syscall.h>
#define READ_INT(a) \
do { \
while(i < n && (in[i] < '0' || in[i] > '9')) { \
i++; \
} \
while(i < n && (in[i] >= '0' && in[i] <= '9')) { \
a = a * 10 + (in[i] - '0'); \
i++; \
} \
} while (0)
// ARM64 中实际系统调用的格式
static inline long sys(long n, long a1, long a2, long a3) {
register long x8 __asm__("x8") = n;
register long x0 __asm__("x0") = a1;
register long x1 __asm__("x1") = a2;
register long x2 __asm__("x2") = a3;
__asm__ volatile(
"svc #0"
: "+r"(x0)
: "r"(x8), "r"(x1), "r"(x2)
: "memory"
);
return x0;
}
void _start(void) {
char in[128];
long n = sys(SYS_read, 0, (long)in, sizeof in);
long a = 0, b = 0;
long i = 0, j = 0;
READ_INT(a);
READ_INT(b);
long sum = a + b;
char out[64];
j = 0;
while(sum != 0) {
out[j++] = sum % 10 + '0';
sum /= 10;
}
if(j == 0) {
out[j++] = '0';
}
for(i = 0; i < j - i - 1; i++) {
char t;
t = out[i], out[i] = out[j - i - 1], out[j - i - 1] = t;
}
out[j++] = '\n';
sys(SYS_write, 1, (long)out, j);
sys(SYS_exit, 0, 0, 0);
}
sys()函数是一个封装了系统调用的函数,在 ARM64 架构中,系统调用的编号放在x8寄存器中,参数分别放在x0、x1、x2中,调用svc #0来触发系统调用。SYS_read、SYS_write和SYS_exit是系统调用的编号,分别对应read()、write()和exit()系统调用。read()系统调用从标准输入(文件描述符 0)读取数据到in数组中,返回实际读取的字节数。write()系统调用将out数组中的数据写到标准输出(文件描述符 1)中,返回实际写入的字节数。- Linux 系统的每个进程有 3 个默认打开的文件描述符:0(标准输入)、1(标准输出)和 2(标准错误)。
SYS_read和SYS_write使用的就是文件描述符。
系统调用
了解这些系统调用,我们得学会自己帮助自己:
man 2 read:查看read()系统调用的手册页,了解它的参数和返回值。man syscalls:查看系统调用的列表,了解有哪些系统调用可用。
<unistd.h> 为我们提供了使用这些系统调用的接口,我们不必再去手写内联汇编,直接调用 read()、write() 和 fork() 等函数就可以了。
神奇的 Pipe
很快,你已经厌倦了这个简单的 A+B 问题了,你想要挑战一下更复杂的任务。
众所周知:
>>> echo "1 2" | ./plus
3
你想要实现:
>>> ./pipe echo "1 2" \| ./plus
3
实现这个功能的核心就在于 fork 和 execve 这两个系统调用,简单来说:
fork():创建一个新的进程,这个新进程是调用进程的一个副本,拥有相同的寄存器状态、内存空间、文件描述符等。子进程的返回值为 0,父进程的返回值为子进程的 PID。execve():用一个新的程序替换当前进程的映像,只保留 PID 和文件描述符等资源不变。
代码如下:
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
int main(int argc, char *argv[]) {
// 使用 '|' 作为分割点
// 在调用命令时,应该加上转义字符 '\|'
int split = -1;
for(int i=1; i<argc; i++) {
if(argv[i][0] == '|' && argv[i][1] == '\0') {
split = i;
}
}
int p[2];
pipe(p); // p[0] -- 读端; p[1] -- 写端
// 文件描述符是 32 位整型
pid_t pid1 = fork();
if (pid1 == 0) {
dup2(p[1], 1); // 让 stdout 指向 p 的写端
close(p[0]);
close(p[1]); // 去除没必要的描述符
argv[split] = NULL; // 截断 argv
execvp(argv[1], &argv[1]);
perror("exec cmd1"); // 如果错误了,打印错误信息
return 1;
}
pid_t pid2 = fork();
if (pid2 == 0) {
dup2(p[0], 0); // 让 stdin 指向 p 的读端
close(p[0]);
close(p[1]);
execvp(argv[split + 1], &argv[split + 1]);
perror("exec cmd2");
return 1;
}
// parent
close(p[0]);
close(p[1]); // 关闭多余的描述符
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0); // 等待子进程结束
return 0;
}
编译该程序后,我们执行以下代码,应该能得到预期的结果:
$ ./pipe echo "1 2" \| ./plus
3
ARM64 Linux 下用户栈的布局
当系统调用 execve() 来执行一个新的程序时,内核会为新程序设置一个用户栈,并将命令行参数、环境变量等信息放在这个栈上,这个栈的布局如下:
|----------------------------|
| argc | <- SP
| argv[0] |
| argv[1] |
| ... |
| argv[argc-1] |
| NULL | <- end of argv
| envp[0] |
| envp[1] |
| ... |
| NULL | <- end of envp
| auxv[0] (key) |
| auxv[0] (value) |
| auxv[1] (key) |
| auxv[1] (value) |
| ... |
| AT_NULL | <- terminator
|----------------------------|
| argument & env strings |
|----------------------------|
argc/argv:命令行参数的数量和内容,与main()函数的参数一致。envp:环境变量的列表,以 NULL 结尾。auxv:辅助向量,包含一些系统信息,如 CPU 架构、页面大小等。argument & env strings:实际的参数和环境变量字符串,argv和envp中的指针指向这里。
在编写 C 程序时,我们通常不关心 envp 和 auxv 的内容(尤其是 auxv),但是 musl 和 glibc 都会在启动的时候解析这些信息。
一个简单的例子是 locale.h,程序设计课上我们基本不会不会用到这个库,但是它在实际的系统中,对本地化支持是非常重要的。下面的代码展示了环境变量对程序的作用:
#include <stdio.h>
#include <time.h>
#include <locale.h>
int main() {
setlocale(LC_ALL, "");
time_t t = time(NULL);
struct tm tm;
localtime_r(&t, &tm);
// Locale-aware format
// %c = preferred date/time representation in current locale
char buf[128];
strftime(buf, sizeof(buf), "%c", &tm);
puts(buf);
return 0;
}
示例输入/输出:
$ LC_ALL=zh_CN.UTF-8 ./date
2026年06月14日 星期日 17时33分25秒
$ LC_ALL=en_US.UTF-8 ./date
Sun 14 Jun 2026 05:33:30 PM CST
系统中,环境变量就是通过用户栈传递给程序的,程序可以通过 getenv() 函数来获取环境变量的值。locale.h 中的 setlocale() 函数也是通过环境变量来设置程序的本地化信息的。
auxv 主要被用于优化 malloc() 等函数的性能,因为它包含了系统的一些关键信息,比如页面大小、CPU 架构等。
(扩展)不用标准库提取用户栈的信息
标准库的作用类似于帮我们定义了程序的一个入口点 main(),并在这个入口点之前做了一些准备工作,比如解析用户栈的信息,设置环境变量等:
// 我们写的部分
int main(int argc, char *argv[]) {
// ...
}
// 标准库帮我们做的部分
__attribute__((noreturn)) void start_c(usize *sp) {
// 解析用户栈的信息,获取 argc、argv、envp、auxv 等
// ...
// 调用 main() 函数
int ret = main(argc, argv);
// 退出程序
exit(ret);
}
绕开标准库,我们也可以直接在 _start() 函数中解析用户栈的信息,并调用 main() 函数,这一部分作为扩展内容,感兴趣的话可以参考以下的 Codex / Claude Code 提示词并阅读 Vibe Coding 的返回结果:
Write a demo (stack.c) that works in Alpine Linux that don't use stdlib (musl or glibc), entrypoint in _start, and prints the argc/argv/envp/auxv information
Vibe Coding 出来的程序输出多半是这个样子的:
$ ./stack alpha beta
initial stack @ 0x0000ffffe3029ad0
argc = 3
argv:
argv[0] @ 0x0000ffffe3029ef2 = "./stack"
argv[1] @ 0x0000ffffe3029efa = "alpha"
argv[2] @ 0x0000ffffe3029f00 = "beta"
argv[3] @ 0x0000000000000000 = (null)
envp:
envp[0] @ 0x0000ffffe3029f05 = "CONTAINER_SHELL=/bin/sh"
......
envp[7] @ 0x0000ffffe3029fe1 = "DEMO_ENV=hello"
envp[8] @ 0x0000000000000000 = (null)
auxv:
auxv[0] AT_SYSINFO_EHDR(33) = 0x0000ffffb329e000
auxv[1] AT_MINSIGSTKSZ(51) = 0x0000000000001270
auxv[2] AT_HWCAP(16) = 0x000000002fb3fffb
......
auxv[11] AT_UID(11) = 0x00000000000001f5
auxv[12] AT_EUID(12) = 0x00000000000001f5
auxv[13] AT_GID(13) = 0x0000000000000014
auxv[14] AT_EGID(14) = 0x0000000000000014
auxv[15] AT_SECURE(23) = 0x0000000000000000
auxv[16] AT_RANDOM(25) = 0x0000ffffe3029cc8
......
auxv[23] AT_NULL(0) = 0x0000000000000000
(扩展)Libc 的启动流程
此部分为扩展内容,感兴趣的话可以在参考 Github 中 musl 中 __libc_start_main() 函数的实现(链接),了解一下 C 运行时(C Runtime, CRT)的启动流程。
以下列出一些重点:
crt0, crt1, crti 和 crtn 是 C 运行时(C Runtime, CRT)启动流程中重要的组件,其中:
- crt0 和 crt1:负责定义程序的入口点 _start(),并在这个入口点之前做一些准备工作,比如解析用户栈的信息,设置环境变量等;在 musl 和 glibc 中,_start() 函数会调用 __libc_start_main() 函数来完成这些准备工作(可见 Github)
- crti 和 crtn:分别包含了 C 运行时的初始化和清理函数。
我们正常编译一个 C 程序时,链接器会自动将标准库提供的这些组件链接到最终的可执行文件中,这就是为什么我们能够用 main() 函数作为入口。
在 __libc_start_main 中,musl 主要做的事情有:
- 初始化线程局部存储(Thread Local Storage, TLS)和栈保护(Stack Protector)等安全机制;
- 初始化内存管理;
- ……
更多内容请自行返回或者询问 AI。
socket 和 Linux 中的网络栈
好像,你被流放到的地方也不像是一个小岛啊……
当你意识到这个岛上不止有 pipe 还有一个东西叫 socket 的时候,一股难以言表的喜悦之情涌上了你的心头。