操作系统第四讲:OS Interfaces and Syscalls(操作系统接口与系统调用)

操作系统第四讲:OS Interfaces and Syscalls(操作系统接口与系统调用)

目录

  • [操作系统第四讲:OS Interfaces and Syscalls(操作系统接口与系统调用)](#操作系统第四讲:OS Interfaces and Syscalls(操作系统接口与系统调用))
    • 写在前面
    • 一、先复习:用户态如何进入内核态
      • [1. IDT、GDT/LDT 和 Gate](#1. IDT、GDT/LDT 和 Gate)
      • [2. 为什么需要 interrupt stack?](#2. 为什么需要 interrupt stack?)
      • [3. disable interrupts / enable interrupts 是特权指令](#3. disable interrupts / enable interrupts 是特权指令)
    • [二、x86 Mode Transfer:硬件和 OS 分别做什么](#二、x86 Mode Transfer:硬件和 OS 分别做什么)
      • [1. 硬件负责的部分](#1. 硬件负责的部分)
      • [2. OS 负责的部分](#2. OS 负责的部分)
      • [3. 谁修改 CPL?](#3. 谁修改 CPL?)
    • [三、本讲目标:OS 给应用提供什么?](#三、本讲目标:OS 给应用提供什么?)
    • [四、系统调用:Windows 和 Unix 的不同风格](#四、系统调用:Windows 和 Unix 的不同风格)
    • [五、POSIX 和 libc:为什么程序可以跨平台](#五、POSIX 和 libc:为什么程序可以跨平台)
      • [1. POSIX 是什么?](#1. POSIX 是什么?)
      • [2. libc 是什么?](#2. libc 是什么?)
      • [3. 只依赖 libc 为什么可移植性更好?](#3. 只依赖 libc 为什么可移植性更好?)
    • 六、系统调用设计的四个考虑因素
    • 七、案例一:进程管理
      • [1. 为什么需要多进程?](#1. 为什么需要多进程?)
      • [2. Windows:CreateProcess](#2. Windows:CreateProcess)
      • [3. Unix:fork 和 exec](#3. Unix:fork 和 exec)
        • [fork 做什么?](#fork 做什么?)
        • [exec 做什么?](#exec 做什么?)
      • [4. fork 和 exec 分别做了哪些事?](#4. fork 和 exec 分别做了哪些事?)
      • [5. fork 后马上 exec,会不会浪费?](#5. fork 后马上 exec,会不会浪费?)
      • [6. exec 不是总是必要的](#6. exec 不是总是必要的)
    • [八、fork 小测题解析](#八、fork 小测题解析)
      • [题 1:打印几个 OS?](#题 1:打印几个 OS?)
      • [题 2:打印几个 OS?](#题 2:打印几个 OS?)
      • [题 3:输出顺序是什么?](#题 3:输出顺序是什么?)
      • [题 4:循环 fork 输出什么?](#题 4:循环 fork 输出什么?)
    • [九、案例二:Unix 输入输出接口](#九、案例二:Unix 输入输出接口)
      • [1. 为什么需要统一 I/O 接口?](#1. 为什么需要统一 I/O 接口?)
      • [2. File Descriptor:文件描述符](#2. File Descriptor:文件描述符)
      • [3. fd 背后的三层结构](#3. fd 背后的三层结构)
    • 十、open、close、read、write
      • [1. open](#1. open)
      • [2. close](#2. close)
      • [3. read](#3. read)
      • [4. write](#4. write)
    • [十一、Unix I/O 接口的几个特征](#十一、Unix I/O 接口的几个特征)
      • [1. Uniformity:统一性](#1. Uniformity:统一性)
      • [2. Open before use:使用前打开](#2. Open before use:使用前打开)
      • [3. Byte-oriented:字节导向](#3. Byte-oriented:字节导向)
      • [4. Kernel-buffered reads/writes:内核缓冲](#4. Kernel-buffered reads/writes:内核缓冲)
      • [5. Explicit close:显式关闭](#5. Explicit close:显式关闭)
    • [十二、Pipe:把 I/O 扩展到进程间通信](#十二、Pipe:把 I/O 扩展到进程间通信)
    • 十三、系统调用不是普通函数调用
      • [1. 一个重要幻觉](#1. 一个重要幻觉)
      • [2. System Call Stub](#2. System Call Stub)
      • [3. x86 中的系统调用 stub](#3. x86 中的系统调用 stub)
    • [十四、为什么要 copy_from_user?](#十四、为什么要 copy_from_user?)
      • [1. 内核能不能直接访问用户参数?](#1. 内核能不能直接访问用户参数?)
      • [2. 为什么要把参数从用户内存拷贝到内核内存?](#2. 为什么要把参数从用户内存拷贝到内核内存?)
      • [3. 能不能先检查参数,再拷贝?](#3. 能不能先检查参数,再拷贝?)
    • 十五、如何添加一个新的系统调用
    • [十六、课后作业:测量 context switch overhead](#十六、课后作业:测量 context switch overhead)
    • 十七、本讲知识点总览
    • 十八、期末复习重点
      • [1. 概念题](#1. 概念题)
      • [2. 流程题](#2. 流程题)
      • [3. 代码题](#3. 代码题)
    • 十九、小测题
      • [题 1:系统调用和普通函数调用的根本区别是什么?](#题 1:系统调用和普通函数调用的根本区别是什么?)
      • [题 2:为什么系统调用不能直接使用用户传入的指针?](#题 2:为什么系统调用不能直接使用用户传入的指针?)
      • [题 3:`fork()` 和 `exec()` 有什么区别?](#题 3:fork()exec() 有什么区别?)
      • [题 4:下面程序会打印几个 `OS`?](#题 4:下面程序会打印几个 OS?)
      • [题 5:为什么 Unix I/O 要设计成字节导向?](#题 5:为什么 Unix I/O 要设计成字节导向?)
      • [题 6:`write()` 返回成功是否表示数据已经永久写入磁盘?](#题 6:write() 返回成功是否表示数据已经永久写入磁盘?)
    • [二十、常见 QA](#二十、常见 QA)
      • Q1:为什么应用不能直接调用内核函数?
      • Q2:系统调用是不是一定很慢?
      • [Q3:为什么 Unix 不直接用一个 `CreateProcess()`?](#Q3:为什么 Unix 不直接用一个 CreateProcess()?)
      • [Q4:`fork()` 后父子进程谁先执行?](#Q4:fork() 后父子进程谁先执行?)
      • [Q5:fd 是文件本身吗?](#Q5:fd 是文件本身吗?)
      • [Q6:为什么 pipe 也可以用 read/write?](#Q6:为什么 pipe 也可以用 read/write?)
    • [二十一、拓展:从系统调用看 OS 的三种角色](#二十一、拓展:从系统调用看 OS 的三种角色)
      • [1. OS as referee](#1. OS as referee)
      • [2. OS as illusionist](#2. OS as illusionist)
      • [3. OS as glue](#3. OS as glue)
    • 二十二、最后总结

写在前面

这一讲的主题是 OS Interfaces and Syscalls,也就是"操作系统接口与系统调用"。

前几讲我们已经知道:操作系统通过用户态和内核态实现保护,用户程序不能随意执行特权指令,也不能直接访问硬件资源。那么问题来了:

用户程序既然不能直接操作硬件,又不能直接进入内核,那它到底怎么请求操作系统帮忙?

答案就是:系统调用(system call / syscall)

系统调用可以理解为用户程序进入内核的"正规入口"。普通应用通过系统调用请求 OS 完成文件读写、创建进程、网络通信、内存管理等工作。表面上看,系统调用像普通函数调用;但本质上,它会触发一次从用户态到内核态的受控切换。

这一讲的主线可以概括为四部分:

  1. 复习用户态到内核态的切换机制;
  2. 理解 OS 给应用提供哪些接口;
  3. 通过 fork/exec/wait 学习进程管理接口;
  4. 通过 open/read/write/close 学习 Unix I/O 接口;
  5. 最后理解系统调用 stub、参数拷贝和 syscall table 的设计。

一、先复习:用户态如何进入内核态

1. IDT、GDT/LDT 和 Gate

上一讲讲过,当中断、异常或系统调用发生时,CPU 不能随便跳到某个内核地址执行,而是必须通过硬件规定好的路径进入内核。

大致流程如下:

text 复制代码
中断号 / 系统调用号
        ↓
查 IDT:Interrupt Descriptor Table,中断描述符表
        ↓
找到对应 Gate
        ↓
Gate 中包含段选择子和 offset
        ↓
通过 GDT 或 LDT 找到段基址 base address
        ↓
base + offset = 内核处理函数入口地址
        ↓
跳转到对应 handler 执行

这里的核心思想是:

用户程序不能自己选择任意内核地址执行,只能通过 OS 和 CPU 预先设置好的入口进入内核。

这样才能保证内核的安全性。如果用户程序可以随便跳进内核函数,用户态和内核态的隔离就失效了。


2. 为什么需要 interrupt stack?

课件中提到 Interrupt stack(中断栈):它是内核内存中的特殊栈,用来保存中断、异常、系统调用发生时的处理状态。

为什么不能继续使用用户栈?

因为用户栈不可信。用户程序可能出现以下情况:

  • 栈指针被破坏;
  • 栈地址非法;
  • 栈内容被恶意伪造;
  • 用户程序故意把栈指向敏感位置。

如果进入内核后还继续使用用户栈,那么内核状态可能被用户程序影响,安全边界就被破坏了。

所以当 CPU 从用户态进入内核态时,需要切换到内核栈或中断栈。可以把它理解成:

用户空间有用户自己的工作台,内核空间有内核自己的工作台。进入内核后,不能继续在用户的工作台上干活。


3. disable interrupts / enable interrupts 是特权指令

课件还提到:

text 复制代码
disable interrupts
enable interrupts

这两个指令是特权指令,只能在内核态执行。

原因很简单:如果用户程序可以随便关闭中断,它就可以让操作系统永远无法重新获得 CPU 控制权。比如用户程序执行:

text 复制代码
disable interrupts;
while (true) {}

这样定时器中断也进不来,OS 就无法抢占这个进程,整个系统可能被一个普通程序卡死。

所以"能不能关中断"不是普通应用该有的能力,而是内核必须严格控制的能力。


二、x86 Mode Transfer:硬件和 OS 分别做什么

当 interrupt、exception 或 syscall 发生时,硬件会先完成一部分工作,然后 OS 再完成后续工作。

1. 硬件负责的部分

课件中列出,当中断、异常或系统调用发生时,硬件会做:

text 复制代码
1. Mask interrupts
2. Save special register values to temporary registers
3. Switch onto the kernel interrupt stack
4. Push three key values onto the new stack
5. Optionally save an error code
6. Invoke the interrupt handler

其中最关键的是保存三个值:

text 复制代码
CS:EIP   当前执行位置
SS:ESP   当前用户栈位置
EFLAGS   当前 CPU 状态标志

为什么要保存它们?

因为内核处理完系统调用或中断之后,需要回到用户程序原来被打断的位置继续执行。如果不保存这些状态,CPU 就不知道该回哪里,也不知道之前的状态是什么。

可以把这个过程类比成游戏存档:

text 复制代码
用户程序正在运行
    ↓
发生 syscall / interrupt / exception
    ↓
硬件保存当前现场
    ↓
进入内核处理
    ↓
处理完后恢复现场
    ↓
用户程序继续运行

2. OS 负责的部分

硬件只保存最关键的寄存器状态,OS 还需要保存更多普通寄存器,例如:

text 复制代码
EAX, EBX, ECX, EDX, ESI, EDI ...

在 x86 中常见指令是:

asm 复制代码
pusha / pushad

恢复时使用:

asm 复制代码
popa / popad

最后通过:

asm 复制代码
iret

从内核态返回用户态。

这里要注意:iret 不只是普通返回指令,它会恢复之前保存的 CS:EIPSS:ESPEFLAGS,并且完成从内核态回用户态的切换。


3. 谁修改 CPL?

CPL 是 Current Privilege Level,当前特权级。x86 里 CPL 存在 CS 段寄存器的低 2 位中。

用户态和内核态的典型 CPL 是:

text 复制代码
CPL = 0  内核态
CPL = 3  用户态

那么谁来修改 CPL?

不是普通程序手动修改,而是通过特殊指令完成:

text 复制代码
int / SYSCALL   用户态进入内核态
iret            内核态返回用户态

比如老式 x86 Linux 系统调用常用:

asm 复制代码
movl $4, %eax      # sys_write 的系统调用号
movl $1, %ebx      # fd = stdout
movl $msg, %ecx    # buffer
movl $len, %edx    # length
int $0x80          # 触发软件中断,进入内核

这说明系统调用不是"普通函数跳转",而是一次受硬件保护的特权级切换。


三、本讲目标:OS 给应用提供什么?

课件给出本讲四个目标:

text 复制代码
OS Programming Interface
Case Study: Process Management
Case Study: Input/Output
System Calls Design

也就是:

  1. 操作系统如何向应用暴露能力;
  2. 进程如何被创建和管理;
  3. 输入输出如何被统一抽象;
  4. 系统调用本身如何设计和实现。

OS 向应用提供的功能包括:

  • 进程管理;
  • 输入输出;
  • 线程管理;
  • 内存管理;
  • 文件系统与存储;
  • 网络;
  • 图形和窗口管理;
  • 认证与安全。

所以,应用程序看起来是在调用库函数,但很多功能最终都要通过系统调用进入内核,由 OS 代表应用完成真正的操作。


四、系统调用:Windows 和 Unix 的不同风格

课件中列出了 Windows 和 Unix 系统调用的对比。

功能 Windows Unix
创建进程 CreateProcess() fork()
退出进程 ExitProcess() exit()
等待进程 WaitForSingleObject() wait()
打开文件 CreateFile() open()
读取文件 ReadFile() read()
写入文件 WriteFile() write()
关闭文件 CloseHandle() close()
管道通信 CreatePipe() pipe()
共享内存 CreateFileMapping() shm_open() / mmap()
权限保护 SetFileSecurity() chmod() / umask() / chown()

不同 OS 的接口风格不完全相同,但它们本质上都在做同一类事情:

把底层硬件和内核资源,封装成应用可以安全使用的接口。


五、POSIX 和 libc:为什么程序可以跨平台

1. POSIX 是什么?

POSIX 是 Portable Operating System Interface,中文可以理解为"可移植操作系统接口"。

它主要规范 Unix-like 系统中的接口,尤其是系统调用相关接口,例如:

c 复制代码
fork();
exec();
wait();
open();
read();
write();
close();
getpid();

有了 POSIX,程序员就可以写出在 Linux、macOS 等 Unix-like 系统上比较容易移植的程序。


2. libc 是什么?

libc 是 C 标准库以及部分系统接口的封装层。

比如我们写:

c 复制代码
printf("Hello, World!\n");

表面上是调用标准 C 函数,但底层可能最终走到:

c 复制代码
write(1, buffer, len);

再通过系统调用进入内核,把数据写到终端。

程序调用系统服务的典型路径是:

text 复制代码
Application
    ↓
libc / glibc
    ↓
system call wrapper
    ↓
trap / syscall instruction
    ↓
kernel syscall handler
    ↓
真正的内核逻辑

所以应用通常不是直接执行 syscall 指令,而是通过 libc wrapper 间接调用。


3. 只依赖 libc 为什么可移植性更好?

如果一个程序只依赖标准 C 库和 POSIX 接口,它通常更容易跨 OS 或跨硬件运行。

例如:

c 复制代码
#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;
}

这个程序在 Linux、macOS、Windows 上都容易编译运行。

但如果你使用 Linux-specific 接口,例如:

c 复制代码
#include <sys/syscall.h>
#include <unistd.h>

pid_t tid = syscall(SYS_gettid);

那么它就不一定能在 macOS 或 Windows 上编译运行。

课件第 16 页强调的是:

这里讨论的是 compilation level 的兼容性,也就是源码能否在对应系统上编译。如果程序已经编译成二进制文件,问题又会涉及 ABI、指令集、动态链接库等更底层内容。


六、系统调用设计的四个考虑因素

课件给出系统调用设计的四个关键词:

text 复制代码
Flexibility
Safety
Reliability
Performance

分别对应:

目标 含义
Flexibility 灵活性 接口要能组合出多种功能,不能太死板
Safety 安全性 用户输入不可信,内核必须检查参数和权限
Reliability 可靠性 出错时不能破坏系统状态
Performance 性能 syscall 有上下文切换成本,接口不能过度低效

这四个目标经常互相冲突。

例如,接口越灵活,内核实现可能越复杂;检查越严格,性能可能越低;接口越底层,性能可能越好,但也可能更危险。

因此操作系统接口通常会形成一种"细腰结构":

text 复制代码
大量应用程序
    ↓
库函数和运行时
    ↓
少量、稳定的系统调用接口
    ↓
内核
    ↓
多样化硬件

系统调用接口一旦发布,很多应用、库和工具链都会依赖它,所以不能随便改。


七、案例一:进程管理

1. 为什么需要多进程?

课件给出的早期动机是:允许开发者写自己的 shell 命令解释器。

比如 shell 脚本中可能执行:

sh 复制代码
cc -c sourcefile1.c
cc -c sourcefile2.c
ln -o program sourcefile1.o sourcefile2.o

shell 自己不负责真正编译代码,而是创建子进程,让编译器和链接器去工作。

所以 shell 需要的核心能力包括:

text 复制代码
创建进程
执行新程序
等待进程结束
管理输入输出
管理信号

这就是进程管理系统调用的意义。


2. Windows:CreateProcess

Windows 风格是用 CreateProcess() 一步完成"创建进程 + 加载程序"。

课件中的描述可以整理为:

text 复制代码
Boolean CreateProcess(char *prog, char *args)

它大致会做:

  1. 在内核中创建并初始化 PCB;
  2. 创建新的内存地址空间;
  3. 把程序 prog 加载到地址空间;
  4. 把参数 args 拷贝进新地址空间;
  5. 初始化硬件上下文,让进程从程序入口开始执行;
  6. 通知调度器:这个新进程已经 ready,可以运行。

现实中还要处理更多细节,例如:

  • 子进程权限;
  • 输入输出重定向;
  • 文件位置;
  • 调度优先级;
  • 环境变量;
  • 句柄继承。

可以把 Windows 的方式理解成:

text 复制代码
创建一个新进程,并直接让它运行指定程序。

3. Unix:fork 和 exec

Unix 采用的是另一种非常经典的设计:

c 复制代码
fork();
exec();

课件中说这是 Unix 中最有争议的设计之一,因为它把"创建进程"和"加载新程序"拆成了两个动作。

fork 做什么?

fork() 创建父进程的完整副本。

区别主要在返回值:

text 复制代码
子进程中 fork() 返回 0
父进程中 fork() 返回子进程 PID
失败时返回 -1

典型代码:

c 复制代码
pid_t pid = fork();

if (pid == 0) {
    // child process
} else {
    // parent process
}

fork() 之后,父进程和子进程从同一个位置继续执行,但它们处在不同进程中。


exec 做什么?

exec() 把当前进程的地址空间替换成另一个程序。

重点是:

exec() 不创建新进程,它只是让当前进程换一个程序运行。

例如子进程调用 exec("foo") 后,原来的子进程代码就被 foo 程序替换了。


4. fork 和 exec 分别做了哪些事?

课件把它们拆成了两列。

fork() 做:

  1. 创建并初始化 PCB;
  2. 创建新地址空间;
  3. 从父进程复制整个内存内容到子进程;
  4. 继承父进程执行上下文,例如打开的文件;
  5. 通知调度器:新进程 ready。

exec(char *prog, char *args) 做:

  1. 将程序 prog 加载到当前地址空间;
  2. 将参数 args 拷贝到地址空间;
  3. 初始化硬件上下文,让程序从入口点开始执行。

所以 shell 中常见模式是:

c 复制代码
pid_t pid = fork();

if (pid == 0) {
    exec("foo");
} else {
    waitpid(pid, &status, options);
}

子进程负责执行新程序,父进程负责等待子进程结束。


5. fork 后马上 exec,会不会浪费?

这是课件第 23 页提出的问题。

表面看,fork() 会复制父进程整个内存,而 exec() 马上又会把子进程地址空间替换掉,好像很浪费。

现代 OS 一般使用 Copy-on-Write,写时复制 优化这个问题。

基本思想是:

text 复制代码
fork 时不立刻复制所有物理页面
父子进程先共享相同物理页面
这些页面被标记为只读
如果某一方尝试写入,就触发 page fault
内核再复制一份私有页面给写入方

所以 fork + exec 通常不会真的完整复制两次内存。

这部分内容后面虚拟内存、分页和 page fault 课程中会继续深入。


6. exec 不是总是必要的

课件第 24 页提到:

exec() is not always necessary.

例如浏览器打开新页面时,可能创建一个新进程,但它并不一定要加载一个完全不同的程序。子进程本来就是浏览器进程的副本,它可以继续执行浏览器内部逻辑。

这一页还提到:

text 复制代码
wait(pid): 等待子进程执行结束
signal: 终止、暂停或恢复进程

这些都是进程管理中常见的系统调用或机制。


八、fork 小测题解析

题 1:打印几个 OS?

c 复制代码
int main() {
    fork();
    fork();
    fork();
    printf("OS ");
    return 0;
}

每次 fork() 都会让进程数量翻倍。

text 复制代码
开始:1 个进程
第一次 fork 后:2 个进程
第二次 fork 后:4 个进程
第三次 fork 后:8 个进程

所以最终打印:

text 复制代码
8 个 OS

题 2:打印几个 OS?

c 复制代码
int main() {
    if (fork() || fork())
        fork();
    printf("OS ");
    return 0;
}

这个题的关键是 || 的短路求值。

第一个 fork() 后:

  • 父进程中返回非 0,左边为 true,所以右边 fork() 不执行;
  • 子进程中返回 0,左边为 false,所以继续执行右边 fork()

最终会有 5 个进程执行到 printf,所以打印:

text 复制代码
5 个 OS

题 3:输出顺序是什么?

c 复制代码
int main() {
    int pid = fork();
    if (pid == 0) {
        printf("I am child, ");
    } else {
        printf("I am parent, ");
        return 0;
    }
}

可能输出:

text 复制代码
I am child, I am parent,

也可能输出:

text 复制代码
I am parent, I am child,

答案是:

text 复制代码
Both are possible.

因为 fork 之后父子进程谁先被调度运行是不确定的。


题 4:循环 fork 输出什么?

c 复制代码
int main() {
    for (int i = 0; i < 3; i += 1) {
        pid_t p = fork();
        if (p == 0) {
            i += 1;
        }
        printf("%d", i);
    }
    return 0;
}

这个题的关键不是死背某一个输出顺序,而是理解:

多个进程并发运行,所以输出顺序可能不同。

但可以通过画进程树分析出最终会打印多个数字,且由于子进程会额外执行 i += 1,所以会出现不同的 i 值。

做 fork 题时,建议按下面步骤:

  1. 遇到一次 fork(),把当前进程分裂成父子两支;
  2. 分别记录父子进程中的返回值;
  3. 继续沿每条分支执行;
  4. 最后统计有多少进程执行到输出语句;
  5. 输出顺序通常由调度决定,不要默认固定。

九、案例二:Unix 输入输出接口

1. 为什么需要统一 I/O 接口?

计算机系统中有很多不同设备:

text 复制代码
键盘:一个个字符
磁盘:固定大小块
网络:变长 packet 或 stream
鼠标:一个个事件

如果每一种设备都设计一套专门接口,那么 OS 接口会随着设备种类增加而膨胀。

Unix 的设计是:

Everything is a file.

也就是尽可能用统一接口处理不同资源:

c 复制代码
open();
read();
write();
close();

你可以打开普通文件:

text 复制代码
/data/readme.txt

也可以打开设备文件:

text 复制代码
/dev/zero
/dev/tty

对用户来说,它们都可以通过 fd 和 read/write 访问。


2. File Descriptor:文件描述符

File Descriptor,简称 fd,是一个整数,用来标识一个进程打开的文件或 I/O 资源。

常见 fd 有:

text 复制代码
0  stdin   标准输入
1  stdout  标准输出
2  stderr  标准错误

课件中强调:

  • 每个进程都有自己的 file descriptor table;
  • 一个文件可以被打开多次,因此可以关联多个 fd;
  • fd 背后隐藏了内核中关于打开文件的状态。

可以用下面命令查看某个进程的 fd:

sh 复制代码
ls -l /proc/[pid]/fd

比如在 Linux 中,fd 可能指向:

text 复制代码
普通文件
目录
终端设备
socket
pipe
匿名文件

3. fd 背后的三层结构

虽然用户看到的只是一个整数 fd,但内核背后通常有多层结构:

text 复制代码
进程自己的 fd table
        ↓
系统级 open file table
        ↓
inode table / 文件对象

为什么需要这么设计?

因为:

  • 不同进程可以打开同一个文件;
  • 同一个进程也可以多次打开同一个文件;
  • 多个 fd 可以共享同一个底层 open file description;
  • 文件本身的元数据需要由 inode 或类似结构维护。

所以 fd 是用户态的"句柄",真正的文件状态在内核中。


十、open、close、read、write

1. open

open() 用来打开一个文件或设备。

c 复制代码
#include <fcntl.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

返回值:

text 复制代码
成功:返回 file descriptor
失败:返回 -1

pathname 可以是普通文件:

text 复制代码
/data/readme.txt

也可以是设备:

text 复制代码
/dev/zero

2. close

c 复制代码
#include <fcntl.h>

int close(int fd);

返回值:

text 复制代码
成功:0
失败:-1

如果这个 fd 是最后一个引用底层 open file description 的文件描述符,内核就会释放相关资源。


3. read

c 复制代码
#include <fcntl.h>

ssize_t read(int fd, void *buf, size_t count);

含义是:

从 fd 对应的资源中最多读取 count 字节,放入 buf 指向的缓冲区。

返回值:

text 复制代码
成功:实际读到的字节数
失败:-1
读到文件尾:0

4. write

c 复制代码
#include <fcntl.h>

ssize_t write(int fd, const void *buf, size_t count);

含义是:

把 buf 开始的 count 字节写入 fd 对应的资源。

返回值:

text 复制代码
成功:实际写入的字节数
失败:-1

这里要注意:write() 返回成功不一定代表数据已经永久写入磁盘,它可能只是进入了内核缓冲区。真正需要持久化时还可能需要 fsync()


十一、Unix I/O 接口的几个特征

课件总结了 Unix I/O 的几个设计特征。

1. Uniformity:统一性

所有 I/O 尽可能使用统一接口:

c 复制代码
open();
close();
read();
write();

这让普通文件、设备、pipe、socket 都可以用相似方式操作。


2. Open before use:使用前打开

在读写前必须先 open。

这样 OS 可以提前完成:

  • 权限检查;
  • 路径解析;
  • 创建 fd;
  • 初始化内核数据结构;
  • 记录当前文件 offset。

后续 read/write 就可以直接基于 fd 操作,而不需要每次重新解析路径。


3. Byte-oriented:字节导向

Unix I/O 是字节导向的。

即使底层磁盘按 block 读写,用户接口仍然按字节寻址。

例如:

c 复制代码
read(fd, buf, 13);

用户可以只读 13 字节。文件系统内部可能读取一个 4KB block,然后只返回其中一部分。

这让上层编程更加简单。


4. Kernel-buffered reads/writes:内核缓冲

读写通常经过内核缓冲。

读的时候,如果数据暂时不可用,进程可能阻塞,CPU 可以去运行其他任务。

写的时候,数据可能先进入内核缓冲区,write() 不一定等到底层设备真正完成写入后才返回。

这带来两个结果:

  1. 流式设备和块设备在接口上看起来类似;
  2. 性能更好,但也带来持久性语义问题。

5. Explicit close:显式关闭

资源使用完后应该显式调用:

c 复制代码
close(fd);

这样内核可以回收不用的数据结构。

如果长时间不 close,服务器程序可能出现:

text 复制代码
Too many open files

十二、Pipe:把 I/O 扩展到进程间通信

课件第 40 页讲到 pipe。

Pipe 是一个内核缓冲区,有两个文件描述符:

text 复制代码
fd[0]:读端
fd[1]:写端

创建 pipe:

c 复制代码
int fd[2];
pipe(fd);

Pipe 常用于 shell,例如:

sh 复制代码
ls | grep txt

可以理解为:

text 复制代码
ls 的标准输出 stdout 连接到 pipe 写端
grep 的标准输入 stdin 连接到 pipe 读端

这种设计非常 Unix:

进程间通信也尽量抽象成文件读写。


十三、系统调用不是普通函数调用

1. 一个重要幻觉

课件第 42 页说:

An illusion that kernel is simply a set of library routines.

也就是说,用户程序看起来好像只是调用了一个函数:

c 复制代码
open(path, flags);

但实际上它不是普通函数调用。

真正流程是:

text 复制代码
用户程序
    ↓
libc 中的 open wrapper
    ↓
把系统调用号和参数放到指定寄存器或栈中
    ↓
执行 trap / syscall / int 指令
    ↓
CPU 从用户态切到内核态
    ↓
进入 syscall handler
    ↓
内核检查参数和权限
    ↓
执行真正的内核逻辑
    ↓
把返回值带回用户态

这就是系统调用和普通库函数调用的本质区别。


2. System Call Stub

课件第 43 页给出了 syscall stub 的结构。

用户程序:

c 复制代码
Main() {
    open(arg1, arg2);
}

用户态 stub:

c 复制代码
file_open() {
    push #SYSCALL_OPEN;
    trap;
    return;
}

内核态 stub:

c 复制代码
file_open_handler() {
    copy_args_from_user();
    check_args();
    file_open(arg1, arg2);
    copy_ret_to_user();
    return;
}

真正内核实现:

c 复制代码
file_open() {
    // do the real operation
}

所以系统调用通常有两层 stub:

text 复制代码
用户态 stub:负责发起 trap
内核态 stub:负责取参数、检查参数、调用真正内核函数、返回结果

3. x86 中的系统调用 stub

课件第 44 页给出老式 x86 示例:

asm 复制代码
open:
    movl #SysCallOpen, %eax
    int #TrapCode
    ret

这里:

  • %eax 中放系统调用号;
  • int #TrapCode 触发软件中断进入内核;
  • 内核根据 TrapCode 和 syscall number 找到对应 handler;
  • 返回值通常放回 %eax

int 指令会做:

text 复制代码
保存 program counter、stack pointer、eflags 到内核栈
通过 interrupt vector table 跳到系统调用 handler
内核 handler 检查 TrapCode 并调用正确 stub

这正好和本讲开头的 mode transfer 连接起来。


十四、为什么要 copy_from_user?

课件第 45 到 46 页提出三个问题。

1. 内核能不能直接访问用户参数?

多数 OS 中可以,因为内核和用户进程通常共享一部分地址空间映射,内核态有权限访问用户态内存。

但"能访问"不代表"应该直接信任"。

用户传入的参数来自用户空间,而用户空间是不可信的。


2. 为什么要把参数从用户内存拷贝到内核内存?

因为原始参数通常在用户栈或用户内存中。

例如:

c 复制代码
open(path, flags);

这里的 path 是一个用户态指针。用户程序可能在系统调用过程中修改它。

所以内核通常要使用:

c 复制代码
copy_from_user();

把用户参数安全拷贝到内核空间。

返回结果时使用:

c 复制代码
copy_to_user();

3. 能不能先检查参数,再拷贝?

这会引出 TOCTOU 问题。

TOCTOU 是:

text 复制代码
Time Of Check To Time Of Use

也就是:

text 复制代码
检查时是一个值
真正使用时已经变成另一个值

举个例子:

text 复制代码
内核检查 path 指向普通文件
用户程序马上把 path 改成敏感文件
内核继续按之前检查结果使用 path

这就是安全漏洞。

因此内核处理用户参数时,必须非常谨慎:

  1. 不信任用户指针;
  2. 不信任用户内存内容稳定不变;
  3. 需要安全拷贝;
  4. 检查和使用之间要避免被用户态篡改。

十五、如何添加一个新的系统调用

课件第 47 页举例:添加一个新的系统调用 get_task_count

大致步骤:

text 复制代码
1. 分配一个 syscall number,例如 333
2. 在 unistd.h 中定义函数
3. 实现 sys_get_task_count
4. 修改 syscall entry,把 syscall number 映射到内核函数

内核实现示例:

c 复制代码
int sys_get_task_count(void) {
    int count;

    // 访问内核全局数据结构,例如 task list
    count = calculate_running_tasks();

    // 简单返回值通常通过寄存器返回用户态
    return count;
}

系统调用表中加入:

c 复制代码
[333] = sys_get_task_count;

用户态封装可能类似:

c 复制代码
int get_task_count() {
    int count;

    asm volatile("int $0x80"
                 : "=a"(count)
                 : "0"(333));

    return count;
}

这个例子的重点是理解:

text 复制代码
系统调用号 → syscall table → 内核函数

用户态并不是直接按函数名调用内核函数,而是通过系统调用号进入统一分发入口。


十六、课后作业:测量 context switch overhead

本讲最后的作业是:

Measure context switch overhead.

也就是测量上下文切换开销。

一种常见方法是使用 pipe:

text 复制代码
进程 A 写一个字节给进程 B
进程 B 收到后写一个字节返回给进程 A
重复很多次
测量总时间
平均每次往返时间 / 2 约等于一次 context switch 开销

但要注意,测到的总时间不只包含上下文切换,还包含:

  • 系统调用开销;
  • pipe 读写开销;
  • 调度器开销;
  • 缓存和 TLB 影响;
  • 计时误差。

因此实验时通常要重复很多轮,取平均值或中位数,并尽量减少其他干扰。


十七、本讲知识点总览

这一讲可以用四句话总结:

第一,系统调用是用户程序请求 OS 服务的受控入口。用户程序不能直接调用内核函数,而是通过 intSYSCALL 等特殊机制进入内核。

第二,POSIX 和 libc 让应用获得更好的可移植性。应用通常调用 libc wrapper,wrapper 再触发真正的系统调用。

第三,Unix 进程管理的核心是 fork + exec + waitfork 创建进程副本,exec 替换当前进程的程序,wait 等待子进程结束。

第四,Unix I/O 的核心思想是"一切皆文件"。普通文件、设备、pipe、socket 都尽量用 open/read/write/close 统一抽象。


十八、期末复习重点

这一讲的期末考点主要集中在下面几个方面。

1. 概念题

需要能解释:

  • 什么是系统调用;
  • 系统调用和普通函数调用的区别;
  • POSIX 和 libc 的作用;
  • fd 是什么;
  • 为什么 Unix 说 Everything is a file;
  • fork()exec() 的区别;
  • 为什么系统调用需要 copy_from_user;
  • 什么是 TOCTOU。

2. 流程题

需要能画出或描述:

text 复制代码
用户程序调用 open
    ↓
libc wrapper
    ↓
trap / syscall
    ↓
硬件切换到内核态
    ↓
内核 syscall handler
    ↓
copy_from_user + check_args
    ↓
真正内核 file_open
    ↓
返回用户态

还要能描述 x86 mode transfer 中硬件和 OS 分别保存哪些状态。


3. 代码题

重点是 fork 输出题。

做题方法:

  1. 每遇到一个 fork(),画出父进程和子进程;
  2. 记录每个分支的返回值;
  3. 注意 &&|| 的短路求值;
  4. 注意父子进程调度顺序不确定;
  5. 最后统计执行到 printf 的进程数量。

十九、小测题

题 1:系统调用和普通函数调用的根本区别是什么?

答案:普通函数调用不改变 CPU 特权级,仍然在用户态执行;系统调用会通过 trap、intSYSCALL 指令进入内核态,触发一次受保护的 mode transfer。


题 2:为什么系统调用不能直接使用用户传入的指针?

答案:用户指针指向用户空间,用户空间不可信。用户可能传入非法地址,也可能在检查和使用之间修改内容。因此内核需要使用 copy_from_user() 把参数安全拷贝到内核空间,并进行权限和合法性检查。


题 3:fork()exec() 有什么区别?

答案:fork() 创建当前进程的副本,产生一个子进程;exec() 不创建新进程,而是把当前进程的地址空间替换成另一个程序。


题 4:下面程序会打印几个 OS

c 复制代码
int main() {
    fork();
    fork();
    fork();
    printf("OS ");
    return 0;
}

答案:8 个。

解释:三个 fork() 让进程数量从 1 变成 2、4、8,最后 8 个进程都执行 printf


题 5:为什么 Unix I/O 要设计成字节导向?

答案:字节导向让上层程序不用关心底层设备的传输粒度。即使磁盘按块传输,用户仍然可以请求任意字节范围,文件系统负责把字节请求转换成块操作。


题 6:write() 返回成功是否表示数据已经永久写入磁盘?

答案:不一定。write() 可能只是把数据写入内核缓冲区或提交给设备队列。真正要求数据落盘时,通常需要 fsync() 等机制。


二十、常见 QA

Q1:为什么应用不能直接调用内核函数?

因为内核函数运行在更高特权级,直接开放给用户程序会破坏系统安全。用户程序可能绕过权限检查、破坏内核数据结构,甚至控制整个系统。因此必须通过系统调用这个受控入口进入内核。


Q2:系统调用是不是一定很慢?

系统调用比普通函数调用慢,因为它涉及用户态和内核态切换、寄存器保存恢复、参数检查等操作。但它是必要成本。现代 CPU 和 OS 也提供了更快的 syscall 指令和优化路径。


Q3:为什么 Unix 不直接用一个 CreateProcess()

Unix 选择把创建进程和加载程序拆成 fork()exec(),这样中间可以做很多灵活操作,例如:

  • 修改环境变量;
  • 重定向 stdin/stdout;
  • 设置 pipe;
  • 改变权限;
  • 设置工作目录。

这也是 shell 能方便实现管道和重定向的重要原因。


Q4:fork() 后父子进程谁先执行?

不确定。由调度器决定。任何依赖父子进程固定执行顺序的程序都是有风险的。如果需要顺序控制,应使用 wait()、pipe、锁或其他同步机制。


Q5:fd 是文件本身吗?

不是。fd 只是当前进程打开文件或 I/O 资源的一个整数句柄。真正的文件状态在内核中的 open file table、inode 等结构里。


Q6:为什么 pipe 也可以用 read/write?

因为 Unix 把 pipe 也抽象成文件描述符。pipe 的读端和写端分别对应两个 fd,所以可以像文件一样使用 read()write()。这体现了 Everything is a file 的设计思想。


二十一、拓展:从系统调用看 OS 的三种角色

前几讲提到 OS 有三个角色:

text 复制代码
referee     裁判
illusionist 幻术师
glue        胶水

这一讲的系统调用正好把这三个角色都串起来了。

1. OS as referee

系统调用是受控入口。OS 检查权限、检查参数、防止用户程序越权访问资源。

例如:

text 复制代码
open 文件时检查权限
read/write 时检查 fd 是否有效
fork 时检查资源限制

2. OS as illusionist

用户程序看到的是简单接口:

c 复制代码
read(fd, buf, count);

但背后可能是:

text 复制代码
磁盘块读取
页缓存
设备驱动
DMA
中断
文件系统
权限检查

OS 把复杂底层包装成简单幻觉。


3. OS as glue

OS 用统一接口连接不同硬件和应用。

例如 Unix I/O 把文件、设备、pipe、socket 都接到 fd 和 read/write 体系下,这就是典型的 glue。


二十二、最后总结

第四讲的核心不是背几个系统调用名字,而是理解:

操作系统接口是用户程序和内核之间的边界。系统调用既要让应用方便地使用系统资源,又要保证内核和硬件不被不可信用户程序破坏。

这一讲之后,我们应该建立下面这条完整链路:

text 复制代码
用户程序需要服务
    ↓
调用 libc/POSIX API
    ↓
进入系统调用 wrapper
    ↓
执行 trap / syscall 指令
    ↓
CPU 切换到内核态
    ↓
内核保存现场、检查参数、执行服务
    ↓
返回用户态
    ↓
用户程序继续运行

理解这条链路后,再学习线程、虚拟内存、文件系统、I/O 设备时,就会发现它们本质上都绕不开这套接口机制。

相关推荐
「QT(C++)开发工程师」1 小时前
免费在线 Ubuntu/Linux 运行环境
linux·运维·ubuntu
hhhh明1 小时前
ubuntu22.04 桌面可视化(vncserver+novnc 方式)
linux·运维·服务器
Fcy6481 小时前
Linux下 进程间通信详解(一)管道、进程池与简单的Linux 进程间聊天室
linux·服务器·管道·进程间通信·进程池
‎ദ്ദിᵔ.˛.ᵔ₎1 小时前
Linux 权限
linux
拳里剑气1 小时前
Linux:权限
linux·学习方法
ole ' ola1 小时前
Linux DDR内存使用情况
linux·运维·服务器
糖果店的幽灵1 小时前
LangChain 1.3 完全教程:从入门到精通-Part 10: Memory(记忆系统)
windows·microsoft·langchain
Kingairy1 小时前
Linux 机器信任关系
linux·运维·服务器
流浪0012 小时前
Linux系统篇(一):从零入门操作系统:冯诺依曼体系到进程的完整理解
linux·运维·服务器