Table of Contents

Laboratorul 10. Module de Kernel & Drivere de dispozitive

Un sistem embedded poate funcționa doar cu perifericele pe care le-am folosit deja (rețea, card SD, USB), însă va fi strict limitat la hardware-ul pentru care exista deja suport. Ce se întâmplă atunci când dorim să folosim un hardware nou sau diferit de cel pentru care există suport?

Memory Management Unit și importanța sa în dezvoltarea de module

Sisteme embedded fără MMU

În sistemele care nu au MMU scrierea unui modul este directă: fie că modulul este foarte strâns cuplat cu întreg sistemul, fie că este sub forma unei biblioteci de funcții, totul se află în același spațiu de memorie cu programul care rulează (sau sistemul de operare, în cazurile mai complexe). Dezavantajele principale al acestui tip de sistem sunt lipsa de securitate și de stabilitate. Orice vulnerabilitate sau bug afectează și periclitează întreg sistemul, putând duce chiar la compromiterea acestuia.

Mai mult, două programe (de exemplu într-un sistem de operare cu multi-tasking cooperativ) pot concura pentru aceeași resursă, chiar dacă nu rulează concomitent. Dacă luăm exemplul aplicațiilor de la PM, dacă o funcție configura seriala cu un anumit baud rate, apoi ceda controlul altei funcții care seta baud rate la altă valoarea, valoarea finală va fi a doua, iar codul asociat cu prima funcție nu va mai rula corect.

Deși pare greu de ajuns la o asemenea situație, în realitate este foarte ușor: unul din modurile de a implementa sisteme multitasking colaborative este folosind corutine, funcții cu mai multe puncte de intrare care cedează controlul. În astfel de sisteme, o corutină joacă rolul unui proces. Astfel, având mai mult de un dezvoltator putem fi siguri că, mai devreme sau mai târziu, va apărea o situație ca cea menționată în scenariul anterior.

Sistem embedded cu MMU

Sistemele cu MMU rezolvă această problemă. Accesul la hardware poate fi făcut doar de aplicațiile ce rulează într-un mod special, numit și privilegiat. Astfel, codul care interfațează hardware-ul va fi pus într-o zonă protejată de memorie, accesibilă doar din modul privilegiat. Un layer adițional va face comunicația între acest cod și codul neprivilegiat care îl folosește. Astfel, modulul va fi protejat de vulnerabilitățile programelor care îl utilizează, nu va avea probleme în urma crash-urilor lor și va putea face o arbitrare a accesului la resursa hardware pe care o gestionează.

Arhitectura kernelului

Din punct de vedere al modului în care este organizată, arhitectura kernelului poate fi de două tipuri: monolitică și microkernel, însă pot exista și implementări hibride.

Monolitic

Arhitectura monolitică a unui kernel însemnă că toate serviciile oferite de acesta rulează într-un singur proces, în același spațiu de adrese (kernel-space) și cu aceleași privilegii. Toate facilitățile pe care le pune la dispoziție sunt înglobate într-un singur binar.

Microkernel

Spre deosebire de cel monolitic, microkernel-ul implementează un pachet minimal de servicii și mecanisme necesare pentru implementarea unui sistem de operare precum: alocarea memoriei, planificarea proceselor, mecanismele de comunicare între procese (IPC). Restul serviciilor (networking, filesystem, etc.) rulează ca daemoni în user-space (modul neprivilegiat).

După cum se poate observa, un mare dezavantaj al kernel-ului de tip monolitic îl reprezintă lipsa de modularitate și de extensibilitate, lucruri care au fost adăugate ulterior. Astfel, actual, kernel-urile monolitice au posibilitatea de a fi extinse la runtime cu noi funcționalități, prin inserarea de module. Aceste module vor rula în spațiu de adrese al kernelului.

Linux Kernel

În general, kernel-ul Linux rulează pe arhitecturi cu suport de MMU, implementarea tuturor funcționalităților bazându-se în mare măsură pe existența acestei componente hardware. Există însă un fork al kernel-ului existent de Linux pentru arhitecuri fără MMU, numit uClinux. Mai multe detalii despre acest subiect găsiți aici.

