[Atom] [Mail] [Twitter]
Liens : git · hacks · divers · cabale · buzz! · à propos +

Détection automatique de bugs

Réactions
<. Histoire de sexe et d'informatique
>. Cohérence des effets de bord

L’Ours & Hippy crew vous présente son second billet feat. Cygal. Sous le regard hippie de blueblue aux commandes des platines d’édition — que l’on reconnaît grâce au manque d’originalité de son titre frigido-sérieux — l’artiste nous a concocté en direct de derrière le studio un nouvel article…
Qui nous parle du chant des insectes déviants,
Leurs pesticides heuristiques,
Et ses recettes hygiéniques,
À inculquer absolument aux ptits enfants !

—minh

Cette réaction traite d’un point qui va finir par ressembler à une obsession de ma part : la correction de bugs. Plus précisément, ce papier traite de la détection automatique de bugs. Le but est d’obtenir du programme assez d’informations pour pouvoir remarquer des comportements anormaux.

☃ : Alors que pas du tout, je suis encore à la recherche d’un fétiche.

C’est d’une certaine manière ce qu’apporte le typage à la compilation : les types apportent suffisament d’informations pour pouvoir détecter un certain nombre d’erreurs avant qu’elles n’apparaissent à l’exécution. L’analyse qui est faite ici est aussi statique, et se base sur un principe général qui peut s’appliquer à un nombre étonnament varié d’erreurs possibles. Les auteurs du papier ont ainsi détecté des centaines de bugs dans les noyaux Linux et BSD, principalement dans des drivers pas toujours maintenus.

Des langages comme OCaml sont capables d’inférer les types utilisés dans un programme, c’est-à-dire les deviner à partir du code. Le principe est ici le même, sauf qu’on essaie d’inférer du sens pour nous aider à trouver des erreurs.

Présentation du papier

Les exemples donnés ici sont directement tirés du papier : Bugs as deviant behavior (PDF, 16 pages).

Le principe est donc d’inférer le maximum d’informations possible dans chaque fonction, et d’essayer de détecter les contradictions présentes. L’existence d’une contradiction entre deux lignes montre qu’il y a forcément une erreur, même si le système ne peut pas forcément savoir de quelle ligne il s’agit.

Le montant d’informations qu’il est possible d’obtenir à partir d’un code source automatiquement est non seulement limité, mais on doit parfois se baser sur des informations peut-être fausses. C’est pourquoi j’ai séparé la présentation en deux courtes parties :

MUST beliefs

Ainsi, prenons un exemple simple, tiré d’une version du noyau Linux aussi vieille que le papier.

