Cómo se comunican una Raspberry Pi y una ESP32 por MODBUS sobre UART: tramas, direcciones, CRC-16, registros, watchdog y la cadena I2C, a partir de un trabajo de Sistemas Embebidos en la UnB

Aprende a conectar una Raspberry Pi y una ESP32 con MODBUS sobre UART: tramas, CRC-16, registros, watchdog e I2C.

Avatar de Pedro Druck
Pedro Druck
Cómo se comunican una Raspberry Pi y una ESP32 por MODBUS sobre UART: tramas, direcciones, CRC-16, registros, watchdog y la cadena I2C, a partir de un trabajo de Sistemas Embebidos en la UnB

En la asignatura de Fundamentos de Sistemas Embebidos, el primer trabajo del semestre es un simulador de ascensores. Una ESP32 representa todo el edificio —tres cabinas, una cola de llamadas y un bus de energía—, y la Raspberry Pi debe controlarlo. En la segunda entrega, el desafío era hacer que ambas se comunicaran por el puerto serie: primero con un protocolo simple y después con MODBUS sobre UART, leyendo y escribiendo los registros reales del simulador.

Imagen de apertura: una Raspberry Pi y una ESP32 se comunican mediante un cable serie, con un ascensor estilizado al fondo
Imagen de apertura: una Raspberry Pi y una ESP32 se comunican mediante un cable serie, con un ascensor estilizado al fondo

El código estaba listo y todas las pruebas unitarias pasaban. Aun así, fue en el banco de pruebas, con la placa real y el dashboard abierto al lado, donde ocurrió la mayor parte del aprendizaje. Este artículo reúne lo que tuve que entender para explicar —y grabar— esta entrega: qué hace cada byte de una trama, por qué el CRC no era el de los tutoriales, qué es un watchdog y cómo el I2C cierra la cadena.

Un cable, dos protocolos

La primera confusión es de vocabulario. UART no es un protocolo de mensajes: es el canal. Es el puerto serie (/dev/serial0, a 115200 bps, 8 bits, sin paridad y 1 bit de parada) que solo sabe transportar bytes de un lado al otro.

Sobre ese mismo cable funcionan dos protocolos de mensajes, y la ESP32 entiende ambos al mismo tiempo:

Raspberry Pi ──── UART (cable serie) ──── ESP32 │ ├─ Protocolo simplificado → comando + dato + matrícula └─ MODBUS modificado → dirección + función + datos + matrícula + CRC

Raspberry Pi ──── I2C ──── sensor BMP280 (otro cable, otro dispositivo)

Además, existe un tercer bus, el I2C, que no llega a la ESP32: conecta la Raspberry con un sensor de temperatura y presión. Llegaremos a él al final.

Del protocolo simplificado a MODBUS sobre UART

Anatomía de una trama: bloques de bytes coloreados según su función, como dirección, función, datos y verificación
Anatomía de una trama: bloques de bytes coloreados según su función, como dirección, función, datos y verificación

El protocolo simplificado es lo más compacto posible. Para solicitar un entero, la Raspberry envía el byte de comando seguido de los seis últimos dígitos de la matrícula del estudiante —cada dígito como byte crudo (el dígito 6 se convierte en 0x06, no en el carácter ASCII 0x36). Con la matrícula de ejemplo del enunciado, 654321:

Simplificado: A1 │ 06 05 04 03 02 01 (7 bytes) cmd │ matrícula

MODBUS: 01 │ 23 │ A1 │ 06 05 04 03 02 01 │ CRC CRC (11 bytes) dir. │ fun│ sub│ matrícula │ verificación

MODBUS toma el mismo mensaje y añade tres respuestas que el protocolo simplificado no ofrece: para quién es, qué debe hacer y ¿llegó completo?

Dirección, función y subcódigo

