跳到主要内容

第 3 章 内存之外——当内核试图触碰硬件

这一章,我们要处理的是驱动程序与硬件通信的「最后一公里」问题。

这比看起来要危险。

在用户空间写程序时,访问内存是再寻常不过的操作——指针指过去,数据读出来,天经地义。但在内核里,当你试图通过一个指针去触碰一块硬件寄存器时,事情就变得完全不同了。如果你以为可以直接把物理地址强转成指针然后解引用,那你离系统崩溃只差一次编译。

为什么?因为硬件不是 RAM。你不能像对待普通内存那样对待它——读写有副作用、缓存会作祟、编译器优化会把你的关键指令「吃掉」。

本章的任务,就是搞清楚内核是如何安全地在这片雷区跳舞的:如何申请权限、如何把硬件地址映射进内核空间、如何使用专用的 API 与外界交换数据,以及如何在一切结束时优雅地收场。

我们会涉及两种截然不同的硬件访问模型:基于内存映射的 I/O(MMIO)和基于端口的 I/O(PMIO)。它们看起来很像,但背后的机制和细节完全不同。

准备好了吗?系好安全带,我们开始。


3.1 从内核访问硬件 I/O 内存

我们要解决的第一个问题很直观:内核怎么访问硬件上的寄存器?

这听起来像是个句废话——既然有了物理地址,直接读写不就行了?

不,不行。在 Linux 的保护模式下,一切并没有那么简单。想象一下,你的代码运行在虚拟地址空间里,拿着一个硬件手册上写的物理地址(比如 0x3F200000),如果你敢直接把这个地址塞给指针解引用,CPU 会在你头上敲出一个 oops,因为你尝试在一个非法的(或者说未映射的)虚拟地址上做操作。

我们要做的,是在内核的虚拟地址空间(VAS)和设备的物理 I/O 内存之间,架起一座安全的桥。

这里有两个核心概念需要先分清楚:I/O 端口和 I/O 内存。

I/O 内存与 I/O 端口:两条截然不同的路

硬件设计师在设计外设控制器时,通常有两种方式把控制权交给你:

  1. Memory-Mapped I/O (MMIO):这是现代 ARM、MIPS 以及大部分嵌入式系统的标准做法。设计师把外设的寄存器直接映射到处理器的物理内存地址空间里。对你来说,访问寄存器看起来就像在访问一段普通的 RAM——当然,这只是表象。
  2. Port-Mapped I/O (PMIO / PIO):这是 x86 架构上的传统艺能。CPU 提供了一套独立的指令(in/out)和一个独立的地址空间(I/O 端口空间)。哪怕你的内存地址只有 32 位,I/O 端口空间依然可能是独立的 16 位(64KB 大小)。在这种架构下,你不能用普通的指针访问寄存器,必须用专门的指令。

现在的 Linux 内核为了兼容性,把这两种情况都封装成了统一的一套 API,但底层的机制差异你必须心里有数。

接下来的这第一节,我们先专注于 MMIO——因为它是你编写 SoC 驱动、设备树驱动时最常遇到的情况。至于 PMIO(端口 I/O),我们会在下一节专门展开。


向内核申请:先问再拿

在我们动手映射地址之前,有一个官僚流程是必不可少的。

Linux 内核也是一个资源管理者。如果两个驱动程序心血来潮,都认为自己有权限操作同一块物理内存区域,后果不堪设想——一个在写配置,另一个在关电源,主板心态就崩了。为了防止这种「撞车」,内核维护了一棵资源树。

任何驱动在真正触碰硬件之前,必须先举手示意:「这块区域归我了,别人别动。」

这就是 request_mem_region() 干的事。

1. 请求 I/O 内存区域

你需要提供这块区域的起始物理地址和长度。

struct resource *request_mem_region(unsigned long start, unsigned long len,
const char *name);
  • start: I/O 内存的物理起始地址。
  • len: 这段区域的大小(字节)。
  • name: 一个字符串,用来标识是谁占用了这块区域(会在 /proc/iomem 里显示)。

如果请求成功,它会返回一个指向 struct resource 的指针;如果失败(比如已经被别的驱动占用了),它会返回 NULL

类比时间:

你可以把 request_mem_region 想象成在酒店前台预订房间

你告诉前台:「从 3F20 号房开始,我要预订 100 个房间,登记名字叫 'my_driver'」。前台查了一下电脑,如果这段区间没人住,就在登记簿上写下你的名字。

但这里有一个区别:你拿到了房卡并不代表你已经进了房间。你只是确保了别人不会走进去。真正要打开门(访问数据),你还需要另一把钥匙。

2. 请求 I/O 端口区域

顺便说一句,如果你在搞 x86 的端口 I/O,对应的 API 是 request_region(),逻辑完全一样,只是对象换成了端口地址:

struct resource *request_region(unsigned long start, unsigned long len,
const char *name);

3. 释放资源

驱动卸载时,千万别忘了把之前占的地盘还回去。这是基本的教养,不然这块内存会一直被标记为「忙碌」,下次加载驱动就会失败。

