Puntos débiles de las aplicaciones de comunicación serie - hasta el diseño de la reconexión y los registros

· Actualizado el: · · Comunicación serie, RS-232, C#, .NET, Desarrollo en Windows, Integración con equipos

Integración con equipos, instrumentos de medición, PLC, lectores de código de barras, conversores USB-serie. La comunicación serie puede parecer una tecnología antigua, pero en el día a día del desarrollo de aplicaciones Windows todavía se usa con bastante normalidad.

Lo que resulta un poco peligroso es que la comunicación serie se puede empezar a implementar con un único puerto COM y un único Read / Write. La prueba de conectividad pasa enseguida, pero al pasar a producción suelen aparecer síntomas como estos.

  • El comando y la respuesta se desalinean de vez en cuando
  • Se queda bloqueada una vez al día
  • Solo deja de recuperarse tras desconectar y volver a conectar el USB
  • La interfaz de usuario se congela de vez en cuando
  • En el registro solo queda “Timeout”

Lo verdaderamente difícil en una aplicación de comunicación serie no es la API de envío y recepción en sí, sino los límites de mensaje, los timeouts, las transiciones de estado, la reconexión y la observabilidad.

Audiencia y requisitos previos de este artículo

Elemento Contenido
Audiencia Personas que desarrollan aplicaciones Windows conectadas por serie a equipos o instrumentos de medición. Está pensado para quienes ya superaron la prueba de conectividad, pero quieren reducir los fallos «ocasionales» que aparecen en producción
Conocimientos previos Saber escribir aplicaciones en C#. No se presupone experiencia previa con la comunicación serie en sí
Entorno previsto Está escrito partiendo de System.IO.Ports.SerialPort de .NET, pero el enfoque sobre límites de mensaje, timeouts y transiciones de estado no depende del lenguaje
Fuera de alcance El cableado eléctrico y las especificaciones de protocolo de un equipo concreto

Terminología usada en este artículo

Término Significado en una línea
PLC Programmable Logic Controller. Controlador industrial usado para controlar equipos de producción
RS-232 / RS-485 Estándares eléctricos de comunicación serie. RS-232 es punto a punto (1 a 1); RS-485 permite conectar varios dispositivos a la misma línea. En RS-485, si no se define quién transmite y cuándo, se producen colisiones
8N1 Notación abreviada de la configuración del puerto. Se refiere a la combinación de 8 bits de datos, sin paridad (None) y 1 bit de parada
DTR / RTS Líneas de control. Originalmente transmiten la disposición para comunicar o la solicitud de envío, pero en equipos reales a veces se usan estos cambios como señal de arranque o cambio de modo
Control de flujo Mecanismo para evitar el envío excesivo. RTS/CTS usa líneas de control, mientras que XON/XOFF transmite la detención y la reanudación mediante caracteres especiales dentro de los datos
keepalive Comando ligero que se envía periódicamente para comprobar que la otra parte de la comunicación sigue activa
Frame Secuencia de bytes que corresponde a un mensaje. El protocolo es quien determina dónde empieza y dónde termina cada frame
single writer Diseño que concentra el envío en un único worker. Significa que no se permite hacer Write desde cualquier punto del código

1. Conclusión por adelantado

Antes de entrar en detalle, aquí va un resumen orientado a la práctica.

  • La comunicación serie es un byte stream ordenado, y los límites de mensaje no se añaden por sí solos
  • Que se llame a Read(100) no garantiza que se devuelvan exactamente 100 byte
  • El DataReceived de .NET no necesariamente se dispara por cada byte recibido, y además no se ejecuta en el hilo de la interfaz de usuario
  • ReadLine() / WriteLine() solo son fiables cuando la otra parte usa de verdad un protocolo de texto basado en líneas
  • Un solo timeout no basta. Es más estable separar los significados de open, inter-byte, response y reconnect
  • En lugar de permitir Write desde cualquier lugar, es menos propenso a romperse concentrar el envío en un single writer
  • En USB-serie conviene asumir desde el principio que habrá desconexiones, reenumeraciones, cambios de número de COM y fallos de reconexión

En resumen, el punto difícil de una aplicación de comunicación serie no es «si se puede abrir el puerto», sino cómo convertir la secuencia de bytes en mensajes con significado, y cómo gestionar el tiempo y el estado alrededor de eso.

2. La comunicación serie no es un «mensaje», sino un «byte stream ordenado»

Desde el punto de vista de la aplicación, la comunicación serie parece «enviar un comando y recibir una respuesta». Pero en la capa inferior, en realidad solo circula una secuencia de bytes ordenada.

Lo que se escribe con un único Write también puede llegar al otro lado de estas formas.

  • Llega en un único Read
  • Llega dividido en dos entregas
  • Llega concatenado con otros datos

Si se pierde de vista esta premisa, la aplicación empieza a asumir que «este Read debe ser la respuesta a esta petición». Esta suposición suele ser la primera trampa de una aplicación de comunicación serie.

Suposición habitual Realidad
Read(16) devuelve exactamente 16 byte Según cómo haya llegado y el timeout, a veces solo se obtiene una parte
DataReceived = llegada de 1 mensaje El evento no está garantizado por byte, ni se ejecuta en el hilo de la interfaz de usuario
Write ha devuelto el control = la otra parte terminó de procesar En la mayoría de los casos, solo significa que el lado emisor pudo depositarlo en el búfer
La lista de COM = la verdad de lo que está conectado ahora El orden de enumeración no está definido, y el resultado puede quedar obsoleto (stale)

