CÓDIGO CRÍTICO Y LAS 10 REGLAS DE HOLZMANN

Las “10 Reglas” desarrolladas por Gerard J. Holzmann de la NASA son un conjunto de normas de codificación diseñadas para garantizar la seguridad y fiabilidad en sistemas de software críticos, es decir en aplicaciones por ejemplo de las industrias: aeroespacial, médico y de algunos sectores industriales. Estas reglas, a menudo aplicadas al lenguaje C, buscan reducir la complejidad y facilitar el análisis estático.

Software crítico: programas donde un error puede costar mucho: dinero, equipo, seguridad o incluso vidas. Por ejemplo: aeronáutica, automoción, equipo médico, control industrial, defensa, robótica peligrosa o firmware embebido importante.

La idea general no es “programar bonito”, sino:

Estas reglas buscan que el código sea:

  • fácil de entender
  • fácil de revisar
  • fácil de probar
  • fácil de analizar con herramientas
  • difícil de comportarse de forma inesperada

En otras palabras: menos trucos, menos magia, menos libertad, más control.

  • No usar goto, setjmp, longjmp ni recursión directa o indirecta.
  • El programa debe seguir caminos simples y claros: if, else, switch, for, while.
  • Evitar mecanismos que “salten” de forma extraña o difícil de seguir.
  • Porque si el flujo del programa se vuelve enredado: cuesta entender qué pasó, cuesta depurarlo, aumenta la posibilidad de errores ocultos.

Ejemplos:

C
if (sensor_ok) {
    procesar();
} else {
    reportar_error();
}
C
goto etiqueta_error;
C
int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

También se prohíbe recursión, porque en sistemas embebidos o críticos: consume pila de forma variable, puede desbordar stack, a veces no es fácil demostrar hasta dónde llegará.


Cada bucle debe tener un límite superior fijo y comprobable estáticamente.

Significa que cada for o while debe tener un número máximo de vueltas conocido de antemano.

Ejemplos:

C
for (int i = 0; i < 128; i++) {
    ...
}

Aquí se sabe que como máximo da 128 iteraciones.

Más delicado. Aquí no está tan claro cuándo termina:

while (dato != 0) {

    …

}

En software crítico importa mucho saber:

– cuánto tarda una función

– si puede quedarse atorada

– si puede bloquear el sistema

Si un bucle no tiene límite claro, puede:

–  tardar demasiado

– volverse infinito

– romper tiempos de tiempo real

En embebidos esto es muy importante. Por ejemplo, en un STM32:

– si una rutina tarda demasiado, puedes perder datos del ADC

– puedes romper la temporización

– puedes afectar interrupciones o tareas

3) No usar memoria dinámica después de inicializar: No utilice asignación dinámica de memoria después de la inicialización.

Significa, evitar: malloc(), calloc(), realloc(), free() durante la operación normal del sistema.

Porque la memoria dinámica puede causar:

– fragmentación

– tiempos impredecibles

– fallos por falta de memoria

– errores difíciles de reproducir

En su lugar, se prefiere:

– memoria estática

– buffers fijos

– arreglos con tamaño definido

Ejemplo preferido:

static uint16_t adc_buffer[256];

En vez de:

uint16_t *adc_buffer = malloc(256 * sizeof(uint16_t));

En firmware esto es muy común, en sistemas embebidos serios se inicializa todo al arranque y luego se trabaja con estructuras ya reservadas.

4) Funciones pequeñas: Ninguna función debe ser más larga de una hoja; normalmente no más de 60 líneas.

Significa, que cada función debe hacer una cosa concreta y ser corta.

Porque las funciones largas:

– mezclan demasiadas responsabilidades

– cuestan mucho de revisar

– esconden errores

– son difíciles de probar

Ejemplos:

Mal. Una función de 300 líneas que:

– lee ADC

– filtra

– detecta eco

– actualiza pantalla

– envía UART

– maneja errores

Bien. Es mejor separar en funciones:

ReadAdcBlock()

ApplyFilter()

DetectEcho()

UpdateDisplay()

SendTelemetry()

No es una ley física. Una función de 70 líneas no es automáticamente “mala”.

La regla busca obligar a **dividir responsabilidades**.

Si una función ya da flojera leerla completa, probablemente ya creció demasiado.

5) Debe haber aserciones: Al menos dos aserciones por función, en promedio.

Es decir, una comprobación que afirma: “esto debería ser siempre cierto; si no lo es, algo anda mal”.

Ejemplos:

Bien:

assert(ptr != NULL);

assert(index < BUFFER_SIZE);

  • No sirve para manejar casos normales.
  • Sirve para detectar condiciones anómalas, que en teoría no deberían ocurrir.

Para el manejo normal de errores utilizar o las funciones de error dedicadas según la plataforma:

if (archivo == NULL) {

    return ERROR_ARCHIVO;

}

Aserción:

assert(buffer_size > 0);

  • La aserción dice: “si esto falla, probablemente hay un bug de diseño o uso”.
  • Que la aserción solo debe comprobar, no modificar cosas.

Bien:

assert(x < 10);

Mal (porque aquí se cambia x):

assert(++x < 10);

Si una aserción falla debe haber una acción clara:

– devolver error

– entrar a modo seguro

– registrar falla

– detener módulo

– llevar a un estado seguro

Las aserciones son como sensores internos para detectar estados imposibles o peligrosos antes de que el sistema siga corrompiéndose.

6) Declarar variables en el ámbito más pequeño posible: Declarar todos los objetos de datos al nivel de ámbito más pequeño posible.