De altfel, din punct de vedere al arhitecurii kernel-ului, Linux este monolitic cu posibilitatea de extindere la runtime prin module.

Programarea modulelor de kernel

Având în vedere că modulele rulează în kernel-space, în mod privilegiat, codul trebuie scris cu mare grijă. Astfel, programarea modulelor de kernel este guvernată de anumite reguli:

Modulul Hello world

Orice modul de kernel are nevoie de o funcție de inițializare și o funcție de cleanup. Prima se apelează atunci când modulul este încărcat, a doua este apelată când modulul este descărcat. De asemenea, modulele au nevoie de autor, licență și descriere, utilizarea acestor macro-uri fiind obligatorie.

hello.c
#include <linux/init.h>   /* for __init and __exit */
#include <linux/module.h> /* for module_init and module_exit */
#include <linux/printk.h> /* for printk call */
 
MODULE_AUTHOR("SI");
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Hello world module");
 
static int __init my_init(void)
{
        printk(KERN_DEBUG "Hello, world!\n");
 
        return 0;
}
 
static void __exit my_exit(void)
{
        printk(KERN_DEBUG "Goodbye, world!\n");
}
 
module_init(my_init);
module_exit(my_exit);

Modulul Hello World conține:

  1. Header-ele necesare
  2. Definirea autorului, licenței și descrierii
  3. Funcția my_init
    • Specificatorul __init este un macro pentru __section(.init.text) __cold notrace care specifică gcc-ului în ce segment al executabilului să pună această funcție. În cazul modulelor care se încarcă odată cu kernelul (builtin), funcția init se va apela o singură dată, după care va rămâne în memoria sistemului (kernel-space) până la închiderea sistemului. .init.text este un segment care este eliberat după inițializarea kernel-ului (se poate observa în timp ce pornește sistemul mesajul Freeing unused kernel memory: 108k freed).
    • Funcția printk este echivalentul în kernel al funcției printf. Ieșirea este însă direcționată către un fișier log, /var/log/messages. Diferența în folosire este dată și de specificarea priorității cu macro-ul KERN_ALERT, care de fapt se traduce în șirul de caractere <1>. Sistemul va putea fi configurat astfel să ignore anumite mesaje.
  4. Funcția my_exit
    • Specificatorul __exit este un macro pentru __section(.exit.text) __exitused __cold notrace. În cazul modulelor care se încarcă odată cu kernelul (builtin), funcția exit nu se va apela niciodată, deci va fi ignorată de compilator.
  5. Înregistrarea celor două funcții ca init și exit pentru modulul respectiv.

Module de kernel In-Tree

Am văzut in laboratorul 9 cum putem configura (personaliza) build-ul de kernel, prin selectarea componentelor care vrem să fie parte din imaginea de kernel (build-in) sau compilate ca module de kernel. Aceste module de kernel (fie ele drivere video, module de criptografie sau suportul de sunet) se numesc “in-tree”, deoarece ele fac parte din codul sursă care vine odata cu tot kernel-ul de Linux. Fișierul de configurație al kernel-ului (.config) păstrează aceaste optiuni:

.config
#
# Serial drivers
#
CONFIG_SERIAL_EARLYCON=y
CONFIG_SERIAL_8250=m   # Compiled as kernel module
CONFIG_SERIAL_8250_DEPRECATED_OPTIONS=y
CONFIG_SERIAL_8250_16550A_VARIANTS=y

Avantajul în a avea aceste componente compilate ca module de kernel este că ele pot fi inserate in kernel la cerere (on-demand), în funcție de ce device-uri sunt prezente in sistem și ce nevoi are utilizatorul. Spre exemplu, folosirea NAT-ului în rețea, pentru a folosi sistemul ca un router de Layer 3, va moment in care modulul de kernel nf_nat.ko este incărcat).

Module de kernel Out-of-Tree

