Entornos de desarrollo C y C++
El salto de «programar» a «programar hardware». Punteros, estructuras y acceso directo a registros son las herramientas con las que un programa deja de correr sobre una abstracción y empieza a manejar patas, temporizadores y conversores reales.
01La cadena de herramientas
Programar un microcontrolador tiene una diferencia central respecto de programar una PC: el programa se escribe y compila en una máquina y se ejecuta en otra. Eso se llama compilación cruzada.
| Componente | Qué hace |
|---|---|
| Toolchain | El conjunto compilador + ensamblador + enlazador + utilidades, específico para la arquitectura destino (avr-gcc, arm-none-eabi-gcc, XC8 de Microchip). |
| IDE | El entorno gráfico que integra editor, compilador, grabador y depurador: MPLAB X, Atmel Studio, STM32CubeIDE, PlatformIO, Arduino IDE. |
| Linker script | Describe el mapa de memoria del chip y decide en qué dirección va cada cosa. |
| Código de arranque | Rutina en assembler que se ejecuta antes de main(): arma la pila, inicializa variables y salta al programa. |
| Grabador / programador | El hardware que escribe el .hex en la flash: PICkit, USBasp, ST-Link, o un bootloader por USB. |
| Depurador | Permite detener el programa en el chip real y ver variables: ICD, JTAG, SWD. |
- Flash — guarda el programa. No volátil, grande (decenas de KB), se escribe al grabar.
- RAM — guarda las variables. Volátil y muy escasa: 512 bytes a 8 KB en un micro de 8 bits. Es el recurso que primero se agota.
- EEPROM — pocos bytes no volátiles, para configuración que debe sobrevivir al apagado.
Al compilar, el entorno informa cuánta flash y cuánta RAM usa el programa. Hay que mirar siempre ese número. Un programa que usa el 95 % de la RAM va a fallar en cuanto la pila crezca un poco.
02Punteros
Un puntero es una variable que guarda una dirección de memoria. Es el concepto que más cuesta y el que más rinde: sin punteros no hay acceso a hardware, ni paso por referencia, ni estructuras de datos.
& (dirección de) y * (contenido de).int x = 42;
int *p; // p es un puntero a int
p = &x; // p toma la DIRECCIÓN de x
printf("%d", *p); // imprime 42 — * accede al CONTENIDO
*p = 7; // ahora x vale 7, sin nombrar a x
Para qué sirven realmente
// NO funciona: recibe copias
void mal(int a, int b) {
int t = a; a = b; b = t;
}
// SÍ funciona: recibe direcciones
void bien(int *a, int *b) {
int t = *a; *a = *b; *b = t;
}
bien(&x, &y);
void leer_sensor(int *temp,
int *hum)
{
*temp = medir_t();
*hum = medir_h();
}
int t, h;
leer_sensor(&t, &h);
Una función C devuelve un solo valor; con punteros puede «devolver» todos los que haga falta.
Aritmética de punteros
Sumarle 1 a un puntero no le suma 1 a la dirección: le suma el tamaño del tipo apuntado. Esa es la razón por la que el puntero tiene tipo.
int v[5] = {10, 20, 30, 40, 50};
int *p = v; // el nombre del arreglo YA es la dirección del primer elemento
*p // 10 — equivale a v[0]
*(p + 2) // 30 — equivale a v[2]; avanzó 2 * sizeof(int) bytes
p[2] // 30 — la notación de corchetes ES aritmética de punteros
// Recorrer un arreglo con punteros
for (int *q = v; q < v + 5; q++) {
printf("%d ", *q);
}
- Puntero no inicializado. Contiene basura y escribir a través de él corrompe memoria al
azar. Todo puntero se inicializa, aunque sea a
NULL. - Puntero colgante. Apunta a una variable local de una función que ya terminó. Esa memoria se reutilizó y el contenido es impredecible. Nunca devolver la dirección de una variable local.
- Desreferenciar NULL. En una PC produce un cierre inmediato; en un microcontrolador escribe en la dirección 0, que suele ser un registro crítico, y el chip se reinicia.
03Estructuras, uniones y typedef
Una estructura agrupa datos de tipos distintos bajo un mismo nombre. Es lo que permite tratar «un sensor» o «una medición» como una sola cosa.
typedef struct {
uint8_t id;
int16_t temperatura; // en décimas de grado
uint16_t humedad;
uint8_t valido;
} Sensor;
Sensor s1;
s1.id = 3;
s1.temperatura = 235; // 23,5 °C
Sensor red[8]; // un arreglo de estructuras
red[0].humedad = 640;
Sensor *ps = &s1;
ps->id = 4; // con puntero se usa -> en vez de .
Todos sus campos comparten el mismo lugar de memoria. Sirve para ver un mismo dato de dos formas.
union {
uint16_t palabra;
uint8_t byte[2];
} conv;
conv.palabra = 0xABCD;
// conv.byte[0] y [1] son
// 0xCD y 0xAB (o al revés,
// según endianness)
Permiten declarar campos de un ancho exacto en bits: ideal para registros de configuración.
struct {
uint8_t habilitado : 1;
uint8_t modo : 2;
uint8_t prescaler : 3;
uint8_t reservado : 2;
} ctrl;
ctrl.modo = 2; // legible
El compilador puede insertar bytes vacíos entre los campos para alinearlos, así que
sizeof(Sensor) puede ser mayor que la suma de sus campos. Esto importa cuando la
estructura representa el formato exacto de una trama de comunicación o de un archivo: en esos casos
hay que usar __attribute__((packed)) o serializar campo por campo.
04Acceso al hardware y device drivers
Acá se junta todo lo anterior. En un microcontrolador, los periféricos se manejan escribiendo y leyendo registros ubicados en direcciones fijas de memoria. Un puntero a una dirección concreta es el mecanismo que lo hace posible.
// Un registro es un puntero a una dirección fija, marcado volatile
#define PORTB (*(volatile uint8_t *) 0x25)
#define DDRB (*(volatile uint8_t *) 0x24)
#define PINB (*(volatile uint8_t *) 0x23)
DDRB |= (1 << 0); // PB0 como salida
PORTB |= (1 << 0); // PB0 en alto
Los tres elementos de esa línea son los que hay que entender:
(volatile uint8_t *) 0x25— convierte el número 0x25 en un puntero a un byte en esa dirección.volatile— le prohíbe al compilador optimizar los accesos, porque el valor puede cambiar por hardware.*(...)— desreferencia, de modo quePORTBse comporta como una variable común.
Qué es un device driver
Es la capa de software que traduce entre «lo que el programa quiere hacer» y «los registros que hay que tocar». Aísla al resto del programa de los detalles del chip.
// ---- led.h : la INTERFAZ, lo único que ve el resto del programa ----
void led_init(void);
void led_on(uint8_t n);
void led_off(uint8_t n);
void led_toggle(uint8_t n);
// ---- led.c : la IMPLEMENTACIÓN, específica de este micro ----
#include "led.h"
#include "registros.h"
void led_init(void) { DDRB = 0xFF; PORTB = 0x00; }
void led_on(uint8_t n) { PORTB |= (1 << n); }
void led_off(uint8_t n) { PORTB &= ~(1 << n); }
void led_toggle(uint8_t n) { PORTB ^= (1 << n); }
- El programa principal se lee como el problema, no como el chip:
led_on(3)en vez dePORTB |= 8. - Si se cambia de microcontrolador, se reescribe sólo el driver. El resto no se toca.
- Se puede probar la lógica del programa en una PC con un driver falso que imprima por pantalla.
- Es exactamente lo que hace la biblioteca de Arduino:
digitalWrite()es un driver sobre esos mismos registros.
05Bootloaders
Un bootloader es un programa pequeño que vive en una zona reservada de la flash y se ejecuta primero al encender. Su función es poder grabar el resto de la flash sin necesidad de un programador externo.
| Sin bootloader | Con bootloader |
|---|---|
| Hace falta un programador (PICkit, USBasp, ST-Link). | Alcanza un cable USB o serie. |
| Toda la flash disponible para el programa. | El bootloader ocupa entre 0,5 y 4 KB. |
| Arranque instantáneo. | Demora inicial mientras espera si le mandan un programa nuevo. |
| Necesario para grabar el bootloader la primera vez. | Permite actualizar el firmware en campo, sin abrir el equipo. |
Una placa Arduino trae un bootloader grabado de fábrica. Cuando se aprieta «Subir», el IDE compila
con avr-gcc, genera el .hex y se lo manda por el puerto serie; el
bootloader lo recibe y lo escribe en la flash. Por eso una placa Arduino no necesita programador, y
por eso un micro «pelado» comprado suelto sí lo necesita al menos una vez, para grabarle el
bootloader.
06Tiempo real e interrupciones
Un sistema es de tiempo real cuando responder tarde equivale a responder mal. No significa «rápido»: significa previsible. Un airbag que se dispara 200 ms tarde falló, aunque el procesador sea velocísimo.
El programa pregunta una y otra vez si pasó algo.
while (1) {
if (boton_apretado())
accion();
otras_tareas();
}
Simple y predecible, pero desperdicia tiempo y la latencia depende de cuánto tarde el resto del bucle.
El hardware avisa: suspende el programa, ejecuta una rutina y vuelve.
ISR(INT0_vect) {
bandera = 1; // corta
}
while (1) {
if (bandera) { ... }
}
Respuesta inmediata y sin desperdiciar CPU, a cambio de un programa más difícil de razonar.
- Que sea cortísima. Poner una bandera, guardar un dato y salir. Mientras la ISR corre, el resto del sistema está detenido.
- Nada de
printf, ni retardos, ni esperas. Todo lo lento va en el bucle principal. - Toda variable compartida con el bucle principal va declarada
volatile. Sin eso, el optimizador rompe la comunicación entre ambos.
La latencia es el tiempo entre que ocurre el evento y que empieza a ejecutarse la ISR. Se compone del tiempo en terminar la instrucción en curso, el guardado del contexto, y —lo más importante— cualquier momento en que las interrupciones estén deshabilitadas o haya otra ISR en ejecución. Por eso las secciones críticas deben ser lo más cortas posible: cada instrucción con interrupciones deshabilitadas empeora la latencia de todo el sistema.
07Concurrencia
Apenas hay interrupciones, hay dos flujos de ejecución compartiendo variables. Y ahí aparecen errores que no existen en un programa secuencial.
volatile uint16_t contador; // 16 bits en un micro de 8 bits
ISR(TIMER0_OVF_vect) { contador++; }
int main(void) {
while (1) {
uint16_t c = contador; // ← PELIGRO
...
}
}
Un micro de 8 bits necesita dos instrucciones para leer una variable de 16 bits. Si la
interrupción cae justo en el medio —después de leer el byte bajo y antes del alto— el valor obtenido
mezcla el antes y el después. Con contador pasando de 0x00FF a 0x0100 se puede leer
0x01FF: un valor que nunca existió.
La solución es la sección crítica: leer con las interrupciones deshabilitadas, y volver a habilitarlas de inmediato.
uint8_t sreg = SREG; // guardar el estado previo
cli(); // deshabilitar interrupciones
uint16_t c = contador; // lectura atómica
SREG = sreg; // restaurar (NO hacer sei() a ciegas)
| Concepto | Qué es |
|---|---|
| Sección crítica | Tramo de código que no puede ser interrumpido sin corromper datos. Se protege deshabilitando interrupciones, y debe ser lo más corto posible. |
| Operación atómica | La que se completa sin posibilidad de interrupción. En un micro de 8 bits, leer o escribir un uint8_t lo es; un uint16_t no. |
| Función reentrante | La que puede ser llamada de nuevo antes de terminar (desde una ISR) sin romperse. No usa variables globales ni estáticas. |
| Buffer circular | La estructura estándar para pasar datos de una ISR al bucle principal: la ISR escribe en un índice y el bucle lee de otro, sin pisarse. |
| Interbloqueo (deadlock) | Dos tareas esperándose mutuamente. Aparece en sistemas con RTOS y semáforos. |
Mientras el problema entre en «configurar + bucle infinito + unas interrupciones», eso es lo que conviene: es simple, predecible y no consume recursos. Cuando hay muchas tareas con tiempos distintos (leer sensores cada 10 ms, actualizar una pantalla cada 100 ms, atender una comunicación cuando llegue), se pasa a un RTOS —FreeRTOS es el más difundido— que reparte el procesador entre tareas con prioridades. Se ve en profundidad en 7° año.
08En el laboratorio
Programa en PC que declare varias variables de distintos tipos e imprima su dirección con
%p y su tamaño con sizeof. Verificar que las direcciones de un arreglo están
separadas exactamente por el tamaño del tipo, y que sumar 1 a un int* avanza 4 bytes
mientras que sumar 1 a un char* avanza 1. Es la forma más directa de entender la
aritmética de punteros.
Escribir dos versiones de una función intercambiar, una con parámetros comunes y otra
con punteros. Comprobar que la primera no hace nada. Después escribir una función que reciba un
arreglo y su longitud y devuelva por punteros el máximo, el mínimo y el promedio.
Escribir led.h y led.c como en el ejemplo, pero con una variable global
unsigned char PORTB que simule el puerto, y una función que imprima su contenido en
binario. Escribir después un main que haga un «auto fantástico» usando sólo las
funciones del driver. Compilar los dos archivos por separado y enlazarlos: es el primer proyecto
multiarchivo del curso.
Definir una estructura que represente una medición (identificador, valor, marca de tiempo,
checksum). Escribir funciones para cargarla, calcular el checksum y volcarla a un arreglo de
bytes listo para transmitir. Imprimir sizeof de la estructura y compararlo con la suma
de sus campos: si difieren, hay padding, y ese es el motivo de usar
packed.
09Errores frecuentes
| Error | Consecuencia |
|---|---|
| Puntero declarado pero no inicializado | Escritura en una dirección al azar. En un micro, reinicio o comportamiento errático. |
| Devolver la dirección de una variable local | Puntero colgante: la memoria ya se reutilizó. |
Confundir *p = 5 con p = 5 | El primero escribe el valor; el segundo cambia la dirección apuntada. El segundo casi nunca es lo que se quiere. |
Usar . con un puntero a estructura | Error de compilación. Con puntero va ->. |
Falta volatile en un registro o en una variable de ISR | El optimizador elimina lecturas y el programa se cuelga. Aparece sólo al compilar con optimización. |
| ISR larga o con retardos | Se pierden otras interrupciones y el sistema deja de responder. |
| Leer una variable de 16 bits compartida sin sección crítica | Condición de carrera: valores imposibles, muy difíciles de reproducir. |
Habilitar interrupciones con sei() al final de una sección crítica | Las habilita aunque antes estuvieran deshabilitadas por otra razón. Hay que guardar y restaurar el estado. |
Usar malloc en un microcontrolador | Fragmentación de una RAM minúscula y fallas impredecibles. En embebidos se usa memoria estática. |
10Autoevaluación
¿Qué es la compilación cruzada y por qué es necesaria en embebidos?
Es compilar en una máquina (la PC) para que el programa se ejecute en otra arquitectura distinta (el microcontrolador). Es necesaria porque el micro no tiene teclado, pantalla ni capacidad para correr un compilador.
Si int *p vale 0x1000 y se hace p++, ¿cuánto vale ahora?
Depende del tamaño de int: si es de 2 bytes, 0x1002; si es de
4 bytes, 0x1004. La aritmética de punteros avanza en unidades del tipo apuntado, no en
bytes.
Escribir una función que intercambie dos enteros del que la llama.
void intercambiar(int *a, int *b) {
int t = *a;
*a = *b;
*b = t;
}
// se llama así: intercambiar(&x, &y);¿Por qué un registro de hardware se declara volatile?
Porque su valor puede cambiar por causas ajenas al programa (el hardware lo
modifica). Sin volatile, el compilador puede leerlo una sola vez y reutilizar esa copia,
o eliminar escrituras que «no sirven para nada» pero que en realidad accionan un
periférico.
¿Qué ventaja concreta tiene separar el código en un driver?
Que el resto del programa deja de depender del microcontrolador. Al cambiar de chip
se reescribe sólo el driver; además el programa se puede probar en una PC con un driver simulado y se
lee mucho mejor (led_on(3) en vez de PORTB |= 8).
¿Qué es un bootloader y qué ventaja da?
Un programa pequeño residente en la flash que puede grabar el resto de la flash por un puerto serie o USB. Evita necesitar un programador externo y permite actualizar el firmware en campo. A cambio ocupa algo de flash y demora un poco el arranque.
Enumerar las tres reglas de una rutina de interrupción.
(1) Que sea lo más corta posible. (2) Sin retardos, esperas ni
funciones lentas. (3) Toda variable compartida con el programa principal declarada
volatile.
Explicar una condición de carrera con un contador de 16 bits.
Un micro de 8 bits lee la variable en dos instrucciones. Si la interrupción que la incrementa cae entre ambas, se obtiene una mezcla del valor viejo y el nuevo — por ejemplo 0x01FF al pasar de 0x00FF a 0x0100. Se resuelve leyendo dentro de una sección crítica, con las interrupciones deshabilitadas.
¿Por qué se evita malloc en sistemas embebidos?
Porque la RAM es de pocos KB y la asignación dinámica repetida la fragmenta: con el tiempo puede fallar aunque haya memoria libre total suficiente. Además su tiempo de ejecución no es predecible, lo cual es inaceptable en tiempo real. Se usa memoria estática dimensionada en compilación.