Introduction à l'écriture de pilotes de périphériques LINUX

Pierre Ficheux (pierre@alienor.fr)

Mars 2000


0. Résumé

Cet article est une initiation à l'écriture de pilotes de périphériques (device drivers) sous LINUX. Les concepts génraux présentés dans l'article sont illustrés d'un petit exemple de pilote en mode caractère (char driver). La lecture de l'article demande quelques connaissances en langage C.

1. Qu'est-ce qu'un pilote de périphérique ?

Un pilote de périphérique (device driver) permet à un programme utilisateur d'accèder à des ressources externes. Ce ressources peuvent être de type:

A de rares exceptions près, ce type de ressource est accessible exclusivement au noyau (kernel space) et non aux programmes utilisateurs (user space). Il existe cependant des exceptions célèbres comme le serveur XFree86 ou bien la svgalib qui effectuent des accès directs à des périphériques.

Il existe différents types de pilotes suivant le périphérique à controler:

Dans la suite de l'article, nous nous intéresserons exclusivement aux pilotes en mode caractère.

2. Les pilotes en mode caractère

Ces pilotes sont accessibles à travers des fichiers spéciaux appelés égalements noeuds (nodes). Ces fichiers sont localisés sur le répertoire /dev et sont caractérisés par deux valeurs numériques:

L'exemple ci-dessous donne la liste des fichiers spéciaux associés aux pilotes des ports séries:

[pierre@ca-ol-bordeaux-7-146 pierre]$ ls -l /dev/ttyS*
crw-------    1 root     tty        4,  64 mai  5  1998 /dev/ttyS0
crw-------    1 root     tty        4,  65 mai  5  1998 /dev/ttyS1
crw-------    1 root     tty        4,  66 mai  5  1998 /dev/ttyS2
crw-------    1 root     tty        4,  67 mai  5  1998 /dev/ttyS3
Dans ce cas la, le majeur vaut 4 et les mineurs vont de 64 à 67.

Pour créer une nouvelle entrée dans le répertoire /dev, on utilisera la commande mknod:

   mknod /dev/monpilote c majeur mineur
Le c indique que l'on crée un noeud pour un pilote en mode caractère. Si l'on désirait créer une entrée pour un pilote en mode bloc, on utiliserait la même commande avec un b à la place du c. La liste des majeurs utilisés par le noyau LINUX est disponible dans le fichier Documentation/devices.txt de l'arborescence des sources du noyau.

A partir du moment ou le noeud est créé et le pilote installé, on peut accèder au périphérique en utilisant les commandes standards de LINUX comme:

    echo "une commande" > /dev/monpilote
pour écrire sur le périphérique.

    dd bs=1 count=10 < /dev/monpilote
pour lire 10 caractères du péripéhrique.

On peut bien entendu accèder au périphérique en utilisant des langages évolués comme le C, exemple:

int fd = open ("/dev/monpilote", O_RDWR);
pour ouvrir un descripteur de fichier associé au périphérique.

char *s = "une commande";
write (fd, s, strlen(s));
pour écrire une chaîne sur le périphérique.

char buf[256];
read (fd, buf, n);
pour lire n caractères du périphérique.

close (fd);
pour terminer l'accès au périphérique.

3. Généralités sur la programmation d'un pilote en mode caractère

Un pilote en mode caractère contiendra en général un certain nombre de points d'entrées associés aux requêtes des programmes utilisateurs. Ces principaux points d'entrées sont: Le pilote pourra également implémenter d'autres points d'entrées comme:

4. Cas particulier de LINUX

Dans le cas de LINUX, le pilotes de périphériques sont ajoutés de deux manières:

L'utilisation des modules chargeables permet aussi de limiter la mémoire utilisée par les pilotes car ceux-ci sont chargés uniquement lorsqu'un programme utilisateur ou un autre module en fait la demande. Les programmes kerneld ou kmod sont destinés à gèrer le chargement/déchargement automatique des modules chargeables.

Il est relativement simple de construire un module chargeable minimal. Considérons le code source suivant:

#include <linux/module.h>

int init_module(void)      
{ 
  printk("<1>Hello, world\n"); 
  return 0; 
}

void cleanup_module(void)  
{ 
  printk("<1>Goodbye cruel world\n"); 
}

Ce code constitue la base d'un module chargeable. Si on le compile par:

  cc -O -DMODULE -D__KERNEL__ -c hello.c
on pourra insérer dynamiquement le module par la commande insmod:
  insmod hello.o