Dezavantajul modulelor de kernel In-Tree este că vin împreună cu acea versiune de kernel pentru care au fost compilate (deoarece au codul in același repository). Dacă spre exemplu utilizatorul dorește o versiune mai nouă a unui driver de placă wireless, el trebuie sa aștepte un nou update al distribuției de Linux (ex. Ubuntu), sau trebuie sa recompileze tot kernel-ul.

Majoritatea producătorilor de componente hardware (ex. Nvidia, Intel, Realtek) fac disponibile online atât codul sursă al driverele cât și firmware-ul pe care driver-ul îl încarcă pentru device-ul respectiv. Pentru a face codul modulelor de kernel compatibil cu cat mai multe versiuni de kernel, se apelează la construcții precum:

some_kernel_module.c
#if ( LINUX_VERSION_CODE < KERNEL_VERSION(3,1,0) )
    // Use specific structures before kernel 3.1
#else
    // Use the new structures, after kernel 3.1
#endif

În exemplul de mai sus, dacă compilăm modulul de kernel pentru un kernel mai nou (ex. 5.1), atunci codul va folosi al doilea branch de “if”.

Pentru a compila un modul de kernel Out-of-Tree, utilizatorul are nevoie doar de header-ele de kernel cu care a fost compilat kernel-ul care rulează. Majoritatea distribuțiilor de Linux au aceste headere disponibile:

sudo apt install linux-headers-`uname -r`

Comanda “uname -r” returnează versiune actuală de kernel care rulează (ex. 5.11.0).

Raspberry PI pune la dispozitia utilizatorilor un alt pachet raspberrypi-kernel-headers.

Utilitare pentru lucrul cu module

insmod inserează module în kernel:

~#insmod hello_mod.ko

Modprobe face același lucru, dar cu modulele puse deja în sistemul de fișiere în locul corespunzător (/lib/modules/`uname -r`/… - unde uname -r este versiunea nucleului). În plus, modprobe va citi și lista de dependențe ale modulului de încarcat și o va rezolva (va insera și alte module, dacă e nevoie).

:!: Observați că doar insmod necesită calea modulului (cu tot cu extensie)

~#modprobe hello_mod

Pentru descărcare, se folosește fie:

~#modprobe -r hello_mod

fie:

~#rmmod hello_mod

Afișarea modulelor încărcate se face cu lsmod

~#lsmod

Afișarea mesajelor date cu printk din modul se găsesc în /var/log/messages, se afișează cu:

~#dmesg | tail 

sau cu

~#tail -f /var/log/messages 

GPIO

Placile Raspberry PI dispun de mai multi pini, numiti pini GPIO (General Purpose I/O) pentru a comunica cu dispozitive exterioare. Acestia pot fi folositi direct din nucleul de Linux, folosind un modul de nucleu, sau folosind o biblioteca user-level, in Python, C, sau alte limbaje. Fiecare pin GPIO are un anumit numar fix, dupa cum indica imaginea de mai jos

.

Unii pini pot avea functii specializate, precum PWM sau I2C.

Exerciții

0. Pregătirea imaginii de kernel si a header-elor de kernel

În cadrul laboratorul de astăzi, vom lucra cu module de kernel Out-of-Tree, pe un Raspberry PI fizic, ideal (se poate si in QEMU). Pentru aceasta avem nevoie de header-ele de kernel. Pentru asta, putem instala pachetul `raspberrypi-kernel-headers`

Pentru a va conecta la Raspberry PI-ul din laborator, trebuie sa va conectati la reteaua WiFi LabSI-PR703, cu parola Il0v3-rpi. Utilizatorul de pe RPi este root, iar parola este Il0v3-rpi.

1. Compilați modulul de kernel Hello World cu header-ele de kernel corespunzătoare.

În directorul unde ați copiat hello.c-ul de mai sus, copiați și acest Makefile:

Makefile
obj-m := hello.o
 
all:
    make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
 
clean:
    make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

Pentru a compila modulul de kernel, invocați comanda “make”:

make CROSS_COMPILE=... ARCH=... 

Revizitați laboratorul 09 pentru detalii.

