01 · Punto de partida
Dos hilos sin coordinador
Si trabajaste con hilos, conocés el problema: dos flujos que acceden a la misma variable pueden dejarla en un estado inconsistente. Con una interrupción pasa lo mismo, con una particularidad. La ISR puede interrumpir al programa principal en cualquier instrucción, pero el programa principal nunca interrumpe a la ISR (mientras ésta corre, las demás interrupciones están deshabilitadas). Es una asimetría que simplifica algunos casos y esconde otros.
En el tema 19 señalamos que un acceso de 16 bits no es atómico, y en el 10 que volatile no lo arregla. Este tema resuelve el problema por completo: qué se rompe, cuándo, y las cuatro herramientas para evitarlo.
Retomamos las instrucciones LDS y STS (tema 7), el costo de una interrupción (tema 19) y las ISR cortas.
02 · El primer error
La lectura partida
Una variable de 16 bits ocupa dos bytes, y el AVR, que es de 8 bits, los lee con dos instrucciones. Entre ellas puede ocurrir una interrupción. Supongamos un contador de milisegundos que una ISR incrementa, y que el valor es 0x00FF:
| Paso | Quién | Qué hace | Contador |
|---|---|---|---|
| 1 | Programa | Lee el byte bajo → 0xFF | 0x00FF |
| 2 | ISR | Incrementa el contador | 0x0100 |
| 3 | Programa | Lee el byte alto → 0x01 | 0x0100 |
El programa arma 0x01FF (511) | Valores reales: 255 y 256 | ||
El resultado, 511, tiene un error de casi 255 ms y no es ni el valor anterior ni el posterior. Ocurre cuando una interrupción cae justo entre las dos lecturas y además el byte bajo da la vuelta (una vez cada 256 incrementos). Es una combinación rara, y por eso el error es intermitente: puede aparecer una vez cada pocos segundos o cada pocas horas, según la frecuencia.
Pasa con cualquier dato de más de un byte: un uint16_t (dos accesos), un uint32_t (cuatro; hay tres oportunidades de partirse), un float, una estructura. Y pasa también en sentido contrario: si el programa principal escribe un valor de 16 bits en dos pasos y la ISR lo lee entre medio, lee una mezcla del viejo y el nuevo.
03 · Lo que no se puede partir
Qué es atómico en un AVR
Una operación es atómica si no puede ser interrumpida a la mitad. La CPU solo atiende una interrupción entre instrucciones, nunca durante una; entonces cualquier cosa que se resuelva en una sola instrucción es atómica.
| Operación | Instrucciones | ¿Atómica? |
|---|---|---|
| Leer o escribir una variable de 8 bits | 1 (LDS / STS) | Sí |
Leer o escribir un uint16_t | 2 | No |
Leer o escribir un uint32_t o un float | 4 | No |
cuenta++ con cuenta de 8 bits en SRAM | 3: cargar, incrementar, guardar | No |
PORTB |= (1 << PB5) (SBI) | 1 | Sí |
PORTB ^= (1 << PB5) | 4: leer, cargar máscara, XOR, escribir | No |
Un registro de 16 bits del hardware (TCNT1, ADC) | 2, con el registro temporal descrito en el tema 9 | Sí para leer (si se usa el nombre completo), con cuidado al escribir |
Las cuentas son sencillas pero decepcionantes: casi nada de lo que escribís en C es atómico salvo los accesos de un byte y las operaciones de un bit sobre los primeros registros de E/S. Incluso x++, que parece indivisible, son tres instrucciones. Cuando el dato lo tocan la ISR y el programa principal, hay que preguntarse siempre cuántas instrucciones lo manejan.
04 · El segundo error
La actualización perdida
Otro caso: ambos hilos modifican la misma variable. La ISR cuenta eventos (cuenta++) y el programa principal, al procesarlos, los descuenta (cuenta--). Cada operación es de lectura, modificación y escritura, y si la ISR cae en el medio de la del programa:
| Paso | Quién | Qué hace | Memoria | Copia del programa |
|---|---|---|---|---|
| 1 | Programa | Carga cuenta en un registro | 5 | 5 |
| 2 | ISR | cuenta++ | 6 | 5 |
| 3 | Programa | Resta 1 a su copia | 6 | 4 |
| 4 | Programa | Guarda su copia | 4 | 4 |
El valor correcto era 5 (5 + 1 − 1). Quedó 4: un evento se perdió. A diferencia de la lectura partida, no hace falta que se dé una coincidencia numérica: ocurre cada vez que la ISR cae entre la carga y el guardado, que es una fracción apreciable del tiempo del programa.
Lo mismo sucede con una variable de banderas compartida: si la ISR hace banderas |= A y el programa hace banderas &= ~B, y la ISR cae a mitad, se pierde la bandera A. Es un error clásico y difícil de ver.
05 · Una aclaración necesaria
Lo que volatile hace y no hace
volatile (tema 10) obliga al compilador a leer y escribir la variable en memoria en cada acceso, y a no reordenar ni eliminar esos accesos. Es imprescindible: sin él, el compilador puede guardar el valor en un registro y el programa nunca ve los cambios de la ISR.
Pero volatile no vuelve atómico ningún acceso. Una variable volatile uint16_t se sigue leyendo con dos instrucciones, y volatile uint8_t cuenta; cuenta--; sigue siendo carga, resta y guardado. Es una condición necesaria, nunca suficiente.
06 · La herramienta principal
La sección crítica
Una sección crítica es un tramo de código que se ejecuta con las interrupciones deshabilitadas, de modo que nadie lo interrumpa. Con cli() se deshabilitan y con sei() se habilitan. La forma más simple y la más común:
cli(); // ninguna interrupción puede colarse
uint16_t copia = milisegundos; // los dos bytes se leen juntos
sei(); // se vuelven a permitir
Tiene un defecto sutil: sei() siempre habilita. Si esta porción de código se llama desde una ISR (donde las interrupciones ya están deshabilitadas) o desde otra sección crítica, se las habilita antes de tiempo. La solución es guardar el estado y restaurarlo:
uint8_t sreg = SREG; // guardar el registro de estado (incluye el bit I)
cli();
uint16_t copia = milisegundos;
SREG = sreg; // restaurar: las habilita solo si estaban habilitadas
La biblioteca avr-libc empaqueta el patrón en una macro, de util/atomic.h, que además evita el olvido de la restauración:
#include <util/atomic.h>
volatile uint16_t milisegundos;
uint16_t leer_milisegundos(void) {
uint16_t copia;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
copia = milisegundos; // sección crítica
}
return copia; // el resto se hace fuera
}
ATOMIC_RESTORESTATE restaura el estado anterior (lo recomendable); ATOMIC_FORCEON fuerza sei() al salir y solo es seguro cuando se sabe que las interrupciones estaban habilitadas. Las funciones cli() y sei() de avr-libc incluyen además una barrera de memoria para el compilador, de modo que éste no traslade accesos a la variable fuera de la sección.
Reglas de la sección crítica
- Cortísima. Mientras dura, el chip no atiende interrupciones: la latencia máxima (tema 19) aumenta en esa cantidad de ciclos, y si dura más que el período de un evento, ese evento se pierde. Lo ideal es copiar el dato y salir.
- Sin llamadas largas dentro. Ni esperas, ni funciones lentas, ni otro bucle.
- Proteger también las escrituras de más de un byte desde el programa principal, no solo las lecturas.
- La ISR no necesita protegerse de otras ISR: ya tiene las interrupciones deshabilitadas. Lo hace automáticamente el hardware.
07 · Otros caminos
Alternativas sin deshabilitar interrupciones
A veces conviene evitar el cli() (por ejemplo, en un sistema con requisitos de latencia estrictos). Hay tres técnicas:
Diseñar con datos de un byte
La solución más barata: que lo compartido sea un byte. Una bandera «hay dato» de 8 bits, escrita por la ISR y borrada por el programa principal, no necesita protección. Cuando hay varias banderas, se usan bytes distintos (uno por bandera) en lugar de bits de un mismo byte, para evitar la actualización perdida. El ATmega328P también tiene registros de propósito general en el espacio de E/S (GPIOR0, GPIOR1, GPIOR2); GPIOR0 admite SBI y CBI, de modo que GPIOR0 |= (1 << 0) es atómico, ideal para banderas.
Leer dos veces hasta que coincidan
Para un contador que la ISR solo incrementa, se puede leer repetidamente hasta que dos lecturas seguidas den lo mismo:
uint16_t leer_sin_cli(void) {
uint16_t a, b;
do { a = milisegundos; b = milisegundos; } while (a != b);
return a;
}
Si hubo una lectura partida, la segunda difiere de la primera y se repite. Funciona cuando el valor cambia lentamente respecto del bucle; no sirve si cambia en cada lectura.
Una sola dirección de escritura
Una variable que escribe solo la ISR y lee solo el programa (o al revés), de un byte, no necesita nada. Es el principio del buffer circular de la sección siguiente.
08 · El patrón por excelencia
El buffer circular
Cuando la ISR produce un flujo de datos (los bytes que llegan por la UART) y el programa principal los consume, se usa un buffer circular (anillo): un arreglo con dos índices, uno donde la ISR escribe (cabeza) y otro de donde el programa lee (cola). Al llegar al final, ambos vuelven al principio.
#define TAM 16 // potencia de 2
#define MASCARA (TAM - 1)
volatile uint8_t buf[TAM];
volatile uint8_t cabeza = 0; // la modifica SOLO la ISR
volatile uint8_t cola = 0; // la modifica SOLO el programa principal
ISR(USART_RX_vect) {
uint8_t dato = UDR0; // leer también borra la bandera
uint8_t siguiente = (cabeza + 1) & MASCARA;
if (siguiente != cola) { // si no está lleno
buf[cabeza] = dato; // primero el dato…
cabeza = siguiente; // …después se publica
} // si está lleno, se descarta
}
int16_t leer_byte(void) { // desde el programa principal
if (cabeza == cola) return -1; // vacío
uint8_t dato = buf[cola];
cola = (cola + 1) & MASCARA;
return dato;
}
Por qué es seguro sin cli(): cada índice lo modifica un solo lado y es de un byte (atómico). La ISR escribe el dato antes de avanzar cabeza, de modo que el programa nunca ve un índice que apunte a un dato incompleto. Es una estructura sin bloqueo para un productor y un consumidor.
Sus detalles: el tamaño potencia de 2 permite usar & MASCARA en lugar de una división; se sacrifica una posición para distinguir lleno (cabeza + 1 == cola) de vacío (cabeza == cola), de modo que un buffer de 16 guarda 15 bytes; y hay que decidir qué hacer si se llena (descartar el nuevo dato o el más viejo, y contar los descartes para diagnosticarlo). El tamaño se calcula con el presupuesto: la ráfaga más larga que pueda llegar mientras el programa principal está ocupado.
09 · Analogía con programación
Medir los errores en una simulación
Las condiciones de carrera son difíciles de ver porque ocurren rara vez. Una simulación permite cuantificarlas. Este programa hace dos pruebas: una ISR incrementa un contador de 16 bits cada pocos instantes mientras el programa lo lee con dos accesos de 8 bits, y después una ISR suma 1 mientras el programa resta 1 a la misma variable.
Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.
import random
random.seed(11)
def simular_lecturas(metodo, lecturas=40000, periodo_isr=3):
"""Una ISR incrementa un contador de 16 bits cada `periodo_isr` unidades de tiempo.
El programa principal lo lee con dos accesos de 8 bits (bajo, alto) separados 1 unidad."""
contador, proxima_isr, t, errores = 0, periodo_isr, 0, 0
def avanzar(hasta): # ejecuta las ISR que caen hasta el instante `hasta`
nonlocal contador, proxima_isr
while proxima_isr <= hasta:
contador = (contador + 1) & 0xFFFF
proxima_isr += periodo_isr
for _ in range(lecturas):
t += random.randint(4, 9) # trabajo del programa principal
avanzar(t)
if metodo == "atómica": # cli(); copiar; sei(): la ISR espera
leido = contador
t += 2
avanzar(t)
continue
validos = set()
for _intento in range(2 if metodo == "dos iguales" else 1):
bajo, antes = contador & 0xFF, contador
t += 1
avanzar(t) # la ISR puede caer entre las dos lecturas
alto = contador >> 8
validos |= {antes, contador}
leido = (alto << 8) | bajo
if metodo == "dos iguales" and _intento == 0:
primero = leido
elif metodo == "dos iguales" and primero != leido:
leido = None # discrepan: se vuelve a leer (no se cuenta como error)
if leido is not None and leido not in validos:
errores += 1
return errores
for metodo in ("sin protección", "atómica", "dos iguales"):
errores = simular_lecturas(metodo)
print(f"{metodo:<15} lecturas partidas: {errores:>4} de 40000")
def perdidas(protegido, vueltas=100_000, q=0.1):
"""El programa principal hace `cuenta -= 1` (cargar, restar, guardar); la ISR hace `cuenta += 1`.
Devuelve cuántos incrementos de la ISR se perdieron."""
var, esperado = 0, 0
for _ in range(vueltas):
if random.random() < q: # la ISR puede caer antes de empezar
var += 1; esperado += 1
if protegido:
var -= 1; esperado -= 1
continue
copia = var # cargar
if random.random() < q: # la ISR cae entre cargar y guardar
var += 1; esperado += 1
copia -= 1 # restar
if random.random() < q:
var += 1; esperado += 1
var = copia # guardar: pisa lo que hizo la ISR
esperado -= 1
return esperado - var
print()
print(f"cuenta -= 1 sin protección : se perdieron {perdidas(False)} incrementos de la ISR")
print(f"cuenta -= 1 con cli/sei : se perdieron {perdidas(True)} incrementos de la ISR")
Resultado: sin protección, 52 de 40 000 lecturas salen partidas (una de cada 770, como predice la cuenta 1/3 × 1/256). La lectura atómica y la de «dos iguales» dan cero. En la prueba de la actualización perdida, con una ISR muy frecuente, sin protección se pierden unos 20 000 incrementos de los 30 000 que ocurrieron; con la sección crítica, ninguno. Probá cambiar periodo_isr a 1 o a 7 y observá cómo cambia la frecuencia del error.
El modelo usa una ISR muy frecuente para que los errores aparezcan rápido. En un sistema real son más raros, y por eso mismo más peligrosos: un error que ocurre una vez cada diez horas pasa todas las pruebas de laboratorio.
10 · Exploración interactiva
Lectura partida y buffer
La primera herramienta te deja ejecutar paso a paso la lectura de un contador de 16 bits y disparar la interrupción en el momento exacto. La segunda es un buffer circular de 8 posiciones. La tercera calcula el costo de una sección crítica.
La herramienta 1 usa un contador de 16 bits y un solo incremento de la ISR, como el ejemplo de la sección 2. La 3 supone 3 ciclos de sobrecosto (guardar SREG, cli y restaurar), una estimación a partir de la tabla de instrucciones del tema 7.
Recorrido guiado
- Con el valor inicial
0x00FFy sin sección crítica: «Leer el byte bajo», luego «Interrupción» y «Leer el byte alto». El programa arma0x01FF, un valor que nunca existió. - Repetí con la sección crítica activada: la interrupción queda pendiente, el programa lee
0x00FFy recién después se atiende la ISR. - Probá con
0x0123: la misma secuencia no produce error, porque el byte bajo no da la vuelta. Por eso el error es tan raro. - Probá
0xFFFF: el contador pasa a0x0000y el programa lee0x00FF. - En el buffer, apretá «ISR: llega un byte» nueve veces: se guardan 7 (se sacrifica una posición) y se pierden 2. Leé dos bytes y observá cómo se mueven
colay la cuenta. Después añadí otros:cabezada la vuelta al principio.
Desafío: encontrar el error más barato
En la herramienta 3, con 1000 entradas por segundo a una sección de 6 ciclos, el chip pasa 0,056 % del tiempo con interrupciones deshabilitadas. Subí el cuerpo a 100 ciclos: ahora son 0,64 % del tiempo y la latencia se acerca a 7 µs. Comparalo con el costo de perder un byte de la UART. La regla práctica: copiar el dato dentro de la sección y procesarlo afuera.
11 · Método de trabajo
Reglas de diseño
- Hacé una lista de lo compartido: cada variable que toquen la ISR y el programa principal, con su tamaño y quién la lee y la escribe.
- Minimizalo. Cuanto menos se comparta, menos hay que proteger. Preferí datos de un byte y un solo escritor.
- Todo lo compartido es
volatile. - Para cada variable, preguntá: ¿cuántas instrucciones la leen o la escriben? Si son más de una, protegé el acceso.
- Protegé con
ATOMIC_BLOCK(ATOMIC_RESTORESTATE), con secciones breves, copiando y saliendo. - Para flujos de datos, usá un buffer circular con un productor y un consumidor.
- Probá bajo estrés: una frecuencia de interrupciones alta, un contador cerca de la vuelta y, si podés, una verificación que compruebe que el valor leído sea siempre uno que existió.
Cuando el error es intermitente y no se reproduce, sospechá de una condición de carrera: leé el código preguntando «¿qué pasa si la ISR cae justo aquí?» en cada línea que toque datos compartidos. El osciloscopio y el depurador ayudan poco, porque detener la ejecución cambia los tiempos que provocan el error.
12 · Comprensión
Confusiones frecuentes
«Con volatile ya está protegida la variable»
volatile asegura que cada acceso vaya a memoria, pero no lo hace indivisible. Una variable volatile de 16 bits se sigue leyendo con dos instrucciones.
«x++ es una sola operación»
En C lo parece; en la CPU son tres instrucciones (cargar, incrementar, guardar). Si una ISR modifica la misma variable en medio, se pierde una actualización.
«Si la ISR es corta, no puede corromper nada»
Lo que importa no es la duración de la ISR, sino que pueda caer entre dos instrucciones del programa que manejan el mismo dato.
«Basta con deshabilitar la interrupción un rato largo para estar seguro»
Una sección crítica larga hace que se pierdan eventos y aumenta la latencia. Debe ser lo más corta posible: copiar el dato y salir.
«sei() al final de la sección es lo mismo que restaurar el estado»
Si las interrupciones ya estaban deshabilitadas (código llamado desde una ISR o desde otra sección crítica), sei() las habilita antes de tiempo. Hay que restaurar el estado guardado, como hace ATOMIC_RESTORESTATE.
13 · Práctica guiada
Predecí antes de comprobar
1. Armar una lectura partida
El contador vale 0x01FF. El programa lee el byte bajo y entonces cae la ISR, que incrementa. ¿Qué valor arma el programa al leer el byte alto?
Ver solución
Byte bajo leído: 0xFF. Tras la ISR el contador es 0x0200 y el byte alto leído es 0x02. El programa arma 0x02FF; los valores reales eran 0x01FF y 0x0200.
2. Contar instrucciones
¿Cuántas instrucciones de carga hay en la lectura de un uint32_t y en cuántos puntos puede partirse?
Ver solución
Cuatro instrucciones LDS, una por byte. Hay tres puntos intermedios (entre la primera y la segunda, la segunda y la tercera, y la tercera y la cuarta) donde una ISR puede colarse.
3. El costo de una sección crítica
Una sección crítica copia un uint16_t (4 ciclos) con 3 de sobrecosto, en un reloj de 16 MHz. ¿Cuánta latencia agrega?
Ver solución
3 + 4 = 7 ciclos = 7 × 62,5 ns ≈ 0,44 µs de latencia máxima adicional. Es despreciable frente a un byte de UART a 115 200 baudios (86,8 µs).
4. Capacidad de un anillo
Un buffer circular de 16 posiciones recibe una ráfaga de 20 bytes sin que el programa lea ninguno. ¿Cuántos bytes se guardan y cuántos se pierden?
Ver solución
Se sacrifica una posición para distinguir lleno de vacío, así que se guardan 15 bytes y se pierden 5.
5. Banderas en un mismo byte
La ISR hace banderas |= 0x01 y el programa hace banderas &= ~0x02. ¿Cómo se pierde la bandera 0x01 y cómo se evita?
Ver solución razonada
El programa carga banderas, la ISR cae y pone el bit 0, y el programa guarda su copia vieja con el bit 1 borrado: el bit 0 se pierde. Se evita usando un byte distinto para cada bandera, protegiendo el acceso con ATOMIC_BLOCK o usando GPIOR0 con SBI/CBI, que son atómicas.
14 · Síntesis
Ideas para recordar
- La ISR puede interrumpir al programa principal entre dos instrucciones cualesquiera; el programa no interrumpe a la ISR.
- Solo es atómico lo que se resuelve en una instrucción: los accesos de un byte y las operaciones de un bit sobre los primeros registros de E/S.
- Los accesos de 16 o 32 bits pueden partirse (lectura partida); las lecturas-modificaciones-escrituras como
x++pueden perder una actualización. volatilees necesario pero no suficiente: no vuelve atómico ningún acceso.- Una sección crítica (
ATOMIC_BLOCK(ATOMIC_RESTORESTATE)) copia el dato con las interrupciones deshabilitadas y sale lo antes posible. - Alternativas: datos de un byte con un solo escritor, un byte por bandera, lectura doble hasta que coincida y
GPIOR0conSBI/CBI. - Un buffer circular con un productor y un consumidor es seguro sin deshabilitar interrupciones.
¿Listo para el tema 22?
Antes de avanzar, explicá una lectura partida con un ejemplo numérico, decí por qué volatile no alcanza y escribí una sección crítica para leer un uint16_t. Si podés justificar las tres respuestas, sabés compartir datos con una interrupción sin corromperlos.
En el próximo tema, un taller de contador de eventos por interrupción: aplicamos todo el bloque a un caso real, contar pulsos de un sensor sin perder ninguno.