Por eso, en la comunicación serie es necesario definir uno mismo los límites de mensaje como parte del protocolo. Puede ser un frame de longitud fija, basado en un carácter delimitador, longitud + payload + checksum, o cualquier otra forma; pero si se empieza a implementar sin resolver esta ambigüedad, casi con seguridad se sufrirá más adelante.

3. Lo que hay que decidir primero

Antes de crear una aplicación de comunicación serie, conviene decidir de antemano, como mínimo, los puntos que se enumeran aquí.

3.1 Límites del frame

Se decide qué secuencia de bytes se considera un mensaje: si es de longitud fija, delimitada por saltos de línea, con longitud incluida, o si lleva checksum / CRC. Si esto queda ambiguo, el lado receptor no puede distinguir entre «todavía falta» y «está corrupto».

3.2 ¿Texto, binario o una mezcla de ambos?

Se decide de antemano si se trata de un protocolo de texto en líneas ASCII / UTF-8, binario puro, o una mezcla de ambos. En particular, en una mezcla del tipo «la parte de comando es una cadena de texto, el payload es binario y solo el final lleva un salto de línea», si no se especifica hasta dónde se decodifica y a partir de dónde se trata como bytes en bruto, los límites se rompen enseguida.

3.3 El significado de los timeouts

Es más seguro no usar un único timeout, sino pensar en varios según su significado.

  • open timeout: hasta que se abre el puerto
  • inter-byte timeout: el tiempo sin que lleguen bytes en mitad de un frame
  • response timeout: desde que se emite un comando hasta que se completa la respuesta
  • reconnect backoff: el intervalo de espera para la reconexión

En lugar de tratar el timeout como un «seguro para cuando algo va lento», es más estable mantenerlo como una regla que hace avanzar las transiciones de estado.

3.4 Control de flujo y estado de las líneas

Las configuraciones que conviene dejar explícitas son estas.

  • BaudRate
  • DataBits
  • Parity
  • StopBits
  • Handshake
  • DTR / RTS

Si esto se resuelve con un “más o menos con 8N1 funciona”, dependiendo del equipo con el que se hable, la comunicación se detendrá sin más.

3.5 Separación de responsabilidades

Se separa quién se encarga de qué.

  • Quién lee
  • Quién escribe
  • Quién parsea
  • Quién actualiza el estado de negocio

En la comunicación serie, cuanto más se mezclan la interfaz de usuario y la comunicación, más fácil es que todo se rompa.

3.6 Transiciones de estado de inicio, parada y reconexión

Como mínimo, conviene incluir en el diseño estados como Closed, Opening, Ready, WaitingResponse, Fault y Reconnecting. Justo después de desconectar y volver a conectar, es posible que el otro extremo todavía esté arrancando, y en algunos casos no se debe arrastrar la pending request anterior.

Transiciones de estado de la conexión serieDiagrama de estados que muestra el ciclo Closed, Opening, Ready, WaitingResponse, Fault y Reconnecting, y por qué no existe una transición directa de Fault a Ready.solicitud de Openopen correcto + secuencia de inicialización completadaopen fallido / error de permisos / timeout de inicializaciónenvío de comandorecepción del frame de respuesta correspondienteresponse timeouterror de E/S / detección de cable desconectadose hace fallar la pending request y comienza el backofftranscurrido el backofflímite alcanzado / detención manualsolicitud de CloseClosedOpeningReadyFaultWaitingResponseReconnecting

Lo importante de este diagrama es que no hay una línea que vuelva directamente de Fault a Ready. Después de una anomalía, siempre se pasa por Reconnecting y Opening, y solo se vuelve a Ready tras reconstruir el búfer de recepción, el estado del parser, las pending request y la secuencia de inicialización. Si se toma un atajo aquí, se cae en el problema de 4.7: «creer que se ha reconectado solo con repetir Open()».

3.7 Registro y capacidad de investigación

Esto es, casi siempre, lo que más problemas da más adelante. Como mínimo, conviene dejar registrados los momentos de open / close / reopen, la configuración del puerto utilizada, el hex dump de los frames enviados y recibidos, los errores de checksum / CRC, los frame timeout / response timeout, y el motivo de cada reconexión.

4. Puntos débiles habituales

4.1 Creer que «1 Read = 1 mensaje»

Esto es lo más frecuente. Supongamos, por ejemplo, que el otro extremo devuelve un frame compuesto por cabecera, longitud, payload y CRC. Si en ese caso se llama una sola vez a Read(buffer, 0, expectedLength) y se asume que el valor devuelto es directamente un frame completo, la implementación se rompe fácilmente en cuanto la recepción llega a medias.

Las tres formas habituales en que esto falla son estas.

  • Solo se lee la longitud y el payload todavía no ha llegado
  • Llega solo un frame y medio, y la segunda mitad queda para el siguiente Read
  • Llegan dos frames juntos, se procesa solo el primero y se descarta el resto

Puesto en imágenes, es simplemente que el orden que envía el equipo no coincide con el orden que devuelve Read.

Lo que envió el equipo
    [--- frame1 ---][--- frame2 ---]

Patrón 1: solo llega hasta la mitad
    1er Read -> [ STX ][ LEN ]                     <- el payload todavía no ha llegado
    2do Read -> [ payload ][ CRC ][--- frame2 ---]

