jueves, 1 de noviembre de 2018

El device tree: una visión general

Introducción

En este post hablaré sobre el Device Tree y su estrecha relación con el Linux kernel.


No es un tema sencillo de explicar puesto que requiere de conocimientos en diversas materias (hardware, firmware, y software). No obstante, la falta de información sobre esta temática en mi idioma materno junto con la escasez de documentación introductoria en general me han animado a escribir este articulo introductorio. Espero que sea de vuestro agrado y os ayude a entender mejor como funciona todo este asunto. Vamos a ver que tal sale esto!


¿Qué problema intenta resolver el Device Tree?

Como todo sistema operativo, el Linux kernel necesita conocer la topología del hardware de nuestro sistema y su configuración. Detalles como cuantas CPU tiene nuestro sistema, cuanta memoria, cual es la dirección base donde esta mapeada la memoria, cuantos buses tenemos, que dispositivos hay conectados en cada bus. Todo sistema operativo necesita conocer estos detalles sobre el hardware antes de poder cargar los device drivers encargados de manejar el sistema de forma adecuada.

Para algunas arquitecturas esto no es un problema porque el hardware se describe a si mismo al arrancar el kernel. Por ejemplo, en arquitecturas PC/x86, el firmware de las placas base (BIOS/UEFI) y los buses del sistema (USB, PCI, etc), ayudan al Linux kernel a identificar la topología hardware del sistema y la configuración en tiempo de ejecución. Por tanto en la plataforma PC/x86 no tenemos este problema.

Sin embargo, en otras arquitecturas como PowerPC, SPARC, OpenRISC, ARM y algunas mas, normalmente no tenemos BIOS ni UEFI. La mayor parte de los periféricos y dispositivos de I/O no son capaces de describirse a si mismos al kernel. Dicho de otro modo, el kernel no tiene una forma fácil de saber que hardware se esta utilizando. ¿Como sabe el kernel que hardware tiene disponible el sistema?


¿Como se resolvía este problema antes de su existencia?

Durante mucho tiempo la solución consistió en describir el hardware en el propio código fuente del kernel. Veamos unos ejemplos extraídos de una placa ARM que continúa en el mainline del kernel, tal cual.

    $ cat arch/arm/mach-imx/mach-pca100.c
    [...]

    static struct i2c_board_info pca100_i2c_devices[] = {
        {
            I2C_BOARD_INFO("at24", 0x52), /* E0=0, E1=1, E2=0 */
            .platform_data = &board_eeprom,
        }, {
            I2C_BOARD_INFO("pcf8563", 0x51),
        }, {
            I2C_BOARD_INFO("lm75", 0x4a),
        }
    };


Vemos una estructura del tipo struct i2c_board_info que define el hardware que hay conectado al bus I2C: tenemos una memoria eeprom (at24), tenemos un reloj de tiempo real (pcf8563) y tenemos un sensor de temperatura (lm75).

Tenemos otro ejemplo usando el bus SPI:

    static struct spi_board_info pca100_spi_board_info[] __initdata = {
        {
            .modalias = "at25",
            .max_speed_hz = 30000,
            .bus_num = 0, 
            .chip_select = 1, 
            .platform_data = &at25320,
        },
    };   

Vemos una estructura spi_board_info que define el hardware que hay conectado al bus SPI: tenemos el nombre del dispositivo "at25" conectado al bus SPI, que usa el Chip Select 1, con una frecuencia máxima de reloj de 30Khz.

Usar el código fuente del Linux kernel para describir el hardware de nuestro sistema no era una buena idea. Si tenemos dos o tres boards diferentes, mas o menos se puede mantener el código. Pero cuando tenemos miles de boards diferentes, las cosas se complican. Las principales desventajas son:
  • Cualquier cambio de hardware requiere modificar el código fuente y recompilar el kernel para generar una nueva imagen.
  • No hay una manera estandar de implementar el código de inicialización de la board. Cada fabricante usa su propio método.
  • Existe mucho código duplicado, distintas boards implementan código para hacer lo mismo, pero cada uno lo hace de una manera diferente.
  • Es imposible tener una sola imagen del kernel que funcione en distintas plataformas hardware.
