Apprendre à déboguer un noyau Linux avec GDB, un tutoriel de Maxime Bélair

Uncategorized

C’est bien connu, la programmation en environnement noyau (kernel), c’est loin d’être simple. Partiellement parce que cela requiert une très bonne maitrise du C qui n’est clairement pas un langage simple, mais aussi parce qu’en cas de bug, les ennuis commencent…

  • La plupart du temps, un bug (mauvais pointeur…) va engendrer une panique du noyau (Kernel Panic) et il faudra alors redémarrer la VM de développement depuis l’hyperviseur (ne me dites pas que vous utilisez votre machine physique pour faire du développement noyau ou elle ne durera pas plus longtemps. Et vous non plus en tant que développeur noyau).
  • Les outils de débogage comme GDB nécessitent un environnement de débogage relativement complexe pet ne peuvent donc pas être utilisés aussi directement qu’en espace utilisateur.
  • Les outils de débogage classiques comme valgrind, ou strace sont des outils uniquement userspace et ne peuvent donc pas être utilisés dans le noyau.

Dans le cadre d’un développement sérieux, il est donc très fortement conseillé d’utiliser des techniques de débogage afin de s’assurer que son code se comporte comme prévu. Dans ce tutoriel, nous présenterons les principales et nous mettrons notamment l’accent sur GDB.

II-A. La famille de fonction printk

En environnement noyau, la famille de fonction printk constitue la technique la plus courante pour déboguer une exécution, elle constitue l’équivalent de la vénérable fonction printf. Cette approche met à disposition du développeur un outil de log multiniveau, simple d’utilisation et très utile pour détecter de nombreux problèmes.

La fonction printk a l’avantage de fonctionner à tout moment et à tout endroit du noyau. Les messages générés via printk peuvent être lus dès qu’une console est initialisée, ce qui se produit très rapidement dans la séquence de démarrage du noyau Linux.

Afin de faciliter le traitement des logs, le développeur peut utiliser un niveau d’importance pour son message. Dans l’implémentation actuelle, les niveaux de log sont :

0 KERN_EMERG System is unusable
1 KERN_ALERT Action must be taken immediately
2 KERN_CRIT Critical conditions
3 KERN_ERR Error conditions
4 KERN_WARNING Warning conditions
5 KERN_NOTICE Normal but significant condition
6 KERN_INFO Informational
7 KERN_DEBUG Debug-level messages

Par ailleurs, le noyau fournit également de faciliter légèrement l’utilisation de ces fonctions, le noyau fournit aussi la famille de fonction pr_* (pr_info, pr_warn, pr_debug …). Ces fonctions peuvent être utilisées de manière similaire à printk pour un niveau de log donné.

Tous les messages de la famille pr_* sont stockés automatiquement dans le buffer circulaire, la seule exception étant pr_debug qui est supprimée à la compilation si la macro DEBUG n’est pas définie pour le bloc de code donné. En la définissant dans le Kbuild ou à l’aide d’un #ifdef, cela permet d’afficher uniquement les messages de débogage souhaités. Ce type de débogage peut être considéré comme dynamique puisqu’il permet d’activer ou retirer simplement un ensemble de messages de débogage à l’aide d’un simple switch, mais nécessite quand même une recompilation du code pour que ces changements soient pris en compte

Il est possible d’indiquer le module qui a généré le message de manière standard en faisant précéder le message du module et de ‘:’ . Ceci peut être fait de manière automatique avec les macros pr_* en définissant la macro pr_fmt comme #define pr_fmt(fmt) KBUILD_MODNAME ": " fmt.

La famille de fonction *_once (printk_once…) permet de n’afficher un message de débogage qu’une seule fois permettant de mettre des messages dans une boucle sans risquer d’être « inondé » sous les messages du noyau.

Enfin, le noyau fournit des fonctions spécialisées pour un contexte telles que dev_*, conseillées pour l’affichage facile de messages dans le cadre de devices.

II-B. Exemple d’utilisation

Dans le code suivant, si mon noyau n’a plus assez de mémoire pour l’allocation de mes données, je permets à l’utilisateur de comprendre la cause du mauvais fonctionnement de mon module en rajoutant ce printk

 

Sélectionnez
if(!driver_allocate_struct()) { printk(KERN_ERR "%s: No memory left to initialize xxx\n", MODULE_NAME); return -ENOMEM;
}

Les messages du noyau comme celui-ci pourront être lus par un utilisateur à l’aide de la commande dmesg ou journalctl.

II-C. Limites de printk