/* 2.4.7 drivers/char/mxser.c */
int mxser_write(struct tty_struct *tty, ...) {
  struct mxser_struct *info = tty->driver_data;
  unsigned long flags;

  if (!tty || !info->xmit_buf)
    return(0);
  ...

Le bug ici vient du déréférencement du pointeur tty alors qu’il pourrait valoir NULL. C’est quelque chose qui peut arriver assez vite si l’on ni porte pas attention, par exemple pendant l’évolution d’un code source. Regardons comment notre programme fonctionne dans ce cas précis.

*info = tty->driver_data
Le pointeur tty est déréférencé, ce qui veut dire que le programmeur estime qu’il ne peut pas être nul.
if (!tty || !info->xmit_buf)
La nullité du pointeur tty est testée, ce qui veut dire que le programmeur estime qu’il peut être nul.

On a donc une contradiction : l’une des deux lignes pose problème, ce qui être un bug. Et en effet si le pointeur peut être nul, il est dangereux de le déréférencer.

En pratique, il y a beaucoup trop de faux positifs. Par exemple, les macros vérifient souvent que leur arguments ne sont pas nul, et une fois que leur expansion est faite, on ne fait pas la différence avec le code normal. Les auteurs ont donc modifié leur programme (basé sur GCC apparement) pour annoter les expansions de macros, et ne pas les traiter pendant l’algorithme. D’autres problèmes ont été traités pour pouvoir montrer de réelles erreurs, je vous laisse lire le papier si ça vous intéresse.

MAY beliefs

Dans l’exemple sur les pointeurs donné ci-dessus et quelques autres, le programme est capable de dire avec certitude qu’il y a un problème. Cependant, ce n’est souvent pas possible de l’affirmer avec certitude : les informations inférées lors de la lecture du code ne sont pas forcément exactes. Ce n’est pas une raison de les ignorer, mais il faut les traiter avec prudence. L’idée est alors de rassembler un maximum de cas d’utilisations similaires, et d’en déduire les cas érronés. Par exemple, si une fonction est utilisée 999 fois d’une certaine manière, et autrement ailleurs, on peut supposer que le cas seul est érroné.

Donnons encore une fois un exemple pour expliquer cette pratique.

lock lk;            // Lock
int a, b;           // Variables potentially
                    // protected by l
void foo() {
       lock(lk);    // Enter critical section
       a = a + b;   // MAY: a, b protected by l
       unlock(lk);  // Exit critical section
       b = b + 1;   // MUST: b not protected by l
}
void bar() {
       lock(lk);
       a = a + 1;   // MAY: a protected by l
       unlock(lk);
}
void baz() {
       a = a + 1;   // MAY: a protected by l
       unlock(lk);
       b = b - 1;   // MUST: b not protected by l
       a = a / 5;   // MUST: a not protected by l
}

Supposons que lock() soit un appel système qui existe, et qui bloque l’accès aux données du noyau. On suppose donc que toute variable modifiée entre deux locks peut être concernée par ce lock. Une fois que l’on sait à peu près quelles sont les chances estimées que la variable soit concernée par le lock lk, on peut dire à l’utilisateur à quel point une utilisation en dehors du lock peut être dangereuse.

‽ : Pour information, le Big Kernel Lock a été créé, puis est enlevé petit à petit.

Ici, la fonction foo nous permet de dire que a est sûrement concerné par le lock lk, et que b peut l’être (mais moins de chance, vu que l’accès ne se fait qu’en lecture, non ?). La fonction bar montre que a est la seule variable utilisée dans le lock, donc il commence à y avoir de grandes chances que ce soit effectivement a qui est protégé. Dans baz, a est utilisé en dehors d’un lock, donc c’est certainement un bug qu’il faut corriger !

Cette manière d’essayer de deviner des informations plausibles pour ensuite essayer de détecter des erreurs a elle aussi de nombreuses applications décrites dans le papier. Certaines ne sont que des idées placées en plein milieu sans aucun rapport : d’ailleurs le style du papier laisse à désirer, le tout n’est pas très structuré, soyez avertis si vous comptez le lire (sachez aussi que je ne prétends pas faire mieux :). Une de ces idées consiste en la vérification de la cohérence d’une API proposée d’une version stable à une autre. Si on compare les croyances obtenues par notre système entre deux versions stables, elle doivent être exactement les mêmes. Sinon cela implique une possibilité de casser les applications utilisant l’API en question. Cela peut être aussi trouvé via l’utilisation de tests (potentiellement unitaires), mais c’est plus compliqué à mettre en place : un effort de la part du programmeur est impératif, et un test doit être exécuté, contrairement à l’analyse qui est ici statique.

N’hésitez pas à lire le papier si ces quelques lignes ne vous ont pas endormies : un certain nombre d’autres techniques intéressantes pour par exemple :

Enseignements à tirer ?

Pour m’être amusé pendant tout un été à corriger des bugs, je commence à avoir une idée des pratiques à éviter ou au contraire à encourager pour éviter l’apparition de bugs. Le principe de base de ces pratiques est d’apporter le maximum d’informations à celui qui va lire le code (sans parler des commentaires). Ainsi, si un programme est capable de penser que telle façon de faire quelque chose est une erreur, c’est que certainement un humain qui va passer par là sans savoir comment fonctionne le code va aussi penser à une erreur, ou du moins ne va pas comprendre au premier abord ce qui se passe.

C’est l’origine de plusieurs pratiques couramment encouragées en programmation. Citons ici KISS et la minimisation des effets de bords. La première permet à tout le monde de mieux comprendre un code, de mieux comprendre ce qu’il fait exactement pour pouvoir l’utiliser et le modifier la conscience tranquille.

La seconde permet de pouvoir utiliser une fonction sans avoir peur qu’elle casse autre chose ailleurs. Ça permet aussi d’utiliser de la composition de fonction sans risque. Il y a beaucoup de choses à dire sur cette pratique, mais retenez que l’intérêt principal est de ne pas cacher les canaux par lesquels transite l’information pour rendre le code le plus compréhensible et facile à modifier possible. Moins le programmeur qui lit le code a de chances d’être surpris, moins il y a de chances d’avoir des problèmes par la suite.

Ainsi, certains 'design patterns' ont beau résoudre des problèmes, ils ajoutent souvent une couche d’abstraction qu’il faut pouvoir traverser pour comprendre exactement ce qui se passe. Cette couche empêche l’échange d’informations entre deux parties du code qui intéragissent pourtant directement, ce qui provoquera forcément des bugs, qui auraient pu être détectés autrement. De la même manière, trop de généricité empêche une vision claire du code qu’on a sous les yeux. Cela a causé certains problèmes qui sont devenus tellement fréquents qu’il a fallu inventer d’autres design patterns pour les éviter. L’important est d’en être conscient et d’ajouter d’autres mesures de protection quand le bon sens où le compilateur ne peut plus aider à filtrer les erreurs.

De la même manière, utiliser son langage de manière idiomatique et rester consistant dans son formattage, sa façon de penser et les techniques utilisées est important. Cela permet au lecteur de retirer beaucoup d’informations rapidement, et donc de pouvoir se concentrer sur les points importants.

Si le programme a cru détecter une erreur qui n’était en fait qu’un faux positif, il reste néanmoins important d’essayer de corriger le code : tous les humains qui le liront risqueront de faire la même erreur et de ne pas comprendre ce qui se passe. Dans ce sens là, les exemples présents dans le papier sont instructifs : se sont des erreurs qui peuvent sembler anodines mais qui restent importantes.

[ tag:blog.huoc.org,2009:bluestorm/3 ]
Quentin Pradet (Cygal).
Le 04.09 2009 à 22:12.
(Modifié le 01.03 2010 à 23:29.)

[>] Commentaires[Atom]

# zulon
04.09.09, 19:46.

Article très intéressant parlant de techniques que je ne connaissais pas, merci :).