Como vemos, tenemos muchas desventajas.


¿Como resuelve el problema el Device Tree?

La idea para superar todos estos problemas consiste en borrar del código fuente del kernel de Linux toda la parte de descripción y configuración del hardware y realizar todo ese proceso de descripción y configuración en un fichero separado conocido como Device Tree.

Es responsabilidad del desarrollador describir toda la topología del hardware y su configuración en un fichero de texto llamado Device Tree Source (DTS), con la extensión .dts, que tiene un aspecto muy similar a un XML o a un JSON. Este fichero de texto se compila generando un binario llamado Device Tree Blob (DTB), con extensión .dtb.

El bootloader pasa a ser el encargado de pasar el DTB al Linux kernel durante el proceso de boot del sistema. El Linux kernel es capaz de interpretar el DTB para identificar la topología del hardware y su configuración, cargar los drivers necesarios y arrancar el sistema correctamente.

De esta manera, un solo código fuente del kernel de Linux puede ser usada en multiples boards. Solo debemos asegurarnos de pasarle al kernel el DTB adecuado durante el proceso de boot, de manera que el  kernel pueda arrancar el hardware de nuestro board.


¿Donde se guarda el Device Tree?

Cada board soportada por el Linux kernel tiene un fichero DTS guardado dentro del código fuente del Linux kernel. Por ejemplo, los Device Tree para todas las board ARM estan disponibles en este directorio:

    $ ls arch/arm/boot/dts
    aks-cdu.dts                 at91sam9xr35ek.dts
    am335x-bone.dts             at91sam9x5cm.dtsi
    am335x-evm.dts              at91sam9x5d.dtsi
    am335x-evmsk.dts            at91sam9x5ek.dtsi
    am33xx.dtsi                 bcm11351.dtsi
    am3517-evm.dts              bcm2835.dtsi
    am3517_mt_ventoux.dts       bcm2835-rpi-b.dts
    animeo_ip.dts               bcm958625k.dts
    armada-370-db.dts           [...]

Todas y cada una de las boards ARM soportadas por nuestro kernel tienen un fichero con extensión .dts en este directorio. En el listado anterior puedes ver que también aparecen ficheros con extensión .dtsi. Son ficheros include sobre el que hablaremos posteriormente.


¿Como se obtiene el DTB?

El DTS necesita ser compilado para obtener el DTB. Existe un compilador llamado Device Tree Compiler (DTC), disponible en los propios fuentes del Linux kernel, en este directorio:

    scripts/src

El DTC es el responsable de compilar el DTS para obtener el DTB.

Para automatizar la compilación de todos los DTS de una arquitectura en concreto, hay un target en el Makefile principal del kernel llamado "dtbs". Por ejemplo, para compilar todos los DTB de la arquitectura ARM, definimos las variales ARCH y CROSS_COMPILE y utilizamos este comando:

    $ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabi- dtbs

Para compilar el DTB de un solo board, utilizamos como target el nombre del Device Tree de ese board:

    $ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabi- imx6dl-colibri-eval-v3.dtb

En el ejemplo anterior hemos compilado el Device Tree de la board imx6dl-colibri-eval-v3

Después de la compilación, el fichero DTB generado se guarda en el mismo directorio en el que se alojan los Device Tree sources:

    arch/arm/boot/dts


¿Como pasamos el DTB al Linux kernel?

Durante el proceso de boot, el bootloader debe pasar el DTB al kernel antes de que kernel tome el control del arranque del sistema.

La idea general consiste en copiar el Device Tree en memoria RAM e indicarle al kernel la dirección donde se ha copiado.

En el caso concreto de la arquitectura ARM, el bootloader copia el fichero DTB en cualquier dirección de la memoria RAM y a través del registro R2 le indica al kernel la dirección base donde se ha copiado. Durante el arranque, el kernel echará un vistazo a ese registro, irá a la dirección de memoria RAM indicada, leerá el DTB, lo validará y si su formato es correcto, lo leerá y arrancará el sistema configurando el hardware según lo indicado.

