Skip to content

Programación de microcontroladores AVR en C

Gui edited this page Sep 24, 2022 · 2 revisions

Introducción súper básica a los microcontroladores

Se puede pensar a un microcontrolador como una pequeña "computadora". Cuenta con elementos básicos para ese fin:

  • Memoria RAM
  • Memoria ROM
    • Flash: La que almacena las instrucciones y datos del programa que ejecuta
    • EEPROM: Usada para guardar otros datos de forma persistente
  • Un procesador
  • Pines metálicos que utiliza para comunicarse con el mundo exterior

El microcontrolador recorre instrucción por instrucción de su programa. De llegar a la última y quedarse sin instrucciones, simplemente vuelve a empezar por la primera. Este comportamiento decanta en la estructura típica de un programa para un microcontrolador:

flowchart TD
    A[Código de Inicialización] --> B
    B[Programa] --> B
Loading

Pines e interrupciones

Lo interesante de un microcontrolador es que puede realizar distintos tipos de tareas con distintos tipos de pines.

  • Los más "simples" son los pines digitales, pueden leer y establecer tensiones asociadas a valores lógicos 0 y 1.
  • Pines de lectura analógica pueden registrar valores de tensión en un continuo mediante un ADC
  • Pines de escritura analógica pueden generar a la salida un valor de tensión continuo mediante un DAC
  • Pines de lectura de flancos, se pueden activar cuando reciben un flanco de tensión desde el mundo exterior
  • Pines que permiten generar ondas de tensión
  • Pines de propósito general GPIO

Un mismo pin puede desempeñar diferentes funciones de acuerdo a la configuración del microcontrolador.

Los microcontroladores permiten ejecutar código cuando ciertos eventos ocurren, estos eventos pueden estar relacionados a un pin o a dispositivos internos del microcontrolador como es el caso de los temporizadores. Al evento que detiene la normal ejecucción del programa para ejecutar este código se lo conoce como "interrupción". En la jerga habital también se le llama interrupción al código que se ejecuta cuando el evento ocurre.

A modo de ejemplo, supongamos que un usuario presiona un botón en un robot y este cambia el color de sus ojos. Una posible implementación es conectar el botón a un pin que detecte cambios de valores de tensión y genere una interrupción y en la función llamada por la interrupción cambiar el color de los ojos del robot.

De esa forma el programa evita tener que controlar todo el tiempo si el usuario presionó un botón. Esta tarea puede resultar trivial en apariencia, pero lo importante del ejemplo es que se pueden delegar ciertas tareas a módulos internos del microcontrolador.

Temporizadores

En esencia un temporizador es un dispositivo que genera interrupciones en función de las cuentas de reloj. El microcontrolador está asociado a una señal de reloj que el procesador utiliza para ejecutar las instrucciones. Los módulos temporizadores además utilizan estas señales para incrementar registros. Los registros no son ni más ni menos que bytes en el procesador.

Ejemplo práctico

Los microcontroladores AVR suelen contar con al menos un temporizador de 8 bits, TIMER0. Transcurridos una cantidad de pulsos de reloj, este incrementa el registro TCNT0 en 1. La cantidad de pulsos de reloj puede ser usualmente 1, 8, 64, 256 o 1024. A esta "caja reductora" de pulsos se lo conoce como "prescaler" y esencialmente sirve para incrementar TCNT0 a un paso más lento. La interrupción típica generada por un temporizador ocurre al desbordar TCNT0, eso es pasar de 255 a 0 en este caso (al ser de 8 bits, no puede almacenar un valor más grande que 255).

¿Por qué se querría incrementar TCNT0 a un ritmo más lento?

Supongamos que el microcontrolador utiliza un cristal de 12 MHz. Eso significa que cada pulso de reloj ocurre en 0,083 µS. Sin un prescaler, TCNT0 pasaría de 0 a 255 (su máximo valor posible al ser un registro de 8 bits) en 21,25 µs. Eso significa procesar una interrupción cada 21,25 µs y el código de la interrupción de por sí debe ser muy breve para poder ejecutarse antes que la próxima interrupción que va a ocurrir 21,25 µs más tarde.

Con un prescaler de 1024, TCNT0 se incrementaría cada 1024 pulsos de reloj, es decir 85.3 µs y pasaría de 0 a 255 en 21,76 ms. La resolución temporal ahora resulta de 85,3 µs, una peor resolución al precio de poder contar hasta un tiempo mayor.

