01 · Punto de partida
Un programa que no termina
Hasta ahora aprendimos las piezas por separado: pines, interrupciones, temporizadores, el ADC, la UART, los sensores, los actuadores y el sueño. Un producto real las usa todas a la vez: lee un sensor cada segundo, atiende un pulsador, recibe comandos por la UART, mueve un motor y refresca una pantalla, sin que una cosa frene a las otras. Eso exige una estructura: un esqueleto que decida dónde vive cada cosa, quién espera a quién y qué ocurre cuando dos cosas pasan a la vez.
Dos diferencias con un programa de escritorio condicionan todo el diseño. La primera: no hay sistema operativo que reparta el tiempo, de modo que si una parte del programa se queda esperando, todo se queda esperando. La segunda: los recursos son mínimos (2048 bytes de SRAM en el ATmega328P) y no hay nadie que avise cuando se acaban: la pila pisa los datos y el programa sigue, sin errores visibles, haciendo cosas imprevistas.
El esquema que usa casi todo el mundo se llama bucle principal con interrupciones (en inglés foreground / background o super loop): el «primer plano» es un for(;;) que recorre tareas cortas; el «segundo plano» son las rutinas de interrupción, que atienden el hardware al instante y le dejan el trabajo pesado al primer plano. El tema 42 lo lleva un paso más allá con máquinas de estados y tareas cooperativas; aquí construimos la base.
02 · Antes de la primera línea
Del reset a main()
Cuando el microcontrolador sale del reset (tema 13), el contador de programa vale cero y ejecuta lo que haya en la dirección 0x0000: el vector de reinicio, el primero de la tabla de vectores de interrupción. De ahí salta al código de arranque (startup code), que el compilador añade sin que lo escribas. Antes de llamar a main(), ese código:
- pone en cero el registro
r1(por convención del compilador) y limpiaSREG; - fija el puntero de pila (
SP) al final de la SRAM, el valorRAMEND; - copia desde la Flash a la SRAM los valores iniciales de las variables globales inicializadas (sección
.data); - pone en cero las variables globales sin inicializar (sección
.bss); - ejecuta
main().
Eso explica un hecho que sorprende a los programadores de escritorio: una variable global sin valor inicial vale cero, mientras una variable local sin valor inicial contiene basura. Y explica también por qué un programa con muchas variables inicializadas tarda más en arrancar y ocupa Flash dos veces: una para guardar el valor inicial y otra, en la SRAM, para trabajar.
| Qué | Dónde | Cuándo se prepara |
|---|---|---|
Código (.text) y constantes con PROGMEM | Flash, 32 KB | Al cargar el programa; no ocupa SRAM |
Globales inicializadas (.data) | SRAM, con el valor inicial guardado también en Flash | Código de arranque: copia de Flash a SRAM |
Globales sin inicializar y static a cero (.bss) | SRAM | Código de arranque: puesta a cero |
Montículo (malloc) | SRAM, crece hacia arriba desde el final de .bss | En ejecución, bajo demanda |
| Pila: locales, argumentos, direcciones de retorno, contexto de las interrupciones | SRAM, crece hacia abajo desde RAMEND | En ejecución, con cada llamada |
| Registros de periféricos | Espacio de E/S (tema 9) | El programa los configura en la inicialización |
#include <stdint.h>
#include <avr/pgmspace.h>
uint8_t contador = 5; // .data: el 5 se guarda en Flash y se copia a la SRAM al arrancar
uint8_t lecturas[32]; // .bss : arranca en cero, sin ocupar Flash
static uint8_t cache; // .bss : lo mismo, visible solo en este archivo
const char texto[] = "Hola"; // OJO en AVR: va a .data, o sea, ¡5 bytes de SRAM y 5 de Flash!
const char ayuda[] PROGMEM = "Hola"; // solo en Flash; se lee con strcpy_P / pgm_read_byte
int main(void) {
uint8_t local; // en un registro o en la pila; sin valor definido hasta asignarlo
local = contador + cache;
/* ... */
for (;;) { } // main() de un microcontrolador no retorna
}
La última línea importa: main() nunca debe retornar. Si lo hiciera, el código de arranque entra en un bucle infinito (o reinicia, según la herramienta) y el dispositivo queda inerte sin que lo notes. Por eso todo programa termina en un for (;;).
En Arduino la estructura es la misma, con otros nombres: el main() del núcleo llama a init() (que configura el Timer0 para millis(), el ADC y los demás), después a setup() (tu inicialización) y por último llama a loop() dentro de un for (;;). Entender eso es entender que loop() es el bucle principal y que cualquier delay() dentro de él frena todo el resto.
03 · Preparar el terreno
La inicialización
La inicialización se ejecuta una vez y su orden importa. Una regla práctica que evita muchos sustos: de afuera hacia adentro y con las interrupciones al final. Primero el estado seguro del mundo exterior, después los periféricos internos y, por último, se habilitan las interrupciones, cuando todo lo que podrían tocar ya está listo.
- Causa del reset y watchdog. Se lee
MCUSR(¿alimentación, reset externo, brown-out o watchdog?), se guarda y se borra. Un reset por watchdog deja al watchdog activo con el plazo mínimo, y si no se lo desactiva enseguida, el chip se reinicia en bucle (tema 13). - Pines en estado seguro. Antes que nada, las salidas que mueven potencia (motor, relé, calefactor) deben quedar apagadas y las entradas, con su pull-up si hace falta (temas 15 y 38). Durante unos milisegundos tras el reset los pines son entradas flotantes, y un relé mal conectado puede dispararse.
- Reloj y alimentación. Prescaler, fuente del reloj, apagado de lo que no se usa (tema 12, tema 40).
- Periféricos uno por uno: temporizadores, ADC, UART, I2C, SPI, cada uno con sus banderas borradas.
- Dispositivos externos (sensores, pantallas), que suelen necesitar un tiempo de arranque y una secuencia propia.
- Variables de estado con sus valores iniciales de aplicación.
- Habilitar interrupciones (
sei()) y, si corresponde, el watchdog como supervisor.
#include <avr/io.h>
#include <avr/interrupt.h>
#include <avr/wdt.h>
static uint8_t causa_reset;
int main(void) {
causa_reset = MCUSR; // 1) guardar la causa antes de borrarla
MCUSR = 0;
wdt_disable(); // un reset previo por watchdog lo deja activo
pines_init(); // 2) actuadores apagados, entradas con pull-up
reloj_init(); // 3) prescaler, ahorro de energía
tick_init(); // 4) Timer0 a 1 kHz (sección 6)
uart_init(115200); // ADC, I2C...
sensor_init(); // 5) dispositivos externos
app_init(); // 6) estado inicial de la aplicación
sei(); // 7) ahora sí: las interrupciones pueden usar todo lo anterior
wdt_enable(WDTO_1S); // supervisión del bucle (sección 9)
for (;;) {
/* bucle principal */
}
}
Dos errores clásicos de esta etapa: habilitar las interrupciones demasiado pronto, con lo que una rutina usa un periférico que todavía no se configuró (o una variable que todavía no tiene valor); y borrar la causa del reset sin guardarla, con lo que después no hay forma de saber por qué se reinició el dispositivo. En un producto conviene registrar esa causa (en la EEPROM o por la UART) como primera pista ante un fallo de campo.
04 · El primer plano
El bucle principal
El bucle principal repite siempre el mismo ciclo: observar, decidir, actuar. La condición de que funcione bien es una sola y se puede enunciar como regla: cada pasada tiene que ser corta, y nada dentro del bucle puede esperar. Si una tarea necesita esperar algo (un sensor que convierte, un botón, un tiempo), anota que está esperando y deja pasar a las demás.
for (;;) {
entradas_leer(); // pulsadores con antirrebote (no bloqueante)
if (uart_hay_dato()) comando_procesar();
if (tick_cada(1000, &t_medida)) sensor_medir(); // cada 1000 ms, sin delay (sección 6)
salidas_actualizar(); // PWM, relés, pantalla (un trozo por vuelta)
wdt_reset(); // «sigo vivo»
}
Compará con la versión ingenua que se escribe al empezar, con esperas:
for (;;) { // MAL: bloqueante
if (boton_apretado()) { led_on(); _delay_ms(500); led_off(); } // 500 ms sordo a todo
sensor_medir(); _delay_ms(1000); // 1 s sordo a todo
pantalla_actualizar_completa(); // 50 ms de bus I2C
}
En la segunda versión el botón tarda más de un segundo en notarse y la UART puede perder bytes. El problema no es que el código esté mal: el programa trata el tiempo como una espera cuando hay que tratarlo como un dato. La salida (sección 6) es reemplazar cada espera por una pregunta rápida: «¿ya pasó el tiempo?».
El tiempo de respuesta de este esquema se puede estimar. Si las tareas del bucle tardan t₁ … tₙ y las interrupciones se llevan una fracción c de la CPU:
Un evento que no tiene interrupción (se detecta mirando una entrada en cada vuelta) se atiende, en el peor caso, una vuelta completa después de producirse: llegó justo después de que el bucle lo miró. Por eso la tarea más lenta fija el plazo de todas. La primera herramienta del laboratorio calcula esa vuelta y la compara con un plazo.
Cuando el bucle no tiene nada que hacer, el microcontrolador no debería estar dando vueltas en vacío: puede dormir en Idle hasta la próxima interrupción (tema 40). Esa es la forma de integrar la estructura con el ahorro de energía: sleep_cpu() al final de la vuelta, y cualquier interrupción (el tick de 1 ms, por ejemplo) lo despierta.
05 · El segundo plano
Interrupciones: avisar y seguir
Una rutina de interrupción (ISR) corta al bucle principal entre dos instrucciones cualesquiera, ejecuta su código y devuelve el control sin que el bucle lo note. Solo tiene sentido si es breve: mientras se ejecuta, otras interrupciones esperan y el bucle principal está detenido. Entrar y salir de una ISR en un AVR cuesta del orden de decenas de ciclos (guardar y restaurar registros y SREG, más los cuatro ciclos de la entrada y los cuatro del RETI), de modo que una rutina de pocas instrucciones pasa a ser, sobre todo, costo de entrada y salida.
El patrón de trabajo tiene tres formas, de la más simple a la más general:
- Bandera. La ISR marca que ocurrió algo; el bucle lo atiende.
- Dato más bandera. La ISR captura un valor (un contador, un byte, una lectura) que el bucle consume.
- Cola o búfer circular. Si llegan datos más rápido de lo que el bucle los consume, la ISR los apila.
#include <util/atomic.h>
static volatile uint8_t flag_boton; // volatile: la modifica una ISR (tema 21)
ISR(PCINT0_vect) { flag_boton = 1; } // la ISR solo avisa
/* en el bucle principal */
uint8_t hubo;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { // leer y borrar como una sola operación
hubo = flag_boton;
flag_boton = 0;
}
if (hubo) boton_atender(); // el trabajo real, fuera de la ISR
El bloque atómico no es un adorno. Sin él, un evento que llegue entre «leer la bandera» y «borrarla» se pierde. En un AVR de 8 bits, leer o escribir un byte es una operación indivisible, pero una variable de 16 o 32 bits no: la ISR puede cambiarla entre dos mitades (tema 21).
Para flujos de datos, como los bytes que llegan por la UART, la estructura estándar es el búfer circular (ring buffer): un arreglo con dos índices, uno de escritura que mueve la ISR (la «cabeza») y otro de lectura que mueve el bucle (la «cola»).
#define RX_TAM 64 // potencia de 2: el módulo se hace con una máscara
#define RX_MASK (RX_TAM - 1)
static volatile uint8_t rx_buf[RX_TAM];
static volatile uint8_t rx_cabeza; // la escribe solo la ISR
static volatile uint8_t rx_cola; // la escribe solo el bucle principal
static volatile uint8_t rx_perdidos;
ISR(USART_RX_vect) {
uint8_t dato = UDR0; // leer UDR0 borra la bandera de recepción
uint8_t sig = (rx_cabeza + 1) & RX_MASK;
if (sig != rx_cola) { rx_buf[rx_cabeza] = dato; rx_cabeza = sig; }
else { rx_perdidos++; } // lleno: contar y descartar, nunca bloquear
}
int16_t uart_leer(void) { // -1 si no hay datos; no espera nunca
if (rx_cabeza == rx_cola) return -1;
uint8_t dato = rx_buf[rx_cola];
rx_cola = (rx_cola + 1) & RX_MASK;
return dato;
}
Este diseño funciona sin bloqueos porque cada índice lo modifica un solo lado (productor y consumidor únicos) y los índices son de un byte. El búfer está vacío cuando cabeza y cola coinciden, y lleno cuando avanzar la cabeza la haría coincidir con la cola: por eso un búfer de N posiciones guarda N − 1 datos. La segunda herramienta del laboratorio calcula qué tamaño hace falta según la velocidad y el tiempo máximo que el bucle pasa sin atender la UART.
Una ISR bien escrita cumple cinco reglas: es corta; no espera (nada de delay, de bucles de espera ni de transmisión bloqueante); no usa printf ni asigna memoria; protege los datos que comparte con el bucle (volatile y secciones críticas); y borra o atiende la causa de la interrupción (si la bandera sigue activa, vuelve a dispararse).
06 · Esperar sin esperar
El tiempo sin bloquear
La herramienta que reemplaza a delay() es una base de tiempo: un temporizador que interrumpe a intervalos regulares (típicamente 1 ms) y cuenta en una variable. Cualquier tarea mira esa variable, calcula cuánto pasó desde la última vez y decide. Con el Timer0 en modo CTC (temas 23 y 24), a 16 MHz: el prescaler 64 da 250 kHz, y contar hasta 250 da exactamente 1 kHz.
static volatile uint32_t ms; // milisegundos desde el arranque
void tick_init(void) {
TCCR0A = (1 << WGM01); // CTC: cuenta hasta OCR0A y vuelve a cero
TCCR0B = (1 << CS01) | (1 << CS00); // prescaler 64: 16 MHz / 64 = 250 kHz
OCR0A = 249; // 250 cuentas = 1 ms
TIMSK0 = (1 << OCIE0A);
}
ISR(TIMER0_COMPA_vect) { ms++; }
uint32_t tick_ms(void) { // lectura atómica de 32 bits
uint32_t t;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { t = ms; }
return t;
}
/* uso: ejecutar algo cada 500 ms sin esperar */
static uint32_t t_led;
if (tick_ms() - t_led >= 500) {
t_led += 500; // y no t_led = tick_ms(): así no se acumula deriva
led_conmutar();
}
Dos detalles distinguen al código correcto del que falla a las semanas:
- La resta, no la comparación. El contador de 32 bits en milisegundos se desborda a los 49,7 días; uno de 16 bits, a los 65,5 s. La expresión
ahora − antes ≥ plazo, con enteros sin signo, da el resultado correcto aunque el contador haya dado la vuelta, porque la resta módulo 2N mide la diferencia real. La comparaciónahora ≥ antes + plazofalla justo en el desborde. El bloque de Python lo demuestra. - Sumar el plazo, no copiar la hora. Con
t_led += 500la tarea mantiene su ritmo aunque una vuelta se atrase; cont_led = tick_ms()cada atraso se acumula.
Es el germen del planificador cooperativo del tema 42: una lista de tareas, cada una con su plazo y su última ejecución, y un bucle que ejecuta las que vencieron. Dos advertencias de integración: Arduino usa el mismo Timer0 para millis() (no lo reconfigures sin saber qué rompés) y el Timer0 se detiene en los modos de sueño profundos, de modo que el tiempo no avanza mientras el chip duerme (tema 40).
07 · Dividir para no perderse
Módulos y capas
Un programa de 300 líneas cabe en un archivo; uno de 3000, no. Los microcontroladores se programan en C, que no tiene clases, pero sí un mecanismo de módulo muy eficaz: el par de archivos .h (la interfaz: qué ofrece) y .c (la implementación: cómo lo hace). Las variables y funciones internas se declaran static y quedan invisibles desde afuera.
/* config.h — lo que cambia de una placa a otra */
#define F_CPU 16000000UL
#define LED_DDR DDRB
#define LED_PORT PORTB
#define LED_BIT PB5
/* led.h — interfaz */
#ifndef LED_H
#define LED_H
#include <stdint.h>
void led_init(void);
void led_set(uint8_t encendido);
void led_conmutar(void);
uint8_t led_estado(void);
#endif
/* led.c — implementación */
#include <avr/io.h>
#include "config.h"
#include "led.h"
static uint8_t estado; // privado: nadie más lo toca
void led_init(void) { LED_DDR |= (1 << LED_BIT); led_set(0); }
void led_set(uint8_t on) {
estado = on;
if (on) LED_PORT |= (1 << LED_BIT);
else LED_PORT &= ~(1 << LED_BIT);
}
void led_conmutar(void) { led_set(!estado); }
uint8_t led_estado(void) { return estado; }
Un buen módulo tiene un solo tema (una UART, un sensor, un controlador), una función xxx_init() que lo prepara, una función de actualización cuando necesita trabajo periódico (xxx_update(), que se llama desde el bucle y no bloquea), y no muestra sus variables. Si cambia el pin del LED, se cambia config.h; si cambia el chip, se reescribe la capa de abajo y la aplicación no se entera.
| Capa | Responsabilidad | Ejemplo |
|---|---|---|
| Aplicación | La lógica del producto: qué hacer y cuándo | main.c, termostato.c |
| Servicios | Utilidades reutilizables sin hardware propio | Base de tiempo, colas, filtros, protocolo de comandos |
| Controladores (drivers) | Un dispositivo, con su interfaz sencilla | led, boton, sensor_temp, motor |
| Acceso al hardware (HAL) | Los registros del chip: GPIO, UART, ADC, temporizador | gpio.c, uart.c, adc.c |
La regla de dependencias es de una sola dirección: cada capa solo usa a la de abajo. La aplicación pide «encender el calefactor» y no sabe en qué pin está; el controlador del calefactor pide «poner el pin en alto» a la capa de GPIO. Con esa regla, un cambio de placa afecta a un archivo, y los módulos se pueden probar uno por uno (por ejemplo, ejecutar la lógica de la aplicación en la computadora con funciones falsas en lugar del hardware). Las rutinas de interrupción pertenecen al módulo dueño del periférico: la ISR de la UART vive en uart.c y expone uart_leer(); el resto del programa no sabe que existe una interrupción.
Un límite razonable: en un chip con 2 KB de SRAM, no hay que pagar por abstracciones que no se usan. Dos capas bien delimitadas valen más que cinco llenas de punteros a función. Las funciones static inline y las macros pequeñas dan interfaces limpias sin costo de ejecución.
08 · El recurso más escaso
Memoria y pila
Con 2048 bytes de SRAM, la memoria se administra en bytes. Lo que informa el compilador al terminar es el tamaño de las secciones (avr-size o la salida del IDE): text (código y constantes en Flash), data y bss (variables globales). Pero esa cuenta es incompleta: el compilador no puede decir cuánta pila usa el programa, porque depende de la profundidad real de las llamadas y de las interrupciones que lleguen en el peor momento.
La SRAM se reparte así: abajo, .data y .bss (fijos); sobre ellos, el montículo, que crece hacia arriba; arriba de todo, la pila, que crece hacia abajo desde RAMEND. Cuando se tocan, pasan cosas silenciosas: variables que cambian solas, direcciones de retorno corruptas, saltos a cualquier lado.
Reglas de ahorro que dan buen resultado:
- Evitar
mallocynew. Con memoria fija y pequeña, la fragmentación del montículo hace que un programa funcione horas y falle a los días. Se reservan los búferes de forma estática, con tamaños conocidos. La claseStringde Arduino, que usa el montículo, es la causa más común de ese fallo. - Las constantes grandes a la Flash. Tablas, textos y mensajes con
PROGMEM(macroF("...")en Arduino): un texto de 100 bytes no necesita 100 bytes de SRAM. - Nada de recursión y poca profundidad de llamadas. Cada llamada gasta, al menos, dos bytes de retorno más los registros guardados.
- Variables locales pequeñas. Un arreglo local de 200 bytes en una función que se llama desde una ISR puede consumir diez por ciento de la memoria en el peor momento.
- Tipos del tamaño justo (
uint8_ten lugar deintcuando el valor entra en un byte): ahorran RAM y ciclos en un procesador de 8 bits.
extern int __heap_start, *__brkval;
int ram_libre(void) { // distancia entre la pila y el final del montículo
int v;
return (int)&v - (__brkval == 0 ? (int)&__heap_start : (int)__brkval);
}
La función de arriba mide la distancia entre el tope actual de la pila y el fin del montículo. Es un dato de ese instante: la pila alcanza su mayor profundidad en un momento puntual. Para el peor caso se usa la técnica de pintar la pila: al arrancar, se llena toda la SRAM libre con un patrón (por ejemplo 0xAA); después de un tiempo de funcionamiento, se busca desde abajo el primer byte que cambió. La distancia hasta RAMEND es la pila máxima usada. La tercera herramienta del laboratorio convierte esas cifras en un balance de Flash y SRAM.
09 · Para que corra años
Robustez: que no se cuelgue
Un programa de escritorio que falla muestra un mensaje; uno de microcontrolador en un invernadero a 200 km, no. El diseño tiene que asumir que alguna vez algo saldrá mal (una interferencia, una caída de tensión, un sensor desconectado) y que nadie va a estar para reiniciar. Cuatro herramientas:
- Plazos en toda espera. Un bucle que espera un bit de un dispositivo externo (
while (!(UCSR0A & (1 << UDRE0)));) se cuelga para siempre si el bit nunca llega. Todo bucle de espera necesita una salida por tiempo. - El watchdog como supervisor (tema 13): se habilita con un plazo (1 s, por ejemplo) y el bucle principal lo «acaricia» con
wdt_reset()una vez por vuelta completa, solo si todo funcionó. Si el bucle se cuelga, nadie acaricia, y el chip se reinicia solo. - Verificar lo imposible. Comprobar rangos de las lecturas (un sensor de temperatura que da −300 °C está desconectado), contar errores y reaccionar: valores por defecto seguros, apagar los actuadores, avisar.
- Un estado seguro conocido. Qué hace cada salida si algo falla: el motor se detiene, el calefactor se apaga, la válvula queda cerrada. Ese estado debe ser también el del reset (sección 3).
// Espera con límite: devuelve 0 si se cumplió el plazo
static uint8_t esperar_bit(volatile uint8_t *reg, uint8_t mascara, uint16_t limite_ms) {
uint32_t t0 = tick_ms();
while (!(*reg & mascara)) {
if (tick_ms() - t0 >= limite_ms) return 0; // salir con error en lugar de colgarse
}
return 1;
}
// En el bucle principal: el perro solo se acaricia si todo respondió
for (;;) {
uint8_t ok = 1;
ok &= sensor_actualizar();
ok &= comunicacion_actualizar();
salidas_actualizar();
if (ok) wdt_reset(); // si algo falla repetidamente, el reinicio lo repara
}
Una advertencia sobre el watchdog: acariciarlo desde una ISR (por ejemplo, la del tick) lo vuelve inútil, porque la interrupción sigue funcionando aunque el bucle principal esté colgado. El acariciado va en el bucle, al final, y condicionado a que las tareas hayan hecho su parte.
10 · Lo que suele salir mal
Trampas y buenas prácticas
| Síntoma | Causa probable |
|---|---|
| El botón responde «a veces» o con retraso | El bucle tiene tareas lentas o delay(): la vuelta dura más que la pulsación (sección 4) |
| Se pierden bytes de la UART | Búfer chico o ausente; el bucle pasa demasiado tiempo sin atender; la ISR tarda más que un byte (sección 5) |
| Una variable cambia «sola» o se lee mal | Falta volatile, o un dato de 16/32 bits se lee sin sección crítica (tema 21) |
| Funciona minutos u horas y después se comporta raro | Pila y montículo chocan; fragmentación por malloc/String; desbordamiento de un búfer (sección 8) |
| Se reinicia en bucle tras un reset por watchdog | No se desactivó el watchdog al arrancar (sección 3) |
| Un temporizador deja de funcionar a los 49 días | Comparación ahora ≥ antes + plazo en lugar de la resta sin signo (sección 6) |
Se cuelga al arrancar, antes de main o justo después | Interrupción habilitada antes de tiempo, con una ISR que usa algo sin inicializar; globales demasiado grandes que dejan sin SRAM para la pila |
| La ISR «no hace nada» | Falta sei(), la máscara de la fuente o el vector está mal escrito (sin ISR definida, el compilador pone un reinicio como acción por defecto) |
El programa funciona con las optimizaciones apagadas y falla con -Os | Variable compartida sin volatile: el compilador la deja en un registro y no ve el cambio de la ISR |
Buenas prácticas, en orden de rendimiento: 1) prohibir las esperas activas en el bucle principal; 2) ISR mínimas que dejan banderas o datos; 3) un módulo por dispositivo, con init y update; 4) memoria estática y medida; 5) watchdog acariciado desde el bucle; 6) una salida de depuración por la UART que informe la vuelta máxima, la RAM libre y la causa del último reinicio.
11 · Analogía con programación
Calcular el tiempo y la memoria con datos
Si venís de la programación de aplicaciones, esta estructura te resulta más familiar de lo que parece. El bucle principal es el bucle de eventos de Node.js o de una interfaz gráfica: una sola línea de ejecución, tareas cortas y la regla de oro de «no bloquear el bucle». Las interrupciones son callbacks que dispara el hardware. Los módulos son paquetes con API pública y detalles privados. La pila es la misma pila de llamadas que conocés; solo que aquí es de bytes y se puede acabar.
Este programa calcula la vuelta del bucle para cuatro configuraciones, el tamaño de búfer de recepción para cuatro velocidades de la UART, demuestra el desborde de un contador de tiempo y arma el balance de memoria de tres programas.
Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.
import math
F_CPU = 16_000_000
# 1) presupuesto de tiempo del bucle principal
def vuelta(tareas_us, tick_hz, isr_us):
carga = tick_hz * isr_us / 1e6 # fraccion de la CPU que se llevan las interrupciones
return sum(tareas_us) / (1 - carga), carga # us por vuelta: las ISR le roban tiempo al bucle
base = {"pulsadores": 40, "sensor": 300, "pantalla": 4000, "serie": 900} # us por vuelta
casos = (
("pantalla por partes (4 ms)", base, 1000, 6),
("pantalla con delay de 50 ms", dict(base, pantalla=50000), 1000, 6),
("igual, con un tick de 20 kHz", dict(base, pantalla=50000), 20000, 6),
("pantalla por partes, ISR de 150 us a 5 kHz", base, 5000, 150),
)
plazo_ms = 20
print("caso ISR vuelta plazo de 20 ms")
for nombre, tareas, hz, isr in casos:
v, carga = vuelta(tareas.values(), hz, isr)
veredicto = "cumple" if v / 1000 <= plazo_ms else "NO cumple"
print(f"{nombre:44s} {carga * 100:5.1f} % {v / 1000:7.2f} ms {veredicto}")
# 2) cuanto tiene que aguantar un bufer de recepcion (UART 8N1: 10 bits por byte)
def tam_buffer(n):
return 1 << math.ceil(math.log2(n + 1)) # potencia de 2; el circular guarda tam - 1
print()
print("baudios us/byte ISR(60 ciclos) bytes en 5 ms en 50 ms tamano para 50 ms")
for baud in (9600, 57600, 115200, 250000):
t_byte = 10 / baud
carga = 60 / F_CPU / t_byte
n5, n50 = math.ceil(0.005 / t_byte), math.ceil(0.050 / t_byte)
tam = tam_buffer(n50)
print(f"{baud:7d} {t_byte * 1e6:9.1f} {carga * 100:11.2f} % {n5:13d} {n50:10d} {tam:11d} ({tam / 2048 * 100:.0f} % de la SRAM)")
# 3) desborde del contador de tiempo
print()
print(f"contador de 32 bits en ms: {2 ** 32 / 1000 / 86400:.1f} dias; de 16 bits: {2 ** 16 / 1000:.1f} s")
antes = 65000
ahora = (antes + 700) & 0xFFFF # pasaron 700 ms y el contador de 16 bits dio la vuelta
print(f"antes = {antes}, ahora = {ahora} (pasaron 700 ms; plazo de 500 ms)")
print(" ahora >= antes + 500 :", ahora >= antes + 500, " (falla en el desborde)")
print(" (ahora - antes) mod 65536 >= 500 :", ((ahora - antes) & 0xFFFF) >= 500, " (correcto)")
# 4) balance de memoria: lo que informa avr-size (text, data, bss) mas montículo y pilas
SRAM, FLASH = 2048, 32768
print()
print("programa Flash SRAM estatica peor caso estado")
programas = (
("sensor sencillo", 6200, 90, 210, 0, 60, 24),
("con pantalla y buferes", 14800, 410, 880, 0, 120, 40),
("con String y malloc", 15400, 420, 910, 600, 140, 40),
)
for nombre, text, data, bss, heap, pila, isr in programas:
flash = text + data # los valores de .data tambien viven en Flash
estatica = data + bss
peor = estatica + heap + pila + isr
estado = f"libre {SRAM - peor}" if peor <= SRAM else f"SE DESBORDA en {peor - SRAM}"
print(f"{nombre:24s} {flash:6d} ({flash / FLASH * 100:4.1f} %) {estatica:5d} ({estatica / SRAM * 100:4.1f} %) {peor:5d} ({peor / SRAM * 100:5.1f} %) {estado}")
Resultado: con la pantalla por partes, la vuelta dura 5,27 ms y cumple el plazo de 20 ms; con un delay de 50 ms sube a 51,55 ms; si además el tick es de 20 kHz (12 % de la CPU), a 58,23 ms; y una ISR de 150 µs a 5 kHz (75 % de la CPU) estira 5,24 ms de tareas a 20,96 ms y rompe el plazo sin que ninguna tarea haya cambiado. A 115 200 baudios un byte llega cada 86,8 µs: en 5 ms son 58 bytes (búfer de 64) y en 50 ms son 576 (búfer de 1024, la mitad de la SRAM); a 250 000 baudios, 1250 bytes necesitarían 2048, toda la memoria. Un contador de 32 bits en milisegundos se desborda a los 49,7 días y uno de 16 bits a los 65,5 s; con el desborde, ahora ≥ antes + 500 da False aunque hayan pasado 700 ms, y la resta módulo 65536 da True. En el balance de memoria, el sensor sencillo usa 384 bytes (18,8 %) y queda con 1664 libres; el programa con pantalla, 1450 (70,8 %); y el que usa String y malloc necesita 2110: se desborda en 62 bytes, aunque su Flash esté al 48 %.
Los tiempos de cada tarea, los ciclos de la ISR y los tamaños de pila son valores de ejemplo; en un proyecto real se miden (tema 14).
12 · Exploración interactiva
Tiempo, búfer y memoria
La primera herramienta calcula la vuelta del bucle principal y la compara con un plazo. La segunda dimensiona el búfer circular de recepción. La tercera arma el balance de Flash, SRAM y pila de un programa.
Banco de estructura
1. Presupuesto de tiempo del bucle principal
2. Búfer circular de recepción
3. Balance de memoria
Recorrido guiado
- Con los valores iniciales, las cuatro tareas suman 5240 µs; con el 0,6 % de las interrupciones, la vuelta dura 5,27 ms y el plazo de 20 ms se cumple con 14,73 ms de margen.
- Cambiá la pantalla a 50 000 µs (un
delayo una actualización completa por I2C): la vuelta sube a 51,55 ms, no cumple, y la nota señala a la pantalla como el 98 % del bucle. - Volvé a 4000 µs y poné 5000 interrupciones por segundo de 150 µs: el 75 % de la CPU se va en interrupciones y la vuelta sube a 20,96 ms. Ninguna tarea cambió; el plazo se rompió igual.
- En el búfer, 115 200 baudios y 5 ms sin atender son 58 bytes: los 64 del valor inicial alcanzan (guardan 63). Con 50 ms hacen falta 576 y el tamaño mínimo es 1024: la nota recomienda acortar el tiempo sin atender antes que agrandar el búfer.
- Pasá a 9600 baudios con 50 ms: son 48 bytes y alcanzan 64. Con 1 000 000 baudios y 1 MHz la rutina de 60 ciclos ocupa más que el intervalo entre bytes: es imposible.
- En la memoria, los valores iniciales dan 9380 bytes de Flash (28,6 %), 820 de SRAM estática y 112 de pila: 932 de 2048 (45,5 %). Poné 1000 bytes de montículo y quedan 116 libres; con 1200, la nota avisa que faltan bytes. Cambiá a ATtiny85 y el mismo programa no entra ni en sus 8 KB de Flash ni en sus 512 bytes de SRAM.
Desafío: ¿cuánto puede tardar la pantalla?
Un termostato lee un pulsador (40 µs), mide la temperatura (300 µs), atiende la UART (900 µs) y actualiza una pantalla. Hay un tick de 1 kHz de 6 µs y se pide que el pulsador responda en menos de 10 ms. La suma de las otras tareas es 1240 µs; la carga de las interrupciones es 0,6 %. El tiempo total permitido por vuelta es 10 ms × (1 − 0,006) = 9,94 ms, de los cuales se descuentan 1,24 ms: la pantalla puede ocupar hasta 8,7 ms por vuelta. Una actualización completa de 50 ms excede el plazo cinco veces: hay que partirla en trozos de pocos milisegundos (por ejemplo, una línea por vuelta) para que cada vuelta quede por debajo de ese límite y la pantalla se refresque completa cada ocho o diez vueltas.
13 · Comprensión
Confusiones frecuentes
«Si la interrupción se dispara sola, puedo hacer todo el trabajo dentro de la ISR»
Mientras la ISR se ejecuta, el bucle principal y las demás interrupciones esperan (en el AVR las interrupciones quedan deshabilitadas al entrar). Una ISR larga produce retrasos y pérdidas de eventos. Lo correcto es atender el hardware lo justo y dejar el resto al bucle.
«delay() está bien si es corto»
Cada milisegundo de delay() se suma a la vuelta de todas las tareas. Con una tarea, no se nota; con cinco, cada una con su espera, el sistema pierde la capacidad de reaccionar. En un programa serio el bucle principal no tiene esperas.
«Con volatile ya no necesito proteger los datos»
volatile impide que el compilador guarde la variable en un registro; no hace atómica una lectura de 16 o 32 bits ni una secuencia leer–modificar–escribir. Para eso se necesitan secciones críticas.
«Si compila y cabe en la Flash, cabe en la memoria»
La Flash y la SRAM son recursos distintos. Un programa puede ocupar el 20 % de la Flash y desbordar la SRAM, y el compilador no avisa del uso de la pila. Hay que sumar las variables, el montículo y la pila del peor caso.
«Estructurar en módulos es cosa de programas grandes»
Los módulos ayudan desde el primer día: aíslan el hardware de la lógica, permiten cambiar de placa tocando un archivo y reducen los errores de variables compartidas sin querer. No cuestan memoria, porque son solo organización del código fuente.
14 · Práctica guiada
Predecí antes de comprobar
1. La vuelta del bucle
Un bucle tiene tres tareas de 100 µs, 2 ms y 500 µs, y una interrupción de 10 µs que se dispara 2000 veces por segundo. ¿Cuánto dura una vuelta? ¿Se cumple un plazo de 5 ms? ¿Y si se agrega una tarea de 6 ms?
Ver solución
Las interrupciones ocupan 2000 × 10 µs = 20 ms por segundo = 2 %. Las tareas suman 2600 µs; la vuelta es 2600 / 0,98 ≈ 2653 µs: cumple los 5 ms. Con la tarea de 6 ms, la suma es 8600 µs y la vuelta es 8600 / 0,98 ≈ 8776 µs: no cumple, y habría que partir la tarea nueva en trozos.
2. Tamaño del búfer
La UART trabaja a 19 200 baudios (8N1) y el bucle puede pasar hasta 30 ms sin atenderla. ¿Cuántos bytes llegan y qué tamaño de búfer circular (potencia de 2) hace falta?
Ver solución
Un byte son 10 bits: 10 / 19 200 = 520,8 µs. En 30 ms llegan 30 / 0,5208 = 57,6 → 58 bytes. Un búfer de N posiciones guarda N − 1: hacen falta al menos 59, o sea 64 bytes (que guardan 63). Con 32 se perderían datos.
3. ¿Dónde vive cada variable?
En un programa para el ATmega328P: (a) int a = 7; global; (b) int b; global; (c) const char msg[] = "hola"; global; (d) static int c = 0; dentro de una función; (e) int d; local; (f) const char ayuda[] PROGMEM = "hola";.
Ver solución
(a) .data (valor inicial en Flash, copia en SRAM). (b) .bss (SRAM, puesta a cero al arrancar). (c) .data, aunque sea const: en AVR las constantes sin PROGMEM se copian a la SRAM, 5 bytes con el terminador. (d) .bss, porque vale cero y es estática. (e) pila o un registro, con valor indefinido. (f) solo Flash, sin ocupar SRAM.
4. Desborde del contador
Un contador de milisegundos de 16 bits valía 60 000 cuando comenzó una espera y ahora vale 100. ¿Cuánto tiempo pasó? ¿Qué comparación se equivoca?
Ver solución
La resta sin signo módulo 65 536 da (100 − 60 000) mod 65 536 = 5636 ms. La comparación ahora ≥ antes + plazo con un plazo de 5000 ms calcula antes + plazo = 65 000 (cabe en 16 bits sin desbordar) y pregunta si 100 ≥ 65 000: falso, aunque hayan pasado más de 5 s. La resta funciona siempre que el plazo sea menor que el período del contador.
5. Una rutina demasiado pesada
Una interrupción de 25 µs se dispara 10 000 veces por segundo y el bucle necesita 3 ms de trabajo por vuelta. ¿Qué carga hay y cuánto dura la vuelta? ¿Qué se podría cambiar?
Ver solución
La carga es 10 000 × 25 µs = 0,25 s por segundo = 25 %. La vuelta dura 3 ms / 0,75 = 4 ms: un tercio más que sin interrupciones. Para reducirla: acortar la rutina (dejar parte del trabajo al bucle), bajar la frecuencia de los eventos (por ejemplo, un tick de 1 kHz en lugar de 10 kHz) o usar hardware que evite la interrupción (comparación de salida del temporizador, FIFO de la UART).
15 · Síntesis
Ideas para recordar
- Un programa de microcontrolador no termina: tras el reset, el código de arranque prepara la pila, copia
.datay pone.bssa cero;main()inicializa una vez y entra en unfor (;;). - La inicialización va de afuera hacia adentro (pines seguros, reloj, periféricos, dispositivos) y habilita las interrupciones al final; se guarda la causa del reset y se desactiva el watchdog heredado.
- El bucle principal repite tareas cortas y no espera nunca; la vuelta dura la suma de las tareas dividida por (1 − carga de las interrupciones), y fija el peor tiempo de respuesta de lo que se detecta mirando.
- Las interrupciones son breves y dejan una bandera, un dato o un elemento de un búfer circular; los datos compartidos son
volatiley las lecturas de más de un byte van en una sección crítica. - El tiempo se mide con un tick de sistema y la resta sin signo (
ahora − antes ≥ plazo), que sobrevive al desborde; no se usadelay()en el bucle. - Los módulos tienen interfaz
.h, detallesstaticen el.c,inityupdate, y cada capa usa solo a la de abajo. - La SRAM se reparte entre variables, montículo y pila; el compilador no calcula la pila. Se evita
malloc, se pasan las constantes a la Flash y se mide el peor caso. - Toda espera tiene un plazo, el watchdog se acaricia desde el bucle solo si las tareas respondieron y los actuadores tienen un estado seguro.
¿Listo para el tema 42?
Antes de avanzar, calculá la vuelta de un bucle con interrupciones y compará con un plazo, explicá qué pasa entre el reset y main(), y escribí cómo reemplazar un delay(500) por una pregunta de tiempo con la resta sin signo. Si podés justificar las tres respuestas, tenés la base de la estructura.
En el próximo tema vemos las máquinas de estados y las tareas cooperativas: cómo expresar un comportamiento que dura en el tiempo (un ciclo de riego, un protocolo, un menú) como estados y transiciones, y cómo repartir el procesador entre varias tareas con un tick de sistema y un planificador simple, sin bloquear.