Tres en raya con el Arduino utilizando el algoritmo de decisión minimax 
Ampliando un post anterior en el que desarrollé un juego de tres en raya con el Arduino, he desarrollado una implementación “inteligente” del mismo. En la anterior versión, si bien el jugador jugaba contra la máquina, las posiciones que jugaba la máquina eran totalmente aleatorias y no seguían ningún criterio. En este caso la máquina utiliza un algoritmo de decisión (el minimax) para calcular el siguiente movimiento y así intentar ganar a su oponente humano.

Antecedentes

En la implementación anterior del tres en raya teníamos una clase abstracta denominada TTTGamePlayer de la cual heredaban las clases TTTHumanGamePlayer y TTTRandomGamePlayer. TTTHumanGamePlayer además de heredar de TTTGamePlayer, también heredaba de KeyMatrixListener, de esta forma los eventos de los pulsadores se transforman en movimientos del jugador humano.

La clase TTTRandomGamePlayer era una clase que inicializaba una semilla aleatoria. Dentro de esta clase el método getSpot(), que es el método virtual puro de TTTGamePlayer que debe devolver el movimiento que desea realizar el jugador, devolvía en este caso una posición aleatoria de entre todas las posiciones libres que quedaban en el tablero.

Algoritmo Minimax

El algoritmo minimax es un algoritmo de decisión muy simple desarrollado a partir del Teorema Minimax de John von Neumann en 1926 (para juegos de suma cero e información perfecta). El algoritmo es muy sencillo:

- Cuando nos toque mover a nosotros, calculamos el árbol de decisión del juego.

- Los nodos hoja del árbol representarán todas las posibles formas de terminar el juego. Asignamos a cada uno de los nodos hoja del árbol un valor numérico directamente proporcional a nuestro beneficio (o inversamente proporcional al beneficio de nuestro oponente).

- Vamos ascendiendo en el árbol escogiendo, alternativamente, el mínimo o el máximo de los hijos (minimax) en función de si cada decisión debe ser tomada por mí o por mi oponente (yo busco maximizar mi beneficio y mi oponente busca minimizarlo o, lo que es lo mismo, maximizar el suyo propio), así hasta la raíz. De esta forma podemos elegir el movimiento que minimize nuestra pérdida esperada.

Con un ejemplo del 3 en raya se ve mejor. Imaginemos que somos X y que nos toca mover a nosotros:
o x x
x . . sig. mov. = x, beneficio = max(-1, -1, 0) = 0
o o .

o x x
x x . sig. mov. = o, beneficio = min(0, -1) = -1
o o .

o x x
x x o sig. mov. = x, beneficio = max(0) = 0
o o .

o x x
x x o tablas, beneficio = 0
o o x

o x x
x x . gana o, beneficio = -1
o o o

o x x
x . x sig. mov. = o, beneficio = min(1, -1) = -1
o o .

o x x
x o x sig. mov. = x, beneficio = max(1) = 1
o o .

o x x
x o x gana x, beneficio = 1
o o x

o x x
x . x gana o, beneficio = -1
o o o

o x x
x . . sig. mov = o, beneficio = min(1, 0) = 0
o o x

o x x
x o . sig. mov. = x, beneficio = max(1) = 1
o o x

o x x
x o x gana x, beneficio = 1
o o x

o x x
x . o sig. mov. = x, beneficio = max(0) = 0
o o x

o x x
x x o tablas, beneficio = 0
o o x

A partir del nodo raíz, el beneficio máximo se corresponde con el movimiento:
o x x        o x x
x . . ---> x . .
o o . o o x

En este caso “beneficio” se refiere a “beneficio para X”. Como se puede ver, partiendo del estado del tablero indicado por la raíz del árbol, el mejor movimiento será el de marcar la fila 3 y la columna 3.

Hardware

El montaje lo he realizado utilizando un Arduino Leonardo, conectando la matriz de pulsadores a los puertos A0, A1, A2, D11, D12 y D13

Y la matriz de leds a los puertos D0, D1, D2, D3, D4 y D5

Si vamos a realizar el montaje para otro modelo de Arduino hay que comprobar el mapeo de los puertos en el microcontrolador y modificar la implementación de las clases MyLedMatrixManager y MyKeyMatrixManager (como se trata de una implementación en C++, no tenemos a nuestra disposición la abstracción de puertos que nos proporciona el lenguaje Arduino).

