Comunicaciones Serie en Windows II (3ª parte)

Cuando comencé a desarrollar el esquema que se ha explicado en el punto anterior se me presentaron dos problemas principales: cómo comunicarse con los hilos de lectura y escritura; y cómo hacer que el proceso fuese lo más rápido posible sin que, a su vez, interfiriese en el transcurso normal de la aplicación.

El resultado creo que fue satisfactorio (el lector juzgará): una de las aplicaciones en que está instalado es capaz de estar realizando consultas a bases de datos situadas en red mientras está recibiendo por módem un archivo y, todo ello, sin que se note una sobrecarga excesiva en el proceso o un rendimiento pobre en ninguno de los dos procesos.

Los datos llegan al puerto en un momento determinado. Esto genera el evento EV_RXCHAR en ese instante. El hilo de lectura es capaz de detectar ese evento y de leer lo que el buffer del puerto tiene en el momento de la lectura. A partir de ese punto, el proceso se complicaba porque una sincronización del hilo (esto es, una llamada a THiloLectura.Synchronize()) paraba el hilo de lectura hasta la vuelta de la sincronización lo que podía producir que determinados eventos del puerto se perdiesen en el transcurso. Más si la aplicación en esos momentos estaba realizando otra tarea larga (una consulta a la base de datos, por ejemplo).

Fue entonces cuando comprendí que Windows pone a disposición de cualquier aplicación una forma relativamente sencilla de transmitir datos entre procesos: la cola de mensajes. Bastaría crear una ventana y poner (esto es, un PostMessage(), en contraposición a enviar que se haría con SendMessage()) en la ventana un mensaje que indicase la recepción de datos.

Había un pequeño problema con esto y era que, en ese momento, el propietario de la ventana podría estar ocupado: si la clase propietaria del hilo se encuentra en el mismo hilo que la aplicación principal, podría estar parada (esperando, por ejemplo, a que finalizase el proceso de consulta a la base de datos). Más tarde, leería el mensaje de la cola de mensajes e intentaría leer del puerto pero, mientras tanto, el puerto podría haberse saturado y, por tanto, podría haber habido pérdida de datos.

Así que la solución pasaba por que el hilo (que no se detendría nunca) debería ser capaz de valerse por sí mismo, esto es, leer los datos tal y como llegan y cuando llegan y, una vez leídos, dejarlos en algún lugar para, seguidamente, avisar a la clase de la recepción de datos: han llegado datos y los he dejado por ahí. Me preocupaba el por ahí: necesitaba un sitio (claramente, una zona de memoria) en que dejar los datos que, además, no interfiriese con el transcurso del hilo y desde donde la aplicación pudiese leer su contenido cuando estuviese preparada para ello.

La cola de mensajes de la ventana de la clase propietaria del hilo mantendría en orden todos los mensajes, tal y como el hilo los iba enviando, y cuando hubiese llegado el momento de procesarse no habría problemas ya que se irían tomando, uno a uno y en orden, dichos mensajes. Así que lo importante era buscar una forma de mandar el mensaje y volver rápidamente a seguir leyendo del puerto.

La función PostMessage() (como, por otro lado, SendMessage()) tiene 4 argumentos: el handle o identificador de la ventana a la que se dirige el mensaje; el código de mensaje; un valor de tipo WPARAM (Longint); y, finalmente, otro de tipo LPARAM (otro Longint). Comprendí que estos dos últimos argumentos podrían servir para enviar al procedimiento de ventana un puntero (como se sabe, un puntero es, en sí, un número entero) a la zona memoria donde estuviesen los datos y el tamaño de los datos.