Ce qui provoque le chargement du module (voir lsmod) et l'apparition du message dans les traces du noyau:
[root@ca-ol-bordeaux-7-146 exemple]# lsmod
Module                  Size  Used by
hello                    184   0  (unused)
3c509                   5972   1  (autoclean)
vfat                    9116   0  (unused)
fat                    30048   0  [vfat]
[root@ca-ol-bordeaux-7-146 exemple]# dmesg | tail -1
Hello, world
De même si on décharge le module par rmmod on obtient:
[root@ca-ol-bordeaux-7-146 exemple]# rmmod hello   
[root@ca-ol-bordeaux-7-146 exemple]# dmesg | tail -1
Goodbye cruel world

5. Etude d'un exemple

L'exemple que nous allons étudier est un petit module destiné à piloter une LED située en face avant d'un petit PC architecturé autour d'un processeur MediaGX. Cette LED peut prendre trois états (ROUGE, ORANGE, ETEINTE) correspondant à trois valeurs binaires à écrire dans un port d'entrée/sortie donné du PC. Nous rappelons qu'un port d'entrée/sortie est une valeur binaire sur 8 ou 16 bits permettant d'accèder à un circuit périphérique de la mémoire du processeur. Un périphérique pourra occuper plusieurs ports comme par exemple la première interface série du PC (COM1) qui utilise les ports 3F8 à 3FF.

Notre LED n'utilisera qu'un octet sur un port dont l'adresse est calculée lors de l'ouverture du pilote. Le code est fourni pour les version 2.0 et 2.2 du noyau LINUX.

Les fonctionnalités du pilote sont les suivantes:

Dans le cas de cet petit module de test, on peut choisir le majeur comme étant la valeur 10. En effet d'après le fichier devices.txt, le majeur 10 est entre-autres réservé à des misc features (usages divers):

 10 char	Non-serial mice, misc features
		  0 = /dev/logibm	Logitech bus mouse
		  1 = /dev/psaux	PS/2-style mouse port
		  2 = /dev/inportbm	Microsoft Inport bus mouse
		  3 = /dev/atibm	ATI XL bus mouse
		  4 = /dev/jbm		J-mouse
		  4 = /dev/amigamouse	Amiga mouse (68k/Amiga)
		  5 = /dev/atarimouse	Atari mouse
		  6 = /dev/sunmouse	Sun mouse
		  7 = /dev/amigamouse1	Second Amiga mouse
		  8 = /dev/smouse	Simple serial mouse driver
		  9 = /dev/pc110pad	IBM PC-110 digitizer pad
		 10 = /dev/adbmouse	Apple Desktop Bus mouse
		 11 = /dev/vrtpanel	Vr41xx embedded touch panel
		128 = /dev/beep		Fancy beep device
...
On peut connaitre la liste des modules chargés sur le majeur 10 en faisant:
[root@ca-ol-bordeaux-7-146 exemple]# cat /proc/misc 
  1 psaux
La valeur du mineur sera allouée dynamiquement au chargement du module.

La première partie du code est constituée des en-têtes et de la fonction permettant de récupérer l'adresse du port controlant la LED:

#include <linux/module.h>
#include <linux/sched.h>
#include <linux/fs.h>
#include <linux/miscdevice.h>
#include <asm/io.h>
#include <asm/segment.h>

static int base_addr = 0;
static struct miscdevice ledctl_dev;

#define LED_RED               0xFD
#define LED_ORANGE            0xFB
#define LED_OFF               0xFF

/* Lecture adresse de base */
static int read_base_addr (void)
{
  int addr;

  unsigned int msb, lsb;

  outb (0x07, 0x2E);
  outb (0x07, 0x2F);
  outb (0x60, 0x2E);
  msb = inb (0x2F);
  outb (0x61, 0x2E);
  lsb = inb (0x2F);
  addr = (msb << 8) + lsb;
  outb (0x0f, addr+1);

  return  addr;
}
Dans le cas du noyau 2.2, on devra ajouter la ligne:
#include <asm/uaccess.h>
qui inclut les macros de transfert entre l'espace utilisateur et l'espace noyau.

Le suite contient les fonctions d'ouverture et de fermeture du device:

static int ledctl_open (struct inode *inode, struct file *filp)
{
    printk("<1>ledctl: open\n");

    /* Comptage du nombre de chargements */
    MOD_INC_USE_COUNT;

    /* Adresse de base du hardware */
    base_addr = read_base_addr ();
    return 0;
}

static int ledctl_close (struct inode *inode, struct file *filp)
{
    /* Dechargement */
    MOD_DEC_USE_COUNT;
}

