El puerto serie en Windows

Generalidades

Puerto serie

En Windows no es posible acceder a los dispositívos físicos directamente, a través de las direcciones de sus puertos. Por el contrario, todos los accesos (salvo que estemos programando un driver) deben hacerse a través de funciones del API.

Los puertos serie, por tratarse de dispositivos incluidos como parte de los PC desde sus comienzos, están muy bien integrados en el API de Windows, por lo tanto, tenemos un amplio repertorio de funciones para manejarlos.

El presente artículo no pretende estudiar en profundidad todas las funciones y opciones del API de Windows con referencia al puerto serie, sólo intentaré que aquellos que necesiten usar estos puertos en sus programas tengan unas nociones más precisas sobre cómo lograrlo.

Windows trata los puertos serie (y también el paralelo), como si se tratase de ficheros de entrada y salida. La única peculiaridad es que su comportamiento es asíncrono, y esta característica influye mucho en el modo en que tenemos que programar nuestras aplicaciones cuando usen uno de estos puertos.

El comportamiento asíncrono se debe a varias características de este tipo de comunicación, para empezar, los datos se envían secuencialmente, a una velocidad relativamente baja. El sistema tiene que estar preparado para recibir los datos en el momento en que están disponibles, ya que si no actúa así, se perderán irremisiblemente.

En los ficheros normales, somos nosotros los que decidimos cuándo y cómo leemos o escribimos los datos. Y como la velocidad de respuesta de estos ficheros es bastante buena, generalmenten no notamos que el programa se para mientras se procesan estas órdenes de lectura y escritura.

Esto no pasa cuando se lee o se escribe de un puerto serie. Los datos que se reciben por uno de estos canales hay que leerlos cuando llegan, casi nunca sabremos cuándo el dispositivo que tenemos conectado al otro extremo del cable va a decidir enviarnos datos. En cuanto a la escritura, pasa algo parecido, no podemos preveer con precisión si el dispositivo al que enviamos los datos los va a procesar con la velocidad a la que se los enviamos, o si está o no preparado para recibirlos.

Aunque el sistema operativo dispone de un buffer para almacenar los datos que se reciben, ese buffer es finito, y si nuestro programa no retira esos datos con cierta frecuencia, los perderá.

En cuanto a las transmisiones hacia afuera, pasa algo parecido. El dispositivo receptor puede tener una capacidad limitada para procesar los datos, de modo que el sistema debe estar preparado para esperar mientras el receptor procesa los datos y nos avisa de que está preparado para recibir más.

Estas características hacen que nuestro programa se complique un poco más de lo que en principio pudiéramos esperar. Pero como veremos, tampoco es para tanto.

Abrir un canal asociado a un puerto serie

Lo primero que necesitamos es un fichero asociado a nuestro puerto serie. Para eso usaremos la función del API CreateFile. CreateFile es una función con muchas opciones y que sirve para muchas cosas, pero ahora nos centraremos en el puerto serie.

Veremos ahora los valores posibles de los siete parámetros que necesita esta función:

  1. LPCTSTR lpFileName: nombre del fichero. Se trata de una cadena que contiene el nombre del puerto que queremos abrir. Los valores posibles son "COM1", "COM2", "COM3" y "COM4".
  2. DWORD dwDesiredAccess: tipo de acceso. En general querremos leer y escribir en el puerto, por lo tanto especificaremos los valores GENERIC_READ | GENERIC_WRITE.
  3. DWORD dwShareMode: modo en que se comparte el fichero. En nuestro caso, un puerto serie no puede ser compartido, de modo que usaremos 0 para este parámetro.
  4. LPSECURITY_ATTRIBUTES lpSecurityAttributes: atributos de seguridad, especifican el modo en que el fichero se puede heredar por procesos hijos. En nuestro caso no queremos que eso ocurra, de modo que usamos el valor NULL.
  5. DWORD dwCreationDistribution: modo de creación. Los puertos serie son dispositivos físicos, por lo tanto, existen. El modo de creación será OPEN_EXISTING.
  6. DWORD dwFlagsAndAttributes: atributos del fichero. Por ahora no nos interesa ninguno de estos atributos, usaremos el valor 0.
  7. HANDLE hTemplateFile: plantilla de fichero. Se puede especificar un fichero existente del cual se copirarán los atributos. En nuestro caso no usaremos esta opción, y usaremos el valor NULL para este parámetro.
   idComDev = CreateFile("COM1", GENERIC_READ | GENERIC_WRITE,
   		0, NULL, OPEN_EXISTING, 0, NULL);