El bootloader principal en ARM (U-Boot) soporta esta característica de pasar el Device Tree al kernel. Sin embargo, si usas un bootloader que no soporta el Device Tree, o estas usando una versión muy antigua del U-Boot que todavía no tenía soporte, debes hacer algo diferente si quieres arrancar el sistema con un Device Tree. Lo que debes hacer primero es configurar el kernel habilitando la opción CONFIG_ARM_APPENDED_DTB, y luego crear una imagen del kernel que contenga la imagen del kernel y el DTB concatenados:

    $ cat zImage board.dtb > zImage_dtb

Durante el proceso de boot, el kernel de Linux se dara cuenta de que tiene habilitada la opción del configure CONFIG_ARM_APPENDED_DTB y obtendrá el DTB a partir de la dirección donde finaliza la imagen del kernel en RAM.


Sintaxis del Device Tree

Hasta aquí hemos entendido qué es, para qué sirve y como se usa un Device Tree. Vamos a aprender ahora a leer e interpretar un Device Tree. Veamos su sintaxis y su semántica.

En términos generales el Device Tree es una estructura de datos capaz de describir la topología y la configuración del hardware presente en el sistema, incluyendo:

    * La cantidad y el tipo de las CPUs 
    * La dirección base y el tamaño de la memoria RAM
    * Los buses presentes en el sistema
    * Los dispositivos conectados a esos buses, ademas de información específica de cada dispositivo como lineas de interrupción, direcciones de I/O, relojes, canales DMA, etc

En lineas generales, esta es la sintaxis del Device Tree:

    / {
        node@0 {
            string-property = "A string";
            string-list-property = "first string", "second string";
            byte-string-property = [0x01 0x23 0x34 0x56];

            child-node@0 {
                first-child-property;
                second-child-property = <1>;
                reference-to-something = <&node1>";
            };

            child-node@1 {
            };
        };

        node1: node@1 {
            empty-property;
            cell-property = <1 2 3 4>
        };
    };

Como vemos el formato interno de un Device Tree se parece bastante al formato de un XML o de un JSON. Tenemos nodos, subnodos, y propiedades.

El primer nodo es llamado nodo root (raíz), y como vemos empieza con un slash "/".

Dentro del nodo raíz se define un sub-nodo llamado node@0 y otro sub-nodo llamado node@1. Hay una convención para identificar a los nodos: les damos un nombre, seguido por una arroba "@" y una dirección. Pero recuerda, es solo una convención, podrías poner lo que te diera la real gana.

Cada nodo se usa para describir a un dispositivo del sistema. Por tanto, usando la convención definida, al nodo le damos el nombre del dispositivo y después de la arroba ponemos la dirección del dispositivo dentro del mapa de memoria. Por ejemplo si estas describiendo un dispositivo como un puerto UART que tienen registros internos, debes poner la dirección base donde estan mapeados esos registros internos. Si estas describiendo un dispositivo que esta conectado al bus I2C, debes poner la dirección del dispositivo en el bus I2C.

Para algunos dispositivos no necesitas poner una dirección después de la arroba. Por ejemplo si estas describiendo un dispositivo analógico, no necesita una dirección. Pondrías solo el nombre del dispositivo analógico y nada mas.

Vemos que tenemos un nodo llamado node@0, que empieza y termina donde indican sus llaves, y tenemos un nodo node@1 que empieza y termina donde indican sus llaves. El nodo node@1 tiene una etiqueta "node1" que no tenemos en el nodo node@0. Las etiquetas se usan para referenciar al nodo en cualquier parte del Device Tree sin usar su nombre. Por ejemplo, vemos en el nodo node@0 que hay una referencia llamada reference-to-something que apunta a <&node1>, lo que indica que esta apuntando al nodo node@1 a través de su etiqueta. 

Vemos que cada nodo define una serie de propiedades y unos sub-nodos. Por ejemplo, el nodo node@0 define tres propiedades: una propiedad de tipo string llamada string-property, una lista de propiedades de tipo string llamada string-list-property y una lista de bytes llamada byte-string-property, que como vemos se define usando corchetes. Y define dos sub-nodos llamados child-node@0 y child-node@1. A su vez, el sub-nodo child-node@0 define tres propiedades first-child-property (que puede usarse como un booleano para indicar al device driver si esta propiedad esta habilitada o no), second-child-property (que define un entero) y reference-to-something que se usa como referencia al nodo node1.