Patrón 2: llega solo un frame y medio
    1er Read -> [--- frame1 ---][ primera mitad de frame2 ]
    2do Read -> [ segunda mitad de frame2 ]

Patrón 3: llegan dos frames juntos
    1er Read -> [--- frame1 ---][--- frame2 ---]   <- se suele procesar solo el primero y descartar el resto

En los tres patrones, no es que «esté roto», sino que «la posición de corte simplemente no coincide con el número de llamadas a Read». Si se confunde esto y se implementa de forma que cualquier diferencia entre el número de bytes recibidos y lo esperado se trate como un error, se empieza a contar comunicaciones normales como errores.

La solución es simple: separar la recepción en dos pasos, primero acumular y, a partir de ahí, dejar que el parser extraiga los frames. El código base está en el apartado 5.1.

4.2 Convertir DataReceived directamente en un evento de negocio

El SerialPort.DataReceived de .NET parece cómodo a primera vista, pero es peligroso interpretarlo como «notificación de que llegó un mensaje». En la práctica, conviene tratar DataReceived simplemente como «parece que llegó algo»: no realizar procesamiento pesado dentro del manejador, y devolver siempre las actualizaciones de la interfaz de usuario al hilo de la interfaz.

4.3 Creer que se puede hacer Write desde cualquier lugar

Una estructura en la que el botón de la interfaz, el temporizador de monitorización, el procesamiento de reconexión y el keepalive escriben cada uno directamente con Write es propensa a romperse. Como la comunicación serie es un byte stream, según el diseño pueden producirse interrupciones de comandos o envíos adicionales mientras se espera una respuesta. Especialmente en protocolos de tipo request-response o en RS-485, concentrar todo en un single writer resulta bastante más estable.

4.4 Resolverlo todo con ReadLine() / WriteLine()

Con un protocolo de texto basado en líneas, ReadLine() / WriteLine() son prácticos. Pero solo son prácticos cuando de verdad se trata de un protocolo de líneas. Si hay discrepancias en NewLine, saltos de línea dentro del payload, diferencias de codificación de caracteres o mezcla con datos binarios, los límites se rompen enseguida.

4.5 No diseñar los timeouts y dejarlos en su valor predeterminado

Colocar una lectura síncrona sin más análisis suele terminar en una espera indefinida. Lo más problemático es que el timeout configurado no siempre afecta a todas las formas de lectura. Implementaciones como leer de forma síncrona en el hilo de la interfaz de usuario, intentar representarlo todo con un único timeout, o simplemente aumentar los reintentos, tienden a quedarse atascadas.

4.6 Subestimar RTS/CTS, XON/XOFF y DTR/RTS

El handshake y las líneas de control tienen bastante peso al tratar con equipos reales. Si hay una discrepancia de configuración, suelen aparecer síntomas como que el envío se detiene de vez en cuando, que se pierden datos al superar cierta cantidad, o que el comportamiento es distinto justo después de abrir la conexión. Dependiendo del equipo, los cambios en DTR/RTS también pueden interpretarse como señal de arranque o de cambio de modo.

4.7 Creer que se ha reconectado solo repitiendo Open()

Especialmente en USB-serie, es normal que el puerto desaparezca temporalmente, que el handle anterior deje de ser válido o que la pending request anterior pierda su sentido. Es más seguro tratar la reconexión como un conjunto que incluya, como mínimo, la invalidación de la sesión, el fallo de las pending request, la detención del reader / writer, el reopen tras el backoff, y la reejecución de la inicialización del equipo.

4.8 Creer que la enumeración de puertos COM es la verdad

GetPortNames() es útil, pero que un puerto aparezca en la lista no es lo mismo que poder abrirlo. Implementaciones como confiar ciegamente en el COM7 anterior, seleccionar automáticamente el primero de la lista enumerada, o darlo por válido en el momento en que aparece en la lista, suelen dar problemas en producción.

4.9 Registros de envío y recepción demasiado pobres

Con solo TimeoutException, IOException o Port closed, prácticamente no se puede saber nada. Si se deja constancia de la hora de envío y recepción, el port profile, el hex dump de envío y recepción, los errores del parser, a qué request corresponde cada response, y el motivo de cada reconexión, la localización de fallos avanza bastante.

Si se decide el formato de antemano, más adelante se puede hacer grep y comparar diferencias. Por ejemplo, se puede usar un formato de una línea como este.

2026-03-19T10:23:41.512+09:00  COM3  TX  req=00A7  len=5   02 01 10 3F 9C
2026-03-19T10:23:41.518+09:00  COM3  RX  req=00A7  len=3   02 01
2026-03-19T10:23:41.531+09:00  COM3  RX  req=00A7  len=6   10 00 4B 02 01 11
2026-03-19T10:23:41.532+09:00  COM3  PARSE req=00A7  frame=02 01 10 00 4B  result=OK
2026-03-19T10:23:41.532+09:00  COM3  PARSE req=-     frame=02 01 11        result=INCOMPLETE  need=2
2026-03-19T10:23:43.540+09:00  COM3  ERR req=00A8  reason=response-timeout  elapsed=2008ms
2026-03-19T10:23:43.541+09:00  COM3  STATE Ready -> Fault  reason=response-timeout

