El acceso a periféricos en sistemas embebidos se realiza, casi siempre, mediante Memory-Mapped I/O (MMIO). En este modelo, el hardware mapea sus registros internos en el mismo espacio de direcciones que la memoria RAM o la memoria Flash. Para el procesador, leer un registro de un controlador de interrupciones o de un GPIO es, desde un punto de vista eléctrico, idéntico a leer una dirección de memoria.
Cuando escribimos un driver, el objetivo es manipular estos bits de forma precisa. Para lograrlo, necesitamos decirle al compilador que ciertas direcciones no son memoria convencional. La mayoría de los compiladores optimizan agresivamente, asumiendo que un valor en memoria solo cambia si el software lo modifica explícitamente. Sin embargo, el hardware es asíncrono: un registro de estado puede cambiar su valor debido a un evento externo (como la llegada de un paquete por Ethernet) sin que el CPU haya ejecutado ninguna instrucción de escritura.
Para evitar que el optimizador descarte lecturas “redundantes” o reordene escrituras críticas, utilizamos la calificador volatile. El uso correcto de volatile es obligatorio para evitar que el compilador asuma la estabilidad de un registro. El uso de const en el puntero es igualmente vital para asegurar que la dirección base del periférico sea inmutable durante la ejecución, evitando errores de punteros que intenten “apuntar” a otro periférico por accidente.
Debes usar este patrón siempre que implementes una capa de abstracción de hardware (HAL) o un driver de bajo nivel. Si ignoras la volatilidad o el alineamiento, el sistema fallará de formas erráticas: un bucle de espera de un flag que nunca termina porque el compilador “cacheó” el valor inicial en un registro de CPU, o un hard fault por acceder a una dirección con alineamiento incorrecto.
#include <stdint.h>
#include <stdio.h>
/*
* Simulamos una región de memoria de hardware para que el código
* sea ejecutable en un entorno de escritorio (PC).
* En un sistema embebido real, estas direcciones serían fijas (ej. 0x48000000).
*/
static uint8_t hardware_memory_space[1024] __attribute__((aligned(4)));
#define HW_BASE ((uintptr_t)hardware_memory_space)
/*
* Definición del mapa de registros de un periférico GPIO ficticio.
* Usamos una struct para mapear el layout de memoria. El compilador
* garantiza que el orden de los miembros coincida con los offsets.
*/
typedef struct {
volatile uint32_t CR; // Control Register (Offset 0x00)
volatile uint32_t DR; // Data Register (Offset 0x04)
volatile uint32_t SR; // Status Register (Offset 0x08)
} GPIO_TypeDef;
/*
* Definición del periférico.
* El puntero es 'const' (la dirección de GPIOA no cambia).
* El contenido apunta a tipos 'volatile' (el valor en DR o SR puede cambiar).
*/
#define GPIOA ((GPIO_TypeDef *)HW_BASE)
/* Máscaras de bits para el control */
#define GPIO_PIN_5 (1U << 5)
#define GPIO_MODE_OUT (1U << 0)
#define GPIO_STATUS_RDY (1U << 0)
/**
* Configura un pin como salida y activa un bit de control.
*/
void gpio_init_pin(GPIO_TypeDef *const gpio, uint32_t pin_mask) {
// Operación de lectura-modificación-escritura
gpio->CR |= pin_mask | GPIO_MODE_OUT;
}
/**
* Activa un pin en el registro de datos.
*/
void gpio_set_high(GPIO_TypeDef *const gpio, uint32_t pin_mask) {
gpio->DR |= pin_mask;
}
/**
* Espera a que el bit de estado indicado sea 1.
*/
int gpio_wait_ready(GPIO_TypeDef *const gpio, uint32_t status_mask) {
// Sin 'volatile' en SR, este bucle podría ser optimizado a un infinito
// si el compilador cree que SR nunca cambia.
while ((gpio->SR & status_mask) == 0) {
// En un sistema real, aquí podría ir una instrucción NOP o una barrera
}
return 1;
}
int main(void) {
// Simulamos que el hardware pone el bit de READY
*(volatile uint32_t *)(HW_BASE + 0x08) = GPIO_STATUS_RDY;
printf("Iniciando configuración de GPIO...\n");
gpio_init_pin(GPIOA, GPIO_PIN_5);
if (gpio_wait_ready(GPIOA, GPIO_STATUS_RDY)) {
printf("Hardware listo. Activando pin 5...\n");
gpio_set_high(GPIOA, GPIO_PIN_5);
}
printf("Valor actual en DR: 0x%08X\n", GPIOA->DR);
printf("Valor actual en SR: 0x%08X\n", GPIOA->SR);
return 0;
}
Desglose del funcionamiento
En el ejemplo, la estructura GPIO_TypeDef es la clave para el mapeo de memoria. Al definirla, estamos dictando que CR está en el offset +0x00, DR en +0x04 y SR en +0x08. Al realizar el cast (GPIO_TypeDef *)HW_BASE, le indicamos al compilador que trate la dirección de memoria de hardware_memory_space como la base de esta estructura.
Fíjate en la declaración GPIO_TypeDef *const GPIOA. Es una distinción crítica de la arquitectura C:
1. GPIO_TypeDef * indica que el objeto al que apuntamos es una estructura de registros.
2. const aplicado al puntero significa que GPIOA es un puntero constante; no puedes hacer GPIOA = (GPIO_TypeDef *)0x1234;. Esto es vital para evitar que el código reasigne accidentalmente la base del periférico.
3. volatile dentro de la struct asegura que cada acceso a gpio->DR o gpio->SR genere una instrucción de carga (LDR) o almacenamiento (STR) real en la CPU, impidiendo que el compilador asuma que el valor es constante o que la escritura es redundante.
En gpio_wait_ready, la expresión while ((gpio->SR & status_mask) == 0) depende enteramente de volatile. Si el compilador analizara la función y viera que no hay instrucciones que modifiquen la dirección de SR dentro del bucle, eliminaría el chequeo y dejaría el bucle como while (0) o while (1), dependiendo de la evaluación previa, lo que resultaría en un fallo de ejecución.
Un detalle avanzado es el uso de __attribute__((packed)). Aunque no es parte del estándar C, es común en drivers para evitar que el compilador inserte padding entre miembros de la struct para alinearlos a 32 bits. Si tu hardware tiene registros de 8 bits seguidos de uno de 32 bits sin alineación, el packing es obligatorio para que la struct coincida con la memoria. Sin embargo, ten cuidado: en arquitecturas como ARM Cortex-M, acceder a un miembro de 32 bits que no esté alineado a 4 bytes puede provocar un UsageFault.
Finalmente, en sistemas multiprocesador (SMP), volatile solo garantiza que el compilador no reordene las instrucciones a nivel de lenguaje C, pero no garantiza el ordenamiento a nivel de hardware. Para asegurar que una escritura en un registro de datos sea visible antes de que un registro de interrupción se active, se requieren barreras de memoria explícitas (como __DMB() en ARM).
El error frecuente
Un error clásico es olvidar el calificador volatile en variables que actúan como flags de hardware o estados de interrupción.
// ERROR: El compilador puede optimizar esto
uint32_t *status_reg = (uint32_t *)0x40001000;
while (*status_reg == 0) {
// El compilador ve que nada en este bucle modifica
// la memoria en la dirección *status_reg.
// Optimización resultante:
// if (*status_reg == 0) while(true);
}
Si intentas compilar esto con -O3, el optimizador detectará que *status_reg no cambia dentro del cuerpo del bucle y transformará el código en un bucle infinito o en una evaluación única, ignorando por completo los cambios físicos que el hardware realice en la memoria. AddressSanitizer no detectará esto porque no es un error de memoria (no hay acceso inválido), es un error de lógica de optimización.
N° 113