Semántica del Device Tree

Acabamos de explicar la sintaxis del Device Tree. Ahora vamos a hablar sobre su semántica, echando un vistazo a un trozo real de un Device Tree. Aquí tenemos un nodo que describe una UART:

    uart1: serial@02020000 {
        compatible = "fsl,imx6q-uart", "fsl,imx21-uart";
        reg = <0x02020000 0x4000>;
        interrupts = <0 26 0x4000>
        clocks = <&clks 160>, <&clks 161>;
        clock-names = "ipg", "per";
        dmas = <&sdma 25 4 0>, <&sdma 26 4 0>;
        dma-names = "rx", "tx";
        status = "disabled";
    };

Tenemos el nombre del nodo, que es serial@02020000, y que tiene asociado una etiqueta llamada "uart1", que podemos referenciar en cualquier parte del Device Tree. Mas adelante veremos cómo se puede usar esta etiqueta para cambiar cualquiera de las propiedades de este nodo.

Dentro del nodo tenemos definidas una serie de propiedades. Una de las mas importantes es la propiedad "compatible". Esta propiedad es utilizada por el Linux kernel para identificar el driver que manejará este nodo del Device Tree.

El device driver se registra en el kernel indicando que es capaz de gestionar nodos del Device Tree que tengan una propiedad compatible con valor "fsl,imx6q-uart". Cuando el kernel encuentra un nodo en el Device Tree con el que es compatible, llama a la función "probe" de ese driver para inicializar ese dispositivo. Lo veremos después con mas detalle.

Al final tenemos una propiedad "status" que es otra propiedad importante porque permite habilitar o deshabilitar un nodo en un Device Tree. Cuando el kernel parsea el nodo y encuentra que el valor de la la propiedad status es "disabled", no llamará a la función "probe" del device driver. Por lo tanto, esta es otra manera de deshabilitar un dispositivo del sistema: puedes deshabilitar el driver en el kernel o simplemente puedes deshabilitar el driver en el Device Tree. Si quieres que el kernel llame a la función probe del device driver, debes cambiar esta propiedad con el valor "okey". Lo cierto es que desconozco el motivo por el que se ha decidido poner "okey" y no "enabled", pero si pones "enabled", no funciona. Si no defines la propiedad status, el estado del nodo por defecto es enabled. Debes ser explicito para deshabilitar un nodo del device tree.

Tenemos también una propiedad llamada "reg", que sigue con dos valores: el primero 0x02020000 es la dirección base del dispositivo, por lo tanto este dispositivo usa memory mapped I/O, y el segundo 0x4000 es el tamaño del bloque de registros internos de este dispositivo. Generalmente puedes encontrar esta información en el Reference Manual (RM) del procesador. 

A continuación tenemos la propiedad "interrupts". Lo que indica es que el procesador utiliza la interrupción 26 para el dispositivo UART1. Necesitamos esta información en el device driver para registrar el interrupt handler y manejar las interrupciones del dispositivo.

Como estamos viendo, en el Device Tree se registran los dispositivos y toda la información necesitada para que los device drivers puedan manejar esos dispositivos.

Veamos ahora un pedazo del device driver que maneja este dispositivo en el Device Tree:

    static const struct of_device_id imx_uart_dt_ids[] = {
        { .compatible = "fsl,imx6q-uart", .data = &imx_uart_devdata[IMX6Q_UART], },
        { .compatible = "fsl,imx1-uart", .data = &imx_uart_devdata[IMX1_UART], },
        { .compatible = "fsl,imx21-uart", .data = &imx_uart_devdata[IMX21_UART], },
        { /* sentinel */ } 
    };   

    static struct platform_driver serial_imx_driver = {
        .probe     = serial_imx_probe,
        .remove    = serial_imx_remove,
        .id_table  = imx_uart_devtype,
        .driver    = {  
            .name  = "imx-uart",
            .of_match_table = imx_uart_dt_ids,
            .pm = &imx_serial_port_pm_ops,
        },
    };   

