← Índice del curso

Comunicación entre Dispositivos · Tema 9

UART en el microcontrolador

El periférico recibe los bytes cuando le llegan, no cuando tu programa está listo para leerlos. Entre uno y otro hace falta un colchón: el buffer. Este tema explica cómo se arma con interrupciones y un buffer circular, cómo leer y enviar sin que el programa se quede esperando y cómo dar formato a los datos.

01 · Punto de partida

Dos ritmos que no coinciden

La UART trabaja al ritmo de la línea: a 115 200 baudios entrega un byte cada 86,8 µs, aunque tu programa esté haciendo otra cosa. El programa, por su parte, trabaja al ritmo de su loop(): lee sensores, actualiza una pantalla, espera 100 ms… y vuelve a mirar el puerto cuando le toca. En el tema 8 vimos qué pasa cuando esos dos ritmos chocan: el hardware solo retiene un par de bytes y, si el programa no los saca a tiempo, se produce un desbordamiento y se pierden datos.

Es el problema clásico del productor y el consumidor. El productor (la línea, a través de la UART) genera datos a una velocidad que no controlás; el consumidor (tu código) los procesa cuando puede. Para desacoplarlos se usa una cola intermedia: un buffer. La UART, apoyada en una interrupción, deposita cada byte en el buffer en cuanto llega; el programa los retira cuando le conviene. Mientras el buffer no se llene, no se pierde nada.

02 · Tres formas de atender el puerto

Consulta, interrupciones y DMA

Estrategias para atender la recepción de una UART.
EstrategiaCómo funcionaVentajasProblemas
Consulta (polling)El programa revisa la bandera «RX completo» cada tanto y lee el datoSimple, sin interrupcionesSi se demora más que unos bytes, hay desbordamiento; ocupa la CPU en revisar
Interrupción por byteCada byte recibido dispara una rutina que lo guarda en un bufferNo se pierden datos mientras haya lugar; el programa lee cuando quiereUna interrupción por byte (a 1 Mbaud, una cada 10 µs); hay que escribirla con cuidado
FIFO de hardwareLa UART acumula varios bytes (por ejemplo 128 en el ESP32) antes de avisarMenos interrupciones; tolera retrasos mayoresLa FIFO también es finita; sigue necesitando un buffer de software
DMAUn controlador de memoria copia los bytes del periférico a un buffer sin intervención de la CPUAltas velocidades con casi cero carga de CPUMás configuración; hay que saber cuándo termina un mensaje de longitud variable (por ejemplo, con la interrupción de línea inactiva)

En Arduino y en la mayoría de las plataformas, la combinación estándar es interrupción más buffer circular: el objeto Serial ya lo trae resuelto. Entender cómo está hecho permite dimensionarlo, evitar sus trampas y, si hace falta, construir uno propio.

03 · La estructura central

El buffer circular

Un buffer circular (ring buffer) es un arreglo de tamaño fijo N usado como una cola: los datos entran por un extremo y salen por el otro, en el mismo orden. Se manejan con dos índices:

  • head (cabeza): posición donde se escribirá el próximo byte. Solo lo modifica quien produce (la interrupción).
  • tail (cola): posición del byte más antiguo, el próximo a leer. Solo lo modifica quien consume (el programa).

Cuando un índice llega al final del arreglo, vuelve a cero: de ahí lo de «circular». Así el espacio se reutiliza sin mover datos.

Buffer circular de 8 posiciones Un arreglo de ocho posiciones numeradas de 0 a 7. Las posiciones 2 a 5 contienen los caracteres T, igual, 2 y 5. El índice tail apunta a la posición 2, que es el próximo byte a leer, y el índice head apunta a la posición 6, donde la interrupción escribirá el siguiente byte. Hay cuatro posiciones ocupadas y tres libres disponibles. T=25 01234567 tail = 2el programa lee de acá head = 6la interrupción escribe acá ocupados = (head − tail) mod N = 4 libres = N − 1 − ocupados = 3 (se deja un lugar vacío para distinguir lleno de vacío) vuelvea 0