Toutefois, printk est loin d’être une solution parfaite. Il pose notamment les problèmes suivants.

  • Puisque les messages printk sont directement « en dur » dans le code source du noyau, cette solution est statique et modifier un message force à recompiler le noyau, l’installer et redémarrer la machine. Cela peut rendre le débogage long et fastidieux.
  • En cas de débogage complexe, il peut être tentant d’afficher beaucoup de données à l’aide de printk (par exemple en le mettant à l’intérieur d’une boucle). Cependant, cela peut rapidement rendre le débogage complexe, remplir le ring buffer (128 KB sur ma machine, souvent 16 KB !) et réécrire dessus, perdant des messages potentiellement importants.
  • Dans les premières phases du démarrage, la console n’est pas initialisée. Il n’est donc pas possible d’afficher de message avec printk. Il est possible de quand même récupérer des messages à l’aide d’early_printk(détaillé ici), mais cela nécessite notamment de recompiler son noyau avec son support et de récupérer les messages via une machine distante, par exemple via USB, ce qui peut s’avérer fastidieux.
  • Enfin, printk est relativement gourmand en ressources, son abus peut nuire aux performances du système.

Dans ce tutoriel, nous apprendrons donc à déboguer notre noyau à l’aide de GDB, qui permet de passer outre les contraintes de printk. Comme en environnement userspace, GDB permet de gagner beaucoup de temps, mais son installation est un peu plus complexe ici. Nous allons donc parcourir les différentes étapes de la mise en place d’un bon environnement de débogage. Puisqu’il existe déjà de nombreux tutoriels sur l’utilisation de  GDB, celui-ci se focalisera principalement sur la configuration de GDB pour l’environnement noyau.

En plus de GDB, on trouve souvent dans la littérature la mention de KGDB ou de KBD pour faire référence à un débogueur noyau.

  • KGDB (Kernel GDB) est juste un moyen de faire l’emphase sur le fait qu’il s’agit d’un débogueur GDB adapté au noyau. Les deux termes peuvent donc être utilisés de manière interchangeable.
  • KDB (ou Built-in Kernel Debugger) est un autre débogueur historique pour le noyau Linux. Il permet notamment de déboguer directement sur la machine de test sans passer par une machine virtuelle, mais il ne permet qu’un débogage au niveau assembleur. Toutefois, depuis la version 4.4 (03/2009), le backend de KDB a été mergé avec celui de KGDB modifiant ces fonctionnalités. Les différences entre ces deux débogueurs sont donc aujourd’hui mineures.

III-A. Étape 1 : Création de la machine virtuelle de débogage

Dans le cadre d’un environnement de programmation noyau, Il est primordial de passer par une machine virtuelle non seulement parce que le système risque de crasher, mais aussi parce que le débogueur ne pourrait pas placer des points d’arrêt dans un noyau classique sinon, puisque celui-ci serait privilégié.

Je conseille l’utilisation de l’hyperviseur kvm qui est très adapté au débogage de Linux puisque le projet est directement intégré au noyau et qu’il facilite le débogage à distance avec GDB. L’installation de la machine de débogage peut s’effectuer à partir de virt-manager (interface graphique facile d’utilisation), virsh (ligne de commande) ou directement qemu/kvm. Cette machine peut utiliser n’importe quelle distribution. Ce n’est toutefois pas la seule possibilité et il est notamment possible de déboguer des machines virtuelles tournant sur d’autres plateformes comme l’émulateur Bochs ou l’hyperviseur VMWare.

III-B. Étape 2 : Compilation du noyau avec support du débogage

Une fois la machine virtuelle installée, il est temps de recompiler son noyau avec les options adaptées à son usage.

Pour configurer votre noyau utilisez make menuconfig. En plus du support liées la virtualisation nous vous conseillons de cocher les options suivantes :

  • CONFIG_GDB_SCRIPTS
  • CONFIG_FRAME_POINTER
  • CONFIG_DEBUG_KERNEL
  • CONFIG_DEBUG_INFO
  • CONFIG_DEBUG_BUGVERBOSE
  • CONFIG_KALLSYMS
  • CONFIG_KALLSYMS_ALL
  • CONFIG_IKCONFIG
  • CONFIG_IKCONFIG_PROC
  • CONFIG_CC_OPTIMIZE_FOR_DEBUGGING

Pour plus d’information sur ces macros, utilisez l’aide de menuconfig ou utilisez votre moteur de recherche préféré.

