调试系统控制寄存器的时候,最容易让人头疼的往往不是某个寄存器位的含义看不懂,而是同一块物理地址区间被好几个驱动同时盯上:一个要写软复位控制位,一个要清中断状态,还有一个只想读启动原因。各模块都直接ioremap,都拿着手册对着偏移地址操作,改到后面根本分不清是哪一路写坏了状态。后来我习惯把这类寄存器区域交给syscon框架统一管理,问题才算真正安静下来。
syscon框架,全称是System Control Register,是Linux内核里专门为一类“杂散系统控制寄存器”提供统一访问的机制。它解决的事情很朴素:一个寄存器块里有大量互相独立的控制位和状态位,却被多个驱动共享使用。只要在设备树里给这个节点挂上syscon,内核就会为它注册一个全局的regmap;任何模块都能通过固定API查到这个regmap,再用统一的regmap读写接口操作寄存器,自己完全不用映射IO。这篇文章就以实际使用为主线,从设备树写法一路讲到驱动侧调用、并发安全性、W1C寄存器的坑,最后补一点调试手段,适合正在做SoC平台适配的嵌入式工程师,也适合对内核驱动框架感兴趣的朋友。
1. 从“一块东拼西凑的寄存器区”说起:syscon要解决的实际痛点
1.1 典型的系统控制寄存器组长什么样
很多处理器手册里都会有一块区域叫System Controller或者PMU,它不是一个完整外设,没有FIFO、没有中断请求线、没有DMA接口,就是一堆控制位和状态位的集合。我见过比较典型的一张表长这样:
| 偏移 | 常见内容 | 谁来访问 | 备注 |
|---|---|---|---|
| 0x00 | 软复位控制 | 复位管理、BSP启动代码 | 某位置位复位GPU,某位置位复位多媒体引擎 |
| 0x04 | 时钟/电源门控 | 时钟驱动、电源管理 | 部分位只写,没有读回意义 |
| 0x20 | 启动原因 | 启动诊断、bootloader传递信息 | 通常是只读状态 |
| 0x100 | 事件/中断状态 | 中断控制器、PMU驱动 | 很多是W1C,写1清除 |
这些位唯一的共同点是“都在同一个物理地址区间里”,偏移挨着,功能却彼此毫无关系。放在内核设备模型里,它们天然就不适合由一个普通驱动完全独占。你说它算外设吗?没有一个完整外设该有的样子。你说它不算外设吗?又确确实实有一组寄存器需要被管理。syscon就是专门给这种场景准备的注册表式共享访问方案。
1.2 多个驱动直接读写同一段寄存器会踩什么雷
如果每个驱动都直接对这片区域做ioremap,代码写起来倒是痛快,但隐患不少。首先是重复映射:每个驱动都得自己解析reg属性、of_iomap、在remove里iounmap,这套模板代码在五六个驱动里各写一份,冗余还是小事,关键是风格很难统一。
真正疼的是并发。两个驱动各管一个bit,同时基于旧值做read-modify-write:A想置位0x04的bit0,B想置位0x04的bit1。A读回来0b1000,B也读回来0b1000;A算完写成0b1001;B那边拿的还是旧值,改完也是0b1001,bit1没写进去。这就是典型的跨驱动读写竞争。单个驱动自己加spinlock只能保护自己的访问路径,锁不住另一个驱动。有些寄存器访问还会发生在不可睡眠的上下文里,连mutex都不敢乱用。
等到了联调阶段,麻烦更明显:你想看0x100偏移为什么被写脏了,可是读代码得翻遍三四个驱动,谁在什么时候改的、改了哪个位,完全是一笔糊涂账。寄存器就像一条公共走廊,谁也不锁门,出事了只能挨个查监控。
1.3 syscon解决问题的核心逻辑:统一映射、统一锁、统一查询
syscon的解决思路其实很贴近现实里的物业管理:把这片公共区域变成一个办公楼,所有模块不再各自攥着地图找房间,而是到前台领钥匙。这把钥匙就是regmap。
内核为syscon节点只创建一份regmap,所有驱动通过查询函数拿到的都是同一个实例。regmap内部自带访问锁,read-modify-write操作是原子的;因为大家共享同一把锁,跨驱动的访问也会被串行化,之前那种“两个驱动同时改不同bit却互相覆盖”的并发问题就从框架层面被挡掉了。同时,驱动侧只需要调用统一API,不用关心设备树里物理地址怎么变化,也不用在五六个文件里重复实现地址映射。
一句话总结:syscon不是替你解决寄存器语义问题,它解决的是“同一段寄存器怎么让多个驱动安全地共享”。语义还是得你自己对着手册来。
2. 设备树挂牌与内核注册流程:节点如何变成一把regmap钥匙
2.1 最小syscon节点写法
设备树里让一个节点成为syscon,写法非常轻量,核心是多加一个兼容字符串:
sysctrl: sysctrl@02040000 { compatible = "somevendor,sysctrl", "syscon"; reg = <0x02040000 0x1000>; };这里有两个关键点。第一,兼容字符串里必须包含"syscon",syscon框架的of_match表就是靠它来识别节点的。第二,前面的"somevendor,sysctrl"是硬件标识,供驱动用完整兼容名查询,千万别图省事只写一个"syscon",否则设备树里一堆节点各自带syscon兼容符,查询时就分不清谁是谁了。
reg属性就是寄存器块的物理地址和长度。可选属性还有一个reg-io-width,代表访问位宽,默认是4字节。绝大多数ARM平台保持默认就好,除非硬件手册明确写了寄存器映射只有8位或16位有效,否则不要随便改,改了regmap的底层访问行为会跟着变。
2.2 当节点还想当clock/reset provider时:simple-mfd的叠加
实际项目里,系统控制寄存器区往往不只是被外部驱动拿来做syscon查询,它自己还会挂一些子功能。常见写法是再叠一个simple-mfd:
sysctrl: sysctrl@02040000 { compatible = "somevendor,sysctrl", "syscon", "simple-mfd"; reg = <0x02040000 0x1000>; #address-cells = <1>; #size-cells = <1>; clkgen: clock-controller@20 { compatible = "somevendor,clkgen"; reg = <0x20 0x10>; }; };simple-mfd的作用是告诉内核:这个节点本身可能没有完整独立的platform驱动,但它下面挂着的子节点需要被注册成platform设备。内核看到这个兼容符后,会为子节点创建设备并走正常probe流程。子驱动在probe时再通过syscon_regmap_lookup_by_phandle找到父节点的regmap,大家依然共用同一片寄存器区域。
这里有个经常被照抄出错的地方:并不是所有syscon都需要加simple-mfd。如果父节点自己就有专门的platform驱动,那么子节点通过of_platform_populate也会被创建,加不加simple-mfd影响不大。只有当父节点只是一个“容器”,没有自己的驱动逻辑时,才需要靠simple-mfd把子节点顶上总线。看到别的芯片这么写就原样复制,结果父设备没有驱动的情况下子节点一个都不出现,排查半天发现就是这个兼容符在起作用。
2.3 从compatible到regmap:内核注册流程与全局登记表
syscon机制的代码主体在drivers/mfd/syscon.c。它定义了一个platform_driver,of_device_id里就写着syscon。内核启动时通过设备树扫描把这些节点变成platform_device,然后syscon驱动在probe阶段读取reg和可选的reg-io-width,填充好一个regmap_config,再调用devm_regmap_init_mmio完成物理地址映射与regmap实例创建。
容易忽略的一点是:创建好的regmap不会只待在syscon驱动手里。它会被挂到一个全局链表syscon_list上,链表的索引就是设备树节点指针。后面任何驱动调查询函数,实质都是在这个链表里按节点指针找regmap。我最初看代码时以为每次查询都会新建一个regmap,其实不会,全系统对一个syscon节点只有一份regmap实例,并发环境下的安全感就来自这个唯一性。
更特殊的是,如果某个驱动在syscon驱动还没跑起来时就发起查询,syscon_node_to_regmap这类接口发现链表里没记录,还支持根据节点现场补注册。也就是说,多数情况下你并不需要担心“父节点没有先probe”导致拿不到regmap,框架已经帮你做了懒加载。只有真正涉及其他资源依赖时,才需要认真对待-EPROBE_DEFER。
3. 驱动侧三种拿regmap的姿势:按节点、按兼容符、按phandle
3.1 按compatible查询:全局唯一时最省事
最直觉的用法是直接用字符串查询:
#include <linux/mfd/syscon.h> struct regmap *map; map = syscon_regmap_lookup_by_compatible("somevendor,sysctrl"); if (IS_ERR_OR_NULL(map)) { dev_err(dev, "failed to get sysctrl syscon\n"); return -EINVAL; }这个API把兼容字符串传进去,框架遍历全局注册表,返回第一个匹配节点的regmap。优点就是简单,适合系统里唯一存在的控制器,比如SoC级的主系统控制器,通常一个系统只有一个。
但风险也藏在“第一个匹配”里。很多板子上的设备树存在多个syscon节点,PMU是一个、GRF是一个、PHY控制区又是一个,它们的compatible里都带着syscon。如果你直接查"syscon",拿到的可能是莫名其妙的那一个。正确姿势是带上前缀,"somevendor,sysctrl"这种粒度才能保证查询唯一。如果硬件上可能同时存在多个同前缀节点,就老老实实换phandle方式,别在这个API上硬撑。
3.2 直接拿设备树节点:懒注册背后的第二次机会
struct device_node *np = pdev->dev.of_node; struct regmap *map = syscon_node_to_regmap(np);syscon_node_to_regmap接收的是设备树节点指针。这个API几乎是另外两个查询函数的底层实现,前面提到的“节点还没被syscon驱动处理也能现场补注册”,就是靠它完成的。
它适合你已经拿着节点引用的场景。比如驱动在probe里先通过of_parse_phandle拿到了父节点的struct device_node *,下一步直接用syscon_node_to_regmap查regmap,就不用再从compatible字符串绕一圈了。这样既精确又高效,也不需要担心匹配到别的同兼容节点。
需要注意参数类型:传进来的是节点指针,不是兼容字符串。有一种常见错误是写syscon_node_to_regmap(pdev->dev.of_node->parent),以为传父节点就能拿到父节点的regmap——能拿,但前提是你确实需要父节点的regmap。这里没有隐式转发,你要哪个节点,就得明确传哪个节点。
3.3 通过phandle显式声明依赖:强烈推荐的做法
当驱动和syscon节点之间有明确属主关系时,phandle是最清晰的表达方式。设备树里先在客户端节点写一个属性指向syscon:
eth: ethernet@10000000 { compatible = "somevendor,eth"; reg = <0x10000000 0x10000>; ctrl-syscon = <&sysctrl 0>; };驱动侧就按属性名查:
struct regmap *map; map = syscon_regmap_lookup_by_phandle(np, "ctrl-syscon"); if (IS_ERR(map)) return PTR_ERR(map);这种写法的最大好处是显式。设备树里一眼就能看出这个以太网驱动依赖哪块系统控制器,将来换芯片换地址,改设备树里的phandle指向就行,驱动代码不用变。代码里也不再有硬编码的兼容字符串,设备树成了唯一事实来源。
三种方式的适用场景,可以简单对照一下:
| 查询API | 依赖信息 | 适用场景 | 主要风险 |
|---|---|---|---|
| syscon_regmap_lookup_by_compatible | 兼容字符串 | 全局唯一的控制器 | 同兼容多个节点时可能拿错 |
| syscon_node_to_regmap | 设备树节点指针 | 已经持有np的场合 | 传错节点不会报错,只会拿错regmap |
| syscon_regmap_lookup_by_phandle | 设备树属性名 | 有明确依赖关系的客户端驱动 | 属性名拼错会返回错误指针 |
我个人在项目里基本只写phandle方式,除非遇到那种只有一个实例且系统内绝无重复的系统控制器,才考虑用compatible查询省一行代码。
4. 拿到regmap后怎么操作:读写API、原子性与两个典型坑
4.1 四个高频接口:读、写、改位、轮询
拿到struct regmap *map之后,操作就完全进入regmap API的领域了。soc项目里最常用的四个接口是:
unsigned int val; struct regmap *map = syscon_regmap_lookup_by_phandle(np, "ctrl-syscon"); /* 读一个状态寄存器 */ ret = regmap_read(map, 0x20, &val); if (ret) { /* 有些寄存器物理上不可读,返回非零值 */ } /* 直接写,常用于触发软复位等动作 */ ret = regmap_write(map, 0x00, 0x1); /* 只修改某几个bit,不改其他位 */ ret = regmap_update_bits(map, 0x04, BIT(1) | BIT(3), BIT(1)); /* 轮询某bit,直到置位或超时 */ ret = regmap_read_poll_timeout(map, 0x20, val, (val & BIT(5)), 1000, 100000); if (ret == -ETIMEDOUT) { dev_err(dev, "wait for bit5 timeout\n"); }注意regmap_read_poll_timeout的最后一个参数是总超时时间,倒数第二个是每次读取间隔。它会先读一次判断条件,不满条件就sleep一小段时间再读。这个宏在驱动里非常实用,很多启动流程要等硬件状态稳定,用它比随手写个while循环加udelay要规范得多。
4.2 W1C寄存器和update_bits的“致命诱惑”
说个我见过不少次的事故:W1C寄存器,也就是write-1-to-clear类型的寄存器,直接用regmap_update_bits去清某一位,结果把不想清的其他位也全清了。
举个例子。0x100偏移里有bit3表示A事件,bit5表示B事件,都靠写1清除。现在A事件发生了,bit3读出来是1,B事件没发生,bit5读出来是0。你用regmap_update_bits(map, 0x100, BIT(3), BIT(3))想清A事件,内部先读,再与mask做运算,最后回写时会把整个寄存器按计算结果写进去。问题在于:如果同一时刻bit5也是1,比如B事件也挂着,读回的值就有bit5=1;update_bits的写法不会去动mask之外的bit,结果回写时bit5仍然是1,而W1C的硬件语义就是“写1清除”,于是你只想清A,B事件也被顺手清掉了。
这种场景不能用update_bits做部分位清除。你要么精确构造出只包含“本次真正想写1的位”的整字值,再用regmap_write一次性写进去;要么在驱动里专门封装W1C寄存器的操作函数,注释里写清楚哪些位是写1清除,禁止别人拿update_bits去碰。看到芯片手册里出现“Write one to clear”,先停下来想想驱动的调用路径,不要想当然复制一个update_bits就完事。
4.3 锁的粒度:syscon能保证什么,不能保证什么
syscon节点对应的regmap只有一个实例,所有通过查询函数拿到它的驱动共享同一把锁。这意味着一顿regmap_update_bits内部的读-改-写过程是原子的,不会出现两个驱动各读各的旧值、分别回写导致丢bit的情况。这个保证在共享系统控制寄存器时非常关键。
但也要清楚边界:regmap只保证单次API调用内的原子性,不保证多次调用合并成一个事务。你连续做两次regmap_update_bits,中间完全可能被另一个驱动插入一次访问。如果硬件要求一组寄存器位必须“同时”生效,比如睡眠模式的多个配置位要一起写,那就得驱动自己额外加一把锁,在锁内完成多次regmap操作,或者尽量把同一偏移里的多个bit合并到一个mask里一次写完。
还有一个细节:syscon创建的regmap默认cache_type是REGCACHE_NONE,每次读操作都直达硬件,不存在缓存一致性问题。我之前见过有人在自己封装的regmap里开了cache,结果多个驱动同时访问同一个寄存器区域时,不同驱动看到不同的值,查了两天才意识到是cache惹的祸。在syscon场景下不要开cache,实时值班才是最安全的。
5. 和MFD、reset控制器联动时的边界问题与调试手段
5.1 一个节点被多个驱动认领:别让访问路径分裂
设备树里一个节点挂多个兼容字符串是常态,比如:
sysreset: sysreset@02040000 { compatible = "somevendor,sysreset", "syscon"; reg = <0x02040000 0x1000>; #reset-cells = <1>; };somevendor,sysreset用来匹配reset控制器驱动,syscon又让它成为系统级regmap。这里真正值得警惕的不是“谁去probe”,而是probe之后访问路径会不会分裂。reset驱动如果自己of_iomap同一段地址并用readl/writel操作,其他模块又通过syscon的regmap操作同一段寄存器,那就出现了两个访问入口、两套锁,互相看不到对方的访问轨迹。
我在一些平台代码里见过这种事故:reset驱动和PMU驱动同时改一个睡眠唤醒标志位,一个用readl,一个用regmap,最终现象是休眠唤醒随机失败,跟踪日志才发现两边的值经常不一致。遇到这种节点,最好立一条规矩:同一寄存器区域只用一种访问机制。要么所有人都通过syscon/regmap来,要么功能驱动自己也基于regmap API实现并向外部暴露接口,绝不要在同一块物理区域内混用ioremap和regmap两套路径。
5.2 probe时序与-EPROBE_DEFER的真实表现
子设备驱动在probe时调syscon_regmap_lookup_by_phandle,拿回来的可能不是regmap而是一个错误指针。常见值是-EPROBE_DEFER。我第一次遇到时也愣了一下,其实这是内核在告诉你:依赖的外部资源还没准备好,先把我挂起,等依赖驱动就绪后再来probe一次。
正确做法就是把这个错误原样返回:
struct regmap *map = syscon_regmap_lookup_by_phandle(np, "ctrl-syscon"); if (IS_ERR(map)) return PTR_ERR(map);有些工程师看到返回-EPROBE_DEFER会不放心,在probe里自己写循环重试,或者干脆sleep几百毫秒再硬调。这么做不但拖慢启动流程,还可能在一个尚未完全初始化的regmap上做操作,埋下更隐蔽的问题。记住,-EPROBE_DEFER不是错误,是内核的正常调度手段。
另外提一句,syscon框架本身有懒注册能力,所以纯粹因为“父节点没probe”而拿不到regmap的情况远没有想象的那么多。更多时候是设备树属性名和代码不一致,或者这个节点压根没写syscon兼容符。遇到这类问题先查设备树,别一上来就怀疑内核时序。
5.3 现场调试三板斧和个人习惯
第一个手段是regmap的debugfs接口。内核开启CONFIG_REGMAP后,在/sys/kernel/debug/regmap/下能看到每个regmap一个目录,里面可以查看寄存器当前值。syscon由于没有cache,读到的就是硬件实时值,拿它和手册比对非常方便。
第二个手段是tracepoint。内核regmap层有trace事件,用ftrace追踪regmap_read、regmap_write这些函数,能看到哪个进程在什么时间访问了哪个偏移地址。排查“谁动了我的寄存器”这类问题,比翻驱动源码快得多。
第三个手段是开deferred probe调试。通过在cmdline或debugfs里打开deferred_probe_debug,可以看到驱动因为什么依赖被延迟、循环了多少次,这对分析设备树依赖链非常有用。
我现在的个人习惯是:只要预感到一片寄存器会被两个以上模块访问,设备树里就直接挂syscon。驱动侧统一用phandle查询,拿到regmap后尽量用update_bits单mask操作;凡是W1C寄存器,先在头文件注释中标明,禁止别人套update_bits。同时,绝不让同一个寄存器区域同时存在ioremap和regmap两条访问路径。syscon这个框架本身很小,但它把驱动之间隐性的寄存器依赖关系变成了设备树里显式的连接关系,排查问题时省下的时间,远超过写那几行设备树的开销。