Wstęp
Device-tree, co było wspominane na początku, jest plikiem opisującym konfigurację sprzętową. Jest ono ładowane do pamięci podczas startowania systemu operacyjnego. Warto jeszcze zwrócić uwagę, że device-tree nie jest używane w przypadku każdej architektury. Np. w przypadku architektury x86/x86_64 nie uświadczysz pracy z device-tree. Natomiast w przypadku procesorów z rodziny ARM czy ARM64 jest używane właściwie zawsze.
Istotne jest jeszcze pytanie co umieszczamy w device-tree? Czy umieszczamy tam wszystkie urządzenia podłączone do płytki? Odpowiedź brzmi nie, w device-tree umieszczamy tylko urządzenie, które nie posiadają mechanizmu wykrywania. Czyli mówiąc obrazowo, będziemy tam umieszczać urządzenia komunikujące się po interfejsach takich jak I2C, SPI czy 1Wire, ale nie będziemy tam umieszczać urządzeń używających np. USB ponieważ te posiadają mechanizm hotplug.
Składnia
Device-tree składa się z węzłów(node’ów). Każdy węzeł może posiadać swoje właściwości(properties) i może zawierać kolejny węzeł potomny, w przypadku gdy węzeł posiada jakieś właściwości i węzły potomne to właściwości muszą być zdefiniowane przed tymi węzłami. Każdy węzeł posiada tylko jednego rodzica za wyjątkiem węzła root, który to nie posiada żadnego rodzica. Dodatkowo węzły mogą mogą się do siebie wzajemnie odwoływać(przez tę właściwość device-tree w sensie matematycznym jest grafem a acyklicznym, a nie drzewem, ale to taka ciekawostka). Kod device-tree przechowuje się w plikach dts, a nagłówki device-tree w plikach dtsi. No dobra, tyle teorii, a jak to wygląda?
W źródłach kernela istnieje pliki arch/arc/boot/dts/skeleton.dtsi, który dobrze prezentuje jak wygląda takie device-tree w ogólności:
/ {
compatible = "snps,arc";
#address-cells = <1>;
#size-cells = <1>;
chosen { };
aliases { };
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu@0 {
device_type = "cpu";
compatible = "snps,arc770d";
reg = <0>;
clocks = <&core_clk>;
};
};
/* TIMER0 with interrupt for clockevent */
timer0 {
compatible = "snps,arc-timer";
interrupts = <3>;
interrupt-parent = <&core_intc>;
clocks = <&core_clk>;
};
/* TIMER1 for free running clocksource */
timer1 {
compatible = "snps,arc-timer";
clocks = <&core_clk>;
};
memory {
device_type = "memory";
reg = <0x80000000 0x10000000>; /* 256M */
};
};
Nie wygląda to jakoś strasznie chyba. Przynajmniej w takiej małej skali. Oczywiści device-tree dla BBB czy RPi4 jest znacznie bardziej złożone i ich device-tree te składają się z kilku plików.
W niektórych linijkach możesz zobaczyć odwołania typu &core_clk i &core_intc. Są to właśnie odwołania do innych węzłów. core_clk oraz core_intc są etykietami i definiujemy je tak:
etykieta: węzeł {
…
};
W praktyce to może wyglądać np. tak:
core_intc: interrupt_controller {
...
};
Do zdefiniowanych węzłów możesz się nie tylko odwoływać, możesz je również modyfikować np. poprzez dodawanie do nich kolejnych węzłów potomnych. Pokażmy to może na przykładzie magistrali I2C. Mamy zdefiniowaną magistralę I2C w device-tree:
i2c1: i2c@48000000 {
…
}
Powyższy kod może być np. w pliku dtsi, który jest współdzielony przez wiele urządzeń bazujących na danej architekturze. Załóżmy, że chcemy dodać jakieś urządzenie na naszej płytce, zatem byśmy załadowali ten plik dtsi, a w naszym pliku dts byśmy dodali wpis:
&i2c1 {
nasze_urządzenie@10 {
...
}
}
Będziemy używać tego typu zapisu w dalszej części lekcji.
Kompilacja
Kompilacja device-tree polega na przetworzeniu pliku dts to postaci pliku binarnego(pliku dtb- device-tree blob), który może być później załadowany do pamięci i użyty przez system operacyjny.
Do kompilacji device-tree używamy programu dtc. Nie będziemy go używać bezpośrednio, choć jego użycie jest bardzo proste:
dtc -O dtb -o output_file.dtb input_file.dts
Z ciekawych rzeczy to kompilacja device-tree jest odwracalna. Tak, możemy sobie podkraść device-tree z jakiegoś urządzenia i sprawdzić jego konfiguracje, służy do tego komenda:
dtc -I dtb -O dts -o output_file.dts input_file.dtb
Nie będziemy używać tych komend, pozwolimy kernelowym Makefile’om wykonać za nas całą robotę.
Bindingi
Bindingi opisują sposób w jaki urządzenie powinno być zdefiniowane w device-tree. Bindingi są dostępne w kodzie kernela w katalogu Documentation/devicetree/bindings. Opisy bindingów są obecnie zapisywane w formacie YAML, wcześniej były to zwykłe pliki tekstowe.
Będziemy podłączać akcelerometr MPU6050. Binding dla tego urządzenia również istnieje: Documentation/devicetree/bindings/iio/imu/invensense,mpu6050.yaml
W pliku tym mamy opisane jakie właściwości to urządzenie posiada, a na samym dole mamy przykład. Może to go weźmy na tapet:
i2c {
#address-cells = <1>;
#size-cells = <0>;
imu@68 {
compatible = "invensense,mpu9250";
reg = <0x68>;
interrupt-parent = <&gpio3>;
interrupts = <21 IRQ_TYPE_LEVEL_HIGH>;
mount-matrix = "-0.984807753012208", /* x0 */
"0", /* y0 */
"-0.173648177666930", /* z0 */
"0", /* x1 */
"-1", /* y1 */
"0", /* z1 */
"-0.173648177666930", /* x2 */
"0", /* y2 */
"0.984807753012208"; /* z2 */
i2c-gate {
#address-cells = <1>;
#size-cells = <0>;
magnetometer@c {
compatible = "ak,ak8975";
reg = <0x0c>;
};
};
};
};
Idąc od samej góry mamy węzeł:
imu@68 {
...
}
Jest to węzeł potomny węzła i2c, wiemy zatem, że będziemy musieli podpiąć nasz akcelerometr pod jedną z dostępnych magistrali i2c. Co do nazwy to w zasadzie może być ona dowolna, ale dobrze jeśli odnosi się ona do definiowanego urządzenia. Liczba po @ nie definiuje adresu urządzenia, jest to część nazwy, ale wg ogólnie przyjętej konwencji po @ stawiamy adres urządzenia. W tym przypadku jest to domyślny adres MPU6050 czyli 0x68.
Następnie mamy właściwość „compatible”. Jest to właściwość dzięki której Linux wie za pomocą którego sterownika ma obsługiwać dane urządzenie.
Kolejną właściwością jest reg, nazwa może być nieco myląca bo nie chodzi o żaden rejestr tylko o adres urządzenia na magistrali I2C. My użyjemy domyślnego adresu czyli 0x68.
interrupt-parent to chip GPIO na którym znajduje się pin GPIO do którego jest podłączony pin INT akcelerometru. Właściwość interrupts to natomiast już dokładny pin na tym chipie oraz informacja na jakie przerwanie sterownik ma reagować. Trochę to zagmatwane z tymi przerwaniami i chipami GPIO, ale wynika to z konstrukcji urządzeń. Zostanie to wytłumaczone na przykładzie.
Następną właściwością jest mount-matrix, jest to macierz kalibrująca pracę MPU6050. Nie będziemy tego używać.
No i na koniec mamy i2c-gate. Czym to jest? W pliku Documentation/devicetree/bindings/i2c/i2c-gate.yaml mamy taką informacje:
An i2c gate is useful to e.g. reduce the digital noise for RF tuners connected to the i2c bus. Gates are similar to arbitrators in that you need to perform some kind of operation to access the i2c bus past the arbitrator/gate, but there are no competing masters to consider for gates and therefore there is no arbitration happening for gates.
Co to znaczy? Jeśli dobrze rozumiem to i2c-gate jest urządzeniem, które w jakiś sposób blokuje bezpośredni dostęp do magistrali I2C, aby się do niej dostać trzeba wykonać jakiś zestaw operacji, który zostanie zaakceptowany przez bramkę, dopiero gdy bramka zaakceptuje nasze operacje MPU6050 będzie mogło przesłać dane po magistrali. Jakieś to dziwne jest dlatego nie będziemy tego używać 🙂
Dodawanie urządzenia do device-tree
Uzbrojeni w wiedzę z poprzednich sekcji możemy przejść w końcu do dodania własnego urządzenia w systemie. Niestety, użytkownicy QEMU znowu będą zawiedzeni ponieważ ze względu na brak możliwości podłączenia czegokolwiek z oczywistych względów nie będą w stanie wykonać tego ćwiczenia.
BeagleBone Black
W przypadku BBB mamy wyprowadzoną magistralę I2C2, do tej magistrali musimy podłączyć MPU6050. Device-tree dla BBB składa się z wielu pliku dtsi oraz pliku, który to zbiera w całość o nazwie am335x-boneblack.dts znajdującego się w katalogu arch/arm/boot/dts/. Otwórz ten plik i dodaj taki wpis na końcu tego pliku:
&i2c2 {
mpu6050@68 {
compatible = "invensense,mpu6050";
reg = <0x68>;
interrupt-parent = <&gpio3>;
interrupts = <21 IRQ_TYPE_EDGE_RISING>;
};
};
Jak widzimy odwołujemy się tutaj do magistrali i2c2 poprzez &i2c2 i dodajemy do niej nowy węzeł mpu6050. W właściwości compatible podaliśmy nazwę producenta i model urządzenia, dzięki temu Linux powiąże nasz wpis z odpowiednim sterownikiem. W reg podaliśmy adres MPU6050 na magistrali I2C2 czyli 0x68. Dwa pozostałe wpisy nie są potrzebne, ale może na przykładzie wytłumaczymy o co chodzi. BBB posiada kilka chipów(modułów) GPIO, każdy z nich posiada 32 piny. Skoro na pinoutcie BBB widzimy np. pin GPIO_117 to skąd się wzięła ta liczba skoro każdy chip ma tylko 32 nóżki? Numer pinu GPIO obliczamy w taki sposób:
numer_chipu * 32 + numer_pinu
W powyższym przykładzie używamy pinu 21 na chipie gpio3 czyli:
3 * 32 + 21 = 117
Zatem jeśli chciałbyś obsługiwać przerwania pochodzące od MPU6050 musisz podłączyć pin INT akcelerometru do pinu GPIO_117 BBB.
Wpis IRQ_TYPE_EDGE_RISING oznacza, że przerwanie ma być generowane tylko w wyniku reakcji na zbocze narastające sygnału.
Device-tree jest gotowe, ale nie przebudowywuj jeszcze kernela.
Raspberry Pi 4
W przypadku RPi4 mamy wyprowadzoną na piny płytki magistralę I2C1 i to do niej podłączymy MPU6050. Device-tree RPi4 składa się oczywiście z wielu plików dtsi oraz pliku bcm2711-rpi-4-b.dts znajdującego się w katalogu arch/arm64/boot/dts/broadcom. Otwórz ten plik i dodaj tam taki wpis:
&i2c1 {
mpu6050@68 {
compatible = "invensense,mpu6050";
reg = <0x68>;
interrupt-parent = <&gpio>;
interrupts = <4 IRQ_TYPE_EDGE_RISING>;
};
};
Jak widzimy odwołujemy się tutaj do magistrali i2c1 poprzez &i2c1 i dodajemy do niej nowy węzeł mpu6050. W właściwości compatible podaliśmy nazwę producenta i model urządzenia, dzięki temu Linux powiąże nasz wpis z odpowiednim sterownikiem. W reg podaliśmy adres MPU6050 na magistrali I2C1 czyli 0x68. Dwa pozostałe wpisy opisują obsługę przerwań, wytłumaczymy na tym przykładzie o co chodziło z tymi kontrolerami przerwań w sekcji o bindingach. RPi4 posiada jeden chip GPIO więc nie mamy problemy z jego wyborem. Następnie wskazujemy numer nóżki, w naszym przykładzie będzie to pin nr 4. Zatem jeśli chciałbyś otrzymywać przerwania od MPU6050 musisz podłączyć pin INT akcelerometra z pinem GPIO4 RPi4. IRQ_TYPE_EDGE_RISING oznacza, że przerwanie ma być generowane w reakcji na zbocze narastające sygnału.
Device-tree jest gotowe, ale nie buduj jeszcze kernela.
Konfiguracja kernela
Mamy device-tree, ale nie mamy jeszcze sterownika, który by to obsłużył w naszym kernelu. Dodaj więc odpowiednie sterowniki w swoim kernelu.
BeagleBone Black
Uruchom menuconfig i dodaj następujące opcje jako wkompilowane w kernel:
- CONFIG_IIO
- CONFIG_I2C_MUX
- CONFIG_INV_MPU6050_I2C
Dla przypomnienia, tryb wyszukiwania uruchamiasz za pomocą klawisza /.
Teraz możesz przebudować swój kernel i wgrać nowozbudowane device-tree i kernel na kartę pamięci.
Raspberry Pi 4
Uruchom menuconfig i dodaj następujące opcje jak wkompilowane w kernel:
- CONFIG_I2C_CHARDEV
- CONFIG_I2C_BCM2835
- CONFIG_I2C_BRCMSTB
- CONFIG_IIO
- CONFIG_I2C_MUX
- CONFIG_INV_MPU6050_I2C
Dla przypomnienia, tryb wyszukiwania uruchamiasz za pomocą klawisza /.
Teraz możesz przebudować swój kernel i wgrać nowozbudowane device-tree i kernel na kartę pamięci.
Podłączenie
Podłącz MPU6050 do wyprowadzeń magistrali I2C.
BeagleBone Black
Po pierwsze MPU6050 podłącz do napięcia 3.3V czyli np. do pinu P9_3.
W przypadku BBB magistrala I2C jest wyprowadzona na pinach P19_19(linia SCL, zegar) oraz P9_20(linia SDA, dane):
Chętni mogą podłączyć jeszcze pin INT MPU6050 do pinu GPIO_117 jednak nie będziemy się tym dzisiaj zajmować.
Raspberry Pi 4
Przede wszystkim podłącz MPU6050 do napięcia 3.3V. W przypadku RPi4 magistrala I2C jest wyprowadzona na pinach 3 i 5:
Chętni mogą podłączyć jeszcze pin INT MPU6050 do pinu GPIO_4 jednak nie będziemy się tym dzisiaj zajmować.
Jak tego użyć w Linuksie?
Gdy już masz wszystko podłączone możesz uruchomić swoją płytkę. Zaloguj się do systemu, przejdź do katalogu z plikami urządzenia:
# BBB
cd /sys/bus/i2c/devices/2-0068/iio:device0/
# RPi4
cd /sys/bus/i2c/devices/1-0068/iio:device0/
Jak było wspomniane w poprzedniej lekcji, urządzenia podłączone do odpowiedniej magistrali są widoczne w katalogu odpowiadającej tej magistrali.
Wewnątrz tego katalogu znajduje się kilkanaście plików, dla nas najbardziej interesujące są pliki in_accel_[xyz]_raw oraz in_angvel_[xyz]_raw. To one zawierają informacje zbierane przez akcelerometr i żyroskop czyli przyspieszenie i prędkość kątową. Możesz sprawdzić jeden z plików np.:
cat in_accel_x_raw
A następnie zacząć obracać MPU6050 ciągle sprawdzając zawartość tego pliku. Pownieneś widzieć, że jego zawartość ciągle się zmienia.
Brawo! Właśnie dodałeś obsługę urządzenia do kernela Linuksa za pomocą device-tree!
Jako pracę domową możesz się zastanowić jak zmodyfikować device-tree tak aby dioda podłączona w poprzedniej lekcji była obsługiwana przez sterowniki do obsługi LEDów.
Reprezentacja device-tree w systemie
Device-tree można również podejrzeć w katalogu /proc/devicetree. W tym przypadku nie ma ono formy tekstowej tylko na jego podstawie jest utworzona odpowiednia struktura katalogów. Możesz zapoznać się z tym katalogiem.