Каждый раз, когда Linux загружается на встраиваемой плате, ядро первым делом задаёт себе вопрос: а что вообще находится на этом железе? Откуда ему знать, сколько ядер у процессора, по какому адресу висит контроллер I2C, и на каком пине сидит кнопка сброса? Ответ на этот вопрос давно перестал быть частью кода ядра. Теперь он живёт в отдельном бинарном файле, который передаётся ядру загрузчиком ещё до того, как система успевает сделать первый вдох. Этот файл называется Device Tree Blob, и история его появления в системе начинается с компиляции.

Понимание того, как именно работает этот процесс, отделяет разработчика, который умеет адаптировать Linux под конкретное железо, от того, кто просто запускает чужие образы и надеется, что всё заработает само.

Что такое дерево устройств и зачем его компилировать

Дерево устройств задаётся в файле с расширением .dts и компилируется в бинарный Device Tree Blob (.dtb) с помощью компилятора DTC. Но прежде чем говорить о компиляции, стоит понять, что именно хранится в этом текстовом файле.

Дерево устройств представляет собой древовидную структуру данных, описывающую аппаратное обеспечение. Узлы представляют устройства или IP-блоки, а свойства определяют их характеристики. Каждый узел может ссылаться на другие узлы через концепцию phandle, что позволяет устанавливать связи между разными ветками дерева.

Текстовый формат .dts удобен для чтения и редактирования человеком. Но ядро работает с бинарным представлением: оно компактнее, парсится быстрее и не требует текстовых парсеров в загрузчике. Именно поэтому между исходником и загрузкой системы стоит шаг компиляции.

Поддерживается модульный подход: файлы .dtsi содержат описание на уровне SoC, а файлы .dts определяют конфигурации, специфичные для конкретной платы. Такая структура сокращает дублирование и упрощает описание общих элементов для похожих плат.

Простой фрагмент .dts выглядит так:

/dts-v1/;

/ {
    compatible = "vendor,myboard";
    #address-cells = <1>;
    #size-cells = <1>;

    memory@80000000 {
        device_type = "memory";
        reg = <0x80000000 0x20000000>;
    };

    i2c0: i2c@e0004000 {
        compatible = "vendor,i2c";
        reg = <0xe0004000 0x1000>;
        clock-frequency = <400000>;
        status = "okay";
    };
};

Строка /dts-v1/; обязательна: она указывает версию синтаксиса. Свойство compatible связывает узел с драйвером в ядре. Свойство reg задаёт адрес и размер ресурса в адресном пространстве родительского узла.

Инструмент DTC и его место в системе сборки

Device Tree Compiler (dtc) принимает на вход дерево устройств в одном формате и выдаёт его в другом. Типичный сценарий: входной формат "dts", читаемый человеком исходный текст, а на выходе "dtb", бинарный формат.

Версия dtc, входящая в состав ядра Linux, находится в каталоге scripts/dtc/ дерева исходников. Новые версии периодически подтягиваются из upstream-проекта. DTC собирается системой сборки ядра автоматически по мере необходимости. Чтобы собрать его явно, используется команда make scripts.

Установить dtc отдельно от дерева исходников ядра можно через пакетный менеджер:

sudo apt-get install device-tree-compiler

Прямая компиляция одного файла .dts в .dtb выглядит лаконично:

dtc -I dts -O dtb -o output.dtb input.dts

Флаг -I задаёт формат входных данных, -O задаёт формат выходных данных, а -o указывает выходной файл. Если выходной файл не указан, результат пишется в stdout.

Обратная операция, декомпиляция бинарного .dtb обратно в читаемый .dts, используется при анализе чужих образов или при отладке:

dtc -I dtb -O dts working.dtb -o /tmp/my_device_tree.dts

Это особенно ценно, когда нужно изучить дерево устройств на работающей системе, а исходники под рукой отсутствуют.

Компиляция через систему сборки ядра

Прямой вызов dtc хорош для экспериментов. В реальных проектах деревья устройств компилируются через Makefile ядра. Этот путь правильнее: система сборки автоматически прогоняет исходник через препроцессор C, раскрывая директивы #include, и только потом передаёт результат в dtc.

Система сборки ядра предоставляет цель dtbs, которая компилирует все деревья устройств, совместимые с текущей конфигурацией ядра. На ARM все файлы .dts и .dtsi расположены в arch/arm/boot/dts, на ARM64 соответственно в arch/arm64/boot/dts.

Команда для компиляции всех деревьев для ARM64:

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- dtbs

Для компиляции одного конкретного файла цель сборки формируется заменой расширения .dts на .dtb:

make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- \
    freescale/imx8mm-evk.dtb

Цель сборки — это имя .dts файла с заменой расширения на .dtb. Необходимо убедиться, что конфигурационная опция, включающая нужный DTB, активна. Проверить это можно так:

grep CONFIG_ARCH_MXC .config
# Ожидаемый вывод:
# CONFIG_ARCH_MXC=y

Если опция не активна, конкретный DTB не войдёт в сборку даже при явном указании цели.

После успешной компиляции скомпилированный файл копируется в каталог /boot целевой системы:

scp arch/arm64/boot/dts/freescale/imx8mm-evk.dtb \
    Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript.:/boot/

