第九章 与硬件通讯
本章主讲设备驱动程序中使用内存的一些其他方法。等第十五章再讨论关于分段分页等描述内存管理的内部细节。
9.1 I/O端口与I/O内存
在计算机系统中,CPU需要与各种外部设备进行通信才能完成各种任务。比如,当你在键盘上按下按键时,CPU需要知道哪个键被按下了;当显示器需要显示内容时,CPU需要将像素数据发送到显示控制器。这些通信的过程,就是通过访问设备的寄存器来实现的。今天,我们就来详细探讨Linux设备驱动中关于I/O端口和I/O内存的基础知识。
9.1.1. I/O 寄存器和常规内存
-
什么是I/O端口与I/O内存?
-
设备寄存器的本质
每当我们想要控制一个外部设备时,实际上都是在与该设备的寄存器进行交互。设备寄存器就像设备与CPU之间通信的"信箱",CPU通过向特定的寄存器写入数据或从特定的寄存器读取数据,来实现对设备的控制和数据交换。
在硬件层面,内存区域和I/O区域并没有本质的区别:它们都是通过在地址总线和控制总线上发出电信号来进行访问的,无论是读取信号还是写入信号,都作用在数据总线上。CPU向某个地址发送读取信号,就能获得该地址上设备寄存器的当前值;CPU向某个地址发送写入信号,就能将数据发送到设备的寄存器中。
-
1.2 为什么需要区分I/O端口和I/O内存?
既然在硬件层面,内存和设备的I/O区域没区别,为什么还要做区分呢?主要因为历史因素和不同处理器架构设计。一些处理器在芯片上实现了一个统一的地址空间,他们认为外设与普通内存不同,因此应该有一个独立的地址空间。
最典型的例子就是x86处理器家族,它有专门的读写线用于I/O端口,并且使用特殊的CPU指令来访问这些端口。比如,在x86架构中,
inb和outb指令就是专门用来访问I/O端口的。然而,其他一些处理器**(如ARM)没有单独的I/O端口地址空间**,ARM处理器将所有硬件设备的寄存器都映射到统一的内存地址空间中,通过普通的内存访问指令(如加载
LDR和存储STR)来读写这些寄存器控制外设。就跟访问普通内存一样,这种方式为内存映射I/O(Memory-Mapped I/O),因此,ARM架构本身没有独立的I/O端口地址空间,也不支持像x86那样的专用I/O指令。!IMPORTANT
但是你可能会在一些ARM Linux的驱动代码或文档中看到"I/O端口"(I/O Port)的说法,这是否有问题?
-
PCIe标准的继承: PCIe规范继承了其前身PCI的设计,保留了I/O端口空间的概念,以便与大量为x86平台设计的遗留硬件和软件兼容。
-
ARM Linux的模拟: 为了让运行在ARM平台上的Linux系统能够使用为PCIe设备编写的、基于I/O端口操作的驱动程序,内核在软件层面模拟了一个"I/O端口"空间。
-
本质仍是MMIO : 这个被模拟出来的"I/O端口"空间,在ARM上本质上仍然是一块被特殊标记的内存区域 。当驱动程序调用
outl()等函数时,内核最终会将其转换为对这块内存的普通读写操作。当这些操作到达PCIe控制器时,控制器会负责将它们转换成符合PCIe协议的I/O读/写事务包(TLP)。
所以,在ARM Linux的语境下,"I/O端口"是一个为了兼容而存在的软件抽象概念,其底层实现依然是内存映射I/O(MMIO)。
-
-
1.3 Linux 的解决方案
由于在不同的处理器架构上采用的设计不同,Linux需要提供一个统一的抽象层来统一这些硬件差异。Linux在所有运行的计算机平台上都实现了I/O端口的概念,即使在那些CPU只有一个统一地址空间的平台上也不例外。
在实际实现中,I/O端口访问的具体方式可能依赖于特定的主机制造商,因为不同的型号可能使用不同的芯片组来将总线传输映射到内存地址空间。这种设计确保了Linux驱动程序在不同平台上的可移植性,驱动程序开发者不需要关心底层硬件的具体实现细节,只需要使用Linux提供的统一接口即可。
-
1.4 两种访问方式的对比
虽然外设总线通常有单独的I/O端口地址空间,但并非所有设备都将寄存器映射到I/O端口。对于传统的ISA外设卡来说,使用I/O端口是常见的方式。然而,现代的PCI设备大多将寄存器映射到内存地址区域。
I/O内存方式通常是更好的选择,原因如下:首先,它不需要使用特殊的处理器指令,CPU访问内存的效率更高;其次,编译器在访问内存时有更多的优化空间,可以更好地进行寄存器分配和寻址模式的选择。这些因素使得I/O内存方式在现代计算机系统中得到了广泛的应用。
-
-
I/O寄存器与常规内存的关键区别
- 对I/O寄存器操作有实际效果,而对普通内存的操作没有,比如对继电器控制寄存器的I/O寄存器写入可能控制继电器动作,但对普通内存没这种效果。
- 编译器为了提高执行效率所作的指令重排等优化操作,这些操作对I/O寄存器来说可能导致严重问题,而对普通内存则没事。比如如果需要多次读取同一个I/O地址来获取设备的最新状态,编译器可能会认为只需要读取一次并将结果保存在寄存器中就行了。再比如指令重排序对普通内存可能提高效率但对I/O寄存器可能造成灾难性的难以调试后果。
- 硬件缓存导致的问题。现代CPU内部有多级缓存,用于加速内存访问。当你写入一个内存地址时,数据可能只是写入了缓存而没有真正到达物理内存或设备。不过,好消息是:底层的硬件已经配置好了,在访问I/O区域(无论是内存区域还是端口区域)时会自动禁用硬件缓存。这个配置通常由Linux初始化代码或BIOS来完成。因此,我们主要需要关注的是编译器和CPU层面的优化问题。
-
内存屏障:解决问题的关键
-
3.1 什么是内存屏障(Memory Barrier)
内存屏障是一种特殊指令,告诉编译器和CPU,在屏障前的指令全部执行完毕后才能执行屏障后的操作。形象的说就像一个墙。墙内外的指令不允许交换顺序。
关于内存屏障,这篇博客做了相关分类和API接口总结:Linux 内核中的内存屏障详解。我仅简要总结,用到再看。简单总结:
- 内存屏障:①
barrier防止编译器对指令重排,②内存屏障:mb();wmb(); rmb();read_barrier_depends();③SMP屏障:smp_mb();smp_wmb();smp_rmb();smp_read_barrier_depends();④一些原子操作,自旋锁等(如spinlock、mutex、atomic操作等)通常隐含一些内存屏障,使用时不需要再添加内存屏障,详情看博客。
- 内存屏障:①
-
3.2 让我们看一个典型的设备驱动中使用内存屏障的例子:
cwritel(dev->registers.addr, io_destination_address); // 设置目标地址 writel(dev->registers.size, io_size); // 设置传输大小 writel(dev->registers.operation, DEV_READ); // 设置操作类型 wmb(); // 写内存屏障,防止设备再正确配置前启动 writel(dev->registers.control, DEV_GO); // 启动设备 -
3.3 性能考虑
内存屏障会影响性能,为了阻止编译器和CPU优化,应只在真正需要的地方使用内存屏障。同类型的屏障特点不同,应选择最符合要求的轻量级屏障。
wmb()目前实际上是空操作,因为对处理器外部的写操作不会被重排序。但是读操作会被重排序,所以wmb()比mb()更快。理解这些平台特性可以帮助你写出更高效的代码。 -
3.4 赋值和屏障组合
一些体系允许一个赋值和一个内存屏障的有效组合,内核提供了几个宏来完成这个组合(我在新版内核已经搜不到了):
c#define set_mb(var, value) do {var = value; mb();} while 0 #define set_wmb(var, value) do {var = value; wmb();} while 0 #define set_rmb(var, value) do {var = value; rmb();} while 0
-
9.2. 使用 I/O 端口
结合现代Linux内核特性(Kernel 4.x/5.x/6.x)和当前硬件架构趋势整理本章节内容。
刚刚我们了解了Linux设备驱动开发中,与硬件通信主要有两种方式:内存映射I/O(MMIO)和端口映射I/O(PMIO)。虽然现代嵌入式设备(尤其是ARM架构)通常使用内存映射I/O(MMIO),但是理解I/O端口(Port I/O)对处理x86架构遗留设备(如 PS/2 键盘、串口)以及PCIe总线配置仍至关重要。
9.2.1. I/O 端口分配
在 x86 架构中,CPU 拥有独立的 I/O 端口地址空间(64KB),与内存空间分离。CPU 必须使用专门的指令(如 in/out)来访问这些端口。
而在 ARM、PowerPC 等大多数现代架构中,不存在独立的 I/O 端口空间 。为了代码的可移植性,Linux 内核提供了一套抽象接口(inb/outb 等)。在ARM上,这些接口实际上是将端口地址映射到内存地址,通过MMIO模拟实现。
💡 现代视角: 即使是 x86 架构,现代 PCIe 设备也更倾向于使用 MMIO,因为内存空间更大且支持缓存和 DMA 等高级特性。I/O 端口主要用于兼容传统 ISA 设备或特定的低速总线。
1. 申请资源
使用 request_region() 函数。如果申请失败(返回 NULL),说明端口已被占用,驱动不应强行操作。
c
申请从 first 开始的 n 个端口,name 用于在 /proc/ioports 中显示
struct resource *request_region(unsigned long first, unsigned long n, const char *name);
2. 释放资源
在驱动卸载或关闭设备时,必须释放资源。
c
void release_region(unsigned long start, unsigned long n);
3. 废弃的 API:check_region
书中提到的 check_region 函数在现代驱动开发中已被废弃(Deprecated)。
- 原因:检查和分配不是原子操作。在你检查完并决定分配的时间间隙中,另一个驱动可能已经抢占了该端口。
- 最佳实践 :直接调用
request_region,通过返回值判断是否成功。
调试技巧: 如果申请失败,可以通过查看 /proc/ioports 文件来确认是谁占用了该端口。
9.2.2. 操作 I/O 端口
Linux 内核屏蔽了底层架构差异,提供了标准的访问宏(定义在 <asm/io.h> 中)。由于硬件寄存器对时序敏感,严禁直接解引用指针,必须使用专用函数。
| 位宽 | 读操作函数 | 写操作函数 | 说明 |
|---|---|---|---|
| 8-bit | unsigned inb(unsigned port) |
void outb(unsigned char val, unsigned port) |
最常用,如串口数据 |
| 16-bit | unsigned inw(unsigned port) |
void outw(unsigned short val, unsigned port) |
如 USB 控制器寄存器 |
| 32-bit | unsigned inl(unsigned port) |
void outl(unsigned long val, unsigned port) |
如 PCIe 配置空间 |
注意: 内核没有定义 64 位的 I/O 端口操作。即使在 64 位架构上,I/O 端口空间通常也只支持最大 32 位数据通路。
9.2.3. 从用户空间的 I/O 存取
过时做法 :使用 ioperm()、iopl() 系统调用或直接操作 /dev/port。
为什么不推荐:
- 安全性 :这要求进程具有
CAP_SYS_RAWIO能力(通常是 root),破坏了系统的权限隔离。 - 可移植性 :
ioperm是 x86 特有的,在 ARM 等架构上不可用或行为不一致。 - 稳定性:用户空间直接操作硬件绕过了内核的资源管理和保护机制,极易导致系统崩溃。
现代替代方案:
- UIO (Userspace I/O) :内核提供 UIO 框架,允许将设备中断和部分内存映射到用户空间,这是合法的用户空间驱动开发方式。参考这篇链接实现一个UIO机制的设备驱动例子:Linux UIO驱动-知乎
- Sysfs/Character Device:通过标准的内核驱动接口暴露控制接口给用户空间应用。
9.2.4. 字串操作
为了高效传输数据块(如网卡数据搬运),处理器提供了串指令(如 x86 的 insb(port, addr, count) / outsb(port, addr, count),insw / outsw,insl / outsl),这些函数在DMA普及前非常重要,在,除非是极早期的引导代码或特定的高性能轮询模式,否则大部分数据传输已由 DMA 接管。(另外需要注意大小端问题陷阱)
9.2.5. 暂停 I/O
书中提到了带 _p 后缀的函数(如 inb_p, outb_p)。
- 原理:在执行 I/O 指令后插入一个微小的延时(通常是向 0x80 端口写数据或空指令循环),以防止高速 CPU 淹没低速 ISA 总线设备。
- 现状 :在现代 PCI/PCIe 总线和高速 ARM 设备上,通常不需要使用这些暂停函数。它们主要用于极老旧的 ISA 硬件兼容。
9.2.6. 平台依赖性
讲解了不同平台对I/O端口的指令或是支持都不同。
9.3. 一个 I/O 端口例子
这一章节讲解的内容是20 年前 x86 架构 PC 的"古早"玩法 。主要讲解通过 I/O 端口(Port-Mapped I/O) 直接操作 PC 的并口(Parallel Port)。
| 特性 | 文中描述 (过时) | 现代 Linux 现状 (2026) |
|---|---|---|
| 硬件接口 | 并口 (DB25 接口,连接打印机) | USB / PCIe / I2C / GPIO。现代主板几乎已淘汰并口,x86 开发板多用 USB 或 PCIe 设备。 |
| 访问方式 | I/O 端口映射 (inb/outb) |
内存映射 I/O (ioremap/readl/writel)。现代高速设备(如 NVMe, GPU)主要使用 MMIO,而非古老的端口指令。 |
| 架构依赖 | x86 专用 (in/out 指令) |
架构无关 。现代驱动开发更多基于 ARM/RISC-V,这些架构没有 inb/outb 指令,统一使用内存访问指令。 |
| GPIO 管理 | 直接读写固定地址 (如 0x378) | Sysfs (旧) 或 libgpiod (新)。Linux 内核现在通过子系统管理引脚,不再推荐直接硬编码物理地址。 |
| 权限与安全 | 用户态直接操作端口 (需 root) | 严格的内核隔离 。用户态严禁直接操作硬件端口,必须通过驱动层或 /dev 接口。 |
下面以现代视角审视一下文中过时的知识但是有价值的思想:
-
从"并口"转向"GPIO 子系统"
注意:Linux GPIO 子系统在过去几年经历了巨大的变革,尤其是从 Sysfs 接口全面转向 Character Device (cdev) + libgpiod 的过程,导致网上 80% 的教程(包括很多经典的书籍如 LDD3)在应用层开发部分都已经过时。
文中提到的"通用数字 I/O 端口",在现代嵌入式Linux中被称为 GPIO(General Purpose I/O)通用输入输出I/O端口。
- 旧方法(文中) :直接对
0x378这样的地址执行outb指令。 - 新方法(现在) :
- 驱动开发:使用Linux内核的 GPIO Descriptor API (
gpiod_get,gpiod_set_value). - 调试:使用
libgpiod工具(如gpioset,gpioget)。 - 应用开发:使用
libgpiod库函数,而不是直接读写文件。
- 驱动开发:使用Linux内核的 GPIO Descriptor API (
- 旧方法(文中) :直接对
-
从"I/O 端口"转向"内存映射 I/O"
文中重点讲解的
inb/outb是 x86 架构特有的"独立编址"方式。而现代 Linux 驱动开发中,内存映射 I/O 才是主流。代码对比:
-
过时写法 :
outb(value, 0x378);(操作端口空间) -
现代写法:I/O内存,后面会讲。
shell// 1. 将物理地址映射到虚拟地址 void __iomem *base = ioremap(phys_addr, size); // 2. 像操作内存一样操作硬件寄存器 writel(value, base + OFFSET); // 3. 读取 val = readl(base + OFFSET);
-
-
推荐Linux GPIO 子系统学习路径
sysfs GPIO 已经过时啦,该学
libgpiod啦。参考学习链接:① libgpiod官网 ② FriendlyELEC写的libgpiod实战总结 ③ kernel docs。-
第一步:应用层开发 (用户空间):学习使用
libgpiod工具集参考网站 。安装并使用调试工具:
sudo apt install gpiod # Debian/Ubuntu,常用命令如gpiodetect gpioinfo gpioget gpiose等。用户态应用开发:学习引用
<gpiod.h>头文件,使用gpiod_chip_open_by_name和gpiod_line_request_output等函数。这是现代 C 语言操作 GPIO 的标准姿势。 -
驱动开发:
dts写法熟悉。
看内核代码:
./Documentation/driver-api/gpio/等目录,学习gpiod_get() gpiod_direction_output() gpiod_set_value() gpiod_to_irq()等内核API使用。 -
子系统理解:
搞清楚三个角色:
GPIO Controller driver(gpio_chip)→ gpiolib(框架层)→ consumer(驱动 / 用户态)
-
9.4. 使用 I/O 内存
在 Linux 驱动开发中,除了 x86 架构下传统的 I/O 端口(I/O Port),更为主流且通用的硬件通信机制是 I/O 内存(I/O Memory) 。无论是网卡的数据缓冲区,还是各类外设的控制寄存器的访问,大多都通过 I/O 内存来实现。
什么是 I/O 内存?I/O内存指的是通过总线访问设备的内存映射区域。在软件层面设备寄存器和设备内存之间的区别是透明的。它能像 RAM 一样被寻址,但读取或写入这些区域可能会触发硬件动作。
访问原理:在大多数现代体系结构(如 ARM、RISC-V 以及现代 x86)中,I/O内存不能直接通过物理地址指针访问,必须通过页表映射,转化为内核可见的虚拟地址。
9.4.1 I/O 内存的标准使用流程
在现代 Linux 内核驱动开发中,访问 I/O 内存必须遵循严格的 "申请 -> 映射 -> 访问 -> 释放" 流程,以确保系统的稳定性和驱动的移植性。
-
原始且过时的做法(手动挡)
在早期的驱动或者非常底层的代码中,程序员需要手动从 DTS 中提取地址,然后一步步来:
- 用
platform_get_resource()从 DTS 里把物理地址取出来。 - 手动调用
request_mem_region()去向内核申请这块物理地址,防止冲突。 - 再调用
ioremap()把它映射成虚拟地址。 - 可以安全访问 I/O 内存了。
- 最后在驱动卸载时,还得记得手动调用
iounmap()和release_mem_region()来解除映射释放资源。
这种做法代码冗长,且很容易因为忘记释放资源而导致 Bug。
- 用
-
现代的推荐做法(自动挡)
现代驱动开发极力推荐使用 Devres(设备资源管理) 接口。最典型的就是
devm_ioremap_resource()函数。当你调用
devm_ioremap_resource(pdev, 0)时,它内部其实帮你自动完成了以下"一条龙"服务:- 它内部先调用
platform_get_resource()从 DTS 中拿到物理地址。 - 紧接着,它内部自动调用了
devm_request_mem_region()来完成"占坑"。 - 最后调用
devm_ioremap()完成地址映射。 - 当驱动卸载或报错退出时,内核会自动把映射解除,并把申请的内存区域释放掉,完全不需要你手动干预接触映射。
所以,
request_mem_region并没有消失,而是被封装进了这些更高级的devm_函数里。提高了可靠性 - 它内部先调用
安全访问 I/O 内存的注意事项
绝对禁止 直接对 ioremap 返回的指针进行解引用(如 *addr = value)。直接指针操作不仅不可移植,还可能因为 CPU 的乱序执行或缓存机制导致硬件状态异常。必须使用内核提供的专用封装函数:
| 操作类型 | 8位读写 | 16位读写 | 32位读写 |
|---|---|---|---|
| 单次读写 | ioread8 / iowrite8 |
ioread16 / iowrite16 |
ioread32 / iowrite32 |
| 重复读写 | ioread8_rep |
ioread16_rep |
ioread32_rep |
| 内存拷贝 | memcpy_fromio (读) |
memcpy_toio (写) |
memset_io (填充) |
-
旧接口废弃提醒 :早期的
readb/writeb等宏定义虽然仍兼容,但缺乏类型检查,新代码中应避免使用。 -
内存屏障 :在操作 I/O 内存时,必要时需配合内存屏障(如
wmb()),确保读写顺序符合硬件时序要求。 -
ioread8_rep, ioread16_rep这类重复读写函数与memcpy_fromio,memcpy_toio,memset_io这类函数有啥区别?类别 memcpy_fromio / toio / memset_io ioread*_rep / iowrite*_rep 抽象层次 类似 memcpy(通用内存语义) 专门 I/O 批量访问 访问方式 CPU可以按宽度优化 固定宽度(8/16/32) 顺序保证 不强调 I/O 语义 强调 I/O 访问顺序 适用设备 MMIO memory region FIFO / 数据端口 典型场景 寄存器块 / buffer 串口 / 网卡 FIFO 是否考虑端序 不特别处理 明确对应8/16/32
9.4.2. 特殊场景与兼容机制
- I/O 端口映射为内存(ioport_map) :
有些硬件既支持 I/O 端口访问,也支持 I/O 内存访问。为了统一驱动代码,内核提供了ioport_map()。它可以将 I/O 端口重映射为看起来像 I/O 内存的地址,之后你就可以统一使用ioread8等函数进行访问,无需在代码中区分端口和内存。 - ISA 内存(历史遗留) :
在 x86 架构中,0xA0000 到 0x100000 之间的区域是著名的 ISA 内存区。虽然现代设备很少使用 ISA 总线,但了解这一区域有助于理解早期的直接内存访问机制。 - 废弃的 ISA 专用函数 :
内核中曾存在isa_readb等无需ioremap的函数,这些仅是为了旧驱动移植提供的临时辅助,在现代开发中应完全避免使用。