← Índice del curso

Comunicación entre Dispositivos · Tema 47 · Taller

Taller: un protocolo con CRC y reintentos

Los seis temas anteriores armaron las piezas de un protocolo, una por una: la trama con su máquina de estados (41), las direcciones y el paquete (42), las causas de los errores (43), el CRC que los detecta (44), las confirmaciones y los reintentos que los reparan (45) y el formato de lo que va adentro (46). Acá se juntan en un proyecto que se puede cablear y medir. Dos placas ESP32 conectadas por una UART: una hace de sensor y manda una lectura por segundo; la otra hace de receptora y confirma. La trama lleva delimitador, largo, destino, origen, tipo y un CRC-16; la lectura va serializada campo por campo, con versión y número de secuencia; el emisor reintenta con una espera que se duplica y el receptor descarta las copias sin dejar de confirmarlas. Y como sobre una mesa el cable casi no falla y un protocolo que nunca vio una falla no está probado, el programa incluye un canal simulado que pierde y daña tramas a pedido, con las que se verifica, con números, que todo lo que se diseñó funciona: cuántas lecturas se confirman, cuántas se repiten, cuántas llegan sin que el emisor lo sepa y cuántas se pierden de verdad. Se acompaña con un modelo en Python y cinco herramientas interactivas que reproducen el protocolo, con las mismas cuentas, en la página.

01 · Objetivo

Qué vamos a construir

El proyecto es un enlace de dos nodos con un protocolo propio, con los requisitos de un enlace real:

  • Enviar una lectura por segundo del sensor a la receptora: identificador del nodo, temperatura, humedad y batería, en el registro de 7 bytes del tema 46 más una versión y un número de secuencia (9 bytes de datos).
  • Encuadrar cada mensaje en una trama [SOF][LEN][DST][SRC][TIPO][datos][CRC-16] que el receptor sepa encontrar en un flujo de bytes, con la máquina de estados del tema 41.
  • Detectar los daños con el CRC-16/Modbus del tema 44: toda trama que no verifique se descarta sin mirar sus campos.
  • Confirmar y reintentar: la receptora contesta con un ACK que lleva la secuencia; el emisor espera, y si no oye nada reenvía con una espera que se duplica, hasta un máximo, y después abandona la lectura (la siguiente la reemplaza).
  • No repetir: la receptora entrega una lectura una sola vez aunque le lleguen tres copias, pero confirma todas, porque la confirmación anterior pudo perderse.
  • Validar el contenido: aunque el CRC sea correcto, una temperatura de 120 °C o una versión desconocida se rechazan y se cuentan.
  • Inyectar fallas a pedido: un canal simulado, con porcentaje de pérdida y de corrupción, aplicado en la recepción de las dos placas.
  • Medirse: contadores y un informe cada 20 segundos que permiten verificar, contra la cuenta teórica, que el protocolo se comporta como se diseñó.

Lo importante no son las líneas de código, que en su mayoría ya se escribieron en los temas anteriores, sino cómo se integran y cómo se prueba el conjunto. Cada capa resuelve un problema distinto y es fácil olvidar que las otras existen:

Qué aporta cada tema al taller.
TemaAporteDónde está en el taller
41 Diseñar un protocoloSOF, largo, tipo y máquina de estados de recepciónLa clase Decodificador
42 Direccionamiento y paquetesDestino y origen en el encabezadoLos campos DST y SRC; el filtro dst != MI_DIR
43 Errores de transmisiónQué fallas esperar y por quéEl canal simulado y el plan de pruebas
44 Detección de erroresCRC-16/ModbusLa función crc16, en el envío y en la recepción
45 Confirmaciones y reintentosACK, secuencia, espera exponencial, copiasEl emisor de parar y esperar y el filtro de secuencias del receptor
46 Formatos de datosSerializar campo por campo, little-endian, validar rangosarmarLectura y leerLectura

02 · Materiales y cableado

Materiales y cableado

Cableado de las dos placas Dos módulos ESP32. El de la izquierda es el emisor, con dirección 1; el de la derecha es el receptor, con dirección 2. El pin de transmisión del emisor, GPIO 17, se conecta con el pin de recepción del receptor, GPIO 16. El pin de transmisión del receptor, GPIO 17, se conecta con el pin de recepción del emisor, GPIO 16. Los dos cables se cruzan. Las masas de las dos placas se unen con un tercer cable. Cada placa se alimenta por su propio puerto USB, que también la conecta con la computadora para ver el monitor serie. ESP32 emisor SOY_EMISOR = true · dirección 1 ESP32 receptor SOY_EMISOR = false · dirección 2 TX1 · GPIO 17RX1 · GPIO 16GND RX1 · GPIO 16TX1 · GPIO 17GND lecturas y copias → ← confirmaciones masa común USB a la computadora (monitor serie)USB a la computadora (monitor serie) Lo que sale por TX de un lado entra por RX del otro: los cables se cruzan. 3,3 V en las dos placas, sin adaptadores de nivel.
Materiales.
CantidadComponenteNota
2Placas ESP32 de desarrollo (con UART1 disponible)Pueden ser de modelos distintos; el programa es el mismo con una constante cambiada
3Cables de conexión (macho-macho)TX a RX, RX a TX y masa a masa
2Cables USBAlimentación y monitor serie de cada placa. Dos ventanas de monitor, una por placa
OpcionalAnalizador lógico de 8 canales (o un osciloscopio)Para ver las tramas en el cable y medir los tiempos reales (tema 48)
OpcionalDos módulos RS-485 (MAX485 o similar), par trenzado y una resistencia de terminaciónPara repetir el taller con un cable largo y errores reales (temas 22 a 24)
OpcionalUn sensor BME280 en el emisorReemplaza a leerSensor(); ya se usó en los temas 16 a 20
Conexiones (con un ESP32 clásico; en otras variantes, usá los pines del puerto serie adicional).
DesdeHastaFunción
Emisor GPIO 17 (TX1)Receptor GPIO 16 (RX1)Lecturas y sus copias, hacia la receptora
Receptor GPIO 17 (TX1)Emisor GPIO 16 (RX1)Confirmaciones, hacia el emisor
GND del emisorGND del receptorReferencia común (sin ella, los niveles no significan nada)
GPIO 2 (en ambas, opcional)LED de la placaUn destello por cada retransmisión del emisor

La UART queda a 9600 baudios, 8 bits, sin paridad y un bit de parada (8N1): la velocidad del ejemplo de los temas 44 y 45. Es lenta a propósito. Con 10 bits por byte, un byte dura 1,04 ms y la trama de una lectura (16 bytes) ocupa 16,7 ms de cable, lo bastante para que los tiempos se vean en el monitor serie y en un analizador. A 115 200 baudios todo sería doce veces más rápido, y los números del taller se escalan en la misma proporción (herramienta 5). La consola de cada placa va por el USB a 115 200: no interfiere con la UART1 del enlace.

Si las dos placas se conectan con cables cortos, casi no habrá errores: un enlace de una UART sobre una mesa tiene una tasa de error del orden de 10−9 o menos (tema 43). Por eso el taller trae su propio generador de fallas; y la variante con RS-485 y varios metros de cable, cerca de un motor o de un cargador, permite repetir las pruebas con errores de verdad.

03 · El diseño

La trama y los mensajes

La trama de una lectura, byte a byte Una trama de 16 bytes: A5 es el inicio; 0C es el largo, 12; 02 es el destino; 01 es el origen; 01 es el tipo lectura; después nueve bytes de datos: 00 es la secuencia, 01 la versión, 07 el nodo, 2E 09 la temperatura 2350, 7C 15 la humedad 5500 y E4 0C la batería 3300; y por último 90 CB, el CRC-16. El CRC cubre los trece bytes que van desde el largo hasta el último dato. La LECTURA del nodo 7 (23,50 °C, 55,00 %, 3300 mV), secuencia 0, del emisor (1) a la receptora (2). A50C020101 0001072E097C15E40C 90CB SOFLENDSTSRCTIPO9 bytes de datosCRC-16 el CRC cubre estos 13 bytes, desde LEN hasta el último dato; el SOF no entra: si está dañado, la trama no se encuentra Los 9 bytes de datos de una LECTURA (el registro del tema 46, con versión y secuencia delante) 0001072E 097C 15E4 0C secuenciaversiónnodotemperaturahumedadbatería uint8uint8 = 1uint8int16 × 0,01 °Cuint16 × 0,01 %uint16 en mV 0170x092E = 23500x157C = 55000x0CE4 = 3300 Los enteros de varios bytes van en little-endian, armados con desplazamientos, nunca copiando memoria.

La trama es la del tema 44 (la del 42 con CRC-16): [SOF 0xA5][LEN][DST][SRC][TIPO][datos 0 a 32 bytes][CRC-16 bajo][CRC-16 alto]. LEN cuenta destino, origen, tipo y datos (de 3 a 35). El CRC es el CRC-16/Modbus (polinomio 0xA001 reflejado, valor inicial 0xFFFF) calculado sobre LEN hasta el último dato y se envía con el byte bajo primero. El receptor lo verifica calculando el CRC sobre todo, incluido el CRC recibido, y esperando un resultado de cero. El gasto de la trama es de 7 bytes, y la lectura de 9 bytes de datos ocupa 16 bytes en el cable.

