Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← 系统与高性能 / Systems & Performance

xv6 源码学习 / xv6 Source Learning

1. xv6 源码学习专题 / xv6 Source Learning

2. xv6 学习路线:从 C 指针到操作系统源码 / xv6 Learning Roadmap

3. 读 xv6 必备的 C 语言和指针基础 / C And Pointers For xv6

4. 读 xv6 必备的 RISC-V 基础 / RISC-V Basics For xv6

5. 如何阅读 xv6 源码 / How To Read xv6 Source

6. xv6 启动与内核入口 / xv6 Boot And Kernel Entry

7. xv6 内存布局与页表 / xv6 Memory Layout And Page Tables

8. xv6 trap、中断与系统调用 / xv6 Traps Interrupts And System Calls

9. xv6 进程与 struct proc / xv6 Process And struct proc

10. xv6 上下文切换与调度器 / xv6 Context Switch And Scheduler

11. xv6 sleep/wakeup、wait 与 exit / xv6 Sleep Wakeup Wait And Exit

12. xv6 锁与并发 / xv6 Locks And Concurrency

13. xv6 文件描述符与 inode / xv6 File Descriptor And Inode

14. xv6 文件系统与日志 / xv6 File System And Log

15. xv6 设备驱动、console、UART 与磁盘 / xv6 Device Driver Console UART And Disk

16. xv6 用户程序与 shell / xv6 User Programs And Shell

17. 用 QEMU/GDB 调试 xv6 / Debugging xv6 With QEMU And GDB

18. 从 xv6 过渡到 Linux 0.11 / Bridge From xv6 To Linux 0.11

19. xv6 专题总结与实践项目 / xv6 Summary And Practice Projects

本页目录

xv6 设备驱动、console、UART 与磁盘 / xv6 Device Driver Console UART And Disk ​

1. 这篇文章解决什么问题 ​

用户程序调用 read() 和 write()。

有时读写的是普通文件。

有时读写的是终端。

有时最终会访问磁盘。

这些动作看起来都像文件 I/O。

但内核底下可能走完全不同的路径。

例如:

text
write(1, "hi\n", 3)
  |
  v
consolewrite
  |
  v
uartwrite
  |
  v
UART MMIO register

read(file_fd, buf, n)
  |
  v
readi
  |
  v
bread
  |
  v
virtio_disk_rw
  |
  v
VirtIO block device
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

本篇关注设备驱动。

重点包括:

  • 什么是设备。
  • 什么是驱动。
  • 什么是 MMIO。
  • 什么是中断。
  • PLIC 在 RISC-V 里做什么。
  • console 输入输出如何走到 UART。
  • 磁盘块读写如何走到 virtio。
  • 为什么驱动经常要 sleep/wakeup。

本文基于标准 MIT xv6-riscv 的设备驱动结构。

本地 E:\Github\xv6-riscv 可读,本文列出的文件来自本地标准仓库。

1.1 本文基础词小注释 ​

设备 device:设备是 CPU 外面的硬件或虚拟硬件,比如键盘、串口、磁盘、时钟。在 QEMU 里很多设备是虚拟出来的,但对 xv6 来说仍然像真实硬件一样访问。

驱动 driver:驱动是内核里专门和某类设备打交道的代码。它把复杂的寄存器、队列、中断细节,包装成内核其他部分能理解的函数。

text
kernel generic code
        |
        v
device driver
        |
        v
hardware registers / queues
1
2
3
4
5
6
7

UART:串口设备。xv6 用它做 console 的底层输入输出,所以你在 QEMU 终端里看到的字符,背后会经过 UART 驱动。

console:控制台。它是用户和系统交互的文本入口。console 负责把用户输入整理成行,也负责把内核或用户程序输出的字符送出去。

MMIO:Memory-Mapped I/O,内存映射 I/O。意思是某些物理地址不是普通内存,而是设备寄存器。CPU 对这些地址读写,效果就是和设备通信。

PLIC:Platform-Level Interrupt Controller,平台级中断控制器。外部设备发来的中断会先到 PLIC,再由内核判断是哪个设备需要处理。

