← Índice del curso

Sistemas Embebidos · Tema 16

Escribir buenas ISR

Una interrupción que funciona en la mesa y falla una vez por mes en el campo casi siempre tiene el mismo origen: una rutina demasiado larga, o un dato compartido con el programa principal sin protección. Este tema reúne las reglas que evitan los errores más difíciles de reproducir de todo el firmware.

01 · Punto de partida

Dos programas y un dato en común

En el tema anterior viste cómo el hardware llega a la primera instrucción de la ISR. Ahora hay que decidir qué escribir adentro. Y el punto de partida es una observación que cambia cómo se razona: la ISR y el programa principal son, a todos los efectos, dos programas que corren «a la vez», aunque haya un solo procesador. Cualquier instrucción de main puede ser interrumpida entre una y la siguiente, y cuando la rutina termina, main sigue sin enterarse de que el mundo cambió.

Mientras los dos programas no comparten nada, no hay problema. Pero el sentido de una interrupción es justamente avisar de algo, y avisar es compartir: una bandera, un contador, un byte recibido, una marca de tiempo. Cada variable que toca la ISR y también main es un punto donde dos hilos de ejecución se cruzan, y ahí aparecen defectos con tres características muy desagradables:

  • Son intermitentes: dependen de en qué instrucción exacta cae la interrupción, y eso cambia en cada ejecución.
  • Son silenciosos: no hay error ni excepción, solo un valor equivocado de vez en cuando.
  • Desaparecen al depurar: detener el programa o agregar una impresión cambia los tiempos y el defecto se esconde.

Seguimos con los Cortex-M y el STM32F103 como ejemplo. Las funciones __disable_irq, __get_PRIMASK y similares son de CMSIS, la capa estándar del núcleo ARM, y existen igual en cualquier fabricante.

02 · La regla de oro

Mantener la rutina corta

Del tema 15 sabés que una ISR retrasa a todo lo que sea de igual o menor urgencia, y que el programa principal es lo menos urgente de todo. Por eso la regla fundamental es: la ISR hace lo mínimo indispensable para no perder el evento, y deja el resto para el programa principal. En la práctica «lo mínimo» son tres cosas:

  1. Atender al hardware: leer el dato antes de que se pierda, o borrar la bandera que mantiene activa la solicitud.
  2. Registrar lo ocurrido: guardar el dato en un buffer, incrementar un contador, levantar una bandera.
  3. Salir.

El trabajo que sigue (interpretar el mensaje recibido, filtrar la muestra, actualizar la pantalla) se hace en el bucle principal, donde puede tardar lo que necesite y ser interrumpido sin consecuencias. El esquema es el de un mostrador de recepción: quien atiende toma nota del pedido y lo deja en la bandeja, y el trabajo se hace en la trastienda.

El patrón «la ISR marca, main procesa» Un evento de hardware dispara una ISR corta que guarda el dato en una bandeja compartida (bandera, contador o buffer). El programa principal, más lento, consulta la bandeja en cada vuelta y hace el trabajo largo. Hardwareevento ISRmicrosegundos Bandejabandera, contadoro buffer mainmilisegundos anota y sale el único punto compartido hace el trabajo largo

Y hay cosas que, directamente, no deben aparecer dentro de una ISR. La tabla resume cuáles y por qué:

Lo que no se hace dentro de una rutina de interrupción, y la alternativa.
No hacerPor quéEn su lugar
Esperas activas y retardos (delay)Congelan a todo lo menos urgente durante la esperaUna bandera y una máquina de estados en main
printf o enviar por una UART bloqueante10 caracteres a 115200 baudios son 868 µs con el procesador esperandoPoner el mensaje en un buffer y que lo envíe main o el DMA
malloc y freeTiempo impredecible; no son seguras si main estaba dentro de ellasBuffers estáticos reservados de antemano
Cálculos largos (filtros, punto flotante por software)Cada operación puede costar cientos de ciclos y multiplican la duraciónGuardar la muestra cruda y calcular en main
Escribir en Flash o EEPROMTardan milisegundos y pueden detener el busMarcar «hay que guardar» y hacerlo en main
Esperar a otra interrupción de menor prioridadInterbloqueo: la menos urgente nunca puede correr mientras la ISR esperaNo depender nunca de lo que dependa de una prioridad menor
Bucles sin cota («esperar hasta que el bit cambie»)Si el hardware falla, el procesador queda atrapado en la ISR para siempreMáximo de intentos, o no esperar

Un criterio práctico para saber qué tan larga es «demasiado»: medí la duración de cada ISR con el método del pin del tema 15 y compará con el plazo de la más urgente que pueda sufrirla. Una rutina de 3 µs en un sistema que atiende bytes cada 86,8 µs es inocente; la misma rutina en un sistema con un plazo de 2 µs es un defecto. Lo importante es que el número esté escrito en alguna parte.

03 · Decirle la verdad al compilador

La palabra volatile

El compilador de C optimiza suponiendo que el programa es un solo hilo de ejecución: si una variable no cambió dentro del fragmento que está mirando, la considera inmutable. No sabe que una interrupción puede modificarla «por detrás». Mirá este programa, que espera una bandera levantada por una ISR:

bool listo = false;                  // ¡sin volatile!

void TIM2_IRQHandler(void) {
    TIM2_SR = 0;                     // borrar la bandera del periférico
    listo = true;
}

int main(void) {
    iniciar();
    while (!listo) { }               // esperar a la interrupción
    atender();
}

Dentro del bucle while nadie modifica listo, así que el compilador (con las optimizaciones activadas, que es como se compila el firmware real) lo transforma en algo equivalente a esto:

bool copia = listo;                  // se lee UNA vez, antes del bucle
while (!copia) { }                   // bucle infinito si no estaba ya levantada

La interrupción llega, la variable cambia en memoria, y el programa nunca se entera: se queda girando para siempre. Con la optimización apagada funciona, con la activada no, y por eso es un clásico. La solución es volatile, que le dice al compilador que el valor puede cambiar por causas que no ve y obliga a leerlo de memoria cada vez que el programa lo nombra y a escribirlo cada vez que se lo asigna:

volatile bool listo = false;         // ahora cada lectura va a memoria

Es la misma palabra que usabas en el tema 9 para los registros del hardware, y por la misma razón: ambos son valores que cambian fuera del flujo del programa. La regla es simple: toda variable que una ISR escribe y main lee (o al revés) debe ser volatile. Y tres advertencias sobre lo que volatile no hace:

  • No vuelve atómica ninguna operación. Obliga a leer y escribir en memoria, pero contador++ sigue siendo tres instrucciones (el apartado siguiente).
  • No ordena los accesos a variables no volátiles. El compilador respeta el orden entre dos accesos volatile, pero puede mover los demás a ambos lados. Esto importa en el buffer circular de más adelante.
  • No es «poner más seguro». Usarlo en variables que no se comparten solo impide optimizaciones y agranda el código. Cada volatile debe tener una razón.

Un detalle de estilo muy recomendable: copiar la variable volátil a una local cuando se usa varias veces en la misma decisión, para que todas las decisiones se tomen sobre el mismo valor y no sobre lecturas distintas de algo que cambia. Lo verás en los patrones de más abajo.

04 · Operaciones que se pueden partir

Atomicidad y la actualización perdida

Una operación es atómica si, desde el punto de vista de una interrupción, ocurre de golpe: o no empezó, o terminó, pero nunca está a medias. Una interrupción puede entrar entre dos instrucciones de máquina cualquiera, de modo que la unidad de atomicidad es la instrucción, no la línea de C. Y una línea de C muy inocente suele ser varias instrucciones:

volatile uint32_t n;

n++;        // en ensamblador de Cortex-M:
            //   LDR  r0, [n]      ; 1) leer de memoria
            //   ADDS r0, r0, #1   ; 2) sumar en un registro
            //   STR  r0, [n]      ; 3) escribir en memoria

Supongamos que main ejecuta n-- (también leer, restar, escribir) y que la ISR ejecuta n++. La interrupción solo puede entrar entre las instrucciones de main, así que hay cuatro posiciones posibles: antes de la lectura, entre lectura y resta, entre resta y escritura, o después de la escritura. Con n = 10 el resultado correcto es 10. Pero si la interrupción cae en los dos huecos del medio, main ya leyó el 10 y va a escribir 9, pisando el 11 que dejó la ISR: se perdió una actualización y n queda en 9. De cuatro posiciones, dos fallan, y cuál ocurre depende de un instante de microsegundos.

El mismo mecanismo aparece cuando el dato es más ancho que lo que el procesador mueve de una vez. La tabla resume qué es atómico en un Cortex-M3 o M4:

Qué accesos son atómicos en un Cortex-M3/M4 (el M0 es igual, salvo por las instrucciones exclusivas).
Operación¿Atómica?Por qué
Leer o escribir uint8_t, uint16_t, uint32_t o un puntero, alineadosSíUna sola instrucción de carga o de almacenamiento
n++, n += x, n |= bit sobre una variableNoLeer, modificar y escribir: tres instrucciones
Leer o escribir uint64_t o doubleNoDos instrucciones de 32 bits
Copiar una estructura o un arregloNoVarias instrucciones
Dato desalineadoNoEl hardware lo parte en varios accesos
Leer dos variables que deben ser coherentes entre sí (hora y fecha)NoCada una es atómica, pero el conjunto no

Observá cómo funciona la regla en la dirección útil: si solo una de las partes escribe y la otra solo lee un dato atómico, no hace falta proteger nada. El problema aparece cuando el dato es ancho, o cuando las dos partes modifican la misma variable. Ese diseño se llama un solo escritor y es la manera más barata de evitar el problema.

Un caso especialmente traicionero es el de un tiempo de 64 bits. Muchos firmwares mantienen un contador de microsegundos de 64 bits que la ISR del temporizador incrementa; si main lo lee con una asignación común, el compilador emite dos lecturas de 32 bits. Si la interrupción cae justo entre ambas y el contador cruza el límite de los 32 bits, main junta la parte alta de antes del cruce con la parte baja de después, y obtiene un valor que el contador nunca tuvo (un error de más de 4000 millones de unidades). Con un tick de 1 ms, ese cruce ocurre una vez cada 49,7 días: el defecto es real y casi imposible de reproducir en el laboratorio. El laboratorio de este tema lo reproduce a voluntad.

