Erstellung und Diagnose Hardwareanbindung mit dem Device-Tree unter Zephyr

Von Andreas Klinger 10 min Lesedauer

Anbieter zum Thema

Bei Zephyr werden Devices bereits zur Kompilierzeit aus dem Device-Tree, der Konfiguration und den Sourcen erstellt. Daher ist die Kenntnis des Build-Vorganges essentiell, wenn man strukturiert Devices anbinden und zudem in der Lage sein möchte, Fehler selber diagnostizieren zu können. Dieser Beitrag führt die hierbei wichtigen Zusammenhänge näher aus.

Devicetree Build Flow unter Zephyr.(Bild:  Zephyr Project Documentation)
Devicetree Build Flow unter Zephyr.
(Bild: Zephyr Project Documentation)

Richtig bekannt wurde der Device-Tree zusammen mit Linux. Als es damals darum ging die HardwarebeschreibAung vom Betriebssystemcode auf nicht Intel-kompatiblen Systemen zu trennen, wurde die Open-Firmware-Implementierung auf ARM portiert und als Device-Tree weiter entwickelt. Dabei handelt es sich um eine vom Betriebssystem getrennte Beschreibung des Systems, auf dem der Linux-Kernel laufen soll. Angefangen von der CPU, dem Memory, den Bussystemen bis hin zu den daran angeschlossenen Devices ist alles enthalten, was zur Laufzeit nicht vom Betriebssystem erkundet werden kann. Diese Datenstruktur existiert die gesamte Laufzeit und wird von Treibern auch zur Instanziierung benötigt.

Aus dieser Sichtweise heraus mag der Ansatz von Zephyr überraschend wirken. Bei Zephyr steht der Device-Tree zur Laufzeit nicht zur Verfügung, er wird auch gar nicht beim Booten und bei der Instanziierung von Treibern benötigt. Er hat seine Rolle während der Kompilierzeit! Wobei er selber nicht einmal mit dem dtc zu einem BLOB kompiliert wird so wie wir das von Linux her kennen.

Der Device-Tree unter Zephyr

Bei Zephyr werden aus dem Device-Tree beim Kompillieren des Sourcecodes die fertigen Devices innerhalb des einen Zephyr-Binaries erstellt. Gegenüber dem Ansatz von Linux bringt das zwei wesentliche Vorteile: Ein wesentlich geringerer Overhead zur Laufzeit und vor allem der Umstand, dass dafür keine dynamische Speicherallokationen benötigt werden.

Dabei ist es nicht so, dass es nur einen einzigen Device-Tree gibt. Der Device-Tree wird aus mehreren Quellen heraus erstellt:

  • Device-Tree aus dem Board-Support, welcher wiederum den
  • Architektur-Support verwendet;
  • Device-Tree-Overlays aus dem Projektverzeichnis (Nicht zu verwechseln mit den Device-Tree-Overlays bei Linux, welche zur Laufzeit typischerweise durch den Bootloader angefügt werden); und
  • Device-Tree-Bindings (*.yaml-Dateien) dienen der Prüfung auf Korrektheit der erzeugten Nodes.

Beispiel für ein Device-Tree-Overlay

Bild 1: Verarbeitung vom Device-Tree.(Bild:  Andreas Klinger)
Bild 1: Verarbeitung vom Device-Tree.
(Bild: Andreas Klinger)

Im Projektverzeichnis befindet sich im Unterverzeichnis boards der Device-Tree-Overlay mit dem Dateinamen olimex_stm32_e407.overlay für das GNSS-Modem des Beispielprojektes:

&usart6 {    status = "okay";    gnss: u-blox-m8 {        compatible = "u-blox,m8";        uart-baudrate = <9600>;    };};

Das Modem wird dabei als Subnode des seriellen Controllers mit dem Label usart6 des Device-Trees aus dem Board-Support erstellt. Falls der usart6 mit status = "disabled" angelegt wurde, muss er auf "okay" gesetzt werden.

In einem ersten Konfigurationsschritt wird der Device-Tree (*.dts-Datei) für das gewählte Board mit dem C-Preprozessor verarbeitet. Device-Tree-Includes (*.dtsi-Dateien) und Headerfiles werden inkludiert. Dem daraus entstehenden Device-Tree wird dann noch ein Device-Tree-Overlay mit Anpassungen für das eigene Projekt (*.overlay-Datei) übergelegt. Daraus entsteht die vorkompilierte Device-Tree-Datei namens zephyr.dts.pre.