Las reglas son sencillas:

  • Vacío: head == tail.
  • Lleno: el siguiente de head sería tail, es decir, (head + 1) mod N == tail. Se sacrifica una posición, así que un buffer de N posiciones guarda como máximo N − 1 bytes (por eso el buffer de 64 de Arduino contiene 63).
  • Escribir: si no está lleno, buf[head] = dato; head = (head + 1) mod N. Si está lleno, el dato nuevo se pierde (o se pisa el más viejo, según el diseño).
  • Leer: si no está vacío, dato = buf[tail]; tail = (tail + 1) mod N.

Con N potencia de dos (16, 64, 256), el «mod N» se reduce a una máscara de bits (& (N − 1)), que es mucho más rápida en un microcontrolador chico. Y como cada índice lo cambia un solo actor, la estructura funciona sin bloqueos entre la interrupción y el programa, siempre que los índices sean de un byte (en un AVR, la lectura y escritura de un byte es atómica).

Cuánto tiene que medir

El tamaño del buffer lo fija el peor retardo con que el programa puede volver a leer. Durante ese tiempo llegan bytes sin parar:

tamaño mínimo ≈ (velocidad / bits por byte) × tiempo máximo sin leer + 1

A 115 200 baudios en 8N1 llegan 11 520 bytes por segundo. Si el programa puede tardar 20 ms en volver a leer (por ejemplo, mientras refresca una pantalla), llegan 230 bytes: un buffer de 64 se desborda y hace falta uno de 256. A 9600 baudios, con la misma pausa, llegan 960 bytes por segundo × 0,02 ≈ 19 bytes y alcanza un buffer de 32. La consecuencia práctica: la velocidad y el tiempo de bloqueo se compran uno con el otro. Los buffers por defecto son 64 bytes en un Arduino Uno y 256 en un ESP32 (ampliable con Serial.setRxBufferSize() antes de begin).

04 · El otro sentido

Transmitir con buffer

La transmisión funciona como la recepción, al revés. Cuando llamás a Serial.print, la biblioteca no espera a que los bytes salgan por el cable: los copia a un buffer de transmisión y vuelve enseguida. Una interrupción («registro de datos vacío») saca de ese buffer un byte cada vez que la UART está libre. Así, un println de 20 bytes a 9600 baudios, que en la línea dura 21 ms, devuelve el control en unos microsegundos.

Pero el buffer es finito. Si el programa escribe más bytes de los que caben, write se queda esperando a que la UART libere lugar, y el loop() queda frenado al ritmo del puerto. Esto es lo que ocurre al imprimir mucho texto a baja velocidad. Una cuenta aproximada:

bloqueo ≈ (bytes a enviar − tamaño del buffer) × tiempo por byte

Para 100 bytes con un buffer de 64 a 9600 baudios (1,04 ms por byte), el programa queda bloqueado cerca de 36 × 1,04 ≈ 37,5 ms; a 115 200 baudios, 3,1 ms. Una lectura de sensores que necesita tiempos precisos puede arruinarse por una impresión de depuración. Por eso conviene:

  • Imprimir poco y breve dentro de los lazos críticos, o subir la velocidad.
  • Preguntar antes: Serial.availableForWrite() devuelve cuántos bytes caben sin bloquear.
  • Usar Serial.flush() solo cuando haga falta (por ejemplo, para soltar un bus RS-485): espera hasta que sale el último bit.

El mismo principio vale a la inversa: la salida más rápida que el enlace puede sostener es la velocidad de la línea. Un programa que genera datos más rápido que eso termina siempre limitado por el puerto o pierde datos, según cómo se diseñe.

05 · Sin quedarse esperando

Leer sin quedarse esperando

Con el buffer, la lectura se vuelve una pregunta rápida: «¿hay algo?». Las funciones básicas de Serial no bloquean:

  • Serial.available(): cuántos bytes hay en el buffer de recepción.
  • Serial.read(): toma un byte (o devuelve −1 si no hay).
  • Serial.peek(): mira el próximo byte sin sacarlo.

