L'analyse statique au secours des programmeurs C

Le langage C pose problème à de nombreux étudiants car il les force à réfléchir à beaucoup de détails qui sont gérés automatiquement par d’autres langages. Dans le cadre du cours de Systèmes Informatiques, les étudiants doivent au minimum compiler leurs programmes C en utilisant les options de détection d’erreur du compilateur. Avec gcc, ils doivent obligatoirement utiliser -Wall et -Werror. Ces options permettent de détecter certaines erreurs, mais pas toutes. Prenons un exemple extrait d’une soumission d’un étudiant sur inginious. L’exercice qui était demandé était d’écrire le corps de la fonction copy

/*
 * @pre file_name != NULL, name of the original file
 *      new_file_name != NULL, name of the new file (the copy)
 *
 * @post copy the contents of {file_name} to {new_file_name}.
 *       return 0 if the function terminates with success, -1 in case of errors.
 */
int copy(char *file_name, char *new_file_name) {

En gros, il suffit de lire le contenu du fichier dont le nom est passé comme premier argument et de l’écrire dans le fichier dont le nom est passé comme second argument.

Un bon étudiant a proposé la solution suivante pour cet exercice:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int copy(char *file_name, char *new_file_name) {
  
  int fd = open(file_name, O_RDONLY);
  struct stat *statbuf = malloc(sizeof(struct stat));
  if(statbuf == NULL)
    return -1;
  if(stat(file_name, statbuf) == -1)
    return -1;
  int fdnew = open(new_file_name, O_WRONLY|O_CREAT|O_TRUNC, statbuf->st_mode);
  char *ch = malloc(sizeof(char) * statbuf->st_size);
  if(ch == NULL)
    return -1;
  int rd = read(fd, ch, sizeof(char) * statbuf->st_size);
  if(rd == -1)
    return -1;
  int wr = write(fdnew, ch, sizeof(char) * statbuf->st_size);
  if(wr == -1)
    return -1;
  if(close(fd) == -1 || close(fdnew) == -1)
    return -1;
  return 0;
}

gcc compile cette fonction sans sourciller, même avec les options gcc -Wall -Wpedantic -Werror -c copy.c. Le concurrent de gcc, clang fait de même. En testant la fonction, l’étudiant pourrait croire qu’elle est tout à fait correcte. Malheureusement, ce n’est pas tout à fait le cas. Le lecteur attentif pourra facilement remarquer qu’il y a des fuites de mémoire dans ce programme et qu’en cas d’erreurs, certaines ressources ne sont pas libérées.

Heureusement pour les étudiants, il existe des logiciels spécialisés qui peuvent détecter un certains nombre de problèmes classiques dans des programmes C en utilisant des techniques d’analyse statique.

Le premier est cppcheck. Il contient différentes analyses qui sont particulièrement utiles. En ligne de commande, il identifie deux fuites de mémoire dans la fonction copy:

cppcheck --enable=all t.c
Checking t.c...
[t.c:16]: (error) Memory leak: statbuf
[t.c:29]: (error) Memory leak: ch
[t.c:10]: (style) The function 'copy' is never used.

Ce sont deux erreurs qui doivent être corrigées dans la fonction. Il est possible d’obtenir des rapports beaucoup plus détaillés en demandant à cppcheck de générer un rapport au format HTML.

cppcheck --enable=all --inconclusive --xml-version=2 --force --library=windows,posix,gnu t.c 2> result.xml
cppcheck-htmlreport --source-encoding="iso8859-1" --title="my project name" --source-dir=. --report-dir=. --file=result.xml

rapport cppcheck

Ce rapport détecte les différentes fuites de mémoire et aussi le fait que les descripteurs de fichiers ne sont pas correctement libérés en cas d’erreur dans un des appels système.

Plusieurs modules d’analyse statique sont associés au compilateur clang. Ils peuvent être appelés via scan-build en ligne de commande

scan-build  -v --keep-going gcc -c t.c
scan-build: Using '/usr/lib/llvm-3.8/bin/clang' for static analysis
scan-build: Emitting reports for this run to '/tmp/scan-build-2018-09-14-192132-19824-1'.
t.c:24:10: warning: Potential leak of memory pointed to by 'statbuf'
int wr = write(fdnew, ch, sizeof(char) * statbuf->st_size);
         ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1 warning generated.
scan-build: 1 bug found.
scan-build: Run 'scan-view /tmp/scan-build-2018-09-14-192132-19824-1' to examine bug reports.

A nouveau, le rapport en format HTML est nettement plus détaillé. Il explique le raisonnement utilisé par l’analyseur pour identifier le problème dans le code de la fonction copy.

rapport scan-build

Sur base de ce rapport, l’étudiant peut corriger son code et relancer scan-build. En itérant, on aboutit finalement au code ci-dessous qui est accepté par scan-build.

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int copy(char *file_name, char *new_file_name) {
  int fd = open(file_name, O_RDONLY);
struct stat *statbuf = malloc(sizeof(struct stat));
if(statbuf == NULL)
    return -1;
 if(stat(file_name, statbuf) == -1){
   free(statbuf);
    return -1;
 }
int fdnew = open(new_file_name, O_WRONLY|O_CREAT|O_TRUNC, statbuf->st_mode);
char *ch = malloc(sizeof(char) * statbuf->st_size);
 if(ch == NULL){
   free(statbuf);
   return -1;
 }
int rd = read(fd, ch, sizeof(char) * statbuf->st_size);
 if(rd == -1){
   free(ch);
   free(statbuf);
   return -1;
 }
int wr = write(fdnew, ch, sizeof(char) * statbuf->st_size);
 if(wr == -1){
   free(statbuf);
   free(ch);
    return -1;
 }
 if(close(fd) == -1 || close(fdnew) == -1){
   free(statbuf);
   free(ch);
   return -1;
 }
 free(statbuf);
 free(ch);
return 0;
}

Si ce code passe tous les tests de scan-build, il n’est pas accepté par cppcheck qui note des problèmes avec les descripteurs de fichiers.

Un dernier logiciel intéressant est infer. Il s’agit également d’un outil d’analyse statique qui supporte différents langages comme C, C++, Java ou Objective C. Il contient des détecteurs pour différents types de bugs classiques. Il s’exécute à la manière de scan-build:

#infer run -- gcc -c copy.c
Capturing in make/cc mode...
Found 1 source file to analyze in /tmp/infer-out
Starting analysis...

legend:
  "F" analyzing a file
  "." analyzing a procedure

F.
Found 11 issues

t.c:24: error: MEMORY_LEAK
  memory dynamically allocated by call to `malloc()` at line 12, column 24 is not reachable after line 24, column 1.
  22.   if(rd == -1)
  23.       return -1;
  24. > int wr = write(fdnew, ch, sizeof(char) * statbuf->st_size);
  25.   if(wr == -1)
  26.       return -1;

t.c:24: error: MEMORY_LEAK
  memory dynamically allocated by call to `malloc()` at line 18, column 12 is not reachable after line 24, column 1.
  22.   if(rd == -1)
  23.       return -1;
  24. > int wr = write(fdnew, ch, sizeof(char) * statbuf->st_size);
  25.   if(wr == -1)
  26.       return -1;

t.c:16: error: MEMORY_LEAK
  memory dynamically allocated to `return` by call to `malloc()` at line 12, column 24 is not reachable after line 16, column 5.
  14.       return -1;
  15.   if(stat(file_name, statbuf) == -1)
  16. >     return -1;
  17.   int fdnew = open(new_file_name, O_WRONLY|O_CREAT|O_TRUNC, statbuf->st_mode);
  18.   char *ch = malloc(sizeof(char) * statbuf->st_size);

t.c:20: error: MEMORY_LEAK
  memory dynamically allocated to `return` by call to `malloc()` at line 12, column 24 is not reachable after line 20, column 5.
  18.   char *ch = malloc(sizeof(char) * statbuf->st_size);
  19.   if(ch == NULL)
  20. >     return -1;
  21.   int rd = read(fd, ch, sizeof(char) * statbuf->st_size);
  22.   if(rd == -1)

t.c:23: error: MEMORY_LEAK
  memory dynamically allocated to `return` by call to `malloc()` at line 18, column 12 is not reachable after line 23, column 5.
  21.   int rd = read(fd, ch, sizeof(char) * statbuf->st_size);
  22.   if(rd == -1)
  23. >     return -1;
  24.   int wr = write(fdnew, ch, sizeof(char) * statbuf->st_size);
  25.   if(wr == -1)

t.c:27: error: RESOURCE_LEAK
  resource acquired to `fdnew` by call to `open()` at line 17, column 13 is not released after line 27, column 4.
  25.   if(wr == -1)
  26.       return -1;
  27. > if(close(fd) == -1 || close(fdnew) == -1)
  28.       return -1;
  29.   return 0;

t.c:14: error: RESOURCE_LEAK
  resource acquired to `return` by call to `open()` at line 11, column 12 is not released after line 14, column 5.
  12.   struct stat *statbuf = malloc(sizeof(struct stat));
  13.   if(statbuf == NULL)
  14. >     return -1;
  15.   if(stat(file_name, statbuf) == -1)
  16.       return -1;

t.c:16: error: RESOURCE_LEAK
  resource acquired to `return` by call to `open()` at line 11, column 12 is not released after line 16, column 5.
  14.       return -1;
  15.   if(stat(file_name, statbuf) == -1)
  16. >     return -1;
  17.   int fdnew = open(new_file_name, O_WRONLY|O_CREAT|O_TRUNC, statbuf->st_mode);
  18.   char *ch = malloc(sizeof(char) * statbuf->st_size);

t.c:20: error: RESOURCE_LEAK
  resource acquired to `return` by call to `open()` at line 17, column 13 is not released after line 20, column 5.
  18.   char *ch = malloc(sizeof(char) * statbuf->st_size);
  19.   if(ch == NULL)
  20. >     return -1;
  21.   int rd = read(fd, ch, sizeof(char) * statbuf->st_size);
  22.   if(rd == -1)

t.c:23: error: RESOURCE_LEAK
  resource acquired to `return` by call to `open()` at line 17, column 13 is not released after line 23, column 5.
  21.   int rd = read(fd, ch, sizeof(char) * statbuf->st_size);
  22.   if(rd == -1)
  23. >     return -1;
  24.   int wr = write(fdnew, ch, sizeof(char) * statbuf->st_size);
  25.   if(wr == -1)

...too many issues to display (limit=10 exceeded), please see /tmp/infer-out/bugs.txt or run `infer-explore` for the remaining issues.


Summary of the reports

  RESOURCE_LEAK: 6
    MEMORY_LEAK: 5

Sur notre exemple, infer identifie les mêmes problèmes que cppcheck. Il est également possible d’obtenir un rapport en format HTML qui détaille les différents problèmes.

#infer-export --html

Cette version du rapport est un peu moins bien présentée que les rapports en HTML de scan-build, mais est tout à fait utilisable.

Ce fichier HTML pointe vers les différents rapports textuels qui décrivent chaque erreur en détails. En voici une à titre d’exemple.

t.c:27: error: RESOURCE_LEAK
  resource acquired to `fdnew` by call to `open()` at line 17, column 13 is not released after line 27, column 4.
Showing all 13 steps of the trace


t.c:10:1: start of procedure copy()
8.   #include <string.h>
9.   
10. > int copy(char *file_name, char *new_file_name) {
11.     int fd = open(file_name, O_RDONLY);
12.   struct stat *statbuf = malloc(sizeof(struct stat));

t.c:11:3: 
9.   
10.   int copy(char *file_name, char *new_file_name) {
11. >   int fd = open(file_name, O_RDONLY);
12.   struct stat *statbuf = malloc(sizeof(struct stat));
13.   if(statbuf == NULL)

t.c:12:1: 
10.   int copy(char *file_name, char *new_file_name) {
11.     int fd = open(file_name, O_RDONLY);
12. > struct stat *statbuf = malloc(sizeof(struct stat));
13.   if(statbuf == NULL)
14.       return -1;

t.c:13:4: Taking false branch
11.     int fd = open(file_name, O_RDONLY);
12.   struct stat *statbuf = malloc(sizeof(struct stat));
13.   if(statbuf == NULL)
         ^
14.       return -1;
15.   if(stat(file_name, statbuf) == -1)

t.c:15:4: Taking false branch
13.   if(statbuf == NULL)
14.       return -1;
15.   if(stat(file_name, statbuf) == -1)
         ^
16.       return -1;
17.   int fdnew = open(new_file_name, O_WRONLY|O_CREAT|O_TRUNC, statbuf->st_mode);

t.c:17:1: 
15.   if(stat(file_name, statbuf) == -1)
16.       return -1;
17. > int fdnew = open(new_file_name, O_WRONLY|O_CREAT|O_TRUNC, statbuf->st_mode);
18.   char *ch = malloc(sizeof(char) * statbuf->st_size);
19.   if(ch == NULL)

t.c:18:1: 
16.       return -1;
17.   int fdnew = open(new_file_name, O_WRONLY|O_CREAT|O_TRUNC, statbuf->st_mode);
18. > char *ch = malloc(sizeof(char) * statbuf->st_size);
19.   if(ch == NULL)
20.       return -1;

t.c:19:4: Taking false branch
17.   int fdnew = open(new_file_name, O_WRONLY|O_CREAT|O_TRUNC, statbuf->st_mode);
18.   char *ch = malloc(sizeof(char) * statbuf->st_size);
19.   if(ch == NULL)
         ^
20.       return -1;
21.   int rd = read(fd, ch, sizeof(char) * statbuf->st_size);

t.c:21:1: 
19.   if(ch == NULL)
20.       return -1;
21. > int rd = read(fd, ch, sizeof(char) * statbuf->st_size);
22.   if(rd == -1)
23.       return -1;

t.c:22:4: Taking false branch
20.       return -1;
21.   int rd = read(fd, ch, sizeof(char) * statbuf->st_size);
22.   if(rd == -1)
         ^
23.       return -1;
24.   int wr = write(fdnew, ch, sizeof(char) * statbuf->st_size);

t.c:24:1: 
22.   if(rd == -1)
23.       return -1;
24. > int wr = write(fdnew, ch, sizeof(char) * statbuf->st_size);
25.   if(wr == -1)
26.       return -1;

t.c:25:4: Taking false branch
23.       return -1;
24.   int wr = write(fdnew, ch, sizeof(char) * statbuf->st_size);
25.   if(wr == -1)
         ^
26.       return -1;
27.   if(close(fd) == -1 || close(fdnew) == -1)

t.c:27:4: Taking true branch
25.   if(wr == -1)
26.       return -1;
27.   if(close(fd) == -1 || close(fdnew) == -1)
         ^
28.       return -1;
29.   return 0;

Tous ces outils automatiques peuvent être utiles à condition de les utiliser régulièrement. Le plus simple est de l’intégrer directement dans le Makefile dès le début du développement d’un programme. De cette façon, chaque nouvelle fonctionnalité sera vérifiée et les erreurs seront corrigées rapidement ( infer inclut notamment un module permettant de tester un nouveau module sans devoir réanalyser ceux qui ont déjà été validé). Il est aussi possible d’utiliser ces logiciels sur des projets complets, mais dans ce cas ils peuvent identifier un grand nombre de problèmes qui décourageront les programmeurs de prendre le temps nécessaire pour les corriger. Par contre, il est très facile d’utiliser ces logiciels lorsque l’on doit corriger les travaux rendus par des étudiants…

Ecrit le September 15, 2018