Table of Contents

Laborator 4 - Device drivere în Linux

Obiectivele laboratorului

Cuvinte cheie

Materiale ajutătoare

Concepte generale

În UNIX, dispozitivele hardware sunt accesate de utilizator prin intermediul fișierelor speciale de tip dispozitiv (device). Aceste fișiere sunt grupate în directorul /dev, iar apelurile de sistem open, read, write, close, lseek, mmap etc. sunt redirecționate de sistemul de operare către device driverul asociat cu dispozitivul fizic. Device driverul este o componentă a nucleului (de obicei un modul) care interacționează cu un dispozitiv hardware.

În lumea UNIX există două categorii de fișiere dispozitiv și, implicit, device drivere: de tip caracter și de tip bloc. Această împărțire este făcută după viteza, volumul și modul de organizare a datelor ce trebuie transferate de la dispozitiv către sistem și invers. În prima categorie intră dispozitivele lente, care gestionează un volum mic de date, iar accesul la date nu necesită operații de căutare (seek) frecvente. Exemple sunt dispozitive cum ar fi tastatura, mouse-ul, porturile seriale, placa de sunet, joystick-ul. În general operațiile cu aceste dispozitive (citire, scriere) se realizează secvențial, octet cu octet. Cea de-a doua categorie cuprinde dispozitive la care volumul de date este mare, datele sunt organizate pe blocuri și operațiile de căutare (seek) sunt frecvente. Exemple de dispozitive ce intră în această categorie sunt hard disk-urile, cdrom-urile, ram discurile, unitățile de bandă magnetică. În cazul acestor dispozitive, citirea și scrierea se realizează la nivel de bloc de date.

Pentru cele două tipuri de device drivere, nucleul Linux oferă API-uri diferite. Dacă pentru dispozitivele de tip caracter apelurile de sistem ajung direct la device drivere, în cazul dispozitivelor de tip bloc device driverele nu lucrează direct cu apelurile de sistem. În cazul dispozitivelor de tip bloc, comunicația între user-space și device driverul de tip bloc este intermediată de subsistemul de gestiune a fișierelor și de subsistemul de block device. Rolul acestor subsisteme este de a pregăti device driverului resursele necesare (buffere), de a menține în buffer cache datele recent citite și de a reordona operațiile de citire și scriere din rațiuni de performanță.

Identificator major și minor

În UNIX, în mod tradițional, dispozitivele aveau asociate un identificator unic, fixat. Această tradiție se păstrează și în Linux, deși este posibil ca identificatorii să se aloce dinamic (din motive de compatibilitate însă, majoritatea driverelor folosesc încă identificatori statici). Identificatorul este format din două părți: major și minor. Prima parte (major) identifică tipul dispozitivului (disc IDE, disc SCSI, port serial etc.), iar cel de-al doilea identifică dispozitivul (primul disc, al doilea port serial etc.). De cele mai multe ori, majorul identifică driverul, în timp ce minorul identifică fiecare dispozitiv fizic deservit de driver. În general, un driver va avea asociat un major și va fi responsabil de toți minorii asociați cu acel major.

# ls -la /dev/hda? /dev/ttyS?
brw-rw----  1 root disk    3,  1 2004-09-18 14:51 /dev/hda1
brw-rw----  1 root disk    3,  2 2004-09-18 14:51 /dev/hda2
crw-rw----  1 root dialout 4, 64 2004-09-18 14:52 /dev/ttyS0
crw-rw----  1 root dialout 4, 65 2004-09-18 14:52 /dev/ttyS1

După cum se observă din exemplul de mai sus, informații pentru fișiere de tip device se pot afla folosind comanda ls. Fișierele speciale de tip caracter sunt identificate prin caracterul c în prima coloană a ieșirii comenzii, iar cele de tip bloc prin caracterul b. În coloanele 5 și 6 ale rezultatului comenzii se pot observa majorul, respectiv minorul pentru fiecare dispozitiv.

Anumiți identificatori major sunt atribuiți în mod static dispozitivelor (în fișierul Documentation/devices.txt din sursele kernel-ului). La alegerea identificatorului pentru un nou dispozitiv se pot folosi două metode: static (se alege un număr care pare să nu fie folosit deja) sau dinamic. În /proc/devices se găsesc dispozitivele încărcate, împreună cu identificatorul major.

