鸿蒙开发越深入,就越绕不开设备树。尤其是当你拿到一块新板子,或者想在一个官方开发板上外接一个不太常见的传感器、屏幕、模组时,十有八九最后都会落到同一个问题上:DTS怎么改?我见过不少人卡在这一步卡了很久——不是不会写驱动,而是根本不知道怎么让内核认出新硬件。这篇就把我在OpenHarmony上改设备树DTS的完整思路和操作过程梳理一遍,从文件在哪、怎么改、怎么编译到怎么验证,一条龙讲清楚。
1. 先搞明白:DTS在OpenHarmony开发里到底管什么
1.1 设备树的本质:不是"配置文件"而是硬件的"地图"
很多刚开始接触OpenHarmony的开发者会把设备树误解成一个普通的配置文件,觉得它跟JSON、ini差不多,改一改就能生效。这个理解方向不太对。设备树本质上是描述整块板子硬件资源的一张"地图",内核启动的时候需要靠这张地图找到:内存有多大、有几个UART、GPIO哪些引脚被复用了、I2C控制器挂在哪个地址上、中断号是多少。
在OpenHarmony的ARM和RISC-V平台上,内核和硬件之间的"信息沟通"基本都靠设备树来完成。比如你写了一个HDF驱动,驱动代码里并不会写死"寄存器的物理地址是0x12340000",而是通过设备树节点拿到这个地址。如果设备树里的信息是错的,驱动代码写得再对也跑不起来。
这也就是为什么修改DTS会成为OpenHarmony硬件适配中最常见的操作——因为所有硬件资源的"定义"都集中在这里,不是散落在驱动代码里,而是先在这里登记,再由内核把资源分配给各个驱动。
1.2 哪些场景必须动DTS
我自己在实际项目里遇到的需要改DTS的场景大概有这么几类:
- 板级外设适配:比如官方开发板只引出了一路SPI,但你的项目需要两路SPI,这时候就要去看第二路SPI的控制节点是否已经被复用成GPIO了,如果是,就得改引脚复用关系。
- 屏幕点不亮调试:LCD屏上电时序、背光引脚、reset引脚、分辨率时序参数,这些全部定义在设备树节点里。很多屏点不亮不是驱动的问题,而是DTS里某个GPIO编号写错了。
- 内存和DDR参数调整:外接大内存或者改DDR频率时,需要动memory节点和dramc相关节点。
- 传感器接入:加速度计、陀螺仪这类外设,通常驱动代码是现成的,你只需要在DTS里加一个I2C子节点,填上I2C地址和中断引脚就能用起来。
- 调试引脚功能切换:有时候想把某个默认的调试串口引脚释放出来当作普通GPIO使用,也要改DTS。
提示:如果你在做x86平台的OpenHarmony适配,情况会稍有不同——x86传统上偏向用ACPI描述硬件,但在OpenHarmony生态里ARM和RISC-V芯片才是DTS的主力战场,所以这篇的实操部分主要围绕这两种架构展开。
2. 在工程里定位DTS:目录结构、编译产物与板型对应关系
2.1 按图索骥:从内核仓库找到你的板型DTS
OpenHarmony的工程目录和普通单片机工程不同,它是"仓库套仓库"的结构。DTS文件不是统一放在某一个地方,而是跟着内核版本走,具体路径在kernel/linux/下面。比如你用的内核版本是5.10,那么DTS一般会在这两个地方之一:
kernel/linux/linux-5.10/arch/arm64/boot/dts/ kernel/linux/linux-5.10/arch/arm/boot/dts/进到这个目录后你会发现,DTS文件不是只有一份,而是按厂商、板型分了多层目录。以全志、瑞芯微、海思这些常见厂商为例,目录结构一般长这样:
arch/arm64/boot/dts/sunxi/ ├── sun55i-w3.dtsi ├── sun55i-aosp.dtsi └── boards/ ├── sun55i_awol_aosp.dts └── ...其中.dtsi是"公共部分",描述同一颗SoC内部的所有硬件资源,比如CPU核数量、内置外设控制器、中断控制器,这些对于所有使用同款芯片的板子基本是通用的。而.dts是"板级文件",描述具体某一款开发板上的差异部分,比如板载了哪个型号的LCD、用的是哪颗Codec、某个GPIO接了什么按键。
注意:在OpenHarmony的很多开发板上,编译时会把多个
.dtsi叠加到.dts里,最终多个文件的内容会被合并成一个完整体。所以你改一个引脚,可能改的是.dtsi而不是.dts,这得先定位清楚。
2.2 dts、dtsi、dtb、dtbo之间的关系
这四个后缀名我相信看晕过不少人。简单梳理一下:
.dts是设备树源文件,给人看的。.dtsi也是设备树源文件,但它是"被包含"的文件,公共部分放这里,可以理解成头文件。.dtb是编译后的二进制文件,内核启动时实际加载的就是它。.dtbo是设备树覆盖层文件,运行时动态加载用,可以在不改主设备树的情况下叠加新硬件描述。
在OpenHarmony的构建流程里,.dts/.dtsi会被dtc编译成.dtb,然后打包进boot_linux.img这个启动镜像里。这也是为什么很多人改了DTS后单独编译内核却还是老样子——因为你虽然改了源码,但没有重新打包boot镜像,设备树内容根本没进去。
2.3 从.config和defconfig反查板型
有些时候你手头的一个系统镜像对应的是哪块开发板、用的是哪个DTS,光看目录是猜不出来的。这时候需要借助内核配置来看。在kernel目录下执行:
make ARCH=arm64 xxx_defconfig grep CONFIG_DEFAULT_DEVICE_TREE .config执行完就能看到类似CONFIG_DEFAULT_DEVICE_TREE="sun55i_awol_aosp"这样的输出,这就锁定了编进镜像的默认设备树是哪一个。这个方法在排错时特别有用,能帮你快速定位"我明明改了文件,为什么编译产物不对"的问题——大概率就是改了另一块板子的DTS。
3. 按场景修改DTS:引脚复用、外设节点与板级参数调整
3.1 GPIO引脚复用:最高频的修改点
先讲最常遇到的情况:一个引脚当前被分配给了某个外设,但你想让它干别的活。比如某块板子的UART3_TX被默认复用成了GPIO,导致串口根本发不出数据。
在DTS里,引脚复用通常体现在两个地方:
pinctrl节点下定义引脚的复用状态和上下拉配置;- 外设节点中的
pinctrl-0属性引用这个状态。
一段典型配置长这样:
&uart3 { pinctrl-names = "default"; pinctrl-0 = <&uart3_pins>; status = "okay"; }; &pinctrl { uart3_pins: uart3_pins { pins = "PH0", "PH1"; function = "uart3"; bias-pull-up; }; };这里每一行的含义分别是:
pins:指定要配置的物理引脚,比如PH0、PH1。function:指定这个引脚充当的功能,是UART3还是GPIO,具体可填的值取决于芯片的pinctrl驱动。bias-pull-up:设置内部上拉,防止引脚悬空导致电平不稳定。status = "okay":使能节点,改成disabled则屏蔽对应外设。
如果你想把这两个引脚改成普通GPIO输出,只需要把function换掉,并去掉外设节点对它们的引用,然后在GPIO子系统里重新申请:
&uart3 { status = "disabled"; }; &pinctrl { gpio_led_pins: gpio_led_pins { pins = "PH0", "PH1"; function = "gpio"; }; };3.2 添加一个I2C外设节点:从一颗传感器说起
假设板子上有一颗BME280温湿度传感器,挂在I2C0总线上,I2C地址是0x76,INT引脚接到了PH3。驱动代码如果有现成的,你只需要在DTS里添加这么一个节点:
&i2c0 { status = "okay"; clock-frequency = <400000>; bme280@76 { compatible = "bosch,bme280"; reg = <0x76>; interrupt-parent = <&pio>; interrupts = <7 3 IRQ_TYPE_LEVEL_LOW>; status = "okay"; }; };几个字段逐个拆开说:
bme280@76:节点名,@后面跟的是I2C从设备地址,方便阅读,也可以不写。compatible:驱动匹配的关键字段。内核和HDF框架会拿这个字符串跟驱动里的of_device_id表做比对,对上了才binding。reg:I2C设备地址,必须是7位地址,别把8位地址填进去,这是个高频坑。interrupt-parent:中断控制器,通常指向&pio。interrupts:中断号、中断类型。具体数字含义需要查芯片手册,不能凭空编。
加完节点后,如果驱动已经编进内核,重启系统后在/proc/device-tree/目录下应该能看到bme280@76这个子目录,说明内核已经识别到节点了。
3.3 调整内存大小和启动参数
有些项目拿到板子会改内存,比如原来的开发板焊了512MB DDR,你换成了1GB。这时候如果不改DTS里的memory节点,内核只会把512MB范围之外的区域当作不存在,白白浪费一半内存。
在DTS里通常是这样的:
memory@40000000 { device_type = "memory"; reg = <0x0 0x40000000 0x0 0x40000000>; };这行reg的含义是:起始物理地址0x40000000,大小0x40000000,也就是1GB。如果你需要改成2GB,按<起始地址高32位 起始地址低32位 大小高32位 大小低32位>的格式写成:
reg = <0x0 0x40000000 0x0 0x80000000>;另外chosen节点里的bootargs也经常会被改,比如调整内核日志级别、关闭某些子系统、指定根文件系统分区等:
chosen { bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p5 rw"; };3.4 时钟与电源域:这块需要边查手册边改
DTS里还有一类修改涉及时钟频率和电源域控制。比如你想把某个外设的工作时钟从24MHz改成48MHz,通常会在时钟控制器节点里配置assigned-clocks和assigned-clock-rates:
&spi1 { assigned-clocks = <&ccu CLK_SPI1>; assigned-clock-rates = <48000000>; status = "okay"; };这里需要你查芯片手册确认时钟ID号,CLK_SPI1这个宏定义在include/dt-bindings/clock/头文件里,编译DTS的时候会用到。要是ID写错了,编译不会报错,但运行时spi时钟会乱,数据收发全部错位——这种问题排查起来特别费劲,我建议你在改之前先确认宏定义的真实数值。
4. 编译、打包与烧录:如何让修改真正生效
4.1 单独编译dtb再验证语法
设备树语法写错是家常便饭,最常见的是少了分号、多写了逗号、或者引用了不存在的宏。在OpenHarmony的构建流程里,可以单独编译一下dtb来快速验证语法是否正确:
cd kernel/linux/linux-5.10/ make ARCH=arm64 sun55i_awol_aosp_defconfig make ARCH=arm64 dtbs编译完成后,产物在arch/arm64/boot/dts/对应目录下。如果你用的不是make直接编译而是hb构建,整套流程会变成:
hb build -f -T kernel不管用哪种方式,核心目的都是先确认DTS能不能被成功编译成.dtb。如果语法有错,编译会直接中断,并且报错信息里会精确指向某个文件某一行,比如:
Error: arch/arm64/boot/dts/sunxi/xxx.dts:123.1-2 syntax error FATAL ERROR: Unable to parse input tree这种报错基本跟着行号改就行,最怕的是"编译能过、启动行不通"的隐性错误,后面专门讲。
4.2 反编译dtb,核对真实生效内容
编译通过不等于你写的节点真的生效了。我有个习惯:编译完dtb后,再用dtc把它反编译成dts,确认关键内容是不是真的按预期编进去了。这个习惯帮我避开过好几次"改错文件"的问题。
dtc -I dtb -O dts -o out.dts arch/arm64/boot/dts/sunxi/sun55i_awol_aosp.dtb grep -n "bme280" out.dts如果反编译结果里能看到你新增的节点,说明它已经在这个dtb里面了,后续只需要确保dtb被正确打包进boot镜像。
4.3 重新打包boot镜像并烧录
在OpenHarmony工程里,dtb一般不会单独烧录,而是和内核一起打包进boot_linux.img。因此,修改完DTS并且编译出新的dtb后,还得重新生成boot镜像。
通常的顺序是:
hb build -f -T kernel hb build -f -T boot_linux_img然后把生成的新boot_linux.img用升级工具烧录到开发板的boot分区。这里有个非常容易踩的坑:有些平台在烧录时会做镜像校验,如果你只烧了boot而没有同步烧录其它配套镜像,可能出现启动失败或者某个外设起不来。
注意:如果你的目标平台支持设备树overlay动态加载(即
.dtbo),那就不需要重新烧录整个boot分区,只需要在系统起来后把新的dtbo覆盖到/vendor或/data分区的指定目录下,再重启即可。这种方式在调试阶段特别省时间,强烈推荐。
4.4 运行时验证:进系统后怎么确认DTS生效
烧录并开机后,验证DTS是否生效有几种手段:
ls /proc/device-tree/:内核会把设备树内容暴露在这个目录下,能看到所有节点,权限不够时文件会显示为r--r--r--,看不全内容,但节点结构是能看到的。cat /proc/device-tree/model:如果这个文件的内容和你的板子型号一致,说明加载的dtb是对的。dmesg | grep -i "bme280\|i2c":驱动加载的过程会留日志,如果设备树匹配成功但驱动初始化失败,这里能看到报错。- 通过HDF框架的调试接口:OpenHarmony里很多外设驱动走的是HDF框架,设备树节点即使匹配上了,还需要看
/sys/kernel/debug/hdf/下的调试信息才能确认驱动真的完成了初始化。
这个验证环节千万不要省——很多时候你改完DTS自以为成功了,实际上系统加载的还是一份旧的dtb,原因可能是镜像缓存、烧录工具选了错误的分区,或者编译时用了另一份设备树。
5. 避坑实录:这几个问题最容易让人卡壳
5.1 同名节点被覆盖,改了等于没改
设备树允许在不同层级的.dtsi文件里定义同名节点,后面解析的会把前面解析的覆盖掉。这意味着你可能在板级.dts里辛辛苦苦配了一堆引脚,结果被SoC的.dtsi里某个同名的&uart3节点里的status = "disabled"给盖掉了,看起来改了却完全没生效。
排查方法也很直接:反编译最终生成的dtb,搜一下你关心的节点名,看status字段的值到底是多少。记住,你写的.dts源码并不是最终产物,被合并、覆盖后的dtb才是内核真正看到的。
5.2 pinctrl与GPIO子系统的"打架"
这类问题在引脚的配置上很典型:DTS里同时有GPIO控制节点和外设pinctrl节点都引用了同一个引脚,结果就是系统启动时报类似"pin PH0 already requested"的警告,或者外设功能时好时坏。
我遇到过一个案例,某块板子的LED用的是PH0,但I2C1的引脚也被误配成了PH0,结果一开I2C通信,LED就乱闪。查了半天才发现是同一个引脚被两个子系统同时占用,内核没有强制仲裁这种冲突,只是打印一句警告就继续运行了。
建议:改DTS时先全局搜索一下目标引脚有没有被其他地方引用过。比如要使用PH0,就搜
PH0这个字符串,所有引用它的地方都能看到,一次性排查完再动手。
5.3 改了DTS但是外设还挂死?先查interrupt和reg
reg地址写错和interrupts中断号写错是驱动挂死的两大元凶。I2C从设备的地址尤其容易出错,很多传感器数据手册里写的地址是8位格式,比如0xEC,但在设备树reg里必须填7位格式0x76,直接把0xEC填进去会导致地址不匹配,驱动一直报NACK。
中断号的问题也一样,不同厂商的中断控制器编号方式差别很大,有的是全局中断号,有的需要按bank计算。你宁可多花几分钟查芯片手册,也别靠猜的——我之前就靠猜填错过一次中断号,驱动一进中断就死循环,最后只能在中断处理函数里加打印才定位出来。
5.4 编译缓存导致"昨天改的,今天怎么还原了"
OpenHarmony的构建系统在某些情况下会对内核编译产物做缓存,特别是当你用hb构建并且没有指定-f(强制全量)时,可能出现"源码改了、编译没执行"的情况,最终打包进镜像的还是旧dtb。
我的习惯是改完DTS之后,用小范围全量编译的方式强制刷新:
hb build -f -T kernel如果时间允许,干脆把整个内核目录的.o文件清掉再编一次,确保万无一失。别觉得这样浪费时间,跟"烧录后发现改了个寂寞"相比,重新编译节省的时间要多得多。
5.5 怎么判断问题到底出在DTS还是驱动
最后分享一个排查思路。很多人遇到外设不起来,第一反应就是改驱动,但改来改去也没用,其实问题在DTS。反过来,也有人反复调DTS,其实驱动代码就有问题。区分这两者的一个有效方法:在驱动初始化入口加一条打印,或者用HDF框架自带的调试节点查看设备是否被成功match。
一般来说,如果你在/proc/device-tree/下能看到对应节点,说明内核层面已经解析到设备树内容了;如果驱动对应的compatible字符串能在/sys/bus/platform/devices/下找到同名设备,说明设备树和驱动的匹配链路是通的。走到这一步还没反应,问题大概率在驱动代码本身。这个判断顺序能省掉很多无用功。
我个人在OpenHarmony上做硬件适配时,一直把DTS当成“硬件的接口契约”来看待——它不光是驱动跑的依赖,也是不同团队协作时对齐硬件信息的地方。改DTS本身并不难,难的是搞清楚现状、看清合并规则、验证是否真正生效。把这套流程走熟了,很多板级适配问题都能在几分钟内定位到根因。