Implementación

La clase TTTMinimaxGamePlayer es la que implementa el algoritmo minimax: Al igual que las clases TTTRandomGamePlayer y TTTHumanGamePlayer, hereda de TTTGamePlayer con la diferencia de que en su método getSpot() calcula el siguiente movimiento a realizar utilizando el algoritmo de decisión minimax.

uint8_t TTTMinimaxGamePlayer::getSpot() {
    uint8_t ret = 0;
    this->minimax(*this->board, true, ret);
    return ret;
}

int8_t TTTMinimaxGamePlayer::minimax(TTTGameBoard &board, bool me, uint8_t &bestMovement) {
    uint8_t winner = board.getWinner();
    if (board.isFinished() || (winner != 0)) {
        int8_t ret = 0;
        if (winner == this->number)
            ret = 1;
        else if (winner == this->opponentNumber)
            ret = -1;
        return ret;
    }
    else {
        int8_t limit = 0;
        if (me)
            limit = -10;
        else
            limit = 10;
        TTTGameBoard auxBoard;
        uint8_t numMovements = 0;
        for (uint8_t m = board.getFirstAvailableMovement(); m != 0; m = board.getNextAvailableMovement(), numMovements++) {
            auxBoard.copyFrom(board);
            if (me)
                auxBoard.set(m, this->number);
            else
                auxBoard.set(m, this->opponentNumber);
            uint8_t childBestMovement;
            int8_t v = minimax(auxBoard, !me, childBestMovement);
            if (me) {
                if (v > limit) {
                    limit = v;
                    bestMovement = m;
                }
            }
            else {
                if (v < limit) {
                    limit = v;
                    bestMovement = m;
                }
            }
        }
        return limit;
    }
}

Se trata de una implementación recursiva y el parametro “me” indica cuando se está simulando un movimiento propio (true) o un movimiento del oponente (false). A pesar de usarse una implementación recursiva, nótese que no se realizarán más de 8 llamadas recursivas: Cada nivel es un movimiento adicional y un tablero de 3 en raya sólo tiene 9 posiciones.

A modo de visión global, el diagrama de clases queda ahora así:


En la sección soft puede descargarse el código fuente del proyecto en C++. Aquí una pequeña guía sobre cómo desarrollar en C++ para el Arduino.



[ añadir comentario ] ( 2766 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 2171 )
Algoritmo de Ramer-Douglas-Peucker de simplificación de rutas 
El algoritmo de Ramer-Douglas-Peucker, a veces también denominado algoritmo de Douglas-Peucker a secas, es un algoritmo que fue desarrollado en los años 70 por Urs Ramer, David Douglas y Thomas Peucker. Dicho algoritmo permite reducir la cantidad de puntos en una ruta de segmentos rectilíneos utilizando un criterio de distancias. Es muy útil para la simplificación de rutas sobre mapas o para la optimización en el renderizado de imágenes vectoriales.

El algoritmo se basa en la técnica de divide y vencerás. Partiendo de una ruta inicial formada por un conjunto de N puntos, $p_0$ a $p_{n - 1}$ buscamos, de entre los puntos intermedios, el que esté más alejado de la recta que une $p_0$ y $p_{n - 1}$. Sea este punto $p_i$ y $d(i, 0, n - 1)$ la distancia entre $p_i$ y la recta que une $p_0$ con $p_{n - 1}$.

Si $d(i, 0, n - 1)$ supera un valor umbral que llamaremos $\varepsilon$, realizamos dos llamadas recursivas, una que ejecuta el algoritmo entre los puntos $p_0$ y $p_i$ y otra que ejecuta el algoritmo entre los puntos $p_i$ y $p_{n - 1}$.

Si $d(i, 0, n - 1)$ no supera el valor umbral de $\varepsilon$, descartamos de la ruta final todos los puntos intermedios entre $p_0$ y $p_{n - 1}$, esto es, descartamos todos los puntos $p_j$ tales que $i < j < n - 1$.

Con un ejemplo se ve mejor. Consideremos la siguiente ruta formada por 5 puntos, de $p_0$ a $p_4$ y consideremos un valor de $\varepsilon = 1.3$.

|                  
|             3    
|   1   2          
|                  
0                
|               4  
+-+-+-+-+-+-+-+-+-+

