2015-03-14
Coder un OS, c’est pas simple : des milliers de lignes de code, une logique complexe et difficile à suivre, beaucoup de composants qui communiquent, beaucoup d’opportunité pour des fautes débiles (surtout quand on a codé toute la journée !). Comme on peut le voir sur le log des commits, j’ai passé ma journée à corriger des bugs sur mon projet d’OS, Kogata (dont je n’ai d’ailleurs pas encore fait la présentation officielle, mais tout est ici). Dans cet article, je vais vous présenter le top 5 des bugs les plus chiants à détecter, ou tout simplement les plus débiles, que j’ai rencontré (et corrigé !) récemment.
C’est un grand classique, et j’imagine que toute personne qui fait de la programmation concurrente dans un langage bas niveau à déjà du y être confronté. Le principe est simple : on a une masse (typiquement 2 ou 3 suffisent) de threads qui veulent accéder en même temps à une structure de donnée pour modifier des trucs. Évidemment si tout le monde le fait en même temps ça cause des conflits, c’est pourquoi on utilise des verrous (mutex) sur ces structures, afin qu’un thread puisse s’approprier temporairement la structure pour pouvoir la modifier sans danger et la laisser dans un état cohérent pour la prochaine personne qui en a besoin.
Les bugs récemment présents dans Kogata étaient simplement dus à de
la négligence, à savoir écrire des fonctions qui modifient des données
sans verrouiller la mutex au préalable. Ces fonctions manipulaient la
structure process_t
, structure fondamentale s’il en est,
puisqu’elle gère l’unité de base du système : le processus utilisateur.
Les conséquences de ce bug étaient multiples et variées, et se
manifestaient parfois à des endroits inattendus (dans les fonctions
hashtbl_add
ou hashtbl_free
par exemple, qui
ne sont pas thread-safe dans mon code). On se retrouvait typiquement
avec des double free
, ou bien à utiliser des données qui
avaient déjà été libérées dans un autre thread, détruisant ainsi des
données utiles à l’allocateur de mémoire.
Attention, le paragraphe suivant est un peu indigeste - âmes sensibles s’abstenir.
Pour faire un context switch, c’est à dire un changement de
contexte permettant de basculer entre deux threads qui veulent
s’exécuter, on doit d’abord sauver le contexte du thread en cours,
c’est-à-dire les informations qui lui sont utiles, à savoir les
registres du processeur, afin de pouvoir reprendre l’exécution de ce
thread dans le même état ultérieurement. Un context switch peut être
invoqué à plusieurs endroits, mais en particulier lorsqu’une IRQ0 a lieu
: celle-ci est déclenchée périodiquement et permet d’interrompre un
processus qui a fini d’utiliser le temps de processeur qui lui était
alloué. La fonction appelée lors d’une IRQ0 doit déjà sauver une partie
du contexte du thread en cours, car le rôle d’une IRQ est d’interrompre
le traitement actuellement en cours et de lancer une procédure
spécifique pour gérer l’IRQ - il faut donc être capable de reprendre là
où on en était lorsque l’interruption a été traitée. Je pensais donc
bien faire en me disant que, lorsque je fait un context switch sur une
IRQ0, il n’était pas nécessaire de sauver deux fois les registres
généraux du processeur, puisque ceux-ci étaient déjà sauvés en entrant
dans l’IRQ0 et qu’après le retour du context switch (c’est-à-dire quand
on re-donnait la main à mon thread), j’allais immédiatement terminer mon
traitement de l’IRQ et restaurer le contexte qui était sauvé par le
handler IRQ0. (Vous me suivez ? Bon, accrochez-vous, c’est bientôt fini
!) En fait cela marchait avec les optimisations -O2
car gcc
optimisait effectivement mon code pour ne pas utiliser les registres
généraux entre le moment où mon thread est relancé et le moment où on
sort de l’IRQ, mais il y a néanmoins un retour de fonction en jeu dans
cette histoire, ce qui fait qu’au moins le registre ebp
est
utilisé. Quand on n’active pas les optimisations, gcc fait un peu ce
qu’il veut et utilise en fait plus de registres que ça. Et on ne peut
pas le blâmer, c’est bien cohérent avec la spécification du langage C.
La solution a donc été de sauvegarder complètement l’état du processeur
lors d’un context switch, même dans le cas d’une IRQ0.
Dans le même genre, il y a un bug où le compilateur optimise le code généré et se permet de modifier les arguments qui sont passés à une fonction. Le prototype de la fonction en question était le suivant :
void idt_irq_handler(registers_t regs) {
...
}
Or, la fonction C était appelée depuis du code assembleur qui présupposait que les arguments n’allaient pas être modifiés par la fonction appelée. C’était d’ailleurs critique car l’argument était justement un contexte sauvé dans le cas d’une IRQ. On se retrouvait donc à relancer des contextes modifiés de façon imprévisible, et on pouvait globalement se retrouver à exécuter n’importe quoi. Ce bug est en fait un bug connu dans un tutoriel d’OSdev plutôt populaire dont j’avais inspiré une partie de mon code. La solution est simple et consiste à passer un pointeur sur la structure, et non sa valeur :
void idt_irq_handler(registers_t *regs) {
...
}
La particularité de ce bug est que lorsque les optimisations n’étaient pas activé il ne se manifestait pas, car le code ne modifiait pas explicitement les arguments de la fonction, mais par contre avec les optimisations GCC réutilisait l’espace des arguments pour autre chose lorsqu’il voyait que ceux-ci n’allaient plus servir. Encore une fois, il avait le droit de le faire, c’est dans la spec.
Pour la gestion de l’espace mémoire virtuel du noyau (ainsi que pour celui de chacun des processus utilisateurs), on utilise un allocateur qui est en charge d’allouer des régions de mémoire, dont la taille est un multiple de la taille d’une page du système (4ko sur un processeur x86). Pour implémenter cet allocateur, on a une structure de donnée utilisée pour décrire par une liste liée les zones de mémoire virtuelle non allouées :
struct free_t {
void* addr;
size_t size;
free_t *next_by_size;
free_t *first_bigger;
free_t *next_by_addr;
};
En fait, cette structure est assez complexe car, comme vous pouvez le voir, on utilise trois pointeurs :
next_by_size
décrit les régions libres dans l’ordre de
taille croissante. Trier les descripteurs par ordre de taille croissante
permet de trouver rapidement le descripteur pour la région la plus
petite qui satisfait une certaine demande.next_by_addr
décrit les régions libres dans l’ordre des
adresses croissantes. Trier les descripteurs par adresse permet de
trouver une région libre juste avant ou juste après une région que l’on
libère, pour pouvoir ainsi fusionner les deux régions libres en une
région plus grande et éviter la fragmentation de la mémoire.first_bigger
permet d’accélérer la
recherche dans la liste triée par taille : il pointe sur le descripteur
de la première région dans cette liste qui a une taille strictement
supérieure à l’élément courant.Le bug qui était présent dans le code était que le pointeur
first_bigger
n’était pas maintenu correctement et pointait
parfois sur NULL
alors qu’il n’aurait pas dû, ce qui
causait l’allocateur de région à ne pas pouvoir allouer de région. Le
système plantait donc avec une erreur out-of-memory (OOM). On
est un peu surpris de continuer à voir cette erreur alors que l’on vient
de quadrupler la RAM allouée à sa VM de test.
Imaginons la structure suivante, décrivant typiquement des éléments d’une liste chaînée:
struct hashtbl_item_t {
void* key;
void* val;
hashtbl_item_t *next;
};
Maintenant, supposons que dans la précipitation, on itère dessus de la façon suivante :
for (hashtbl_item_t *i = ht->items[s]; i != 0; i++) {
f(i->key, i->val);
}
Le code précédent compile (en effet, il est autorisé d’incrémenter un pointeur, c’est d’ailleurs souhaitable), mais il ne fait pas du tout ce qu’on veut, et risque grandement de causer des écritures par-dessus d’autres données utiles. La bonne façon d’itérer est bien sûr la suivante :
for (hashtbl_item_t *i = ht->items[s]; i != 0; i = i->next) {
f(i->key, i->val);
}
Le code précédent n’est pas directement issu de Kogata (s’il y avait
eu un bug dans la structure hashtbl_t
, les conséquences
auraient été bien plus extrêmes), mais il y avait exactement la même
erreur à un autre endroit. Dans le même genre, il y avait à un moment le
bug suivant :
for (void* it = r->addr; it < r->addr + r->size; r += PAGE_SIZE) {
...
}
Où est le bug ? En fait l’itérateur qui devrait être incrémenté est
évidemment it
et non r
; ici le fait
d’incrémenter r
de PAGE_SIZE
correspondait en
fait à déplacer le pointeur r
de PAGE_SIZE
fois la taille de la structure pointée par r
, qui
était assez grande. On se retrouvait du coup avec un pointeur sur une
zone totalement random, ce qui causait généralement une page fault sur
une région invalide (équivalent en code noyau de la classique
segmentation fault) plutôt que des trashages de données
difficilement identifiables.
malloc
avec la mauvaise tailletoken_table_entry_t *e = (token_table_entry_t*)malloc(sizeof(token_t));
La structure token_table_entry_t
contient un champ
token_t
, et est donc plus grande que celle-ci. On alloue
donc ici un espace mémoire trop petit, ce qui fait que quand on écrira
dans certains champs de la structure allouée, on écrira en fait
par-dessus des données sensée appartenir à quelqu’un d’autre.
Les conséquences de ce bug sont assez imprévisibles, en l’occurrence
c’était des crash dans les fonctions malloc
et
free
car le bloc suivant en mémoire correspondait
généralement à un bloc décrivant une région libre pour l’allocateur de
mémoire.
Maintenant, pour terminer en beauté, imaginez un peu comme le bug précédent, sauf que j’avais écrit très précisément cette ligne :
iso9660_node_t *n = (iso9660_node_t*)malloc(sizeof(iso9660_node_t*));
Alors, où est le bug ? AHA ! Encore une fois, la taille allouée est
mauvaise, mais il faut mettre ses lunettes pour s’en rendre compte. En
effet, ici, au lieu d’allouer une structure iso9660_node_t
entière, on alloue simplement un pointeur sur une telle structure, ce
qui est bien sûr complètement inutile.
La conséquence de ce bug était que l’on se retrouvait à allouer de la mémoire parmi les “petits objets” (taille d’un pointeur), et l’écriture au-delà de la zone allouée causait des trashage aléatoire de données utiles au système, rendant ainsi les conséquences de ce bug totalement aléatoires, et très drôles (ou pas).
Avant que je corrige tous ces bugs, j’avais globalement un crash à
chaque fois que je lançais mon OS, ce qui rendait le développement
d’applications un peu difficile, comme vous pouvez l’imaginer. Quand on
est dans cette situation, il n’y a pas d’autre recours que de retrousser
ses manches et de mettre les mains dans le cambouis numérique. C’est
d’ailleurs le bon côté de la chose : j’ai appris à désassembler du code
avec gdb
, et Kogata est maintenant capable d’afficher un
stack trace à peu près utile en cas d’erreur. Mais à défaut de
vouloir s’abaisser à ce genre de magouilles, certains seront tentés de
se dire fuck it je vais coder dans un langage haut niveau,
d’ailleurs on me l’a répétitivement conseillé. Mais lorsque l’on part
comme ça, on se prend la tête sur des questions compliquées, du style
quel langage ? comment le rendre suffisamment bas niveau pour pouvoir
faire de l’OSdev quand même ? etc., et on abandonne après quelques
semaines de prise de tête conceptuelle pour revenir sur les pas de nos
aînés qui, eux, on compris comment on écrit du vrai code (true
story!).