Diese wird mit Python-Skripten weiter zum fertigen Device-Tree (zephyr.dts) sowie aus Performacegründen zu einer binären und serialisierten Darstellung namens edt.pickle verarbeitet. In diesem Schritt wird der Device-Tree auch auf Korrektheit geprüft. Die dafür notwendigen Informationen sind in YAML-Dateien hinterlegt und werden als Device-Tree-Bindings bezeichnet.

Der fertige Devicetree (zephyr.dts) wird im Build-Verzeichnis abgelegt und dient nur einem Check, ob er syntaktisch in Ordnung ist. Mehr passiert mit ihm nicht. Dennoch ist dieser fertige Device-Tree für die Fehleranalyse herzlich willkommen.

Im Folgenden sehen wir den Ausschnitt von oben zusätzlich mit dem Node des referenzierten seriellen Controller im generierten zephyr.dts.

usart6: serial@40011400 {    compatible = "st,stm32-usart", "st,stm32-uart";    reg = < 0x40011400 0x400 >;    clocks = < &rcc 0x44 0x20 >;    resets = < &rctl 0x485 >;    interrupts = < 0x47 0x0 >;    status = "okay";    pinctrl-0 = < &usart6_tx_pc6 &usart6_rx_pc7 >;    pinctrl-names = "default";    current-speed = < 0x1c200 >;    gnss: u-blox-m8 {        compatible = "u-blox,m8";        uart-baudrate = < 0x2580 >;    };};

Nun wird aus dem edt.pickle ein Headerfile namens devicetree_generated.h erzeugt. In ihm sind alle im Device-Tree vorhanden Nodes mit ihren Properties sowie Hilfsmakros für die weitere Verarbeitung durch den C-Compiler abgelegt. Nodes deren Status nicht "okay" ist, werden erst gar nicht angelegt und fallen daher schon bei diesem Schritt unter den Tisch.

Die Definitionen aus diesem Headerfile sind in weiteren Compiler-Aufrufen verarbeitbar und werden verwendet um die konkreten Instanzen der Treiber zur Compilierzeit anzulegen. Dies passiert in der Applikation oder auch in Treibern immer dann, wenn direkt oder indirekt das Include <zephyr/devicetree.h> verwendet wird.

Nachfolgend das Device-Tree-Binding aus der Datei dts/bindings/gnss/u-blox,m8.yaml:

description: U-BLOX M8 GNSS Modulecompatible: "u-blox,m8"include:  - uart-device.yamlproperties:  uart-baudrate:    type: int    description: |      Baudrate for communication on the UART port.    default: 115200    enum: [4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600]

Ändert man obiges Overlay so, dass es nicht mehr der YAML-Definition entspricht, und startet man den Erstellvorgang erneut wird unmittelbar sichtbar, wie der Build mit einem Fehler abbricht.

Konfiguration

Bild 2: Verarbeitung der Konfiguration(Bild:  Andreas Klinger)
Bild 2: Verarbeitung der Konfiguration
(Bild: Andreas Klinger)

Bei der Software-Konfiguration gibt es zum einen die vom Zephyr-Projekt und auch -Modulen definierte Konfiguration in den Kconfig-Dateien. Dort ist hinterlegt, welche CONFIG_-Schalter es gibt und von welchen anderen Schaltern diese abhängen. Das ist ganz ähnlich wie beim Linux-Kernel oder vielen anderen Open-Source-Projekten.

Jetzt Newsletter abonnieren

Verpassen Sie nicht unsere besten Inhalte

Mit Klick auf „Newsletter abonnieren“ erkläre ich mich mit der Verarbeitung und Nutzung meiner Daten gemäß Einwilligungserklärung (bitte aufklappen für Details) einverstanden und akzeptiere die Nutzungsbedingungen. Weitere Informationen finde ich in unserer Datenschutzerklärung. Die Einwilligungserklärung bezieht sich u. a. auf die Zusendung von redaktionellen Newslettern per E-Mail und auf den Datenabgleich zu Marketingzwecken mit ausgewählten Werbepartnern (z. B. LinkedIn, Google, Meta).

Aufklappen für Details zu Ihrer Einwilligung

Das besondere an Zephyr ist, dass auch Schalter verwendet werden können, welche aus dem vorhergehenden Schritt der Generierung vom Device-Tree erstellt wurden. Dazu ist das verarbeitende Python-Skript (kconfig.py) in der Lage, aus dem edt.pickle heraus Informationen des Device-Trees abzufragen.

