蒋炎岩操作系统 笔记3

16 min

C 标准库和实现

  • C 标准库:在语言机制上的运行库,大部分使用 C 语言本身实现,少部分需要底层支持(内联汇编等)
  • C 语言是高级的汇编语言:
  1. 硬件级操作 C 语言支持指针、直接内存访问和位操作,能精准控制硬件资源(如寄存器、内存地址),这与汇编语言相似。
  2. 高效性 C 代码经编译后生成的机器指令效率接近汇编,且可通过内联汇编进一步优化关键代码,适合系统级开发。
  3. 结构化抽象 相比汇编的指令式编程,C 语言提供函数、循环、条件分支等结构化语法,显著提升了代码可读性和可维护性。
  4. 跨平台与可移植 C 语言通过编译器实现硬件适配,避免了汇编语言对特定架构的强依赖,同时保留了对底层细节的控制权。

musl.libc 里面:先调用 _start 函数进行几个内联汇编的指令,然后运行 _start_c,在进行一些预备操作后,使用 exit(main(…))(所以 main 函数才需要返回一个值)

popen pclose:pipe stream to or from a process。用在在一个程序中启动另一个程序,并读取返回的值

环境变量的传递:

  1. int main(argc, char *argv[], char *envp[]);
  2. 声称系统中有一个变量为 environ,然后尝试读取,如:
#include <stdio.h>

extern char **exviron;

extern void ******************************end;

int main() {
    for (char **env = environ; *env; env++) {
        printf("%s\n", *env);
    }
    end = NULL;
}

动态内存管理

虽然 mmap 可以向操作系统申请任意大的内存(即使超过物理内存上限也可以),但是操作系统不支持分配一小段内存,需要应用程序每次想操作系统多要一点内存,并自己在内存上实现一个数据结构

脱离 workload 做优化就是耍流氓

需要管理的对象:认为对象空间越大,生存周期也应该越长才合理;否则例如如果取到了一个很大的对象(分配了一个很大的空间),然后就初始化一遍就结束了,显然不太合理。结论是:malloc() 几乎只需要管理较小的对象就好了。

在 glibc 中,malloc() 对小块内存分配,使用 brk 扩展堆,而对大块分配使用 mmap;同时维护了多个空闲块链表,被释放的内存可能不立即归还系统,而是放入空闲链表等待复用。如下是 linux 中每个进程的虚拟地址空间布局

+---------------------+
|    text segment     | 代码
+---------------------+
|    data segment     | 全局变量
+---------------------+
|     heap            | malloc/sbrk/brk 扩展的区域
|     ↑ grows up      |
+---------------------+
|     mmap 区域       | mmap 分配的大块内存、库等
+---------------------+
|     stack           | 局部变量、函数调用栈
|     ↓ grows down    |
+---------------------+

当然会引发一系列可能出现的问题,如内存泄漏、悬挂指针、越界访问、二次释放等

可执行文件

什么是可执行文件:一个操作系统中的对象(文件)、一个字节序列、一个描述了状态机初始状态的数据结构

ELF:executable and linkable format(linkable 指可以链接外部函数、变量等)

可执行文件需要包含:动态链接库、基本信息(版本、体系结构……)、内存布局、其他……这当然不是唯一的选择,可以有其他的实现

+----------------------+
| ELF Header           | <-- 文件起始处 (0x00)
+----------------------+
| Program Header Table | <-- 可选(用于可执行文件 / 共享库)
+----------------------+
| Section Header Table | <-- 可选(用于目标文件 / 静态链接)
+----------------------+
| Sections (段数据)    |
|   .text, .data, etc. |
+----------------------+
  • ELF header:前 64 / 52 字节
  • Program Header Table:用于运行时加载。每个 program header 描述一个段,如 .text 段、.data 段等的内存映射关系
  • Sections & Section Header Table:用于链接与调试的信息,主要包括 .text(代码)、.data(数据)、.bss(未初始化变量)、.symtab(符号表)、.strtab(字符串表)等
  • Section Contents:在 ELF 文件的后半部分,存储着段数据本身
    • .text:汇编/机器码指令
    • .data:已初始化数据
    • .rodata:只读数据(如字符串字面量)
    • .bss:未初始化数据(不实际存储,但会占地址空间)
    • .symtab, .strtab:符号表和字符串表

显然 ELF 不是人类友好的

core dump 是指操作系统在某个进程发生严重错误(通常是崩溃)时,将该进程当时的内存内容、寄存器状态、调用栈等信息保存到一个文件中的行为(瞬间快照)。包括:

  • 程序崩溃时的内存内容(堆、栈、数据段等)
  • 寄存器的值
  • 调用栈(backtrace)
  • 程序计数器(PC)等信息
  • 打开的文件描述符(有配置时)

保存下来的 core 文件也是一个 ELF 文件,这样就可以用 gdb 等工具还原程序崩溃前的状态进行分析。Windows 的休眠恢复就用了这个原理。

在 ELF 之前,使用 a.out (assembler output) 格式,但功能太少了(不支持动态链接、调试信息、内存对齐……)

