← Índice del curso

Microcontroladores · Tema 41

Estructura de un programa para microcontroladores

Un programa de computadora empieza, hace su trabajo y termina. Uno de microcontrolador no termina nunca: arranca, se prepara una vez y después atiende el mundo para siempre, mientras los eventos llegan cuando quieren. Casi todos los errores difíciles (el programa que «se cuelga a los tres días», el botón que a veces no responde, el byte que se pierde) vienen de cómo está organizado, no de una línea mal escrita.

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()

Estructura de ejecución de un programa de microcontrolador Una cadena de cuatro bloques: el reset salta al vector de reinicio, el código de arranque prepara la pila y las variables, main ejecuta la inicialización una sola vez y luego entra en un bucle principal que no termina. Las interrupciones de hardware interrumpen el bucle en cualquier momento, ejecutan su rutina y devuelven el control al mismo punto. Resetvector 0x0000 Código de arranquepila, .data, .bss main()inicialización, una vez for (;;) — bucle principal leer · decidir · actuar tareas cortas, sin esperas y vuelta al principio ISR: rutina de interrupción breve; deja una bandera o un dato evento retorno Una sola vez no termina nunca Una interrupción corta el bucle entre dos instrucciones cualesquiera y devuelve el control al mismo punto.

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:

  1. pone en cero el registro r1 (por convención del compilador) y limpia SREG;
  2. fija el puntero de pila (SP) al final de la SRAM, el valor RAMEND;
  3. copia desde la Flash a la SRAM los valores iniciales de las variables globales inicializadas (sección .data);
  4. pone en cero las variables globales sin inicializar (sección .bss);
  5. 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.

Dónde vive cada cosa de un programa en el ATmega328P.
QuéDóndeCuándo se prepara
Código (.text) y constantes con PROGMEMFlash, 32 KBAl cargar el programa; no ocupa SRAM
Globales inicializadas (.data)SRAM, con el valor inicial guardado también en FlashCódigo de arranque: copia de Flash a SRAM
Globales sin inicializar y static a cero (.bss)SRAMCódigo de arranque: puesta a cero
Montículo (malloc)SRAM, crece hacia arriba desde el final de .bssEn ejecución, bajo demanda
Pila: locales, argumentos, direcciones de retorno, contexto de las interrupcionesSRAM, crece hacia abajo desde RAMENDEn ejecución, con cada llamada
Registros de periféricosEspacio 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.

  1. 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).
  2. 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.
  3. Reloj y alimentación. Prescaler, fuente del reloj, apagado de lo que no se usa (tema 12, tema 40).
  4. Periféricos uno por uno: temporizadores, ADC, UART, I2C, SPI, cada uno con sus banderas borradas.
  5. Dispositivos externos (sensores, pantallas), que suelen necesitar un tiempo de arranque y una secuencia propia.
  6. Variables de estado con sus valores iniciales de aplicación.
  7. 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:

Tvuelta = ( t₁ + t₂ + … + tn ) / ( 1 − c ) c = frecuencia de la ISR · duración de la ISR

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:

  1. Bandera. La ISR marca que ocurrió algo; el bucle lo atiende.
  2. Dato más bandera. La ISR captura un valor (un contador, un byte, una lectura) que el bucle consume.
  3. 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ón ahora ≥ antes + plazo falla justo en el desborde. El bloque de Python lo demuestra.
  • Sumar el plazo, no copiar la hora. Con t_led += 500 la tarea mantiene su ritmo aunque una vuelta se atrase; con t_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.

Capas de un programa de microcontrolador, de arriba a abajo.
CapaResponsabilidadEjemplo
AplicaciónLa lógica del producto: qué hacer y cuándomain.c, termostato.c
ServiciosUtilidades reutilizables sin hardware propioBase de tiempo, colas, filtros, protocolo de comandos
Controladores (drivers)Un dispositivo, con su interfaz sencillaled, boton, sensor_temp, motor
Acceso al hardware (HAL)Los registros del chip: GPIO, UART, ADC, temporizadorgpio.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 malloc y new. 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 clase String de 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 (macro F("...") 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_t en lugar de int cuando 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íntomas y causas habituales en la estructura de un programa.
SíntomaCausa probable
El botón responde «a veces» o con retrasoEl bucle tiene tareas lentas o delay(): la vuelta dura más que la pulsación (sección 4)
Se pierden bytes de la UARTBú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 malFalta 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 raroPila 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 watchdogNo se desactivó el watchdog al arrancar (sección 3)
Un temporizador deja de funcionar a los 49 díasComparación ahora ≥ antes + plazo en lugar de la resta sin signo (sección 6)
Se cuelga al arrancar, antes de main o justo despuésInterrupció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 -OsVariable 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

Carga de las interrupciones—
Vuelta del bucle—
Peor tiempo de respuesta—
Reparto del bucle—

2. Búfer circular de recepción

Tiempo por byte—
Costo en CPU—
Lo que hay que guardar—
Veredicto—

3. Balance de memoria

Flash—
SRAM estática—
Pila—
Peor caso de SRAM—

Recorrido guiado

  1. 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.
  2. Cambiá la pantalla a 50 000 µs (un delay o 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 .data y pone .bss a cero; main() inicializa una vez y entra en un for (;;).
  • 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 volatile y 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 usa delay() en el bucle.
  • Los módulos tienen interfaz .h, detalles static en el .c, init y update, 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.