← Índice del curso

Sistemas Embebidos · Tema 12 · Taller

Taller: esqueleto de firmware desde cero

Es hora de juntar todo el bloque. Vas a construir, sin bibliotecas del fabricante, un firmware mínimo pero completo: un linker script, el código de arranque, el reloj a 72 MHz, un módulo de entrada y salida, una base de tiempo y una aplicación que parpadea un LED según un botón. Es el esqueleto sobre el que se apoyan los demás bloques del curso.

01 · Qué vamos a construir

Objetivo, materiales y resultado esperado

Un esqueleto de firmware (bring-up, en la jerga) es el programa mínimo que demuestra que toda la cadena funciona: que el chip arranca, que el reloj es el esperado, que se puede controlar un pin y medir el tiempo. Es lo primero que se escribe sobre una placa nueva, y lo primero que se revisa cuando algo deja de funcionar. Lo construimos con los pasos de los temas 5 a 11, uno por vez.

Materiales.

  • Una placa Blue Pill (microcontrolador STM32F103C8T6 con cristal de 8 MHz y un LED en PC13). Sirve cualquier placa similar si adaptás pines y direcciones.
  • Un programador ST-Link (o compatible con SWD) y 4 cables: masa, 3,3 V, SWDIO y SWCLK.
  • Un pulsador conectado entre el pin PA0 y masa.
  • En la PC: arm-none-eabi-gcc, make y openocd (tema 5).
  • Opcional, muy recomendable: un analizador lógico o un osciloscopio para medir el pin del LED.

Resultado esperado. Al encender la placa, el LED parpadea a 1 Hz (500 ms encendido y 500 ms apagado). Mientras se mantiene presionado el pulsador, parpadea cinco veces más rápido (100 ms y 100 ms). El programa ocupa menos de 1 KB de flash, y no usa ninguna biblioteca del fabricante.

Los listados son completos pero los números (direcciones, bits) son los de la familia STM32F103; los tomamos del manual de referencia y del tema 3 y 9. Si usás otra placa, el procedimiento es el mismo: lo que cambia son esas constantes.

02 · La estructura

Estructura del proyecto

Aplicamos el árbol del tema 11, reducido a lo que este esqueleto necesita:

esqueleto/
├── src/
│   ├── main.c              (punto de entrada)
│   ├── app.c, app.h        (la aplicación: parpadeo según el botón)
│   ├── tiempo.c, tiempo.h  (esperar_ms con SysTick)
│   ├── reloj.c, reloj.h    (reloj de 72 MHz y su frecuencia real)
│   └── hal/
│       ├── hal_io.h        (interfaz: LED y botón)
│       └── stm32/hal_io.c  (implementación para el chip)
├── config/config.h
├── linker/stm32f103.ld
├── startup/startup.c       (vectores y Reset_Handler)
├── build/                  (generado)
└── Makefile

Dos reglas de la estructura que se aplican desde el principio: main.c y app.c no incluyen nada del fabricante (no hay ningún archivo stm32f1xx.h; los registros se definen a mano dentro de la HAL y de reloj.c), y la configuración del producto vive en config/config.h:

/* config/config.h */
#ifndef CONFIG_H
#define CONFIG_H

#define F_CPU_OBJETIVO_HZ   72000000UL    /* reloj que se pretende con el cristal y el PLL */
#define F_RC_INTERNO_HZ     8000000UL     /* reloj con el que arranca el chip */
#define MEDIO_PERIODO_LENTO_MS  500
#define MEDIO_PERIODO_RAPIDO_MS 100

#endif

Observá un detalle de diseño: el reloj de 72 MHz es un objetivo, no un hecho. Si el cristal no arranca, el chip seguirá en 8 MHz. Por eso el código nunca debe suponer el valor, sino preguntarlo (paso 3). Es una lección del tema 8 aplicada a una línea de código.

03 · Paso 1

El linker script

Es el archivo que le dice al enlazador cuánta memoria hay y cómo repartirla (tema 7). Para los 64 KB de flash y 20 KB de RAM del chip:

/* linker/stm32f103.ld */
ENTRY(Reset_Handler)

MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
  RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

_estack = ORIGIN(RAM) + LENGTH(RAM);        /* tope de la pila: 0x20005000 */

SECTIONS
{
  .isr_vector : { KEEP(*(.isr_vector)) } > FLASH       /* la tabla de vectores, primero */

  .text : {
    *(.text*)
    *(.rodata*)
    . = ALIGN(4);
  } > FLASH

  _sidata = LOADADDR(.data);                   /* dónde está la copia inicial de .data */

  .data : {
    _sdata = .;
    *(.data*)
    . = ALIGN(4);
    _edata = .;
  } > RAM AT> FLASH

  .bss : {
    _sbss = .;
    *(.bss*)
    *(COMMON)
    . = ALIGN(4);
    _ebss = .;
  } > RAM
}

