Structure des pilotes noyau Linux (Partie I)

Architecture des pilotes de périphériques sous Linux

*Référence : https://www.youtube.com/watch?v=XoYkHUnmpQo&list=LL&index=1&t=272s*Un pilote de périphérique est une abstraction logicielle qui permet à l'utilisateur d'interagir avec un matériel physique. Il agit comme un intermédiaire entre le système d'exploitation et le composant matérielle.

Distinguer pilote et firmware : Le pilote s'exécute dans l'espace noyau (parfois en espace utilisateur), il informe le noyau comment communiquer avec un périphérique. Le firmware, lui, tourne directement sur le périphérique lui-même et n'est présent que sur les dispositifs dotés d'une certaine intelligence.

Le rôle fondamental du système d'exploitation inclut la mise en place d'un cadre permettant le développement et l'exécution de ces pilotes. Sur les systèmes Unix-like, l'abstraction principale utilisée est celle des fichiers. Ainsi, les interactions avec le matériel se font généralement via des fichiers situés dans des répertoires comme /dev ou /sys.

  1. Interagir avec le pilote : Enregistrer le pilote dans le noyau, créer un fichier périphérique visible par l'utilisateur, puis manipuler ce fichier comme un fichier standard.
  2. Contrôler le matériel : Le pilote gère directement les communications avec le périphérique physique.

Le noyau expose des API pour convertir des périphériques matériels en fichiers accessibles depuis l’espace utilisateur. Ces fichiers sont appelés nœuds de périphérique ou fichiers périphériques, et possèdent plusieurs caractéristiques essentielles :

  • Type : char (caractère) ou block (bloc). Les fichiers char permettent des lectures/écritures par flux octet, tandis que les block opèrent par blocs fixes.
  • Numéro majeur : Identifie le type de périphérique (ex. : SATA, audio).
  • Numéro mineur : Permet de distinguer plusieurs instances du même type de périphérique.

La combinaison numéro majeur / numéro mineur constitue un identifiant unique au sein du système. Par exemple :

  • /dev/ttyS0 : type char, numéros 4/64
  • /dev/ram0 : type block, numéros 1/0

Communication avec un pilote de type caractère

Pour les périphériques de type char, les appels système classiques comme open(), read(), write(), close(), mmap() sont redirigés vers le pilote correspondant. Ce dernier est un composant noyau, souvent chargé comme module.

En tant que développeur, vous devez fournir des fonctions de rappel (callbacks) pour chaque opération souhaitée, puis les inscrire dans le noyau. Cela permet aux appels utilisateur de transitre par votre code, qui peut ensuite interagir avec le matériel.

Les trois étapes fondamentales du développement d’un pilote :

  1. Attribuer un numéro de périphérique (majeur/mineur) via alloc_chrdev_region() ou register_chrdev_region().
  2. Implémenter les fonctions de gestion des opérations sur fichiers : open, read, write, ioctl.
  3. Enregistrer le pilote dans le noyau grâce à cdev_init() et cdev_add().

Ces étapes peuvent être vues comme la création d’un lien entre le pilote et le fichier périphérique visible par l’utilisateur.

Structure du pilote caractère : struct cdev

Dans le noyau, chaque périphérique de type caractère est représenté par une structure struct cdev. Cette structure contient les informations nécessaires à son enregistrement et à sa gestion.

Elle est associée à une structure struct file_operations, définissant les fonctions appelées lors des opérations utilisateur :

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 *);
    // ...
};

Contrairement aux appels système standards, ces fonctions prennent en paramètre des structures struct inode et struct file, plutôt qu’un nom de fichier ou un desrcipteur.

Différence entre inode et file : On peut comparer inode à une classe en programmation orientée objet, tandis que file représente une instance de cette classe. L’inode décrit l’état statique du fichier (taille, permissions, identifiant unique), tandis que le file modélise l’état dynamique (position courante, flags d’ouverture, etc.).

Source : https://linux-kernel-labs.github.io/refs/heads/master/labs/device_drivers.html

