嵌入式Linux CCF通用时钟框架与时钟驱动开发全链路解析

发布时间:2026/9/18 0:29:12
嵌入式Linux CCF通用时钟框架与时钟驱动开发全链路解析
做嵌入式Linux驱动这行最容易被低估的就是时钟部分。很多人拿到一块新板子先在设备树里把引脚、中断、寄存器基址配好probe一跑devm_ioremap_resource返回成功就以为大功告成结果readl回来的永远是0xffffffff或者0x0。折腾半天才发现外设的总线时钟根本没打开——寄存器压根没上电。这类问题在嵌入式Linux项目里出现的频率高得离谱而解决它的核心抓手就是CCF通用时钟框架Common Clock Framework。这篇文章不讲空理论我想把CCF从为什么存在到怎么写一个能进的时钟控制器驱动再到外设拿不到时钟怎么一步步定位整条链路掰开说清楚。适合已经能写字符设备驱动、跑过几次设备树修改、准备往时钟子系统下沉的嵌入式Linux时钟驱动开发者如果你只是做应用层、连clk_get都没见过那前半部分可以先当背景了解后面的排错章节留着备用。关键词先亮出来嵌入式Linux、CCF、通用时钟框架、时钟驱动开发这四个词基本就是全篇的主线。1. 时钟树在芯片里长什么样CCF 又是怎么把它抽象成代码的1.1 一颗 SoC 的时钟树本质上是一张有向图在任何一颗现代SoC里时钟不是一个孤立的方波发生器而是一张层层分叉的树。最上游通常是晶振XTAL常见24MHz或26MHz晶振进PLL倍频PLL再分出几路每一路经过mux选择源、经过divider分频、经过gate开关最后送到具体外设——UART、I2C、SDIO、GPU、DDR控制器各吃一路。这颗树里的每一个节点都可能被改动调PLL倍频、切mux父节点、改分频比、开关gate。问题就在于这棵树的每个节点都被修改时上游的变化会传导到下游所有子节点。你只想把I2C的速率从100kHz调到400kHz结果发现顺手动了一下共享的PLL分频把DDR的时钟也带偏了系统直接挂死。所以时钟管理的核心难点从来不是怎么写寄存器而是怎么在改动一个节点时知道影响范围、知道当前引用计数、知道谁在用它。早期ARM平台的做法是各自为政。三星Exynos一套、TI OMAP一套、Freescale i.MX一套每家的clkAPI都不一样改一个外设驱动换平台就得重写时钟部分。CCF做的事情就是把这些私有实现收拢成一套内核公共的抽象层让驱动只面向统一的struct clk编程平台差异藏在clk_ops回调里。这是理解CCF所有设计的一把钥匙——它不是为了性能而是为了解耦和可维护。1.2 clk、clk_hw、clk_core、clk_provider 这四个名词必须先分清初学者看CCF源码最容易晕的就是这四个概念混着出现。我用一个类比讲清楚时钟树的每个节点是一个硬件实体它有两张脸。struct clk_hw是生产者视角的脸代表硬件本身。时钟控制器驱动的作者拿它来注册节点、实现clk_ops回调。struct clk是消费者视角的脸外设驱动拿到的就是这个指针调用clk_prepare_enable、clk_set_rate。两边的接口正交互不干扰。struct clk_core是内核内部真正维护的核心对象持有父节点指针、当前频率、引用计数、prepare计数这些运行状态。它藏在内核源码里驱动作者基本不直接碰。clk_provider则是这一批时钟节点的集合的概念设备树里看到#clock-cells对应的就是某个provider。provider通过of_clk_add_hw_provider发布出去别的节点用clocks属性引用它。分清楚这四者的关系后面写驱动时你就知道设备树里填的是provider的引用信息驱动里注册的是clk_hw而外设驱动拿到的struct clk只是句柄。三者通过CCF的查找机制串起来任何一个环节名字对不上都会导致时钟拿不到。1.3 为什么 CCF 要用 have/consumer 分离的模型有人会问为什么不直接让外设驱动拿到硬件指针非要多一层struct clk句柄核心原因有两个。第一是引用计数。一路时钟可能被多个外设共享比如I2C和UART共用根时钟。如果每个驱动直接开关硬件一定会互相踩。CCF在clk_core里维护enable_count和prepare_count只有当计数归零时才真正关掉gate这就是共享时钟能安全工作的基础。第二是父节点传播。当你调clk_set_rate时CCF会沿着clk_core的父子链往上走尝试在合适层级完成调速。它不会傻乎乎地改根PLL而是从当前节点往上找最近能改频率的节点。外设驱动只需要说我要400kHz剩下的层级选择由CCF和clk_ops里的determine_rate共同完成。这种需求驱动、逐级上溯的机制正是CCF相对私有实现的最大进步。理解了这一层你在调试时的心态会完全不同——时钟不对先从树的结构和引用计数入手而不是急着翻寄存器手册。2. 设备树里描述时钟provider 和 consumer 两端必须严丝合缝地配对2.1 #clock-cells 填几取决于这个 provider 有几个参数#clock-cells是设备树里最容易配错、而且配错了症状最隐晦的字段之一。它的含义是引用这个provider的某个时钟时需要几个额外的单元格来定位。常见取值有三种。取值含义典型场景0provider只有一个时钟输出单路晶振、单路固定门控时钟1需要一个索引号选择具体哪一路最常见的多功能时钟控制器2需要索引加子索引部分带多级分组的复杂控制器配错的后果很直接。假设provider是1那么consumer就该写clocks clks CLK_ID_I2C0如果你把provider写成0consumer又按两个单元格写解析时就会把后面的数据当成别的属性读进去轻则报-EINVAL重则解析出错误的时钟索引指向一个完全无关的gate。这类错误最坑的地方在于编译期完全不报只有运行时通过日志和clk_summary才能看出来。我的建议是provider驱动的第一件事就是把时钟索引表定义成头文件或者宏枚举并且这个头文件同时被provider驱动和consumer的设备树一起引用。设备树里不写裸数字写CLK_ID_I2C0这类宏配错单元格数会立刻暴露成编译或解析错误。2.2 clocks 与 clock-names 的配对规则外设节点通常这样写i2c0: i2c12020000 { compatible vendor,i2c-v1; reg 0x12020000 0x1000; clocks clks CLK_ID_I2C0, clks CLK_ID_I2C0_PCLK; clock-names i2c, pclk; status okay; };这里的配对规则是数组下标一一对应。驱动里用devm_clk_get(dev, i2c)拿到的就是clocks数组第0项devm_clk_get(dev, pclk)拿到第1项。如果驱动里写的名字和clock-names任何一个对不上devm_clk_get就会返回-ENOENT。我踩过的坑是厂商BSP的驱动里用i2c_clk做名字而设备树里写的是i2c中间被某个人改过一处没改另一处结果驱动加载时报failed to get i2c_clk。排查起来不难但如果你手里同时有几十个外设、每个名字都不统一那就很耗时间。一个很实用的做法在驱动里可以用clk_bulk_get或者devm_clk_get_optional来放宽约束。clk_bulk_get按clock-names批量拿不关心顺序devm_clk_get_optional在拿不到时钟时返回NULL而不是错误适合那些时钟可有可无的软IP外设。选哪个取决于外设是否强依赖时钟——硬件上时钟是必须的就用devm_clk_get让错误尽早暴露。2.3 assigned-clocks 系列的生效时机比你想的更早assigned-clocks、assigned-clock-rates、assigned-clock-parents这组属性是设备树里一个非常反直觉的设计它们在consumer驱动probe之前就已经被执行了。具体机制是内核在of_clk_set_defaults阶段也就是设备注册到platform bus、调用probe之前会解析这些属性并把设置应用到时钟树上。这就是为什么很多SD卡驱动不需要在代码里显式调clk_set_rate速率却已经对了——根源在设备树。mmc0 { assigned-clocks clks CLK_ID_SDIO0; assigned-clock-rates 200000000; assigned-clock-parents clks CLK_ID_PLL_APLL; };一个真实的教训曾经调一颗SDIO WiFi芯片反复改驱动里的clk_set_rate都没用抓clk_summary发现速率仍是默认值。最后发现是设备树里有个assigned-clock-rates写死了另一个值它在probe之前就把频率锁定了驱动里的设置又被后续流程覆盖回去。只要设备树里出现了assigned系列属性你就得先去看它再去改驱动否则永远是改代码不生效。另一个细节assigned-clocks里的时钟引用必须能通过provider的#clock-cells正确解析且provider必须已经注册完成。如果provider的probe晚于consumer这些设置就会失败并打印failed to set default rate之类的日志。这个时序问题在多级时钟控制器里非常常见。3. 从零写一个时钟控制器驱动注册流程与代码骨架3.1 先照着寄存器手册把时钟树画出来再动笔拿到一颗新SoC的时钟控制器CRU/CMU模块我建议第一件事不是写代码而是在纸上把时钟树画一遍。需要的输入是寄存器手册里的PLL配置表、mux选择表、divider分频表、gate使能位。画树的时候回答这几个问题根晶振频率是多少有哪几个PLL各自倍频公式是什么从PLL出来到目标外设中间经过几级mux和dividergate位在哪个寄存器的哪一位有没有必须常开的时钟。这些信息直接决定了后面clk_ops的复杂度和provider的#clock-cells设计。以我的经验画树花的这半小时能省掉后面至少两天的调试时间。很多人上来就抄开源BSP的驱动结果发现BSP里有些PLL根本没实现、有些gate位对应的外设和自己板子不一样改起来反而更费劲。3.2 clk_hw 加 clk_ops 的最小骨架固定频率时钟是最简单的切入点但真正能体现CCF设计思想的是门控时钟加可调速时钟的组合。下面给一个可调速率时钟门控的骨架涵盖了最常见的回调#include linux/clk-provider.h #include linux/platform_device.h #include linux/of_address.h struct myclk { struct clk_hw hw; void __iomem *base; u32 reg_offset; spinlock_t lock; u32 gate_bit; u32 div_shift; u32 div_width; }; #define to_myclk(_hw) container_of(_hw, struct myclk, hw) static unsigned long myclk_recalc_rate(struct clk_hw *hw, unsigned long parent_rate) { struct myclk *clk to_myclk(hw); u32 val, div; val readl(clk-base clk-reg_offset); div (val clk-div_shift) GENMASK(clk-div_width - 1, 0); /* 硬件分频是 0 表示 1 分频 */ return parent_rate / (div 1); } static long myclk_round_rate(struct clk_hw *hw, unsigned long rate, unsigned long *parent_rate) { unsigned long prate *parent_rate; u32 div DIV_ROUND_CLOSEST(prate, rate); if (div 0) div 1; return prate / div; } static int myclk_set_rate(struct clk_hw *hw, unsigned long rate, unsigned long parent_rate) { struct myclk *clk to_myclk(hw); unsigned long flags; u32 div, val; div DIV_ROUND_CLOSEST(parent_rate, rate); if (div 0) div 1; spin_lock_irqsave(clk-lock, flags); val readl(clk-base clk-reg_offset); val ~GENMASK(clk-div_width - 1, 0) clk-div_shift; val | (div - 1) clk-div_shift; writel(val, clk-base clk-reg_offset); spin_unlock_irqrestore(clk-lock, flags); return 0; }这段代码里有几个细节值得展开。recalc_rate读的是硬件真实值所以每次调用都要读寄存器round_rate负责诚实回答我能给到什么频率它必须返回硬件能实现的值而不是用户期望的值set_rate才真正写寄存器。round_rate和set_rate分家的原因在于CCF在设置频率前会先问一遍round_rate确认目标频率是否可以达成、达成后实际是多少再决定要不要真的写。驱动如果round_rate返回一个硬件根本给不出的值后面set_rate就会写出错误的divider导致外设工作异常但日志上看起来一切正常——这也是时钟调试里非常隐蔽的一类问题。3.3 用现成的 helper 少写几百行代码CCF提供了一堆开箱即用的时钟类型能直接用的千万不要自己写。clk_register_fixed_rate注册恒定时钟clk_hw_register_mux注册muxclk_hw_register_divider注册分频器clk_hw_register_gate注册门控clk_hw_register_composite把mux、divider、gate组合成一个复合时钟。以gate为例一个纯门控时钟只需要hw devm_clk_hw_register_gate(dev, i2c0_clk, i2c0_mux, CLK_SET_RATE_PARENT | CLK_IGNORE_UNUSED, base REG_GATE, BIT(5), CLK_GATE_SET_TO_DISABLE, lock);一行就能省掉prepare、enable、disable、is_enabled四个回调的编写。而clk_hw_register_composite配合devm_clk_hw_register能把muxdividergate串成一条链代码量比你手写四个clk_ops回调少一半以上。需要注意的一点这些helper注册出来的时钟父节点的名字必须是字符串且这个名字必须能通过clk_get链找到provider。如果父节点名字打错时钟就成了orphan挂在clk_summary的orphan列表里永远拿不到正确的父频率。这个名字的匹配是纯字符串比较没有任何编译期检查。3.4 of_clk_add_hw_provider 的传参方式和常见误用provider要发布出来让设备树能引用需要调用ret devm_of_clk_add_hw_provider(dev, of_clk_hw_onecell_get, data);这里的data是struct clk_hw_onecell_data它包含一个clk_hw指针数组和num个时钟节点。关键点在于数组下标就是#clock-cells里消费者填的索引值。你在设备树里写clks CLK_ID_I2C0如果CLK_ID_I2C0是3那么>clk_prepare_enable(clk); /* 大多数场景直接这样一行即可 */ ... clk_disable_unprepare(clk);但要注意在中断上下文中不能调clk_prepare_enable只能调clk_enable因为prepare会走可能存在睡眠的路径。我见过有驱动在中断里调clk_prepare_enable跑起来是偶发卡死非常难定位。CLK_SET_RATE_PARENT标志也值得单独提一句它表示允许这个时钟的频率设置向父节点传播。如果你的外设时钟是某个分频后的子节点而分频范围又不够覆盖目标频率就必须带上这个标志否则set_rate只能做到局部最接近永远达不到目标值。这个标志加不加直接影响clk_round_rate返回的结果。5. 实测排错链路外设拿不到时钟、时钟打不开、频率不对5.1 第一站永远是 clk_summary调试时钟问题我第一条命令永远是mount -t debugfs none /sys/kernel/debug cat /sys/kernel/debug/clk/clk_summary输出的信息量极大每个时钟节点的名字、enable计数、prepare计数、保护标志、当前速率、父节点名字、精度。一个健康的时钟树应该呈现明显的层级关系。排查时按这个顺序看。第一看顶层是否有输出如果根晶振都没出现在列表里说明CONFIG_COMMON_CLK或provider本身没注册成功。第二看你的目标时钟在哪一层如果它孤零零挂在orphan区说明父节点名字对不上。第三看enable_count和prepare_count如果是0说明外设根本没有成功使能时钟。clk_summary还有个隐藏用途它能告诉你时钟的实际速率。很多人只看驱动里设置的速率不看这个值。曾经遇到一个I2C波特率不对的问题驱动里设置的是400kHz抓clk_summary发现实际是100MHz——分频比写错了导致I2C波形完全跑飞但驱动日志没有任何报错。这种问题只能靠clk_summary和示波器交叉验证。另外可以关注/sys/kernel/debug/clk/clk_dump和/sys/kernel/debug/clk/clk_orphan_summary。前者给出更底层的引用关系后者直接列出所有orphan节点——排查父节点名字对不上时orphan列表是最直接的证据。5.2 orphan clock 与 parent 名字对不上的完整排查orphan是时钟调试里出现频率最高的一类问题。症状是provider注册成功设备树也写对了但clk_get拿到的时钟频率是错的或者clk_set_rate怎么调都不生效。排查步骤我总结成这样一条链路第一步确认目标时钟是否出现在clk_orphan_summary里。如果出现了问题锁定在父节点名字。第二步把orphan时钟声明的父名字和provider里实际注册的名字逐个字符对比。常见的坑是大小写不一致、下划线中划线混用、名字里带了不该有的后缀。字符串匹配没有任何容错。第三步确认父节点在注册顺序上是否早于子节点。provider注册的顺序很关键如果子节点注册时父节点还没注册CCF会把它放进orphan列表等父节点出现后才重新挂接。如果你在子节点的clk_ops里或者init_data里写死了父节点指针而不是名字那顺序就更重要。第四步看内核日志有没有clk: failed to reparent或者orphan相关打印。这些日志通常在启动早期就打了如果没开CONFIG_DEBUG_FS或者串口日志被裁剪过可能会漏掉。我实际遇到的一个案例某颗SoC的PLL名字定义在驱动里是apll但另一个时钟的parent_names里写的是APLL。整整找了两个小时最后靠clk_orphan_summary和逐字符对比才定位。只要涉及时钟名一定要统一大小写规范最好从宏定义源头控制。5.3 频率算出来对但外设不工作问题可能不在时钟有时候clk_summary显示的速率和目标完全一致但外设就是不工作比如I2C超时、SDIO初始化失败、SPI数据错乱。这种情况要分几种可能。分频相位问题。有些外设对时钟相位敏感比如SD卡在高速模式下需要采样点和数据对齐。单纯看频率对不代表相位合适。这类问题通常需要在驱动里调采样相位寄存器或者在时钟控制器里配phase相关的clk_opsget_phase/set_phase。时钟分频后的占空比。某些硬件分频实现只能整数分频且占空比不固定比如3分频时高通部分可能只有1/3周期。外设如果对占空比有要求就需要额外的duty-cycle修复。CCF里对应的回调是get_duty_cycle/set_duty_cycle。门控还没真正打开。时钟的enable只是设置了一个标记真正写gate寄存器可能在CCF的clk_disable_unused阶段被优化掉。如果时钟在启动早期被enable过、之后无人使用clk_disable_unused会把它关掉。解决方式一般是给时钟加CLK_IGNORE_UNUSED标志。这几类问题的排查顺序是先确认enable_count大于0且gate位确实置位再看频率最后看相位和占空比。按这个顺序走避免一开始就往最难的地方钻。5.4 寄存器读不出来时钟没开的连锁反应这是我在新平台上遇到的第一个经典坑。现象是probe里readl读回来的值永远是0xffffffff或者0x0ioremap成功了但寄存器像死了一样。根因很朴素外设的总线时钟没使能寄存器接口根本不上电。很多SoC的寄存器访问需要总线时钟bus clock的存在而这个时钟默认是关闭的。驱动里如果只ioremap而不打开时钟读寄存器就会返回总线的默认值。修复方法同样是围绕CCF在probe的早期就devm_clk_get拿到总线时钟然后clk_prepare_enable再做寄存器的读写。注意顺序先拿时钟再碰寄存器不要反过来。这里也有个技巧如果驱动需要多个时钟比如bus、func、core用clk_bulk_get_all一次性拿全配合clk_bulk_prepare_enable批量使能出错时用clk_bulk_disable_unprepare清理能省掉大量手写的错误处理代码。6. 系统裁剪和长期维护阶段几个容易踩而不自知的细节6.1 CLK_IS_CRITICAL 与 CLK_IGNORE_UNUSED 的使用边界这两个标志长得很像作用完全不同但都容易被滥用。CLK_IS_CRITICAL表示这个时钟永远不能被关闭一旦注册CCF就会阻止对它调用clk_disable_unused。典型场景是时钟控制着自己的寄存器访问或者某些外设如DDR、总线的时钟一旦关闭系统直接挂死。用它的代价是省电能力受损所以只应该给真正不能关的时钟加。CLK_IGNORE_UNUSED则是忽略未使用检查让clk_disable_unused跳过它。区别在于前者是全局保护后者只是躲过一次清理。通常应该优先用CLK_IGNORE_UNUSED只在确认关不掉会死机的场景下才用CLK_IS_CRITICAL。我在一个低功耗项目里见过反例有人给一堆外设时钟都加了CLK_IS_CRITICAL导致设备无法进低功耗模式功耗比正常高出一大截。排查时必须回到clk_summary看每个节点的标志位逐一定位。6.2 引用计数的坑谁 enable 谁负责 disableCCF的引用计数机制保证了共享时钟的安全但前提是每个enable必须配对一个disable。出错的常见场景有两种。一是probe中途失败后清理不全。devm_clk_get配合devm_clk_prepare_enable这类托管接口能自动在设备销毁时清理是比较稳的写法但如果手动写clk_prepare_enable中途return -EIO时忘了配对计数就永远不归零时钟永远关不掉。二是多个驱动共享同一路时钟时各自认为对方会关。典型后果是这个时钟从来不会被真正关闭功耗一直高。定位方法同样是看clk_summary里的enable_count——如果某个外设已经卸载了它的时钟计数还是1那就说明有泄漏。一个实用习惯在每个驱动的remove或probe失败路径里都执行clk_disable_unprepare即使你确信时钟是常开的让CCF自己管理计数不要人工判断这个时钟该不该关。6.3 系统裁剪时关掉调试选项之后量产固件为了节省内核体积经常会关掉CONFIG_DEBUG_FS和CONFIG_COMMON_CLK_DEBUG。这带来的后果是调试阶段能用的clk_summary全部消失出问题时你再也看不到时钟树了。我的建议是分两套配置维护调试版本保留DEBUG_FS量产版本关掉但要把关键的时钟错误日志保留下来比如clk_provider注册失败的返回值和设备树解析错误。这需要在驱动的错误处理路径里加足够的pr_err或dev_err而不是把错误默默吞掉。另外裁剪系统时很容易漏掉CONFIG_COMMON_CLK本身。有些精简的配置里时钟框架被关掉后devm_clk_get会变成空实现驱动里所有时钟操作都变成静默成功硬件层面的问题要等到很远的地方才暴露出来。如果你的驱动用到了CCF的任何API务必确认目标配置里CONFIG_COMMON_CLKy。最后一个体会跟所有的排错经历有关时钟问题几乎没有看代码就能看出来的一定要动手抓clk_summary、量波形、对比寄存器。我在实际调试里养成的习惯是每次给新板子写驱动第一件事就是把时钟树打印出来贴在工位上每加一个外设就在上面标出它用哪一路。这张纸往往比任何文档都管用因为它把哪路时钟在动、谁在用它这种抽象关系变成了可以随时对照的具体结构。等这张纸上的树画完、clk_summary的层级也对上剩下的寄存器读写和业务逻辑才不会无谓地背锅。