Los dos tipos de mensaje del taller.
TipoSentidoDatosBytes en el cableA 9600 baudios
0x01 LECTURAEmisor → receptorasecuencia, versión, nodo, temperatura, humedad, batería (9 bytes)1616,7 ms
0x41 ACKReceptora → emisorla secuencia que confirma (1 byte)88,3 ms

Decisiones de diseño que conviene mirar de cerca:

  • La secuencia va en los datos y es de 8 bits. Un solo bit alcanzaría para un protocolo de parar y esperar, que es lo que dice el tema 45, pero con ocho el receptor puede además contar las lecturas que se perdieron por los saltos (sección 4), y el emisor distingue una confirmación vieja de una nueva aun después de varias demoras. Da la vuelta en 255, y el cálculo con aritmética de 8 bits lo tiene en cuenta.
  • La versión del formato va dentro de los datos, como pide el tema 46: el receptor rechaza una versión que no conoce en lugar de interpretarla. Los campos nuevos se agregarían al final, y el largo de la trama indicaría cuántos hay.
  • El ACK es mínimo: confirma solo la secuencia. Se podría agregar un código de estado («recibido y guardado», «recibido pero lleno»), que es la forma de pasar de «llegó» a «se procesó» (tema 45).
  • La dirección de origen está en la trama: el receptor contesta a SRC sin tenerlo grabado, y con más nodos serviría para llevar una secuencia por cada uno.
  • Las magnitudes son enteros escalados (centésimas y milivoltios), por lo que la lectura de 23,50 °C ocupa 2 bytes y no tiene errores de redondeo.

04 · Quién hace qué

Las reglas del emisor y del receptor

La máquina de estados del emisor Dos estados: LIBRE y ESPERANDO. De LIBRE a ESPERANDO se pasa cuando termina el período: se lee el sensor y se envía la lectura. De ESPERANDO a LIBRE se vuelve cuando llega la confirmación con la secuencia en curso, y entonces se cuenta un confirmado y se avanza la secuencia. Si vence el temporizador y quedan reintentos, se permanece en ESPERANDO: se cuenta un reintento y se reenvía con una espera doble. Si vence sin reintentos disponibles, se vuelve a LIBRE contando un mensaje rendido y avanzando la secuencia. Una confirmación con otra secuencia se ignora. LIBRE ESPERANDO pasó el períodoleer el sensor, enviar (intento 1) llega el ACK de la secuencia en cursoconfirmados++ · seq++ venció y quedan reintentosreintentos++ · reenviar · espera ×2 venció y no quedan reintentosrendidos++ · seq++ (la lectura se abandona) En ESPERANDO, un ACK con otra secuencia (una confirmación vieja) seignora y no cambia de estado.

El emisor es una máquina de dos estados que no bloquea nunca: el loop() sigue alimentando el decodificador mientras espera. En LIBRE espera al período y, al cumplirse, lee el sensor, manda la lectura con la secuencia actual y pasa a ESPERANDO con un temporizador de ESPERA_BASE_MS más un pequeño azar. Si llega la confirmación de esa secuencia, vuelve a LIBRE y la secuencia avanza. Si el temporizador vence, reenvía la misma lectura con la misma secuencia y duplica la espera (60, 120, 240 y 480 ms, con tope de tres duplicaciones), hasta MAX_REINTENTOS. Después de eso abandona: cuenta un mensaje rendido y sigue. La política es deliberada para un sensor: una lectura de hace dos segundos vale menos que la que se va a tomar ahora, así que no tiene sentido retrasar todas las demás por insistir con una. Para una orden («abrir la válvula») habría que insistir hasta confirmar o avisar de la falla.

El receptor sigue cinco reglas, en este orden:

  1. La trama tiene que verificar. La máquina de estados del tema 41 la encuentra por el SOF y el largo, y el CRC-16 decide: si el residuo no es cero, se descarta sin mirar nada más y se cuenta como error de trama.
  2. Tiene que ser para mí. Si el destino no es la propia dirección, se ignora (en un bus con más nodos, es lo que evita responder lo que no es de uno).
  3. El contenido tiene que ser plausible. Si el largo es corto, la versión es desconocida o algún valor está fuera de rango (una temperatura de 120 °C, una humedad de 317 %), se cuenta como inválida y no se confirma: si el emisor está mandando basura con CRC correcto, no hay que premiarlo con un ACK.
  4. Si la secuencia es nueva, se entrega; si es la misma, es una copia. La secuencia nueva se entrega a la aplicación (en el taller, se imprime), y el salto respecto de la anterior, calculado en aritmética de 8 bits, se suma a los huecos: son las lecturas que nunca llegaron. Una secuencia repetida es una retransmisión: se cuenta como copia y no se entrega.
  5. Se confirma siempre. Las nuevas y las copias, porque la copia existe justamente porque la confirmación anterior no llegó. Si la copia no se confirmara, el emisor seguiría reenviando hasta rendirse.
Un intercambio con una lectura perdida y una confirmación perdida Línea de tiempo entre el emisor y la receptora. En el instante cero, el emisor envía la lectura número 5 y la trama se pierde. A los 62 milisegundos, vence el primer temporizador y reenvía: la trama llega, la receptora la entrega a la aplicación y manda la confirmación, que se pierde. A los 184 milisegundos, vence el segundo temporizador, de 120 milisegundos más un azar, y el emisor reenvía por tercera vez: la trama llega, la receptora la reconoce como copia, no la entrega otra vez y la confirma. La confirmación llega al emisor a los 210 milisegundos y la secuencia pasa a 6. EmisorReceptora LECTURA #5 · intento 1se pierde LECTURA #5 · intento 2ACK #5se pierde LECTURA #5 · intento 3ACK #5 0 ms≈ 62 ms≈ 184 ms≈ 210 ms espera 60 ms+ azarespera 120 ms+ azar llega: secuencia nuevase entrega a la aplicación llega: la #5 otra vez (copia)no se entrega, pero se confirma confirmado a los ≈ 210 ms, y seq pasa a #6

El intercambio de la figura reúne los tres fallos posibles de un intento: la ida se pierde (la receptora no se entera), la vuelta se pierde (la receptora entregó y el emisor no lo sabe) y, al tercer intento, todo sale bien. El emisor no puede distinguir los dos primeros casos: en los dos, lo único que observa es silencio. La diferencia, la lectura que ya se había entregado, la cubre el número de secuencia: la receptora reconoce la copia, no entrega dos veces y confirma de nuevo. Con una pérdida del 20 % por sentido, este recorrido exacto es raro (0,2 × 0,8 × 0,2 × 0,64 = 2 %, una lectura de cada 50), pero alguna forma de reintento ocurre en más de una de cada tres lecturas (1 − 0,64 = 36 %).

05 · Fallas a pedido

El canal simulado

Un protocolo que nunca vio una falla no está probado. El problema de probarlo en la mesa es que el cable casi no falla, y esperar a que ocurra un error de verdad lleva horas y no se repite. La solución es inyectar las fallas: una función se interpone entre la UART y el decodificador, y con una probabilidad configurable hace que la trama desaparezca o que llegue con un bit invertido. Se aplica a la trama completa, antes de verificar el CRC, en las dos placas, de modo que cada sentido del enlace falla de forma independiente:

Las dos perillas del canal simulado.
PerillaQué haceQué ejercita
PERDIDA_PCTLa trama desaparece antes de llegar al decodificadorLos temporizadores del emisor, los reintentos, las copias y los huecos de secuencia. Equivale a un cable que corta, una colisión o un desborde de búfer
CORRUPCION_PCTSe invierte un bit al azar de la trama (de LEN en adelante)El CRC: la trama llega, no verifica y se descarta, con la cuenta de «rechazadas por CRC». Equivale al ruido eléctrico de los temas 43 y 44

Desde el punto de vista del protocolo, las dos perillas producen el mismo efecto: una trama que no llega a la aplicación y un silencio que el emisor tiene que interpretar. Por eso el modelo del laboratorio, que trata las dos como excluyentes, da resultados idénticos para «20 % de pérdida» y para «10 % de pérdida y 10 % de corrupción»; la diferencia está en los contadores: la corrupción aparece como errores de CRC. Lo que el CRC no detecta no está en el simulador: invertir un bit lo detecta siempre, y las ráfagas de hasta 16 bits también; lo que escapa son los datos aleatorios, con probabilidad de 1 en 65 536 (tema 44).

Qué no prueba el canal simulado, y hay que tenerlo presente: no ejercita las fallas eléctricas (niveles, adaptación de impedancias, terminación), los errores de encuadre de la UART (un bit de parada mal leído), el desborde de la cola de recepción ni los desajustes de velocidad. Prueba la lógica del protocolo, que es donde se esconden la mayoría de los errores de programación: una confirmación que se contesta mal, un contador que no avanza, una copia que se entrega dos veces. Las fallas del medio físico se prueban con el cable, un motor cerca y un analizador (tema 48).