Como vemos se declara una estructura del tipo of_device_id donde se indica la compatibilidad del device driver. Este device driver en concreto es compatible con 3 dispositivos: "fsl,imx6q-uart", "fsl,imx1-uart" y "fsl,imx21-uart". Por lo tanto es capaz de manejar tres dispositivos diferentes.

Puesto que éste es un driver de plataforma, se declara una estructura de tipo platform_driver donde entre otras se define la función probe (serial_imx_probe) que será llamada por el kernel durante la carga de este driver. Veamos con mas detalle esta función:

    static int serial_imx_probe(struct platform_device *pdev)
    {
        [...]
        res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
        [...]
        base = devm_ioremap(&pdev->dev, res->start, PAGE_SIZE);
        [...]
        sport->port.irq = platform_get_irq(pdev, 0);
        [...]
        sport->clk_ipg = devm_clk_get(&pdev->dev, "ipg");
        [...]
    }

Esta función utiliza funciones definidas en el Linux kernel para acceder a la información del Device Tree. Por ejemplo, la función platform_get_irq devuelve el número 26 que previamente definimos en la propiedad interrupts del Device Tree. De esta manera, el driver puede registrar el irq handler para esta irq. 

Y así es como se hace. La idea es describir el hardware y toda la información necesaria para que el device driver pueda configurarse funcionar correctamente.


Modularidad con los Device Tree includes

Imagina ahora que tenemos dos boards muy similares. ¿Voy a tener que crear dos Device Tree muy similares y tener que mantener cada uno por separado? No, esa no es una buena idea porque tendremos problemas para mantenerlos.

El Device Tree se puede dividir en varios ficheros para hacerlo mas modular y reusable. Lo que haremos será crear un fichero DTS que pueda ser incluído en otros ficheros DTS. Es lo que se conoce como ficheros DTS de include, o DTSI, que llevan la extensión .dtsi.

Cada board tiene su propio fichero .dts. Los ficheros .dts pueden incluir ficheros .dtsi. Y los ficheros .dtsi pueden incluir a su vez a otros ficheros .dtsi. Las inclusiones se realizan con la directiva #include y son procesados a través de un mecanismo de overlays que explicaremos después. 

En la practica esto se convierte en un diseño muy modular y usable.

Veamos un ejemplo extraído del código fuente del Linux kernel:

    $ cat arch/arm/boot/dts/imx6dl-colibri-eval-v3.dts:

    /dts-v1/;

    #include <dt-bindings/input/input.h>
    #include <dt-bindings/interrupt-controller/irq.h>
    #include "imx6qdl.dtsi"
    #include "imx6qdl-colibri.dtsi"

    / {
        model = "Toradex Colibri iMX6DL/S on Colibri Evaluation Board V3";
        compatible = "toradex,colibri imx6dl-eval", "toradex,colibri_imx6dl", "fsl,imx6dl";
        [...]
    };

    &uart1 {
        status = "okay";
    };

    [...]

Podemos ver que al principio utiliza la directiva #include para incluir algunos ficheros. Básicamente está incluyendo el Device Tree que describe a la familia de SoC imx6dl (imx6qdl.dtsi). Luego incluye el imx6qdl-colibri.dtsi, que es el Device Tree de una familia de boards basadas en el iMX.6 llamada Colibri. Y luego define algunos nodos y propiedades específicos de esta board.

Cuando el compilador parsea este fichero, primero parseará el primer include, después el segundo include. Si este segundo include se cambia algún nodo o propiedad que ya hubiera sido descrito por el primer nodo, lo cambiará. Por tanto, el compilador maneja estos includes utilizando un mecanismo de overlays. Si luego en nuestro Device Tree cambiamos alguna propiedad o algún nodo de este device tree, la última propiedad establecida será la que tendrá efecto.

