Multitarea en sistemas embebidos pequeños 
La multitarea es la capacidad que tienen los sistemas de realizar varias tareas o procesos de forma simultánea en el tiempo. En el ámbito de los sistemas grandes o de sistemas embebidos avanzados esta capacidad viene dada normalmente por un pequeño sistema operativo encargado de gestionar dicha multitarea y de crear una capa de abstracción entre los procesos y el hardware (Linux embebido, eCos, Windows CE, etc.). En sistemas embebidos pequeños con procesadores más modestos (ya sea por anchura de bus, por RAM o por ambas razones) la cosa cambia: No tenemos sistema operativo y la multitarea debe ser gestionada por el propio software.

Vamos a asumir que todas las tareas que queremos planificar son igual de prioritarias y, por lo tanto, utilizaremos una planificación estilo round-robin (la más simple).

Multitarea apropiativa.

En la multitarea apropiativa, el proceso principal (voy a resistirme a utilizar el concepto de “sistema operativo”) se encarga de distribuir el tiempo de CPU entre las diferentes tareas. Si una tarea se cuelga o comienza a consumir muchos recursos, el resto de tareas no tiene por qué verse afectado ya que el software principal le quita el control de la CPU a las tareas sin importarle lo que estén haciendo en ese momento.

Es el tipo de multitarea ideal para evitar cuelgues completos del sistema y al mismo tiempo es el que más restricciones técnicas tiene.

- El sistema debe permitir “proteger” ciertas funciones o instrucciones para que no puedan ser ejecutadas por las tareas.
- Normalmente es necesario realizar manipulaciones de la pila del sistema.
- Se suelen utilizar timers para controlar el tiempo de CPU que tiene cada tarea.

Un esqueleto muy básico de lo que sería implementar multitarea apropiativa en un sistema embebido pequeño sería el siguiente:

class Tarea {
    public:
        bool enEjecucion;
        Estado estado;
        Tarea *siguiente;
        Tarea() { this->enEjecucion = false; };
        virtual void run() = 0;
};

Tarea *tareaInicial;
Tarea *tareaActual = NULL;

void leerEstadoActual(Tarea *t) {
    // se lee de la pila del sistema el estado de los registros en el momento de la interrupción y se guarda en t->estado
}

void escribirEstadoActual(Tarea *t) {
    if (!t->enEjecucion) {
        // hay que inicializar t->estado para que cada tarea tenga su propia pila y para que el contador de programa apunte a la primera instrucción de t->run
        t->enEjecucion = true;
    }
    // se sobreescribe en la pila del sistema el estado de los registros guardado en t->estado
}

void isr() {
    if (tareaActual != NULL) {
        leerEstadoActual(tareaActual);
        tareaActual = tareaActual->siguiente;
    }
    else
        tareaActual = tareaInicial;
    // este "if" sobra si las tareas se meten en una lista circular
    if (tareaActual == NULL)
        tareaActual = tareaInicial;
    escribirEstadoActual(tareaActual);
}

int main() {
    configurarISRTimer(isr);
    configurarYLanzarTimer();
    while (true)
        ;
    return 0;
}


En los comentarios de leerEstadoActual y escribirEstadoActual puede apreciarse el nivel de complejidad que requiere este tipo de multitarea: Es necesario conocer bien cómo funciona el mecanismo de interrupciones en el procesador en el que estamos así como controlar bien el layout de la memoria para cada tarea (pila, código, datos, etc.). En un sistema embebido normalmente no tenemos instrucciones protegidas con lo que nada impedirá que una de las tareas se “apropie” del ISR del timer que estamos utilizando para planificar y se nos fastidie el invento. Algo parecido ocurriría si alguna de las tareas decide deshabilitar la interrupción del timer: Se hará con el control completo de la CPU.

Multitarea cooperativa.

La multitarea cooperativa consiste en que cada tarea cede voluntariamente el procesador a otras tareas. Es el tipo de multitarea más sencillo de implementar, también puede ser más problemático ya que, si una tarea ocupa demasiado tiempo de procesador (ya sea por un error, por una condición mal evaluada o por una mala praxis de programación) el resto de tareas se ejecutarán, en el mejor de los casos, de forma menos eficiente y en el peor de los casos dejarán de ejecutarse. Sin embargo en los sistema embebidos pequeños normalmente podemos controlar bien todas las tareas que están en ejecución, y, si realizamos un buen diseño, podemos conseguir muy buenos resultados.

Una multitarea cooperativa con planificación simple estilo round-robin quedaría como sigue:

class Tarea {
    public:
        virtual void init() = 0;
        virtual void run() = 0;
};

class Tarea1 : public Tarea {
    ...
};

class Tarea2 : public Tarea {
    ...
};

Tarea1 t1;
Tarea2 t2;

int main() {
    t1.init();
    t2.init();
    while (true) {
        t1.run();
        t2.run();
    }
}


O, de forma más genérica, utilizando una lista simplemente enlazada de tareas:

class Tarea {
    public:
        Tarea *siguiente;
        virtual void init() = 0;
        virtual void run() = 0;
};

...

int main() {
    Tarea *t = TAREA_INICIAL;
    while (t != NULL) {
        t->init();
        t = t->siguiente;
    }
    while (true) {
        t = TAREA_INICIAL;
        while (t != NULL) {
            t->run();
            t = t->siguiente;
        }
    }
}


