进阶 #os#kernel#syscall

系统调用与内核态/用户态

系统调用(System Call)是用户程序请求操作系统服务的"正式通道"——你写的程序不能直接碰硬件,必须通过系统调用请内核代劳

为什么程序不能想干嘛就干嘛?

假设你在宿舍里,室友的柜子是上锁的——你要拿他的东西必须请他本人来开。操作系统也是一样:

  • 用户态(User Mode):你(普通程序)的活动范围——不能直接操作硬件
  • 内核态(Kernel Mode):操作系统内核的活动范围——可以访问所有硬件和内存

系统调用(System Call) 就是”请内核帮忙开门”的正式请求流程。

类比:学校行政大厅

你(普通程序)
  │  "我要办成绩单!"

窗口(系统调用接口)
  │  "好的,我帮你转给内部"

办公室(操作系统内核)
  │  核实身份、调取数据、打印盖章

你拿到成绩单

你不需要知道办公室里面怎么运作——你只需要在正确的窗口填正确的表。

用户态 vs 内核态

为什么需要隔离?

场景用户态(允许吗)内核态(允许吗)
执行普通运算(加法、比较)
读写自己的内存
读写别人的内存
读写硬盘文件
发送网络数据
关闭计算机
修改系统时钟

💡 普通程序在用户态几乎什么”危险”事都干不了——这就是为什么病毒很难直接破坏硬件。

硬件如何实现隔离?

CPU 有一个特权级别(Privilege Level) 寄存器:

x86 架构的保护环(Protection Rings):

┌──────────────────────┐
│  Ring 0(内核态)     │ ← 操作系统内核
│  ┌────────────────┐  │
│  │ Ring 3(用户态)│  │ ← 普通程序
│  └────────────────┘  │
└──────────────────────┘
  • Ring 0:可以执行特权指令(修改页表、开关中断、访问所有内存)
  • Ring 3:执行非特权指令,碰特权指令就崩溃(或触发异常)

💡 x86 还定义了 Ring 1 和 2,但主流操作系统只用 0 和 3——要么最高权限,要么最低。

系统调用的完整流程

当你调用 printf("Hello") 时,背后发生了什么?

用户程序(用户态)         操作系统(内核态)
┌─────────────┐         ┌─────────────────┐
│ printf("Hello") │         │                 │
│   ↓              │         │                 │
│ 调用 write()    │  ───→  │ sys_write()      │
│ 触发中断/      │  ①     │ 解析参数          │
│ 执行 syscall   │  syscall │ 驱动终端显示      │
│                 │  ←───  │ 返回结果          │
│ printf 返回     │  ②     │                 │
│ 继续执行        │  返回  │                 │
└─────────────┘         └─────────────────┘

详细步骤

步骤 1:准备参数
  用户程序把参数放入特定寄存器
  mov  rdi, 1        ; 文件描述符:1=stdout
  mov  rsi, msg      ; 缓冲区地址
  mov  rdx, 13       ; 要写入的字节数

步骤 2:触发系统调用
  mov  rax, 1        ; 系统调用号:1=sys_write
  syscall            ; 切换到内核态!

步骤 3:内核处理
  CPU 切换到 Ring 0
  根据 rax=1 找到 sys_write 函数
  验证参数合法性
  执行实际写入操作

步骤 4:返回用户态
  恢复用户态寄存器
  回到用户程序的下一条指令

系统调用号

每个系统调用有一个唯一编号:

# Linux 系统调用号(x86-64)
0   read        # 读文件
1   write       # 写文件
2   open        # 打开文件
3   close       # 关闭文件
9   mmap        # 内存映射
57  fork        # 创建进程
59  execve      # 执行程序
60  exit        # 退出进程

💡 不同的操作系统(Linux、Windows、macOS)有不同的系统调用号和约定。所以 Windows 程序不能直接在 Linux 上运行——它们用的是不同的”暗号”。

系统调用 vs 普通函数调用

对比普通函数调用系统调用
权限不切换特权级用户态→内核态
开销低(几纳秒)较高(几十纳秒到微秒)
风险调用者和被调用者同级内核可以拒绝请求
参数验证不验证内核必须验证每个参数

💡 系统调用的额外开销主要来自:切换特权级、保存/恢复寄存器、参数合法性检查、TLB 刷新。这就是为什么频繁的系统调用(比如每次读 1 字节)性能很差——应该用缓冲区批量操作。

常见系统调用举例

文件操作

#include <fcntl.h>
#include <unistd.h>

int main() {
    // open() → 系统调用
    int fd = open("hello.txt", O_CREAT | O_WRONLY, 0644);
    
    // write() → 系统调用
    write(fd, "Hello, OS!", 10);
    
    // close() → 系统调用
    close(fd);
    return 0;
}

strace 查看程序的实际系统调用:

$ strace -c ./myprogram
% time     seconds  usecs/call     calls    errors  syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         1           execve
  0.00    0.000000           0         4           read
  0.00    0.000000           0         1           write
  0.00    0.000000           0         5           open
  0.00    0.000000           0         5           close
  0.00    0.000000           0         7           mmap
  0.00    0.000000           0         3           fstat
------ ----------- ----------- --------- --------- ----------------

一个简单的 C 程序背后可能调用了几十次系统调用——这就是为什么 Hello World 看似简单,但操作系统在背后做了大量工作。

系统调用的封装:标准库

你很少直接调用系统调用——而是通过 标准库(Standard Library)

// 你写的 C 代码
printf("Hello");   // ← 标准库函数(libc)

// libc 内部
printf → fwrite → write() → syscall 指令 → 内核
                  ↑            ↑
            libc 封装    真正的系统调用

标准库的好处:

  1. 可移植性:Windows 和 Linux 的 printf 用法一样,但底层系统调用不同
  2. 缓冲printf 先把数据写入缓冲区,满了才调用 write——减少系统调用次数
  3. 易用性:比直接编写系统调用参数简单得多

实际体验:用 strace 观察系统调用

你的 Linux 系统上可以用 strace 实时观察任意程序的系统调用:

# 观察 ls 命令的系统调用
$ strace -c ls

% time    calls   errors  syscall
 32.15    0.0002    5           openat
 18.44    0.0001    5           read
 15.23    0.0001    5           close
 10.25    0.0001    4           fstat
  8.22    0.0001    1           write
  ...      ...      ...         ...

# 观察 "hello world" 用了哪些系统调用
$ strace ./hello
execve("./hello", ...) = 0
brk(NULL)               = 0x555555...
write(1, "Hello World\n", 12) = 12    ← 关键!输出一行用了 1 次 write
exit_group(0)           = ?

write(1, "Hello World\n", 12) 这行就是”用户态 → 内核态”的切换点——printf 最终调用 write 系统调用来真正输出到屏幕。strace 是调试”程序怎么和系统交互”的最有力工具。

小结

概念要点
用户态普通程序的运行级别,不能操作硬件
内核态操作系统的运行级别,可以访问所有资源
系统调用用户程序请求内核服务的”正式渠道”
syscall 指令触发从用户态到内核态的切换
系统调用号每个系统调用的唯一编号
标准库封装系统调用,提供更方便、可移植的 API

为什么先学这个? 系统调用是用户程序和操作系统之间的边界——后续所有 OS 概念(进程、内存、文件)都以系统调用为入口。接下来看看进程的概念——操作系统如何管理”正在运行的程序”。