Documentos de Académico
Documentos de Profesional
Documentos de Cultura
Nuestro controlador (digital) eventualmente tendrá que interactuar con el mundo exterior
(analógico). Al margen de usar dispositivos externos (convertidor digital analógico o DAC)
muchos microcontroladores cuentan con un módulo PWM (Pulse Width Modulation) que puede
usarse (con ciertas limitaciones) para mandar "ordenes" analógicas.
En este tutorial describiremos en que consiste la modulación por ancho de pulsos (PWM),
veremos las rutinas disponibles en C18 y, como hacemos habitualmente, describiremos los
registros asociados y su funcionalidad.
Aprovecharemos los conocimientos adquiridos para escribir una rutina para inicializar el
módulo PWM especificando la frecuencia deseada y escribiremos un pequeño programa para
usar los dos módulos PWM de un PIC para crear transiciones de colores en un LED bicolor.
En una entrada posterior aplicaremos lo que hemos aprendido a una aplicación más
interesante usando el módulo PWM como conversor DAC para un archivo de audio.
--------------------------------------------------------------------------------------------
En algunos de los tutoriales anteriores usábamos un truco para estimar la ocupación del PIC.
Consistía en poner a 1 un cierto pin mientras estábamos haciendo una cierta tarea. Luego,
nos bastaba con medir el voltaje medio (con un voltímetro) en dicho pin. Dicho voltaje
(dividido por los 5V de alimentación) nos daba el % del tiempo que el pin estaba alto.
La modulación PWM consiste precisamente en eso. El módulo PWM del micro genera una onda
cuadrada con una frecuencia dada (típicamente bastante alta, por ejemplo 10 KHz). Luego
nosotros podemos ir cambiando el ciclo de trabajo (% del periodo en ON) de la señal:
Si por el contrario aplicamos un filtro paso-bajo a la señal PWM, los cambios rápidos (10 KHz)
de la señal se eliminarán y simplemente veremos los cambios lentos del ciclo de trabajo,
observando un voltaje "medio" entre 0 y 5V dependiendo del ciclo de trabajo (0% -> 100%)
programado.
El valor máximo del divisor previo PRE es 16 y el de (PR2+1) es 256. Por lo tanto la
frecuencia PWM más baja posible será Fosc/16384. Para un oscilador de 20 MHz tenemos una
Fpwm mínima de 1.22 KHz (20000/16384).
Notad que el módulo PWM usa el timer TMR2, por lo que éste no podrá usarse como
temporizador de propósito general mientras se esté usando PWM. Si que es posible usarlo (y
ahorrarnos gastar otro timer) si queremos hacer saltar una interrupción cada cierto tiempo. El
postscaler del TMR2 no tiene efecto sobre la frecuencia PWM, pero si influye sobre cuando
salta (si está habilitada) la correspondiente interrupción (TMR2_flag). Si por ejemplo el post-
scaler es 1:16 entonces la interrupción del TMR2 saltará cada 16 periodos del PWM.
Lo primero que tenemos que hacer para usar el módulo PWM es habilitarlo indicando que va a
usarse como generador de una onda PWM, ya que dicho módulo es compartido con otras
funciones (Capture/Compare). La forma de hacerlo es poner a 11XX los 4 bits menos
significativos del registro CCP1CON. Los PIC18 suelen tener 2 módulos PWM por lo que
existe un segundo registro CCP2CON.
Podemos habilitar uno o los dos módulos independientemente. Sin embargo, como ambos
usan el registro PR2 y el timer TMR0 como base de tiempos, la frecuencia programada será la
misma en ambos módulos.
Lo que si es posible variar por separado es el ciclo de trabajo (duty cicle o DC) de cada
módulo. El ciclo de trabajo se codifica con un número de hasta 10 bits (0-1023) almacenado
de la siguiente forma:
Con los 10 bits dados el ciclo de trabajo se podrá especificar en principio con 1024 niveles (0
corresponde a 0% y 1023 al 100%). Sin embargo, los valores válidos pueden ser menores
que los 1024 posibles.
La razón es que el valor de DC (duty_cicle) determina el tiempo que la señal se mantiene alta
(ON) de la forma:
Comparando ambas fórmulas y siendo obvio que el tiempo total ON no puede exceder el
tiempo total del periodo tenemos que el valor máximo de DC es (PR2+1) x 4. Por lo tanto,
aunque podemos dar a DC cualquier valor entre 0 y 1023 está claro que en realidad debemos
limitarnos al rango [ 0, (PR2+1)x4 ]. Valores más altos van a hacer que Ton > Tpwm, o lo
que es lo mismo, la señal PWM se mantiene alta todo el rato (100%).
Frecuencias por debajo de Fosc/16384 no son posibles porque los valores de PR2 y PRE están
ya en su máximo posible.
Frecuencias por encima de Fosc/1024 son posibles pero a costa de bajar PR2 y por lo tanto
disponer de menor resolución para el ciclo de trabajo. Por ejemplo si aceptamos trabajar con
8 bits (valores de DC de 0 a 255) podemos llegar a una frecuencia de:
F_pwm = F_osc / [PRE x 4 x (PR2+1)] = Fosc/(1 x 256) = 78 KHz
La razón por la que para frecuencias muy altas no podemos especificar con tanta precisión el
ciclo es que el periodo empieza a hacerse muy pequeño. En el caso anterior (Fpwm =
Fosc/256) es obvio que en un ciclo del PWM sólo entran 256 ciclos del oscilador. Como es
imposible que el micro haga algo entre ciclos de reloj, está claro que sólo puede bajar la línea
del PWM en 256 puntos como mucho (esto es, con una resolución de 8 bits).
Valores de frecuencias intermedios son también posibles, pero de nuevo van a exigir
PR2<255. Por ejemplo para conseguir 10 KHz con un oscilador de 20 MHz
Fosc / Fpwm =20000/10 = 2000 = 4 x PRE x (PR2+1) -> PRE x (PR2+1) = 500
lo que puedo conseguir con PRE=2 y PR2 = 249. Pero entonces el valor del ciclo de trabajo
(DC) tiene que moverse en el rango 0 a 4(PR2+1) = 1000. Con DC=1000 ya alcanzamos un
100% del ciclo de trabajo y valores superiores no tendrán ningún efecto extra.
En cuanto al pin de salida al que se manda la señal PWM, usualmente la salida PWM1 va al
pin RC2 y la del PWM2 al RC1 (notad el cambio 1-2). En algunos dispositivos (consultar
datasheet) es posible cambiar la salida de PWM2 a otro pin mediante un bit de configuración.
Las rutinas básicas del compilador C18 para manejar los módulos PWM son las siguientes
(las declaraciones se encuentran en pwm.h):
Notad que las rutinas anteriores no tocan el timer TMR2. Es responsabilidad del usuario
invocar a la rutina OpenTimer2 para fijar el valor del divisor o pre-scaler y arrancar el
temporizador.
void main()
{
uint16 DC_max, dd=0;
int8 inc=1;
DC_max=1023;
while(1)
{
SetDCPWM1(dd); SetDCPWM2(DC_max-dd); // Set complementary DC in PWM1 and PWM2
dd+=inc; // Increase duty cicle
if ((dd==DC_max) || (dd==0)) inc=-inc; // If we get to DC_max or 0 reverse direction.
Delay10KTCYx(5);
}
}
Vemos que hemos usado PR2=255 y pre-scaler = 1. Esto nos da una frecuencia PWM de :
El siguiente video es una captura de pantalla del osciloscopio monitorizando PWM1 y PWM2.
Se observa que el periodo del PWM (unos 51 usec, correspondientes a 19.5 KHz) no cambia y
es común a ambos canales. El tiempo en ON de PWM1 (arriba) es justo el tiempo OFF del
PWM2 (abajo) ya que los hemos programado para ser complementarios: dd y (DC_max-dd)
Los saltos que se observan en las transiciones del duty_cicle son debidas a la frecuencia
(baja) con la que se refrescan en pantalla los datos del osciloscopio. En el osciloscopio se ve
una variación gradual, como corresponde a un incremente de 1 en 1 en el ciclo de trabajo.
Vamos a cambiar la frecuencia haciendo OpenPWM1(199);OpenPWM2(199); lo que
corresponde a (PR2+1)=200 y a una frecuencia de 20000/(4x200) = 25 KHz. En el siguiente
video vemos la captura del osciloscopio:
La frecuencia son justo los 25 KHz esperados. Sin embargo, se aprecia que algo va mal.
Ahora ambos canales no son complementarios. El canal PWM1 llega al 100% y permanece
allí, no empezando a bajar hasta después de un rato. El comportamiento de PWM1 y PWM2
no parece ser el que hemos programado. La evolución del ciclo de trabajo antes describía la
función de la izquierda (como correspondía a nuestro programa), y ahora parece describir la
de la derecha.
La razón es que al haber bajado PR2 hemos subido la frecuencia (correcto) pero sin darnos
cuenta también hemos alterado el rango de valores posible para DC. El valor máximo para el
que alcanzamos e 100% es de 4x(200) = 800. Al llegar dd a 800, la señal PWM alcanza un
100% y se mantiene mientras dd sigue subiendo hasta 1023 y vuelve a bajar. Sólo cuando
volvemos a entrar en el rango [0 800] volvemos a notar variación.
En resumen, recordar que si PR2 no es 255 el valor máximo de DC no será 1024 sino
DC_max = 4 x (PR2+1).
Ahora que entendemos como funciona el módulo PWM y conocemos los registros
involucrados, vamos a escribir nuestras propias rutinas de manejo del PWM. En primer lugar
las rutinas para ajustar el duty cicle:
void set_pwm1(uint16 duty)
{
CCP1CONbits.DC1B0=(duty& 0x01); duty>>=1; //Least Significant bit
CCP1CONbits.DC1B1=(duty& 0x01); duty>>=1; // 2nd Least Significant bit
CCPR1L=(duty); // 8 Most Significant bits
}
void set_pwm2(uint16 duty)
{
CCP2CONbits.DC2B0=(duty& 0x01); duty>>=1;
CCP2CONbits.DC2B1=(duty& 0x01); duty>>=1;
CCPR2L=(duty);
}
Vemos que sólo es cuestión de poner los 2 bits menos significativos del argumento en los bits
DC1b0 y DC1b1 de CCP1CON y los 8 más significativos en CCPR1L. Lo mismo para los
registros CCP2CON y CCPR2L para PWM2.
Obviamente, estas rutinas no aportan nada sobre las suministradas por C18, sólo nos
permiten confirmar que lo que se está haciendo no es nada complicado.
Veamos una rutina con algo de "valor añadido". Era un poco incomodo tener que acordarnos
de configurar y arrancar TMR2 por separado. Vamos a escribir una rutina que combine la
configuración del módulo y el arranque del timer TMR2. Además, en vez de aportar como
argumentos los valores de PR2 y del prescaler de TMR2 vamos a especificar la frecuencia
Fpwm deseada (en KHz) y dejar que la rutina calcule y configure los registros adecuados.
Como siempre está rutina puede ser combinada con las del C18. Podemos usar esta rutina
para inicializar el módulo y luego usar las rutinas de C18 para fijar el ciclo de trabajo. El
código es el siguiente:
La rutina recibe la frecuencia del oscilador y la frecuencia PWM deseada (ambas en KHz,
uint16 para Fosc y uint8 para Fpwm) y el canal PWM que deseamos inicializar (1 para PWM1,
2 para PWM2 o 3 para ambos). Todo esto pensando en PICs con dos módulos PWM, aunque
sería fácilmente modificable para otros casos. El tener una sola función para inicializar todos
los canales tiene sentido porque los parámetros calculados (PR2, pre-scaler) son comunes
entre canales.
La función calcula el valor de PR2 y PRE que consiguen la frecuencia pedida y los usa para el
registro PR2 y para configurar el divisor previo del TMR2. También arranca el timer por lo que
no es necesaria una llamada adicional.
Si la frecuencia requerida es demasiado baja se fija la frecuencia permitida más baja posible.
Como es posible que en PR2 resulte un valor < 255, la función devuelve el valor de DC que
corresponde a un 100% del ciclo ON.
void main()
{
uint16 DC_max,dd=0;
int8 inc=1;
DC_max=1023; // Nominal Value for DC max
while(1)
{
set_pwm1(dd); set_pwm2(DC_max-dd);
dd+=inc; // Increase duty cicle
if ((dd==DC_max) || (dd==0)) inc=-inc;// Reverse direction.
Delay10KTCYx(5);
}
}
Vemos como para DC_max no usamos el valor nominal 1023 sino el que nos devuelve la
función setup_PWM.
La aplicación más sencilla que podemos ver del uso de PWM es modular la luminosidad de un
LED. Al contrario que con una lámpara incandescente no podemos atenuar un LED bajando su
voltaje ya que al ser esencialmente un diodo, pasará de no conducir (OFF) a conducir (ON)
con una muy pequeña variación de voltaje. Lo que podemos hacer con PWM es encenderlo y
apagarlo muy rápidamente (a la frecuencia del PWM). El tiempo ON del ciclo (duty)
determinará la luminosidad aparente del LED (en este caso el elemento integrador o paso
bajo es nuestro ojo, que es incapaz de apreciar como el LED se enciende y se apaga).
En el ejemplo siguiente usamos la salida de PWM1 y PWM2 para modular el color e intensidad
de un Led bicolor RG. Usaremos RC1 y RC2 conectados al positivo de los leds R y G y
pondremos el negativo común a tierra:
El diferente valor de las resistencias usadas (800K, 100K) es para compensar la mayor
eficiencia del LED rojo e intentar que la luminosidad de ambos LED estén equilibradas.
El código (pwm2.c) es muy similar al anterior, pero ahora los valores del ciclo de trabajo los
sacamos de un par de tablas (básicamente una oscilación sinusoidal dando más preferencia a
los niveles cerca del 0). El tamaño de ambas tablas corresponde a dos números primos y se
ha elegido así para que se de una mayor combinación de colores, antes de que empiecen a
repetirse las combinaciones. También se han definido un par de macros que incrementan los
respectivos punteros p1 y p2 a las tablas, haciéndolos voltear al llegar al final.
Como ambas tablas son de sólo lectura una posibilidad sería colocarlas en la memoria de
programa (usando el calificador const rom) para no gastar memoria de datos.
#define N1 61
#define N2 59
uint16 duty1[N1]={
121, 153, 192, 239, 294, 357, 428, 507, 590, 676, 761, 840, 909, 964,1001,1018,
1012, 985, 939, 876, 801, 719, 633, 548, 467, 392, 324, 265, 215, 172, 137, 108,
84, 65, 50, 38, 29, 22, 16, 11, 8, 5, 3, 2, 1, 0, 0, 0,
1, 2, 4, 6, 9, 13, 18, 25, 33, 44, 57, 74, 95};
uint16 duty2[N2]={
121, 155, 195, 244, 302, 369, 444, 526, 613, 702, 789, 867, 934, 983,1012,1018,
1000, 961, 902, 829, 746, 658, 569, 484, 405, 334, 272, 219, 174, 137, 107, 83,
64, 49, 37, 27, 20, 14, 10, 7, 4, 2, 1, 0, 0, 0, 1, 2,
3, 5, 8, 12, 17, 24, 32, 42, 56, 73, 95};
uint8 p1=0;
uint8 p2=0;
void main()
{
setup_PWM(20000,5,3);
while(1)
{
set_pwm1(duty1[p1]); inc_p1;
set_pwm2(duty2[p2]); inc_p2;
Delay10KTCYx(5);
}
}
En la siguiente película podemos ver el resultado. A la derecha, los LEDs de la placa EasyPic6
correspondientes a RC1 y RC2 se van encendiendo y apagando. A la izquierda el LED Red-
Green conectado a ambas salidas va cambiando de color.
El problema del video es que los sensores de las cámaras digitales son muy sensibles al rojo
(de hecho más al infrarrojo), saturandose el canal rojo y no apreciandose correctamente los
detalles de las transiciones de color.
Veremos un repaso de los bits y registros de control asociados a las diferentes interrupciones,
como habilitarlas y como escribir rutinas de servicio (ISR). Crearemos definiciones
(#define) que nos permitirán operar con las interrupciones sin tener que recordar los
bits/registros asociados, a la vez que facilitarán la tarea de portar nuestro programa a otro
compilador y/o microcontrolador.
Es importante familiarizarse con el manejo de interrupciones, ya que nos evita poder manejar
muchos tipos de eventos sin estar pendientes de ello. En sucesivos tutoriales veremos como
el uso de interrupciones nos permite aprovechar de forma mucho más eficiente los recursos
del PIC.
--------------------------------------------------------------------------------------------------------
----
Habilitación de interrupciones:
Antes de entrar en detalles sobre cada interrupción por separado hemos de describir un par
de bits (bits 7 y 6 del SFR INTCON) que tienen un efecto global sobre la activación de bloques
de interrupciones.
Por ejemplo, antes de poder usar la interrupción del temporizador TMR0 debemos
asegurarnos de que las interrupciones globales estén habilitadas (INTCON.GIE=1). Si lo que
deseamos es usar la interrupción asociada a la recepción del puerto serie, tanto INTCON.GIE
como INTCON.PEIE deben estar a 1, ya que dicha interrupción está declarada como
periférica.
Para usar estos bits de una forma más conveniente incluiríamos los siguientes defines (en el
programa principal o bien en un fichero .h incluido en el proyecto):
Además de los bits anteriores que afectan de forma global a las interrupciones, para cada
fuente de interrupción hay tres bits asociados:
IE (interrupt enable): determina si la interrupción está o no habilitada. Si no lo está,
aunque la condición de la interrupción se cumpla, la interrupción no se producirá.
IF (interrupt flan): indica si la condición de la interrupción se ha producido. Es
responsabilidad del usuario borrar dicho bit antes de regresar de la ISR.
IP (interrupt priority): indica si la prioridad asociada a la interrupción es alta (1) o
baja (0). Obviamente, solo tiene efecto si está activado el modo de niveles de prioridad.
Consideremos por ejemplo la interrupción asociada la temporizador TMR0. Un temporizador
es simplemente un contador que se incrementa con cada ciclo máquina (4 ciclos del
oscilador). Dicho contador puede configurarse como de 8 o 16 bits. Cuando dicho contador
rebosa y pasa de 0xFF a 0x00 (modo 8 bits) o de 0xFFFF a 0x0000 (modo 16 bits) la
bandera IF asociada a la interrupción del TMR0 se pone a 1. Para activar o desactivar la
interrupción, establecer su prioridad o acceder al valor de su bandera de interrupción
definiríamos los siguiente macros:
#define enable_TMR0_int INTCONbits.TMR0IE=1
#define disable_TMR0_int INTCONbits.TMR0IE=0
#define TMR0_flag INTCONbits.TMR0IF
#define set_TMR0_high INTCON2bits.TMR0IP=1
#define set_TMR0_low INTCON2bits.TMR0IP=0
Las dos primeras líneas activan o desactivan la interrupción a través del correspondiente bit
de IE. La tercera define el flag (IF) asociado al temporizador como TMR0_flag. Finalmente las
dos últimas establecen la interrupción del TMR0 como de alta o baja prioridad, modificando el
correspondiente bit IP.
Algunas interrupciones pueden tener algunos bits extras dedicados. Por ejemplo, en la
interrupción INT0 (asociada a detectar cambios en el pin RB0) podemos especificar si la
interrupción salta al pasar de nivel alto a bajo o viceversa.
Como se observa en los ejemplos anteriores todos los bits que hemos visto hasta ahora están
en los registros especiales INTCON e INTCON2. Al ir añadiendo más fuentes de interrupción
se han tenido que crear nuevos registros para interrupciones de periféricos (PIEx,PIRx),
lugares donde almacenar los bits de prioridades (IPRx), etc.
Podemos repetir las definiciones anteriores para el resto de las interrupciones. Cada
interrupción tendría una serie de definiciones similares a las listadas anteriormente para
TMR0. Por ejemplo, para la interrupción INT1 tenemos definidas:
#define enable_INT1_int INTCON3bits.INT1IE=1
#define disable_INT1_int INTCON3bits.INT1IE=0
#define INT1_low2high INTCON2bits.INTEDG1=1
#define INT1_high2low INTCON2bits.INTEDG1=0
#define INT1_flag INTCON3bits.INT1IF
#define set_INT1_high INTCON3bits.INT1IP=1
#define set_INT1_low INTCON3bits.INT1IP=0
De esta forma tendríamos una forma sencilla de activar una u otra interrupción, consultar sus
banderas y establecer su prioridad sin tener que recordar la posición de los diferentes bits en
los registros. Lo usual es guardar dichas definiciones en un fichero que incluiríamos en
nuestros proyectos. El fichero que usare en sucesivos programas es int_defs_C18.h.
Para el resto de las interrupciones tenemos similares definiciones, sin más que cambiar TMR0
o INT1 por el código de la interrupción en cuestión. Entre las interrupciones que más
usaremos podemos destacar:
INTx: La interrupción se produce con un cambio en el nivel de los pines RB0, RB1 y RB2
respectivamente. Es posible establecer si la interrupción se produce en el flanco ascendente o
descendente (ver INT1_low2high e INT1_high2low en los ejemplos citados antes).
RB: se produce ante cualquier cambio en los pines RB4 a RB7. Al contrario que las anteriores
no es posible especificar un pin o una transición determinada.
TX, interrupción de transmisión que nos avisa cuando el buffer de transmisión está libre para
mandar un nuevo carácter.
Conversor Analógico/Digital (AD)
Como hemos visto, para que una interrupción se produzca, los siguientes bits deben estar a
1:
Es responsabilidad del usuario activar los tres primeros bits con los correspondiente
comandos enable que hemos definido. Por ejemplo para activar la interrupción del timer 0,
TMR0, haríamos:
Con los tres primeros bits en 1, cuando se cumpla una condición de interrupción el
microcontrolador pondrá a 1 el correspondiente bit IF y la interrupción se producirá. El
microcontrolador pasará el control a la posición 0x0008.
Cuando esto suceda es fundamental que en dicha posición tengamos un código válido para
gestionar la interrupción. A dicho código se le denomina rutina de servicio de la interrupción
(Interrupt Service Routine o ISR).
Una vez determinada que interrupción en particular ha sucedido, ejecutar el código que
sirve a dicha interrupción.
Hemos dicho que ante una interrupción el PIC saltará a una dirección determinada. ¿Cómo
podemos poner el código de la ISR en la posición de memoria adecuada? Eso va a depender
del compilador. Usualmente los compiladores nos facilitan dicha tarea teniendo una rutina de
nombre predefinido para las interrupciones. Si se define una rutina con dicho nombre el
compilador la pondrá en la posición adecuada al compilar por lo que se ejecutará
Compilador C18:
#pragma interrupt high_ISR
void high_ISR (void)
{
if (TMR0_flag) // ISR de la interrupcion de TMR0
{
PORTCbits.RC0=1; Delay10KTCYx(255); PORTCbits.RC0=0;
TMR0_flag=0;
}
}
Se ve que C18 deja ver lo que realmente está pasando. En la posición 0x0008 no podríamos
meter una rutina muy grande (al fin y al cabo en 0x0018 podríamos tener otra rutina, la de la
interrupción de bajo nivel). Lo único que metemos es un salto a la verdadera rutina de
interrupción high_ISR().
MikroC Pro:
En el caso del compilador MikroC Pro, hay una rutina de nombre reservado, interrupt( ).
Veamos como escribiríamos el código anterior para MikroC Pro.
Vemos que el código es más sencillo. Basta declarar la función (reservada) interrupt y el
compilador se ocupa de todo. Por debajo el compilador MikroC Pro hará lo mismo que el C18
(escribir el código en algún sitio y poner un salto en 0x0008 a esa dirección), pero la ventaja
(o inconveniente según se mire) es que hace que el usuario se despreocupe de los detalles.
Lo único que tenemos que hacer en el main() es configurar de forma adecuada TMR0 para
que salte en el tiempo deseado. Para ello damos cierto valor al registro T0CON (veremos
detalles de cómo se hace en la entrada dedicada a los temporizadores). Tras programar el
temporizador lo único que queda es habilitar las interrupciones globales y la interrupción del
TMR0 en particular:
void main() {
Vemos que en el bucle principal no hacemos nada. Sin embargo, al ejecutar el programa
veremos como el pin RC0 parpadea: cada 1.6 segundos entra la interrupción en la que RC0
se pone en 1 (LED encendido) y permanece así durante 600 msec, apagándose hasta el
próximo ciclo.
Podéis comprobar que si comentáis cualquiera de las dos líneas enable el LED en RC0
permanece apagado, ya que la interrupción no se ejecuta al no estar habilitada (o al no estar
habilitadas la interrupciones de forma global).
Como siempre empezaremos con una introducción sobre los fundamentos de un conversor AD
y los registros usados para su manejo en los PIC. Tras entender el proceso, presentaremos
las rutinas de C18 para el manejo del ADC y escribiremos nuestras propias funciones, que nos
permitirán optimizar el rendimiento del ADC y no depender de un compilador en particular.
En la siguiente entrada veremos como al igual que pasaba con el puerto serie, usando
interrupciones podemos optimizar el rendimiento del PIC mientras estamos usando el
conversor AD.
------------------------------------------------------------------------------------------
Un ADC convierte mide el voltaje V en un pin (que tendrá que estar declarado como
entrada con el correspondiente registro TRISA) y lo convierte en un número. El voltaje se
mide en referencia a un voltaje mínimo, Vref(-) , y a un voltaje máximo, Vref (+):
Nivel Voltaje
0 <r
1 [r,2r]
2 [2r,3r]
… …
1023 >1023r
Aunque un PIC puede tener del orden de 8-12 posibles canales (pines) de entrada
analógica, solo tiene normalmente un único módulo ADC, lo que significa que no podemos
tomar medidas simultáneas de varios canales. Si es necesario, lo que podemos hacer es ir
conectando (seleccionando) los sucesivos canales al ADC para ir midiendo sus voltajes.
El proceso de una conversión ADC se divide en un tiempo de adquisición Ta (durante
el cual un condensador interno se carga al voltaje exterior) y un tiempo de conversión
Tc (durante el cual se desconecta el pin exterior y se cuantifica el voltaje del
condensador).
El tiempo de adquisición Ta depende de las características eléctricas del PIC (en
particular de la capacidad del condensador). Si no se respeta este tiempo, el condensador
no habrá alcanzado el nivel del voltaje exterior y la medida será incorrecta. Los datasheet
de los PIC indican los Ta recomendados para diversas familias.
Otro aspecto importante de la adquisición es recordar que Microchip recomienda
una impedancia máxima para el sensor (o lo que sea) suministrador de voltaje que está
conectado a nuestro pin. En la mayoría de los casos se recomienda que dicha impedancia
no supere los 2.5 Kohmios. Según entiendo con mis escasos conocimientos de
electrónica, impedancias mucho mayores harían que (debido a corrientes de pérdida
siempre presentes) el condensador no llegara nunca a cargarse al voltaje de entrada o
tardase mucho en hacerlo, lo que daría lugar a que la medida posterior fuese incorrecta.
Si la impedancia de nuestra fuente es muy superior a la recomendada deberíamos
plantearnos insertar un driver.
El tiempo de conversión Tc depende fundamentalmente del número de bits del
conversor. La unidad básica es el llamado Tad, aproximadamente el tiempo necesario para
ganar un bit adicional. La conversión total tarda entre 11 y 14 Tad (contando con la
descarga final del condensador para estar listo para otra medida. El reloj del ADC se debe
ajustar (como una fracción del oscilador principal) para que dicho Tad no sea inferior a un
valor mínimo especificado en los datasheet. Por ejemplo para la familia PIC18F252/452 el
Tad mínimo es de 1.6 usec y Tc = 14 Tad. En cambio para la familia PIC18F2520/4520
tenemos un Tad mínimo de 0.75 usec y un Tc = 11 Tad.
Un comentario final: aunque es muy conveniente tener un ADC integrado dentro del
microcontrolador, si lo pensamos en términos de nivel de ruido, etc. las cercanías de un
microcontrolador con la líneas de reloj, rápidos cambios en las líneas digitales,
comunicaciones, etc. no es el mejor sitio para ubicar un ADC. Esto quiere decir que si
estamos muy interesados en la precisión, tal vez sería conveniente considerar un ADC aparte,
que nos comunicaría los datos adquiridos a través de SPI o similares. Una vez dicha la
advertencia nos centraremos en como usar de la forma más eficiente el ADC de un
microcontrolador (en este caso un PIC).
Los registros de control del ADC en un PIC (familia 18F 2520/4520)
Para estos ejemplos voy a usar código para la familia PIC18F2520/4520 con algunos
comentarios sobre la familia 252/452. El conversor AD es una de los módulos de un PIC
donde hay más diferencias entre modelos: distintos número de canales AD, diferentes formas
de programar que canales son digitales o analógicos, la posibilidad o no de programar
tiempos de adquisición, etc.
Esto hace que la configuración del ADC sea bastante dependiente del modelo y siempre sea
bueno tener a mano la documentación ante comportamientos inesperados. Por ejemplo, los
registros asociados al puerto serie son los mismos entre una amplia gama de dispositivos. Por
el contrario, es fácil que el número de registros de configuración cambie entre modelos.
Además de los registros de control tenemos otros dos registros ADRESH y ADRESL (AD result
High/Low) donde se guarda el resultado de la conversión, pero estos si son comunes entre
dispositivos.
Veamos un resumen de estos registros (de nuevo, los datasheet de Microchip son los mejores
manuales de usuario):
CHS: selecciona cual de los posibles 13 canales analógicos se conecta al ADC para medir.
Registro ADCON1: dedicado a seleccionar cuales de los posibles canales se van a usar como
entradas analógicas y a programar el uso o no de voltajes de referencia externos (distintos de
los valores 0 y 5V por defecto)
VCFG1: por defecto (0) usamos Vss=Gnd=0V como Vref-. Si es 1 se tomara como Vref- el
voltaje presente en AN2.
VCFG0: Lo mismo que el anterior pero para Vref+. Por defecto usaremos Vdd (alimentación,
típicamente 5 o 3.3V) como Vref+. Si es 1 se usa el voltaje presente en AN3 como Vref+.
PCFG: Posiblemente no necesitaremos todos los posibles canales analógicos. Estos bits
permiten decidir cuales de los pines se dedican a canales analógicos y cuales permanecen
como canales digitales. Los valores posibles van desde 0000 (todos analógicos) a 1111 (todos
digitales).
Los canales analógicos se denotan en la documentación como AN0, AN1, AN2, etc. Los
dispositivos con 8 canales (252/452) los reparten entre el puerto A y el E. Los que tienen 13
canales usan también parte del puerto B (2520/4520). Mirar la documentación, porque el
orden no es consecutivo. Por ejemplo, para el 4520
ADFM: Formateo del resultado. El conversor da 10 bits que se guardan en dos registros
(ADRESH:ADRESL) de 8 bits, por lo que 6 de los 16 bits estarán vacíos. Si ADFM=1 el
resultado está desplazado a la derecha, por lo que los 6 bits más significativos de ADRESH
estarán vacíos. Si es 0 se justifica a la izquierda y son los 6 bits menos significativos de
ADRESL los que están vacíos. El caso ADFM=1 se suele usar cuando nos interesan los 10
bits. El resultado se puede pasar a un entero de 16 bits como:
res = (ADRESH<<8)+ADRESL;
El caso ADFM=0 se suele usar si sólo queremos los 8 bits más significativos. En ese caso
basta hacer: res = ADRESH;
ACQT: bits para la programación del T de adquisición. Recordar que el tiempo de adquisición
es el que debemos esperar después de seleccionar (conectar) un canal al ADC mientras se
carga el condensador cuyo voltaje posteriormente mediremos. Si escogemos 000 el usuario
es responsable de la espera (de unos pocos usec, especificada en la documentación) entre la
selección del canal (poner los bits CHS adecuados en ADCON0) y el inicio del proceso de
conversión (poner a 1 el bit GO de ADCON0).
El modo manual (000) era la norma entre familias anteriores, que de hecho no contaban con
estos bits de configuración. En la familia 2520/4520 es posible programar dicho tiempo de
espera. De esta forma tras seleccionar el canal podemos inmediatamente lanzar la conversión
(ADCON0.GO=1) y el módulo esperara el tiempo programado antes de iniciar la conversión.
Esto permite optimizar el aprovechamiento del ADC como veremos.
El tiempo de adquisición es la suma de varios factores, siendo los principales la capacidad del
condensador a cargar y el tiempo que tarda el amplificador del ADC en estabilizarse. Es
importante consultar el datasheet si queremos optimizar el muestreo porque podemos
encontrarnos bastantes diferencias entre diversos modelos.
ADCS: seleccionan la velocidad del reloj del ADC como una fracción (div = 2, 4, 8, 16, 32,
64) del reloj principal (aunque también es posible asociarlo a un oscilador RC independiente).
El inverso de la frecuencia del ADC determina el llamado Tad, que viene a ser el tiempo
dedicado a obtener cada bit del resultado:
Obviamente deberíamos escoger un divisor bajo para obtener la frecuencia más alta (Tad
más pequeño) posible para tardar lo menos posible en cada conversión. Sin embargo hay una
limitación: el Tad no puede ser menor que un cierto tiempo mínimo dependiente del modelo.
Por ejemplo para la familia 252/452 se recomienda que Tad sea como mínimo 1.6 usec. En
cambio para la 2520/4520 podemos bajar a 0.75 usec.
1:32 si tenemos un 452, ya que Tad = div / Fosc = 32/20 Mhz = 1.6 usec = recomendado
1:16 si tenemos un 4520 ya que Tad = div / Fosc = 16/20 MHz = 0.8 usec > 0.75 usec
recomendados
Las rutinas básicas para operar en C18 con el ADC pueden verse en este código ejemplo del
manual de C18:
res = ADCRead(0); que configura, selecciona canal 0, espera, lanza conversión y cuando
termina devuelve res.
Los parámetros que recibe OpenADC son esencialmente los valores de ADCON0, ADCON1 y
ADCON3. Como siempre la ventaja es que damos valores a dichos registros combinando
diversas banderas más fáciles de recordar. Para aquellos dispositivos con sólo disponen de
ADCON0 y ADCON1 la función sólo usa dos parámetros en vez de tres. Con SetChanADC
seleccionamos (ADC_CH0) AN0=RA0 como el pin cuyo voltaje vamos a medir.
Normalmente la configuración del ADC se hace una sola vez y luego se hacen sucesivas
conversiones (posiblemente de diferentes canales). Podemos agrupar el código anterior en
una función que reciba como argumento el canal a muestrear y haga todas las etapas
(aunque como veremos más adelante, en ciertas circunstancias es más eficiente "romper" la
función para no tener una función bloqueante):
Una llamada al código anterior mide el voltaje del canal deseado una sola vez. Si el voltaje en
el canal analógico está cambiando rápidamente, querremos muestrearlo muchas veces por
segundo para seguir sus cambios. Supongamos que queremos tomar 2000 muestras por
segundo. A esto es a lo que se suele denominar una frecuencia de muestreo de 2000 Hz. Con
este ritmo, deberemos lanzar tomar una muestra cada 1/2000 seg = 0.2 msec = 200
usec/muestra.
El código quedaría:
Obviamente esto funcionará cuando el periodo sea lo suficientemente rápido para que el
voltímetro responda con el valor medio del voltaje a lo largo del periodo.
Aunque 15000 muestras por segundo pueden ser más que adecuadas, vamos a ver que es
posible mejorar dichos resultados, aprovechando lo que hemos visto de la programación del
ADC en base a registros de control y el estudio de las especificaciones detalladas del micro
que estamos usando.
Si repetimos el bucle anterior con esta función vemos que el tiempo baja a unos 45 usec, una
ganancia del 15%.
Podemos mejorar un poco más si nos ajustamos a las especificaciones del PIC usado
(18F2520/4520). La documentación indica que para esta familia basta un tiempo de
adquisición (entre seleccionar canal y lanzar conversión) de unos 3 usec, lo que a 20 MHz
equivale a unos 15 ciclos . Claramente los 50 ciclos (10 usec) de antes son excesivos. Nos
bastaría con especificar un retrado de p.e. 20 ciclos:
En este caso preprogramamos un delay de 4 x Tad. Como Tad = 16/20 = 0.8 usec eso
equivale a 3.2 usec, que es superior al tiempo de adquisición mínimo recomendado para
estos PIC. La función de conversión queda ahora:
Como vemos no tenemos que preocuparnos del delay, ya que el módulo lo respetará. Con
este nuevo código la duración de la llamada a la función es de unos 24 usec. Las ganancias se
pueden detectar con la bajada del voltaje del pin RC0 o más gráficamente, monitorizando
RC0 con el osciloscopio. En la figura se adjuntan las capturas de la duración de la función
para el original y las tres modificaciones propuestas (de arriba abajo y de izquierda a
derecha). Vemos que hemos conseguido reducir a la mitad el tiempo, pudiendo llegar hasta
unas 40000 muestras/segundo (24 usec por conversion).
En otra entrada veremos como combinar el uso del ADC con interrupciones. En primer lugar
veremos como usar una interrupción de un temporizador para lanzar la conversión AD a
intervalos exactos. En segundo lugar veremos como usar la interrupción del propio módulo
ADC para evitar que la función anterior sea bloqueante. El uso de la interrupción AD nos
permite no tener que esperar (línea while(ADCON0bits.GO)) hasta que el módulo termine:
la interrupción nos avisará cuando el resultado este listo en ADRESH:ADRESL.
Antes de explorar esta combinación de ADC + INTS, vamos a ver un pequeño proyecto
(brújula electrónica ) en el que usaremos lo que ya sabemos del ADC. Consiste en leer el
voltaje de dos sensores magnéticos (en dos ejes perpendiculares) y combinar su información
para obtener una desviación en grados respecto al norte magnético
En entradas posteriores veremos ejemplos de comunicaciones SPI con otros periféricos con
protocolos de comunicación más complicados.
---------------------------------------------------------------------------------------------
Es importante entender los conceptos básicos detrás de una comunicación SPI, que son
diferentes de otros tipos de comunicaciones.
En un registro de desplazamiento, con cada clock del reloj, un nuevo bit entra en el registro y
desplaza a todos los bits una posición. El último bit sale del registro. Los registros de
desplazamiento son la base de las conversiones paralelo/serie y viceversa. Por ejemplo, en la
UART existe un TSR (Tx Shift Register) donde se carga el byte a enviar (de forma paralela) y
van saliendo sucesivamente (serie) los bits a enviar. Dicho TSR se carga como vimos con el
dato colocado en TXREG. De forma totalmente análoga en el circuito de recepción de la UART
tenemos otro registro RSR (Rx Shift register) donde van entrando sucesivamente (serie) los
bits recibidos. Al llenarse, el dato se transfiere (en paralelo) al registro buffer de recepción
(RCREG). Otro uso similar (se suele denominar SIPO, Serial In, Parallel Out) de un SR se da
en un “port expander”, donde un registro (puerto extendido) se va llenando sucesivamente
con datos recibidos de forma serie.
Vemos que en la UART tenemos sendos registros de desplazamiento para la recepción como
para la transmisión. Dichos registros son independientes entre sí. En el caso de SPI solo
tendremos un registro SSP SR (accesible a través de un buffer SSPBUF).
¿Cómo podemos recibir y transmitir con un solo registro? Consideremos ahora un registro de
desplazamiento circular, donde la salida del registro se usa como entrada del mismo:
Supongamos que nuestro registro tiene 16 bits y al ser circular, el bit que entra “empujando”
a los demás es justo el que acaba de salir por el otro lado. Ahora pensar que este registro se
parte en dos, cada uno de 8 bits, pero formando conceptualmente un único registro de 16
bits:
Lo que tenemos es justo la base de una comunicación SPI entre dos dispositivos. Cada una de
las partes del registro circular es el registro SSPSR de cada dispositivo y ambos comparten el
reloj. La idea es que si en el SSPSR1 hay un dato A y en el SSPSR2 un dato B, tras 8 ticks de
reloj, los datos A y B se habrán intercambiado entre los dispositivos. Esta es la razón por la
que al contrario que la UART sólo se dispone de un registro, sin diferenciar entre entrada y
salida. En el protocolo SPI no hay realmente transmisiones ni recepciones, solo intercambios
de datos A y B, ya que por cada dato enviado debe haber siempre uno recibido. Depende de
las circunstancias el cómo se interprete una transferencia SPI:
1. Será una transmisión si el 1er dispositivo tenía por objetivo era mandar el
dato A al 2do dispositivo, mientras que el dato B recibido era basura (pero no
puedo "evitar" recibir dicho dato).
2. Será una recepción si el dato A enviado es irrelevante y solo lo mando
para obtener a cambio el dato B (pero no hay forma de recibir nada si yo no
mando algo "a cambio").
3. Puede ser una transmisión/recepción simultánea si tanto el dato A como el
B son significativos para la comunicación. Pensar por ejemplo en un
DigitalSignalProcesor (DSP) que recibe una serie de muestras de una señal y
efectúa algún tipo de procesado sobre ella. Tras un cierto retraso, inherente al
procesado, empezará a mandar muestras de vuelta. A partir de ese momento,
por cada muestra de la señal original que mande el host recibirá una muestra
procesada en una comunicación full-duplex.
4. Finalmente, hay situaciones donde los datos intercambiados no le
interesan a ningún dispositivo. Por ejemplo, en las especificaciones del protocolo
SPI de las tarjetas SD se requiere mandar 8 clocks de reloj tras un intercambio
comando/respuesta para que la tarjeta pase a ejecutar el comando recibido. En
ese caso el microcontrolador y la tarjeta se intercambiarán un byte que a
ninguno de los dos interesa solo para que le lleguen los 8 pulsos de reloj
necesarios a la tarjeta.
La única asimetría entre ambos dispositivos es que uno de ellos debe generar los pulsos de
reloj que hacen “avanzar” el registro de desplazamiento. Dicho dispositivo (a la izquierda en
la gráfica anterior) es el master y será quien controle la transmisión.
La línea de SDO (master) a SDI (slave) también se suele etiquetar MOSI (Master Out Slave
In). Igualmente la línea que conecta SDO (slave) con SDI (master) es denominada MISO
(Master In Slave Out):
Además de las dos líneas de datos (MOSI y MISO) y el reloj (SCK), en la figura anterior se
muestra una cuarta línea (CS, Chip Select, o SS, Slave Select) que se usa para indicar al
slave que se va a iniciar una comunicación. También permite la comunicación de un master
con varios slaves:
Esta es la razón por la cual SPI es el protocolo preferido por su simplicidad cuando sólo
tenemos una única conexión master-slave. Cuando hay que manejar varios esclavos se
prefiere el protocolo I2C. Este protocolo también es de tipo serie y síncrono (en la familia
PIC18, SPI e I2C comparten el mismo puerto de comunicaciones serie síncronas SSP), pero
implementa un sistema de direcciones, por lo que no es preciso añadir líneas adicionales para
los nuevos dispositivos.
Pasemos ahora a detallar como implementar el protocolo SPI al trabajar sobre un PIC
(asumimos que dispone del hardware adecuado, el puerto síncrono paralelo SSP). Como todo
periférico del PIC su configuración y manejo están controlado por una serie de registros SFR
(Special Function Registers. Para el puerto SPI dichos registros SFRs son:
Pasamos ahora a describir las opciones posibles en la configuración del puerto SPI en un PIC,
que se determinan con una serie de bits en los registros SSPCON1 y SSPSTAT.
1) Elección Slave/Master
Como se ve el primer bit (SSPCON1.SSPM3) es siempre 0 para ambos modos (esto sucede
porque al estar compartido el puerto SSP, estos cuatro bits también son usados para la
configuración del modo I2C).
En modo master los dos últimos bits (SSPCON1.SSPM1 y SSPCON.SSPM0) determinan las
cuatro posibles frecuencias del reloj. La frecuencia del reloj será una fracción (4, 16, 64) del
oscilador principal o puede asociarse al ritmo del Timer2.
Por ejemplo, con un cristal de 20 MHz podríamos tener un master con un reloj de 5MHz
(0000), 1.25MHz (0001) y 312KHz (0010). La opción del TMR2/2 (0011) nos permite
programar otras frecuencias a través del timer TMR2.
Si hemos escogido el modo slave, los bits restantes determinan si usaremos o no el pin
dedicado para SS (Slave Select). En la familia PIC18F4520, dicho pin es el RA4. Si el valor es
01 no se usará SS y RA4 podrá usarse como un pin normal. Si el valor es 00 se habilita RA4
como pin de control SS.
Si vamos a ser un dispositivo SLAVE ya no hay nada más que configurar. Lo único
recomendable es hacer SSPSTAT.SMP=0 aunque no es estrictamente necesario ya que ese es
su valor por defecto.
En cambio, si nuestro dispositivo va a actuar como MASTER debemos configurar el modo SPI
en el que vamos a trabajar.
El primero (CKP) define la polaridad de la señal de reloj (su IDDLE_LEVEL, si está a nivel alto
o bajo cuando el puerto este inactivo).
El segundo bit (CKE) especifica la fase de los datos de salida con respecto al reloj.
Por último, el tercer bit(SMP) determina el momento en que se muestrean los datos de
entrada (también referido a la señal de reloj).
El parámetro más sencillo es la polaridad del reloj (SSPCON1.CKP) que en la literatura SPI se
suele denotar como CPOL (Clock Polarity). Si es 0 indica que el reloj esta bajo mientras no se
manda nada. Si es 1 el IDDLE_STATE del reloj será un nivel alto (1).
El segundo parámetro (SSPSTAT.CKE) determina la fase de los datos de salida con respecto
al reloj. En la literatura standard nos encontramos con un parámetro totalmente equivalente
CPHA (Clock Phase), aunque su definición es inversa de CKE. Esto es, CPHA = 1-CKE.
Juntos, CPOL y CPHA determinan lo que se conoce como el modo SPI usado. Generalmente
se expresa como un par de número. Así, el modo SPI (0,1) indica que debemos hacer
CPOL=0 y CPHA=1, o traducido a la nomenclatura PIC
SSPCON1.CKP= CPOL = 0
SSPSTAT.CKE = (1-CPHA)= 0
Hemos dicho que CPHA determina el momento en el que los datos de salida están estables (y
deberían ser muestreados por el otro dispositivo), pero no hemos explicado cual es su
relación ni que significa un valor de 0 o 1.
Para entenderlo, veamos la siguiente figura (adaptada del datasheet de Microchip),
ilustrando las posibilidades del reloj y su relación con los datos de entrada/salida:
Las cuatro primeras trazas ilustran las cuatro posibilidades de reloj y la traza etiquetada
como SDO la posición de los datos de salida. Las líneas verdes indican el momento en que los
datos debería ser muestreados.
Mirando la gráfica (primeras dos trazas de reloj) podemos ver que si CKE=0 (CPHA=1) el
"centro" del bit de salida corresponde a las "segundas" transiciones del reloj. Por el contrario
si CKE=1 (CPHA=0) el centro del bit está alineado con la primera transición del reloj.
En la práctica, ¿cómo elegir estos parámetros para comunicar nuestro PIC con un cierto
dispositivo? Como siempre hay varias posibilidades:
Una vez establecidas las opciones solo queda habilitar el puerto SSP (con
SSPCON1.SSPEN=1) y establecer las correspondientes direcciones de los pines involucrados
(a través del correspondiente registro TRIS).
En la mayoría de los PICs los pines asociados a las líneas SCL, SDI, SD0 son respectivamente
RC3, RC4 y RC5.
El pin RC4 (SDI) deberá ser configurado como entrada.
Al igual que hicimos en el caso de la UART presentaremos las típicas funciones de las que
disponemos en un compilador y luego las reescribiremos usando nuestros recién adquiridos
conocimientos.
INICIALIZACION: SPI_Init_advance,
TRANSFERENCIA: SPI_read, SPI_write.
La primera inicializa y configuran el puerto SSP en modo SPI. Los parámetros que se pasan a
SPI_Init_advance son bastante descriptivos y corresponden a las opciones explicadas con
anterioridad. Por ejemplo:
INICIALIZACIÓN: OpenSPI
TRANSFERENCIA: ReadSPI, WriteSPI o de forma equivalente getcSPI, putcSPI
sync_mode:
bus_mode:
smp_phase
Reescribir por nuestra cuenta una función de inicialización es sencillo: basta poner los bits de
los registros SSPCON1 y SSPSTAT a los valores adecuados.
Las siguientes funciones pueden ser usadas para conseguir el mismo objetivo:
Remarcar que estas funciones no aportan nada que las funciones de C18 o MikroC no puedan
hacer. Las listamos para mostrar que configurar un periférico no es complicado y para
beneficio de aquellos que usen un compilador sin soporte para SPI.
Una vez inicializado el puerto con unas u otras funciones, estamos listos para mandar/recibir
datos. Tanto en C18 (ReadSPI, WriteSPI) como en MikroC (SPI_read, SPI_write) tenemos un
par de funciones que leen/escriben un byte en la línea SPI.
La función recibe un byte de datos x y lo coloca en el buffer SSPBUF. Esto provoca las
siguientes acciones:
Notad que la función se llama spi_transfer, sin especificar si es TX o RX, porque nosotros ya
sabemos que en SPI no hay transmisiones ni recepciones propiamente dichas, sólo
transferencias.
Si por una mayor legibilidad del código queremos disponer de una función de escritura y otra
de lectura podemos simplemente usar unos #define:
// SPI functions aliases
#define spi_tx(x) spi_transfer(x) // sends TX data, ignores return value.
#define spi_rx() spi_transfer(0xFF) // sends dummy data, returns RX data.
#define spi_clock() spi_transfer(0xFF) // send 8 clocks (nobody cares about the data)
Por ejemplo, la rutina de transferencia anterior (al igual que las de MikroC o C18) son
bloqueantes. El bucle while(SSPSTAT.BF==0) asegura que la función vuelve con un dato.
Pero la comunicación se interrumpe en mitad de un byte, nuestro programa se bloquearía. El
PIC cuenta con una interrupción del puerto SSP. La bandera SSPIF se levanta cuando una
recepción se ha completado y el dato está listo para ser recogido en SSPBUF. Usando
interrupciones la función anterior podría volver sin haber completado la transferencia y sería
responsabilidad de la interrupción rescatar el dato recibido.
Definición de la línea CS: estrategias:
En el apartado anterior vimos las funciones necesarias para una comunicación SPI, pero
dejamos un aspecto de lado: la definición de la línea CS a usar.
Esto es necesario puesto que al contrario del resto de los pines (SCL,SDO,SDI =
RC3,RC5,RC4) para la línea CS podemos usar cualquier pin I/O genérico que tengamos libre.
El puerto SPI no depende de donde está ni le importa. Habrá situaciones donde no se use u
otras donde tengamos varias líneas al haber más de un dispositivo. Es por eso por lo que la
declaración y manejo de dicho línea debe hacerse dentro de las rutinas del dispositivo SPI
específico. Sin embargo, como con la mayoría de los dispositivo SPI vamos a necesitar una
línea CS, este puede ser un buen lugar para explorar como hacerlo y las alternativas posibles.
La forma más sencilla (aunque como veremos no la más recomendable) es hacer un par de
#defines indicando el pin que va a ser usado como línea CS y su correspondiente bit TRIS.
Por ejemplo, para usar el pin RC1 como CS en C18 usaríamos:
// CS line
#define device_CS LATCbits.LATC1
#define device_CS_dir TRISCbits.TRISC1
#define select_device device_CS=0
#define deselect_device device_CS=1
De esta forma solo tenemos que añadir la línea device_CS_dir=0; (output) durante la inicialización del dispositivo.
Posteriormente, cada vez que queramos hacer un intercambio SPI haríamos:
select_device;
SPI_talk ...
deselect_device;
El problema de este enfoque viene si en otro montaje nos viene mejor usar un pin diferente
como línea CS. Si estamos usando nuestro propio código no hay problemas, cambiamos el
#define y recompilamos. El problema es si estamos escribiendo una librería. En ese caso el
usuario final tendría que estar buceando en nuestro código y cambiando los ficheros fuente
de la librería. Eso en el caso de que disponga de los ficheros fuente. Si solo publicamos la
librería ya compilada forzamos al usuario a usar siempre el pin RC1 cuando use un dispositivo
SPI.
Obviamente esto no es muy conveniente, pero parece ser que en C18 no hay otra
alternativa. De hecho, en el manual de librerías del C18 (pag. 80) indica que si se desea
p.e, usar un LCD con una selección de pines diferente de la asignada por defecto se deben
modificar las definiciones del correspondiente fichero xlcd.h.
Este es un caso donde el compilador de MikroC presenta una ventaja frente al C18. En MikroC
tenemos una solución más flexible: se trata de declarar dos variables
(p.e. devicename_CS y devicename_CS_dir) para informar al resto de las rutinas de que
pin estamos usando como ChipSelect (CS) y su correspondiente bit de dirección TRIS.
Esto parece muy similar a los #defines de antes. La ventaja es que si estamos escribiendo
una librería, en el fichero fuente de la librería declararíamos las variables devicename_CS,
devicename_CS_dir como externas, sin asignarles ningún valor en particular. Sería el
usuario en su programa quien diera valores a dichas variables, en función de su configuración
hardware:
De esta forma el usuario puede elegir el pin a usar para cada montaje o definir diferentes
pines para varios dispositivos, sin tener que modificar la librería original.
Terminaremos este tutorial usando las rutinas dadas en un programa muy sencillo que
establezca una simple comunicación SPI entre un PIC (master) y un periférico (slave).
En la entrada Audio con PWM vimos como era posible usar la salida PWM del PIC como un
conversor DAC de conveniencia. Si se desean mejores resultados existen por supuesto
dispositivos DAC específicos.
Lo que nos importa en este caso es que la comunicación con dicho DAC es a través de SPI.
Además se trata de
una comunicación muy sencilla, perfecta para una primera prueba. El periférico recibe un sólo
tipo de mensaje (de 2 bytes de tamaño, donde se le especifica el voltaje deseado en uno u
otro canal) y no envía ninguno.
Hardware:
Notad que estamos usando RC1 como línea CS y que no usamos el pin SDI (RC4) del PIC,
puesto que no vamos a recibir ningún dato. La salida de los canales A y B las conectamos al
osciloscopio para ver los resultados.
Software:
El programa es muy sencillo y consiste en poco más de las funciones que ya hemos visto.
Lo primero que hay que hacer es determinar el modo SPI con el que trabaja el periférico. En
su datasheet nos dicen que soporta los modos SPI (0,0) y (1,1). Del momento de muestreo
de los datos de entrada no debemos preocuparnos porque no existen.
En la docu también se nos informa de que el dispositivo acepta hasta 20 MHz de reloj en la
comunicación SPI. En este montaje estoy usando un reloj de 8 MHz, por lo que puedo usar la
frecuencia máxima (Fosc/4 = 2 MHz) y aún así estar muy por debajo de las capacidades del
dispositivo.
Una rutina que inicializase la comunicación SPI con los parámetros indicados usando las
rutinas presentadas antes sería:
void DAC_spi_init(byte clock)
{
DAC_CS_dir=0; deselect_DAC; // Configure CS line as output and set it low.
spi_master(clock); // Configure SPI as master and set clock
spi_mode(0,0,0); // SPI mode (0,0). Third arg doesnt matter
spi_enable(); // Enable SPI
}
El más significativo (Ã/B) indica el canal donde debe aparecer el voltaje (0-->A, 1--
>B).
El tercero (~GA) nos permite especificar una ganancia. Si es 0 cubrimos el rango
completo de voltaje (aprox de 0 a 4V) y si es 1 solo barremos la mitad (de 0 a 2V).
Finalmente (~SHDW) nos permite poner al dispositivo en un modo "apagado" (0)
Por lo tanto si usamos el rango completo de voltaje los valores de esos 4 bits serán 0001 si
queremos usar el canal A y 1001 si queremos mandar un voltaje al canal B
Finalmente las especificaciones también nos indican que debemos mandar primero el byte
más significativo. Dado un uin16 (2 bytes) que contenga el mensaje a mandar, el siguiente
macro lo mandaría por la línea:
Primero avisamos al DAC de que se inicia una comunicación y luego mandamos el byte más
significativo seguido del menos significativo. Finalmente devolviendo a nivel alto la línea CS
terminamos la conversación.
uint8 tabla[256]= {
0x80,0x83,0x86,0x89,0x8C,0x8F,0x92,0x95,0x98,0x9B,0x9E,0xA2,0xA5,0xA7,0xAA,0xAD,
0xB0,0xB3,0xB6,0xB9,0xBC,0xBE,0xC1,0xC4,0xC6,0xC9,0xCB,0xCE,0xD0,0xD3,0xD5,0xD7,
0xDA,0xDC,0xDE,0xE0,0xE2,0xE4,0xE6,0xE8,0xEA,0xEB,0xED,0xEE,0xF0,0xF1,0xF3,0xF4,
0xF5,0xF6,0xF8,0xF9,0xFA,0xFA,0xFB,0xFC,0xFD,0xFD,0xFE,0xFE,0xFE,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFE,0xFE,0xFE,0xFD,0xFD,0xFC,0xFB,0xFA,0xFA,0xF9,0xF8,0xF6,
0xF5,0xF4,0xF3,0xF1,0xF0,0xEE,0xED,0xEB,0xEA,0xE8,0xE6,0xE4,0xE2,0xE0,0xDE,0xDC,
0xDA,0xD7,0xD5,0xD3,0xD0,0xCE,0xCB,0xC9,0xC6,0xC4,0xC1,0xBE,0xBC,0xB9,0xB6,0xB3,
0xB0,0xAD,0xAA,0xA7,0xA5,0xA2,0x9E,0x9B,0x98,0x95,0x92,0x8F,0x8C,0x89,0x86,0x83,
0x80,0x7C,0x79,0x76,0x73,0x70,0x6D,0x6A,0x67,0x64,0x61,0x5D,0x5A,0x58,0x55,0x52,
0x4F,0x4C,0x49,0x46,0x43,0x41,0x3E,0x3B,0x39,0x36,0x34,0x31,0x2F,0x2C,0x2A,0x28,
0x25,0x23,0x21,0x1F,0x1D,0x1B,0x19,0x17,0x15,0x14,0x12,0x11,0x0F,0x0E,0x0C,0x0B,
0x0A,0x09,0x07,0x06,0x05,0x05,0x04,0x03,0x02,0x02,0x01,0x01,0x01,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x01,0x01,0x01,0x02,0x02,0x03,0x04,0x05,0x05,0x06,0x07,0x09,
0x0A,0x0B,0x0C,0x0E,0x0F,0x11,0x12,0x14,0x15,0x17,0x19,0x1B,0x1D,0x1F,0x21,0x23,
0x25,0x28,0x2A,0x2C,0x2F,0x31,0x34,0x36,0x39,0x3B,0x3E,0x41,0x43,0x46,0x49,0x4C,
0x4F,0x52,0x55,0x58,0x5A,0x5D,0x61,0x64,0x67,0x6A,0x6D,0x70,0x73,0x76,0x79,0x7C};
void main()
{
uint16 d=0;
uint16 msg;
uint16 v;
while(1)
{
msg = d + 0x1000; send_msg(msg); // Value of d to chan A
d++; d&=0x0FFF; // increment d and makes sure it remains within [0,4095]
}
}
En el siguiente tutorial pondremos a prueba nuestras rutinas SPI con un ejemplo un poco más
complicado de interfaz con un dispositivo SPI, en particular una memoria flash (M25P80 de 1
Mbyte de capacidad), donde tendremos comunicaciones en ambos sentidos.