05 · Proteger el acceso

Secciones críticas

Una sección crítica es un fragmento de código que no puede ser interrumpido por quien comparte los datos que usa. La forma más directa en un Cortex-M es deshabilitar las interrupciones al entrar y volver a habilitarlas al salir. Pero hay una forma correcta y una incorrecta de hacerlo:

// MAL: si ya estaban deshabilitadas, las habilita de más
__disable_irq();
t = ticks64;
__enable_irq();                      // ¿y si la función fue llamada desde dentro de otra sección crítica?

// BIEN: guardar el estado anterior y restaurarlo
uint32_t previo = __get_PRIMASK();   // 1 = ya estaban deshabilitadas
__disable_irq();
t = ticks64;
__set_PRIMASK(previo);               // vuelve exactamente a como estaba

El primer esquema falla cuando las secciones se anidan: una función con su sección crítica llama a otra con la suya, y al salir de la interna las interrupciones quedan habilitadas en medio de la externa. Guardar y restaurar PRIMASK resuelve el problema. Conviene envolver el patrón en un par de funciones static inline en una cabecera (tema 11) y usarlas siempre:

static inline uint32_t critica_entrar(void) {
    uint32_t previo = __get_PRIMASK();
    __disable_irq();
    return previo;
}
static inline void critica_salir(uint32_t previo) { __set_PRIMASK(previo); }

uint64_t tiempo_us(void) {           // lectura coherente de un contador de 64 bits
    uint32_t previo = critica_entrar();
    uint64_t t = ticks64;
    critica_salir(previo);
    return t;
}

Una sección crítica no es gratis: mientras dura, ninguna interrupción se atiende, sea cual sea su prioridad. Es el término «sección crítica en curso» de la tabla de latencia del tema 15, y suma su duración completa al peor caso de todas las interrupciones del sistema. De ahí las reglas:

  • Que sea tan corta como se pueda: lo ideal es lo mínimo imprescindible, como copiar el dato a una variable local y salir. No se llama a funciones, no se hacen cálculos, no se espera adentro. Medí su duración igual que la de las ISR.
  • Proteger el acceso, no el trabajo. Se copia el dato dentro de la sección crítica y se procesa la copia afuera.
  • Sin saltos que no vuelvan. Un return o un break en medio de la sección que se salte la restauración deja las interrupciones deshabilitadas para siempre, uno de los defectos más difíciles de ver.

Cuando hay interrupciones con plazos muy ajustados que no comparten datos con main, deshabilitar todas es un exceso. Los Cortex-M3 y superiores tienen el registro BASEPRI para enmascarar solo las de prioridad igual o menos urgente que un nivel y dejar pasar las más urgentes:

// Proteger un dato compartido con las ISR de prioridad 2 y 3,
// pero seguir atendiendo la UART (prioridad 1) sin retraso
uint32_t viejo = __get_BASEPRI();
__set_BASEPRI(2u << (8 - __NVIC_PRIO_BITS));   // enmascara los niveles 2, 3, ...
dato_copia = dato_compartido;
__set_BASEPRI(viejo);

El desplazamiento aparece porque el chip implementa solo los bits más altos del campo de prioridad (4 bits en el STM32F103), y BASEPRI se escribe en ese formato. Hay una consecuencia de diseño: este mecanismo solo funciona si las ISR de plazo corto no comparten datos con main (porque a ellas no se las protege), o si lo comparten de una forma que no necesita protección, como los patrones de un solo escritor del próximo apartado.

Por último, existen herramientas más finas: las instrucciones exclusivas LDREX y STREX del Cortex-M3 y superiores, que permiten un incremento atómico sin deshabilitar nada (en C, los tipos _Atomic y atomic_fetch_add de C11 las usan). No existen en el Cortex-M0, que es otra razón para tener a mano las secciones críticas como recurso universal.

06 · Diseño de la comunicación

Cómo pasar datos de la ISR al programa principal

Con las herramientas anteriores se arma un pequeño repertorio de patrones, cada uno para un tipo de información. Elegir el correcto es la mitad del trabajo.

Patrón 1: la bandera («pasó algo»)

La ISR levanta una variable booleana; main la consulta, la baja y actúa. Sirve cuando solo interesa saber si ocurrió, no cuántas veces ni con qué datos. Hay un error clásico en el orden de las operaciones:

volatile bool hay_muestra;

// MAL: borrar la bandera DESPUÉS de procesar
if (hay_muestra) {
    procesar();                      // si llega otra muestra aquí, se pierde la señal...
    hay_muestra = false;             // ...porque esta línea la borra sin haberla visto
}

// BIEN: borrar ANTES de procesar
if (hay_muestra) {
    hay_muestra = false;             // cualquier evento nuevo vuelve a levantarla
    procesar();
}

Aun así, una bandera tiene una limitación irremediable: dos eventos entre dos revisiones valen lo mismo que uno. Si necesitás contar, usá otra cosa.

Patrón 2: el contador de eventos («cuántas veces»)