VirtIO:一种虚拟设备标准。QEMU 通过 VirtIO 提供虚拟磁盘,xv6 的 virtio_disk.c 负责和它交互。

2. 最简单心智模型 ​

设备驱动是内核和硬件之间的翻译层。

用户程序不应该知道 UART 寄存器地址。

文件系统不应该知道 virtio 队列细节。

所以内核把这些细节封装在驱动里。

图:

text
user program
  |
  v
system call
  |
  v
kernel abstraction
  |
  +-- console device -> UART driver
  |
  +-- block cache    -> virtio disk driver
  |
  v
hardware / qemu device
1
2
3
4
5
6
7
8
9
10
11
12
13
14

一句话:

text
驱动把内核中的通用请求翻译成设备能理解的寄存器、队列和中断协议。
1

3. 涉及的 xv6 源码文件 ​

本篇主要涉及:

text
kernel/console.c
kernel/uart.c
kernel/plic.c
kernel/virtio_disk.c
kernel/virtio.h
kernel/trap.c
kernel/file.c
kernel/file.h
kernel/bio.c
kernel/buf.h
kernel/memlayout.h
kernel/riscv.h
kernel/defs.h
kernel/param.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14

kernel/console.c

实现 console 设备的输入输出逻辑。

处理行缓冲、退格、Ctrl-D、Ctrl-U、Ctrl-P。

kernel/uart.c

实现 16550a UART 的低层驱动。

通过 MMIO 读写 UART 寄存器。

kernel/plic.c

实现 RISC-V PLIC 的初始化、claim 和 complete。

PLIC 负责把外部设备中断分发给 CPU。

kernel/virtio_disk.c

实现 QEMU virtio block 磁盘驱动。

构造 descriptor chain,把请求交给设备,等待中断完成。

kernel/virtio.h

定义 virtio MMIO 寄存器、状态位、descriptor、avail ring、used ring。

kernel/trap.c

处理 trap 和设备中断分发。

kernel/file.c 和 kernel/file.h

把设备注册到 devsw,让 read/write 可以分发到设备。

kernel/bio.c 和 kernel/buf.h

把磁盘块请求转成 virtio_disk_rw()。

kernel/memlayout.h

定义 UART、VIRTIO、PLIC 等 MMIO 地址。

4. 什么是设备 ​

设备是 CPU 外部的 I/O 对象。

例如:

  • 串口。
  • 磁盘。
  • 网卡。
  • 键盘。
  • 显示器。

xv6 在 QEMU 中运行。

所以它面对的是 QEMU 模拟出来的设备。

这不影响理解。

因为真实硬件和模拟设备都需要类似机制:

text
driver writes command
device performs work
device raises interrupt
driver handles completion
1
2
3
4

设备的关键特点是:

CPU 不直接“调用”设备函数。

CPU 通过寄存器、内存队列和中断与设备通信。

5. 什么是驱动 ​

驱动是内核里理解某种设备协议的代码。

它的职责包括:

  • 初始化设备。
  • 配置设备寄存器。
  • 把内核请求转成设备命令。
  • 处理中断。
  • 唤醒等待设备完成的进程。
  • 隐藏设备细节。

图:

text
kernel wants:
  read block 100

driver translates:
  descriptor chain
  sector number
  device notify register

device returns:
  interrupt + status byte
1
2
3
4
5
6
7
8
9
10

没有驱动,上层只能自己操作寄存器。

那会让文件系统、console、系统调用全部混在一起。

6. 什么是 MMIO ​

MMIO 是 memory-mapped I/O。

中文可以说“内存映射 I/O”。

意思是:

设备寄存器被映射到物理地址空间中。

CPU 用普通的 load/store 指令访问这些地址。

但读写的不是普通内存。

而是设备寄存器。

图:

text
CPU store to address 0x10000000
  |
  v
not RAM
  |
  v
UART register changes
1
2
3
4
5
6
7

xv6 的 memlayout.h 定义了这些地址。

例如 UART、VIRTIO 和 PLIC。