(I ♥ ☃)
[ tag:blog.huoc.org,2009-09-04:1252086379.6075 ]

# gnomnain
04.09.09, 19:49.

Ouais, moi aussi j'ai bien aimé l'article. Je me demandais juste si c'était normal qu'il y ait un unlock(lk) et pas de lock(lk) dans la fonction baz de l'exemple.
[ tag:blog.huoc.org,2009-09-04:1252086558.6075 ]

# Asgeir
04.09.09, 19:53.

Bien kiffant, surtout le dernier passage. Ça donne bien envie de lire la suite, sans nous laisser trop sur notre faim.

Par contre, par pitié, retire tes nom-prénom de la signature. Merci pour ta future engeance.

P.S. Sinon, c'est quoi ce délire avec les '☃' et '‽' ?
[ tag:blog.huoc.org,2009-09-04:1252086833.6075 ]

# Cygal
04.09.09, 20:16.

gnomnain, ce sont des choses qui peuvent arriver. Le lock() peut avoir été fait dans une autre fonction. Au contraire je trouve ça intéressant pour deux raisons :
  • on se demande si il y a un bug donc c’est peut-être pas une bonne pratique,
  • le programme doit pouvoir aussi se débrouiller dans ces là. Il suppose que si on a appelé unlock() sans lock() avant, ça a été fait ailleurs (il préfère supposer que le programmeur sait ce qu’il fait, sinon il y aurait largement de quoi s’inquiéter en dehors des problèmes de concurrence).
Ce sont des questions traitées dans le papier que je vous encourage à lire si vous êtes intrigués.
[ tag:blog.huoc.org,2009-09-04:1252088199.6075 ]

# GuilOooo
04.09.09, 20:38.

Je comptais en premier lieu ne pas commenter jusqu'à avoir lu le papier. Me voici malgré tout, mais je commenterai sûrement à nouveau après l'avoir lu.

Une première remarque, cependant : on voit dans ton papier la méthode générale pour exploiter les informations données par le code source, mais pas la façon dont elles sont inférées. J'imagine que c'est dans le papier, mais c'est l'aspect qui m'intéresse le plus ici, donc je suis un peu resté sur ma faim.

Une question, ensuite : ne peut-on pas déléguer la récupération d'informations au système de type du langage ? Par exemple, on pourrait imaginer (même si le C est un mauvais exemple ici) deux types de pointeurs : le pointeur qui ne vaut jamais NULL, et celui qui peut valoir NULL. En C, c'est difficilement imaginable, mais dans un langage avec un système de type plus évolués, ce genre de choses est tout à fait réalisable : c'est en gros le type Maybe en Haskell (et son homologue en caml, dont j'ignore le nom).
Mon exemple se généralise-t-il bien aux autres techniques décrites dans la réaction, et le papier ?
[ tag:blog.huoc.org,2009-09-04:1252089482.6075 ]

# Cygal
04.09.09, 20:58.

La méthode est expliquée après le premier code, et dans les commentaires. Ligne après ligne, des informations sont relevées, jusqu'à l'apparition d'une contradiction. C'est pas un algo particulièrement compliqué, mais c'est une idée qui leur a permis de trouver plein de bugs, contrairement à leurs autres méthodes.

Si j'ai bien compris ce que tu proposes, à mon avis le système de typage joue déjà ce rôle, ici on cherche d'autres bugs. Et oui, un typage évolué permet d'éviter plus de bugs, ce n'est pas une découverte. :)
[ tag:blog.huoc.org,2009-09-04:1252090712.6075 ]

# rz0
04.09.09, 21:07.

GuilOooo > Hum, ouais c’est l’idée d’annoter explicitement le langage C, une idée que j’affectionne pas mal, mais dont je n’ai jamais trouvé d’exécution satisfaisante (ÀMHA). Pour l’exemple, avec splint doit yavoir une annotation /*@null*/ si jme rappelle bien, mais ça remonte à loin mon dernier usage de splint. Sinon en Cyclone ya les pointeurs non nuls, ça doit être T @.

Mais à mon avis, la meilleure source d’informations ce sont les assertions. Je n’ai pas lu l’article mais à mon avis, ce devrait être la toute première source d’info. C’est traditionnel en C d’en placer à l’entrée des interfaces. Notamment, des choses comme assert(p != NULL) en entrée de fonction sont très fréquentes.

[ tag:blog.huoc.org,2009-09-04:1252091251.6075 ]

Nouveau commentaire