La ISR incrementa un contador de 32 bits, y main lleva su propia cuenta de los que ya atendió. La diferencia entre ambos es la cantidad de eventos nuevos. Como solo la ISR escribe en eventos y solo main escribe en vistos, y la lectura de 32 bits es atómica, no hace falta ninguna sección crítica:

static volatile uint32_t eventos;    // la escribe solo la ISR
static uint32_t vistos;              // la escribe solo main

void EXTI0_IRQHandler(void) { EXTI_PR = 1u; eventos++; }

void atender_eventos(void) {
    uint32_t ahora = eventos;        // UNA lectura, guardada en una local
    uint32_t nuevos = ahora - vistos;    // la resta sin signo tolera el desborde del contador
    vistos = ahora;
    while (nuevos--) { procesar_un_evento(); }
}

Patrón 3: el buffer circular («los datos en orden»)

Cuando cada evento trae un dato (un byte de la UART, una muestra del conversor) y no se puede perder ninguno, se usa un buffer circular: un arreglo con dos índices, uno donde la ISR escribe (cabeza) y otro desde donde main lee (cola). El diseño es seguro con una sola ISR productora y un solo consumidor porque cada índice lo modifica una sola de las partes:

#define RX_TAM 64u                              // potencia de 2: el módulo se hace con una máscara
static volatile uint8_t  rx_buf[RX_TAM];        // volátil: que el dato se escriba ANTES que la cabeza
static volatile uint8_t  rx_cab;                // escribe la ISR
static volatile uint8_t  rx_cola;               // escribe main
static volatile uint32_t rx_perdidos;           // para diagnosticar un buffer desbordado

void USART1_IRQHandler(void) {                  // productor
    uint8_t dato = (uint8_t)USART1_DR;          // leer el dato también borra la bandera
    uint8_t sig = (rx_cab + 1u) & (RX_TAM - 1u);
    if (sig != rx_cola) { rx_buf[rx_cab] = dato; rx_cab = sig; }
    else                { rx_perdidos++; }      // lleno: descartar y contar
}

bool rx_leer(uint8_t *dato) {                   // consumidor, en main
    if (rx_cola == rx_cab) return false;        // vacío
    *dato = rx_buf[rx_cola];
    rx_cola = (rx_cola + 1u) & (RX_TAM - 1u);
    return true;
}

Detalles que hacen que funcione: el dato se escribe antes de avanzar la cabeza, de modo que main nunca ve un índice que apunta a un lugar sin escribir (por eso el arreglo es volatile: así el compilador no puede invertir el orden de los dos accesos); se deja siempre un lugar libre para distinguir «lleno» de «vacío», así que la capacidad útil es 63 bytes; y el contador rx_perdidos convierte un desborde silencioso en un dato visible. Dimensionar el buffer es un cálculo: el tamaño debe cubrir los bytes que llegan durante la vuelta más larga del programa principal, más un margen.

Patrón 4: la copia protegida («un conjunto coherente»)

Cuando la ISR actualiza varios campos que deben leerse juntos (una estructura con la lectura de tres ejes, por ejemplo), la solución simple es copiar toda la estructura a una local dentro de una sección crítica corta y usar la copia:

typedef struct { int16_t x, y, z; uint32_t instante; } Muestra;
static volatile Muestra ultima;                 // la escribe la ISR completa, campo por campo

Muestra muestra_leer(void) {
    Muestra copia;
    uint32_t previo = critica_entrar();
    copia.x = ultima.x;  copia.y = ultima.y;  copia.z = ultima.z;  copia.instante = ultima.instante;
    critica_salir(previo);
    return copia;                               // main trabaja con la copia, sin apuro
}
Qué patrón usar según lo que se necesita comunicar.
NecesidadPatrónProtección
Saber que ocurrió algoBanderaNinguna (borrar antes de procesar)
Contar ocurrenciasContador de eventosNinguna (un escritor por variable)
Transmitir una secuencia de datosBuffer circularNinguna (un escritor por índice)
Transmitir un conjunto de datos coherenteCopia protegidaSección crítica corta
Muchos productores o consumidoresCola del sistema operativoLa da el sistema (tema 21)

07 · Los costos escondidos

La pila y las funciones prohibidas

Cada interrupción gasta memoria de pila, y el tema 15 no lo dijo: el hardware apila 8 registros (32 bytes) en el momento de la entrada, y la rutina suma sus propias variables locales y los registros que el compilador necesite preservar. Como las interrupciones se pueden anidar, hay que reservar pila para el peor caso: todas las que puedan anidarse, una encima de otra, además de la profundidad máxima de main.

Un ejemplo con números: supongamos 3 niveles de anidamiento posibles y que cada rutina usa unos 40 bytes propios. Cada nivel cuesta 32 + 40 = 72 bytes, y los tres suman 216 bytes. Si main llega a usar 700 bytes en su peor momento, la pila necesita al menos 916 bytes. En un chip de pila de 1 KB queda un margen de 108 bytes. Pero si se activa la unidad de punto flotante y una ISR la utiliza, el marco que apila el hardware crece de 32 a 104 bytes: 3 × (104 + 40) = 432 bytes, y 700 + 432 = 1132 bytes, más de la pila disponible. El desborde sobrescribe lo que haya debajo (las variables globales, tema 29) sin avisar, y el síntoma es un comportamiento errático en un lugar sin relación. Dos medidas lo previenen:

  • Pintar la pila: al arrancar, llenarla con un patrón conocido (por ejemplo 0xA5A5A5A5) y, después de someter al sistema a sus peores casos, ver cuánto del patrón sigue intacto. La parte destruida es la profundidad máxima real.
  • Dejar margen: reservar al menos 25 a 30 % más que el máximo medido.