Nos fijamos ahora en el contenido del fichero imx6qdl.dtsi:

    [...]
    uart1: serial@02020000 {
        compatible = "fsl,imx6q-uart", "fsl,imx21-uart";
        reg = <0x02020000 0x4000>
        interrupts = <0 26 IRQ_TYPE_LEVEL_HIGH>
        clocks = <&clks IMX6QDL_CLK_UART_IPG>,
                 <&clks IMX6QDL_CLK_UART_SERIAL>;
        clock-names = "ipg", "per";
        dmas = <&sdma 25 4 0>, <&sdma 26 4 0>;
        dma-names = "rx", "tx";
        status = "disabled";
    };
    [...]

En este nodo se describe el dispositivo uart1. Pero esta descrita de forma que su estado esta en disabled por defecto, como puedes ver en la propiedad status. En nuestro Device Tree hemos usado su etiqueta (uart1) para redefinir la propiedad status con valor "okay" para habilitar la uart1. El Device Tree final que tendremos tras el parseo será este:

    [...]
    uart1: serial@02020000 {
        compatible = "fsl,imx6q-uart", "fsl,imx21-uart";
        reg = <0x02020000 0x4000>
        interrupts = <0 26 IRQ_TYPE_LEVEL_HIGH>
        clocks = <&clks IMX6QDL_CLK_UART_IPG>,
                 <&clks IMX6QDL_CLK_UART_SERIAL>;
        clock-names = "ipg", "per";
        dmas = <&sdma 25 4 0>, <&sdma 26 4 0>;
        dma-names = "rx", "tx";
        status = "okay";
    };
    [...]

Como vemos, el nodo status ha cambiado su valor de disabled a okay. Podemos hacer esto con cualquier propiedad y con cualquier nodo del Device Tree.


Los Device Tree bindings

Imagina que quieres habilitar un acelerómetro, o un magnetómetro o cualquier otro dispositivo en un Device Tree. ¿Donde puedo encontrar documentación sobre las propiedades que necesito definir para que el kernel de Linux reconozca ese dispositivo?

Las propiedades de cada nodo del Device Tree se definen y documentan en un fichero llamado Device Tree bindings. Cada documento describe que propiedades se aceptan, con qué valores, que propiedades son obligatorias, cuales son opcionales, etc. ¿Donde podemos localizar los Device Tree bindings? Todas estas bindings estan (o por lo menos deberían estar) documentadas en el código fuente del kernel de Linux, en este directorio:

    Documentation/devicetree/bindigs

Veamos un ejemplo sobre el códec de audio sgtl5000:

    $ cat Documentation/devicetree/bindings/sound/sgtl5000.txt

    * Freescale SGTL5000 Stereo Codec

    Required properties:
    - compatible : "fsl,sgtl5000".

    - reg : the I2C address of the device

    - clocks : the clock provider of SYS_MCLK

    Example:

    codec: sgtl5000@0a {
        compatible = "fsl,sgtl5000";
        reg = <0x0a>;
        clocks = <&clks 150>;
    };

Como vemos es un fichero de texto. Primero se hace una descripción del dispositivo. Después se habla de las propiedades obligatorias (required properties). En este caso en concreto tenemos 3 propiedades obligatorias: compatible, reg y clocks. Podríamos tener propiedades opcionales, aunque no las hay en este ejemplo en concreto. Y al final se muestra un ejemplo que demuestra como usar las propiedades. Ese ejemplo es importante ya que lo podemos usar como un punto de inicio para describir el hardware en nuestro propio Device Tree. Por supuesto se trata de un ejemplo, así que deberemos verificar que la dirección base, que en el ejemplo es 0x0a, o el reloj que vamos a utilizar sean los correctos. Pero en cualquier caso es un punto de inicio para definir el Device Tree de nuestra
nueva placa.


Cierre del post

Termino de esta manera con la redacción de un post sobre el Device Tree bastante técnico que empecé a escribir hace algunos meses y y no encontraba el momento oportuno para publicar. Espero que este articulo ayude a la comunidad de desarrolladores del Linux Kernel en español a mejorar el entendimiento de un sistema operativo que tantos y tantos progresos ha traído a nuestra sociedad en general. Como siempre estaré encantado de atender vuestras dudas y opiniones en los comentarios. Un placer, gracias por leerme y hasta una próxima ocasión!

Visitas:

Seguidores