Aquí se persiguen tres objetivos.

  • Separar las líneas de RX y las de PARSE. RX indica «cuántos bytes llegaron», y PARSE indica «cuántos frames se pudieron extraer». En el ejemplo anterior, un frame llega repartido entre el segundo y el tercer RX, y el resto queda como el principio del siguiente frame. Si se mezclan estos dos tipos de registro, más adelante no se puede determinar si se está produciendo el desajuste de división descrito en 4.1
  • Permitir cruzar envío y recepción mediante req=. Qué respuesta corresponde a qué comando no se puede reconstruir a partir del registro por sí solo, más adelante
  • Dejar constancia de las transiciones de estado en una sola línea. Si se conserva una transición como Ready -> Fault junto con su motivo, se puede seguir directamente el desencadenante de cada reconexión

Como el hex dump consume mucho espacio, lo más realista es un esquema en dos niveles: el registro en bruto se conserva en un buffer circular con una cantidad limitada, y el registro resumido se guarda a largo plazo.

5. Buenas prácticas

Lo más efectivo es separar las responsabilidades.

  • reader: solo lee la secuencia de bytes del puerto
  • writer: solo escribe en orden desde la outbound queue
  • parser: solo extrae frames de la secuencia de bytes
  • protocol: gestiona la correspondencia entre request y response, y el checksum
  • app state: solo actualiza el estado de negocio

En el procesamiento de recepción, es más estable no convertir directamente la unidad devuelta por Read en la unidad de negocio, sino acumularla primero en un búfer y dejar que el parser extraiga los frames. El envío se concentra en un único worker, y llevar el Write real a un single writer reduce el desorden de secuencia.

En cuanto al timeout, en lugar de conformarse con un solo número, dividirlo según el significado de open, inter-byte, response y reconnect facilita la localización de la causa. Mantener la configuración del puerto como un profile en lugar de valores sueltos en el código, y mostrarla en el registro al arrancar, facilita bastante la investigación in situ.

La reconexión es más estable si se piensa como una regeneración de sesión en lugar de un simple reopen. Reconstruir el búfer de recepción, el estado del parser, las pending request, la secuencia de inicialización y la determinación de readiness ayuda a reducir los errores de reconexión que fallan «solo ocasionalmente».

Por último, se recomienda mantener tanto un registro en bruto como uno resumido. El raw hex dump y el historial de open / close son fuertes para la investigación; el resumen del request id y el número de reintentos es fuerte para la operación.

A partir de aquí se incluye el código base solo de los dos puntos más efectivos. Se asume .NET 8 / C# 12, haciendo referencia al paquete System.IO.Ports.

5.1 Recepción: acumular y luego extraer

Como ejemplo, se asume un frame compuesto por STX(0x02), LEN(1 byte), payload(LEN byte) y CRC16(2 byte, little-endian). La forma puede ser cualquiera; lo esencial es cortar los frames según esta definición, y no según la unidad devuelta por Read.

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;

public static class Crc16Modbus
{
    // CRC-16/MODBUS: valor inicial 0xFFFF, desplazamiento a la derecha con polinomio 0xA001
    public static ushort Compute(ReadOnlySpan<byte> data)
    {
        ushort crc = 0xFFFF;
        foreach (var b in data)
        {
            crc ^= b;
            for (var i = 0; i < 8; i++)
            {
                crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
            }
        }

        return crc;
    }
}

public static class Frame
{
    public const byte Stx = 0x02;
    public const int HeaderLength = 2;   // STX + LEN
    public const int CrcLength = 2;

    public static byte[] Build(ReadOnlySpan<byte> payload)
    {
        // LEN es de 1 byte, así que a partir de 256 byte se produce un wraparound por el cast.
        // Aun así, el payload se copia entero, por lo que el receptor cortará el frame con
        // una longitud truncada y leerá parte del payload como si fuera el CRC. Los límites
        // de los frames siguientes también se desmoronan. Si se divide o si LEN pasa a
        // ser de 2 byte es una decisión de protocolo; aquí simplemente se rechaza
        if (payload.Length > byte.MaxValue)
        {
            throw new ArgumentOutOfRangeException(
                nameof(payload),
                $"El payload de un frame no puede superar {byte.MaxValue} byte (porque LEN es de 1 byte).");
        }

        var frame = new byte[HeaderLength + payload.Length + CrcLength];
        frame[0] = Stx;
        frame[1] = (byte)payload.Length;
        payload.CopyTo(frame.AsSpan(HeaderLength));

        var body = frame.AsSpan(0, frame.Length - CrcLength);
        BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(frame.Length - CrcLength), Crc16Modbus.Compute(body));
        return frame;
    }
}

public sealed class FrameParser
{
    private readonly List<byte> _buffer = new();

    /// <summary>El inter-byte timeout de 3.3. Tiempo hasta renunciar a un frame que se está ensamblando.</summary>
    private static readonly TimeSpan AssemblyTimeout = TimeSpan.FromMilliseconds(200);

    /// <summary>Desde cuándo el candidato que se está ensamblando ahora pasó a estado de espera (valor monótono creciente).</summary>
    private long _pendingSince;

    /// <summary>Notifica un frame descartado por no coincidir el CRC. Hay que suscribirse siempre para volcarlo al registro.</summary>
    public event Action<byte[]>? FrameDiscarded;

    /// <summary>Notifica que se renunció al ensamblado y se resincronizó. Si esto sigue aumentando, hay que sospechar del cableado o de la configuración.</summary>
    public event Action<int>? Resynchronized;