驱动中常见这种宏:

text
Reg(offset) = UART0 + offset
R(offset)   = VIRTIO0 + offset
1
2

这些宏让代码像访问内存一样访问设备。

7. 什么是中断 ​

中断是设备通知 CPU 的机制。

设备不能直接调用内核函数。

它只能发出中断信号。

CPU 收到中断后进入内核 trap 处理路径。

然后内核查询是哪个设备打断了它。

最后调用对应驱动的 interrupt handler。

图:

text
device finishes work
  |
  v
raises interrupt
  |
  v
CPU enters trap
  |
  v
devintr
  |
  +-- UART IRQ   -> uartintr
  |
  +-- VIRTIO IRQ -> virtio_disk_intr
1
2
3
4
5
6
7
8
9
10
11
12
13
14

中断让设备 I/O 可以异步完成。

发起请求的进程可以睡眠。

设备完成时再唤醒它。

8. PLIC 的角色 ​

PLIC 是 Platform Level Interrupt Controller。

中文可以理解为平台级中断控制器。

在 RISC-V 中,外部设备中断通常通过 PLIC 分发。

PLIC 做几件事:

  • 给设备中断设置优先级。
  • 给每个 hart 启用某些中断源。
  • 让内核 claim 当前要处理的 IRQ。
  • 在处理完后 complete 这个 IRQ。

plic.c 中核心函数:

text
plicinit
plicinithart
plic_claim
plic_complete
1
2
3
4

图:

text
UART / VIRTIO
  |
  v
PLIC
  |
  v
hart external interrupt
  |
  v
kernel trap
1
2
3
4
5
6
7
8
9
10

PLIC 不处理设备数据。

它只处理“哪个设备中断了”的路由问题。

9. console 是什么 ​

console 是用户和系统交互的终端抽象。

在 xv6 中,console 主要通过 UART 输入输出字符。

用户看到的是:

text
read from console
write to console
1
2

内核里分成两层:

text
console.c
  |
  +-- line discipline
  +-- editing keys
  +-- read buffer
  +-- devsw registration

uart.c
  |
  +-- UART register access
  +-- transmit / receive
  +-- UART interrupt handler
1
2
3
4
5
6
7
8
9
10
11
12

console.c 更接近“终端语义”。

uart.c 更接近“硬件协议”。

10. console 注册到 devsw ​

设备文件能通过 read/write 使用,是因为 devsw。

devsw 是设备分发表。

它在 file.h 中定义概念。

consoleinit() 会做:

text
devsw[CONSOLE].read  = consoleread
devsw[CONSOLE].write = consolewrite
1
2

之后普通 write(fd, ...) 如果 fd 对应 FD_DEVICE 且 major 是 CONSOLE,就会调用 consolewrite()。

图:

text
write(fd)
  |
  v
filewrite
  |
  v
FD_DEVICE
  |
  v
devsw[major].write
  |
  v
consolewrite
1
2
3
4
5
6
7
8
9
10
11
12
13

这就是 Unix “设备也是文件”的实现骨架。

11. console 输出路径 ​

用户程序写 console 时,路径大致是:

text
user write
  |
  v
sys_write
  |
  v
filewrite
  |
  v
consolewrite
  |
  v
uartwrite
  |
  v
UART transmit register
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

consolewrite() 负责从用户地址空间拷贝数据。

它会用一个小缓冲区分批拷贝。

然后调用 uartwrite()。

uartwrite() 会等待 UART 发送寄存器空闲。

当 UART 忙时,发送线程可以睡眠。

当 UART 发送完成,中断处理会唤醒发送线程。

这就是异步设备和 sleep/wakeup 的配合。

12. uart 输出细节 ​

UART 是 Universal Asynchronous Receiver/Transmitter。

中文常译为通用异步收发器。

xv6 使用的是 16550a UART。

关键寄存器包括:

  • RHR:receive holding register,接收寄存器。
  • THR:transmit holding register,发送寄存器。
  • IER:interrupt enable register,中断使能。
  • LSR:line status register,线路状态。

输出时最关心的是 LSR_TX_IDLE。