Si vous n’avez jamais compilé de noyau, le tutoriel de debian-facile peut vous aider. N’ayez pas peur, compiler un noyau peut paraître intrigant, mais cela se fait en quelques commandes et si vous êtes attentif vous ne risquez pas de casser votre machine. Notez que la compilation en elle-même peut être longue (environ 30 minutes), mais ne nécessite pas votre attention !

Une fois votre noyau configuré, il est temps de lancer sa compilation

 

Sélectionnez
time make -j$(($(nproc)+1)) # nproc + 1 pour optimiser la performance. Vous pouvez quand même aller prendre un café.
time make modules install -j$(($(nproc)+1)) sudo make install -j$(($(nproc)+1))
reboot

Si tout s’est bien passé vous devriez pouvoir démarrer sur votre nouveau noyau sans encombre. En cas de problème, vérifiez votre configuration. Dans le cas improbable où votre noyau panique dmesg pourra peut-être vous aider à localiser l’erreur.

III-C. Étape 3 : Mise en place du débogage à distance

La première étape est de modifier la configuration de la machine afin d’ouvrir un port sur lequel GDB pourra se connecter depuis la machine de débogage (hôte). Cela peut se faire avec virsh.

 

Sélectionnez
virsh edit ${vm_to_debug}

Ajoutez les lignes suivantes :

 

Sélectionnez
 <qemu:commandline> <qemu:arg value='-gdb'/> <qemu:arg value='tcp::1200'/> </qemu:commandline>

Vous pouvez évidemment remplacer 1200 par le port de votre choix.

Copiez ensuite le fichier vmlinux (qui correspondant à votre noyau) sur votre hôte et VM. À noter : à la place de la copie, vous pouvez aussi monter le noyau de la machine virtuelle dans l’hôte par exemple avec un sshfs.

 

Sélectionnez
rsync ${user}@${ip_slave}:/lib/modules/${KERNEL_VERSION}/source/vmlinux ${DEBUG_DIR}/
rsync --copy-links ${user}@${ip_slave}:/lib/modules/${KERNEL_VERSION}/source/vmlinux-gdb.py ${DEBUG_DIR}/

Si vous avez compilé votre noyau avec les symboles de débogage, vous devriez utiliser les sources pour faciliter votre débogage (fortement conseillé).

 

Sélectionnez
rsync -ra --include '*/' --include='*.'{c,h,py,ko} --exclude='*' [email protected]{ip_slave}:/lib/modules/${KERNEL_VERSION}/build/ remote/ # J’importe tous les fichiers .c et .h vers mon hôte.
rsync [email protected]{ip_slave}~/path/to/my/module.{c,ko,o,mod.o,mod.c} remote/modules/ # Même choses avec les éventuels modules à déboguer

Évidemment à chaque recompilation du noyau, n’oubliez pas de mettre à jour le fichier vmlinux et les sources dans l’hôte. En cas de simple modification d’un module noyau, seules les données correspondantes nécessitent d’être recopiées.

Le fichier vmlinux-gdb.py fournit des commandes qui peuvent largement faciliter votre expérience de débogage telle que lx-dmesg qui permet d’exécuter la commande dmesg depuis votre débogueur ou lx-symbols qui met à jour dans GDB les symboles noyau (c’est notamment utile lorsqu’un module noyau a été chargé). Pour pouvoir l’utiliser, il est probablement nécessaire que vous autorisiez ce script en ajoutant le répertoire correspondant aux dossiers autorisés.

 

Sélectionnez
echo "add-auto-load-safe-path ${DEBUG_DIR}/vmlinux-gdb.py” >>~/ .gdbinit

Mettez en place les variables d’environnement nécessaires au bon fonctionnement de GDB :

 

Sélectionnez
export PYTHONPATH="${DEBUG_DIR}/:$PYTHONPATH" # pour avoir accès au vmlinux-gdb.py
export cdir="${DEBUG_DIR}/" # Fichier contenant le code source du noyau.

Afin de ne pas oublier de réaliser une copie et d’éviter de réécrire ces lignes à chaque session, nous vous conseillons de créer un script contenant toutes ces commandes de configuration.

Ça y est, votre environnement est prêt, vous pouvez lancer votre débogueur !

 

Sélectionnez
$ gdb -q vmlinux -ex 'target remote localhost:1200' # Remplacer 1200 par votre port débogueur
Reading symbols from vmlinux…
gdb-peda$

Hourra, tout fonctionne ! Vous pouvez vous amuser travailler avec votre nouveau débogueur.

 