Punto de control. Todavía no hay nada que compilar, pero ya podés comprobar tres cosas. La flash empieza en 0x08000000 y la RAM en 0x20000000, como en el mapa del tema 3. El tope de la pila queda en 0x20005000, que es el primer valor que leerá el hardware (tema 8). Y la tabla de vectores va primero en la flash, donde el procesador la busca. Si la placa que usás tiene otra memoria, estas son las líneas que cambian.

04 · Paso 2

El código de arranque

El archivo contiene la tabla de vectores y el Reset_Handler que ya vimos en el tema 8. Aquí está completo y listo para compilar:

/* startup/startup.c */
#include <stdint.h>

extern uint32_t _estack, _sidata, _sdata, _edata, _sbss, _ebss;
int  main(void);
int  reloj_iniciar(void);

void Reset_Handler(void);
void Default_Handler(void) { while (1) { } }          /* si algo inesperado ocurre, nos quedamos acá */

void NMI_Handler(void)        __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void)  __attribute__((weak, alias("Default_Handler")));
void SVC_Handler(void)        __attribute__((weak, alias("Default_Handler")));
void PendSV_Handler(void)     __attribute__((weak, alias("Default_Handler")));
void SysTick_Handler(void)    __attribute__((weak, alias("Default_Handler")));

__attribute__((section(".isr_vector"), used))
void (* const vectores[16])(void) = {
    (void (*)(void))(&_estack),                      /*  0: valor inicial de SP */
    Reset_Handler,                                   /*  1 */
    NMI_Handler,                                     /*  2 */
    HardFault_Handler,                               /*  3 */
    Default_Handler, Default_Handler, Default_Handler,   /* 4-6: fallos de memoria, bus y uso */
    0, 0, 0, 0,                                      /* 7-10: reservados */
    SVC_Handler,                                     /* 11 */
    Default_Handler,                                 /* 12: depuración */
    0,                                               /* 13: reservado */
    PendSV_Handler,                                  /* 14 */
    SysTick_Handler,                                 /* 15 */
};

void Reset_Handler(void) {
    uint32_t *origen = &_sidata;
    for (uint32_t *d = &_sdata; d < &_edata; ) *d++ = *origen++;   /* copiar .data */
    for (uint32_t *p = &_sbss;  p < &_ebss; )  *p++ = 0;             /* limpiar .bss */

    reloj_iniciar();             /* el resultado se consulta después con reloj_frecuencia() */
    main();
    while (1) { }
}

La tabla tiene solo 16 entradas (64 bytes) porque este esqueleto no habilita ninguna interrupción de periférico: si más adelante agregás alguna, la tabla tiene que crecer hasta incluir su posición (tema 15). Fijate en SysTick_Handler: es weak y apunta a Default_Handler, de modo que si el SysTick llegara a interrumpir sin que nadie lo atienda, el programa quedaría detenido en un bucle que el depurador muestra al instante.

Punto de control. Con estos dos archivos podrías compilar un programa casi vacío y, antes de seguir, comprobar con el depurador que el PC arranca en Reset_Handler y que el SP vale 0x20005000. Si ese primer paso falla, no sigas: se arregla acá, no en los pasos posteriores (tabla de síntomas del tema 8).

05 · Paso 3

El reloj a 72 MHz

Tras el reinicio el chip corre a 8 MHz con su oscilador interno. Para llegar a 72 MHz hay que seguir la secuencia del tema 8: arrancar el cristal externo, fijar los estados de espera de la flash, configurar el PLL (cristal × 9), ajustar el divisor del bus lento y recién entonces conmutar. El módulo, además, recuerda qué frecuencia logró:

/* src/reloj.h */
#ifndef RELOJ_H
#define RELOJ_H
#include <stdint.h>

int      reloj_iniciar(void);        /* 0 si quedó en 72 MHz; -1 si el cristal no arrancó */
uint32_t reloj_frecuencia(void);     /* frecuencia real del procesador, en Hz */

#endif
/* src/reloj.c */
#include "reloj.h"
#include "config.h"

#define RCC_CR     (*(volatile uint32_t *)0x40021000)
#define RCC_CFGR   (*(volatile uint32_t *)0x40021004)
#define FLASH_ACR  (*(volatile uint32_t *)0x40022000)

static uint32_t f_cpu = F_RC_INTERNO_HZ;      /* mientras no se configure, 8 MHz (va a .data) */

uint32_t reloj_frecuencia(void) { return f_cpu; }

