跳转至

操作系统随笔

这年夏天,你被流放了——不是流放到了高新区,而是流放到了一个叫做 Alpine Linux 的孤岛上。

除了孤独之外,这个岛上一点也不少,除了遍地的系统调用,像 open()read()fork() 以外,还有一些你好像从来没有见过的东西,比如 busyboxapk/etc/inittab……

我是谁(whoami)?我在干什么(ps)?等等,我从哪里来?我要到哪里去?

看着面前一望无际的大海,你陷入了无边的沉思……

一个不用 stdio 的 A+B 问题

用系统调用实现输入输出

muslglibc 都是 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 寄存器中,参数分别放在 x0x1x2 中,调用 svc #0 来触发系统调用。
  • SYS_readSYS_writeSYS_exit 是系统调用的编号,分别对应 read()write()exit() 系统调用。
  • read() 系统调用从标准输入(文件描述符 0)读取数据到 in 数组中,返回实际读取的字节数。
  • write() 系统调用将 out 数组中的数据写到标准输出(文件描述符 1)中,返回实际写入的字节数。
  • Linux 系统的每个进程有 3 个默认打开的文件描述符:0(标准输入)、1(标准输出)和 2(标准错误)。SYS_readSYS_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

实现这个功能的核心就在于 forkexecve 这两个系统调用,简单来说:

  • 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:实际的参数和环境变量字符串,argvenvp 中的指针指向这里。

在编写 C 程序时,我们通常不关心 envpauxv 的内容(尤其是 auxv),但是 muslglibc 都会在启动的时候解析这些信息。

一个简单的例子是 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, crticrtn 是 C 运行时(C Runtime, CRT)启动流程中重要的组件,其中: - crt0crt1:负责定义程序的入口点 _start(),并在这个入口点之前做一些准备工作,比如解析用户栈的信息,设置环境变量等;在 muslglibc 中,_start() 函数会调用 __libc_start_main() 函数来完成这些准备工作(可见 Github) - crticrtn:分别包含了 C 运行时的初始化和清理函数。

我们正常编译一个 C 程序时,链接器会自动将标准库提供的这些组件链接到最终的可执行文件中,这就是为什么我们能够用 main() 函数作为入口。

__libc_start_main 中,musl 主要做的事情有:

  • 初始化线程局部存储(Thread Local Storage, TLS)和栈保护(Stack Protector)等安全机制;
  • 初始化内存管理;
  • ……

更多内容请自行返回或者询问 AI。

socket 和 Linux 中的网络栈

好像,你被流放到的地方也不像是一个小岛啊……

当你意识到这个岛上不止有 pipe 还有一个东西叫 socket 的时候,一股难以言表的喜悦之情涌上了你的心头。

聊天程序

基于 socket 的 TCP 服务器

Linux 驱动编写

评论