Detección de tonos en sistemas embebidos 
Cuando pensamos en detectar determinadas frecuencias o tonos en una señal lo primero que se nos viene a la cabeza suele ser la FFT, en concreto la implementación de Cooley-Tukey con N potencia de 2. La FFT está muy bien si lo que queremos es todo el espectro de una señal, pero si lo que necesitamos es detectar un único tono en una frecuencia concreta podemos recurrir al algoritmo de Goertzel, más rápido y muy fácil de implementar en sistemas embebidos.

El algoritmo de Goertzel permite calcular un coeficiente aislado de la DFT sobre un conjunto de N muestras con una complejidad temporal de O(n) y una complejidad espacial de O(1), además N no tiene por qué ser potencia de 2. Este algoritmo se basa en la aplicación de una ecuación en diferencias finitas (un filtro IIR).

A mayor N, mayor resolución en frecuencia y también mayor latencia en la detección de los tonos. Es necesario, por tanto encontrar un compromiso. La resolución en hercios del algoritmo de Goertzel viene dada por:
$$R = {f_s \over N}$$
Siendo $f_s$ la frecuencia de muestreo y $N$ el número de muestras que se evalúan en cada pasada.

1. Se calcula el índice del coeficiente correspondiente

Lo primero que hay que hacer es calcular el índice del coeficiente asociado a la DFT a partir de la frecuencia que queremos detectar:
$$k = N{f \over f_s}$$
Siendo $f$ la frecuencia que queremos detectar, $f_s$ la frecuencia de muestreo y $N$ el número de muestras que procesamos cada vez. La máxima frecuencia que podemos detectar será la mitad de la frecuencia de muestreo.

2. Para cada conjunto de N muestras

2.1. Se aplica la ecuación en diferencias

Para cada una de las muestras que van llegando, vamos aplicando la siguiente ecuación en diferencias finitas:
$$s[n] = x[n] + 2\cos\left({2 \pi k \over N}\right)s[n-1] - s[n-2]$$
$$n = 0..N$$
Con codiciones iniciales $s[-1] = s[-2] = 0$. Se trata, como se puede ver, de un sencillo filtro IIR de segundo orden.

2.2. Se obtiene del coeficiente k-esimo de la DFT

Se puede demostrar que el coeficiente k-ésimo de la DFT de tamaño N es:
$$X(k) = s[N] - W_N^ks[N-1]$$
Siendo:
$$W_N = e^{-j\left({2 \pi \over N}\right)}$$
Por tanto:
$$X(k) = s[N] - e^{-j\left({2 \pi \over N}\right)k}s[N-1]$$
Si desarrollamos la exponencial compleja mediante la fórmula de Euler tenemos que:
$$e^{-j\left({2 \pi \over N}\right)k} = \cos\left({2 \pi \over N}\right) - j \sin\left({2 \pi k \over N}\right)$$
Y, por tanto:
$$X(k)_{real} = s[N] - \cos\left({2 \pi k \over N}\right)s[N-1]$$
$$X(k)_{imag} = \sin\left({2 \pi k \over N}\right)s[N-1]$$
Para calcular la magnitud de la banda de frecuencia correspondiente calculamos el módulo de $X(k)$:
$$M^2 = \left|X(k)\right|^2 = X(k)_{real}^2 + X(k)_{imag}^2$$
De esta forma podemos medir la magnitud de la banda de frecuencia correspondiente al coeficiente k-ésimo de la DFT o, lo que es lo mismo, la magnitud de la banda de frecuencia correspondiente a la frecuencia $f$.
$$k = N{f \over f_s} \Rightarrow f = {k f_s \over N}$$

Implementación

A continuación se puede ver una implementación del algoritmo de Goertzel en Arduino:
const int ANALOG_INPUT = A0;
const int SAMPLE_RATE_HZ = 3000;
const int SAMPLE_PERIOD_US = 1000000 / SAMPLE_RATE_HZ;
const int F_HZ = 440;
const int N = 3000;
const int K = 440;  // N * F_HZ / SAMPLE_RATE_HZ;
unsigned long tPrev;

struct goertzelFilter {
  float s1, s2;
  int k, n;
  float sinv, cosv, cosv2;
  int nextIteration;
};

struct goertzelFilter filter;

void goertzelFilterReset(struct goertzelFilter &f) {
  f.s1 = 0;
  f.s2 = 0;
  f.nextIteration = 0;
}

void goertzelFilterInit(struct goertzelFilter &f, int k, int n) {
  f.k = k;
  f.n = n;
  f.cosv = cos(2 * PI * k / n);
  f.sinv = sin(2 * PI * k / n);
  f.cosv2 = 2 * f.cosv;
  goertzelFilterReset(f);
}

boolean goertzelFilterFinished(struct goertzelFilter &f) {
  return (f.nextIteration == (f.n + 1));
}

void goertzelFilterRun(struct goertzelFilter &f, float input) {
  float s = input + (f.cosv2 * f.s1) - f.s2;
  f.s2 = f.s1;
  f.s1 = s;
  f.nextIteration++;
}

float goertzelFilterGetMagnitude(struct goertzelFilter &f) {
  float real = f.s1 - (f.cosv * f.s2);
  float imag = f.sinv * f.s2;
  return ((real * real) + (imag * imag));
}

void setup() {
  Serial.begin(9600); 
  tPrev = micros();
  goertzelFilterInit(filter, K, N);
  goertzelFilterReset(filter);
}

void loop() {
  unsigned long t = micros();
  if ((t - tPrev) >= SAMPLE_PERIOD_US) {
    int v = analogRead(ANALOG_INPUT);
    if (goertzelFilterFinished(filter)) {
      float magnitude = goertzelFilterGetMagnitude(filter);
      Serial.println(magnitude);
      goertzelFilterReset(filter);
    }
    else
      goertzelFilterRun(filter, ((float) v - 512) / 512);
    tPrev = t;
  }
}

Se ha elegido una frecuencia de muestreo baja (3000Hz) para poder trabajar cómodamente con tipos float. Utilizando aritmética de punto fijo podríamos incremenentar la frecuencia de muestreo y que la detección sea más precisa.

La entrada de audio se toma de la entrada analógica A0 a la que se conecta un sencillo circuito amplificador:


Elegir el valor de N

A la hora de elegir la N lo ideal es escogerla lo más grande posible, que nos permita una latencia razonable y que se cumpla que:
$$k = N{f \over f_s} \in \mathbb{N}$$
Por ejemplo, para detectar un tono de 1Khz sobre una señal muestreada a 6KHz lo ideal sería que la N valiese: 96, 102, 114… Ya que para todos estos valores se cumple que $k$ es número natural.

Magnitudes medidas para diferentes tonos

En ausencia de señal de entrada: 0.30, 0.37, 0.30, 0.35, 0.44, 0.32, 0.37, 0.28...

Con una señal de entrada de 200Hz: 2.56, 1.73, 3.56, 0.81, 0.58, 1.67, 4.71, 6.81...

Con una señal de entrada de 440Hz (la frecuencia del detector): 138.41, 87.29, 441.14, 185.20, 233.03, 762.27, 80.62, 330.98...

Con una señal de entrada de 500Hz: 25.70, 9.60, 22.50, 2.76, 16.62, 18.75, 23.56, 35.58...



[ añadir comentario ] ( 2510 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3.1 / 3145 )
Implementación de una red neuronal tipo perceptrón mediante aritmética de punto fijo 
Los perceptrones multicapa (Multilayer Perceptron o MLP) son redes neuronales de aprendizaje supervisado de tipo unidireccional en las que tenemos una capa de neuronas de entrada, una o más capas de neuronas intermedias y una capa de neuronas de salida. Son muy utilizadas para reconocimiento y clasificación de patrones y relativamente sencillas de implementar en sistemas embebidos utilizando aritmética de punto fijo.

En un MLP cada neurona tiene una salida y n entradas y se modela de la siguiente manera:

Las entradas de cada neurona suman de forma ponderada (pesos sinápticos) y dicha suma ponderada se hace pasar por una función de activación que, en el caso más general (clasificadores), se trata de la función sigmoide:
$$f(x) = {{1} \over {1+e^{-x}}}$$
Los MLP son estructuras de neuronas organizadas en forma de capas:

En este caso tenemos un MLP con 8 neuronas de entrada (las neuronas de entrada no son neuronas en sí, se les llama neuronas pero realmente son simplemente las entradas de la red), dos capas (denominadas “ocultas”) de 8 neuronas cada una y una capa de salida de 2 neuronas.

Los MLP con función de activación sigmoide son más utilizados para reconocimiento y clasificación de patrones. En este caso se abordará este tipo de MLP utilizando el clásico algoritmo de aprendizaje Backpropagation.

Algoritmo Backpropagation

El algoritmo Backpropagation consiste en hacer pasar por la red una serie de patrones predefinidos para los que se indica una salida “esperada”. La salida real que emite la red para cada uno de los patrones de entrada se compara con la salida “esperada” y se recalculan los pesos sinápticos hacia atrás (desde la salida hasta la entrada). De ahí el nombre del algoritmo. Los pasos de dicho algoritmo son los siguientes:

Evaluar la red

Se evalúa la red, esto es, para cada neurona desde la capa de entrada hasta la capa de salida se realiza el cálculo de los valores de salida de cada neurona utilizando la ecuación:
$$suma^{o}_{pk} = \sum_{j=1}^{L}w_{kj}^{o}y_{pj} + \theta_{k}^{o}$$
$$y_{pk}=f_{k}^{o}(suma^{o}_{pk})$$
Siendo:
$w_{kj}^{o}$ el peso sináptico de la neurona $j$ de la capa anterior sobre la neurona $k$ de la capa actual.
$f$ la función de activación (en este caso la sigmoide).

Calcular los términos del error

Para cada neurona $k$ de la capa de salida se calcula su error:
$$\delta_{pk}^{o}=(d_{pk}-y_{pk})f_{k}^{'o}(suma_{pk}^{o})$$
Siendo:
$d_{pk}$ el valor deseado para la neurona $k$ de la capa salida y para el patrón de entrada $p$.
$y_{pk}$ el valor actual de la neurona $k$ de la capa de salida para el patrón $p$.
$f'$ la derivada de la función de activación (derivada de la función sigmoide).

Mientras que para cada neurona $j$ de cada capa oculta $h$ el error hay que estimarlo a partir de los errores de la capa siguiente:
$$\delta_{pj}^{h}=f_{j}^{'h}(suma_{pj}^{h})\sum_{k}\delta_{pk}^{o}w_{kj}^{o}$$
Siendo:
$p$ el vector de entrenamiento.
$suma_{pj}^{h}$ la entrada neta (suma ponderada) de la neurona $j$ en la capa $h$ para el vector $p$.
$\delta_{pk}^{o}$ el término de error de la neurona $k$ de la capa anterior.
$w_{kj}^{o}$ el peso de la salida de la neurona $j$ de la capa anterior sobre la neurona $k$.

Actualizar los pesos

$$w_{kj}^{o}(t+1)=w_{kj}^{o}(t)+\Delta w_{kj}^{o}(t+1)$$
$$\Delta w_{kj}^{o}(t+1)=\alpha \delta_{pk}^{o}y_{pj}$$
Siendo:
$w_{kj}^{o}$ el peso de la salida de la neurona $j$ de la capa anterior sobre la neurona $k$.
$y_{pj}$ la salida de la neurona $j$ (la del extremo origen del peso $w_{kj}^{o}$).

Se repite hasta que el error sea aceptable

$$E_{p}={1 \over 2}\sum_{k}\delta_{pk}^{2}$$
Siendo:
$\delta_{pk}$ el error de la neurona $k$ de la capa de salida para el patrón de entrada $p$.

Implementación en punto fijo

En el caso de que queramos implementar una red neuronal de tipo MLP en un sistema embebido sin unidad de coma flotante, debemos intentar realizar una implementación en punto fijo de toda la operativa (tanto evaluación como aprendizaje de la red). Como se puede ver en la ecuaciones mostradas, la parte más compleja viene dada por la función de activación.
$$f(x)={1 \over {1+e^{-x}}}$$
Y por su derivada:
$$f'(x)={{e^{-x}} \over {(1+e^{-x})^2}}$$
Utilizando aproximantes de Padé de orden [3 / 3] para el cálculo de la exponencial podemos aproximar ambas funciones de forma razonablemente buena.
$$e^{x} \simeq {{1+{x \over 2}+{{x^2} \over 10}+{{x^3} \over 120}} \over {1-{x \over 2}+{{x^2} \over 10}-{{x^3} \over 120}}}$$
fixedpoint_t FixedPoint::getExp(fixedpoint_t x) {
    fixedpoint_t x2 = FP_MUL(x, x);
    fixedpoint_t x3 = FP_MUL(x2, x);
    fixedpoint_t num = TO_FP(1) + FP_DIV(x, TO_FP(2)) + FP_DIV(x2, TO_FP(10)) + FP_DIV(x3, TO_FP(120));
    fixedpoint_t den = TO_FP(1) - FP_DIV(x, TO_FP(2)) + FP_DIV(x2, TO_FP(10)) - FP_DIV(x3, TO_FP(120));
    return FP_DIV(num, den);
}

[...]

fixedpoint_t MultilayerPerceptron::getNetValue(uint8_t numNeuronsPrevLayer, uint8_t currentLayer, uint8_t n) {
    fixedpoint_t acc = 0;
    for (uint8_t p = 0; p < numNeuronsPrevLayer; p++) {
        fixedpoint_t x = this->getNeuronValue(currentLayer - 1, p);
        fixedpoint_t w = this->getInputWeight(currentLayer, p, n);
        acc = acc + FP_MUL(x, w);
    }
    return acc;
}
    
    
fixedpoint_t MultilayerPerceptron::getSigmoid(fixedpoint_t x) {
    return FP_DIV(TO_FP(1), TO_FP(1) + FixedPoint::getExp(-x));
}   


void MultilayerPerceptron::evaluate() {
    uint8_t numLayers = this->getNumHiddenLayers() + 1;
    for (uint8_t l = 1; l <= numLayers; l++) {
        uint8_t numNeurons = this->getNumNeurons(l);
        uint8_t numNeuronsPrevLayer = this->getNumNeurons(l - 1);
        for (uint8_t n = 0; n < numNeurons; n++) {
            fixedpoint_t acc = this->getNetValue(numNeuronsPrevLayer, l, n);
            fixedpoint_t y = MultilayerPerceptron::getSigmoid(acc);
            this->setNeuronValue(l, n, y);
        }
    }
}

Para el algoritmo de entrenamiento Backpropagation es necesario utilizar la derivada de la función de activación. Esta derivada puede simplificarse y ponerse en función de la propia función sigmoide:
$$f'(x)={{e^{-x}} \over {(1+e^{-x})^2}}={1 \over {1+e^{-x}}}{{e^{-x}} \over {1+e^{-x}}}=f(x){{e^{-x}} \over {1+e^{-x}}}$$
$$f'(x)=f(x){{1+e^{-x}-1} \over {1+e^{-x}}}=f(x)\left({{1+e^{-x}} \over {1+e^{-x}}}-{1 \over {1+e^{-x}}}\right)$$
$$f'(x)=f(x)\left(1-{1 \over {1+e^{-x}}}\right)=f(x)(1-f(x))$$
Por tanto, para la función sigmoide, se cumple que:
$$f'(x)=f(x)(1-f(x))$$
Como $f(x)$ se corresponde con la salida de cada neurona (siendo $x$ la suma ponderada de sus entradas), la derivada de la salida de cada neurona puede calcularse, por tanto, de esta manera:
$$salida(1-salida)$$
En el algoritmo de entrenamiento Backpropagation esto simplifica enormemente el cálculo de los términos de error ya que se puede calcular la derivada de cada neurona a partir de su valor de salida.

fixedpoint_t MultilayerPerceptron::getEstimatedError(uint8_t layer, uint8_t n) {
    uint8_t numLayers = this->getNumHiddenLayers() + 1;
    fixedpoint_t ret = 0;
    if (layer == numLayers) {
        fixedpoint_t out = this->getNeuronValue(layer, n);
        ret = (this->getDesiredOutput(n) - out);
    }
    else {
        uint8_t numNeuronsNextLayer = this->getNumNeurons(layer + 1);
        for (uint8_t k = 0; k < numNeuronsNextLayer; k++) {
            fixedpoint_t e = this->getNeuronErrorValue(layer + 1, k);
            fixedpoint_t w = this->getInputWeight(layer + 1, n, k);
            ret += FP_MUL(e, w);
        }
    }
    return ret;
}


void MultilayerPerceptron::backpropagate(uint8_t layer, fixedpoint_t *totalError) {
    if (totalError != NULL)
        *totalError = 0;
    uint8_t numNeurons = this->getNumNeurons(layer);
    for (uint8_t n = 0; n < numNeurons; n++) {
        fixedpoint_t out = this->getNeuronValue(layer, n);
        fixedpoint_t aux = FP_MUL(out, TO_FP(1) - out);    // derivada de la función de activación
        fixedpoint_t error = FP_MUL(aux, this->getEstimatedError(layer, n));
        this->setNeuronErrorValue(layer, n, error);
        if (totalError != NULL)
            *totalError += FP_MUL(error, error);
    }
    uint8_t numNeuronsPrevLayer = this->getNumNeurons(layer - 1);
    for (uint8_t n = 0; n < numNeurons; n++) {
        fixedpoint_t e = this->getNeuronErrorValue(layer, n);
        for (uint8_t k = 0; k < numNeuronsPrevLayer; k++) {
            fixedpoint_t y = this->getNeuronValue(layer - 1, k);
            fixedpoint_t w = this->getInputWeight(layer, k, n);
            w = w + FP_MUL(this->trainRate, FP_MUL(e, y));
            this->setInputWeight(layer, k, n, w);
        }
    }
}


void MultilayerPerceptron::train(uint8_t times, fixedpoint_t &totalError) {
    while (times > 0) {
        uint8_t outputLayer = this->getNumHiddenLayers() + 1;
        for (uint8_t l = outputLayer; l >= 1; l--) {
            fixedpoint_t *e = (l == outputLayer) ? &totalError : NULL;
            this->backpropagate(l, e);
        }
        this->commitInputWeights();
        times--;
    }
}

Se ha utilizado como tipo funto fijo el formato Q16.16 mapeado sobre un entero de 32 bits (int32_t), esto nos da una precisión de
$$2^{-16} = 0,0000152587890625$$
Un error bastante aceptable teniendo en cuenta lo que vamos a ganar en velocidad.

typedef int32_t fixedpoint_t;

#define  FP_FRACTIONAL_BITS  16
#define  FP_MUL(x, y)        ((((int64_t) (x)) * ((int64_t) (y))) >> 16)
#define  FP_DIV(x, y)        ((((int64_t) (x)) << 16) / ((int64_t) (y)))
#define  TO_FP(x)            (((int32_t ) (x)) << 16)

Pruebas realizadas

Las pruebas se han realizado partiendo de dos patrones de entrenamiento sencillos:

$p_{1}=\{0, 0, 0, 0, 0, 1, 1, 1\}$ que debe generar la salida $\{0, 1\}$.
$p_{2}=\{0, 1, 1, 0, 0, 0, 0, 1\}$ que debe generar la salida $\{1, 0\}$.
Con los que se han obtenido muy buenos resultados:

Para el patrón $p_{1}$ se obtiene, tras el entrenamiento, la salida $$\{0.0270996, 0.972183\}$$
Para el patrón $p_{2}$ se obtiene, tras el entrenamiento, la salida $$\{0.971191, 0.0284729\}$$
Todo el código fuente puede descargarse de la sección soft.

[ añadir comentario ] ( 1559 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3.1 / 3415 )
Implementación a bajo nivel de un dispositivo USB sobre Arduino Leonardo. Prueba de concepto 
El protocolo USB es uno de los protocolos de comunicación más utilizado y, a la vez, uno de los más complicados y esotéricos para una gran mayoría de programadores. Lo habitual cuando nos enfrentamos a un desarrollo embebido con USB es tirar de librerías o frameworks ya existentes para el microcontrolador que estemos usando (dentro del ecosistema de microcontroladores AVR de 8 bits una de las mejores librerías es, sin duda, LUFA). Sin embargo, en este caso he optado por una implementación desde cero para, por un lado, aprender más sobre el protocolo y, por otro lado, desmitificar la programación de dispositivos USB.

Breve introducción al protocolo USB

El protocolo USB es un protocolo serie basado en tramas de paquetes y con una arquitectura muy sencilla. En un bus USB hay un único host, cero o más dispositivos y cero o más hubs. En el protocolo USB es siempre el host el que lleva la voz cantante: un dispositivo nunca envía de forma asíncrona datos a su host, es el host el que interroga al dispositivo si tiene datos para él (esta forma de hacer las cosas, a priori enrevesada, facilita luego mucho la implementación del protocolo).

Cada conexión USB entre un host y un dispositivo está formada por varios “endpoints”, un endpoint es un canal de comunicación entre el host y el dispositivo y cada dispositivo puede tener hasta 16 endpoints (o canales) de comunicación con el host. Cada endpoint es unidireccional, esto es, cada endpoint debe ser definido como de entrada o de salida (siempre desde el punto de vista del host, IN es dispositivo->host, y OUT es host->dispositivo, siempre, aunque estemos en el contexto del dispositivo).

Hay cuatro tipos de endpoints: de control (estos son especiales y pueden ser bidireccionales), bulk, interrupt e isócronos:

Control: Endpoints usados para configurar el dispositivo.
Bulk: Endpoints para transferencias “estándar” de datos. No tienen latencia máxima garantizada.
Interrupt: Endpoints destinados a envíos asíncronos. Tienen una latencia máxima fija garantizada.
Isócronos: Endpoints para transferencias con alto ancho de banda, con detección de errores pero sin reintento de envío de paquetes. No se garantiza la entrega de los paquetes.

Cada dispositivo USB tiene una serie de tablas (normalmente alojadas en flash o en ROM) denominadas descriptores, que son transferidas al host en el arranque (mediante un endpoint de control) y que identifican de forma precisa y estándar la funcionalidad y los endpoints disponibles (tipo, dirección, etc.) en el dispositivo.



Cada dispositivo tiene un descriptor de dispositivo, dentro de cada descriptor de dispositivo podrá haber diferentes configuraciones (descriptores de configuración), a su vez dentro de cada configuración habrá descriptores de interfaces y dentro de éstos habrá descriptores de endpoints. Lo más habitual es que cada dispositivo sólo tenga un único descriptor de configuración. Hay un descriptor de endpoint por cada endpoint en uso por parte del dispositivo. No existe descriptor para el endpoint 0 ya que es un endpoint que siempre debe estar disponible y sólo puede ser de control.

Inicialización

La secuencia de inicialización es como sigue:

1. Cuando el host detecta un dispositivo conectado a un puerto USB realiza una secuencia de reset sobre dicho puerto.

2. A continuación, el host envía un paquete SETUP a través del endpoint de control 0 con el comando GET_DESCRIPTOR(device)

3. El dispositivo, como respuesta, devuelve su descriptor de dispositivo.

4. El host solicita a través del endpoint de control 0 los descriptores de cada una de las configuraciones con el comando GET_DESCRIPTOR(configuration). Normalmente cada dispositivo sólo tiene una única configuración.

5. El dispositivo envía como respuesta también a través del endpoint de control 0 los descriptores de configuración (normalmente uno). Cuando un dispositivo envía el descriptor de configuración, envía dentro de él los descriptores de interface y de endpoints asociados a dicha configuración (ver imagen anterior).

6. Cuando el host (el ordenador) ya “sabe quién es” el dispositivo lo “enumera”, esto es, le asigna una dirección en el bus USB y envía al dispositivo el comando SET_ADDRESS(dirección).

7. El dispositivo queda enumerado y pasa a escuchar en la dirección indicada por el host.

8. El host envía al dispositivo el comando SET_CONFIGURATION(configuracion) para decirle al dispositivo que quiere trabajar con una configuración en concreto (como vimos antes, normalmente sólo hay una configuración por cada dispositivo).

A partir de este momento el dispositivo queda listo para usar y para que se empiecen a usar el resto de sus endpoints.

El protocolo es mucho más complejo y es necesario tener en cuenta algunos comandos más (SET_FEATURE, GET_STATUS, etc.) pero a grosso modo así es como funciona el invento.

Recomiendo a todo aquel interesado en el tema, dos lecturas de referencia:
- USB in a Nutshell
- USB Made Simple
Son muy sencillas de leer y mucho más asequibles que el aterrador tocho de 650 páginas de la especificación oficial.

Implementación en el ATmega32u4

Este microcontrolador, incluido en el Arduino Leonardo, puede ser configurado como dispositivo USB. Debido a que los tiempos USB son extremadamente cortos, es necesario realizar una implementación orientada a interrupciones. Como prueba de concepto he decidido implementar un puerto serie virtual (al estilo de los cables conversores USB a RS232) basado en dos endpoints de tipo bulk, uno de entrada y otro de salida.

El pseudocódigo para implementar el dispositivo USB quedaría así:
interrupción de reset de bus USB
configurar endpoints 0 como control, 1 como bulk in y 2 como bulk out
inicializar buffers
habilitar interrupción de endpoints
fin interrupción

interrupción de endpoints
si ha llegado un paquete de setup por el endpoint 0 entonces
si es de tipo GET_DESCRIPTOR entonces
devolver el descriptor correspondiente
en otro caso, si es de tipo SET_ADDRESS entonces
configurar el módulo USB del chip para usar la dirección indicada
en otro caso, si es de tipo GET_STATUS entonces
devolver 0 (todo ok)
en otro caso, si es de tipo GET_CONFIGURATION entonces
devolver un 1 (la configuración activa es siempre la 1)
en otro caso, si es de tipo SET_CONFIGURATION entonces
no hacer nada (sólo hay una configuración y es la que siempre está activa)
fin si
en otro caso, si ha llegado un token bulk out por el endpoint 2 entonces
transferir bytes del buffer del endpoint 2 al buffer de usuario
en otro caso, si ha llegado un token bulk in por el endpoint 1 entonces
transferir bytes del buffer de usuario al buffer del endpoint 1
fin si
fin interrupción
Los descriptores se encuentran definidos en USBDescriptor.H:
class USBSerialDescriptorContainer {
    public:
        USBDeviceDescriptor deviceDescriptor;
        USBConfigurationDescriptor configurationDescriptor;
        USBInterfaceDescriptor interfaceDescriptor;
        USBEndpointDescriptor inEndpointDescriptor;
        USBEndpointDescriptor outEndpointDescriptor;
} __attribute__ ((packed));

const USBSerialDescriptorContainer MyUSBSerialDescriptorContainer = {
    {  // device descriptor
        0x12,    // descriptor size
        0x01,    // descriptor type (device)
        0x0100,  // usb protocol version
        0x00,
        0x00,
        0x00,
        0x40,    // bMaxPacketSize0 = 64  (for endpoint 0)
        0xA4F6,  // idVendor
        0x5678,  // idProduct
        0x0100,  // product version
        0x00,
        0x00,
        0x00,
        0x01     // one available configuration
    },
    {  // configuration descriptor
        0x09,    // descriptor size
        0x02,    // descriptor type (configuration)
        0x0020,  // total size of this descriptor and rest of descriptors inside this configuration 9 + 9 + 7 + 7 = 32 bytes
        0x01,    // num interfaces = 1
        0x01,    // this configuration number = 1
        0x00,
        0x80,    // not self powered
        0x04     // max power in units of 5 mA (4 * 5 = 20 mA)
    },
    {  // interface descriptor
        0x09,    // descriptor size
        0x04,    // descriptor type (interface)
        0x00,    // interface number (zero based)
        0x00,
        0x02,    // num endpoints = 2
        0xFF,    // class = vendor specific
        0x00,    // subclass
        0x00,
        0x00
    },
    {  // in endpoint descriptor
        0x07,    // descriptor size
        0x05,    // descriptor type (endpoint)
        0x81,    // in endpoint 1
        0x02,    // bulk endpoint
        0x0040,  // max packet size = 64
        0x0A     // 10ms for interval polling
    },
    {  // out endpoint descriptor
        0x07,    // descriptor size
        0x05,    // descriptor type (endpoint)
        0x02,    // out endpoint 2
        0x02,    // bulk endpoint
        0x0040,  // max packet size = 64
        0x0A     // 10ms for interval polling
    }
};

Para hacer la prueba de concepto lo he implementado todo, por ahora, en una única clase “USB” a modo de utility class (con atributos y métodos estáticos):
ISR(USB_GEN_vect) {
    USB::__general_isr();
}

void USB::__general_isr() {
    if (UDINT & (1 << EORSTI))
        USB::configureEndpoint0();
    UDINT = 0;
}

ISR(USB_COM_vect) {
    USB::__endpoint_isr();
}

void USB::sendDataToEndpoint0() {
    while (USB::toSendSize > 0) {
        // wait for host ready for in packet
        while (!(UEINTX & ((1 << TXINI) | (1 << RXOUTI))))
            ;
        if (UEINTX & (1 << RXOUTI))
            break;
        // send in packet
        uint16_t n = (USB::toSendSize >= 64) ? 64 : USB::toSendSize;
        USB::writeFromBuffer(n, USB::toSend);
        USB::toSend += n;
        USB::toSendSize -= n;
        UEINTX = ~(1 << TXINI);
    }
}

void USB::__endpoint_isr() {
    if (USB::status == USB::STATUS_IDLE) {
        UENUM = 0;
        uint8_t aux = UEINTX;
        if (aux & (1 << RXSTPI)) {
            // setup packet received
            USB::readOnBuffer(8, USB::buffer);
            USB::setupPacketReceived = (USBSetupPacket *) USB::buffer;
            UEINTX = ~((1 << RXSTPI) | (1 << RXOUTI) | (1 << TXINI));
            if (USB::setupPacketReceived->bRequest == USBSetupPacket::REQUEST_GET_DESCRIPTOR) {
                uint8_t descriptorType = USB::setupPacketReceived->wValue >> 8;
                USB::toSendSize = 0;
                if (descriptorType == 1) {
                    // get the device descriptor
                    USB::toSend = (uint8_t *) &MyUSBSerialDescriptorContainer.deviceDescriptor;
                    USB::toSendSize = sizeof(USBDeviceDescriptor);
                    USB::toSendSize = (USB::toSendSize > USB::setupPacketReceived->wLength) ? USB::setupPacketReceived->wLength : USB::toSendSize;
                }
                else if (descriptorType == 2) {
                    // get the configuration descriptor
                    USB::toSend = (uint8_t *) &MyUSBSerialDescriptorContainer.configurationDescriptor;
                    USB::toSendSize = sizeof(USBSerialDescriptorContainer) - sizeof(USBDeviceDescriptor);
                    USB::toSendSize = (USB::toSendSize > USB::setupPacketReceived->wLength) ? USB::setupPacketReceived->wLength : USB::toSendSize;
                }
                USB::sendDataToEndpoint0();
            }
            else if (USB::setupPacketReceived->bRequest == USBSetupPacket::REQUEST_SET_ADDRESS) {
                UEINTX = ~(1 << TXINI);
                USB::address = USB::setupPacketReceived->wValue;
                UDADDR = USB::address;
                while (!(UEINTX & (1 << TXINI)))
                    ;
                UDADDR |= (1 << ADDEN);
                UEINTX = ~(1 << TXINI);
            }
            else if (USB::setupPacketReceived->bRequest == USBSetupPacket::REQUEST_GET_STATUS) {
                USB::buffer[0] = 0;
                USB::buffer[1] = 0;
                USB::toSend = (uint8_t *) USB::buffer;
                USB::toSendSize = 2;
                USB::sendDataToEndpoint0();
            }
            else if (USB::setupPacketReceived->bRequest == USBSetupPacket::REQUEST_GET_CONFIGURATION) {
                USB::buffer[0] = 1;
                USB::toSend = (uint8_t *) USB::buffer;
                USB::toSendSize = 1;
                USB::sendDataToEndpoint0();
            }
            else if (USB::setupPacketReceived->bRequest == USBSetupPacket::REQUEST_SET_CONFIGURATION) {
                UEINTX = ~(1 << TXINI);
            }
        }
        // check for bulk out transfer
        UENUM = 2;
        if (UEINTX & (1 << RXOUTI)) {
            UEINTX &= ~(1 << RXOUTI);
            uint16_t numBytesReceived = (((uint16_t) UEBCHX) << 8) | ((uint16_t) UEBCLX);
            numBytesReceived = (numBytesReceived > 64) ? 64 : numBytesReceived;
            USB::readOnBuffer(numBytesReceived, USB::rxBuffer);
            USB::rxBufferFull = true;
            UEINTX &= ~(1 << FIFOCON);
        }
        // check for bulk in transfer
        UENUM = 1;
        if (UEINTX & (1 << TXINI)) {
            UEINTX &= ~(1 << TXINI);
            // TODO: write data to buffer
            UEINTX &= ~(1 << FIFOCON);
        }
    }
    else if (USB::status == USB::STATUS_HALT) {
        UENUM = 0;
        UEINTX = ~((1 << RXSTPI) | (1 << RXOUTI) | (1 << TXINI));
        UECONX = (1 << STALLRQ) | (1 << EPEN);   // stall
    }
}

Una vez conectado el Arduino Leonardo y enumerado el dispositivo podemos, desde un ordenador con Linux, reconocer el nuevo dispositivo como interface serie:

1. Instalamos el driver usbserial:
modprobe usbserial vendor=0xa4f6 product=0x5678
Esto instala el driver genérico usbserial y lo asocia a nuestro dispositivo creando el dispositivo /dev/ttyUSB0 en el sistema de archivos.

2. Emitimos una cadena de caracteres de ejemplo para hacer la prueba:
echo -en "Prueba desde Linux\n\0" > /dev/ttyUSB0
3. Voilà:





El código es compatible también con el microcontrolador ATmega16u4 y es muy fácil de adaptar al resto de microcontroladores USB de AVR. Puede descargarse de la sección soft.

[ 2 comentarios ] ( 6539 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 2895 )
Instalación y configuración del compilador C++ de GNU para la placa de desarrollo Teensy3 
Teensy3 es una pequeña placa de desarrollo que se integra perfectamente en el entorno de desarrollo de Arduino y que incluye un potente procesador ARM Cortex-M4 a 96 MHz con 256Kb de Flash, 64Kb de RAM, DAC, ADC, CAN y otros periféricos. A lo largo de este artículo se explicará paso a paso cómo construir y configurar desde cero la toolchain de GNU para programar el Teensy3 en C++.



Compilar la toolchain de GNU

En mi caso he utilizado una placa Teensy 3.1. El corazón de esta placa es el microcontrolador de 32 bits MK20DX256VLH7, un ARM Cortex-M4 a 96MHz. Lo primero que hay que hacer es compilar la toolchain en sí para el target arm-none-eabi. Uno de los mejores tutoriales de compilación dde la toolchain de GNU para ARM es el publicado aquí. En mi caso he utilizado las binutils 2.24, el gcc 4.9.0 y la última versión de newlib disponible por CVS.

El tutorial anterior utiliza una versión de gcc antigua y no echa en falta ninguna librería, sin embargo, si queremos compilar el gcc 4.9.0 hemos de incluir las librerías GMP, MPFR y MPC. En este enlace se indica cómo incluir las librerías en el código fuente del gcc 4.9.0 para que se compilen junto con el gcc.

Una vez compilada la toolchain tendremos el compilador de C++ y el resto de herramientas (ensamblador, enlazador, etc.) disponibles en la carpeta de prefijo de instalación que hayamos utilizado. En mi caso he usado /opt/teensy.

El microcontrolador MK20DX256

Este microcontrolador está basado en un núcleo ARM Cortex-M4. La secuencia de arranque de estos procesadores se describe aquí. De forma resumida, en la direción de memoria 0x00000000 se encuentra el valor que se le asigna al puntero de pila en el arranque, mientras que en la dirección 0x00000004 se encuentra la dirección de memoria donde se ejecuta la primera instrucción, hay más vectores, aunque estos dos son los más importantes. Como se puede ver, no se trata de instrucciones, sino de una tabla de punteros.

Los 256Kb de Flash comienzan a partir de la dirección 0x00000000 mientras que los 64Kb de RAM van desde 0x1FFF8000 hasta 0x20008000. La pila del Cortex-M4 puede configurarse de cualquier forma (ascendente, descendente, etc.). Sin embargo las instrucciones “push” y “pop” por defecto trabajan con una pila descendente (mas información aquí). De esta forma nuestro mapa de memoria queda como sigue:

offset      tamaño        uso
0x00000000 4 bytes puntero de pila en el arranque
0x00000004 4 bytes puntero a la primera instrucción tras el reset
0x00000400 16 bytes palabras de configuración del microcontrolador
0x00000410 261104 bytes código
0x1FFF8000 32768 bytes variables globales
0x20000000 32768 bytes pila y variables locales


Al ser una pila descendente, el puntero de pila se inicializa con 0x20007FFC, que es la última palabra de 32 bits direccionable en la RAM. Con este mapa de memoria elaboramos el siguiente linker script:

SECTIONS {
	. = 0x00000000 ;
	.cortex_m4_vectors : {
		LONG(0x20007FFC);
		LONG(0x00000411);
	}
	. = 0x00000400 ;
	.flash_configuration : {
		LONG(0xFFFFFFFF);
		LONG(0xFFFFFFFF);
		LONG(0xFFFFFFFF);
		LONG(0xFFFFFFFE);
	}
	.text : {
		_linker_code = . ;
		init.o (.text)
		*(.text)
		*(.text.*)
		*(.rodata*)
		*(.gnu.linkonce.t*)
		*(.gnu.linkonce.r*)
	}
	.preinit_array : {
		__preinit_array_start = . ;
		*(.preinit_array)
		__preinit_array_end = . ;
	}
	.init_array : {
		__init_array_start = . ;
		*(.init_array)
		__init_array_end = . ;
	}
	.fini_array : {
		__fini_array_start = . ;
		*(.fini_array)
		__fini_array_end = . ;
	}
	.ctors : {
		__CTOR_LIST__ = . ;
		LONG((__CTOR_END__ - __CTOR_LIST__) / 4 - 2)
		*(.ctors)
		LONG(0)
		__CTOR_END__ = . ;
	}
	.dtors : {
		__DTOR_LIST__ = . ;
		LONG((__DTOR_END__ - __DTOR_LIST__) / 4 - 2)
		*(.dtors)
		LONG(0)
		__DTOR_END__ = . ;
	}
	flash_sdata = . ;
	. = 0x1FFF8000 ;
	ram_sdata = . ;
	.data : AT (flash_sdata) {
		_linker_data = . ;
		*(.data)
		*(.data.*)
		*(.gnu.linkonce.d*)
	}
	ram_edata = . ;
	data_size = ram_edata - ram_sdata;
	ram_sbssdata = . ;
	.bss : AT (LOADADDR(.data) + SIZEOF(.data)) {
		_linker_bss = . ;
		*(.bss)
		*(.bss.*)
		*(.gnu.linkonce.b.*)
		*(.COMMON)
	}
	ram_ebssdata = . ;
	bssdata_size = ram_ebssdata - ram_sbssdata;
	_linker_end = . ;
}


El linker script tiene las secciones habituales: .text, .init_array, .fini_array, .ctors, .dtors, .data, .bss, etc. más dos secciones adicionales: .cortex_m4_vectors y .flash_configuration.

Dirección de carga y dirección virtual en el linker script

Cuando se escribe un linker script para un microcontrolador o para un sistema basado en memoria Flash hay que tener en cuenta que las secciones .data y .bss (datos inicializados y sin inicializar, respectivamente) se cargan inicialmente en Flash y que, en el momento de la ejecución, dichas secciones deben copiarse a RAM. El linker script permite definir direcciones de carga y direcciones virtuales para cada sección de forma independiente con lo que es posible hacer de forma sencilla esta funcionalidad:

flash_sdata = . ;
. = 0x1FFF8000 ;
ram_sdata = . ;
.data : AT (flash_sdata) {
*(.data)
}
ram_edata = . ;
data_size = ram_edata - ram_sdata ;


Antes de definir la sección .data guardamos en la variable “flash_sdata” la dirección virtual actual, definimos la dirección virtual actual como 0x1FFF8000 (inicio de la parte de la RAM que hemos decidido que aloje las variables globales) y guardamos en “ram_sdata” esta dirección virtual. A continuación definimos la sección .data con el atributo “AT (flash_sdata)” para indicarle al linker que queremos que se cargue en la dirección “flash_sdata”, que es una dirección dentro de la memoria Flash, aunque la dirección virtual será 0x1FFF8000. Tras la definición de la sección .data generamos símbolos para calcular el tamaño de la sección. Con la sección .bss (datos globales sin inicializar) hacemos lo mismo.

Código de inicialización

El linker script nos ha permitido definir las secciones .data y .bss con direcciones virtuales y de carga diferentes, sin embargo la copia de los datos habrá que hacerla a mano antes de la ejecución de la función “main”.

Este fichero contiene el código de inicialización en ensamblador que vamos a utilizar.

	.syntax unified
	.thumb    
	.thumb_func
	.global _startup
	.section .text

_init:
    mov     r0,#0
    mov     r1,#0
    mov     r2,#0
    mov     r3,#0
    mov     r4,#0
    mov     r5,#0
    mov     r6,#0
    mov     r7,#0
    mov     r8,#0
    mov     r9,#0
    mov     r10,#0
    mov     r11,#0
    mov     r12,#0

/* disable interrupts */
    CPSID i

/* unlock_watchdog */
    ldr r6, = 0x4005200e /* WDOG_UNLOCK doc: K20P64M50SF0RM.pdf ( Page: 423 ) */
    ldr r0, = 0xc520
    strh r0, [r6]
    ldr r0, = 0xd928
    strh r0, [r6]

/* disable_watchdog */
    ldr r6, = 0x40052000 /* WDOG_STCTRLH doc: K20P64M50SF0RM.pdf ( Page: 418 ) */
    ldr r0, = 0x01d2
    strh r0, [r6]

/* enable interrupts */
    CPSIE i

	/* copy data section from flash to ram */
	ldr r0, = flash_sdata
	ldr r1, = ram_sdata
	ldr r2, = data_size
	cmp r2, #0
	beq skip_copy
copy:
	ldrb r4, [r0], #1
	strb r4, [r1], #1
	subs r2, r2, #1
	bne copy
skip_copy:

	/* fill bssdata section with zeros */
	ldr r1, = ram_sbssdata
	ldr r2, = bssdata_size
	cmp r2, #0
	beq skip_fill
	mov r4, #0
fill:
	strb r4, [r1], #1
	subs r2, r2, #1
	bne fill
skip_fill:

	b _startup

    .end


Como se puede ver: Inicializamos los registros del procesador, deshabilitamos las interrupciones, desactivamos el watchdog del MK20 (hay que hacerlo dentro de los primeros 256 ciclos del procesador tras el reset), copiamos los datos de la sección .data desde la Flash a la RAM y rellenamos con ceros la sección .bss. Cuando terminamos de rellenar con ceros la sección .bss saltamos a la función “_startup”.

La función "_startup" la definimos en un fichero de C++:

#include <stdint.h>

using namespace std;

extern "C" {
	extern void (*__CTOR_LIST__)();
	extern void (*__DTOR_LIST__)();
	extern void (*__init_array_start)();
	extern void (*__init_array_end)();
	extern void (*__fini_array_start)();
	extern void (*__fini_array_end)();
}

void callInitArray() {
	void (**f)() = &__init_array_start;
	while (f != &__init_array_end) {
		(*f)();
		f++;
	}
}

void callConstructors() {
	void (**constructor)() = &__CTOR_LIST__;
	uint32_t total = *(uint32_t *) constructor;
	constructor++;
	while (total) {
		(*constructor)();
		total--;
		constructor++;
	}
}

void callDestructors() {
	void (**destructor)() = &__DTOR_LIST__;
	uint32_t total = *(uint32_t *) destructor;
	destructor++;
	while (total) {
		(*destructor)();
		total--;
		destructor++;
	}
}

void callFiniArray() {
	void (**f)() = &__fini_array_start;
	while (f != &__fini_array_end) {
		(*f)();
		f++;
	}
}

extern int main();

void _startup() asm("_startup");

void _startup() {
	callConstructors();
	callInitArray();
	main();
	callFiniArray();
	callDestructors();
	while (true)
		;
}

extern "C" void __cxa_pure_virtual() {}
void *__dso_handle = 0;
extern "C" void __cxa_atexit() {}


La función “_startup” se encarga de invocar a los constructores globales y a la función “main”. El fichero Makefile que se utiliza es el siguiente:

BIN_PREFIX=/opt/teensy/bin/
CXX=${BIN_PREFIX}arm-none-eabi-g++
OBJCOPY=${BIN_PREFIX}arm-none-eabi-objcopy
CXX_FLAGS=-mtune=cortex-m4 -mthumb -fno-exceptions -fno-rtti -nostdlib -nodefaultlibs -nostartfiles

main.hex: main.elf
	$(OBJCOPY) -O ihex -R .eeprom main.elf main.hex

main.elf: main.o init.o startup.o
	$(CXX) $(CXX_FLAGS) -Wl,-Tteensy31.ld -o main.elf main.o init.o startup.o

main.o: main.cc
	$(CXX) $(CXX_FLAGS) -c -o $@ $<

startup.o: startup.cc
	$(CXX) $(CXX_FLAGS) -c -o $@ $<

init.o: init.s
	$(CXX) $(CXX_FLAGS) -c -o $@ $<

clean:
	rm -f *.elf *.o *.hex *.bin


Prueba de concepto

En este caso simplemente he implementado la típica funcionalidad de led parpadeante (para variar xD)

#include <stdint.h>

using namespace std;

#define  GPIO_ENABLE      (1 << 8)
#define  PULL_UP_ENABLE   (1 << 1)
#define  PULL_UP_SELECT   (1 << 0)
#define  DRIVE_STR        (1 << 6)
#define  PORT_CTRL_FLAGS  (DRIVE_STR | GPIO_ENABLE | PULL_UP_ENABLE | PULL_UP_SELECT)

#define  PORTC_PCR5    *((uint32_t *) 0x4004B014)
#define  GPIOC_PDDR    *((uint32_t *) 0x400FF094)
#define  GPIOC_PDOR    *((uint32_t *) 0x400FF080)
#define  SIM_SCGC5     *((uint32_t *) 0x40048038)
#define  WDOG_UNLOCK   *((uint32_t *) 0x4005200E)
#define  WDOG_STCTRLH  *((uint32_t *) 0x40052000)

#define  CLOCKS_ACTIVE_TO_ALL_GPIO  0x00043F82
#define  WATCHDOG_UNLOCK_VALUE_1    0xC520      
#define  WATCHDOG_UNLOCK_VALUE_2    0xD928      
#define  WATCHDOG_DISABLE_VALUE     0x01D2      

class Clase {
	public:
		uint32_t n;
		uint32_t v1, v2;
		Clase(uint32_t x1, uint32_t x2, uint32_t aux);
};

Clase::Clase(uint32_t x1, uint32_t x2, uint32_t aux) {
	this->v1 = x1;
	this->v2 = x2;
	this->n = aux;
}

Clase objeto(1, 2, 100000);
uint32_t a = 50000;
//uint32_t a = 100000;

int main() {
	// configure PTC5
	SIM_SCGC5 = CLOCKS_ACTIVE_TO_ALL_GPIO;
	PORTC_PCR5 = PORT_CTRL_FLAGS;
	// PORTC is output
	GPIOC_PDDR = (uint32_t) 0xFFFFFFFF;
	while (1) {
		volatile uint32_t i;
		// PORTC to 1
		GPIOC_PDOR = (uint32_t) 0xFFFFFFFF;
		for (i = 0; i < objeto.n; i++)
			;
		// PORTC to 0
		GPIOC_PDOR = (uint32_t) 0x00000000;
		for (i = 0; i < objeto.n; i++)
			;
	}
}


El código fuente puede descargarse de la sección soft.


ACTUALIZACIÓN: En la sección "Compilar la toolchain de GNU" se incluye un enlace a una web que ya no se encuentra disponible (http://kunen.org/uC/gnu_tool.html). En dicha web se indicaban los pasos pormenorizados para compilar toda la toolchain de GNU. Afortunadamente he podido rescatar gracias al histórico de mi ordenador los pasos que seguí yo guiándome por esa web y los he recompilado es este post.

ACTUALIZACIÓN: La versión anterior no rellenaba con ceros la sección .bss de la RAM de datos globales. Se ha incluido la corrección en la descripción del texto, en el código mostrado en el post y en el fichero tar.gz de la sección soft.

[ añadir comentario ] ( 1880 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 1826 )
Vúmetro LCD 
La posibilidad de redefinir una parte del juego de caracteres en los displays LCD alfanuméricos, en combinación con el uso de una de las entradas analógicas del AVR y un pequeño circuito analógico, nos va a permitir la implementación de un sencillo vúmetro en el Arduino.

Un vúmetro no es más que un medidor gráfico de intensidad de señal de audio. En este caso vamos a abordar una implementación muy sencilla y lineal (no logarítmica).



Entrada analógica

La entrada analógica del microcontrolador AVR en el Arduino permite medir entre 0 y 5 voltios con una precisión de 10 bits, de 0 a 1023. 0 se corresponde con 0 voltios y 1023 se corresponde con 5 voltios. Para obtener la intensidad de señal en una de las entradas analógicas, realizamos los siguientes pasos:

1. Amplificación
2. Detección de envolvente

La amplificación se ha implementado construyendo una sencilla etapa estándar de amplificación basada en un transistor NPN con configuración de emisor común:



La señal de entrada es una señal de audio en alterna (en este caso la he conectado a la salida de auriculares de mi móvil). Dicha señal, al pasar por el circuito de amplificación se invierte en fase (no nos importa) y, lo más importante, se amplifica. La señal de salida es también en alterna (el condensador de salida elimina la componente de continua) y pasa la siguiente parte del circuito: el detector de envolvente.



Un detector de envolvente es un circuito que, a partir de una señal de entrada, determina su envolvente:



(imagen extraida de Wikimedia Commons)

El circuito detector de envolvente es muy sencillo: La señal alterna, al hacerla pasar por un diodo, se rectifica y sólo deja pasar los semiciclos positivos. En cada uno de los semiciclos positivos de la señal se carga el condensador y, durante las pausas entre semiciclos, en ausencia de corriente que pase por el diodo, el condensador se descarga lentamente a través de la resistencia, así hasta el siguiente ciclo, que se repite el proceso. El resultado en la salida es una señal que “sigue” de forma aproximada a los picos de la señal de audio que hay en la entrada.

Hay que elegir correctamente los valores del condensador y la resistencia: Un valor de condensador muy bajo hará que se descargue rápidamente mientras que un valor de condensador muy alto hará que tarde excesivamente en cargarse. En el caso de la resistencia de descarga del condensador un valor muy alto hará que el condensador apenas se descargue en las pausas entre ciclos (lo que puede provocar que los ciclos se “sumen” a medida que llegan) y un valor muy bajo hará que el condensador se descargue muy rápido, perdiendo el efecto de seguimiento de envolvente.

Display LCD

En post anteriores de este blog publiqué varias clases C++ para la gestión de displays LCD. En este caso he reutilizado la clase “Lcd20x4” de proyectos anteriores, redefiniendo de forma estática 5 de los caracteres definibles por el usuario.

Para implementar una barra horizontal hay que tener en cuenta que tenemos 20 columnas y que cada carácter tiene 5 columnas de puntos: En total tenemos 100 pixels en horizontal para un display LCD de 20x4. Si definimos los caracteres de la siguiente forma:
* . . . .
* . . . .
* . . . .
* . . . . --> 1
* . . . .
* . . . .
* . . . .

* * . . .
* * . . .
* * . . .
* * . . . --> 2
* * . . .
* * . . .
* * . . .

...

* * * * *
* * * * *
* * * * *
* * * * * --> 5
* * * * *
* * * * *
* * * * *

Utilizando este método de visualización, la barra LCD podrá adoptar valores entre 0 y 100. El pseudocódigo para visualizar un valor “v” será:

procedimiento mostrar_valor(v)     // 0 <= v <= 100
numCaracteresTotalmenteLlenos := parte entera de (v / 5)
valorCaracterParcial := (v mod 5)
ir_coordenada(0, 0)
para x := 1 hasta numCaracteresTotalmenteLlenos hacer
escribir_carácter(5)
fin para
escribir_carácter(valorCaracterParcial)
para x := (numCaracteresTotalmenteLlenos + 2) hasta 20 hacer
escribir_carácter(0)
fin para
fin procedimiento

Como se puede ver, para cada valor “v” que se quiera visualizar en la barra, se escribe toda una fila horizontal del display LCD. La visualización puede optimizarse si partimos de la base de que la diferencia entre un valor “v” enviado en un instante “t” y el valor “v” enviado al display en un instante “t + d” no va a variar mucho si “d” es lo suficientemente pequeño. En nuestro caso vamos a hacer un muestreo de la entrada analógica cada 50ms por lo que la “v” no va a variar excesivamente entre un instante de muestreo y el siguiente.

Si asumimos que la “v” no va a variar mucho podemos enviar al LCD sólo los cambios:

barraLogica[20]
barraFisica[20]

procedimiento mostrar_valor(v)
numCaracteresTotalmenteLlenos := parte entera de (v / 5)
valorCaracterParcial := (v mod 5)
j := 0
para x := 1 hasta numCaracteresTotalmenteLlenos hacer
barraLogica[j] := 5
j := j + 1
fin para
barraLogica[j] := valorCaracterParcial
j := j + 1
para x := (numCaracteresTotalmenteLlenos + 2) hasta 20 hacer
barraLogica[j] := 0
j := j + 1
fin para
fin procedimiento

procedimiento actualiza
dentroCambio := NO
inicioCambio := -1
para j := 0 hasta 19 hacer
si (dentroCambio) entonces
si (barraFisica[j] = barraLogica[j]) entonces
escribir_barra_fisica(inicioCambio, j)
dentroCambio := NO
fin si
en otro caso
si (barraFisica[j] <> barraLogica[j]) entonces
inicioCambio := j
dentroCambio := SI
fin si
fin si
barraFisica[j] := barraLogica[j]
fin para
si (dentroCambio) entonces
escribir_barra_fisica(inicioCambio, 19)
fin si
fin procedimiento

El procedimiento “actualiza” se ejecutará de forma periódica y, como se puede ver, sólo envía al display LCD los caracteres que cambien. Esta forma de refresco del display LCD es más eficiente y nos permite tasas de muestreo mayores.

En el siguiente vídeo puede verse el circuito en acción:



El código en C++ puede descargarse de la sección soft.

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

<< <Anterior | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Siguiente> >>