int reloj_iniciar(void) {
    uint32_t espera;

    RCC_CR |= (1u << 16);                                              /* HSEON: arrancar el cristal */
    for (espera = 200000; espera && !(RCC_CR & (1u << 17)); espera--) { }   /* HSERDY, con límite */
    if (!(RCC_CR & (1u << 17))) return -1;                            /* no arrancó: seguimos en 8 MHz */

    FLASH_ACR = (1u << 4) | 2u;                    /* precarga + 2 estados de espera (antes de subir la frecuencia) */

    RCC_CFGR = (RCC_CFGR & ~((0xFu << 18) | (1u << 16) | (7u << 8)))
             | (7u << 18)                           /* PLLMUL = 0111: × 9 */
             | (1u << 16)                           /* PLLSRC = HSE */
             | (4u << 8);                           /* PPRE1 = 100: bus lento / 2 (máximo 36 MHz) */

    RCC_CR |= (1u << 24);                           /* PLLON */
    while (!(RCC_CR & (1u << 25))) { }            /* esperar PLLRDY */

    RCC_CFGR = (RCC_CFGR & ~3u) | 2u;               /* SW = PLL */
    while ((RCC_CFGR & (3u << 2)) != (2u << 2)) { }   /* SWS = PLL: ya está en uso */

    f_cpu = F_CPU_OBJETIVO_HZ;                      /* recién ahora decimos que son 72 MHz */
    return 0;
}

Tres decisiones merecen comentario. Primera: la espera del cristal tiene un límite. Sin él, un cristal mal soldado cuelga el arranque para siempre antes de main() (tema 8). Con él, el chip sigue funcionando a 8 MHz y el resto del programa puede avisarlo. Segunda: el orden: los estados de espera de la flash se fijan antes de subir la frecuencia. Tercera: f_cpu solo cambia a 72 MHz al final y solo si todo salió bien; reloj_frecuencia() es la única fuente de verdad. Los demás módulos le preguntan, no suponen (el laboratorio de este tema muestra qué ocurre si suponen).

Como f_cpu tiene un valor inicial distinto de cero, el enlazador la coloca en .data (4 bytes de flash más 4 de RAM, tema 7), y la copia el Reset_Handler antes de llamar a reloj_iniciar().

Punto de control. Con el depurador, leé RCC_CFGR tras la función: los bits 3:2 (SWS) deben valer 10. Mejor todavía, conectá un osciloscopio al pin MCO si lo configurás, o seguí al paso 5, que lo mide indirectamente con el parpadeo.

06 · Paso 4

El módulo de entrada y salida

La interfaz es la del tema 10: habla en el idioma del producto, no del chip. La implementación aplica el orden de inicialización del tema 9 y el estado seguro de las salidas:

/* src/hal/hal_io.h */
#ifndef HAL_IO_H
#define HAL_IO_H
#include <stdbool.h>

void io_iniciar(void);
void led_encender(void);
void led_apagar(void);
bool boton_presionado(void);       /* true mientras está apretado */

#endif
/* src/hal/stm32/hal_io.c */
#include "hal/hal_io.h"
#include <stdint.h>

#define RCC_APB2ENR (*(volatile uint32_t *)0x40021018)
#define GPIOA_CRL   (*(volatile uint32_t *)0x40010800)
#define GPIOA_IDR   (*(volatile uint32_t *)0x40010808)
#define GPIOA_ODR   (*(volatile uint32_t *)0x4001080C)
#define GPIOC_CRH   (*(volatile uint32_t *)0x40011004)
#define GPIOC_BSRR  (*(volatile uint32_t *)0x40011010)

void led_apagar(void)   { GPIOC_BSRR = 1u << 13; }          /* el LED de la placa enciende con nivel bajo */
void led_encender(void) { GPIOC_BSRR = 1u << (13 + 16); }

bool boton_presionado(void) { return !(GPIOA_IDR & 1u); }  /* pulsador a masa: activo en bajo */

void io_iniciar(void) {
    RCC_APB2ENR |= (1u << 2) | (1u << 4);                 /* 1) relojes: puertos A y C */
    (void)RCC_APB2ENR;                                      /*    lectura de espera */

    led_apagar();                                           /* 2) nivel de reposo ANTES de pasar a salida */
    GPIOC_CRH = (GPIOC_CRH & ~(0xFu << 20)) | (0x2u << 20);   /*    PC13: salida push-pull, 2 MHz */

    GPIOA_CRL = (GPIOA_CRL & ~0xFu) | 0x8u;                /* 3) PA0: entrada con resistencia... */
    GPIOA_ODR |= 1u;                                        /*    ...pull-up (ODR en 1 la selecciona) */
}

Dos cosas para notar. El LED se apaga antes de convertir el pin en salida: así no hay un destello en el arranque (tema 9). Y la inversión de lógica (el LED enciende con nivel bajo; el botón está apretado con nivel bajo) queda encerrada dentro de la HAL: quien llama a led_encender() o a boton_presionado() no necesita saber nada de eso.

