Análisis estático: los bugs que se ven sin ejecutar
clang-tidy, cppcheck y el analizador de GCC no ejecutan tu programa: lo recorren simbólicamente. Qué clase de bug caza cada uno, de dónde salen los falsos positivos y cómo configurarlos para que el equipo los lea en vez de silenciarlos.
Un sanitizer necesita una entrada que active el bug. Un test necesita que alguien lo haya imaginado antes. El análisis estático no necesita ninguna de las dos cosas: recorre el grafo de flujo de control razonando sobre valores que todavía no existen y denuncia lo que ocurriría en caminos que quizá nadie ha ejecutado jamás. A cambio cobra una moneda que casi nadie quiere pagar: el falso positivo.
- Distinguir el análisis por patrones sintácticos del análisis sensible al camino.
- Conocer las tres herramientas del ecosistema y en qué se especializa cada una.
- Entender por qué los falsos positivos son matemáticamente inevitables.
- Configurar, suprimir e integrar los avisos sin que el equipo aprenda a ignorarlos.
Dos familias bajo el mismo nombre
Bajo la etiqueta “análisis estático” conviven dos tecnologías que casi no se parecen.
La primera opera sobre el árbol sintáctico. Reconoce formas: una llamada a strcpy, un bucle que compara un entero con signo contra un size_t, un memset cuyo tamaño es el de un puntero y no el del objeto apuntado. Es rápida, barata y prácticamente no se equivoca, porque no afirma nada sobre valores en ejecución: afirma que esa forma escrita es sospechosa. Casi todo clang-tidy vive aquí.
La segunda construye el grafo de flujo de control y lo recorre simbólicamente: en cada bifurcación bifurca su propio estado, arrastra restricciones sobre cada variable y pregunta a un resolutor si el camino sigue siendo satisfacible. Cuando llega a un free con la restricción “este puntero ya fue liberado en la rama de arriba”, tiene un hallazgo y además la traza completa que lo produce. Aquí viven el analizador estático de Clang y -fanalyzer de GCC.
flowchart TD A[Codigo fuente] --> B[Arbol sintactico] B --> C[Coincidencia de patrones] B --> D[Grafo de flujo de control] D --> E[Exploracion simbolica de caminos] C --> F[Avisos de forma y modernizacion] E --> G[Fugas uso tras liberar y nulos condicionales] style F fill:#89b4fa,color:#11111b style G fill:#f38ba8,color:#11111b
La diferencia práctica es de coste y de alcance. El análisis por patrones cuesta segundos y encuentra lo que ya sabías que estaba mal. El sensible al camino cuesta minutos, tiene que podar el árbol de caminos para no explotar combinatoriamente, y encuentra lo que ningún revisor humano habría seguido: la rama de error del tercer if anidado en la que se retorna sin liberar el búfer.
Las tres herramientas
# GCC: analisis sensible al camino, integrado en el propio compilador
gcc -std=c23 -O2 -Wall -Wextra -fanalyzer prog.c -o prog
# clang-tidy: cientos de checks, sobre la base de compilacion real
clang-tidy -p build/ src/*.c
# el analizador de Clang, por debajo de la interfaz de scan-build
scan-build --status-bugs -o informes/ make
# cppcheck: independiente del compilador, con analisis interprocedural propio
cppcheck --enable=all --std=c23 --inconclusive --project=build/compile_commands.json
gcc -fanalyzer
Máquinas de estado sobre punteros, descriptores de fichero y datos contaminados. Encuentra dobles free, fugas por rama de error y uso tras liberar. Lento y solo C, pero sin instalar nada.
clang-tidy
El catálogo más amplio: familias bugprone, cert, clang-analyzer, misc, readability. Necesita compile_commands.json para ver los mismos flags que el compilador.
cppcheck
No depende del compilador ni de sus cabeceras. Fuerte en desbordamientos de búfer con índices constantes y en análisis interprocedural de tamaños.
El detalle que decide si estas herramientas sirven o no es la base de compilación. Sin compile_commands.json, clang-tidy no conoce tus rutas de inclusión ni tus macros de configuración y analiza un programa que no es el tuyo. Meson y CMake lo generan solos; con Make lo genera bear:
meson setup build # escribe build/compile_commands.json
cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
bear -- make # para makefiles escritos a mano
De dónde salen los falsos positivos
No son un defecto de implementación: son una consecuencia del teorema de Rice. Decidir si un programa arbitrario tiene una propiedad semántica no trivial es indecidible, así que todo analizador aproxima. Solo puede elegir hacia qué lado se equivoca.
Un analizador sound nunca deja pasar un bug real, y por tanto denuncia también casos imposibles. Uno complete nunca denuncia un caso imposible, y por tanto deja pasar bugs reales. Las herramientas de producción eligen lo segundo con matices: prefieren callar antes que gritar en falso, porque saben que un informe con un veinte por ciento de ruido es un informe que nadie vuelve a abrir.
El caso típico en C es la invariante que tú conoces y el analizador no:
int leer(struct conf *c, const char *ruta) {
FILE *f = fopen(ruta, "r");
if (!f) return -1;
char *buf = malloc(4096);
if (!buf) { fclose(f); return -1; }
if (!fgets(buf, 4096, f)) {
fclose(f);
return -1; /* -fanalyzer: fuga de buf, y tiene razon */
}
c->linea = buf; /* aqui la propiedad pasa a la estructura */
fclose(f);
return 0;
}
El primer aviso es un bug de verdad. El segundo camino no lo es, pero solo porque el analizador entiende que la propiedad del bloque se transfiere a c. Cuando esa transferencia ocurre a través de una función tuya que él no puede inspeccionar, el falso positivo aparece. GCC deja anotarlo con atributos de propiedad, y esa anotación mejora el análisis de todo el proyecto:
void liberar_nodo(struct nodo *n)
__attribute__((malloc, malloc(liberar_nodo)));
Suprimir sin anestesiar
Silenciar un aviso es una decisión de ingeniería y debe quedar escrita con su motivo. Las tres herramientas ofrecen supresión local y global:
/* clang-tidy: una sola linea, con el check nombrado */
/* NOLINTNEXTLINE(bugprone-sizeof-expression): el tamano del puntero es intencional */
size_t n = sizeof p;
/* cppcheck: mismo principio */
/* cppcheck-suppress memleak ; la propiedad pasa al registro global */
Y la configuración por proyecto, que es lo que de verdad hace usable la herramienta:
# .clang-tidy en la raiz del repositorio
Checks: >
-*,
bugprone-*,
cert-*,
clang-analyzer-*,
misc-*,
-bugprone-easily-swappable-parameters
WarningsAsErrors: 'bugprone-*,clang-analyzer-*'
HeaderFilterRegex: '^src/'
Empezar con -* y añadir familias es la única estrategia que funciona: activar todo de golpe en una base de código existente produce miles de avisos, el equipo declara la herramienta inservible y no vuelve nunca. La táctica que sí sobrevive es exigir cero avisos en las líneas que cambia cada parche, con clang-tidy-diff.py, y dejar la deuda histórica congelada mientras se drena por familias.
Cuesta ver el análisis estático como lo que realmente es porque su interfaz —una lista de avisos, como los del compilador— disfraza su naturaleza. Un analizador sensible al camino no está buscando patrones sospechosos: está intentando demostrar un teorema sobre tu programa y contándote dónde falla la demostración. Cuando -fanalyzer dice que hay un uso tras liberar, no ha visto código feo: ha construido un camino concreto y satisfacible desde la entrada de la función hasta la dereferencia, con las restricciones de cada bifurcación cumplidas simultáneamente. Ese camino es una prueba. Y cuando calla, no está afirmando que tu código sea correcto —el teorema de Rice le prohíbe afirmarlo— sino que dentro del presupuesto de caminos que exploró no encontró contraejemplo. Entender esa asimetría cambia cómo lees los informes: un hallazgo es casi siempre real y merece que reconstruyas el camino que te ofrece; un silencio no significa absolutamente nada. Los proyectos que tratan el análisis estático como un filtro de calidad se frustran con el ruido. Los que lo tratan como un verificador parcial —que demuestra propiedades acotadas y te dice exactamente cuáles— acaban escribiendo código que el analizador puede razonar: menos aliasing, propiedad explícita, funciones pequeñas con contratos anotados. La herramienta termina mejorando el diseño, no solo cazando bugs.
- Escribe una función con cuatro ramas de error donde solo la tercera olvide liberar un bloque, y pásale
gcc -fanalyzer,cppcheck --enable=allyclang-tidy. Anota cuál de los tres lo encuentra. - Genera
compile_commands.jsonen tu proyecto y compara los avisos declang-tidycon y sin el flag-p: verás checks que aparecen solo cuando conoce tus macros. - Escribe un
.clang-tidyque arranque en-*y active solobugprone-*. Cuenta los avisos, arregla los reales y subecert-*. - Provoca un falso positivo pasando la propiedad de un bloque a través de una función wrapper, y elimínalo anotando el atributo
mallocde GCC en lugar de con unNOLINT. - Integra
clang-tidy-diff.pysobre el diff de tu última rama y comprueba que solo se queja de lo que tocaste.