Otro detalle útil: el azar de las placas es el del hardware y no se repite entre una ejecución y otra. Cuando hace falta reproducir una secuencia exacta de fallas (para depurar un caso raro, o para comparar dos políticas con las mismas pérdidas), se usa el modelo de Python o el laboratorio, que tienen un generador con semilla.

06 · Verificar con números

Plan de pruebas

El plan va de lo simple a lo difícil, y cada prueba tiene un resultado esperado calculado de antemano. Si una prueba falla, no se sigue con la siguiente: el diagnóstico queda más claro cuando las capas se verifican en orden. Todos los valores son para los parámetros del código (9600 baudios, 60 ms de espera base, 3 reintentos, un mensaje por segundo) y se calculan con la herramienta 3 o la 4.

Plan de pruebas del taller.
#PruebaQué se esperaSi no ocurre
0El CRC. Arrancar cualquiera de las dos placas y mirar el monitor«CRC-16/Modbus de 123456789: 4B37 (esperado 4B37)»La implementación del CRC está mal (polinomio, valor inicial, reflejo): nada de lo que sigue sirve
1Enlace limpio. PERDIDA_PCT = 0, CORRUPCION_PCT = 0. Dejar correr un minutoEmisor: enviados = confirmados, reintentos 0, rendidos 0, viaje medio de unos 26 ms (30 a 40 en la placa real, por el vaciado de la UART y el ciclo del programa). Receptora: nuevas = confirmadas, copias 0, huecos 0, inválidas 0Cables cruzados mal, masa sin unir, direcciones iguales, velocidad distinta en las dos placas
2Pérdida del 20 %. PERDIDA_PCT = 20. Dejar correr cinco minutosEntre 50 y 55 reintentos cada 100 lecturas, unas 23 copias en la receptora, un viaje medio de unos 70 ms, de 1 a 2 lecturas «rendidas» cada 100 que sin embargo llegaron (huérfanas) y casi ningún hueco (0,16 %)Si hay huecos de más, los reintentos no se hacen o la espera es corta; si hay duplicados entregados, falla el filtro de secuencia
3Corrupción del 20 %. PERDIDA_PCT = 0, CORRUPCION_PCT = 20Los mismos números que la prueba 2, pero la pérdida simulada es 0 y aparecen errores de CRC en las dos placas (alrededor del 20 % de las tramas recibidas); la «corrompidas» de la receptora cuenta las inyectadasSi no aparecen errores de CRC, el canal no está dañando; si una trama dañada se acepta, el CRC se calcula sobre un tramo equivocado
4Corte del cable. Con el enlace limpio, sacar el cable de TX del emisor durante unos 5 segundos y volver a ponerloEmisor: unas 5 lecturas rendidas, cada una tras 4 envíos. Receptora: al volver, un salto de secuencia, y «huecos» crece lo mismo que los rendidos. Rendidos menos huecos, cerca de ceroSi el emisor se queda esperando para siempre, falta el límite de reintentos; si la receptora no se recupera, la máquina de estados se atascó
5Reinicio de la receptora. Con el enlace limpio, apretar RESET en la receptoraDe 1 a 3 lecturas rendidas en el emisor; al volver, la receptora acepta la primera secuencia que ve (sin contar un hueco falso) y sigue con los contadores en ceroUn hueco enorme al arrancar: no se distingue «primera lectura» de «salto»
6Contenido imposible. En el emisor, forzar r.t = 12000 en leerSensor()Receptora: «inválidas» crece una por cada envío y no confirma. Emisor: todas las lecturas rendidas, con 4 envíos cada unaSi se confirman, falta la validación de rangos; si no se cuentan, el rechazo es silencioso
7Espera menor que el viaje. ESPERA_BASE_MS = 20 en el emisor, con el enlace limpioSin una sola pérdida, el emisor reenvía cada lectura una vez y la receptora cuenta una copia por lectura: el síntoma típico de un temporizador mal elegido—
8Período menor que el peor caso. PERIODO_MS = 400 con pérdida del 20 %El ritmo nominal pasa a 2,5 lecturas por segundo, pero cuando una lectura necesita reintentos largos (hasta unos 960 ms) la siguiente sale recién al terminar, y el ritmo real se desparrama: con 20 % de pérdida, unas 6 de cada 100 lecturas se atrasan (herramienta 4)—

Dos de estas pruebas (la 7 y la 8) rompen a propósito la política: sirven para reconocer el síntoma, de modo que cuando aparezca en un sistema real se identifique de inmediato. La prueba 7 deja un rastro muy particular (copias sin pérdidas) que casi siempre es un temporizador mal calculado, no un cable defectuoso. Y la 6 muestra el costo de la decisión de no confirmar lo inválido: el emisor insiste cuatro veces con una lectura que nunca va a ser aceptada. La alternativa, confirmar y descartar, ahorra ancho de banda pero oculta el problema al emisor; es una decisión de diseño que se documenta, no un detalle.

07 · El código completo

Un solo programa para las dos placas

El mismo archivo sirve para el emisor y para la receptora: cambia la constante SOY_EMISOR (verdadero en una, falso en la otra) antes de cargar. Se compone, de arriba abajo, de la configuración, el CRC (tema 44), el registro con su serialización campo por campo (tema 46), el canal simulado, el envío de una trama, el emisor de parar y esperar, el receptor con su filtro de secuencias (tema 45) y el decodificador de tramas (tema 41). El loop() alimenta el decodificador con lo que llega, atiende el temporizador del emisor, enciende el LED en cada retransmisión y emite el informe cada 20 segundos.

// ---------- Configuración ----------
const bool     SOY_EMISOR = true;                              // true en la placa del sensor; false en la receptora
const uint8_t  MI_DIR = SOY_EMISOR ? 1 : 2;
const uint8_t  OTRA_DIR = SOY_EMISOR ? 2 : 1;
const uint8_t  NODO = 7;                                       // identificador del sensor dentro del registro
const uint8_t  PERDIDA_PCT = 20;                               // % de tramas sanas que el canal simulado hace desaparecer
const uint8_t  CORRUPCION_PCT = 0;                             // % de tramas a las que el canal simulado invierte un bit
const uint8_t  MAX_REINTENTOS = 3;                             // 4 envíos como máximo por lectura
const uint32_t ESPERA_BASE_MS = 60;                            // unas 2,3 veces el viaje de ida y vuelta a 9600 baudios
const uint8_t  TOPE_EXP = 3;                                   // la espera deja de duplicarse: 60, 120, 240, 480, 480...
const uint32_t PERIODO_MS = 1000;                              // una lectura nueva por segundo
const uint32_t INFORME_MS = 20000;
const uint8_t  PIN_LED = 2;
#define DEPURAR 1                                              // 1: una línea por trama en el monitor serie

const uint8_t  SOF = 0xA5;
const uint8_t  MAX_LEN = 35;
const uint8_t  TIPO_LECTURA = 0x01, TIPO_ACK = 0x41;
const uint8_t  VERSION = 1;
const uint8_t  TAM_LECTURA = 9;                                // secuencia, versión, id, temperatura, humedad, batería
const uint32_t TIEMPO_BYTE_MS = 10;

// ---------- CRC-16/Modbus (tema 44) ----------
uint16_t crc16(const uint8_t *d, size_t n) {
    uint16_t crc = 0xFFFF;
    while (n--) {
        crc ^= *d++;
        for (uint8_t i = 0; i < 8; i++) crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
    }
    return crc;
}

// ---------- El registro y su serialización campo por campo (tema 46) ----------
struct Registro {
    uint8_t  id;
    int16_t  t;                                                // temperatura en centésimas de grado
    uint16_t h;                                                // humedad en centésimas de por ciento
    uint16_t bat;                                              // batería en milivoltios
};

void poner16(uint8_t *p, uint16_t v) { p[0] = v & 0xFF; p[1] = v >> 8; }
uint16_t leer16(const uint8_t *p) { return p[0] | (p[1] << 8); }

void armarLectura(uint8_t *d, uint8_t seq, const Registro &r) {
    d[0] = seq;
    d[1] = VERSION;
    d[2] = r.id;
    poner16(d + 3, (uint16_t)r.t);
    poner16(d + 5, r.h);
    poner16(d + 7, r.bat);
}

bool leerLectura(const uint8_t *d, uint8_t n, uint8_t &seq, Registro &r) {
    if (n < TAM_LECTURA || d[1] != VERSION) return false;      // largo mínimo y versión conocida
    Registro x;
    x.id = d[2];
    x.t = (int16_t)leer16(d + 3);
    x.h = leer16(d + 5);
    x.bat = leer16(d + 7);
    if (x.t < -4000 || x.t > 8500 || x.h > 10000 || x.bat < 1800 || x.bat > 5500) return false;   // valores imposibles
    seq = d[0];
    r = x;
    return true;
}

Registro leerSensor() {                                        // sensor simulado; se cambia por el BME280 real
    Registro r;
    r.id = NODO;
    r.t = 2350 + (int16_t)(300 * sin(millis() / 20000.0));
    r.h = 5500 + random(-200, 200);
    r.bat = 3300 - (millis() / 600000) % 100;
    return r;
}