07 · Paso 5

Una base de tiempo

Para parpadear hace falta medir tiempo. La forma ingenua es un bucle vacío que cuenta, y es frágil: su duración depende de la frecuencia del reloj y de cómo el compilador traduzca el bucle (el laboratorio de este tema lo cuantifica). La forma robusta es usar el SysTick, un temporizador de 24 bits que tiene todo Cortex-M dentro del núcleo, y que cuenta ciclos del procesador. Se configura para que complete una vuelta cada milisegundo:

/* src/tiempo.h */
#ifndef TIEMPO_H
#define TIEMPO_H
#include <stdint.h>

void esperar_ms(uint32_t ms);       /* espera ocupada, precisa al milisegundo */

#endif
/* src/tiempo.c */
#include "tiempo.h"
#include "reloj.h"

#define SYST_CTRL  (*(volatile uint32_t *)0xE000E010)
#define SYST_LOAD  (*(volatile uint32_t *)0xE000E014)
#define SYST_VAL   (*(volatile uint32_t *)0xE000E018)

void esperar_ms(uint32_t ms) {
    SYST_LOAD = reloj_frecuencia() / 1000u - 1u;     /* una vuelta = 1 ms (71 999 a 72 MHz) */
    SYST_VAL  = 0;                                   /* empezar de cero */
    SYST_CTRL = (1u << 2) | 1u;                      /* reloj del procesador + habilitado (sin interrupción) */

    while (ms--) {
        while (!(SYST_CTRL & (1u << 16))) { }       /* COUNTFLAG: se activa al completar la vuelta */
    }                                                /* (leer CTRL borra la bandera) */
    SYST_CTRL = 0;                                   /* apagar el temporizador */
}

La cuenta del LOAD sale de la frecuencia: a 72 MHz hay 72.000 ciclos en un milisegundo, y el contador cuenta desde LOAD hasta 0 inclusive, es decir, LOAD + 1 ciclos, por lo que LOAD = 71999 (0x1193F). La frecuencia se la pedimos a reloj_frecuencia(), no a la configuración: si el cristal falló, el SysTick se calibra solo para 8 MHz (LOAD = 7999) y el parpadeo sigue siendo de 1 Hz. El módulo es independiente del resultado de reloj_iniciar().

Esta espera sigue siendo ocupada: el procesador no hace otra cosa mientras cuenta, y gasta energía en vano. Es aceptable en un esqueleto y inaceptable en un producto; en el bloque de tiempo real la reemplazamos por interrupciones del SysTick (tema 17) y por planificación (tema 19), y en el de bajo consumo por el modo de reposo (tema 34).

08 · Paso 6

La aplicación y main()

La aplicación solo habla con las interfaces de los módulos: no menciona ningún registro, ningún pin ni ninguna frecuencia.

/* src/app.h */
#ifndef APP_H
#define APP_H
void app_iniciar(void);
void app_paso(void);         /* una pasada de la aplicación */
#endif

/* src/app.c */
#include "app.h"
#include "config.h"
#include "tiempo.h"
#include "hal/hal_io.h"

void app_iniciar(void) {
    led_apagar();
}

void app_paso(void) {
    uint32_t medio = boton_presionado() ? MEDIO_PERIODO_RAPIDO_MS : MEDIO_PERIODO_LENTO_MS;
    led_encender();
    esperar_ms(medio);
    led_apagar();
    esperar_ms(medio);
}

/* src/main.c */
#include "app.h"
#include "hal/hal_io.h"

int main(void) {
    io_iniciar();
    app_iniciar();
    while (1) {
        app_paso();
    }
}

El orden de main() sigue la receta del tema 9: primero el hardware (relojes ya configurados por el arranque, luego pines), después la aplicación, y recién entonces el bucle. Observá que main.c ni siquiera incluye tiempo.h: lo usa solo app.c. Cada archivo incluye lo que necesita, no más (tema 11).

En app_paso(), el botón se consulta una vez por cada ciclo de encendido y apagado, de modo que el cambio de velocidad se nota con una demora de hasta un período completo (hasta 1 s en modo lento). Es el defecto de los superloops con esperas ocupadas que vimos en el tema 4, a la vista en un programa de cuarenta líneas. No lo corregimos acá a propósito: los temas siguientes lo resuelven.

09 · Paso 7

Compilar y grabar

El sistema de construcción es el del tema 11, reducido a este proyecto. Las líneas de comandos de cada regla empiezan con un tabulador.

CC      = arm-none-eabi-gcc
CFLAGS  = -mcpu=cortex-m3 -mthumb -Os -Wall -Wextra -ffunction-sections -fdata-sections -Isrc -Iconfig -MMD -MP
LDFLAGS = -T linker/stm32f103.ld -nostartfiles -Wl,--gc-sections -Wl,-Map=build/firmware.map