Оверлеи дерева устройств и компиляция DTBO

Классический подход предполагает один DTB на одну аппаратную конфигурацию. Но что делать, если продукт выпускается в десятке вариантов, различающихся наличием дополнительных датчиков, дисплеев или интерфейсов? Держать отдельный DTB под каждую комбинацию неудобно. Здесь и появляются оверлеи.

Оверлеи дерева устройств позволяют добавлять или изменять узлы и свойства без перекомпиляции всего дерева устройств. Они загружаются и применяются после загрузки ядра, обеспечивая изменения во время выполнения без перекомпиляции всего дерева.

Исходный файл оверлея (.dts или .dtso) имеет специфическую структуру с обязательной директивой /plugin/;:

/dts-v1/;
/plugin/;

/ {
    fragment@0 {
        target = <&i2c0>;
        __overlay__ {
            temperature-sensor@48 {
                compatible = "ti,tmp102";
                reg = <0x48>;
                status = "okay";
            };
        };
    };
};

Компиляция оверлея в бинарный .dtbo требует флага -@, который включает генерацию таблицы символов. Без этого флага оверлей не сможет найти точку подключения в базовом дереве:

dtc -@ -I dts -O dtb -o sensor-overlay.dtbo sensor-overlay.dts

Флаг -@ необходим, чтобы оверлей мог использовать символы из базового дерева. Если базовый DTB скомпилирован без этого флага, оверлей не найдёт нужный узел и применение завершится ошибкой.

При кросс-компиляции через Makefile флаг передаётся через переменную окружения:

export DTC_FLAGS='-@'
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- \
    freescale/imx93-lvds-panel.dtbo

Применение оверлеев через загрузчик и configfs

Скомпилированный .dtbo можно применить двумя принципиально разными способами. Первый происходит до старта ядра, в U-Boot. Второй позволяет подключать оверлеи прямо во время работы системы.

В U-Boot последовательность загрузки с оверлеем выглядит так:

load mmc 0:1 ${fdt_addr_r} /boot/base.dtb
fdt addr ${fdt_addr_r}
load mmc 0:1 ${overlay_addr_r} /boot/sensor-overlay.dtbo
fdt resize 8192
fdt apply ${overlay_addr_r}
bootm ${kernel_addr_r} - ${fdt_addr_r}

Команда fdt apply выполняет слияние оверлея с базовым деревом в памяти. Процесс слияния происходит в оперативной памяти и добавляет лишь миллисекунды ко времени загрузки.

Второй способ актуален для систем, где железо меняется в процессе работы. Современные ядра поддерживают применение оверлеев через интерфейс /sys/kernel/config/device-tree/overlays/, предоставляемый configfs. Это позволяет выполнять динамическую реконфигурацию аппаратного обеспечения после загрузки.

Последовательность применения оверлея через configfs:

# Монтируем configfs если не смонтирована
mount -t configfs none /sys/kernel/config

# Создаём директорию для оверлея
mkdir /sys/kernel/config/device-tree/overlays/my-sensor

# Загружаем бинарник оверлея
cat sensor-overlay.dtbo > \
    /sys/kernel/config/device-tree/overlays/my-sensor/dtbo

После выполнения последней команды ядро автоматически применяет оверлей к живому дереву устройств, и устройство становится доступным без перезагрузки.

Отладка и валидация скомпилированного дерева

Компилятор dtc проверяет синтаксис, но не семантику. Он не скажет, правильно ли указан адрес контроллера или корректен ли compatible-строка. Семантическая валидация достигается через YAML-биндинги: dtc проверяет синтаксическую корректность, тогда как биндинги обеспечивают семантическую проверку.

Включить расширенные предупреждения компилятора можно флагом W=1:

make ARCH=arm64 dtbs W=1 2>&1 | grep "Warning\|Error"

Для инспекции уже скомпилированного .dtb или живого дерева устройств на работающей системе используется fdtdump и утилита fdtget:

# Просмотр структуры скомпилированного DTB
fdtdump output.dtb | less

# Чтение конкретного свойства
fdtget output.dtb /i2c0 clock-frequency
# Вывод: 400000

# Проверка живого дерева на целевой системе
ls /proc/device-tree/
cat /proc/device-tree/compatible

Каталог /proc/device-tree/ отражает дерево устройств в том виде, в каком его видит работающее ядро. Если оверлей был применён успешно, его узлы появятся здесь немедленно. Если чего-то не хватает, это первое место, где стоит искать.

Для сравнения двух DTB, например до и после применения оверлея, существует инструмент dtx_diff:

dtx_diff system-before.dtb system-after.dtb

Вывод покажет только изменения: добавленные и удалённые узлы, изменённые свойства. Этот инструмент особенно ценен при отладке сложных оверлеев, когда нужно точно понять, что именно изменилось в дереве после его применения.

Компиляция дерева устройств кажется механической задачей ровно до того момента, пока не возникает первая нетривиальная проблема: оверлей не находит целевой узел, пропущен флаг -@, или кросс-компилятор и версия ядра рассинхронизированы. За каждой такой ошибкой стоит конкретная причина, и понимание устройства всей цепочки от .dts до загрузчика делает поиск этих причин быстрым и уверенным, а не случайным перебором вариантов.