
1. 从一次驱动调试说起为什么通用时钟框架值得花时间啃很多做嵌入式Linux驱动的朋友第一次接触clk相关代码大概率是在改某个外设驱动的时候。比如调一个I2S音频接口发现采样率死活对不上最后定位到是某个时钟分频系数没配对又或者调一个SPI屏刷图总是花屏查了半天发现是SPI时钟频率超出了屏幕手册的上限。这类问题的共同点是问题不在你写的业务逻辑里而在时钟树上。Linux的通用时钟框架Common Clock Framework简称CCF就是干这件事的它把SoC内部错综复杂的时钟树抽象成一套统一的模型让驱动开发者不用去直接操作寄存器而是通过一组标准API来申请、配置、开关时钟。这套框架从2012年前后进入主线内核到现在已经是所有主流SoC瑞芯微RK3568、全志、NXP i.MX、TI等的标准配置。你打开任何一个现代SoC的设备树几乎都能看到clocks、clock-names、assigned-clocks这些属性。但问题在于CCF的文档分散在内核源码的Documentation/driver-api/clk.rst、include/linux/clk.h以及各个provider驱动的实现里初学者很容易陷入知道有这些API但不知道什么时候该用哪个的困境。更麻烦的是clk_prepare_enable和clk_enable到底有什么区别clk_get和devm_clk_get该选哪个clk_set_rate调用之后为什么实际频率没变这些问题在官方文档里往往只有一句话真正的答案藏在实现细节和使用场景里。这篇内容就是围绕时钟使用者clock consumerAPI展开的。所谓使用者就是那些需要用到时钟的外设驱动比如UART、I2C、SPI、LCD控制器、音频编解码器等等。我会把常用的API按使用场景分类讲清楚配上实际调试中踩过的坑以及设备树里怎么描述时钟关系。目标读者是已经能写基本字符设备驱动、但对时钟框架还比较模糊的嵌入式工程师。看完之后你应该能独立完成一个外设驱动的时钟配置并且在时钟出问题时知道从哪里下手排查。2. 时钟使用者API的全景地图先搞清楚你手里有哪些牌2.1 获取时钟clk_get、devm_clk_get与of_clk_get的区别在驱动里操作时钟的第一步是拿到一个struct clk *句柄。这个句柄代表时钟树上某个具体的时钟节点。获取句柄的API有好几个用哪个取决于你的驱动模型和上下文。最传统的是clk_getstruct clk *clk_get(struct device *dev, const char *id);它通过设备指针和时钟名称con_id来查找时钟。在设备树时代这个id对应的是设备树节点里clock-names属性中的字符串。比如uart2: serialff1a0000 { clocks cru SCLK_UART2, cru PCLK_UART2; clock-names baudclk, apb_pclk; };驱动里就可以用clk_get(pdev-dev, baudclk)来获取第一个时钟。如果id传NULL则会获取clocks属性里的第一个时钟。但clk_get有个麻烦它不会自动释放。你必须在驱动卸载或者出错路径里手动调用clk_put否则就会泄漏。对于现代驱动更推荐用devm_clk_getstruct clk *devm_clk_get(struct device *dev, const char *id);devm_前缀意味着设备资源管理Device Resource Management内核会在设备销毁时自动帮你clk_put。这大大简化了错误处理路径。我个人的经验是只要你的驱动是平台驱动platform driver或者基于设备模型的驱动一律用devm_clk_get。只有在极少数非设备模型场景比如早期初始化代码才用clk_get。还有一个of_clk_get它直接通过设备树节点和索引获取struct clk *of_clk_get(struct device_node *np, int index);这个API在provider驱动或者一些特殊场景下用得多普通consumer驱动很少直接用。它的缺点是绕过了clock-names的语义直接用索引代码可读性差。除非你在写时钟控制器驱动本身否则不建议用。注意devm_clk_get在失败时返回的是ERR_PTR而不是NULL。所以判断必须用IS_ERR()不能用if (!clk)。这个坑我见过太多次了新手很容易写成if (clk NULL)结果错误指针被当成有效句柄传给后续API直接oops。2.2 准备与使能clk_prepare_enable和clk_enable的层次关系拿到时钟句柄之后下一步是让它开始工作。这里有两个层次的APIclk_prepare/clk_unprepare和clk_enable/clk_disable。很多初学者会困惑为什么要有两套直接一个clk_enable不就完了这个设计跟时钟控制器的硬件特性有关。有些时钟的使能操作可能涉及到可能睡眠的操作比如通过I2C去配置一个外部时钟芯片或者等待PLL锁定。这类操作不能在原子上下文比如中断处理程序里执行。而另一些时钟的开关只是写一个寄存器位可以快速完成能在原子上下文里执行。所以CCF把时钟控制分成了两个阶段clk_prepare执行可能睡眠的操作必须在进程上下文调用不能在中端里调用。clk_enable执行不能睡眠的快速操作可以在原子上下文调用。对应的关闭时先clk_disable再clk_unprepare。顺序不能反。对于绝大多数consumer驱动你不需要分别调用这两个直接用组合APIint clk_prepare_enable(struct clk *clk); void clk_disable_unprepare(struct clk *clk);这个组合在进程上下文里完成准备和使能是最常用的形式。只有在中断处理程序里需要快速开关时钟时才会单独用clk_enable/clk_disable而且前提是这个时钟已经被clk_prepare过了。这里有个重要的引用计数机制clk_prepare和clk_enable都是可重入的每次调用会增加引用计数每次unprepare/disable会减少。只有计数降到0时硬件才真正关闭。这意味着你可以在多个地方安全地调用clk_prepare_enable不用担心重复使能的问题。但反过来每次clk_prepare_enable必须对应一次clk_disable_unprepare否则计数永远不归零时钟就关不掉了。2.3 频率配置clk_set_rate的生效条件与传播机制设置时钟频率用clk_set_rateint clk_set_rate(struct clk *clk, unsigned long rate);这个API看起来简单但实际行为比想象中复杂。首先你请求的频率不一定能被精确满足。时钟树上的分频器、倍频器有硬件限制最终设置的频率是最接近且不超过请求值的那个可用频率。你可以用clk_round_rate先查询long clk_round_rate(struct clk *clk, unsigned long rate);它返回实际能设置的频率。我通常会在clk_set_rate之前先调用clk_round_rate把结果打印出来确认硬件是否支持我想要的频率。这在调音频采样率比如44.1kHz和48kHz系列时特别有用因为音频时钟对精度要求高差一点就会导致音调不对。其次clk_set_rate的生效还取决于时钟是否已经使能。有些时钟控制器在时钟关闭时设置频率会失败或者设置后不会立即生效。稳妥的做法是先clk_prepare_enable再clk_set_rate。虽然后设置频率在大多数平台上也能工作但先使能再设置是更安全的顺序。还有一个关键点clk_set_rate会沿着时钟树向上传播。比如你设置一个叶子时钟的频率框架会尝试调整它的父时钟来满足要求。如果父时钟不能改就会尝试调整分频器。这个传播过程由provider驱动的determine_rate或round_rate回调实现。如果最终无法满足clk_set_rate可能返回成功但实际频率没变或者返回错误。所以设置之后一定要用clk_get_rate读回来确认unsigned long actual clk_get_rate(clk); pr_info(requested %lu, got %lu\n, rate, actual);这个习惯能帮你省下大量调试时间。2.4 设备树中的时钟描述clocks、clock-names与assigned-clocks设备树是CCF的配置入口。一个典型的consumer节点长这样i2s1: i2sff320000 { compatible rockchip,rk3568-i2s, rockchip,rk3066-i2s; reg 0x0 0xff320000 0x0 0x1000; clocks cru MCLK_I2S1_8CH, cru HCLK_I2S1_8CH; clock-names i2s_clk, i2s_hclk; assigned-clocks cru CLK_I2S1_8CH_TX_SRC; assigned-clock-rates 1188000000; assigned-clock-parents cru PLL_GPLL; };这里有几个关键属性clocks列出该设备用到的所有时钟句柄顺序很重要。clock-names给每个时钟起名字驱动里用这个名字来devm_clk_get。assigned-clocks指定需要在设备初始化时自动配置的时钟。assigned-clock-rates配合assigned-clocks指定目标频率。assigned-clock-parents指定时钟的父时钟。assigned-*系列属性的好处是内核会在设备probe之前自动帮你完成这些配置驱动代码里就不用再写一遍。这在时钟树初始化顺序敏感的场景下特别有用。比如I2S的MCLK必须从GPLL分频得到如果驱动自己去设可能因为父时钟还没准备好而失败。用assigned-clock-parents让框架在合适的时机处理更可靠。但要注意assigned-clock-rates设置的是时钟的初始频率如果驱动后续用clk_set_rate改了以驱动为准。另外assigned-clocks里的时钟不一定要出现在clocks属性里它可以是中间节点。这个灵活性有时候会让人困惑我的建议是只把驱动真正需要操作的时钟放进clocks中间节点的配置用assigned-*处理。3. 从设备树到寄存器一个UART驱动时钟配置的完整链路3.1 硬件视角UART时钟树的典型结构要理解API的行为得先知道硬件上时钟是怎么走的。以瑞芯微RK3568的UART2为例它的时钟树大致是这样的GPLL (1188MHz) └── CLK_UART2_SRC (mux: GPLL / CPLL / 24M) └── CLK_UART2_DIV (分频器) └── SCLK_UART2 (波特率时钟) └── UART2控制器 PCLK_UART2 (APB总线时钟来自GPLL分频) └── UART2寄存器接口UART需要两个时钟一个是波特率时钟SCLK_UART2决定通信速率一个是APB总线时钟PCLK_UART2用于寄存器访问。这两个时钟在设备树里分别对应baudclk和apb_pclk。波特率时钟的计算公式是baud_rate SCLK_UART2 / (16 * divisor)其中divisor是UART内部的分频系数。所以如果你要115200的波特率SCLK_UART2最好是115200 * 16 1843200Hz的整数倍。实际中通常设SCLK_UART2为24MHz或48MHz然后通过内部divisor分频。3.2 驱动代码从probe到数据传输的时钟操作序列一个典型的UART驱动probe函数里时钟相关的代码大概是这样static int my_uart_probe(struct platform_device *pdev) { struct my_uart *uart; int ret; uart devm_kzalloc(pdev-dev, sizeof(*uart), GFP_KERNEL); if (!uart) return -ENOMEM; uart-baudclk devm_clk_get(pdev-dev, baudclk); if (IS_ERR(uart-baudclk)) { dev_err(pdev-dev, failed to get baudclk\n); return PTR_ERR(uart-baudclk); } uart-pclk devm_clk_get(pdev-dev, apb_pclk); if (IS_ERR(uart-pclk)) { dev_err(pdev-dev, failed to get apb_pclk\n); return PTR_ERR(uart-pclk); } ret clk_prepare_enable(uart-pclk); if (ret) { dev_err(pdev-dev, failed to enable pclk\n); return ret; } ret clk_prepare_enable(uart-baudclk); if (ret) { dev_err(pdev-dev, failed to enable baudclk\n); clk_disable_unprepare(uart-pclk); return ret; } /* 设置波特率时钟频率 */ ret clk_set_rate(uart-baudclk, 24000000); if (ret) { dev_err(pdev-dev, failed to set baudclk rate\n); goto err_disable; } dev_info(pdev-dev, baudclk actual rate: %lu\n, clk_get_rate(uart-baudclk)); /* 后续初始化硬件寄存器... */ return 0; err_disable: clk_disable_unprepare(uart-baudclk); clk_disable_unprepare(uart-pclk); return ret; }这段代码有几个值得注意的地方第一先使能pclk再使能baudclk。因为寄存器访问依赖pclk如果先使能baudclk在设置波特率时钟的过程中可能需要访问寄存器而pclk还没开就会出问题。这个顺序在大多数SoC上都是必须的。第二错误处理路径要逆序关闭。如果baudclk使能失败要关掉已经使能的pclk。如果clk_set_rate失败要关掉两个时钟。这种逆序清理是驱动开发的基本功但时钟这块特别容易漏因为时钟句柄是devm_管理的很多人以为不用管但devm_clk_get只管理句柄释放不管理使能状态。使能了就必须手动关闭。第三设置频率后读回确认。clk_set_rate返回0不代表频率一定设成了你想要的。有些时钟控制器会静默地选择最接近的频率。打印clk_get_rate的结果是最简单的验证手段。3.3 运行时PM时钟在suspend/resume中的处理UART驱动通常支持运行时电源管理Runtime PM。在suspend时关闭时钟resume时重新使能。这部分代码如果写错会导致系统休眠后串口无输出或者更严重的时钟引用计数不平衡导致系统无法进入低功耗状态。典型的实现static int my_uart_suspend(struct device *dev) { struct my_uart *uart dev_get_drvdata(dev); clk_disable_unprepare(uart-baudclk); clk_disable_unprepare(uart-pclk); return 0; } static int my_uart_resume(struct device *dev) { struct my_uart *uart dev_get_drvdata(dev); int ret; ret clk_prepare_enable(uart-pclk); if (ret) return ret; ret clk_prepare_enable(uart-baudclk); if (ret) { clk_disable_unprepare(uart-pclk); return ret; } return 0; }这里的关键是suspend和resume的时钟操作必须严格配对。如果suspend里disable了两次resume里只enable一次引用计数就会变成负数内核会报warning。反过来如果suspend里少disable一次时钟就永远关不掉功耗下不去。我调试过一个案例某驱动在suspend里调用了clk_disable_unprepare但在resume里只调用了clk_prepare_enable看起来配对。但问题是这个驱动在probe里已经clk_prepare_enable过一次suspend时disable了一次resume时又enable了一次计数是平衡的。但如果在suspend和resume之间有其他代码路径也操作了同一个时钟计数就会乱。所以最好的做法是每个驱动只管理自己的时钟引用不要假设其他驱动不会碰同一个时钟。CCF的引用计数是全局的多个驱动共享一个时钟时任何一个驱动的操作都会影响整体状态。4. 那些文档不会告诉你的时钟调试经验4.1 clk_summary一眼看穿时钟树的状态内核提供了一个debugfs接口/sys/kernel/debug/clk/clk_summary这是调试时钟问题最有力的工具。它列出了系统中所有时钟的当前状态clock enable_cnt prepare_cnt rate accuracy phase -------------------------------------------------------------------------------------- clk_24m 5 5 24000000 0 0 clk_32k 1 1 32768 0 0 gpll 3 3 1188000000 0 0 clk_uart2_src 1 1 24000000 0 0 clk_uart2_div 1 1 24000000 0 0 sclk_uart2 1 1 24000000 0 0几个关键列enable_cntclk_enable的引用计数。如果某个时钟你明明disable了这里还是非零说明有其他地方还在用。prepare_cntclk_prepare的引用计数。rate当前实际频率。我排查时钟问题的第一步永远是cat /sys/kernel/debug/clk/clk_summary。比如有一次调SPI屏发现SPI时钟频率是50MHz但屏幕手册要求最大30MHz。在clk_summary里找到SPI时钟节点确认它的父时钟和分频器然后算出正确的分频值用clk_set_rate设置。整个过程不到五分钟。注意clk_summary需要内核开启CONFIG_DEBUG_FS和CONFIG_COMMON_CLK_DEBUG或者CONFIG_COMMON_CLK自带的debugfs支持。在量产固件里通常会关掉但调试阶段一定要开。4.2 时钟设置失败的五种常见原因clk_set_rate返回错误或者频率不对通常逃不出这几种情况现象可能原因排查方法返回-EINVAL请求的频率超出范围用clk_round_rate查可用范围返回0但频率没变时钟被其他驱动占用或父时钟不可调查clk_summary的enable_cnt和父时钟频率是请求值的一半分频器计算方式理解错误查provider驱动的round_rate实现设置后系统卡死在原子上下文调用了可能睡眠的API确认调用路径不在中断里频率抖动父时钟被其他设备动态调整用clk_set_parent固定父时钟其中频率是请求值的一半这个坑特别隐蔽。有些SoC的时钟分频器是分频值1的语义比如你写2表示3分频。如果provider驱动没有正确处理就会出现频率偏差。这种情况只能去看provider的代码或者用示波器量实际输出。4.3 共享时钟的引用计数陷阱多个设备共享同一个时钟时引用计数是最容易出问题的地方。假设I2S和SPDIF共享一个MCLKI2S驱动在probe里clk_prepare_enableSPDIF驱动也在probe里clk_prepare_enable。此时计数是2。如果I2S驱动在suspend里clk_disable_unprepare计数变成1时钟仍然开着SPDIF还能工作。这没问题。但如果I2S驱动在remove时忘记clk_disable_unprepare计数就永远停在2时钟永远关不掉。更糟的是如果I2S驱动在suspend里disable了两次比如错误处理路径重复调用计数变成0时钟被关掉SPDIF就挂了。我的经验是每个驱动只在自己的probe/remove和suspend/resume里成对操作时钟不要在中断或其他异步路径里操作共享时钟。如果确实需要在运行时动态开关用clk_prepare_enable/clk_disable_unprepare的引用计数来保证安全但一定要确保每次enable都有对应的disable。4.4 用clk_get_rate验证时钟的实际状态clk_get_rate返回的是CCF缓存的实际频率不是硬件寄存器的原始值。这个区别很重要如果provider驱动没有正确实现recalc_rate回调clk_get_rate可能返回一个过时的值。所以在设置频率之后除了clk_get_rate最好再用示波器或者频率计确认一下实际输出。特别是在调音频、视频这类对时钟精度敏感的模块时软件读数和硬件实测可能会有偏差。我调过一个I2S案例clk_get_rate返回24576000看起来是对的48kHz * 512但音频还是有杂音。后来用示波器量MCLK引脚发现实际频率是24.576MHz没错但占空比不是50%导致codec采样时序偏移。这个问题在软件层面完全看不出来只能靠硬件测量。所以时钟调试要软硬结合不要只信软件读数。5. 从consumer到provider理解时钟框架的另一半5.1 为什么consumer也需要懂provider你可能觉得我只是写个外设驱动时钟控制器驱动是SoC厂商的事跟我没关系。但实际工作中你至少会遇到两种情况需要理解provider侧的逻辑第一种是调试时钟问题时。当clk_set_rate不生效你需要知道provider的round_rate和set_rate是怎么实现的才能判断是硬件限制还是驱动bug。比如RK3568的clk_uart2_div是一个特殊的divider它的分频比不是线性的而是有一组离散值。如果你不知道这个就会奇怪为什么请求3分频得到了4分频。第二种是SoC厂商的时钟驱动有bug时。国产SoC的时钟驱动质量参差不齐有些provider驱动没有正确实现determine_rate导致clk_set_rate总是失败。这时候你需要能看懂provider代码甚至打补丁。我遇到过某SoC的I2S时钟provider把round_rate写成了直接返回请求值但set_rate又设不了那么高结果就是clk_set_rate返回成功但频率不对。这种问题只能改provider驱动。5.2 设备树中的时钟provider节点长什么样一个典型的时钟控制器节点cru: clock-controllerfdd20000 { compatible rockchip,rk3568-cru; reg 0x0 0xfdd20000 0x0 0x1000; #clock-cells 1; #reset-cells 1; clocks xin24m; clock-names xin24m; };#clock-cells 1表示这个provider用1个cell来标识具体的时钟所以consumer引用时写成cru SCLK_UART2。如果是#clock-cells 0说明provider只有一个时钟引用时写clk_single就行。clocks和clock-names属性说明这个provider自己也需要输入时钟通常是晶振。这就是时钟树的根节点。5.3 时钟注册的两种方式CLK_OF_DECLARE与platform driver时钟provider的注册有两种方式CLK_OF_DECLARE在设备树扫描的早期阶段注册适用于系统启动早期就需要使用的时钟比如定时器时钟。platform driver在设备模型初始化后注册适用于大多数外设时钟。CLK_OF_DECLARE的时钟在of_clk_init阶段就被注册这时候内存管理还没完全初始化所以不能用devm_系列API。这也是为什么有些早期时钟驱动看起来写法很原始。对于consumer驱动开发者来说需要知道的是如果你的驱动在probe时发现某个时钟还没注册devm_clk_get返回-EPROBE_DEFER不要慌返回-EPROBE_DEFER让内核稍后重试就行。这是正常的依赖处理机制。我见过有驱动在devm_clk_get失败时直接返回错误导致设备永远probe不了就是因为没有正确处理-EPROBE_DEFER。正确的写法clk devm_clk_get(pdev-dev, baudclk); if (IS_ERR(clk)) { if (PTR_ERR(clk) -EPROBE_DEFER) return -EPROBE_DEFER; dev_err(pdev-dev, failed to get clock\n); return PTR_ERR(clk); }这个细节在时钟provider注册顺序不确定时特别重要。比如你的驱动依赖一个I2C时钟芯片提供的时钟而I2C总线还没初始化完devm_clk_get就会返回-EPROBE_DEFER。返回这个错误码让内核延迟probe等时钟provider就绪后再试。6. 把时钟API用对一份来自实战的检查清单6.1 probe阶段的时钟操作顺序把前面讲的内容整理成一个可执行的检查清单。每次写新驱动或者review别人的驱动时按这个顺序过一遍获取时钟句柄用devm_clk_get检查IS_ERR处理-EPROBE_DEFER。使能总线时钟先使能pclk/apb_pclk这类寄存器接口时钟。使能功能时钟再使能baudclk/mclk这类功能时钟。设置频率用clk_set_rate之后用clk_get_rate确认。配置父时钟如果需要用clk_set_parent选择时钟源。错误处理任何一步失败逆序关闭已使能的时钟。这个顺序不是绝对的但覆盖了大多数场景。特殊情况下比如时钟必须在寄存器访问之前设置频率需要根据硬件手册调整。6.2 remove和suspend/resume的对称性检查remove和suspend/resume里的时钟操作必须与probe和resume严格对称。我习惯在代码里用注释标出配对关系/* probe: enable pclk - enable baudclk - set rate */ /* remove: disable baudclk - disable pclk */ /* suspend: disable baudclk - disable pclk */ /* resume: enable pclk - enable baudclk */这样review时一眼就能看出是否配对。另外suspend里不需要重新设置频率因为resume后时钟频率会保持suspend前的值除非硬件掉电。如果硬件掉电导致频率丢失需要在resume里重新clk_set_rate。6.3 用clk_summary做回归验证每次修改时钟相关代码后用clk_summary做一次回归验证加载驱动前记录目标时钟的enable_cnt和rate。加载驱动后确认enable_cnt增加了正确的次数rate是期望值。卸载驱动后确认enable_cnt回到加载前的值。执行一次suspend/resume确认计数和频率不变。这个流程能抓住大多数引用计数不平衡和频率设置错误的问题。我把它写成了一个简单的shell脚本在CI里自动跑#!/bin/bash # 记录加载前的状态 cat /sys/kernel/debug/clk/clk_summary | grep uart2 /tmp/clk_before.txt # 加载驱动 modprobe my_uart # 检查加载后的状态 cat /sys/kernel/debug/clk/clk_summary | grep uart2 /tmp/clk_after.txt # 对比enable_cnt diff /tmp/clk_before.txt /tmp/clk_after.txt # 卸载驱动 rmmod my_uart # 确认恢复 cat /sys/kernel/debug/clk/clk_summary | grep uart2 /tmp/clk_final.txt diff /tmp/clk_before.txt /tmp/clk_final.txt如果最后的diff不为空说明有时钟泄漏。6.4 常见错误码的含义与处理错误码含义处理方式-EPROBE_DEFER时钟provider还没注册返回-EPROBE_DEFER让内核重试-ENOENT设备树里没有对应的clock-names检查设备树配置-EINVAL频率参数无效或时钟不支持该操作用clk_round_rate查可用范围-EBUSY时钟被占用无法修改检查是否有其他驱动在用-ENOMEM内存分配失败检查系统内存状态其中-ENOENT最常见通常是设备树里clock-names拼写错误或者clocks属性里少写了一个时钟。我遇到过把clock-names写成clock-name的找了半天才发现是拼写问题。所以设备树修改后一定要用dtc编译一遍确认没有语法错误。7. 写在最后一些个人体会时钟框架的API不多常用的就那么七八个但要用对、用好需要对硬件时钟树和CCF的实现机制都有理解。我刚开始做驱动时也觉得clk_prepare_enable和clk_enable的区别很绕直到有一次在中断里调用了clk_prepare_enable导致系统休眠警告才真正明白这个设计的必要性。另一个深刻的体会是时钟问题一定要用工具定位不要靠猜。clk_summary、clk_get_rate、示波器这三个工具能解决90%以上的时钟问题。剩下的10%需要去看provider驱动的实现理解时钟树的硬件结构。最后分享一个习惯每次写新的外设驱动我会先在设备树里把时钟配置写清楚用assigned-clocks把初始频率和父时钟设好然后在驱动里只做必要的运行时调整。这样驱动代码更简洁时钟树的初始化也更可靠。设备树是描述硬件连接的地方把时钟关系放在设备树里比在驱动代码里硬编码要清晰得多。