La segunda causa de los problemas escondidos es la reentrancia. Una función es reentrante si puede ser interrumpida a la mitad y llamada de nuevo, desde otro contexto, sin que se corrompan sus datos. Las funciones que usan variables estáticas o globales internas no lo son: strtok, malloc, printf (por el buffer de salida) y toda función de biblioteca que use errno en un sistema con varios contextos. Si main estaba dentro de malloc cuando entró la ISR y la ISR también llama a malloc, la estructura interna del reservador queda corrompida. La regla práctica: en una ISR, llamá solo a funciones propias que sepas reentrantes (que operan solo sobre sus parámetros y variables locales) y a las de acceso al hardware que tengan ese mismo cuidado.

Cuando una función necesita ser usada por main y por una ISR, hay tres salidas: hacerla reentrante, protegerla con una sección crítica en las llamadas de main, o separar el uso (que solo main la llame y la ISR pida el servicio a través de una bandera).

08 · Analogía con programación

Tres defectos reproducidos en Python

Estos defectos dependen del instante exacto de la interrupción, lo que los hace casi imposibles de provocar en un equipo real. En una simulación, en cambio, se puede recorrer sistemáticamente cada instante posible y ver cuáles fallan. El programa usa una convención: la ISR entra completa en un «hueco» entre dos instrucciones de main (0 es antes de la primera, 1 entre la primera y la segunda, etc.), porque una ISR no puede ser interrumpida por main.

Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.

# --- Parte 1: la actualización perdida (n -= 1 en main, n += 1 en la ISR) -------------
def decrementar_en_main(hueco, n0=10):
    """main ejecuta LDR, SUB, STR; la ISR (n += 1) entra COMPLETA en el hueco indicado (0 a 3)."""
    n, reg = n0, 0
    for i, instruccion in enumerate(["LDR", "SUB", "STR"] + ["fin"]):
        if i == hueco:
            n += 1                                   # la ISR corre entera y no puede ser interrumpida por main
        if instruccion == "LDR":   reg = n
        elif instruccion == "SUB": reg -= 1
        elif instruccion == "STR": n = reg
    return n

print("Parte 1: n = 10; main hace n -= 1 y la ISR hace n += 1. Debería quedar 10.")
for hueco in range(4):
    r = decrementar_en_main(hueco)
    print(f"  ISR entra en el hueco {hueco}: n = {r:2d}  {'correcto' if r == 10 else 'SE PERDIÓ UNA ACTUALIZACIÓN'}")


# --- Parte 2: la lectura rota de un contador de 64 bits (dos lecturas de 32 bits) -------
M32 = 0xFFFFFFFF
def leer(metodo, hueco, contador):
    """La ISR suma 1 al contador una sola vez, en el hueco indicado."""
    estado = {"c": contador, "paso": 0}
    def punto():                                      # entre dos instrucciones de main puede entrar la ISR
        if estado["paso"] == hueco and not estado.get("hecho"):
            estado["c"] += 1; estado["hecho"] = True
        estado["paso"] += 1
    if metodo == "ingenua":
        punto(); alto = estado["c"] >> 32
        punto(); bajo = estado["c"] & M32
        punto()
        return (alto << 32) | bajo
    if metodo == "interrupciones deshabilitadas":
        punto(); valor = estado["c"]                  # la ISR espera hasta que main reabra las interrupciones
        punto(); punto()
        return valor
    while True:                                       # "alto, bajo, alto" y reintento si el alto cambió
        punto(); alto1 = estado["c"] >> 32
        punto(); bajo = estado["c"] & M32
        punto(); alto2 = estado["c"] >> 32
        punto()
        if alto1 == alto2:
            return (alto1 << 32) | bajo

inicial = 0x00000000FFFFFFFF                          # el contador está a un tick de cruzar los 32 bits
print()
print(f"Parte 2: contador = 0x{inicial:X}; válidos: 0x{inicial:X} o 0x{inicial+1:X}")
for metodo in ("ingenua", "interrupciones deshabilitadas", "alto-bajo-alto"):
    resultados = []
    for hueco in range(4):
        v = leer(metodo, hueco, inicial)
        resultados.append((hueco, v, inicial <= v <= inicial + 1))
    malos = [h for h, v, ok in resultados if not ok]
    print(f"  {metodo:30s} huecos con error: {malos if malos else 'ninguno'}")
    for h, v, ok in resultados:
        if not ok:
            print(f"      hueco {h}: leyó 0x{v:X} (¡ni antes ni después!)")