So kann geprüft werden, ob im Device-Tree ein Device überhaupt vorhanden ist. Nur dann wird auch die zugehörige Konfiguration angezeigt und später erstellt. Das bedeutet: Wenn man in der Zephyr-Konfiguration einen Treiber nicht angezeigt bekommt, hat man diesen ggf. im Device-Tree gar nicht oder unter falschem Compatible angelegt. Die Vorgehensweise bei der Entwicklung muss also immer so sein, dass man zuerst den Device-Tree anlegt und dann die Konfiguration durchführt.

In den Kconfig-Dateien sind diese Abhängigkeiten vom Device-Tree an den Config-Schaltern mit dem Namen DT_HAS_<compatible-upper>_ENABLED zu erkennen. Dabei ist "<compatible-upper>" durch das Compatible des Treibers in Grossbuchstaben und "-" und "," durch "_" ersetzt zu verwenden. So wird beispielsweise aus compatible = "u-blox,m8" im Devicetree der Schalter DT_HAS_U_BLOX_M8_ENABLED.

Hier der Ausschnitt aus der Kconfig.u_blox_m8:

config GNSS_U_BLOX_M8  bool "U-BLOX M8 GNSS Module"  default y  depends on GNSS  depends on DT_HAS_U_BLOX_M8_ENABLED  depends on GNSS_REFERENCE_FRAME_WGS84  select MODEM_MODULES  select MODEM_BACKEND_UART  select MODEM_CHAT  select MODEM_UBX  select GNSS_PARSE  select GNSS_NMEA0183  select GNSS_NMEA0183_MATCH  select GNSS_U_BLOX_PROTOCOL  select UART_USE_RUNTIME_CONFIGURE  help    Enable U-BLOX M8 GNSS modem driver.

Wie man sieht, ist dieser Config-Schalter vom Vorhandensein des compatible im Device-Tree abhängig.

Bei der Generierung der verwendeten Konfiguration wird nun die Konfiguration des Boards (<board>_defconfig) verwendet und mit der Projekt-Konfiguration (prj.conf) überlagert. Allein die Auswahl des Boards bewirkt eine Selektion des SoC bzw. der Architektur. Mittels Kconfig.defconfig-Dateien werden dadurch eine ganze Menge an Default-Schaltern für den betreffenden SOC eingeschaltet. Dies ist bei Mainline-Boards schon vorhanden und kann einfach verwendet werden.

Am Ende wird die projektspezifische Konfiguration in der Datei prj.conf überlagert und damit die Konfiguration abgeschlossen. Als Ausgabe entsteht die fertige Konfiguration in der Datei .config. Außerdem wird ein Headerfile namens autoconf.h mit Präprozessor-Defines für alle Config-Schalter erzeugt. Dieses wird bei der weiteren Erstellung des Systems verwendet.

Erstellvorgang von Devices

Bild 3: Erstellung der Applikation mit Devices.(Bild:  Andreas Klinger)
Bild 3: Erstellung der Applikation mit Devices.
(Bild: Andreas Klinger)

Nun werden Devices aus dem Treiber-Code heraus erstellt. Wenn jetzt die Konfiguration so ist, dass ein Treibermodul erstellt werden soll, ist es dem Präprozessor überlassen dieses zu generieren.

Am Ende des Treibercodes finden wir ein Makro DT_INST_FOREACH_STATUS_OKAY(). Dieses prüft, ob das Compatible, welches im Makro DT_DRV_COMPAT (im gleichen C-Modul) definiert ist, als Device-Tree-Node mit status = "okay" mindestens einmal existiert. Wenn ja, wird für jede vorhandene Instanz das übergebene Makros aufgelöst und damit die Datenstrukturen inklusive ihrer Einsprungfunktionen zur Compilierzeit angelegt.

Für diese Instanz wird eine struct device __device_dts_ord_<NN> angelegt. Genau diese Instanz wird dann wiederum im C-Code durch den Präprozessor eingesetzt, wo das betreffende struct device von einem Makro DEVICE_DT_GET() zugewiesen wird.

In der Applikation erfolgt die Referenzierung beispielsweise mit:

const struct device *gdev = DEVICE_DT_GET(DT_NODELABEL(gnss));

Das Makro DT_NODELABEL() holt den Device-Tree-Node mit der Labelkennung "gnss"; DEVICE_DT_GET() macht daraus das fertig verwendbare Device.

Kompilliert man mit:

west build [...] -DCONFIG_COMPILER_SAVE_TEMPS=y

