Con los sistemas electrónicos cada vez más complejos es normal que un microcontrolador deba atender al menos dos tareas que pueden tener poca o nula relación en los eventos a los que responden, por poner un ejemplo, podemos decir que el microcontrolador atiende la comunicación serial en espera de comandos y al mismo tiempo, responde a la interfaz de usuario a través de un teclado. En esta entrada introduciremos el concepto de programación multitarea en microcontroladores.
En una computadora normal, es el sistema operativo el que reparte el tiempo de procesador (o procesadores) y los recursos entre todas las aplicaciones y tareas que estamos ejecutando. Sin embargo, en el terreno de los CPUs y computadoras que a penas realizan unos pocos millones de operaciones por segundo y rondan los 2KB de RAM, muchas veces un sistema operativo no es una opción para considerar.
La opción más viable para implementar características multitarea en sistemas embebidos de pocos recursos es utilizar un paradigma de programación cooperativa. En este tipo de implementación de multitarea, son los propios procesos que ceden el uso del procesador a intervalos regulares, en vez de ser interrumpidos por el planificador de un sistema operativo.
Estructura de un programa multitarea en microcontroladores
Los programas escritos para microcontroladores en “modo cooperativo”, normalmente se construyen como un ciclo infinito el cual se repite una y otra vez, procesando en cada iteración un segmento de datos o realizando alguna tarea específica sobre cada uno de los recursos de hardware y software. El programador debe descomponer las tareas extensas o demandantes en tiempo de CPU a una serie de pasos que se ejecutan total o parcialmente en cada pasada por el ciclo. Tomemos en cuenta el siguiente ejemplo de función main que es el punto de entrada de un programa que corre en un microcontrolador:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | void main() { // Iniciar el hardware vHardwareInitialize(); // Ciclo principal de programa for(;;) { // Tarea de control de leds de estado vLedTask(); // Tarea de control del módulo de radio vRadioTask(); // Tarea de interfaz de usuario vUITask(); } } |
Las funciones vLedTask(), vRadioTask() y vUITask() representan los principales bloques funcionales del programa. Cada una destinada a encapsular una parte de la lógica general de la aplicación. Como podemos ver, la multitarea (multitasking) se simula en este caso con una serie de llamadas a funciones que ocurren en secuencia.

Para que el sistema funcione como debe, cada función debe tomar el control de CPU durante un intervalo de tiempo y luego devolver el control al loop principal para que otras actividades puedan continuar con el uso del CPU. Para lograr esto, podemos implementar cada una de las funciones (tareas) del loop principal en su respectivo archivo de código fuente. De manera que las variables, funciones y estados de dicha tarea queden encapsuladas en un solo archivo fuente. Nosotros recomendamos implementar cada una de las tareas como una máquina de estados en la siguiente forma:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | #include "UART.h" #include "NRF24L01P.h" /* Definición de los estados de la máquina de estados */ typedef enum { E_RADIOTASK_IDLE, E_RADIOTASK_LISTEN, E_RADIOTASK_WAITDATA, E_RADIOTASK_PROCESS, }enRadioTaskStates; /* Buffer para almancenamiento de datos */ static char cBuffer[32]; /* Esta función maneja las comunicaciones por radio */ void vRadioTask() { /* La variable estática eCurrentState almancena el estado actual de esta tarea aunque el programa retorne al ciclo principal. Las variables que deban persistir después del retorno deben declararse como estáticas */ static enRadioTaskStates eCurrentState = E_RADIOTASK_IDLE; /* Revisamos el estado de la tarea y ejecutamos una parte del programa */ switch(eCurrentState) { //Preparar y configurar el radio, podríamos mostrar mas mensajes case E_RADIOTASK_IDLE: cBuffer[0] = cNRF24L01PReadRegister(REG_CONFIG); vUARTPuts(xSerial, "NRF24L01> "); printf( "CONFIG Register 0x%02Xrn", cBuffer[0] ); eCurrentState++; break; // Colocar en modo de recepción case E_RADIOTASK_LISTEN: vUARTPuts( xSerial, (const char *)"NRF24L01> Listening...rn" ); vNRF24L01PStartListen(); eCurrentState++; break; // Revisamos si hay datos disponibles en el buffer del radio case E_RADIOTASK_WAITDATA: if( xNRF24L01PReceiveReady() ) { // Mostramos los datos recibidos por la UART // Aunque recibimos 32 bytes, solo mostramos los primeros 4 mediante // la función printf, que equivalen a la longitud de un entero de // 32 bits (long) vUARTPuts( xSerial, (const char *)"NRF24L01: Data receivedrn" ); vNRF24L01PReceive( cBuffer, 32 ); printf( "NRF24L01: Payload %lurn",*((unsigned long *)&cBuffer) ); eCurrentState++; } break; // Retransmitir los datos que acabamos de recibir case E_RADIOTASK_PROCESS: // Voltemos a transmitir los datos que recibimos del radio vNRF24L01PStopListen(); vDelayMilis( 1 ); vNRF24L01PTransmit( cBuffer, 32 ); // Volvemos a modo de escucha para recibir nuevos datos eCurrentState = E_RADIOTASK_LISTEN; break; } } |
El programa anterior es parte de un programa que funciona utilizando un par de módulos de radio NRF24L01+ de Nordic Semiconductor que retransmite todo lo que recibe. Este programa es solamente el receptor de los mensajes. Discutiremos algunas peculiaridades en la forma en que esta escrito este programa en las siguientes secciones.
Funciones de IO y consideraciones de tiempo
Como pudimos observar en el ejemplo anterior, la estabilidad del sistema depende de que cada uno de los procesos retorne el control al loop principal después de realizar parte de su trabajo. Es por esto que bajo este esquema las operaciones de IO deben realizarse en segmentos o bien, debemos asegurarnos que la transmisión de un bloque de datos no tome demasiado tiempo como para que pueda afectar el funcionamiento general del sistema.
Otro punto importante es que las funciones de recepción de datos no bloquean la ejecución de este programa. Por ejemplo realizamos y utilizamos la función xNRF24L01PReceiveReady() que devuelve un valor distinto de 0 cuando hay datos disponibles para ser procesados, de manera que entonces podemos procesarlos. Las funciones que se encargan de la lectura de datos SI bloquean la ejecución del programa, por lo que vNRF24L01PReceive() y vUARTPuts() retornan hasta que se han recibido y/o transmitido la totalidad de los datos.
El hecho de que algunas funciones efectivamente bloqueen el uso del CPU esperando a que se completen operaciones de entrada y salida hace necesario usarlas con cautela, sobre todo si requerimos que otras tareas respondan a tiempo a sus eventos.

Uso de retardos: No aconsejable
El desarrollo de un programa cooperativo multitarea, muchas veces hace que los retardos o delays a los que estamos acostumbrados queden obsoletos, debido a las reestricciones que se imponen para repartir el tiempo entre los procesos o tareas. Por ejemplo un programa para el parpadeo de un led con la siguiente estructura consumiría la mayor parte de tiempo del CPU:
1 2 3 4 5 6 7 8 | void vLedBlink() { for(;;) { vDelayMilis(500); PORTA ^= 0x0001; } } |
Esta función en un entorno multitarea evitaria que otras funciones obtuvieran tiempo para ejecutar código durante todo lo que dure su ejecución. Los bucles infinitos y las llamadas a retardos con más de un par de decenas de milisegundos de duración estan fuertemente desaconsejadas, ya que disminuyen la capacidad del sistema de responder a los eventos de otras tareas.
La implementación de una rutina para parpadear un led, se vuelve entonces una tarea un poco más complicada que llamar a alguna función incluida por defecto en el compilador. En nuestra experiecia, la mayoría de los programas utilizan timers para realizar el control de tiempos y están escritos, al igual que el ejemplo del radio, de manera tal que la ejecución del programa no se detiene.
El siguiente fragmento muestra nuestra como implementamos una función de parpadear un led sin utilizar retardos (delays). El API para utilizar las funciones de Tick del sistema están definidas dentro del archivo de cabecera Tick.h.
1 2 3 4 5 6 7 8 9 10 11 | #include "Tick.h" void vLedTask() { static unsigned long ulLastTick = 0; if( xTickGet() - ulLastTick >= TICK_SECOND/2ul ) { ulLastTick = xTickGet(); LED3_IO ^= 1;// Toggle led } } |
De igual manera que en el ejemplo anterior, utilizamos una variable estática que almacena el número de “Ticks” del sistema que han ocurrido la ultima vez que se cambio de estado el pin conectado a un led. Este conteo es comparado con el tick actual y si la diferencia es mayor que una constante (equivalente a 1/2 segundo en ticks), cambiamos el estado del pin.
Este programa permíte parpadear un led pero sin la necesidad de utilizar las funciones de retardos. La desventaja es que debemos utilizar un Timer que realiza el conteo, pero hay que aclarar que podemos utilizar este timer y la función vTickGet() en muchas otras funciones y tareas para implementar retardos y temporizaciones que van desde milisegundos hasta horas.
Conclusiones.
En este artículo hemos cubierto las bases para realizar programas de mediana complejidad en cualquier microcontrolador que se programe en C. Seguramente se habrán dado cuenta de que muchas de las funciones y técnicas de programación que ya veníamos ocupando son igualmente válidas para la programación multitarea, sin embargo, siempre debemos tener presente el tiempo de ejecución de las operaciones de entrada / salida (como por ejemplo la escritura de una cadena al UART) y el uso de retardos.