Inicialmente consideramos la recta que une $p_0$ con $p_4$ (el primero con el último) y buscamos el punto más alejado de ella. En nuestro caso es $p_3$ (lo he subrayado) y la distancia de la recta $\overline{p_0p_4}$ a $p_3$ es de 3.82 aproximadamente, dicho valor es superior a nuestro $\varepsilon$ (1.3) y, por tanto dividimos el problema en dos subproblemas de forma recursiva.

Invocamos, por un lado el algoritmo considerando la ruta formada por los puntos $p_0$ a $p_3$ y, por otro lado el mismo algoritmo considerando la ruta formada por los puntos $p_3$ a $p_4$. Este último caso es trivial ya que no hay puntos intermedios y, obviamente, no se puede descartar ningún punto entre $p_3$ y $p_4$.

Si consideramos ahora la ruta formada por los puntos $p_0$ a $p_3$.

|                  
|             3    
|   1   2          
|                  
0                
|               4  
+-+-+-+-+-+-+-+-+-+


Hacemos el mismo razonamiento: consideramos la recta que une el primer punto con el último ($p_0$ con $p_3$) y buscamos el punto intermedio más alejado de dicha recta. En este caso el punto más alejado de la recta que une $p_0$ con $p_3$ es $p_1$ (de nuevo subrayado) con una distancia aproximada de 1.34.

Como 1.34 es mayor que nuestro $\varepsilon$, volvemos a subdividir el problema en dos nuevos problemas más pequeños. Por un lado llamamos al algoritmo con la ruta formada por los puntos $p_0$ y $p_1$ y por otro lado llamamos al algoritmo con la ruta formada por los puntos $p_1$ a $p_3$. El cálculo del algoritmo para dos puntos, al igual que pasó anteriormente con $p_3$ y $p_4$ es trivial ya que, al no haber puntos intermedios, no es posible descartar ningún punto. Por tanto nos centraremos en el caso de la ruta entre $p_1$ y $p_3$.

|                  
|             3    
|   1   2          
|                  
| 0                
|               4  
+-+-+-+-+-+-+-+-+-+


Recordemos que, hasta ahora, no se ha descartado ningún punto para la ruta final. Para la ruta formada por los puntos $p_1$ a $p_3$, el punto más alejado de la recta entre $p_1$ y $p_3$ es $p_2$ con una distancia de aproximadamente 0.39. Dicha distancia sí que es menor que nuestro umbral $\varepsilon$, por tanto descartamos todos los puntos intermedios entre $p_1$ y $p_3$.

|                  
|             3    
|   1              
|                  
| 0                
|               4  
+-+-+-+-+-+-+-+-+-+


Como se puede ver, el algoritmo finalmente ha descartado el punto $p_2$ de la ruta.

A continuación se puede ver cómo sería el pseudocódigo:
ramer_douglas_peucker(vector, epsilon, izquierda, derecha)
indice := 0
max := 0
para j := (izquierda + 1) hasta (derecha - 1) hacer
    d := distancia del punto vector[j] a la recta (vector[izquierda], vector[derecha])
    si (d > max) entonces
        max := d
        indice := j
    fin si
fin para
si (max > epsilon) entonces
    ramer_douglas_peucker(vector, epsilon, izquierda, indice)
    ramer_douglas_peucker(vector, epsilon, indice, derecha)
en otro caso
    para j := (izquierda + 1) hasta (derecha - 1) hacer
        marcar vector[j] como descartado de la ruta final
    fin para
fin si

Y un ejemplo de implementación en C++ utilizando plantillas:

#include <vector>

extern "C++" {
    namespace avelino {
        using namespace std;

        class RamerDouglasPeuckerPoint {
            public:
                virtual double getRamerDouglasPeuckerDistance(RamerDouglasPeuckerPoint &p1, RamerDouglasPeuckerPoint &p2) = 0;
                virtual void markAsDiscarded() = 0;
        };

        template <class T>
        void ramerDouglasPeuckerSimplify(vector<T> &v, double epsilon, int left, int right) {
            // find the point with the max distance
            int index = 0;
            double maxDistance = 0;
            for (int j = left + 1; j <= (right - 1); j++) {
                RamerDouglasPeuckerPoint *p = &v[j];
                double d = p->getRamerDouglasPeuckerDistance(v[left], v[right]);
                if (d > maxDistance) {
                    index = j;
                    maxDistance = d;
                }
            }
            if (maxDistance > epsilon) {
                // recursive calls
                ramerDouglasPeuckerSimplify(v, epsilon, left, index);
                ramerDouglasPeuckerSimplify(v, epsilon, index, right);
            }
            else {
                // discard all but first and last point
                for (int j = left + 1; j <= (right - 1); j++)
                    v[j].markAsDiscarded();
            }
        }
    }
}