Pentru a crea un fișier de tip dispozitiv, se folosește comanda mknod; comanda primește ca argumente tipul (bloc sau caracter), majorul și minorul dispozitivului (mknod name type major minor). Astfel, dacă se dorește crearea dispozitivului de tip caracter cu numele mycdev cu majorul 42 și minorul 0 se folosește comanda:

# mknod /dev/mycdev c 42 0

Pentru a crea dispozitivului de tip block cu numele mybdev cu majorul 240 și minorul 0 comanda folosită va fi:

# mknod /dev/mybdev b 240 0

În continuare, ne vom referi la drivere pentru dispozitive de tip caracter.

Structuri de date importante pentru un dispozitiv de tip caracter

În kernel, un dispozitiv de tip caracter este reprezentat de structura cdev, structură folosită la înregistrarea acestuia în sistem.

Majoritatea operațiilor cu drivere folosesc trei structuri importante: struct file_operations, struct file și struct inode.

Structura file_operations

După cum s-a precizat, device driverele de tip caracter primesc nealterate apelurile de sistem efectuate de utilizatori asupra fișierelor de tip dispozitiv. În consecință, pentru implementarea unui device driver vor trebui implementate apelurile de sistem de lucru cu fișiere: open, close, read, write, lseek, mmap etc. Aceste operații sunt descrise în câmpuri ale structurii file_operations 1):

#include <linux/fs.h>
 
struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    [...]
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    [...]
    int (*open) (struct inode *, struct file *);
    int (*flush) (struct file *, fl_owner_t id);
    int (*release) (struct inode *, struct file *);
    [...]
};

Se poate observa că semnătura funcției diferă de apelul de sistem pe care îl folosește utilizatorul. Sistemul de operare se interpune între utilizator și device driver, pentru a simplifica implementarea în device driver.

open nu primește ca parametru calea sau diverșii parametri care controlează modul de deschidere a fișierului. În mod similar, read, write, release, ioctl, lseek nu primesc ca parametru un descriptor de fișier. În schimb, aceste rutine primesc ca parametri două structuri: file și inode. Ambele structuri reprezintă un fișier, dar din perspective diferite.

Majoritatea parametrilor pentru operațiile prezentate au semnificație directă:

Structurile ''inode'' și ''file''

Un inode reprezintă un fișier din punctul de vedere al sistemului de fișiere. Atribute ale unui inode sunt dimensiunea, drepturile, timpii asociați fișierului. Un inode identifică în mod unic un fișier într-un sistem de fișiere3).

Structura file reprezintă tot un fișier, dar mai aproape de punctul de vedere al utilizatorului care a deschis fișierul, deci conține atribute care descriu starea unui fișier deschis. Dintre atributele structurii file enumerăm: inode-ul, numele fișierului, atributele de deschidere ale fișierului, poziția curentă în fișier. Toate fișierele deschise la un moment dat au asociate o structură file, care sunt stocate într-o tabelă pentru fiecare proces. File descriptor-ul este un index în această tabelă.

Pentru a înțelege diferențele dintre inode și file, vom folosi o analogie din programarea orientată pe obiecte: dacă vom considera un inode o clasă, atunci file-urile sunt obiecte, adică instanțe ale clasei inode. Inode-ul reprezintă imaginea statică a fișierului (inode-ul nu are stare), pe când file reprezintă imaginea dinamică a fișierului (file-ul are stare).

Revenind la device drivere, cele două entități au aproape întotdeauna modalități standard de folosire: inode-ul se folosește pentru a determina majorul și minorul device-ului asupra căruia se face operația, iar file-ul se folosește pentru a determina flag-urile cu care a fost deschis fișierul, dar și pentru a memora și accesa (mai târziu) date private.

Structura file conține, printre multe câmpuri, și:

Structura inode conține, printre multe informații, un câmp i_cdev, care este un pointer către structura care definește dispozitivul de tip caracter (atunci când inode-ul corespunde unui dispozitiv de tip caracter).

Implementarea operațiilor

