从 printf 到屏幕:程序和硬件之间发生了什么
先看一个问题
c
printf("hello")
这句话最终怎么出现在屏幕上的?

为什么要这样设计?
如果程序直接操作硬件
- 程序A 直接写显存地址 0xB8000
- 程序B 也写显存地址 0xB8000
结果:
- 两程序互相干扰,画面变乱
- 恶意程序可随意读写其他程序内存
- 换显卡后,所有程序都要重写
那操作系统作为中间层,他可以做到
- 统一管理 -> 谁在什么时候能访问哪块硬件,OS 说了算
- 安全隔离 -> 程序只能通过 API 申请,不能直接抢
- 屏蔽差异 -> 换显卡只需换驱动,程序不用改

in 指令 和 out 指令
虽然说程序不直接访问硬件,但驱动程序最终还是要直接和硬件说话
那驱动是如何做到的,靠的就是: in 和 out
首先来看,端口是什么

从端口读数据:
text
in al, 60h # 从端口 60h(键盘)读一个字节到 al 寄存器
从端口写数据:
text
out 378h, al # 把 al 的内容写到端口 378h(打印机)
Windows 为什么限制这两条指令?
- 恶意程序直接 out 到硬盘控制器 强制覆盖硬盘数据,绕过所有文件系统保护
- 两程序 out 到同意端口 数据互相干扰
所有 Windows 规定:只有内核态的驱动才能用 in/out,普通程序使用直接报错

内核态和用户态
CPU并不只有一种运行模式,他有两个权限级别

那 用户态程序想用 硬件 怎么办,通过 系统调用,申请内核代劳

为什么分两种模式,简单来说
- 如果程序都在内核态,一崩全崩,恶意程序随意读写
- 隔离之后,崩只崩自己,不影响OS,恶意程序只能影响自己的内存范围
新问题来了,CPU 通过端口和外设通信,但 CPU 怎么知道外设"准备好了"?
一般想法, 让CPU从端口中查看某某寄存器中是否有值
这没错,理论可行,这种方法也被称为轮询,如果用代码解释
c
while(true){
//检查键盘端口有没有新数据?
//检查打印机端口有没有新数据?
}
但是有个严重问题:
键盘每秒最多几个字,但CPU 每秒执行几十亿条指令
这会导致一个现象,CPU绝大部分时间都在查找,是否有新数据
那么就需要一个新的解决办法:中断
中断控制器
它和轮询截然相反,就相当于
- 轮询:CPU去问,你好了吗?
- 中断:由你去说:"我准备好啦!"

假如同时有多个设备发出中断,则排队一个个交给CPU处理,由中断控制器负责排列优先级,避免 CPU 同时收到多个中断
问题来了,如果 中断处理程序忘记恢复某个寄存器,会发生什么?
无法回到原先程序?不全对,如果返回地址在栈上,就能回去,但回去之后行为错乱

而且还有个问题,我们知道,一次只处理一个字节,如果我们外设传输量很大,比如1GB,会有什么问题?
这会导致CPU中断次数过于频繁,有多夸张
每中断一次:
- 数 10 亿次中断
- CPU 全程都在保存/恢复寄存器
- 真去处理数据的时间几乎没有
看到这个问题的答案能发现,中断也不够用了,那么,轮到 DMA 登场了
DMA
DMA:让外设直接和内存交换数据,绕过CPU

补充个我当时的疑惑点:为什么要经过 CPU 中转?
简单来说,外设全都是死的,只有 CPU 是唯一能执行指令和做决策的东西
CPU:
- 执行指令
- 做判断
- 协调多个设备
- 管理内存地址
其他所有东西:
- 内存:被读被写
- 硬盘:被读被写
- 网卡:被告知发什么收什么
显存
显示器并不是从 CPU 那数据,而是专门的一块内存: 显存

在早期 PC 的显存映射:
内存地址:
- 00000h ~ 9FFFFh → 普通内存(程序用)
- A0000h ~ BFFFFh → 显存(写这里 = 改变屏幕画面)
- C0000h 以上 → ROM、其他硬件
CPU 要在屏幕上显示字符,只需要
text
movl $'A', 0xA0000 # 往显存地址写字符,屏幕上就出现 A
现代显卡:
text
主内存(RAM) 显存(VRAM,显卡上面)
程序的数据 当前帧的画面数据
CPU 操作 GPU操作
数据通过 PCIe 总线传输
CPU -> 显卡驱动 -> GPU -> 显存 -> 屏幕
本质没变,还是 CPU 决定显示什么,写入显存,GPU负责输出到屏幕

所以,画面流程度不够,可能不只是 GPU 不够好,也可能是你的 GPU 上的显存不够用啊
总结
最后,如果只用一句话去理解 程序与硬件的关系 ,那就是: 程序并不是直接去控制硬件,而是要先经过操作系统这一层,再由操作系统去协调硬件。
这一章最重要的地方,我觉得不是记住 in/out 、中断 、DMA 这些名字,而是开始明白一件事: 为什么程序不能随便直接碰硬件。因为一旦让所有程序都直接去抢硬件,马上就会出现互相干扰、安全问题和兼容性问题。
所以后面这一整套设计,其实都是围着这个问题展开的。用户态和内核态 是为了隔离权限,系统调用 是为了让程序通过操作系统申请服务,中断 是为了避免 CPU 一直傻等,DMA 则是在数据量太大时,继续减少 CPU 的负担。
如果说前面几章让我知道程序、操作系统和运行环境各自是什么,那这一章更像是在告诉我: 程序最后要真正影响屏幕、键盘、硬盘这些硬件时,中间到底经过了哪些层,又为什么必须经过这些层。