// ---------- Canal simulado: se aplica a cada trama completa, antes de verificar el CRC ----------
uint32_t perdidasSim = 0, corrompidasSim = 0;

bool canal(uint8_t *b, uint8_t n) {                            // b empieza en LEN: el SOF ya se reconoció
    uint8_t x = random(100);
    if (x < PERDIDA_PCT) { perdidasSim++; return false; }      // la trama desaparece
    if (x < PERDIDA_PCT + CORRUPCION_PCT) {                    // la trama llega con un bit invertido
        b[random(n)] ^= 1 << random(8);
        corrompidasSim++;
    }
    return true;
}

// ---------- Envío de una trama ----------
void enviar(uint8_t dst, uint8_t tipo, const uint8_t *datos, uint8_t n) {
    uint8_t p[MAX_LEN + 4];
    p[0] = SOF;
    p[1] = n + 3;                                              // el largo cuenta destino, origen, tipo y datos
    p[2] = dst;
    p[3] = MI_DIR;
    p[4] = tipo;
    memcpy(p + 5, datos, n);
    uint16_t c = crc16(p + 1, n + 4);                          // desde LEN hasta el último dato
    p[5 + n] = c & 0xFF;                                       // el byte bajo primero
    p[6 + n] = c >> 8;
    Serial1.write(p, n + 7);
    Serial1.flush();
}

// ---------- Emisor: parar y esperar, con espera exponencial ----------
uint8_t  seq = 0, intento = 0;
bool     esperando = false;
Registro actual;
unsigned long venceEn = 0, enviadoEn = 0, ultimoMensaje = 0, ledHasta = 0;
uint32_t enviados = 0, confirmados = 0, reintentos = 0, rendidos = 0, sumaViaje = 0;

uint32_t espera(uint8_t n) {
    return (ESPERA_BASE_MS << (n < TOPE_EXP ? n : TOPE_EXP)) + random(ESPERA_BASE_MS / 4);
}

void mandarLectura() {
    uint8_t d[TAM_LECTURA];
    armarLectura(d, seq, actual);
    enviar(OTRA_DIR, TIPO_LECTURA, d, TAM_LECTURA);
    enviados++;
    venceEn = millis() + espera(intento);
#if DEPURAR
    Serial.printf("-> LECTURA #%u, intento %u\n", seq, intento + 1);
#endif
}

// ---------- Receptor: confirmar todo, entregar lo nuevo ----------
uint8_t  ultimaSeq = 0;
bool     hayUltima = false;
uint32_t nuevos = 0, copias = 0, huecos = 0, invalidos = 0;

void alPaquete(uint8_t dst, uint8_t src, uint8_t tipo, const uint8_t *d, uint8_t n) {
    if (dst != MI_DIR) return;                                 // no es para este nodo
    if (!SOY_EMISOR && tipo == TIPO_LECTURA) {
        uint8_t s;
        Registro r;
        if (!leerLectura(d, n, s, r)) { invalidos++; return; } // CRC correcto pero contenido imposible: no se confirma
        if (!hayUltima || s != ultimaSeq) {                    // secuencia nueva: se entrega a la aplicación
            if (hayUltima) huecos += (uint8_t)(s - ultimaSeq - 1);   // los saltos son lecturas que nunca llegaron
            ultimaSeq = s;
            hayUltima = true;
            nuevos++;
            Serial.printf("lectura %u: nodo %u, %.2f C, %.2f %%, %u mV\n", s, r.id, r.t / 100.0, r.h / 100.0, r.bat);
        } else {
            copias++;                                          // misma secuencia: es una copia, no se entrega otra vez
        }
        enviar(src, TIPO_ACK, &s, 1);                          // se confirma siempre: la confirmación anterior pudo perderse
    } else if (SOY_EMISOR && tipo == TIPO_ACK && esperando && n >= 1 && d[0] == seq) {
        esperando = false;                                     // la confirmación es de la lectura en curso
        confirmados++;
        sumaViaje += millis() - enviadoEn;
        seq++;
#if DEPURAR
        Serial.printf("<- ACK #%u a los %lu ms\n", d[0], millis() - enviadoEn);
#endif
    }                                                          // una confirmación con otro número es vieja: se ignora
}

// ---------- Decodificador de tramas: la máquina de estados del tema 41 con CRC-16 ----------
class Decodificador {
  public:
    typedef void (*AlRecibir)(uint8_t dst, uint8_t src, uint8_t tipo, const uint8_t *datos, uint8_t n);
    uint32_t errores = 0;
    Decodificador(AlRecibir cb) : alPaq(cb) {}
    void alimentar(uint8_t b) {
        ultimoByte = millis();
        switch (estado) {
            case ESPERA_SOF:
                if (b == SOF) estado = ESPERA_LEN;
                break;
            case ESPERA_LEN:
                if (b >= 3 && b <= MAX_LEN) { buf[0] = b; n = 1; faltan = b + 2; estado = CUERPO; }   // el largo, más 2 bytes de CRC
                else { errores++; estado = ESPERA_SOF; alimentar(b); }
                break;
            case CUERPO:
                buf[n++] = b;
                if (--faltan == 0) {
                    estado = ESPERA_SOF;
                    if (!canal(buf, n)) break;                 // el canal simulado la perdió
                    if (crc16(buf, n) == 0) alPaq(buf[1], buf[2], buf[3], buf + 4, n - 6);
                    else {
                        errores++;                             // CRC incorrecto: se vuelven a mirar los bytes, uno por uno
                        uint8_t copia[MAX_LEN + 3];
                        uint8_t m = n;
                        memcpy(copia, buf, m);
                        n = 0;
                        for (uint8_t i = 0; i < m; i++) alimentar(copia[i]);
                    }
                }
                break;
        }
    }
    void vigilarTiempo() {
        if (estado != ESPERA_SOF && millis() - ultimoByte > TIEMPO_BYTE_MS) { errores++; estado = ESPERA_SOF; n = 0; }
    }
  private:
    enum Estado { ESPERA_SOF, ESPERA_LEN, CUERPO } estado = ESPERA_SOF;
    AlRecibir alPaq;
    uint8_t buf[MAX_LEN + 3];
    uint8_t n = 0, faltan = 0;
    unsigned long ultimoByte = 0;
};

Decodificador decodificador(alPaquete);
unsigned long ultimoInforme = 0;

void setup() {
    Serial.begin(115200);
    Serial1.begin(9600, SERIAL_8N1, 16, 17);                   // RX = GPIO 16, TX = GPIO 17
    pinMode(PIN_LED, OUTPUT);
    Serial.printf("Taller de protocolo: soy el %s (direccion %u)\n", SOY_EMISOR ? "emisor" : "receptor", MI_DIR);
    uint8_t v[9] = { '1', '2', '3', '4', '5', '6', '7', '8', '9' };
    Serial.printf("CRC-16/Modbus de 123456789: %04X (esperado 4B37)\n", crc16(v, 9));
}

void loop() {
    while (Serial1.available()) decodificador.alimentar(Serial1.read());
    decodificador.vigilarTiempo();

    if (SOY_EMISOR) {
        if (!esperando && millis() - ultimoMensaje >= PERIODO_MS) {              // lectura nueva
            ultimoMensaje = millis();
            actual = leerSensor();
            intento = 0;
            enviadoEn = millis();
            mandarLectura();
            esperando = true;
        } else if (esperando && (long)(millis() - venceEn) >= 0) {               // venció el temporizador
            if (intento < MAX_REINTENTOS) {
                intento++;
                reintentos++;
                ledHasta = millis() + 80;                      // un destello por cada retransmisión
                mandarLectura();
            } else {
                esperando = false;                             // se abandona: la lectura siguiente reemplaza a esta
                rendidos++;
                Serial.printf("la lectura %u no se pudo confirmar\n", seq);
                seq++;
            }
        }
    }
    digitalWrite(PIN_LED, (long)(ledHasta - millis()) > 0);

    if (millis() - ultimoInforme >= INFORME_MS) {
        ultimoInforme = millis();
        if (SOY_EMISOR) {
            Serial.printf("enviados %lu | confirmados %lu | reintentos %lu | rendidos %lu | viaje medio %.1f ms | rechazadas por CRC %lu | perdidas por la simulacion %lu\n",
                          enviados, confirmados, reintentos, rendidos, confirmados ? (double)sumaViaje / confirmados : 0.0, decodificador.errores, perdidasSim);
        } else {
            Serial.printf("nuevas %lu | copias %lu | huecos %lu | invalidas %lu | rechazadas por CRC %lu | perdidas por la simulacion %lu | corrompidas %lu\n",
                          nuevos, copias, huecos, invalidos, decodificador.errores, perdidasSim, corrompidasSim);
        }
    }
}

