第15章 系统调用API函数库

操作系统内核就像一座戒备森严的城堡,普通的应用程序不能随意进入其中执行代码或者直接操作硬件资源。但应用程序又确实需要内核提供的各种服务——读写文件、创建进程、分配内存、收发网络数据。系统调用(System Call)就是内核向外界开放的一扇受控的大门——应用程序通过特定的指令和约定好的接口向内核提出请求,内核验证请求的合法性后代为执行,然后将结果返回给应用程序。

但对于应用程序开发者来说,直接使用汇编指令触发系统调用既不方便也不可移植。系统调用API库就是包裹在原始系统调用之上的一层封装——它提供了C语言函数形式的接口,隐藏了底层的寄存器传参和中断/指令细节,让程序员可以像调用普通函数一样使用内核服务。Linux上的glibc、musl,Windows上的kernel32.dll和ntdll.dll,macOS上的libSystem.dylib,都是典型的系统调用API库。

本章将从系统调用API的整体结构入手,依照POSIX标准设计和实现一套系统调用函数库,覆盖文件操作、进程创建和内存管理三大核心领域。


15.1 系统调用API的整体架构

理解用户态与内核态的边界

要理解系统调用API的架构,首先必须搞清楚操作系统中最基本的一条分界线:用户态(User Mode)和内核态(Kernel Mode)的划分。

x86-64处理器通过特权级(Privilege Level)机制来实现这种隔离。CPU有4个特权级,用Ring 0到Ring 3表示。Ring 0权限最高,可以执行任何指令、访问任何内存区域;Ring 3权限最低,不能执行特权指令(如操作I/O端口、修改页表基址寄存器等),也不能访问被标记为内核态的内存页。

绝大多数操作系统只使用两个特权级——内核运行在Ring 0,应用程序运行在Ring 3。中间的Ring 1和Ring 2基本没有操作系统使用(OS/2曾经使用Ring 2运行设备驱动,但这是少数例外)。虚拟化场景下,某些Hypervisor会利用这些中间特权级,但随着硬件虚拟化技术(VT-x/AMD-V)的普及,这种做法也越来越少见。

当应用程序需要内核服务时,必须通过某种受控的方式从Ring 3切换到Ring 0——这就是系统调用的本质。在x86-64架构上,这个切换有两种主要方式:

传统方式——通过int指令触发软件中断。Linux早期使用int 0x80作为系统调用入口。CPU在执行int指令时会自动切换到Ring 0,跳转到中断描述符表(IDT)中注册的处理函数。这种方式通用但相对较慢,因为中断处理涉及完整的上下文保存和恢复流程。

快速系统调用——使用syscall/sysret指令(AMD64)或sysenter/sysexit指令(Intel IA-32e)。这些专门为系统调用设计的指令省去了中断处理的许多步骤,速度更快。现代64位Linux内核在x86-64上统一使用syscall指令。syscall指令的行为非常精确:它将当前的RIP(指令指针)保存到RCX寄存器,将RFLAGS保存到R11寄存器,然后从MSR寄存器(IA32_LSTAR)中加载内核态的入口地址并跳转执行。

nasm

; x86-64 Linux下的原始系统调用(不通过库函数)
; 示例:write(1, "Hello\n", 6) -- 向标准输出写入字符串

mov rax, 1          ; 系统调用号:1 = sys_write
mov rdi, 1          ; 第一个参数:文件描述符1(stdout)
lea rsi, [msg]      ; 第二个参数:缓冲区地址
mov rdx, 6          ; 第三个参数:写入字节数
syscall             ; 触发系统调用
; 返回值在rax中

msg: db "Hello", 10  ; 字符串"Hello\n"

可以看到,直接使用syscall指令涉及很多细节——系统调用号放在rax中,参数依次放在rdi、rsi、rdx、r10、r8、r9中(注意r10而不是rcx,因为syscall指令会覆盖rcx),返回值在rax中。这些都是Linux x86-64的约定,换一种架构(ARM64使用svc指令,RISC-V使用ecall指令),约定又完全不同了。

系统调用API库的分层结构

系统调用API库在应用程序和内核之间扮演的角色,可以分为以下几层:

最上层是标准C库接口。这是应用程序开发者直接使用的函数——open()、read()、write()、close()、fork()、exec()、malloc()、free()等。这些函数的接口由C语言标准(ISO C)和POSIX标准定义,在不同的操作系统上保持一致(至少在函数签名和基本语义上)。

中间层是系统调用封装。标准C库函数内部调用对应的系统调用封装函数——这些函数负责将参数按照目标平台的调用约定放入正确的寄存器,执行syscall(或int 0x80、svc等)指令,然后检查返回值是否表示错误,如果是则设置errno全局变量并返回-1。

c

// 典型的系统调用封装函数(简化版)
long syscall_write(int fd, const void *buf, size_t count)
{
    long ret;
    
    __asm__ volatile (
        "syscall"
        : "=a"(ret)                        // 输出:rax = 返回值
        : "a"(1),                          // 输入:rax = 系统调用号(write=1)
          "D"(fd),                         // rdi = 第一个参数
          "S"(buf),                        // rsi = 第二个参数
          "d"(count)                       // rdx = 第三个参数
        : "rcx", "r11", "memory"           // 被破坏的寄存器
    );
    
    if (ret < 0) {
        errno = -ret;
        return -1;
    }
    return ret;
}

最底层是内核中的系统调用处理。内核的系统调用入口函数接收到请求后,根据系统调用号从系统调用表(一个函数指针数组)中找到对应的处理函数并执行。

c

// 内核中的系统调用分发(简化)
typedef long (*sys_call_fn)(long, long, long, long, long, long);

sys_call_fn sys_call_table[] = {
    [0] = sys_read,
    [1] = sys_write,
    [2] = sys_open,
    [3] = sys_close,
    // ... 更多系统调用
    [57] = sys_fork,
    [59] = sys_execve,
    [60] = sys_exit,
    [12] = sys_brk,
    [9]  = sys_mmap,
    [11] = sys_munmap,
    // ...
};

long system_call_handler(long syscall_nr, long arg1, long arg2, 
                         long arg3, long arg4, long arg5, long arg6)
{
    if (syscall_nr >= NR_SYSCALLS || !sys_call_table[syscall_nr])
        return -ENOSYS;  // 无效的系统调用号
    
    return sys_call_table[syscall_nr](arg1, arg2, arg3, arg4, arg5, arg6);
}

Linux x86-64上有400多个系统调用(截至内核6.x版本),每个都有唯一的编号。这些编号定义在内核头文件中,应用层的unistd.h提供了对应的宏定义(__NR_read、__NR_write等)。

Windows的系统调用架构有所不同。Windows的系统调用不是由应用程序直接触发的——应用程序调用kernel32.dll或其他Win32 API库的函数,这些函数内部调用ntdll.dll中的"Native API",ntdll.dll再通过syscall指令进入内核。Native API的函数名通常以Nt或Zw开头(如NtCreateFile、NtReadFile)。微软不鼓励应用程序直接调用Native API——虽然技术上可行,但这些接口被视为内部实现细节,可能在不同Windows版本之间变化。Win32 API才是微软承诺的稳定接口。

Windows系统调用号在不同版本之间也不固定。比如NtCreateFile的系统调用号在Windows 10的某个版本可能是0x55,在另一个版本可能是0x56。ntdll.dll封装了这些差异,所以只要通过ntdll.dll调用,应用程序就不需要关心具体的调用号。而Linux的系统调用号是ABI(应用程序二进制接口)的一部分,承诺永远不变——这也是为什么一个在Linux 2.6上编译的静态链接程序通常可以在Linux 6.x上直接运行。

macOS在底层使用Mach微内核和BSD子系统的混合架构。系统调用分为两类:Mach陷阱(trap)用于微内核服务(如线程管理、IPC),BSD系统调用用于传统Unix服务(如文件I/O、进程管理)。libSystem.dylib(相当于macOS上的libc)统一封装了这两类调用。

errno与错误处理机制

系统调用的错误处理有一套统一的约定。在内核中,系统调用函数通过返回负数来表示错误——负数的绝对值就是错误码。比如返回-2表示ENOENT(文件不存在),返回-13表示EACCES(权限不足)。

系统调用封装函数负责将内核的错误约定转换为C库的错误约定——如果返回值为负数,将其绝对值存入线程局部的errno变量,函数返回-1。

c

// 标准的系统调用封装模式
ssize_t write(int fd, const void *buf, size_t count)
{
    long ret = raw_syscall(__NR_write, fd, buf, count);
    
    if (ret < 0 && ret > -4096) {
        // 内核返回了错误码
        errno = -ret;
        return -1;
    }
    
    return (ssize_t)ret;
}

为什么判断条件是ret > -4096而不是简单的ret < 0?因为某些系统调用(如mmap)可能返回很大的正值(高地址的指针),在某些情况下这个值转换为有符号数后看起来是负数。-4096这个阈值保证了不会把合法的返回值误判为错误码——Linux的错误码范围是1到4095(MAX_ERRNO = 4095)。

errno是一个线程局部变量——每个线程有自己独立的errno,不会因为多线程并发调用系统调用而产生竞争条件。在glibc中,errno实际上是一个宏,展开后调用一个返回线程局部存储地址的函数:

c

// glibc中errno的实现
#define errno (*__errno_location())