Pentru implementarea unui device driver, se recomandă crearea unei structuri care să conțină informații despre dispozitivul dat, informații utilizate în cadrul modulului. În cazul unui driver pentru un dispozitiv de tip caracter, structura va conține un câmp de tipul struct cdev pentru a referi dispozitivul. Exemplul de mai jos folosește în acest sens structura struct my_device_data:

#include <linux/fs.h>
#include <linux/cdev.h>
 
struct my_device_data {
    struct cdev cdev;
    /* my data starts here */
    //...
};
 
static int my_open(struct inode *inode, struct file *file)
{
    struct my_device_data *my_data =
            container_of(inode->i_cdev, struct my_device_data, cdev);
 
    file->private_data = my_data;
    //...
}
 
static int my_read(struct file *file, char __user *user_buffer, size_t size, loff_t *offset)
{
    struct my_device_data *my_data =
             (struct my_device_data *) file->private_data;
    //...
}

O structură de tipul my_device_data va conține datele asociate unui dispozitiv. Câmpul cdev (de tipul struct cdev) reprezintă un dispozitiv de tip caracter și este folosit la înregistrarea acestuia în sistem și identificarea dispozitivului. Pointer-ul către membrul cdev se poate afla cu ajutorul câmpului i_cdev al structurii inode (cu ajutorul macro-ului container_of). În câmpul private_data al structurii file se pot memora informații la open care apoi sunt disponibile în rutinele read, write, release etc.

Înregistrarea și deînregistrarea dispozitivelor de tip caracter

Înregistrarea/deînregistrarea unui dispozitiv se realizează prin specificarea majorului și minorului acestuia. Tipul dev_t este folosit pentru a păstra identificatorii unui dispozitiv (atât majorul, cât și minorul) și se poate obține cu ajutorul macro-ului MKDEV.

Pentru alocarea și dezalocarea statică a identificatorilor unui dispozitiv, se folosesc funcțiile register_chrdev_region, respectiv unregister_chrdev_region:

#include <linux/fs.h>
 
int register_chrdev_region(dev_t first, unsigned int count, char *name);
void unregister_chrdev_region(dev_t first, unsigned int count);

Este recomandat ca identificatorii de dispozitiv să fie alocați dinamic cu funcția alloc_chrdev_region.

Secvența de mai jos rezervă my_minor_count dispozitive, începând de la dispozitivul cu majorul my_major și minorul my_first_minor (dacă se depășește valoare maximă pentru minor, se trece la următorul major):

#include <linux/fs.h>
 
    //...
    int err;
    err = register_chrdev_region(MKDEV(my_major, my_first_minor), my_minor_count,
                                  "my_device_driver");
    if (err != 0) {
        /* report error */
        return err;
    }
    //...

4)

După atribuirea identificatorilor, dispozitivul de tip caracter va trebui inițializat ( cdev_init) și va trebui informat nucleul de existența lui ( cdev_add). Funcția cdev_add trebuie apelată doar după ce dispozitivul este pregătit sa primească apeluri. Eliminarea unui dispozitiv se realizează folosind funcția cdev_del.

#include <linux/cdev.h>
 
void cdev_init(struct cdev *cdev, struct file_operations *fops);
int cdev_add(struct cdev *dev, dev_t num, unsigned int count);
void cdev_del(struct cdev *dev);

Următoarea secvență înregistrează și inițializează MY_MAX_MINORS dispozitive:

register.c
#include <linux/fs.h>
#include <linux/cdev.h>
 
#define MY_MAJOR       42
#define MY_MAX_MINORS  5
 
struct my_device_data {
    struct cdev cdev;
    /* my data starts here */
    //...
};
 
struct my_device_data devs[MY_MAX_MINORS];
 
const struct file_operations my_fops = {
    .owner = THIS_MODULE,
    .open = my_open,
    .read = my_read,
    .write = my_write,
    .release = my_release,
    .unlocked_ioctl = my_ioctl
};
 