它表示发送寄存器可以接收下一个字符。

简化流程:

text
uartwrite
  |
  +-- acquire tx_lock
  +-- wait until LSR_TX_IDLE
  +-- write char to THR
  +-- maybe sleep waiting for next ready event
  +-- release tx_lock
1
2
3
4
5
6
7

tx_lock 是 sleeplock。

它让多个写 console 的进程串行发送。

否则字符可能互相交错。

13. uartputc_sync ​

uartputc_sync() 是同步输出单字符。

它不用中断。

它会自旋等待 UART 空闲。

它常用于 printk() 和输入回显。

为什么需要它?

因为有些场景不能睡眠。

例如 panic 或中断上下文。

这时用 uartwrite() 这类可能睡眠的函数不合适。

图:

text
uartputc_sync
  |
  +-- spin until TX idle
  +-- write one byte
1
2
3
4

它简单但会占 CPU。

所以适合紧急或短输出。

不适合大量普通用户输出。

14. console 输入路径 ​

输入和输出不同。

输入通常由中断驱动。

用户敲键。

UART 收到字符。

UART 产生中断。

内核进入 uartintr()。

uartintr() 读出字符并交给 consoleintr()。

consoleintr() 把字符放进 console 缓冲区。

如果形成一行,就唤醒等待 consoleread() 的进程。

图:

text
keyboard input in QEMU
  |
  v
UART receive register
  |
  v
UART interrupt
  |
  v
uartintr
  |
  v
consoleintr
  |
  v
cons.buf
  |
  v
wakeup readers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

15. consoleread 的行缓冲 ​

consoleread() 不是每按一个字符就返回。

它按行读取。

也就是说,通常要等到换行。

console 维护一个输入环形缓冲区。

字段:

text
cons.buf
cons.r
cons.w
cons.e
1
2
3
4

r 是读位置。

w 是已经提交给读者的位置。

e 是编辑位置。

图:

text
cons.buf circular buffer

r ---- readable start
w ---- committed end
e ---- edit end
1
2
3
4
5

当用户输入普通字符时,e 前进。

当输入换行或 Ctrl-D 时,w 更新到 e。

等待读的进程被唤醒。

这样用户程序一次读到一行。

16. console 特殊字符 ​

consoleintr() 处理几个特殊输入。

Ctrl-H 或 Delete 表示退格。

Ctrl-U 表示删除当前行。

Ctrl-D 表示文件结束。

Ctrl-P 表示打印进程列表。

这些功能属于 console 层。

不是 UART 层。

UART 只知道字节。

console 才知道这些字节代表什么终端编辑语义。

图:

text
raw byte from UART
  |
  v
consoleintr
  |
  +-- normal char -> append buffer
  +-- backspace   -> edit buffer
  +-- Ctrl-U      -> kill line
  +-- Ctrl-D      -> EOF marker
  +-- Ctrl-P      -> procdump
1
2
3
4
5
6
7
8
9
10

这就是分层的价值。

低层驱动不懂用户交互。

高层 console 不直接碰寄存器。

17. 为什么 console read 会 sleep ​

如果用户程序调用 read(0, buf, n)。

但当前还没有完整输入行。

进程不能一直占用 CPU 等键盘。

它应该睡眠。

consoleread() 会在缓冲区为空时睡眠。

当 consoleintr() 收到完整行后,会 wakeup()。

流程:

text
reader:
  cons.r == cons.w
  sleep on &cons.r

interrupt:
  store input char
  cons.w = cons.e
  wakeup(&cons.r)
1
2
3
4
5
6
7
8

这是设备输入和进程调度的经典配合。

设备中断改变条件。

等待进程醒来继续执行。

18. 磁盘驱动路径总览 ​

磁盘不像 console 那样以字符为单位。

文件系统以块为单位读写磁盘。

路径通常从 buffer cache 开始:

text
file system
  |
  v
bread / bwrite
  |
  v
virtio_disk_rw
  |
  v
virtio descriptor chain
  |
  v
virtio block device
1
2
3
4
5
6
7
8
9
10
11
12
13