int *__errno_location(void)
{
    // 返回当前线程的errno地址(通过线程局部存储TLS)
    return &current_thread->errno_value;
}

常见的errno值包括:

c

#define EPERM    1   // 操作不允许
#define ENOENT   2   // 文件或目录不存在
#define ESRCH    3   // 进程不存在
#define EINTR    4   // 系统调用被信号中断
#define EIO      5   // I/O错误
#define ENOMEM  12   // 内存不足
#define EACCES  13   // 权限不足
#define EEXIST  17   // 文件已存在
#define ENOTDIR 20   // 不是目录
#define EISDIR  21   // 是目录(不能对目录执行该操作)
#define EINVAL  22   // 无效参数
#define ENOSPC  28   // 设备空间不足
#define EAGAIN  11   // 资源暂时不可用(非阻塞操作)
#define ENOSYS  38   // 系统调用未实现

strerror()函数可以将错误码转换为人类可读的错误描述字符串,perror()函数直接将错误信息打印到标准错误输出——这些都是C库提供的辅助功能。

系统调用与库函数的区别

值得特别指出的是,并非所有C库函数都是系统调用的简单封装。有三种情况:

一对一的封装——库函数直接对应一个系统调用。比如read()、write()、open()、close()、fork()等。库函数的参数几乎直接传递给内核,只在错误处理上做了转换。

增值封装——库函数在系统调用的基础上添加了额外的功能。最典型的例子是stdio的缓冲I/O:printf()和fprintf()并不直接调用write()系统调用,而是先将数据写入用户态的缓冲区,当缓冲区满了或者遇到换行符(行缓冲模式)时才调用write()将缓冲区内容一次性写入。这大大减少了系统调用的次数——系统调用涉及特权级切换,每次调用都有固定的开销(几百纳秒到几微秒),如果每输出一个字符都调用一次write(),效率会非常低。

纯用户态实现——有些库函数完全不涉及系统调用。比如strlen()(计算字符串长度)、memcpy()(复制内存)、qsort()(排序)等,它们纯粹在用户态完成。还有一个重要的例子是malloc()——虽然malloc()在需要从内核获取更多内存时会调用brk()或mmap()系统调用,但大部分时候它只是在已经获取的内存池中分配和释放块,不需要进入内核。这也是为什么malloc()的性能远好于每次都调用mmap()的原因。


15.2 依照POSIX标准构建系统调用API函数库

15.2.1 POSIX标准中的系统调用API概览

什么是POSIX

POSIX(Portable Operating System Interface)是IEEE制定的一系列操作系统接口标准,编号为IEEE 1003。POSIX标准的全称中"X"来自Unix——它本质上是将Unix操作系统的核心接口标准化,使得在一个符合POSIX标准的系统上编写的程序可以在另一个符合标准的系统上编译和运行。

POSIX标准的诞生有深刻的历史背景。1980年代,Unix世界出现了严重的分裂——AT&T的System V和加州大学伯克利分校的BSD是两大主要分支,此外还有Sun的SunOS、IBM的AIX、HP的HP-UX等商业Unix变种。这些系统虽然都叫"Unix",但在系统调用接口、头文件位置、命令行工具的选项等方面存在大量差异。程序员为一个Unix系统编写的程序,移植到另一个Unix系统时往往需要大量修改。这就是所谓的"Unix战争"。

POSIX标准就是为了终结这种混乱局面而诞生的。1988年第一版POSIX标准(POSIX.1)发布,定义了C语言接口的核心操作系统服务。此后不断扩展——POSIX.1b加入了实时扩展(信号量、共享内存、消息队列等),POSIX.1c加入了线程(pthread),POSIX.2定义了Shell和工具(命令行工具的标准行为)。2001年的大规模修订将这些标准统一为POSIX:2001(也叫Single UNIX Specification Version 3),2008年和2017年又有更新。

今天几乎所有的类Unix系统都在不同程度上兼容POSIX标准——Linux、macOS、FreeBSD、Solaris、AIX等。macOS和部分商业Unix通过了Open Group的正式认证,可以使用"UNIX"商标。Linux虽然没有正式认证(认证费用很高),但在实践中高度兼容POSIX。

Windows对POSIX的支持历史比较曲折。Windows NT最初内置了一个POSIX子系统(用于满足美国政府采购的合规要求),但功能极其有限,几乎无人使用。后来微软推出了SFU(Services for Unix),再后来是WSL(Windows Subsystem for Linux)——WSL1通过在Windows内核中模拟Linux系统调用来运行Linux二进制程序,WSL2则直接在轻量级虚拟机中运行完整的Linux内核。WSL2的兼容性极好,绝大多数Linux程序可以直接运行。

POSIX定义的核心系统调用类别

POSIX标准定义的系统调用(更准确地说是"系统接口",因为标准不关心内核内部的实现)覆盖了操作系统的方方面面:

文件与I/O——open()、close()、read()、write()、lseek()、stat()、fstat()、dup()、dup2()、pipe()、fcntl()、ioctl()、select()、poll()等。这是最基础也最常用的一组接口。

目录操作——opendir()、readdir()、closedir()、mkdir()、rmdir()、chdir()、getcwd()、link()、unlink()、rename()、symlink()、readlink()等。

进程管理——fork()、exec系列(execve、execvp等)、wait()、waitpid()、exit()、_exit()、getpid()、getppid()等。

信号处理——signal()、sigaction()、kill()、sigprocmask()、sigsuspend()等。

线程(Pthreads)——pthread_create()、pthread_join()、pthread_exit()、pthread_mutex_()、pthread_cond_()等。

进程间通信——pipe()、mkfifo()、mmap()(共享内存)、shm_open()、sem_open()、mq_open()等。

内存管理——brk()、sbrk()、mmap()、munmap()、mprotect()等。

时间与定时——time()、gettimeofday()、clock_gettime()、nanosleep()、alarm()、setitimer()等。

对于我们正在开发的64位操作系统,不需要一开始就实现所有的POSIX接口。最小可行的系统需要以下核心子集:

文件操作:open、close、read、write(支持基本的文件读写)。

进程管理:fork、execve、exit、waitpid(支持创建和管理进程)。

内存管理:brk或mmap(支持动态内存分配,让malloc可以工作)。

有了这些,就可以运行一个简单的Shell——Shell读取用户输入(read),解析命令,通过fork+execve启动子进程,通过waitpid等待子进程结束。这已经是一个"能用"的操作系统了。

系统调用号的分配

每个系统调用需要一个唯一的编号。Linux x86-64的系统调用号定义在arch/x86/entry/syscalls/syscall_64.tbl文件中,以下是一些关键的系统调用号:

basic

编号    名称            简要说明
0       read            读取文件
1       write           写入文件
2       open            打开文件
3       close           关闭文件
4       stat            获取文件状态
5       fstat           通过文件描述符获取文件状态
8       lseek           修改文件偏移量
9       mmap            内存映射
10      mprotect        修改内存区域的保护属性
11      munmap          取消内存映射
12      brk             调整数据段结束地址
39      getpid          获取进程ID
56      clone           创建轻量级进程(线程)
57      fork            创建子进程
59      execve          执行程序
60      exit            终止进程
61      wait4           等待子进程状态改变
62      kill            发送信号

为我们的操作系统分配系统调用号时,可以选择与Linux兼容(方便移植Linux用户态程序),也可以自行定义。与Linux兼容的好处是,只要ABI一致,很多Linux程序可以不经修改就在我们的系统上运行(前提是实现了它们用到的所有系统调用)。

c

// 系统调用号定义
#define __NR_read       0
#define __NR_write      1
#define __NR_open       2
#define __NR_close      3
#define __NR_lseek      8
#define __NR_mmap       9
#define __NR_munmap     11
#define __NR_brk        12
#define __NR_fork       57
#define __NR_execve     59
#define __NR_exit       60
#define __NR_waitpid    61
#define __NR_getpid     39

#define NR_SYSCALLS     256   // 系统调用表大小

15.2.2 改进与升级系统调用框架

从int 0x80到syscall指令

在前面的章节中,我们的操作系统可能已经实现了基于int指令的系统调用机制。现在需要将其升级为使用syscall指令——这是x86-64长模式下的标准做法,性能更好。

syscall指令的工作原理依赖于几个MSR(Model Specific Register)寄存器:

IA32_STAR(MSR地址0xC0000081)——存储系统调用和返回时的代码段选择子。高32位的第47:32位是sysret时加载到CS的选择子基值,低32位的第47:32位是syscall时加载到CS的选择子。

IA32_LSTAR(MSR地址0xC0000082)——存储syscall指令的目标RIP(即内核的系统调用入口函数地址)。

IA32_FMASK(MSR地址0xC0000084)——syscall执行时RFLAGS中需要被清除的位(通常清除中断标志IF,防止在保存上下文之前被中断打断)。

初始化代码如下:

c

// MSR寄存器地址
#define MSR_STAR    0xC0000081
#define MSR_LSTAR   0xC0000082
#define MSR_FMASK   0xC0000084

static inline void wrmsr(unsigned int msr, unsigned long value)
{
    unsigned int low = (unsigned int)(value & 0xFFFFFFFF);
    unsigned int high = (unsigned int)(value >> 32);
    __asm__ volatile ("wrmsr" : : "c"(msr), "a"(low), "d"(high));
}