La siguiente instrucción (ver el archivo de módulo HiloLeer.pas) realiza la tarea: PostMessage(FHandleWindow, PWM_RECIBIR_DATOS, wParam(dwTamBuffer), lParam(PNodo)). Aquí, FHandleWindow es el identificador de la ventana a la que se envía el mensaje PWM_RECIBIR_DATOS (constante declarada en el módulo GlobComm.pas como WM_USER + 4), dwTamBuffer es una variable que almacena el tamaño de los datos leídos y PNodo es la posición (puntero) en que están los datos. Con esto, el procedimiento de ventana recibe los datos en el argumento lParam y puede saber su tamaño mediante el argumento wParam.

La escritura en el puerto presentaba inconvenientes similares a los apuntados antes. Por suerte, el API de Windows proporciona una función que permite enviar mensajes directos al hilo. Para ello, lo único necesario es saber a qué hilo se desea enviar el mensaje (lógico, ¿no?). La siguiente instrucción (en el procedimiento Escribir() del módulo PtoSerie.pas) realiza esta operación: PostThreadMessage(FHiloEscritura.ThreadID, PWM_ENVIAR_DATOS, wParam(dwTamano), lParam(PNodo)). El argumento FHiloEscritura.ThreadID es el identificador del hilo al que se envía el mensaje (FHiloEscritura es un campo de tipo TThread o, mejor dicho, derivado de éste, de la clase TPuertoSerie); PWM_ENVIAR_DATOS es una constante declarada en el módulo GlobComm.pas como WM_USER + 3); dwTamano tiene el tamaño de los datos a escribir y PNodo la dirección en memoria de dichos datos.

Ahora sólo hacía falta un sistema de almacenamiento que, por un lado, fuese rápido y que, por otro, permitiese a la aplicación desentenderse de los datos hasta el momento que estuviese preparada para su lectura.

La autopista del Sur

Podría haber usado el típico TList para ello: para almacenar las lecturas y escrituras en el puerto. Sin embargo, preferí no sobrecargar este proceso, crucial, y decidí hacer una clase de tipo cola donde se pudiese almacenar, de forma secuencial, los datos antes de ser transmitidos o después de recibidos. De alguna manera, con esto no inventaba nada: es lo que el controlador del puerto hace, esto es, mantener los datos recibidos y los datos a enviar en una cola FIFO.

La clase TTADCola se encuentra implementada en el módulo TAD_Cola.pas y, si el lector se detiene en ella, verá que es muy pequeña y, a la vez, potente. Permite almacenar cualquier variable que sea susceptible de convertirse en puntero (números, imágenes, objetos,…) y que su recuperación se realice de forma absolutamente secuencial: el primer elemento que se ponga en cola (mediante el procedimiento Meter() de la clase) saldrá en primer lugar (mediante el procedimiento Sacar()).

La mayor parte de los procedimientos de la clase son virtuales y protegidos ya que no he considerado que ninguna aplicación deba acceder directamente a los elementos de la cola. Así, se ha derivado desde TTADCola una clase, TColaSerie, que expone los métodos de la cola (ver módulo Cola_Ser.pas) y que hace de interfaz, resultando en un manejo más claro y cómodo de los datos que se envían y reciben desde el puerto.

Si se estudia la clase TPuertoSerie se verá que se mantienen dos colas de escritura y otras dos de lectura. Esto es así porque, en las pruebas realizadas, el hecho de pedir y liberar memoria continuamente producía una sobrecarga impresionante en el sistema. Piense que la llegada de un carácter, esto es, un byte al puerto, dispara el evento EV_RXCHAR durante el cual el hilo de lectura debe obtener memoria en la que almacenar los datos. Ahora, si por ejemplo se está recibiendo un archivo de 1 Mb podría haber miles de peticiones de memoria y sus consiguientes liberaciones.