用户的文件读写要经过很多层:

text
read(fd)
  |
  v
fileread
  |
  v
readi
  |
  v
bread
  |
  v
virtio_disk_rw
1
2
3
4
5
6
7
8
9
10
11
12
13

这条路径非常重要。

它把文件抽象、文件系统、缓存和驱动连到一起。

19. virtio 是什么 ​

VirtIO 是一种虚拟化设备标准。

它让客户机操作系统和虚拟机管理器之间用标准协议通信。

xv6 在 QEMU 中使用 virtio block 设备作为磁盘。

这意味着:

text
xv6 kernel
  |
  v
virtio MMIO protocol
  |
  v
QEMU virtual block device
  |
  v
fs.img
1
2
3
4
5
6
7
8
9
10

virtio.h 定义了协议结构。

virtio_disk.c 实现了 xv6 对这个协议的使用。

20. virtio 三个核心结构 ​

virtio 队列里有三个关键区域。

第一是 descriptor table。

它描述内存中的一段 buffer。

第二是 avail ring。

驱动把要交给设备处理的 descriptor chain 头放在这里。

第三是 used ring。

设备完成后把结果放在这里。

图:

text
driver-owned memory

+-------------------+
| descriptor table  |
+-------------------+
          ^
          |
+-------------------+
| avail ring        |  driver -> device
+-------------------+

+-------------------+
| used ring         |  device -> driver
+-------------------+
1
2
3
4
5
6
7
8
9
10
11
12
13
14

descriptor 描述“哪里有数据,长度是多少,设备读还是写”。

ring 描述“哪些 descriptor chain 可用或已完成”。

21. virtio 磁盘初始化 ​

virtio_disk_init() 做很多硬件协商。

大致流程:

  • 检查 magic、version、device id、vendor id。
  • reset device。
  • 设置 ACKNOWLEDGE。
  • 设置 DRIVER。
  • 读取并筛掉不使用的 feature。
  • 设置 FEATURES_OK。
  • 初始化 queue 0。
  • 分配 descriptor、avail、used 三块内存。
  • 把这些内存地址写给设备。
  • 设置 QUEUE_READY。
  • 标记所有 descriptor 为空闲。
  • 设置 DRIVER_OK。

图:

text
reset
  |
  v
acknowledge
  |
  v
negotiate features
  |
  v
allocate queue memory
  |
  v
publish queue addresses
  |
  v
driver ok
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这一步完成后,设备和驱动才有共同的队列可以通信。

22. virtio_disk_rw 的职责 ​

virtio_disk_rw(struct buf *b, int write) 读写一个 buffer 对应的磁盘块。

参数 b 是 buffer cache 的块。

参数 write 表示方向。

write == 0 表示从磁盘读到 b->data。

write == 1 表示把 b->data 写到磁盘。

简化流程:

text
virtio_disk_rw
  |
  +-- acquire disk lock
  +-- allocate 3 descriptors
  +-- fill request header
  +-- point descriptor to data buffer
  +-- point descriptor to status byte
  +-- put head descriptor into avail ring
  +-- notify device
  +-- sleep until interrupt marks done
  +-- free descriptors
  +-- release disk lock
1
2
3
4
5
6
7
8
9
10
11
12

这里的核心是 descriptor chain。

一次磁盘请求需要三段描述。

23. 一次磁盘请求的三个 descriptor ​

virtio block 请求使用三个 descriptor。

第一个 descriptor 是请求头。

它告诉设备:

  • 读还是写。
  • 哪个 sector。

第二个 descriptor 是数据缓冲区。

读请求时,设备往这里写数据。

写请求时,设备从这里读数据。

第三个 descriptor 是状态字节。

设备完成后写入状态。

图:

text
descriptor 0
  |
  +-- virtio_blk_req { type, sector }
  |
  v
descriptor 1
  |
  +-- b->data[BSIZE]
  |
  v
descriptor 2
  |
  +-- status byte
1
2
3
4
5
6
7
8
9
10
11
12
13

descriptor 之间用 NEXT 标志串起来。