Qué hay que notar:

  • El orden de las verificaciones en la recepción es el de las reglas de la sección 4: primero el canal simulado y el CRC en el decodificador, después el destino, la validación del contenido, la secuencia y, por último, la confirmación. Una trama dañada nunca llega a alPaquete.
  • El emisor no bloquea nunca. No hay delay() ni bucles de espera: la espera es una hora de vencimiento (venceEn) y la comparación (long)(millis() - venceEn) >= 0 funciona aunque millis() dé la vuelta a los 49 días. Mientras tanto, el loop() sigue leyendo la UART.
  • La confirmación vieja se ignora por la condición d[0] == seq y por esperando: una confirmación tardía de una lectura que ya se abandonó, o repetida, no cambia nada. Y una confirmación tardía de la lectura en curso, que llega después de la primera retransmisión, sí sirve: la prueba 7 la muestra.
  • El azar de la espera (random(ESPERA_BASE_MS / 4)) evita que dos emisores que fallaron al mismo tiempo reintenten al mismo tiempo. Con dos nodos no hace falta; con un bus de varios, sí.
  • Los huecos se calculan en 8 bits: (uint8_t)(s - ultimaSeq - 1) da bien la cuenta aunque la secuencia dé la vuelta (de 255 a 0). Si llega la 3 después de la 250, faltaron 8: la 251 a la 255, la 0, la 1 y la 2.
  • El registro se serializa como en el tema 46: enteros en little-endian armados con desplazamientos, la temperatura con signo recuperada con el cast a int16_t, y la validación de rangos antes de confiar en nada. Sin ella, un CRC correcto con una temperatura de 120 °C se imprimiría como una lectura buena.
  • El sensor es simulado (una oscilación lenta alrededor de 23,5 °C) para que el taller no dependa de ningún componente más. Para usar un BME280, se reemplaza leerSensor() por la lectura real, multiplicada por 100; el resto del programa no cambia.
  • El programa imprime una línea por trama con DEPURAR 1; a mayor tasa de mensajes conviene ponerlo en 0, porque el puerto serie es lento y puede demorar el ciclo.

08 · Entender lo que pasa

Leer los informes y medir

Cada 20 segundos, las dos placas imprimen sus contadores. Un informe típico con una pérdida simulada del 20 % (los valores son ilustrativos, de una ventana de 20 lecturas):

emisor:   enviados 31 | confirmados 19 | reintentos 11 | rendidos 0 | viaje medio 71.3 ms | rechazadas por CRC 0 | perdidas por la simulacion 5
receptor: nuevas 20 | copias 5 | huecos 0 | invalidas 0 | rechazadas por CRC 0 | perdidas por la simulacion 6 | corrompidas 0

Los dos informes se leen juntos, porque entre ellos hay relaciones que cualquier fallo del programa rompería. Son una forma de verificar el protocolo sin mirar el código:

Relaciones entre los contadores.
RelaciónPor qué se cumpleEn el informe de arriba
enviados = lecturas iniciadas + reintentosCada lectura se envía una vez, y cada reintento agrega un envío31 = 20 + 11
nuevas + copias = enviados − perdidas en la receptora − rechazadas por CRC en la receptoraTodo lo que sale por el cable llega a la receptora, salvo lo que el canal pierde o daña25 = 31 − 6 − 0
nuevas = confirmados + huérfanas (+ la lectura en curso)La receptora entrega todas las que llegan; el emisor confirma solo aquellas cuya confirmación le llegó. La diferencia son las que llegaron sin que él lo supiera20 = 19 + 0 + 1 en curso
rendidos − huecos = huérfanasUna lectura rendida o bien no llegó (y deja un hueco) o bien llegó sin que el emisor lo supiera0 − 0 = 0
copias = reenvíos que llegaron después de una entregaUna copia sale de una confirmación perdida o de un temporizador corto5 (con el enlace limpio y la espera correcta, 0)

La relación «rendidos − huecos» es la más útil para el diagnóstico. Si en una prueba el emisor se rinde de 12 lecturas de cada 100 y la receptora no cuenta ningún hueco, esas 12 lecturas llegaron todas: lo que falla es el camino de vuelta (el cable de las confirmaciones, la dirección de destino del ACK o un temporizador que vence antes de que llegue). Si, al revés, los huecos igualan a los rendidos, falla el camino de ida. Es el mismo razonamiento que en los temas 43 y 45, aplicado con contadores.

Cuánto debería dar

La cuenta teórica, con una pérdida o daño f por sentido y R reintentos, ya se usó en el tema 45: un intento resulta bien con probabilidad (1 − f)²; se confirma con 1 − (1 − (1 − f)²)^(R+1); llega a la receptora con 1 − f^(R+1). Para f = 0,2 y R = 3: 64 % por intento, 98,3 % confirmado, 99,84 % entregado (se pierde el 0,16 %) y 1,5 % que llega sin confirmación. La simulación de 1000 lecturas con semilla 42 da 985 confirmadas, 1000 entregadas, 15 huérfanas y 226 copias: dentro del azar de la cuenta. Con 20 lecturas por informe, los números oscilan mucho (una ventana puede tener 0 rendidas y la siguiente, 2); para comparar con la cuenta hay que acumular cientos de lecturas, y de ahí la importancia de dejar correr unos minutos.

Instrumentos de medición

  • Analizador lógico (tema 48) con dos canales sobre TX del emisor y TX de la receptora y un decodificador de UART a 9600 8N1: se ven los 16 bytes de la lectura, A5 0C 02 01 01 …, durante unos 16,7 ms; una pausa de aproximadamente 1 a 3 ms; y los 8 bytes de la confirmación, A5 04 01 02 41 …, durante unos 8,3 ms. La suma es el viaje de ida y vuelta del taller. En una retransmisión se ve la misma trama repetida a los 60 ms.
  • Osciloscopio sobre TX: la línea en reposo está en alto (3,3 V); cada byte es un bit de inicio en bajo, ocho bits y uno de parada, de 104 µs cada uno. El nivel y los flancos limpios se ven mejor con él que con el analizador, y sirven para repetir la medición con un cable largo.
  • El monitor serie con marcas de tiempo (la mayoría de los monitores las ofrecen): la línea «-> LECTURA #12, intento 2» debería llegar unos 60 ms después de «intento 1», y «<- ACK #12» debería informar un viaje de unos 26 ms si no hubo pérdidas.

09 · Analogía con programación

El protocolo en Python

Para ver el comportamiento del protocolo sin placas, y con fallas que se repiten exactamente, el modelo siguiente reproduce todo: la trama con su CRC-16 (con los bytes de verdad), el receptor con su filtro de secuencias, un canal que pierde o daña tramas con un generador de números aleatorios con semilla (el mismo que usa el laboratorio de esta página) y el emisor de parar y esperar con espera exponencial. Primero verifica el CRC y comprueba que los 128 daños posibles de un bit de la trama de ejemplo se detectan; después corre 1000 lecturas por cuatro canales distintos.

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

import struct

SOF, TIPO_LECTURA, TIPO_ACK = 0xA5, 0x01, 0x41
TOPE_EXP = 3          # la espera deja de duplicarse después de 3 duplicaciones


def crc16(datos):
    crc = 0xFFFF
    for b in datos:
        crc ^= b
        for _ in range(8):
            crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
    return crc


def armar(dst, src, tipo, datos):
    cuerpo = bytes([len(datos) + 3, dst, src, tipo]) + datos
    return bytes([SOF]) + cuerpo + crc16(cuerpo).to_bytes(2, "little")


def verificar(trama):
    return len(trama) >= 7 and trama[0] == SOF and trama[1] + 4 == len(trama) and crc16(trama[1:]) == 0


def lectura(seq, id_, t, h, bat):
    return struct.pack("<BBBhHH", seq, 1, id_, round(t * 100), round(h * 100), bat)


class Azar:
    """Generador con semilla (mulberry32): el mismo que usa el laboratorio de la página."""
    def __init__(self, semilla):
        self.a = semilla & 0xFFFFFFFF

    def siguiente(self):
        self.a = (self.a + 0x6D2B79F5) & 0xFFFFFFFF
        a = self.a
        t = ((a ^ (a >> 15)) * (a | 1)) & 0xFFFFFFFF
        t = ((t + ((t ^ (t >> 7)) * (t | 61))) & 0xFFFFFFFF) ^ t
        return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296


def canal(trama, azar, perdida, corrupcion, indice):
    """Pierde la trama, le invierte un bit o la deja pasar."""
    x = azar.siguiente()
    if x < perdida:
        return None
    if x < perdida + corrupcion:
        malo = bytearray(trama)
        malo[indice % len(malo)] ^= 1 << (indice % 8)
        return bytes(malo)
    return trama


class Receptor:
    def __init__(self):
        self.ultima = None
        self.entregados = self.duplicados = self.crc = self.huecos = 0

    def recibir(self, trama):
        """Devuelve la confirmación, o None si la trama se descarta."""
        if trama is None:
            return None
        if not verificar(trama):
            self.crc += 1
            return None
        if trama[4] != TIPO_LECTURA:
            return None
        seq = trama[5]
        if seq != self.ultima:                       # secuencia nueva: se entrega
            if self.ultima is not None:
                self.huecos += (seq - self.ultima - 1) % 256
            self.ultima = seq
            self.entregados += 1
        else:                                        # misma secuencia: es una copia
            self.duplicados += 1
        return armar(trama[3], trama[2], TIPO_ACK, bytes([seq]))     # se confirma siempre