En cambio, un grupo de funciones muy usadas sí bloquean, porque esperan hasta recibir lo que piden o hasta que vence un tiempo límite (1000 ms por omisión, ajustable con Serial.setTimeout()):

Funciones de lectura de Serial que pueden bloquear el programa.
FunciónQué esperaRiesgo
readStringUntil('\n')Un fin de línea o el tiempo límiteHasta 1 s detenido si la línea no llega completa; además usa String (memoria dinámica)
readBytes(buf, n) y readBytesUntilQue lleguen n bytes o un carácter terminadorBloquea si el mensaje es más corto de lo esperado
parseInt() y parseFloat()Que aparezca y termine un númeroBloquea hasta el tiempo límite si llegan menos caracteres
find() y findUntil()Una cadena concreta en el flujoIdem

Para un programa que debe seguir atendiendo otras tareas, el patrón correcto es acumular carácter a carácter: en cada vuelta del loop() se retiran del buffer los bytes que haya, se arma el mensaje en un arreglo fijo y solo cuando aparece el terminador se procesa. Es una pequeña máquina de estados; no se detiene nunca. Un ejemplo completo está en la sección «En el microcontrolador».

06 · Texto o binario

Dar formato a los datos

Los bytes del buffer no significan nada por sí mismos: el programa debe decidir cómo representar los valores. Hay dos grandes opciones.

Texto

Los números viajan como caracteres: «25.4», «LED=1». Se puede leer en cualquier monitor serie y depurar a ojo, y los terminadores de línea delimitan el mensaje. A cambio, ocupa más bytes y exige convertir (imprimir y analizar) en cada extremo.

Binario

Los valores viajan en su representación nativa: un entero de 16 bits ocupa dos bytes, un float, cuatro. Es compacto y rápido de procesar, pero ilegible en un monitor y exige acordar el orden de los bytes y la estructura exacta (tema 46).

Tres maneras de enviar una temperatura de 25,4 °C, y lo que cuesta cada una a 9600 baudios en 8N1.
RepresentaciónBytesTiempo en la líneaComentario
Texto con fin de línea: 25.4\r\n66,25 msLegible; el receptor busca el \n
Entero escalado ×10: 254 en 16 bits22,08 msCompacto; hay que acordar la escala (décimas) y el orden de bytes
float de 32 bits44,17 msConserva decimales sin escala; ambos extremos deben usar el mismo formato de coma flotante

La regla general: texto mientras estás desarrollando y para comandos de baja frecuencia; binario cuando importan el ancho de banda y la velocidad de procesamiento. Además hay trampas propias de los microcontroladores pequeños:

  • snprintf en un AVR no imprime números con coma (%f devuelve un signo de interrogación). Se usa dtostrf(valor, ancho, decimales, destino) o se envía el entero escalado.
  • La clase String reserva y libera memoria dinámica, y en 2 KB de RAM puede fragmentarla hasta que el programa falle de forma misteriosa. Para mensajes se prefieren arreglos char de tamaño fijo.
  • Serial.print(65) envía «65»; Serial.write(65), el byte 0x41. Mezclarlos produce mensajes ilegibles.
  • Enviar una estructura entera con Serial.write((uint8_t*)&datos, sizeof(datos)) es cómodo, pero depende del alineamiento y del orden de bytes de cada plataforma. Es una fuente típica de errores al comunicar un AVR con un ESP32.

07 · Interrupciones

Reglas de una rutina de interrupción