La dirección indica qué dispositivo debe responder. En el protocolo simplificado no existe una dirección: responde quien esté escuchando. En MODBUS, varios dispositivos comparten el mismo cable y solo habla el dispositivo direccionado. La respuesta vuelve con la misma dirección en el primer byte, lo que confirma quién respondió.

Curiosamente, todos esos "dispositivos" viven dentro de la misma ESP32, que simula ser varios aparatos:

Dirección Qué es Para qué sirve
0x01 dispositivo didáctico devuelve valores fijos; existe únicamente para validar el formato de los mensajes
0x11, 0x12, 0x13 cabinas 1, 2 y 3 piso, nivelación, puerta, corriente, carga, posición y fallos
0x20 controlador del edificio cola de llamadas, bus de potencia, watchdog y métricas

La función indica el tipo de operación. En el dispositivo didáctico, 0x23 significa "dame un dato" y 0x16, "recibe este dato". El subcódigo que viene después (A1, A2, A3, B1, B2, B3) es exactamente el comando del protocolo simplificado: indica si el dato es un entero, un float o una cadena. Es decir, el byte único del protocolo simplificado se dividió en dos.

Por eso la parte de MODBUS de la entrega solo se comunica con 0x01: valida el envoltorio —dirección, función y CRC— con respuestas predecibles antes de tocar el simulador real. Un detalle interesante: en los comandos de envío, la ESP32 devuelve el valor recibido multiplicado por el último dígito de la matrícula. Si la respuesta coincide, sabes que leyó tu matrícula como byte crudo; en ASCII, habría multiplicado por un número completamente diferente.

CRC-16: la trampa del valor inicial

El CRC-16 son los dos últimos bytes de la trama: una suma de verificación calculada sobre todos los bytes anteriores. Quien recibe vuelve a calcularlo; si no coincide, algún byte se corrompió durante el trayecto y el mensaje se descarta. Del lado de la Raspberry, una respuesta con CRC incorrecto genera un nuevo intento, hasta tres. El protocolo simplificado no tiene nada de eso: un byte cambiado pasa inadvertido.

La trampa es que el CRC de este trabajo no es el CRC-16/MODBUS que aparece en todos los tutoriales. El polinomio es el mismo (0xA001, en forma reflejada), pero el valor inicial es cero, no 0xFFFF. Esto solo quedó claro al comprobar el cálculo con los ejemplos resueltos del enunciado —y deja una lección: cuando la especificación incluye ejemplos con los bytes de verificación ya calculados, esos ejemplos son la referencia definitiva.

c /* CRC-16 con polinomio 0xA001 reflejado y valor inicial 0x0000. * El CRC-16/MODBUS de los tutoriales comienza en 0xFFFF y NO coincide con los ejemplos. */ uint16_t crc16(const uint8_t *datos, size_t n) { uint16_t crc = 0x0000;

for (size_t i = 0; i < n; i++) {
    crc ^= datos[i];
    for (int bit = 0; bit < 8; bit++)
        crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
}

return crc;   /* va en la trama en little-endian: byte bajo primero */

}

Con este código, la lectura de los 9 registros de la cabina 1 del enunciado (11 03 00 00 09 00 06 05 04 03 02 01) da 0xE40E, que viaja como 0E E4, exactamente como muestra el ejemplo.

Registros: dónde guarda el simulador el estado

Con el envoltorio validado, la tercera parte de la entrega se comunica con los dispositivos reales mediante dos funciones estándar de MODBUS: 0x03 —leer registros— y 0x10 —escribir registros—. Cada registro tiene 16 bits y se identifica mediante un offset. La cabina tiene 9 —del 0 al 8— y el edificio tiene 16.

La lectura del estado de la cabina 1 en el banco de pruebas devolvió lo siguiente:

11 03 12 │ 00 00 │ 00 00 │ 00 00 │ 00 00 │ 01 2C │ 00 00 │ 00 00 │ FF 98 │ 00 01 │ 16 37 dir fun n│ piso │ nivel │ puerta│ cmd │ 300mA │ carga │ pas. │ posic. │ fallo │ CRC