void release_mem_region(unsigned long start, unsigned long len);
void release_region(unsigned long start, unsigned long len);

拿到钥匙:使用 ioremap*() APIs

好了,资源申请下来了(request_mem_region 成功了),现在我们手里握着物理地址。但这还是不够。

内核的代码运行在虚拟地址空间,CPU 里的 MMU(内存管理单元)并不知道这个物理地址对应哪个虚拟地址。你需要建立页表映射,把设备的物理地址映射到内核虚拟地址空间(通常是 0xFFFF... 开头的高位地址区)。

这就是 ioremap() 的职责。

void __iomem *ioremap(phys_addr_t offset, size_t size);
  • offset: 物理地址(也就是你在手册上看到的那个地址)。
  • size: 你要映射多大。
  • 返回值:一个 void __iomem * 类型的指针。这就是你之后用来读写寄存器的「虚拟钥匙」。

关于 __iomem 这个标记

注意看那个返回值类型里的 __iomem。这是一个编译器属性(sparse 用来做静态分析的),它在告诉你(和编译器):「这不是普通的内存指针,别乱优化!」

你绝对不能把它当成普通指针来用——比如直接用 * 去解引用,或者把它传给 memcpy。如果这样做,在 ARM 上可能会触发 CPU 的对齐错误,或者在 x86 上因为缓存一致性问题导致数据根本没写进硬件。

反向操作:取消映射

当你不想用这块硬件了,或者驱动要卸载时,必须把这个映射拆掉:

void iounmap(void volatile __iomem *addr);

回到那个「预订房间」的类比ioremap 就是拿房卡开门的过程。你拿到了一个虚拟地址(房卡),以后进房间就靠它。iounmap 就是退房交卡。

⚠️ 注意 千万别搞错顺序:先 request_mem_region(确保房间是你的),再 ioremap(开门)。 释放的时候反过来:先 iounmap(出门),再 release_mem_region(退房)。 如果顺序反了,你手里可能握着一个已经失效的指针,还在那写数据,后果不可预测。


新派玩法:devm_* 托管 API

如果你写过一段时间的驱动,你会发现 request_... 和 ioremap... 以及对应的 release_...、iounmap... 简直就是噩梦的源头。

最常见的情况是:你在驱动的 probe 函数里映射了内存,结果在 remove 函数里忘了 unmap,或者在错误处理路径上漏了一环。这就会导致内存泄漏。

为了拯救我们这些粗心的工程师,内核引入了 devm_* (Device Managed)系列 API。这些 API 会自动追踪资源的生命周期,当驱动分离(device detach)时,内核会自动帮你释放这些资源。

最常用的是这两个:

  • devm_ioremap(): 托管版本的 ioremap
  • devm_request_mem_region(): 托管版本的 request_mem_region

当你用 devm_ioremap 时,你甚至不需要显式调用 iounmap。当驱动卸载或 probe 失败时,内核会自动帮你把善后工作做了。这不仅能减少代码行数,更重要的是能防止那种「在错误处理路径里忘记释放资源」的蠢bug。


获取资源:platform_get_resource

