十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

嵌入式Linux CCF时钟框架:设备树、clk_ops与调试实战

嵌入式Linux CCF时钟框架:设备树、clk_ops与调试实战 1. 为什么时钟管理需要一个通用框架1.1 裸机时代的时钟配置有多乱刚开始接触嵌入式Linux的时候我干过一件现在想起来有点蠢的事直接把裸机代码里那套时钟初始化函数照搬到驱动里每个外设驱动自己去算分频、自己去写寄存器、自己在probe里按顺序使能PLL。结果就是板子能跑但只要两个驱动同时用同一个PLL就会出现后启动的把先启动的配置冲掉串口突然波特率漂移SPI时序偶尔抽风查到最后全都是时钟寄存器互相踩踏。这个问题在单外设demo里基本不会暴露一旦系统里挂了十几个需要不同频率的外设就是灾难。那会儿我最大的困惑不是怎么算分频系数而是整个系统里谁有权改这个时钟、改了之后别人怎么办。这也是我后来认真去啃CCFCommon Clock Framework通用时钟框架的原因。CCF是Linux内核在drivers/clk目录下建立的一套统一时钟管理子系统它要解决的核心问题不是怎么配置时钟而是怎么让全系统安全、可追溯、可继承地共享一棵时钟树。这篇内容适合已经能写简单字符设备驱动、能看懂设备树但一碰到clk相关API就犯怵的朋友也适合那些驱动能跑但总在时钟上出玄学问题的老手。1.2 CCF的抽象层次与设计取舍CCF最值钱的地方在于它把时钟抽象成了一棵树而不是一堆寄存器。物理上你面对的是一颗晶振、若干PLL、一堆分频器和门控开关逻辑上CCF把它们全部建模成struct clk节点每个节点有唯一的parent根节点就是晶振或者外部时钟输入。任何一个consumer消费时钟的驱动调用clk_set_rateCCF都会沿着这棵树往上找可以调频的节点往下递归通知所有子节点重新计算频率同时做引用计数防止某个分支的时钟被别人使能着的时候被关掉。这套机制背后是几个明确的取舍第一CCF不帮你决定策略只提供机制谁设频谁负责框架只保证一致性第二CCF默认时钟树拓扑由设备树描述代码只负责实现每个节点的硬件操作拓扑和寄存器操作的解耦让同一套代码能跨芯片复用第三CCF允许provider把整棵子树一次性注册进来不需要为每个时钟单独写驱动这就是of_clk_add_provider的意义。理解这三条后面所有的API和数据结构的用法就都顺了。1.3 一个典型SoC的时钟树长什么样我拿手头一块常见ARM SoC举例你对照着自己的芯片手册看结构基本同构。最顶上是一个24MHz的外部晶振进到一个OSC节点OSC分出几路其中一路进主PLL比如把24MHz倍频到1.2GHz另一路直接作为低速外设的根时钟主PLL下面挂CPU分频器、总线分频器、DDR分频器总线分频器再往下分出AHB、APBAPB再挂UART、I2C、SPI的门控。整个结构就是一棵树叶子是外设。CCF要做的事情就是每个中间节点对应一个clk_hw每个叶子对应一个consumer拿到的struct clk句柄。当UART驱动说我要115200波特率请给我一个合适的时钟CCF会从UART这个叶子节点往上爬找到最近一个可以调频的分频器算出分频比写寄存器然后检查这条路径上的父节点是否已经使能没使能就帮着使能最后返回实际能达到的频率。整个过程驱动完全不需要知道PLL的寄存器在哪。2. CCF的三层数据结构与设备树绑定2.1 clk_hw、clk_core、clk_provider 到底谁管谁刚看内核源码的时候clk_hw、clk_core、clk_provider这三个东西最容易晕。我用大白话拆一下clk_hw是你驱动作者要实现的它代表一个具体的硬件时钟单元里面挂着你写的clk_ops回调以及一个指向clk_core的指针clk_core是CCF框架内部自己维护的核心对象它保存了parent指针、children链表、rate、enable_count、prepare_count这些运行期状态你一般不需要直接碰它clk_provider是一批时钟的供应商的概念一个provider可以对应一个SoC的整个时钟控制器也可以只对应一颗孤立的晶振它的作用是让CCF能通过设备树里的#clock-cells和phandle找到对应的时钟。三者的关系可以这样理解你写的是clk_hw框架拿它生成clk_coreclk_provider是注册时给框架的索引方式。搞清楚这个分工你在调试时看到/sys/kernel/debug/clk/clk_summary里的节点是clk_core而你的代码里操作的是clk_hw就不会再对不上了。注意老版本内核用clk_register配clk_ops直接注册新内核4.13以后统一收敛到clk_hw_register加clk_hw。写新驱动别再抄老教程否则会踩到已废弃接口的坑。2.2 设备树里的时钟描述规范时钟在设备树里分两侧provider侧和consumer侧。provider侧的节点必须声明#clock-cells这个属性告诉CCF我这批时钟需要几个cell来定位一个。如果时钟控制器只提供一个时钟写#clock-cells 0consumer引用时直接clk如果控制器里有很多路时钟用索引区分写#clock-cells 1consumer引用时写成clk 3这里的3就是你在注册时为每个时钟指定的索引。consumer侧则用clocks属性列出它需要的时钟句柄用clock-names给出名字驱动里通过devm_clk_get(dev, baud)就能拿到对应句柄。这里有个很多人踩的坑clocks里引用的数组顺序必须和clock-names一一对应名字拼错一个字母devm_clk_get返回的就是ERR_PTR(-ENOENT)而且这个错误在编译期完全不报只有上板跑才会发现。我现在的习惯是设备树写完先dtc编译一遍再用grep clocks核对一遍名字能省下不少上板调试的时间。一个提供三路时钟的provider节点标准写法是这样clk_ctrl: clock-controller12000000 { compatible vendor,soc-clk; reg 0x12000000 0x1000; #clock-cells 1; clocks osc24m; clock-names osc; }; uart0: serial12100000 { compatible vendor,soc-uart; reg 0x12100000 0x100; clocks clk_ctrl 0, clk_ctrl 1; clock-names baud, bus; };2.3 provider与consumer的配对关系provider和consumer的配对是通过of_clk_add_provider与devm_clk_get这一对函数完成的中间靠设备树的phandle牵线。provider注册的时候传进去一个of_clk_get风格的回调CCF在解析consumer的clocks属性时会用phandle找到provider再把你给的cell值传给你的回调回调里返回对应的struct clk。也就是说consumer拿到的句柄本质上是provider在回调里按索引发牌。这个设计带来一个很实用的特性一个provider可以给不同的consumer发不同的牌甚至可以懒加载第一次被引用时才去初始化对应时钟。但反过来说如果provider的回调里对索引做了错误的映射或者映射表和设备树里写的不一致consumer拿到的就可能是另一路时钟现象往往是驱动能跑但频率不对——这种问题最难受因为没有任何报错。我的做法是在provider的映射回调里加一条pr_debug打印索引和返回的时钟名bringup阶段打开动态调试一眼就能看出映射对不对。3. 从寄存器手册到一个能跑的时钟驱动3.1 硬件信息提取锁相环、分频器、门控拿到芯片手册第一件事不是写代码是画表。打开时钟章节把所有跟你要实现的子系统相关的时钟节点列出来每个节点记四样东西寄存器地址、位域、可调范围、依赖关系。我一般会分三类处理第一类是锁相环PLL它通常有倍频FBDIV、参考分频REFDIV、输出分频POSTDIV几个字段还有一个LOCK状态位第二类是分频器形式很统一一般是某个寄存器里的一个N位字段分频比是field 1或者2^field具体看手册第三类是门控就一个bit0关1开。依赖关系就是parent是谁。举个例子某个PLL的公式是fout fin * FBDIV / (REFDIV * POSTDIV1 * POSTDIV2)而它的输入fin来自24MHz晶振输出接到一个APB分频器APB分频器的输出再门控给UART。把这条链画清楚代码的结构其实就定了一个PLL驱动节点、一个分频器节点、一个gate节点三个节点用parent串起来。3.2 clk_ops 关键回调实现clk_ops里回调很多但真正必须实现的不超过六个recalc_rate、round_rate、set_rate、enable、disable如果这个节点是可切换parent的mux再加get_parent和set_parent。我逐个说它们的职责和最容易写错的地方。recalc_rate是从寄存器反推当前频率它接收父节点频率返回本节点频率。这个函数必须无副作用只读寄存器。很多人偷懒直接把上次set的值存到私有结构里返回一旦别的驱动或者bootloader改过寄存器读出来就是错的clk_summary也会骗你。所以我的原则是能读寄存器的字段就算一遍不要缓存。round_rate是给我一个目标频率告诉我实际能给多少它不改寄存器只做计算。它的实现必须和set_rate的算法完全一致否则会出现set完再recalc频率和round返回的对不上。我踩过这个坑round里用了整数除法先除后乘set里先乘后除结果大频率下差了几十KHz。统一算法抽成一个内部函数round和set都调它是唯一靠谱的办法。set_rate是真正写寄存器的。这里最大的难点不是写值是改频时的时序。PLL改倍频必须先在寄存器里把PLL关掉或者切到bypass改完等LOCK再切回来如果这条PLL下面还挂着正在跑的CPU或者DDR改频前还得先切到备用时钟。这个改频前后切换备用时钟的动作CCF本身不管得靠clk_notifier或者驱动自己在set_rate里做。新手最容易忽略的就是这一点直接把运行中系统的PLL倍频改了板子当场死给你看。下面是一个最简化的分频器节点回调示意static unsigned long div_recalc_rate(struct clk_hw *hw, unsigned long parent_rate) { struct div_clk *d to_div_clk(hw); u32 val readl(d-base d-reg); u32 div (val d-shift) d-mask; return parent_rate / (div 1); } static long div_round_rate(struct clk_hw *hw, unsigned long rate, unsigned long *parent_rate) { struct div_clk *d to_div_clk(hw); unsigned long pr *parent_rate; u32 div; if (rate 0) return 0; div DIV_ROUND_UP(pr, rate); if (div 0) div 1; if (div (d-mask 1)) div d-mask 1; return pr / div; } static int div_set_rate(struct clk_hw *hw, unsigned long rate, unsigned long parent_rate) { struct div_clk *d to_div_clk(hw); u32 div DIV_ROUND_UP(parent_rate, rate); u32 val; if (div 0) div 1; if (div (d-mask 1)) div d-mask 1; val readl(d-base d-reg); val ~(d-mask d-shift); val | ((div - 1) d-mask) d-shift; writel(val, d-base d-reg); return 0; }提示round_rate和set_rate里对div的钳位逻辑必须一字不差建议抽成一个calc_div()静态函数两处共用。别问我是怎么知道的。3.3 时钟树注册与 provider 挂载回调写完接下来是把clk_hw注册进框架、把provider挂到设备树上。注册有两个层次单个时钟用clk_hw_register一批时钟用devm_clk_hw_register配of_clk_add_hw_provider。对于SoC时钟控制器这种一个节点管很多路时钟的场景正确姿势是先准备好一个clk_hw_onecell_data结构里面放一个clk_hw指针数组数组下标就是设备树里的cell值每个时钟单独clk_hw_register注册成功后把返回的clk_hw填进数组全部注册完最后调of_clk_add_hw_provider(np, of_clk_hw_onecell_get, data)把这批时钟一次性挂上去。这个顺序不能乱provider必须最后挂因为一旦挂上去consumer就可能立刻来取时钟如果那时候数组里还是空指针consumer会拿到NULL。初始化顺序上还有个大坑如果一个时钟的parent是另一个provider提供的时钟那么注册本provider之前必须先确认parent provider已经注册完成。内核里靠CLK_OF_DECLARE和initcall的执行顺序来保证但如果你自己的两个模块之间有依赖就必须用of_clk_add_hw_provider的parent provider提前init或者干脆把相关时钟合并到一个驱动里注册。我遇到过最隐晦的一个问题就是两个时钟驱动都用了module_platform_driver加载顺序不确定导致A先注册时找不到B提供的parentA的整棵子树全成了orphan频率直接按0处理最后是靠给B加core_initcall提前加载解决的。static int soc_clk_probe(struct platform_device *pdev) { struct soc_clk *sc; int i, ret; sc devm_kzalloc(pdev-dev, sizeof(*sc), GFP_KERNEL); sc-base devm_platform_ioremap_resource(pdev, 0); for (i 0; i CLK_NR; i) { sc-hws[i] soc_clk_hw_register(i, sc); if (IS_ERR(sc-hws[i])) return PTR_ERR(sc-hws[i]); } sc-data.hws sc-hws; sc-data.num CLK_NR; ret devm_of_clk_add_hw_provider(pdev-dev, of_clk_hw_onecell_get, sc-data); return ret; }3.4 编译进内核与上板验证代码写完之后验证要走三步别嫌麻烦省下的每一步都会在后面加倍还回来。第一步是make menuconfig里打开CONFIG_COMMON_CLK和你的驱动顺便打开CONFIG_DEBUG_FS否则后面没法看时钟树。第二步是编译完先别上板本地跑一遍dtc -I dtb -O dts反编译你的设备树确认clocks和clock-names没有拼错phandle能对上这一步能拦下大部分低级错误。第三步才是上板第一时间看/sys/kernel/debug/clk/clk_summary重点核对三样东西每个节点的rate是不是你预期的、enable_cnt有没有异常、有没有出现orphan标记。如果rate是0基本可以断定parent没找到或者recalc_rate实现有问题如果enable_cnt一直涨不降说明有consumer使能了没释放长时间跑下去时钟关不掉待机功耗会很难看。我每次bringup新板子都要盯着这张表至少跑一遍全外设开开关关的压力测试跑完enable_cnt全归零才算这支时钟驱动及格。4. 时钟树的调试与踩坑实录4.1 clk_summary 怎么读clk_summary是CCF送的免费调试器读法其实很简单但很多人只看rate不看层级。它的每一行从左到右依次是时钟名、enable_cnt、prepare_cnt、protect_cnt、rate、accuracy、phase、duty最后是parent名。层级用缩进表示同一棵子树缩进相同。读表的时候我按三个顺序看先看缩进层级对不对跟你手册里画的时钟树能不能对上对不上说明设备树拓扑或者provider映射错了再看rate从根往下核对父rate除以子rate应该等于你设计的分频比不对就查分频器最后看enable_cnt和prepare_cnt这两个计数必须有借有还涨了不降就是泄漏。特别说一下protect_cnt这个计数不为零表示该时钟被clk_prepare_protected保护了一般出现在改了PLL这种危险操作前后的临界区里如果你看到它长期不为零说明有代码进去没出来早晚会挂。4.2 常见问题速查表下面这张表是我这些年在本子上攒下来的现象和根因的对应关系做时钟驱动的时候直接对着查能省一大半时间。现象可能根因排查手段clk_summary里rate为0parent未找到或recalc_rate读错寄存器查orphan标记核对parent phandledevm_clk_get返回-ENOENTclock-names拼写错误或provider未注册dtc反编译核对名字查probe顺序驱动probe成功但外设无输出gate没使能或时钟是NULLclk_prepare_enable返回值是否被忽略改频后系统卡死运行中PLL直接改倍频检查set_rate里是否有bypass切换set后recalc对不上roundround和set算法不一致抽公共函数重算enable_cnt只涨不降clk_disable_unprepare未调用check释放路径考虑devm_clk_get_enabled频率偏低一点稳定分频比取整方向不对用DIV_ROUND_CLOSEST而非UP偶发时序错误改频时子时钟还在跑加notifier在改频前disable子时钟4.3 几个反直觉的经验第一条clk_prepare_enable的返回值一定要看。我见过太多代码直接调用不检查结果时钟根本没使能后面寄存器怎么配都没反应查半天以为是外设问题。养成习惯只要返回值非0就dev_err打出来。第二条能用一个clk_hw表示就别拆成两个。有些分频器和gate在同一个寄存器里我一开始拆成两个独立节点结果CCF在计算rate时会经过gate节点而gate的recalc_rate实现里如果不返回parent_raterate就断了。后来学乖了同一个寄存器里相关的功能尽量合成一个复合时钟用clk_hw_register_mux/clk_hw_register_divider/clk_hw_register_gate这些现成的辅助宏拼装代码短还少出错。第三条调试时钟问题时不要相信printk打出来的rate要相信clk_summary。因为驱动里缓存的rate可能和寄存器实际值不一致而clk_summary每次都是调recalc_rate现算的是相对可信的一手数据。我现在的习惯是任何一次时钟相关的改动落板第一件事就是cat clk_summary | grep xxx比在代码里加一堆打印高效得多。第四条谨慎使用clk_set_parent。这个操作会改变子树的时钟源如果子树下还挂着正在工作的外设切换瞬间的频率跳变会让外设直接出问题。正确做法是在切parent之前先disable所有子consumer切完再重新enable或者用clk_notifier注册回调让关心的驱动自己处理。ccf里没有魔法它只保证树的一致性不管你的外设能不能承受频率跳变。4.4 一个真实的上板调试过程最后记录一次印象最深的排查。现象是板子启动后UART能打印但波特率偏高约3%而且只在高温下出现。clk_summary显示UART节点rate是48.003MHz目标是48MHz。往上查parent分频器输出是960.06MHz目标是960MHz再往上是PLL输出1200.075MHz目标是1200MHz。根因就出来了PLL的倍频比在计算时用了整数除法1200 / 24这个除法本身没问题但中间一步24 * 50 / 1在固件里被算成了24 * (50 / 1)之外的某种取整顺序导致微弱偏差被逐级放大。修正后重新走一遍round和set的公共函数rate严格锁在48.000MHz高温下也不飘了。这次之后我就定了个规矩凡是时钟计算先写一个本地的C小程序把关键频点全跑一遍把边界值和中间取整都验证过再进内核比在内核里反复重启高效太多。5. 写在最后的一点个人做法做嵌入式Linux时钟驱动这些年我最大的体会是CCF本身不难难的是把硬件的时序约束和框架的抽象契约对齐。框架只关心树的拓扑和引用计数它不会替你处理PLL锁定、bypass切换、频变时的外设瞬态。所以每写一个时钟节点我都会先在手边问自己三个问题——这个节点的parent是谁、改它的时候谁会被影响、出问题的时候我怎么从clk_summary里一眼看出来。把这三个问题答清楚代码基本就不会有大方向的问题。至于具体的寄存器位域、分频公式那都是查手册的体力活反而是最不容易出错的部分。如果你也正在啃CCF建议从给板子加一个最简单的fixed-rate时钟节点开始跑通一遍clk_hw_register到clk_summary的完整链路再逐步往上叠分频器和PLL这个顺序比一上来就啃完整的SoC时钟控制器要舒服得多。
返回列表