Modificar los parámetros de transmisión

Empezaremos por definir el tamaño de los buffers de entrada y salida para el puerto serie. Para ello usaremos la función SetupComm, usando como primer parámetro el manipulador del puerto obgenido de CreateFile, como segundo el tamaño del buffer de entrada y para el tercero, el tamaño del buffer de salida:

    // Iniciar parámetros de comunicación
    if(!SetupComm(idComDev, 1024, 1024)) {
  	    return false;
	}

En general, necesitaremos establecer los parámetros de la línea serie que vamos a usar. Tendremos que fijar la velocidad de transmisión, el número de bits de datos, la paridad, y los bits de stop. Y a veces algunos parámetros más.

Para hacer esto, primero recuperaremos los parámetros del canal que acabamos de abrir, los modificaremos y actualizaremos la configuración del canal.

Para recuperar los parámetros usaremos la función GetCommState. Esta función nos devuelve una estructura DCB, que contiene la configuración actual del puerto serie.

   fSuccess = GetCommState(idComDev, &dcb);

De todos los valores que incluye la estructura DCB, de momento sólo nos preocuparemos por unos pocos:

  1. DWORD BaudRate: velocidad en baudios. Este parámetro puede tener los siguientes valores: CBR_110, CBR_300, CBR_600, CBR_1200, CBR_2400, CBR_4800, CBR_9600, CBR_14400, CBR_19200, CBR_38400, CBR_56000, CBR_57600, CBR_115200, CBR_128000 y CBR_256000.
  2. BYTE ByteSize: tamaño de los datos en bits. Tradicionalmente 7 u 8.
  3. BYTE Parity: valor de paridad. Se admiten los valores EVENPARITY, para paridad par; MARKPARITY; NOPARITY, para no paridad y ODDPARITY, para paridad impar.
  4. BYTE StopBits: Bits de stop. Admite los valores ONESTOPBIT, 1 bit de stop; ONE5STOPBITS, 1.5 bits de stop y TWOSTOPBITS, 2 bits de stop.
  5. DWORD fBinary:1 Una bandera, en un campo de bits, que indica el modo de transmisión: binario o texto. 1 para modo binario, 0 para modo texto. La diferencia es que en modo texto se verifica la recepción del carácter EOF, que detiene la transmisión.
  6. DWORD fAbortOnError:1 Otra bandera que indica si interrumpe la transmisión o recepción en caso de error. 1 para interrumpir, 0 para no interrumpir.

Una vez que hemos actualizado la estructura de datos podemos configurar el puerto enviándosela mediante la función SetCommState.

    m_dcb.BaudRate = CBR_115200;
	m_dcb.ByteSize = 8;
	m_dcb.Parity = NOPARITY;
	m_dcb.StopBits = ONESTOPBIT;
	m_dcb.fBinary = FALSE;
	m_dcb.fAbortOnError = TRUE;

	SetCommState(idComDev, &dcb);

Por último, podemos modificar los timeouts. Se trata de unos intervalos de tiempo definidos para que las rutinas de lectura y escritura distingan entre diferentes paquetes de datos.

Generalmente, las comunicaciones por el puerto serie se harán por bloques, de los que no siempre conoceremos el tamaño. Estos tiempos nos permiten detectar divisiones entre bloques, de modo que el API las detecta y pone a nuestra disposición la información leída.

