驱动和硬件打交道,九成是对着一段地址读写.MMIO 这件事原理简单,坑全在细节:寄存器布局,访问属性,顺序保证.这篇从 ioremap 讲到 readl/writel,每个环节都在真硬件上验证,中间还有一个我自己翻的车,值得先看.
这篇回答几件事:
- ioremap 到底做了什么,和普通内存映射差在哪
- readl/writel 为什么不能换成指针解引用
- 同一个"串口寄存器"为什么我会读错,怎么避免
- 用户态 /dev/mem 和内核 ioremap 的关系
先摆事实.板上的控制台串口在设备树里登记为 serial@ff1a0000,/proc/iomem 里能看到内核给它的资源划分:
ff1a0000-ff1a00ff : serial
物理地址 0xff1a0000 起 256 字节.驱动想访问它,第一步 ioremap:内核在 vmalloc 区找一段虚拟地址,建页表映射到这段物理区域.注意这个映射的属性 — 1.4 那篇读过 MAIR,ioremap 默认用 Device nGnRE 那一档:不缓存,不推测,访问汇聚都关掉.为什么必须这样:外设寄存器不是内存,读一次可能有副作用(读清中断),写两次可能状态机就乱了,Normal 内存那套"缓存住慢慢来,合并写,预取"的优化对寄存器全是毒药.
映射好就是读写,readl 和 writel:
void __iomem *base = ioremap(0xff1a0000, 0x100);
u32 lsr = readl(base + 0x14);
writel(val, base + offset);
板上实测,读控制台串口的状态寄存器:
ioremap(0xff1a0000) -> va 00000000c051060f, LSR=0x60: DR(data)=0 THRE(tx空)=1 TEMT=1
LSR=0x60,发送器空,没有待收数据 — 控制台正闲着,读数完全自洽.
然后是我翻的那个车,比成功案例有教育意义.起步我按 PL011 的寄存器布局读 0x18 偏移的 FR(Flag Register),读出来是 0x00,一个标志位都没有,不对劲.翻手册才想起来:RK3399 的串口是 DesignWare 8250,不是 ARM PL011,两家的寄存器地图完全不同 — 8250 在 0x18 那格放的是 MSR(Modem Status),它当然恒 0;真正该读的 LSR 在 0x14.换了偏移,立刻读到合理的 0x60.教训很直接:裸调寄存器之前,先确认这块外设是谁家的,PL011 和 8250 的同名功能寄存器隔着十万八千里,别拿上一块板子的肌肉记忆套用.设备树里 compatible 字符串(rockchip,rk3399-uart/snps,dw-apb-uart)早就把答案写好了.
读在真硬件上验证,写就得挑地方 — 往控制台串口的发送寄存器乱写等于污染自己的日志.QEMU 里没有这个顾虑,PL011 在 0x09000000,写 DR(Data Register)一个字符就会直接从串口出来.模块里两行:
writel('K', base + UART_DR);
writel('n', base + UART_DR);
QEMU 实测,内核日志的洪流中间凭空冒出一个孤零零的 K,前后都是正常 printk.这个 K 没经过 printk,没经过 tty 层,没经过任何缓冲 — writel 直捅寄存器,硬件当场把它发了出去.MMIO 的"直接"用这种方式看见,比文字描述深刻得多.读寄存器那边也顺带对了答案:PL011 的 FR 读出 0x90,bit7 TXFE 和 bit4 RXFE 置位,空闲串口的标准姿势,和 8250 的 LSR 异曲同工,两种布局同一时刻的状态快照.
再说为什么必须用 readl/writel,不能 (volatile u32 )base 一把梭.看 arch/arm64/include/asm/io.h 的定义,readl 展开是 __raw_readl 加 __iormb,writel 是 __iowmb 加 __raw_writel,那两个 mb 最终是 dsb:读屏障保证"先读到寄存器的值,再执行后面的加载",写屏障保证"前面的存储全部落地,再写寄存器".这不是可有可无的洁癖:CPU 和编译器都会重排,设备更是异地异步,没有这些屏障,你的"先写命令再读状态"完全可能在硬件看来是"先读状态再写命令",查这种 bug 能把人逼疯.relaxed 后缀的变体(readl_relaxed)去掉屏障,省那么几纳秒,只有你自己能证明顺序无关时才用,通常是在同一段寄存器上的连读连写.
用户态也有直捅通道:/dev/mem 加 mmap.实测代码十行,把 0xff1a0000 那页映射进来,指针一偏移就能读同一个 LSR.调试工具(devmem2,一些 hwmon 工具)全靠它.它和内核 ioremap 的区别不在映射本身,而在治理:谁能开 /dev/mem 由权限和内核配置(CONFIG_STRICT_DEVMEM 可限制只能访问特定区域)说了算,生产系统这个口子越小越好.调试时爽,上线时锁.
最后留几个容易忽略的细节:
- ioremap 的区域必须来自设备资源(/proc/iomem 有主的),随便 ioremap 一段 RAM 地址是自找麻烦,拿到的是 Device 属性的普通内存,性能和一致性都乱了.
- 寄存器宽度要对:8250 的寄存器实际是 8 位宽,靠 reg-shift=2 摊在 4 字节间隔上,readl 读一个寄存器会把邻居也带上,大部分场景没害处,但别拿读出来的高位当数据.
- devm_ioremap_resource 把"从 DT 拿资源+检查+映射+devm 自动卸载"一锅端,新代码别再手写 platform_get_resource 加 ioremap 老三样.
- 写寄存器后立刻读回来验证,对 Device 内存不一定成立,有的寄存器写入后就变状态,读回的不是你写的值,别据此判断"写失败了".
- __iomem 指针类型是稀疏工具的标记,故意让你不能直接解引用,消警告别用强转,用 accessors.
- 一条 writel 的到达时间没有全局保证,确信设备收到指令要靠协议层的确认手段(状态位,中断),dsb 只保证顺序不保证设备执行完.