    /// <summary>Acumula la secuencia de bytes recibida y devuelve solo los frames que se pudieron extraer.</summary>
    public IReadOnlyList<byte[]> Append(ReadOnlySpan<byte> received)
    {
        foreach (var b in received)
        {
            _buffer.Add(b);
        }

        var frames = new List<byte[]>();

        while (true)
        {
            // 1. Descartar hasta que el principio sea STX. Aquí se absorbe el ruido o el resto del frame anterior
            var stxIndex = _buffer.IndexOf(Frame.Stx);
            if (stxIndex < 0)
            {
                _buffer.Clear();
                _pendingSince = 0;   // Ya no hay candidato, así que también se deja de medir el tiempo de espera
                break;
            }

            if (stxIndex > 0)
            {
                // Cambió el principio del candidato = se empieza a ensamblar otro frame
                _buffer.RemoveRange(0, stxIndex);
                _pendingSince = 0;
            }

            // 2. ¿Ha llegado lo suficiente como para poder leer la longitud?
            if (_buffer.Count < Frame.HeaderLength)
            {
                if (GiveUpOnStaleCandidate()) { continue; }
                break;   // No es «está corrupto», sino «todavía falta»
            }

            int payloadLength = _buffer[1];
            int frameLength = Frame.HeaderLength + payloadLength + Frame.CrcLength;

            // 3. ¿Está completo un frame entero?
            if (_buffer.Count < frameLength)
            {
                // En este punto no se puede distinguir entre «todavía falta» y «LEN está
                // corrupto por ruido». Si el ruido o un STX falso hacen que LEN valga 255,
                // se sigue absorbiendo como payload incluso el siguiente frame correcto que
                // llegue, y no sube nada hasta reunir 259 byte y fallar el CRC.
                // En un equipo con poco tráfico, esto se ve como varios minutos sin
                // respuesta. Se pone un límite a la espera, y al superarlo se descarta
                // el candidato y se busca de nuevo el STX
                if (GiveUpOnStaleCandidate()) { continue; }
                break;   // Se sale aquí y se espera la siguiente recepción
            }

            var frame = _buffer.GetRange(0, frameLength).ToArray();
            _buffer.RemoveRange(0, frameLength);
            _pendingSince = 0;

            // 4. Se descarta lo que no coincide en el CRC. Lo descartado siempre se notifica hacia fuera
            var expected = BinaryPrimitives.ReadUInt16LittleEndian(frame.AsSpan(frame.Length - Frame.CrcLength));
            if (expected == Crc16Modbus.Compute(frame.AsSpan(0, frame.Length - Frame.CrcLength)))
            {
                frames.Add(frame);
            }
            else
            {
                // Aquí es una decisión de diseño: descartar el frame entero de una vez, o descartar
                // solo 1 byte de STX y volver a leer. Lo primero es simple; lo segundo es más
                // robusto si el propio LEN era ruido. Hay que decidir cuál se usa y documentarlo.
                FrameDiscarded?.Invoke(frame);
            }
        }

        return frames;
    }

    /// <summary>
    /// Si el candidato que se está ensamblando supera AssemblyTimeout, se descarta solo 1 byte del STX inicial.
    /// Si se descarta, devuelve true, y el llamador vuelve a leer a partir del siguiente STX.
    /// No se descarta el frame entero porque el STX real podría estar dentro de este candidato.
    /// </summary>
    private bool GiveUpOnStaleCandidate()
    {
        if (_pendingSince == 0)
        {
            // El instante en que empieza la espera. Con el reloj de pared se producirían saltos por la sincronización NTP, así que se mide con un valor monótono creciente
            _pendingSince = Stopwatch.GetTimestamp();
            return false;
        }

        if (Stopwatch.GetElapsedTime(_pendingSince) < AssemblyTimeout)
        {
            return false;
        }

        _buffer.RemoveAt(0);
        _pendingSince = 0;
        Resynchronized?.Invoke(_buffer.Count);
        return true;
    }
}

GiveUpOnStaleCandidate es la implementación real del inter-byte timeout mencionado en 3.3. Sin esto, cuando el ruido o un STX falso hacen que LEN tome un valor grande (por ejemplo, 255), el parser sigue tratándolo como «todavía falta». Absorbe incluso el frame correcto que llegue después, como parte de ese payload corrupto, y no sube nada hasta reunir 259 byte y fallar el CRC. En un equipo con poco tráfico, esto se ve como varios minutos sin respuesta. Que solo se descarte 1 byte de STX se debe a que el STX real podría estar enterrado dentro del candidato.

Conviene dejar claras dos premisas. Este tiempo de espera solo se evalúa cuando se llama a Append. Si la línea se queda completamente en silencio, no ocurre nada del lado del parser, así que eso se recoge con el response timeout del llamador (5.2). La otra premisa: el valor de AssemblyTimeout debe decidirse a partir del baud rate y la longitud del frame. El límite inferior es el tiempo de transmisión de 1 byte × la longitud máxima de frame prevista, con un margen adicional; si es más corto que eso, se descartarán frames correctos a mitad de camino. Sacar al registro el número de veces que se dispara Resynchronized da un indicio para sospechar del cableado o de la configuración del baud rate.

En el lado de la lectura, basta con leer del puerto y pasarlo al parser. Si aquí se empieza a escribir procesamiento de negocio, la unidad devuelta por Read termina convirtiéndose en la unidad de negocio.

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