int init_module(void)
{
    int i, err;
 
    err = register_chrdev_region(MKDEV(MY_MAJOR, 0), MY_MAX_MINORS,
                                      "my_device_driver");
    if (err != 0) {
        /* report error */
        return err;
    }
 
    for(i = 0; i < MY_MAX_MINORS; i++) {
        /* initialize devs[i] fields */
        cdev_init(&devs[i].cdev, &my_fops);
        cdev_add(&devs[i].cdev, MKDEV(MY_MAJOR, i), 1);
    }
 
    return 0;
}

în vreme ce următoarea secvență le șterge și deînregistrează:

void cleanup_module(void)
{
    int i;
 
    for(i = 0; i < MY_MAX_MINORS; i++) {
        /* release devs[i] fields */
        cdev_del(&devs[i].cdev);
    }
    unregister_chrdev_region(MKDEV(MY_MAJOR, 0), MY_MAX_MINORS);
}

Observație: la inițializarea structurii my_fops s-a folosit inițializarea membrilor după nume, definită în standardul C99 (vezi designated initializers și The file_operations Structure). Membrii structurii care nu apar explicit într-o astfel de inițializare vor fi setați la valoarea implicită pentru tipul lor. De exemplu, după inițializarea de mai sus, my_fops.mmap va fi NULL.

Accesul la spațiul de adresă al procesului

Un driver pentru un dispozitiv este interfața de comunicație între o aplicație și hardware. Drept urmare, deseori va trebui să accesăm în cadrul unui device driver date din user-space. Accesarea spațiului de adresă al proceselor nu se poate face, însă, direct (prin dereferențierea unui pointer din user-space). Accesarea directă a unui pointer din user-space poate duce la un comportament incorect (în funcție de arhitectură, un pointer din user-space poate să nu fie valid sau mapat în kernel-space), un kernel oops (pointerul din user-mode poate referi o zonă de memorie care nu este rezidentă) sau probleme de securitate. Accesarea corectă a datelor din user-space se realizează prin apelarea macro-urilor/funcțiilor de mai jos:

#include <asm/uaccess.h>
 
put_user(type val, type *address);
get_user(type val, type *address);
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n)

Toate macro-urile/funcțiile întorc 0 în caz de succes și altă valoare în caz de eroare și au următoarele roluri:

O secțiune uzuală de cod care lucrează cu aceste funcții este:

#include <asm/uaccess.h>
 
/*
 * Copy at most size bytes to user space.
 * Return ''0'' on success and some other value on error.
 */
if (copy_to_user(user_buffer, kernel_buffer, size))
    return -EFAULT;
else
    return 0;

Operații implementate de device drivere de tip caracter

open și release

În funcția open se realizează operațiile de inițializare a unui dispozitiv 5). În majoritatea cazurilor, aceste operații se referă la inițializarea dispozitivului și completarea datelor specifice (în cazul în care este primul apel open). Funcția release se ocupă de eliberarea resurselor specifice dispozitivului: se dezalocă datele specifice și se închide dispozitivul daca este ultimul apel close.

În cele mai multe cazuri, funcția open va avea următoarea structură:

static int my_open(struct inode *inode, struct file *file)
{
    struct my_device_data *my_data =
            container_of(inode->i_cdev, struct my_device_data, cdev);
 
    /* validate access to device */
    file->private_data = my_data;
 
    /* initialize device */
	//..
 
    return 0;
}

O problemă care apare la implementarea funcției open este controlul accesului. Uneori este necesar ca un dispozitiv să fie deschis o singură dată la un moment dat; mai exact, nu se permite al doilea open înainte de release. Pentru a implementa această restricție se alege o modalitate de tratare a unui apel open pentru un dispozitiv deja deschis: se poate întoarce o eroare (-EBUSY), se pot bloca apelurile open până la o operație de release sau se poate închide dispozitivul înainte de a realiza operația de open.

La apelul din user-space al funcțiilor open și close asupra dispozitivului, se vor apela operațiile my_open și my_release din driver. Un exemplu de apel din user-space:

    int fd = open("/dev/my_device", O_RDONLY);
    if (fd < 0) {
        /* handle error */
    }
 
    /* do work */
    //..
 
    close(fd);

read și write