La rutina de servicio (ISR) de la UART se ejecuta cuando llega un byte, interrumpiendo lo que el programa esté haciendo. Para que el sistema funcione hay que respetar unas pocas reglas:

  • Que sea corta. Mientras se ejecuta, otras interrupciones pueden quedar en espera. A 115 200 baudios, la ISR de recepción debe terminar mucho antes de los 86,8 µs entre bytes. Lo mínimo: leer el dato, guardarlo y salir.
  • Leer siempre el registro de datos. Si la ISR no lee el byte, la bandera de recepción sigue activa y la interrupción se dispara sin parar. Por eso el ejemplo lo lee antes de comprobar si hay lugar.
  • Marcar como volatile toda variable que se comparte con el programa. Sin esa palabra, el compilador puede conservar el valor en un registro y el programa nunca vería el cambio hecho por la interrupción.
  • No llamar a funciones lentas ni bloqueantes: ni delay, ni Serial.print, ni asignación de memoria. Un print dentro de una ISR, con el buffer de transmisión lleno, espera a una interrupción que no puede ejecutarse porque está dentro de otra: se cuelga el programa.
  • Cuidado con los datos de más de un byte. En un AVR de 8 bits, leer un int compartido son dos instrucciones; si la interrupción lo cambia entre ambas, el valor queda mezclado. El programa debe desactivar las interrupciones un instante (noInterrupts()) para copiarlo.
  • Avisar, no trabajar. Lo habitual es que la ISR levante una bandera o encole el dato, y que el procesamiento real lo haga el loop().

08 · Con el microcontrolador

Un buffer propio y un lector de líneas

El primer programa implementa la recepción completa en un ATmega328P, sin la biblioteca Serial: una rutina de interrupción que llena un buffer circular de 64 bytes, y dos funciones para consultarlo. Lleva además un contador de bytes perdidos, para saber si el buffer alcanzó alguna vez.

#define TAM 64                              // potencia de dos: el módulo se hace con una máscara
volatile uint8_t buf[TAM];
volatile uint8_t head = 0;                  // lo modifica solo la ISR
volatile uint8_t tail = 0;                  // lo modifica solo el programa
volatile uint8_t perdidos = 0;              // bytes descartados por buffer lleno

ISR(USART_RX_vect) {
    uint8_t dato = UDR0;                    // SIEMPRE leer: así se limpia la bandera de la interrupción
    uint8_t siguiente = (head + 1) & (TAM - 1);
    if (siguiente == tail) {
        perdidos++;                         // lleno: este byte se pierde
    } else {
        buf[head] = dato;
        head = siguiente;
    }
}

uint8_t disponibles() { return (head - tail) & (TAM - 1); }

int leerByte() {                            // -1 si no hay datos, igual que Serial.read()
    if (head == tail) return -1;
    uint8_t dato = buf[tail];
    tail = (tail + 1) & (TAM - 1);
    return dato;
}

void uartInicio() {
    UBRR0 = 103;                                                // 9600 baudios con 16 MHz
    UCSR0B = (1 << RXEN0) | (1 << TXEN0) | (1 << RXCIE0);     // receptor, transmisor e interrupción de recepción
    UCSR0C = (1 << UCSZ01) | (1 << UCSZ00);                  // 8N1
    sei();                                                      // habilitar interrupciones globales
}

El segundo programa usa la biblioteca Serial y lee líneas de comandos sin bloquear nunca. Retira del buffer los bytes que haya, arma la línea en un arreglo de tamaño fijo y, cuando aparece el fin de línea, interpreta el comando. El resto del programa sigue corriendo en cada vuelta:

const byte MAX_LINEA = 32;
char linea[MAX_LINEA];
byte largo = 0;

bool leerLinea() {                              // true cuando hay una línea completa en 'linea'
    while (Serial.available() > 0) {
        char c = Serial.read();
        if (c == '\r') continue;                // ignorar el retorno de carro
        if (c == '\n') {
            linea[largo] = '\0';                // terminar la cadena
            largo = 0;
            return true;
        }
        if (largo < MAX_LINEA - 1) linea[largo++] = c;   // si es demasiado larga, se descarta el exceso
    }
    return false;
}

void procesar(const char *texto) {
    if (strncmp(texto, "T=", 2) == 0) {
        float t = atof(texto + 2);              // "T=25.4" -> 25.4
        Serial.print("Temperatura recibida: ");
        Serial.println(t, 1);
    } else if (strcmp(texto, "LED=1") == 0) {
        digitalWrite(13, HIGH);
    } else if (strcmp(texto, "LED=0") == 0) {
        digitalWrite(13, LOW);
    } else {
        Serial.println("Comando desconocido");
    }
}