¿Qué se puede realizar con esto?

Podríamos, por ejemplo, generar una onda cuadrada de una frecuencia que querramos en un pin específico. Es cierto que existen modos de funcionamiento de los temporizadores que ya hacen este trabajo por nosotros. Pero, en principio, también podríamos hacerlo de esta forma.

#include <avr/io.h>
#include <stdint.h>
#include <avr/interrupt.h>

#define COUNT (256 - 47)  // Necesitamos esperar 47 cuentas para que pase 1 ms

ISR(TIMER0_OVF_vect) {
    PORTC ^= _BV(PC3);  // Invertimos el valor del pin PC3 (encendido -> apagado, apagado -> encendido)
    TCNT0 = COUNT;  // Reseteamos la cuenta para esperar exactamente 1 ms desde ahora
}

int main (void)
{
  DDRC |= _BV(DDC3);

  TCNT0 = COUNT;
  TCCR0A = 0x00;  // Utilizamos el temporizador de forma convencional y no un modo más avanzado
  TCCR0B = (1 << CS02) | (0 << CS01) | (0 << CS00);  // Prescaler de 256
  TIMSK0 = _BV(TOIE0);  // Generamos una interrupción cada vez que TCNT0 desborde

  sei();  // Habilitamos las interrupciones

  while(1) {
    ;  // El cuerpo principal del programa no hace nada en este caso
  }

  return 1;
}

Todo el código asume que el microcontrolador está utilizando un oscilador de 12 MHz, al utilizar temporizadores dependemos de la frecuencia a la que opera el microcontrolador. Nótese que en este caso la cuenta da 47 × 256 / 12 000 000 = 1,002 ms pero el periódo de la onda cuadrada es el doble porque pasa 1,002 ms encendida y 1,002 ms apagada. Se obtiene finalmente la onda cuadrada de 500 Hz buscada.

Ejemplo práctico 2, creando una melodía con el microcontrolador

Vamos a utilizar el microcontrolador para reproducir una melodía. Las notas pueden modelarse simplemente con señales de onda cuadrada de una determinada frecuencia, que es algo que un temporizador puede generar. Como contamos con más de un temporizador se puede delegar la medición del tiempo de cada nota a otro más.

Es perfectamente posible utilizar una interrupción de overflow como en el ejemplo pasado. Pero a modo ilustrativo vamos a generar las notas utilizando el modo PWM del temporizador. El modo PWM hace que el temporizador emita una onda cuadrada con características establecidas por nosotros en el pin OCnB (la n corresponde al temporizador, por lo que los pines son OC0B, OC1B, OC2B).

Hay varias formas de configurar el modo PWM. Nosotros vamos a utilizar TIMER1 y hacer que OC1B se apague cuando TCNT1 coincida con OCR1B, se vuelva a prender cuando TCNT1 coincida con OCR1A y TCNT1 se resetee a 0 cuando llegue a OCR1A.

Eso lo podemos lograr con el siguiente código

int main (void)
{
  DDRB |= _BV(DDB2);  // Setea OC1B como salida.

  .
  .
  .

  TCNT1 = 0;
  // WGM en 1111 configura el temporizador como Fast PWM con
  //    OCR1A como el tope superior de la cuenta
  //        0 como el tope inferior de la cuenta
  // Esto quiere decir que TCNT1 pasa de OCR1A a 0 (en lugar de hacerlo en overflow)
  // COM1B en 10 hace que el pin OC1B se apague cuando TCNT1 coincida con OCR1B
  // OC1B se vuelve a setear en el tope inferior de la cuenta, en nuestro caso 0
  // Prescaler de 1
  TCCR1A = (1 << COM1B1) | (0 << COM1B0) | (1 << WGM11) | (1 << WGM10);
  TCCR1B = (0 << CS12) | (0 << CS11) | (1 << CS10) | (1 << WGM13) | (1 << WGM12);
  TIMSK1 = 0;

  TCNT0 = 0;
  // Prescaler de 256
  // Interrupción por overflow
  // Operación normal del temporizador
  TCCR0A = 0;
  TCCR0B = (1 << CS02) | (0 << CS01) | (0 << CS00);
  TIMSK0 = _BV(TOIE0);

  sei();

  while(1) {
    ;
  }

  return 1;
}

De alguna forma tenemos que iterar entre notas, para eso vamos a usar un arreglo estático. Cada elemento del arreglo va a estar compuesto por:

  • valor para OCR1B
  • valor para OCR1A
  • tiempo de la nota

Como la nota determina el valor para OCR1B y OCR1A, podemos cubrir todo eso con un macro Es necesario también dejar un silencio entre notas, de otra forma el sonido producido sería legato. Para enfatizar esto vamos a utilizar un macro que deje un silencio muy breve y logre un sonido más parecido a un staccato.

El silencio puede ser otra nota más, en este caso OCR1A puede valer cualquier cosa, pero valores chicos siempre son deseables y OCR1B puede valer 0 para mantener al pin OC1B en 0 durante el transcurso del silencio. Las notas pueden ser definidas en un archivo de cabecera notas.h

#include "notas.h"

#define STACCATO       {SILENCE, 1},

#define SN  12
#define EN  (SN * 2)
#define QN  (EN * 2)
#define HN  (QN * 2)
#define WN  (HN < 128 ? HN * 2 : 255)

uint16_t notes[][3] = {{G4, EN}, STACCATO
                       {B4b, QN}, STACCATO
                       {C5, EN}, STACCATO
                       {D5, EN + SN}, STACCATO
                       {E5, SN}, STACCATO
                       {D5, EN}, STACCATO
                       {C5, QN}, STACCATO
                       {A4, EN}, STACCATO
                       {F4, EN + SN}, STACCATO
                       {G4, SN}, STACCATO
                       {A4, EN}, STACCATO

                       {B4b, QN}, STACCATO
                       {G4, EN}, STACCATO
                       {G4, EN + SN}, STACCATO
                       {F4s, SN}, STACCATO
                       {G4, EN}, STACCATO
                       {A4, QN}, STACCATO
                       {F4s, EN}, STACCATO
                       {D4, QN}, STACCATO

                       {G4, EN}, STACCATO
                       {B4b, QN}, STACCATO
                       {C5, EN}, STACCATO
                       {D5, EN + SN}, STACCATO
                       {E5, SN}, STACCATO
                       {D5, EN}, STACCATO
                       {C5, QN}, STACCATO
                       {A4, EN}, STACCATO
                       {F4, EN + SN}, STACCATO
                       {G4, SN}, STACCATO
                       {A4, EN}, STACCATO

                       {B4b, EN + SN}, STACCATO
                       {A4, SN}, STACCATO
                       {G4, EN}, STACCATO
                       {F4s, EN + SN}, STACCATO
                       {E4, SN}, STACCATO
                       {F4s, EN}, STACCATO
                       {G4, HN + QN}, STACCATO

                       {F5, QN + EN}, STACCATO
                       {F5, EN + SN}, STACCATO
                       {E5, SN}, STACCATO
                       {D5, EN}, STACCATO
                       {C5, QN}, STACCATO
                       {A4, EN}, STACCATO
                       {F4, EN + SN}, STACCATO
                       {G4, SN}, STACCATO
                       {A4, EN}, STACCATO

                       {B4b, QN}, STACCATO
                       {G4, EN}, STACCATO
                       {G4, EN + SN}, STACCATO
                       {F4s, SN}, STACCATO
                       {G4, EN}, STACCATO
                       {A4, QN}, STACCATO
                       {F4s, EN}, STACCATO
                       {D4, QN + EN}, STACCATO

                       {F5, QN + EN}, STACCATO
                       {F5, EN + SN}, STACCATO
                       {E5, SN}, STACCATO
                       {D5, EN}, STACCATO
                       {C5, QN}, STACCATO
                       {A4, EN}, STACCATO
                       {F4, EN + SN}, STACCATO
                       {G4, SN}, STACCATO
                       {A4, EN}, STACCATO

                       {B4b, EN + SN}, STACCATO
                       {A4, SN}, STACCATO
                       {G4, EN}, STACCATO
                       {F4s, EN + SN}, STACCATO
                       {E4, SN}, STACCATO
                       {F4s, EN}, STACCATO
                       {G4, HN + QN}, STACCATO

                       {SILENCE, WN}
};

El archivo notas.h puede ser generado con otro script. A modo de ejemplo se agrega código rápido hecho en Python para generarlo. Las únicas variables a modificar son la frecuencia del clock y el prescaler. Para generar notas.h basta con correr el script de Python y copiar la salida en ese archivo.

#!/usr/bin/env python
import textwrap

CLOCK_FREQ = 4_000_000
PRESCALER = 1
MAX_COUNT = int(2 ** 16)

notes = [
    'Cn',
    ('Cns', 'Dnb'),
    'Dn',
    ('Dns', 'Enb'),
    'En',
    'Fn',
    ('Fns', 'Gnb'),
    'Gn',
    ('Gns', 'Anb'),
    'An',
    ('Ans', 'Bnb'),
    'Bn'
]


def freq_to_count(freq):
    """
    Determina el valor de la cuenta que más se aproxima
    al periodo asociado a la frecuencia deseada.
    """
    target_time = 1 / freq
    time_step = PRESCALER / CLOCK_FREQ
    optimal_count = min(range(MAX_COUNT), key=lambda i: abs(target_time - i * time_step))
    return optimal_count


if __name__ == '__main__':
    factor = 2 ** (1 / 12)
    A_to_C = 1 / (2 ** (9 / 12))

    A_POS = 3
    A_REF = 440

    print(textwrap.dedent('''
          #define SILENCE_BOTTOM  0
          #define SILENCE_TOP     200
          #define SILENCE         SILENCE_BOTTOM, SILENCE_TOP
    '''))

    for i in [2, 3, 4, 5]:
        A_i_ref = A_REF / (2 ** (A_POS - i))
        C_i_ref = A_i_ref * A_to_C

        for j, note in enumerate(notes):
            freq = C_i_ref * (factor ** j)

            alias = False
            if not isinstance(note, tuple):
                note = note.replace('n', str(i))
            else:
                note, alias = [item.replace('n', str(i)) for item in note]

            print(f'#define {note}_BOTTOM {freq_to_count(freq * 2)}')
            print(f'#define {note}_TOP    {freq_to_count(freq)}')
            print(f'#define {note} {note}_BOTTOM, {note}_TOP')

            if alias:
                print(f'#define {alias}_BOTTOM {note}_BOTTOM')
                print(f'#define {alias}_TOP    {note}_TOP')
                print(f'#define {alias} {note}')

Finalmente tenemos que definir qué es el tiempo de una nota y lo que entendemos por "tiempo" son cantidad de interrupciones por overflow de TIMER0. Por lo que conservamos un contador manual que decrementamos hasta llegar a 0 y cambiar de nota. Hay que definir qué implica cambiar de nota y para eso podemos utilizar una macro. No es la mejor práctica pero todo esto es un ejemplo ilustrativo.

#define SET_NOTE()  OCR1B = notes[i][0]; \
                    OCR1A = notes[i][1]; \
                    timer0_counter = notes[i][2];

Siendo i el índice que indica cual es la nota actual.

El código de la interrupción del TIMER0 resulta:

volatile uint8_t i = 0;
volatile uint8_t timer0_counter;
ISR(TIMER0_OVF_vect) {
    if ((--timer0_counter) == 0) {
        if (++i >= NOTES_LEN) {
            i = 0;
        }
        SET_NOTE();
    }
}

Se ha de notar que TIMER1 no necesita ninguna interrupción, todo el trabajo está hecho al setear OCR1B y OCR1A.

El código final resulta

#include <avr/io.h>
#include <stdint.h>
#include <avr/interrupt.h>

#include "notas.h"

#define STACCATO       {SILENCE, 1},

#define SET_NOTE()  OCR1B = notes[i][0]; \
                    OCR1A = notes[i][1]; \
                    timer0_counter = notes[i][2];

#define SN  12
#define EN  (SN * 2)
#define QN  (EN * 2)
#define HN  (QN * 2)
#define WN  (HN < 128 ? HN * 2 : 255)

uint16_t notes[][3] = {{G4, EN}, STACCATO
                       {B4b, QN}, STACCATO
                       {C5, EN}, STACCATO
                       {D5, EN + SN}, STACCATO
                       {E5, SN}, STACCATO
                       {D5, EN}, STACCATO
                       {C5, QN}, STACCATO
                       {A4, EN}, STACCATO
                       {F4, EN + SN}, STACCATO
                       {G4, SN}, STACCATO
                       {A4, EN}, STACCATO

                       {B4b, QN}, STACCATO
                       {G4, EN}, STACCATO
                       {G4, EN + SN}, STACCATO
                       {F4s, SN}, STACCATO
                       {G4, EN}, STACCATO
                       {A4, QN}, STACCATO
                       {F4s, EN}, STACCATO
                       {D4, QN}, STACCATO

                       {G4, EN}, STACCATO
                       {B4b, QN}, STACCATO
                       {C5, EN}, STACCATO
                       {D5, EN + SN}, STACCATO
                       {E5, SN}, STACCATO
                       {D5, EN}, STACCATO
                       {C5, QN}, STACCATO
                       {A4, EN}, STACCATO
                       {F4, EN + SN}, STACCATO
                       {G4, SN}, STACCATO
                       {A4, EN}, STACCATO

                       {B4b, EN + SN}, STACCATO
                       {A4, SN}, STACCATO
                       {G4, EN}, STACCATO
                       {F4s, EN + SN}, STACCATO
                       {E4, SN}, STACCATO
                       {F4s, EN}, STACCATO
                       {G4, HN + QN}, STACCATO

                       {F5, QN + EN}, STACCATO
                       {F5, EN + SN}, STACCATO
                       {E5, SN}, STACCATO
                       {D5, EN}, STACCATO
                       {C5, QN}, STACCATO
                       {A4, EN}, STACCATO
                       {F4, EN + SN}, STACCATO
                       {G4, SN}, STACCATO
                       {A4, EN}, STACCATO

                       {B4b, QN}, STACCATO
                       {G4, EN}, STACCATO
                       {G4, EN + SN}, STACCATO
                       {F4s, SN}, STACCATO
                       {G4, EN}, STACCATO
                       {A4, QN}, STACCATO
                       {F4s, EN}, STACCATO
                       {D4, QN + EN}, STACCATO

                       {F5, QN + EN}, STACCATO
                       {F5, EN + SN}, STACCATO
                       {E5, SN}, STACCATO
                       {D5, EN}, STACCATO
                       {C5, QN}, STACCATO
                       {A4, EN}, STACCATO
                       {F4, EN + SN}, STACCATO
                       {G4, SN}, STACCATO
                       {A4, EN}, STACCATO

                       {B4b, EN + SN}, STACCATO
                       {A4, SN}, STACCATO
                       {G4, EN}, STACCATO
                       {F4s, EN + SN}, STACCATO
                       {E4, SN}, STACCATO
                       {F4s, EN}, STACCATO
                       {G4, HN + QN}, STACCATO

                       {SILENCE, WN}
};

const uint8_t NOTES_LEN = sizeof(notes) / sizeof(notes[0]);

volatile uint8_t i = 0;
volatile uint8_t timer0_counter;
ISR(TIMER0_OVF_vect) {
    if ((--timer0_counter) == 0) {
        if (++i >= NOTES_LEN) {
            i = 0;
        }
        SET_NOTE();
    }
}


int main (void)
{
  DDRB |= _BV(DDB2);

  SET_NOTE();

  TCNT1 = 0;
  // WGM en 1111 configura el temporizador como Fast PWM con
  //    OCR1A como el tope superior de la cuenta
  //        0 como el tope inferior de la cuenta
  // Esto quiere decir que TCNT1 pasa de OCR1A a 0 (en lugar de hacerlo en overflow)
  // COM1B en 10 hace que el pin OC1B se apague cuando TCNT1 coincida con OCR1B
  // OC1B se vuelve a setear en el tope inferior de la cuenta, en nuestro caso 0
  // Prescaler de 1
  TCCR1A = (1 << COM1B1) | (0 << COM1B0) | (1 << WGM11) | (1 << WGM10);
  TCCR1B = (0 << CS12) | (0 << CS11) | (1 << CS10) | (1 << WGM13) | (1 << WGM12);
  TIMSK1 = 0;

  TCNT0 = 0;
  // Prescaler de 256
  // Interrupción por overflow
  // Operación normal del temporizador
  TCCR0A = 0;
  TCCR0B = (1 << CS02) | (0 << CS01) | (0 << CS00);
  TIMSK0 = _BV(TOIE0);

  sei();

  while(1) {
    ;
  }

  return 1;
}