Champs principaux de struct file

  • f_mode : indique si l’ouverture est en lecture (FMODE_READ) ou écriture (FMODE_WRITE).
  • f_flags : contient les options d’ouverture (ex. O_RDONLY, O_NONBLOCK).
  • f_op : pointeur vers la structure file_operations liée au fichier.
  • private_data : mémoire allouée par le pilote pour stocker des données spécifiques au périphérique.
  • f_pos : position actuelle dans le fichier (offset).

Champ principal de struct inode

  • i_cdev : pointeur vers la structure cdev associée au périphérique.

Implémentation des opérations sur fichiers

Pour organiser les données du pilote, on utilise une structure dédiée. Pour un périphérique char, celle-ci doit contenir une instance de struct cdev :

#include <linux/fs.h>
#include <linux/cdev.h>

struct my_device_data {
    struct cdev cdev;
    /* Données propres au périphérique */
    // ...
};

static int my_open(struct inode *inode, struct file *file)
{
    struct my_device_data *my_data;

    my_data = container_of(inode->i_cdev, struct my_device_data, cdev);

    file->private_data = my_data;
    // Initialisation du contexte
    return 0;
}

static ssize_t my_read(struct file *file, char __user *buffer, size_t count, loff_t *offset)
{
    struct my_device_data *my_data;

    my_data = (struct my_device_data *) file->private_data;

    // Lecture depuis le matériel
    // ...
    return 0;
}

Le macro container_of permet de retrouver la structure parente à partir d’un membre interne (ici i_cdev). Une fois l’ouverture effectuée, les données du périphérique sont stockées dans private_data, accessibles depuis les autres fonctions callback.

Lorsque le pilote lit ou écrit des données vers l’espace utilisateur, il doit utiliser des fonctions sécurisées pour accéder à la mémoire utilisateur :

#include <asm/uaccess.h>

int put_user(int val, int *addr);       // Écrit une valeur dans l'espace utilisateur
int get_user(int val, int *addr);       // Lit une valeur depuis l'espace utilisateur
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);

Enregistrement et désenregistrement des périphériques

L’enregistrement d’un périphérique repose sur l’attribution de numéros majeur et mineur. On peut générer un identifiant dev_t avec la macro MKDEV()>, mais il est recommandé d’utiliser alloc_chrdev_region() pour une allocation dynamique.

dev_t MKDEV(unsigned int maj, unsigned int min);

int alloc_chrdev_region(dev_t *dev, unsigned int baseminor, unsigned int count, const char *name);

int register_chrdev_region(dev_t first, unsigned int count, char *name);

void unregister_chrdev_region(dev_t first, unsigned int count);

Exemple d’enregistrement de my_minor_count périphériques, commençant par my_major:my_first_minor :

err = register_chrdev_region(MKDEV(my_major, my_first_minor), my_minor_count, "my_device_driver");
if (err != 0) {
    // Gérer l'erreur
    return err;
}

Après attribution des numéros, on initialise chaque instance de pilote via cdev_init() puis on l’ajoute au noyau avec cdev_add().

#define MY_MAJOR       42
#define MY_MAX_MINORS  5

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)
        return err;

    for (i = 0; i < MY_MAX_MINORS; i++) {
        cdev_init(&devs[i].cdev, &my_fops);
        cdev_add(&devs[i].cdev, MKDEV(MY_MAJOR, i), 1);
    }

    return 0;
}

Pour désinstaller le pilote :

void cleanup_module(void)
{
    int i;

    for (i = 0; i < MY_MAX_MINORS; i++)
        cdev_del(&devs[i].cdev);

    unregister_chrdev_region(MKDEV(MY_MAJOR, 0), MY_MAX_MINORS);
}

Les champs non initialisés de struct file_operations sont automatiquement mis à zéro (ex. : mmap sera NULL), conformément à la norme C99.

Une fois compilé, le pilote peut être chargé via modprobe [nom_du_pilote]. Le noyau exécute alors init_module() pour initialiser les ressources.

Utilisez cat /proc/devices pour vérifier l’attribution des numéros majeurs et mineurs.

Créez le fichier périphérique utilisateur avec mknod /dev/[nom] c [maj] [min].

Les accès en lecture/écriture à ce fichier déclenchent automatiquement les fonctions callback du pilote.

Étiquettes: Linux kernel device driver char device cdev file_operations

Publié le 7 octobre à 13h16