这叫 descriptor chain。

24. sector 和 block 的关系 ​

xv6 文件系统块大小是 1024 字节。

virtio block 设备通常按 512 字节 sector 工作。

所以 virtio_disk_rw() 会把文件系统块号转成 sector:

text
sector = blockno * (BSIZE / 512)
1

由于 BSIZE 是 1024。

一个 xv6 block 对应两个 512 字节 sector。

这个换算很小。

但它是文件系统块和设备扇区之间的边界。

25. 磁盘请求为什么会 sleep ​

磁盘 I/O 比 CPU 慢很多。

发起请求后,CPU 不应该一直忙等。

virtio_disk_rw() 把请求放进 avail ring 并通知设备后,会等待完成。

等待条件是 b->disk == 1。

设备完成后,中断处理函数会把它改为 0。

然后唤醒睡眠进程。

图:

text
request thread
  |
  +-- b->disk = 1
  +-- notify device
  +-- sleep on b

interrupt handler
  |
  +-- b->disk = 0
  +-- wakeup(b)
1
2
3
4
5
6
7
8
9
10

这和 console 输入的模式类似。

等待者睡眠。

中断唤醒。

26. virtio_disk_intr ​

磁盘中断处理函数是 virtio_disk_intr()。

它会:

  • 获取磁盘锁。
  • 确认设备中断。
  • 查看 used ring。
  • 对每个完成请求检查 status。
  • 找到对应 struct buf。
  • 把 b->disk 设为 0。
  • wakeup(b)。
  • 前进 used_idx。
  • 释放锁。

图:

text
virtio interrupt
  |
  v
virtio_disk_intr
  |
  +-- read used ring
  +-- find completed buf
  +-- mark done
  +-- wake request thread
1
2
3
4
5
6
7
8
9

中断处理函数不继续执行文件系统逻辑。

它只完成设备层状态转换。

真正等待的进程被唤醒后会继续从 virtio_disk_rw() 返回。

27. trap.c 与设备中断分发 ​

虽然本篇重点不是 trap,但设备中断离不开 trap 路径。

设备中断到来后,CPU 进入 trap。

devintr() 会判断中断类型。

如果是外部中断,就通过 PLIC claim 得到 IRQ。

然后分发:

text
if irq == UART0_IRQ:
  uartintr()

if irq == VIRTIO0_IRQ:
  virtio_disk_intr()
1
2
3
4
5

处理完后调用 plic_complete(irq)。

图:

text
external interrupt
  |
  v
devintr
  |
  v
plic_claim
  |
  +-- UART0_IRQ   -> uartintr
  +-- VIRTIO0_IRQ -> virtio_disk_intr
  |
  v
plic_complete
1
2
3
4
5
6
7
8
9
10
11
12
13

这就是 PLIC、trap 和驱动的连接点。

28. console 和磁盘的共同模式 ​

console 和磁盘看起来完全不同。

但它们有共同模式:

text
process issues request
  |
  v
driver talks to device
  |
  v
process may sleep
  |
  v
device interrupt arrives
  |
  v
interrupt handler updates state
  |
  v
wakeup waiting process
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

console read:

text
consoleread sleeps
consoleintr receives line
wakeup reader
1
2
3

disk read:

text
virtio_disk_rw sleeps
virtio_disk_intr sees used ring
wakeup requester
1
2
3

这是 I/O 驱动的基本模型。

29. 锁在驱动中的作用 ​

驱动处理共享状态。

例如 console 的输入缓冲区。

例如 UART 发送锁。

例如 virtio 的 descriptor 空闲表和 used ring 位置。

这些都需要锁。

console 使用 cons.lock。

UART 输出使用 tx_lock。

virtio disk 使用 vdisk_lock。

图:

text
console input buffer -> cons.lock
uart writer ordering -> tx_lock
virtio queue state   -> vdisk_lock
buffer contents      -> buf sleeplock
1
2
3
4

锁的目标仍然是保护不变量。

例如 descriptor 不能被两个请求同时使用。

used ring 不能被两个 CPU 同时消费乱。

