El Device Tree: describir el hardware que no se descubre
Por qué ARM y el mundo embebido necesitaron sacar la descripción del hardware fuera del kernel: la anatomía de un archivo .dts con sus nodos, propiedades, compatible, reg e interrupts, y el camino del .dts al blob .dtb que el bootloader entrega al kernel.
Si el hardware embebido es mudo y su descripción tiene que vivir en algún sitio, la pregunta es dónde. La respuesta que ganó no es el código, son los datos: el device tree, un árbol de nodos y propiedades que describe la topología del hardware de una placa sin una sola línea ejecutable. Nació de una crisis concreta —el caos de miles de board files de ARM— y se convirtió en el lenguaje con el que hoy se describe casi todo SoC. Un archivo .dts no es un programa: es un mapa del silicio que el kernel lee al arrancar.
- Entender por qué el device tree existe y qué crisis de los board files vino a resolver.
- Leer la anatomía de un
.dts: nodos, propiedades, direcciones de unidad y jerarquía. - Conocer las propiedades canónicas
compatible,regeinterruptsy qué significan. - Seguir el camino del
.dtsal blob.dtby cómo llega al kernel en el arranque.
Por qué existe: la rebelión contra los board files
Hacia 2011 el árbol de ARM en el kernel era un vertedero. Cada placa —y había miles— aportaba un archivo .c en arch/arm/mach-*/ con sus direcciones, sus IRQ y su cableado, todo compilado dentro del binario. Añadir una placa era añadir código; cambiar una dirección era recompilar el kernel. Linus Torvalds estalló públicamente contra aquel diluvio de placas, y la comunidad adoptó una idea que ya usaban PowerPC y SPARC: describir el hardware en un archivo de datos externo, no en código. Ese formato es el device tree, heredado de Open Firmware.
La consecuencia es un cambio de naturaleza. La descripción del hardware deja de ser lógica compilada y pasa a ser un dato que el kernel interpreta en tiempo de arranque. Un mismo binario del kernel sirve para decenas de placas distintas; lo que cambia entre ellas es el blob de device tree que se le entrega. La descripción se separó del código.
Board file (antes)
La descripción de la placa era un .c compilado en el kernel. Cada placa, un binario. Cambiar una dirección obligaba a recompilar. Datos disfrazados de código.
Device tree (ahora)
La descripción es un .dtb externo que el bootloader entrega al kernel. Un binario sirve para muchas placas. Cambiar el hardware es cambiar datos.
Anatomía de un .dts
Un device tree es un árbol. Tiene un nodo raíz, y de él cuelgan nodos hijos que representan buses y dispositivos, cada uno con propiedades de tipo clave y valor. Este es un fragmento realista de la descripción de un SoC:
/dts-v1/;
/ {
compatible = "acme,board-evk", "acme,soc-8";
#address-cells = <1>;
#size-cells = <1>;
soc {
compatible = "simple-bus";
#address-cells = <1>;
#size-cells = <1>;
ranges;
uart0: serial@50000000 {
compatible = "acme,uart-9000";
reg = <0x50000000 0x1000>;
interrupts = <0 42 4>;
clocks = <&clk_uart>;
status = "okay";
};
};
};
Léelo despacio, porque cada pieza tiene un papel:
- El nombre de nodo y su dirección de unidad.
serial@50000000es un nombre genérico (serial) seguido de arroba y la dirección donde vive. Esa dirección de unidad debe coincidir con la primera celda dereg. compatible. La propiedad más importante: una lista de cadenasfabricante,modeloque identifica el hardware. Es la clave con la que el kernel encontrará el driver (nivel 29.4). Se lee de la más específica a la más genérica.reg. El rango de registros: aquí0x50000000de base y0x1000de tamaño. Cuántas celdas ocupa la base y cuántas el tamaño lo fijan#address-cellsy#size-cellsdel nodo padre.interrupts. La descripción de la línea de interrupción, en el formato que imponga el controlador de interrupciones padre (aquí tres celdas: tipo, número y flags).status.okayhabilita el nodo;disabledhace que el kernel lo ignore, útil para apagar un periférico en una variante de placa.
La etiqueta uart0: es un rótulo (label) que permite referenciar el nodo desde otro sitio con &uart0, el mecanismo de los phandle que conecta un dispositivo con su reloj, su GPIO o su controlador de interrupciones.
Phandles y celdas: cómo se enlazan los nodos
Un árbol de nodos sueltos no describiría hardware real, donde una UART necesita un reloj y un controlador de interrupciones que viven en otros nodos. El device tree los conecta con phandles: una referencia a otro nodo a través de su rótulo. Y para saber cuántos números acompañan a cada referencia usa las propiedades #*-cells, que cada proveedor declara.
clk_uart: clock@40000000 {
compatible = "fixed-clock";
#clock-cells = <0>; /* al referenciarme no hace falta indice */
clock-frequency = <24000000>;
};
intc: interrupt-controller@48000000 {
compatible = "acme,intc";
interrupt-controller;
#interrupt-cells = <3>; /* cada interrupts lleva 3 celdas */
reg = <0x48000000 0x1000>;
};
uart0: serial@50000000 {
compatible = "acme,uart-9100";
reg = <0x50000000 0x1000>;
interrupt-parent = <&intc>; /* de quien cuelga mi interrupcion */
interrupts = <0 42 4>;
clocks = <&clk_uart>; /* mi reloj, por phandle */
};
Aquí clocks = <&clk_uart> es un phandle al nodo del reloj; como este declara #clock-cells = <0>, la referencia no lleva más números. La propiedad interrupt-parent apunta al controlador que interpreta mi línea, y como declara #interrupt-cells = <3>, mi interrupts lleva exactamente tres celdas. Esta gramática de celdas es lo que permite que el kernel resuelva, en el arranque, qué reloj alimenta qué dispositivo y qué controlador atiende qué interrupción, sin saber nada de esta placa en particular.
Del .dts al .dtb: fuente, blob y kernel
El .dts es texto para humanos; el kernel consume un binario compacto llamado FDT (Flattened Device Tree) o .dtb. El compilador es dtc, y vive en el propio árbol del kernel:
# compilar la fuente legible a un blob binario que el kernel entiende
dtc -I dts -O dtb -o acme-board.dtb acme-board.dts
# el proceso inverso: descompilar un blob para inspeccionarlo
dtc -I dtb -O dts acme-board.dtb
En el arranque, el bootloader (U-Boot, o UEFI en ARM64) carga ese .dtb en memoria y le pasa al kernel un puntero a él. El kernel lo desdobla muy temprano, antes incluso de tener memoria dinámica completa, y a partir del árbol crea un struct platform_device por cada nodo describible, rellenando sus recursos con lo que dicen reg e interrupts. Después, ya en marcha, expone el árbol vivo en el sistema de archivos:
/proc/device-tree/ el arbol tal como lo ve el kernel
/sys/firmware/devicetree/base/ la misma vista, en sysfs
/sys/firmware/fdt el blob crudo que recibio del bootloader
flowchart LR DTS[archivo dts legible] -->|dtc| DTB[blob dtb binario] DTB -->|el bootloader lo carga y lo pasa| KERNEL[Kernel arranca] KERNEL -->|desdobla el FDT| NODOS[Un platform_device por nodo] NODOS -->|reg e interrupts| REC[Recursos MEM e IRQ]
Dos matices del mundo real. Primero, los device tree overlays permiten modificar el árbol en caliente para añadir un periférico conectado en un conector de expansión, como un HAT de Raspberry Pi, sin recompilar el árbol base. Segundo, el device tree es la vía de ARM y RISC-V embebido, pero el x86 de servidor y portátil describe su hardware no enumerable con ACPI. El kernel abstrae ambos bajo la misma API de propiedades de firmware (fwnode), que verás en el nivel 29.5: un mismo driver puede alimentarse de device tree o de ACPI sin enterarse.
Da un paso atrás y reconoce el patrón, porque es el mismo que recorre el kernel entero bajo mil disfraces: la separación entre mecanismo y descripción. Un driver encierra el mecanismo, el conocimiento de cómo se programa una familia de UART, cómo se limpia su interrupción, cómo se configura su velocidad; ese conocimiento es genérico y no depende de ninguna placa concreta. El device tree encierra la descripción, los hechos particulares de esta placa: que hay una UART de esa familia en esta dirección, con esta interrupción, alimentada por este reloj. Los board files fracasaron porque mezclaban ambas cosas, y al mezclarlas obligaban a recompilar el mecanismo cada vez que cambiaba un hecho. El device tree las divorcia con una limpieza casi matemática: el mecanismo se compila una vez en el binario del kernel y sirve para todo el planeta; la descripción viaja aparte, en un blob de datos que puede editarse, versionarse y sustituirse sin tocar una línea de código ejecutable. De esa división brotan consecuencias enormes. Un solo kernel arranca en centenares de placas ARM distintas, algo impensable en la era de los board files. Un fabricante puede sacar una revisión de placa cambiando un .dts en lugar de un parche al kernel. Y la frontera entre quien escribe drivers y quien integra hardware queda nítida: unos aportan mecanismo, otros aportan descripción, y se encuentran en la propiedad compatible. Cuando ves el device tree no como un formato de configuración más sino como la materialización del principio de que la descripción de los hechos debe vivir separada de la lógica que los explota, entiendes por qué se impuso y por qué ese mismo principio reaparece, una y otra vez, en cada capa bien diseñada del sistema.
- Si tienes acceso a una Raspberry Pi u otra placa ARM, ejecuta
dtc -I fs /proc/device-treey localiza un nodo con sucompatible, suregy susinterrupts. - Explica por qué la dirección de unidad de
serial@50000000debe coincidir con la primera celda de su propiedadreg, y qué papel juega#address-cellsdel padre. - Cambia mentalmente
status = "okay"pordisableden el nodo del ejemplo y describe qué deja de ocurrir en el arranque. - Argumenta por qué un único binario del kernel puede arrancar en cincuenta placas distintas gracias al device tree, algo imposible con board files.
- Describe el camino completo de un byte de la propiedad
reg: desde el.dts, pasando pordtc, el.dtb, el bootloader y el kernel, hasta convertirse en unstruct resource.