Observa el FF 98: es la posición, el único registro con signo. Leído como un entero de 16 bits con signo, vale −104 mm. La cabina no estaba "en la planta baja", sino 10 cm por debajo de ella, en el foso. Como la puerta solo funciona con la cabina nivelada —dentro de ±10 mm de la planta—, fue necesario reposicionarla antes de continuar.

El orden de los bytes no es uniforme

Este es el tipo de detalle que hace que un banco de pruebas devuelva "basura que parece ruido". En la solicitud, el número del registro y la cantidad se envían en little-endian —byte bajo primero—. En la respuesta, los valores llegan en big-endian —byte alto primero—. El 01 2C anterior vale 300, no 11265, precisamente por eso. Equivocarse en el orden de un solo campo desplaza todo lo demás, y el resultado parece un problema de comunicación cuando en realidad es un problema de decodificación.

Las excepciones no se repiten

Cuando la solicitud es inválida, el dispositivo no permanece en silencio: responde con la función "marcada" —el bit 0x80 activado— y un código. Intentar escribir en la posición de la cabina, que es de solo lectura, devuelve 0x90 con el código 0x02; leer a partir del registro 50, fuera del mapa, devuelve 0x83 con 0x02:

11 83 02 B0 F4 → cabina 0x11, lectura (0x03 + 0x80), código 0x02 = dirección inválida

La regla de diseño que conviene recordar es: una excepción no es un fallo de comunicación. Es una respuesta válida, con CRC correcto, que dice "no". Repetir la solicitud produciría el mismo "no" tres veces. Solo un timeout o un CRC inválido justifican un nuevo intento.

Watchdog y condición de contorno: el supervisor y el dato

El watchdog como supervisor de la condición de contorno: termómetro, medidor de potencia y cronómetro
El watchdog como supervisor de la condición de contorno: termómetro, medidor de potencia y cronómetro

Esta fue la parte que generó más preguntas, incluidas las mías.

El edificio tiene un bus de potencia, la fuente que alimenta los motores de las cabinas. La corriente que entrega depende de la temperatura de la sala de máquinas: cuanto más caliente está, menor es la potencia. El enunciado define la regla —el derating térmico—:

bus = 12000 − 200 × max(0, temperatura − 25) mA 25,0 °C → 12000 mA 25,3 °C → 11940 mA 35,0 °C → 10000 mA

Pero el simulador no conoce la temperatura por sí solo. Alguien debe informársela, y esa información —la temperatura y la presión ambiental— es la condición de contorno. El nombre proviene de la simulación física: es el dato del mundo exterior que el modelo necesita recibir para poder calcular.

El watchdog supervisa ese dato. Si la Raspberry pasa más de 30 segundos sin volver a escribir la condición de contorno, el simulador concluye que el sensor —o el programa— falló, deja de confiar en la última temperatura y asume lo peor: fija el bus en un mínimo de 3000 mA, y las cabinas comienzan a moverse a velocidad mínima. Funciona como el modo de seguridad de un automóvil cuando el sensor de temperatura del motor deja de responder.

Tuve dos malentendidos frecuentes:

  • El watchdog no bloquea nada. Aunque haya expirado, todas las lecturas y escrituras siguen funcionando. La puerta se abrió y se cerró normalmente con el watchdog en 1. La única consecuencia es que la potencia queda limitada.
  • Expirado es el estado normal en reposo. Si nadie lo alimenta, permanece en 1. Solo aparece como "OK" durante los 30 segundos posteriores a una escritura.

En la práctica, la demostración consiste en tres pasos: leer el edificio —watchdog en 1 y 3000 mA—, escribir 253 —25,3 °C, en décimas de grado— y 1013 hPa en los registros 5 y 6 mediante una única escritura 0x10, y volver a leer —watchdog en 0 y 11940 mA—. La trama de escritura, todavía con la matrícula de ejemplo, es:

20 10 05 00 02 00 04 FD 00 F5 03 06 05 04 03 02 01 56 C3 fun reg cant. bc 253 1013 matrícula CRC

Si el orden de los bytes fuera incorrecto, 253 llegaría como 64768 y el bus nunca coincidiría con la fórmula. El número correcto en la lectura es, al mismo tiempo, una prueba del protocolo y de la física del simulador.

Cerrando la cadena con I2C

Cadena completa: sensor, I2C, Raspberry, MODBUS y simulador
Cadena completa: sensor, I2C, Raspberry, MODBUS y simulador

Escribir 25,3 °C manualmente demuestra que el camino MODBUS funciona, pero no representa el sistema real. El enunciado es directo: un sistema que no implemente la cadena I2C → MODBUS no mueve el edificio.

La mitad que falta es el I2C: un segundo bus, otro par de cables, que conecta la Raspberry con el sensor BMP280. El programa abre el bus, identifica el chip mediante el registro de identificación, lee los valores brutos de temperatura y presión y los convierte usando los coeficientes de calibración grabados de fábrica en el propio sensor. Solo entonces utiliza la misma escritura MODBUS de antes.

BMP280 ──I2C──► Raspberry Pi ──UART/MODBUS──► ESP32 (simulador) mide convierte y decide aplica el derating

La diferencia respecto al paso anterior no está en el destino, sino en el origen del dato: antes era el teclado; ahora es el sensor. La presión medida en Brasilia se acerca a 880 hPa, bastante por debajo de los 1013 del nivel del mar, debido a la altitud: una buena comprobación de que el valor es real. Y un bucle que repite la lectura y la escritura cada 10 segundos, con margen dentro de los 30 segundos del watchdog, es exactamente lo que hará automáticamente el servidor central en la entrega final.

Un aprendizaje adicional: en algunas placas del laboratorio, el bus I2C no estaba habilitado en la configuración del sistema y no teníamos permisos de administrador para cambiarlo. En lugar de dejar que el programa fallara con un error crudo, el driver detecta la ausencia del dispositivo y explica la causa. Un mensaje de error que indique qué hacer ahorra tiempo a quien venga después.

Lo que enseñó el banco de pruebas

Algunas lecciones solo aparecen con hardware real y con el tiempo contado: cada sesión con la placa duraba 15 minutos.

  • Prepara la placa antes de grabar. La placa tenía llamadas que había dejado en la cola la persona que la usó antes y una cabina fuera de nivel. Un script que compruebe el entorno —puerto serie, permisos, compilación y pruebas— con un único comando puede ahorrar toda la sesión.
  • El simulador tiene un comportamiento propio. Envié el comando de abrir la puerta y solo empecé a observar seis segundos después: ya se estaba cerrando sola, como un ascensor real. La lectura continua debe estar funcionando antes del comando; en mi caso, con el terminal dividido en dos mitades, una para observar y otra para enviar comandos.
  • Los contadores contabilizan cosas diferentes. Después de asignar una llamada a una cabina y retirarla de la cola, el dashboard mostraba "en cola: 0", pero "en atención: 1". No era un bug: la llamada había salido de la cola de espera, pero solo termina cuando la cabina completa el trayecto, algo que en esta entrega no ocurría.
  • El dashboard es el otro lado de la conversación. Cada comando que recibía la ESP32 aparecía en el widget, junto con el protocolo de origen —MODBUS o "ALT", de alternativo, para el protocolo simplificado— y la matrícula. Ver ambos lados al mismo tiempo transforma "creo que funcionó" en "funcionó".

Al final, la lección más útil fue la más sencilla: las pruebas unitarias garantizan que construyes la trama correcta; el banco de pruebas garantiza que el otro lado está de acuerdo contigo. Ambas son necesarias y ninguna sustituye a la otra.

Te gusto el articulo?

Compartelo con tus amigos y ayuda a difundir conocimiento!