public sealed class SerialReader
{
    private readonly SerialPort _port;
    private readonly FrameParser _parser;
    private readonly byte[] _readBuffer = new byte[4096];

    public SerialReader(SerialPort port, FrameParser parser)
    {
        _port = port;
        _parser = parser;
    }

    public event Action<byte[]>? FrameReceived;

    public async Task RunAsync(CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            int count;
            try
            {
                count = await _port.BaseStream.ReadAsync(_readBuffer.AsMemory(), token);
            }
            catch (OperationCanceledException)
            {
                break;
            }

            if (count <= 0)
            {
                continue;
            }

            foreach (var frame in _parser.Append(_readBuffer.AsSpan(0, count)))
            {
                FrameReceived?.Invoke(frame);
            }
        }
    }
}

No usar DataReceived es intencional. Como se explicó en 4.2, no tiene más significado que «parece que llegó algo», así que mantener el propio bucle de lectura facilita gestionar el estado y los timeouts.

5.2 Envío: concentrarlo en un single writer

En el lado del envío, la clave es no dejar que se pueda hacer Write desde cualquier lugar. El punto donde se apila en la cola puede ser llamado por cualquiera, pero quien realmente ejecuta el Write es un único worker.

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;

public sealed class SingleWriter
{
    private sealed record Outbound(byte[] FrameBytes, TaskCompletionSource<byte[]> Completion);

    /// <summary>Límite de la cola de envío. Se determina con el tiempo de un round-trip del equipo × la longitud de cola que se tolere.</summary>
    private const int QueueCapacity = 64;

    private readonly SerialPort _port;
    private readonly TimeSpan _responseTimeout;

    // No dejarlo sin límite. Si la interfaz, el temporizador o el worker apilan más
    // rápido que el round-trip del equipo, los frames y los TaskCompletionSource se
    // acumulan sin fin y la memoria crece aunque el equipo esté respondiendo. Se fija un límite y, si se desborda, se devuelve al emisor
    private readonly Channel<Outbound> _queue = Channel.CreateBounded<Outbound>(
        new BoundedChannelOptions(QueueCapacity)
        {
            // Si está lleno, TryWrite devuelve false. El llamador puede saber al instante
            // que «ahora mismo está saturado». No se usa DropOldest: quien apiló
            // está esperando el Task, así que descartarlo en silencio haría que nunca vuelva
            FullMode = BoundedChannelFullMode.Wait,
            SingleReader = true,
        });

    private Outbound? _inFlight;

    public SingleWriter(SerialPort port, TimeSpan responseTimeout)
    {
        _port = port;
        _responseTimeout = responseTimeout;
    }

    /// <summary>Se puede llamar tanto desde la interfaz de usuario como desde un temporizador. El Write real lo realiza solo un worker.</summary>
    public Task<byte[]> SendAsync(ReadOnlySpan<byte> payload)
    {
        var item = new Outbound(
            Frame.Build(payload),
            new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously));

        if (!_queue.Writer.TryWrite(item))
        {
            // Está lleno, o el worker ya se detuvo. En ambos casos se devuelve al
            // llamador que «no se pudo apilar». Descartarlo en silencio haría que el Task en espera nunca vuelva
            item.Completion.TrySetException(new InvalidOperationException(
                $"No se pudo apilar en la cola de envío (límite de {QueueCapacity} elementos, o el worker ya se detuvo)."));
        }

        return item.Completion.Task;
    }

    /// <summary>Se llama cuando el parser extrae un frame. Se vincula al elemento que está esperando respuesta.</summary>
    public void OnFrameReceived(byte[] frame)
    {
        var pending = Interlocked.Exchange(ref _inFlight, null);
        pending?.Completion.TrySetResult(frame);
    }

    public async Task RunAsync(CancellationToken token)
    {
        try
        {
            await foreach (var item in _queue.Reader.ReadAllAsync(token))
            {
                Interlocked.Exchange(ref _inFlight, item);
                try
                {
                    await _port.BaseStream.WriteAsync(item.FrameBytes.AsMemory(), token);
                }
                catch (Exception ex)
                {
                    // Aquí llegan la desconexión del cable, el cierre del puerto o la cancelación.
                    // Si se sale sin completar este elemento, el llamador que está
                    // haciendo await sobre SendAsync espera para siempre
                    Interlocked.Exchange(ref _inFlight, null);
                    item.Completion.TrySetException(ex);
                    throw;
                }

                // Aquí se espera hasta la respuesta, así que el siguiente comando no se cuela
                var timeout = Task.Delay(_responseTimeout, token);
                var finished = await Task.WhenAny(item.Completion.Task, timeout);

                if (finished != item.Completion.Task)
                {
                    // Cuando se solicita la detención, Task.Delay también se cancela y
                    // termina antes. Si no se comprueba esto, el cierre normal se trata
                    // como timeout y el llamador recibe un TimeoutException
                    token.ThrowIfCancellationRequested();

                    // Puede que la respuesta llegue justo después de que WhenAny elija timeout.
                    // En ese momento, OnFrameReceived ya tomó _inFlight y completó este
                    // elemento con éxito. Si aquí no se puede tomar, gana la respuesta, y
                    // disparar TrySetException no tiene efecto. Si no se nota esto y se
                    // avanza hasta el throw, el llamador ya recibió el resultado pero
                    // solo el worker se cae. Quién gana la disputa se decide con CompareExchange
                    if (Interlocked.CompareExchange(ref _inFlight, null, item) != item)
                    {
                        // Ganó la respuesta. OnFrameReceived ya puso el resultado (justo después de ponerlo)
                        await item.Completion.Task;
                        continue;
                    }

                    item.Completion.TrySetException(new TimeoutException("No hubo respuesta."));

                    // Si hay timeout, ya no se puede confiar en esta conexión.
                    // El motivo por el que no basta con «renunciar y enviar el siguiente» se
                    // explica más abajo: como este protocolo no tiene ID de request, la
                    // respuesta de A que llega con retraso termina vinculándose como
                    // respuesta de B. Como en el diagrama de estados de 3.6, se cae a
                    // Fault y se reconstruye la sesión
                    throw new TimeoutException("No hay respuesta, se reconstruye la sesión.");
                }
            }
        }
        finally
        {
            // Sea cual sea el motivo por el que se detiene el worker, hay que terminar
            // siempre lo que está esperando: tanto el elemento en espera de respuesta como lo que quedó apilado en la cola sin enviar
            var stopped = new OperationCanceledException("El worker de envío se ha detenido.");
            Interlocked.Exchange(ref _inFlight, null)?.Completion.TrySetException(stopped);
            _queue.Writer.TryComplete();
            while (_queue.Reader.TryRead(out var pending))
            {
                pending.Completion.TrySetException(stopped);
            }
        }
    }
}

