LEC 9 Device Drivers
主题:设备驱动程序
- CPU需要外部设备:存储、通信等
- 操作系统负责设备的编程
- 新问题/复杂性:
- 设备通常有刚性且复杂的接口
- 设备和CPU并行运行
- 中断
- 硬件需要立即注意!
- 软件必须放下当前工作并响应
- 在RISC-V上使用与系统调用和异常相同的陷阱机制
编程设备:内存映射I/O
- 设备控制器具有地址范围
- load/store访问这些地址读/写控制寄存器
- 平台设计者决定设备在物理内存空间中的位置
例子设备:UART
内核设备驱动程序如何使用这些寄存器?
- 简单示例:kernel/uart.c中的
uartgetc()
-
ReadReg(RHR)
变成*(char*)(0x10000000 + 0)
设备驱动程序等待的方式?
- 可能是“忙等待”:
while((LSR & 1) == 0) ; return RHR;
- 如果等待不太可能发生,这是可以接受的 - 如果输入几乎总是可用
- 因此,对于控制台来说是不可接受的!
- 通常在FIFO中没有等待(按键输入)
- 大多数设备都是这样的 - 可能需要等待很长时间才能进行I/O
解决方案:中断
- 当设备需要驱动程序关注时,设备发出中断
- UART在以下情况下中断:
- rx FIFO由空变为非空,或
- tx FIFO由满变为非满
内核如何看到中断?
- 设备 -> PLIC -> 陷阱 -> usertrap()/kerneltrap() -> devintr()
trap.c devintr()
-
scause
高位指示陷阱是来自设备中断 - PLIC寄存器指示哪个设备中断
- "IRQ" -- UART的IRQ是10
中断通常只是设备状态可能已更改的提示
- 真相在设备的状态寄存器中
- 设备驱动程序必须检查它们以决定如何行动(如果有的话)
- 对于UART,检查LSR以查看rx FIFO是否非空,tx FIFO是否非满
uart.c uartintr()
void
uartintr(void)
{
// read and process incoming characters.
while(1){
int c = uartgetc();
if(c == -1)
break;
consoleintr(c);
}
// send buffered characters.
acquire(&uart_tx_lock);
uartstart();
release(&uart_tx_lock);
}
典型的设备驱动程序结构
- 顶半部:
- 执行进程的系统调用,例如write()或read()
- 启动I/O,会发生等待
- 底半部:
- 中断处理程序
- 从/发送设备硬件读取输入或输出
- 需要与“顶半部”进程进行交互
- 将输入放在顶半部可以找到的地方
- 告诉它更多的输入已到达
- 或者可以发送更多的输出
- 不在顶半部进程的上下文中运行
- 可能在不同的核上
- 可能中断其他一些进程
让我们看看xv6如何设置中断机制
控制中断的寄存器
-
sie
- 每个软件中断、外部中断、定时器中断一个位
- UART
IER
- PLIC claim --- 获取下一个IRQ;确认IRQ
-
sstatus
--- S-mode状态寄存器,SIE位启用中断
xv6的中断设置代码
-
start()
:w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);
-
main()
:consoleinit(); uartinit() plicinit(); scheduler(); intr_on(); w_sstatus(r_sstatus() | SSTATUS_SIE);
让我们看看从控制台/UART读取输入的shell
% make qemu-gdb
% gdb
(gdb) c
(gdb) break sys_read
(gdb) c
(gdb) tui enable
-
sys_read()
-
fileread()
-
consoleread()
- 这是顶半部
- 查看
cons.buf
、cons.r
、cons.w
- "生产者/消费者缓冲区" sleep()
-
-
现在是底半部
(gdb) break kerneltrap
(gdb) c
<按回车键>
- 我们是如何到达这里的?
(gdb) where
- 在内核中;没有进程想要运行;
scheduler()
- UART -> PLIC ->
stvec
->kernelvec
kernelvec.S
-
kernelvec
类似于跳板,但用于内核陷阱- 更简单:堆栈有效,页表已经是内核了
- 因此可以将寄存器保存在堆栈上,跳转到
kerneltrap
- 在当前堆栈上保存寄存器;哪个堆栈?
- 在这种情况下,是特殊的调度程序堆栈
- 如果在内核中执行系统调用,某个进程的堆栈
kerneltrap()
-
devintr()
(gdb) p/x $scause
-
scause
高位表示陷阱来自设备中断- 在riscv手册的第85页 / 表4.2中
-
plic_claim()
找到IRQ(哪个设备) (gdb) p irq
-
uartintr()
uartgetc()
x/1bx 0x10000005
- 检查LSR以获取rx,从RHR复制到缓冲区,唤醒
-
consoleintr()
- 打印
cons
- 打印
cons.buf[cons.r]
wakeup()
-
x/1bx 0x10000005
-- 注意低位不再设置
- 打印
plic_complete(irq)
- 通过
devintr
、kerneltrap
、kernelvec
返回 -
(gdb) b *0x80005b92
--kernelvec
的结尾 - ...
- 调度程序现在将运行顶半部 --
sh
的read()
- 因为唤醒了
- 让我们在顶半部中断 --
consoleread()
(gdb) b console.c:99
(gdb) c
(gdb) where
-
consoleread()
的sleep()
返回 -
consoleread()
看到我们在cons.buf[cons.r]
中的字符 -
sh
的read()
返回,带有我键入的字符
如果多个设备希望同时中断会怎么样?
- PLIC在核之间分发中断
- 中断可以在不同核上并行处理
- 如果没有CPU claim中断,中断将保持挂起
- 最终每个中断都被传递到某个CPU
如果内核在设备请求中断时禁用了中断会怎么样?
- 通过在
sstatus
中清除SIE,使用intr_off()
- PLIC/CPU记住待处理的中断
- 在内核重新启用中断时传递
// disable device interrupts
static inline void
intr_off()
{
w_sstatus(r_sstatus() & ~SSTATUS_SIE);
}
中断涉及几种并发形式
- 设备和CPU之间
- 生产者/消费者并行性
- 如果启用,中断可能发生在任何两条指令之间!
- 在代码必须是原子的时候禁用中断
- 中断可能在与顶半部并行运行的不同CPU上运行
中断的演进:
- 中断曾经在CPU周期中是廉价的;现在它们需要许多周期。
- 由于流水线、大型寄存器集、缓存缺失等原因,中断开销约为一微秒,不包括实际设备驱动程序代码!
- 因此:
- 旧的方法:简单的硬件,智能的软件,大量中断
- 新的方法:智能硬件,在每个中断中执行大量工作
如果中断频率非常高呢?
- 例如:现代以太网每秒可传递数百万个数据包。
- 在这种情况下,CPU时间的大部分都用于中断开销。
轮询:一种高频事件通知策略
- Top-half 循环去pull,直到设备准备好
- 或者在一些频繁执行的内核代码中检查,例如:scheduler()
- 然后处理自上次轮询以来累积的所有内容
- 如果设备通常很快准备好,比中断更有效
- 可能根据测得的速率切换策略
DMA(直接内存访问)可以有效地传输数据
- xv6 uart驱动程序以软件逐字节读取字节
- 对于低速设备,CPU效率不高:离芯片远,不可缓存,每次8位
- 但对于低速设备来说还可以
- 大多数设备会自动将输入复制到RAM -- DMA
- 然后中断
- 输入已经在内存中
- CPU内存操作通常更加高效
LEC 10 Lock
-
为什么要谈论锁?
- 应用程序希望利用多核处理器以实现并行加速。
- 内核必须处理并行系统调用以及对内核数据的并行访问
- 锁有助于正确共享数据,但可能限制并行加速。
-
锁的抽象:
- 锁是一个对象,有
acquire
和release
两个操作。 - 锁不一定专门与某个数据关联,由程序员计划数据和锁的对应关系。
- 锁是一个对象,有
-
何时需要锁:
- 当两个线程使用内存位置,且至少有一个是写入时。
- 持有正确的锁再触碰共享数据。
-
锁是否可以自动处理?
- 考虑语言是否能够将锁与每个数据对象关联起来。
- 编译器在每次使用周围自动添加
acquire/release
,减少程序员的遗漏。 - 通常这个想法太死板,因为程序员通常需要对锁的持有时间进行显式控制。
if present(table1, key1):
add(table2, key1)
race:
- another thread may remove key1 from table1
- before this thread gets a chance to add to table2
我们需要:
lock table1
lock table2
present(..)
add()
unlock table1; unlock table2
-
锁的作用:
- 避免丢失更新。
- 实现原子多步操作,隐藏中间状态。
- 帮助操作维护数据结构的不变性。
-
锁与模块化
- 锁使得隐藏模块内部细节变得困难。(防止死锁)
- 为了避免死锁,需要知道调用的函数获取的锁。
-
锁与并行性
- 锁阻止并行执行,需要以允许每个核使用不同数据和不同锁的方式划分数据和锁。
- 选择数据/锁的最佳划分是一个设计挑战,可能需要重构代码。
-
锁的粒度建议
- 从大锁开始,例如保护整个模块的一个锁。
- 只有在必要时才进行细粒度锁设计。
-
在xv6中看锁的典型用法
- 以
uart.c
为例,典型的设备驱动布局。 -
uart_tx_lock
是唯一的锁,相对较粗粒度。 -
uartputc()
和uartintr()
的锁的作用。 -
uartputc()
--uart_tx_lock
保护什么?- 在
uart_tx_buf
操作中避免race condition。 - 如果队列不为空,UART硬件正在执行队列头部的操作。
- 防止对UART写寄存器的并发访问。
- 在
- 以
-
如何实现锁?
- 为什么不采用如下形式:
struct lock { int locked; } acquire(l) { while(1){ if(l->locked == 0){ // A l->locked = 1; // B return; } } }
- 存在A和B之间的竞争,如何原子性地执行A和B?
- 为什么不采用如下形式:
-
原子交换指令:
-
__sync_lock_test_and_set
用于执行原子交换。 - 硬件中的原子交换保证了在操作期间不会被中断。
- xv6中的自旋锁实现使用了这个概念。
-
-
为什么使用自旋锁?
- 自旋锁会浪费CPU
- 自旋锁指南:持有自旋锁的时间很短,不要在持有自旋锁时放弃CPU。
- 系统提供"阻塞"锁用于更长的临界区,等待的线程会放弃CPU,但开销较高。
-
建议:
- 如果不必要,不要共享数据。
- 从少数粗粒度锁开始。
- 仔细检查代码,了解哪些锁阻止了并行性。
- 只在需要并行性能时才使用细粒度锁。
LEC 11 Scheduling
上下文切换
- 从一个用户进程切换到另一个涉及多个转换:
- 用户->内核;在trapframe中保存用户寄存器
- 内核线程->调度程序线程;在context中保存内核线程寄存器
- 调度程序线程->内核线程;从ctx中还原内核线程寄存器
- 内核->用户;从trapframe中还原用户寄存器
proc.h中的struct proc
- p->trapframe保存了保存的用户线程的寄存器
- p->context保存了保存的内核线程的寄存器
- p->kstack指向线程的内核栈
- p->state是RUNNING、RUNNABLE、SLEEPING等
- p->lock保护p->state(和其他item...)
为什么需要一个单独的调度程序线程?
- 以便始终有一个堆栈可以运行调度程序循环
- 例如,切换到一个正在退出的进程
- 例如,CPU少于进程
代码细节:
为什么scheduler()在内核中启用中断,使用intr_on()?
可能没有可运行的线程,它们可能都在等待I/O(例如,磁盘或控制台)。启用中断是为了让设备有机会发出完成信号,从而唤醒一个线程。否则,系统将会冻结。
为什么sched()的注释说只能持有p->lock?
- 在单核计算机上,考虑以下情况:
P1 P2 acq(Lx) sched() acq(Ly) acq(Lx)
- P2将永远等待:
- P2会自旋等待P1释放Lx。
- P2持有Ly,因此必须关闭中断。
- 没有时钟中断,因此P1不会运行。
- 因此Lx永远不会被释放。
- 即使在多核上,有更多的锁/线程,也可能出现这种情况。
- 解决方案:禁止在放弃CPU时持有锁!(除了
p->lock
)
- P2将永远等待:
为什么在swtch()期间要持有p->lock?
- 这一点影响xv6中的许多情况。
-
yield()
和scheduler()
都要在swtch()
调用期间持有p->lock
的两个原因:- 防止另一个核心的调度程序在原始核心停止执行线程之前看到
p->state == RUNNABLE
。- 需要在原始核心停止使用线程的堆栈之后。
- 防止在
swtch()
期间调用yield()
的定时器中断。- (记住:
acquire()
关闭中断)。第二次的swtch()
将覆盖context
中已保存的寄存器。
- (记住:
- 防止另一个核心的调度程序在原始核心停止执行线程之前看到
LEC 12 Coordination
线程经常等待特定事件或条件:
- 等待磁盘读取完成(事件来自中断)
- 等待管道写入者生成数据(事件来自线程)
- 等待子进程退出
- 协调是线程编程的基本构建块,但受到可能引发难题的规则约束。
为什么不直接使用while循环自旋直到事件发生?
- 更好的解决方案是使用能够放弃CPU的协调原语。
- 有很多,例如屏障、信号量、事件队列。
- xv6使用
sleep
和wakeup
,类似于“条件变量”。
示例:uartwrite()和uartintr()在uart.c中的使用
- UART是连接到控制台的设备硬件。
- 基本思想:
- UART硬件一次接受一个字节的输出(实际上是几个)。
- 写控制台的进程必须等待UART发送上一个字符。
- UART硬件在发送每个字符后都会发生中断。
- 写()调用
uartwrite()
。-
uartwrite()
写入第一个字节(如果可能)。 -
uartwrite()
调用sleep()
等待UART的中断。 -
uartintr()
调用wakeup()
。
-
-
&tx_chan
参数用于连接sleep
和wakeup
。
为什么sleep()
需要锁参数?
-
sleep()
不能简单地是“等待此事件”。 - 问题被称为“丢失唤醒”。
- 这个问题潜伏在所有序列协调方案背后,是一个痛苦的问题。
假设只是sleep(chan)
,我们如何实现?
- 这里是一个错误的
sleep/wakeup
实现:broken_sleep(chan) // 在“通道”上休眠,一个数字/地址 用于标识我们正在等待的条件/事件 p->state = SLEEPING; p->chan = chan; sched(); broken_wakeup(chan) // 唤醒在chan上睡眠的所有线程 // 可能唤醒多个线程 for each p: if p->state == SLEEPING && p->chan == chan: p->state = RUNNABLE
- 我省略了
p->lock
,两者都需要,后面再加。
uart代码如何使用这个(错误的)sleep/wakeup
?
int busy int chan uartwrite(buf): for each char c: while busy: broken_sleep(&chan) send c busy = 1 uartintr(): busy = 0 broken_wakeup(&chan)
-
busy==0
是我们等待的条件 -
&chan
是休眠通道(一个虚拟变量)
但是锁怎么办呢?
- 驱动程序的数据结构(例如
busy
)和UART硬件都需要锁。 -
uartwrite()
和uartintr()
都需要锁。 -
uartwrite()
是否应该在整个序列中持有锁?- 不行:因为uartintr()无法获取锁并清除忙标志。
-
uartwrite()
是否应该在sleep()
之前释放锁?- 让我们尝试一下 - 修改uart.c以调用
broken_sleep()
:release(&uart_tx_lock); broken_sleep(&tx_chan); acquire(&uart_tx_lock);
- 让我们尝试一下 - 修改uart.c以调用
make qemu ; cat README
当uartwrite()在broken_sleep()之前释放锁,会出什么错?
- uartwrite()发现前一个字符还没有完成发送。
- 在释放锁之后,发生中断,broken_sleep()之前。
- uartwrite()去休眠,即使UART TX已经完成。
- 现在没有东西可以唤醒uartwrite(),它将永远休眠,直到下一个UART中断,由于输入。
这就是“失去唤醒”的问题。
- 我们需要消除uartwrite()检查条件和sleep()标记进程为睡眠之间的窗口。
- 我们将使用锁防止在整个窗口期间运行wakeup()。
- 我们将更改sleep()接口和使用方式。
- 我们要求存在一个保护条件的锁,并要求sleep()和wakeup()的调用者都持有这个“条件锁”。
sleep(chan, lock)
:调用者必须持有锁,sleep释放锁,返回前重新获取。
wakeup(chan)
:调用者必须持有锁。
让我们看看proc.c
中的wakeup(chan)
:
- 它扫描进程表,查找
SLEEPING
和chan
的进程。 - 它获取每个
p->lock
。 - 还要记住调用者在调用
wakeup()
之前获取了条件锁。 - 因此,
wakeup()
同时持有条件锁和每个p->lock
。
让我们看看proc.c
中的sleep()
:
-
sleep
必须释放条件锁,因为在调用swtch()
时不能持有锁(除了p->lock
)。 -
问题:如何防止在
sleep()
释放条件锁后运行wakeup()
? -
答案:在释放条件锁之前获取
p->lock
。- 因为
wakeup()
同时持有两个锁,sleep()
只需持有其中一个,以强制wakeup()
自旋而不是查看这个进程。
- 因为
- 现在,
wakeup()
不能在swtch()
完成之前进行,因此wakeup()
保证能看到p->state==SLEEPING
和p->chan==chan
。 - 因此,没有失去唤醒!
请注意,uartwrite()
在循环中包装sleep()
。
即在sleep()
返回后重新检查条件,可能再次休眠的两个原因:
- 可能有多个等待者,另一个线程可能已经使用了事件。
-
kill()
即使条件不成立也会唤醒进程。
所有sleep
的使用都包装在循环中,因此它们会重新检查。
另一个协调挑战 - 如何终止一个线程?
问题:线程X不能只是销毁线程Y
- 如果Y正在另一个核心上执行怎么办?
- 如果Y持有锁怎么办?
- 如果Y正在对重要数据结构进行复杂的更新怎么办?
问题:一个线程不能释放所有自己的资源
- 例如,它自己的堆栈,它仍在使用
- 例如,它的
struct context
,可能需要调用swtch()
xv6有两种方法来终止进程:exit()
和kill()
普通情况:进程通过exit()
系统调用自愿退出
- 策略:
-
exit()
释放一些东西,但不释放proc
slot或堆栈 - 父母的
wait()
进行最后的释放
-
- 目标:
-
p->state = UNUSED
,以便将来的fork()
可以使用这个proc[]
slot
-
-
exit()
在proc.c
中:- 一些清理
- 唤醒
wait()
的父母 -
p->state = ZOMBIE
-
ZOMBIE
意味着准备好父母的wait()
- 不是
UNUSED
- 因此不会被fork()
分配 - 不会再次运行
-
- (请注意,堆栈和
proc[]
条目仍然被分配...) -
swtch()
到调度程序
-
wait()
在proc.c
中(parent最终会调用):-
sleep()
等待任何子进程退出 - 扫描
proc[]
表,找到p->state==ZOMBIE
的子进程 - 调用
freeproc()
-
p->lock
持有 - 释放
trapframe
,pagetable
等,p->state=UNUSED
-
-
- 因此,
wait()
不仅仅是为了应用程序方便,还为了操作系统。- 必须对每个进程进行
wait()
。 - 一些复杂性是由于
- 先等待然后退出 或 先退出然后等待
- parent已经退出了怎么办?
- 必须对每个进程进行
那么kill(pid)
呢
- 问题:强制终止一个进程可能是不安全的
- 它可能正在内核中执行
- 使用它的内核堆栈、页表、
proc[]
条目、trapframe
- 使用它的内核堆栈、页表、
- 它可能持有锁
- 例如,在fork()新进程的中间
- 必须完成以恢复不变性
- 它可能正在内核中执行
- 解决方案:
-
kill()
设置p->killed
标志,什么也不做 - 目标进程本身检查
p->killed
- 并调用
exit()
自己
- 并调用
- 在
usertrap()
中查找if(p->killed) exit(-1);
- 在这里没有在执行中,也没有持有锁,因此安全退出
-
如果kill()
目标正在sleep()
呢?
- 在这种情况下,它既没有持有锁,也没有执行!
-
kill()
立即销毁目标是否可以?- 不行:如果在文件创建的中途等待磁盘怎么办?
xv6对kill()
的解决方案
- 参见
proc.c
中的kill()
:- 将
SLEEPING
更改为RUNNABLE
- 类似于wakeup()
- 因此,
sleep()
将返回,可能在条件为真之前
- 将
- 一些
sleep
循环检查p->killed
- 例如,
piperead()
,consoleread()
- 否则,对于已经被杀死的进程,读可能会无限期挂起
- 例如,
- 一些
sleep
循环不检查p->killed
- 例如,
virtio_disk.c
- 不必检查
p->killed
,因为磁盘读取相当快
- 例如,
- 因此,被
kill()
的进程可能会继续一段时间- 但是
usertrap()
将在系统调用完成后调用exit()
- 但是
xv6对kill的规范
- 如果目标在用户空间
- 在它下次进行系统调用或接受定时器中断时会死亡
- 如果目标在内核中
- 目标将永远不会执行另一条用户指令
- 但在内核中可能还要花一段时间
总结
-
sleep/wakeup
允许线程等待特定事件 - 并发意味着我们必须担心唤醒丢失
- 终止在线程系统中是一个麻烦