El sistema calcula dos tiempos, uno para lectura y otro para escritura, en función de ciertos parámetros (que veremos ahora como fijar), y de los caracteres requeridos o escritos.

Estos tiempos se definen en una estructura COMMTIMEOUTS, y se puede ver el uso de cada campo en la descripción de ella.

La elección de los tiempos y la necesidad de modificarlos dependerá del tipo de comunicación serie que necesitemos para nuestra aplicación. Estos tiempos harán que las operaciones de lectura y escritura puedan terminar antes de que se haya completado las funciones de lectura o escritura, para los datos indicados. Su uso es, de todos modos, opcional.

Para modificarlos usaremos la función GetCommTimeouts para obtener los timeouts actuales en una estructura COMMTIMEOUTS. Modificaremos esos tiempos a nuestro criterior, y los activaremos usando la función SetCommTimeouts.

	// Leer time-outs actuales:
    GetCommTimeouts (hComm, &m_CommTimeouts));

	// Nuevos valores de timeout:
    m_CommTimeouts.ReadIntervalTimeout = 50;
	m_CommTimeouts.ReadTotalTimeoutConstant = 50;
	m_CommTimeouts.ReadTotalTimeoutMultiplier = 10;
	m_CommTimeouts.WriteTotalTimeoutConstant = 50;
	m_CommTimeouts.WriteTotalTimeoutMultiplier = 10;

	// Establecer timeouts:
	SetCommTimeouts (hComm, &m_CommTimeouts));

Escribir en el puerto serie

Para enviar caracteres al puerto serie se usa la función WriteFile.

Sin embargo, como ya hemos explicado, no basta con enviar los caracteres al puerto serie, el destinatario puede interrumpir la transmisión si no es capaz de procesar los datos a la misma velocidad que se los enviamos, de modo que los datos que intentamos enviar pueden no ser enviados por completo.

Para estar seguros de que enviamos toda la información que queremos, usaremos uno de los parámetros que devuelve la función, y que nos dice cuántos caracteres se han enviado.

Colocando la función WriteFile en un bucle, podemos enviar los caracteres que aún están pendientes hasta que todos hayan sido enviados.

WriteFile puede retornar con valor false si se ha producido un error. Sin embargo, uno de los errores no es tal, el error ERROR_IO_PENDING en realidad sólo nos informa de que no se ha completado la operación de escritura. En caso de recibir ese error, debemos continuar enviado datos al puerto serie.

Si estamos seguros de que el receptor puede procesar los datos enviados, o si hemos diseñado un mecanismo para que el receptor nos informe de que los datos no han llegado correctamente, podemos ignorar el valor de retorno y el de iBytesWritten de la función WriteFile, y continuar asumiendo que los datos fueron enviados.

void EscribirSerie(HANDLE idComDev, char *buf)
{
    char oBuffer[256];  /* Buffer de salida */
    DWORD iBytesWritten;

    iBytesWritten = 0;
    strcpy(oBuffer, buf);
    WriteFile(idComDev, oBuffer, strlen(oBuffer), &iBytesWritten, NULL);
}

En la mayor parte de los casos, y sobre todo cuando somos los responsables de programar los dos extremos de la comunicación serie, este modo de trabajar puede ser suficiente.

Si por el contrario, el otro extremo de la comunicación no es responsabilidad nuestra, puede que debamos depurar algo más esta función.

Podemos usar el valor del parámetro iBytesWriten posterior a la ejecución para actualizar el buffer de salida. Indicaremos algún valor de retorno en EscribirSerie para indicar que la operación de escritura no ha enviado todos los caracteres del buffer, y el proceso que la ha invocado debe llamar de nuevo para completar la transmisión.

Leer desde el puerto serie

Por la misma naturaleza de las transmisiones, y debido a que nuestro ordenador normalmente será muy rápido en comparación con las velocidades de transmisión, la mayor parte del tiempo no estaremos recibiendo nada por el puerto serie.

