Каждый раз, когда 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 до загрузчика делает поиск этих причин быстрым и уверенным, а не случайным перебором вариантов.