def simular(perdida, corrupcion, reintentos, base, periodo, n, semilla, baud=9600):
    azar, rx = Azar(semilla), Receptor()
    t_dato = (9 + 7) * 10 / baud * 1000
    t_ack = (1 + 7) * 10 / baud * 1000
    ahora = 0.0
    confirmados = huerfanos = perdidos = envios = crc_emisor = suma_lat = 0
    for m in range(n):
        inicio = max(m * periodo, ahora)
        s, fin, llegadas = inicio, None, []
        antes = rx.entregados
        dato = armar(2, 1, TIPO_LECTURA, lectura(m & 255, 7, 23.5, 55, 3300))
        for k in range(reintentos + 1):
            espera = base * 2 ** min(k, TOPE_EXP) + int(azar.siguiente() * base / 4)
            vence = s + espera
            envios += 1
            ack = rx.recibir(canal(dato, azar, perdida, corrupcion, m + k))
            if ack is not None:
                vuelta = canal(ack, azar, perdida, corrupcion, m + k)
                if vuelta is not None and not verificar(vuelta):
                    crc_emisor += 1
                elif vuelta is not None and vuelta[5] == m & 255:
                    llegadas.append(s + t_dato + 1 + t_ack)
            if llegadas and min(llegadas) <= vence:
                fin = min(llegadas)
                break
            s = vence
        if fin is not None:
            confirmados += 1
            suma_lat += fin - inicio
            ahora = fin
        else:
            ahora = s
            if rx.entregados > antes:
                huerfanos += 1
            else:
                perdidos += 1
    return dict(confirmados=confirmados, entregados=rx.entregados, duplicados=rx.duplicados,
                huerfanos=huerfanos, perdidos=perdidos, huecos=rx.huecos, envios=envios,
                crc=rx.crc + crc_emisor, latencia=suma_lat / max(confirmados, 1))


ejemplo = armar(2, 1, TIPO_LECTURA, lectura(0, 7, 23.5, 55, 3300))
print("trama:", ejemplo.hex(" ").upper(), f"({len(ejemplo)} bytes)")
print("CRC-16/Modbus de 123456789:", f"{crc16(b'123456789'):04X}", "| verifica:", verificar(ejemplo))

un_bit = sum(not verificar(bytes(ejemplo[i] ^ (1 << b) if j == i else ejemplo[j] for j in range(len(ejemplo))))
             for i in range(len(ejemplo)) for b in range(8))
print("daños de un bit detectados:", un_bit, "de", len(ejemplo) * 8)

for perdida, corrupcion in ((0, 0), (0.2, 0), (0.1, 0.1), (0.5, 0)):
    r = simular(perdida, corrupcion, 3, 60, 1000, 1000, 42)
    print(f"pérdida {perdida:.0%}, corrupción {corrupcion:.0%}:",
          f"confirmados {r['confirmados']}, entregados {r['entregados']}, copias {r['duplicados']},",
          f"huérfanos {r['huerfanos']}, perdidos {r['perdidos']}, huecos {r['huecos']},",
          f"envíos {r['envios']}, rechazos por CRC {r['crc']}, latencia {r['latencia']:.1f} ms")

Resultado: la trama de ejemplo es A5 0C 02 01 01 00 01 07 2E 09 7C 15 E4 0C 90 CB (16 bytes) y el CRC-16/Modbus de «123456789» da 4B37; la trama verifica y los 128 daños de un bit se detectan (128 de 128). Con 1000 lecturas y la semilla 42: sin fallas, 1000 confirmadas, 1000 envíos y 26,0 ms de latencia (el viaje mínimo). Con 20 % de pérdida: 985 confirmadas, 1000 entregadas, 226 copias, 15 huérfanas (llegaron sin que el emisor lo supiera), ninguna perdida de verdad, 1514 envíos y 69,1 ms de latencia media. Con 10 % de pérdida y 10 % de corrupción: exactamente los mismos números (el protocolo no distingue una trama perdida de una dañada), más 271 tramas rechazadas por el CRC. Con 50 % de pérdida: 706 confirmadas, 941 entregadas, 419 copias, 235 huérfanas, 59 perdidas (59 huecos de secuencia), 2697 envíos y 153,1 ms de latencia.

Tres lecciones del modelo. Primera: «confirmada» y «entregada» no son lo mismo. Con 50 % de pérdida se entregan 941 y se confirman 706: 235 lecturas llegaron sin que el emisor se enterara, y el emisor las cuenta como fracasos. Segunda: el CRC es un detector perfecto en este rango: no hubo un solo daño de un bit que escapara, y el sistema se comporta con la corrupción exactamente como con la pérdida, que es lo que permite razonar con un solo número de «tramas que no llegan». Tercera: la latencia crece más que la pérdida: con 20 % de pérdida (1,5 envíos por lectura), la latencia media sube de 26 a 69 ms, casi el triple, porque cada reintento paga la espera completa (60 ms) y no solo el viaje.

10 · Exploración interactiva

Laboratorio: trama, recepción, probabilidades, simulación y tiempos

El laboratorio tiene cinco herramientas, que reproducen en la página lo que hace el taller. La primera arma una trama con su CRC-16, la segunda la recibe paso a paso, con un daño opcional que se inyecta a mano, la tercera calcula las probabilidades de la entrega con reintentos, la cuarta simula el protocolo completo con las políticas del emisor y del receptor y la quinta calcula los tiempos y comprueba que el peor caso cabe en el período de la lectura.

1. Armar una trama con CRC-16

Trama completa—
Largo (LEN)—
CRC-16/Modbus—
Tamaño—
Tiempo en el cable—
Verificación—

Escribí los campos del mensaje.

2. Recibir una trama, con un daño opcional

Lo que llega—
Inicio (SOF)—
Largo—
CRC—
Campos—
Veredicto—

Escribí una trama.

3. Probabilidades de la entrega con reintentos

Una trama—
Un intento—
Confirmadas—
Entregadas—
Huérfanas—
Perdidas de verdad—
Envíos por lectura—
Latencia esperada—
Veredicto—

Escribí los porcentajes.

4. Simulación del protocolo completo

Confirmadas—
Entregadas a la aplicación—
Copias—
Huérfanas—
Perdidas de verdad—
Envíos—
Tramas dañadas y perdidas—
Latencia—
Ocupación del enlace—
Cadencia—

