HHP3 xv6 内核笔记(二)
wakeup() 函数实现
接下来,我们看看与 sleep() 配对的 wakeup() 函数是如何工作的。它的逻辑相对直接:遍历所有进程,唤醒在指定通道上休眠的进程。
// 唤醒函数伪代码
void wakeup(void *chan) {
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++) { // 遍历进程表
acquire(&p->lock); // 获取该进程的锁
if(p->state == SLEEPING && p->chan == chan) { // 状态为休眠且通道匹配
p->state = RUNNABLE; // 将状态改为可运行
}
release(&p->lock); // 释放进程锁
}
}
wakeup() 在修改任何进程状态前,都必须先获取该进程的锁。这与 sleep() 中获取进程锁的操作共同构成了同步的基石,确保了不会出现竞态条件。
原子性保证
现在,让我们总结一下 sleep() 和 wakeup() 如何协同工作以保证原子性。关键在于进程锁 (p->lock) 的互斥作用。
考虑 sleep() 中释放调用者锁 (lk) 和设置休眠状态的操作:
-
sleep()先获取进程锁p->lock。 -
然后释放调用者锁
lk。 -
接着设置
p->chan和p->state。
对于 wakeup():
-
它必须获取进程锁
p->lock后才能检查p->state和p->chan。 -
因此,对于同一个进程,
sleep()中的步骤 1-3 和wakeup()中的检查是互斥的。 -
wakeup()要么在sleep()设置好状态之前看到进程(此时进程还未休眠),要么在之后看到(此时进程已正确设置休眠信息)。它不可能看到中间的不一致状态。
这就保证了“检查条件”和“进入休眠”这两个操作作为一个整体是原子的,不会错过任何发生在期间的唤醒信号。
总结
本节课中我们一起学习了 xv6 操作系统的 sleep() 和 wakeup() 同步原语。我们了解了:
-
通道 作为进程休眠和唤醒的匹配标识。
-
结合 自旋锁 使用的标准模式,以安全地检查条件并进入休眠,避免错过唤醒。
-
sleep()函数的内部实现,特别是它如何原子性地 释放调用者锁 并 设置休眠状态。 -
wakeup()函数如何遍历进程表并 唤醒匹配通道 的进程。 -
通过 进程锁 实现的 原子性保证 机制,这是
sleep()和wakeup()正确协作的核心。
理解这些机制是掌握操作系统内核中进程同步与通信的基础。
18:uart.c 与 console.c 详解 🖥️
在本节课中,我们将学习 xv6 操作系统中负责串行输入输出的两个核心文件:uart.c 和 console.c。我们将了解它们如何协同工作,管理来自键盘的输入和发送到显示器的输出,包括缓冲、中断处理和字符回显等关键机制。
系统概述
上一节我们介绍了 xv6 内核的总体结构,本节中我们来看看具体的硬件交互模块。xv6 通过模拟的 16550A 芯片与终端进行通信。内核通过向特定内存映射地址写入字节来向显示器发送字符,并通过从特定地址读取字节来从键盘获取字符。
硬件内部可能包含一些 FIFO 缓冲区以使通信更顺畅,但内核可以忽略这些。软件层面,内核维护了两个主要缓冲区:
-
输出队列:称为 UART 发送缓冲区。
-
输入队列:称为控制台缓冲区。
每个队列都由自己的锁保护。内核通过三个主要函数与外界交互:
-
printf:内核用于打印错误信息,这些信息被视为关键,会绕过输出缓冲区立即显示。 -
consolewrite:用户模式程序用于向输出写入数据。 -
consoleread:用户模式程序用于从输入读取数据。
输出处理流程
现在,让我们深入了解输出是如何被缓冲和发送的。输出缓冲区是一个环形缓冲区,称为 UART 发送缓冲区。
以下是其工作原理的关键点:
-
缓冲区有固定大小(实际为32字节)。
-
使用读索引(
r)和写索引(w)来管理。 -
当
r == w时,缓冲区为空。 -
当
w - r == 缓冲区大小时,缓冲区为满。 -
索引值会持续递增,并通过取模运算(
% 缓冲区大小)来实现环形访问。
由于多个进程和核心可能同时访问此缓冲区,因此它由一个名为 uart_tx_lock 的锁保护。
输入处理流程
接下来,我们转向输入缓冲区。输入缓冲区(控制台缓冲区)比输出缓冲区更复杂,因为它需要处理行编辑(如退格和整行删除)。
输入缓冲区使用三个索引:
-
r:读取索引,指向下一个待读取给用户程序的字符。 -
w:写入索引,指向下一个待写入的新行起始位置(或文件结束符)。 -
e:编辑索引,指向当前正在输入的行尾。
其工作流程如下:
-
用户键入字符时,字符被添加到
e指向的位置,然后e递增。 -
按下退格键时,
e递减,并回显删除操作。 -
按下
Ctrl+U时,e回退到上一行末尾(或w的位置),以删除整行。 -
当用户输入换行符或
Ctrl+D(文件结束符)时,w被设置为e的位置,标志着一行输入完成,并唤醒等待输入的consoleread函数。
硬件寄存器映射
为了与硬件通信,内核需要读写特定的内存映射寄存器。UART 设备被映射到物理内存地址空间中的固定地址(UART0)。
以下是关键寄存器及其偏移量:
-
偏移量 0:写操作时是发送保持寄存器(THR),用于输出字节。读操作时是接收保持寄存器(RHR),用于输入字节。
-
偏移量 5:线路状态寄存器(LSR)。包含两个重要位:
-
一位指示接收保持寄存器中是否有数据可读。
-
另一位指示发送保持寄存器是否就绪,可以接收下一个要发送的字节。
-
-
其他寄存器用于设置波特率、启用中断和 FIFO 等。
uart.c 文件函数解析
在了解了基本概念后,我们深入代码。uart.c 文件包含以下核心函数:
1. uartputc_sync
此函数用于尽可能快地将字符发送到硬件输出,绕过输出缓冲区。它被 printf 用于打印错误信息,也用于回显输入字符。
void uartputc_sync(int c) {
// 等待发送寄存器就绪
while((ReadReg(LSR) & LSR_TX_IDLE) == 0);
// 写入字符到发送寄存器
WriteReg(THR, c);
}
2. uartputc
此函数由 consolewrite 调用,用于将字符添加到输出缓冲区。如果缓冲区满,调用者会睡眠等待。
void uartputc(int c) {
acquire(&uart_tx_lock);
// 如果缓冲区满,则睡眠
while(uart_tx_w == uart_tx_r + UART_TX_BUF_SIZE) {
sleep(&uart_tx_r, &uart_tx_lock);
}
// 将字符放入缓冲区并更新写索引
uart_tx_buf[uart_tx_w % UART_TX_BUF_SIZE] = c;
uart_tx_w += 1;
// 尝试启动发送
uartstart();
release(&uart_tx_lock);
}
3. uartstart
此函数检查输出缓冲区是否有数据,以及硬件是否就绪。如果条件满足,则从缓冲区取出一个字符发送给硬件,并唤醒可能正在等待缓冲区空间变空的 uartputc 函数。
4. uartgetc
此函数从硬件读取一个输入字节(如果就绪)。如果没有数据,则立即返回 -1,不会等待。
5. uartintr
这是 UART 设备的中断处理函数。当硬件有输入字符到达或准备好接收下一个输出字符时,会触发中断并调用此函数。它的职责是:
-
读取所有可用的输入字符,并为每个字符调用
consoleintr。 -
调用
uartstart来发送输出缓冲区中的下一个字符。
console.c 文件函数解析
现在,我们来看控制台相关的函数,它们主要管理输入缓冲区。
1. consolewrite
此函数被系统调用实现使用,用于将用户空间的数据写入输出。它循环调用 uartputc 将每个字符放入输出缓冲区。
2. consoleread
此函数被系统调用实现使用,用于从输入缓冲区读取数据到用户空间。它等待直到有一整行数据可用(即 w > r),然后将字符复制到用户缓冲区,直到遇到换行符或文件结束符。
3. consoleintr
此函数由 uartintr 为每个输入的字符调用。它负责处理字符并将其添加到输入缓冲区,同时处理特殊字符:
-
退格/删除:将编辑索引
e回退,并回显删除操作。 -
Ctrl+U:将
e回退到行首或上一个换行符,删除整行。 -
换行符/Ctrl+D:将写入索引
w更新到e,标志一行输入完成,并唤醒所有等待输入的consoleread进程。 -
普通字符:回显字符,并将其存储到
e指向的缓冲区位置。
4. consoleinit
初始化函数,设置控制台锁并调用 uartinit 来初始化 UART 硬件。
总结
本节课中我们一起学习了 xv6 操作系统中串行输入输出子系统的详细工作原理。我们分析了 uart.c 和 console.c 两个关键文件,了解了它们如何通过环形缓冲区管理输入输出,如何处理硬件中断,以及如何实现字符回显和行编辑功能。核心在于通过锁保护共享缓冲区,并通过睡眠/唤醒机制协调生产者(输入/写入者)和消费者(输出/读取者)的速度。这套机制是理解操作系统设备驱动和系统调用接口的基础。
19:虚拟内存辅助函数概述 🧠
在本节课中,我们将学习 xv6 内核中用于虚拟内存管理的一系列辅助函数。这些函数定义在 vm.c 文件中,用于操作页表结构。它们相对独立且不涉及锁或休眠操作。为了便于理解,我们将分两部分介绍:本节是函数概述,下一节将详细分析代码。
首先,让我们回顾一下页表的结构。
页表结构回顾 🌳
在 xv6 中,页表是一个三级树形结构。我们可以将根节点称为根页,所有构成页表的页都称为索引页。在最底层,索引页指向数据页。
一个虚拟地址的格式如下:
| 一级索引 | 二级索引 | 三级索引 | 页内偏移 |
我们使用地址的高位字段(一级、二级、三级索引)在页表中逐级向下查找,最后的“页内偏移”用于在数据页内部定位。
页表项(PTE)的格式包含一个页对齐的指针(用于指向下一级页表或数据页)和几个标志位:
-
有效位 (V):表示该页表项是否有效。
-
读/写/执行位 (R/W/X):定义页面的访问权限。
-
用户模式位 (U):表示该页面是否可在用户模式下访问。
请注意:R/W/X/U 这些权限位仅在最后一级(指向数据页)的页表项中有效。在上层的索引页中,只有 V 位有意义,其他位应为零。
上一节我们回顾了页表的基本结构,本节中我们来看看操作这些结构的具体函数。
核心辅助函数详解 🔧
以下是 vm.c 中定义的主要虚拟内存辅助函数及其功能。
1. 页表遍历函数 walk
walk 函数接收一个页表(即指向根页的指针)和一个虚拟地址。它的作用是遍历页表树,并返回指向最终数据页的页表项(PTE)的地址。
pte_t *walk(pagetable_t pagetable, uint64 va, int alloc);
-
参数
alloc:如果路径上的中间索引页不存在,此参数决定是否创建它们(分配物理页并设置)。 -
返回值:成功则返回指向目标 PTE 的指针;如果无法分配所需页面(且
alloc为 0),则返回空指针0。
2. 建立映射函数 mappages
mappages 函数用于向页表中添加一个或多个映射。它接收一个页表、一个起始虚拟地址、一个起始物理地址、要映射的页面数量以及权限标志。
int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm);
-
功能:将一段连续的虚拟页面映射到一段连续的物理页面。
-
返回值:成功返回
0;如果映射过程中出现错误(如页面已映射),则返回-1。这是 xv6 内核函数常见的错误处理模式。
3. 内核页表相关函数
内核只有一个页表,所有 CPU 核心共享它。创建内核页表涉及以下函数:
-
kvmmap:此函数与mappages功能类似,但用于构建内核页表。由于内核页表在初始化时必须成功建立,如果mappages返回错误,kvmmap会直接调用panic使内核崩溃。 -
kvminit:这是初始化内核页表的主函数。它调用kvmmap来:-
建立直接映射,将全部物理内存映射到内核虚拟地址空间。
-
映射所有内存映射的 I/O 设备。
-
为 trampoline 页(蹦床页)建立映射。该物理页在虚拟地址空间中有两个映射:一个在其实际物理位置,另一个在虚拟地址空间的最高页。
-
调用
proc_mapstacks为每个进程分配内核栈页。
-
-
kvminithart:每个 CPU 核心在初始化时调用此函数。它将全局变量kernel_pagetable(由kvminit设置)的地址写入satp寄存器。这步操作实际上为该核心开启了分页机制。
4. 地址翻译函数 walkaddr
walkaddr 函数用于将用户空间的虚拟地址转换为物理地址。
uint64 walkaddr(pagetable_t pagetable, uint64 va);
-
功能:调用
walk查找虚拟地址对应的页表项,然后结合页内偏移计算出物理地址。 -
要求:目标页面必须标记为有效 (
V) 且用户可访问 (U)。 -
返回值:成功返回物理地址;如果地址无效或权限不足,则返回
0。由于虚拟地址0被映射到物理地址0,没有其他虚拟地址会翻译成物理地址0,因此返回0是安全的错误指示。
5. 用户地址空间创建与修改
-
uvmcreate:创建一个空的用户虚拟地址空间。它分配一个物理页作为页表的根页,并将其内容清零。 -
uvminit:创建第一个用户地址空间(init 进程)。它分配一个页,映射到虚拟地址0,并标记为可读、可写、可执行且用户可访问 (R|W|X|U)。然后,它将内核中存储的initcode字节数组(一段汇编程序)复制到这个页面。这段代码会执行exec(“/init”)系统调用。 -
uvmalloc:为用户地址空间增加页面(例如扩展堆)。uint64 uvmalloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz);-
功能:分配新的物理页,并使用
mappages将其映射到用户地址空间中oldsz到newsz的区域。新页面权限为R|W|X|U。 -
返回值:成功返回新的地址空间大小 (
newsz);失败则释放所有已分配的资源并返回0。
-
-
uvmdealloc:为用户地址空间减少页面(例如收缩堆)。它内部调用uvmunmap来解除映射并释放物理页。uint64 uvmdealloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz);- 注意:
oldsz和newsz不需要页面对齐。如果newsz > oldsz,该函数不执行任何操作。
- 注意:
6. 解除映射与空间释放
-
uvmunmap:从页表中移除指定虚拟地址范围内的映射。void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free);-
参数
do_free:一个布尔值,指示是否在解除映射后调用kfree释放对应的数据页。 -
操作:遍历指定范围内的每个页面,将其页表项的有效位 (
V) 清零,使其无效。
-
-
uvmfree:释放整个用户地址空间。void uvmfree(pagetable_t pagetable, uint64 sz);-
功能:首先调用
uvmunmap(…, 1)释放所有用户数据页(代码、数据、堆、栈)。然后调用freewalk释放页表本身的所有索引页。 -
注意:用户地址空间高位的 trampoline 页(所有进程共享)和 trapframe 页(每个进程独立预分配)不会被此函数释放。
-
-
freewalk:递归地释放页表树中的所有索引页,但不释放数据页。void freewalk(pagetable_t pagetable);- 递归深度:由于 xv6 页表只有三级,递归深度有限,不会导致内核栈溢出。
7. 地址空间复制 uvmcopy
在 fork 系统调用中,需要复制父进程的整个地址空间给子进程。uvmcopy 负责这项工作。
int uvmcopy(pagetable_t old, pagetable_t new, uint64 sz);
-
功能:将旧页表 (
old) 中[0, sz)范围内的所有数据页逐页复制到新分配的物理页,并将这些新页以相同的权限映射到新页表 (new) 中。 -
实现:对每个需要复制的页面,调用
walk查找 PTE,kalloc分配新物理页,memmove复制数据,mappages建立新映射。 -
错误处理:如果中途失败(如内存不足),它会回滚所有操作:释放已分配的新页并解除已建立的新映射。
8. 用户空间访问函数
内核经常需要读写用户进程地址空间中的数据(例如,系统调用参数)。由于用户数据可能跨多个不连续的物理页,需要特殊函数来处理。
-
copyin:从用户空间复制数据到内核缓冲区。int copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len);- 功能:将用户页表
pagetable中,起始于虚拟地址srcva的len字节数据,复制到内核地址dst。它逐页处理,确保在内核中获得连续的数据。
- 功能:将用户页表
-
copyout:从内核缓冲区复制数据到用户空间。int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len);- 功能:将内核地址
src处的len字节数据,复制到用户页表pagetable中起始于虚拟地址dstva的位置。
- 功能:将内核地址
-
copyinstr:copyin的变体,专门用于复制以空字符 (\0) 结尾的字符串。int copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max);-
功能:从用户空间复制字符串到
dst,最多复制max个字节。如果遇到空终止符则停止。 -
返回值:成功返回
0;如果达到max仍未遇到空终止符,则返回-1。
-
9. 其他工具函数
-
uvmclear:用于将用户地址空间中的栈保护页标记为用户不可访问。它通过清除对应页表项中的U位来实现。void uvmclear(pagetable_t pagetable, uint64 va);
总结 📚
本节课我们一起学习了 xv6 内核虚拟内存管理的一系列核心辅助函数。我们了解了如何遍历页表 (walk)、建立和解除映射 (mappages, uvmunmap)、管理用户地址空间的创建、扩展、收缩和复制 (uvmcreate, uvmalloc, uvmdealloc, uvmcopy),以及如何在用户空间和内核空间之间安全地复制数据 (copyin/out)。这些函数是 xv6 实现内存隔离、进程创建和系统调用的基础。在下一节中,我们将深入代码,详细查看这些函数的具体实现。
20:VM 函数代码详解 🧠
在本节课中,我们将深入学习 xv6 内核中虚拟内存(VM)相关的辅助函数。这些函数位于 vm.c 文件中,负责管理页表、地址映射、内存分配与释放等核心操作。我们将通过代码走查的方式,逐一解析每个函数的工作原理和实现细节。
函数 walk:遍历页表 🚶♂️
上一节我们概述了 VM 函数的功能,本节中我们来看看 walk 函数的具体实现。该函数接收一个指向页表树根节点的指针、一个虚拟地址和一个布尔值 alloc。它的作用是遍历页表树,并返回指向目标虚拟地址对应的页表项(PTE)的指针。如果遍历过程中发现中间层页表不存在,且 alloc 为真,则会分配并初始化这些页表。
以下是 walk 函数的核心逻辑:
pte_t *walk(pagetable_t pagetable, uint64 va, int alloc) {
if(va >= MAXVA)
panic("walk");
for(int level = 2; level > 0; level--) {
pte_t *pte = &pagetable[PX(level, va)];
if(*pte & PTE_V) {
pagetable = (pagetable_t)PTE2PA(*pte);
} else {
if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
return 0;
memset(pagetable, 0, PGSIZE);
*pte = PA2PTE(pagetable) | PTE_V;
}
}
return &pagetable[PX(0, va)];
}
代码执行步骤如下:
-
检查虚拟地址:确保虚拟地址
va不超过最大允许值MAXVA。 -
循环遍历层级:从第 2 级(根页表)开始,向下遍历到第 1 级。
-
使用宏
PX(level, va)从虚拟地址中提取当前层级的索引位。 -
通过索引在当前页表中找到对应的页表项(PTE)。
-
-
处理页表项:
-
如果 PTE 的有效位(
PTE_V)已设置,则通过PTE2PA宏将 PTE 转换为物理地址,并更新pagetable指针,指向下一级页表。 -
如果 PTE 无效:
-
若
alloc为假,则返回 0(表示失败)。 -
若
alloc为真,则调用kalloc分配一个新的物理页作为下一级页表,用memset清零,并通过PA2PTE宏将其物理地址转换为 PTE 格式,设置有效位后,存入当前 PTE。
-
-
-
返回最终 PTE:循环结束后,
pagetable指向第 0 级页表。使用PX(0, va)提取最终索引,返回指向目标页表项的指针。
函数 mappages:建立地址映射 🗺️
理解了如何查找页表项后,我们来看看如何建立虚拟地址到物理地址的映射。mappages 函数用于在页表中创建一系列连续的页表项,将一段虚拟地址空间映射到一段物理地址空间。
该函数接收页表根指针、起始虚拟地址 va、映射大小 size、起始物理地址 pa 以及权限位 perm。它会为 [va, va+size) 范围内的每个虚拟页,在页表中创建对应的页表项,使其指向 [pa, pa+size) 范围内对应的物理页,并设置相同的权限。
以下是 mappages 函数的关键部分:
int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm) {
uint64 a, last;
pte_t *pte;
a = PGROUNDDOWN(va);
last = PGROUNDDOWN(va + size - 1);
for(;;) {
if((pte = walk(pagetable, a, 1)) == 0)
return -1;
if(*pte & PTE_V)
panic("remap");
*pte = PA2PTE(pa) | perm | PTE_V;
if(a == last)
break;
a += PGSIZE;
pa += PGSIZE;
}
return 0;
}
执行流程如下:
-
地址对齐:使用
PGROUNDDOWN确保起始虚拟地址a和结束地址last是页对齐的。 -
逐页映射:循环处理每一页。
-
调用
walk函数查找或创建当前虚拟地址a对应的页表项pte。alloc参数为 1,允许分配中间页表。 -
检查找到的 PTE 是否已经有效(
PTE_V),如果是,说明发生了重映射,触发panic。 -
使用
PA2PTE将物理地址pa转换为 PTE 格式,与权限位perm和有效位PTE_V进行或操作,然后存入页表项*pte。 -
如果当前页是最后一页(
a == last),则结束循环。 -
否则,虚拟地址
a和物理地址pa都增加一个页的大小(PGSIZE),处理下一页。
-
-
返回结果:成功映射所有页后返回 0。
函数 kvmmake:构建内核页表 🏗️
现在,我们来看内核如何构建自己的页表。kvmmake 函数负责创建并初始化内核的页表,它通过调用 kvmmap(一个包装了 mappages 的函数)来建立各种映射。
内核页表需要建立以下几种映射:
-
直接映射:将大部分物理内存(包括内核代码、数据)以相同的虚拟地址进行映射,方便内核访问。
-
设备映射:将 UART、磁盘、PLIC 等 I/O 设备的物理地址映射到特定的虚拟地址。
-
跳板页映射:将
trampoline代码页映射到虚拟地址空间的最高页。 -
进程内核栈映射:为每个进程分配一个独立的内核栈页,并映射到内核地址空间。
以下是 kvmmake 函数的核心映射调用:
pagetable_t kvmmake(void) {
pagetable_t kpgtbl = (pagetable_t) kalloc();
memset(kpgtbl, 0, PGSIZE);
// 映射 UART 设备
kvmmap(kpgtbl, UART0, UART0, PGSIZE, PTE_R | PTE_W);
// 映射磁盘设备
kvmmap(kpgtbl, VIRTIO0, VIRTIO0, PGSIZE, PTE_R | PTE_W);
// 映射 PLIC
kvmmap(kpgtbl, PLIC, PLIC, 0x400000, PTE_R | PTE_W);
// 映射内核代码段 (只读、可执行)
kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64)etext-KERNBASE, PTE_R | PTE_X);
// 映射内核数据段及剩余物理内存 (可读、可写)
kvmmap(kpgtbl, (uint64)etext, (uint64)etext, PHYSTOP-(uint64)etext, PTE_R | PTE_W);
// 映射跳板页 (只读、可执行)
kvmmap(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);
// 为每个进程映射内核栈
proc_mapstacks(kpgtbl);
return kpgtbl;
}
函数 proc_mapstacks 会为每个进程分配一个物理页作为内核栈,并使用 kvmmap 将其映射到内核虚拟地址空间中一个特定的、受保护的位置(通常每个栈之间有一个“保护页”以防止溢出)。
函数 uvmalloc 与 uvmdealloc:管理用户地址空间大小 📏
用户进程的堆空间需要能够动态增长和收缩。uvmalloc 和 uvmdealloc 函数分别用于扩展和缩小用户虚拟地址空间。
uvmalloc:当进程需要更多内存时(例如通过 sbrk 系统调用),此函数被调用。
-
输入:用户页表
pagetable,旧大小oldsz,新大小newsz。 -
逻辑:
-
将
oldsz向上舍入到页边界。 -
循环分配新的物理页,并调用
mappages将其映射到从舍入后地址开始的虚拟地址空间。 -
新页的权限设置为可读、可写、用户可访问(
PTE_R | PTE_W | PTE_U)。 -
如果任何一步失败(如
kalloc失败),则调用uvmdealloc回滚所有已分配的页,并返回 0。 -
成功则返回
newsz。
-
uvmdealloc:当进程释放内存或 uvmalloc 需要回滚时,此函数被调用。
-
输入:用户页表
pagetable,旧大小oldsz,新大小newsz。 -
逻辑:
-
检查
newsz是否小于oldsz,如果不是,则不进行收缩。 -
计算需要释放的页数:
(PGROUNDUP(oldsz) - PGROUNDUP(newsz)) / PGSIZE。 -
调用
uvmunmap函数,从虚拟地址PGROUNDUP(newsz)开始,取消映射指定数量的页,并释放对应的物理页(如果do_free参数为真)。 -
返回
newsz。
-
函数 uvmcopy:复制用户地址空间 (用于 fork) 👯♂️
fork 系统调用需要复制父进程的整个用户地址空间给子进程。uvmcopy 函数实现了这个功能。
-
输入:父进程页表
old,子进程页表new,父进程地址空间大小sz。 -
逻辑:
-
遍历父进程地址空间的每一页(从 0 到
sz)。 -
对于每一页:
-
使用
walk找到父进程页表中对应的 PTE。 -
获取该 PTE 指向的物理页地址和权限标志。
-
为子进程分配一个新的物理页(
kalloc)。 -
将父进程物理页的内容复制到子进程的新物理页中(
memmove)。 -
调用
mappages将子进程的新物理页映射到子进程页表中相同的虚拟地址,并设置相同的权限。
-
-
如果任何一步失败(如分配失败),则跳转到错误处理标签,调用
uvmunmap释放子进程中已成功映射的所有页及其物理内存,并返回 -1。 -
成功复制所有页后返回 0。
-
函数 copyin / copyout / copyinstr:内核与用户空间的数据拷贝 🔄
内核经常需要从用户空间读取数据(如系统调用参数),或向用户空间写入数据(如系统调用结果)。由于用户空间可能跨页,且需要检查地址有效性,xv6 提供了专门的拷贝函数。
copyin:从用户虚拟地址空间拷贝数据到内核缓冲区。
-
逻辑:循环处理可能跨页的数据。每次迭代:
-
使用
walkaddr(内部调用walk)将用户虚拟地址转换为物理地址。此函数会检查地址有效性和用户权限。 -
计算当前页内可拷贝的字节数。
-
使用
memmove从转换得到的物理地址拷贝数据到内核目标地址。 -
更新指针和剩余长度。
-
copyout:从内核缓冲区拷贝数据到用户虚拟地址空间。
- 逻辑:与
copyin几乎对称,只是方向相反。
copyinstr:从用户空间拷贝一个以空字符结尾的字符串到内核缓冲区,并防止缓冲区溢出。
- 逻辑:与
copyin类似,但逐字节拷贝,并在遇到空字符\0或达到最大长度max时停止。如果成功拷贝到空字符,返回 0;如果达到最大长度仍未遇到空字符,返回 -1。
其他重要函数概览
-
uvmcreate:创建一个空的用户页表(仅分配根页表并清零)。 -
uvmfree:释放整个用户地址空间。它先调用uvmunmap释放所有用户页(数据页和页表项),然后调用freewalk递归释放页表树本身的所有层级页表。 -
freewalk:递归地释放页表树。它遍历一个页表,对所有有效的、指向下一级页表(非叶子节点)的 PTE,递归调用自身释放下级页表,最后释放当前页表页。 -
walkaddr:将用户虚拟地址转换为物理地址。它调用walk(alloc=0),并检查 PTE 的有效性和用户权限位。
总结 🎯
本节课中我们一起深入学习了 xv6 操作系统中虚拟内存管理的核心函数。我们从遍历页表的 walk 函数开始,了解了如何查找和创建页表项。接着,我们分析了建立地址映射的 mappages,以及构建内核页表的 kvmmake。然后,我们探讨了管理用户地址空间动态大小的 uvmalloc 和 uvmdealloc,以及用于 fork 系统调用的 uvmcopy 函数。最后,我们学习了在内核与用户空间之间安全拷贝数据的 copyin、copyout 和 copyinstr 函数。这些函数共同构成了 xv6 内存管理子系统的基础,确保了进程隔离、内存分配和高效的系统调用数据传递。
21:进程创建
本视频是 XB6 操作系统内核系列的一部分。在本视频中,我们将讨论进程,特别是用于表示进程的数据结构。我们将介绍进程结构数组如何初始化,以及如何为初始的第一个进程设置数据。这些内容来自文件 proc.c,该文件还包含许多其他内容。除了我将要讨论的这些材料外,它还包含调度器、yield 等代码,以及 sleep 和 wakeup 函数的代码。这些内容已在之前的视频中介绍过。该文件还包含 fork、wait 和 kill 等函数的代码,我将在未来的视频中介绍它们。
那么,让我们从文件本身开始,我想从这两个字段开始。有一个初始化为 1 的进程 ID 计数器,还有一个名为 pid_lock 的自旋锁。首先,我们可以看看这个函数 allocpid。每当我们需要一个新的进程 ID 时,就可以调用这个函数。它相当直接,返回新的进程 ID。为了访问和更新全局变量 nextpid,我们需要先获取锁。因此,这里先获取锁,然后将当前值读入一个局部变量,递增全局变量,最后释放锁。这是 acquire 和 release 的典型用法。
好了,现在我们可以进入我想介绍的第一个函数 procinit。在此之前,让我提醒你本视频相关的结构。我们有一个 proc 结构数组。这里我展示了一个,总共有 64 个,每个进程一个。请记住,它们有一个自旋锁和一些字段,比如状态(是运行中、可运行等)、通道(如果正在睡眠)、killed(进程是否已被杀死)以及其他一些东西。我们还有一个指向虚拟内存中栈所在区域的指针。我稍后会讲到这个。地址空间的大小,即该进程堆顶的断点、指向页表的指针、指向陷阱帧的指针、用于保存的上下文以及其他一些字段,如 name 字段。我现在不打算详细讨论这些,但我想谈谈这个函数 procinit,它初始化进程数组。这个函数在内核初始化期间仅由 core0 调用一次,它初始化了我们刚才看到的进程 ID 锁,还初始化了另一个名为 wait_lock 的锁。然后它遍历这个进程数组。它遍历数组,并为每个元素初始化自旋锁。同时,它还初始化这个 kstack。在这一点上,我将讨论内核的虚拟地址空间是什么样的。请记住,内核虚拟地址空间,像所有地址空间一样,会将蹦床页面映射到最高页。然后它有一系列的保护页和栈页。因此,对于 64 个进程中的每一个,都有一个页面,当该进程在内核模式下运行时,将使用该栈页面的虚拟地址空间。在 procinit 函数中,我们正在确定虚拟地址。这是一个预处理器宏函数,给定一个 0 到 63 之间的数字,它确定该栈页面的虚拟地址,并简单地将其保存在这个 proc 结构的 kstack 字段中。这是 proc 结构,这是 kstack 字段。它只是保存虚拟地址。我添加了这条虚线来表示这是一个虚拟地址。与此处的链接相反,页表指向物理内存中的某个物理地址,陷阱帧指针也指向陷阱帧的实际物理地址。当然,陷阱帧将被映射到虚拟地址空间的第二高页,但这里的这个点指向的是从 kalloc 返回的实际物理页,我们稍后会看到这一点。
我已经提到了这个文件中的另外几个函数,但我只是回顾一下。cpuid 通过返回 tp 寄存器的值来返回当前执行此函数的核心编号。mycpu 使用当前核心编号并返回指向 cpu 结构的指针。这是 cpu 结构数组。这里我展示了一个。最多有 8 个核心,这是一个固定常量。每个核心都有一个 cpu 结构。因此,我们返回指向当前核心的那个结构的指针。最后,我们有 myproc 例程,它获取当前 cpu 结构的指针,然后跟随 proc 字段获取指向表示当前正在执行的进程的结构的指针。每个 cpu 结构都有一个 proc 字段,指向一个 proc 结构。每个核心在任何时刻,要么正在执行调度器代码,要么正在执行某个进程。如果它正在执行一个进程,那么这个指针是有效的,它指向一个 proc 结构。如果 CPU 正在执行调度器代码,那么这个指针将是 null。所以,这些函数相当直接。
接下来,让我们看看 procdump。我认为 procdump 很简单。基本上,它用于调试。它只是遍历进程数组并打印出信息。它不接收参数,也不返回值。它做什么呢?正如我所说,它是一个 for 循环,遍历这里所有 64 个 proc 结构的整个数组。如果该结构是未使用的,换句话说,如果这里的 state 是 UNUSED,那么我们就不打印任何内容。我们继续并重复循环体。否则,我们在我们这里的小数组中查找状态。我们有一个小数组,将常量 UNUSED、SLEEPING、RUNNABLE、RUNNING 和 ZOMBIE 映射为短字符串。因此,我们在这里获取名为 state 的字符串。我们在这里只是检查以确保它是一个有效的数字。否则,我们使用那个。然后我们打印一行。这将为进程打印一行,包含进程 ID、当前状态和名称。name 字段是一个固定长度的字段,我们可以在其中存储一个短名称。在这里,我们打印名称。这相当直接。这就是 procdump。
现在,我们将进入 allocproc 函数。让我们看看这个函数做什么。首先,它不接收参数。当我们需要一个 proc 结构来创建一个新进程时,我们调用 allocproc。它将找到一个并初始化它,然后返回一个指向它的指针。在进程表中查找一个未使用的 proc。如果找到,初始化它并返回,同时持有锁。如果有问题,它返回 null。这是代码将要执行的操作的概述。它首先搜索一个未使用的 proc 结构。然后它调用 kalloc 为该进程分配一个陷阱帧页面。接着,它创建一个新的页表,为蹦床页面添加映射(当然,蹦床页面由所有虚拟地址空间共享),并为刚刚分配的陷阱帧页面添加映射。然后它设置上下文,或者我应该说它初始化上下文,为该进程的第一个时间片做准备。回想一下,在 proc 结构中,我们有这个上下文区域,这是寄存器保存区,所以我们在那里做一些初始化。创建的地址空间,我们在这里创建了一个页表,但还没有放入任何内容。所以还没有代码或数据。allocproc 函数不负责这个,但稍后会添加。如果有任何问题,这个函数必须撤销一切,通过调用 kfree 将所有分配的页面返回到空闲池,然后返回 null。
让我们看看这里的代码。我们正在寻找一个状态为 UNUSED 的 proc 结构。我们在这个 for 循环中遍历整个 64 个元素的数组。对于每个元素,我们获取锁。如果它是未使用的,那么我们就找到了。然后跳转到这里的标签。但如果它正在使用,那么我们释放锁并继续寻找。如果我们找不到任何东西,就返回 null。假设我们找到了。那很好。在这里,我们填写进程 ID。每个进程将获得一个新的标识符。当这个变量溢出时会发生什么?在 xv6 中我们不担心这种事情,因为它在我们有生之年永远不会发生。我们将状态设置为 USED。这是我们可以拥有的状态之一。请记住,USED 状态没有列在这里。无论如何,它进入被使用的状态。稍后,调度器将负责在某个未来时间使其变为可运行状态。然后我们分配陷阱帧页面。这里我们调用 kalloc。如果出现任何问题,我们将释放锁并返回 null。我们还将调用这个 freeproc 函数。我接下来会介绍这个函数,但基本上,任何时候出现问题都会调用它。我们在这里也看到它被调用。它基本上将使 proc 结构再次变为未使用状态,并将所有字段清零,以便可以回收。
现在,我们创建页表。这里我们调用 proc_pagetable,我稍后会介绍,但那是要创建页表并添加几个映射。如果一切正常,我们就继续;否则,我们调用 freeproc 来撤销我们所做的,释放锁并返回 null,就像我们在这里做的那样。allocproc 在两个地方被调用。一个是 userinit,用于创建第一个进程(init 进程);另一个地方是 fork 函数,用于 fork 系统调用。无论如何,在进程创建之后,它将被调度运行。所以我们需要为此设置一些东西。请记住,在 proc 结构中,我们有寄存器的保存区。这将是进程开始运行时将使用的寄存器。当然,当它在内核模式下被调度时,它将开始运行。所以,我们需要有返回地址和栈指针用于它的初始调度。我们设置上下文。首先,我们清除所有寄存器。memset 只是将寄存器保存区中的所有寄存器写为零。然后我们将 ra 寄存器初始化为指向这个 forkret 函数。我稍后会看一下这个。我们将 sp 寄存器初始化为指向栈页面。请记住,每个进程在虚拟内存中都有一个页面。例如,进程 2 在虚拟内存中的这个地址有这个页面,栈是向下增长的。所以我们希望将栈指针初始化为指向这个页面的顶部,以便它可以从这个区域开始向下增长。因此,我们初始化栈指针。当这个进程被调度时,也就是当它获得第一个时间片时,它将开始执行这里给出的 ra 地址处的代码,并带有一个栈。
在我们调用 allocproc 之后,我们要做的基本上是填充代码和数据。我们还需要释放锁。请记住,我们在这里获取了锁,我们需要释放锁。然后这个线程可以继续做它想做的任何事情。但在某个时刻,调度器将选择这个进程,它将获取该进程的锁,调度它,然后从上下文加载寄存器,其中包括返回地址。因此,这个进程将开始执行。每个进程,包括 init 进程,都将通过执行 forkret 函数开始。这是 forkret 函数。我们可以看到它做什么。当我们第一次被调度时,我们持有进程锁。所以我们必须释放那个锁,除了从陷阱返回之外,我们没有什么可做的。我们实际上并没有发生陷阱,但我们从陷阱返回开始。在 fork 的情况下,我们确实发生了陷阱。在初始进程的情况下,我们将伪造一个陷阱。但无论如何,我们在这里进行陷阱返回。forkret 中的其他内容,你可以看到一个全局变量 first,或者我应该说一个静态变量,它被初始化为 true,所以这只会执行一次。任何进程第一次被调度时,它将执行这段代码,然后将 first 设置为 false。它说文件系统初始化必须在常规进程的上下文中运行,因为它调用了 sleep,因此不能从 main 函数运行。这就是这里发生的事情。当我们即将调度初始进程时,我们会看到 first 是 1,我们将调用这个 fsinit 函数,并在我们执行 usertrapret 返回到初始进程的第一条指令之前处理它。
接下来,让我们看看 freeproc。如果出现任何问题,我们将调用 freeproc。当我们完成一个进程时,我们也会调用 freeproc。那么它会做什么呢?它接收一个指向我们要放弃的进程的指针。它查看陷阱帧指针。如果它不是 null,那么它指向某个东西,所以将其返回到空闲池。这在这里发生。将陷阱帧设置为 null。然后它查看页表指针。它查看这里指向页表的指针,它需要基本上销毁那个页表。我们将讨论 proc_pagetable 和 proc_freepagetable。但基本上,它将调用 proc_freepagetable 来撤销那个页表并将所有内容返回到空闲池。然后它清零一些剩余的字段。这并非严格必要,但它做了所有这些。在 name 字段中设置为 null 等等。但最重要的是,它将状态设置为 UNUSED。在这一点上,这个进程结构是未使用的。
回到 allocproc 函数,我们找到了一个 proc 结构,并设置了陷阱帧。然后我们调用 proc_pagetable 来创建一个空的用户页表。现在让我们看看 proc_pagetable。它为给定的进程创建一个用户页表。它接收一个指向 proc 结构的指针。它将设置页表并返回一个指向该页表的指针,正如我们在 allocproc 中看到的,它将被存储在 pagetable 字段中。那么它做什么呢?我们已经介绍过 uvmcreate 来创建一个空的页表。所以那只是创建用户页表。如果有任何问题,我们返回 0。接下来,我们为蹦床页面创建一个映射。蹦床地址是虚拟地址空间中的最高页,长度为一页。我们将其映射到蹦床页面。这是内核代码区域中的页面,标记为可读和可执行。我们已经讨论过 mappages。所以这为蹦床页面向页表添加了一个映射。如果出现问题,那么我们释放已创建的页表并返回。接下来,我们将陷阱帧映射到第二高页。我们再次调用 mappages。陷阱帧地址是第二高页,同样长度为一页。那个页面在哪里?我们之前通过调用 kalloc 分配了页面,并将 trapframe 设置为指向物理内存中的物理页面,这就是我们在这里使用的,trapframe,我们使其可读和可写。如果出现任何问题,我们取消映射蹦床页面,然后再次释放页表并返回 0。
那么相反的操作,proc_freepagetable 呢?我们已经在关于虚拟内存函数的视频中讨论过这些函数。我们基本上移除蹦床页面的映射,而不释放数据页面本身。我们移除陷阱帧页面的映射,而不释放陷阱帧页面本身。然后我们调用 uvmfree 来完全销毁页表,将所有数据页面返回到空闲池,并将所有索引页面返回到空闲池。我们在哪里调用这个?在 freeproc 中。这是我们的函数 freeproc。你看,我们已经在这里释放了陷阱帧页面,所以这就是为什么我们在这里不要求释放它,因为我们已经释放了它。我们在这里调用 proc_freepagetable 并将 pagetable 设置为 null。这就是我们使用 proc_freepagetable 的地方。
现在,让我们谈谈这个函数 userinit。userinit 被调用来设置第一个用户进程。让我们逐步查看代码。它不太长。它做的第一件事是调用 allocproc 来找到一个 proc 结构并进行设置。现在我们有了一个指向 proc 结构的指针。这被称为 initproc。我们将保存一个指向它的指针。接下来,我们将分配一个单独的页面,并复制初始进程的代码。这里我们调用 uvminit,我们之前讨论过,但它接收一个指向页表的指针。allocproc 应该已经设置了这个页表,但还没有填充任何代码或数据。uvminit 被传递了一些字节的指针和字节数。那是什么?这是 initcode。这是这个数组,它包含字节。我想它们数出了 52 个字节。这里我们将把这 52 个字节复制到这个页表指向的虚拟地址空间的第 0 页。我们还将设置 size 字段。这是 proc 结构中的一个字段,它告诉虚拟地址空间从 0 到断点的大小,这里我们只有一个页面,所以不是很大。
为第一次从内核返回到用户做准备。这是陷阱帧。epc 是我们保存程序计数器的地方。当正在运行的用户进程中发生陷阱时,我们保存程序计数器,我们把它保存在这里,我们也保存所有寄存器。我们保存所有通用寄存器,包括栈指针。当我们准备返回到用户进程时,我们在恢复所有寄存器后,返回到这个程序计数器处执行。我们在这里做的是将程序计数器设置为 0。因此,对于这个进程,并且仅对于这个进程,我们将从 0 开始执行它。对于所有其他进程,当它们第一次开始执行时,它们是由于 fork 构造而创建的,它们将在 fork 系统调用之后直接开始执行。所以我们不需要为其他进程这样做。但对于第一个进程,init 进程,我们将其程序计数器设置为 0。我们还将它的栈指针设置为页面大小。请记住,初始进程将恰好有一个页面,其中将包含代码和栈。通过将 sp 设置为页面大小,我们将其设置为该初始页面的顶部。因此,希望初始进程不会增长它的栈太多。事实上,它根本不会增长。但我们无论如何都设置了它。最后,我们复制到 proc 结构的 name 字段中。这是 proc 结构,我们这里有这个 name 字段。它是一个固定数量的字节,一个小的字节数。我们正在复制这些字符。safestrcpy 是一个将字符串从一个地方复制到另一个地方的函数,它不会溢出目标区域。因此,我们通过这里的 sizeof 参数传入要复制的最大字符数。这样我们就不会意外地溢出这个数组。我们还有几个其他字段,proc 结构中的当前工作目录被初始化为指向这个斜杠。这就是那个。最后,我们设置状态。将状态设置为 RUNNABLE 并释放锁。请记住,allocproc 返回时锁是设置的。现在我们已经分配了一个 proc 结构,初始化了它的页表和所有内容,一切都准备好了。我们将其状态设置为 RUNNABLE,我们就完成了。运行这个的线程可以忽略它。所以我们只需释放锁。然后调度器在寻找要运行的东西时会找到这个进程,它会看到它是可运行的,就会调度它,初始代码就会开始执行。当然,它做的事情不多,只是查看这里的代码。这是代码。当然,你无法阅读它,因为它是 RISC-V 处理器的机器代码。无论如何,它做的是调用 exec 系统调用,传递一个字符串 /init,这就是它所做的一切。我们基本上获取这个代码。这是 UNIX 命令 init。基本上我们对其进行 objdump 并将其放入一个文件,然后复制到这里。
就是这样。我将在未来的视频中介绍这个文件 proc.c 的其余部分。
22:系统调用剖析 🧠
在本节课中,我们将详细剖析 XV6 操作系统中一个系统调用从用户态发起,到内核处理,再返回用户态的完整过程。我们将以 sbrk 系统调用为例,逐步跟踪其执行路径,理解其中涉及的代码文件、函数和关键机制。
概述
系统调用是用户程序请求操作系统内核服务的接口。当用户程序执行 ecall 指令时,会触发一个从用户态到内核态的“陷阱”。内核接管后,根据特定的系统调用号执行相应服务,处理完毕后,再通过 sret 指令返回用户态。整个过程涉及用户态存根、陷阱处理、参数传递和内核服务函数等多个环节。
用户态入口:user.h 与存根函数
任何想要发起系统调用的用户模式程序都需要包含 user.h 头文件。该文件为每个系统调用提供了函数原型。
例如,sbrk 系统调用的原型如下:
int sbrk(int n);
它接收一个参数 n,表示堆内存需要增长(正数)或缩小(负数)的字节数。返回值为调整前堆的大小。
那么,当用户程序调用 sbrk() 时,实际执行的是哪个函数呢?答案是位于 usys.S 文件中的一段简短的汇编语言存根函数。
汇编存根:usys.S
usys.S 是一个汇编语言文件,为 21 个系统调用中的每一个都提供了一个存根函数。这些函数由 Perl 脚本自动生成。
每个存根函数的核心逻辑相同,以 sbrk 为例:
.global sbrk
sbrk:
li a7, SYS_sbrk
ecall
ret
这段代码执行以下操作:
-
li a7, SYS_sbrk:将系统调用号(对于sbrk是 12)加载到寄存器a7中。这是内核识别用户请求哪个系统调用的关键。 -
ecall:执行环境调用指令,触发从用户态到内核态的陷阱。 -
ret:从函数返回。此时,内核已将返回值放入寄存器a0。
在调用存根函数时,系统调用的参数已按约定存放在寄存器 a0 到 a5 中。ecall 指令不会改变这些寄存器的值,因此内核可以读取它们来获取参数。同样,内核会将返回值写入 a0 寄存器,供用户程序在 ecall 返回后使用。
陷阱处理流程:从 ecall 到 sret
当 ecall 指令执行后,处理器会陷入内核态,并开始执行一系列预设的陷阱处理代码。整个流程可以概括为:
-
陷入内核:
ecall指令将模式切换为内核模式,禁用中断,保存程序计数器(PC),并跳转到uservec函数。 -
保存上下文:
uservec位于“蹦床”页面,它保存用户寄存器,加载内核寄存器,并切换到内核的虚拟地址空间。 -
陷阱原因分发:随后跳转到 C 函数
usertrap。usertrap会检查陷阱原因(通过scause寄存器)。对于系统调用,scause的值为 8。 -
执行系统调用:
usertrap识别出是系统调用后,会调用syscall函数。 -
恢复与返回:
syscall返回后,usertrap调用usertrapret,后者再调用汇编函数userret。userret负责恢复用户寄存器,切换回用户地址空间,最后执行sret指令。sret指令将模式切换回用户模式,并从之前保存的 PC(此时已指向存根函数中的ret指令)处恢复执行。
上一节我们介绍了用户态如何发起调用以及整体的陷阱流程,本节中我们来看看内核是如何分发和执行具体的系统调用的。
内核分发:syscall 函数
usertrap 函数确定陷阱原因为系统调用后,会调用 syscall 函数(位于 syscall.c)。这是系统调用的核心分发器。
以下是 syscall 函数的关键步骤:
-
获取系统调用号:从当前进程的陷阱帧(trapframe)中读取用户之前存入
a7寄存器的系统调用号。int num = p->trapframe->a7; -
查询并调用处理函数:使用该系统调用号作为索引,查询一个名为
syscalls的函数指针数组。该数组在文件开头初始化,将每个系统调用号映射到对应的内核处理函数(例如,sys_sbrk)。// syscalls 数组示例 static uint64 (*syscalls[])(void) = { [SYS_fork] sys_fork, [SYS_exit] sys_exit, // ... [SYS_sbrk] sys_sbrk, // 索引 12 // ... }; // 调用对应的系统调用函数 p->trapframe->a0 = syscalls[num](); -
错误检查与返回:在调用前,会检查系统调用号是否有效(在数组范围内且对应的函数指针非空)。如果无效,则将返回值
a0设置为 -1 并打印错误信息。如果有效,则执行对应的函数,并将其返回值存入陷阱帧的a0寄存器,这将成为返回用户态后的返回值。
参数传递与辅助函数
内核的系统调用处理函数(如 sys_sbrk)如何获取用户传递的参数呢?它们依赖一组辅助函数。
以下是主要的参数获取辅助函数:
-
argraw(int n):直接从陷阱帧中返回第n个参数寄存器(a0-a5)的值。 -
argint(int n, int *ip):调用argraw获取第n个参数,并将其存储到指针ip指向的整数中。 -
argaddr(int n, uint64 *ip):与argint类似,但用于获取 64 位地址参数。 -
argstr(int n, char *buf, int max):获取第n个参数(一个指向字符串的指针),并从用户地址空间将该字符串安全地拷贝到内核缓冲区buf中,最多拷贝max个字符。
这些函数的核心是安全地从用户虚拟地址空间读取数据,使用了如 copyin、copyinstr 等虚拟内存辅助函数,并会进行边界检查,以防止用户程序传递非法指针导致内核崩溃或安全漏洞。
具体执行:sys_sbrk 与 growproc
现在,我们来看 sbrk 系统调用的具体实现函数 sys_sbrk(位于 sysproc.c)。
uint64 sys_sbrk(void)
{
int addr;
int n;
// 获取用户传递的参数 n
if(argint(0, &n) < 0)
return -1;
// 获取当前堆的大小(即进程的 sz 字段)
addr = myproc()->sz;
// 调用 growproc 来调整内存大小
if(growproc(n) < 0)
return -1;
// 返回调整前的堆大小
return addr;
}
其工作流程如下:
-
使用
argint(0, &n)从用户陷阱帧的a0寄存器获取要调整的字节数n。 -
记录当前进程地址空间的大小(
myproc()->sz),作为返回值。 -
调用
growproc(n)函数来实际调整内存。 -
如果
growproc成功,返回旧的地址空间大小;如果失败(如内存不足),返回 -1。
真正的内存调整工作由 growproc 函数(位于 proc.c)完成:
int growproc(int n)
{
struct proc *p = myproc();
uint64 sz = p->sz;
if(n > 0){
// 扩大内存
if((sz = uvmalloc(p->pagetable, sz, sz + n)) == 0) {
return -1;
}
} else if(n < 0){
// 缩小内存
sz = uvmdealloc(p->pagetable, sz, sz + n);
}
p->sz = sz;
return 0;
}
-
如果
n > 0,则调用uvmalloc分配新的物理页并映射到用户地址空间。 -
如果
n < 0,则调用uvmdealloc取消映射并释放物理页。 -
最后更新进程结构体中的
sz字段。
总结
本节课中我们一起学习了 XV6 系统调用的完整解剖过程:
-
用户侧发起:用户程序调用
user.h中声明的函数,实际执行的是usys.S中的汇编存根。存根将系统调用号存入a7,参数存入a0-a5,然后执行ecall。 -
陷入内核:
ecall触发陷阱,硬件切换到内核态,跳转到uservec保存上下文,再进入usertrap。 -
内核分发:
usertrap根据scause识别系统调用,调用syscall函数。syscall根据a7中的号码,从syscalls数组中找到对应的处理函数(如sys_sbrk)并执行。 -
参数与执行:处理函数通过
argint等辅助函数安全获取用户参数,并完成具体功能(如growproc调整内存)。 -
返回用户:返回值被置入
a0。控制流经由usertrapret、userret返回,最终执行sret指令,恢复用户态执行。
这个过程清晰地展示了用户态与内核态之间受控的边界跨越、参数的标准化传递以及内核服务的结构化分发,是理解操作系统如何为用户程序提供支持的关键。
23:Fork 系统调用 🧬
在本节课中,我们将要学习 XV6 操作系统中 fork 系统调用的工作原理。fork 是进程创建的唯一方式(除了初始进程的创建)。我们将详细探讨父进程如何克隆自身以创建子进程,以及内核在背后执行了哪些关键步骤。
概述
fork 系统调用允许一个进程(父进程)创建另一个几乎完全相同的进程(子进程)。调用成功后,两个进程都会从系统调用返回,但返回值不同,这使得它们能够区分自己是父进程还是子进程,并执行不同的代码路径。
进程创建的核心步骤
当父进程调用 fork 时,内核会执行一系列操作来创建子进程。以下是这些核心步骤的分解。
1. 分配进程结构
首先,内核调用 allocproc 函数。该函数在进程表中寻找一个空闲的 proc 结构体并占用它,开始初始化过程。
-
分配新进程ID (PID):为新进程分配一个唯一的标识符。
-
创建空地址空间:为新进程初始化一个空的虚拟地址空间。
-
映射核心页面:为
trampoline和trapframe页面建立映射,这是进程陷入内核和返回用户空间所必需的。
2. 复制虚拟地址空间
接下来,内核需要复制父进程的虚拟地址空间。这是通过 uvmcopy 函数完成的。
-
复制数据页:父进程的每一个数据页都会被复制,并添加到子进程的虚拟地址空间中。
-
复制权限:子进程中每个数据页的权限设置与父进程中对应页面的权限完全相同。
3. 初始化进程控制块
然后,内核初始化子进程 proc 结构体中的其他关键字段。
-
复制地址空间大小 (
sz):这个字段表示虚拟地址空间的大小(以字节为单位),它等同于堆顶的break地址。该值从父进程复制而来。 -
复制陷阱帧 (
trapframe):父进程进行系统调用时,其所有用户态寄存器(包括程序计数器 PC)的值都保存在其陷阱帧中。通过复制陷阱帧,子进程在被调度运行时,其所有寄存器将拥有与父进程调用fork时完全相同的值,并从ecall指令之后的位置开始执行。 -
修改返回值寄存器 (
a0):在子进程的陷阱帧中,将寄存器a0的值设置为0。这样,当子进程从fork系统调用返回时,看到的返回值是0。而在父进程中,a0保持不变,返回的是子进程的 PID。 -
复制进程名:子进程获得与父进程相同的名称。
-
复制文件描述符表:父进程所有已打开文件的文件描述符都会被复制到子进程中。这意味着在父进程中打开的任何文件,在子进程中同样处于打开状态。
-
复制当前工作目录:子进程继承父进程的当前工作目录。
-
设置父进程指针:在子进程的
proc结构体中,parent指针被设置为指向父进程的proc结构体。这是子进程与父进程在进程父子关系层次结构中的主要区别。 -
设置为可运行状态:最后,将子进程的状态设置为
RUNNABLE。至此,子进程创建完成,一旦调度器有机会,就会调度它运行。
如果上述任何步骤失败(例如内存不足),fork 系统调用会在父进程中返回 -1。
代码解析
上一节我们介绍了 fork 的理论步骤,本节中我们来看看在 XV6 的 proc.c 文件中,fork 函数是如何具体实现的。
系统调用处理函数 sys_fork 会直接调用 fork 函数并返回其结果。fork 函数的主要逻辑如下:
-
获取父进程指针:首先获取当前(父)进程的
proc结构体指针。 -
调用
allocproc:尝试分配一个新的进程结构体。如果失败,则返回-1。 -
复制地址空间:调用
uvmcopy复制父进程的所有数据页到子进程。如果复制失败,则释放刚分配的进程结构体并返回-1。 -
复制关键数据:
-
复制地址空间大小 (
sz)。 -
复制陷阱帧 (
trapframe),这包括了所有用户态寄存器。 -
在子进程的陷阱帧中,将
a0寄存器清零。
-
-
复制资源:
-
遍历父进程的打开文件表,为每个打开的文件复制文件描述符到子进程。
-
复制当前工作目录的引用。
-
复制进程名称。
-
-
处理父子关系与锁:
-
获取子进程的 PID 并保存到一个局部变量中(原因后述)。
-
为了修改子进程的
parent指针,需要获取一个全局的wait_lock。这里有一个关键操作:在获取wait_lock之前,必须先释放子进程proc结构体上的锁,这是为了避免死锁。 -
获取
wait_lock,设置子进程的parent指针指向父进程,然后释放wait_lock。
-
-
完成创建:将子进程状态设为
RUNNABLE,释放其proc结构体锁,最后返回之前保存的子进程 PID。
关于死锁的说明
为什么需要先释放子进程锁再获取 wait_lock?考虑一个典型的死锁场景:进程 A 持有锁 L1 并试图获取锁 L2,同时进程 B 持有锁 L2 并试图获取锁 L1,双方都无法继续执行。
在 fork 中,我们持有子进程的锁,然后需要获取 wait_lock。而在 exit(进程退出)和 wakeup(唤醒父进程)函数中,代码路径是先持有 wait_lock,然后尝试获取特定子进程的锁。如果 fork 不释放子进程锁就直接获取 wait_lock,就可能与正在执行 exit 的进程形成上述的循环等待,导致死锁。因此,释放锁再获取的顺序至关重要。
另外,在释放锁之前将 PID 保存到局部变量,是因为一旦锁被释放,这个 proc 结构体可能立即被调度、退出并被新的 fork 重用,从而分配新的 PID。如果之后再去读取 pid 字段,可能会得到错误的(新的)PID。
子进程的首次执行
子进程创建后处于 RUNNABLE 状态,但它如何开始执行呢?这与普通的进程调度返回略有不同。
当调度器最终选择子进程运行时,它会调用 swtch 进行上下文切换。swtch 会从子进程的 context 中加载寄存器并返回。关键在于,allocproc 函数在初始化子进程时,将其 context 中的返回地址寄存器 ra 设置为了一个特殊函数 forkret 的地址,同时设置了内核栈指针 sp。
因此,当 swtch “返回”时,它实际上跳转到了 forkret 函数。forkret 函数只做两件事:
-
释放当前进程(即子进程)的
proc结构体锁。 -
调用
usertrapret。
调用 usertrapret 后,流程就与一次普通的中断或系统调用返回完全一致了:它从子进程的陷阱帧中恢复所有用户态寄存器(其中 a0 已被设为 0),然后通过 sret 指令返回到用户空间。由于陷阱帧是父进程的副本,子进程将从父进程调用 fork 时 ecall 指令之后的那条指令开始执行,就像它自己刚刚完成了 fork 调用一样。
总结
本节课中我们一起学习了 XV6 的 fork 系统调用。我们了解到:
-
fork通过克隆父进程来创建子进程,是进程创建的核心机制。 -
内核需要执行分配结构体、复制地址空间、复制文件描述符、设置父子关系等一系列复杂步骤。
-
代码实现中需要精心处理锁的顺序,以避免死锁。
-
子进程通过一个特殊的
forkret路径完成初始化,并最终返回到用户空间,使其看起来像是从fork调用中返回。
理解 fork 是理解进程模型和后续如 exec、wait、exit 等系统调用的重要基础。
24:Exit、Wait、Kill 系统调用 🖥️
概述
在本节课中,我们将要学习 xv6 操作系统中用于进程终止和管理的三个核心系统调用:exit、wait 和 kill。我们将探讨进程如何终止、父进程如何回收子进程资源,以及如何强制终止一个进程。理解这些机制是理解操作系统进程生命周期管理的基础。
Exit 系统调用
上一节我们介绍了进程的基本概念,本节中我们来看看进程如何结束自己的生命。exit 系统调用用于终止一个进程。
-
功能:
exit终止调用它的进程。 -
参数:一个整数状态码。在 Unix/Linux 和 xv6 中,状态码 0 通常表示成功,非零值表示错误。
-
核心行为:
-
进程停止执行指令。
-
如果存在正在等待(
wait)的父进程,则唤醒父进程,并将状态码传递给父进程。 -
进程自身变为“僵尸”(Zombie)状态。僵尸进程已停止运行,但其进程结构体(
proc)仍被保留,用于存放退出状态,等待父进程回收。 -
如果父进程没有在等待,则终止的进程必须保持僵尸状态,直到父进程调用
wait。 -
如果父进程先于子进程退出,子进程会被“重新指定父进程”(reparent)给初始进程
init。init进程会负责回收这些孤儿进程。
-
以下是 exit 系统调用在 xv6 内核中的主要代码逻辑框架:
void exit(int status) {
// 关闭进程打开的文件
for(int fd = 0; fd < NOFILE; fd++) {
if(proc->ofile[fd]) {
// 关闭文件描述符
}
}
// 进程状态变为 ZOMBIE
proc->state = ZOMBIE;
// 保存退出状态
proc->xstate = status;
// 如果有子进程,将它们重新指定给 init 进程
reparent(proc);
// 唤醒可能正在等待的父进程
wakeup(proc->parent);
// 跳转到调度器,此进程永远不会再被调度
sched();
}
Wait 系统调用
了解了进程如何终止后,我们来看看父进程如何得知子进程的结束并清理资源。wait 系统调用用于父进程等待子进程终止。
-
功能:
wait使父进程等待任意一个子进程结束,并获取其退出状态。 -
参数:一个用于存放子进程退出状态码的内存地址指针。
-
返回值:返回终止子进程的进程 ID(PID)。如果没有子进程,则返回 -1。
-
核心行为:
-
父进程遍历进程表,寻找状态为
ZOMBIE的子进程。 -
如果找到僵尸子进程,则获取其退出状态码,复制到参数指定的用户空间地址,然后释放该子进程的
proc结构体,最后返回该子进程的 PID。 -
如果没有僵尸子进程,但存在活着的子进程,则父进程调用
sleep函数进入睡眠状态,等待子进程退出。 -
当子进程调用
exit时,会调用wakeup唤醒正在睡眠的父进程。父进程被唤醒后,再次执行步骤1。
-
以下是 wait 系统调用在 xv6 内核中的主要逻辑:
int wait(uint64 addr) {
// 循环等待子进程退出
for(;;) {
// 遍历所有进程,寻找当前进程的子进程
for(np = proc; np < &proc[NPROC]; np++) {
if(np->parent == proc) { // 找到子进程
acquire(&np->lock);
if(np->state == ZOMBIE) { // 子进程已终止
// 复制退出状态到用户空间 addr
copyout(..., addr, np->xstate, ...);
// 释放子进程资源
freeproc(np);
release(&np->lock);
return np->pid;
}
release(&np->lock);
}
}
// 没有找到可回收的僵尸子进程,但有子进程存活,则睡眠
sleep(proc, &wait_lock);
}
}
Kill 系统调用
最后,我们学习如何从外部强制终止一个进程。kill 系统调用用于向指定进程发送“终止”信号。
-
功能:
kill请求终止一个具有指定 PID 的进程。 -
参数:目标进程的 PID。
-
返回值:成功返回 0,如果 PID 不存在则返回 -1。
-
核心行为:
-
根据 PID 找到目标进程的
proc结构体。 -
将该进程的
killed标志设置为 1(真)。 -
如果目标进程正在睡眠(
SLEEPING状态),则将其状态改为可运行(RUNNABLE),并唤醒它。这是为了确保被kill的进程能尽快执行到检查killed标志的代码路径。 -
kill系统调用本身并不立即停止目标进程。目标进程会在下次有机会执行内核代码时(例如,从系统调用返回用户空间前)检查自己的killed标志。如果发现被置位,则会调用exit来终止自己。
-
以下是 kill 系统调用在 xv6 内核中的主要逻辑:
int kill(int pid) {
// 遍历进程表
for(p = proc; p < &proc[NPROC]; p++) {
acquire(&p->lock);
if(p->pid == pid) { // 找到目标进程
p->killed = 1; // 设置终止标志
if(p->state == SLEEPING) {
// 如果进程在睡眠,唤醒它以便其能检查 killed 标志
p->state = RUNNABLE;
}
release(&p->lock);
return 0;
}
release(&p->lock);
}
return -1; // 未找到指定 PID 的进程
}
总结
本节课中我们一起学习了 xv6 操作系统中三个关键的进程管理系统调用。
-
exit:进程主动终止,保存退出状态,变为僵尸进程,并通知或等待父进程。 -
wait:父进程等待并回收僵尸子进程的资源,获取其退出状态。这是防止僵尸进程残留的必要操作。 -
kill:向另一个进程发送终止请求。它通过设置标志位并可能唤醒目标进程来实现,实际的终止动作由目标进程在检查到标志后调用exit来完成。
这三个系统调用协同工作,构成了 xv6 进程从创建、运行到终止和清理的完整生命周期管理机制。理解它们对于掌握操作系统的进程模型至关重要。
25:休眠锁(Sleeplocks)
概述
在本节课中,我们将学习 xv6 操作系统内核中的休眠锁(Sleep Locks)。我们将了解休眠锁与自旋锁(Spin Locks)的区别,并详细解析休眠锁的数据结构、初始化、获取、释放以及调试功能。休眠锁允许进程在持有锁时进入睡眠状态,适用于需要长时间持有锁的场景。
休眠锁与自旋锁的对比
上一节我们介绍了自旋锁,它要求锁的持有时间必须非常短,并且在持有期间不允许进程进入睡眠状态。本节中我们来看看休眠锁,它解决了自旋锁在长时间持有锁时的局限性。
自旋锁有两个主要函数:acquire 和 release。获取自旋锁的函数会在一个紧密循环中等待锁被释放,这适用于多核系统,因为锁通常很快就会被释放。然而,如果需要在获取锁和释放锁之间等待很长时间,或者需要在此期间调用 sleep 函数让进程挂起,自旋锁就不适用了,因为它会占用 CPU 核心进行无意义的循环等待。
休眠锁则允许进程在持有锁时进入睡眠状态。在 xv6 的实现中,休眠锁也有对应的获取和释放函数,分别是 acquiresleep 和 releasesleep。
休眠锁的数据结构
以下是休眠锁的核心数据结构,它包含四个字段:
struct sleeplock {
uint locked; // 锁状态:1 表示被持有,0 表示空闲
struct spinlock lk; // 保护本结构体的自旋锁
char *name; // 锁的名称,用于调试
int pid; // 持有锁的进程 ID
};
-
locked是一个布尔值,表示锁是否被持有。 -
lk是一个自旋锁,用于保护locked和pid字段的并发访问。 -
name是锁的名称,仅用于调试。 -
pid记录当前持有锁的进程 ID。
休眠锁的函数实现
以下是休眠锁相关函数的实现细节。
初始化函数 initsleeplock
此函数用于初始化一个休眠锁结构体。
void initsleeplock(struct sleeplock *lk, char *name) {
initlock(&lk->lk, "sleep lock");
lk->name = name;
lk->locked = 0;
lk->pid = 0;
}
它初始化了保护锁的自旋锁 lk,设置了锁的名称 name,并将锁状态 locked 和持有者进程 ID pid 清零,表示锁处于未持有状态。
获取锁函数 acquiresleep
此函数用于获取一个休眠锁。
void acquiresleep(struct sleeplock *lk) {
acquire(&lk->lk); // 获取保护锁的自旋锁
while (lk->locked) { // 如果休眠锁已被持有
sleep(lk, &lk->lk); // 进入睡眠,等待锁被释放
}
lk->locked = 1; // 标记锁为已持有
lk->pid = myproc()->pid; // 记录当前进程 ID
release(&lk->lk); // 释放保护锁的自旋锁
}
-
首先获取保护休眠锁结构的自旋锁
lk->lk。 -
检查
locked字段。如果锁已被其他进程持有(locked == 1),则调用sleep函数让当前进程进入睡眠状态。sleep函数会原子性地释放自旋锁lk->lk并使进程睡眠,以避免错过唤醒信号。 -
当进程被唤醒并重新获得自旋锁后,再次检查
locked字段。如果锁已空闲,则跳出循环。 -
将
locked设置为 1,并记录当前进程的 ID 到pid字段。 -
最后释放保护用的自旋锁
lk->lk。
释放锁函数 releasesleep
此函数用于释放一个休眠锁。
void releasesleep(struct sleeplock *lk) {
acquire(&lk->lk); // 获取保护锁的自旋锁
lk->locked = 0; // 标记锁为空闲
lk->pid = 0; // 清除持有者进程 ID
wakeup(lk); // 唤醒所有等待此锁的进程
release(&lk->lk); // 释放保护锁的自旋锁
}
-
获取保护休眠锁结构的自旋锁
lk->lk。 -
将
locked字段设置为 0,表示锁已空闲。 -
将
pid字段清零。 -
调用
wakeup(lk)唤醒所有正在等待此休眠锁(通过相同的通道地址lk标识)的进程。 -
释放保护用的自旋锁
lk->lk。
调试函数 holdingsleep
此函数用于检查当前进程是否持有指定的休眠锁,主要用于错误检测。
int holdingsleep(struct sleeplock *lk) {
int r;
acquire(&lk->lk); // 获取保护锁的自旋锁
r = lk->locked && (lk->pid == myproc()->pid);
release(&lk->lk); // 释放保护锁的自旋锁
return r;
}
-
获取保护休眠锁结构的自旋锁
lk->lk。 -
检查条件:锁被持有(
locked == 1)且持有者进程 ID 等于当前进程的 ID。 -
释放保护用的自旋锁
lk->lk。 -
返回检查结果。如果返回假(0),内核可能会触发 panic 以指示错误。
总结
本节课中我们一起学习了 xv6 操作系统内核中的休眠锁。我们了解了休眠锁与自旋锁的关键区别:休眠锁允许进程在持有锁时进入睡眠状态,适用于需要长时间操作的临界区。我们详细分析了休眠锁的数据结构,它包含一个用于内部保护的自旋锁。我们还逐步解析了休眠锁的初始化、获取、释放以及用于调试的检查函数的工作原理。掌握休眠锁是理解 xv6 中涉及长时间等待或 I/O 操作的同步机制的重要一步。
26:内核模式下的陷阱处理
概述
在本节课中,我们将要学习当 xv6 操作系统内核在内核模式下执行时,如果发生陷阱(例如设备中断或时钟中断),系统是如何处理的。我们将分析相关的汇编代码和 C 语言函数,理解寄存器保存、中断处理以及进程调度的具体流程。
内核模式陷阱处理流程回顾
上一节我们介绍了用户模式下的陷阱处理流程。本节中我们来看看内核模式下的情况。当内核代码执行时发生陷阱,硬件会跳转到 kernelvec 汇编代码处,而不是 uservec。
内核模式下的陷阱处理与用户模式有一个关键区别:寄存器被保存在当前内核栈上,而不是进程的固定陷阱帧中。这是因为内核在执行时已经拥有一个有效的栈。
内核陷阱入口:kernelvec
首先,我们查看 kernelvec.S 中的汇编代码,这是内核模式陷阱发生后首先执行的地方。
.globl kernelvec
.align 4
kernelvec:
# 在内核栈上分配256字节空间
addi sp, sp, -256
# 保存所有通用寄存器(除了始终为0的x0寄存器)
sd ra, 0(sp)
sd sp, 8(sp)
sd gp, 16(sp)
# ... 保存其他寄存器 t0-t6, s0-s11, a0-a7
# 调用C语言陷阱处理函数
call kerneltrap
# 恢复所有通用寄存器
ld ra, 0(sp)
ld sp, 8(sp)
ld gp, 16(sp)
# ... 恢复其他寄存器
# 释放栈空间并返回
addi sp, sp, 256
sret
这段代码首先在内核栈上预留空间,然后保存所有通用寄存器的状态。接着,它调用C函数 kerneltrap 进行具体的陷阱处理。处理完毕后,恢复寄存器状态,并通过 sret 指令返回到被中断的内核代码继续执行。
注意:tp(线程指针)寄存器在此过程中不会被恢复。因为陷阱(如时钟中断)可能导致进程被重新调度到不同的CPU核心上执行,而 tp 寄存器用于标识当前运行的核心,应由调度器在恢复进程时正确设置。
核心陷阱处理函数:kerneltrap
kerneltrap 函数(位于 trap.c 中)是内核模式陷阱处理的核心。它负责诊断陷阱原因并分发给相应的处理程序。
以下是该函数的关键步骤:
-
保存关键状态寄存器:首先保存发生陷阱时的程序计数器(
sepc)、状态寄存器(sstatus)和原因寄存器(scause)的值。这些信息对于返回和诊断至关重要。 -
完整性检查:确认陷阱发生时确实处于内核模式(通过检查
sstatus中的特权模式位),并确认中断当时是禁用的。 -
调用设备中断处理:调用
devintr()函数来判断陷阱的具体类型。 -
根据返回值处理:
-
如果返回 2,表示是时钟中断,则调用
yield()函数让出CPU。 -
如果返回 1,表示是设备中断(如UART或磁盘),
devintr()内部已调用相应设备的中断处理程序。 -
如果返回 0,表示是未知原因,则打印错误信息并触发内核恐慌(
panic)。
-
-
恢复与返回:最后,恢复之前保存的
sepc和sstatus寄存器值,然后返回到kernelvec汇编代码,由后者完成最终的寄存器恢复和sret返回。
一个重要的检查:在因时钟中断调用 yield() 之前,代码会检查当前进程的状态是否为 RUNNING。这是因为中断也可能发生在调度器代码本身(而非某个进程)执行时。例如,当调度器循环寻找可运行进程并临时打开中断时,就可能被中断。此时没有进程处于 RUNNING 状态,不应调用 yield()。
设备中断分发函数:devintr
devintr 函数负责解析 scause 寄存器,以确定具体的中断来源。
其逻辑如下:
-
读取
scause寄存器。 -
判断中断类型:
-
如果是外部中断,则来源于平台级中断控制器(PLIC),通常是UART或磁盘设备。函数会查询PLIC是哪个设备触发的,然后调用对应的中断处理程序(
uartintr或virtio_disk_intr),最后告知PLIC中断已处理,并返回值 1。 -
如果是软件中断,在xv6中,这由机器模式代码在收到时钟中断后模拟产生,因此代表时钟中断。函数会清除中断等待位,如果是核心0,还会更新全局时钟滴答数(
ticks)。最后返回值 2。 -
其他情况返回值 0。
-
时钟滴答数 ticks 由一个自旋锁 tickslock 保护,对其进行累加操作时需要先获取锁。
总结
本节课中我们一起学习了 xv6 内核模式下的陷阱处理机制。关键点在于:
-
内核陷阱使用当前内核栈来保存上下文。
-
处理入口是
kernelvec汇编代码,它保存/恢复寄存器并调用kerneltrap。 -
kerneltrap函数进行状态保存、原因诊断,并分发给设备中断处理或调度器(yield)。 -
devintr函数具体区分设备中断和时钟中断。 -
处理过程需要仔细管理中断的禁用与启用状态,并考虑调度器上下文等特殊情况。
理解内核模式下的陷阱处理,对于掌握操作系统的并发控制、中断响应和进程调度至关重要。
27:平台级中断控制器 (PLIC) 🎯
在本节课中,我们将要学习平台级中断控制器(Platform Level Interrupt Controller,简称 PLIC)的工作原理。PLIC 是 RISC-V 处理器生态系统中一个关键的硬件电路,负责管理和路由来自各种输入/输出(I/O)设备的中断信号到处理器核心。理解 PLIC 是理解操作系统如何处理硬件中断的基础。
概述:中断路由问题
当 I/O 设备需要中断一个处理器核心时会发生什么?这是一个核心问题。设想我们有一些 I/O 设备,如磁盘、键盘、UART 或网卡,以及多个处理器核心。当设备需要关注时(例如,用户按下键盘或磁盘完成读写操作),它会发送一个中断信号。这个信号需要被路由到某个核心,触发该核心上的陷阱(trap),从而运行相应的中断处理程序。中断处理程序会直接与设备通信,处理请求,然后恢复被中断的工作。PLIC 就是负责这个路由过程的硬件。
PLIC 的基本架构与工作流程
上一节我们介绍了中断路由的基本问题,本节中我们来看看 PLIC 是如何具体解决这个问题的。
PLIC 与 I/O 设备、处理器核心以及主内存通过某种互连结构(如总线)连接。设备通过内存映射 I/O 寄存器与核心通信,而 PLIC 本身也是一组内存映射寄存器,供核心进行配置和交互。
中断信号通过专门的线路(电线)从设备传送到 PLIC。信号进入 PLIC 后,首先经过一个称为“网关”(gateway)的组件。网关可以理解为一个比特位,当中断到达时,该位被置为 1,表示有一个中断正在等待处理(pending)。此时,PLIC 会根据一个“使能矩阵”(enable matrix)来决定通知哪些核心。
以下是 PLIC 处理单个中断的基本步骤:
-
中断发生:设备发送中断信号,PLIC 网关中对应的“源中断挂起位”(source interrupt pending bit)被置为 1。
-
通知核心:PLIC 查询使能矩阵,向所有为该设备使能的核心发送外部中断信号。
-
核心响应:如果目标核心的中断是启用的,则会立即发生陷阱,并跳转到陷阱处理程序。
-
声明中断:陷阱处理程序的第一件事是向 PLIC “声明”(claim)这个中断。这是通过读取 PLIC 中一个特定的内存映射寄存器来完成的。PLIC 会返回触发中断的设备 ID,并(通常)清除对应的源中断挂起位。
-
处理中断:获得设备 ID 的核心会运行对应的设备驱动程序代码,直接与设备交互。
-
完成中断:中断处理完毕后,处理程序通过向同一个声明寄存器写入设备 ID 来通知 PLIC 中断处理已完成。
-
后续处理:PLIC 收到完成通知后,会再次检查该设备的源中断挂起位。如果该位仍为 1(表示设备又发出了中断请求),则 PLIC 会再次发起中断流程。
中断信号类型:电平敏感与边沿触发
在深入细节之前,我们需要了解设备发送给 PLIC 的两种信号类型,这决定了 PLIC 如何检测中断请求。
-
电平敏感(Level-sensitive):PLIC 持续监测信号线的电平。当信号线为高电平(1)时,PLIC 就认为有中断请求。中断处理完成后,PLIC 会再次检查,如果线仍然是高电平,则会触发下一次中断。
-
边沿触发(Edge-triggered):PLIC 监测信号从低电平到高电平的跳变(上升沿)。每次上升沿代表一次中断请求。网关有两种实现方式:
-
单比特位:上升沿将位置 1,直到中断被处理完成才清零。在此期间的新上升沿被忽略。
-
计数器:每个上升沿使计数器加 1,每次中断被声明时计数器减 1。处理完成后,如果计数器大于 0,则立即触发下一次中断。
-
PLIC 的详细机制与配置
现在我们已经了解了 PLIC 的基本流程和信号类型,本节我们来探讨一些更复杂的机制和配置细节。
首先,每个能产生中断的设备都被分配一个唯一的 ID(1 到 1023),ID 0 表示“无设备”。其次,现代处理器核心可能支持多个硬件线程(HART),并且 RISC-V 核心可以在不同的特权模式(如机器模式、监管者模式)下运行。PLIC 规范将每个“目标”(target)定义为(HART, 模式)的组合,最多支持 15872 个目标。
PLIC 引入了优先级和阈值的概念来实现中断仲裁:
-
设备优先级:每个设备被赋予一个优先级数字,数字越大优先级越高。快速设备(如磁盘)通常设置高优先级,慢速设备(如键盘)设置低优先级。
-
核心阈值:每个目标(核心/模式)有一个优先级阈值。
-
仲裁规则:一个设备的中断只会发送给一个核心,当且仅当该设备的优先级高于该核心的当前阈值。PLIC 会选择优先级最高的待处理中断,并将其发送给阈值低于该中断优先级且使能了该设备的所有核心中,ID 最小的那个核心。
处理器核心通过读写 PLIC 的内存映射 I/O 寄存器来控制它。这些寄存器占据了物理地址空间中的一大段区域(例如 64 MB),主要包括:
-
优先级寄存器:为每个设备(ID 1-1023)设置优先级。
-
阈值寄存器:为每个目标设置优先级阈值。
-
使能寄存器:一个大的位矩阵,配置每个设备可以向哪些目标发送中断。
-
挂起寄存器:核心可以读取这些位来查询哪些设备有中断正在挂起。
-
声明/完成寄存器(每个目标一个):这是最重要的寄存器。读取操作会“声明”一个中断并返回设备 ID;写入一个设备 ID 则“完成”该中断的处理。
在 xv6 与 QEMU 中的具体实现
理论部分已经介绍完毕,本节我们来看看 PLIC 在 xv6 操作系统和 QEMU 模拟器中的具体实现。
在 xv6 运行的 QEMU 环境中,PLIC 是虚拟模拟的。xv6 支持 8 个核心(NCPU),并且只使用监管者模式(supervisor mode)来处理中断,因此总共有 8 个目标。QEMU 模拟了两个主要 I/O 设备:
-
虚拟 I/O 磁盘(VirtIO disk):设备 ID 1
-
UART(串口):设备 ID 10
xv6 的 PLIC 驱动代码在 plic.c 文件中,非常简洁。它主要包含四个函数:
以下是相关函数的简要说明:
-
plicinit(): 由核心 0 在启动时调用一次,用于设置两个设备的优先级。// 设置磁盘(ID 1)和 UART(ID 10)的优先级为 1 *(uint32*)(PLIC + UART0_IRQ*4) = 1; *(uint32*)(PLIC + VIRTIO0_IRQ*4) = 1; -
plicinithart(): 每个核心在启动时调用,用于配置本核心的 PLIC。int hart = cpuid(); // 1. 设置使能位:允许磁盘(ID 1)和UART(ID 10)中断本核心 uint32 enabled = (1 << UART0_IRQ) | (1 << VIRTIO0_IRQ); *(uint32*)PLIC_SENABLE(hart) = enabled; // 2. 设置本核心的优先级阈值为 0,允许所有优先级的中断 *(uint32*)PLIC_SPRIORITY(hart) = 0; -
plic_claim(): 当核心进入设备中断处理程序时调用,用于向 PLIC 声明并获取中断的设备 ID。int hart = cpuid(); int irq = *(uint32*)PLIC_SCLAIM(hart); // 读取声明寄存器 return irq; // 返回设备ID,若为0则表示本核心未获得中断 -
plic_complete(): 设备中断处理完毕后调用,通知 PLIC 中断已完成。int hart = cpuid(); *(uint32*)PLIC_SCLAIM(hart) = irq; // 向声明寄存器写入设备ID
当中断发生时,devintr() 函数会调用 plic_claim()。如果返回非零的设备 ID(1 或 10),则调用相应的设备处理程序(如 uartintr() 或 virtio_disk_intr()),处理完毕后再调用 plic_complete()。如果 plic_claim() 返回 0,说明其他核心已经处理了该中断,则本核心直接返回。
总结
本节课中我们一起学习了平台级中断控制器(PLIC)的核心概念。我们首先了解了 PLIC 如何解决多设备到多核心的中断路由问题。接着,我们剖析了其工作流程:从中断发生、核心通知、声明中断、处理中断到最终完成中断。我们还区分了电平敏感和边沿触发两种中断信号类型。然后,我们深入探讨了优先级、阈值、内存映射寄存器等详细机制。最后,我们通过分析 xv6 内核中简短的 plic.c 代码,看到了 PLIC 在实践中的初始化、声明和完成操作是如何实现的。PLIC 是操作系统与硬件中断交互的关键枢纽,理解它对于掌握操作系统的底层工作原理至关重要。
28:磁盘缓冲区缓存 🗃️
在本节课中,我们将学习 Unix 文件系统的底层实现,特别是磁盘系统和缓冲区缓存机制。我们将从磁盘的基本概念开始,逐步深入到 xv6 内核中管理磁盘块缓存的代码实现。
概述
在本节中,我们将首先了解磁盘如何以“块”为单位进行数据读写,以及操作系统如何通过“缓冲区缓存”来管理这些磁盘块,以提高性能并协调多个进程对同一数据的访问。我们将重点分析 xv6 中的 buf.h 和 bio.c 文件。
磁盘与数据块
上一节我们介绍了课程的整体目标,本节中我们来看看数据存储的基础——磁盘。
磁盘与主内存交换数据的单位不是字节或字,而是一个更大的单位,称为“块”。块是固定大小的字节块。在 xv6 中,块大小由常量 BSIZE 定义为 1024 字节。其他系统(如 Linux)可能使用不同的块大小,但在任何操作系统中,块大小都是固定的。
磁盘可以被视为一系列按编号排列的块,编号从 0 开始,直到某个最大值。其中,编号为 1 的第二个块是特殊的,它包含称为“超级块”的信息。超级块是固定的,包含多个参数,其中一个参数是磁盘的大小或块数,文件系统通过读取它来了解可用空间。
磁盘实际读写字节的另一个单位是“扇区”。块和扇区有时被混用,但它们是不同的概念。通常,扇区大小比块小。例如,在 Unix 中,块大小可能是 4096 字节,而某个磁盘模型的扇区大小可能是 512 字节。通过让操作系统以块为单位工作,我们可以忽略不同磁盘驱动器的扇区大小差异。
每当内核需要读写磁盘时,它都会读写整个块,这将导致设备驱动程序读写多个扇区。例如,如果磁盘的扇区大小为 512 字节,那么每次内核读写 4096 字节时,将导致 8 个扇区的读写操作。
在旋转式磁盘设备上,存在多种延迟,例如移动磁头到其他磁道,或等待磁盘旋转使目标扇区位于读写头下方。通过将扇区组合成块,我们可以确保在读取时,大部分扇区是连续读取的,这显著提高了性能。当然,这也有代价:文件的最后一个块可能只被部分填充,最小文件大小将是块大小(如 4096 字节),可能导致一些空间浪费。
磁盘驱动接口
上一节我们了解了磁盘块的概念,本节中我们来看看 xv6 如何与磁盘交互。
xv6 通过 QEMU 模拟器运行,该模拟器与一个 VirtIO 磁盘设备接口。VirtIO 旨在标准化设备驱动程序与实际硬件之间的接口。xv6 的磁盘驱动程序提供了一个用于读写操作的函数。
这个函数名为 virtio_disk_rw,它可以执行读或写操作,具体由第二个参数决定。第一个参数是指向缓冲区(buf 结构体实例)的指针。该缓冲区包含足够的空间来存储一个块的数据(在 xv6 中是 1024 字节),以及我们想要读写的块号。
在 xv6 系统中,此函数不返回任何错误报告。如果发生错误(如读取失败),该函数内部会处理(例如重读),直到最终获取数据。在模拟器中,磁盘由主机系统上的文件模拟,因此可能不会发生实际错误。
此函数可能会休眠。例如,如果我们想读取一个缓冲区,该函数会启动读取操作,然后进入睡眠状态。当操作完成时,磁盘会引发一个陷阱,磁盘的中断处理程序将被激活,并唤醒睡眠的函数。此时,调用读/写操作的进程将被重新唤醒并返回。
此外,该函数不会重新排序操作,它会严格按照函数被调用的顺序执行操作,这对于原子事务很重要。
缓冲区缓存结构
了解了磁盘接口后,现在我们聚焦于核心的缓存机制——缓冲区缓存。
buf 结构体被用作磁盘块的缓存。在内核启动时,会预分配固定数量的缓冲区,由常量 NBUF 控制。在 xv6 中,恰好有 30 个缓冲区,每个缓冲区都有足够的空间容纳一个数据块(1024 字节)。每个缓冲区还包含其缓存数据对应的磁盘块号,以及其他用于同步的字段。
缓冲区可以是空闲的或正在使用的。我们有一个空闲缓冲区列表,不在列表中的缓冲区正在被使用。
以下是缓冲区的组织方式,它们被组织成一个双向循环链表(也称为环形链表):
-
有一个特殊的头节点(
head),它不包含任何数据,仅用于其next和prev指针。 -
缓存中的缓冲区(例如 30 个)按“最近最少使用”到“最近最多使用”的顺序组织。我们可以通过头节点的
next指针找到最近最多使用的缓冲区,通过prev指针找到最近最少使用的缓冲区。 -
使用环形链表可以轻松地移除元素,例如,如果一个元素变为最近最多使用,我们可以将其移到列表前端。
让我们详细查看缓冲区的内容:
-
next,prev: 指向链表中其他buf结构的指针。 -
refcnt: 引用计数。如果为 0,表示该缓冲区当前未被使用,可以回收重用。如果大于 0,则缓冲区正在使用中。 -
dev: 设备号。在 xv6 中只有一个磁盘设备,所以它基本上是常量 1。 -
blockno: 指示此缓冲区中存储的数据对应磁盘上的哪个块。 -
data: 存储整个数据块的空间(1024 字节)。 -
valid: 标志位,指示data字段是否包含来自磁盘的有效数据。如果不包含,当我们需要使用该数据时,必须从磁盘读入。 -
disk: 字段,仅在磁盘驱动程序内部使用(virtio_disk_rw函数),用于指示磁盘操作是否正在进行。 -
lock: 睡眠锁,用于保护数据以及valid和disk标志位。
缓冲区通过一个名为 bcache 的结构体进行分配,它包含三个字段:
-
lock: 一个自旋锁。 -
head: 一个buf结构体,作为链表的头节点。 -
buf: 一个buf结构体数组(在 xv6 中是 30 个元素),这些是实际的缓冲区。
我们不直接访问这个数组,而是通过头节点的指针来访问。该数组仅在初始化时用于分配缓冲区并构建初始的循环链表。自旋锁用于保护整个链表,特别是 next、prev、refcnt、dev 和 blockno 字段。每次我们想要分配或释放缓冲区时,都需要获取这个自旋锁。
缓存初始化与核心函数
在了解了缓冲区缓存的结构后,本节我们来看看它是如何初始化和运作的。
首先,我们查看 buf.h 文件,它包含了 buf 结构体的定义,其字段与我们之前描述的一致。
接下来,我们查看 bio.c 文件,其中定义了 bcache 结构体。文件顶部的注释说明了缓冲区缓存的用途和接口:
-
缓冲区缓存是
buf结构体的链表,用于缓存磁盘块内容。 -
在内存中缓存磁盘块可以减少磁盘读取次数,并为多个进程使用的块提供同步点。
-
接口:
-
要获取特定磁盘块的缓冲区,应调用
bread函数。 -
更改缓冲区中的数据后,可以调用
bwrite将其写回磁盘。 -
使用完缓冲区后,应调用
brelse释放它,之后不应再使用该缓冲区。 -
一次只能有一个进程使用一个缓冲区,因此需要注意不要过长时间持有缓冲区。
-
初始化函数 binit 在内核启动时被调用。它初始化 bcache 锁,并创建缓冲区的链表。它首先创建一个空链表,其中头节点的 prev 和 next 指针都指向自身。然后遍历 buf 数组,初始化每个缓冲区的睡眠锁,并将其添加到链表中。refcnt、dev 和 blockno 字段被隐式初始化为 0。
缓冲区读写与获取
现在,让我们深入核心的缓冲区操作函数。
bread 函数返回一个已上锁的缓冲区,其中包含指定磁盘块的内容。它会搜索缓存,如果找到已缓存该磁盘块的缓冲区,则直接返回。否则,它会分配一个新缓冲区,从磁盘读取数据到该块,然后返回。在返回前,它会获取该缓冲区的睡眠锁,并增加其引用计数。
bread 接收设备号和块号作为参数。它首先调用 bget。bget 会搜索循环缓冲区链表,看是否已有该块的缓存副本。如果有,则返回指向该缓冲区的指针。如果没有,bget 会返回一个指向新分配的(引用计数为 0 的)缓冲区的指针。无论哪种情况,它都会增加引用计数并获取锁。然后,bread 检查 valid 标志。如果是从缓存中找到的现有副本,则 valid 为真。如果是新分配的缓冲区,则 valid 为假,此时需要调用 virtio_disk_rw 从磁盘读取数据,然后将 valid 标志设为 1,最后返回缓冲区指针。
bget 函数首先遍历缓冲区缓存,寻找是否已有包含所需数据的块。如果找到,则返回指向该缓冲区的指针。如果没找到,则分配一个空闲缓冲区。无论哪种情况,它都返回一个指向缓冲区(已上锁且引用计数已增加)的指针。
在 bget 中,我们首先获取 bcache 锁以保护链表。第一个循环正向遍历链表(跟随 next 指针),寻找设备号和块号都匹配的缓冲区。如果找到,则增加其引用计数,释放 bcache 锁,然后获取该缓冲区自身的睡眠锁,最后返回指针。
如果遍历链表后没有找到匹配项,则需要获取一个当前未使用的缓冲区。此时,我们反向遍历链表(跟随 prev 指针),寻找引用计数为 0 的缓冲区。如果找到,我们将其 dev 和 blockno 设置为目标值,将其 valid 标志设为 0(因为它可能包含其他磁盘块的数据),将其引用计数从 0 增加到 1,释放 bcache 锁,然后获取该缓冲区的睡眠锁并返回。如果找不到任何空闲缓冲区,则会触发错误(在预分配足够缓冲区的情况下,这不应发生)。
关于 LRU 列表的维护:xv6 采用的方式是,每次使用完一个缓冲区(即释放时),将其移动到列表的尾部。因此,在 bget 中我们看不到缓冲区位置的改变,这将在 brelse 函数中完成。
缓冲区写回与释放
获取和读取缓冲区后,我们还需要知道如何将修改写回磁盘并释放缓冲区。
bwrite 函数接收一个指向缓冲区的指针,并将该缓冲区中块的内容写回磁盘上的对应位置。每个要写入磁盘的缓冲区必须首先通过调用 bread 函数获取。因此,此时缓冲区已设置好 dev 和 blockno 字段,并包含数据块中的 1024 字节数据。此外,bread 函数返回的缓冲区总是处于上锁状态。bwrite 会检查是否持有该缓冲区的锁,然后调用 virtio_disk_rw 函数并传入参数 1 来执行写操作。该操作将缓冲区中的块写回磁盘,并在写操作完成后返回。
使用缓冲区的典型流程是:首先调用 bread 将数据从磁盘读入缓冲区。此时缓冲区已上锁,并包含数据。然后,我们可以修改该块中的部分或全部字节,接着调用 bwrite 将修改后的块写回磁盘。我们还可以继续修改缓冲区中的字节并再次调用 bwrite,因为 bwrite 函数本身不会释放缓冲区。
当我们准备释放缓冲区时,调用 brelse 函数。由于该缓冲区是通过调用 bread 获取的,我们应该持有它的锁。brelse 首先检查是否确实持有该缓冲区的睡眠锁,然后释放该锁。接着,它减少引用计数,并检查是否变为 0。
每次访问引用计数或 prev/next 字段时,都需要持有 bcache 锁。因此,在 brelse 中,我们先获取 bcache 锁,然后减少引用计数。如果引用计数变为 0,表示该缓冲区空闲,不再被任何人使用。它仍然包含该特定块的数据,dev 和 blockno 字段以及 valid 标志仍然准确。未来的 bread 调用可能会重用这个缓冲区,也可能需要它来缓存不同的块而丢弃现有数据。
如果引用计数变为 0,该缓冲区就成为最近最少使用的缓冲区。随后的代码会修改该缓冲区以及头节点的 next 和 prev 字段,基本上是将该缓冲区从当前位置解除链接,然后移动到循环链表的尾部(即最近最少使用的一端)。
其他辅助函数
最后,我们简要介绍两个辅助函数。
bpin 和 bunpin 函数用于“固定”或“取消固定”缓冲区。固定缓冲区就是增加其引用计数,取消固定则是减少其引用计数。当我们想确保某个缓冲区不会被过早释放时,可以调用 bpin 函数来增加其引用计数。当我们用完该缓冲区,并希望允许其他人释放它时,可以调用 bunpin 来减少引用计数。为了修改引用计数,必须持有 bcache 锁,因此在这两个函数中我们都先获取再释放该锁。
总结
本节课中,我们一起学习了 xv6 操作系统中磁盘缓冲区缓存的实现。我们从磁盘块的基本概念出发,了解了 VirtIO 磁盘驱动接口,深入探讨了缓冲区缓存的数据结构(buf 和 bcache)及其组织方式(双向循环链表)。我们分析了核心函数 bread、bget、bwrite 和 brelse 的工作原理,它们共同实现了磁盘块的缓存、读取、修改、写回和释放,并维护了 LRU 替换策略。此外,我们还了解了用于缓冲区管理的 bpin 和 bunpin 辅助函数。这套机制有效减少了磁盘 I/O,并协调了多进程对磁盘数据的访问。
29:磁盘日志文件系统 🗂️
在本节课中,我们将要学习 xv6 操作系统内核中一个关键的机制:磁盘日志文件系统。这个系统用于确保文件系统在发生崩溃时,其数据结构能保持一致性。我们将深入分析 log.c 文件中的代码,理解其工作原理。
概述
文件系统的更新通常涉及对多个磁盘块的写入。如果在写入过程中系统发生崩溃,可能导致文件系统处于不一致的状态(例如,数据损坏或丢失)。日志文件系统通过将多个写入操作组合成一个“事务”来解决这个问题。一个事务要么全部完成(提交),要么在崩溃时完全不生效,从而保证了“全有或全无”的原子性。
核心问题与动机
上一节我们介绍了文件系统一致性的重要性,本节中我们来看看一个具体的例子。
考虑一个需要交换两个节点顺序的单向链表。这需要更新三个指针。如果系统在更新过程中崩溃,链表可能处于损坏状态。在文件系统中,每个“更新”对应一个完整磁盘块的写入,一个复杂操作(如增加文件大小)可能涉及写入多个块。如果崩溃发生在部分写入之后,文件系统就会不一致。
例如,增加文件大小需要两个步骤:
-
从空闲块池中分配一个块。
-
将这个块添加到目标文件的索引结构中。
如果先执行步骤1后崩溃,会导致一个块既不在空闲池中,也不在任何文件中(块丢失)。如果先执行步骤2后崩溃,会导致一个块同时属于文件又属于空闲池(块重复)。这两种情况都是灾难性的。
事务与日志机制
为了解决上述问题,xv6 引入了事务机制。多个磁盘写入操作被分组到一个事务中。事务的边界由 begin_op() 和 end_op() 函数标记。
-
begin_op(): 标记事务开始。 -
end_op(): 标记事务结束。只有当所有进行中的事务都调用end_op()后,系统才会真正提交事务,将所有累积的写入一次性执行。
在事务内部,写入操作通过 log_write() 记录,而不是立即写入磁盘。这些被修改的块(称为“脏块”)被暂存在内存的缓冲区缓存中,并被“固定”,以防止被移出缓存。读取操作 bread() 会优先从缓冲区缓存中获取数据,如果数据不在缓存中,再从磁盘读取,这确保了事务内部能看到自己写入的最新数据。
磁盘布局与内存结构
xv6 的磁盘布局如下所示:
| 引导块 | 超级块 | 日志区 | 主数据区 |
| (块0) | (块1) | | |
日志区本身由一个日志头块和固定数量(例如30个)的日志数据块组成。日志头块在内存中对应一个 struct logheader 结构体,其核心字段是:
-
int n: 当前日志中已使用的数据块数量。 -
int block[LOGSIZE]: 一个数组,记录每个日志数据块实际对应主数据区中的哪个块号。
整个日志系统在内存中由一个 struct log 全局变量管理,它包含了日志的元数据(如起始位置、大小)、用于同步的自旋锁、记录进行中事务数量的计数器等。
关键操作流程
以下是事务处理中关键步骤的分解:
1. 开始事务 (begin_op)
begin_op() 在每次文件系统调用开始时被调用。
-
检查是否有其他线程正在提交事务,如果有则等待。
-
检查日志空间是否足够容纳本次事务可能的最大写入量(由
MAXOPBLOCKS定义,例如10个块)。如果空间不足,则等待。 -
通过增加
log.outstanding计数器来记录一个新的进行中事务。
2. 记录写入 (log_write)
当需要修改一个块时,调用 log_write(buf)。
-
该函数不立即写盘,而是将目标块的块号记录到内存中的日志头数组
log.lh.block[]中。 -
如果该块号已存在于日志中(即同一事务内多次修改同一块),则复用该条目,不增加计数
n。 -
否则,在数组末尾新增条目,并递增
n。 -
固定对应的缓冲区 (
bpin(buf)),增加其引用计数,确保在事务提交前它不会被重用或驱逐。
3. 结束事务 (end_op)
end_op() 在每次文件系统调用结束时被调用。
-
递减
log.outstanding计数器。 -
如果计数器减为 0,意味着这是最后一个进行中的事务,此时可以启动提交过程。它会设置
log.committing标志,并调用commit()函数。 -
如果计数器不为 0,则直接返回,等待其他事务结束。
4. 提交事务 (commit)
提交过程分为两个关键阶段,确保即使在提交中途崩溃也能恢复一致性。
阶段一:将脏数据写入日志区
-
调用
write_log():遍历内存日志头数组,将每个脏块(log.lh.block[i]指定的块)从缓冲区缓存复制到磁盘上对应的日志数据块中。 -
调用
write_head():将更新后的内存日志头(包含数组block[]和计数n)写入磁盘的日志头块。这一步是真正的提交点。在此之后,即使系统崩溃,恢复流程也能知道有一个完整的事务等待完成。
阶段二:将数据写回主数据区
-
调用
install_trans(recovering=0):再次遍历日志头数组,这次是将日志数据块中的内容,写回到它们真正的归宿——主数据区对应的块中。 -
清理:将内存日志头中的计数
n置为 0,并再次调用write_head()将清空的日志头写回磁盘。这标志着整个事务完成,日志空间被释放。
5. 崩溃恢复 (recover_from_log)
系统启动时,在初始化日志系统 (initlog) 的过程中会调用 recover_from_log()。
-
调用
read_head()从磁盘读取日志头。 -
如果日志头中的计数
n > 0,说明上次系统关闭前有一个已提交(阶段一完成)但未完全应用(阶段二未完成)的事务。 -
调用
install_trans(recovering=1),将日志中的数据块重新应用到主数据区。由于恢复时缓冲区未被固定,因此无需调用bunpin()。 -
恢复完成后,将
n置 0 并写回磁盘,清空日志。
这种设计保证了:只要阶段一完成,事务就是持久的;阶段二可以安全地重复执行(幂等性)。
并发与资源管理
系统需要处理多个并发事务:
-
提交互斥:
begin_op()会检查log.committing标志,确保同一时间只有一个提交在进行。 -
日志空间预留:
begin_op()会预留足够的日志空间(基于MAXOPBLOCKS),防止事务因空间不足而无法完成,这避免了死锁。 -
防止饿死:当最后一个事务调用
end_op()并启动提交后,它会唤醒所有可能在begin_op()中等待日志空间或提交完成的线程。
总结
本节课中我们一起学习了 xv6 磁盘日志文件系统的工作原理。其核心思想是通过事务将多个磁盘写入操作捆绑,并借助预写日志技术来保证原子性。关键步骤包括:在事务中延迟写入并记录日志,在提交时先确保所有修改持久化到日志区,再将修改应用到主数据区。这种机制确保了即使在系统崩溃的情况下,文件系统也能恢复到一致的状态。代码通过精巧的缓冲区管理、日志空间预留和并发控制,实现了这一复杂但至关重要的功能。
30:文件系统
概述
在本节课中,我们将学习 xv6 文件系统在磁盘上的组织方式。我们将了解磁盘布局、文件与目录的表示方法,以及用于创建文件系统的工具。核心概念包括inode、超级块和目录结构。
磁盘布局与访问
上一节我们介绍了日志系统,本节我们来看看文件系统数据在磁盘上的具体组织。
磁盘被划分为一系列连续的块。xv6 内核提供了函数来访问这些块。以下是访问磁盘块的核心代码:
struct buf *bp = bread(dev, blockno); // 读取块到内存缓冲区
... // 操作缓冲区数据
log_write(bp); // 将修改写入日志(事务的一部分)
brelse(bp); // 释放缓冲区
对磁盘块的修改被包裹在事务中,以确保原子性(要么全部写入,要么全部不写入)。
begin_op(); // 开始事务
... // 多次调用 log_write
end_op(); // 结束事务,确保所有写入原子提交
文件系统结构
一个文件系统包含一棵目录树和位于这些目录中的文件,它们都位于同一个设备(如磁盘)上。
-
目录 组织成树形结构(无环图)。
-
文件 通过路径名引用,文件本身没有名字。
-
文件类型:在 xv6 中,文件有三种类型:目录、普通文件和设备文件。
以下是文件系统类型的对比:
-
硬链接:允许多个目录条目指向同一个文件(inode)。xv6 支持。
-
符号链接(软链接):文件内容是一个路径名,内核会间接访问目标文件。xv6 不支持。
Inode:文件的标识符
用户程序通过路径名访问文件,但内核内部使用一个称为 inode 号 的小整数来唯一标识文件。
每个文件都关联一组属性,存储在磁盘上的一个固定大小的结构体中,称为 inode。内核会将正在使用的 inode 缓存在内存中。
以下是磁盘上 inode 结构体的定义(简化):
struct dinode {
short type; // 文件类型(目录、文件、设备)
short major; // 主设备号(仅设备文件有效)
short minor; // 次设备号(仅设备文件有效)
short nlink; // 指向此文件的硬链接数
uint size; // 文件大小(字节)
uint addrs[NDIRECT+1]; // 数据块地址数组
};
inode 号隐含在磁盘 inode 数组的索引中。类型为 0 表示该 inode 空闲。
详细的磁盘组织
现在,让我们更详细地了解 xv6 的磁盘布局。磁盘块按顺序组织,包含以下区域:
-
引导块:用于系统启动,内核不读写。
-
超级块:包含文件系统的元数据(如大小、inode 数量、各区域起始位置)。内核只读不写。
-
日志区:用于事务日志。
-
inode 区:存储所有 inode 结构体的数组。
-
位图区:每个数据块对应一个位,表示该块是空闲(0)还是已用(1)。
-
数据块区:存储文件和目录的实际内容。
超级块在启动时通过 readsb() 函数读入内存,其结构如下:
struct superblock {
uint magic; // 魔数,标识文件系统类型
uint size; // 文件系统总块数
uint nblocks; // 数据块数量
uint ninodes; // inode 数量
uint nlog; // 日志块数量
uint logstart; // 日志起始块号
uint inodestart; // inode 区起始块号
uint bmapstart; // 位图区起始块号
};
目录的表示
目录在 xv6 中是一种特殊类型的文件。其内容是一个线性数组,每个条目将文件名映射到 inode 号。
以下是目录条目的结构:
struct dirent {
ushort inum; // inode 号
char name[DIRSIZ]; // 文件名(最多14字符)
};
查找文件时,内核需要线性扫描目录数组。现代系统使用更复杂的数据结构来支持更长的文件名和更快的查找。
创建文件系统:mkfs
mkfs 是一个独立的 C 程序,用于从头创建一个 xv6 文件系统镜像。
它的作用是:
-
创建一个名为
fs.img的磁盘镜像文件。 -
初始化镜像:写入超级块、初始化 inode 数组和位图。
-
创建初始的目录树结构(如根目录
/)。 -
将编译好的用户程序(如
init,sh)作为文件写入镜像中。
这样,当 xv6 内核启动时,就能看到一个已初始化完毕、包含可用程序的完整文件系统。
总结
本节课我们一起学习了 xv6 文件系统的磁盘表示。
-
我们了解了文件系统在磁盘上的布局,包括超级块、inode 区、位图区和数据区。
-
我们学习了 inode 是文件的唯一标识,存储了文件的元数据和数据块指针。
-
我们知道了目录是一种将文件名映射到 inode 号的特殊文件。
-
最后,我们了解了
mkfs工具如何创建初始的文件系统镜像。
理解这些磁盘上的数据结构是理解文件系统代码如何工作的基础。
31:Inodes 详解 🗂️
在本节课中,我们将学习 XV6 操作系统中 Inode 的核心概念。我们将了解 Inode 在磁盘上的表示方式、在内存中的缓存机制,以及相关的数据结构和关键函数。通过本节内容,你将掌握文件系统如何通过 Inode 来组织和管理文件。
磁盘布局概览 📊
上一节我们介绍了文件系统的基本概念,本节中我们来看看 XV6 磁盘的具体布局。下图展示了磁盘的示意图,其中每个小方块代表一个磁盘块。
磁盘布局包含以下几个关键区域:
-
日志区:用于事务日志记录。
-
Inode 数组区:存储所有 Inode 结构。
-
位图区:标记数据块的使用情况。
-
数据块区:存储常规文件和目录的实际数据。
位图中的每一位对应一个数据块,1 表示已使用,0 表示空闲。当需要为文件或目录分配新块时,内核会在此位图中查找空闲块。
Inode 在磁盘上的结构 💾
让我们更深入地观察 Inode 数组区。该区域从超级块中 inodestart 字段指定的块号开始。这个数组由多个 inode 结构紧密排列组成。
每个磁盘上的 inode 结构包含以下字段:
-
type:文件类型(常规文件、目录或设备)。
-
major 与 minor:设备的主、次设备号(仅对设备文件有效)。
-
nlink:指向此文件的硬链接数量。
-
size:文件大小(字节数,对设备文件无效)。
-
addrs[]:一个包含 12 个直接块指针的数组。
如果文件大小超过 12 个块,系统将使用一个间接块。addrs[12] 指向一个磁盘块,该块本身不存储数据,而是存储 256 个指向其他数据块的指针。
因此,XV6 中单个文件的最大尺寸由以下公式决定:
最大文件大小 = (直接指针数 + 间接块指针数) * 块大小
最大文件大小 = (12 + 256) * 1024 字节
与支持双重、三重间接块的现代系统相比,XV6 的文件大小限制较为严格。
Inode 在内存中的缓存 🧠
为了高效操作,内核会将正在使用的 Inode 读入内存进行缓存。内存中有一个名为 itable 的结构,它包含一个自旋锁和一个大小为 50 的 inode 结构数组。
以下是内存中缓存的 inode 结构(定义在 file.h 中)包含的字段:
struct inode {
uint dev; // 设备号
uint inum; // Inode 编号
int ref; // 引用计数
struct sleeplock lock; // 睡眠锁
int valid; // 数据是否已从磁盘读入的标志位
short type; // 文件类型
short major; // 主设备号
short minor; // 次设备号
short nlink; // 硬链接数
uint size; // 文件大小
uint addrs[NDIRECT+1]; // 数据块地址数组
};
-
dev和inum共同唯一标识一个文件。 -
ref表示当前有多少个指针(或线程)正在使用此缓存 Inode。ref为 0 表示该缓存槽位空闲。 -
lock是一个睡眠锁,用于保护valid标志位及以下的所有字段(type,size,addrs等)。 -
valid标志位指示该 Inode 的元数据(如type,size)是否已从磁盘读入。若为 0,则在需要访问时必须先从磁盘读取。
itable 中的自旋锁保护的是整个缓存数组的分配状态,即 dev、inum 和 ref 字段。
设备切换表 ⚙️
XV6 通过一个名为 devsw(设备切换表)的数组来管理不同设备的驱动函数。该数组有 NDEV(默认为 10)个元素。
每个元素是一个包含 read 和 write 函数指针的结构体:
struct devsw {
int (*read)(int, uint64, int);
int (*write)(int, uint64, int);
};
extern struct devsw devsw[NDEV];
例如,控制台设备(主设备号 1)就使用此表中的特定函数进行读写操作。read 和 write 函数的参数包括目标/源地址、字节数以及一个标识地址空间(内核物理地址或用户虚拟地址)的标志位。
关键头文件解析 📄
以下是 fs.h 和 file.h 中与 Inode 相关的核心定义:
在 fs.h 中:
-
ROOTINO:根目录的 Inode 编号,固定为 1。 -
BSIZE:磁盘块大小,固定为 1024 字节。 -
NDIRECT:直接指针数量,值为 12。 -
NINDIRECT:一个间接块能容纳的指针数,值为BSIZE / sizeof(uint)= 256。 -
MAXFILE:最大文件块数,值为NDIRECT + NINDIRECT。 -
struct dinode:磁盘上 Inode 的结构体定义,与内存版本对应,但不包含缓存管理字段。 -
IPB:每块磁盘能容纳的 Inode 数量,计算公式为BSIZE / sizeof(struct dinode)。 -
IBLOCK宏:根据 Inode 编号计算其所在的磁盘块号。 -
BPB:每个位图块包含的比特数,值为BSIZE * 8。 -
struct dirent:目录项结构,包含一个 Inode 编号 (inum) 和一个最多 14 字符的文件名。
在 file.h 中:
-
struct inode:如前所述,内存中缓存的 Inode 结构。 -
struct devsw:如前所述,设备切换表项结构。
文件系统函数概览 🔧
fs.c 文件中包含了约 25 个管理文件系统的函数。在深入代码之前,我们先对这些函数进行简要介绍:
初始化与块管理:
-
iinit(): 初始化内存中的 Inode 缓存表 (itable)。 -
fsinit(int dev): 初始化文件系统,读取超级块。 -
bzero(int dev, int bno): 将指定块清零。 -
balloc(uint dev): 分配一个空闲数据块,更新位图并返回块号。 -
bfree(int dev, uint b): 释放一个数据块,更新位图。
Inode 生命周期管理:
-
ialloc(uint dev, short type): 在磁盘上分配一个新的 Inode(类型为type),将其读入内存缓存,并返回指针。它会设置磁盘 Inode 的type字段。 -
iget(uint dev, uint inum): 根据设备号和 Inode 编号,在内存缓存中获取对应的 Inode 结构。如果尚未缓存,则分配一个缓存槽位。增加其引用计数 (ref),但不对其加锁,也不保证数据 (valid) 已从磁盘读入。 -
ilock(struct inode *ip): 对 Inode 加睡眠锁。如果valid为 0,则从磁盘读取其元数据。 -
iunlock(struct inode *ip): 对 Inode 解锁。 -
iupdate(struct inode *ip): 将内存中已修改的 Inode 元数据写回磁盘。 -
idup(struct inode *ip): 增加 Inode 的引用计数。 -
iput(struct inode *ip): 减少 Inode 的引用计数。如果引用计数和硬链接数 (nlink) 都降为 0,则调用itrunc释放文件占用的所有数据块,并将磁盘 Inode 的type置 0 以标记为空闲。 -
itrunc(struct inode *ip): 将文件截断为长度 0,释放其所有数据块和间接块。
数据访问与路径解析:
-
bmap(struct inode *ip, uint bn): 将文件内的逻辑块号bn转换为磁盘上的物理块号。如果需要,会分配新的数据块。 -
readi(struct inode *ip, int user_dst, uint64 dst, uint off, uint n): 从文件的偏移off处读取n字节到内存地址dst。 -
writei(struct inode *ip, int user_src, uint64 src, uint off, uint n): 从内存地址src写入n字节到文件的偏移off处。 -
dirlookup(struct inode *dp, char *name, uint *poff): 在目录dp中查找文件名name。如果找到,则通过iget获取其 Inode,并可在poff中返回目录项偏移量。 -
dirlink(struct inode *dp, char *name, uint inum): 在目录dp中创建一个新的目录项,将name链接到 Inode 编号inum。 -
skipelem(char *path, char *name): 解析路径名,提取第一个组成部分到name,并返回指向路径剩余部分的指针。 -
namex(char *path, int nameiparent, char *name): 路径名解析的核心函数,根据nameiparent标志决定是解析到最后一级(返回目标 Inode)还是倒数第二级(返回父目录 Inode 并保存最后一级名称)。 -
namei(char *path)与nameiparent(char *path, char *name): 对外接口,封装了对namex的调用,分别用于获取目标 Inode 和父目录 Inode。
重要提示:以上所有涉及磁盘读写的函数(如 readi, writei, iupdate)本身并不包含事务(transaction)的开始 (begin_op) 和结束 (end_op)。它们被设计为在由更高层函数发起的事务上下文中被调用,通过日志系统来保证操作的原子性。
总结 📝
本节课中我们一起学习了 XV6 操作系统中 Inode 的核心机制。我们了解了 Inode 在磁盘上的物理布局与数据结构,以及在内核中如何通过缓存表 (itable) 进行高效管理。我们还浏览了 fs.h 和 file.h 中的关键定义,并对 fs.c 中负责 Inode 分配、释放、数据读写和路径解析的主要函数有了整体认识。理解这些基础组件是掌握 XV6 文件系统工作原理的关键。在下一节中,我们将深入这些函数的源代码,探究其具体实现细节。
32:fs.c 文件系统代码详解(第一部分)
在本节课中,我们将学习 xv6 文件系统核心代码文件 fs.c 中的一系列函数。我们将从初始化函数开始,逐步深入到文件创建、数据块管理以及索引节点(inode)缓存的操作。本教程将详细解释每个函数的逻辑,确保初学者能够理解其工作原理。
概述
fs.c 文件包含了 xv6 文件系统的核心实现。由于函数数量众多,我们将其分为两部分进行讲解。本视频(第一部分)将涵盖初始化、数据块分配与释放、索引节点缓存管理以及文件截断等核心功能。在下一部分中,我们将继续讲解文件读写、路径名解析等高级功能。
初始化函数
上一节我们介绍了文件系统的整体结构,本节中我们来看看系统启动时如何初始化文件系统组件。
文件系统初始化 (fsinit)
系统启动后,第一个用户进程会调用 fsinit 函数。该函数负责读取磁盘上的超级块(superblock)并初始化日志系统。
void fsinit(int dev) {
readsb(dev, &sb);
if(sb.magic != FSMAGIC)
panic("invalid file system");
initlog(dev, &sb);
}
代码解释:
-
readsb函数从指定设备读取超级块到内存中的sb结构体。 -
检查超级块的魔数(
magic)以确保文件系统类型正确。 -
调用
initlog初始化日志系统,此后系统可以开始事务操作(如begin_op,end_op)。
缓冲区缓存初始化 (binit)
在 main 函数中,会调用 binit 来初始化与缓冲区缓存相关的数据。
索引节点表初始化 (iinit)
索引节点缓存是一个包含 50 个 inode 结构的数组,由一个自旋锁保护整个数组,每个结构还有自己的睡眠锁。
void iinit() {
initlock(&itable.lock, "itable");
for(i = 0; i < NINODE; i++) {
initsleeplock(&itable.inode[i].lock, "inode");
}
}
代码解释:
-
初始化保护整个索引节点表(
itable)的自旋锁。 -
遍历数组,初始化每个
inode结构体的睡眠锁。 -
每个结构的引用计数(
ref)默认初始化为 0,表示该条目未被使用。
数据块管理
文件系统需要管理磁盘上的数据块,包括分配空闲块和释放已使用的块。
分配数据块 (balloc)
每当创建或扩展一个普通文件时,都需要在磁盘上找到一个未使用的数据块,将其清零,然后分配给文件。balloc 函数负责此操作。
static uint balloc(uint dev) {
for(b = 0; b < sb.size; b += BPB) {
bp = bread(dev, BBLOCK(b, sb));
for(bi = 0; bi < BPB && b + bi < sb.size; bi++) {
m = 1 << (bi % 8);
if((bp->data[bi/8] & m) == 0) {
bp->data[bi/8] |= m;
log_write(bp);
brelse(bp);
bzero(dev, b + bi);
return b + bi;
}
}
brelse(bp);
}
panic("balloc: out of blocks");
}
算法流程:
-
外层循环遍历位图的所有块(
b以每块位数BPB递增)。 -
对于每个位图块,调用
bread读入缓冲区。 -
内层循环遍历该块中的所有位(
bi)。 -
计算字节内位的位置并创建掩码
m。 -
如果该位为 0(表示空闲),则将其置 1,通过
log_write写回磁盘,然后释放缓冲区。 -
调用
bzero将新分配的数据块清零,最后返回块号。 -
如果搜索完所有位图都未找到空闲块,则系统崩溃。
释放数据块 (bfree)
当文件被删除或缩小时,需要将其占用的数据块归还给空闲池。bfree 函数负责此操作。
void bfree(int dev, uint b) {
bp = bread(dev, BBLOCK(b, sb));
bi = b % BPB;
m = 1 << (bi % 8);
if((bp->data[bi/8] & m) == 0)
panic("freeing free block");
bp->data[bi/8] &= ~m;
log_write(bp);
brelse(bp);
}
算法流程:
-
根据块号
b计算其对应的位图块,并读入缓冲区。 -
计算块在位图块内的位偏移
bi。 -
创建掩码
m以定位特定位。 -
检查该位是否已为 0(即已空闲),若是则报错。
-
使用
& ~m操作将该位清零。 -
通过
log_write将修改后的位图块写回磁盘,然后释放缓冲区。
索引节点缓存操作
文件系统通过索引节点缓存来高效管理正在使用的文件。主要操作包括获取、锁定、释放缓存条目。
获取索引节点 (iget)
当需要访问一个已存在的文件时,调用 iget。它在缓存中查找指定设备号和索引节点号的 inode。如果找到,则增加其引用计数并返回指针;如果未找到,则分配一个缓存条目。
struct inode* iget(uint dev, uint inum) {
acquire(&itable.lock);
// 1. 查找缓存中是否已存在
for(ip = &itable.inode[0]; ip < &itable.inode[NINODE]; ip++){
if(ip->ref > 0 && ip->dev == dev && ip->inum == inum){
ip->ref++;
release(&itable.lock);
return ip;
}
}
// 2. 查找一个空闲条目(ref == 0)并分配
for(ip = &itable.inode[0]; ip < &itable.inode[NINODE]; ip++){
if(ip->ref == 0) {
ip->dev = dev;
ip->inum = inum;
ip->ref = 1;
ip->valid = 0; // 标记数据尚未从磁盘读入
release(&itable.lock);
return ip;
}
}
panic("iget: no inodes");
}
关键点:
-
iget不会从磁盘读取inode数据,也不会对其加锁。 -
它只管理缓存条目的分配和引用计数。
-
新分配的条目
valid字段为 0,表示其磁盘数据尚未加载。
锁定并读取索引节点 (ilock)
在对 inode 进行修改或确保其数据已就绪前,需要调用 ilock。它会获取该 inode 的睡眠锁,如果 valid 为 0,则从磁盘读取数据。
void ilock(struct inode *ip) {
if(ip == 0 || ip->ref < 1)
panic("ilock");
acquiresleep(&ip->lock);
if(ip->valid == 0) {
bp = bread(ip->dev, IBLOCK(ip->inum, sb));
dip = (struct dinode*)bp->data + ip->inum % IPB;
ip->type = dip->type;
ip->major = dip->major;
ip->minor = dip->minor;
ip->nlink = dip->nlink;
ip->size = dip->size;
memmove(ip->addrs, dip->addrs, sizeof(ip->addrs));
brelse(bp);
ip->valid = 1;
if(ip->type == 0)
panic("ilock: no type");
}
}
释放索引节点引用 (iput)
当一个进程完成对文件的操作后,需要调用 iput 来减少缓存 inode 的引用计数。如果引用计数降为 0,并且文件的硬链接数也为 0,则删除该文件(释放其所有数据块)。
void iput(struct inode *ip) {
acquire(&itable.lock);
if(ip->ref == 1 && ip->valid && ip->nlink == 0) {
// 这是最后一个引用,且文件已无硬链接,可以删除
acquiresleep(&ip->lock);
release(&itable.lock);
itrunc(ip); // 截断文件,释放所有数据块
ip->type = 0; // 标记 inode 类型为未使用
iupdate(ip); // 将 type=0 写回磁盘
ip->valid = 0; // 使缓存失效
releasesleep(&ip->lock);
acquire(&itable.lock);
}
ip->ref--; // 减少引用计数
release(&itable.lock);
}
关键逻辑:
-
检查是否是该
inode的最后一个引用(ref == 1)且其硬链接数已为 0(nlink == 0)。 -
如果是,则获取该
inode的睡眠锁,然后释放全局表锁(避免长时间持有)。 -
调用
itrunc释放文件所有数据块。 -
将
inode类型置 0 并写回磁盘,标记为未使用。 -
将缓存条目的
valid置 0,防止后续ialloc错误重用旧缓存。 -
最后减少引用计数。如果引用计数不为 0,文件仍保留在缓存中供其他进程使用。
解锁并释放 (iunlockput)
这是一个常用组合操作:先解锁 inode 的睡眠锁,然后调用 iput 释放引用。
void iunlockput(struct inode *ip) {
iunlock(ip);
iput(ip);
}
文件创建与截断
分配索引节点 (ialloc)
当需要创建新文件(或目录)时,调用 ialloc。它在磁盘上查找一个类型为 0(未使用)的 inode,将其标记为已分配(设置类型),并返回其缓存版本。
struct inode* ialloc(uint dev, short type) {
for(inum = 1; inum < sb.ninodes; inum++) {
bp = bread(dev, IBLOCK(inum, sb));
dip = (struct dinode*)bp->data + inum % IPB;
if(dip->type == 0) { // 找到空闲 inode
memset(dip, 0, sizeof(*dip));
dip->type = type;
log_write(bp); // 将分配信息写入日志
brelse(bp);
return iget(dev, inum); // 获取缓存版本
}
brelse(bp);
}
panic("ialloc: no inodes");
}
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)