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

资讯详情

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

嵌入式Linux时钟驱动开发:从CCF核心结构体到设备树实战

嵌入式Linux时钟驱动开发:从CCF核心结构体到设备树实战 1. 为什么需要CCF从点灯配置寄存器到框架化管理我早年调试一块嵌入式板卡的时候I2C总线上的触摸屏总是随机失效用示波器抓SCL引脚发现时钟频率漂得离谱——后来查了半天发现是某个驱动在初始化时直接ioremap了时钟控制器的寄存器随手改了一位分频值把I2C的根时钟给带偏了。这种各驱动各自为政、直接操作时钟寄存器的老式做法在核数少、外设少的单片机上还能凑合一旦进入嵌入式Linux这种多进程、多驱动并发的环境立刻就会变成灾难。这正是CCFCommon Clock Framework通用时钟框架存在的根本原因。它不是一个单纯帮你配置时钟的API集合而是把整个SoC的时钟资源抽象成了一棵有层级关系的树每个时钟节点知道自己从哪里来parent、能输出什么频率rate、当前有没有被使用prepare/enable引用计数并且通过统一的接口对外提供服务。驱动开发者不再需要关心某个PLL的锁定寄存器在哪、分频器的系数怎么算只需要向CCF申请一个时钟然后调用标准的clk_get_rate、clk_set_rate、clk_prepare_enable等API即可。本文就围绕CCF框架下时钟驱动的完整开发流程展开从核心数据结构到设备树绑定从驱动代码编写到调试实测把整个链路掰开揉碎。适合正在做嵌入式Linux驱动开发、尤其是第一次接触CCF的工程师参考。如果你以前只用过旧的clk_register接口那这篇文章也能帮你梳理清楚新旧接口的对应关系。1.1 没有CCF时驱动开发者在做什么在老的内核版本或者一些轻量级RTOS里驱动要使用一个时钟流程通常是这样的在SoC手册里查到目标外设比如UART的时钟源是PLL2的某个分频输出。手动计算PLL2的倍频系数、分频系数算出目标频率对应的寄存器值。直接ioremap时钟控制器的基地址往寄存器里写值。如果驱动A和驱动B同时用到了同一个PLL要么靠约定俗成的顺序初始化要么就得自己维护一个谁在用的计数。这种做法有几个致命问题第一寄存器操作散落在各个驱动里代码冗余还容易写错第二无法感知时钟的父子依赖关系改了一个PLL的频率下游所有分频输出的外设全部遭殃第三没有引用计数驱动卸载时时钟状态无法自动恢复。CCF把这些问题全部收归框架管理驱动开发者只跟struct clk指针打交道底层的寄存器操作由时钟驱动实现者负责。1.2 CCF的三大角色Provider、Consumer、Framework理解CCF之前先记住三个角色Provider时钟提供者通常是某个时钟控制器驱动负责注册一个或多个时钟节点到CCF实现各种底层操作回调比如计算频率、设置频率、开关门控等。Consumer时钟使用者任何想要使用时钟的外设驱动通过设备树或板级代码获取struct clk指针调用通用API。Framework框架核心内核的drivers/clk/clk.c维护时钟树、引用计数、rate传播通知、debugfs统计等。这三者之间Provider和Consumer完全通过Framework间接交互互不知道对方的存在。这种解耦恰恰是CCF能在一个复杂SoC上稳定运行的根基。2. 动手写驱动前必须吃透的三个核心结构体2.1 struct clk_hw框架视角下的硬件句柄很多刚接触CCF的人会被struct clk和struct clk_hw搞晕。简单来说struct clk是Consumer手里的句柄由框架分配驱动代码里根本不需要自己创建。struct clk_hw是Provider驱动的核心它是对一个具体时钟硬件的抽象里面包含框架需要的一切信息。struct clk_hw { struct clk_core *core; struct clk_init_data *init; struct clk_ops *ops; };其中clk_core是框架内部真正管理时钟树的节点包含parent指针、引用计数、rate等信息驱动开发中一般不直接碰它。clk_init_data是注册时传给框架的初始化数据clk_ops则是这个时钟硬件支持的操作函数集合。写一个时钟驱动本质就是填充clk_hw实现clk_ops然后把clk_hw注册进CCF的Provider列表里。至于这个clk_hw背后是PLL、MUX、divider还是gate框架并不关心它只认你实现的clk_ops。2.2 struct clk_ops一个时钟的行为契约clk_ops里的每个回调都对应一个标准API比如Consumer调clk_prepare_enable()时框架会依次调用注册的prepare和enable回调。核心回调如下回调对应API语义注意事项prepareclk_prepare可能睡眠的准备工作比如PLL锁定进程上下文可以mutexunprepareclk_unprepareprepare的反操作进程上下文enableclk_enable原子操作比如门控写位中断/自旋锁上下文不能睡眠disableclk_disableenable的反操作原子上下文recalc_rateclk_get_rate根据当前寄存器状态计算实际频率必须真实反映硬件不能拍脑袋round_rateclk_round_rate查询目标频率最接近的可实现值返回硬件能实际输出的频率set_rateclk_set_rate配置寄存器让输出频率等于目标值通常先调round_rateset_parentclk_set_parent切换时钟源针对MUX型时钟get_parentclk_get_parent查询当前父时钟返回父clock的indexis_enabledclk_is_enabled查询时钟当前是否开启有时不必实现读硬件状态更可靠这里面最有技术含量的是round_rateset_raterecalc_rate这组配套操作它们共同保证了一个时钟的频率配置链路是自洽的。round_rate是承诺set_rate是执行recalc_rate是反馈三者必须一致否则Consumer读取的频率会和实际不匹配。2.3 struct clk_init_data注册时的一次性简历struct clk_init_data { const char *name; const struct clk_ops *ops; const char * const *parent_names; u8 num_parents; unsigned long flags; };这里最关键的是parent_names和flags。parent_names是父母时钟的名字列表框架会根据名字在全局时钟树里查找并建立父子关系。对于单时钟源的divider/gate只需要一个名字对于MUX型多路选择器需要把所有可能的父时钟名都列出来。flags里的常用标志有CLK_SET_RATE_PARENT当前时钟设置频率时如果做不到允许向上游传播频率请求让父时钟调整频率。典型场景是I2C的根时钟来自PLL分频你只想设置I2C的频率但实际要调整的是PLL。CLK_IGNORE_UNUSED内核在启动后期会关闭所有没有被使用的时钟如果不希望某个时钟被自动关闭比如看门狗时钟就加上这个标志。CLK_GET_RATE_NOCACHE每次读频率都重新调用recalc_rate不从缓存读适合频率会被硬件自动改变的时钟。3. 时钟驱动开发全流程从设备树节点到Rate调整实现下面我以一块虚拟SoC上的外设时钟为例走一遍完整的开发流程。这个例子极具普遍性SoC内部有一个PLL作为主时钟源经过一个DIV分频器后输出给UART外设使用。我们要为这个DIV分频器实现一个CCF时钟驱动。3.1 设备树侧如何声明一个时钟输出首先在设备树里声明时钟控制器节点clock-controller10001000 { compatible virtual,div-clock; reg 0x10001000 0x1000; clocks pll0; #clock-cells 0; };注意#clock-cells 0表示这个控制器只有一个输出时钟。如果#clock-cells 1则意味着输出多个时钟Consumer引用时需要传递一个索引号例如clocks foo 2。然后在UART节点里引用这个时钟uart0: serial10000000 { compatible virtual,uart; reg 0x10000000 0x1000; clocks div_clock; clock-names uart_clk; };设备树里的clocks属性是Consumer和Provider绑定的桥梁驱动代码通过devm_clk_get()加时钟名来获取对应的struct clk指针。3.2 驱动侧probe函数里的注册顺序一个最简单的divider时钟驱动框架如下#include linux/clk-provider.h #include linux/platform_device.h #include linux/module.h #include linux/of.h #include linux/clk.h #define DIV_REG_OFFSET 0x00 struct div_clock_priv { void __iomem *base; struct clk_hw hw; }; static unsigned long div_clock_recalc_rate(struct clk_hw *hw, unsigned long parent_rate) { struct div_clock_priv *priv container_of(hw, struct div_clock_priv, hw); u32 val readl(priv-base DIV_REG_OFFSET); int div (val 0xFF) 1; return parent_rate / div; } static long div_clock_round_rate(struct clk_hw *hw, unsigned long rate, unsigned long *parent_rate) { int div DIV_ROUND_CLOSEST(*parent_rate, rate); return *parent_rate / div; } static int div_clock_set_rate(struct clk_hw *hw, unsigned long rate, unsigned long parent_rate) { struct div_clock_priv *priv container_of(hw, struct div_clock_priv, hw); int div DIV_ROUND_CLOSEST(parent_rate, rate); u32 val; if (div 1 || div 256) return -EINVAL; val readl(priv-base DIV_REG_OFFSET); val ~0xFF; val | (div - 1); writel(val, priv-base DIV_REG_OFFSET); return 0; } static const struct clk_ops div_clock_ops { .recalc_rate div_clock_recalc_rate, .round_rate div_clock_round_rate, .set_rate div_clock_set_rate, }; static int div_clock_probe(struct platform_device *pdev) { struct device *dev pdev-dev; struct div_clock_priv *priv; struct clk_init_data init {0}; struct clk_parent_data parent_data {0}; int ret; priv devm_kzalloc(dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv-base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(priv-base)) return PTR_ERR(priv-base); init.name dev_name(dev); init.ops div_clock_ops; parent_data.fw_name parent; init.parent_data parent_data; init.num_parents 1; priv-hw.init init; ret devm_clk_hw_register(dev, priv-hw); if (ret) return ret; ret devm_of_clk_add_hw_provider(dev, of_clk_hw_simple_get, priv-hw); if (ret) return ret; return 0; } static const struct of_device_id div_clock_match[] { { .compatible virtual,div-clock }, { } }; MODULE_DEVICE_TABLE(of, div_clock_match); static struct platform_driver div_clock_driver { .probe div_clock_probe, .driver { .name div-clock, .of_match_table div_clock_match, }, }; module_platform_driver(div_clock_driver);这里需要注意几个细节父时钟的获取用了clk_parent_data搭配fw_name这是新版本内核推荐的方式本质上是在设备树里用clocks pll0声明父时钟驱动里通过fw_name去匹配。老代码里直接在init.parent_names里写死父时钟名字那种方式不灵活一旦时钟树改名就会挂。devm_clk_hw_register()和devm_of_clk_add_hw_provider()都带devm前缀驱动卸载时框架会自动清理不需要手动处理。如果这个时钟在系统早期就要使用比如早期的串口打印那就不能用platform_driver的方式必须改用CLK_OF_DECLARE在setup_arch阶段就完成注册。判断依据很简单属于基础时钟树上的节点尽量提前普通外设时钟platform_driver足够。3.3 Consumer视角外设驱动如何调用时钟驱动注册完之后外设驱动这边就是标准的CCF API使用流程struct clk *clk; int ret; clk devm_clk_get(dev, uart_clk); if (IS_ERR(clk)) { dev_err(dev, failed to get uart clock: %ld\n, PTR_ERR(clk)); return PTR_ERR(clk); } ret clk_prepare_enable(clk); if (ret) return ret; unsigned long rate clk_get_rate(clk); dev_info(dev, UART clock rate: %lu Hz\n, rate); /* 如果需要调整频率 */ ret clk_set_rate(clk, 48000000); if (ret) dev_err(dev, failed to set clock rate: %d\n, ret);clk_prepare_enable()是clk_prepare()和clk_enable()的合并快捷调用最常见。这里有个小坑clk_prepare可以睡眠clk_enable不能所以如果你在原子上下文比如中断处理函数里必须分成两步提前在进程上下文prepare然后在原子上下文只调clk_enable——很多驱动在这里踩过BUG睡眠的雷。4. 时钟不听话怎么办故障排查链路与实测心得代码写完只是第一步真正折磨人的是调试阶段。我把自己反复踩过的坑和排查思路整理一下。4.1 从clk_summary定位异常节点CCF框架自带debugfs节点这是排查时钟问题最重要的入口# 挂载debugfs如果尚未挂载 mount -t debugfs none /sys/kernel/debug # 查看整棵时钟树的状态 cat /sys/kernel/debug/clk/clk_summary输出大致长这样clock enable_cnt prepare_cnt rate accuracy phase -------------------------------------------------------------------------------------------- pll0 1 1 120000000 0 0 div_clock 1 1 48000000 0 0 uart0_clk 1 1 48000000 0 0这个表的信息量极大enable_cnt和prepare_cnt都能看到每个时钟被引用的次数rate列能看到实际计算出的频率。如果某个时钟的rate是0说明recalc_rate没实现或者父时钟rate本身就为0如果enable_cnt不符合预期说明有驱动忘了enable或者unprepare。4.2 set_rate不生效的典型原因与调用链走读clk_set_rate()是使用频率最高的API之一它的调用链大致如下Consumer调用clk_set_rate(clk, target)。框架获取时钟树全局锁调用clk_calc_new_rates()沿着时钟树从下往上找能够实现目标频率的节点。框架调用clk_core_round_rate_nolock()也就是你的round_rate回调判断当前节点能否实现目标频率。如果当前节点做不到且设置了CLK_SET_RATE_PARENT框架会继续修改父时钟的频率请求然后重新计算。最终调用clk_change_rate()依次调用set_rate回调然后调用recalc_rate更新实际的rate缓存。所以如果你的set_rate调用了但实际输出不变复盘时可以按这个顺序排查round_rate是不是返回了错误的值框架会用round_rate的结果作为最终频率如果你这里返回的值和set_rate里算出来的分频比不一致就会导致设置完频率不对。有没有加CLK_SET_RATE_PARENT如果当前分频器能实现目标的整数比值但硬件分频范围太窄这时需要父时钟配合调整不加这个标志set_rate直接返回一个误差范围内的近似值甚至直接失败。寄存器写入位是否正确我遇到过把chip select偏移当成分频值的写倒是写了但写到了相邻的位域上。这时候读一下寄存器对照数据手册就能发现。4.3 自测方法trace event配合示波器双验证软件层面看树、看寄存器是一方面最终验证还是得回到物理世界。我的习惯是双管齐下内核里打开时钟事件的tracetrace-cmd record -e clk:clk_enable -e clk:clk_disable -e clk:clk_set_rate然后跑应用触发时钟变更再用trace-cmd report查看能精确看到驱动在什么时刻请求了哪个时钟、频率变成了多少排查逻辑问题非常好使。硬件层面如果调的是UART、I2C这类外设时钟直接测对应引脚的波形数一下频率对不对。这里有一个经验很多SoC的UART波特率和时钟频率之间不是直接相等的中间还有分频逻辑你测到的引脚波形频率可能是时钟频率的1/16别一看到数字对不上就以为驱动有问题。4.4 一个必须记住的原子上下文教训最后讲一个我调查功耗问题时遇到的案例。内核启动过程中会调用clk_disable_unused()关闭所有没有使用者且没有CLK_IGNORE_UNUSED标志的时钟。如果某个时钟驱动没实现is_enabled回调框架会按默认逻辑处理假设时钟是开启的进而调用disable。但如果这个时钟硬件其实已经被某个早期启动代码关了第二次关闭时寄存器写入的就是一个非法状态。这类问题不会稳定复现非常隐蔽排查手段就是加打印在disable回调里读寄存器确认实际状态并且尽量实现is_enabled让框架准确感知硬件状态。5. 为什么强调不要绕过CCF代价与长期收益项目开发中经常会遇到这样的灵魂拷问CCF这么复杂我直接在驱动里操作寄存器还更快为什么非要绕一圈我给的建议是除非你开发的是一个永远不更新、永远不换内核、外设永远不会动态开关频率的板子否则请老老实实走框架。原因有几点第一CCF帮你管好了并发。两个驱动同时调用clk_enable引用同一个时钟框架内部有锁和引用计数不会出现一个驱动关掉另一个驱动正需要的时钟这种事故。自己操作寄存器根本做不到。第二挂起/恢复的电源管理只有框架能做到。系统休眠时框架会根据PM回调自动关停所有使用者为0的时钟恢复时按顺序重新打开。这个过程如果靠各个驱动各自去弄必然会有遗漏而且难以调试。第三调试信息是免费的。上面说的clk_summary、trace event全部由框架提供绕过框架就意味着一出事就得自己打dev_dbg逐行查寄存器效率完全不同。这套框架在i.MX、Rockchip、Allwinner等常见SoC上早已全面落地新板子的BSP基本都是基于CCF来实现时钟树。学会CCF不只意味着你能写时钟驱动更重要的是能看懂厂商SDK里复杂的时钟树实现——很多外设驱动的怪异行为顺着时钟树往上一查答案往往就在某个divider的配置逻辑里。最后分享一个我个人的学习心得不要一上来就去读drivers/clk/clk.c这个核心文件里那些复杂的rate传播算法那是框架维护者才需要深入关心的东西。做驱动开发先从clk_ops的实现入手对照着数据手册把自己手里这颗芯片的PLL、divider、gate分别实现出来跑通一个外设再回头读框架代码会有完全不同的体会。CCF是一套边界清晰的设计你只要把自己这一层的职责做好上面的框架和下面的硬件都由它们自己去衔接。
返回列表