En termes de vitesse et de puissance au niveau machine, peu de langages de programmation peuvent rivaliser avec le langage C. Cette affirmation, vraie il y a 50 ans, l'est encore aujourd'hui. Cependant, c’est à juste titre que les programmeurs ont inventé le terme « footgun » pour rendre compte de la puissance du C. Car, si vous n’y prenez pas garde, le langage C peut vous faire perdre pied.
Voilà quatre erreurs parmi les plus courantes que l’on peut commettre en programmation C, et cinq conseils pour les éviter.
1 - Ne pas libérer la mémoire malloc allouée dynamiquement (ou la libérer plus d'une fois)
C'est l'une des grandes erreurs de la programmation C, dont beaucoup concernent la gestion de mémoire. La mémoire allouée (réalisée à l'aide de la fonction malloc) n'est pas automatiquement supprimée en C. C'est au programmeur de libérer cette mémoire quand elle n'est plus utilisée. Si vous ne parvenez pas à libérer les demandes répétées de mémoire, vous risquez d’être confronté à une fuite de mémoire. Si vous essayez d'utiliser une zone de mémoire qui a déjà été libérée, votre programme plantera - ou, pire, sera bancale et deviendra vulnérable à une attaque exploitant ce mécanisme. Á noter que la fuite de mémoire fait uniquement référence aux situations où la mémoire est censée être libérée, mais ne l'est pas. Si un programme continue à allouer de la mémoire parce que celle-ci est réellement nécessaire et utilisée pour le travail, il se peut que son utilisation de la mémoire ne soit pas efficace, mais dans ce cas, il ne s'agit pas à proprement parler d'une fuite de mémoire.
2 - Lire un tableau en dehors des limites
C’est une autre erreur parmi les plus courantes et les plus dangereuses de la programmation C. Une lecture au-delà de la fin d'un tableau peut renvoyer des données de déchets. Une écriture au-delà des limites d'un tableau peut corrompre l'état du programme, ou le faire planter complètement, ou, pire encore, être utilisée comme vecteur d'attaque par les malwares. Alors, pourquoi laisser la vérification des limites d'un tableau à la charge du programmeur ? Dans la spécification officielle du langage C, lire ou écrire un tableau au-delà de ses limites est qualifié de « comportement indéterminé », ce qui signifie que la spécification n'a pas son mot à dire sur ce qui est censé se produire. Le compilateur n'est même pas tenu de s'en plaindre. Pendant longtemps, le langage C a été favorable à l’octroi de pouvoirs de décision au programmeur, même à ses propres risques. Un dépassement des limites en lecture ou en écriture n'est généralement pas bloqué par le compilateur, à moins d’activer spécifiquement les options du compilateur pour s’en prémunir. Mais, même avec une demande de vérification, il peut arriver que le dépassement des limites d'un tableau se produise à l'exécution, sans que le compilateur puisse la bloquer.
3 – Ne pas vérifier les résultats de malloc
Les fonctions malloc et calloc (pour la mémoire pré-zéro) de la bibliothèque C gèrent la mémoire allouée en heap du système. Si elles ne sont pas capables d'allouer de la mémoire, elles génèrent une erreur. À l'époque où les ordinateurs avaient relativement peu de mémoire, il y avait de fortes chances qu'un appel malloc provoque une erreur. Même si les ordinateurs actuels disposent de gigaoctets de mémoire vive, il y a toujours un risque que l’appel malloc échoue, en particulier en cas de forte pression sur la mémoire ou lors de l'allocation simultanée de gros blocs de mémoire. C’est particulièrement vrai pour les programmes C qui « allouent » d'abord un grand bloc de mémoire à partir du système d'exploitation, puis le divisent pour leur propre usage. Si cette première allocation échoue parce qu'elle est trop importante, il est possible de bloquer ce refus, de réduire l'allocation et d’ajuster l'heuristique d’usage de la mémoire du programme en conséquence. Mais si l'allocation de mémoire échoue sans être piégée, le programme tout entier pourrait s'effondrer.
4 – Utiliser void* pour les pointeurs génériques vers la mémoire
Une vieille et mauvaise habitude de la programmation C consiste à utiliser void* pour pointer vers la mémoire. Les pointeurs vers la mémoire doivent toujours être des char*, des char* non signés ou des types de données non signés uintptr_t*. Les compilateurs C modernes devraient comprendre le type de données uintptr_t dans stdint.h. Quand il est marqué de cette façon, il est clair que le pointeur fait référence à un emplacement mémoire abstrait plutôt qu'à un type d'objet indéfini. C’est d’autant plus important si l’on effectue des calculs de pointeur. Avec le type de données uintptr_t* et autres, l'élément de taille vers lequel il est pointé et la façon dont il sera utilisé sont sans ambiguïté. C’est moins le cas avec void*.
5 conseils pour éviter les erreurs courantes en programmation C
Voilà 5 conseils pour éviter ces erreurs trop courantes avec la gestion de mémoire, les tableaux et les pointeurs.
1 - Structurer les programmes de manière à ce que la propriété de mémoire reste claire
Dès le début du développement d’une application en C, il peut s’avérer payant de réfléchir à la manière dont la mémoire est allouée et libérée, et même d’en faire l’un des principes structurels du programme. Si vous ne savez pas exactement où une allocation de mémoire donnée est libérée ou dans quelles circonstances, vous risquez d’avoir de mauvaises surprises. Faites un effort supplémentaire pour rendre la propriété de la mémoire aussi claire que possible. Vous vous rendrez service à vous-même (et aux futurs développeurs). C'est la philosophie qui sous-tend des langages comme Rust. En Rust, il est impossible d’écrire un programme qui se compile correctement si l’on n’exprime pas clairement la propriété de la mémoire et son mode de transfert. Le langage C n'impose pas de telles restrictions, mais ce serait une bonne chose d'adopter cette philosophie chaque fois que c’est possible.
2 - Utiliser les options du compilateur C pour se prémunir des problèmes de mémoire
De nombreux problèmes décrits dans la première moitié de cet article peuvent être signalés en utilisant des options strictes du compilateur. Par exemple, les versions récentes du logiciel libre GCC, qui peut compiler plusieurs langages de programmation dont le C, offrent des outils du genre AddressSanitizer (« ASAN ») comme option de compilation pour vérifier les erreurs courantes de gestion de la mémoire. Attention, ces outils ne prennent pas tout en compte. Ce sont des garde-fous, et leurs fonctions ne peuvent se substituer au travail du développeur. De plus, certains de ces outils, comme ASAN, s’accompagnent de coûts de compilation et d'exécution, ce qui devrait être évité pour les releases.
3 - Utiliser Cppcheck ou Valgrind pour analyser le code C afin de détecter les fuites de mémoire
Quand les compilateurs eux-mêmes ne sont pas à la hauteur, il est toujours possible de s’appuyer sur d'autres outils pour pallier leurs manques, en particulier quand il s'agit d'analyser le comportement des programmes à l'exécution. L’outil Cppcheck, sous licence GNU General Public License, effectue une analyse statique sur le code source du langage C pour rechercher les erreurs courantes dans la gestion de la mémoire et les comportements non définis (entre autres). Valgrind, également sous licence GNU General Public License, fournit un cache d'outils pour détecter les erreurs de mémoire et de threads dans l'exécution des programmes C. Cet outil est bien plus puissant que l'analyse à la compilation, car il permet d’avoir des informations sur le comportement du programme quand il est réellement en cours d'exécution. L'inconvénient, c’est que le programme s'exécute à une fraction de sa vitesse normale. Mais cela convient généralement pour les tests. Ces outils ne sont pas des remèdes miracle et ils ne détecteront pas tous les problèmes. Mais ils sont efficaces dans le cadre d’une stratégie défensive générale pour lutter contre une mauvaise gestion de la mémoire en C.
4 - Automatiser la gestion de la mémoire C avec un ramasse-miettes
Étant donné que les erreurs de mémoire sont une source évidente de problèmes en langage C, une solution facile consiste à ne pas gérer manuellement la mémoire en C. Le mieux est d’utiliser un ramasse-miettes. Oui, c'est possible en C. Vous pouvez utiliser par exemple le ramasse-miettes Boehm-Demers-Weiser pour gérer automatiquement la mémoire dans les programmes C. Pour certains programmes, l’usage du ramasse-miettes de Boehm peut même accélérer les choses. Il peut même servir de mécanisme de détection des fuites. Le principal inconvénient du ramasse-miettes de Boehm, c’est qu'il ne peut pas analyser ou libérer la mémoire qui utilise malloc par défaut. En effet, ce ramasse-miettes utilise sa propre fonction d'allocation, et ne fonctionne que sur la mémoire que vous lui allouez spécifiquement.
5 – Ne pas utiliser le C si un autre langage fait l'affaire
Certaines personnes écrivent en C parce qu'elles aiment vraiment ce langage et trouvent qu’il est riche. Cependant, de façon générale, il est préférable de n'utiliser le C que quand cela s’impose, et avec parcimonie, pour les rares situations où il constitue vraiment le choix idéal. Si vous avez un projet dont les performances d'exécution seront limitées par les I/O ou l'accès au disque, l'écrire en C ne le rendra probablement pas plus rapide pour répondre aux besoins importants, et il n’en sera probablement que plus sujet aux erreurs et plus difficile à maintenir. Le même programme pourrait bien être écrit en Go ou en Python. Une autre approche consiste à n'utiliser le C que pour les portions de l'application qui demandent vraiment beaucoup de performances, et d’utiliser un langage plus fiable, même s’il est plus lent, pour les autres portions de code. Encore une fois, il est possible d’utiliser le langage Python pour empaqueter des bibliothèques C ou du code C personnalisé, ce qui en fait un bon choix pour des composants plus « passe-partout » comme la gestion des options en ligne de commande.
Le type de pointeur à utiliser a la place de void*, c'est uintptr_t, sans étoile, le type décrivant en lui-même un pointeur.
Signaler un abus