在现实世界(尤其是嵌入式 Linux)里,你的驱动通常是一个 platform_driver。这意味着你并不应该在代码里硬编码物理地址(比如 #define PHY_ADDR 0x3F200000),这是被鄙视的做法。

正确的姿势是从设备树或者内核静态配置里获取资源。

这就用到了 platform_get_resource()

struct resource *platform_get_resource(struct platform_device *pdev,
unsigned int type, unsigned int num);
  • pdev: 你的平台设备指针。
  • type: 资源类型,通常是 IORESOURCE_MEM
  • num: 索引号,通常写 0(取第一个 reg 属性)。

它会返回一个 struct resource 指针,里面包含了 start(物理起始地址)和 end(结束地址)。

有了这个结构体,你可以用 resource_size(res) 来获取长度,或者直接用它的成员变量来传给 request_mem_region


终极兵器:devm_ioremap_resource()

因为「请求资源」和「映射内存」这两个动作几乎总是连在一起做的,而且总是很烦琐,内核开发者们最终决定把它们合并成一个超级 API。

这就是 devm_ioremap_resource()。如果你在写现代的 Linux 驱动,这应该是你最常用的函数。

void __iomem *devm_ioremap_resource(struct device *dev, struct resource *res);

它一口气干了三件事

  1. 检查:确保传入的 res 是有效的。
  2. 申请:内部自动调用 devm_request_mem_region(),把这块内存占住。
  3. 映射:调用 devm_ioremap() 建立映射。

如果任何一步失败,它会返回 ERR_PTR()(错误指针),并且内部已经打印了错误日志,你只需要检查返回值是不是 IS_ERR() 即可。

这种设计非常符合我们「折腾工程」的直觉:给设备,给资源,我要指针。拿到指针,就能干活了。


验证映射:通过 /proc/iomem 查看新映射

有时候代码写完了,你心里没底:我到底映射成功了没有?内核认这块地盘了吗?

你可以去 /proc/iomem 里看一下。这个文件展示了整个系统的内存资源分配图。

$ cat /proc/iomem
...
3f200000-3f200fff : /soc/gpio@7e200000
3f200000-3f200fff : pinctrl-bcm2835
...

如果你看到你的驱动名字出现在这个列表里,并且对应的地址范围是你期望的,那么恭喜你,申请这一步已经稳了。


实战 I/O:ioreadX / iowriteX

好了,地址映射好了,指针(void __iomem *)也拿到了。现在我们要真正地读写硬件了。

请记住一条铁律:绝不使用普通的指针解引用(*ptr)或 memcpy 来访问 I/O 内存。

硬件 I/O 内存和普通 RAM 有本质区别:

  1. 副作用:写一个寄存器可能会触发硬件动作(比如开始发送数据),这不像写内存只是存个 0 或 1。
  2. 时序:指令顺序不能乱。普通编译器优化可能会把两次写操作合并,或者打乱顺序,这对硬件来说是灾难。
  3. 总线宽度:你必须按照硬件规定的位宽(8位、16位、32位)来访问,否则可能读出垃圾数据。

为了解决这些问题,内核提供了一组带屏障(barrier)功能的读写函数。

MMIO 读取 API

u8 ioread8(void __iomem *addr);
u16 ioread16(void __iomem *addr);
u32 ioread32(void __iomem *addr);
u64 ioread64(void __iomem *addr);

这些函数会确保:

  • 编译器不会优化掉这次读操作。
  • 指令严格按照代码顺序执行(内存屏障)。
  • 访问宽度严格匹配(比如 ioread16 就会发一个 16位的读指令)。

MMIO 写入 API

void iowrite8(u8 value, void __iomem *addr);
void iowrite16(u16 value, void __iomem *addr);
void iowrite32(u32 value, void __iomem *addr);
void iowrite64(u64 value, void __iomem *addr);

比如,你想向某个 GPIO 的置位寄存器写入 0xFF,你会这么写:

u32 __iomem *reg_base; // 假设已经映射好了
iowrite32(0xFF, reg_base + OFFSET_SET);

这里的 OFFSET_SET 会被内核 API 自动处理成字节偏移量。

批量操作:ioreadX_rep / iowriteX_rep

有时候你需要从一个 FIFO(先入先出缓冲区)里一口气读一堆数据,或者写一堆数据。如果你用 for 循环包 ioread32,效率可能不够高,因为每次都有函数调用开销。

这时可以用「重复读写」指令:

void ioread8_rep(void __iomem *addr, void *buf, unsigned long count);
void ioread16_rep(void __iomem *addr, void *buf, unsigned long count);
void ioread32_rep(void __iomem *addr, void *buf, unsigned long count);

void iowrite8_rep(void __iomem *addr, const void *buf, unsigned long count);
void iowrite16_rep(void __iomem *addr, const void *buf, unsigned long count);
void iowrite32_rep(void __iomem *addr, const void *buf, unsigned long count);

这些函数会利用 CPU 的字符串指令(如果架构支持的话)来搬移数据,速度会快很多。


设置与拷贝:memset_io / memcpy_fromio / memcpy_toio

虽然我们不推荐用 memcpy,但有时候确实需要对一块 I/O 内存区域进行清零或者批量搬运。内核提供了对应的「Io」版本:

  • memset_io(void __iomem *addr, int value, size_t size): 像 memset 那样,把 I/O 内存的一块区域设置成某个值。
  • memcpy_fromio(void *buffer, const void __iomem *addr, size_t size): 从 I/O 内存拷贝数据到 RAM(读硬件状态到内存变量)。
  • memcpy_toio(void __iomem *addr, const void *buffer, size_t size): 从 RAM 拷贝数据到 I/O 内存(把配置写入硬件)。

⚠️ 这里有个巨大的坑:这些操作可能非常慢,尤其是 memcpy_toio。因为这不仅仅是内存拷贝,每一次写入都可能通过总线穿透到硬件芯片。如果你的硬件 FIFO 满了,你的 CPU 可能会在这里卡很久。所以,在中断上下文或者持有自旋锁的时候,千万小心使用大块数据的 memcpy_toio


章节小结:从物理到虚拟的桥梁

这一节我们走完了从「拿到物理地址」到「成功写入寄存器」的全过程。这条路并不平坦,我们小心翼翼地避开了直接指针操作的陷阱,学会了如何向内核礼貌地申请资源(request_mem_region),如何建立安全的映射(ioremap),以及如何用规范的 API(ioread32/iowrite32)来与硬件对话。

这是一切硬件操控的基础。如果你跳过了这些步骤,直接去强改地址,内核会毫不留情地把你踢出局。

但这只是故事的一半。

我们刚才讲的是 MMIO——把硬件当成内存来访问。但在 PC 世界里,还有一大批古老而顽固的设备(比如 x86 上的并口、串口或者老式声卡),它们并不住在内存地址空间里,而是躲在另一个叫做「I/O 端口」的平行宇宙里。

下一节,我们会把目光从 ioremap 上移开,去看看那个使用 inboutb 指令的特殊世界。理解了它,你才算真正看透了 Linux 硬件驱动的全貌。