Comunicaciones Serie en Windows (II)

Abordamos la lectura y escritura en el puerto y la creación de componentes que oculten la complejidad del proceso

Por Mario Rodríguez

Finalizaba el artículo anterior explicando la forma en que la aplicación de ejemplo leía y escribía del puerto. En el código que acompañaba al artículo se mostraban dos formas de hacerlo: mediante un bucle (implementado con un timer) que, periódicamente, leyese el estado del puerto, y mediante un hilo que estuviese alerta a cualquier evento que se produzca en el puerto y avise a la aplicación de dicha contingencia para que ésta realice las acciones apropiadas.

Comentaba que el timer podría hacer que se perdiesen datos si el intervalo del mismo era lo suficiente amplio como para que se llenase el buffer del controlador del puerto antes de la siguiente lectura. Lo mismo podía pasar al utilizar el hilo si el método Synchronize() del mismo consumía mucho tiempo. El controlador del puerto va leyendo, byte a byte, desde la UART los bytes recibidos y colocándolos en el bufferhasta que la aplicación lee éste, lo que hace que se produzca el vaciado del buffer y que el proceso vuelva a comenzar. El buffer puede saturarse si la aplicación tarda mucho, en comparación con el tamaño del mismo, entre lectura y lectura del mismo.

Se puede ajustar el tamaño del buffer del controlador mediante la función del API de comunicaciones SetupComm(). Esta función admite 3 argumentos: el manejador, identificador o handle del puerto, para lo que, lógicamente, hemos de tenerlo abierto; el tamaño del buffer de entrada, donde situará todo aquello que lea del puerto hasta que la aplicación realice una lectura o hasta que lo sature; y el tamaño del buffer de salida, donde situará lo que la aplicación vaya escribiendo (ver figura 1). Desde este buffer de salida irá enviando, byte a byte, a la UART (que añadirá a cada grupo de bits recibidos –los denominados bits de datos, el bit de paro y, si es necesario, el bit de paridad) hasta que el buffer se vacíe.

El tamaño apropiado del buffer de entrada y salida depende del tamaño de las tramas (grupos de bytes) que se vayan a leer o escribir en el puerto, respectivamente, y debería ajustarse a ese tamaño. Ni la UART ni el controlador del puerto entienden estas tramas: es la aplicación la que determina el tamaño de cada trama, esto es, dónde comienza y termina cada una. Asimismo, es la aplicación la responsable de enviar las tramas en el orden apropiado y de reenviar las tramas que hayan llegado incorrectamente a destino. Como es lógico, el buffer de entrada y el de salida no necesariamente tienen que ser iguales. Más aún si las tramas que se escriben tienen distinto tamaño de las que se leen. Me explico.

Cuando la aplicación va a enviar datos al puerto, pongamos que sea un archivo, divide éste en grupos de bytes: la trama. Generalmente, el proceso se hace así para evitar tener que reenviar el archivo completo si se produce un error: se enviaría sólo desde donde se ha detectado el error o la trama que no ha llegado a destino. Si lo que se está enviando al puerto son comandos o grupos de bytes para que, remotamente, otra aplicación los procese, estos bytes pueden ser la trama. También es común que, cuando una aplicación recibe una trama o comando, le informe al emisor de que la ha recibido y/o si ésta ha llegado correctamente. A esto me refería con las tramas que se leen: supongamos que la aplicación del lado de acá envía una trama de 1024 bytes; cuando la trama llega al lado de allá, la aplicación remota comprueba la trama recibida y envía, por ejemplo, un byte que indica trama recibida correctamente (o no). Así, mientras se están escribiendo en el puerto tramas de 1024 bytes, se leen del puerto tramas de 1 byte.

Sin embargo, se ha de tener en cuenta que, al fijar el tamaño del buffer de entrada y salida, se le pide al controlador del puerto que cree éste de un determinado tamaño, lo que no quiere decir que lo haga siempre: es el controlador el que decide, en último término (al menos, según la documentación de la función), el tamaño más oportuno. Así que, el que la función SetupComm() devuelva TRUE, no significa, necesariamente, que se haya establecido el buffer al valor pedido.

Averiguar el tamaño que el controlador ha dado a un determinado buffer es sencillo: una llamada a la función GetCommProperties() lo proporcionará. Esta función admite sólo dos argumentos: el manejador o handle del puerto (de nuevo, el puerto debe estar abierto) y una variable de tipo record TCommProp donde copiará las propiedades del puerto y, entre ellas, el tamaño del buffer de entrada (dwCurrentRxQueue) y salida (dwCurrentTxQueue). El proceso de configuración se esquematiza en el listado 1.

function XXX.ConfigurarColas(dwTamColaEntrada, dwTamColaSalida: DWORD): Boolean;
var
  PropsComm: TCommProp;
bBegin
  // Pedir al driver que establezca los tamaños de los buffers de entrada
  // y salida. Aquí 'FHandlePuerto' es el manejador o 'handle' del puerto
  // y debe ser válido (el puerto debe estar abierto) o la función fallará
  Result := SetupComm(FHandlePuerto, dwTamColaEntrada, dwTamColaSalida);
  if (not (Result)) then
    FError := GetLastError	// Error del API
  else
  begin
    // Comprobar los tamaños que el driver ha asignado a los bufferes
    Result := GetCommProperties(FHandlePuerto, PropsComm);
    if (not (Result)) then
      FError := GetLastError	// Error del API
    else
    begin
      // Los bufferes han sido configurados: guardar valores.
      FTamColaEntrada := PropsComm.dwCurrentRxQueue;
      FTamColaSalida  := PropsComm.dwCurrentTxQueue;
    end;
  end;