void syscall_init(void)
{
    // 设置LSTAR——系统调用入口地址
    wrmsr(MSR_LSTAR, (unsigned long)syscall_entry);
    
    // 设置STAR——段选择子
    // 低32位[47:32]:syscall时CS = KERNEL_CS,SS = KERNEL_CS + 8
    // 高32位[63:48]:sysret时CS = value + 16,SS = value + 8
    // 假设GDT布局:KERNEL_CS=0x08, KERNEL_DS=0x10, USER_DS=0x18, USER_CS=0x20
    unsigned long star = 0;
    star |= ((unsigned long)0x08) << 32;     // syscall: CS=0x08
    star |= ((unsigned long)0x10) << 48;     // sysret: CS=0x10+16=0x20, SS=0x10+8=0x18
    wrmsr(MSR_STAR, star);
    
    // 设置FMASK——syscall时清除的RFLAGS位
    // 清除IF(中断标志)和TF(单步标志)
    wrmsr(MSR_FMASK, 0x200 | 0x100);  // IF=bit9, TF=bit8
    
    // 启用syscall指令(设置IA32_EFER.SCE位)
    unsigned long efer = rdmsr(0xC0000080);
    efer |= 1;  // SCE = System Call Enable
    wrmsr(0xC0000080, efer);
}

系统调用入口必须用汇编编写——因为syscall指令不会自动保存大部分寄存器,内核入口代码需要手动保存用户态的寄存器状态:

nasm

; 系统调用入口(汇编)
global syscall_entry
syscall_entry:
    ; 此时的状态:
    ; RCX = 用户态的返回地址(原RIP)
    ; R11 = 用户态的RFLAGS
    ; RAX = 系统调用号
    ; RDI, RSI, RDX, R10, R8, R9 = 参数1-6
    ; 注意:R10代替RCX传递第4个参数(因为RCX被syscall覆盖了)
    
    ; 切换到内核栈
    ; swapgs指令交换GS基址——从用户态GS切换到内核态GS
    ; 内核通过GS基址访问per-CPU数据(包括内核栈指针)
    swapgs
    
    ; 保存用户态栈指针,加载内核栈指针
    mov [gs:0x10], rsp          ; 将用户态RSP保存到per-CPU区域
    mov rsp, [gs:0x08]          ; 从per-CPU区域加载内核栈指针
    
    ; 在内核栈上保存寄存器(构建pt_regs结构)
    push qword [gs:0x10]       ; 用户态RSP
    push r11                    ; 用户态RFLAGS(保存在R11中)
    push rcx                    ; 用户态RIP(保存在RCX中)
    push rax                    ; 系统调用号
    
    ; 保存通用寄存器
    push r15
    push r14
    push r13
    push r12
    push r11
    push r10
    push r9
    push r8
    push rbp
    push rdi
    push rsi
    push rdx
    push rcx
    push rbx
    
    ; 重新开启中断(syscall通过FMASK关闭了中断)
    sti
    
    ; 将R10的值移到RCX(恢复标准C调用约定的第4个参数)
    mov rcx, r10
    
    ; 调用C语言的系统调用分发函数
    ; 参数已经在RDI, RSI, RDX, RCX, R8, R9中
    ; RAX = 系统调用号
    mov rdi, rax                ; 第一个参数:系统调用号
    ; 原始参数需要从栈上的保存值恢复
    ; 或者直接传递寄存器——这里简化处理
    
    call system_call_dispatcher
    
    ; 返回值在RAX中
    
    ; 恢复寄存器
    cli                         ; 关闭中断
    
    pop rbx
    pop rcx
    pop rdx
    pop rsi
    pop rdi
    pop rbp
    pop r8
    pop r9
    pop r10
    pop r11
    pop r12
    pop r13
    pop r14
    pop r15
    
    add rsp, 8                  ; 跳过保存的系统调用号
    pop rcx                     ; 恢复用户态RIP到RCX
    pop r11                     ; 恢复用户态RFLAGS到R11
    pop rsp                     ; 恢复用户态RSP(注意这条指令的原子性)
    
    swapgs                      ; 切回用户态GS
    sysretq                     ; 返回用户态
    ; sysretq会将RCX加载到RIP,R11加载到RFLAGS
    ; 同时切换到Ring 3

这段汇编代码看起来复杂,但逻辑是清晰的:保存用户态状态、切换到内核栈、调用C分发函数、恢复状态、返回用户态。实际的Linux内核中这段代码更加复杂——需要处理信号递送、ptrace跟踪、审计、seccomp过滤等。

系统调用分发器

C语言的分发函数根据系统调用号查表调用对应的处理函数:

c

// 系统调用表
typedef long (*syscall_fn_t)(long, long, long, long, long, long);

syscall_fn_t sys_call_table[NR_SYSCALLS] = {
    [__NR_read]     = (syscall_fn_t)sys_read,
    [__NR_write]    = (syscall_fn_t)sys_write,
    [__NR_open]     = (syscall_fn_t)sys_open,
    [__NR_close]    = (syscall_fn_t)sys_close,
    [__NR_lseek]    = (syscall_fn_t)sys_lseek,
    [__NR_brk]      = (syscall_fn_t)sys_brk,
    [__NR_mmap]     = (syscall_fn_t)sys_mmap,
    [__NR_munmap]   = (syscall_fn_t)sys_munmap,
    [__NR_fork]     = (syscall_fn_t)sys_fork,
    [__NR_execve]   = (syscall_fn_t)sys_execve,
    [__NR_exit]     = (syscall_fn_t)sys_exit,
    [__NR_waitpid]  = (syscall_fn_t)sys_waitpid,
    [__NR_getpid]   = (syscall_fn_t)sys_getpid,
    // 其他项默认为NULL
};

long system_call_dispatcher(long nr, long arg1, long arg2, 
                            long arg3, long arg4, long arg5)
{
    if (nr < 0 || nr >= NR_SYSCALLS) {
        return -ENOSYS;
    }
    
    syscall_fn_t fn = sys_call_table[nr];
    if (!fn) {
        printk("Warning: unimplemented syscall %ld\n", nr);
        return -ENOSYS;
    }
    
    return fn(arg1, arg2, arg3, arg4, arg5, 0);
}

将未实现的系统调用返回-ENOSYS(Function not implemented)是标准做法。应用程序可以根据这个错误码判断某个系统调用在当前系统上是否可用。glibc等C库也会据此在运行时选择替代实现——比如如果内核不支持epoll,就降级使用poll或select。

参数验证与安全性

系统调用处理函数在使用用户态传来的参数之前,必须进行严格的验证。这是系统安全的关键——恶意程序可能传入精心构造的非法参数来攻击内核。

最常见的问题是用户态传入的指针。用户程序调用write(fd, buf, count)时,buf是一个用户态地址。内核在读取buf指向的数据之前,必须验证:

这个地址确实属于用户态地址空间(不能指向内核地址空间)。

这个地址范围是可读的(对于read的缓冲区参数,要验证是可写的)。

c

// 验证用户态指针(简化版)
int verify_user_pointer(const void *ptr, size_t size, int write)
{
    unsigned long addr = (unsigned long)ptr;
    unsigned long end = addr + size;
    
    // 检查地址范围不溢出
    if (end < addr)
        return -EFAULT;
    
    // 检查地址在用户态空间内(x86-64的用户态地址低于0x7FFFFFFFFFFF)
    if (end > USER_SPACE_TOP)
        return -EFAULT;
    
    // 检查页面是否已映射且权限正确
    // 实际实现中可以依赖页表和缺页异常处理
    return 0;
}

// 安全地从用户态复制数据到内核
long copy_from_user(void *to, const void *from, unsigned long n)
{
    if (verify_user_pointer(from, n, 0) < 0)
        return -EFAULT;
    
    // 使用特殊的复制函数,如果访问出错会被异常处理捕获
    // 而不是导致内核崩溃
    return __copy_from_user_safe(to, from, n);
}

// 安全地从内核复制数据到用户态
long copy_to_user(void *to, const void *from, unsigned long n)
{
    if (verify_user_pointer(to, n, 1) < 0)
        return -EFAULT;
    
    return __copy_to_user_safe(to, from, n);
}

Linux内核中的copy_from_user()和copy_to_user()是使用最频繁的内核函数之一。它们的实现非常精巧——不是在复制前逐页检查权限,而是直接尝试复制,如果发生页面错误(Page Fault),由页面错误处理程序检查错误地址是否属于一个"预期中的用户态访问"(通过一张异常处理表查找),如果是则优雅地返回错误而不是崩溃。这种"乐观"策略在正常情况下比预先检查更快。

Windows内核中对应的函数是ProbeForRead()和ProbeForWrite()——它们在访问之前验证地址范围,如果无效则抛出STATUS_ACCESS_VIOLATION异常。

vDSO加速

某些系统调用非常频繁但又很简单——典型的例子是gettimeofday()(获取当前时间)。如果每次调用都经过完整的syscall/sysret流程,开销就不合算了。

Linux通过vDSO(virtual Dynamic Shared Object)技术解决这个问题——内核将一小段代码和数据映射到每个进程的用户态地址空间。这段代码可以直接在用户态读取内核维护的时间数据(这块共享内存由内核定期更新),不需要进入内核态。gettimeofday()、clock_gettime()等时间相关的调用都通过vDSO加速。

c

