UP | HOME

Module

Table of Contents

https://embetronicx.com/linux-device-driver-tutorials/

Je recommande très fortement le Eudyptula Challenge pour pouvoir s'exercer sur la création de modules pour le noyau Linux. On peut retrouver sur Github des dépôts avec la liste des tâches, vu que le site est en hiatus depuis quelques temps maintenant. Vous pouvez retrouver sur le git de Gnous mes réponses. Vous allez aussi retrouver des notes similaires entre ici et le dépôt. Ces notes sont plus à jour que celles du README. Elles sont amenées à évoluer là où le dépôt illustre mes compétences à l'instant T.

Je vous conseille aussi de lire la section Développement sur Linux afin de voir les bonnes pratiques de code.

1. Ressources

La documentation du noyau
manque parfois d'exemple et peut être compliquée à parcourir.
Linux Device Drivers, Third Edition
livre un peu vieux, mais contient toujours des exemples fonctionnels. À recouper avec des documents plus récents parfois.
The Linux Kernel Module Programming Guide
livre encore en cours d'écriture sur le développement de modules. Contient de bons exemples, mais encore incomplet.
Linux Kernel Teaching
compilation de cours sur le noyau Linux.

2. Commandes de base

Quelques commandes basiques pour interagir avec les modules et le noyau.

dmesg
affiche le journal du noyau
modinfo
affiche les informations d'un module
lsmod
liste tous les modules actifs
insmod
charge un module
rmmod
décharge un module, il ne doit pas être utilisé par un autre
mknod
permet de créer un fichier device, avec un nom, un numéro majeur et mineur et un type. Il faut indiquer le chemin vers le fichier dans le nom (/dev/test par exemple). Il peut être de type bloc ou character.

3. Module basique

Un module basique est composé de 2 fonctions : une d'initialisation et une de sortie. Elles sont chargées via des macros. Si l'on peut maintenant donner le nom que l'on souhaite, on était obligé d'utiliser des fonctions bien précises avant la version 2.4.

Pour pouvoir écrire dans le journal, il est possible d'utiliser la fonction printk() en lui indiquant le type d'évènement. Une macro existe pour ne pas avoir à préciser ce type : pr_info(); cf. la documentation noyau pour plus d'informations.

// SPDX-License-Identifier: GPL-2.0

#include <linux/kernel.h>
#include <linux/module.h>

/* int init_module(void) */
static int __init my_init(void)
{
        pr_info("Coucou le gens !!!!\n");
        return 0;
}