# --- Parte 3: bandera contra contador ------------------------------------------------
eventos = [3, 5, 12, 40, 41, 42]                      # instantes (ms) en que la ISR detecta un evento
vueltas = range(10, 60, 10)                           # main revisa cada 10 ms
bandera = contador_total = 0
previo = 0
for t in vueltas:
    nuevos = sum(1 for e in eventos if previo < e <= t)
    bandera += 1 if nuevos else 0                     # con bandera: "hubo algo", sin saber cuántas veces
    contador_total += nuevos                          # con contador: se cuentan todos
    previo = t
print()
print(f"Parte 3: ocurrieron {len(eventos)} eventos. Con bandera se procesan {bandera}; con contador, {contador_total}.")

Resultado: en la parte 1 la ISR falla en los huecos 1 y 2 (el 50 % de las posiciones): n queda en 9 en lugar de 10, porque main pisó el incremento de la rutina. En la parte 2 la lectura ingenua falla en un solo hueco de cuatro, el 1 (entre las dos lecturas): lee 0x0 cuando el valor real era 0xFFFFFFFF o 0x100000000, un error de 4300 millones. Ese hueco es una ventana de una instrucción, y por eso en el equipo real parece imposible de ver. La versión con interrupciones deshabilitadas y la de «alto, bajo, alto» no fallan en ningún hueco. En la parte 3 ocurrieron 6 eventos y la bandera registra 4: los pares 3 y 5, y 41 y 42, cayeron entre dos revisiones y se fusionaron. El contador cuenta los 6.

Una diferencia importante entre los dos métodos que resuelven la parte 2: deshabilitar las interrupciones agrega latencia a todo el sistema (muy poca, dos instrucciones, pero mayor que cero), mientras que «alto, bajo, alto» no toca el sistema a costa de unas pocas instrucciones más en main y de un reintento improbable. Es un ejemplo de un patrón general: cuando el escritor es una ISR y el lector es main, a veces el lector puede verificar su propia lectura y repetirla si fue interrumpida.

09 · Exploración interactiva

La lectura rota

El laboratorio reproduce la parte 2 del programa anterior paso a paso. Hay un contador de 64 bits que una ISR incrementa una vez, y main debe leerlo. Elegí cómo lo lee, el valor en que se encuentra el contador y en qué hueco entra la interrupción. Verás cada instrucción, el momento de la interrupción, el valor leído y si es uno que el contador realmente tuvo.

Leer un contador de 64 bits en un Cortex-M

Valor leído por main—
Valores válidos (antes o después de la ISR)—
Resultado—

Secuencia de instrucciones

—

—

Elegí un método y un hueco para ver qué lee main.

Recorrido guiado

  1. Con los valores iniciales (lectura ingenua, contador en el cruce, hueco 1): main lee la parte alta (0), la ISR incrementa el contador a 0x1_00000000, y main lee la parte baja (0). Junta 0x0_00000000: un valor que el contador nunca tuvo, 4300 millones por debajo del real.
  2. Probá los huecos 0, 2 y 3 con el mismo método: todos dan una lectura válida. El defecto solo existe si la interrupción cae en la ventana de una instrucción. Una prueba que sale bien no demuestra que el código sea correcto.
  3. Cambiá el valor a 0x0_00001000 (lejos del cruce) y volvé al hueco 1: la lectura sale bien, porque la parte alta no cambia. El defecto solo se manifiesta en un contador que cruza el límite, algo que con un tick de 1 ms ocurre una vez cada 49,7 días.
  4. Volvé al cruce, hueco 1, y elegí interrupciones deshabilitadas: la ISR queda pendiente y entra recién al reabrirlas, después de la lectura. El resultado es coherente en cualquier hueco.
  5. Elegí alto, bajo, alto con el hueco 1: la primera lectura de la parte alta no coincide con la segunda, main lo detecta y repite el intento, ahora sin interrupciones en medio. No deshabilitó nada y el resultado es coherente.
  6. Leé el mensaje bajo la tabla de resultados para cada combinación: te dice en cuántos de los cuatro huecos falla el método elegido.
Desafío: ¿se puede evitar el problema sin proteger nada?

Sí: no compartir un dato de 64 bits. Si el contador de 32 bits de la ISR se lee atómicamente y main calcula por su cuenta cuántas veces desbordó (comparando cada lectura con la anterior), nunca hay una lectura de dos mitades. O bien, usar directamente el contador de hardware del temporizador (tema 17) y tomar una sola lectura de 32 bits. Es un criterio general de este tema: antes de proteger un dato compartido, preguntate si hace falta compartirlo, o si se puede reducir a algo que el procesador lea de una sola vez.

10 · Método de trabajo

Método para escribir una ISR

  1. Definí qué debe hacer la ISR en una frase. Si la frase tiene una «y» o una «luego», probablemente la mitad de eso es trabajo para main.
  2. Hacé una lista de los datos que toca la ISR y marcá cuáles también usa main. Esos son los únicos puntos de riesgo; el resto es código local.
  3. Para cada dato compartido, decidí el patrón de la tabla: bandera, contador, buffer o copia protegida. Si se puede reducir a un solo escritor y un dato atómico, hacelo.
  4. Declará volatile todo dato compartido, y nada más.
  5. Escribí la ISR: borrar la causa, leer el dato, registrarlo, salir. Nada de esperas, de bibliotecas pesadas ni de bucles sin cota.
  6. Protegé los accesos de main a lo que no sea atómico, con secciones críticas tan cortas como se pueda (guardar y restaurar PRIMASK), o con BASEPRI si hay interrupciones críticas que no deban retrasarse.
  7. Medí la duración de la ISR y de cada sección crítica con un pin y un analizador lógico, y anotá los números junto con la tabla de prioridades.
  8. Dimensioná buffers y pila para el peor caso, y agregá contadores de pérdidas para que un desborde no pase silencioso.
  9. Probá forzando el cruce: inicializá los contadores cerca de su límite, saturá los buffers, ejecutá a máxima frecuencia de eventos. Los defectos de este tema solo se ven donde no se los busca.