console 缓冲区的 r/w/e 不能互相矛盾。

30. 中断上下文里不能做太多 ​

中断处理函数应该尽量短。

原因:

  • 中断打断了当前执行流。
  • 持锁太久会影响系统响应。
  • 复杂逻辑更容易引入死锁。
  • 某些上下文不能睡眠。

xv6 的中断处理通常做最必要的事:

text
read/ack device state
move minimal data
update completion flags
wakeup waiters
1
2
3
4

例如 virtio_disk_intr() 不解析文件系统。

它只标记 buffer 完成。

例如 uartintr() 读出字符并交给 console。

它不执行用户程序逻辑。

31. console 输出和 printk 的差异 ​

用户 write() 到 console 会走 consolewrite() 和 uartwrite()。

它可能睡眠。

内核 printk() 通常使用同步输出路径。

这更适合调试和 panic。

区别:

text
user console write
  |
  +-- can sleep
  +-- uses UART interrupts
  +-- serialized by tx_lock

kernel printk path
  |
  +-- synchronous
  +-- spins waiting for UART
  +-- usable in tighter contexts
1
2
3
4
5
6
7
8
9
10
11

这解释了为什么同样是输出字符,内核有不同函数。

上下文不同,允许的行为也不同。

32. buffer cache 到磁盘驱动的边界 ​

bio.c 不懂 virtio descriptor。

它只知道要读写某个 (dev, blockno)。

virtio_disk.c 不懂 inode。

它只知道要读写 struct buf 对应的数据块。

图:

text
fs.c
  |
  | wants inode block or data block
  v
bio.c
  |
  | wants block cache buffer
  v
virtio_disk.c
  |
  | wants device operation
  v
virtio device
1
2
3
4
5
6
7
8
9
10
11
12
13

这个边界非常漂亮。

上层表达“我要这个块”。

下层负责“如何让设备把这个块搬进内存”。

33. 常见误解 ​

误解一:设备驱动就是读写文件。

不是。

驱动是把内核请求翻译成设备协议。

误解二:console 和 UART 是同一层。

不是。

console 管终端语义。

UART 管寄存器和字节收发。

误解三:PLIC 负责读取设备数据。

不是。

PLIC 只负责中断路由。

误解四:中断一到,请求函数就自动继续执行。

不是。

中断处理函数通常 wakeup() 等待者。

之后调度器让等待进程继续运行。

误解五:virtio descriptor 里存文件内容。

不准确。

descriptor 描述内存区域。

真实数据在 b->data 或请求头结构中。

误解六:磁盘块号就是 sector 号。

不是。

xv6 block 是 1024 字节,virtio sector 通常是 512 字节。

两者需要换算。

误解七:所有输出都能 sleep。

不是。

中断上下文、panic 路径等场景不能随便睡眠。

34. 调试和阅读方法 ​

阅读驱动代码时,不要只看函数名。

要看状态机。

可以按这个模板:

text
device:

request object:

shared state:

start operation:

sleep condition:

interrupt completion:

wakeup channel:

locks:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

例如 virtio disk:

text
device:
  virtio block

request object:
  descriptor chain + struct buf

shared state:
  descriptor free table, avail ring, used ring

start operation:
  virtio_disk_rw

sleep condition:
  b->disk == 1

interrupt completion:
  virtio_disk_intr

wakeup channel:
  b

locks:
  disk.vdisk_lock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

这个模板能帮你避免陷在寄存器细节里。

35. 小实验一:追踪 console 输出 ​

在 xv6 中写用户程序:

c
write(1, "hello\n", 6);
1

然后在内核里临时打印路径。

推荐位置:

text
filewrite
consolewrite
uartwrite
1
2
3

观察:

text
filewrite: FD_DEVICE
consolewrite: n=6
uartwrite: chars sent
1
2
3

不要在每个字符都打印太多。

否则输出会互相干扰。

36. 小实验二:观察 console 输入唤醒 ​

在 consoleread() 睡眠前加一条打印。

在 consoleintr() 形成完整行并唤醒前加一条打印。