Para utilizar la función template hay que crear una clase que herede de RamerDouglasPeuckerPoint y que implemente, al menos, los dos métodos virtuales puros. Para puntos en el plano 2D podríamos hacer una clase de este estilo:

#include "RamerDouglasPeucker.H"

extern "C++" {
    namespace avelino {
        using namespace std;

        class MapPoint : public RamerDouglasPeuckerPoint {
            protected:
                double x;
                double y;
                bool discarded;
            public:
                MapPoint(double x, double y);
                double getRamerDouglasPeuckerDistance(RamerDouglasPeuckerPoint &p1, RamerDouglasPeuckerPoint &p2);
                void markAsDiscarded();
                double getX();
                double getY();
                bool isDiscarded();
        };
    }
}

Esta clase implementaría el método getRamerDouglasPeuckerDistance, que devuelve la distancia entre el punto y la recta formada por los dos puntos pasados por parámetros, y el método markAsDiscarded que permite marcar puntos como descartados. Utilizando esta clase, invocar el algoritmo es tan sencillo como hacer:

...
vector<MapPoint> v;
ramerDouglasPeuckerSimplify<MapPoint>(v, 1.3, 0, v.size() - 1);
...


A continuación puede verse el resultado de la ejecución del algoritmo sobre un perfil del continente africano formado por 28653 puntos, al que se le aplica el algoritmo con $\varepsilon = 4.5$.



(imagen extraida de http://www.scielo.br/scielo.php?pid=S01 ... ci_arttext con licencia Creative Commons Attribution License. Sociedade Brasileira de Computação)

Código fuente en la sección soft.

[ 2 comentarios ] ( 4523 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 2084 )
Aproximación de Padé para el cálculo eficiente de la función exponencial 
Implementar la función exponencial en un sistema embebido con poca RAM, poca memoria de programa y sin coprocesador matemático pasa, normalmente, por intentar evitar el uso de la librería matemática de C. La sobrecarga que produciría el utilizar la función “exp” de dicha librería unida a la sobrecarga propia de la manipulación de datos en coma flotante por software desaconsejan totalmente el uso de dicha librería en sistema embebidos pequeños. Analizaremos diferentes aproximaciones polinomiales a la función exponencial y el uso de aritmética de punto fijo para realizar dicho cálculo.

Analizaremos dos aproximaciones polinómicas: La serie de Taylor y la aproximación de Padé (esta última se trata realmente de una aproximación racional).

Serie de Taylor

Lo primero que se le suele venir a uno a la cabeza cuando piensa en aproximaciones polinómicas suele ser la serie de Taylor, dicha serie es muy sencilla de calcular y genera una buena aproximación en el entorno de un punto. En este caso se ha optado por aproximar la función exponencial en el entorno de x=0:
$$e^{x} \simeq 1 + {x \over 1!} + {x^2 \over 2!} + {x^3 \over 3!} + ...$$
Esto nos da una muy buena aproximación, aunque para conseguir un error aceptable, es necesario calcular la serie de Taylor para un orden relativamente alto. El error con respecto a la función “exp” de la librería matemática de C comienza a ser asumible a partir del orden 6 trabajando en coma flotante.

Aproximación de Padé

La aproximación de Padé de orden (m, n) es la función racional:
$$R(x) = {p_0 + p_{1}x + p_{2}x^2 + ... + p_{m}x^m \over 1 + q_{1}x+ q_{2}x^2 + ... + q_{n}x^n}$$
Que cumple que:
$$f(0) = R(0)$$
$$f'(0) = R'(0)$$
$$f''(0) = R''(0)$$
$$...$$
$$f^{(m+n)}(0) = R^{(m+n)}(0)$$
El cálculo de los coeficientes de Padé no es trivial y existen varias técnicas para obtenerlos, como el algoritmo Epsilon de Wynn o el algoritmo euclídeo extendido para el cálculo del máximo común divisor. Por suerte, para la función exponencial, podemos consultar las tablas de Padé que ya se encuentran en internet calculadas para diferentes órdenes (valores de m y de n):

Aquí una tabla publicada en wikipedia.

Se ha optado en este caso por utilizar la aproximación de Padé de orden [3 / 3] (m = n = 3).

A continuación puede verse una implementación en C de ambas aproximaciones.

double taylor_exp(double x) {
	double ret = 0;
	int i;
	double num = 1;
	double den = 1;
	for (i = 0; i <= 6; i++) {
		ret += num / den;
		num *= x;
		den *= (i + 1);
	}
	return ret;
}

double pade_exp(double x) {
	double x2 = x * x;
	double x3 = x2 * x;
	double num = 1 + (x / 2) + (x2 / 10) + (x3 / 120);
	double den = 1 - (x / 2) + (x2 / 10) - (x3 / 120);
	return num / den;
}

Como puede apreciarse, la serie de Taylor es de orden 6, mientras que la aproximación de Padé que se ha implementado es la de orden [3 / 3]. A continuación se reproduce la salida de una prueba de ambas funciones comparándolas con la función “exp” de la librería matemática:
# ./taylor_vs_pade_float
exp(0.250000):
exp() function : 1.2840254167
6th order taylor : 1.2840254042
3rd order pade : 1.2840254175

Como puede verse, la aproximación de Padé consigue un error comparable al de la serie de Taylor con muchas menos operaciones.

Utilizar aritmética de punto fijo

Ahora que están ambos algoritmos implementados en coma flotante, pasaremos el cálculo a aritmética de punto fijo en formato Q16.16 (más info sobre la notación Q). En el formato Q16.16 tenemos 16 bits para la parte entera y 16 bits para la parte fraccionaria, en total 32 bits.

typedef int32_t fixedpoint_t;

#define  FP_NEG(x)           (-(x))
#define  FP_ADD(x, y)        ((x) + (y))
#define  FP_SUB(x, y)        ((x) - (y))
#define  FP_MUL(x, y)        ((int32_t) (((int64_t) (x)) * ((int64_t) (y)) >> 16))
#define  FP_DIV(x, y)        ((int32_t) ((((int64_t) (x)) << 16) / ((int64_t) (y))))
#define  TO_FP(x)            ((int32_t) ((x) << 16))
#define  FROM_FP(x)          ((x) >> 16)
#define  FP_FRACTIONAL_BITS  16

Las funciones anteriores puede ser ahora reescritas para utilizar el formato Q16.16:

fixedpoint_t taylor_exp(fixedpoint_t x) {
	fixedpoint_t ret = 0;
	int i;
	fixedpoint_t num = TO_FP(1);
	fixedpoint_t den = TO_FP(1);
	for (i = 0; i <= 6; i++) {
		ret = FP_ADD(ret, FP_DIV(num, den));
		num = FP_MUL(num, x);
		den = FP_MUL(den, FP_ADD(TO_FP(i), TO_FP(1)));
	}
	return ret;
}

fixedpoint_t pade_exp(fixedpoint_t x) {
	fixedpoint_t x2 = FP_MUL(x, x);
	fixedpoint_t x3 = FP_MUL(x2, x);
	fixedpoint_t num = FP_ADD(TO_FP(1), FP_ADD(FP_DIV(x, TO_FP(2)), FP_ADD(FP_DIV(x2, TO_FP(10)), FP_DIV(x3, TO_FP(120)))));
	fixedpoint_t den = FP_ADD(FP_SUB(FP_DIV(x2, TO_FP(10)), FP_DIV(x3, TO_FP(120))), FP_SUB(TO_FP(1), FP_DIV(x, TO_FP(2))));
	return FP_DIV(num, den);
}

En este caso, realizando la misma prueba obtenemos resultados algo peores (debido a la pérdida de precisión inherente al uso del punto fijo) y, aunque para las dos aproximaciones obtenemos el mismo valor, la aproximación de Padé requiere menor cantidad de operaciones que la serie de Taylor.
# ./taylor_vs_pade_fixed
exp(0.250000):
exp() function : 1.2840254167
6th order taylor : 1.2839965820
3rd order pade : 1.2839965820

La aproximación de Padé, como ha podido verse, da mejores resultados que las series de Taylor como aproximación a la función exponencial, tanto desde el punto de vista de la eficiencia como de la precisión.

[ añadir comentario ] ( 3416 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 2166 )
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 ] ( 1576 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 1980 )
Luces del belén controladas por Arduino con ciclo día-noche 
El año pasado por estas mismas fechas planteé y desarrollé unas luces para el belén controladas por una placa Arduino y que generaban una cadencia de destellos en función de la luz ambiente: a menor luz ambiente, más destellos y a mayor luz ambiente, menos destellos. Este año he planteado y desarrollado una segunda revisión un poco más avanzada del concepto de luces del belén.

Aspectos funcionales

La idea es que las luces del belén varíen sus destellos, no en función de la luz ambiente, sino en función de la hora que sea en cada momento. Es preciso definir lo que se entiende por “día” y por “noche” (en intervalo horario) y se debe especificar un patrón de comportamiento de las luces para cada uno de esos intervalos horarios.

Lo que he hecho en este caso es definir, para cada tramo horario, la probabilidad de que una luz cualquiera de las 5 brille. Dicha probabilidad será de 0.1 cuando estemos en el intervalo horario que hemos definido como “día” y de 0.8 cuando estemos en el intervalo horario que hemos definido como “noche”.

Aspectos técnicos

El problema es principalmente saber cuándo estamos en el intervalo horario “día” y cuándo estamos en el intervalo horario “noche”. Para ello, he recurrido al circuito desarrollado en el anterior post, en el que conecté un chip RTC (el clásico DS1307) a la placa Arduino mediante el bus I2C.



El circuito consta de varias partes:

- Por un lado tenemos la parte del reloj RTC con los dos hilos del bus I2C interconectando el Arduino y el DS1307 (SDA y SCL). El chip RTC tiene conectada una pila para mantener la hora cuando el resto del circuito esté apagado y un cristal de cuarzo de 32768Hz necesario para que el reloj sea preciso.

- Por otro lado tenemos las luces en sí: 5 leds blancos de alta luminosidad.

- Y finalmente tenemos un pulsador y un led adicional etiquetados como “Reset RTC” y “Led indicador de reset del RTC” respectivamente. El pulsador sirve para reiniciar la hora del RTC a las 00:00 (para ponerlo en hora, vamos) y el led indica que se ha puesto en hora de forma satisfactoria. Este último led se deja en la misma placa, no forma parte de las luces parpadeantes del belén. Para evitar que pulsaciones accidentales cambien la hora del chip RTC, es necesario mantener pulsado el botón al menos 2 segundos para que tenga efecto.

Las clases I2C y RTC desarrolladas en el post anterior han tenido que ser readaptadas para hacerlas no bloqueantes (básicamente usando autómatas). Las principales clases son las siguientes:

ADCManager: Es una utility class que permite leer las entradas analógicas. Se utiliza para inicializar el generador de números pseudoaleatorios.
I2C: Otra utility class para gestionar la comunicación a través del bus I2C del microcontrolador (SDA y SCL). Incluye un autómata para controlar los estados de espera del bus.
RTC: Es otra utility class que llama internamente a los métodos de I2C utilizando comandos específicos del RTC DS1307.
RTCListener: Es una clase abstracta que debe ser implementada por aquel objeto que desee ser avisado cada vez que cambia la hora.
RTCObserver: Una utility class encargada de consultar el RTC cada 250 milisegundos y de avisar al objeto que implemente RTCListener cada vez que cambie la hora. Lo he hecho así para no “estresar” el RTC.
StarsManager: Es la clase encargada de generar los destellos. Hereda de RTCListener y, cada vez que cambia la hora, comprueba en qué intervalo se encuentra (“día” o “noche”) y modifica, si procede, la probabilidad de destello de los leds del belén.

Todo el código fuente, en C++, puede ser descargado de la sección soft. Para compilar el proyecto sólo es necesario tener instalada la toolchain de GNU para AVR, dicha toolchain se instala junto con el resto de software que viene con el Arduino. Sólo hay que revisar el fichero Makefile y cambiar las rutas para adecuarlas a la ruta de instalación que tengamos en nuestro ordenador.



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

<< <Anterior | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Siguiente> >>