Escribí los parámetros.

    5. Tiempos y período

    Tramas—
    Viaje de ida y vuelta—
    Espera contra viaje—
    Esperas sucesivas—
    Peor caso—
    Contra el período—
    Ocupación mínima—
    Veredicto—

    Escribí los parámetros.

    En la herramienta 1, el CRC es el CRC-16/Modbus del tema 44 (el valor de prueba de «123456789» es 4B37) y se envía con el byte bajo primero; el tiempo cuenta 10 bits por byte. En la 2, el receptor aplica las verificaciones en el orden del decodificador: SOF, largo, CRC y, solo si el CRC es correcto, los campos y los rangos. En las herramientas 3 y 4, la pérdida y la corrupción son excluyentes: la trama se pierde con una probabilidad, se daña con otra o llega sana. La simulación sigue la política del código del taller (secuencia, espera base × 2n con tope de tres duplicaciones, azar de hasta un cuarto de la base, abandono tras el último reintento) y usa el mismo generador con semilla que el modelo de Python, de modo que con la misma semilla dan lo mismo. En la 5, el proceso de cada placa entre recibir y contestar se toma de 1 ms.

    Recorrido guiado

    1. En la herramienta 1, con los valores por defecto: A5 0C 02 01 01 00 01 07 2E 09 7C 15 E4 0C 90 CB, LEN = 12, CRC 0xCB90 enviado como 90 CB, 16 bytes (44 % de gasto) y 16,7 ms. Cambiando a ACK con destino 1, origen 2 y secuencia 5: A5 04 01 02 41 05 85 AF, 8 bytes y 8,33 ms. Con −5,25 °C y secuencia 1: la temperatura viaja como F3 FD (−525 en complemento a dos) y el CRC cambia a 3E 03. A 115 200 baudios, la lectura ocupa 1,39 ms.
    2. En la herramienta 2, con la trama de ejemplo: «aceptada». Probá dañar el byte 1 (el inicio): A5 pasa a A4 y la trama ni se ve. El byte 2 (el largo), bit 3: LEN pasa de 12 a 4 y el CRC calculado sobre un tramo equivocado no coincide. El byte 9 (la temperatura), bit 2: 2E pasa a 2A, el CRC calculado da 0x4F91 contra el recibido 0xCB90. Los bytes 15 y 16 (el CRC mismo): rechazada. Cualquier byte, cualquier bit: rechazada. Después pegá A5 0C 02 01 01 00 01 07 E0 2E 7C 15 E4 0C B4 E2 (una temperatura de 120 °C con CRC correcto, armada con la herramienta 1): pasa el CRC pero la rechaza la plausibilidad.
    3. En la herramienta 3, con 20 % de pérdida y 3 reintentos: un intento sale bien con 64 %, se confirma el 98,3 %, llega a la receptora el 99,8 %, el 1,52 % llega sin que el emisor lo sepa, el 0,16 % se pierde de verdad, hay 1,54 envíos por lectura y 71,7 ms de latencia. Con 40 %: 83,2 % confirmado, 14,2 % huérfano, 2,56 % perdido y 129 ms. Con 20 % y 8 reintentos: la pérdida baja de 0,16 % a menos de 0,01 %, pero la latencia sube a 90,8 ms.
    4. En la herramienta 4, con los valores por defecto y semilla 42: 985 confirmadas de 1000, las 1000 entregadas, 226 copias descartadas (ninguna entregada dos veces), 15 huérfanas, ninguna perdida, 1514 envíos y 69,1 ms: los mismos números que el modelo de Python. Con 10 % de pérdida y 10 % de corrupción: los mismos resultados, con 271 tramas rechazadas por el CRC. Con 50 % de pérdida: 706 confirmadas, 941 entregadas, 235 huérfanas y 59 perdidas de verdad. Con pérdida 0 y espera base de 20 ms (menor que el viaje de 26 ms): 1000 confirmadas, pero 2000 envíos y 1000 copias, sin una sola pérdida: el síntoma de un temporizador corto. Con 20 % y un período de 400 ms: 58 lecturas se atrasan.
    5. En la herramienta 5, con 9600 baudios, 60 ms, 3 reintentos y 1000 ms: la lectura tarda 16,7 ms, la confirmación 8,33 ms, el viaje es de 26,0 ms, la espera es 2,3 veces el viaje, las esperas son 60, 120, 240 y 480 ms, el peor caso es 960 ms y cabe con 40 ms de sobra. Con 5 reintentos: 1,95 s, no cabe. Con 115 200 baudios y 10 ms: viaje de 3,08 ms y peor caso de 160 ms. Con 30 ms de espera base a 9600: la relación baja a 1,2 y el veredicto avisa que se reenviará aunque no haya pérdidas.
    Desafío: de dos placas a un bus de doce nodos

    Un invernadero tiene 12 sensores que mandan una lectura cada 10 s a una central, por un bus RS-485 de un solo par (half duplex, 9600 baudios, 8N1), con el mismo protocolo. Con las herramientas y lo visto: (1) Capacidad: una lectura y su confirmación ocupan 16,7 + 8,3 = 25 ms de cable; 12 nodos, 300 ms cada 10 s, un 3 %. Con 20 % de pérdida, 1,54 envíos de 16,7 ms más 1,23 confirmaciones de 8,3 ms: unos 36 ms por lectura y 432 ms por ronda, un 4,3 %. El medio sobra. (2) El receptor: la receptora del taller guarda una sola ultimaSeq. Con doce nodos, las secuencias de unos y otros se entremezclan y todo parece nuevo o repetido al azar; hay que guardar una secuencia por origen (un arreglo indexado por SRC) y contar huecos y copias por nodo. (3) Colisiones: en un bus compartido, dos nodos que transmiten a la vez se pisan y ninguna trama verifica. El CRC lo detecta (se descartan las dos), los temporizadores reintentan, y el azar del reintento (hasta 15 ms) apenas separa tramas de 16,7 ms: hay que ampliar el azar o, mejor, evitar la colisión: que un maestro pregunte a cada nodo por turno (como Modbus, tema 25), o que cada uno arranque con un desfase de 833 ms (10 s / 12) respecto de los otros. Con desfase, el peor caso de 960 ms de un nodo invade el turno del siguiente: se acorta la política o se alarga la ronda. (4) Half duplex: la placa tiene que pasar el transceptor a transmisión antes de enviar y volver a recepción después del último bit (con Serial1.flush() ya está resuelto lo segundo), y la central debe esperar un par de milisegundos antes de contestar. (5) Falla de un nodo: un nodo apagado no afecta a los demás, pero la central debe detectar que dejó de hablar (sin lecturas por varios períodos). Es el contenido de los temas 41 a 46 más el 25 en un mismo problema.

    11 · Seguir mejorando

    Ampliaciones y buenas prácticas

    • NACK. Hoy la receptora calla ante una trama dañada. Se puede contestar con un NACK cuando el largo y el CRC dicen que algo llegó y estaba mal, para acelerar la recuperación en lugar de esperar el temporizador. Hay que decidir qué hacer con un NACK que lleva una secuencia que, por estar dañada la trama, no es de fiar (por eso conviene que el NACK diga solo «mandá de nuevo», sin número).
    • Tiempo de espera adaptativo. En lugar de una espera fija, estimar el viaje de ida y vuelta con un promedio móvil de los tiempos medidos y su variación, como hace TCP, y fijar la espera en el promedio más unas cuatro veces la variación. Se ajusta solo al cambiar la velocidad o el cable.
    • Ventana deslizante. Con parar y esperar, el enlace está ocupado un 2,5 % del tiempo. Para un archivo grande habría que mandar varias tramas antes de recibir la primera confirmación, con ventana y confirmaciones acumulativas (tema 45).
    • Versión y campos nuevos. Agregar un campo al final del registro, subir la versión y comprobar que la receptora de la versión 1 sigue funcionando, o pasar a tipo-largo-valor (tema 46).
    • Un tipo de mensaje de comando (por ejemplo, «cambiar el período») con una semántica de «exactamente una vez»: la secuencia permite descartar la orden repetida, pero el efecto de la orden tiene que ser idempotente o estar protegido (tema 45).
    • Autenticación. Un CRC protege contra el ruido, no contra una persona. Una trama puede falsificarse con un CRC correcto o repetirse tal cual (el número de secuencia no alcanza). Se necesita una firma o un código de autenticación y un contador que no se repita (tema 49).
    • Pasar a RS-485 con un cable largo para tener errores reales: las mismas pruebas, con contadores comparados contra los del canal simulado.
    • Una puerta de enlace que reciba las lecturas confirmadas y las publique por MQTT con el formato JSON del tema 46 (tema 51).
    • Buenas prácticas. Un contador por cada cosa que puede salir mal; un informe periódico; una tabla de campos documentada; vectores de prueba (la trama de ejemplo, en hexadecimal); parámetros en constantes con nombre; y una prueba de cada falla antes de darse por conforme.

    12 · Comprensión

    Confusiones frecuentes

    «Con 20 % de pérdida y 3 reintentos, no se pierde ninguna lectura»

    Se pierde una de cada 600 (0,16 %), y otras 15 de cada 1000 llegan pero el emisor las cuenta como fracasos. «Casi ninguna» no es «ninguna»: los reintentos bajan la pérdida de 20 % a 0,16 %, pero no la anulan. Si hace falta cero, hay que reintentar hasta confirmar, con el costo de la latencia y la acumulación.

    «Si el emisor se rindió, el receptor no recibió la lectura»

    No necesariamente. El emisor solo sabe que no llegó la confirmación. Si se perdieron todas, la lectura puede haber llegado y entregado (huérfana). La receptora es la que sabe qué recibió, y por eso se comparan los dos informes: rendidos menos huecos son las lecturas que llegaron sin que el emisor lo supiera.

    «Una confirmación perdida no tiene importancia, porque el dato ya llegó»

    Tiene la importancia de provocar una copia. Sin número de secuencia, la copia se entregaría como una lectura nueva y el dato aparecería dos veces. Con la secuencia, la receptora reconoce la copia, la descarta y, sin embargo, la confirma: si no lo hiciera, el emisor seguiría reintentando hasta rendirse.

    «El CRC corrige los errores»

    El CRC los detecta. Quien repara es el reintento: la receptora descarta la trama dañada, el emisor no recibe la confirmación y reenvía. La corrección verdadera (códigos como el de Hamming del tema 45) necesita más bits y se usa donde no se puede reenviar.

    «Un canal simulado no prueba nada porque no es el cable de verdad»

    Prueba la lógica del protocolo, que es donde se esconden los errores de programación: una copia que se entrega dos veces, un contador que no avanza, una confirmación que no se manda. No prueba la capa física (niveles, ruido, terminación), que se prueba con el cable y los instrumentos. Las dos pruebas se complementan.

    «El CRC es correcto, así que la lectura es buena»

    Garantiza que los bytes llegaron como salieron, no que sean sensatos. Un sensor averiado puede mandar 120 °C con un CRC perfecto. Por eso la receptora valida los rangos y la versión, y cuenta lo inválido por separado de los errores de CRC.

    «Más reintentos siempre es mejor»

    Reduce las pérdidas, pero alarga el peor caso: con 5 reintentos y una espera base de 60 ms, el emisor tarda casi 2 s en rendirse y, con una lectura por segundo, el siguiente mensaje se atrasa. Además, insistir con una lectura vieja retrasa las nuevas. La política tiene que caber en el período (herramienta 5).

    «Cuanto más corta la espera, mejor»

    Una espera menor que el viaje de ida y vuelta genera retransmisiones que nadie necesitaba: sin una sola pérdida, el emisor reenvía y la receptora recibe copias de todo (prueba 7). Se toma entre 2 y 3 veces el viaje, y se duplica en cada reintento para no agravar una congestión.

    «Si funcionó en la mesa con los cables cortos, el protocolo está probado»

    En la mesa casi no hay errores y ninguna rama de recuperación se ejecuta. Un protocolo se prueba haciéndolo fallar: pérdidas, corrupción, un cable desconectado, un reinicio de la receptora, un contenido imposible. El código que nunca se ejecutó en una prueba no se puede dar por bueno.

    13 · Práctica guiada

    Predecí antes de comprobar

    1. Tiempos a otra velocidad

    A 19 200 baudios, ¿cuánto dura la trama de una lectura (16 bytes) y la de su confirmación (8 bytes)? ¿Cuánto es el viaje de ida y vuelta con 1 ms de proceso, y qué espera base elegirías?

    Ver solución

    Lectura: 16 × 10 / 19 200 = 8,33 ms. Confirmación: 8 × 10 / 19 200 = 4,17 ms. Viaje: 8,33 + 1 + 4,17 = 13,5 ms. Con la regla de 2 a 3 veces el viaje, una espera base de unos 30 ms (2,2 veces). Con la política del taller (30, 60, 120 y 240 ms), el peor caso es de 450 ms más el azar, la mitad que a 9600.

    2. Una trama a mano

    Una confirmación del nodo 2 al nodo 1 para la secuencia 5: ¿cuánto vale LEN, cuántos bytes tiene la trama y en qué orden van los bytes del CRC? Verificalo con la herramienta 1.

    Ver solución

    LEN cuenta destino, origen, tipo y un byte de datos: 4. La trama es A5 04 01 02 41 05 más el CRC: 8 bytes. El CRC-16 (calculado sobre 04 01 02 41 05) es 0xAF85 y se envía con el byte bajo primero: 85 AF. La trama completa es A5 04 01 02 41 05 85 AF.

    3. Probabilidades

    Con 10 % de pérdida por sentido y 2 reintentos, ¿qué fracción de las lecturas se confirma, cuál llega a la receptora y cuántas son huérfanas?

    Ver solución

    Un intento sale bien con (1 − 0,1)² = 0,81. Con 3 intentos, se confirma 1 − 0,19³ = 99,31 %. Llega a la receptora 1 − 0,1³ = 99,9 %. Huérfanas: 99,9 − 99,31 = 0,59 %. Se pierde de verdad 0,1 %, una de cada mil.

    4. Que quepa en el período

    Una política tiene espera base de 40 ms, 5 reintentos y tope de tres duplicaciones. ¿Cuál es el peor caso? ¿Cabe en un período de 1 s? ¿Qué cambiarías?

    Ver solución

    Esperas: 40, 80, 160, 320, 320 y 320 ms = 1240 ms, más hasta 6 × 10 = 60 ms de azar: 1300 ms. No cabe en 1 s. Con 3 reintentos (40 + 80 + 160 + 320 = 600 ms más 40 ms de azar, 640 ms) sí cabe, a costa de que la pérdida sea algo mayor. O se alarga el período a 2 s.

    5. Un diagnóstico por contadores

    Después de un rato, el emisor informa 12 lecturas rendidas de 100, y la receptora, 100 nuevas, 0 huecos y 30 copias. ¿Qué camino falla, el de ida o el de vuelta? ¿Dónde mirarías?

    Ver solución

    Las 100 lecturas llegaron (nuevas 100, 0 huecos), así que falla el camino de vuelta: las confirmaciones. Las 30 copias son reintentos de lecturas cuya confirmación se perdió. Se miran el cable que va de la receptora al emisor (TX de la receptora a RX del emisor), el destino con que la receptora arma el ACK (src de la lectura) y la pérdida configurada en el emisor, que es quien recibe las confirmaciones.

    6. Una confirmación perdida

    La receptora recibe la lectura 7 por segunda vez, porque su ACK se perdió. ¿Qué hace, y por qué no se limita a descartarla en silencio?

    Ver solución

    Reconoce que la secuencia es la misma que la última (s == ultimaSeq), suma una copia, no entrega la lectura otra vez y vuelve a confirmar el 7. Si se limitara a descartarla, el emisor, que sigue sin oír la confirmación, reenviaría hasta rendirse, y contaría como fracaso una lectura que ya está entregada. Confirmar es lo que corta el ciclo.

    7. La vuelta de la secuencia

    La receptora tenía como última secuencia la 250 y ahora recibe la 3. ¿Cuántas lecturas se perdieron, según la cuenta de huecos del programa?

    Ver solución

    (uint8_t)(3 − 250 − 1) = (uint8_t)(−248) = 256 − 248 = 8. Faltaron la 251, 252, 253, 254, 255, 0, 1 y 2. La cuenta en 8 bits da bien aunque la secuencia dé la vuelta; con una cuenta en un entero más ancho daría −248 y habría que corregirla. Con más de 255 lecturas seguidas perdidas la cuenta se equivocaría: es el límite de una secuencia de 8 bits.

    8. Un CRC correcto con un valor imposible

    El emisor manda una lectura con 120 °C y un CRC válido. ¿Qué hace la receptora y qué ve el emisor? ¿Qué alternativa de diseño hay y qué costo tiene?

    Ver solución

    La receptora acepta la trama (CRC correcto), pero leerLectura la rechaza por el rango: suma una inválida y no confirma. El emisor no oye nada, reintenta 3 veces (4 envíos) y la abandona; cada lectura cuesta 4 envíos y sale como fracaso. La alternativa es confirmar y descartar: el emisor no insiste y el ancho de banda se ahorra, pero no se entera de que su sensor está mal. Para detectarlo se podría contestar con un NACK de «contenido inválido». Es una decisión de diseño, y se documenta.

    14 · Síntesis

    Ideas para recordar

    • Un protocolo es la suma de capas con funciones distintas: encuadre (SOF, largo, máquina de estados), direcciones, detección de errores (CRC-16), recuperación (ACK, secuencia, reintentos) y formato del contenido (campos, orden de bytes, versión). Cada una resuelve un problema que las otras no ven; un protocolo robusto las tiene todas.
    • La trama del taller es [A5][LEN][DST][SRC][TIPO][datos][CRC-16 bajo][CRC-16 alto]; el CRC cubre desde LEN hasta el último dato. Una lectura de 9 bytes de datos ocupa 16 bytes (16,7 ms a 9600 baudios) y su confirmación, 8 bytes (8,3 ms), con un viaje de ida y vuelta de unos 26 ms.
    • El emisor es de parar y esperar y no bloquea: envía, arma un temporizador, reenvía la misma lectura con una espera que se duplica (60, 120, 240, 480 ms) y abandona tras el último reintento, porque en un sensor la lectura nueva reemplaza a la vieja.
    • La receptora aplica cinco reglas en orden: la trama verifica, es para ella, el contenido es plausible, si la secuencia es nueva se entrega y si es repetida se cuenta como copia, y se confirma siempre, porque una copia existe porque la confirmación anterior se perdió. Los saltos de secuencia son las lecturas perdidas.
    • «Confirmada» y «entregada» no son lo mismo: con 20 % de pérdida por sentido y 3 reintentos, se confirma el 98,3 %, se entrega el 99,84 % y se pierde el 0,16 %; la diferencia, las huérfanas, son lecturas que llegaron sin que el emisor lo supiera. Rendidos menos huecos son las huérfanas.
    • Un protocolo que nunca vio una falla no está probado. El canal simulado (pérdida y corrupción en la recepción) ejerce la lógica de recuperación; el CRC convierte el daño en pérdida, por lo que ambas perillas dan los mismos resultados salvo por el contador de errores de CRC. No sustituye a las pruebas físicas.
    • La política tiene que caber en el período: el peor caso es la suma de las esperas más el azar (960 ms con los valores del taller, para un período de 1 s). Una espera menor que el viaje genera copias sin pérdidas; demasiados reintentos atrasan las lecturas nuevas.
    • Los contadores de los dos extremos se leen juntos y cumplen relaciones que permiten diagnosticar sin mirar el código: enviados = lecturas + reintentos, nuevas + copias = lo que llegó, rendidos − huecos = huérfanas. Una prueba de cada falla (enlace limpio, pérdida, corrupción, cable cortado, reinicio, contenido imposible) es la verificación del protocolo.

    ¿Listo para el siguiente tema?

    Antes de avanzar, calculá la probabilidad de que una lectura se confirme con 15 % de pérdida por sentido y 4 reintentos; explicá qué dice cada contador del emisor y de la receptora cuando el emisor informa 8 rendidos de 100 y la receptora, 8 huecos; y decidí si una espera base de 25 ms es adecuada a 9600 baudios para este protocolo y por qué. Si podés justificar las tres respuestas, entendés cómo funciona y cómo se prueba un protocolo con reintentos.

    En el próximo tema se abre el último bloque del curso: depurar comunicaciones. Cuando algo no funciona, el programa no dice por qué; hacen falta instrumentos. Se estudian el analizador lógico y el osciloscopio, la decodificación de protocolos (UART, I²C, SPI, CAN), los sniffers y los errores típicos, con las tramas de este taller como material de prueba.