Una revisión rápida de cualquier código existente: buscá todas las variables que aparecen tanto en una ISR como en main (o en dos ISR de distinta prioridad). Para cada una, preguntá en voz alta: «¿es volatile? ¿es atómico el acceso de cada lado? ¿quién la escribe?». Si no se puede contestar con seguridad las tres, ahí está el defecto, o está esperando.

11 · Comprensión

Confusiones frecuentes

«Con volatile ya es seguro»

Solo asegura que cada acceso va a memoria. No vuelve atómico un n++ ni una lectura de 64 bits, y no ordena los accesos a otras variables. volatile resuelve la visibilidad; la atomicidad se resuelve con otro mecanismo.

«Si lo probé mil veces y anduvo, está bien»

Un defecto que depende de caer en una ventana de una instrucción puede tener una probabilidad por ejecución de una en cientos de miles. Mil pruebas no lo ven. Hay que razonar sobre todos los instantes posibles, o forzar las condiciones (como acercar un contador a su límite), no esperar a encontrarlo.

«Deshabilitar interrupciones es gratis»

Retrasa todas las interrupciones durante todo el tiempo que dure, sea cual sea su prioridad. Dos instrucciones son inofensivas; un bucle adentro es un defecto de tiempo real. Medí la duración y mantenela mínima.

«__enable_irq() al final de la sección crítica es suficiente»

Solo si estás seguro de que las interrupciones estaban habilitadas al entrar. Si la función puede ser llamada desde otra sección crítica (o desde una ISR), habilitar incondicionalmente rompe la protección de quien la llamó. Lo correcto es guardar PRIMASK y restaurarlo.

«Una bandera alcanza para contar eventos»

Una bandera dice «ocurrió al menos una vez». Si dos eventos llegan entre dos revisiones, se funden en uno. Para contar hay que usar un contador; para no perder datos, un buffer.

«Mi ISR es corta porque tiene pocas líneas»

Las líneas no miden tiempo. Una línea puede llamar a una función que espera, divide en punto flotante por software o escribe en Flash. La duración se mide, no se estima con la vista.

«Si solo main lee y solo la ISR escribe, no hay problema nunca»

Vale para un dato atómico (hasta 32 bits, alineado). Con un dato de 64 bits o una estructura, el lector puede ver una mezcla de dos versiones distintas. Un solo escritor soluciona las escrituras simultáneas, no la lectura de un dato partido.

12 · Práctica guiada

Predecí antes de comprobar

1. ¿Atómico o no?

En un Cortex-M3, decidí si cada operación es atómica respecto de una interrupción: (a) flag = 1; con volatile uint8_t flag; (b) cuenta++; con volatile uint32_t; (c) x = reloj64; con volatile uint64_t; (d) y = contador32; con volatile uint32_t alineado; (e) copia = ultima; con una estructura de 12 bytes.

Ver solución

(a) Sí: una sola instrucción de escritura de un byte. (b) No: leer, sumar y escribir. (c) No: dos lecturas de 32 bits. (d) Sí: una sola lectura alineada de 32 bits. (e) No: varias instrucciones, y la ISR puede modificar la estructura entre ellas. Solo (a) y (d) se pueden usar sin protección, y siempre que haya un solo escritor.

2. El hueco que falla

n vale 5; main hace n-- y una ISR hace n++. Para cada uno de los cuatro huecos posibles, ¿cuánto vale n al final? ¿Cuál debería ser?

Ver solución

Debería quedar en 5 (se resta uno y se suma uno). Hueco 0 (antes de leer): la ISR deja 6, main lee 6, escribe 5. Correcto. Huecos 1 y 2: main ya leyó el 5 y va a escribir 4, pisando el 6: queda 4, con un incremento perdido. Hueco 3 (después de escribir): main deja 4 y la ISR lo sube a 5. Correcto. Dos de cuatro fallan, con el mismo resultado de 4.

3. El defecto que aparece en la flota

Un firmware lee un contador de 64 bits de milisegundos en un bucle de 20 µs, con lecturas ingenuas. Entre las dos lecturas de 32 bits pasan 2 ciclos de reloj a 72 MHz (≈ 28 ns). El contador cruza los 32 bits cada 49,7 días. Estimá cada cuánto falla un equipo, y una flota de 10 000.

Ver solución