SRC = startup/startup.c src/main.c src/app.c src/tiempo.c src/reloj.c src/hal/stm32/hal_io.c
OBJ = $(SRC:%.c=build/%.o)

all: build/firmware.elf
	arm-none-eabi-size $<

build/firmware.elf: $(OBJ)
	$(CC) $(CFLAGS) $(LDFLAGS) $(OBJ) -o $@

build/%.o: %.c
	@mkdir -p $(dir $@)
	$(CC) $(CFLAGS) -c $< -o $@

flash: build/firmware.elf
	openocd -f interface/stlink.cfg -f target/stm32f1x.cfg -c "program build/firmware.elf verify reset exit"

clean:
	rm -rf build

-include $(OBJ:.o=.d)
.PHONY: all flash clean

Con eso, dos comandos: make compila, enlaza y muestra el tamaño; make flash graba, verifica y reinicia la placa. Lo que debería ver size es un programa diminuto, con text por debajo de 1 KB y data de 4 bytes (la variable f_cpu). Si ves varios kilobytes, algo se coló: una biblioteca estándar o una función de formato que no pedimos. Si make falla, leé el mensaje con las etapas del tema 5 en la cabeza: un error de compilación es del código; undefined reference, del enlace; «region overflowed», del tamaño.

Una vez grabada la placa, el LED de PC13 debe empezar a parpadear en cuanto se reinicia. Si el programa grabó pero no parpadea, ya sabés dónde mirar: primero el arranque (tema 8), después el reloj (paso 3), después el pin (paso 4).

10 · Comprobar que funciona

Verificación y diagnóstico

«Parpadea» no es un criterio de aceptación: es una impresión. Un esqueleto bien hecho se verifica con mediciones y con una lista de pruebas con resultado esperado:

Pruebas de aceptación del esqueleto.
PruebaCómo se haceResultado esperado
ArranqueDepurador: detener tras el reinicio y mirar PC y SPPC en Reset_Handler; SP = 0x20005000
RelojLeer RCC_CFGR (bits 3:2) o medir el período del LEDSWS = 10 (PLL); período del LED de 1000 ms
Parpadeo lentoAnalizador lógico en PC13, botón suelto500 ms en bajo y 500 ms en alto, error menor al 2 %
Parpadeo rápidoMismo pin, botón apretado100 ms y 100 ms; el cambio se nota a más tardar al terminar el período en curso
Estado seguroReiniciar con el analizador conectadoLED apagado desde el primer instante, sin destellos (PC13 sin forzar el LED a encenderse)
TamañoSalida de sizetext menor de 1 KB; data de 4 bytes; bss de cero
Sin dependencias del fabricanteBuscar stm32f1xx.h con grep en src/Ninguna aparición fuera de los archivos de la HAL (aquí, ninguna en absoluto)

Y cuando algo falla, la lista de síntomas más frecuentes en un esqueleto nuevo:

Síntomas en un bring-up y qué revisar.
SíntomaCausa probableQué hacer
El programador no encuentra el chipConexiones SWD, alimentación o pin BOOT0 malRevisar cables y masa común; reiniciar con el botón de reset presionado
Graba bien pero el LED no hace nadaTabla de vectores fuera de lugar; linker script de otra memoriaDepurador: ¿llega a Reset_Handler? Revisar .isr_vector en el .map
Parpadea nueve veces más lento (período de 9 s)Cálculo hecho para 72 MHz con el chip a 8 MHzRevisar el uso de reloj_frecuencia(); comprobar el cristal
Parpadea a 1 Hz pero el botón no cambia nadaEl pin PA0 no quedó con pull-up, o el botón no está a masaMedir PA0 en reposo (3,3 V) y apretado (0 V); revisar CRL y ODR
El LED está encendido todo el tiempoLógica de nivel invertida o BSRR mal usadoRecordar que la placa enciende con nivel bajo; revisar los bits 13 y 29
Funciona con el depurador conectado y no sin élAlimentación o reloj dependen del adaptadorProbar con alimentación propia y sin el adaptador

La tercera fila es la más instructiva del taller. Si el cristal no arranca, reloj_iniciar() devuelve -1 y el chip queda en 8 MHz; como la espera con SysTick usa reloj_frecuencia(), el parpadeo sigue siendo correcto. Pero si hubieras escrito SYST_LOAD = 71999 con el número «mágico», quedaría 9 veces más lento. Una línea que lee la verdad en lugar de suponerla es lo que distingue un esqueleto robusto de uno frágil.

11 · Analogía con programación

Predecir los tiempos, en Python