Comme vous pouvez le constater tout est fonctionnel… Bonne session de débogage !

III-D. Exemple de débogage avec GDB

Dans cette section, nous verrons un exemple simple, mais tout de même réaliste d’erreur de programmation noyau. Nous la résoudrons à l’aide du débogueur noyau. Puisque cet exemple est volontairement très simple, un développeur familier du noyau doit pouvoir trouver l’erreur par une simple inspection du code et ne devrait pas avoir besoin de recourir à un débogage du noyau. Cependant cela permet de garder un exemple simple à comprendre y compris pour un néophyte et le raisonnement reste valable pour des codes plus complexes.

Partons du code du tutoriel sur les character devices de Derek Molloy. Ce tutoriel est souvent conseillé pour commencer le développement de modules noyau Linux, mais il comporte une erreur de conception et ne fonctionnera pas sur des noyaux récents. Cela permet donc de montrer un exemple pédagogique de débogage noyau ici.

Tentons d’exécuter le code dans son état actuel :

 

/**
 * @file ebbchar.c
 * @author Derek Molloy
 * @date 7 April 2015
 * @version 0.1
 * @brief An introductory character driver to support the second article of my series on
 * Linux loadable kernel module (LKM) development. This module maps to /dev/ebbchar and
 * comes with a helper C program that can be run in Linux user space to communicate with
 * this the LKM.
 * @see http://www.derekmolloy.ie/ for a full description and follow-up descriptions.
 */ #include <linux/init.h> // Macros used to mark up functions e.g. __init __exit
#include <linux/module.h> // Core header for loading LKMs into the kernel
#include <linux/device.h> // Header to support the kernel Driver Model
#include <linux/kernel.h> // Contains types, macros, functions for the kernel
#include <linux/fs.h> // Header for the Linux file system support
#include <linux/uaccess.h> // Required for the copy to user function
#define DEVICE_NAME "ebbchar" ///< The device will appear at /dev/ebbchar using this value
#define CLASS_NAME "ebb" ///< The device class -- this is a character device driver MODULE_LICENSE("GPL"); ///< The license type -- this affects available functionality
MODULE_AUTHOR("Derek Molloy"); ///< The author -- visible when you use modinfo
MODULE_DESCRIPTION("A simple Linux char driver for the BBB"); ///< The description -- see modinfo
MODULE_VERSION("0.1"); ///< A version number to inform users static int majorNumber; ///< Stores the device number -- determined automatically
static char message[256] = {0}; ///< Memory for the string that is passed from userspace
static short size_of_message; ///< Used to remember the size of the string stored
static int numberOpens = 0; ///< Counts the number of times the device is opened
static struct class* ebbcharClass = NULL; ///< The device-driver class struct pointer
static struct device* ebbcharDevice = NULL; ///< The device-driver device struct pointer // The prototype functions for the character driver -- must come before the struct definition
static int dev_open(struct inode *, struct file *);
static int dev_release(struct inode *, struct file *);
static ssize_t dev_read(struct file *, char *, size_t, loff_t *);
static ssize_t dev_write(struct file *, const char *, size_t, loff_t *); /** @brief Devices are represented as file structure in the kernel. The file_operations structure from
 * /linux/fs.h lists the callback functions that you wish to associated with your file operations
 * using a C99 syntax structure. char devices usually implement open, read, write and release calls
 */
static struct file_operations fops =
{ .open = dev_open, .read = dev_read, .write = dev_write, .release = dev_release,
}; /** @brief The LKM initialization function
 * The static keyword restricts the visibility of the function to within this C file. The __init
 * macro means that for a built-in driver (not a LKM) the function is only used at initialization
 * time and that it can be discarded and its memory freed up after that point.
 * @return returns 0 if successful
 */