La interrupción llega en un instante cualquiera de la vuelta de 20 µs; la probabilidad de que caiga en la ventana de 28 ns es 28 ns / 20 µs = 1,4 × 10⁻³. Eso solo importa en el único tick que cruza el límite, una vez cada 49,7 días, así que el defecto se manifiesta en promedio cada 49,7 / 0,0014 ≈ 35 500 días, unos 97 años por equipo. Es invisible en el laboratorio. En una flota de 10 000 equipos, se reparte entre todos: 35 500 / 10 000 ≈ 3,6 días entre fallas en algún equipo. Un defecto «que no se puede ver» es una falla semanal cuando hay escala.

4. Bandera o contador

Un sensor interrumpe en los instantes 3, 5, 12, 40, 41 y 42 ms. main revisa cada 10 ms (a los 10, 20, 30, 40 y 50 ms). ¿Cuántos eventos procesa con una bandera, y cuántos con un contador? ¿Qué revisión ve cada grupo?

Ver solución

Revisión de las 10: eventos 3 y 5 (la bandera los cuenta como uno; el contador, dos). Las 20: el evento 12 (uno y uno). Las 30: ninguno. Las 40: el evento 40 (uno y uno). Las 50: los eventos 41 y 42 (la bandera cuenta uno; el contador, dos). Con la bandera: 4 de 6; con el contador: 6 de 6. Si el sistema necesita contar o procesar cada evento, la bandera es insuficiente.

5. ¿Alcanza la pila?

Una pila de 1024 bytes; main usa hasta 700 en su peor caso; se pueden anidar 3 ISR que usan 40 bytes cada una, además de los 32 bytes del marco de hardware. ¿Alcanza? ¿Y si las ISR usan la FPU y el marco de hardware pasa a 104 bytes?

Ver solución

Sin FPU: 3 × (32 + 40) = 216 bytes; 700 + 216 = 916 bytes ≤ 1024, con 108 bytes de margen (un 10 %, poco). Con FPU: 3 × (104 + 40) = 432 bytes; 700 + 432 = 1132 bytes > 1024: la pila se desborda en 108 bytes y pisa las variables globales. La solución es aumentar la pila, reducir el anidamiento (poniendo las ISR al mismo nivel de prioridad, de modo que no se anidan) o evitar la FPU dentro de las rutinas.

6. Dimensionar el buffer

Una UART a 115200 baudios (tramas de 10 bits) alimenta un buffer circular. main lo vacía una vez por vuelta del bucle, que normalmente dura 5 ms. Un buffer de 64 bytes tiene capacidad útil de 63. ¿Alcanza? ¿Qué pasa si una vuelta tarda 6 ms?

Ver solución

115 200 / 10 = 11 520 bytes por segundo, es decir, 57,6 bytes en 5 ms: se necesita un lugar para 58 bytes, y hay 63, con un margen de 5. Alcanza, pero justo. Si una vuelta tarda 6 ms llegan 69,1 bytes, más de los 63 disponibles: se descartan unos 6 bytes y rx_perdidos se incrementa. Un buffer de 128 bytes (capacidad útil 127) cubre hasta 11 ms de vuelta; hay que dimensionar para la vuelta más larga posible, no para la típica.

13 · Síntesis

Ideas para recordar

  • La ISR y el programa principal son dos programas que se cruzan en los datos compartidos; los defectos resultantes son intermitentes, silenciosos y desaparecen al depurar.
  • La ISR hace lo mínimo: atender al hardware, registrar lo ocurrido y salir; el trabajo largo se deja a main, y no se esperan, imprimen, reservan memoria ni calculan cosas largas dentro de ella.
  • Todo dato que comparten la ISR y main debe ser volatile; sin eso el compilador puede leer la variable una sola vez y ocultar la interrupción. Pero volatile no hace atómica ninguna operación.
  • Una interrupción puede entrar entre dos instrucciones de máquina cualesquiera: n++ son tres, y una lectura de 64 bits son dos. En un Cortex-M los accesos a datos de hasta 32 bits alineados son atómicos; los demás no.
  • Una sección crítica protege un acceso no atómico deshabilitando interrupciones: se guarda y restaura PRIMASK (nunca se habilita incondicionalmente) y debe ser lo más corta posible, porque retrasa a todas las interrupciones. BASEPRI enmascara solo las menos urgentes.
  • La mejor protección es no necesitarla: un solo escritor por variable y un dato atómico. Así funcionan el contador de eventos y el buffer circular.
  • Se elige el patrón según la necesidad: bandera (si ocurrió; se borra antes de procesar), contador (cuántas veces), buffer circular (los datos en orden) y copia protegida (un conjunto coherente).
  • Las ISR consumen pila en el peor caso de anidamiento, y las funciones no reentrantes no se llaman desde ellas. Estos defectos se encuentran forzando los casos límite, no esperando a que ocurran.

¿Listo para el tema 17?

Antes de avanzar, explicá por qué volatile no alcanza para proteger n++; decidí qué patrón usarías para pasar bytes de una UART a main sin perder ninguno y por qué no hace falta una sección crítica; y escribí la forma correcta de guardar y restaurar el estado de las interrupciones. Si podés justificar las tres respuestas, entendés el tema.

En el próximo tema vemos los temporizadores y contadores: el periférico que genera la base de tiempo del sistema, mide intervalos, captura eventos y produce señales PWM, y que será la fuente de la mayoría de las interrupciones que escribas.