Antes de ir a medir con un instrumento, conviene saber qué se espera. Este programa calcula el LOAD del SysTick para cada reloj, estima el período de una espera por bucle vacío calibrada para 72 MHz bajo distintas condiciones reales, y calcula cuándo se vuelve a cero un contador de milisegundos de 32 bits.

Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.

F_RC, F_PLL = 8_000_000, 72_000_000


def load_systick(f_cpu, tick_ms=1):
    return f_cpu * tick_ms // 1000 - 1         # el contador cuenta LOAD, LOAD-1, ..., 0: LOAD+1 ciclos


for f in (F_RC, F_PLL):
    load = load_systick(f)
    print(f"{f / 1e6:3.0f} MHz: LOAD = {load:6d} (0x{load:X}) -> cuenta {load + 1} ciclos = {(load + 1) / f * 1000:.3f} ms")

# Espera por bucle vacío calibrada para 72 MHz y 4 ciclos por vuelta, para 500 ms
CPI = 4
N = int(0.5 * F_PLL / CPI)
print(f"\nBucle vacío: N = {N:,} iteraciones para 500 ms")
for etiqueta, f_real, cpi_real in (
    ("todo como se calculó",          F_PLL, 4),
    ("compilado con -O0 (8 ciclos)",  F_PLL, 8),
    ("compilado con -O2 (3 ciclos)",  F_PLL, 3),
    ("el cristal no arrancó (8 MHz)", F_RC,  4),
):
    periodo = N * cpi_real / f_real * 1000
    print(f"  {etiqueta:32s} -> {periodo:7.1f} ms")

print(f"\nUn contador de milisegundos de 32 bits da la vuelta cada {2**32 / 1000 / 86400:.1f} días")

Resultado: a 8 MHz el LOAD vale 7999 (0x1F3F) y a 72 MHz vale 71999 (0x1193F); en ambos casos el contador cuenta exactamente 1,000 ms. El bucle vacío calibrado necesita N = 9.000.000 iteraciones y da 500 ms si todo es como se calculó, 1000 ms con el código compilado sin optimizar, 375 ms con -O2 y 4500 ms si el cristal no arrancó. El contador de milisegundos da la vuelta a los 49,7 días. Probá cambiar CPI o agregar otro caso (por ejemplo, un reloj de 48 MHz) y predecí antes de ejecutar.

Dos conclusiones. Primera: la espera por SysTick es robusta frente a los cambios del compilador, pero no frente a una frecuencia equivocada; por eso el esqueleto le pregunta a reloj_frecuencia(). Segunda: el contador de 32 bits da la vuelta cada 49,7 días, cosa que en un equipo que corre meses sin reiniciarse puede romper una comparación mal escrita. Es un clásico en firmware, y lo vemos con cuidado en el bloque de tiempo real (tema 17).

12 · Exploración interactiva

Calibrar el parpadeo

El laboratorio reproduce el cálculo de la espera del esqueleto. Elegí el reloj real del chip, el reloj que el código supone, el método de espera (bucle vacío o SysTick), con qué optimización se compila el bucle y el semiperíodo que querés. Verás el parámetro que calcularía el código, el semiperíodo que obtenés en realidad, el error y la frecuencia resultante del parpadeo.

Recorrido guiado

  1. Con los valores iniciales (72 MHz, bucle, -Os, 500 ms): N = 9.000.000, semiperíodo de 500 ms, frecuencia de 1 Hz y error nulo. Todo coincide con lo que se calculó.
  2. Cambiá la optimización a -O0: el mismo programa, con el mismo N, espera 1 s (error de +100 %). Cambiaste un flag del compilador y el parpadeo se duplicó. Con -O2 pasa a 375 ms (−25 %).
  3. Volvé a -Os y poné el reloj real en 8 MHz, con el código suponiendo 72: el semiperíodo sale en 4,5 s, nueve veces más largo. Es el caso del cristal que no arrancó.
  4. Cambiá a SysTick manteniendo el reloj real en 8 MHz y el código en 72: el resultado es el mismo (4,5 s). El SysTick no te salva si le das una frecuencia equivocada: LOAD se calcula con 72 MHz.
  5. Ahora, con SysTick, poné el código en 8 MHz y el reloj real también en 8 MHz: error nulo. Es lo que hace el esqueleto: reloj_frecuencia() devuelve la frecuencia verdadera y el cálculo siempre es coherente.
  6. Con SysTick, cambiá la optimización entre -O0, -Os y -O2: no hay efecto. El tiempo lo cuenta el hardware, no las instrucciones del bucle.
  7. Probá reloj real 72 y código 8 con SysTick: LOAD = 7999 y el semiperíodo de 500 ms sale en 56 ms (parpadeo de 9 Hz). El error ahora es al revés.
Desafío: ¿se puede confiar en un bucle vacío?