// vDSO中的gettimeofday实现(简化概念)
// 这段代码运行在用户态!
int __vdso_gettimeofday(struct timeval *tv, struct timezone *tz)
{
    // 直接从内核映射到用户态的共享页读取时间数据
    struct vdso_data *vd = __vdso_data;
    
    unsigned int seq;
    do {
        seq = vd->seq;          // 读取序列号(用于检测并发更新)
        smp_rmb();              // 内存屏障
        
        tv->tv_sec = vd->wall_time_sec;
        tv->tv_usec = vd->wall_time_nsec / 1000;
        
        smp_rmb();
    } while (seq != vd->seq);  // 如果期间内核更新了数据,重试
    
    return 0;
}

Windows有类似的机制——KUSER_SHARED_DATA结构映射在每个进程的固定地址(0x7FFE0000),包含系统时间、处理器信息、系统版本等。GetTickCount()等高频调用直接从这个共享页读取数据。

15.2.3 文件基本操作的系统调用API开发

open系统调用

open()是文件操作的起点——打开一个文件(或创建一个新文件),返回一个文件描述符。

c

// 用户态API
int open(const char *pathname, int flags, ...);

// flags参数的常见取值:
#define O_RDONLY    0x0000  // 只读
#define O_WRONLY    0x0001  // 只写
#define O_RDWR      0x0002  // 读写
#define O_CREAT     0x0040  // 如果文件不存在则创建
#define O_TRUNC     0x0200  // 打开时截断文件(清空内容)
#define O_APPEND    0x0400  // 追加模式(写入总是在末尾)
#define O_EXCL      0x0080  // 与O_CREAT配合,如果文件已存在则失败
#define O_NONBLOCK  0x0800  // 非阻塞模式

内核中的实现涉及多个步骤:

c

long sys_open(const char *pathname, int flags, int mode)
{
    // 第一步:从用户态复制路径名
    char kpath[PATH_MAX];
    long ret = copy_path_from_user(kpath, pathname, PATH_MAX);
    if (ret < 0)
        return ret;
    
    // 第二步:分配文件描述符
    int fd = alloc_fd(current_process());
    if (fd < 0)
        return -EMFILE;  // 打开的文件太多
    
    // 第三步:路径查找(通过VFS层)
    struct inode *inode = NULL;
    struct dentry *dentry = NULL;
    
    ret = path_lookup(kpath, flags, &dentry, &inode);
    
    if (ret == -ENOENT && (flags & O_CREAT)) {
        // 文件不存在但指定了O_CREAT——创建新文件
        ret = vfs_create(kpath, mode, &dentry, &inode);
        if (ret < 0) {
            free_fd(current_process(), fd);
            return ret;
        }
    } else if (ret < 0) {
        free_fd(current_process(), fd);
        return ret;
    }
    
    // 如果O_CREAT | O_EXCL,且文件已存在
    if ((flags & O_CREAT) && (flags & O_EXCL) && ret == 0) {
        free_fd(current_process(), fd);
        return -EEXIST;
    }
    
    // 第四步:权限检查
    ret = check_permission(inode, flags);
    if (ret < 0) {
        free_fd(current_process(), fd);
        return ret;
    }
    
    // 不能用open打开目录(除非是特殊用途)
    if (S_ISDIR(inode->i_mode) && (flags & (O_WRONLY | O_RDWR))) {
        free_fd(current_process(), fd);
        return -EISDIR;
    }
    
    // 第五步:创建文件对象
    struct file *filp = alloc_file();
    filp->f_inode = inode;
    filp->f_dentry = dentry;
    filp->f_flags = flags;
    filp->f_pos = 0;
    filp->f_op = inode->i_fop;  // 从inode获取文件操作函数集
    
    // 第六步:如果指定了O_TRUNC,截断文件
    if ((flags & O_TRUNC) && (flags & (O_WRONLY | O_RDWR))) {
        if (filp->f_op->truncate)
            filp->f_op->truncate(inode, 0);
        inode->i_size = 0;
    }
    
    // 第七步:如果指定了O_APPEND,将偏移量设到末尾
    if (flags & O_APPEND) {
        filp->f_pos = inode->i_size;
    }
    
    // 第八步:调用文件系统的open回调(如果有)
    if (filp->f_op->open) {
        ret = filp->f_op->open(inode, filp);
        if (ret < 0) {
            free_file(filp);
            free_fd(current_process(), fd);
            return ret;
        }
    }
    
    // 第九步:将文件对象安装到进程的文件描述符表中
    current_process()->fd_table[fd] = filp;
    
    return fd;
}

文件描述符(File Descriptor)是一个小的非负整数,本质上就是进程文件描述符表的数组下标。每个进程维护自己的文件描述符表——一个struct file*的数组。当进程通过fork()创建子进程时,文件描述符表被复制(但struct file对象被共享——父子进程共享同一个file对象,共享文件偏移量和打开模式)。

c

struct process {
    // ...
    struct file *fd_table[MAX_FD];  // 文件描述符表
    int next_fd;                     // 下一个可用的fd
    // ...
};

int alloc_fd(struct process *proc)
{
    for (int i = proc->next_fd; i < MAX_FD; i++) {
        if (proc->fd_table[i] == NULL) {
            proc->next_fd = i + 1;
            return i;
        }
    }
    return -1;  // 没有可用的fd
}

文件描述符0、1、2有特殊含义——分别是标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。Shell在启动子进程之前会设置好这三个描述符,通常指向终端设备。重定向(如"command > output.txt")就是在fork之后、exec之前关闭fd 1然后重新open一个文件——新打开的文件会自动获得最小的可用fd号(即1),于是子进程的标准输出就被重定向到了文件。

read和write系统调用

read和write是最基本的I/O系统调用:

c

// 用户态API
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

read从文件描述符fd指向的文件的当前偏移量处读取最多count字节到buf中,返回实际读取的字节数(0表示到达文件末尾,-1表示错误)。

write将buf中的count字节写入文件描述符fd指向的文件的当前偏移量处,返回实际写入的字节数。

两者都会自动更新文件偏移量(f_pos)。

c

long sys_read(int fd, void *buf, size_t count)
{
    // 验证文件描述符
    struct file *filp = get_file(current_process(), fd);
    if (!filp)
        return -EBADF;  // Bad file descriptor
    
    // 检查文件是否可读
    if (!(filp->f_flags & (O_RDONLY | O_RDWR)) && filp->f_flags != 0)
        return -EBADF;
    
    // 验证用户缓冲区(必须可写)
    if (verify_user_pointer(buf, count, 1) < 0)
        return -EFAULT;
    
    // 调用文件系统的read操作
    if (!filp->f_op || !filp->f_op->read)
        return -EINVAL;
    
    ssize_t ret = filp->f_op->read(filp, buf, count, &filp->f_pos);
    
    // 更新文件的最后访问时间
    if (ret > 0) {
        filp->f_inode->i_atime = current_time();
    }
    
    return ret;
}

long sys_write(int fd, const void *buf, size_t count)
{
    struct file *filp = get_file(current_process(), fd);
    if (!filp)
        return -EBADF;
    
    // 检查文件是否可写
    if (!(filp->f_flags & (O_WRONLY | O_RDWR)))
        return -EBADF;
    
    // 验证用户缓冲区(必须可读)
    if (verify_user_pointer(buf, count, 0) < 0)
        return -EFAULT;
    
    if (!filp->f_op || !filp->f_op->write)
        return -EINVAL;
    
    // 如果是追加模式,先设置偏移量到文件末尾
    if (filp->f_flags & O_APPEND) {
        filp->f_pos = filp->f_inode->i_size;
    }
    
    ssize_t ret = filp->f_op->write(filp, buf, count, &filp->f_pos);
    
    // 更新文件的最后修改时间
    if (ret > 0) {
        filp->f_inode->i_mtime = current_time();
        
        // 如果写入扩展了文件,更新文件大小
        if (filp->f_pos > filp->f_inode->i_size)
            filp->f_inode->i_size = filp->f_pos;
    }
    
    return ret;
}

需要注意的一个重要细节是,read和write的返回值可能小于请求的count——这叫做"短读"和"短写"(short read / short write)。这在以下情况下会发生:

读取时到达文件末尾——文件只剩100字节,但请求读4096字节,read返回100。

从终端、管道、套接字等非普通文件读取——数据可能还没全部到达,read返回当前可用的数据量。

被信号中断——read在等待数据时收到一个信号,返回-1且errno设为EINTR。

因此健壮的应用程序在读取数据时应该使用循环,直到读够需要的字节数或遇到EOF:

c

// 完整读取指定字节数的辅助函数
ssize_t read_full(int fd, void *buf, size_t count)
{
    size_t total = 0;
    while (total < count) {
        ssize_t n = read(fd, (char *)buf + total, count - total);
        if (n < 0) {
            if (errno == EINTR)
                continue;  // 被信号中断,重试
            return -1;
        }
        if (n == 0)
            break;  // EOF
        total += n;
    }
    return total;
}

close系统调用

close释放文件描述符和关联的资源:

c

long sys_close(int fd)
{
    struct file *filp = get_file(current_process(), fd);
    if (!filp)
        return -EBADF;
    
    // 从文件描述符表中移除
    current_process()->fd_table[fd] = NULL;
    
    // 减少文件对象的引用计数
    filp->f_count--;
    
    if (filp->f_count == 0) {
        // 没有其他引用了——真正关闭
        
        // 调用文件系统的release回调
        if (filp->f_op && filp->f_op->release)
            filp->f_op->release(filp->f_inode, filp);
        
        // 减少inode的引用计数
        iput(filp->f_inode);
        
        // 释放dentry引用
        dput(filp->f_dentry);
        
        // 释放文件对象
        free_file(filp);
    }
    
    return 0;
}

引用计数的存在是因为一个file对象可能被多个文件描述符引用——fork()会复制文件描述符表但共享file对象,dup()/dup2()也会创建指向同一个file对象的新描述符。只有当所有引用都关闭后,file对象才能被释放。

忘记close文件描述符是常见的编程错误。每个进程的文件描述符数量有上限(Linux默认1024,可以通过ulimit调整),如果持续打开文件不关闭,最终会耗尽可用的描述符,导致后续的open调用失败。这在长时间运行的服务器程序中尤其严重——所谓的"文件描述符泄漏"。

lseek系统调用

lseek修改文件描述符的当前偏移量,用于随机访问文件:

c

// 用户态API
off_t lseek(int fd, off_t offset, int whence);

// whence的取值:
#define SEEK_SET  0   // 绝对偏移量(从文件开头算起)
#define SEEK_CUR  1   // 相对于当前位置
#define SEEK_END  2   // 相对于文件末尾

// 内核实现
long sys_lseek(int fd, long offset, int whence)
{
    struct file *filp = get_file(current_process(), fd);
    if (!filp)
        return -EBADF;
    
    long new_pos;
    
    switch (whence) {
    case SEEK_SET:
        new_pos = offset;
        break;
    case SEEK_CUR:
        new_pos = filp->f_pos + offset;
        break;
    case SEEK_END:
        new_pos = filp->f_inode->i_size + offset;
        break;
    default:
        return -EINVAL;
    }
    
    if (new_pos < 0)
        return -EINVAL;
    
    // 对于管道和套接字,lseek没有意义
    if (filp->f_op && filp->f_op->llseek) {
        long ret = filp->f_op->llseek(filp, new_pos, whence);
        if (ret < 0)
            return ret;
        new_pos = ret;
    }
    
    filp->f_pos = new_pos;
    return new_pos;
}

lseek有一个经典的用法——lseek(fd, 0, SEEK_END)返回文件的大小(因为它返回新的偏移量,而从末尾偏移0就是文件大小)。不过获取文件大小的标准方式是使用stat()或fstat()。

lseek还可以创建"空洞文件"(sparse file)——将偏移量设到超过当前文件末尾的位置然后写入数据。文件中间没有写入数据的区域不占用磁盘空间(物理上),读取时返回全零。这在某些场景下非常有用——比如虚拟机的磁盘镜像文件可能声称是100GB大小,但实际只有写入了数据的部分占用磁盘空间。

stat系统调用

stat获取文件的元数据信息,不需要打开文件:

c

struct stat {
    unsigned long st_dev;       // 设备号
    unsigned long st_ino;       // inode号
    unsigned int  st_mode;      // 文件类型和权限
    unsigned int  st_nlink;     // 硬链接数
    unsigned int  st_uid;       // 所有者UID
    unsigned int  st_gid;       // 所有者GID
    unsigned long st_rdev;      // 设备号(如果是设备文件)
    long          st_size;      // 文件大小
    long          st_blksize;   // I/O块大小
    long          st_blocks;    // 分配的512字节块数
    long          st_atime;     // 最后访问时间
    long          st_mtime;     // 最后修改时间
    long          st_ctime;     // 最后状态变更时间
};

// 用户态API
int stat(const char *pathname, struct stat *statbuf);
int fstat(int fd, struct stat *statbuf);
int lstat(const char *pathname, struct stat *statbuf);

// stat和lstat的区别:对于符号链接,stat返回链接目标的信息,
// lstat返回链接本身的信息。

ls -l命令之所以能显示文件的大小、权限、修改时间等信息,靠的就是对目录中每个文件调用stat()。

dup和dup2系统调用

dup和dup2用于复制文件描述符:

c

// dup返回一个新的文件描述符,指向与oldfd相同的文件
int dup(int oldfd);

// dup2将newfd关闭(如果已打开),然后让newfd指向oldfd的文件
int dup2(int oldfd, int newfd);

long sys_dup(int oldfd)
{
    struct file *filp = get_file(current_process(), oldfd);
    if (!filp)
        return -EBADF;
    
    int newfd = alloc_fd(current_process());
    if (newfd < 0)
        return -EMFILE;
    
    filp->f_count++;  // 增加引用计数
    current_process()->fd_table[newfd] = filp;
    
    return newfd;
}

long sys_dup2(int oldfd, int newfd)
{
    if (oldfd == newfd)
        return newfd;  // 什么都不做
    
    struct file *filp = get_file(current_process(), oldfd);
    if (!filp)
        return -EBADF;
    
    if (newfd < 0 || newfd >= MAX_FD)
        return -EBADF;
    
    // 如果newfd已经打开,先关闭
    if (current_process()->fd_table[newfd])
        sys_close(newfd);
    
    filp->f_count++;
    current_process()->fd_table[newfd] = filp;
    
    return newfd;
}

dup2是Shell实现I/O重定向和管道的核心工具。比如"ls | grep foo"的实现过程:

Shell创建一个管道(pipe系统调用),得到一对文件描述符pipe_read和pipe_write。

fork第一个子进程(执行ls):dup2(pipe_write, 1)将标准输出重定向到管道的写端,然后关闭不需要的描述符,execve执行ls。ls的输出数据流入管道。

fork第二个子进程(执行grep):dup2(pipe_read, 0)将标准输入重定向到管道的读端,execve执行grep。grep从管道读取数据。

Shell等待两个子进程结束。

整个过程中,ls和grep完全不知道自己的输入/输出被重定向了——它们只是正常地读写标准输入/输出。这就是Unix"一切皆文件"设计的威力——文件、管道、终端、套接字、设备,都通过相同的read/write接口访问,可以自由组合。

15.2.4 进程创建相关的系统调用API开发

fork系统调用

fork()是Unix/Linux中创建新进程的基本方式。fork()调用一次,返回两次——在父进程中返回子进程的PID,在子进程中返回0。

c

// 用户态使用
pid_t pid = fork();
if (pid < 0) {
    // 错误
    perror("fork failed");
} else if (pid == 0) {
    // 子进程
    printf("I am the child, PID=%d\n", getpid());
    exec("/bin/ls", ...);  // 通常紧接着exec
} else {
    // 父进程
    printf("I am the parent, child PID=%d\n", pid);
    waitpid(pid, &status, 0);  // 等待子进程结束
}

fork的内核实现是操作系统中最复杂的系统调用之一,因为它需要创建当前进程的一个(几乎)完整副本:

c

long sys_fork(void)
{
    struct process *parent = current_process();
    struct process *child = alloc_process();
    
    if (!child)
        return -ENOMEM;
    
    // 分配新的PID
    child->pid = alloc_pid();
    child->ppid = parent->pid;
    child->state = PROCESS_READY;
    
    // 复制进程的内存空间
    // 现代实现使用COW(Copy-on-Write)——不实际复制内存页,
    // 而是让父子进程共享物理页并标记为只读。
    // 当任一方尝试写入时触发页面错误,此时才真正复制该页。
    child->mm = copy_mm(parent->mm);
    if (!child->mm) {
        free_process(child);
        return -ENOMEM;
    }
    
    // 复制文件描述符表
    for (int i = 0; i < MAX_FD; i++) {
        if (parent->fd_table[i]) {
            child->fd_table[i] = parent->fd_table[i];
            child->fd_table[i]->f_count++;  // 增加引用计数
        }
    }
    
    // 复制信号处理设置
    copy_sighand(child, parent);
    
    // 复制CPU寄存器状态
    // 子进程的寄存器状态和父进程相同,除了返回值(RAX)设为0
    copy_thread(child, parent);
    child->regs.rax = 0;  // 子进程的fork返回值为0
    
    // 将子进程加入调度队列
    wake_up_process(child);
    
    // 父进程返回子进程的PID
    return child->pid;
}

Copy-on-Write(COW)是fork性能的关键。如果fork时真的复制父进程的所有内存页,对于一个占用几百MB内存的进程来说,fork会非常慢。COW让fork几乎瞬间完成——只需要复制页表(不是物理页),并将所有可写的页面标记为只读。当父进程或子进程尝试写入某个共享页面时,CPU产生页面异常(Page Fault),内核的异常处理程序检查到这是一个COW页面,才复制该页并恢复可写权限。

c

// Copy-on-Write的页面异常处理
int handle_cow_fault(struct vm_area_struct *vma, unsigned long addr)
{
    // 获取当前的物理页
    struct page *old_page = get_page_at(current_mm(), addr);
    
    if (page_count(old_page) == 1) {
        // 只有一个引用——不需要复制,直接改为可写
        set_page_writable(current_mm(), addr);
        return 0;
    }
    
    // 分配新的物理页
    struct page *new_page = alloc_page();
    if (!new_page)
        return -ENOMEM;
    
    // 复制页面内容
    copy_page(new_page, old_page);
    
    // 更新页表——将虚拟地址映射到新的物理页,并设为可写
    remap_page(current_mm(), addr, new_page, PAGE_WRITABLE);
    
    // 减少旧页面的引用计数
    put_page(old_page);
    
    return 0;
}

在实际的Linux内核中,fork的实现是通过clone()系统调用完成的——fork()只是clone()的一个特例(设置了特定的标志位)。clone()支持更细粒度的控制——可以选择性地共享地址空间、文件描述符表、信号处理等,这正是线程的实现基础。在Linux中,线程就是共享地址空间的进程。

Windows没有fork的对应物——Windows创建新进程使用CreateProcess(),它直接指定要执行的程序(不经过先fork再exec的两步流程)。CreateProcess()在功能上相当于Unix的fork+exec合二为一。这种设计各有利弊——CreateProcess更直接高效(不需要COW的复杂机制),但fork+exec的组合更灵活(fork之后exec之前可以做各种设置,比如重定向I/O、修改环境变量、切换工作目录等)。

Cygwin(在Windows上模拟POSIX环境的工具)在Windows上实现fork非常困难——因为Windows内核不直接支持复制进程地址空间。Cygwin的fork实现通过暂停子进程、逐块复制内存来模拟COW语义,性能比原生Unix的fork差得多。WSL1也面临类似的挑战,而WSL2因为运行了真正的Linux内核,fork就没有问题了。

execve系统调用

execve()用一个新程序替换当前进程的内容——加载新的可执行文件到内存,重新设置代码段、数据段、栈和堆,然后从新程序的入口点开始执行。调用execve后,原来进程的代码和数据都被替换了(除非execve失败)。

c

// 用户态API
int execve(const char *pathname, char *const argv[], char *const envp[]);

// pathname: 可执行文件的路径
// argv: 参数数组(以NULL结尾)
// envp: 环境变量数组(以NULL结尾)

内核实现:

c

long sys_execve(const char *pathname, char **argv, char **envp)
{
    char kpath[PATH_MAX];
    long ret;
    
    // 从用户态复制路径名
    ret = copy_path_from_user(kpath, pathname, PATH_MAX);
    if (ret < 0)
        return ret;
    
    // 从用户态复制参数和环境变量
    // 这些数据需要先复制到内核态,因为接下来要替换地址空间
    char **kargv = copy_strings_from_user(argv);
    char **kenvp = copy_strings_from_user(envp);
    
    if (!kargv || !kenvp) {
        free_strings(kargv);
        free_strings(kenvp);
        return -EFAULT;
    }
    
    // 打开可执行文件
    struct file *exec_file = vfs_open(kpath, O_RDONLY);
    if (IS_ERR(exec_file)) {
        free_strings(kargv);
        free_strings(kenvp);
        return PTR_ERR(exec_file);
    }
    
    // 读取文件头,判断可执行文件格式
    unsigned char header[256];
    vfs_read(exec_file, header, sizeof(header), 0);
    
    // 检查ELF魔数
    if (header[0] != 0x7F || header[1] != 'E' || 
        header[2] != 'L'  || header[3] != 'F') {
        // 不是ELF格式——可能是脚本(#!开头)
        if (header[0] == '#' && header[1] == '!') {
            // 解析shebang行,递归执行解释器
            return exec_script(exec_file, header, kargv, kenvp);
        }
        vfs_close(exec_file);
        free_strings(kargv);
        free_strings(kenvp);
        return -ENOEXEC;  // 不是可执行文件
    }
    
    // 解析ELF文件
    struct elf64_header *ehdr = (struct elf64_header *)header;
    
    // 验证ELF头——64位、小端序、可执行文件
    if (ehdr->e_ident[EI_CLASS] != ELFCLASS64 ||
        ehdr->e_ident[EI_DATA] != ELFDATA2LSB ||
        ehdr->e_type != ET_EXEC && ehdr->e_type != ET_DYN) {
        vfs_close(exec_file);
        free_strings(kargv);
        free_strings(kenvp);
        return -ENOEXEC;
    }
    
    // === 到达不可返回的点 ===
    // 从这里开始,execve不能失败(已经开始破坏原进程的状态)
    // 实际实现中会更小心地处理错误恢复
    
    // 释放旧的地址空间
    struct mm_struct *old_mm = current_process()->mm;
    struct mm_struct *new_mm = create_mm();
    
    // 读取程序头表,加载各段
    for (int i = 0; i < ehdr->e_phnum; i++) {
        struct elf64_phdr phdr;
        vfs_read(exec_file, &phdr, sizeof(phdr),
                ehdr->e_phoff + i * ehdr->e_phentsize);
        
        if (phdr.p_type != PT_LOAD)
            continue;
        
        // 计算对齐后的虚拟地址范围
        unsigned long vaddr_start = phdr.p_vaddr & PAGE_MASK;
        unsigned long vaddr_end = PAGE_ALIGN(phdr.p_vaddr + phdr.p_memsz);
        
        // 设置内存保护属性
        unsigned long prot = 0;
        if (phdr.p_flags & PF_R) prot |= PROT_READ;
        if (phdr.p_flags & PF_W) prot |= PROT_WRITE;
        if (phdr.p_flags & PF_X) prot |= PROT_EXEC;
        
        // 在新地址空间中创建VMA并映射物理页
        struct vm_area_struct *vma = create_vma(new_mm, vaddr_start,
                                                vaddr_end - vaddr_start,
                                                prot);
        
        // 从文件加载段内容
        load_segment(new_mm, exec_file, &phdr);
        
        // BSS段(p_filesz < p_memsz的部分)需要清零
        if (phdr.p_memsz > phdr.p_filesz) {
            clear_bss(new_mm, phdr.p_vaddr + phdr.p_filesz,
                     phdr.p_memsz - phdr.p_filesz);
        }
    }
    
    // 设置栈
    unsigned long stack_top = USER_STACK_TOP;
    unsigned long stack_size = DEFAULT_STACK_SIZE;  // 通常8MB
    create_vma(new_mm, stack_top - stack_size, stack_size,
              PROT_READ | PROT_WRITE);
    
    // 将参数和环境变量压入栈中
    unsigned long sp = stack_top;
    sp = push_strings_to_stack(new_mm, sp, kenvp);
    sp = push_strings_to_stack(new_mm, sp, kargv);
    sp = setup_auxv(new_mm, sp, ehdr);  // 辅助向量
    
    // 设置堆的起始地址
    new_mm->brk_start = PAGE_ALIGN(get_max_load_addr(new_mm));
    new_mm->brk = new_mm->brk_start;
    
    // 切换到新的地址空间
    current_process()->mm = new_mm;
    switch_mm(old_mm, new_mm);
    destroy_mm(old_mm);
    
    // 关闭标记了CLOEXEC的文件描述符
    close_cloexec_files(current_process());
    
    // 重置信号处理为默认
    reset_signal_handlers(current_process());
    
    // 设置新的寄存器状态
    struct pt_regs *regs = current_pt_regs();
    memset(regs, 0, sizeof(*regs));
    regs->rip = ehdr->e_entry;   // 程序入口点
    regs->rsp = sp;               // 栈指针
    regs->cs = USER_CS;
    regs->ss = USER_DS;
    regs->rflags = 0x200;         // 允许中断
    
    // 清理
    vfs_close(exec_file);
    free_strings(kargv);
    free_strings(kenvp);
    
    // execve成功不返回——从新程序的入口点开始执行
    return 0;
}

execve的工作量很大——解析ELF文件、设置新的地址空间、加载代码和数据、设置栈。整个过程中需要处理各种边界情况和错误。

Linux支持多种可执行文件格式——ELF(现在的标准)、a.out(历史格式,几乎不再使用)、脚本文件(#!机制)。内核通过linux_binfmt结构注册格式处理器,execve会依次尝试每种格式直到找到合适的。

#!(shebang)机制是一个精巧的设计。当内核发现可执行文件的前两个字节是"#!"时,它读取第一行的剩余部分作为解释器路径,然后用该解释器来执行脚本文件。比如一个Python脚本的第一行是"#!/usr/bin/python3",execve会实际执行"/usr/bin/python3 script.py"。这个机制完全在内核中实现,对用户态透明。

exit系统调用

exit()终止当前进程:

c

void sys_exit(int status)
{
    struct process *proc = current_process();
    
    // 设置退出状态
    proc->exit_code = (status & 0xFF) << 8;
    
    // 关闭所有打开的文件
    for (int i = 0; i < MAX_FD; i++) {
        if (proc->fd_table[i]) {
            sys_close(i);
        }
    }
    
    // 释放内存空间
    if (proc->mm) {
        destroy_mm(proc->mm);
        proc->mm = NULL;
    }
    
    // 将子进程托孤给init进程
    reparent_children(proc);
    
    // 设置进程状态为僵尸(ZOMBIE)
    proc->state = PROCESS_ZOMBIE;
    
    // 向父进程发送SIGCHLD信号
    send_signal(proc->parent, SIGCHLD);
    
    // 切换到其他进程——当前进程不再运行
    schedule();
    
    // 不会到达这里
}

进程退出后不会立即消失——它变成"僵尸"(zombie)状态,保留最基本的信息(PID和退出状态),等待父进程通过wait/waitpid来收集退出状态。这就是Unix中"僵尸进程"的由来。如果父进程不调用wait,僵尸进程的PID和少量内核数据结构就会一直占用,虽然资源很少但积累多了也是问题。如果父进程先于子进程退出,子进程被"托孤"给init进程(PID 1),由init负责wait回收。

waitpid系统调用

waitpid等待子进程的状态变化(退出、被信号停止等):

c

long sys_waitpid(pid_t pid, int *wstatus, int options)
{
    struct process *parent = current_process();
    
    while (1) {
        int found = 0;
        
        // 遍历子进程
        struct process *child;
        list_for_each_entry(child, &parent->children, sibling) {
            // pid > 0: 等待指定PID的子进程
            // pid == -1: 等待任何子进程
            // pid == 0: 等待同一进程组的子进程
            if (pid > 0 && child->pid != pid)
                continue;
            if (pid == 0 && child->pgid != parent->pgid)
                continue;
            
            found = 1;
            
            if (child->state == PROCESS_ZOMBIE) {
                // 子进程已退出——收集状态
                int status = child->exit_code;
                pid_t child_pid = child->pid;
                
                // 将状态复制到用户态
                if (wstatus) {
                    if (copy_to_user(wstatus, &status, sizeof(int)) < 0)
                        return -EFAULT;
                }
                
                // 释放僵尸进程的残余资源
                release_zombie(child);
                
                return child_pid;
            }
        }
        
        if (!found)
            return -ECHILD;  // 没有匹配的子进程
        
        if (options & WNOHANG)
            return 0;  // 非阻塞模式,没有已退出的子进程
        
        // 阻塞等待——挂起父进程直到某个子进程退出
        set_current_state(PROCESS_WAITING);
        schedule();
        
        // 被唤醒后重新检查(可能是收到SIGCHLD信号)
    }
}

waitpid返回的状态值包含了子进程退出的详细信息——正常退出的退出码、被信号杀死的信号号等。POSIX定义了一组宏来解析状态值:

c

// 检查子进程是否正常退出
#define WIFEXITED(status)    (((status) & 0x7F) == 0)
// 获取退出码
#define WEXITSTATUS(status)  (((status) >> 8) & 0xFF)
// 检查子进程是否被信号杀死
#define WIFSIGNALED(status)  (((status) & 0x7F) != 0 && ((status) & 0x7F) != 0x7F)
// 获取信号号
#define WTERMSIG(status)     ((status) & 0x7F)

15.2.5 内存管理基础系统调用API的开发

brk系统调用

brk()是最古老的内存分配系统调用——它调整进程数据段(堆)的结束地址。增加brk就是扩展堆(分配内存),减少brk就是缩小堆(释放内存)。

c

// 传统的sbrk接口(C库封装)
void *sbrk(intptr_t increment);
// 返回旧的brk值,增加increment字节

// 内核接口
long brk(unsigned long new_brk);
// 设置新的brk值,返回实际的brk值

long sys_brk(unsigned long new_brk)
{
    struct mm_struct *mm = current_process()->mm;
    unsigned long old_brk = mm->brk;
    
    // 如果new_brk为0,返回当前brk值
    if (new_brk == 0)
        return old_brk;
    
    // 对齐到页边界
    unsigned long new_brk_page = PAGE_ALIGN(new_brk);
    unsigned long old_brk_page = PAGE_ALIGN(old_brk);
    
    if (new_brk < mm->brk_start)
        return old_brk;  // 不能低于堆的起始地址
    
    if (new_brk_page == old_brk_page) {
        // 在同一页内,不需要分配/释放页面
        mm->brk = new_brk;
        return new_brk;
    }
    
    if (new_brk > old_brk) {
        // 扩展堆——分配新的页面
        
        // 检查是否超过限制
        if (new_brk - mm->brk_start > mm->rlim_data)
            return old_brk;  // 超过数据段大小限制
        
        // 检查是否与其他VMA冲突(如栈)
        if (find_vma_overlap(mm, old_brk_page, new_brk_page))
            return old_brk;
        
        // 分配并映射新页面
        for (unsigned long addr = old_brk_page; addr < new_brk_page; 
             addr += PAGE_SIZE) {
            struct page *page = alloc_page();
            if (!page) {
                // 内存不足——回滚已分配的页面
                shrink_brk(mm, old_brk_page, addr);
                return old_brk;
            }
            
            // 清零页面(安全要求——不能将其他进程的数据泄露)
            zero_page(page);
            
            // 映射到进程地址空间
            map_page(mm, addr, page, PROT_READ | PROT_WRITE);
        }
    } else {
        // 缩小堆——释放页面
        for (unsigned long addr = new_brk_page; addr < old_brk_page;
             addr += PAGE_SIZE) {
            unmap_page(mm, addr);
        }
    }
    
    mm->brk = new_brk;
    return new_brk;
}

早期的malloc实现(如K&R版本)完全基于brk/sbrk。调用sbrk(n)扩展堆获取n字节的连续内存,malloc在这块内存中维护空闲链表来分配和释放小块。这种方式的问题在于brk只能线性增长——即使堆中间有大量空闲块,brk也不能减少(除非堆顶部的块全部释放了)。

现代的malloc实现(如glibc的ptmalloc、jemalloc、tcmalloc)对小内存块使用brk/sbrk,对大内存块(通常大于128KB)使用mmap。mmap分配的内存不受brk位置的限制,可以在地址空间的任意位置,释放时通过munmap直接归还给操作系统。

mmap系统调用

mmap()是一个功能极其强大的系统调用——它将文件或设备映射到进程的虚拟地址空间,或者分配匿名的虚拟内存区域:

c

void *mmap(void *addr, size_t length, int prot, int flags, 
           int fd, off_t offset);

// prot: 内存保护属性
#define PROT_NONE   0x0   // 不可访问
#define PROT_READ   0x1   // 可读
#define PROT_WRITE  0x2   // 可写
#define PROT_EXEC   0x4   // 可执行

// flags: 映射类型
#define MAP_SHARED    0x01  // 共享映射(写入对其他进程可见,写回文件)
#define MAP_PRIVATE   0x02  // 私有映射(写入不影响文件,COW)
#define MAP_ANONYMOUS 0x20  // 匿名映射(不关联文件,用于分配内存)
#define MAP_FIXED     0x10  // 精确指定映射地址

mmap有三种典型用法:

文件映射——将文件内容映射到内存。读取映射区域相当于读取文件,写入映射区域(MAP_SHARED时)相当于写入文件。这比read/write更高效——不需要在内核缓冲区和用户缓冲区之间复制数据,直接通过页表将文件的Page Cache页面映射到进程地址空间。数据库系统(如SQLite的mmap模式、MongoDB的旧存储引擎)大量使用文件映射来高效访问数据文件。

匿名映射——分配纯内存区域(不关联文件)。MAP_PRIVATE | MAP_ANONYMOUS是malloc分配大块内存的底层机制。释放通过munmap完成——不像brk只能从堆顶释放,munmap可以释放地址空间中任意位置的映射。

共享内存——MAP_SHARED | MAP_ANONYMOUS创建的映射可以通过fork传递给子进程——父子进程看到的是同一块物理内存,可以用于进程间通信。POSIX的shm_open() + mmap()提供了更正式的共享内存API。

c

long sys_mmap(unsigned long addr, unsigned long length, int prot,
              int flags, int fd, long offset)
{
    struct mm_struct *mm = current_process()->mm;
    
    // 参数验证
    if (length == 0)
        return -EINVAL;
    
    if (offset & (PAGE_SIZE - 1))
        return -EINVAL;  // 偏移量必须页对齐
    
    length = PAGE_ALIGN(length);
    
    // 确定映射地址
    if (addr == 0 || !(flags & MAP_FIXED)) {
        // 由内核选择映射地址
        addr = find_free_vma(mm, length);
        if (addr == 0)
            return -ENOMEM;
    } else {
        // 使用指定的地址
        addr = addr & PAGE_MASK;
        
        // 检查是否与现有映射冲突
        if (find_vma_overlap(mm, addr, addr + length)) {
            if (flags & MAP_FIXED) {
                // MAP_FIXED: 取消冲突区域的旧映射
                do_munmap(mm, addr, length);
            } else {
                return -ENOMEM;
            }
        }
    }
    
    // 创建VMA(虚拟内存区域)
    struct vm_area_struct *vma = alloc_vma();
    vma->vm_start = addr;
    vma->vm_end = addr + length;
    vma->vm_prot = prot;
    vma->vm_flags = flags;
    
    if (flags & MAP_ANONYMOUS) {
        // 匿名映射——不需要文件
        vma->vm_file = NULL;
        
        // 实际的物理页面在访问时才分配(demand paging)
        // 这里只创建VMA数据结构
    } else {
        // 文件映射
        struct file *filp = get_file(current_process(), fd);
        if (!filp) {
            free_vma(vma);
            return -EBADF;
        }
        
        vma->vm_file = filp;
        vma->vm_pgoff = offset >> PAGE_SHIFT;
        filp->f_count++;  // 增加文件引用计数
    }
    
    // 将VMA插入进程的VMA红黑树
    insert_vma(mm, vma);
    
    return addr;
}

注意上面代码中的一个关键设计决策——mmap不会立即分配物理页面。它只是创建了一个VMA数据结构,描述了"这个虚拟地址范围应该映射到什么"。当进程实际访问这些地址时,CPU因为找不到页表映射而产生Page Fault,内核的缺页处理程序检查到该地址位于一个合法的VMA中,才真正分配物理页面(对于匿名映射)或从磁盘读取文件数据(对于文件映射)并建立页表映射。这就是"按需分页"(Demand Paging)——一种延迟加载策略,避免了一开始就分配大量可能永远不会使用的内存。

munmap系统调用

munmap取消之前通过mmap创建的映射,释放对应的虚拟地址空间和物理页面:

c

long sys_munmap(unsigned long addr, unsigned long length)
{
    if (addr & (PAGE_SIZE - 1))
        return -EINVAL;
    
    length = PAGE_ALIGN(length);
    
    struct mm_struct *mm = current_process()->mm;
    
    return do_munmap(mm, addr, length);
}

int do_munmap(struct mm_struct *mm, unsigned long start, unsigned long len)
{
    unsigned long end = start + len;
    
    // 找到所有与[start, end)重叠的VMA
    struct vm_area_struct *vma = find_vma(mm, start);
    
    while (vma && vma->vm_start < end) {
        struct vm_area_struct *next = vma->vm_next;
        
        if (vma->vm_start >= start && vma->vm_end <= end) {
            // VMA完全被覆盖——整个移除
            remove_vma(mm, vma);
            
            // 取消所有页面映射,释放物理页面
            unmap_page_range(mm, vma->vm_start, 
                           vma->vm_end - vma->vm_start);
            
            // 如果是文件映射,减少文件引用
            if (vma->vm_file)
                fput(vma->vm_file);
            
            free_vma(vma);
        } else if (vma->vm_start < start && vma->vm_end > end) {
            // VMA被从中间挖掉一段——需要分裂为两个VMA
            split_vma(mm, vma, start, end);
            unmap_page_range(mm, start, len);
        } else if (vma->vm_start < start) {
            // VMA的尾部被截断
            unsigned long old_end = vma->vm_end;
            vma->vm_end = start;
            unmap_page_range(mm, start, old_end - start);
        } else {
            // VMA的头部被截断
            unsigned long old_start = vma->vm_start;
            vma->vm_start = end;
            unmap_page_range(mm, old_start, end - old_start);
        }
        
        vma = next;
    }
    
    // 刷新TLB(Translation Lookaside Buffer)
    flush_tlb_range(mm, start, end);
    
    return 0;
}

TLB刷新是munmap中容易被忽略但非常关键的一步。TLB是CPU中缓存页表映射的高速缓存——如果不刷新TLB,CPU可能还会使用旧的映射来访问已经释放的物理页面,导致数据错误或安全问题。在多核系统上,还需要通过IPI(处理器间中断)通知其他CPU核心刷新它们的TLB(TLB shootdown)——这是SMP系统中一个显著的性能开销。

mprotect系统调用

mprotect修改已映射内存区域的保护属性:

c

long sys_mprotect(unsigned long addr, unsigned long len, int prot)
{
    if (addr & (PAGE_SIZE - 1))
        return -EINVAL;
    
    len = PAGE_ALIGN(len);
    
    struct mm_struct *mm = current_process()->mm;
    unsigned long end = addr + len;
    
    // 遍历受影响的VMA,修改保护属性
    struct vm_area_struct *vma = find_vma(mm, addr);
    
    while (vma && vma->vm_start < end) {
        // 可能需要分裂VMA
        if (vma->vm_start < addr)
            vma = split_vma_at(mm, vma, addr);
        
        if (vma->vm_end > end)
            split_vma_at(mm, vma, end);
        
        // 修改VMA的保护属性
        vma->vm_prot = prot;
        
        // 更新页表中的保护位
        update_page_protection(mm, vma->vm_start, 
                              vma->vm_end - vma->vm_start, prot);
        
        vma = vma->vm_next;
    }
    
    flush_tlb_range(mm, addr, end);
    
    return 0;
}

mprotect有一些重要的应用场景。JIT(Just-In-Time)编译器(如Java HotSpot、V8 JavaScript引擎、.NET CoreCLR)在运行时动态生成机器代码——先用mmap分配一块可写内存,将生成的代码写入,然后用mprotect将其改为可执行(去掉可写权限,添加可执行权限)。出于安全考虑,现代系统通常不允许内存同时可写且可执行(W^X策略),所以需要先写后改权限。

栈保护也使用mprotect——在栈的底部设置一个"保护页"(guard page),权限设为PROT_NONE。如果程序栈溢出到这个页面,会触发段错误(SIGSEGV)而不是默默覆盖其他数据——这是一种栈溢出检测机制。

用户态的malloc实现

虽然malloc不是系统调用,但它是最常用的内存分配接口,建立在brk和mmap之上。理解malloc的实现对于理解内存管理系统调用的使用方式至关重要。

c

// 简化版malloc实现

// 内存块头部
struct block_header {
    size_t size;                // 块大小(不含头部)
    int free;                   // 是否空闲
    struct block_header *next;  // 下一个块
};

#define HEADER_SIZE sizeof(struct block_header)
#define MMAP_THRESHOLD (128 * 1024)  // 大于128KB使用mmap

static struct block_header *free_list = NULL;

void *malloc(size_t size)
{
    if (size == 0)
        return NULL;
    
    // 对齐到16字节
    size = (size + 15) & ~15;
    
    // 大块内存直接使用mmap
    if (size >= MMAP_THRESHOLD) {
        void *ptr = mmap(NULL, size + HEADER_SIZE, 
                        PROT_READ | PROT_WRITE,
                        MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
        if (ptr == MAP_FAILED)
            return NULL;
        
        struct block_header *header = (struct block_header *)ptr;
        header->size = size;
        header->free = 0;
        header->next = NULL;
        
        return (void *)(header + 1);
    }
    
    // 小块内存——在空闲链表中查找
    struct block_header *current = free_list;
    struct block_header *prev = NULL;
    
    while (current) {
        if (current->free && current->size >= size) {
            // 找到足够大的空闲块
            
            // 如果剩余空间足够大,分裂为两个块
            if (current->size > size + HEADER_SIZE + 16) {
                struct block_header *new_block = 
                    (struct block_header *)((char *)current + HEADER_SIZE + size);
                new_block->size = current->size - size - HEADER_SIZE;
                new_block->free = 1;
                new_block->next = current->next;
                
                current->size = size;
                current->next = new_block;
            }
            
            current->free = 0;
            return (void *)(current + 1);
        }
        
        prev = current;
        current = current->next;
    }
    
    // 没有合适的空闲块——通过brk扩展堆
    size_t alloc_size = size + HEADER_SIZE;
    if (alloc_size < 4096)
        alloc_size = 4096;  // 至少扩展一页
    
    void *new_mem = sbrk(alloc_size);
    if (new_mem == (void *)-1)
        return NULL;
    
    struct block_header *new_block = (struct block_header *)new_mem;
    new_block->size = alloc_size - HEADER_SIZE;
    new_block->free = 0;
    new_block->next = NULL;
    
    // 加入链表
    if (prev)
        prev->next = new_block;
    else
        free_list = new_block;
    
    // 如果分配的比请求的大,分裂
    if (new_block->size > size + HEADER_SIZE + 16) {
        struct block_header *remainder = 
            (struct block_header *)((char *)new_block + HEADER_SIZE + size);
        remainder->size = new_block->size - size - HEADER_SIZE;
        remainder->free = 1;
        remainder->next = new_block->next;
        
        new_block->size = size;
        new_block->next = remainder;
    }
    
    return (void *)(new_block + 1);
}

void free(void *ptr)
{
    if (!ptr)
        return;
    
    struct block_header *header = (struct block_header *)ptr - 1;
    
    // 大块内存直接munmap
    if (header->next == NULL && header == free_list) {
        // 可能是mmap分配的——检查地址范围
        // 简化处理...
    }
    
    header->free = 1;
    
    // 合并相邻的空闲块(减少碎片)
    struct block_header *current = free_list;
    while (current && current->next) {
        if (current->free && current->next->free) {
            // 合并
            current->size += HEADER_SIZE + current->next->size;
            current->next = current->next->next;
        } else {
            current = current->next;
        }
    }
}

实际的生产级malloc实现(glibc ptmalloc2、jemalloc、tcmalloc、mimalloc)比这复杂得多:

ptmalloc2(glibc默认)——使用多个arena(内存池),每个线程优先使用自己的arena,减少锁争用。小块内存按大小分类放入不同的bin(fastbin、smallbin、largebin),分配和释放接近O(1)。

jemalloc(FreeBSD默认,Firefox使用)——更细粒度的大小分类,每个线程有独立的缓存(thread cache),几乎无锁。在内存碎片控制方面优于ptmalloc。

tcmalloc(Google开发)——thread-caching malloc,设计理念与jemalloc类似,每个线程维护本地缓存,小内存分配不需要全局锁。Google内部大规模使用。

mimalloc(微软研究院开发)——最新一代的分配器,在某些工作负载下比jemalloc和tcmalloc更快,内存占用更低。


系统调用API库是连接用户程序和操作系统内核的关键桥梁。本章从系统调用的底层机制(syscall指令、寄存器约定、特权级切换)开始,逐步构建了一套覆盖文件操作、进程管理和内存管理的系统调用接口。

文件操作的open/read/write/close构成了I/O的基础,dup/dup2为Shell的重定向和管道提供了支撑。进程管理的fork/execve/exit/waitpid实现了进程的创建、执行、终止和回收的完整生命周期。内存管理的brk和mmap为用户态的动态内存分配(malloc/free)提供了底层支持。

遵循POSIX标准来设计这些接口,不仅保证了接口的合理性和完备性(这些接口经过了几十年的实践检验),也为将来移植现有的Unix/Linux应用程序打下了基础。一个能运行标准POSIX程序的操作系统,已经具备了实用价值——Shell、文本编辑器、编译器、网络工具等丰富的开源软件都可以在上面运行。这正是我们构建64位操作系统所追求的目标。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