static int __init ebbchar_init(void){ printk(KERN_INFO "EBBChar: Initializing the EBBChar LKM\n"); // Try to dynamically allocate a major number for the device -- more difficult but worth it majorNumber = register_chrdev(0, DEVICE_NAME, &fops); if (majorNumber<0){ printk(KERN_ALERT "EBBChar failed to register a major number\n"); return majorNumber; } printk(KERN_INFO "EBBChar: registered correctly with major number %d\n", majorNumber); // Register the device class ebbcharClass = class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(ebbcharClass)){ // Check for error and clean up if there is unregister_chrdev(majorNumber, DEVICE_NAME); printk(KERN_ALERT "Failed to register device class\n"); return PTR_ERR(ebbcharClass); // Correct way to return an error on a pointer } printk(KERN_INFO "EBBChar: device class registered correctly\n"); // Register the device driver ebbcharDevice = device_create(ebbcharClass, NULL, MKDEV(majorNumber, 0), NULL, DEVICE_NAME); if (IS_ERR(ebbcharDevice)){ // Clean up if there is an error class_destroy(ebbcharClass); // Repeated code but the alternative is goto statements unregister_chrdev(majorNumber, DEVICE_NAME); printk(KERN_ALERT "Failed to create the device\n"); return PTR_ERR(ebbcharDevice); } printk(KERN_INFO "EBBChar: device class created correctly\n"); // Made it! device was initialized return 0;
} /** @brief The LKM cleanup function
 * Similar to the initialization function, it is static. The __exit macro notifies that if this
 * code is used for a built-in driver (not a LKM) that this function is not required.
 */
static void __exit ebbchar_exit(void){ device_destroy(ebbcharClass, MKDEV(majorNumber, 0)); // remove the device class_unregister(ebbcharClass); // unregister the device class class_destroy(ebbcharClass); // remove the device class unregister_chrdev(majorNumber, DEVICE_NAME); // unregister the major number printk(KERN_INFO "EBBChar: Goodbye from the LKM!\n");
} /** @brief The device open function that is called each time the device is opened
 * This will only increment the numberOpens counter in this case.
 * @param inodep A pointer to an inode object (defined in linux/fs.h)
 * @param filep A pointer to a file object (defined in linux/fs.h)
 */
static int dev_open(struct inode *inodep, struct file *filep){ numberOpens++; printk(KERN_INFO "EBBChar: Device has been opened %d time(s)\n", numberOpens); return 0;
} /** @brief This function is called whenever device is being read from user space i.e. data is
 * being sent from the device to the user. In this case is uses the copy_to_user() function to
 * send the buffer string to the user and captures any errors.
 * @param filep A pointer to a file object (defined in linux/fs.h)
 * @param buffer The pointer to the buffer to which this function writes the data
 * @param len The length of the b
 * @param offset The offset if required
 */
static ssize_t dev_read(struct file *filep, char *buffer, size_t len, loff_t *offset){ int error_count = 0; // copy_to_user has the format ( * to, *from, size) and returns 0 on success error_count = copy_to_user(buffer, message, size_of_message); if (error_count==0){ // if true then have success printk(KERN_INFO "EBBChar: Sent %d characters to the user\n", size_of_message); return (size_of_message=0); // clear the position to the start and return 0 } else { printk(KERN_INFO "EBBChar: Failed to send %d characters to the user\n", error_count); return -EFAULT; // Failed -- return a bad address message (i.e. -14) }
} /** @brief This function is called whenever the device is being written to from user space i.e.
 * data is sent to the device from the user. The data is copied to the message[] array in this
 * LKM using the sprintf() function along with the length of the string.
 * @param filep A pointer to a file object
 * @param buffer The buffer to that contains the string to write to the device
 * @param len The length of the array of data that is being passed in the const char buffer
 * @param offset The offset if required
 */
static ssize_t dev_write(struct file *filep, const char *buffer, size_t len, loff_t *offset){ sprintf(message, "%s(%zu letters)", buffer, len); // appending received string with its length size_of_message = strlen(message); // store the length of the stored message printk(KERN_INFO "EBBChar: Received %zu characters from the user\n", len); return len;
} /** @brief The device release function that is called whenever the device is closed/released by
 * the userspace program
 * @param inodep A pointer to an inode object (defined in linux/fs.h)
 * @param filep A pointer to a file object (defined in linux/fs.h)
 */
static int dev_release(struct inode *inodep, struct file *filep){ printk(KERN_INFO "EBBChar: Device successfully closed\n"); return 0;
} /** @brief A module must use the module_init() module_exit() macros from linux/init.h, which
 * identify the initialization function at insertion time and the cleanup function (as
 * listed above)
 */
module_init(ebbchar_init);
module_exit(ebbchar_exit);

Je compile le code et l’insère dans le noyau.

 

 make && make install

Le module charge sans souci

 

[ 166.509046] EBBChar: Initializing the EBBChar LKM
[ 166.509048] EBBChar: registered correctly with major number 247
[ 166.509056] EBBChar: device class registered correctly
[ 166.509655] EBBChar: device class created correctly

Mais si j’essaye d’écrire dans le character device avec la commande suivante :

 

sudo echo 'azer' >/dev/ebbchar

On obtient le kernel Oops suivant (c’est-à-dire un bug sérieux de niveau juste inférieur à une panique noyau) :

 