Funcțiile read și write transferă date între dispozitiv și user-space: funcția read citește datele de la dispozitiv și le transferă în user-space, în timp ce write citește datele din user-space și le scrie pe dispozitiv. Buffer-ul primit ca parametru reprezintă un pointer în user-space, motiv pentru care este necesară folosirea funcțiilor copy_to_user sau copy_from_user.

Valoarea întoarsă de read sau write poate fi:

Pentru a realiza un transfer de date format din mai multe transferuri parțiale, vor trebui realizate următoarele operații:

Secvența de mai jos prezintă un exemplu de apel simplu al funcției read. Apelul nu actualizează câmpul offset astfel că tot timpul va întoarce mesajul de la începutul buffer-ului. O implementare corectă trebuie să țină cont de parametrul offset și să-l actualizeze după citire.

static int my_read(struct file *file, char __user *user_buffer, 
                                size_t size, loff_t *offset)
{
    struct my_device_data *my_data =
             (struct my_device_data *) file->private_data;
 
    /* read data from device in my_data->buffer */
    if(copy_to_user(user_buffer, my_data->buffer, my_data->size))
        return -EFAULT;
 
    return my_data->size;
}

Structura funcției write este similară: citește date din user-space folosind funcția copy_from_user și le scrie pe dispozitiv.

static int my_write(struct file *file, const char __user *user_buffer, 
                                             size_t size, loff_t * offset)
{
    // write data from user buffer into kernel buffer
    // update file offset in userspace
    // ..
 
}

La apelul funcțiilor read și write din user-space (folosind descriptorul de fișier obținut în urma unui apel open), se vor apela operațiile my_read și my_write din driver. Un exemplu de cod pentru user-space:

    if (read(fd, buffer, size) < 0) {
        /* handle error */
    }
 
    if (write(fd, buffer, size) < 0) {
        /* handle error */
    }

Imaginile de mai jos ilustrează operațiile de read și write și cum sunt transferate datele între userspace și driver. În primele două imagini putem observa două situații pentru operația read:

  1. când driverul are suficiente date disponibile (începând cu poziția dată de OFFSET) pentru a transfera exact dimensiunea cerută (SIZE) de user.
  2. când se transferă o cantitate mai mică decât cea cerută.

Putem privi operația de read implementată de driver ca pe un răspuns la o cerere de read din userspace. În acest caz, driverul având responsabilitatea să avanseze offsetul în funcție de cât s-a citit și să returneze dimensiunea citită (care poate fi mai mică decât cea cerută).

În paralel, operația de write va răspunde unei cereri de write din userspace (ultimele două imagini). În acest caz, în funcție de capacitatea maximă a driverului (MAXSIZ), se poate scrie mai mult sau mai puțin decât dimensiunea cerută.

read read less

write write less

ioctl

Pe lângă operațiile de read și write, un driver are nevoie de posibilitatea de a realiza anumite operații de control asupra dispozitivului fizic. Aceste operații se realizează prin implementarea unei funcții de tip ioctl. Inițial, apelul de sistem ioctl folosea Big Kernel Lock. De aceea, apelul a fost înlocuit treptat cu versiunea sa fără lock-uri, numită unlocked_ioctl. Puteți citi mai multe pe LWN: http://lwn.net/Articles/119652/

static long my_ioctl (struct file *file, unsigned int cmd, unsigned long arg);

cmd reprezintă comanda transmisă din user-space. Dacă la apelul din user-space se transmite un întreg, acesta poate fi accesat direct. Dacă se trasmite un buffer, valoarea arg va fi un pointer către acesta și trebuie accesat prin intermediul funcțiilor copy_to_user sau copy_from_user.

Înainte de a implementa funcția ioctl, vor trebui alese numerele ce corespund comenzilor. O metodă este de a alege numere consecutive începând de la 0, dar se recomandă folosirea macrodefiniției _IOC(dir, type, nr, size)6) pentru generarea codurilor ioctl. Parametrii macrodefiniției sunt după cum urmează:

În exemplul de mai jos este prezentată o implementare pentru o funcție ioctl:

#include <asm/ioctl.h>
 
#define MY_IOCTL_IN _IOC(_IOC_WRITE, 'k', 1, sizeof(my_ioctl_data))
 