Copiați și incărcați modulul hello.ko pe mașina de Qemu/RPi și verificați că mesajele sunt afișate.

Elixir Cross Reference este un site care indexează sursele de Linux. Îl puteți folosi pentru a vedea definițiile funcțiilor și macro-urilor pe care le veți folosi.

2. Afisati mesajul de “Hello World” cu mai multe niveluri de afisare: info, warning, error, critical. Incercati si cu nivelul de debug. Puteti urmari discutia de aici.

Puteti utiliza 2 functii pentru a afisa mesaje de kernel: printk si pr_<nivel>.

In continuare, pentru nota maxima, puteti alege una dintre cele 2 cai: software, care aprofundeaza modulele de nucleu, sau hardware, care face cehstii sa lumineze / miste. Puteti sa faceti toate exercitiile si sa primiti puncte bonus.

Calea Software

3. Folosiți modulul de kernel de aici pentru a redenumi procesul “Init”

În kernel, un proces este reprezentat de structura struct task_struct

Folosiți structura struct task_struct, cu accent pe cămpurile pid, și parent

Există o variabilă, current, de tip struct task_struct *, care reprezintă procesul curent, și care este exportată de kernel tuturor modulelor.

Inainte de inserarea modulului de kernel, citiți numele procesului Init (PID 1) cu următoarea comanda:

cat /proc/1/status

Nu uitați sa faceți modificarile necesare in fișierul Makefile de la exercitiul anterior

Verificați că numele procesului Init a fost modificat.

4. Dezactivați intreruperea de timp cu ajutorul următorului modul de kernel

Pentru a afla numărul alocat intreruperii de timp, folosiți comanda:

cat /proc/interrupts

Odată ce ați aflat numărul înteruperii, completați numărul interuperii in codul C si recompilați. După inserarea modulului de kernel rulați comanda:

sleep 1

Comanda “sleep 1” ar trebui să returneze după o secundă, dar va rămâne blocată deoarece timer-ul HW (de pe placă de bază) sau SW (din Qemu) nu mai poate genera interuperi. După descărcarea modulului de kernel, comanda sleep va relua execuția.

Listați sursa de ceas pe care o emulează Qemu cu ajutorul comenzii:

cat /sys/devices/system/clocksource/clocksource0

Calea Hardware

La laborator aveti disponibile urmatoarele “jucarii”:

  • 5 Raspberry Pi-uri 4
  • 2 drivere de motoare L298N, care pot comanda cate 2 motoare, prin pinii ENA, IN1, IN2, respectiv ENB, IN3 si IN4. ENA si ENB pot fi folositi ca pini PWM, pentru a controla viteza. Exemplu pentru motorul conectat la ENA, IN1 si IN2:
  • LED-uri normale - au nevoie de o rezistenta de 150 de ohmi
  • fire
  • breadboard-uri
  • baterii de 9V pentru driverele de motoare

Puteti lucra cu oricare din piesele de mai sus, daca sunt disponibile, pentru exercitiile de mai jos.

5. Scrieti un modul de nucleu care aprinde un LED sau care porneste un motor, in functie de ce este disponibil fizic in laborator. Testati folosind materialele din laborator. Modulul trebuie sa execute cel putin urmatorii pasi:

6. Modificati modulul de nucleu pentru a comuta starea LED-ului sau directia de miscare a motorului, la un anumit interval de timp.

pentru a astepta X milisecunde, puteti folosi functia msleep din nucleu.

Nu rulati bucle de tip “while(1)” in module de nucleu. Pentru a porni o actiune care se desfasoara pana cand este eliminat modulul, trebuie folosite thread-uri de nucleu (kthread). Un exemplu de utilizare e aici.

7. Faceti acelasi lucru ca la exercitiul anterior, folosind biblioteca de Python RPi.GPIO.

Urmatoarele functii sunt utile:

  • RPi.GPIO.setmode
  • RPi.GPIO.setup
  • RPi.GPIO.output

8. Folosind RPi.GPIO.PWM, schimbati viteza mortorului sau intensitatea cu care lumineaza LED-ul.

Resurse