Necesitamos un mecanismo que avise a nuestro programa cuando existan datos para leer, pero no debemos bloquear el sistema preguntando constantemente si hay algo preparado. Ni siquiera debemos bloquear nuestro programa, a menudo hay otras cosas que hacer además de esperar la llegada de nueva información.

La mejor forma es introducir crear un hilo distinto del de nuestro programa principal. Podemos hacer que ese hilo espere a que haya datos para leer, y cuando eso ocurra, que lo procese.

Lo siguiente que necesitamos saber es cuantos caracteres hay en el buffer de entrada. Para esas dos necesidades podemos usar la función ClearCommError. Esta función nos actualiza una estructura COMSTAT, uno de cuyos miembros es cbInQue, que nos dice cuantos caracteres hay en el buffer de entrada. Si no hay ninguno, no hay nada que hacer, si hay caracteres en el buffer, los leemos y procesamos.

Con ese dato podemos llamar a la función ReadFile, y leer todos los caracteres que haya en el buffer.

    // Creamos el hilo:
	hHilo = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)Hilo, (LPDWORD)&idComDev, 0, &id);
...

// Hilo de escucha del puerto serie:
DWORD Hilo(LPDWORD lpdwParam)
{
    DWORD leidos;
    COMSTAT cs;
    char *cad;
    HANDLE idComDev = *((HANDLE*)lpdwParam);

    do {
        ClearCommError(idComDev, &leidos, &cs);
        leidos=0;
        /* Leer buffer desde puerto serie */
        if(cs.cbInQue) {
            cad = new char[cs.cbInQue+3]; // Caracteres en buffer, más retorno de línea, más nulo
            ReadFile(idComDev, cad, cs.cbInQue, &leidos, NULL);
			ProcesarDatos(cad);
            delete[] cad;
        }
        Sleep(100); // Esperar entre una lectura y otra.
    } while(true);
    return 0;
}

El tiempo de espera en la función Sleep está elegido arbitrariamente, se puede indicar un tiempo menor, si se necesita una respuesta más rápida por parte del programa. Sin embargo siempre es necesario dejar algún tiempo, para no sobrecargar la CPU excesivamente.

Programa de ejemplo

Con esto ya es suficiente para crear un programa de comunicaciones serie sencillo, pero que ilustra la mayor parte de los casos que necesiten comunicación serie.

El siguiente ejemplo es un monitor de comunicación serie en modo texto. Pueden usarse dos programas en ordenadores diferentes, conectados con un cable serie, y es posible comunicarse entre ellos. Yo he probado este programa como terminar serie de un VAX, y salvo por las secuencias de escape propias de estos terminales, el programa funciona perfectamente.

Operaciones superpuestas

Todo lo dicho es para usar el puerto serie de forma no superpuesta, no overlapped. La ventaja de este modo es que es más sencillo de implemetar y más portable.

Existe otro modo, el superpuesto, u overlapped. Este modo es más eficiente, pero es más complicado de implementar y menos portable.

En este artículo no hablaremos del modo overlapped, y lo dejaremos para otro momento.

Notas finales

En casi todos los ejemplos del uso del puerto serie usando el API de Windows se hace uso de las funciones SetCommMask y WaitCommEvent para leer datos desde el puerto. He de decir que no he coseguido que ningún programa que haga uso de ellas funcione correctamente. Siempre se quedan bloquedos en la llamada a WaitCommEvent, y jamás se reciben datos, ni se pueden enviar más de una vez.

Supongo que estaré haciendo algo mal, pero por más que he buscado en la red y por más pruebas que he hecho, no he conseguido mejores resultados.

Si alguien sabe qué está pasando, por favor, que me lo cuente. Estaré encantado de completar este artículo con la nueva información.

Incluyo un segundo ejemplo que hace uso de esas funciones en el hilo de lectura.