CLCC — C Library Calls Controller
Librairie C/C++ pour intercepter et mocker les appels à la libc pendant les tests unitaires, via dlsym et RTLD_NEXT — sans modifier le code testé.
Contexte
En deuxième année à Epitech, dans le cadre du projet myftp, je me suis retrouvé face à un problème classique mais frustrant : comment tester fiablement le comportement de mon code quand une fonction système échoue ? Simuler un connect() qui rate ou un open() qui retourne -1 n'est pas trivial — à l'époque je n'avais pas trouvé de moyen simple de le faire.
C'est après le module d'ASM, en comprenant précisément comment fonctionnent les symboles en C, le linkage dynamique et l'ordre de résolution des symboles, que l'idée m'est venue : utiliser dlsym() avec RTLD_NEXT pour intercepter les appels aux fonctions de la libc et substituer mon propre comportement à la volée.
Comment ça fonctionne
Interception des symboles
Quand un programme C est lié dynamiquement, les appels aux fonctions de la libc ne sont pas résolus à la compilation — ils passent par la PLT (Procedure Linkage Table), une table de sauts que le dynamic linker remplit à l'exécution. CLCC exploite ce mécanisme : en déclarant une fonction avec le même nom qu'une fonction libc (malloc, connect...) dans une bibliothèque liée avant la libc, le linker résoudra le symbole vers la version de CLCC en premier.
Pour appeler ensuite la vraie fonction, CLCC utilise dlsym(RTLD_NEXT, "malloc") — RTLD_NEXT demande au résolveur de chercher le symbole à partir de la bibliothèque suivante dans l'ordre de chargement, sautant ainsi par-dessus le wrapper pour atteindre la libc réelle.
Contexte d'état par fonction
Chaque fonction wrappée possède une structure de contrôle statique générée à la compilation :
typedef struct {
bool control; // mock actif ou non
int counter; // appels restants avant activation
void *return_value; // valeur à retourner quand actif
} clcc_malloc_data_ctx_t;
static clcc_malloc_data_ctx_t clcc_malloc_data_ctx = {
.control = false,
.counter = 0
};Le wrapper généré consulte ce contexte à chaque appel :
void *malloc(size_t arg1) {
if (!ctx.control || ctx.counter > 0) {
void *(*real_func)(size_t) = dlsym(RTLD_NEXT, "malloc");
ctx.counter -= 1;
return real_func(arg1); // appel réel
}
return ctx.return_value; // valeur mockée
}Génération par macros
Tout ce code est produit par le preprocesseur C à partir d'une seule ligne dans le .c :
DECL_CLCC_ARGS_1(void *, malloc, size_t)
// ^return ^nom ^argsCette macro déplie : la struct de contexte, les fonctions de contrôle (set_control, set_return_value, control_after), et le wrapper complet avec la bonne signature. Il existe une variante DECL_CLCC_ARGS_N pour 0 à 6 arguments.
Côté utilisateur, les macros publiques (clcc_enable_control, clcc_return_value_after...) ne sont que du sucre syntaxique qui appelle ces fonctions générées — le preprocesseur résout clcc_enable_control(malloc) en clcc_malloc_set_control(true) sans aucune indirection à l'exécution.
Ajouter une fonction
Déclarer le prototype dans le header du module :
// modules/stdlib.h
PUBLIC_PROTO_CLCC(void *, malloc)Et l'implémenter en une ligne dans le .c correspondant :
// src/stdlib.c
DECL_CLCC_ARGS_1(void *, malloc, size_t)C'est tout — le reste est généré.
Exemple concret
Tester qu'un serveur gère correctement l'échec de socket() à l'initialisation :
#include "libs/clcc/modules/sys/socket.h"
#include "libs/clcc/modules/stdlib.h"
void test_init_handles_socket_failure(void)
{
// socket() retourne -1 dès le premier appel
clcc_return_now(socket, -1);
server_t *srv = server_init(8080);
assert(srv == NULL); // init doit échouer proprement
clcc_disable_control(socket); // on rend socket au système. Important sinon
// le mock reste actif pour les tests suivants
// maintenant tester qu'un malloc raté en cours d'init est géré
clcc_return_now(malloc, NULL);
srv = server_init(8080);
assert(srv == NULL);
clcc_disable_control(malloc);
}Sans CLCC, ces deux branches de server_init n'auraient pas pu être testées simplement.
API
| Macro | Description |
|---|---|
clcc_enable_control(fn) | Active le mock pour fn |
clcc_disable_control(fn) | Désactive le mock, revient au comportement réel |
clcc_set_return_value(fn, val) | Définit la valeur retournée quand le mock est actif |
clcc_return_value_after(fn, val, n) | Active le mock après n appels réels |
clcc_return_now(fn, val) | Mock immédiat avec la valeur donnée |
clcc_control_after(fn, n) | Active le mock après n appels (valeur déjà configurée) |
Utilisation dans mes projets Epitech
La lib a évolué au fil des projets de l'année :
- myftp — origine du projet, tests des échecs de
connect,bind,listen - my_teams — couverture des erreurs de
socketet d'IPC - Panoramix — portée aux scénarios de concurrence, tests de
fork,wait,read/write - The Plazza — portée en C++ (
extern "C") pour un projet de multithreading et concurrence - Zappy — intégrée dans le gros projet de fin d'année en équipe
Sans CLCC, plusieurs mécanismes de gestion d'erreur auraient été difficilement testables.
Limites connues
La lib utilise write en interne pour ses messages d'erreur — ce qui pose un problème circulaire si write est lui-même surchargé dans les tests. C'est une limitation que j'aurais adressée avec plus de temps, probablement en isolant les writes internes via un fd dédié ou en évitant tout I/O dans le chemin de contrôle.
Avec le recul, j'aurais aussi voulu creuser davantage les mécanismes de linkage : LD_PRELOAD, les sections .got/.plt, le résolveur de symboles — il y a un espace intéressant entre ce que fait CLCC et ce que font des outils comme LD_PRELOAD au niveau système.