end;
Listado 1. Configuración del buffer de entrada y salida

Tanto el buffer de entrada como el de salida están implementados en forma de cola o estructura FIFO (first-in, first-out; primero en entrar, primero en salir) lo que nos permite asegurar la escritura y lectura secuencial correcta de los bytes que se vayan enviando o recibiendo desde el puerto. Esto tiene una ventaja clara. Imaginemos que en el puerto tenemos 1024 bytes: si leemos ahora 512 bytes tenemos la seguridad de que, la próxima lectura, se realizará comenzando en el byte 513 actual, independientemente de que, mientras tanto, hayan llegado más bytes al puerto (suponemos que no se ha saturado el puerto por la llegada de estos bytes). Igualmente cuando se escribe en el puerto: enviar, primero, 512 bytes y, luego, otros tantos, hace que se sitúen uno tras otro, la segunda escritura tras la primera.

También hay una desventaja en el método y es que la aplicación, en todo momento, debe controlar dónde comienzan y finalizan las tramas. Pongamos un ejemplo. La aplicación está recibiendo tramas de 1024 bytes. Cuando se recibe un byte, el controlador del puerto informa a la aplicación y ésta se dispone a leer del puerto. Comprueba el tamaño actual del buffer de entrada y… se da cuenta de que no hay 1024 bytes sino 1000. ¿Qué hacer? ¿Esperar a que lleguen tantos bytes como se esperaban? Si no lee ahora del puerto, corre el peligro de que se sature el buffer de entrada. Si lo lee no obtendrá una trama sino parte de ella. Si se decide por leer hasta 1024 bytes, el hilo donde se está produciendo la lectura se detendrá hasta que el buffer de entrada tenga esa cantidad (esto es, hasta que la función ReadFile() retorne) y, si es el hilo principal de la aplicación, ésta aparecerá, de cara al usuario, como si se hubiese quedado colgada.

La solución al problema pasa por que la aplicación, obedientemente, lea del puerto tantos bytes como existan en el momento en que se disponga a ello, los almacene en algún lugar y, posteriormente, los procese. Lo que implica que, de alguna forma, se ha de llevar un control sobre el número de bytes que se han tratado: el tamaño de la trama. El listado 2 muestra el pseudo–proceso de lectura del puerto, tomando como ejemplo el código entregado en el artículo anterior.

...
var
  Lectura: String;
  dwValor: DWORD;
  Sta: COMSTAT;
  bResult: Boolean;
begin
  // Comprobar el tamaño del buffer de entrada
  if (ClearCommError(FHandlePuerto, dwValor, @Sta)) then
  begin
    // Mientras haya algo en el buffer de entrada...
    while (0 < Sta.cbInQue) do
    begin
      // Redimensionar la variable de lectura
      SetLength(Lectura, (Sta.cbInQue + 1));
      // Leer del puerto
      bResult := ReadFile(FHandlePuerto,
                          PChar(Lectura)^,
                          Sta.cbInQue,
                          dwValor,
                          nil);
      if (bResult) then
      begin
        // Aquí se almacenaría lo leído
        ...
        // Comprobar si hay algo más para leer
        // y, si es así, continuar en el bucle
        ClearCommError(FHandlePuerto, dwValor, @Sta);
      end
      else
      begin
        // Esto es un error de lectura.
        // IMPORTANTE: si se produce un error nos
        // quedamos en un bucle sinfín si no se hace:
        Sta.cbInQue := 0;
        // También vale un 'break' o 'exit'
      end;
    end;
  end;
end;
Listado 2. Proceso de lectura del puerto

Lo ideal sería que la aplicación viese el puerto como si fuese un archivo de disco donde leyese y escribiese sin preocuparse de cómo se realiza internamente ese proceso. La aplicación que acompaña a este artículo o, mejor dicho, los componentes que se explicarán en siguientes apartados, leen y escriben en el puerto a través de unas estructuras de datos de tipo cola (ver figura 2). Cuando la aplicación envía (escribe) algo al puerto, se sitúa en una cola, la cola de escritura, desde la que el hilo de escritura va leyendo, nodo a nodo, y enviando cada nodo, en orden, al puerto. Asimismo, cuando se recibe algo del puerto, el hilo de lectura sitúa lo leído, en orden, en la cola de lectura desde la que la aplicación podrá leerla a continuación.

Si siempre es preferible la programación orientada a objetos que la procedural, en el caso de las comunicaciones serie se añade un condicionante más y es el que las llamadas a las funciones del API deben realizarse en un orden correcto: fallará si el puerto no está abierto o está mal configurado. No es un buen método el presentado en el código que acompañaba al artículo anterior debido a que las funciones de comunicaciones están esparcidas por distintos lugares en vez de agrupadas en una clase de manejo del puerto y, fundamentalmente, porque si en el futuro se tiene necesidad de realizar otra aplicación que acceda al puerto se encontrará copiando y pegando código de un lugar a otro. En lo que sigue, se implementará el acceso al puerto serie a través de clases que aíslen a la aplicación de la complejidad del manejo del puerto.