I. Introduction▲
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. Débogage du noyau par affichage : printk▲
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
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. Mise en place et utilisation de GDB pour environnement noyau▲
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
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.
virsh edit ${vm_to_debug}
Ajoutez les lignes suivantes :
<
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.
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é).
rsync -ra --include '*/' --include='*.'{c,h,py,ko} --exclude='*' user@{ip_slave}:/lib/modules/${KERNEL_VERSION}/build/ remote/ # J’importe tous les fichiers .c et .h vers mon hôte.
rsync user@{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.
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 :
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 !
$ 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
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 par0x00007f
. 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.
IV. Systèmes de traçage Linux▲
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.
$ 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/.
V. Conclusion▲
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.
VI. Voir aussi▲
VII. Remerciements▲
Je tiens à remercier LittleWhite et Chrtophe pour leur relecture technique ainsi que ClaudeLELOUP pour sa relecture orthographique et Malick pour son aide à la publication.