第二部分 操作系统基础概念详解与开发环境准备
第二部分 操作系统基础概念详解与开发环境准备第2章 操作系统总览2.1 操作系统的定义与本质计算机从通电到能运行各种程序中间需要一个大管家来协调硬件和软件之间的关系这个大管家就是操作系统。很多人每天都在使用Windows、macOS或者Linux但真正理解操作系统究竟做了什么的人并不多。要把操作系统讲清楚首先得从计算机本身说起。一台计算机的核心硬件包括中央处理器CPU、内存RAM、硬盘、显卡、网卡、键盘鼠标等各种输入输出设备。这些硬件各有各的脾气——CPU只认识二进制的机器指令内存需要按照特定的地址进行读写硬盘有自己的一套扇区寻址方式显卡则需要按照特定的协议才能在屏幕上画出图像。如果每个应用程序都需要自己直接跟这些硬件打交道那软件开发将变得极其痛苦而且极容易出错。想象一下你写一个文本编辑器还得自己实现磁盘驱动、键盘中断处理、屏幕像素绘制——这显然是不现实的。操作系统的出现就是为了解决这个问题。从最本质的角度来说操作系统扮演着两个核心角色第一个角色是硬件的抽象层。操作系统把复杂的硬件操作封装成简洁的接口供上层应用程序使用。比如你在程序里调用一个文件读取函数实际上操作系统替你完成了磁盘寻道、数据传输、缓存管理等一系列复杂操作。应用程序看到的是一个简单的文件概念而不需要知道数据到底存在磁盘的哪个扇区、用的是什么文件系统格式。第二个角色是资源的管理者。一台计算机上往往同时运行着几十甚至上百个程序它们都要争夺CPU时间、内存空间、磁盘带宽和网络资源。操作系统必须公平而高效地分配这些资源确保每个程序都能得到合理的运行机会同时防止某个程序霸占所有资源或者恶意破坏其他程序的数据。从历史发展来看操作系统经历了从无到有、从简到繁的过程。最早的计算机根本没有操作系统。上世纪四五十年代程序员使用穿孔卡片把程序输入计算机一次只能运行一个任务运行完了才能换下一个。这种方式效率极低计算机大量时间都在等待人工操作。为了提高效率人们发明了批处理系统把多个任务排成队列让计算机自动一个接一个地执行。后来又发展出分时系统允许多个用户通过终端同时使用一台计算机每个用户轮流获得一小段CPU时间由于切换速度很快每个用户都感觉自己在独占这台机器。到了上世纪七十年代UNIX操作系统的诞生成为操作系统发展史上的里程碑。UNIX由贝尔实验室的Ken Thompson和Dennis Ritchie开发它最大的贡献在于证明了操作系统可以用高级语言C语言来编写而不必全部使用汇编语言。这大大降低了操作系统开发的难度也使得操作系统可以方便地移植到不同的硬件平台上。UNIX的设计哲学——一切皆文件、小工具组合完成复杂任务——至今仍然深刻影响着整个计算机行业。再后来个人计算机的普及催生了面向普通用户的操作系统。微软的MS-DOS和后来的Windows系列让普通人也能使用计算机。苹果的Macintosh和后来的macOS则以优秀的图形界面著称。而Linux作为一个开源的类UNIX操作系统从1991年Linus Torvalds发布第一个版本以来已经发展成为服务器、嵌入式设备和超级计算机领域最主要的操作系统。Android手机系统的底层也是Linux内核。理解了操作系统是什么之后我们就能明白为什么学习操作系统的开发如此重要。它不仅能让你深入理解计算机的工作原理还能帮助你写出更高效、更可靠的应用程序。当你知道操作系统是如何调度进程、管理内存、处理中断的时候你对程序运行机制的理解就不再停留在表面。2.2 操作系统的架构与模块划分操作系统虽然功能复杂但从架构上来看可以分为几个相对独立的功能模块。理解这些模块的划分方式是学习操作系统设计的基础。进程管理模块进程是操作系统中最核心的概念之一。简单来说一个正在运行的程序就是一个进程。但进程和程序是不同的——程序是静态的代码和数据存储在磁盘上进程是程序运行时的动态实体包括代码、数据、堆栈、寄存器状态等。同一个程序可以同时运行多个进程比如你可以同时打开两个记事本窗口它们是同一个程序的两个不同进程。进程管理模块负责进程的创建、调度、同步、通信和销毁。其中进程调度是最关键的部分——在只有一个CPU核心的情况下如何让多个进程同时运行答案是时分复用也就是让每个进程轮流使用CPU每次使用一小段时间通常是几毫秒到几十毫秒然后切换到下一个进程。由于切换速度极快用户感觉所有程序都在同时运行。不同的调度算法会产生不同的效果。最简单的是轮转调度Round Robin每个进程获得相同的时间片。优先级调度则根据进程的重要程度分配不同的时间片高优先级的进程获得更多的CPU时间。Linux内核使用的完全公平调度器CFS则试图让每个进程获得的CPU时间都尽可能公平。Windows NT系列内核则采用了多级反馈队列调度算法既能保证交互式程序的响应速度又能让后台任务得到合理的执行时间。内存管理模块内存是计算机中除CPU外最宝贵的资源。内存管理模块负责跟踪每一块内存的使用状态——哪些被占用了、哪些是空闲的、每一块被哪个进程占用。现代操作系统普遍采用虚拟内存技术。每个进程都以为自己拥有一大片连续的内存空间在64位系统上理论上可以达到2的48次方甚至2的57次方字节但实际上这些虚拟地址会被内存管理单元MMU映射到物理内存的不同位置。这样做有几个好处第一每个进程的内存空间是隔离的一个进程无法直接访问另一个进程的内存这大大提高了系统的安全性和稳定性第二物理内存可以被更灵活地分配不需要每个进程都占用连续的物理内存第三当物理内存不够用时可以把一些暂时不用的内存页面交换到硬盘上即所谓的交换空间或页面文件需要时再换回来。Windows系统中你可能见过虚拟内存不足的提示macOS中也有内存压缩的机制来缓解物理内存紧张的情况。Linux则通过swap分区或swap文件来实现内存交换同时还有OOM Killer机制——当系统内存实在不够用时内核会选择杀死一些进程来释放内存虽然这种做法有些粗暴但总比整个系统崩溃要好。文件系统模块文件系统把磁盘上的原始数据块组织成人类可以理解的文件和目录结构。没有文件系统磁盘上的数据就是一堆无意义的0和1。不同的操作系统通常使用不同的文件系统格式。Windows主要使用NTFSNew Technology File System它支持大文件、文件权限控制、日志功能和数据压缩。macOS使用APFSApple File System专门为闪存存储优化支持快照、克隆和空间共享等高级特性。Linux生态中最常用的是ext4文件系统此外还有XFS、Btrfs等选择。Btrfs被称为Linux上的下一代文件系统支持快照、数据校验和透明压缩等功能。文件系统的设计直接影响着数据读写的性能和可靠性。一个好的文件系统需要考虑如何高效地利用磁盘空间、如何快速定位文件数据、如何在系统崩溃或突然断电后恢复数据的一致性。现代文件系统普遍采用日志技术Journaling在实际修改数据之前先把操作记录到日志中这样即使中途发生故障也能根据日志恢复到一致的状态。设备驱动模块计算机连接的外部设备种类繁多——键盘、鼠标、打印机、USB设备、网卡、声卡、显卡等。每种设备都有自己的通信协议和控制方式。设备驱动程序是操作系统与硬件设备之间的桥梁它把特定硬件的操作细节封装起来向上提供统一的接口。在Windows系统中设备驱动采用的是WDMWindows Driver Model或更新的WDFWindows Driver Framework模型。Linux系统中设备驱动以内核模块的形式存在可以动态加载和卸载。macOS则使用IOKit框架来管理设备驱动。设备驱动程序运行在内核态拥有最高的权限因此一个有缺陷的驱动程序可以导致整个系统崩溃。Windows的蓝屏死机BSOD和Linux的Kernel Panic很多时候就是由有问题的驱动程序引起的。这也是为什么微软推出了驱动签名机制只有经过认证的驱动程序才能在Windows上运行。网络协议栈现代计算机几乎都需要联网因此网络功能也是操作系统的重要组成部分。操作系统的网络协议栈实现了TCP/IP等网络协议让应用程序可以通过Socket接口方便地进行网络通信。Linux的网络协议栈是其内核中最复杂的子系统之一支持TCP、UDP、ICMP、IPv4、IPv6等众多协议。Windows的网络协议栈同样非常完善其Winsock接口是Windows平台网络编程的基础。操作系统的架构风格从整体架构来看操作系统主要有以下几种设计风格宏内核Monolithic Kernel把进程管理、内存管理、文件系统、设备驱动、网络协议栈等所有功能都放在内核空间中运行。Linux和传统的UNIX系统就是典型的宏内核。宏内核的优点是模块之间的通信开销小、效率高缺点是内核代码庞大、维护困难一个模块的错误可能导致整个系统崩溃。微内核Microkernel只把最基本的功能进程调度、进程间通信、中断处理放在内核中其他功能如文件系统、设备驱动、网络协议栈都作为用户态的服务进程运行。Mach、QNX、MINIX 3就是典型的微内核。微内核的优点是结构清晰、可靠性高——即使一个服务进程崩溃也不会影响内核和其他服务。缺点是用户态和内核态之间频繁的上下文切换会带来性能损失。混合内核Hybrid Kernel结合了宏内核和微内核的特点。Windows NT内核和macOS的XNU内核都属于混合内核。它们在设计上采用了微内核的思想但为了性能把一些关键的服务如文件系统和设备驱动放到了内核空间中运行。还有一种外内核Exokernel的设计思路由MIT提出它尽可能少地对硬件资源进行抽象让应用程序直接管理硬件资源。这种设计可以获得极高的性能但编程难度也大大增加目前主要停留在学术研究阶段。2.3 开发操作系统所需的前置知识编写一个操作系统哪怕只是一个最简单的原型也需要掌握不少基础知识。这并不是说你必须成为每个领域的专家但至少需要对以下几个方面有足够的了解。计算机体系结构操作系统是直接跟硬件打交道的软件因此你必须了解CPU的工作原理。对于x86-64架构也就是当今个人电脑和服务器上最常用的处理器架构你需要知道CPU有哪些寄存器、指令是如何执行的、中断和异常是怎么处理的、保护模式和长模式有什么区别等。x86架构的CPU有多种运行模式。最原始的是实模式Real Mode这是8086处理器的模式只能寻址1MB的内存空间。后来80386引入了保护模式Protected Mode支持32位寻址和内存保护功能。到了AMD64/Intel 64又增加了长模式Long Mode支持64位寻址理论上可以访问海量的内存空间。操作系统在启动时通常从实模式开始逐步切换到保护模式最终进入长模式。你还需要了解分页机制和分段机制。x86架构支持两种内存管理方式——分段把内存划分成不同长度的段分页把内存划分成固定大小的页通常是4KB。现代操作系统基本上都使用分页机制分段主要是为了向后兼容而保留。在64位长模式下分段功能被大幅简化分页成为唯一实际使用的内存管理方式。汇编语言虽然现代操作系统的大部分代码都是用C语言编写的但有一些工作只能用汇编语言来完成。比如操作系统的引导加载器Bootloader需要在没有任何高级语言运行环境的情况下执行所以必须用汇编来写。CPU模式切换、中断处理、上下文切换等底层操作也通常需要汇编代码。x86汇编有两种主要的语法风格Intel语法和ATT语法。Intel语法是Intel公司在官方手册中使用的风格指令格式是操作码 目标, 源ATT语法是UNIX/Linux世界中常用的风格指令格式是操作码 源, 目标而且操作数需要加上前缀符号寄存器加%立即数加$。这两种语法表达的内容完全相同只是写法不同。C语言C语言是操作系统开发的主力语言。UNIX是用C语言写的Linux内核是用C语言写的Windows内核的大部分代码也是C语言。C语言之所以适合写操作系统是因为它既有高级语言的抽象能力函数、结构体、指针又能进行底层的内存操作和位运算还能方便地嵌入汇编代码。开发操作系统时你使用的C语言跟平时写应用程序时有很大不同。首先你不能使用标准库函数——printf、malloc、fopen这些函数都依赖操作系统提供的服务而你正在写的就是操作系统本身所以这些函数都不存在。你需要自己实现打印输出、内存分配等基本功能。其次你需要使用很多GNU C的扩展特性比如内联汇编用来在C代码中嵌入汇编指令、属性声明用来控制变量和函数的存储方式和对齐方式等。链接器与编译工具链操作系统开发涉及很多底层的编译和链接操作。你需要了解ELF可执行文件格式、链接脚本的编写方法、交叉编译的概念等。链接脚本用来控制程序各个段代码段、数据段、BSS段等在内存中的布局这在操作系统开发中非常重要因为操作系统的代码必须被加载到特定的内存地址才能正确运行。调试技巧操作系统开发中最头疼的问题之一就是调试。你不能像调试普通应用程序那样使用GDB直接连接到你的操作系统进程上——因为GDB本身就是运行在操作系统上的应用程序。不过借助虚拟机和模拟器你可以在一定程度上解决这个问题。Bochs模拟器内置了调试功能可以让你单步执行操作系统代码、查看寄存器和内存内容。QEMU配合GDB的远程调试功能也是一种常用的调试方式。2.4 本书操作系统项目概览在正式动手编写操作系统之前有必要对我们要实现的操作系统做一个整体的规划。一个完整的操作系统项目包含的内容非常多从引导启动、内核初始化、中断处理、内存管理、进程调度到文件系统和设备驱动每一部分都需要精心设计和实现。我们要实现的是一个运行在x86-64架构上的64位操作系统。选择64位而不是32位不仅是因为64位已经成为当今计算机的标准配置更重要的是64位长模式在架构设计上比32位保护模式更加简洁——它取消了分段的大部分功能简化了内存管理的复杂度。这个操作系统将包含以下核心功能引导加载器负责从BIOS或UEFI手中接管计算机的控制权初始化CPU状态加载内核到内存中并跳转执行。内核核心包括中断和异常处理、物理内存管理、虚拟内存管理分页机制、进程管理和调度器。设备驱动至少包括键盘驱动、屏幕显示驱动和简单的磁盘驱动以便操作系统能够与用户进行基本的交互。系统调用接口为用户态程序提供访问内核功能的标准接口。这个操作系统不会像Linux或Windows那样功能完备它的目标是帮助你理解操作系统的核心原理和实现细节。当你亲手把这些功能一个个实现出来之后你对操作系统的理解将达到一个全新的层次。从商业操作系统的发展历程来看几乎所有成功的操作系统都是从一个小而精的内核逐步发展壮大的。Linux最初只是Linus Torvalds为自己的386电脑写的一个小内核第一个版本只有大约一万行代码。Windows NT最初也只是Dave Cutler带领的一个小团队从零开始设计的。所以不要因为我们的操作系统功能简单就觉得没有价值——理解原理永远比堆砌功能更重要。第3章 开发环境配置与基础工具3.1 虚拟化平台与开发系统平台搭建开发操作系统不同于开发普通应用程序。普通应用程序可以在你的日常操作系统上直接编译和运行但你正在开发的操作系统本身需要一个独立的运行环境。如果你每次测试都要重启电脑从你的操作系统启动不仅效率极低而且代码中的错误很可能导致硬件损坏虽然现代硬件有很多保护机制但还是存在风险。因此使用虚拟机或模拟器来运行和测试你的操作系统是必不可少的。3.1.1 VMware虚拟化软件的部署VMware是目前最成熟的商业虚拟化软件之一。VMware Workstation Pro面向Windows和Linux平台和VMware Fusion面向macOS平台都可以用来创建和管理虚拟机。对于操作系统开发来说VMware的优势在于它对x86-64架构的模拟非常接近真实硬件性能也很好。安装VMware本身并不复杂。在Windows系统上从VMware官网下载安装包后双击运行按照向导一步步操作即可。安装过程中需要注意的几点第一确保你的电脑BIOS中开启了硬件虚拟化支持Intel VT-x或AMD-V否则虚拟机的性能会大打折扣甚至无法运行64位客户机操作系统。第二如果你的电脑同时安装了Hyper-VWindows自带的虚拟化技术可能会与VMware产生冲突需要在Windows功能中禁用Hyper-V。安装好VMware之后你需要创建一个虚拟机来作为你的开发和测试环境。创建虚拟机时有几个参数需要设置处理器数量和核心数、内存大小、硬盘大小、网络连接方式等。对于操作系统开发来说分配1到2个CPU核心、2到4GB内存、20到40GB硬盘空间通常就够了。网络连接方式建议选择NAT模式这样虚拟机可以通过宿主机的网络连接上网方便下载开发工具和软件包。VMware在操作系统开发中有两个用途。第一个用途是运行开发环境——你可以在VMware中安装一个Linux发行版比如CentOS在里面进行代码编写和编译。第二个用途是运行你自己开发的操作系统——你可以创建一个空白虚拟机把你的操作系统镜像作为启动盘来引导。值得一提的是除了VMware之外VirtualBox也是一个不错的选择。VirtualBox是Oracle开发的开源虚拟化软件功能上与VMware Workstation相近而且完全免费。不过在某些细节上比如对硬件的模拟精度和调试支持方面VMware通常做得更好一些。从商业角度来看虚拟化技术已经成为现代IT基础设施的基石。VMware的ESXi被广泛用于企业级服务器虚拟化微软的Hyper-V也占据了重要的市场份额。在云计算领域AWS的EC2、阿里云的ECS底层都大量使用了虚拟化技术。KVMKernel-based Virtual Machine是Linux内核自带的虚拟化方案很多云服务商都在使用它。这些技术的核心原理都是一样的——通过软件模拟硬件环境让多个操作系统可以同时运行在一台物理机器上。3.1.2 编译环境CentOS 6的配置部署操作系统的编译需要一整套工具链包括C编译器GCC、汇编器NASM、as、链接器ld、二进制工具objcopy、objdump以及各种辅助工具make、dd等。虽然这些工具在大多数Linux发行版上都可以安装但为了保证编译环境的一致性和可重复性选择一个特定版本的Linux发行版是很有必要的。CentOSCommunity Enterprise Operating System是基于Red Hat Enterprise LinuxRHEL源码编译的开源发行版以稳定性著称广泛用于服务器环境。CentOS 6虽然已经比较老了但正因为如此它的软件包版本比较固定不容易出现因为工具版本差异而导致的编译问题。在VMware中安装CentOS 6的步骤如下。首先从CentOS的镜像站点下载CentOS 6的ISO安装镜像。由于CentOS 6已经结束了官方支持你可能需要从vault.centos.org这样的归档站点下载。下载完成后在VMware中创建一个新虚拟机选择稍后安装操作系统操作系统类型选择Linux - CentOS 64位。然后把下载好的ISO镜像挂载到虚拟机的光驱中启动虚拟机按照CentOS的安装向导完成安装。安装完成后需要配置开发环境。首先要安装GCC和相关的开发工具yum groupinstall Development Tools yum install gcc gcc-c make然后安装NASM汇编器。CentOS 6的软件仓库中可能没有NASM或者版本太旧你可能需要从NASM官网下载源码自行编译安装apachewget https://www.nasm.us/pub/nasm/releasebuilds/2.15.05/nasm-2.15.05.tar.gz tar xzf nasm-2.15.05.tar.gz cd nasm-2.15.05 ./configure make make install还需要安装一些其他工具比如用于创建磁盘镜像的dd命令通常已经预装、用于挂载文件系统镜像的mount命令、用于处理二进制文件的binutils工具集等。如果你觉得CentOS 6太旧不方便使用也可以选择其他发行版。Ubuntu、Fedora、Arch Linux都是不错的选择。关键是确保GCC、NASM、make、binutils等基础工具都能正确安装和运行。现在很多开发者甚至使用Docker容器来搭建编译环境这样可以在任何系统上获得一致的编译环境而不需要安装虚拟机。顺便说一句编译操作系统时通常需要使用交叉编译Cross-Compilation。所谓交叉编译就是在一种平台上编译出在另一种平台上运行的代码。虽然我们的开发环境和目标平台都是x86-64但由于我们编译出来的代码需要在一个裸机环境中运行没有操作系统和标准库的支持所以我们需要使用一个特殊的交叉编译工具链。这个工具链的目标平台是x86_64-elf也就是生成独立的ELF格式可执行文件不链接任何系统库。构建交叉编译工具链需要从源码编译binutils和GCC并指定目标平台为x86_64-elf。这个过程虽然有些繁琐但只需要做一次之后就可以一直使用了。3.1.3 Bochs硬件模拟器的使用Bochs是一个开源的x86硬件模拟器它完全通过软件来模拟x86 CPU和各种外围设备。与VMware和VirtualBox不同Bochs不使用硬件虚拟化加速而是逐条解释执行每一条x86指令。这使得Bochs的运行速度远远慢于VMware但换来了一个巨大的优势——内置的调试功能。Bochs的调试器可以让你像使用GDB调试普通程序一样调试你的操作系统。你可以设置断点、单步执行、查看寄存器内容、检查内存数据、跟踪代码执行流程。这对于操作系统开发来说简直是救命工具因为操作系统中的很多错误比如页表设置不正确、中断处理有误、内存越界等非常难以定位没有调试器的帮助几乎是不可能找到问题所在。安装Bochs有两种方式。一种是从发行版的软件仓库安装预编译的包不过很多发行版的Bochs没有启用调试功能。另一种是从源码编译这样可以确保开启调试支持。从源码编译Bochs的步骤大致如下apachewget https://sourceforge.net/projects/bochs/files/bochs/2.7/bochs-2.7.tar.gz tar xzf bochs-2.7.tar.gz cd bochs-2.7 ./configure --enable-debugger --enable-disasm --enable-x86-64 make make install其中--enable-debugger开启内置调试器--enable-disasm开启反汇编功能--enable-x86-64开启64位支持。使用Bochs需要创建一个配置文件通常叫bochsrc在里面指定虚拟机的硬件配置——CPU类型、内存大小、启动设备、磁盘镜像等。一个典型的bochsrc文件看起来像这样gradlemegs: 512 romimage: file/usr/local/share/bochs/BIOS-bochs-latest vgaromimage: file/usr/local/share/bochs/VGABIOS-lgpl-latest boot: disk ata0-master: typedisk, pathos.img, modeflat log: bochs.log这个配置指定了512MB的内存、标准的BIOS和VGA BIOS、从硬盘启动、硬盘镜像文件为os.img。当Bochs以调试模式启动时它会在执行第一条指令之前停下来等待你的调试命令。常用的调试命令包括b 0x7c00在地址0x7C00处设置断点BIOS加载引导扇区到这个地址c继续执行直到遇到断点s单步执行一条指令n单步执行但不进入函数调用r显示所有寄存器的值xp /16bx 0x7c00以十六进制显示从地址0x7C00开始的16个字节info gdt显示全局描述符表的内容info idt显示中断描述符表的内容page 0x100000查看虚拟地址0x100000的页表映射除了Bochs之外QEMU也是一个非常流行的硬件模拟器。QEMU支持多种处理器架构x86、ARM、MIPS、RISC-V等而且通过KVM加速后性能可以接近原生。QEMU配合GDB的远程调试功能也能实现断点调试只需要在启动QEMU时加上-s -S参数然后在另一个终端用GDB连接到localhost:1234即可。在实际的操作系统开发过程中很多开发者会同时使用Bochs和QEMU。Bochs用于需要精确调试的场合QEMU用于日常的快速测试。在最终发布前还需要在VMware或真实硬件上进行测试确保操作系统在更接近真实环境的条件下也能正常工作。3.2 汇编语言基础汇编语言在操作系统开发中的重要性怎么强调都不为过。虽然大部分内核代码可以用C语言编写但引导加载器、CPU模式切换、中断入口点、上下文切换等关键部分都必须使用汇编。而且在调试操作系统时你经常需要阅读反汇编代码来理解程序的实际行为。因此熟练掌握x86汇编是操作系统开发者的必备技能。3.2.1 ATT汇编风格与Intel汇编风格的比较x86汇编语言有两种主流的语法风格它们表达的内容完全相同只是书写方式不同。这种局面的形成有历史原因——Intel在它的处理器手册中使用Intel语法而ATT的UNIX系统则发展出了自己的汇编语法。由于GCC和GNU工具链默认使用ATT语法而NASM和Intel官方文档使用Intel语法作为操作系统开发者你最好两种语法都能读懂。下面通过几个例子来对比两种语法的区别。操作数顺序这是最容易搞混的地方。Intel语法的操作数顺序是目标在前源在后而ATT语法正好相反。Intel语法mov eax, 42把42放入eax寄存器ATT语法movl $42, %eax把42放入eax寄存器两条指令做的事情完全一样但写法截然不同。前缀符号ATT语法中寄存器名前面要加%符号立即数前面要加$符号。Intel语法中不需要任何前缀。Intel语法add eax, 10ATT语法addl $10, %eax操作数大小后缀ATT语法中指令助记符后面通常要加一个字母来表示操作数的大小b表示字节8位w表示字16位l表示双字32位q表示四字64位。Intel语法中操作数大小通常由寄存器名决定eax是32位rax是64位在某些需要明确指定大小的地方使用BYTE PTR、WORD PTR、DWORD PTR、QWORD PTR等修饰符。Intel语法mov DWORD PTR [ebx], 42ATT语法movl $42, (%ebx)内存寻址内存寻址方式的写法差异很大。x86的通用寻址公式是基址 变址 * 比例因子 偏移量。Intel语法mov eax, [ebx ecx*4 8]ATT语法movl 8(%ebx, %ecx, 4), %eaxATT语法的寻址格式是偏移量(基址, 变址, 比例因子)初看起来不太直观但习惯了之后其实也还好。跳转指令在远跳转和远调用方面两种语法的差异也很明显。Intel语法jmp far 0x08:0x100000ATT语法ljmp $0x08, $0x100000对于操作系统开发来说建议以Intel语法为主。原因有几个第一Intel和AMD的处理器手册都使用Intel语法你在查阅手册时不需要做语法转换第二NASM汇编器使用Intel语法而NASM在操作系统开发社区中的使用率非常高第三Intel语法通常被认为更加直观易读。但同时你也需要能读懂ATT语法因为GCC生成的汇编代码默认是ATT语法GDB的反汇编输出默认也是ATT语法不过可以切换到Intel语法。3.2.2 NASM汇编工具介绍NASMNetwide Assembler是一个免费、开源的x86汇编器使用Intel语法。它因为语法清晰、功能强大、跨平台支持好而在操作系统开发社区中广受欢迎。Linux内核虽然使用GASGNU AssemblerATT语法但很多教学性质的操作系统项目和引导加载器都选择NASM。NASM的基本用法很简单。假设你写了一个汇编源文件boot.asm可以用以下命令将其编译为纯二进制文件nasm -f bin boot.asm -o boot.bin其中-f bin指定输出格式为纯二进制没有任何文件头信息这在编写引导加载器时非常有用因为BIOS加载引导扇区时就是把磁盘第一个扇区的512字节原封不动地复制到内存0x7C00处执行。如果你需要生成ELF格式的目标文件用于后续与C代码链接可以使用nasm -f elf64 kernel_entry.asm -o kernel_entry.oNASM支持丰富的伪指令和宏功能。常用的伪指令包括db、dw、dd、dq定义字节、字、双字、四字数据resb、resw、resd、resq预留指定大小的未初始化空间equ定义常量times重复某个指令或数据定义org设置程序的起始地址section定义段global导出符号extern声明外部符号一个简单的引导扇区的NASM代码可能长这样nasm[org 0x7c00] ; BIOS把引导扇区加载到这个地址 [bits 16] ; 引导时CPU处于16位实模式 mov ah, 0x0e ; BIOS打印字符的功能号 mov al, H int 0x10 ; 调用BIOS中断 mov al, i int 0x10 jmp $ ; 无限循环 times 510-($-$$) db 0 ; 填充到510字节 dw 0xAA55 ; 引导扇区标志这段代码的功能非常简单——在屏幕上打印Hi两个字符然后进入无限循环。最后两行确保这个文件恰好是512字节并且以0xAA55结尾这是BIOS识别有效引导扇区的标志。NASM的宏功能也很强大可以用来减少重复代码。比如你可以定义一个打印字符串的宏nasm%macro print_string 1 mov si, %1 %%loop: lodsb or al, al jz %%done mov ah, 0x0e int 0x10 jmp %%loop %%done: %endmacro然后在代码中这样使用nasmprint_string msg msg: db Hello, OS!, 03.2.3 在汇编中调用C语言函数的方法操作系统的大部分功能最终要用C语言来实现但系统启动的最初阶段必须用汇编来完成。因此汇编代码需要能够调用C语言函数C语言函数有时也需要调用汇编实现的功能。理解汇编和C之间的调用约定Calling Convention是实现这种混合编程的关键。在x86-64架构的Linux系统上使用的是System V AMD64 ABI调用约定。这个约定规定了以下内容函数参数的传递方式前六个整型或指针参数依次通过RDI、RSI、RDX、RCX、R8、R9寄存器传递。如果参数超过六个多出来的参数通过栈来传递。浮点参数通过XMM0到XMM7寄存器传递。返回值的传递方式整型或指针返回值通过RAX寄存器传递。如果返回值是128位的高64位放在RDX中低64位放在RAX中。寄存器保存规则RBX、RBP、R12到R15是被调用者保存的寄存器Callee-saved也就是说如果一个函数要使用这些寄存器必须先把原来的值保存到栈上返回前再恢复。RAX、RCX、RDX、RSI、RDI、R8到R11是调用者保存的寄存器Caller-saved被调用的函数可以随意修改这些寄存器的值。栈对齐要求在调用一个函数之前栈指针RSP必须是16字节对齐的。这意味着在执行CALL指令时CALL会把8字节的返回地址压栈进入被调函数后RSP的值对16取余应该是8。下面是一个实际的例子。假设你在C语言中定义了一个内核入口函数cvoid kernel_main(void) { // 内核初始化代码 }那么在汇编中调用这个函数就很简单nasm[bits 64] [extern kernel_main] ; 声明外部符号 global _start _start: ; 设置栈指针 mov rsp, 0x200000 ; 调用C函数 call kernel_main ; 如果kernel_main返回了就停机 hlt反过来如果你在汇编中实现了一个函数需要被C代码调用比如一个读取端口数据的函数nasmglobal inb inb: ; 参数端口号在RDI中 mov dx, di ; IN指令要求端口号在DX中 in al, dx ; 从端口读取一个字节到AL movzx eax, al ; 零扩展到EAX返回值通过RAX传递 ret对应的C语言声明cextern unsigned char inb(unsigned short port);这样你就可以在C代码中直接调用inb(0x60)来读取键盘端口的数据了。在Windows系统上调用约定有所不同。Windows x64使用的是Microsoft x64 calling convention前四个参数通过RCX、RDX、R8、R9传递注意不是RDI、RSI等而且要求调用者为前四个参数在栈上预留32字节的影子空间Shadow Space即使参数是通过寄存器传递的。这些差异需要在跨平台开发时特别注意不过对于我们的操作系统开发来说我们只需要关注System V AMD64 ABI即可。链接汇编和C代码时需要使用链接器把它们组合成一个可执行文件。一个典型的编译和链接流程如下bashnasm -f elf64 boot.asm -o boot.o gcc -c -ffreestanding -mno-red-zone -mcmodellarge kernel.c -o kernel.o ld -T linker.ld -o kernel.bin boot.o kernel.o其中-ffreestanding告诉GCC不要假设标准库的存在-mno-red-zone禁用Red Zone这在内核代码中很重要因为中断处理可能会破坏Red Zone中的数据-mcmodellarge使用大代码模型允许代码和数据分布在整个64位地址空间中。链接脚本linker.ld用来控制各个段的内存布局。3.3 C语言编程基础C语言是操作系统开发的母语。从1973年Dennis Ritchie用C语言重写UNIX开始C就成为了操作系统开发的首选语言。时至今日Linux内核、Windows内核、macOS的XNU内核、FreeBSD内核等主流操作系统的核心代码仍然是用C语言编写的。操作系统开发中使用的C语言跟应用程序开发中的C语言有很大的不同。首先你不能使用任何标准库函数。printf、scanf、malloc、free、fopen、fclose等你在学校里学过的函数底层都需要操作系统的支持。既然你正在编写操作系统本身这些函数自然是不可用的。你需要从零开始实现自己的打印函数、内存分配器、字符串处理函数等。其次操作系统开发中大量使用了GNU C的扩展特性。GNU C是GCC编译器实现的C语言超集在标准C的基础上增加了很多有用的扩展。这些扩展虽然不是C语言标准的一部分但在Linux内核等项目中被广泛使用。3.3.1 GNU C内联汇编技术内联汇编Inline Assembly是GNU C最重要的扩展特性之一它允许你在C代码中直接嵌入汇编指令。对于操作系统开发来说这个功能不可或缺因为很多硬件操作如读写I/O端口、操作控制寄存器、执行特权指令等没有对应的C语言语句只能通过汇编来完成。GNU C的内联汇编使用asm关键字或__asm__语法格式如下casm volatile ( 汇编模板 : 输出操作数列表 : 输入操作数列表 : 破坏列表 );这个语法初看起来比较晦涩我们通过几个实际的例子来理解它。例1读取CR3控制寄存器CR3寄存器保存了当前页表的物理基地址在内存管理中经常需要读取或修改它。cstatic inline unsigned long read_cr3(void) { unsigned long val; asm volatile (mov %%cr3, %0 : r(val)); return val; }这段代码的含义是执行mov %%cr3, %0这条汇编指令其中%0是第一个操作数在这里就是val变量r约束表示这是一个输出操作数存放在通用寄存器中。%%cr3中的双百分号是因为在内联汇编模板中单个%用来引用操作数%0、%1等所以要表示字面的百分号需要写两个。例2写入CR3控制寄存器cstatic inline void write_cr3(unsigned long val) { asm volatile (mov %0, %%cr3 : : r(val) : memory); }这里val是输入操作数在第二个冒号后面r约束表示它应该放在通用寄存器中。memory出现在破坏列表中告诉编译器这条指令可能会影响内存内容因为切换页表会改变虚拟地址到物理地址的映射编译器不应该对内存访问进行跨越这条指令的优化。例3读写I/O端口x86架构有专门的I/O端口地址空间通过IN和OUT指令来访问。很多硬件设备如键盘控制器、PIC中断控制器、串口等都通过I/O端口来控制。cstatic inline void outb(unsigned short port, unsigned char data) { asm volatile (outb %0, %1 : : a(data), Nd(port)); } static inline unsigned char inb(unsigned short port) { unsigned char data; asm volatile (inb %1, %0 : a(data) : Nd(port)); return data; }注意这里使用了特定的约束a表示必须使用AL/AX/EAX寄存器IN和OUT指令要求数据在这个寄存器中Nd表示使用DX寄存器或者8位立即数端口号可以放在DX中或者作为立即数直接编码在指令中。例4关闭和开启中断cstatic inline void cli(void) { asm volatile (cli); // 清除中断标志禁止中断 } static inline void sti(void) { asm volatile (sti); // 设置中断标志允许中断 }这是最简单的内联汇编形式——没有输入输出操作数只有一条汇编指令。volatile关键字在内联汇编中的作用是告诉编译器不要优化掉这段汇编代码。如果没有volatile编译器在发现汇编代码的输出没有被使用时可能会将其优化掉。对于操作系统开发中的汇编代码来说几乎都应该加上volatile因为这些操作通常有副作用如修改硬件状态即使它们的输出看起来没有被使用。内联汇编的约束系统是最难理解的部分。常用的约束字符包括r通用寄存器aEAX/RAX寄存器bEBX/RBX寄存器cECX/RCX寄存器dEDX/RDX寄存器DEDI/RDI寄存器SESI/RSI寄存器m内存操作数i立即数N0到255之间的立即数输出操作数只写输入输出操作数读写掌握内联汇编需要大量的练习。建议你在阅读Linux内核源码时特别关注那些使用内联汇编的地方主要在arch/x86/include/asm/目录下学习内核开发者是如何使用这个特性的。3.3.2 GNU C语言对标准C语法的扩展特性GNU C除了内联汇编之外还有很多其他的扩展特性在操作系统开发中经常使用。下面介绍几个最重要的。属性声明attribute__attribute__是GNU C最常用的扩展之一它可以给变量、函数和类型附加各种属性影响编译器的行为。__attribute__((packed))用来取消结构体的自动对齐。在默认情况下编译器会在结构体成员之间插入填充字节来保证对齐因为CPU访问对齐的数据更快。但在操作系统开发中很多数据结构的内存布局必须严格按照硬件规范不能有任何额外的填充。比如GDT全局描述符表的表项结构必须严格是8字节IDT中断描述符表的表项在64位模式下必须是16字节cstruct gdt_entry { unsigned short limit_low; unsigned short base_low; unsigned char base_middle; unsigned char access; unsigned char granularity; unsigned char base_high; } __attribute__((packed));如果不加packed属性编译器可能会在某些成员之间插入填充字节导致整个结构体的大小超过8字节CPU在解析GDT时就会出错。__attribute__((aligned(n)))指定变量或类型的对齐方式。比如页表必须4KB对齐cunsigned long page_table[512] __attribute__((aligned(4096)));__attribute__((section(name)))把变量或函数放到指定的段中。在链接脚本中可以控制各个段在内存中的位置cvoid early_init(void) __attribute__((section(.init.text)));__attribute__((noreturn))告诉编译器这个函数不会返回。比如内核的panic函数cvoid panic(const char *msg) __attribute__((noreturn));这样编译器可以优化调用点的代码不需要在调用后生成处理返回值的代码。__attribute__((unused))告诉编译器某个变量或参数虽然没有被使用但这是故意的不要产生警告。在操作系统开发中有些函数参数是为了匹配特定的函数签名而存在的即使当前没有用到。typeof关键字typeof可以获取一个表达式的类型这在编写类型无关的宏时非常有用。Linux内核中大量使用这个特性c#define min(a, b) ({ \ typeof(a) _a (a); \ typeof(b) _b (b); \ _a _b ? _a : _b; \ })这个宏比简单的#define min(a,b) ((a)(b)?(a):(b))好在避免了参数的多次求值问题。如果你写min(x, y)简单的宏版本会对x和y各执行两次而使用typeof的版本只会执行一次。语句表达式上面的min宏中使用了语句表达式Statement Expression({ ... })。这是GNU C允许在表达式中嵌入语句块的扩展整个语句块的值是最后一条表达式语句的值。这个特性让宏可以包含局部变量声明避免了命名冲突和多次求值的问题。零长度数组GNU C允许在结构体的最后一个成员使用零长度数组这是实现变长结构体的常用技巧cstruct message { unsigned int length; char data[0]; // 零长度数组 };分配内存时可以根据实际数据长度来分配cstruct message *msg kmalloc(sizeof(struct message) data_len); msg-length data_len; memcpy(msg-data, source, data_len);这样msg-data就可以作为一个可变长度的数组来使用。C99标准中引入了弹性数组成员char data[];来实现类似的功能。内建函数Built-in FunctionsGCC提供了大量的内建函数其中一些在操作系统开发中非常有用__builtin_expect(expr, val)给编译器提供分支预测提示。Linux内核中的likely()和unlikely()宏就是基于它实现的c#define likely(x) __builtin_expect(!!(x), 1) #define unlikely(x) __builtin_expect(!!(x), 0)使用这些宏可以告诉编译器哪个分支更可能被执行编译器会相应地安排代码布局以优化CPU的分支预测性能。比如cif (unlikely(ptr NULL)) { panic(null pointer!); }__builtin_ctz(x)和__builtin_clz(x)分别返回一个整数末尾和开头的连续零位个数这在位图操作中非常有用。比如在空闲内存页面管理中需要快速找到第一个空闲的页面就可以用这些函数来加速位图扫描。__builtin_offsetof(type, member)返回结构体成员相对于结构体起始地址的偏移量。Linux内核的container_of宏就依赖这个函数c#define container_of(ptr, type, member) ({ \ const typeof(((type *)0)-member) *__mptr (ptr); \ (type *)((char *)__mptr - offsetof(type, member)); \ })这个宏的作用是通过一个结构体成员的指针反推出整个结构体的指针在Linux内核的链表实现中被大量使用。volatile关键字的特殊用途虽然volatile是C标准的一部分但在操作系统开发中它有特别重要的含义。volatile告诉编译器不要对被标记的变量进行优化——每次读取都必须从内存中真正读取每次写入都必须真正写入内存不能缓存在寄存器中。在操作系统开发中以下场景需要使用volatile访问内存映射的硬件寄存器。很多硬件设备的寄存器被映射到特定的内存地址读写这些地址就是在操作硬件。编译器可能认为连续读取同一个地址会得到相同的值从而用寄存器中的缓存值来替代实际的内存读取但硬件寄存器的值可能随时变化比如状态寄存器所以必须用volatile来阻止这种优化。cvolatile unsigned int *uart_status (volatile unsigned int *)0x3F8; while (!(*uart_status 0x01)) { // 等待UART接收到数据 }多核环境中的共享变量。在多核处理器上一个CPU核心修改的变量可能不会立即被其他核心看到因为CPU缓存的存在。虽然volatile不能完全解决多核同步问题还需要内存屏障指令但它至少保证编译器不会做出错误的优化。中断处理中的共享变量。中断处理函数可能在任何时候被执行修改被主程序使用的变量。如果编译器把这些变量缓存在寄存器中主程序就看不到中断处理函数的修改。编译器优化的陷阱操作系统开发者需要对编译器的优化行为有深入的了解。编译器的优化有时会帮倒忙。比如死代码消除如果编译器认为某段代码不会被执行或者执行结果不会被使用就可能将其删除。但在操作系统中一些看似无用的操作实际上是有副作用的。指令重排编译器可能会改变指令的执行顺序以提高性能。但在操作系统中某些操作的顺序是有严格要求的。比如你必须先设置好页表然后才能开启分页。内存访问合并编译器可能会把多次小的内存写入合并成一次大的写入但这对于内存映射的硬件寄存器是不允许的。为了解决这些问题除了使用volatile之外还需要使用编译器屏障和内存屏障。编译器屏障asm volatile ( ::: memory)告诉编译器在这个点不要对内存访问进行重排。硬件内存屏障如x86的MFENCE、LFENCE、SFENCE指令则确保CPU不会对内存访问进行乱序执行。到这里我们已经对操作系统的基本概念、开发环境搭建以及所需的基础工具做了比较全面的介绍。这些内容虽然还没有涉及操作系统内核代码的具体编写但它们是后续一切工作的基础。就像盖房子需要先打地基一样扎实的基础知识和完备的开发环境是成功开发一个操作系统的前提条件。回顾一下本部分的核心内容操作系统是计算机硬件与应用软件之间的桥梁它管理硬件资源、提供抽象接口、保证系统安全和稳定。从架构上看操作系统包含进程管理、内存管理、文件系统、设备驱动和网络协议栈等核心模块不同的架构风格宏内核、微内核、混合内核在模块的组织方式上有不同的选择。在开发环境方面我们需要虚拟化平台VMware或VirtualBox来运行测试环境需要Linux发行版如CentOS来搭建编译环境需要Bochs或QEMU作为调试工具。在编程语言方面x86汇编语言负责处理最底层的硬件操作C语言承担大部分内核代码的编写任务而GNU C的各种扩展特性则是连接高级语言和底层硬件的重要工具。接下来的章节将进入操作系统内核的实际开发阶段我们将从最基础的引导加载器开始一步步构建起一个可以运行的64位操作系统内核。每一步都会涉及大量的细节和需要注意的地方但只要你掌握了本部分介绍的基础知识就有足够的能力去理解和实现它们。操作系统开发是计算机科学中最有挑战性的领域之一但也是最能让人获得成就感的领域之一。当你第一次看到自己写的操作系统成功在屏幕上打印出一行字符时那种喜悦是任何其他编程体验都无法比拟的。因为你知道从CPU上电的那一刻起到屏幕上出现这行字符的那一刻中间的每一步都是你自己用代码控制的——没有标准库没有运行时环境没有操作系统因为你就是操作系统。你直接站在硬件之上掌控着整台计算机。从Linux到Windows从macOS到Android今天我们使用的所有操作系统都是从一个类似的起点开始的。它们的创造者都经历过同样的过程——从一个能在屏幕上打印字符的小程序开始一步步添加中断处理、内存管理、进程调度等功能最终成长为支撑亿万用户日常使用的庞大软件系统。而现在你也即将踏上这段旅程。