Como se puede ver no accedemos ni a la pila del sistema ni a características que requieran un conocimiento profundo de la arquitectura o del procesador que estamos utilizando. Esto hace que el código resultante sea más fácilmente portable a otros sistemas. Como contrapartida tenemos que asegurarnos de que ningún método “run” estrangule mucho al procesador.

Donde más podemos estrangular al resto de tareas es:
- En tiempos de espera para entrada/salida.
- En algoritmos pesados.

Desbloquear tareas de entrada y salida.

Las tareas que realizan entrada/salida suelen requerir de tiempos de espera para completarse. Pensemos en una comunicación serie a 9600 bps. Si tenemos que crear una clase UART con el método write una primera aproximación sería la siguiente:

void UART::write(uint8_t *data, uint16_t size) {
    for (uint16_t j = 0; j < size; j++) {
        while (!BUFFER_UART_TX_VACIO)
            ;
        BUFFER_UART = data[j];
    }
    while (!BUFFER_UART_TX_VACIO)
        ;
}


Llamar al método UART::write() desde el método run de alguna de las tareas estrangulará, claramente al resto de tareas ya que la tarea desde la que se llame no regresará del run hasta que, como mínimo, se envíe el buffer completo de bytes. Hay que tener en cuenta lo siguiente: Si queremos transmitir 64 bytes y nuestra UART manda un bit de start y otro de stop, realmente está enviando 10 bits por cada byte, lo que hacen un total 640 bits. A 9600 bps, 640 bits tardan 0.067 segundos. Es poco en términos humanos, pero si pensamos que un procesador RISC a 16 MHz (un PIC o un AVR de gama media) ejecuta 16 millones de instrucciones por segundo (una instrucción cada 0.0000000625 segundos), esperar 0.067 segundos supone un estrangulamiento del resto de tareas.

¿Qué hacemos en estos casos? Muy sencillo, utilizar un modelo de estados. Podemos crear una tarea adicional encargada de gestionar la transmisión UART y definirla así:

class UART : public Tarea {
    protected:
        uint8_t estado;
        uint8_t *bufferEnvio;
        uint16_t indiceBufferEnvio;
        uint16_t tamBufferEnvio;
    public:
        UART();
        void init();
        void run();
        bool escribir(uint8_t *buffer, uint16_t tam);
};

#define  ESTADO_UART_OCIOSO                   0
#define  ESTADO_UART_ESPERAR_BUFFER_TX_VACIO  1
#define  ESTADO_UART_ESCRIBIR_BYTE            2

UART::UART() {
    this->estado = ESTADO_UART_OCIOSO;
    this->bufferEnvio = NULL;
}

void UART::init() {
    this->bufferEnvio = NULL;
    this->estado = ESTADO_UART_OCIOSO;
}

void UART::run() {
    if (this->estado == ESTADO_UART_OCIOSO) {
        if (this->bufferEnvio != NULL)
            this->estado = ESTADO_UART_ESPERAR_BUFFER_TX_VACIO;
    }
    else if (this->estado == ESTADO_UART_ESPERAR_BUFFER_TX_VACIO) {
        if (BUFFER_UART_TX_VACIO)
            this->estado = ESTADO_UART_ESCRIBIR_BYTE;
    }
    else if (this->estado == ESTADO_UART_ESCRIBIR_BYTE) {
        if (this->indiceBufferEnvio == this->tamBufferEnvio) {
            this->estado = ESTADO_UART_OCIOSO;
            this->bufferEnvio = NULL;
        }
        else {
            BUFFER_UART = this->bufferEnvio[this->indiceBufferEnvio];
            this->indiceBufferEnvio++;
            this->estado = ESTADO_UART_ESPERAR_BUFFER_TX_VACIO;
        }
    }
}

bool UART::escribir(uint8_t *buffer, uint16_t tam) {
    if (this->estado == ESTADO_UART_OCIOSO) {
        this->tamBufferEnvio = tam;
        this->indiceBufferEnvio = 0;
        this->bufferEnvio = buffer;
        return true;
    }
    else
        return false;
}


Ahora incluimos nuestra tarea UART (que hemos heredado de la clase Tarea) como una tarea más a ser planificada: Invocamos su método “init” antes de comenzar y luego, en el bucle infinito invocamos su método “run” junto con el resto de métodos “run” del resto de tareas:

UART uart;

int main() {
    t1.init();
    t2.init();
    uart.init();
    while (true) {
        t1.run();
        t2.run();
        uart.run();
    }
}


A priori puede resultar un poco lioso desbloquear las tareas bloqueantes de esta forma, mediante máquinas de estados que generan más código, pero a la larga mejora el rendimiento general de la aplicación y es más sencillo adaptar el código para que funcione mediante interrupciones: con muy poco esfuerzo se puede adaptar el método “run” para convertirlo en una rutina de interrupción (ISR) que atienda la tranmisión UART.

Espero que este post haya servido de ayuda. Los últimos códigos fuente en C++ que he publicado para Arduino (el último de las luces del belén y el del tres en raya) utilizan multitarea cooperativa.

[ añadir comentario ] ( 559 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 813 )

<< <Anterior | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Siguiente> >>