/* void cleanup_module(void) */
static void __exit my_exit(void)
{
        pr_info("Tschuss !!!\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("rick <rick​@gnous.eu>");
MODULE_DESCRIPTION("Module de la premiere tache de l'Eudyptula challenge");

4. Devices

Un device est un fichier qui correspond à un périphérique. C'est ce qu'on peut retrouver dans le dossier /dev/. On peut connaître le type de device grâce à la commande ls -l (cf. exemple). Il en existe 2 :

  • character device : on ne lit qu'un caractère à la fois, commence par c dans la sortie ls. Exemple : le clavier.
  • block device : on lit des blocs (avec char * ou char []), commence par b dans la sortie ls. Exemple : le disque dur.

On peut les lire ou écrire dedans, les modules qui gèrent les devices vont se charger de traiter les informations en conséquence.

4.1. Les numéros majeurs et mineurs

Les devices sont gérés par différents modules. Lorsqu'on tape ls, on peut voir des numéros séparés par une virgule. Le premier indique le numéro majeur, le module qui gère le device. Le second indique le numéro mineur, un numéro identifiant le device pour le module.

ls -l --time-style=+"" /dev/input/mouse* /dev/sda
crw-rw---- 1 root input 13, 32  /dev/input/mouse0
crw-rw---- 1 root input 13, 33  /dev/input/mouse1
crw-rw---- 1 root input 13, 34  /dev/input/mouse2
brw-rw---- 1 root disk   8,  0  /dev/sda

Un numéro majeur peut être partagé entre plusieurs modules.

Il est possible de lister tous les devices et leur numéro majeur dans le fichier /proc/devices.

Pour créer un numéro, il faut utiliser la macro MKDEV() qui prend un numéro majeur et mineur. Elle génère un type dev_t. Il est possible de récupérer ces numéros avec les macros MAJOR() et MINOR().

void test_mkdev(void)
{
  dev_t dev = MKDEV(245, 0);
  pr_info("Major: %d\nMinor: %d\n", MAJOR(dev), MINOR(dev));
}

4.1.1. Réservation

Lors d'une réservation, le noyau va enregistrer un nom (que l'on donne) et l'assigner à un numéro majeur et un mineur. Une fois les opérations terminées, il faut supprimer l'enregistrement avec unregister_chrdev_region(), qui prend en paramètre le numéro majeur (dev_t) ainsi que le nombre de devices.

  1. Statique

    La réservation statique permet de choisir son numéro majeur, ainsi que le nombre de numéros mineurs que l'on souhaite avoir. Si trop de numéros sont demandés, cela risque de déborder sur le numéro majeur suivant. La fonction register_chrdev_region() prend en paramètre une structure dev_t indiquant le numéro majeur et le premier numéro mineur, le nombre de devices à allouer et le nom du module qui va l'utiliser. Elle retourne 0 ou un code d'erreur négatif.

    static void example_stat(void)
    {
      dev_t dev = MKDEV(244, 0);
      if (register_chrdev_region(dev, 1, "example") < 0)
        pr_error("Erreur initialisation.");
      unregister_chrdev_region(dev, 1);
    }
    
  2. Dynamique

    Le noyau va réserver un numéro majeur disponible pour nous. On peut préciser le premier numéro mineur que l'on souhaite ainsi que le nombre de devices voulus. La fonction alloc_chrdev_region() prend en paramètre un pointeur d'une structure dev_t vide qui sera rempli, le premier numéro mineur, le nombre de devices à allouer et le nom du module qui va l'utiliser. Elle retourne 0 ou un code d'erreur négatif.

    static void example_dyn(void)
    {
      dev_t dev = 0;
      if (alloc_chrdev_region(&dev, 0, 1, "example") < 0)
        pr_error("Erreur initialisation.");
      unregister_chrdev_region(dev, 1);
    }
    

4.2. Création d'un device

Il est possible de les créer avec la commande mknod. Il suffit de mettre le numéro majeur réservé par le module ainsi qu'un numéro mineur compris dans la plage réservée. Il faut le supprimer avec la commande rm après avoir déchargé le module.

4.3. misc character device

4.4. Écriture et lecture

Pour pouvoir interagir avec un fichier device, il faut mettre en place une structure file_operations avec des méthodes de lecture et d'écriture. Cette structure possède beaucoup de champs, chacun avec comportement différent si on le met à NULL. Un exemple ci-dessous montre juste les champs de lecture et d'écriture mais pas d'ouverture ou de verouillage.

Pour pouvoir interagir avec le userspace, la fonction put_user permet d'envoyer un caractère à l'utilisateur et copy_from_user un tampon (cf. fichier source indiqué dans le lien). Les fonctions copy_ retournent le nombre de bytes restant à transférer. Il s'agit là d'une erreur, 0 indique le succès de l'opération.

static ssize_t my_read(struct file *, char __user *buffer, size_t count,
                       loff_t *offset)
{
  /* lecture du fichier par l'utilisateur */
}

static ssize_t my_write(struct file *, char __user *buffer, size_t count,
                       loff_t *offset)
{
  /* écriture dans le fichier */
}

static const struct file_operations fops = {
  .owner = THIS_MODULE,
  .read = my_read,
  .write = my_write,
};

static struct cdev *my_cdev;

static int __init my_init(void)
{
  my_cdev = cdev_alloc();
  my_cdev->ops = &fops;
  my_cdev->owner = THIS_MODULE;
}

Ces fonctions peuvent être appelées en boucle ! Selon la valeur de retour, le programme essayant de lire ou d'écrire peut réessayer l'opération. Il est possible de jouer avec count et offset pour vérifier si on essaie de lire/écrire plus qu'il ne faut et retourner 0 dans ce cas.

Valeur État
count tous les bytes ont été lus / écris
entre 0 et count seul une partie des bytes a été traitée. Dans la plupart des cas, le programme va réessayer l'opération pour la finir.
0 fin du fichier en lecture, rien n'a été écrite en écriture + nouvelle tentative d'appel de write.
inférieur à 0 erreur

Les erreurs les plus communes à retourner sont :

-EINVAL
-EFAULT
mauvaise adresse, à utiliser quand une fonction copy_ échoue

Les pages 63 à 69 du Linux Device Drivers (chapitre 3) sont très intéressantes sur le fonctionnement de ces fonctions.

Ressources

5. jiffies

C'est le compteur du nombre de ticks depuis le boot du système. Il s'incrémente à chaque timer interrupt. Chapitre sur les jiffies.

6. Kobject et sysfs

6.1. kobject

Les kobject sont des obets noyaux génériques. Ils sont derrière beaucoup de choses dans le noyau, comme le /sys ou les devices par exemple. Ils ont un nom, des attributs et parfois un parent.

Les kset sont des ensembles de kobject tout en étant eux-même un kobject.

Il est possible de mettre les kobject dans le dossier /sys, un dossier sera alors créé.

Le kobject enregistre le nombre d'utilisation, un peu comme un sémaphore. Il faut qu'il ne soit plus du tout utilisé pour pouvoir le supprimer.

Initialisation

Il faut initaliser la zone mémoire avec des 0 dans un premier temps. Cela peut se faire via memset. On peut ensuite créer le kobject avec kobject_init(). Il faut bien penser à libérer la structure lorsqu'on n'a plus besoin de l'utiliser avec kobject_put.

Pour renommer le kobject, il faut utiliser kobject_rename() et non pas kobject_set_name() comme indiqué dans le livre ou les articles LWM.

static struct kobject foo;

static int __init my_init(void)
{
  if (!memset(&foo, 0, sizeof(struct kobject)))
    return -ENOMEM;

  kobject_init(&foo, NULL);
  if (kobject_rename(&foo, "my_foo"))
    return -EINVAL;

  return 0;
}

static void __exit my_exit(void)
{
  kobject_put(foo);
}

6.2. sysfs

Conseil : il faut bien lire la documentation et les exemples pour comprendre comment développer les kobject. Ce n'est pas écrit explicitement dans le livre et les articles LWN comment faire.

Un kobject représente un dossier. Un attribute représente un fichier.

Pour pouvoir créer un dossier, il faut en premier créer et initialisier un kobject puis l'ajouter dans le système de fichiers. Il faut ensuite le retirer lorsqu'on souhaite le supprimer du dossier. Il existe plusieurs façons de faire ça :

  • tout faire à la main (kobject_add())
  • l'initialiser et l'ajouter en même temps (kobject_init_and_add())
  • utiliser directement une fonction pour tout faire à notre place (kobject_create_and_add())

Pour créer un fichier, il faut ajouter un attribut. Il est lié à un kobject, le dossier où il se trouve. On peut ajouter ensuite le fichier dans le système de fichiers sys avec sysfs_create_file(), elle retourne 0 si l'opération s'est bien déroulée. sysfs_remove_file() permet de retirer le fichier.

Pour pouvoir gérer les opérations de lecture et d'écriture, il faut ajouter des fonctions dans une structure sysfs_ops :

  • ssize_t show(struct kobject *kobj, struct attribute *attr, char *buffer)
  • ssize_t store(struct kobject *kobj, struct attribute *attr, const char *buffer, size_t len)

Si on a plusieurs attribute, il est possible de vérifier leur nom avec attr->name et faire un appel à la bonne fonction.

Il faut utiliser la fonction sysfs_emit() pour pouvoir copier des informations dans le tampon de l'espace utilisateur, dans la partie show().

Exemple :

static struct kobject foo;

static struct attribute bar = {
  .name = "bar",
  .mode = 0666
};

ssize_t show(struct kobject *kobj, struct attribute *attr, char *buffer)
{
  return sysfs_emit(buffer, "foo bar");
}

ssize_t store(struct kobject *kobj, struct attribute *attr, const char *buffer,
              size_t len)
{
  ssize_t ret = 0;

  if (!strcmp(buffer, "foo"))
    ret = size;

  return ret;
}

static const struct sysfs_ops my_ops = {
  .show = &show,
  .store = &store,
};

static const struct kobj_type my_type = {
  .sysfs_ops = &my_ops,
};

static int __init my_init(void)
{
  if (!memset(&foo, 0, sizeof(struct kobject)))
    return -ENOMEM;

  kobject_init(&foo, NULL);
  if (kobject_init_and_add(&foo, &my_type, NULL, "foo")) {
    kobject_put(&foo);
    return -EINVAL;
  }

  if (sysfs_create_file(&foo, &bar) {
      kobject_put(&foo);
      return -ENOMEM;
   }

  return 0;
}

static void __exit my_exit(void)
{
  kobject_put(&foo);
  sysfs_remove_file(&foo, &bar);
}

Ressources

Quelques articles de LWN qui sont très intéressants mais pas à jour, par exemple pour la fonction kobject_set_name.

7. debugfs

Cette partition est normalement montée par défaut sur le système. Elle permet aux modules de fournir des informations de débug facilement et d'être mis en place rapidement. Elle se trouve dans /sys/kernel/debug/.

mount | grep debug
debugfs on /sys/kernel/debug type debugfs (rw,nosuid,nodev,noexec,relatime)

La documentation noyau est très bien faite sur la création et la suppression de ces fichiers. Il est possible de faire des fichiers qui contiennent des valeurs dynamiques comme des int avec debugfs_create_u64. Article de LWN sur le sujet, qui reprend la documentation et un peu l'historique / le but de cette partition.

8. Synchronisation (sémaphore et spinlock)

Il existe plusieurs mécanismes pour faire de la synchronisation dans le noyau Linux :

les sémaphores
attente passive
les spinlocks
attente active, utilise 100% du CPU. Pratique pour verrouiller de petites parties de code

Il existe différentes variantes de ces verrous, permettant de faciliter leur utilisation dans certains cas.

8.1. Sémaphores

8.1.1. Lecture/écriture

Un sémaphore de lecture écriture permet d'avoir un verrou en lecture exclusive. Une seule personne peut accéder en écriture, autant que l'on souhaite en écriture, mais pas les deux à la fois. Il est possible de "descendre" le niveau d'un write pour passer en read mais l'inverse n'est pas possible.

Les fonctions trylock vont essayer de prendre le verrou. Si ce n'est pas possible, elles retournent 0. Cela permet de ne pas faire d'attente active ou de faire autre chose si le verrou est déjà pris.

#include <linux/rwsem.h>

static struct rw_semaphore sem;

static void concurrent_read(void)
{
  if (down_read_trylock(&sem))
  {
    /* ... */
    up_read(&sem);
  }
}

static void concurrent_write(void)
{
  if (down_write_trylock(&sem))
  {
    /* ... */
    up_write(&sem);
  }
}

static int __init my_init(void)
{
  init_rwsem(&sem);  
  return 0;
}

Ressources

Documentation noyau et Chapitre 5 de Linux Device Drivers.

9. Types du noyau

9.1. Liste chainée

Linked Lists du chapitre 11 de Linux Device Drivers (Data Types in the Kernel) explique très bien comment utiliser les listes chaînées du noyau.

Pour faire une liste chaînée d'une structure, il faut lui ajouter un attribut de type struct list_head. On peut le nommer comme l'on veut. Il faut aussi créer la tête de la liste, en déclarant une structure list_head que l'on initialise avec INIT_LIST_HEAD() et un pointeur vers la structure.

Cette structure contient un pointeur vers l'élement suivant (next) et l'élément précédent (pred).

Il existe plusieurs fonctions pour rajouter un élément (en tête, à la fin), en supprimer un, le déplacer (cf. la doc.)… Il faut toujours donner la structure list_head se trouvant dans la structure. Il est possible de récupérer un élément de la liste avec la macro list_entry. Elle prend en paramètre la tête, la structure qui la contient et le nom du champ contenant la list_head de cette structure : list_entry(my_head, my_struct, list).

Author: rick

Email: rick@gnous.eu

Created: 2024-11-25 lun. 18:55

Validate