La solución pasó por crear una cola de reserva de memoria, tanto para lecturas como para escrituras. La cola de lectura puede alcanzar un tamaño de MAXBYTE (255 elementos. Ver el procedimiento ObtenerMemoriaLectura() en el módulo PtoSerie.pas). Antes de realizar una lectura se comprueba si se ha llegado a ese tamaño y, si es así, se utiliza el nodo de cabeza de la cola de lectura (FColaLectura) sobrescribiendo, lógicamente, los valores almacenados en dicho nodo. Si no es el caso, se comprueba si hay elementos en la cola de reserva (campo FReservaLeer de la clase TPuertoSerie). Finalmente, si tampoco hay reserva de memoria para la lectura, se pide al gestor que cree un nuevo nodo.

Teniendo en cuenta que cada nodo puede albergar un total de 4096 bytes y de que la cola de lectura puede tener 255 elementos, el máximo tamaño que puede almacenarse es de alrededor de 1 Mb que considero suficiente para que cualquier aplicación normal tenga tiempo de realizar las labores que considere oportunas entre una lectura y la siguiente. Si se desea cambiar este tamaño, bastará con modificar el valor de la constante TAM_BUFFERS_PUERTO que encontrará en el módulo GlobComm.pas.

En general, excepto que se requiera un tratamiento muy especializado, bastará con que la aplicación lea del puerto en el evento OnRecibirDatos del componente (ver el código del formulario principal, TFrmModemER, de la aplicación de ejemplo). El manejo de la cola de lectura y de la adquisición y liberación de memoria asociada queda, de esta forma, oculta para las clases clientes.

La cola de escritura no tiene límite pero no alcanzará ese tamaño debido a que el proceso de escritura es rápido: pasa muy poco tiempo entre el envío de una trama al hilo THiloEscritura y que éste finalice la escritura. Además, antes de realizar una escritura, se comprueba si hay nodos en la cola de reserva de escritura (campo FReservaEscr de la clase TPuertoSerie) en cuyo caso, se utilizan.

De esta forma, la aplicación comenzará consumiendo un número considerable de memoria para, en un momento determinado, mantenerse en una constante y, a partir de entonces, no necesitar más del gestor de memoria hasta el momento de cerrar el puerto (momento en que las colas se vacían y se libera la memoria de las mismas).

En ocasiones, sobretodo si se están recibiendo tramas por el puerto serie, nada garantiza que una lectura del puerto vaya a obtener una trama completa. Esto es, como se ha dicho antes, la llegada de un byte produce el evento de lectura del puerto. El hilo de lectura lee del mismo lo que en esos momentos haya en el buffer del puerto.

Esto ya lo he comentado antes pero conviene aclararlo un poco. Al recibirse el evento EV_RXCHAR en el hilo de lectura (en el procedimiento Execute() de la clase THiloLectura, módulo HiloLeer.pas) el hilo se dispone a leer el buffer. Para ello, pide memoria y comprueba cuantos bytes hay en el buffer. Todo este proceso lleva un tiempo durante el cual han podido llegar más bytes al puerto (de hecho, en una comunicación normal, llegan). Así que nunca puede saberse, hasta el momento previo a la lectura (una llamada a la función del API de Windows GetOverlappedResult()) el número de bytes que se leerán.

En ningún caso el hilo sabe el tamaño de la trama. Si lo supiese podría esperar a conseguir tantos bytes como tenga la trama esperada en cuestión. Pero entonces nos encontraríamos de nuevo con el posible problema de la pérdida de eventos del puerto. Además, el hilo de lectura se detendría en la llamada a leer (ReadFile(). Ver también listado 2) y no volvería hasta haber completado la lectura, esto es, hasta obtener el número de bytes pedidos: si no se reciben más bytes (un corte de línea, por ejemplo) el hilo se quedaría colgado en ese proceso y la aplicación en su conjunto podría no responder a los intentos del usuario por finalizar.