La fonction ledctl_read retourne à l'espace utilisateur la valeur contenue dans l'adresse de base, soit l'état de la LED. Le lecture sur le port se fait par la macro inb.

static int ledctl_read(struct inode *inode, struct file *filp,
              char *buf, int count)
{
    unsigned char led_value = inb (base_addr), led_color;

    switch (led_value) {

    case LED_RED: 
      led_color = 'R';
      break;

    case LED_ORANGE: 
      led_color = 'O';
      break;

    case LED_OFF: 
      led_color = 'E';
      break;

    default:
      led_color = '?';
      break;
    }
      
    put_user (led_color, buf);
    return count;
}

Dans le cas du noyau 2.2, les paramètres de la fonction sont un peu différents:
static ssize_t ledctl_read (struct file *file, char *buf, 
        size_t count, loff_t *ppos)

La fonction ledctl_write est similaire: on lit le caractère passé par le programe utilisateur et on l'écrit dans le port par un outb:

int ledctl_write(struct inode *inode, struct file *filp,
               const char *buf, int count)
{
  unsigned char cmd;

  cmd = get_user(buf++);

  switch (cmd) {
  case 'R':
    outb (LED_RED, base_addr);
    break;

  case 'O':
    outb (LED_ORANGE, base_addr);
    break;

  case 'E':
    outb (LED_OFF, base_addr);
    break;
    
  default:
    break;
  }
  
  return count;
}
Dans le cas du noyau 2.2, les paramètres de la fonction sont différents:
static ssize_t ledctl_write (struct file *file, const char *buf, size_t count, 
        loff_t *ppos)
L'appel à get_user permet de tester une erreur éventuelle. La fonction prend donc maintenant deux paramètres:
  if (get_user (cmd, buf++))
    return -EFAULT;

La suite contient la structure décrivant la liste des méthodes du pilote. Les entrées contenant NULL indiquent que la méthode n'est pas utilisée pour ce pilote:

struct file_operations ledctl_fops = {
    NULL,
    ledctl_read,
    ledctl_write,
    NULL,          /* ledctl_readdir */
    NULL,          /* ledctl_select */
    NULL,          /* ledctl_ioctl */
    NULL,          /* ledctl_mmap */
    ledctl_open,
    ledctl_close,
    NULL,          /* ledctl_fsync */
    NULL,          /* ledctl_fasync */
};
Pour le noyau 2.2, on a quelques différences, en particulier le remplacement de la méthode select par poll et l'apparition de la méthode flush.
struct file_operations ledctl_fops = {
    NULL,
    ledctl_read,
    ledctl_write,
    NULL,          /* ledctl_readdir */
    NULL,          /* ledctl_poll */
    NULL,          /* ledctl_ioctl */
    NULL,          /* ledctl_mmap */
    ledctl_open,
    NULL,          /* ledctl_flush */
    ledctl_close,
    NULL,          /* ledctl_fsync */
    NULL,          /* ledctl_fasync */
};

On termine par les fonctions de chargement/déchargement du module:

int init_module(void)
{
    int retval;

    printk("<1>ledctl: init_module\n");

    /* Calcul dynamique du mineur */
    ledctl_dev.minor = MISC_DYNAMIC_MINOR;

    ledctl_dev.name = "ledctl";
    ledctl_dev.fops = &ledctl_fops;

    /* Enregistrement du module */
    retval = misc_register(&ledctl_dev);
    if (retval) return retval;
    return 0;
}

void cleanup_module(void)
{
    printk("<1>ledctl: cleanup_module\n");

    /* Suppression du module */
    misc_deregister(&ledctl_dev);
}

On peut compiler ce module par:

  cc -O -DMODULE -D__KERNEL__ -c ledctl.c
puis l'insérer par:
  insmod ledctl.o
On obtient alors le mineur, qui permet de créer le point d'entrée dans le répertoire /dev. Dans un cas réel, on pourra facilement automatiser cette procédure en créant/supprimant dynamiquement le point d'entrée à chaque chargement/déchargement du module.
root@ca-ol-bordeaux-7-146 2.2]# cat /proc/misc 
 63 ledctl
  1 psaux
[root@ca-ol-bordeaux-7-146 2.2]# mknod /dev/ledctl c 10 63

On peut alors utiliser le module, en particulier pour passer la LED au rouge on fera:

  echo R > /dev/ledctl
et pour lire la valeur:
 dd bs=1 count=1 < /dev/ledctl

6. Références et bibliographie