Significa que si una variable solo se usa dentro de un for, declárala ahí.

Si solo se usa en una función, no la hagas global.

Ejemplos:

Bien:

void procesar(void) {

    int suma = 0;

    for (int i = 0; i < 10; i++) {

        suma += i;

    }

}

Mal:

int i;

int suma;

void procesar(void) {

    …

}

Porque mientras más grande el alcance de una variable:

– más lugares pueden modificarla

– más difícil es seguir su valor

– más probable es que cause errores laterales

En firmware las variables globales son útiles a veces, pero hay que limitar su uso.

Si todo es global, el sistema se vuelve muy difícil de razonar.

Cada dato debe vivir en el lugar más pequeño donde realmente se necesita.

7) Revisar retornos y validar parámetros: Quien llama debe revisar el retorno; quien recibe debe validar parámetros.

Si llamas una función que devuelve algo, debes comprobarlo.

Ejemplos:

Bien:

status = InitSensor();

if (status != OK) {

    return ERROR_SENSOR;

}

Mal:

InitSensor();   // y se ignora si falló

La función llamada no debe confiar ciegamente en lo que le mandan.

Bien:

int ProcesarBuffer(uint16_t *buf, size_t len) {

    if (buf == NULL) return -1;

    if (len == 0) return -2;

    …

}

Porque muchos errores nacen en interfaces entre módulos:

– punteros nulos

– tamaños incorrectos

– índices fuera de rango

– valores inválidos

El que llama debe verificar que todo salió bien.

La función llamada debe desconfiar de sus entradas.

8) Restringir el preprocesador: Limitar el preprocesador a includes y macros sencillas (#include, #define, #if, etc.).

Porque el preprocesador puede volver el código:

– difícil de leer

– difícil de analizar

– diferente según configuración

– propenso a errores raros

Ejemplos:

Bien:

#define MAX_SAMPLES 256

#define ADC_REF_MV 3300

Más peligroso, con macros complejas:

#define SQUARE(x) ((x)*(x))

Aunque parece simple, puede dar problemas si pones:

SQUARE(a+b)

O peor aún con efectos secundarios:

SQUARE(i++)

También restringir compilación condicional, es decir, mucho #ifdef puede hacer que en realidad tengas 5 programas distintos ocultos en uno mismo.

El preprocesador debe usarse poco y con cuidado. Nada de magia excesiva.

9) Restringir punteros:

No más de un nivel de desreferenciación; no punteros a funciones.

Ejemplos:

Bien:

*ptr

Mal:

**ptr2

ya es más complejo.

Porque los punteros son poderosos, pero también una gran fuente de errores:

– acceso inválido

– corrupción de memoria

– errores difíciles de rastrear

En especial evitar:

– punteros a punteros

– punteros a funciones

– casts extraños

– ocultar punteros en macros o typedefs

Ejemplos:

Bien (más simple):

void FillBuffer(uint16_t *buf, size_t len);

Mal (más complejo no):

void Process(uint16_t **buf);

En C real, sobre todo en sistemas embebidos, a veces sí se necesitan punteros.

La regla no dice “prohibidos totalmente”, sino “úsarlos con mucha disciplina”.

Usa punteros solo cuando de verdad hagan falta, y mantén su uso lo más directo posible.

10) Compilar sin warnings y usar análisis estático:

Compilar siempre con warnings estrictos y pasar análisis estático sin advertencias, desde el inicio del proyecto:

– activar warnings máximos del compilador

– no tolerar advertencias

– usar analizadores estáticos

Ejemplos de warnings útiles:

– variable no usada

– conversión peligrosa de tipos

– comparación entre signed/unsigned

– posible acceso fuera de rango

– función sin retorno correcto

El análisis estático son herramientas que revisan el código **sin ejecutarlo** y detectan posibles fallos.

Por ejemplo:

– ramas imposibles

– punteros nulos potenciales

– desbordamientos

– bucles sospechosos

– variables no inicializadas

Porque muchos errores se pueden detectar antes de probar en hardware.

Para firmware STM32 esto es extremadamente útil, porque a veces un warning “pequeño” termina siendo un bug real en runtime.

Un warning no es decoración. En software crítico, se trata como posible defecto real.

Estas reglas tienen una filosofía muy concreta:

1. Predictibilidad: Que el programa haga siempre lo esperado.

2. Analizabilidad: Que una persona o herramienta pueda demostrar propiedades del código.

3. Simplicidad: Reducir complejidad accidental.

4. Contención del daño: Si algo sale mal, que falle de forma controlada.

En software crítico, el criterio no es “qué tan elegante o poderoso es”, sino: ¿qué tan fácil es demostrar que no fallará de forma peligrosa?

Resumen muy simple de las 10 reglas

1. Nada de saltos raros ni recursión → que el flujo sea claro.

2. Bucles con máximo conocido → que no se cuelgue ni tarde de más.

3. Sin malloc/free en operación normal → evitar problemas de memoria.

4. Funciones cortas → más fáciles de entender y probar.

5. Usar aserciones → detectar estados imposibles o bugs.

6. Variables en el menor alcance posible → menos confusión y menos errores.

7. Revisar retornos y validar entradas → interfaces seguras entre funciones.

8. Pocas macros y simples → menos código oculto o engañoso.

9. Punteros restringidos → menos errores de memoria.

10. Cero warnings + análisis estático → detectar problemas temprano.

Si el software es importante, no programes con libertad total; programa de forma limitada, controlada y demostrable.