Translate

viernes, 22 de junio de 2012

Comunicaciones UART con interrupciones

En el tutorial anterior hemos visto que lo que un compilador hace al escribir/leer de la UART no es muy complicado. Esencialmente se reduce a leer/escribir de un par de registros (TXREG, RCREG) verificando previamente que están libres (caso de TXREG) o que sus contenidos se han renovado (caso de RCREG).

Aprovecharemos estos conocimientos para implementar un programa de transmisión/recepción de datos más eficaz, usando las interrupciones asociadas a la UART, tanto de recepción (RX, activada al recibir un byte) como de transmisión (TX, activada cuando la UART está libre para mandar otro byte).

Asimismo usaremos dos buffers de mayor capacidad, uno de recepción (donde la interrupción RX depositará los datos recibidos) y otro de transmisión (de donde la interrupción de TX cogerá los datos a transmitir).

Veremos como la implementación de estas técnicas puede llevarnos a drásticas reducciones de la ocupación del PIC, liberándolo para otras tareas. 

También veremos como nuestra implementación con buffers e interrupciones puede configurarse como la salida por defecto stdout, pudiendo ser usada directamente por rutinas como printf, puts, etc. de forma transparente para el usuariuo.

Código asociado a esta entrada:  uart3_int.c
--------------------------------------------------------------------------------------------

En la implementación con buffers que haremos, el "usuario" no toca los registros RXREC ni TXREG, sino sólo los buffers de rx/tx. Dichos buffers estarán implementados como buffers circulares, de forma que si el usuario p.e. no va procesando los datos recibidos estos se sobrescribirán si se supera la longitud del buffer.

Lo ideal en el caso de buffers circulares es que su tamaño sea una potencia de 2, lo que facilitará resetear el índice del buffer al llegar al final. Si por ejemplo el tamaño del buffer es 64, cada incremento del índice debe venir seguido de:

ind++; ind&=63; lo que es más eficaz que:  ind++; if(ind==64) ind==0;

Lo ideal sería usar buffers de 256 entradas que son especialmente eficaces. En efecto, si usamos una variable de 8 bits (unsigned char) como índice, dicha variable se reseteará automáticamente al llegar a 256 sin necesidad de añadir la instrucción extra. Para una variable de 8 bits, la instrucción ind &= 255 es superflua.

La inconveniencia es que en C18 si queremos declarar un array de 256 bytes o más hay que hacer algunos apaños. No es complicado, pero retrasaremos dicha lección hasta más adelante. Para no complicarnos, en este ejemplo usaremos un tamaño de 128. Veamos como reservar los buffers de entrada/salida así como los punteros (índices) asociados:

#define BUF_SIZE 128
#define inc(x) {x++; x&=(BUF_SIZE-1);}

uint8 tx_buf[BUF_SIZE];            // TX buffer
uint8 tx_next=0; uint8 tx_sent=0;  // TX indexes
uint8 rx_buf[BUF_SIZE];            // RX buffer
uint8 rx_next=0; uint8 rx_read=0;  // RX indexes

Hay dos punteros asociados a cada buffer, inicializados ambos a 0.

RX buffer: 
  • escribe en él la interrupción de RX, incrementando rx_next.
  • lee de él el usuario, incrementando rx_read.
  • Si rx_read==rx_next es que no hay nuevos caracteres a procesar.

TX buffer: 
  • escribe en él el usuario (lo que desee transmitir), incrementando tx_next.
  • lee de él la interrupción de TX, cada vez que manda un byte, incrementando tx_sent.
  • Si ambos son iguales, no hay nada nuevo que mandar.

Se ha definido una macro para incrementar los punteros. En ella se incrementa el puntero y se voltea si se sale del intervalo [0, BUF_SIZE-1].

Si el usuario no procesa los caracteres recibidos es posible que se pierdan al voltear el buffer de recepción. En este caso se podría disponer de una bandera de buffer_overflow que se dispararía si rx_next supera a rx_read. En nuestro caso no se implementará este control, ignorando posibles overflows del buffer.

Las correspondientes rutinas de interrupción (ISR) son muy simples (como por otra parte debería ser todo código de interrupción). Para la recepción, si se ha recibido algo (RX_flag) se coloca el contenido de RCREG en rx_buf, incrementándose rx_next:

// RX_isr gets executed when RX_flag is set. That means a new byte is waiting in RCREG.
// The code picks it and places it in the next available (rx_next) pos of the RX buffer,
// increasing rx_next in the process. Somewhere else the user must read the
// new bytes (from rx_read to rx_next) from the RX buffer.
void RX_isr(void)
{
  rx_buf[rx_next] = RCREG;  // Read RX char and place it in the RX buffer
  inc(rx_next);  //rx_next++; rx_next&=(BUF_SIZE-1);
  RX_flag=0;
}

Si el buffer de transmisión está libre (TX_flag) y hay algo que transmitir se carga el correspondiente byte de tx_buf en el registro de transmisión TXREG y se incrementa tx_sent:

// TX_isr gets called when TX_flag is set. That means that the port is ready to tramsmit.
// If tx_next==tx_sent, there is nothing to send and TX_INT is disable.
// If tx_next!=tx_sent, next byte in TX buffer is loaded in TXREG, and the
// pointer is incremented, making sure it remains within the [0,BUF_SIZE-1] range
void TX_isr(void)
{
 if (tx_sent==tx_next) disable_TX_int;
 else { TXREG=tx_buf[tx_sent]; inc(tx_sent); }
 TX_flag=0;
}

La interrupción de recepción estará siempre activa (no queremos perder nada) pero la de transmisión se desactiva si no hay nada que mandar (tx_sent==tx_next). Esto evita que estemos entrando continuamente en la interrupción de transmitir (porque el registro TXREG está disponible) cuando no tenemos nada que mandar. Es responsabilidad del usuario activar la interrupción TX (enable_TX_int) cuando escriba algo al buffer de salida.

Juntando ambas rutinas en la rutina de interrupción:

// High priority interruption
#pragma interrupt high_ISR
void high_ISR (void)
{
 if (RX_flag) RX_isr();
 if (TX_flag) TX_isr();
}

// Code @ 0x0008 -> Jump to ISR
#pragma code high_vector = 0x0008
  void code_0x0008(void) {_asm goto high_ISR _endasm}
#pragma code

Y eso es todo.  Ahora lo único preciso es que nuestras rutinas de escribir al puerto serie deben mover caracteres al buffer de transmisión (de tx_next en adelante, incrementando tx_next) y las rutinas de lectura cogerlos del buffer de recepción (desde rx_read hasta rx_next, incrementando rx_read):

El usuario sólo debe modificar (incrementándolos adecuadamente) los punteros tx_next y rx_read. El incremento de los punteros tx_sent y rx_next es responsabilidad de las rutinas de interrupción.

Veamos las ventajas de este tipo de enfoque.  Imaginad un programa en el que cada cierto tiempo hay que mandar cierta información por el puerto serie. En C18 el stream stdout corresponde por defecto al puerto serie, por lo que al usar rutinas como puts o fprintf los resultados se volcarán al puerto serie. El siguiente programa abre el puerto serie a 9600 y usar puts para mandar la cadena "0123456789ABCDEF" por el puerto serie:

#include <usart.h>
#include <stdio.h>

void main()
  {
   uint8 sp, brgh, brgh_config;
  
   TRISC=0; PORTC=0; 

   brgh=get_usart_speed(9600,20000,&sp);
   brgh_config = (brgh)? USART_BRGH_HIGH:USART_BRGH_LOW;
   OpenUSART(USART_ASYNCH_MODE & USART_EIGHT_BIT & brgh_config, sp); //Open port @ 9600
   
   while(1)
   {
    PORTCbits.RC0=1; puts("0123456789ABCDEF"); PORTCbits.RC0=0;
    Delay10KTCYx(25); // 50 msec delay
   }  
}

Hemos incluido <usart.h> y <stdio.h> para poder usar OpenUSART() y puts(). Tras abrir el puerto entramos en un bucle donde mandamos la cadena y ponemos un delay de 50 msec. Abriendo un terminal @ 9600 veríamos  llegar repetidamente la cadena "ABC … ".

Notad que hemos puesto el pin RC0 a 1 durante el tiempo en que puts está ocupado mandando el mensaje. La figura siguiente corresponde a una captura de RC0 con el  osciloscopio mientras el programa está ejecutándose:




Claramente es lo esperado. La rutina puts() bloquea al PIC durante unos 18 msec  (tiempo necesario para mandar 16 chars + LF a 9600 bauds). Luego viene un espaciado de 50 msec (delay) y el proceso se repite. El PIC está mandando 16 bytes en 68 msec (unos 230 bytes por segundo) y esto le ocupa un 25% (18/68) de su tiempo.  Incluso sin tener un osciloscopio se puede estimar el tiempo de ocupación tomando una medida del voltaje en RC0. En este caso obtenemos unos 1.3V. 5V corresponderían a una ocupación total (RC0 alto todo el tiempo), por lo que 1.3 V correspondería a un porcentaje de un 25-30% (obviamente este método es aproximado y puede depender de las características del voltímetro usado).

Veamos como mejoran las cosas usando nuestro enfoque de interrupciones. Es interesante darnos cuenta de que podemos aprovechar nuestro trabajo sin tener que modificar apenas nada. Hemos dicho que por defecto C18 usa el puerto serie como salida standard (stdout). El usuario puede redireccionar la salida standard sin más que:

  • Hacer stdout = _H_USER; para indicar a C18 que sus rutinas de salida deben usar la función de usuario _user_putc( ) en vez de la definida por defecto.
  • Escribir una rutina propia llamada _user_putc(char) que escriba un carácter en el stream escogido.
Nosotros seguiremos usando el puerto serie como salida, pero ahora en la rutina _user_putc, en vez de usar los comandos que escriben directamente al puerto serie usaremos nuestro buffer de salida:

int _user_putc(char ch)
{
 tx_buf[tx_next]=ch; inc(tx_next); enable_TX_int;
 return ch;
}

La rutina coloca el carácter recibido en el buffer de transmisión (buffer TX), en la posición tx_next. A continuación incrementa dicho puntero y habilita la interrupción de transmisión, indicando que tenemos algo que mandar.

Y ya esta. A partir de ahora (tras redefinir stdout) siempre que usemos las rutinas de salida puts, printf, etc. por debajo estaremos usando nuestro sistema de buffers de una manera transparente.

El nuevo programa principal apenas ha cambiado:

void main()
  {
   uint8 sp, brgh, brgh_config;

   stdout = _H_USER;


   TRISC=0; PORTC=0;  
   brgh=get_usart_speed(9600,20000,&sp); //setup_UART(brgh,sp);
   brgh_config = (brgh)? USART_BRGH_HIGH:USART_BRGH_LOW;
   OpenUSART(USART_ASYNCH_MODE & USART_EIGHT_BIT & brgh_config, sp);
 
   enable_RX_int; enable_global_ints; enable_perif_ints;  
   while(1)
   {
    PORTCbits.RC0=1; puts("0123456789ABCDEF"); PORTCbits.RC0=0;
    Delay10KTCYx(25); // 50 msec delay
   }
 }

La única diferencia es la redefinición de stdout (_H_USER) y la habilitación de las interrupciones de las que depende nuestro sistema de buffers. El bucle principal no ha cambiado. Si ejecutamos el programa volveremos a ver llegar por el terminal la misma cadena "ABCD …".

Veamos el retardo de la función puts (ahora usando nuestra _user_putc) a través del pin RC0. Como en este caso estamos "pasándole" el trabajo a las interrupciones hemos puesto también RC1=1 durante el tiempo en el que la interrupción está activa ya que es también tiempo dedicado a mandar cosas por el puerto serie. Veamos la ocupación tanto en puts (RC0, arriba en amarillo) como en la interrupción (RC1, abajo en azul).



La ocupación de RC0 ha sido reducida drásticamente, ya que con nuestra nueva rutina puts() no espera a que los caracteres hayan sido enviados, simplemente los copia al buffer de salida. De hecho ahora estamos enviando más bytes por segundo ya que el siguiente bloque se manda 50 msec después (sin contar el tiempo que tardan en salir los caracteres). El ritmo es ahora de unos 16/17 bytes cada 50 msec (unos 340 bytes/sec, un 50% más que antes).

En RC1 la cosa parece más similar. El ancho del pulso sigue siendo unos 18 msec (ya que 16 caracteres a 9600 baudios no pueden tardar menos en salir). Sin embargo dicho periodo de actividad no es sólido (ya que la interrupción solo tiene que poner un byte en TXREG). La clave es no esperar a que el periférico termine.

Si hacemos un zoom de la zona de un periodo de actividad veremos lo siguiente:



El pulso amarillo inicial (menos de 1 msec) es el tiempo usado por puts para volcar los 16 bytes al TX buffer. Los pulsos azules (aún más cortos) corresponden al tiempo usado por la interrupción en ir colocándolos en TXREG. Están espaciados porque la interrupción no vuelve a entrar hasta que el periférico no ha termina y libera TXREG.

Todo el tiempo en el que RC1 está abajo (la mayoría) el PIC puede estar haciendo otras tareas. De hecho la dedicación en el periodo de 18 msec que antes tardábamos en mandar el mensaje es del orden de 1 msec. En total el PIC está dedicado a la tarea de comunicaciones 1 msec cada 50 msec. Un 2% frente al 25% de antes (a pesar de estar mandando un 50% más de bytes como vimos).

De nuevo podríamos usar un voltímetro para estimar la ocupación sin tener un osciloscopio. Basta usar el mismo pin, p.e. RC0, tanto para la interrupción como para puts. El voltaje medido es de 0.12V lo que corresponde a 0.12/5 =  0.024 (2.4%) lo que es compatible con lo visto en el osciloscopio.

La desventaja del enfoque de interrupciones es que somos nosotros los responsables de no mandar más bytes por segundo que los que puede procesar el puerto serie. Si lo hacemos tendremos un overrun del buffer de TX y habrá caracteres perdidos. Con el enfoque de esperar obviamente eso no puede pasar (a costa de no hacer otra cosa).

3 comentarios:

  1. Respuestas
    1. Gracias por el feedback. Estoy un poco liado en el curro con el arranque del curso, pero espero poder añadir algún que otra entrada adicional durante este mes, posiblemente de PWM.

      Antonio

      Eliminar
  2. Es justo lo que estaba pensando en crear para poder leer y escribir correctamente de la UART sin perder datos o bloquear el PIC.

    La mejor explicación de uso de la UART que he encontrado.

    Estoy utilizando un PIC18F97j60 de la placa PICDEM.net 2, y pegándome con el código de Microchip, voy a adaptarlo a la misma y a ver si así no se bloquea el servidor web


    saludos



    ResponderEliminar