void setup() {
    Serial.begin(115200);
    pinMode(13, OUTPUT);
}

void loop() {
    if (leerLinea()) procesar(linea);           // atiende comandos cuando llegan…
    // …y el resto del programa sigue corriendo sin esperar (sensores, pantalla, control)
}

Observá lo que evita este diseño: no hay readStringUntil con su espera de hasta un segundo, no hay String que fragmente la memoria y una línea mal formada, o demasiado larga, no desordena el programa: se corta el exceso y se sigue. Para un protocolo real, el procesamiento agrega validación y límites (tema 41).

09 · Analogía con programación

Simular el buffer y sus pérdidas

Antes de elegir el tamaño de un buffer, conviene simularlo. El modelo de Python reproduce el problema del productor y el consumidor: llega un byte cada bits / baud segundos y el programa vacía el buffer cada periodo_ms milisegundos. Si el buffer está lleno cuando llega un byte, se pierde. La función devuelve cuántos bytes llegaron, cuántos se perdieron y la ocupación máxima alcanzada.

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


def simular(baud, tam, periodo_ms, bits=10, segundos=1.0):
    """Productor (la línea) y consumidor (el programa, que vacía el buffer cada 'periodo_ms')."""
    llegados = perdidos = ocupados = maximo = 0
    k = 1                                          # número de la próxima lectura del programa
    i = 1                                          # número del byte que llega
    while i * bits / baud <= segundos:
        t = i * bits / baud
        while k * periodo_ms / 1000 <= t:          # el programa vacía el buffer
            ocupados = 0
            k += 1
        llegados += 1
        if ocupados < tam:
            ocupados += 1
            maximo = max(maximo, ocupados)
        else:
            perdidos += 1                          # buffer lleno: el byte se pierde
        i += 1
    return llegados, perdidos, maximo


def tamano_minimo(baud, periodo_ms, bits=10):
    return math.ceil(baud / bits * periodo_ms / 1000) + 1


print("115 200 baudios, 8N1: bytes perdidos en 1 segundo")
print("periodo   mínimo  |  2 bytes (solo hardware)   64 bytes   256 bytes")
for periodo in (2, 5, 10, 20):
    celdas = []
    for tam in (2, 64, 256):
        _, perdidos, _ = simular(115200, tam, periodo)
        celdas.append(f"{perdidos:6d}")
    print(f"{periodo:4d} ms   {tamano_minimo(115200, periodo):5d}   |  " + "         ".join(celdas))

llegados, perdidos, maximo = simular(9600, 64, 100)
print()
print(f"9600 baudios, 64 bytes, lectura cada 100 ms: llegan {llegados}, se pierden {perdidos}, máximo {maximo}")
print("tamaño mínimo:", tamano_minimo(9600, 100))

Resultado: a 115 200 baudios, con solo los 2 bytes del hardware, el programa pierde la inmensa mayoría de los datos (10 519 de 11 520 con lecturas cada 2 ms). Con 64 bytes no se pierde nada con lecturas cada 2 ms y cada 5 ms (la ocupación máxima es 24 y 58), pero a 10 ms ya se pierden 5119 bytes por segundo; con 256 bytes se aguanta hasta 20 ms (ocupación máxima 231). A 9600 baudios, con 64 bytes y lecturas cada 100 ms, llegan 960 bytes y se pierden 319 porque el mínimo sería 97. Probá otros valores y comprobá que el tamaño mínimo calculado siempre alcanza.

El modelo supone que el programa vacía todo el buffer en cada lectura y que los bytes llegan a ritmo constante. En un sistema real, el retardo máximo puede ser mayor que el promedio, y es el máximo el que dimensiona el buffer.

10 · Exploración interactiva

Buffers y bloqueos

El laboratorio tiene dos herramientas. La primera simula un segundo de recepción continua: elegís la velocidad, el tamaño del buffer y cada cuánto vuelve a leer el programa, y ves cuántos bytes llegan, cuántos se pierden y qué ocupación máxima alcanzó el buffer. La segunda calcula cuánto bloquea una impresión larga el programa.

