做嵌入式这行的人,手里多半都经历过这么一段:板子换了主控,老版本的 U-Boot 里一堆自己改过的驱动全都对不上号,一编译就是满屏的未定义引用,一上电串口连个字符都不吐。我最近刚把一块基于 ARM Cortex-A 系列新主控的参考板从老版本 U-Boot 迁到新版,整个过程里最耗时间的不是编译环境,而是驱动的移植和修改。"Linux--uboot移植(三)新uboot驱动修改"这个标题说的就是这一段最硬核的活:新版 U-Boot 全面转向驱动模型(Driver Model,后面统称 DM)和设备树之后,驱动不再是"找到那个 c 文件改两行"这么简单,而是一整套"设备树描述硬件、Kconfig 决定编不编、DM 负责绑定和 probe"的协作机制。这篇内容面向已经能把 U-Boot 编出来、但一换板子就卡在驱动层的朋友,也适合刚接触 U-Boot 移植、想搞清楚"为什么我的驱动不工作"的初学者。我会把驱动修改的取舍逻辑、逐类驱动的改法、设备树怎么配合、以及一堆只有真正上手才会遇到的坑,尽量掰开揉碎讲清楚,代码和配置都可以直接参照着改。
1. 新版 U-Boot 驱动修改和老版本的思路差异
很多人第一次打开新版 U-Boot 的 drivers 目录会有点懵:串口驱动不再是 drivers/serial/ 下一个孤零零的 .c 文件说了算,而是多出一堆 uclass、ops、platdata 之类的结构体,还有 kconfig 文件层层嵌套。这不是把简单的事搞复杂,而是整个驱动生命周期被重新设计了。理解这个设计差异,是后面所有修改工作的前提,否则你会一直在错误的地方打补丁。
1.1 驱动模型(DM)把"硬件操作"和"设备管理"拆开了
老版本 U-Boot 的驱动是过程式的:谁要用串口,就直接调一个 board_serial_init(),里面把寄存器地址写死或者从宏里取,硬件和代码是强绑定的。换一块板子,宏一改,代码就跑不通了。这种写法在板子少、硬件简单的年代很高效,但一旦芯片种类爆炸,维护成本就压不住了。
新版引入的 DM 做的事情,本质是把"这个设备是什么"和"这个设备怎么操作"分成两层。描述"是什么"的是设备树节点和 platdata(平台数据),描述"怎么操作"的是 driver 里注册的 ops 函数表。中间由一个 uclass(设备类)来统一管理同一类设备,比如所有串口都归 UCLASS_SERIAL 管,所有 MMC 都归 UCLASS_MMC 管。启动时 DM 扫描设备树里的节点,找到 compatible 匹配的 driver,把节点信息解析成 platdata 塞给 driver,然后调用 probe 完成初始化。
这个机制带来的直接好处是:同一份串口驱动代码,只要 compatible 匹配、platdata 结构一致,就能同时服务好几块不同的板子,驱动里不需要出现任何板级宏。代价是你要改驱动,就必须同时改三个地方:driver 里的 ops 实现、Kconfig 里的开关、设备树里的节点。少改一处,驱动就静默地不工作,而且常常不报错,这是新手最容易栽跟头的地方。
我在第一次迁的时候,串口驱动明明已经 include 进来了,编译也过了,就是没输出。查了大半天才发现是设备树里 status 没改成 "okay",DM 压根没有实例化那个节点。这类"编译通过但运行不生效"的问题,几乎全是三处没对齐导致的。
1.2 设备树成了硬件描述的唯一入口,源码里的地址宏要清干净
老款 U-Boot 里,地址、时钟频率、引脚复用这些信息大量写在 board 目录下的头文件里,什么 CONFIG_SYS_UART_BASE、CONFIG_SYS_MMC_BASE,随手一个宏就是寄存器基地址。新版 U-Boot 虽然为了兼容还保留了一部分这类宏,但方向非常明确:硬件信息应该全部收敛到设备树里,驱动只从 platdata 或 ofnode 里去取。
这个转变对移植工作的影响是根本性的。以前改驱动,脑子里想的是"哪个宏要改成新地址";现在改驱动,脑子里要换成"设备树里这个节点描述对不对,驱动里的 of_ 系列解析函数取到的值对不对"。我在实际操作中养成了一个习惯:每接一个新外设,先把设备树节点按参考手册写全,再去看驱动代码里需要哪些属性,两边对着改,比先改代码再猜设备树要快得多。
还有一点很关键,源码里残留的老宏一定要清理。新驱动一旦走了 DM 路径,读的是设备树里的 reg 属性,你在 board 头文件里把 CONFIG_SYS_XXX_BASE 改成新地址,对 DM 驱动完全无效,反而会在排查时误导你自己,以为"地址已经改对了"。我见过不止一个人卡在这上面,最后发现是自己改的宏根本没被新驱动读取。
1.3 修改策略要分层:能配置解决的绝不改代码
新版 U-Boot 的配置体系比老版本完善很多,很多以前必须改代码的事情,现在改 Kconfig 和 defconfig 就够了。比如时钟频率、串口波特率、启动顺序这类参数,优先考虑通过 defconfig 或者环境变量解决;驱动里真正需要改代码的,通常是寄存器操作序列、时序参数、新增硬件特性的支持。
我把驱动修改按侵入程度分成三档,实际工作中严格按这个优先级来:第一档是纯配置,改 defconfig、改设备树、改 Kconfig 默认值,不动 C 代码;第二档是板级适配,在 board 目录里写板级初始化,或者写一个薄薄的 glue 驱动;第三档才是改核心驱动代码,或者新增一个全新驱动。绝大多数移植工作其实停留在第一、第二档,真正要动核心驱动的场景比你想象得少。先把前两档用尽,能省掉大量后续维护的麻烦,因为核心驱动一改,下次升级 U-Boot 版本时合并冲突会让你痛不欲生。
2. 上手修改前先把新版 U-Boot 的驱动框架摸清楚
在动手改任何一个驱动之前,我建议花半天时间把框架摸清楚,这半天能省掉后面好几天的瞎试。摸清楚的重点不是把 DM 的源码全读一遍,而是搞清楚几个具体问题:驱动放在哪、怎么被编进来、怎么和设备树对上、probe 的时机和顺序是什么。下面按我自己的排查顺序讲。
2.1 目录结构与关键文件的定位方法
新版 U-Boot 的驱动按 uclass 分类放在 drivers/ 下,每个子目录通常包含三类文件:驱动实现(如 serial_xxx.c)、uclass 层实现(serial-uclass.c)、Kconfig 和 Makefile。以串口为例,drivers/serial/ 下会有各个厂商的驱动文件,加上一个统一的 serial-uclass.c 负责设备类的通用逻辑。
定位一个外设的驱动,我一般按这个顺序找:先在 drivers/ 对应子目录里按文件名关键词找;找不到就看设备树节点里的 compatible 字符串,用 grep 在整个源码树里搜这个字符串,通常能直接命中 driver 里的 of_match 表;再找不到就查 Kconfig,看这个 uclass 下有哪些驱动选项被列出。这个流程看起来笨,但比漫无目的地翻目录靠谱得多,尤其是面对不熟悉的厂商驱动时。
有一点值得提醒:uclass 层的文件和具体驱动的文件职责要分清楚。设备注册、设备查找、uclass 的通用行为在 uclass 层;寄存器的读写、时序控制、状态机在具体驱动里。改驱动时如果你发现要改的东西明显是通用逻辑,比如设备查找方式,那就要小心了,这往往是框架层的问题,改了会影响所有同类设备。我在早期就犯过这个错,为了迁就一块板子的特殊初始化顺序,直接改了 uclass 层的 probe 流程,结果其他几个外设全乱了,最后老老实实退回板级 glue 代码里解决。
2.2 Kconfig 与 Makefile 的联动:驱动为什么"编不进来"
新版 U-Boot 的驱动编译完全由 Kconfig 控制,Makefile 里写的是 obj-$(CONFIG_XXX) += xxx.o 这种形式,CONFIG_XXX 为 y 才编。所以一个驱动"没生效",第一步永远是确认它到底有没有被编进去,而不是去怀疑代码逻辑。
确认的方法很直接:编译完之后在生成的 .config 里搜对应的 CONFIG 项,或者看 build 目录下有没有生成对应的 .o 文件。如果 CONFIG 是空的或者 n,那就顺着 Kconfig 的依赖链往上看。Kconfig 里的 depends on 是链式的,比如一个串口驱动可能依赖 CONFIG_DM_SERIAL,而 DM_SERIAL 又依赖 CONFIG_DM,一层没开,整条链都断。
我在实际项目里遇到过这么一件事:新驱动的 Kconfig 选项加上了,defconfig 里也写了 CONFIG_XXX=y,但编译出来就是不生效。原因是那个选项在 Kconfig 里 depends on 一个默认没开的架构特性,Kconfig 在解析时直接把这条配置丢弃了,defconfig 里写的 y 被静默忽略。这种"写了等于没写"的情况没有任何报错,只能靠逐层检查依赖来发现。所以我的习惯是,新增任何一个 CONFIG 项,都要回头把它的 depends on 链路走一遍,确认每一层在当前配置下都是满足的。
2.3 probe 的时机与顺序:驱动初始化为什么有先有后
DM 框架下,设备的 probe 不是一股脑全做,而是有顺序的。大致分两个阶段:早期阶段在重定位之前,只有少数标记为 early 的驱动会 probe,比如调试串口、定时器;常规阶段在重定位之后,按 uclass 的初始化顺序依次 probe。这个顺序由 uclass 的 id 和驱动里声明的 flags 共同决定。
为什么这个顺序重要?因为驱动之间有依赖。串口驱动要用时钟,时钟驱动就得先 probe;MMC 驱动要用 GPIO 做卡检测,GPIO 驱动就得先就绪。如果你的驱动 probe 时报"clock not found"或者"gpio not found",八成不是设备树写错了,而是依赖的驱动还没起来。
排查这类问题的办法是打开 DM 的调试输出。在 defconfig 里加上 CONFIG_DEBUG_UART 相关的调试选项,或者在启动参数里提高 loglevel,启动日志里会打印每个设备的绑定和 probe 过程,哪个设备在什么时候被 probe、失败原因是什么,一目了然。我第一次看到那份详细日志的时候才明白,之前很多"莫名其妙不工作"的问题,日志里其实早就写清楚了,只是默认的日志级别把这些信息藏起来了。
3. 按启动链路逐类改驱动:串口、存储、网络
驱动修改不要贪多,按启动链路一步步来。我的顺序固定是:串口先通,能看到打印;然后存储(MMC/eMMC)通,能读内核;再然后网络通,能 tftp 下载;最后才是各种外设。这个顺序的好处是每一步都有明确的验证手段,出问题能立刻定位,不会一堆问题搅在一起。
3.1 串口驱动:一切调试的起点
串口是新板子第一个要打通的东西,没有它后面全是盲调。新版 U-Boot 的串口分两条路径:一条是 DM 之前的 debug uart,用于最早期的打印,通常靠 CONFIG_DEBUG_UART_BASE 之类的宏配置;另一条是 DM 下的正式串口驱动,负责后续所有控制台输出。
debug uart 的配置相对简单,在 defconfig 里指定基地址、波特率、时钟,早期打印就能出来。但这里有个坑我必须提醒:debug uart 用的是轮询方式直接写寄存器,不经过 DM,所以它能不能工作,取决于你对新主控串口寄存器的了解是否正确,特别是 FIFO 状态位和发送完成位的定义。不同主控这两位的含义可能完全不一样,照抄参考板的配置经常打不出字符,或者打出乱码。
正式串口驱动走 DM 路径,需要设备树节点和 driver 配合。设备树里要写全 reg(寄存器基地址和长度)、interrupts(如果驱动用中断模式)、clocks、clock-names、status 这些属性。driver 侧的 of_match 表里 compatible 字符串必须和节点里完全一致,一个字符都不能差。我在一次移植中把 compatible 写成了 "vendor,uart-01" 而节点里是 "vendor,uart-1",肉眼几乎看不出区别,结果驱动就是不匹配,DM 报"no match found",查了很久。
下面是我常用的串口节点写法示例,属性名按常见的设备树约定来:
&uart0 { compatible = "vendor,uart-1"; reg = <0x0 0x10000000 0x0 0x1000>; interrupts = <0 32 4>; clocks = <&clk_uart0>; clock-names = "uartclk"; status = "okay"; };改完串口要验证的不只是"有输出",还要看波特率是否准确。波特率不对通常是时钟频率配错了,输入时钟和分频系数没对上。我一般的做法是用示波器或者逻辑分析仪量一下实际波特率,和设定值偏差超过 2% 就得回头查时钟树,因为波特率偏差会让后续交互时好时坏,特别难排查。
3.2 MMC/eMMC 驱动:能读内核才算过关
串口通了之后,下一个目标是存储。新版 U-Boot 的 MMC 驱动同样走 DM,uclass 是 UCLASS_MMC,设备树节点里要描述控制器寄存器、时钟、电源、总线宽度、是否非可移除等属性。驱动修改的重点通常在几个地方:控制器的时钟分频配置、卡检测引脚、总线宽度、以及高速模式的支持。
时钟分频是这块最容易出错的地方。MMC 协议的时钟有上限,初始化阶段要用低速时钟(通常 400kHz 左右),识别完成后切到高速。新主控的时钟分频寄存器位定义如果和参考驱动不一致,就会出现在低速阶段能识别卡、一切高速就报 CRC 错误的现象。我遇到过一块板子,卡能识别、容量也能读对,就是读大文件时频繁超时,最后定位到是高速模式的时钟分频算错了,实际跑到了协议上限之上。
卡检测这块,如果板子用的是 GPIO 检测而不是控制器内置的 CD 信号,设备树里要写 cd-gpios,驱动里要确认有没有解析这个属性。有些厂商的驱动默认走控制器内置 CD,你接了 GPIO 上去它根本不看,表现就是插拔卡 U-Boot 完全没反应。这种情况要么改驱动去解析 cd-gpios,要么在板级代码里手动触发扫描,前者更规范。
eMMC 和 SD 卡的区别也要注意,eMMC 通常是非可移除设备,设备树里要写 non-removable,否则驱动会去做卡检测,等一个永远不会到来的电平变化。总线宽度上 eMMC 常见 8 位,SD 常见 4 位,bus-width 写错了不是不能工作,而是性能腰斩甚至报错,这个属性一定要对照硬件原理图确认。
3.3 网络 PHY 驱动:MDIO 总线和 PHY 地址先对
网络驱动这块的坑集中在 PHY 上。MAC 控制器本身的驱动相对好改,麻烦的是 MDIO 总线上挂的 PHY:地址对不对、PHY ID 认不认、自协商能不能起来。新版 U-Boot 的网络驱动通过设备树描述 MAC 和 PHY 的关系,PHY 节点通常作为 MAC 节点的子节点,或者通过 phy-handle 引用。
第一个要确认的是 PHY 地址。MDIO 总线上可以挂多个 PHY,每个有唯一地址,地址配错了,驱动会一直读不到 PHY ID,打印"PHY not found"或者读出来全是 0xffff。地址一般由硬件上的 strapping 引脚决定,原理图上能查到,也可以在 U-Boot 里用 mdio 命令扫描整个地址段确认。
第二个是 PHY 驱动的匹配。新版 U-Boot 有一套通用的 PHY 驱动框架,按 PHY ID 匹配具体驱动。如果你的 PHY 型号比较新,通用驱动里可能没有它的 ID,表现是能读到 ID 但没有驱动接手,自协商起不来。解决办法是查 PHY 厂商的寄存器手册,确认它的 ID 寄存器值和通用驱动的兼容性,必要时在 phy 驱动表里补一条,或者在设备树里指定一个兼容的 PHY 类型先用起来。
我自己踩过最深的一个网络坑是 RGMII 接口的延时配置。新主控和 PHY 之间的 RGMII 需要 TX/RX 延时,这个延时可以配在 MAC 侧也可以配在 PHY 侧,两边都配会叠加导致采样错误,两边都不配又采不准。现象是链路能起来、ping 通,但大流量传输丢包严重。最后是查了 PHY 的延时配置寄存器,把 PHY 侧延时关掉,统一在 MAC 侧的设备树属性里配,问题才解决。这类问题不会报错,只会表现为性能异常,非常考验耐心。
4. 设备树与驱动的联动修改要点
设备树在新版 U-Boot 移植里不是配角,而是主角之一。驱动代码是通用的,设备树是板子专属的,两者通过 compatible 和属性名对齐。这一章专门讲设备树侧怎么写、怎么写错、怎么验证。
4.1 节点编写:属性宁可按手册写全,不要猜
设备树节点的属性名不是随便起的,大部分有约定俗成的标准名,比如 reg、interrupts、clocks、clock-names、status、pinctrl-names、pinctrl-0 这些。厂商私有的属性通常带厂商前缀,比如 vendor,xxx-mode。写节点时我的原则是:标准属性严格按绑定文档写,私有属性严格按驱动代码里的读取逻辑写,两边都不猜。
具体做法是反过来读驱动代码。打开驱动文件,找到 probe 函数,看它调了哪些 dev_read_xxx 或者 ofnode 相关的解析函数,每个函数读的属性名就是设备树里必须提供的。这个反向推导法比翻文档快,而且准确,因为文档可能滞后,代码是最新的。我一般会把 probe 里读取的属性列个清单,对着清单写设备树,写完再反过来检查一遍有没有漏。
status 属性是最容易被忽略的。默认很多节点在 SoC 级 dtsi 里是 disabled 的,板级 dts 里必须显式改成 "okay" 才会被 DM 实例化。我见过太多"驱动编进去了但不工作"的案例,根因就是 status 还是 disabled。所以我的检查清单第一条永远是:这个节点的 status 是什么?
4.2 compatible 匹配与驱动绑定的验证方法
compatible 是设备和驱动之间的唯一纽带,匹配不上,后面全免谈。验证匹配是否成功,最直接的办法是看启动日志里 DM 的绑定信息。打开详细日志后,会看到每个设备节点尝试匹配驱动的过程,匹配成功的会显示绑定的 driver 名字,匹配不上的会显示没有找到 driver。
如果确认 compatible 字符串一致但就是不匹配,要检查 of_match 表里的 data 字段和 driver 的 id 是否对应,有些驱动用 of_match 的 data 来区分不同变体,data 对不上会导致匹配成功但初始化走错分支。还有一种情况是 compatible 匹配了,但驱动因为依赖没满足而 probe 失败,这时候日志里会有明确的失败原因,比如某个 clock 找不到、某个 gpio 请求失败,顺着报错去补设备树属性就行。
5. 配置裁剪、编译与上板验证的完整流程
驱动和��备树都改完之后,还有配置、编译、验证这一整套流程。这部分看起来是体力活,但配置没做对,前面改的驱动一样白搭。
5.1 defconfig 的定制与增量修改
新版 U-Boot 的配置以 defconfig 为基准,编译时生成 .config,再据此生成头文件。定制 defconfig 我一般用两步:先用 make xxx_defconfig 生成基础配置,再用 make menuconfig 做增量调整,最后用 savedefconfig 把精简后的配置存回 defconfig。savedefconfig 很重要,它会把和默认值相同的项去掉,只保留真正的差异,这样 defconfig 干净、可读、合并冲突少。
需要重点确认的配置项通常包括:DM 相关的基础开关、目标 uclass 的驱动开关、目标架构的选项、以及调试相关的选项。调试选项在开发阶段可以开着方便看日志,但出货前一定要关掉,因为调试输出会拖慢启动,某些调试选项还会改变内存布局。
5.2 从上电到命令行的验证清单
上板验证我有一套固定的检查顺序,按这个顺序走基本不会漏:上电看 debug uart 有没有早期打印;进入正式 U-Boot 后看版本信息和板级信息是否正常;执行 mmc list 看存储设备有没有枚举出来;执行 mmc info 看容量和速度模式;执行 ping 看网络通不通;最后才是加载内核镜像。每一步失败都能对应到前面某一章的驱动修改,定位起来非常快。
这套流程的价值在于把问题隔离。如果你跳过串口直接去调网络,网络不通时你根本不知道是网络驱动的问题还是串口没输出导致你误判了启动阶段。按顺序来,每次只引入一个新变量,是嵌入式调试最朴素也最有效的方法。
6. 踩坑实录与排查速查表
最后这部分是我这些年在这类移植里攒下来的问题清单,按现象归类,遇到问题可以先对着表查一遍,能省不少时间。
6.1 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 编译通过但驱动完全不工作 | 设备树 status 为 disabled | 检查节点 status 是否 okay |
| 驱动 .o 文件没生成 | Kconfig 依赖链断开 | 逐层检查 depends on |
| 报 no match found | compatible 字符串不一致 | 逐字符比对节点和 of_match |
| probe 报 clock not found | 时钟驱动未就绪或属性缺失 | 检查 clocks 属性及 probe 顺序 |
| 串口输出乱码 | 输入时钟或分频算错 | 量实际波特率,查时钟树 |
| MMC 高速模式报 CRC | 高速时钟分频超限 | 核对分频系数与协议上限 |
| 插拔卡无反应 | CD 走 GPIO 但驱动未解析 | 补 cd-gpios 或改驱动 |
| PHY 读不到 ID | MDIO 地址错 | 用 mdio 命令扫描地址段 |
| 大流量丢包 | RGMII 延时双重配置 | 确认延时只配一侧 |
| eMMC 无响应 | 未标 non-removable | 补 non-removable 属性 |
6.2 几条只有上手才会懂的经验
第一条,日志级别要舍得开。新版 U-Boot 默认日志很安静,很多绑定和 probe 信息都藏着。开发阶段把 loglevel 提到 7 甚至更高,DM 的详细过程全打出来,虽然刷屏,但每一条都是线索。我现在的习惯是新板子第一次跑,日志级别一定是拉满的,等全部打通再降回来。
第二条,改驱动前先 git diff 一下厂商提供的参考代码。很多厂商会给一份基于某个 U-Boot 版本的 BSP,里面已经有他们改好的驱动。不要直接整份替换,但可以 diff 出他们改了哪些文件、改了哪些行,这些差异就是你需要迁移到新版本的核心内容。这个办法能省掉大量从零读代码的时间。
第三条,每改完一类驱动就提交一次。驱动修改是典型的"牵一发动全身",串口改完可能影响时钟,时钟改完可能影响存储。分阶段提交,出问题时能快速回退到上一个可用状态,比在一堆改动里逐行找 bug 高效得多。
第四条,设备树和驱动代码要成对维护。我见过太多项目,设备树是 A 版本改的,驱动是 B 版本改的,两边属性名对不上,谁也说不清哪个是对的。我的做法是在驱动文件头部注释里写清楚它依赖哪些设备树属性,改设备树时同步更新注释,虽然土,但团队协作时特别有用。
第五条,别迷信"能跑就行"。有些驱动改完之后功能是通的,但某些配置项其实是错的,比如总线宽度配小了、时钟配低了,功能不受影响只是性能打折。这类问题在开发阶段容易被忽略,等到量产或者跑高负载时才暴露。驱动改完,除了功能验证,最好再对照手册把关键参数核一遍,确认没有为了"先跑起来"而做的临时妥协留在代码里。