Detener todo el worker por un timeout puede parecer drástico, pero es una medida necesaria. Este frame no incluye un ID de request. Por eso el receptor no puede determinar «a qué comando responde el frame que acaba de llegar», y OnFrameReceived no tiene más remedio que vincular mecánicamente cada respuesta con el único elemento que está esperando.

Si aquí, tras el timeout, se sigue enviando el siguiente comando sin más, ocurre esto.

  1. Se envía el comando A. Como no llega respuesta en el tiempo predeterminado, se marca como timeout
  2. Se envía el siguiente comando B
  3. La respuesta de A, que llega con retraso, se entrega al llamador como si fuera la respuesta de B

Desde el punto de vista del llamador, se envió B pero se recibió el valor de A. Como el formato del valor es correcto, incluso pasa la validación, lo que lo convierte en uno de los fallos más difíciles de detectar. El motivo por el que en el diagrama de estados de 3.6 no se trazó una línea que vuelva directamente de Fault a Ready es precisamente esta ruta. El timeout no se trata como «el fallo de un elemento», sino como el juicio de que «ya no se puede confiar en esta conexión»; y la recuperación desde un estado en el que no se sabe qué queda en el búfer de recepción solo se puede garantizar cerrando el puerto y volviendo a abrirlo mediante una regeneración de sesión.

Si se puede modificar el protocolo, la solución más de fondo es que el frame lleve un ID de request para cruzarlo con la respuesta. Así, una respuesta que llega con retraso simplemente se descarta por ser «un ID desconocido», y deja de ser necesario reconstruir la conexión cada vez que hay un timeout.

Por último, se ensambla todo. La configuración del puerto se mantiene como un profile en un único lugar y se muestra en el registro al arrancar; eso llega hasta el punto tratado en 3.4.

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

// La configuración decidida en 3.4 se agrupa aquí, en lugar de usar valores sueltos
using var port = new SerialPort("COM3", 115200, Parity.None, 8, StopBits.One)
{
    Handshake = Handshake.None,
    DtrEnable = true,
    RtsEnable = true,
    ReadTimeout = 500,
    WriteTimeout = 500,
};

Console.WriteLine($"open {port.PortName} baud={port.BaudRate} data={port.DataBits} parity={port.Parity} " +
                  $"stop={port.StopBits} handshake={port.Handshake} dtr={port.DtrEnable} rts={port.RtsEnable}");
port.Open();

var parser = new FrameParser();
var writer = new SingleWriter(port, TimeSpan.FromSeconds(2));
var reader = new SerialReader(port, parser);

// Hay que suscribirse siempre a los eventos definidos. Si se olvida, ni los frames descartados ni las respuestas se muestran
parser.FrameDiscarded += frame => Console.Error.WriteLine($"crc error: {Convert.ToHexString(frame)}");
reader.FrameReceived += writer.OnFrameReceived;

using var cts = new CancellationTokenSource();
var readerTask = reader.RunAsync(cts.Token);
var writerTask = writer.RunAsync(cts.Token);

var request = new byte[] { 0x10, 0x00 };
try
{
    var response = await writer.SendAsync(request);
    Console.WriteLine($"response: {Convert.ToHexString(response)}");
}
finally
{
    // Aunque falle el envío, hay que detener siempre los workers antes de salir. Si se
    // omite esto, después de que el using cierre el SerialPort, el reader/writer
    // seguirán tocando ese stream, y encima nadie observará esa excepción
    cts.Cancel();
    try
    {
        await Task.WhenAll(readerTask, writerTask);
    }
    catch (OperationCanceledException)
    {
        // Finalización por solicitud de detención. Aquí se trata como el caso normal
    }
    catch (Exception ex)
    {
        // Fallo del lado del worker. Si se relanza aquí, se oculta el motivo original
        // del fallo (la excepción de SendAsync), así que solo se deja constancia
        Console.Error.WriteLine($"worker stopped with error: {ex.Message}");
    }
}