1. Recepción continua durante un segundo

Bytes por segundo en la línea—
Bytes llegados en un segundo—
Bytes perdidos—
Ocupación máxima del buffer—
Tamaño mínimo recomendado—
Veredicto—

Elegí velocidad, buffer y periodo de lectura.

2. ¿Cuánto bloquea una impresión?

Tiempo total en la línea—
Bytes que caben sin esperar—
El programa queda bloqueado—

Cambiá la cantidad de bytes o el buffer.

La primera herramienta usa el mismo modelo que el programa de Python (llegada a ritmo constante y vaciado completo del buffer en cada lectura). La barra muestra la ocupación máxima frente al tamaño del buffer; se pone en rojo cuando hay pérdidas. La segunda es una aproximación: bloqueo ≈ (bytes − buffer) × tiempo por byte.

Recorrido guiado

  1. Con los valores iniciales (115 200 baudios, 64 bytes, lectura cada 5 ms) llegan 11 520 bytes por segundo, la ocupación máxima es 58 y no se pierde nada. El mínimo recomendado es 59: el buffer alcanza con poco margen.
  2. Cambiá la lectura a 10 ms: el mínimo sube a 117 y el buffer de 64 se desborda; se pierden 5119 bytes. Con un lector más lento, el mismo buffer ya no sirve.
  3. Subí el buffer a 256 bytes: sin pérdidas, con ocupación máxima 116. Con lectura cada 20 ms todavía no se pierde nada, pero la ocupación llega a 231 de 256 (veredicto «justo»); a 50 ms vuelven las pérdidas.
  4. Elegí el buffer de 2 bytes (solo el hardware del AVR) y lectura cada 2 ms: se pierden 10 519 bytes, el 91 % de lo que llega. Es el desbordamiento del tema 8.
  5. Bajá la velocidad a 9600 baudios con buffer de 64 y lectura cada 50 ms: llegan 960 bytes y la ocupación máxima es 48, sin pérdidas. A baja velocidad, el mismo buffer tolera retardos mucho mayores. Con 100 ms ya se pierden 319.
  6. Probá 230 400 baudios con lectura cada 5 ms: hay que subir el buffer hasta 128 bytes o más para no perder.
  7. En la segunda herramienta, con 100 bytes, buffer de 64 y 9600 baudios, el programa queda bloqueado unos 37,5 ms. A 115 200 baudios, 3,1 ms. Con 60 bytes no se bloquea en absoluto.
  8. Con 1000 bytes y buffer de 256 a 115 200: el bloqueo es de unos 64 ms. Imprimir mucho siempre termina limitado por el ritmo del puerto.
Desafío: dimensionar para una pantalla lenta

Tu programa refresca una pantalla que tarda 40 ms en cada actualización, durante los cuales no lee el puerto. Los datos llegan a 57 600 baudios. ¿Qué tamaño de buffer necesitás? A 57 600 baudios llegan 5760 bytes por segundo; en 40 ms llegan 230 bytes (230,4 exactamente), así que hace falta un buffer de al menos 232: en la práctica, 256. Verificalo en la herramienta 1 con 57 600 baudios: leyendo cada 20 ms la ocupación máxima es 116 y con 50 ms sube a 288, que ya no cabe en 256. Otra salida: partir la actualización de la pantalla en trozos de pocos milisegundos para volver a leer más seguido.

11 · Método de trabajo

Cómo diseñar la capa serie

  1. Calculá el flujo. Bytes por segundo en cada sentido y si llegan en ráfagas (con la fórmula del tema 4).
  2. Medí o estimá el peor retardo del programa entre lecturas: pantallas, escrituras en memoria, esperas, otras comunicaciones.
  3. Dimensioná el buffer de recepción con la fórmula: bytes por segundo × retardo máximo + 1, más un margen, y redondeá a una potencia de dos.
  4. Evitá el bloqueo en la transmisión. Verificá con availableForWrite() o limitá el largo de los mensajes; mové las impresiones largas fuera de los lazos críticos.
  5. No uses funciones bloqueantes (readStringUntil, parseInt) cuando el programa tenga otras tareas; acumulá los caracteres en un arreglo fijo.
  6. Definí terminadores y límites de mensaje, y qué hacer con una línea demasiado larga o incompleta.
  7. Contá los errores. Mantené contadores de bytes perdidos, de errores de trama y de líneas descartadas: son la mejor señal de que algo se degradó.
  8. Elegí texto o binario según el ancho de banda y la facilidad de depuración, y documentá el formato exacto.