重定位:在需要链接外部函数/变量时,将对应位置留空,之后在链接的时候补齐即可实现重定位的功能。链接的时候,先把同名 section (.text .data 等) 拼接,然后计算需要的地址等

加载 ELF 文件(execve)是内核实现的一部分

#!:操作系统在运行可执行文件时,会先检查前两个字符是否是 #!,如果是,则通过修改 execve 的参数的方式运行对应的程序,如对 a.py

#!/usr/bin/env python3
print("Hello")

当使用 chmod +x 指令后,运行 ./a.py 时,会自动将 execve 的参数修改为 execve("/usr/bin/env", ["/usr/bin/env", "python3", "a.py"], …)

动态链接和加载

拆解应用程序,实现运行库和应用代码分离:应用之间的库共享,大型项目分解

file 查看文件性质,ldd 查看依赖的库

通过 mmap 将 ELF 搬运到内存中,也就包括动态链接库(.so .dll

动态链接的可执行文件执行时,首先加载的不是文件本身或者 libc,而是链接器如 ld-linux-x86-64.so.2,在 program header 中的前几排可以看到这样一行:

[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

virtual memory:操作系统维护 memory mappings 的数据结构,采用延迟加载(非必要不分配内存)、写时复制 copy-on-write(fork 时,父子进程先只读共享全部地址空间,当 page fault 的时候,写者复制一份来写,减少内存消耗)。如下是写时复制的说明图:

flowchart TD
    A["父进程 fork()"] --> B["父子进程共享内存页面
(页表指向相同物理页)
页面标记为只读"] B --> C[父进程或子进程尝试写内存] C --> D{该页面为只读?} D -- 是 --> E["触发页错误 (Page Fault)"] E --> F[内核分配一块新的物理页] F --> G[将旧页面内容复制到新页面中] G --> H[将写入进程的页表指向新页面] H --> I[设置新页面为可写] I --> J[写入成功,COW 完成] D -- 否 --> J style A fill:#f9f,stroke:#333,stroke-width:1px style C fill:#ff9,stroke:#333,stroke-width:1px style F fill:#bbf,stroke:#333,stroke-width:1px style G fill:#bbf,stroke:#333,stroke-width:1px style J fill:#9f9,stroke:#333,stroke-width:1px
graph TD
    subgraph Parent Process
        P1[页表项A ➝] --> PageA
        P2[页表项B ➝] --> PageB
    end

    subgraph Child Process
        C1[页表项A' ➝] --> PageA
        C2[页表项B' ➝] --> PageB
    end

    subgraph Physical Memory
        PageA[物理页 A
只读] PageB[物理页 B
只读] end style PageA fill:#ffe,stroke:#c00,stroke-width:2px style PageB fill:#ffe,stroke:#c00,stroke-width:2px style P1 fill:#bbf style P2 fill:#bbf style C1 fill:#bfb style C2 fill:#bfb

memory deduplication:操作系统在后台扫描内存,如果有两个重复的 read-only pages,就合并;发现 cold pages,就压缩/swap 到硬盘

动态链接:编译时,动态链接库调用 = 查表;链接时,收集所有符号,“生成”符号信息和相关代码。实现流程:

  • 编译时
    • 编译器使用头文件(.h)了解库中有哪些函数。
    • 编译生成目标文件(.o)时,符号没有被解析。
    • 链接器使用 .so 文件的符号表生成可执行文件,但不会复制库代码,只记录“需要链接哪些库”。
  • 运行时
    • 程序启动时,ld.so 动态链接器会:
      • 读取 ELF 可执行文件中的 .dynamic.interp 段,识别需要加载哪些共享库。
      • 加载这些共享库(.so 文件)到内存。
      • 使用重定位(relocation)技术将未解析的符号与库中实际地址关联起来。
      • 修改 GOT(Global Offset Table)和 PLT(Procedure Linkage Table)表,使函数调用转向库中的实现。

动态加载:程序运行过程中,有程序自己决定是否加载一个动态库并使用其中的函数,接口 dlopen, dlsym, dlclose,实现流程:

  1. 程序中调用 dlopen("libxxx.so", RTLD_LAZY) 加载库到进程地址空间中。
  2. 使用 dlsym(handle, "function_name") 获取函数指针。
  3. 调用函数指针来使用库中的函数。
  4. 使用完后,调用 dlclose(handle) 卸载库(可选)。

对比:

特性动态链接动态加载
加载时机程序启动时程序运行时
加载方式系统自动加载程序主动调用 dlopen
使用方式直接调用函数先获取函数指针再调用
使用场景正常依赖、系统库等插件系统、可选功能、热更新等
是否需要链接时指定需要 (-lxxx)不需要,在代码中动态指定 .so 名称

代码示例:

# hello.c
#include <stdio.h>
void hello() { printf("Hello, dynamic link!\n"); }

# main.c
void hello();  // 声明

int main() {
    hello();  // 链接时由动态链接器找到真正的 hello 实现
    return 0;
}

# 编译
gcc -fPIC -shared -o libhello.so hello.c
gcc -o main main.c -L. -lhello
export LD_LIBRARY_PATH=.
./main
// main.c
#include <stdio.h>
#include <dlfcn.h>

int main() {
    void* handle = dlopen("./libhello.so", RTLD_LAZY);
    if (!handle) { perror("dlopen"); return 1; }

    void (*hello)() = dlsym(handle, "hello");
    if (!hello) { perror("dlsym"); return 1; }

    hello();

    dlclose(handle);
    return 0;
}

LD_PRELOAD 机制:在程序运行前预加载指定的共享库,并用库中的函数覆盖原本程序或其它库中的实现。工作原理:

  1. 程序启动 → ld.so 读取 ELF 文件头中的 .interp 段,找到动态链接器(如 /lib64/ld-linux-x86-64.so.2)。
  2. 动态链接器初始化时读取环境变量 LD_PRELOAD
  3. 对于 LD_PRELOAD 中指定的 .so 文件:
    1. 加载进地址空间。
    2. 查找导出的符号。
    3. 这些符号插入到符号解析顺序的最前面
  4. 后续所有符号解析都会优先查找这些库中的实现

可以用于如下场景:

应用场景说明
系统调用拦截hook open, read, write, connect 等函数,实现审计、沙箱、调试
内存调试工具替换 malloc/free 实现,记录调用栈,检测内存泄漏(如 Valgrind)
性能分析记录每次函数调用时间,例如 malloc/free 的时间开销
热修复 bug修复某些已部署程序的逻辑 bug,无需重编译(灰度更新、补丁)
库替代/兼容层实现替代 libc、libGL、libpthread 等实现,用于模拟、兼容

举个例子:可以写一个 mymalloc.c 来修改 malloc 的使用,从而在不修改原有程序的基础上查看 malloc 情况

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
#include <unistd.h>
#include <string.h>

void* malloc(size_t size) {
    static void* (*real_malloc)(size_t) = NULL;
    if (!real_malloc) {
        real_malloc = dlsym(RTLD_NEXT, "malloc");
    }

    void* p = real_malloc(size);

    // 用 write 打印,避免 printf 引发递归
    char buf[100];
    int len = snprintf(buf, sizeof(buf), "malloc(%zu) = %p\n", size, p);
    write(STDOUT_FILENO, buf, len);

    return p;
}

然后编译

gcc -fPIC -shared -o libmymalloc.so mymalloc.c -ldl

运行 ls 命令:

LD_PRELOAD=./libmymalloc.so ls

需要注意的是,避免在预加载的动态库中调用被修改了的函数,不然会导致递归调用和栈溢出,出现 segment fault 等错误。比方说上面的例子中使用的是 write 而不能是 printf,因为 printf 会调用 malloc,最终出现类似于 [1] 82377 segmentation fault (core dumped) LD_PRELOAD=./libmymalloc.so ls 的错误

构建应用生态

[BIOS/UEFI]

[Bootloader (GRUB)] → 加载 vmlinuz + initramfs

[Kernel]

[initramfs] → 执行 /init → 挂载真实根

[switch_root]

[init (systemd)] → 启动服务、网络、图形界面等

[用户登录 / 图形界面]

如下展示 linux 启动过程:

  • BIOS/UEFI → Bootloader 阶段
flowchart TD
    PowerOn[上电]
    Firmware[BIOS / UEFI]
    Bootloader["Bootloader (GRUB, systemd-boot)"]
    KernelAndInitramfs["加载内核 (vmlinuz) + initramfs"]
    JumpToKernel[跳转执行内核]

    PowerOn --> Firmware
    Firmware --> Bootloader
    Bootloader --> KernelAndInitramfs
    KernelAndInitramfs --> JumpToKernel
  • 内核初始化阶段(Kernel Stage)
flowchart TD
    KernelStart[开始执行内核]
    InitDrivers[初始化驱动和子系统]
    MountInitramfs["挂载 initramfs 为根 /"]
    ExecuteInit["执行 initramfs 中的 /init 脚本"]

    KernelStart --> InitDrivers
    InitDrivers --> MountInitramfs
    MountInitramfs --> ExecuteInit
  • initramfs 阶段(过渡文件系统)
flowchart TD
    InitScript["/init 脚本"]
    LoadModules[加载必要内核模块]
    DetectRoot[探测并挂载真实根文件系统]
    SwitchRoot["switch_root/pivot_root 切换根"]
    ExecuteInit2["执行真实根中的 init(PID 1)"]

    InitScript --> LoadModules
    LoadModules --> DetectRoot
    DetectRoot --> SwitchRoot
    SwitchRoot --> ExecuteInit2
  • 用户空间(User Space)
flowchart TD
    InitPID1["init (PID 1)"]
    MountVirtualFS["挂载 /proc, /sys, /dev"]
    StartUdev["启动 udev 管理设备"]
    StartServices[启动服务(网络、sshd、图形等)]
    ReachTarget["达到默认目标(multi-user.target, graphical.target)"]
    UserLogin[用户登录终端或图形界面]

    InitPID1 --> MountVirtualFS
    MountVirtualFS --> StartUdev
    StartUdev --> StartServices
    StartServices --> ReachTarget
    ReachTarget --> UserLogin