Que cts.Cancel() y Task.WhenAll estén dentro de finally no es una cuestión de estilo. Que SendAsync falle en la comunicación serie no es la excepción, sino el pan de cada día ── el equipo no responde, el cable se desconectó, la escritura hizo timeout. Si en ese momento se sale directamente hacia arriba, se llega a la destrucción del using sin haber detenido el worker. Después de que se cierre el SerialPort, el reader / writer seguirán intentando tocar ese stream, y nadie observará la excepción que surja ahí. En una aplicación de ejecución continua, esto se va acumulando poco a poco en forma de que solo sobreviven los workers de las operaciones que fallaron. Que no se relance la excepción en el lado del finally es para no ocultar el motivo original del fallo (la excepción de SendAsync).

Con esta forma, lo que se quiera añadir más adelante —por ejemplo retry, keepalive o reconnect— cabe siempre en uno de dos lugares: el lado que apila en la cola, o el lado del worker. Como no aumentan los sitios donde se llama directamente a Write, tampoco aumentan las causas de desorden en la secuencia.

6. Lista de verificación inicial

  • ¿Están documentados por escrito los límites de mensaje?
  • ¿La recepción sigue el esquema de acumular byte → extraer frame?
  • ¿Se está tratando DataReceived como la llegada de un mensaje?
  • ¿Se está haciendo E/S síncrona en el hilo de la interfaz de usuario?
  • ¿El envío está concentrado en un single writer?
  • ¿Los timeouts están separados por significado en lugar de ser uno solo?
  • ¿Están explicitados Handshake / DTR / RTS?
  • ¿La reconexión reconstruye la sesión?
  • ¿Se conserva el raw hex dump?
  • ¿Se han probado la desconexión y reconexión del equipo real, y los cortes a mitad de comunicación?

Si varios de estos puntos resultan dudosos, vale la pena detenerse una vez antes de pasar a producción.

7. Resumen

Para terminar, se enumeran de nuevo solo los puntos clave.

  • La comunicación serie es un byte stream, no un mensaje
  • La unidad de Read y la unidad de mensaje no coinciden
  • Los límites deben definirse como parte del protocolo
  • Convertir DataReceived directamente en un evento de negocio es propenso a romperse
  • Hay que separar las responsabilidades de envío y recepción, y concentrar el envío en un single writer
  • Los timeouts se dividen por significado, y la reconexión se diseña a nivel de sesión
  • Un registro que incluya el raw hex dump facilita bastante la investigación posterior

En definitiva, en una aplicación de comunicación serie, mucho más importante que abrir el puerto es cómo se interpreta la secuencia de bytes y cómo se controlan el tiempo y el estado. Con solo separar estos aspectos desde el principio en el diseño, los fallos de comunicación del tipo «se rompe solo ocasionalmente» se reducen considerablemente.

8. Referencias

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Si llamo a Read(16) en una comunicación serie, recibiré exactamente 16 bytes?
No necesariamente. La comunicación serie es un byte stream ordenado, y los límites de mensaje no se añaden por sí solos. Lo que se escribe con un único Write puede llegar al otro lado dividido en dos entregas, o concatenado con otros datos. Los patrones típicos de fallo son: se lee solo la longitud y el payload todavía no ha llegado, llega solo un frame y medio, o llegan dos frames juntos. La solución es separar la recepción en dos pasos: primero acumular en un buffer y, a partir de ahí, dejar que el parser extraiga los frames.
¿Qué hay que tener en cuenta al usar el evento SerialPort.DataReceived de .NET?
DataReceived no necesariamente se dispara por cada byte recibido, y tampoco se ejecuta en el hilo de la interfaz de usuario. Es peligroso interpretarlo como «notificación de que llegó un mensaje». En la práctica conviene tratarlo simplemente como «parece que llegó algo»: dentro del manejador no se debe realizar procesamiento pesado, y las actualizaciones de la interfaz de usuario siempre deben devolverse al hilo de la interfaz. La estructura estable consiste en acumular primero la secuencia de bytes recibida y luego dejar que el parser extraiga los frames.
¿Cómo se debe diseñar el timeout en la comunicación serie?
Un solo timeout no es suficiente; es más estable dividirlo según su significado. Están el open timeout, hasta que se abre el puerto; el inter-byte timeout, el tiempo sin que lleguen bytes en mitad de un frame; el response timeout, desde que se emite un comando hasta que se completa la respuesta; y el reconnect backoff, el intervalo de espera para la reconexión. En lugar de tratar el timeout como un seguro para cuando algo va lento, es más estable definirlo como una regla que hace avanzar las transiciones de estado. También hay que tener cuidado: dejar una lectura síncrona con los valores predeterminados suele terminar en una espera indefinida.
¿Por qué no se recupera la comunicación tras desconectar y volver a conectar el cable en un conversor USB-serie?
Porque en los conversores USB-serie es normal que el puerto desaparezca temporalmente, que el handle anterior deje de ser válido, que cambie el número de COM, o que la pending request anterior pierda su sentido. Repetir solo el Open() no basta como reconexión. Diseñarlo como una «regeneración de sesión» que agrupe la invalidación de la sesión, el fallo de las pending requests, la detención del reader y el writer, el reopen tras el backoff, y la reejecución de la secuencia de inicialización del equipo, permite reducir los errores de reconexión que fallan solo ocasionalmente.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog