Linux 基础体系 · 第 2/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 内核与用户态:系统调用、异常、中断和硬件抽象

一、先建立整体模型

Linux 中的“用户态”和“内核态”不是两个独立进程,而是处理器对当前执行权限的两种不同状态:

  • 用户态(user mode):普通应用代码运行的权限级别,不能直接执行特权指令,也不能任意访问内核地址空间或设备寄存器。
  • 内核态(kernel mode):内核代码运行的权限级别,可以管理页表、处理中断、访问设备和调度任务。

一个进程通常会在两种状态之间往返:

sequenceDiagram
    participant U as 用户态线程
    participant C as C 库/运行时
    participant CPU as CPU 硬件
    participant K as Linux 内核
    participant D as 设备

    U->>C: 调用 open/read/write
    C->>CPU: 执行系统调用入口指令
    CPU->>K: 保存用户上下文并切换到内核入口
    K->>K: 参数检查、权限检查、VFS/驱动处理
    K->>D: 访问设备或等待设备事件
    D-->>K: 硬件中断或轮询结果
    K-->>CPU: 返回值、恢复用户上下文
    CPU-->>U: 继续执行或返回 errno

这张图描述的是常见路径,但并非所有用户态服务都需要陷入内核。例如:

  • getpid() 的 C 库实现可能使用 vDSO 或缓存;
  • 用户态访问已经建立的内存映射时,CPU 直接访问页表;
  • 某些网络数据可以通过特殊机制绕过部分传统内核路径;
  • 内核也可能因为页缺失、非法指令或调试事件主动处理异常。

因此,“用户程序调用函数”不等于“必然执行系统调用”,“设备完成操作”也不等于“用户线程立即继续运行”。


二、处理器为什么需要权限等级

2.1 特权指令和受保护资源

如果任意应用都能执行以下操作,系统就无法可靠隔离进程:

  • 修改页表;
  • 禁用或配置中断;
  • 直接访问磁盘控制器;
  • 修改其他进程的内存;
  • 设置定时器或 CPU 控制寄存器;
  • 停止其他 CPU。

现代处理器因此提供权限级别、地址转换和异常入口机制。Linux 利用这些机制让普通应用运行在受限环境中,而把资源管理集中到内核。

在 x86-64 上,常见概念是:

  • Ring 3:用户态;
  • Ring 0:内核态。

在 arm64 上通常称为异常级别,例如:

  • EL0:用户态;
  • EL1:内核态。

Linux 的抽象不是“必须使用 Ring 0/Ring 3”这一具体硬件形式,而是要求处理器提供一种可靠方式,使低权限代码不能伪造高权限执行环境。不同架构的入口寄存器、返回指令和权限表示方式不同,但系统调用、异常和中断的语义可以被 Linux 统一起来。

2.2 权限切换不是进程切换

下面两个概念经常被混淆:

  1. 用户态到内核态切换:同一个线程继续执行,但处理器权限改变,内核开始处理请求。
  2. 任务切换(context switch):CPU 从一个线程切换到另一个线程,保存和恢复寄存器、栈、地址空间等上下文。

系统调用通常只要求第一种切换,不一定发生第二种切换。可是,如果系统调用需要等待磁盘、网络或锁,当前线程可能进入睡眠,调度器再把 CPU 分配给其他线程,于是两种切换会连续发生:

线程 A 用户态
  -> 系统调用
  -> 线程 A 内核态
  -> 等待 I/O,线程 A 睡眠
  -> 调度线程 B
  -> 线程 B 运行
  -> 设备中断到来,唤醒线程 A
  -> 调度线程 A
  -> 线程 A 返回用户态

系统调用本身不能保证“不发生调度”。例如 getpid() 通常很短,而阻塞式 read() 可能让调用线程长时间不运行。


三、系统调用:用户态请求内核服务的正式入口

3.1 系统调用的定义

**系统调用(system call)**是用户态代码请求内核执行受保护操作的接口。它具有几个关键特征:

  1. 用户态通过约定的机器指令进入内核;
  2. CPU 保存必要的用户执行上下文;
  3. 内核根据系统调用号分派到实现函数;
  4. 内核检查参数、身份和权限;
  5. 内核完成操作或让线程等待;
  6. 通过规定的返回路径回到用户态;
  7. 以返回值和错误码表示结果。

系统调用是 ABI 的一部分,但具体系统调用号、参数寄存器和入口指令依赖架构。例如:

  • x86-64 Linux 通常使用 syscall 指令;
  • arm64 Linux 通常使用 svc 指令;
  • 32 位架构可能使用不同的陷入指令;
  • 系统调用号在不同架构上不一定相同。

因此,直接写死系统调用号的程序可移植性明显低于使用标准 C 库接口的程序。

3.2 C 库函数与系统调用不是一回事

应用通常写:

int fd = open("data.txt", O_RDONLY);

这里至少存在三层可能的抽象:

应用代码
  -> libc 的 open() 包装函数
      -> openat/openat2 等实际系统调用
          -> Linux 内核

