本节摘要:访问外设寄存器的第一步是把物理地址映射成内核虚拟地址。本节讲清两种 I/O 编址流派、
ioremap的映射原理、专用访问函数与内存屏障的关系,以及设备资源接口带来的现代化写法,让你写的每一行寄存器操作都"师出有名"。
规格书上写着:GPIO 数据寄存器位于物理地址 0xFDD20000。一个新手本能地写出 *(unsigned int *)0xFDD20000 = 1;——然后收获一次系统崩溃。为什么直接用物理地址不行?因为内核开启内存管理单元后,CPU 眼中只有虚拟地址,任何访问都要先过页表翻译;一个没有页表项覆盖的地址,访问即触发异常。物理地址是"硬件世界的门牌",虚拟地址才是"内核世界的通行证",ioremap 就是办证窗口。
外设寄存器如何编址,体系结构给出了两种答案。
独立编址(端口 I/O):x86 的传统流派。外设寄存器放在独立的 I/O 地址空间,用专用指令访问。优点是地址空间不占内存;缺点是指令功能弱、只有少数架构支持,现代驱动里越来越少直接出现。
统一编址(内存映射 I/O):ARM、RISC-V 等主流流派。外设寄存器映射进物理内存地址空间,访问寄存器与访问内存在地址形式上无异,但背后完全不同——内存地址落到 DRAM,外设地址落到设备,读取可能触发真实的硬件动作。本节的 GPIO 就属于这一流派。
物理地址空间布局示例(32 位 ARM): 0x00000000 - 0x3FFFFFFF DRAM(真正的内存) 0xFDD20000 - 0xFDD2FFFF GPIO 控制器寄存器区 0xFF1F0000 - 0xFF1FFFFF UART 寄存器区
⚠️ 常见坑:把外设寄存器区当普通内存对待——用普通的指针解引用、加缓存属性、做编译器重排优化,三者中任何一条都可能让寄存器访问行为错乱。寄存器地址必须经
ioremap映射,且用专用访问函数。
映射、访问、解除映射三步,配上一段真实可用的 GPIO 初始化代码:
#include <asm/io.h> #define GPIO_BASE 0xfdd20000 /* 本章先写死,第 4 章用设备树取代 */ #define GPIO_SIZE 0x100 static void __iomem *gpio_base; /* __iomem 标注:这是 I/O 地址,别当内存用 */ static int __init led_hw_init(void) { gpio_base = ioremap(GPIO_BASE, GPIO_SIZE); if (!gpio_base) return -ENOMEM; return 0; } static void led_hw_exit(void) { if (gpio_base) iounmap(gpio_base); /* 与 ioremap 严格配对 */ }
ioremap 返回的虚拟地址只在此内核、此次映射中有效;__iomem 是稀疏检查标注,配合第 7 章的静态分析能揪出"把 I/O 地址当普通指针用"的代码。访问寄存器一律用专用函数族:
unsigned int dir = ioread32(gpio_base + 0x04); /* 读方向寄存器 */ iowrite32(dir | (1 << 18), gpio_base + 0x04); /* 置第 18 位为输出 */ iowrite32(1 << 18, gpio_base + 0x00); /* 数据寄存器拉高:灯亮 */
三个理由,一个比一个隐蔽。
第一,地址没有映射。ioremap 之前的物理地址在页表里无对应项,直接访问立刻异常——这就是本节开头那次崩溃。
第二,编译器与 CPU 会重排。普通指针写可能被编译器合并、被 CPU 乱序执行。而寄存器写入常有顺序语义:先配置方向、再写数据,顺序颠倒灯就不亮。专用访问函数内含编译屏障与必要的内存屏障,保证"写下去的顺序就是你代码的顺序"。
第三,缓存会撒谎。普通内存访问会被缓存加速,而寄存器的每次读写都应直达硬件——缓存一份旧值等于读到过期的世界。ioremap 建立的映射带"不可缓存"属性,从根上隔绝了这个问题。

写死地址终归是权宜之计。内核为设备资源提供了统一的申请接口——从设备模型登记的资源里取地址并映射,失败自动回收。先睹为快(完整机制在第 4 章):
#include <linux/platform_device.h> static int led_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -EINVAL; /* 设备管理版映射:设备移除时自动解除,无需手写 iounmap */ base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(base)) return PTR_ERR(base); return 0; }
devm_ 前缀是内核的"托管资源"机制:资源与设备生命周期绑定,probe 失败或设备拔除时自动释放。本章后续的 devm_request_irq、dmam_alloc_coherent 同属一族——托管资源把错误路径的回滚代码几乎清零,是现代驱动与二十年前的老驱动最直观的分野。
寄存器映射不是抽象概念,运行中的系统里到处是它的痕迹。设备资源清单文件按区域列出每台设备占用的物理地址段与大小;映射信息能在设备树运行时视图里对应到节点——地址、长度、属主三样对上号,映射关系就尽在掌握:
$ cat /proc/iomem | head 00000000-3fffffff : System RAM ... fdd20000-fdd20fff : gpio@fdd20000 ← 本章写死的那个 GPIO 区 ff020000-ff020fff : i2c@ff020000
调试时这份清单常用来核对"驱动映射的地址对不对":第 7 章排错实录里"写入成功但灯不亮"的一类根因,就是映射基址与设备实际占用的区域对不上——对着清单查一遍,两分钟就能定性。
追问一:映射成功之后,能不能把返回的指针直接当普通内存用指针算术访问? 地址能通,规矩不能破。普通指针访问绕开了专用访问函数的三个承诺——不保证访问宽度、不保证顺序、在部分架构上可能连地址空间都不对;更隐蔽的是编译器与处理器都可能对普通访问重排序,寄存器操作一旦被重排,硬件收到的顺序与代码书写顺序无关,故障表现为"偶尔不亮、偶尔状态错乱"。规矩的记法很简单:映射来的地址只交给读写函数族,不交给裸指针。
追问二:一块寄存器区能不能映射两次,两个驱动各用一份? 技术上办得到,工程上绝对禁止。同一寄存器区两份映射意味着两个属主同时改写硬件状态,谁也不知道对方写过什么——这本质上是把并发问题从代码层压到了硬件层,比软件竞态更难查。正确姿势是硬件资源归属唯一驱动、驱动内部再用锁保护临界区;真要跨驱动共享硬件,内核有专门的复用与热插拔机制,而不是靠两份映射各写各的。
ioremap 负责办证。devm_ 前缀让映射随设备生命周期自动管理。地址打通了,但点灯是驱动"说"、硬件"听"。下一节反过来——硬件主动说话时,驱动怎么接。