Device Tree, Linux tarafından gelmeyen biri için öğrenmesi zor bir konsept; ben de zorlananlardandım.
Türkçeye “donanım tarifnamesi” diye çevirebiliriz. Her PCB için bir .dts
dosyası oluşturuyorsunuz. Bu dosya işlemciye bağlı tüm elektronik birimleri
hiyerarşik bir yapıda tanımlıyor.
Bu cümle doğru, ama üzerine bir şey inşa etmek zor. O yüzden bu yazı gerçek bir örneği baştan sona takip ediyor: bir STM32 Nucleo kartına BME280 sensörü eklemek; yazdığınız overlay’den, sonunda dönüştüğü makine koduna kadar. Aşağıdaki her şey Zephyr 4.4.2 ile derlendi.
board.h’den devicetree’ye#
Bare-metal’de kartın tarifi bir header’da yaşar:
/* board.h */
#define LED_PORT GPIOA
#define LED_PIN 5
#define SENSOR_I2C I2C1
#define SENSOR_ADDR 0x77
Çalışıyor ve üç sessiz sorunu var. Onu kimse kontrol etmiyor: bir pin
numarasındaki yazım hatası sorunsuz derleniyor. Sizin kodunuz dışında kimse
onu okuyamıyor; bu yüzden build, kartın hangi driver’lara ihtiyaç duyduğunu
bilemiyor. Ve kodla iç içe: ilk #ifdef BOARD_REV_B ortaya çıktığı anda
donanımın tarifi ile firmware’in mantığı aynı dosya oluyor.
Bir devicetree aynı bilgileri veri olarak, build’in okuyabileceği, kontrol edebileceği ve koda dönüştürebileceği bir formatta tutuyor.
Üç katman: SoC, kart, overlay#
Bir Zephyr devicetree’si katmanlardan oluşuyor ve her katmanın sahibi farklı:
- SoC
.dtsiçipi tarif ediyor: her çevre birimini adresi, kesmeleri ve clock’larıyla. Çoğustatus = "disabled"olarak işaretli. Bu dosyanın sahibi silikon üreticisi ve Zephyr maintainer’ları. - Kart
.dtsSoC’u dahil ediyor ve PCB’yi tarif ediyor: hangi çevre birimleri bağlı ve nasıl, hangi pinleri kullanıyorlar, LED’ler ve butonlar nerede, hangi UART konsol. Sahibi kartı tasarlayan kişi. - Overlay’ler projenizin bunların üzerine yaptığı değişiklikler: eklediğiniz sensör, açılmasını istediğiniz çevre birimi, taşıdığınız pin. Bunların sahibi sizsiniz.
Zephyr’in BME280 örneğiyle gelen overlay şu:
&arduino_i2c {
status = "okay";
bme280@77 {
compatible = "bosch,bme280";
reg = <0x77>;
};
};
Neyi söylemediğine dikkat edin: hiçbir STM32 I2C çevre biriminin adını
anmıyor. Arduino konnektöründeki I2C hattının etiketi olan arduino_i2c‘ye
atıf yapıyor. Nucleo kartının konnektör dosyası o etiketi tek satırla
eşliyor:
arduino_i2c: &i2c1 {};
nRF52840 DK ise onu kendi i2c0‘ına eşliyor. Yani bu overlay hiç de Nucleo
için yazılmış değil. Konnektör için yazılmış ve iki kart için de hiç
değiştirilmeden derleniyor: Nucleo’da build STM32 I2C driver’ını, DK’da
Nordic’inkini açıyor.
Build’den sonra birleştirilmiş sonuç build/zephyr/zephyr.dts içinde ve bu,
build klasörünün tamamındaki en faydalı dosya. Her özelliğin yanında hangi
dosyanın hangi satırından geldiğini söyleyen bir yorum var. Nucleo’daki I2C
node’u, kısaltılmış ve yollar sadeleştirilmiş hâliyle:
i2c1: arduino_i2c: i2c@40005400 {
compatible = "st,stm32-i2c-v1"; /* in zephyr/dts/arm/st/f4/stm32f4.dtsi:316 */
reg = < 0x40005400 0x400 >; /* in zephyr/dts/arm/st/f4/stm32f4.dtsi:320 */
interrupts = < 0x1f 0x0 >,
< 0x20 0x0 >; /* in zephyr/dts/arm/st/f4/stm32f4.dtsi:322 */
pinctrl-0 = < &i2c1_scl_pb8 &i2c1_sda_pb9 >; /* in zephyr/boards/st/nucleo_f401re/nucleo_f401re.dts:107 */
clock-frequency = < 0x61a80 >; /* in zephyr/boards/st/nucleo_f401re/nucleo_f401re.dts:110 */
status = "okay"; /* in arduino_i2c.overlay:14 */
/* node '/soc/i2c@40005400/bme280@77' defined in arduino_i2c.overlay:16 */
bme280@77 {
compatible = "bosch,bme280"; /* in arduino_i2c.overlay:17 */
reg = < 0x77 >; /* in arduino_i2c.overlay:18 */
};
};
Üç katman da tek bir node’da görünüyor. Adres, kesmeler ve compatible SoC
dosyasından geliyor. Pinler (PB8 ve PB9) ve 400 kHz’lik hat hızı karttan.
status ve sensörün kendisi overlay’den. Bir build beklemediğiniz bir şey
yaptığında, kimin ne dediğini bu dosya söylüyor.
Binding’ler: compatible’a anlamını veren şey#
compatible = "bosch,bme280" serbest metinli bir yorum değil. Build onu bir
binding ile eşleştiriyor: dts/bindings altında, o türden bir node’un hangi
özelliklere, hangi tiplerde sahip olabileceğini ve hangilerinin zorunlu
olduğunu söyleyen bir YAML dosyası. BME280’nin binding’i kısa, çünkü neredeyse
her şeyi miras alıyor:
description: BME280 integrated environmental sensor
compatible: "bosch,bme280"
include: [sensor-device.yaml, i2c-device.yaml]
Devicetree’yi bir tariften kontrol edilen bir tarife dönüştüren şey
binding’ler. Örneğin gpio-leds binding’i, her LED’in gpios özelliğini
zorunlu olarak işaretliyor. Bir overlay’e bu özellik olmadan bir LED eklerseniz
build, daha tek bir C dosyası derlenmeden duruyor:
devicetree error: 'gpios' is marked as required in 'properties:' in 'zephyr/dts/bindings/led/gpio-leds.yaml', but does not appear in <Node /leds/led_9 in bad-led.overlay:3>
Binding’i, kuralı ve overlay’inizin tam satırını söylüyor. Bunu board.h
içindeki bir yazım hatasıyla karşılaştırın.
Kendi donanımınız için de binding yazabilirsiniz ve göründüğünden daha az iş. Ayarlanabilir yanıp sönme periyodu olan bir durum LED’i için bir binding:
description: |
A status LED that blinks at a fixed period.
compatible: "fwnotes,status-led"
properties:
gpios:
type: phandle-array
required: true
description: The GPIO that drives the LED.
blink-period-ms:
type: int
default: 500
description: Full on-off period, in milliseconds.
Sonra bir kart overlay’i gerçek donanımı tarif ediyor, bu örnekte Nucleo’daki LD2’yi:
/ {
status_led: status-led {
compatible = "fwnotes,status-led";
gpios = <&gpioa 5 GPIO_ACTIVE_HIGH>;
blink-period-ms = <1000>;
};
};
Kod da onu DT_ makrolarıyla okuyor:
#define STATUS_LED DT_NODELABEL(status_led)
#define PERIOD_MS DT_PROP(STATUS_LED, blink_period_ms)
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(STATUS_LED, gpios);
İki pratik detay. Bir binding, uygulamanızın ya da modülünüzün içindeki bir
dts/bindings klasöründe duruyor ve build onu orada kendiliğinden buluyor.
fwnotes gibi ağaç dışı bir vendor prefix’i ise, binding’lerinizin yanındaki
bir vendor-prefixes.txt dosyasına yazana kadar bir uyarı (has unknown vendor
prefix) alıyor. native_sim ve Nucleo için birer overlay’i olan örneğin tamamı
firmware-notes içindeki device-tree klasöründe
ve CI onu her push’ta çalıştırıyor.
Peki bu nasıl çalışıyor?#
Derleme sırasında CMake (west build‘in yönettiği) kartın .dts dosyasını ve
overlay’lerinizi toplayıp C preprocessor’dan geçiriyor. Bir devicetree’nin
#include ve GPIO_ACTIVE_HIGH gibi makroları kullanabilmesinin sebebi bu.
Sonuç Zephyr’in devicetree script’lerine gidiyor. gen_edt.py onu parse edip
her node’u binding’ine göre kontrol ediyor. gen_defines.py adlı bir Python
scripti de devicetree_generated.h adında bir header dosyası üretiyor. Bu
header içindeki DT_ makroları, donanım bilgilerini C koduna compile time’da
veriyor.
Overlay’deki sensör için header’da 86 makro oluşuyor. Birkaçı:
#define DT_N_S_soc_S_i2c_40005400_S_bme280_77_PATH "/soc/i2c@40005400/bme280@77"
#define DT_N_S_soc_S_i2c_40005400_S_bme280_77_P_compatible {"bosch,bme280"}
#define DT_N_S_soc_S_i2c_40005400_S_bme280_77_P_reg {119 /* 0x77 */}
Bu isimleri hiçbir zaman kendiniz yazmıyorsunuz; DT_NODELABEL(), DT_PROP()
ve benzerleri onları sizin için kuruyor. Ama bir DT_ makrosu derlenmediğinde,
bu header’da node’unuzu aramak build’in gerçekte ne ürettiğini görmenin en
hızlı yolu.
Device Tree ayrıca Kconfig’i de besliyor: etkin her node’un compatible değeri
için bir DT_HAS_<compat>_ENABLED sembolü üretiliyor ve doğru driver’ların
açılıp derlenmesi bu sayede oluyor. BME280 driver’ının Kconfig’i bunun ne kadar
ileri gittiğini gösteriyor:
menuconfig BME280
bool "BME280/BMP280 sensor"
default y
depends on DT_HAS_BOSCH_BME280_ENABLED
select I2C if $(dt_compat_on_bus,$(DT_COMPAT_BOSCH_BME280),i2c)
select SPI if $(dt_compat_on_bus,$(DT_COMPAT_BOSCH_BME280),spi)
Driver, devicetree’de bir BME280 varsa kendini açıyor; I2C alt sistemini ise
yalnızca o BME280 bir I2C hattı üzerindeyse açıyor. Örneğin prj.conf
dosyası iki satırdan ibaret: CONFIG_SENSOR=y ve CONFIG_SENSOR_ASYNC_API=y.
BME280’den de I2C’den de hiç bahsetmiyor; ikisi de açık hâle geliyor, çünkü
devicetree bir I2C hattı üzerinde bir BME280 olduğunu söylüyor.
Hepsi compile time’da#
Tüm bu işlem compile time’da gerçekleşiyor, runtime’da hiçbir yük yok.
Bunu söylemek kolay; nasıl göründüğüne bakalım. Yukarıdaki durum LED’i
örneğinin Nucleo için derlenmiş main() fonksiyonu, disassemble edilmiş ve
kısaltılmış hâliyle:
8000492: mov.w r3, #1000 @ 0x3e8
8000496: movs r2, #5
8000498: bl 8002b7c <printk>
...
800049e: movs r1, #32
...
80004ae: movw r0, #5000 @ 0x1388
80004b2: movs r1, #0
80004b4: bl 80034ee <z_impl_k_sleep>
#1000, overlay’deki yanıp sönme periyodu. #5 pin, yani PA5; #32 de onun
bit maskesi, 1 << 5. #5000 ise PERIOD_MS / 2, yani yarım saniye; bu
kartın saniyede 10.000 tick’ine göre çoktan kernel tick’ine çevrilmiş.
Devicetree binary’ye veri olarak geçmedi. Komutların içindeki sabit
operandlara dönüştü; tıpkı sayıları elle yazmışsınız gibi, tek farkla: build
onları önce kontrol etti.
Linux’ta aynı mekanizma farklı çalışır: .dts bir binary blob’a derlenir ve
kernel bunu runtime’da okur. Zephyr ise bunu bilinçli olarak compile time’a
taşımış, çünkü hedef mikrodenetleyicilerde runtime için harcayacak ne bellek
var ne de işlem gücü.
Neden uğraşmaya değer#
Bare-metal’den gelince bu başta gereksiz karmaşıklık gibi görünüyor. Ama aynı
uygulama kodunu farklı bir PCB için sadece .overlay dosyasını değiştirerek
derleyebiliyorsunuz. Uygulama kodu donanımı hiç tanımıyor, sadece Device
Tree’den gelen soyutlamayı görüyor.
Bunun karşılığı, bir demonun hiç göstermediği yerlerde alınıyor. Sensörü başka
bir I2C hattına taşıyan bir kart revizyonu, bir overlay’de tek satırlık bir
değişiklik. Farklı sensörlü ikinci bir ürün varyantı bir #ifdef değil,
ikinci bir overlay. Arduino konnektörü gibi konnektörlerin kendi etiketleri
olduğu için de bir eklenti kart bir kez tarif edilip o konnektöre sahip her
ana kartta kullanılabiliyor.
Zaman kazandıran üç alışkanlık#
- Şaşırtan her build’den sonra
build/zephyr/zephyr.dts‘i okuyun. Nihai devicetree o ve her özellik nereden geldiğini söylüyor. - Bir
DT_makrosu derlenmediğindedevicetree_generated.hiçinde arayın. Hata neredeyse her zaman node’un ya da özelliğin sandığınız gibi var olmadığı anlamına geliyor; header neyin var olduğunu gösteriyor. - Bir node’a doğru şekilde atıf yapın. Kendi donanımınız için node
etiketlerini (
DT_NODELABEL), her kartın sağlaması beklenen şeyler için takma adları (DT_ALIAS,led0gibi), konsol gibi sistem rolleri için dechosennode’unu (DT_CHOSEN) kullanın.
Devicetree hata mesajları ayrı bir yazıyı hak ediyor ve bu serinin ilerisinde o yazı da gelecek.
Resmi Zephyr reposunda blinky örneğine bakmanızı öneririm. Konunun tamamı için Device Tree dokümantasyonu iyi bir başlangıç. Serinin bir sonraki yazısı yapılandırmanın diğer yarısını anlatıyor: Kconfig.