C 库可能:

  • 直接执行一个系统调用;
  • 把旧接口转换成新接口;
  • 在调用前处理线程取消、符号兼容或参数转换;
  • 通过 vDSO 避免真正陷入内核;
  • 在用户态维护缓冲区,例如 stdio 的 fread()

所以“调用了 read()”和“产生了一次名为 read 的系统调用”不能简单画等号。用 strace 观察的是进程实际进入内核的系统调用,而不是所有 C 函数调用。

3.3 系统调用的核心数据流

write(fd, buf, count) 为例:

用户态参数:
    fd       文件描述符整数
    buf      用户虚拟地址
    count    要写入的字节数

系统调用入口:
    读取寄存器中的系统调用号和参数

内核处理:
    检查 fd 是否属于当前进程
    从文件描述符表找到 struct file
    检查写权限和文件状态
    通过 VFS 调用具体文件系统或设备操作
    必要时从用户地址复制数据
    更新文件偏移或设备状态

返回:
    >= 0:实际写入的字节数
    -1:libc 设置 errno

这里最容易误解的是 count。它表示“希望写入的字节数”,不表示“内核必须一次写完”。返回值 n 满足:

0 <= n <= count

如果 n < count,原因可能包括:

  • 管道或 socket 当前只能接受部分数据;
  • 信号在操作中断;
  • 非阻塞文件描述符暂时无法继续;
  • 文件系统或设备只完成了部分操作;
  • 写入遭遇错误,但错误发生在部分数据已经提交之后。

因此可靠的用户态写循环不能假设一次 write() 完整完成。

3.4 可运行示例:观察一次文件系统调用路径

保存以下程序为 syscall_demo.c

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

static int write_all(int fd, const char *buf, size_t len)
{
    size_t off = 0;

    while (off < len) {
        ssize_t n = write(fd, buf + off, len - off);

        if (n > 0) {
            off += (size_t)n;
            continue;
        }

        if (n == -1 && errno == EINTR)
            continue;

        return -1;
    }

    return 0;
}

int main(void)
{
    const char message[] = "hello from user space\n";

    int fd = open("syscall-demo.txt",
                 O_WRONLY | O_CREAT | O_TRUNC,
                 0644);
    if (fd == -1) {
        perror("open");
        return EXIT_FAILURE;
    }

    if (write_all(fd, message, sizeof(message) - 1) == -1) {
        perror("write");
        close(fd);
        return EXIT_FAILURE;
    }

    if (close(fd) == -1) {
        perror("close");
        return EXIT_FAILURE;
    }

    return EXIT_SUCCESS;
}

编译并运行:

cc -Wall -Wextra -O2 syscall_demo.c -o syscall_demo
./syscall_demo
cat syscall-demo.txt

预期内容:

hello from user space

strace 观察:

strace -o trace.txt ./syscall_demo
grep -E 'open|write|close' trace.txt

不同发行版和 libc 版本可能出现类似但不完全相同的结果,例如:

openat(AT_FDCWD, "syscall-demo.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644) = 3
write(3, "hello from user space\n", 21) = 21
close(3) = 0

这里的 3 是文件描述符。AT_FDCWD 表示相对于当前工作目录解析路径。程序使用 open(),但 libc 可能选择 openat() 系统调用,这是接口实现细节,不应依赖 strace 中一定出现某个名称。

程序中 write_all() 的循环不是多余的:

  • write() 返回正数时,只能确认这些字节已经被接受;
  • 返回 -1errno == EINTR 时,系统调用被信号中断,可以重试;
  • 其他错误不能无条件重试;
  • 对普通文件,短写通常较少见,但对管道、socket 和设备文件必须认真处理。

close() 也可能失败。尤其在网络文件系统或延迟写入场景中,前面的 write() 成功并不意味着数据已经安全持久化。若程序需要持久化语义,通常还要考虑 fsync()、文件系统语义和存储设备的写缓存;这会增加延迟,不能用“调用了 close”替代。


四、系统调用入口内部发生了什么

系统调用入口可抽象为以下步骤:

1. 用户线程把系统调用号和参数放入约定的位置
2. 执行 syscall/svc 等入口指令
3. CPU 切换到内核权限并保存返回所需上下文
4. 内核入口代码建立内核栈和寄存器保存区
5. 检查入口来源、追踪点、审计和安全策略
6. 按系统调用号分派
7. 执行具体内核逻辑
8. 将内核结果转换为用户态可见的返回值
9. 处理待处理信号、抢占和调度
10. 恢复用户寄存器并返回用户态

第 9 步很重要。系统调用返回前,内核可能发现:

  • 当前线程有待处理信号;
  • 当前线程不再拥有 CPU;
  • 需要重新调度;
  • 发生了信号处理器、系统调用重启或退出;
  • 需要执行审计或追踪逻辑。

所以系统调用返回路径不是简单的“把一个整数放入寄存器然后返回”。

4.1 参数为什么必须验证

用户态传入的指针不能直接当作可信内核指针使用:

char *user_buf = ...;
write(fd, user_buf, len);

内核需要防范:

  • 指针指向不存在的用户地址;
  • 地址范围溢出;
  • 缓冲区跨越不可访问页;
  • 参数在检查后被用户线程并发修改;
  • 指针指向内核地址或不属于当前地址空间;
  • 传入长度导致整数溢出。

Linux 内核通常通过体系结构相关的用户内存访问机制和 copy_from_user()copy_to_user() 等接口访问用户地址。即便地址看起来有效,复制过程中也可能发生页故障,因此内核必须能够处理失败。

这也是一个重要边界:系统调用参数的验证不是一次“if 判断”就结束,而是整个操作过程都必须遵守用户内存访问规则。

4.2 返回值与 errno

Linux 内核内部通常用负的错误码表示失败,例如 -EFAULT-EACCES-ENOENT。用户态 libc 包装后通常表现为:

函数返回 -1
errno 被设置为对应错误码

例如:

int fd = open("/root/private-file", O_RDONLY);
if (fd == -1) {
    fprintf(stderr, "open failed: %s\n", strerror(errno));
}

需要区分:

  • errno 是线程局部的错误状态;
  • 只有在函数明确返回错误时才应读取 errno
  • 成功调用后 errno 可能保留旧值;
  • EINTR 表示被信号中断,不等于所有操作都能安全重试;
  • 对有副作用的系统调用,重试可能造成重复操作。

例如:

send(fd, buf, len, 0)

如果它返回部分成功,再从原始起点重试,可能导致数据重复。重试必须根据返回的字节数推进缓冲区。


五、异常:由当前执行流同步触发的处理路径

5.1 异常的定义

**异常(exception)**是处理器在执行当前指令或检查当前执行状态时发现特殊情况,并转入内核或其他特权处理程序的事件。

异常通常具有同步性:同一条指令、同一组寄存器和同一执行位置,在相同条件下会触发相同类型的异常。常见异常包括:

  • 缺页(page fault);
  • 除零;
  • 非法指令;
  • 一般保护错误;
  • 调试异常;
  • 断点;
  • 系统调用入口指令引发的陷入。

“同步”不代表一定没有并发影响。其他 CPU、内核线程或设备仍可能同时改变系统状态;同步性主要指异常与当前指令流存在直接因果关系。

5.2 fault、trap 和 abort

不同架构对异常分类略有差异,但通常可以用以下直觉理解:

  • fault:当前指令可能在修复后重新执行,例如缺页异常;
  • trap:当前指令已经完成或故意进入处理程序,例如调试断点;
  • abort:严重错误,通常无法可靠恢复当前执行流。

这些术语是处理器架构概念,Linux 对外呈现的结果还取决于异常发生位置:

  • 用户态非法访问通常转化为 SIGSEGVSIGBUS
  • 内核态非法访问可能触发 oops;
  • 无法恢复的内核错误可能导致 panic;
  • 缺页可能被内核透明修复,用户完全感知不到异常。

5.3 缺页异常的完整算例

假设一个进程执行:

char *p = malloc(4096);
p[0] = 'A';

不能简单地说 malloc() 已经分配了一个物理页。现代 Linux 中,匿名内存通常存在延迟分配和按需分配过程。一个可能的路径是:

malloc()
  -> libc 从堆管理器取得虚拟地址
  -> 该地址可能尚未映射到实际物理页

p[0] = 'A'
  -> CPU 查询当前页表
  -> 页表项不存在或权限不满足
  -> 触发 page fault
  -> CPU 进入内核异常入口
  -> 内核判断地址属于该进程合法匿名区域
  -> 分配物理页并清零
  -> 建立用户页表映射
  -> 返回用户态
  -> 重新执行 p[0] = 'A'

形式化地说,若虚拟地址为 v,页大小为 P,则:

页号 vpn = floor(v / P)
页内偏移 offset = v mod P

页表负责把 vpn 映射到物理页框号 pfn。若当前页表中没有有效映射,CPU 触发缺页异常。内核随后需要判断:

  1. v 是否落在进程合法的虚拟内存区域;
  2. 当前访问类型是否允许,例如读、写或执行;
  3. 是否需要分配匿名页、加载文件页或执行写时复制;
  4. 是否有足够的内存;
  5. 是否应该向进程发送信号。

如果 v 不属于任何合法区域,例如:

int *p = NULL;
*p = 1;

内核无法修复该访问,通常会向进程发送 SIGSEGV。如果地址在物理访问上存在但不满足对齐或总线要求,也可能得到 SIGBUS,具体取决于架构和映射类型。

5.4 写时复制是另一个可修复异常

调用 fork() 后,父子进程通常不会立即复制全部物理内存。内核可以让父子进程暂时共享只读物理页:

fork()
  -> 父子页表都指向同一物理页
  -> 相关页表项被标记为只读或 COW

子进程写入共享页
  -> CPU 发现写权限不足
  -> 触发 page fault
  -> 内核分配新物理页
  -> 复制旧页内容
  -> 修改子进程页表,使其指向新页
  -> 重新执行写入

这说明“页错误”不总是错误。它可能是虚拟内存正常工作的组成部分。


六、中断:由外部事件异步通知 CPU

6.1 中断的定义

**中断(interrupt)**通常指外部硬件或处理器平台向 CPU 发出的异步事件,例如:

  • 网卡收到数据;
  • NVMe 请求完成;
  • 定时器到期;
  • 键盘或 GPIO 状态变化;
  • 多处理器系统中一个 CPU 请求另一个 CPU 执行某项操作。

中断与当前用户指令通常没有直接的同步因果关系。CPU 可能正在执行任意用户代码,中断却在下一时机到达。

现代系统中,设备通常通过 APIC、GIC 等中断控制器向某个 CPU 发送中断。设备驱动负责注册中断处理函数,并通过内核接口配置中断资源。

6.2 中断上下文和进程上下文

Linux 处理设备事件时,要区分两种执行环境:

  • 进程上下文:由某个线程代表,可以睡眠,可以访问当前进程地址空间,通常允许执行可能阻塞的操作。
  • 中断上下文:由硬件中断触发,不代表某个可睡眠的用户线程,不能随意睡眠,也不能执行需要长期等待的操作。

典型中断处理被拆成两部分:

硬件中断
  -> 顶半部(top half)
      快速确认设备状态
      读取必要的硬件信息
      清除或屏蔽中断源
      记录事件
  -> 软中断 / tasklet / 工作队列 / threaded IRQ
      进行较重的处理
      可能睡眠的工作放入进程上下文

具体机制随内核子系统和驱动实现而变化。现代驱动常使用 threaded IRQ、工作队列、NAPI 等机制,避免在硬中断路径执行过多工作。

6.3 中断如何唤醒阻塞的系统调用

以阻塞式网络读取为例:

线程调用 read(socket)
  -> socket 接收队列为空
  -> 内核把线程放入等待队列
  -> 线程状态变为可中断睡眠或不可中断睡眠
  -> 调度其他线程

网卡收到数据
  -> 网卡发出硬件中断
  -> 驱动处理中断并取出数据
  -> 数据放入 socket 接收队列
  -> 唤醒等待队列中的线程

线程重新获得 CPU
  -> read() 发现数据可读
  -> 复制数据到用户缓冲区
  -> 返回用户态

如果等待期间收到信号,系统调用可能返回 -1 并设置 errno = EINTR,也可能根据系统调用和 SA_RESTART 等条件自动重启。不能把所有阻塞调用都假设为“信号后自动继续”。

6.4 中断不是轮询

轮询(polling)是软件主动反复检查设备状态:

while (!device_ready())
    ;

中断则由设备在状态变化时通知 CPU。两者取舍不同:

  • 中断通常减少空转,但每次中断有入口、保存上下文和调度成本;
  • 高频网络包可能导致中断风暴;
  • 网卡常用 NAPI,在高负载时暂时关闭逐包中断并批量轮询;
  • 实时系统可能为了确定性选择轮询或固定预算处理。

因此“中断一定比轮询快”并不是规范保证,而是依赖事件频率、设备、驱动和负载。


七、系统调用、异常和中断的关系与区别

三者都可能让 CPU 从普通执行流进入内核,但触发原因不同:

类型 触发来源 与当前指令关系 常见例子 是否可能修复后重试
系统调用 用户程序主动请求 同步、故意 read()mmap() 通常不是按原指令重试
异常 CPU 执行或检查时发现问题 同步 缺页、除零、非法指令 缺页等可以
中断 外部设备或定时器 异步 网卡、磁盘、时钟 通常返回被打断的执行流

系统调用在硬件层面通常也通过一种同步异常或陷入机制进入内核,但在 Linux API 层面,它被单独视为“主动请求内核服务”的入口。

一个反例是:

read(fd, buf, len);

如果 fd 是普通文件,执行过程中可能触发缺页异常;如果数据来自设备,设备完成还可能产生硬件中断。一次系统调用可以同时经历多个低层事件:

系统调用入口
  -> 访问用户缓冲区时触发缺页
  -> 等待设备
  -> 设备中断唤醒线程
  -> 复制结果
  -> 返回用户态

所以三者不是互斥的三条流水线,而是不同层次的事件分类。


八、硬件抽象:应用为什么不需要知道磁盘控制器寄存器

8.1 抽象层次

Linux 用多层内核子系统把硬件差异隔离起来。以存储设备为例:

应用
  -> read()/write()
  -> libc
  -> VFS
  -> 具体文件系统 ext4/xfs/btrfs/...
  -> 块层
  -> I/O 调度和请求队列
  -> SCSI/NVMe/virtio 等协议层
  -> 设备驱动
  -> PCIe、MMIO、中断
  -> 真实硬件或虚拟设备

以网络为例:

应用
  -> socket()/send()/recv()
  -> socket 层
  -> TCP/UDP
  -> IP
  -> qdisc/网络设备层
  -> 网卡驱动
  -> DMA 描述符和硬件队列
  -> 网卡

这些层次并不是每次操作都完整经过同样的路径。例如:

  • sendfile() 可以减少用户态和内核态之间的数据复制;
  • mmap() 让用户态直接访问文件页映射;
  • io_uring 改变了提交和完成请求的方式;
  • 虚拟机中的 virtio 通过虚拟设备与宿主机协作;
  • 页缓存可能让一次 read() 根本不访问物理磁盘。

8.2 “文件”是统一抽象,不是所有设备都是磁盘

Linux 将很多对象表示为文件描述符:

  • 普通文件;
  • 目录;
  • 管道;
  • socket;
  • 终端;
  • 字符设备;
  • 块设备;
  • epoll 实例;
  • eventfd、timerfd 等内核对象。

文件描述符只是当前进程文件描述符表中的一个整数索引。它通常指向内核对象:

进程 fd 表
    3 ─────> struct file
                  ├── 当前偏移
                  ├── 打开标志
                  └── file_operations
                                  └── 具体文件系统或驱动方法

因此:

read(fd, buf, len);

同一个系统调用可以最终调用:

  • 普通文件系统的读取逻辑;
  • 管道读取逻辑;
  • socket 接收逻辑;
  • 字符设备驱动的 read 方法;
  • 一个返回特殊语义的伪文件实现。

统一接口降低了应用与硬件的耦合,但不意味着所有对象具有相同语义。比如:

  • 普通文件通常有偏移;
  • socket 没有普通文件意义上的持久偏移;
  • 管道是字节流且容量有限;
  • 终端可能受行规程影响;
  • 设备文件的 read() 语义由驱动定义。

8.3 设备驱动承担什么职责

设备驱动是内核中理解特定设备或协议的代码,典型职责包括:

  1. 发现和初始化设备;
  2. 分配 DMA 缓冲区和队列;
  3. 配置设备寄存器;
  4. 提交 I/O 请求;
  5. 响应中断或轮询完成队列;
  6. 处理超时、复位和错误;
  7. 向上层提供标准内核接口;
  8. 向用户态暴露受控的设备文件、sysfs、netlink 或 ioctl 接口。

驱动不会把硬件寄存器直接暴露给普通应用作为默认接口。用户态如果需要设备专有能力,通常通过:

  • /dev/... 设备文件;
  • ioctl()
  • mmap()
  • sysfs;
  • netlink;
  • 专用库。

这些接口的安全性取决于驱动实现。ioctl() 并不是天然安全或可移植的通用接口,其命令号、结构体布局、权限要求和行为通常由具体驱动定义。


九、虚拟内存是硬件抽象的基础

9.1 虚拟地址与物理地址

用户程序使用的是虚拟地址。CPU 的内存管理单元(MMU)根据页表把虚拟地址转换为物理地址:

虚拟地址
  -> 页表遍历
  -> 物理页框 + 页内偏移
  -> CPU cache / 内存控制器
  -> 物理内存

如果虚拟地址 v 的页大小为 P

vpn    = floor(v / P)
offset = v mod P

页表通过 vpn 查找物理页框 pfn,最终物理地址可以表示为:

physical_address = pfn * P + offset

页表项除了保存物理页框,还保存权限和状态,例如:

  • 是否存在;
  • 是否可读、可写、可执行;
  • 是否属于用户或内核;
  • 是否被访问或修改;
  • 是否可缓存。

9.2 用户态为什么不能访问内核内存

典型页表会将内核映射到每个进程的地址空间中,但页表项带有内核权限标记。用户态访问内核地址时,CPU 检查权限失败并触发异常。

这比“完全没有内核地址映射”更高效,但也要求内核和硬件提供严格隔离。现代系统还会使用 KPTI 等机制减轻特定侧信道风险;具体启用方式和性能影响随架构、内核配置和处理器而变化。

即使用户态拥有 root 权限,也不能因为身份是 root 就直接执行任意 CPU 特权指令。root 是 Linux 权限模型中的高权限身份,仍然通过系统调用、能力(capabilities)、设备驱动和内核策略执行操作,而不是直接获得 Ring 0 或 EL1。

9.3 共享内存不是取消权限边界

mmap()、共享内存和 DMA 可以减少复制,但不能简单理解为“用户态直接拥有物理内存”:

  • 映射仍由内核建立;
  • 页表权限仍然决定能否读写执行;
  • 设备 DMA 还受到 IOMMU 和驱动配置影响;
  • 错误的共享映射可能造成数据竞争或信息泄露;
  • 设备驱动把硬件寄存器映射给用户态时,必须严格限制范围和权限。

性能优化改变的是数据路径,不会自动消除隔离责任。


十、从系统调用到设备完成:一个阻塞 I/O 的状态变化

考虑线程执行:

ssize_t n = read(fd, buf, 4096);

其中 fd 是一个暂时没有数据的 socket。状态变化可以写成:

运行态
  -> 用户态执行 read 包装函数
  -> 内核态处理 read
  -> 检查 socket 接收队列为空
  -> 阻塞模式下将线程加入等待队列
  -> 可运行态之外的睡眠状态
  -> 调度器选择其他线程

设备收到数据
  -> 网卡中断
  -> 驱动处理接收队列
  -> 网络栈构造 sk_buff
  -> 放入 socket 接收队列
  -> 唤醒等待线程

线程变为可运行
  -> 获得 CPU
  -> 重新检查条件
  -> 把数据复制到 buf
  -> 返回实际字节数
  -> 用户态继续执行

“唤醒”只表示线程重新进入可运行队列,不表示它立即执行。CPU 可能正在运行其他线程,调度器还要考虑优先级、CPU 亲和性、实时策略和抢占状态。

内核通常会在唤醒后重新检查条件,而不是假设条件一定成立。这是因为:

  • 多个线程可能竞争同一个数据;
  • 另一个线程可能先取走数据;
  • 虚假唤醒或并发状态变化可能发生;
  • 中断处理与线程处理之间存在竞态。

因此,内核等待代码常表现为“检查条件—睡眠—被唤醒—再次检查”的循环。这种模式也影响用户态条件变量和事件驱动程序的正确写法。


十一、信号与异常、中断的边界

信号是 Linux 面向进程或线程的异步通知机制,但它不等于硬件中断,也不等于 CPU 异常。

11.1 三者的因果链可能不同

例如除零:

用户态执行除零指令
  -> CPU 异常
  -> 内核异常处理
  -> 向当前线程发送 SIGFPE
  -> 用户态信号处理器运行,或进程终止

例如用户按下终端的 Ctrl-C:

键盘硬件事件
  -> 驱动和终端子系统
  -> 识别控制字符
  -> 向前台进程组发送 SIGINT
  -> 进程执行处理器或退出

例如定时器:

硬件定时器中断
  -> 内核更新时间
  -> 到期的 POSIX timer 产生信号或唤醒等待者

CPU 异常、中断和信号分别位于不同层次:

硬件/CPU事件
  -> 内核处理
  -> Linux 对进程暴露信号、返回值或状态变化

11.2 系统调用被信号中断

一个阻塞系统调用可能出现:

read()
  -> 线程睡眠
  -> 收到 SIGTERM
  -> 信号处理器运行
  -> read() 返回 EINTR,或被内核重启

是否自动重启依赖系统调用、信号处理器安装方式和具体 libc/内核行为。应用应根据接口语义处理 EINTR,不能统一采用“所有错误都重试”。

如果程序正在等待退出,常见模式是设置一个 volatile sig_atomic_t 标志:

#include <signal.h>
#include <stdio.h>
#include <unistd.h>

static volatile sig_atomic_t stop = 0;

static void on_term(int signo)
{
    (void)signo;
    stop = 1;
}

int main(void)
{
    struct sigaction sa = {0};
    sa.sa_handler = on_term;
    sigemptyset(&sa.sa_mask);

    if (sigaction(SIGTERM, &sa, NULL) == -1)
        return 1;

    while (!stop) {
        /* 实际程序应使用 poll/ppoll 等可唤醒等待机制 */
        pause();
    }

    return 0;
}

信号处理器中能安全调用的函数非常有限。printf()malloc() 等函数可能不是异步信号安全的,在信号处理器中调用会产生死锁或内部状态损坏风险。上例只修改 sig_atomic_t,把复杂清理工作留给主循环。

更复杂的服务通常使用 signalfdeventfdppoll 等机制,把事件纳入统一的文件描述符事件循环,但这些机制仍然通过系统调用实现,并且需要正确处理关闭、超时和并发。


十二、异常和中断在内核中的故障路径

12.1 用户态非法访问

用户态访问无效地址时,典型路径是:

CPU page fault
  -> 内核检查地址和访问权限
  -> 地址不合法或权限不匹配
  -> 生成 SIGSEGV/SIGBUS
  -> 若无可用处理器,终止进程
  -> 生成 core dump(若策略允许)

诊断:

ulimit -c
cat /proc/sys/kernel/core_pattern
dmesg | tail -n 30

在 systemd 系统上还可以使用:

coredumpctl list
coredumpctl info <PID-or-executable>

发行版可能禁用 core dump、限制文件大小,或者将 core 文件交给 systemd-coredump、容器运行时或其他收集器。没有 core 文件不代表没有发生段错误。

12.2 内核 oops 与 panic

内核态代码遇到无法安全恢复的问题,可能输出 oops。oops 后系统有时还能继续运行,但损坏的内核状态可能导致后续故障。若错误严重或配置要求,系统会 panic。

生产环境中不应随意执行会触发异常的内核测试命令。读取 dmesg 也可能受 kernel.dmesg_restrict 限制:

dmesg | tail
journalctl -k -b

需要区分:

  • dmesg:当前内核环形缓冲区内容;
  • journalctl -k:systemd journal 收集的内核日志,是否完整取决于日志配置;
  • 重启后,旧日志可能已轮转或丢失;
  • 访问内核日志可能泄露地址和硬件信息,生产环境应按权限控制。

12.3 中断风暴和设备异常

设备或驱动异常可能表现为:

  • 某个 IRQ 占用大量 CPU;
  • 网络吞吐下降但软中断持续升高;
  • I/O 请求超时;
  • 内核日志出现 reset、timeout、AER 或 IOMMU 错误;
  • 线程长期处于 D 状态。

可以观察:

cat /proc/interrupts
cat /proc/softirqs
cat /proc/pressure/io
ps -eo pid,stat,wchan:32,comm

这些输出是诊断证据,不是单独的结论:

  • /proc/interrupts 的计数增长表示 CPU 收到中断,但不直接表示设备故障;
  • D 通常表示不可中断睡眠,常见于等待 I/O,但具体原因要结合 wchan、内核日志和设备状态;
  • 高软中断可能来自网络、定时器或块层;
  • PSI I/O 压力反映任务因资源竞争受到的延迟,不等于某个设备已经损坏。

重置设备、卸载驱动或写入 /sys 控制接口都可能中断业务,必须先确认接口语义、权限和恢复方式。很多服务器设备只能通过重启、链路重置或厂商工具恢复,不能把诊断命令当成无风险操作。


十三、如何验证“是否真的发生了系统调用”

13.1 strace

strace -f -tt -T -e trace=file,read,write ./syscall_demo

参数含义:

  • -f:跟踪子进程和线程;
  • -tt:显示更精确时间;
  • -T:显示系统调用耗时;
  • -e trace=...:筛选系统调用类别。

它能观察用户进程进入内核的接口、参数和返回值,但不能直接告诉你:

  • 内核内部经过了哪些函数;
  • 是否访问了真实磁盘;
  • 设备中断何时发生;
  • CPU 是否因为缺页而停顿;
  • 系统调用耗时是否来自锁竞争、调度还是 I/O。

例如 read() 返回成功,只说明内核向调用者交付了数据;数据可能来自页缓存,并不代表发生了磁盘读取。

13.2 /proc 和调度观察

cat /proc/<pid>/status
cat /proc/<pid>/maps
cat /proc/<pid>/syscall
cat /proc/<pid>/fd

这些文件可以帮助观察:

  • 进程当前状态;
  • 虚拟内存区域;
  • 某些架构下当前系统调用信息;
  • 文件描述符指向的对象。

/proc/<pid>/maps 展示的是虚拟地址区域,不等于每个区域都已经分配物理页。要分析实际驻留页,需要结合 smaps、缺页计数和内存压力信息。

13.3 性能工具的边界

常用工具包括:

perf stat -e cycles,instructions,context-switches,cpu-migrations \
    ./syscall_demo

perf trace ./syscall_demo

工具可用性依赖内核配置、权限和发行版安全策略。perf_event_paranoid、容器权限、内核符号和 BPF 限制都可能影响结果。

性能分析需要先定义测量对象:

  • 系统调用次数多,不必然意味着程序慢;
  • 系统调用耗时长,不必然是入口切换成本,可能是 I/O 等待;
  • 上下文切换多,不必然是系统调用造成,锁竞争和唤醒也会导致切换;
  • CPU 周期增加,可能来自页错误、缓存未命中、分支预测失败或中断处理。

十四、常见误解与反例

误解一:每个库函数都会进入内核

反例:

strlen(s);
memcpy(dst, src, n);

这些通常是用户态执行的普通代码。即使函数内部偶尔触发缺页,也不是因为它主动调用了系统调用。

相反,clock_gettime() 在某些时钟类型和平台上可能通过 vDSO 在用户态完成,而不是每次都进入内核。不能仅凭函数名称判断是否发生系统调用。

误解二:进入内核态就一定发生进程切换

系统调用可以在同一个线程中完成并返回。只有当内核阻塞、主动调度、被抢占或发生其他调度条件时,才会切换到其他线程。

误解三:root 可以直接访问所有硬件

root 仍然受到:

  • 内核接口;
  • capabilities;
  • LSM;
  • seccomp;
  • 容器 namespace;
  • 设备节点权限;
  • 驱动策略;
  • IOMMU 和硬件限制

的共同影响。root 可以获得更高权限,但不是跳过内核安全边界。

误解四:write() 成功就表示数据已落盘

write() 通常表示数据已被内核接受,可能进入页缓存或设备队列。要获得更强的持久化保证,需要根据存储系统语义使用 fsync()fdatasync()、同步打开标志,并确认硬件写缓存和文件系统行为。

误解五:中断发生时用户线程立即执行

中断处理程序先在内核中运行。它可能唤醒线程,但线程要等调度器再次选择它,才能继续执行。

误解六:所有页错误都是程序 bug

匿名内存首次访问、文件映射首次访问和写时复制都可能产生可恢复页错误。真正的 bug 通常是内核判断该地址或权限不合法后产生的 SIGSEGVSIGBUS,或者内核代码无法恢复而触发 oops。

误解七:系统调用参数只要指针不为 NULL 就合法

指针可能:

  • 指向未映射内存;
  • 只映射了部分范围;
  • 指向不可写区域;
  • 在并发线程中被修改;
  • 与长度相加发生溢出。

内核必须对地址范围和访问过程进行完整验证。


十五、架构、版本和部署边界

15.1 架构差异

以下内容是 Linux 统一抽象,但底层实现可能不同:

  • 系统调用入口指令;
  • 系统调用号;
  • 参数寄存器;
  • 用户态返回路径;
  • 异常向量表;
  • 中断控制器;
  • 页表层级;
  • 内存屏障和缓存一致性;
  • DMA 与 IOMMU 行为。

x86-64、arm64、riscv 等架构不能共享所有底层寄存器和汇编代码。编写需要直接使用系统调用或内核接口的程序时,应优先使用 libc、架构无关的 API 和内核文档明确规定的 ABI。

15.2 内核配置和发行版差异

现代主流发行版可能存在以下差异:

  • 是否启用 seccomp、BPF、LSM;
  • 是否使用 systemd-coredump;
  • 是否允许普通用户使用 perf
  • 是否启用内核抢占、实时补丁或不同调度配置;
  • /dev 设备节点由 udev、容器运行时或静态配置管理;
  • 内核模块、驱动和固件包由不同包管理系统提供;
  • 内核日志访问权限不同。

因此,本文命令中的输出只能作为典型示例,不能当作所有发行版的固定输出。

15.3 容器中的“硬件访问”

容器中的进程仍然运行在宿主机内核上,并没有自己的内核。容器隔离主要来自:

  • namespace;
  • cgroup;
  • capabilities;
  • seccomp;
  • LSM;
  • 设备控制器和 /dev 映射。

容器内看到的 CPU、内存和设备可能是宿主机经过虚拟化或限制后的视图。把宿主机设备节点映射到容器,或者授予 CAP_SYS_ADMIN 等高权限,都会显著扩大故障和逃逸风险,不能仅因为调试方便就长期保留。


十六、把四个主题放回 Linux 启动和运行时

启动链可以简化为:

Firmware
  -> Bootloader
  -> Linux kernel
  -> initramfs
  -> 根文件系统
  -> PID 1(常见为 systemd)
  -> 服务和用户进程

在内核完成早期初始化、建立内存管理和中断处理后,用户态才有可能运行。PID 1 及其启动的服务全部通过系统调用使用内核资源:

  • 创建进程和线程;
  • 建立文件描述符;
  • 加载 ELF;
  • 配置网络;
  • 挂载文件系统;
  • 访问设备;
  • 接收信号;
  • 等待子进程状态。

内核启动阶段本身也会初始化:

  • 异常入口;
  • 中断控制器;
  • 页表和内存管理;
  • 调度器;
  • VFS 和块层;
  • 驱动模型;
  • 时间子系统。

因此,系统调用、异常、中断和硬件抽象不是运行时互不相关的模块,而是从启动完成后一直协作:

启动时初始化硬件抽象
  -> 驱动注册设备
  -> 内核建立系统调用和异常入口
  -> PID 1 启动用户态服务
  -> 服务通过系统调用请求资源
  -> 设备通过中断报告异步结果
  -> 内核通过返回值、阻塞唤醒和信号通知用户态

十七、工程上应如何判断一个问题属于哪一层

遇到“程序卡住、变慢、崩溃或读不到设备”时,可以先按因果层次拆分:

程序是否进入了内核

使用:

strace -f -tt -T -p <pid>

观察是否长期停在某个系统调用,例如 futex()poll()read()openat()

是系统调用本身失败,还是参数错误

检查:

  • 返回值;
  • errno
  • 指针和长度;
  • 文件描述符生命周期;
  • 权限和 namespace;
  • 是否被信号中断。

是线程睡眠,还是 CPU 忙等

观察:

ps -o pid,stat,wchan:32,comm -p <pid>
top -H -p <pid>

睡眠通常说明线程等待条件;高 CPU 则可能是忙循环、锁自旋、软中断或计算负载。

是内存异常,还是设备异常

分别检查:

dmesg | tail -n 100
cat /proc/interrupts
cat /proc/softirqs
cat /proc/<pid>/status

用户态崩溃更可能涉及非法地址、竞态或 ABI 误用;内核日志中的 page fault、I/O timeout、reset 和 firmware error 则可能指向不同路径。

是否误把抽象层当成硬件事实

例如:

  • read() 成功不代表磁盘刚刚转动;
  • /proc/meminfo 中的可用内存不等于所有内存都空闲;
  • 网卡收到数据不代表应用线程已经被调度;
  • open("/dev/...") 成功不代表设备已完成初始化;
  • mmap() 成功不代表映射的每个页都已驻留。

诊断必须沿数据流继续向下验证,而不能根据单个 API 名称直接推断硬件动作。


结语:抽象的价值在于隐藏差异,而不是隐藏因果

Linux 让应用通过文件描述符、虚拟内存、socket、进程和系统调用使用资源,而不必直接理解每种磁盘、网卡和中断控制器的寄存器布局。这个硬件抽象由多个层次共同实现:

用户态 API
  -> 系统调用 ABI
  -> 内核子系统
  -> 文件系统/网络协议/调度器
  -> 驱动
  -> 中断、DMA、MMU 和设备

系统调用是用户态主动进入内核的受控入口;异常是当前执行流同步触发的 CPU 事件;中断是外部设备异步通知 CPU 的机制;硬件抽象则把这些底层机制组织成稳定的内核接口。

理解它们之间的关系后,可以准确解释几个关键事实:

  • 一次用户态函数调用未必产生系统调用;
  • 一次系统调用可能触发缺页、阻塞、调度和设备中断;
  • 一个中断可能只唤醒线程,线程仍需等待调度;
  • 一个页错误可能是正常的按需分配,也可能是致命的非法访问;
  • 一个成功的 I/O 返回值只描述该接口的语义,不自动等于物理设备已经完成最终持久化;
  • root、容器和设备节点权限都不能替代内核的硬件保护机制。

这些边界是排查 Linux 性能、崩溃、I/O 和设备问题时最重要的基础。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。