static long my_ioctl (struct file *file, unsigned int cmd, unsigned long arg)
{
    struct my_device_data *my_data =
         (struct my_device_data*) file->private_data;
    my_ioctl_data mid;
 
    switch(cmd) {
    case MY_IOCTL_IN:
        if( copy_from_user(&mid, (my_ioctl_data *) arg, 
                           sizeof(my_ioctl_data)) )
            return -EFAULT;
 
        /* process data and execute command */
 
        break;
    default:
        return -ENOTTY;
    }
 
    return 0;
}

8)

La apelul din user-space pentru funcția ioctl, se va apela funcția my_ioctl a driver-ului. Un exemplu de astfel de apel în user-space:

    if (ioctl(fd, MY_IOCTL_IN, buffer) < 0) {
        /* handle error */
    }

9)

Sincronizare - cozi de așteptare

Cozile de așteptare sunt mecanisme utile în probleme de sincronizare. De multe ori este necesar ca un thread să aștepte terminarea unei operații, dar este de dorit ca această așteptare să nu fie busy-waiting. Folosind cozi de așteptare și funcții care schimbă starea thread-ului din planificabil în neplanificabil și invers se pot rezolva astfel de probleme. În Linux, o coadă de așteptare este o listă în care sunt trecute procesele care așteaptă un anumit eveniment. O coadă de așteptare este definită cu tipul wait_queue_head_t și poate fi folosită de funcțiile/macro-urile:

#include <linux/wait.h>
 
DECLARE_WAIT_QUEUE_HEAD(wq_name);
 
void init_waitqueue_head(wait_queue_head_t *q);
 
int wait_event(wait_queue_head_t q, int condition);
 
int wait_event_interruptible(wait_queue_head_t q, int condition);
 
int wait_event_timeout(wait_queue_head_t q, int condition, int timeout);
 
int wait_event_interruptible_timeout(wait_queue_head_t q, int condition, int timeout);
 
void wake_up(wait_queue_head_t *q);
 
void wake_up_interruptible(wait_queue_head_t *q);

Rolurile macro-urilor/funcțiilor de mai sus sunt:

Un exemplu simplu este cel al unui thread care așteaptă modificarea valorii unui flag. Inițializările se realizează prin secvența:

#include <linux/sched.h>
 
    wait_queue_head_t wq;
    int flag = 0;
 
    init_waitqueue_head(&wq);

Un thread va aștepta ca flag-ul sa fie modificat la o valoare diferită de zero:

    wait_event_interruptible(wq, flag !=0);
    flag = 0;

în timp ce un alt thread va modifica valoarea flag-ului și va trezi thread-urile care așteaptă:

    flag = 1;
    wake_up_interruptible(&wq);

Resurse utile

1) struct file_operations - aici am prezentat doar o parte din operațiile din structură, cele care se implementează în mod uzual de către device drivere
2) user_buffer - acest buffer trebuie accesat cu funcții speciale (pe care le vom discuta imediat) și nu direct, pentru că este un pointer în spațiul de adresă al procesului ce a invocat apelul de sistem
3) nume fisier - după cum ați observat, la atributele inode-ului nu am enumerat și numele. Aceasta pentru că în UNIX un fișier poate avea mai multe nume, datorită link-urilor soft sau hard
4) my_device_driver – “my_device_driver” este numele device-ului asociat cu intervalul de identificatori și care va apărea în /proc/devices
5) driver/device - după cum s-a specificat, un driver poate gestiona mai multe device-uri; funcția init_module realizează inițializările specifice driver-ului, pe când funcția open pe cele specifice device-ului pentru care se apelează funcția
6) IOC - alternativ, se pot folosi macrodefinițiile _IO (pentru o comandă fără parametri), _IOR (pentru o comandă de citire), _IOW (pentru o comandă de scriere) sau _IOWR (pentru o comandă de transfer bidirecțional)
7) dir - direcția este precizată din punct de vedere al aplicației: _IOC_READ când se citește de pe device, iar _IOC_WRITE când se scrie pe device
8) my_ioctl_data - my_ioctl_data poate fi un tip de date (int, char, etc.) sau o structura definita anterior
9) buffer - dacă nu se transferă date între user-space și kernel-space, parametrul buffer poate lipsi