werden die vom Präprozessor generierten C-Files nicht gelöscht und können zur Diagnose verwendet werden. Diese Dateien heissen *.c.i und existieren für die Dateien des Projektes (z. B. main.c.i) sowie für die Treiber (z. B. gnss_u_blox_m8.c.i). Gerade für die Fehlersuche kann dies sehr nützlich sein.

Die obige C-Zeile ist in der vom Präprozessor generierten Datei main.c.i zu finden:

const struct device *gdev = (&__device_dts_ord_102);

Und genau nach dem automatisch vergebenen Device-Namen __device_dts_ord_102 im Beispiel kann man in den Precompilierten Treiber-Sourcen suchen und gelangt dadurch zur eigentlichen Treiberinstanz:

struct device __device_dts_ord_102 __attribute__((section("." "_device" ".""static" "." "3_80_"))) __attribute__((__used__)) = { .name = "u-blox-m8", .config = (&ubx_m8_cfg_0), .api = (&gnss_api), .state =(&__devstate_dts_ord_102), .data = (&ubx_m8_data_0), }; static const

Für die Fehlersuche ein ganz wichtiger Zusammenhang. Wenn beispielsweise beim Bauen die Fehlermeldung entsteht, dass ein __device_dts_ord_XX nicht gefunden wird, sollte man prüfen, ob für die im Device-Tree verwendeten Compatibles auch entsprechende Treiber konfiguriert und mit dem Präprozessor erstellt wurden.

Auf den ersten Blick erscheint dies ziemlich viel Präprozesser-Magie zu sein. Bei genauerer Betrachtung des Makros DT_INST_FOREACH_STATUS_OKAY() zusammen mit einem beispielhaft generierten devicetree_generated.h sowie der devicetree.h aus den Includes im Repository werden die Zusammenhänge aber schnell logisch und nachvollziehbar.

Workflow für die Anbindung von Devices

Beim Einbinden von Devices in Zephyr empfiehlt sich folgender Workflow:

1. Einbinden des Devices in den Device-Tree: Dazu sollte man das Device-Tree-Overlay für das verwendete Board im Unterverzeichnis boards des Projektordners erstellen und den Node entsprechend der Device-Tree-Bindings (YAML-Files im Verzeichnis zephyr/dts/bindings/) anlegen. Ist das Device Teilnehmer eines Bussystems (z. B. I2C, SPI) ist darauf zu achten, dass auch das übergeordnete Bussystem mit status = "okay" angelegt wurde.

2. Konfiguration des Projektes im Menuconfig: Der Treiber muss in der Konfiguration eingeschaltet werden; sollte er gar nicht sichtbar sein empfiehlt es sich, alle Konfigurationsschalter (auch die nicht erreichbaren) sichtbar zu machen (Taste <a>) und mittels der eingebauten Hilfe (Taste <?>) nach unerfüllten Abhängigkeiten (depends) zu suchen und aufzulösen. Gefundene Config-Schalter müssen in die Projekt-Konfiguration prj.conf eingetragen werden.

3. Im C-Code das Device referenzieren: Mit dem Makro DEVICE_DT_GET() kann aus einem Device-Tree-Node ein struct device erhalten werden. Der Device-Tree-Node wiederum kann gut mittels Label oder Node-Name erhalten werden.

4a. Wenn beim Erstellen keine Fehler auftreten und das C-Modul kompilliert wurde, kann man davon ausgehen, dass das Device zur Kompillierzeit referenziert werden konnte und softwaretechnisch verfügbar ist.

4b. Sollten beim Erstellen Fehler auftreten, empfiehlt sich zunächst die Überprüfung der Device-Tree-Compatibles auf genaue Schreibweise, Prüfung der erstellten Konfiguration in .config, Prüfung des aus dem Device-Tree generierten C-Codes in devicetree_generated.h und ggf. die Erstellung unter Beibehaltung der temporären Dateien, um die vorverarbeiteten C-Dateien anschauen zu können. Dies geschieht mit dem Übergabeargument -DCONFIG_COMPILER_SAVE_TEMPS=y beim Aufruf von west.  (sg)

* Andreas Klinger ist selbständiger Trainer und Entwickler. Als Spezialist für Linux beschäftigt er sich mit dem internen Aufbau des Kernels, den Systemmechanismen sowie vor allem mit deren Einsatz in Embedded Systemen. Contributor zum Linux-Kernel, Zephyr und einigen anderen Open-Source-Projekten.

(ID:50226013)