从零构建可移植驱动:wl_arm设备树绑定实战精要
你有没有遇到过这样的场景?
手头的这块新板子,CPU还是熟悉的那颗wl_arm芯片,但外设布局一变,原本好好的驱动编译进去却启动失败。查来查去发现——不是代码写错了,而是硬件“说”的语言变了。
在现代嵌入式开发中,这种“硬件即配置”的理念早已深入人心。而实现这一转变的核心技术之一,就是设备树(Device Tree)。它让驱动不再“死记硬背”硬件信息,而是学会“听懂”系统描述,自动适配不同平台。
本文不讲空泛理论,带你一步步穿透设备树与驱动绑定的本质,用真实开发视角还原一个工程师该如何从零写出高兼容性、易维护的wl_arm平台驱动。
设备树到底解决了什么问题?
我们先回到原点:为什么需要设备树?
早期Linux ARM驱动常采用静态板级文件(board file),所有资源如内存映射、中断号、时钟都硬编码在C代码里。比如:
static struct resource my_sensor_resources[] = { [0] = DEFINE_RES_MEM(0x12080000, 0x100), [1] = DEFINE_RES_IRQ(IRQ_GPIO_18), };这带来严重问题:
- 换个板子就得改代码;
- 多种配置共存时条件编译满天飞;
- 内核体积膨胀,维护成本飙升。
于是,设备树应运而生——把硬件描述从代码中剥离出来,变成独立的数据结构。内核启动时读取这份“说明书”,就知道有哪些设备、在哪里、怎么用。
你可以把它理解为:一份给内核看的JSON格式硬件BOM清单。
DTS语法核心要点:写对才能被识别
.dts文件是设备树的源码形式,最终会被dtc编译成.dtb二进制 blob,由U-Boot加载并传给内核。
别被复杂的语法吓到,真正影响驱动工作的关键字段其实就那么几个。
最重要的属性:compatible
这是整个匹配机制的起点。它的值决定了哪个驱动会被调起来。
temperature-sensor@48 { compatible = "ti,tmp102"; reg = <0x48>; interrupts = <7>; };其中"ti,tmp102"是标准命名格式:厂商,型号。
当内核看到这个节点,就会去查找所有注册了.of_match_table的驱动,看看谁支持ti,tmp102。
✅ 建议实践:如果你做的是通用传感器驱动,可以同时支持多个型号:
c static const struct of_device_id my_sensor_of_match[] = { { .compatible = "ti,tmp102" }, { .compatible = "nxp,lm75" }, { } // 结束标记 };
地址和寄存器:reg属性
对于I²C设备,reg表示从设备地址;SPI则是片选编号;内存映射外设则表示基地址和长度。
uart2: serial@12c20000 { compatible = "snps,dw-apb-uart"; reg = <0x12c20000 0x100>; /* 起始地址 + 大小 */ clocks = <&clks UART2_CLK>; };注意这里的<address size>格式受父节点的#address-cells和#size-cells控制,一般设为<1>和<1>即可满足大多数情况。
中断怎么接?interrupts与interrupt-parent
中断定义通常包含中断号和触发类型:
interrupts = <7 IRQ_TYPE_EDGE_FALLING>;如果设备挂在某个中断控制器下(比如GPIO控制器),还需要指明interrupt-parent:
interrupt-parent = <&gpio1>;这样内核就知道该去哪找这条中断线。
状态控制:status决定是否启用
不想让某个设备工作?不用删节点,只要改成:
status = "disabled";反之,“okay”表示启用。这个字段非常实用,尤其在调试阶段,避免频繁修改代码或重新编译设备树。
驱动如何“找到”设备?绑定流程全解析
现在我们来看最关键的部分:驱动是怎么和设备树节点对应上的?
答案就在platform_driver和of_match_table的配合中。
第一步:声明我能支持哪些设备
在驱动代码中,你需要定义一个匹配表:
#include <linux/of.h> static const struct of_device_id my_sensor_of_match[] = { { .compatible = "ti,tmp102", .data = &tmp102_config }, { .compatible = "nxp,lm75", .data = &lm75_config }, { } }; MODULE_DEVICE_TABLE(of, my_sensor_of_match);这里的.data可以附加私有数据,用来区分不同设备的行为差异,后面会详细讲。
然后把这个表挂到驱动结构体上:
static struct platform_driver my_sensor_driver = { .probe = my_sensor_probe, .remove = my_sensor_remove, .driver = { .name = "my-temp-sensor", .of_match_table = my_sensor_of_match, }, }; module_platform_driver(my_sensor_driver);一旦注册,内核就知道:“哦,有个叫my-temp-sensor的驱动,能处理ti,tmp102和nxp,lm75。”
第二步:内核扫描未绑定设备,尝试匹配
内核初始化过程中,会对设备树中的每个节点进行遍历。对于属于platform_bus_type的设备(SoC内部外设基本都是这类),它会检查是否有驱动的of_match_table匹配其compatible字符串。
匹配成功后,触发probe()回调函数。
第三步:在 probe 中获取资源信息
这才是真正的“干活”环节。通过pdev->dev.of_node,你可以拿到对应的设备树节点指针。
如何读取基本属性?
static int my_sensor_probe(struct platform_device *pdev) { struct device_node *np = pdev->dev.of_node; u32 reg_val; if (!np) { dev_err(&pdev->dev, "无设备树节点\n"); return -EINVAL; } /* 获取 reg 属性 */ if (of_property_read_u32(np, "reg", ®_val)) { dev_warn(&pdev->dev, "未定义 reg,默认使用 0x48\n"); reg_val = 0x48; } dev_info(&pdev->dev, "I2C 地址: 0x%x\n", reg_val); return 0; }常用API总结如下:
| 功能 | API |
|---|---|
| 读取整型值 | of_property_read_u32() |
| 判断是否存在某属性 | of_property_read_bool() |
| 获取字符串 | of_property_read_string() |
| 查找 phandle 引用 | of_parse_phandle() |
实战技巧:如何优雅地获取复杂资源?
设备往往不只是一个地址那么简单。电源、时钟、GPIO……这些都需要动态获取。
Linux提供了一套统一接口,让你无需关心底层是固定LDO还是PMIC供电。
GPIO管理:使用gpiod接口
假设你的传感器有一个alert引脚用于上报异常:
temperature-sensor@48 { compatible = "ti,tmp102"; alert-gpios = <&gpio1 18 GPIO_ACTIVE_HIGH>; };驱动中这样获取:
struct gpio_desc *alert_gpio; alert_gpio = devm_gpiod_get(&pdev->dev, "alert", GPIOD_IN); if (IS_ERR(alert_gpio)) return PTR_ERR(alert_gpio); gpiod_set_consumer_name(alert_gpio, "temp-alert"); // 后续可用 gpiod_get_value(alert_gpio) 读取状态⚠️ 注意命名规范:使用
-gpios后缀(如alert-gpios),才能被gpiod_get()正确识别。
时钟开启:别忘了使能 clock
很多外设依赖外部时钟源才能工作:
clocks = <&clks 19>; clock-names = "sensor_clk";驱动中获取并使能:
struct clk *clk; clk = devm_clk_get(&pdev->dev, "sensor_clk"); if (IS_ERR(clk)) return PTR_ERR(clk); clk_prepare_enable(clk); // 上电并使能别忘了在remove或出错路径中调用clk_disable_unprepare()。
电源域控制:稳定供电是前提
有些传感器对电压敏感,必须确保电源就绪:
vdd-supply = <&ldo2_reg>;驱动中请求并打开:
struct regulator *vdd; vdd = devm_regulator_get(&pdev->dev, "vdd"); if (IS_ERR(vdd)) return PTR_ERR(vdd); regulator_enable(vdd);💡 提示:推荐使用
devm_开头的资源获取函数,它们会在设备卸载时自动释放资源,防止泄漏。
构建高兼容性驱动框架:工程级设计思路
当你面对的不是一个设备,而是一类设备时,就需要考虑架构层面的设计了。
场景举例:多种温度传感器共用一套驱动逻辑
虽然tmp102和lm75寄存器布局略有不同,但整体流程一致:初始化 → 定期采样 → 上报数据。
这时就可以利用.data字段传递差异化配置:
struct sensor_ops { int (*init)(struct i2c_client *client); int scale; // 温度换算系数 }; static int tmp102_init(struct i2c_client *client) { ... } static const struct sensor_ops tmp102_cfg = { .init = tmp102_init, .scale = 100, }; static const struct sensor_ops lm75_cfg = { .init = lm75_init, .scale = 50, }; static const struct of_device_id my_sensor_of_match[] = { { .compatible = "ti,tmp102", .data = &tmp102_cfg }, { .compatible = "nxp,lm75", .data = &lm75_cfg }, { } };在probe中提取配置:
const struct sensor_ops *ops = of_device_get_match_data(&pdev->dev); // 使用 ops->init(client), ops->scale 等这样一来,新增型号只需添加一条.compatible条目和对应操作函数,主逻辑完全复用。
支持 fallback 兼容模式
为了增强健壮性,建议按“具体→通用”顺序排列compatible条目:
{ .compatible = "vendor,sensor-v2" }, { .compatible = "vendor,sensor" }, /* 通用型号 */这样即使新版驱动没更新,也能降级运行。
利用 overlay 实现热插拔模块支持
对于扩展板卡等动态接入设备,可通过设备树 overlay 在运行时注入配置:
mkdir /config/device-tree/overlays/my-sensor echo "my-sensor.dtbo" > /config/device-tree/overlays/my-sensor/path无需重启即可加载新设备,非常适合工业现场调试或模块化产品。
调试秘籍:如何确认设备树生效了?
写了半天,怎么知道设备树真的起作用了?
方法一:查看/proc/device-tree
系统启动后,设备树会被展开为文件系统结构:
cd /proc/device-tree/ find . -name "compatible" -exec cat {} \;能看到所有节点的compatible值。
方法二:打印当前节点信息
在probe函数开头加一句:
of_print_phandle_mask_info(np); // 需开启 DEBUG 宏或者直接用:
of_dump_flat_tree(); // 打印完整树状结构方法三:使用fdtdump工具分析 DTB
在主机端安装设备树工具:
sudo apt install device-tree-compiler fdtdump -a system.dtb | grep tmp102快速验证.dtb是否正确包含了你的修改。
易踩坑点提醒:这些细节决定成败
忘记添加
MODULE_DEVICE_TABLE(of, ...)
- 否则模块工具无法识别支持的设备类型,可能导致无法自动加载。DTS节点名与 label 混淆
- 正确做法:使用label: node-name@addr,引用时用&label。phandle 指向不存在的节点
- 如vdd-supply = <&ldo2_reg>,但ldo2_reg并未定义,会导致内核启动卡住。误将可选资源当作必选
- 应优先使用*_optional接口,例如:c supply = devm_regulator_get_optional(&pdev->dev, "vpp"); if (!IS_ERR(supply)) { regulator_enable(supply); }设备树未正确加载
- 检查U-Boot是否设置了fdt_addr并调用bootz ... ${fdt_addr};
- 确保.dtb分区烧录正确。
写在最后:设备树不止是配置,更是设计哲学
掌握设备树,本质上是在学习一种解耦思维:把硬件抽象为数据,把驱动变为解释器。
今天你在 wl_arm 平台上写的这套机制,明天就能迁移到 RISC-V 或者自研 SoC 上。Zephyr、RT-Thread 等实时操作系统也已全面拥抱设备树,说明这条路走对了。
作为驱动开发者,你不只是在写代码,更是在搭建一座桥——连接千变万化的硬件世界与稳定的软件生态。
下次当你面对一块陌生的开发板,别急着翻原理图。先去看看它的设备树,听听它想告诉你什么。
也许你会发现,硬件自己会说话。