Cuando el flujo supera lo que un buffer de interrupciones puede sostener (altas velocidades o ráfagas largas), las soluciones son el control de flujo (RTS/CTS y XON/XOFF, tema 10), una FIFO de hardware mayor o el DMA con detección de línea inactiva para delimitar mensajes de longitud variable.

12 · Comprensión

Confusiones frecuentes

«Serial.print espera a que el mensaje salga por el cable»

Solo copia los bytes al buffer de transmisión y vuelve. Espera únicamente si el buffer se llena. Para esperar a que salga el último bit hay que usar Serial.flush().

«Con una velocidad alta, un buffer pequeño alcanza porque los datos pasan rápido»

Es al revés: a mayor velocidad llegan más bytes por unidad de tiempo, y el buffer debe cubrir el mismo retardo del programa con más datos. A 115 200 baudios, 20 ms de pausa acumulan 230 bytes.

«Un buffer más grande resuelve cualquier pérdida»

Un buffer mayor tolera retardos más largos, pero si el flujo promedio es mayor que lo que el programa procesa, termina llenándose igual. Entonces hace falta más velocidad de procesamiento, menos datos o control de flujo.

«Puedo imprimir dentro de una interrupción para depurar»

Es peligroso: Serial.print puede esperar a que se libere el buffer de transmisión, y eso depende de una interrupción que no se ejecuta mientras se está dentro de otra. El programa se cuelga. Dentro de la ISR se levanta una bandera, y se imprime fuera de ella.

«readStringUntil es lo más cómodo, así que es lo mejor»

Es cómodo pero bloquea hasta un segundo si el mensaje no llega completo, y usa String, que fragmenta la memoria en los microcontroladores chicos. Un lector con arreglo fijo es apenas más largo y no detiene el programa.

«Enviar la estructura entera en binario funciona igual en todas las placas»

No necesariamente: el tamaño de los tipos, el alineamiento de los campos y el orden de los bytes cambian entre un AVR y un ESP32. Los mensajes binarios deben definirse campo por campo, con un tamaño y un orden explícitos.

13 · Práctica guiada

Predecí antes de comprobar

1. Dimensionar un buffer

Un programa recibe datos a 115 200 baudios en 8N1 y puede tardar hasta 15 ms en volver a leer el puerto. ¿Cuántos bytes llegan en ese lapso y qué tamaño de buffer conviene?

Ver solución

11 520 bytes por segundo × 0,015 s = 172,8, es decir 173 bytes; el mínimo es 174 (173 + 1). El valor práctico es 256, la potencia de dos siguiente, que además deja margen. Un buffer de 128 se desbordaría.

2. Índices del buffer circular

Un buffer circular de 8 posiciones tiene head = 6 y tail = 2. ¿Cuántos bytes hay guardados y cuántos lugares libres? ¿Qué pasa si llegan 4 bytes más sin que el programa lea?

Ver solución

Ocupados = (6 − 2) mod 8 = 4. Libres = 8 − 1 − 4 = 3. Los tres primeros bytes nuevos se guardan en las posiciones 6, 7 y 0, y head pasa a 1. Ahora (head + 1) mod 8 = 2 = tail: el buffer está lleno, y el cuarto byte se pierde.

3. Una impresión larga

El programa ejecuta Serial.println con un texto de 100 bytes a 9600 baudios. El buffer de transmisión es de 64 bytes y está vacío. ¿Cuánto tiempo queda bloqueado?

Ver solución