Funciona como demostración, y muchos tutoriales lo usan. Pero depende de tres cosas que no controlás desde el código: la frecuencia del reloj, la optimización del compilador y hasta la memoria desde la que se ejecuta (la flash con estados de espera puede cambiar los ciclos por vuelta). Con 8 ciclos por vuelta en lugar de 4 el error es de 100 %; con otra versión del compilador puede ser distinto. Nunca hay que basar la temporización de un producto en un bucle vacío: ni siquiera para esperas «poco importantes», porque tarde o temprano alguien cambia la optimización. Usá siempre un temporizador de hardware.

13 · Método de trabajo

Método: cómo hacer un bring-up

Lo que hiciste en este taller es un patrón que se repite con cada placa y cada chip nuevos. Su esencia es avanzar de a un paso comprobable, de modo que cuando algo falla, la causa esté en lo último que agregaste:

  1. Conectividad. Que el programador vea el chip y pueda leerlo y borrarlo. Si esto no anda, nada más importa.
  2. Un programa vacío que arranca. Tabla de vectores, Reset_Handler y un bucle infinito. Con el depurador, comprobar el PC y el SP.
  3. Una señal de vida. Un pin que cambia (el LED). Es la prueba de que el código corre.
  4. El reloj. Configurarlo y verificarlo con una medición, no con la lectura de un registro solamente.
  5. La base de tiempo. Medir un intervalo conocido con un analizador, para confiar en todo lo que dependa del tiempo.
  6. Un periférico por vez. Una consola serie primero, porque sirve para depurar todo lo que sigue; después el resto, cada uno con su comprobación.
  7. La estructura en capas desde el principio, aunque haya pocos archivos: moverlos después cuesta más.
  8. Congelar el esqueleto. Guardarlo con una etiqueta en el control de versiones. Si más adelante aparece un problema raro, volver a este punto y comprobar que sigue funcionando descarta el hardware.

El criterio que lo une es el de la depuración científica: nunca avances si el paso anterior no está verificado. Dos horas invertidas en un esqueleto firme ahorran dos días de hipótesis cruzadas más adelante.

14 · Comprensión

Confusiones frecuentes

«Si el LED parpadea, el esqueleto está bien»

Parpadear solo prueba que el código corre. No prueba que el reloj sea el esperado, que el estado seguro se respete ni que el tiempo sea correcto. Cada una de esas cosas se mide por separado, como en la tabla de pruebas de aceptación.

«Usar la biblioteca del fabricante habría sido más rápido»

Para obtener un parpadeo, sí. Pero habrías ocultado justo lo que este taller enseña: qué hace el arranque, qué hace el reloj, qué hace un pin. Cuando el generador de código produzca algo que no anda, necesitás saber qué debería haber hecho. Después de entender el esqueleto, usar la HAL del fabricante dentro de tu capa es una decisión informada.

«El número 72 MHz es una constante»

Es un objetivo. El hardware real puede quedar en 8 MHz si el cristal falla. El esqueleto guarda la frecuencia lograda y todos los cálculos de tiempo la consultan; un número escrito a mano en otro archivo es una mina esperando.

«Un bucle vacío sirve de retardo para siempre»

Su duración depende de la frecuencia, del compilador y de la memoria de la que se ejecuta. El laboratorio muestra que un solo flag de optimización duplica o recorta la espera. Para tiempo de verdad, un temporizador de hardware.

«El esqueleto es descartable, después lo rehago bien»

Es lo contrario: es la parte que más se reutiliza y la que menos conviene improvisar. Se le dedica cuidado precisamente porque después nadie lo vuelve a mirar y todo se apoya en él.

«Si cambia la placa, empiezo de cero»

Con la estructura en capas, cambian el linker script, el arranque, el reloj y la HAL de E/S; la aplicación y la base de tiempo (que le pregunta la frecuencia al reloj) no cambian. Cambiar de placa pasa a ser reemplazar un conjunto acotado de archivos.

15 · Práctica guiada

Ejercicios y variantes

1. Mover el LED

Querés usar un LED externo en PB5 (se enciende con nivel alto) en lugar del PC13. ¿Qué archivos tocás y qué cambia en cada uno?

Ver solución

Solo src/hal/stm32/hal_io.c: el reloj del puerto B (bit 3 de RCC_APB2ENR en lugar del de C), la configuración de PB5 en GPIOB_CRL (bits 20 a 23, porque 5 × 4 = 20) y las operaciones de BSRR (con lógica directa: encender pone el bit 5; apagar pone el bit 21). La aplicación, el reloj y el tiempo no cambian: es la prueba de que la capa cumple su trabajo.

2. Avisar cuando el reloj falla