La clase TLecturaLinea implementada en el módulo LeeLinea.pas permite leer byte a byte desde la cola de lectura. La clase cliente puede, entonces, determinar dónde comienza y termina una trama e interpretarlas adecuadamente. Puede verse un ejemplo de su utilización en el procedimiento ObtenerRespuestaModem() de la clase TControlModem (módulo ConModem.pas). Se llama a este procedimiento tras hacer una llamada telefónica: el módem marca el número enviado (ver MarcarTelefono() en el mismo módulo), envía su identificación y espera la contestación del módem remoto devolviendo un código u otro. El hilo lee del puerto pero nada garantiza que se haya podido leer completo el código de resultado del módem (por ejemplo, en vez del CONNECT que se produce al conectar con el módem remoto, se ha podido leer un CON, seguido por una lectura del NECT). Se utiliza la clase TLecturaLinea para ir explorando la cadena devuelta por el módem y averiguar el código de resultado.

Una aplicación puede crear un objeto de este tipo en cualquier momento en que requiera leer, byte a byte o carácter a carácter, del puerto: lo único que necesita es un objeto de tipo TPuertoBase al que llama la clase para leer el contenido de la cola de lectura. La aplicación utilizaría entonces los procedimientos LeerByte() o LeerChar() (hay más procedimientos Leerxxx) para recorrer la lectura del puerto y determinar los límites de cada trama.

Otro hilo más es el que crea la clase TControlModem. El hilo THiloTimer, cuya implementación puede verse en el módulo HiloTime.pas, envía a cada cierto intervalo de tiempo en milisegundos (especificado en su propiedad Intervalo) el mensaje especificado en su creación, a la ventana propietaria (ver el módulo indicado). Esto permite a TControlModem averiguar el número de bytes enviados o recibidos. El hilo es lo suficientemente general como para poder ser utilizado como una alternativa al componente TTimer.

Estación de la mano

Finalizamos este artículo aquí sabiendo que quedan por explicar los procesos de configuración del puerto y el manejo de eventos en los hilos así como lo específico que tiene el módem. Será en la próxima entrega.

La aplicación de ejemplo entregada con este artículo tiene los mismos formularios que la del artículo anterior. Se ha eliminado del formulario principal el Timer ya que ahora no se necesita para nada. Ahora el código de este formulario es mucho más simple ya que no posee todo el manejo del puerto que tenía el del artículo anterior. Lo que aparece en el TMemo se obtiene a través del evento OnRecibirDatos del componente TControlModem: una simple línea.

Los 3 tipos de errores del puerto que se han mencionado antes (ErrorWin32, ErrorSerie y ErrorPuerto) se muestran a través de llamadas a la función del API de Windows MessageBox() (ver los eventos OnError del componente TControlModem en el formulario principal). Se ha dejado así para que se compruebe (en el caso hipotético de producirse un error en el puerto) como el diálogo de error no para la lectura del puerto: aparecerán varios diálogos en pantalla, uno tras otro, hasta que se solucione el problema. En una aplicación real éste no es el método a seguir (sobretodo si es desatendida) debido a que puede consumir rápidamente los recursos del sistema.

El módulo de proyecto incluye todas las unidades correspondientes a las clases de las que hemos hablado antes. Esto no sería necesario (por lo que se explica más abajo): en realidad, bastaría con el formulario principal (TFrmModemER) y el de configuración del puerto (TFrmCommConfig). Pero he preferido dejarlo así para que sea cómoda la navegación por el código.

Los archivos que se entregan, en su conjunto, contienen dos componentes (TPuertoSerie y TControlModem). Para instalarlos se suministra un archivo: MundoDel.dpk. Se debe realizar el proceso de instalación como siempre: elegir en el menú File, el comando Open y, en el diálogo que aparezca a continuación, seleccionar Delphi package (*.dpk) y abrir el archivo MundoDel.dpk. Luego, se pulsa en el botón Install que compilará los módulos y creará una pestaña de nombre Mundo Delphi (Ver figura 5) en la que aparecerán los dos componentes mencionados. Dado que las clases que conforman los componentes se han separado en distintos módulos y que se ha comentado cada uno de los procedimientos espero que sea fácil su estudio, comprensión y modificación, si así se desea.