[ 359.839419] BUG: unable to handle page fault for address: 0000555fc992e400
[ 359.839421] #PF: supervisor read access in kernel mode
[ 359.839422] #PF: error_code(0x0001) - permissions violation
[ 359.839422] PGD 80000001afd25067 P4D 80000001afd25067 PUD 188157067 PMD 1998e8067 PTE 800000015967d867
[ 359.839424] Oops: 0001 [#1] SMP PTI
[ 359.839426] CPU: 1 PID: 5152 Comm: echo Tainted: G OE 5.3.0-rc1mytesting-00056-g7b5cf701ea9c-dirty #5
[ 359.839426] Hardware name: VMware, Inc. VMware Virtual Platform/440BX Desktop Reference Platform, BIOS 6.00 07/29/2019
[ 359.839430] RIP: 0010:string_nocheck+0x13/0x70
[ 359.839431] Code: 00 00 00 4c 89 e7 e8 2c 95 00 00 4c 01 eb e9 79 ff ff ff 0f 1f 40 00 48 89 c8 55 49 89 f1 48 c1 f8 30 66 85 c0 48 89 e5 74 44 <44> 0f b6 02 45 84 c0 74 3b 83 e8 01 4c 8d 54 07 01 b8 01 00 00 00
[ 359.839432] RSP: 0018:ffffb5b7c2a6bd78 EFLAGS: 00010286
[ 359.839432] RAX: ffffffffffffffff RBX: ffffffffffffffff RCX: ffff0a00ffffff04
[ 359.839433] RDX: 0000555fc992e400 RSI: ffffffffffffffff RDI: ffffffffc09e94e0
[ 359.839433] RBP: ffffb5b7c2a6bd78 R08: ffffffffc09e9000 R09: ffffffffffffffff
[ 359.839434] R10: ffffb5b7c2a6be80 R11: 0000000000000000 R12: 0000555fc992e400
[ 359.839435] R13: ffff0a00ffffff04 R14: ffffffffc09e82d4 R15: ffffffffc09e82d4
[ 359.839435] FS: 00007fd67ce03540(0000) GS:ffff9d8e73440000(0000) knlGS:0000000000000000
[ 359.839436] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[ 359.839436] CR2: 0000555fc992e400 CR3: 0000000142f4e004 CR4: 00000000003606e0
[ 359.839460] Call Trace:
[ 359.839463] string+0x48/0x60
[ 359.839466] vsnprintf+0x444/0x510
[ 359.839467] sprintf+0x51/0x70
[ 359.839469] dev_write+0x26/0x80 [ebbchar]
[ 359.839470] __vfs_write+0x1b/0x40
[ 359.839471] vfs_write+0xb1/0x1a0
[ 359.839472] ksys_write+0xa7/0xe0
[ 359.839474] __x64_sys_write+0x1a/0x20
[ 359.839476] do_syscall_64+0x5a/0x130
[ 359.839477] entry_SYSCALL_64_after_hwframe+0x44/0xa9

En lisant ce dmesg, on constate que j’ai un accès incorrect dans la fonction sprintf qui est appelée dans ma fonction dev_write. Essayons de comprendre ce qui se passe à l’aide du débogueur. Reprenons depuis le début

 

# insmod ebbchar.ko # J’insère mon module noyau
$ ./start.sh
gdb-peda$ lx-symbols # J’actualise les symboles
loading vmlinux
scanning for modules in /home/max/prog/kgdb/remote
loading @0xffffffffc015d000: /home/my/path/modules/ebbchar.ko
loading @0xffffffffc027d000: /home/my/path/drivers/input/evdev.ko
# …
gdb-peda$ b dev_write # Je mets un point d’arrêt dans la fonction qui m’intéresse
Breakpoint 1 at 0xffffffffc013d000: file /home/user/testMmap/ebbchar.c, line 144.
gdb-peda$ c # Je continue l’exécution

Mon test est prêt, je lance donc un code de test qui va l’appeler

 

sudo echo 'azer' >/dev/ebbchar

Quand l’exécution arrive sur ma fonction déboguée, elle s’arrête.

 

Thread 2 hit Breakpoint 1, dev_write () at /home/user/testMmap/ebbchar.c:144
144 static ssize_t dev_write(struct file *filep, const char *buffer, size_t len, loff_t *offset){
gdb-peda$ x/s message # Je peux lire mes variables normalement
0xffffffffc013f4e0 <message>: "aa\003(4 letters)"
gdb-peda$ x/s buffer # Par contre je ne peux pas lire ma variable buffer
0x7ffc7fa6e4dc: Cannot access memory at address 0x7ffc7fa6e4dc

On peut donc remonter à la cause première de ce crash :

  • on n’a pas accès à la variable buffer, c’est donc sa lecture dans le sprintf qui causera un crash ;
  • la variable est située à une adresse complètement différente des autres. Les adresses noyau commencent normalement par 0xff, ici l’adresse commence par 0x00007f. Ce type de mémoire appartient à l’espace utilisateur (cf. mapping mémoire Linux). On essaye donc d’accéder depuis le noyau à de la mémoire utilisateur sans utiliser les fonctions appropriées. Cette opération brisant la séparation des espaces, elle déclenche une erreur de privilèges (sauf dans certains cas particuliers où la mémoire est cachée) ce qui engendre le kernel Oops que l’on a rencontré.
  • Il faudrait utiliser copy_from_user pour faire correctement ce type d’accès et éviter ce problème.

On a trouvé la cause du problème, nous pouvons donc corriger le code, par exemple comme suit :

 

static ssize_t dev_write(struct file *filep, const char *buffer, size_t len, loff_t *offset) { char* kbuffer = kmalloc(len, GFP_KERNEL); copy_from_user(kbuffer, buffer, len); sprintf(message, “%s(%zu letters)”, kbuffer, len); // appending received string with its length size_of_message = strlen(message); // store the length of the stored message printk(KERN_INFO “EBBChar: Received %zu characters from the user\n”, len); kfree(kbuffer); return len;
}

On peut alors exécuter le code, il fonctionne comme prévu !

 

# echo 'frjiogrfrkogpregkep'>/dev/ebbchar
# dmesg
/* … */
[ 1535.923624] EBBChar: Device has been opened 1 time(s)
[ 1535.951148] EBBChar: Received 20 characters from the user
[ 1535.951161] EBBChar: Device successfully closed

GDB nous a permis de remonter facilement à la cause de l’erreur. Si ce cas était simple, et pouvait être trouvé par une simple lecture attentive, GDB peut s’avérer extrêmement précieux dans des cas plus complexes.

III-E. Limites de GDB en environnement noyau

Dans l’ensemble, l’utilisation noyau de GDB est très similaire à une utilisation en espace utilisateur. Toutefois, étant donné l’architecture du noyau, on peut constater dans ce cadre deux limitations supplémentaires :

  • puisque déboguer un noyau implique d’utiliser les symboles de débogage et d’être connecté depuis une machine distante, on a une perte de performance non négligeable ;
  • certaines fonctionnalités Linux étant liées à des optimisations du compilateur, il est impossible de désactiver complètement l’optimisation noyau, même dans un contexte de débogage. Ainsi dans certains cas, il sera impossible d’afficher la valeur de certaines variables qui seront supprimées lors de la phase d’optimisation de GCC. Il faudra donc parcourir le code assembleur pour comprendre dans quel registre/adresse mémoire se trouve la variable afin de l’afficher. Il est toutefois possible de désoptimiser une fonction en rajoutant l’annotation __attribute__((optimize("O0"))), mais cela n’est pas généralisable à toutes les fonctions et nécessite de recompiler le noyau.

Malgré les limitations de GDB en environnement noyau, il reste utilisable de manière assez similaire à l’espace utilisateur. Dans la prochaine partie nous verrons un exemple d’utilisation de GDB en espace noyau.

Linux fournit un ensemble de systèmes de traçages qui peuvent être utilisés comme outils de débogage. Cette section présente les principaux.

IV-A. eBPF

eBPF (Extended Berkeley Packet Filter) est une fonctionnalité Linux assez populaire qui permet d’écrire, d’insérer et d’exécuter dynamiquement un code dans le noyau. Pour des raisons évidentes de sécurité, ce langage possède de fortes limitations (pas de boucles, moins de 1024 instructions, pas d’accès direct aux structures noyau, vérification des accès mémoire, garantie de terminaison…). Ainsi, eBPF est considéré comme un langage sûr, c’est-à-dire qu’un programme eBPF même malveillant ne doit (en théorie) pas pouvoir compromettre un noyau. Ce ne serait évidemment pas du tout le cas avec du code noyau arbitraire. eBPF ou cBPF sont utilisés dans de très nombreux logiciels dont netfilter et seccomp-bpf.

Par ailleurs, le code eBPF est rapide et peut être compilé depuis des langages de plus haut niveau comme le C avec clang-bpfet chargés facilement comme avec bcc en Python.

Ainsi, malgré ses limitations eBPF permet d’exécuter dynamiquement un code très versatile et comprend la sémantique noyau, généralement à l’aide d’helpers qui permettent d’accéder à des ressources sans être trop dépendant des détails d’implémentations des structures du noyau.

Ainsi, dans le cadre du débogage de multitudes d’opérations peuvent être réalisés avec eBPF telles que des tests de performance, de l’exécution de code lors d’événements particuliers, l’affichage des objets accédés…

L’utilisation d’eBPF peut être facilitée à l’aide de frameworks de haut niveau comme bpftrace.

La fonctionnalité eBPF est présentée plus en détail dans cette série d’articles LWN.

IV-B. Kprobes

Kprobes est un mécanisme de débogage pour le noyau Linux qui peut aussi être utilisé pour monitorer des événements dans un système en production. Il peut aussi être utilisé pour détecter les goulots d’étranglement performance, logger des événements spécifiques, tracer des problèmes…

Techniquement, kprobes modifie le code en y ajoutant des breakpoints (int3) ce qui permet d’effectuer des opérations arbitraires.

Kprobes permet de créer des primitives de débogage ou de monitoring simplement comme montré dans l’exemple ci-dessous.

 

Sélectionnez
$ sudo ./kprobe 'p:myopen do_sys_open filename=+0(%si):string'

Toutefois, cela nécessite de connaitre les détails des registres utilisés dans les appels système (ici savoir que l’argument contenant le nom de fichier se trouve dans le registre %rsi, ce qui est une information de très bas niveau et dépendante de l’architecture. Cela complexifie donc l’utilisation des kprobes.

Les kprobes sont présentés plus en détail ici.

IV-C. Tracepoints

Les tracepoints sont une fonctionnalité Linux permettant d’insérer des « bouts de code » à des endroits spécifiques du noyau.

Contrairement aux Kprobes, Les tracepoints permettent de monitorer des événements de manière statique dans le noyau. Cela signifie que son utilisation requiert une recompilation du noyau. Ces tracepoints sont relativement indépendants de la version du noyau utilisée ce qui rend plus flexible leur maintenance en comparaison des kprobes. Les tracepoints peuvent être activés et désactivés à la volée et ne génèrent quasiment pas d’overhead s’ils sont désactivés.

Toutefois l’utilisation des tracepoints peut s’avérer complexe, comme l’illustre l’exemple ci-dessous :

 

 DECLARE_EVENT_CLASS(sched_wakeup_template, TP_PROTO(struct rq *rq, struct task_struct *p, int success), TP_ARGS(rq, p, success), TP_STRUCT__entry( __array( char, comm, TASK_COMM_LEN ) __field( pid_t, pid ) __field( int, prio ) __field( int, success ) __field( int, target_cpu ) ), TP_fast_assign( memcpy(__entry->comm, p->comm, TASK_COMM_LEN); __entry->pid = p->pid; __entry->prio = p->prio; __entry->success = success; __entry->target_cpu = task_cpu(p); ), TP_printk("comm=%s pid=%d prio=%d success=%d target_cpu=%03d", __entry->comm, __entry->pid, __entry->prio, __entry->success, __entry->target_cpu) );

Les tracepoints sont présentés plus en détail dans les trois articles suivants https://lwn.net/Articles/381064/, https://lwn.net/Articles/381064/, https://lwn.net/Articles/383362/.

Une raison faisant que peu de gens utilisent un débogueur sous Linux est que Linus Torvalds n’aime pas vraiment le débogage noyau. Cela explique en partie le fait que peu d’effort ait été fait par la communauté Linux pour faciliter la mise en place du débogueur.

Malgré tout, il me semble que le débogueur reste un outil extrêmement utile. S’il ne remplace pas la grande rigueur qui doit caractériser un développeur Linux, il peut faciliter le développement et la vérification de l’absence de bugs. Bien que de nombreuses solutions existent aujourd’hui au débogueur, GDB semble être plus puissant et versatile que celles-ci pour de nombreux usages. C’est pourquoi, dans le cas de plantages complexes ou rares, le débogueur est un ami incontournable qui permet à son utilisateur d’éviter un grand nombre d’heures d’arrachage de cheveux.

En conclusion, il est possible de s’en sortir sans débogueur, mais si vous développez régulièrement en environnement noyau, il est très probable que vous l’adoptiez.

Source

Sharing is caring!

Leave a Reply