Agregá un comportamiento para el caso en que reloj_iniciar() devuelve -1: el LED debe parpadear muy rápido (50 ms) para que el problema sea visible a simple vista. ¿Dónde lo implementarías?

Ver solución

En app.c (la aplicación decide cómo señalar un estado), consultando una función nueva de la interfaz de reloj, por ejemplo bool reloj_ok(void), que devuelva si reloj_frecuencia() alcanzó el objetivo. En app_paso(): if (!reloj_ok()) medio = 50;. Así el LED se convierte en un indicador de diagnóstico y no hace falta un depurador para saber que el cristal no arrancó.

3. Otro reloj

Si el chip se configurara a 48 MHz, ¿cuánto vale el LOAD del SysTick para 1 ms? ¿Hay que cambiar algo en tiempo.c?

Ver solución

LOAD = 48.000.000 / 1000 − 1 = 47.999 (0xBB7F). No hay que tocar tiempo.c: lee la frecuencia de reloj_frecuencia(). Sí cambia reloj.c (el multiplicador del PLL: × 6 con el cristal de 8 MHz, y los estados de espera de la flash, que para 48 MHz son 1, no 2).

4. Medir el resultado

Con un analizador lógico medís un semiperíodo de 501,3 ms donde esperabas 500. ¿Es un error preocupante? ¿Qué lo explica?

Ver solución

El error es (501,3 − 500) / 500 = 0,26 %, dentro del 2 % que fijamos. Lo explican el tiempo que tardan en ejecutarse las instrucciones entre las esperas, el arranque del SysTick en cada llamada a esperar_ms y la propia tolerancia del cristal (unas decenas de partes por millón). Es un error de sistema esperable en una espera ocupada; para precisión mayor se usaría un temporizador que no se detenga entre esperas (tema 17).

5. Una espera con interrupción

Describí en tres frases cómo cambiarías esperar_ms para que el procesador pueda hacer otra cosa mientras cuenta.

Ver solución

Se habilita la interrupción del SysTick (bit TICKINT) para que se dispare cada 1 ms; su rutina SysTick_Handler incrementa un contador global ms_contador. La aplicación guarda la hora de inicio de cada intervalo y, en cada pasada de su bucle, compara ms_contador - inicio con la duración que busca, sin bloquearse. Es la base de los planificadores por tiempo de los temas 17 a 19.

6. El orden de la inicialización

Un colega mueve en io_iniciar() la línea que da reloj a los puertos al final de la función. ¿Qué pasa y por qué?

Ver solución

Las escrituras a GPIOC_CRH y GPIOA_CRL ocurrirían con los puertos sin reloj y se perderían en silencio (tema 9). El LED quedaría como entrada flotante y el pulsador sin pull-up: el programa grabaría y correría, y no ocurriría nada visible. Es el error clásico de orden, a seis líneas de distancia de su causa.

16 · Síntesis

Ideas para recordar

  • Un esqueleto de firmware (bring-up) es el programa mínimo que demuestra que arranque, reloj, E/S y tiempo funcionan; se construye y se verifica paso por paso.
  • El linker script fija las regiones de memoria y reparte las secciones; el código de arranque define la tabla de vectores y un Reset_Handler que copia .data, limpia .bss, configura el reloj y llama a main().
  • El reloj se configura en orden (cristal, estados de espera de la flash, PLL, divisores, conmutación), con límites de tiempo en las esperas y guardando la frecuencia realmente lograda.
  • El módulo de E/S habla en el idioma del producto, deja las salidas en estado seguro antes de configurarlas y encierra la lógica invertida del hardware.
  • La base de tiempo se apoya en un temporizador de hardware (SysTick) calculado a partir de la frecuencia real; un bucle vacío depende del reloj, del compilador y de la memoria.
  • La aplicación solo usa interfaces: no menciona registros, pines ni frecuencias, y los archivos incluyen solo lo que necesitan.
  • La verificación se hace con mediciones y pruebas de aceptación: arranque, reloj, tiempos, estado seguro, tamaño y ausencia de dependencias del fabricante.
  • Se avanza de a un paso comprobable; el esqueleto verificado se congela con una etiqueta para poder volver a él cuando aparece un problema difícil.

¿Listo para el bloque siguiente?

Antes de avanzar, intentá reconstruir el esqueleto sin mirar: ¿qué archivos hay, qué hace cada uno y en qué orden se ejecutan desde el reinicio hasta el primer parpadeo? Y respondé qué mediciones demostrarían que está bien hecho. Si podés, estás listo para ponerle comportamiento en tiempo real.

El próximo bloque se ocupa justamente de eso. En el tema 13 empezamos por qué significa el tiempo real: plazos, latencia, jitter y la diferencia entre tiempo real duro y blando; la base para reemplazar las esperas ocupadas de este esqueleto por interrupciones, temporizadores y planificación.