然后运行:

text
cat
1

键入一行。

观察:

text
reader sleeps
input arrives
reader wakes
1
2
3

重点不是打印本身。

重点是理解:

用户进程等待输入时不占 CPU。

输入中断到来后才被唤醒。

37. 小实验三:追踪磁盘读 ​

在 bread() 和 virtio_disk_rw() 附近加少量打印。

执行:

text
cat README
1

观察:

text
bread dev blockno
virtio read sector
virtio interrupt complete
1
2
3

第一次读取可能走磁盘。

后续读取同一块可能命中 buffer cache。

这能帮助你区分缓存命中和真实设备 I/O。

38. 小实验四:观察 descriptor 耗尽 ​

virtio descriptor 数量有限。

xv6 中 NUM 很小。

可以阅读 alloc3_desc() 和等待逻辑。

不一定要强行构造耗尽。

只要理解:

text
one request needs 3 descriptors
NUM descriptors total
if not enough descriptors -> sleep
free_desc wakes waiters
1
2
3
4

这说明驱动内部也有资源管理。

不是只有进程和文件系统有资源限制。

39. 小实验五:区分 block 和 sector ​

在 virtio_disk_rw() 打印:

text
blockno
sector
1
2

读几个文件。

你会看到:

text
sector = blockno * 2
1

因为 xv6 block 是 1024 字节。

virtio sector 是 512 字节。

这个实验很小。

但能避免把文件系统块和设备扇区混为一谈。

40. 工程取舍 ​

xv6 驱动层也做了教学化简。

例如:

  • 只支持少数设备。
  • 没有复杂 DMA 映射层。
  • 没有通用驱动模型。
  • 没有热插拔。
  • 没有电源管理。
  • 没有多队列高性能磁盘 I/O。
  • 没有复杂终端模式。

但保留了关键机制:

  • MMIO。
  • 中断。
  • PLIC。
  • 设备分发表。
  • buffer cache 到块设备驱动。
  • sleep/wakeup 等待 I/O 完成。

这些机制在真实内核里仍然存在。

只是规模更大。

41. 和前两篇的连接 ​

第 12 篇讲 fd 到 inode。

第 13 篇讲 inode 到磁盘块。

本篇讲磁盘块和设备。

三篇连起来就是:

text
fd
  |
  v
struct file
  |
  v
inode / device / pipe
  |
  v
file system blocks
  |
  v
buffer cache
  |
  v
virtio disk driver
  |
  v
hardware or QEMU device
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

console 则走另一条设备路径:

text
fd
  |
  v
FD_DEVICE
  |
  v
devsw[CONSOLE]
  |
  v
console.c
  |
  v
uart.c
  |
  v
UART MMIO
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这就是 xv6 I/O 子系统的大图。

42. 本篇总结 ​

设备驱动是内核和硬件之间的翻译层。

console 把设备文件抽象接到终端语义。

UART 驱动把终端字节接到 MMIO 寄存器。

PLIC 把外部中断路由到 CPU。

virtio disk 驱动把块缓存请求接到 virtio 队列。

中断处理函数负责确认设备完成并唤醒等待者。

核心模型:

text
request
  |
  v
driver starts device operation
  |
  v
sleep if operation is slow
  |
  v
device interrupt
  |
  v
driver marks completion
  |
  v
wakeup requester
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

学完这三篇后,xv6 的 I/O 主线已经连起来:

text
file descriptor
  -> struct file
  -> inode / pipe / device
  -> file system / console
  -> buffer cache / UART
  -> disk / terminal hardware
1
2
3
4
5
6

下一步可以回到源码里做路径追踪。

从一个 write() 开始。

从一个 read() 开始。

从一个磁盘块缺页式的 bread() 开始。

把调用链画出来,xv6 的 I/O 就不再是一堆散文件,而是一套可解释的系统。

最后更新于:

Pager
上一篇14. xv6 文件系统与日志 / xv6 File System And Log
下一篇16. xv6 用户程序与 shell / xv6 User Programs And Shell

持续记录,持续成长

Copyright © Tidenflow