Caben unos 64 bytes; los 36 restantes tienen que esperar a que la UART los libere. Cada byte tarda 10 / 9600 ≈ 1,04 ms, así que el bloqueo es de ≈ 36 × 1,04 ≈ 37,5 ms. A 115 200 baudios sería de 3,1 ms. Todo el mensaje tarda en la línea 100 × 1,04 ≈ 104 ms.

4. Texto o binario

Un sensor envía 50 lecturas por segundo, cada una un entero de 0 a 1023. Compará el tráfico enviándolas como texto con fin de línea (hasta 4 dígitos más CR y LF) y como entero de 2 bytes. ¿Cuál es la utilización a 9600 baudios en 8N1?

Ver solución

Texto: hasta 6 bytes por lectura → 50 × 6 × 10 = 3000 bps → 31,3 %. Binario: 2 bytes → 50 × 2 × 10 = 1000 bps → 10,4 %. El binario usa un tercio del ancho de banda, a costa de perder la legibilidad.

5. Un lector que se cuelga

Un programa lee comandos con Serial.readStringUntil('\n') y a la vez controla un motor que necesita una actualización cada 10 ms. Alguien envía un comando sin fin de línea. ¿Qué pasa y cómo se arregla?

Ver solución

La función espera hasta el tiempo límite (1000 ms por omisión) antes de rendirse: el motor queda sin actualizar durante un segundo. Se arregla con un lector que acumule caracteres en un arreglo fijo y solo procese cuando aparece el fin de línea (como leerLinea()), sin esperar nunca, o al menos bajando el tiempo límite con Serial.setTimeout().

6. Qué está mal en esta interrupción

Una ISR de recepción hace contador++ (con int contador, sin volatile), llama a Serial.println("llegó un byte") y no lee el registro de datos. Nombrá tres problemas.

Ver solución

(1) No lee el registro de datos: la bandera de recepción sigue activa y la interrupción se dispara sin parar; además se pierden los datos. (2) Llama a Serial.println dentro de la ISR: puede quedar esperando una interrupción de transmisión que no se ejecuta, y se cuelga; además es lenta. (3) contador sin volatile y de 16 bits: el programa puede no ver los cambios, y leer un int en un AVR no es atómico (puede leer un valor mezclado). Se corrige con volatile y noInterrupts() al copiarlo.

14 · Síntesis

Ideas para recordar

  • La UART produce bytes a su ritmo y el programa los consume al suyo: un buffer los desacopla.
  • La recepción por consulta pierde datos si el programa se demora; la combinación estándar es interrupción por byte más buffer circular, con FIFO de hardware o DMA para velocidades altas.
  • El buffer circular usa dos índices (head lo mueve la ISR, tail lo mueve el programa); está vacío si head == tail y lleno si (head + 1) mod N == tail, con capacidad N − 1.
  • Tamaño mínimo ≈ (velocidad / 10) × peor retardo sin leer + 1; velocidad alta o pausas largas exigen buffers grandes.
  • La transmisión también usa un buffer: print vuelve enseguida, pero bloquea si el mensaje excede el buffer (≈ (bytes − buffer) × tiempo por byte).
  • Funciones como readStringUntil, parseInt y readBytes bloquean hasta un segundo; para no detener el programa se acumulan caracteres en un arreglo fijo hasta el terminador.
  • Texto es legible y fácil de depurar; binario es compacto pero exige acordar tamaño, orden de bytes y estructura. En un AVR, snprintf no admite %f.
  • Una ISR debe ser corta, leer siempre el dato, usar variables volatile, no llamar a funciones bloqueantes y proteger los datos de varios bytes.

¿Listo para el tema 10?

Antes de avanzar, calculá el buffer necesario para recibir a 57 600 baudios con pausas de 30 ms, escribí a mano cómo se avanza head en un buffer de 16 posiciones y explicá por qué Serial.print dentro de una ISR es peligroso. Si podés justificar las tres respuestas, tenés dominada la capa serie del microcontrolador.

En el próximo tema estudiamos los protocolos de texto sobre UART: los comandos AT, las sentencias NMEA de un GPS y el control de flujo por RTS/CTS, que resuelve lo que los buffers no pueden.