一篇面向初学者的操作系统入门文档, 介绍系统栈, 内核, 用户态, 系统调用, 进程线程, 调度, 同步, 内存管理, 文件系统, 驱动, 网络, 权限和虚拟化.
操作系统 (Operating System, OS) 是计算机系统里最容易被低估的一层. 初学者刚开始写程序时, 往往看到的是 Python, C++, JavaScript, IDE, 命令行和各种库. 但这些东西真正运行起来时, 都要依赖操作系统提供的抽象: 文件怎么打开, 内存怎么分配, 进程怎么调度, 网络包怎么发送, 键盘鼠标怎么变成输入事件, GPU/硬盘/网卡这些硬件怎么被统一管理.
可以把操作系统放在整个系统栈的中间看. 上面是应用程序, 编程语言运行时和开发工具; 下面是 CPU, 内存, 硬盘, 网卡, GPU 等硬件. 操作系统在中间负责把复杂硬件包装成稳定接口, 让上层软件不用直接和每个硬件细节打交道.
这张图里最关键的是"抽象"两个字. 应用程序通常不会自己控制硬盘磁头, 不会自己配置页表, 不会自己决定哪一个 CPU 核心下一毫秒运行哪个线程. 它会调用操作系统提供的接口, 让操作系统代替它管理这些资源. 例如你在 C 里调用 open, read, write, 在 Python 里打开一个文件, 在浏览器里发起一个网络请求, 背后最终都会落到操作系统提供的能力上.
这篇文档不是要替代完整 OS 课程. 真正的 OS 课程会要求你读论文, 写调度器, 写内存分配器, 实现文件系统, 甚至写一个教学内核. 这里更像一份面向初学者的地图: 先让你知道操作系统由哪些核心概念组成, 它们为什么存在, 以及这些概念如何解释你在开发中遇到的实际问题.
学习操作系统的目标不是背诵术语. 更实际的目标是能够解释程序运行时发生了什么, 能在遇到性能问题, 死锁, 内存泄漏, 文件权限错误, 网络连接异常, 驱动加载失败时有基本判断.
malloc/free, mmap 这些概念之间的关系.正如我在 Basic Skills: Unix-like Systems 里面所说, 我一直认为 OS 应该和计算机体系结构导论一样作为所有 EE/ECE 的必修课, 它的确非常实用. 你可能会遇到 GPU driver, DMA, FPGA runtime, kernel module, /dev 设备文件, NUMA, huge pages, real-time scheduling, license server, NFS, SSH 远程环境, containerized toolchain 等问题. 这些问题很多不是算法问题, 而是系统问题. 不懂操作系统, 你会把它们都看成玄学报错; 懂一点操作系统, 至少能判断问题大概发生在哪一层.
但这篇文章并不想, 也不可能代替专业的 OS 课程. 网上有非常多的优秀 OS 课程, 比如经典的 MIT 6. 828 XV6, 南京大学 jyy 的 OS 课程, 这些课程都配备了完善的 lab assignments, 通常要求你实现/完善一个教学级的系统内核, 对能力的锻炼是很明显的. 这篇文章主要是为了提供快速理解操作系统核心概念的地图, 让你在遇到实际问题时能有个大致的判断.
最朴素地说, 操作系统是一层管理硬件资源并为应用程序提供接口的软件. 它一方面向下管理 CPU, 内存, 磁盘, 网络, 外设; 另一方面向上提供进程, 文件, Socket, 虚拟内存, 权限, 时间, 线程等抽象.
没有操作系统时, 每个程序都要自己处理硬件细节. 一个程序想读硬盘, 就要知道硬盘控制器怎么发命令; 想使用内存, 就要自己避免和其他程序互相覆盖; 想使用网卡, 就要自己处理中断和 DMA. 这不仅困难, 也不安全. 操作系统把这些复杂问题集中管理, 让应用程序通过统一接口使用资源.
例如:
应用看到的是: open("data.txt"), read(fd), write(fd)
OS 内部处理的是: 路径解析, 权限检查, 文件系统元数据, page cache, 块设备驱动, 磁盘 I/O这就是 OS 的核心价值: 它把复杂硬件和共享资源包装成可理解, 可隔离, 可复用的抽象.
操作系统设计里有几个反复出现的原则. 第一个是抽象. 文件抽象了磁盘和设备, 进程抽象了正在运行的程序, 虚拟内存抽象了物理内存, Socket 抽象了网络连接. 好的抽象让上层程序可以不用关心太多硬件细节.
第二个是隔离. 多个程序同时运行时, 它们不能随便读写彼此内存, 不能随便抢占全部 CPU, 也不能随便修改系统文件. OS 通过虚拟地址空间, 权限机制, 用户态/内核态, namespace, cgroups 等机制维持边界.
第三个是共享. 硬件资源有限, 但很多程序要同时使用. OS 需要决定 CPU 时间怎么分配, 内存怎么回收, 磁盘 I/O 怎么排队, 网络包怎么收发. 共享带来的问题是公平性, 性能和安全性之间的平衡.
第四个是兼容性. 一个系统一旦有大量应用依赖, 接口就不能随便改. POSIX, Linux syscall ABI, Windows Win32 API, macOS 系统框架都承载了大量兼容性成本. 很多 OS 设计看起来不够优雅, 背后常常是历史兼容的结果.
Unix-like 指一类遵循 Unix 设计传统的系统, 包括 Linux, macOS, FreeBSD, OpenBSD 等. 它们不一定来自同一份源码, 但在文件系统, 进程模型, 权限模型, Shell 工具和系统接口上有很多相似之处.
Unix-like 系统里有几个非常重要的思想. 第一是"一切皆文件", 很多资源都可以通过文件描述符读写. 第二是小工具组合, 一个命令做好一件事, 再通过管道组合. 第三是文本配置和命令行自动化. 第四是明确区分普通用户和特权用户.
这也是为什么很多开发, 服务器, 编译和科研环境都偏向 Linux/Unix-like. 它们的接口稳定, 自动化能力强, 工具生态丰富, 也更接近很多底层系统课程和开源项目的默认环境.
POSIX 是一组操作系统接口标准, 主要定义类 Unix 系统应该提供哪些 API 和命令行为. 它不是某一个操作系统, 而是一套约定. fork, exec, open, read, write, pthread, 文件权限, Shell 命令等很多概念都和 POSIX 有关.
POSIX 的价值是可移植性. 如果一个程序只依赖 POSIX 接口, 它理论上可以在多个 Unix-like 系统上比较容易地移植. 当然现实里仍然会有 Linux 扩展, BSD 差异, macOS 差异, glibc/musl 差异, 但 POSIX 至少提供了一层共同语言.
Windows 不是 POSIX 原生系统, 它有自己的 Win32/NT API 传统. 这就是为什么很多 Linux 命令和脚本不能直接在 Windows cmd 或 PowerShell 里运行. WSL 的出现, 本质上就是在 Windows 上提供一个更接近 Linux 的用户环境.
内核是操作系统最核心的部分. 它运行在最高权限级别, 负责管理硬件和系统资源. 应用程序不能随便直接操作硬件, 而是通过系统调用请求内核代办.
内核通常负责这些事情: 创建和销毁进程, 调度线程, 分配内存, 管理页表, 处理文件系统, 控制设备驱动, 收发网络包, 检查权限, 响应中断, 提供系统调用接口. 你可以把内核理解成系统里的资源管理员和裁判. 它不是普通库, 因为它有普通程序没有的硬件权限.
宏内核 (Monolithic Kernel) 把大部分核心服务都放在内核空间里, 例如进程调度, 内存管理, 文件系统, 网络协议栈, 设备驱动等. Linux 就是典型宏内核. 它的优势是性能好, 模块之间调用成本低, 工程实现直接. 缺点是内核代码量大, 一个驱动 bug 可能影响整个系统稳定性.
微内核 (Microkernel) 则尽量把内核做小, 只保留最核心的调度, IPC, 基本内存管理等功能, 把文件系统, 驱动, 网络栈等服务放到用户态进程里. 它的优势是隔离更强, 某个服务崩溃不一定拖垮整个系统. 缺点是组件之间需要更多 IPC, 设计和性能优化更复杂.
现实系统经常不是纯粹二选一. macOS 的 XNU 内核常被称作 hybrid kernel, 它融合了 Mach 微内核思想和 BSD 组件. Windows NT 也有 hybrid 设计. 理解宏内核和微内核的意义, 不是为了给系统贴标签, 而是理解"哪些东西放在内核里"这个设计选择会影响性能, 稳定性和安全边界.
现代操作系统通常把内存和 CPU 权限分成用户空间和内核空间. 普通应用运行在用户空间, 内核运行在内核空间. 用户态程序不能直接执行特权指令, 不能随便访问内核内存, 也不能直接控制硬件.
这种边界非常重要. 如果所有程序都能直接访问硬件和系统内存, 一个普通 bug 就可能把整个系统弄崩. 用户态/内核态的隔离让普通程序出错时通常只崩掉自己, 不会立刻破坏整个系统.
系统调用是用户态程序请求内核服务的入口. 例如读文件, 写文件, 创建进程, 分配映射内存, 发送网络包, 获取时间, 都需要通过系统调用或由库函数间接触发系统调用.
例如 C 程序调用:
read(fd, buffer, size);表面上是调用 C 标准库或 libc 封装, 背后会进入内核, 让内核检查文件描述符是否合法, 当前进程有没有权限, 数据是否在 page cache 里, 是否需要发起磁盘 I/O. 内核完成后再返回用户态.
系统调用比普通函数调用更重, 因为它涉及用户态和内核态切换, 权限级别变化, 参数检查和内核路径执行. 一般不需要因为这个就害怕 syscall, 但理解它的成本有助于解释为什么高性能程序会减少频繁小 I/O, 使用 buffer, batch, async I/O 或 mmap.
上下文切换指 CPU 从运行一个执行上下文切换到另一个执行上下文. 这里的上下文可以是进程, 线程, 或从用户态切到内核态再切回来. OS 需要保存当前上下文的寄存器, 程序计数器, 栈指针等状态到一块内存区域中, 然后从另一块内存区域加载另一个上下文的状态.
上下文切换不是免费的. 它会消耗 CPU 时间, 还可能破坏缓存局部性. 一个系统如果创建了过多线程, 或者线程频繁阻塞唤醒, 可能大量时间都花在切换上, 而不是实际计算上.
不过上下文切换也是多任务系统的基础. 没有它, 一个程序占住 CPU 后其他程序就无法运行. OS 的调度器会在公平性, 响应速度和吞吐量之间做平衡.
中断是硬件或 CPU 向内核发出的"请立即处理某件事"的信号. 键盘输入, 网卡收到数据包, 磁盘 I/O 完成, 定时器到期, 都可能触发中断.
中断让 OS 不需要一直傻等硬件. 例如磁盘读请求发出去以后, CPU 可以去运行别的进程. 等磁盘完成后, 硬件通过中断通知内核, 内核再唤醒等待这个 I/O 的进程.
定时器中断尤其重要, 因为它支撑抢占式调度. 即使某个程序没有主动让出 CPU, 定时器中断也会周期性打断它, 让内核有机会决定是否切换到另一个任务.
进程是操作系统对"正在运行的程序"的抽象. 一个可执行文件放在磁盘上只是文件; 当它被加载到内存, 拥有自己的地址空间, 打开的文件描述符, 线程, 权限和运行状态时, 它就成为进程.
每个进程通常有自己的虚拟地址空间. 这意味着进程看到的地址不直接等于物理内存地址. 进程 A 的 0x1000 和进程 B 的 0x1000 可以映射到完全不同的物理页. 这样既方便程序编写, 也提供隔离.
一个典型进程地址空间大致包含:
高地址
栈 (stack): 函数调用, 局部变量
共享库映射区
堆 (heap): malloc/new 动态分配
BSS: 未初始化全局变量
Data: 已初始化全局变量
Text: 程序代码
低地址这只是简化图. 真实系统还会有 ASLR, memory mapped files, guard pages, thread stacks, vDSO 等细节. 但初学者先理解栈和堆的区别就很有帮助. 栈主要由函数调用自动管理, 堆主要由程序通过 allocator 动态申请释放.
OS 会为每个进程维护一份进程控制信息, 在 Linux 里常说 task_struct. 它记录进程 ID, 状态, 优先级, 地址空间, 打开的文件, 信号处理, 用户权限, 父子关系等.
进程状态通常可以简化成几类: running, ready, blocked, stopped, zombie. Running 表示正在 CPU 上运行; ready 表示可以运行但还没轮到; blocked 表示在等待 I/O, 锁或某个事件; zombie 表示进程已经结束但父进程还没回收退出状态.
Unix-like 系统里创建新进程的经典方式是 fork + exec. fork 复制当前进程, exec 把当前进程映像替换成另一个程序. Shell 执行命令时经常就是这样做的: 先 fork 出一个子进程, 再在子进程里 exec 目标命令.
进程调度解决的问题是: 就绪队列里有很多任务, CPU 核心数量有限, 下一刻应该运行谁. 早期或教学系统里常见调度算法包括 FCFS, SJF, Round Robin, Priority Scheduling. 真实系统会更复杂, 需要考虑交互响应, CPU 密集任务, I/O 密集任务, 多核负载均衡, NUMA, 优先级, 实时任务等.
现代通用操作系统通常使用抢占式调度. 抢占式的意思是, 一个任务即使不主动让出 CPU, 内核也可以通过定时器中断拿回控制权, 然后切换到另一个任务. 这样可以避免某个普通程序无限占用 CPU, 也让桌面系统和服务器能同时响应多个任务.
非抢占式调度则依赖任务主动让出 CPU, 例如主动阻塞, 主动 yield, 或执行完一段任务. 它实现简单, 上下文切换点可控, 但如果任务不合作, 系统响应性会很差. 协程运行时里经常能看到协作式调度思想.
Linux 的普通任务调度器长期使用 CFS (Completely Fair Scheduler) 思想. 它不是简单给每个进程固定时间片, 而是尝试让每个可运行任务获得相对公平的 CPU 时间. nice 值, cgroups, realtime policy, CPU affinity 等配置都可以影响调度.
抢占式调度带来的代价是并发不确定性. 线程可能在任意时刻被切走, 共享数据如果没有同步保护, 就会出现 race condition. 这也是为什么锁, 原子操作和条件变量如此重要.
进程之间默认是隔离的, 不能直接读写彼此地址空间. 如果它们需要协作, 就需要 IPC. 常见 IPC 包括 pipe, named pipe, Unix domain socket, TCP socket, shared memory, message queue, signal, file lock 等.
Pipe 适合单向数据流, Shell 管道就是最常见例子:
cat access. log | grep ERROR | wc -lShared memory 适合高性能共享数据, 因为多个进程可以映射同一段物理内存. 但它也带来同步问题, 需要配合 mutex, semaphore 或 atomic 操作. Socket 则适合更通用的通信, 本机和跨机器都可以使用.
IPC 的核心取舍是性能, 复杂度和隔离性. 共享内存快, 但同步复杂; Socket 抽象清楚, 但开销更大; 文件简单, 但实时性和一致性要小心.
同步解决的是多个执行单元访问共享资源时如何保持正确性. 如果两个进程或线程同时修改同一份数据, 最终结果可能取决于它们执行顺序, 这就是竞态条件.
临界区是指一段访问共享资源的代码. 同步机制的目标是控制谁能进入临界区, 什么时候等待, 什么时候唤醒, 以及如何避免死锁和饥饿.
进程同步和线程同步的概念很接近, 只是进程之间共享内存不如线程天然. 很多同步原语既可以用于线程, 也可以通过共享内存或内核对象用于进程.
线程是进程内部的执行流. 一个进程可以有多个线程, 它们共享同一个进程地址空间, 但每个线程有自己的寄存器状态, 栈和线程本地存储.
进程之间隔离更强, 通信成本更高. 线程之间共享内存, 通信方便, 但也更容易互相踩. 一个线程写坏共享数据, 同进程其他线程都会受影响; 一个线程触发进程崩溃, 通常整个进程都会退出.
可以这样理解: 进程像不同房间, 每个房间有自己的家具; 线程像同一个房间里的多个人, 共享桌子和工具. 共享让协作更快, 也让冲突更容易发生.
用户态看到的线程库, 例如 POSIX pthread, C++ std::thread, Java Thread, Go runtime 的 M
线程适合 I/O 并发, 后台任务, 多核计算等场景. 但线程不是越多越好. 线程太多会带来调度开销, 内存开销, 锁竞争和调试困难.
协程是一种更轻量的并发抽象. 它通常运行在用户态, 由语言运行时或库来调度. 协程不一定由 OS 内核直接感知, 因此创建和切换成本通常低于线程.
线程由内核调度, 可以被抢占. 协程通常由运行时调度, 很多协程模型是协作式的, 也就是协程在 await, yield, I/O 等点主动让出执行权.
协程很适合大量 I/O 并发. 例如一个 Web server 同时处理很多网络连接, 每个请求大部分时间都在等数据库或网络返回. 用一个线程对应一个请求会消耗大量线程资源, 用协程则可以让少量线程承载大量逻辑任务.
但协程不是魔法. 如果一个协程里执行长时间 CPU 密集计算, 又不主动让出控制权, 它可能阻塞整个 event loop. JavaScript 的主线程, Python asyncio, Rust async 都会遇到类似问题.
有栈协程有自己的调用栈, 可以在深层函数调用中挂起, 恢复时继续使用原来的栈. 它的编程模型接近线程, 但每个协程需要一段栈空间.
无栈协程通常由编译器把 async 函数转换成状态机. 它不保存完整调用栈, 而是保存必要状态. Rust async, C++20 coroutine, JavaScript async/await 都更接近这种模型. 无栈协程内存开销更可控, 但实现和类型系统会更复杂.
从 OS 角度看, 协程更多属于用户态运行时设计. 它们最终仍然要运行在 OS 线程上, 也仍然要通过系统调用完成真正的 I/O.
并发程序的困难不在于"同时做很多事"这个目标, 而在于共享状态. 只要多个执行单元可能同时读写同一份数据, 就需要同步机制. 锁是最常见的同步工具之一.
互斥锁 (mutex) 的含义是同一时间只允许一个线程进入临界区. 拿不到锁的线程通常会阻塞, 由 OS 挂起, 等锁释放后再唤醒.
自旋锁 (spinlock) 则是不阻塞, 而是在循环里不断检查锁是否可用. 它适合等待时间极短, 且不能睡眠的场景, 例如某些内核临界区. 如果等待时间较长, 自旋锁会浪费 CPU.
用户态程序里通常优先使用 mutex, 不要轻易手写 spinlock. spinlock 看起来简单, 但涉及内存序, CPU cache, preemption 等细节, 用错很容易让性能更差.
死锁是指多个执行单元互相等待对方释放资源, 导致谁都无法继续. 经典例子是线程 A 拿了锁 1 等锁 2, 线程 B 拿了锁 2 等锁 1.
死锁通常需要四个条件: 互斥, 持有并等待, 不可抢占, 循环等待. 避免死锁的常见办法是固定加锁顺序, 缩小临界区, 避免持锁时调用复杂外部代码, 使用 timeout 或 try-lock, 或用更高层的并发结构减少共享状态.
调试死锁时, 关键是找到每个线程卡在哪里, 持有哪些锁, 正在等待哪些锁. Debugger, thread dump, gdb, pstack, Java thread dump, Go goroutine dump 都可以帮助定位.
读写锁允许多个读者同时进入, 但写者需要独占. 它适合读多写少的场景. 例如配置表大部分时间被读取, 偶尔更新一次.
读写锁并不总是比 mutex 快. 如果写操作频繁, 或读临界区很短, 读写锁的额外管理成本可能不划算. 有些实现还会出现写者饥饿或读者饥饿, 需要公平策略.
条件变量用于等待某个条件成立. 它通常和 mutex 配合使用. 一个线程发现条件不满足时, 释放锁并睡眠; 另一个线程修改状态后通知它醒来.
典型模式是生产者消费者. 消费者发现队列为空, 就等待条件变量; 生产者放入数据后通知消费者. 使用条件变量时通常要在循环里检查条件, 而不是用 if, 因为可能有虚假唤醒, 或醒来后条件又被其他线程改变.
信号量可以理解成带计数的同步原语. 计数大于 0 时可以获取, 获取后计数减一; 释放时计数加一. 它适合控制同时访问某资源的数量.
例如一个连接池最多允许 10 个并发连接, 就可以用初始值为 10 的 semaphore 控制. mutex 可以看成一种特殊的二值互斥, 但 semaphore 更适合资源计数.
原子操作是不可被其他线程观察到中间状态的操作. CPU 提供一些原子读改写指令, 例如 compare-and-swap, fetch-add. 语言标准库通常把它们封装成 atomic 类型.
原子操作常用于计数器, lock-free 数据结构, 引用计数, 状态标志等. 但原子操作不只是"不会被打断"这么简单, 还涉及内存序 (memory ordering). 初学者使用时建议优先使用语言标准库的高层同步工具, 只有在确实需要时再深入 atomic 和 memory model.
内存管理是 OS 最核心也最容易让初学者困惑的部分. 你在程序里看到的是变量, 指针, 对象, 数组; 操作系统和硬件看到的是虚拟地址, 页表, 物理页帧, cache, TLB, page fault.
物理内存被分成固定大小的块, 通常称为页帧 (page frame). 虚拟地址空间也被分成页 (page). 页表负责记录虚拟页到物理页帧的映射.
当程序访问某个虚拟地址时, CPU 需要把它翻译成物理地址. 这个翻译过程依赖页表. 因为每个进程有自己的页表, 所以不同进程可以拥有彼此独立的虚拟地址空间.
页表还记录权限位, 例如这一页是否可读, 可写, 可执行, 是否属于用户态, 是否已在物理内存中. 访问违反权限时会触发异常, 例如 segmentation fault.
MMU 是 CPU 里负责地址翻译和内存保护的硬件单元. 每次程序访问内存, MMU 会根据当前页表把虚拟地址转换成物理地址, 并检查权限.
没有 MMU 或不启用虚拟内存时, 程序更接近直接使用物理地址. 很多嵌入式系统或微控制器可能没有完整 MMU, 因此它们的内存隔离能力和通用操作系统不同. 这也是为什么 Linux 这种通用 OS 依赖硬件提供的内存保护能力.
页表查询如果每次都访问内存, 成本会很高. TLB 是 MMU 旁边的一小块缓存, 用来缓存最近使用的虚拟页到物理页映射.
TLB 命中时地址翻译很快; TLB miss 时需要走页表查询, 成本更高. 上下文切换, 大内存随机访问, 页表变化都可能影响 TLB 效率. Huge pages 的一个好处就是减少页数量, 提高 TLB 覆盖范围.
内存段是早期和某些架构中常见的内存管理方式, 把程序分成代码段, 数据段, 栈段等. 现代 x86-64 通用系统主要依赖分页, segmentation 的作用已经弱化, 但"代码段, 数据段, BSS, 堆, 栈"这些说法仍然常见.
在程序布局里, text segment 存代码, data segment 存已初始化全局变量, BSS 存未初始化全局变量, heap 存动态分配, stack 存函数调用栈. 理解这些区域有助于解释栈溢出, 全局变量生命周期, 堆内存泄漏等问题.
内存分配分两层看. 第一层是操作系统如何管理物理页和虚拟内存区域. 第二层是用户态 allocator 如何在进程堆里满足 malloc, free, new, delete 这类请求.
内核管理物理页时常用 buddy allocator 思想. 它把空闲内存按 2 的幂大小分组, 需要大块内存时找合适阶数的块, 需要小块时把大块拆成两个 buddy; 释放时如果相邻 buddy 也空闲, 就合并成更大块. 这个算法的好处是合并和拆分相对简单, 能处理不同大小连续页需求. 缺点是可能产生内部碎片, 因为分配大小按 2 的幂对齐.
内核还会使用 slab/slub/slob 这类对象缓存分配器. 很多内核对象大小固定, 例如 inode, task_struct, socket buffer. 与其每次从页分配器里切小块, 不如为常见对象建立 cache, 反复复用已经初始化或部分初始化的对象. 这样可以提高性能, 减少碎片.
用户态堆分配器要解决的问题是: 程序不断 malloc 不同大小的块, 又以不可预测顺序 free, 如何快速找到合适空闲块, 并尽量减少碎片. 最简单的办法是 bump allocator, 只维护一个指针, 每次分配就向前移动. 它极快, 但不能单独高效处理任意顺序释放, 适合 arena 或临时内存池.
通用 allocator 常用 free list. 释放的块会被挂到空闲链表里, 下次分配时从空闲块中寻找. 寻找策略可以是 first fit, best fit, next fit 等. First fit 找到第一个够大的块就用, 速度快但可能产生碎片; best fit 找最接近大小的块, 理论上减少浪费, 但搜索成本更高, 也不一定总是更好.
现代 allocator 通常会按大小分级, 也就是 size class. 小对象走固定大小 class, 大对象走单独路径, 甚至直接用 mmap 向内核申请大块虚拟内存. glibc 的 ptmalloc, jemalloc, tcmalloc, mimalloc 都在这个方向上做了很多优化, 包括线程本地缓存, arena, fast bin, per-CPU cache 等.
内存分配里有两个重要问题: 内部碎片和外部碎片. 内部碎片是你申请 33 字节, allocator 给了 48 或 64 字节, 多出来的部分浪费在块内部. 外部碎片是总空闲内存很多, 但分散成小块, 找不到足够大的连续块.
理解 allocator 能解释很多现象. 程序 free 了内存, RSS 不一定马上下降, 因为 allocator 可能把内存留在进程内部复用, 不立刻还给 OS. 频繁分配小对象可能导致碎片和锁竞争. 多线程程序可能因为 allocator arena 变多而占用更多内存. C/C++ 里 double free, use-after-free, memory leak, heap corruption 都和堆管理有关.
虚拟内存让每个进程看到一大片连续地址空间, 但这些虚拟地址不需要一开始都对应真实物理内存. 内核可以按需分配物理页, 可以把不常用页面换出到磁盘, 可以把同一个文件映射到多个进程, 也可以用 copy-on-write 延迟复制.
例如 fork 时, 父子进程一开始可以共享同一批物理页, 标记为只读. 如果其中一个进程写某一页, CPU 触发 page fault, 内核再复制这一页, 让两个进程各自拥有自己的版本. 这就是 copy-on-write, 它让 fork 变得更便宜.
Page fault 不一定是错误. 它只是表示当前访问的虚拟页没有以所需方式映射到物理内存. 合法 page fault 可以由内核处理, 例如按需加载文件页, 分配匿名页, 执行 copy-on-write. 非法访问才会变成 segmentation fault.
mmap 可以把文件或匿名内存映射到进程地址空间. 映射文件后, 程序可以像访问内存一样访问文件内容, 内核负责按需把文件页读入内存, 并在需要时写回.
内存映射常用于加载共享库, 大文件处理, 进程间共享内存, allocator 大块分配等. 它的优势是减少显式 read/write, 利用 page cache 和按需加载. 缺点是错误处理和访问模式要更小心, 例如访问超出文件大小的映射区域可能触发信号.
文件系统负责把磁盘或其他存储设备上的块组织成文件和目录. 应用看到的是路径, 文件名, 目录树, 权限, 时间戳; 文件系统内部管理的是 inode, block, extent, journal, metadata, page cache 等.
Linux 里有 VFS (Virtual File System) 层. 它给上层提供统一接口, 让 open/read/write 可以作用于 ext4, XFS, Btrfs, NFS, tmpfs, procfs 等不同文件系统. VFS 是"一切皆文件"能成立的重要原因之一.
路径解析也由内核处理. 当你访问 /home/kai/a.txt, 内核需要从根目录开始逐级查找目录项, 检查权限, 找到 inode, 再通过文件系统驱动定位数据块. 为了性能, 内核会缓存目录项, inode 和文件页.
常见 Linux 文件系统包括 ext4, XFS, Btrfs. ext4 稳定通用, XFS 擅长大文件和高并发 I/O, Btrfs 提供 copy-on-write, snapshot, checksum 等高级功能. macOS 常用 APFS, Windows 常用 NTFS.
还有一些不是传统磁盘文件系统的"文件系统". tmpfs 存在内存里, 常用于 /tmp 或共享内存. procfs 通常挂载在 /proc, 暴露进程和内核信息. sysfs 挂载在 /sys, 暴露设备和内核对象. 这些虚拟文件系统让用户态可以通过文件接口查看和配置系统状态.
文件系统设计要考虑一致性. 如果系统写文件写到一半突然断电, 元数据和数据可能不一致. Journaling 文件系统会先记录日志, 再提交实际修改, 以便崩溃后恢复到一致状态.
NFS 让远程机器上的目录像本地目录一样挂载使用. 实验室服务器, HPC 集群, EDA 环境里经常使用 NFS 共享 home directory 或项目目录.
NFS 的好处是多台机器共享同一份文件, 用户体验简单. 缺点是网络延迟, 缓存一致性, 文件锁, 权限映射和服务器可用性都会影响表现. 一个程序在本地 SSD 上很快, 放在 NFS 上可能慢很多, 尤其是频繁创建小文件或扫描大目录时.
如果你在服务器上遇到命令突然卡住, ls 某个目录很慢, 或构建系统处理大量小文件异常慢, 不要只怀疑 CPU. 也可能是 NFS 或网络存储问题.
驱动是内核和硬件设备之间的适配层. 硬件设备各有自己的寄存器, 中断, DMA 方式和协议, 应用程序不应该直接理解这些细节. 驱动把设备包装成 OS 能管理的接口, 例如块设备, 字符设备, 网络设备, GPU 设备.
Linux 里很多设备会出现在 /dev 下, 例如 /dev/sda, /dev/ttyUSB0, /dev/nvidia0, /dev/null. 这些路径不是普通文件, 而是设备文件. 应用打开它们时, 背后会调用对应驱动.
DKMS (Dynamic Kernel Module Support) 用于在内核版本变化时自动重新构建外部内核模块. 很多第三方驱动, 例如某些 GPU, 虚拟化或硬件采集卡驱动, 需要和当前内核版本匹配. 内核升级后, 模块如果不重新编译, 可能加载失败.
这解释了为什么 Linux 上升级内核后, 显卡驱动, Wi-Fi 驱动或某些实验设备驱动可能突然坏掉. 不是应用程序变了, 而是内核 ABI 和模块匹配关系变了.
设备树常见于 ARM 和嵌入式系统. 它用一种数据结构描述板子上有哪些硬件设备, 它们的地址, 中断号, 时钟, GPIO, 总线连接等信息. 内核启动时读取设备树, 知道应该加载和初始化哪些设备.
在 PC 上, 很多硬件可以通过 PCI/ACPI 等机制自动发现. 但在嵌入式板子上, 硬件布局常常是板级设计决定的, 内核需要设备树告诉它"这块板子长什么样". 做 FPGA SoC, 嵌入式 Linux, 驱动开发时经常会接触设备树.
Linux 驱动可以编进内核, 也可以作为模块动态加载. 模块文件通常是 . ko. 可以用 lsmod 查看已加载模块, 用 modprobe 加载模块, 用 dmesg 查看内核日志.
模块化的好处是灵活. 不需要把所有驱动都静态编进内核, 也可以在不重启的情况下加载某些功能. 缺点是模块和内核版本, 配置, 符号导出需要匹配.
有些驱动逻辑可以放在用户态完成, 例如通过 libusb 控制 USB 设备, 通过 FUSE 实现用户态文件系统, 通过 DPDK 绕过内核网络栈做高性能包处理. 用户态驱动的优势是开发和调试更容易, 崩溃时不一定拖垮整个内核. 缺点是性能, 权限和接口能力可能受限制.
OS 设计里经常需要在内核态和用户态之间权衡. 放在内核态性能和权限更强, 但风险更大; 放在用户态安全和开发体验更好, 但可能需要更多上下文切换或额外机制.
操作系统提供网络协议栈, 让应用程序可以用 Socket 发送和接收数据, 而不是自己处理网卡寄存器, Ethernet, IP, TCP, UDP 的全部细节.
一个简化的网络发送路径是:
应用程序 -> Socket API -> TCP/UDP -> IP -> 网卡驱动 -> 网卡硬件 -> 网络接收方向则反过来. 网卡收到包后触发中断或轮询机制, 驱动把包交给内核网络栈, 网络栈解析协议头, 找到对应 Socket, 最后唤醒等待数据的应用.
TCP 提供可靠字节流, 负责重传, 拥塞控制, 顺序保证. UDP 提供无连接数据报, 更简单, 延迟低, 但不保证可靠性. 应用选择 TCP 还是 UDP, 会影响 OS 网络栈路径, buffer 管理和性能调优方式.
Socket 是应用使用网络的主要接口. 服务器通常 bind 一个地址和端口, listen, accept 客户端连接; 客户端 connect 到服务器地址. 读写 Socket 在很多方面像读写文件描述符, 这正是 Unix-like 抽象的力量.
常见问题如 connection refused, timeout, address already in use, permission denied 都可以从 OS 角度解释. connection refused 往往表示目标主机可达但端口没有服务监听; timeout 可能是包丢了, 防火墙丢弃, 路由不可达; address already in use 表示端口已被占用; 低端口绑定失败可能是权限不足.
Network namespace 让不同进程看到不同的网络设备, IP 地址, 路由表和端口空间. 容器网络就大量依赖这个机制. 一个容器里看到的 eth0 和宿主机上的真实网卡不一定是同一个东西, 它可能是 veth pair 的一端.
Network namespace 解释了为什么容器里监听 localhost 不等于宿主机能访问, 为什么 Docker 需要端口映射, 为什么不同容器可以各自使用同一个端口. 它本质上是 OS 对网络视图的隔离.
权限管理分很多层. CPU 有特权级, OS 有用户和组, 文件系统有权限位和 ACL, Linux 有 capabilities, 企业系统还有 RBAC, 某些系统还有 SELinux/AppArmor 这类强制访问控制. 它们共同解决一个问题: 谁可以对什么资源做什么操作.
CPU 硬件通常提供特权级. 在 x86 上常说 Ring 0 到 Ring 3, 其中内核运行在 Ring 0, 普通应用运行在 Ring 3. Ring 0 可以执行特权指令, 修改页表, 配置中断, 访问硬件; Ring 3 不能直接做这些事.
虚拟化场景里还会看到 VMX root/non-root, EL0/EL1/EL2 这类说法. ARM 架构常说 EL0 是用户态, EL1 是内核态, EL2 是 hypervisor, EL3 是 secure monitor. 不同架构名字不同, 核心思想都是把权限分层, 防止低权限代码随便控制高权限资源.
这类 CPU 特权级和 Linux 文件权限不是一回事. 普通用户即使对某个文件有读写权限, 也仍然运行在用户态; root 用户权限很大, 但用户态 root 程序仍然需要通过 syscall 进入内核完成特权操作.
Unix-like 系统的基础权限模型是用户, 组和其他人. 文件有 read, write, execute 三类权限. 目录的 execute 权限表示能不能进入或穿过这个目录, 这点初学者经常忽略.
例如:
-rwxr-xr--可以拆成三组: owner, group, others. owner 有 rwx, group 有 r-x, others 有 r--. 权限模型看起来简单, 但已经能覆盖很多基本场景.
ACL 是对传统 Unix 权限的扩展. 传统权限只能给 owner, group, others 三类对象设置权限, ACL 可以给多个具体用户或组设置更细粒度规则.
在共享服务器和团队目录里, ACL 很有用. 比如一个文件属于某个用户, 但还想单独给另一个用户读权限, 不想改变整个 group 权限, 就可以用 ACL.
RBAC 常见于应用系统, 云平台和企业权限管理. 它不是直接给某个用户绑定所有权限, 而是定义角色, 再把用户放进角色. 例如 admin, developer, viewer, operator. 角色拥有权限, 用户通过角色获得权限.
RBAC 的好处是管理规模更大时更清晰. 离开团队的人移除角色即可, 新成员加入角色即可. 不需要逐个资源手动配置权限.
Linux capabilities 把传统 root 权限拆成更小的能力. 例如绑定低端口, 修改网络配置, 加载内核模块, 改变文件所有者, 都可以对应不同 capability.
这解决了"要么普通用户, 要么 root"过于粗糙的问题. 一个程序可能只需要 CAP_NET_BIND_SERVICE 来绑定 80/443 端口, 不应该因此获得完整 root 权限. 容器安全也大量涉及 capabilities, 因为减少容器内进程能力可以降低逃逸和破坏风险.
虚拟化的目标是把一台物理机器抽象成多台隔离的虚拟机器, 或者把一组资源包装成独立环境. 它和操作系统关系很深, 因为虚拟化需要模拟或隔离 CPU, 内存, I/O, 网络和设备.
全虚拟化让 guest OS 以为自己运行在真实硬件上. Hypervisor 负责模拟或虚拟化硬件接口. 传统全虚拟化可以运行未修改的操作系统, 但模拟硬件会带来开销.
现代全虚拟化通常结合硬件辅助, 性能已经好很多. 你在 VMware, VirtualBox, QEMU/KVM, Hyper-V 里运行 Linux 或 Windows, 都是在类似思想下工作.
半虚拟化要求 guest OS 知道自己运行在虚拟化环境里, 并通过特殊接口和 hypervisor 协作. 这样可以避免某些昂贵的硬件模拟, 提高性能.
很多现代虚拟化设备驱动都有半虚拟化思想, 例如 virtio. Guest 不再假装自己在使用某个真实老网卡或磁盘控制器, 而是使用为虚拟化设计的高效接口.
硬件辅助虚拟化指 CPU 和平台提供专门机制支持虚拟化, 例如 Intel VT-x, AMD-V, ARM Virtualization Extensions. 它们让 hypervisor 可以更高效地运行 guest OS, 处理特权指令, 页表和中断.
内存虚拟化里常见二级地址转换, 例如 Intel EPT, AMD NPT. Guest 以为自己管理的是物理地址, 但这些 guest physical address 还要再映射到 host physical address. 硬件辅助让这个过程更高效.
容器不是传统虚拟机. 容器共享宿主机内核, 通过 namespaces 隔离进程看到的系统视图, 通过 cgroups 限制资源使用, 再配合 capabilities, seccomp, AppArmor/SELinux 等机制控制权限.
这就是为什么 Linux 容器必须依赖 Linux 内核. 容器里的 Ubuntu, Debian, Alpine 主要是用户空间文件系统和程序, 不是另一个完整内核. macOS/Windows 上运行 Linux Docker 容器时, 通常背后仍然有一个 Linux VM.
容器启动快, 占用资源少, 很适合开发环境和服务部署. 但容器隔离通常弱于虚拟机. 给容器 --privileged, 挂载 Docker socket, 挂载宿主机敏感目录, 都会显著扩大风险.
Hypervisor 是管理虚拟机的软件层. Type 1 hypervisor 直接运行在硬件上, 例如 ESXi, Hyper-V, Xen 的某些形态. Type 2 hypervisor 运行在宿主操作系统之上, 例如传统桌面 VirtualBox/VMware Workstation.
KVM 比较特殊. Linux 内核提供 KVM 模块, 让 Linux 本身具备 hypervisor 能力, QEMU 等用户态程序负责设备模拟和虚拟机管理. 实际使用中常说 QEMU/KVM.
硬件直通是把某个物理设备直接交给虚拟机使用, 常见于 GPU passthrough, 网卡 passthrough, FPGA card passthrough. 这样 guest 可以接近原生性能访问设备, 但配置复杂, 需要 IOMMU 支持, 也会影响宿主机对该设备的使用.
IOMMU 的作用类似内存管理里的 MMU, 但面向设备 DMA. 它限制设备能访问哪些物理内存, 防止设备或 guest 随便 DMA 到宿主机内存. 没有 IOMMU, 设备直通的安全边界会很弱.
操作系统知识看起来底层, 但它会不断出现在日常开发里. 程序突然很慢, 可能是频繁上下文切换, 锁竞争, page fault, I/O 等待, NFS 延迟, DNS 超时. 程序占用内存越来越高, 可能是泄漏, allocator 碎片, page cache, mmap 未释放, 或对象缓存. 服务连不上, 可能是端口没监听, 防火墙, network namespace, DNS, 路由或权限问题.
遇到问题时, 先问它属于哪一层. 是编译期, 链接期, 运行期? 是用户态错误, 还是内核日志里有驱动错误? 是 CPU 忙, 还是进程在等 I/O? 是进程崩了, 还是被 OOM killer 杀了? 是文件权限问题, 还是路径挂载问题? 这些问题听起来琐碎, 但它们能让排查从盲猜变成有方向.
你不需要一开始写一个内核, 但应该逐渐熟悉这些工具和现象: ps, top, htop, strace, lsof, dmesg, journalctl, vmstat, iostat, perf, gdb, ip, ss, /proc, /sys. 它们都是观察 OS 状态的窗口. 真正学会操作系统, 不是背完概念, 而是在真实问题里能把现象和底层机制连起来.