← Índice del curso

Sistemas Embebidos · Tema 11

Organizar un proyecto de firmware

Un firmware que empieza en un archivo termina en cincuenta. Si cada archivo cae donde cae, el proyecto se vuelve una maraña en la que cambiar una línea rompe otra a tres carpetas de distancia. La estructura de archivos, carpetas y configuración no es un asunto de prolijidad: es la forma concreta que toma el diseño por capas.

01 · Punto de partida

De un archivo a cincuenta

Los ejemplos de los temas anteriores cabían en una pantalla. Un producto real, no. Un termostato con sensor, pantalla, comunicaciones, control y almacenamiento tiene varias decenas de archivos, escritos durante meses por más de una persona. En ese tamaño aparecen problemas que no existían a escala pequeña: nadie sabe dónde está cada cosa, un cambio de pines obliga a revisar diez archivos, compilar tarda cada vez más y dos módulos terminan dependiendo uno del otro en círculo.

La mayor parte de esos problemas se evitan con decisiones tempranas y baratas: cómo se divide el código en módulos, qué muestra cada archivo de cabecera, cómo se ordenan las carpetas y dónde viven los parámetros de configuración. Corregirlas más tarde es posible, pero cuesta mucho más, porque hay que mover código que ya está en uso.

Este tema es el puente entre la teoría de capas del anterior y el taller del tema 12, donde vas a construir un esqueleto de firmware completo. Todo lo que se explica acá se usa en ese taller.

02 · La unidad básica

El módulo: interfaz y implementación

En C, la unidad natural de organización es el módulo: un par de archivos, uno .h con la interfaz pública y uno .c con la implementación. El .h responde «¿qué se puede pedirle a este módulo?»; el .c contiene el «¿cómo lo hace?». Quien usa el módulo incluye solo el .h.

/* tmp102.h — driver del sensor de temperatura TMP102 (interfaz pública) */
#ifndef TMP102_H
#define TMP102_H

#include <stdint.h>

/* Lee la temperatura en décimas de grado (250 = 25,0 °C).
 * Devuelve 0 si salió bien y -1 si falló el bus. No se puede llamar desde una ISR. */
int tmp102_leer(int16_t *decimas);

#endif /* TMP102_H */
/* tmp102.c — implementación */
#include "tmp102.h"
#include "hal/hal_i2c.h"

#define TMP102_DIR  0x48                  /* privado: no aparece en el .h */

static int16_t ultima_lectura;            /* privada del módulo: static */

static int16_t convertir(uint8_t alto, uint8_t bajo) {      /* función privada */
    int16_t crudo = (int16_t)((alto << 8) | bajo) >> 4;
    return (int16_t)((crudo * 5) / 8);
}

int tmp102_leer(int16_t *decimas) {       /* función pública, declarada en el .h */
    uint8_t b[2];
    if (i2c_leer_registro(TMP102_DIR, 0x00, b, 2) != 0) return -1;
    ultima_lectura = convertir(b[0], b[1]);
    *decimas = ultima_lectura;
    return 0;
}

Lo que importa es la asimetría. El .h es corto y estable; el .c puede cambiar por completo sin que nadie lo note. La palabra static aplicada a una función o variable global la vuelve privada del archivo: nadie más puede verla ni usarla por error. Es la herramienta que hace cumplir el ocultamiento de información en C.

Algunas reglas que hacen que un módulo sea bueno:

  • Una responsabilidad. «Maneja el sensor de temperatura» es un módulo; «maneja el sensor, la pantalla y un poco de red» es una fuente de problemas.
  • Prefijo del módulo en todo lo público (tmp102_leer, pid_calcular). En C no hay espacios de nombres; el prefijo evita choques y dice de dónde viene cada función.
  • Tamaño manejable. Si un .c pasa de unos pocos cientos de líneas, probablemente son dos módulos.
  • Estado privado. Las variables del módulo son static; se accede a ellas con funciones. Una variable global pública es un acuerdo tácito entre todos los archivos, y los acuerdos tácitos se rompen.

03 · El lado público

Qué va (y qué no va) en una cabecera

Un archivo de cabecera no es compilado por sí mismo: el preprocesador lo pega, tal cual, en cada archivo .c que lo incluye. Esa idea simple, texto copiado, explica casi todas las reglas.

Qué puede y qué no puede contener un archivo de cabecera.
Va en la cabeceraNo va en la cabecera
Declaraciones de funciones públicas (prototipos)El cuerpo de las funciones (salvo static inline cortas)
Tipos (typedef, struct, enum) que forman parte de la interfazDefiniciones de variables globales
Constantes y macros públicasDetalles internos: registros, constantes privadas
Declaraciones extern de las variables que sí deben ser públicasInclusión de más cabeceras de las necesarias
Un include guardCódigo que se ejecuta

El include guard

Si una cabecera se incluye dos veces en el mismo archivo (algo muy fácil de provocar: main.c incluye A.h y B.h, y B.h también incluye A.h), las definiciones de tipos se repiten y el compilador se queja de redefiniciones. La solución estándar es el include guard: las tres líneas que envuelven el contenido de toda cabecera:

#ifndef HAL_IO_H        /* si todavía no se definió esta marca... */
#define HAL_IO_H        /* ...definirla, para que la segunda vez no entre */

/* contenido de la cabecera */

#endif /* HAL_IO_H */
/* Alternativa no estándar pero muy extendida: una sola línea, #pragma once, al comienzo */

Variables en cabeceras: el error del enlazador

Otra consecuencia del «texto pegado»: una variable definida en una cabecera se define una vez por cada archivo .c que la incluye. Cada archivo objeto la contiene, y al enlazar aparecen duplicadas.

/* MAL — contador.h */
int contador = 0;                /* se define en cada .c que incluya este archivo */
/* error al enlazar: multiple definition of `contador' */

/* BIEN — contador.h */
extern int contador;             /* solo se declara: "existe en algún lado" */

/* BIEN — contador.c */
int contador = 0;                /* se define una única vez */

La diferencia es la misma que entre declarar y definir: la declaración dice que algo existe; la definición lo crea y le reserva memoria. En una cabecera solo debe haber declaraciones. Y mejor aún: evitar la variable pública y exponer funciones contador_leer() y contador_incrementar(), que mantienen el estado privado.

Dos hábitos más. Incluir lo mínimo: si una cabecera solo necesita saber que existe un tipo, a veces alcanza con una declaración adelantada en lugar de incluir otra cabecera entera; cada #include de más es una dependencia más. Y una cabecera debe compilar sola: si para usarla hay que incluir otra antes, esa otra falta dentro de ella.

04 · El árbol de archivos

La estructura de carpetas

Una buena estructura de carpetas hace visible la arquitectura. Quien abre el proyecto por primera vez debería poder ver las capas del tema 10 en el propio árbol:

termostato/
├── src/
│   ├── app/              main.c, termostato.c, termostato.h        (la aplicación)
│   ├── servicios/        pid.c, pid.h                              (lógica reutilizable)
│   ├── drivers/          tmp102.c, tmp102.h, pantalla.c ...        (componentes externos)
│   └── hal/
│       ├── hal_io.h, hal_i2c.h                                     (interfaces)
│       ├── stm32/        hal_io.c, hal_i2c.c                       (implementación para el chip)
│       └── pc/           hal_io.c, hal_i2c.c                       (simulación para pruebas)
├── vendor/               CMSIS y HAL del fabricante (no se modifican)
├── config/               config.h, config_pines.h
├── linker/               stm32f103.ld, startup_stm32f103.c
├── test/                 pruebas que corren en el PC
├── build/                objetos, .elf, .map  (no se versiona)
├── docs/                 requisitos, diseño, decisiones
├── Makefile
└── README.md             cómo compilar, grabar y probar

Cada carpeta responde a una pregunta y tiene una dependencia permitida:

  • src/app puede incluir lo de servicios, drivers y las interfaces de hal; nunca vendor ni los archivos de hal/stm32.
  • src/hal/stm32 y src/hal/pc son dos implementaciones de las mismas interfaces. La compilación elige cuál; el resto del proyecto no cambia. La del PC permite correr la lógica en la computadora (tema 5).
  • vendor/ contiene código ajeno. Se trata como una biblioteca de terceros: no se edita, se actualiza de forma controlada, y solo la HAL lo incluye.
  • config/ concentra los parámetros del producto en un lugar conocido.
  • build/ se genera y se borra sin riesgo; jamás se guarda en el control de versiones.
  • test/ y docs/ viven al lado del código, no en otro lado, para que se mantengan actualizados.

No existe una estructura única correcta. Lo que importa es que sea predecible (el nombre de la carpeta dice qué hay), coherente con las capas y que se cumpla. Un proyecto chico puede fusionar carpetas; uno grande las subdivide. Lo grave es el árbol que crece sin criterio hasta que ya nadie sabe dónde poner un archivo nuevo.

05 · Un solo lugar para los parámetros

Configuración centralizada

Cada producto tiene valores que lo definen: frecuencia del reloj, velocidad de la consola, tamaños de buffers, períodos de control, pines usados. Si esos valores están desperdigados por el código, cambiarlos exige buscar y es fácil olvidarse de uno. La práctica es reunirlos en archivos de configuración, con nombre, en la carpeta config/:

/* config.h — parámetros del producto */
#ifndef CONFIG_H
#define CONFIG_H

#define F_CPU_HZ            72000000UL
#define UART_BAUDIOS        115200UL
#define BUFFER_RX_TAM       128          /* bytes; potencia de 2 para usar máscara */
#define PERIODO_CONTROL_MS  100

/* Verificaciones en tiempo de compilación: un valor imposible no llega a la placa */
_Static_assert(BUFFER_RX_TAM >= 64 && (BUFFER_RX_TAM & (BUFFER_RX_TAM - 1)) == 0,
               "BUFFER_RX_TAM debe ser una potencia de 2 y al menos 64");

#endif
/* config_pines.h — asignación de pines (un solo lugar) */
#ifndef CONFIG_PINES_H
#define CONFIG_PINES_H

#define LED_ESTADO_PUERTO   C
#define LED_ESTADO_PIN      13
#define LED_ESTADO_ACTIVO_BAJO  1       /* 1: el LED enciende con nivel bajo */

#define BOTON_PUERTO        A
#define BOTON_PIN           0

#endif

Ventajas que ya se ven: un cambio de placa toca un archivo; nadie escribe «13» en medio de una función sin que se sepa qué es; y _Static_assert convierte un requisito en algo que el compilador comprueba por vos. Algunas recomendaciones:

  • Nada de números mágicos. Un 128 suelto en el código es una promesa implícita de que dos lugares coinciden. Con BUFFER_RX_TAM, o coincide por construcción, o falla la compilación.
  • Un nombre, un lugar. Si un parámetro aparece definido en dos archivos con valores distintos, el comportamiento depende de cuál se incluya primero; es de los errores más silenciosos (el laboratorio lo muestra).
  • Constantes con tipo cuando se puede. static const o enum dan chequeo de tipos que una macro no tiene; las macros quedan para lo que debe resolverse en el preprocesador (tamaños de arreglos en C, selección de código).
  • Variantes por construcción. Para versiones del producto (otra placa, otro mercado), se elige el archivo de configuración o se pasa una marca al compilador (-DPLACA_V2), y el código usa #if en el archivo de configuración, no desparramado por el programa.
  • Separar configuración de compilación y de ejecución. Lo que se fija al compilar va aquí. Lo que el usuario puede cambiar en campo (una consigna, un horario) es un dato, no una macro, y se guarda en memoria no volátil (tema 40).

06 · Construir el árbol

El sistema de construcción

El Makefile del tema 5 listaba los objetos a mano. Con decenas de archivos eso no escala. Un esquema más general usa comodines para encontrar los fuentes, separa los objetos por plataforma y genera las dependencias de las cabeceras automáticamente:

PLATAFORMA ?= stm32                    # stm32 (placa) o pc (pruebas): make PLATAFORMA=pc

ifeq ($(PLATAFORMA),stm32)
  CC      = arm-none-eabi-gcc
  CFLAGS  = -mcpu=cortex-m3 -mthumb -Os -ffunction-sections -fdata-sections
  LDFLAGS = -T linker/stm32f103.ld -nostartfiles -Wl,--gc-sections
else
  CC      = gcc
  CFLAGS  = -O0 -g
  LDFLAGS =
endif

CFLAGS += -Wall -Wextra -Isrc -Iconfig -MMD -MP      # -MMD -MP: genera las dependencias de cabeceras

SRC = $(wildcard src/app/*.c src/servicios/*.c src/drivers/*.c src/hal/$(PLATAFORMA)/*.c)
OBJ = $(SRC:%.c=build/$(PLATAFORMA)/%.o)

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

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

-include $(OBJ:.o=.d)                                  # reutiliza las dependencias generadas

Tres ideas valen la pena:

  • PLATAFORMA elige la implementación de la HAL. Con make se compila para la placa con src/hal/stm32; con make PLATAFORMA=pc se compila para la computadora con src/hal/pc. La aplicación y los drivers son los mismos archivos en ambos casos. Es la separación en capas, hecha operativa.
  • Las opciones -Isrc -Iconfig agregan esas carpetas a la búsqueda de cabeceras, para escribir #include "hal/hal_i2c.h" y #include "config.h" sin ruta relativa.
  • Las opciones -MMD -MP hacen que el compilador escriba, para cada objeto, un archivo .d con las cabeceras de las que depende. Si se modifica una cabecera, make recompila solo los .c que la incluyen. Sin esto, cambiar un .h deja objetos viejos y produce errores de enlace o comportamientos fantasma que desaparecen con un make clean: una fuente de horas perdidas.

A medida que el proyecto crece se pasa a CMake o a herramientas equivalentes, que automatizan más, pero la idea es la misma: una descripción declarativa de qué fuentes forman cada versión.

07 · Orden compartido

Convenciones y herramientas de calidad

Cuando más de una persona toca el código, los acuerdos sobre estilo importan tanto como la arquitectura. Un conjunto mínimo:

  • Nombres. Archivos en minúscula; funciones y variables públicas con el prefijo del módulo; constantes y macros en mayúsculas; tipos terminados en _t. Nombres que dicen qué hacen (temperatura_decimas y no t2).
  • Formato automático. Una herramienta como clang-format con un archivo de configuración compartido elimina las discusiones de estilo y los cambios ruidosos en las revisiones.
  • Avisos como errores. Compilar con -Wall -Wextra y, en integración continua, -Werror. Un aviso ignorado hoy es un defecto mañana.
  • Análisis estático. Herramientas como cppcheck o los analizadores del propio compilador detectan desbordes, variables sin inicializar y fugas sin ejecutar el programa. Para sistemas críticos existen guías de codificación como MISRA C, que restringen las construcciones del lenguaje más propensas a errores.
  • Comentarios que explican el porqué. «Espera 20 ms: el sensor tarda eso en estabilizarse tras el encendido» sirve; «espera 20 ms» no. Las cabeceras documentan el contrato de cada función (bloqueo, errores, uso desde interrupciones).

Y las convenciones del repositorio: un .gitignore que excluya build/; vendor/ con una versión fija (copiada o como submódulo) y una nota de cuál es; etiquetas en cada versión entregada (tema 5); y un README.md que diga en pocas líneas cómo compilar, grabar y probar. Si un colega nuevo no puede compilar el proyecto en media hora leyendo el README, la documentación está incompleta.

08 · Analogía con programación

Un revisor de dependencias en Python

Vigilar las dependencias a mano no escala. El programa siguiente representa un proyecto como un diccionario (archivo → capa e includes) y revisa dos reglas: que ningún archivo dependa de una capa superior ni de las cabeceras del fabricante salvo la HAL, y que no haya inclusiones circulares. Se corre sobre un proyecto limpio y sobre uno con dos defectos.

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

CAPAS = {"app": 0, "servicios": 1, "drivers": 2, "hal": 3, "vendor": 4}


def proyecto(defectos=()):
    p = {   # archivo: (capa, [archivos que incluye])
        "app/main.c":         ("app", ["app/termostato.h", "hal/hal_io.h"]),
        "app/termostato.h":   ("app", ["servicios/pid.h", "hal/hal_io.h"]),
        "app/termostato.c":   ("app", ["app/termostato.h", "drivers/tmp102.h"]),
        "servicios/pid.h":    ("servicios", []),
        "servicios/pid.c":    ("servicios", ["servicios/pid.h"]),
        "drivers/tmp102.h":   ("drivers", ["hal/hal_i2c.h"]),
        "drivers/tmp102.c":   ("drivers", ["drivers/tmp102.h"]),
        "hal/hal_io.h":       ("hal", []),
        "hal/hal_i2c.h":      ("hal", []),
        "hal/stm32/hal_io.c": ("hal", ["hal/hal_io.h", "vendor/stm32f1xx.h"]),
        "hal/stm32/hal_i2c.c":("hal", ["hal/hal_i2c.h", "vendor/stm32f1xx.h"]),
        "vendor/stm32f1xx.h": ("vendor", []),
    }
    if "vendor_en_app" in defectos:
        p["app/termostato.c"][1].append("vendor/stm32f1xx.h")
    if "pid_conoce_app" in defectos:
        p["servicios/pid.h"][1].append("app/termostato.h")
    return p


def revisar(p):
    hallazgos = []
    for archivo, (capa, incs) in p.items():
        for h in incs:
            capa_h = p[h][0]
            if capa_h == "vendor" and capa != "hal":
                hallazgos.append(f"{archivo} incluye {h}: solo la HAL puede incluir cabeceras del fabricante")
            elif CAPAS[capa_h] < CAPAS[capa]:
                hallazgos.append(f"{archivo} ({capa}) incluye {h} ({capa_h}): la dependencia apunta hacia arriba")

    estado, camino = {}, []
    def visitar(x):
        estado[x] = 1
        camino.append(x)
        for h in p[x][1]:
            if estado.get(h) == 1:                         # volvimos a un archivo de la ruta actual
                ciclo = camino[camino.index(h):] + [h]
                hallazgos.append("inclusión circular: " + " -> ".join(ciclo))
            elif h not in estado:
                visitar(h)
        camino.pop()
        estado[x] = 2
    for x in p:
        if x not in estado:
            visitar(x)
    return hallazgos


for titulo, defectos in (("Proyecto limpio", ()), ("Proyecto con defectos", ("vendor_en_app", "pid_conoce_app"))):
    print(titulo)
    problemas = revisar(proyecto(defectos))
    print("  sin hallazgos" if not problemas else "\n".join("  - " + t for t in problemas))

Resultado: el proyecto limpio no tiene hallazgos. El que tiene defectos informa tres: la aplicación incluye la cabecera del fabricante, servicios/pid.h incluye una cabecera de la aplicación (dependencia hacia arriba) y, como consecuencia, la inclusión circular app/termostato.h -> servicios/pid.h -> app/termostato.h. Un solo defecto de diseño (la biblioteca que conoce a la aplicación) produce dos síntomas distintos. Probá con solo "pid_conoce_app" o agregá una línea que haga que hal/hal_io.h incluya drivers/tmp102.h.

Este tipo de revisión se automatiza en la integración continua: cada cambio al repositorio corre un revisor y rechaza los que rompen la estructura. Herramientas reales como include-what-you-use, las reglas de dependencias de CMake o simples grep sobre los #include cumplen la misma función. Lo importante es la idea: una regla de arquitectura que nadie comprueba es un deseo.

09 · Exploración interactiva

Analizar un proyecto

El laboratorio carga el proyecto del termostato (12 archivos en las cinco capas) y aplica el mismo tipo de revisión del apartado anterior, ampliada con tres reglas más: cabeceras sin include guard incluidas dos veces, variables definidas en cabeceras y macros duplicadas con valores distintos. Cada casilla introduce un defecto en el proyecto; mirá cómo cambian la lista de includes y los hallazgos.

Defectos que se pueden introducir

Archivos del proyecto—
Errores—
Avisos—
Resultado—

Qué incluye cada archivo

—

Hallazgos del revisor

—

Marcá una casilla para introducir un defecto y ver cómo lo detecta el revisor.

Recorrido guiado

  1. Sin ninguna casilla: 12 archivos, ningún hallazgo. Mirá la lista de includes: cada flecha apunta hacia una capa inferior o igual, y stm32f1xx.h solo es incluido por los dos archivos de la HAL.
  2. Marcá La aplicación incluye stm32f1xx.h: error de capa. La aplicación ya conoce el chip; el día que cambie la familia habrá que reescribirla.
  3. Desmarcala y marcá pid.h incluye termostato.h: aparecen dos errores, una dependencia hacia arriba y una inclusión circular. Un solo defecto de diseño, dos síntomas.
  4. Probá hal_io.h sin include guard: main.c incluye hal_io.h directamente y también a través de termostato.h, y lo recibe dos veces. Es un error de compilación que no se debe a nada que escribiste mal en main.c.
  5. Con tmp102.h define una variable, el error no sale al compilar sino al enlazar: cada archivo .c compila bien por separado, pero ambos contienen su propia copia de la variable y el enlazador no sabe con cuál quedarse.
  6. Marcá LED_PIN definido en dos archivos: la única entrada del análisis es un aviso, no un error. El proyecto compila y se ejecuta, pero con una configuración ambigua. Ese tipo de defecto es el que llega al cliente.
  7. Marcalas todas: más de media docena de hallazgos, de causas independientes. Probablemente un proyecto real que nació sin estructura se vea así.
Desafío: ¿por qué no basta con que compile?

De los seis defectos, solo dos impiden compilar o enlazar: el include guard faltante y la variable en la cabecera. Esos se descubren enseguida. Los otros cuatro (la aplicación que incluye al fabricante, la HAL que incluye a un driver, la biblioteca que conoce a la aplicación y la macro duplicada) compilan sin quejarse: nada falla hoy, y el costo aparece al cambiar de chip, al probar en el PC, al reutilizar un módulo o cuando el LED se enciende en el pin equivocado. Son defectos de diseño, no de código, y solo los encuentra una revisión que mire la estructura. Por eso esa revisión se automatiza, como en el revisor del apartado anterior.

10 · Método de trabajo

Método para estructurar un proyecto

  1. Dibujá las capas y los módulos en una página antes de crear archivos: qué hace cada uno, de quién depende. Si no entra en una hoja, el diseño es demasiado enredado.
  2. Creá el árbol de carpetas con las capas, aunque al principio tenga un solo archivo por carpeta.
  3. Escribí primero las cabeceras. Cada módulo comienza con su .h: nombre, funciones, contrato. Si una cabecera es difícil de escribir, el módulo todavía no está bien pensado.
  4. Hacé privado todo lo que se pueda con static, y exponé funciones, no variables.
  5. Centralizá la configuración y ponele verificaciones en tiempo de compilación.
  6. Armá la compilación temprano: un comando para la placa y uno para el PC, con dependencias de cabeceras automáticas y avisos activados.
  7. Automatizá las reglas de arquitectura: un revisor de #include en la integración continua, para que la estructura no se degrade sin que nadie lo vea.
  8. Documentá en el README cómo compilar, grabar, probar y dónde va cada cosa, y mantenelo al día cuando el proyecto cambie.

Un criterio de salud simple: agregar un componente nuevo (un sensor, una pantalla) debería implicar crear archivos nuevos y tocar muy pocos existentes. Si cada cambio obliga a editar quince archivos, la estructura no está cumpliendo su trabajo.

11 · Comprensión

Confusiones frecuentes

«Poner todo en pocos archivos grandes es más simple»

Es más simple hoy y mucho más caro después: un archivo de tres mil líneas no se puede probar por partes, genera conflictos cuando dos personas lo editan y esconde dependencias. Dividir en módulos con interfaces cortas cuesta poco al principio y evita la maraña.

«Un include guard evita todos los problemas de inclusión»

Evita las redefiniciones por incluir dos veces, pero no resuelve las dependencias circulares ni las que apuntan en el sentido equivocado: un ciclo protegido por guards puede compilar y dejar un tipo sin declarar según el orden de inclusión.

«Puedo definir una variable global en la cabecera si la necesitan varios archivos»

Se define una vez por cada archivo que incluye la cabecera y el enlazador se queja. Lo correcto es extern en la cabecera y la definición en un único .c; mejor todavía, que el módulo oculte la variable y ofrezca funciones.

«Los #define de configuración pueden estar donde se usen»

Si el mismo parámetro vive en dos lugares, tarde o temprano divergen. Un valor, un nombre, un archivo, sobre todo para pines, tamaños y tiempos.

«La estructura de carpetas es cuestión de gusto»

Hay libertad en los nombres, pero la estructura expresa la arquitectura: dónde vive el código que toca el chip, cuál se puede probar en el PC, qué no se modifica. Una estructura que no refleja las capas obliga a descubrirlas leyendo los #include.

«Con una buena estructura no hace falta revisarla»

La estructura se degrada con cada atajo: un «solo esta vez» que incluye una cabecera del fabricante en la aplicación. La única defensa es una comprobación automática en cada cambio.

12 · Práctica guiada

Predecí antes de comprobar

1. ¿Declaración o definición?

Indicá cuáles de estas líneas pueden estar en una cabecera incluida por varios archivos: (a) extern int modo;; (b) int modo = 0;; (c) void led_encender(void);; (d) static inline int doble(int x) { return 2 * x; }; (e) const uint8_t tabla[4] = {1, 2, 3, 4};.

Ver solución

Pueden estar: (a) declaración; (c) prototipo; (d) función static inline, que cada archivo tiene como copia privada. No deben estar: (b) define una variable en cada archivo (error de enlace «multiple definition»), y (e) define una tabla en cada archivo: ocupa flash por cada inclusión y, según el compilador, produce el mismo error. La tabla se define en un solo .c y se declara con extern const uint8_t tabla[4]; en la cabecera.

2. Armar el include guard

Escribí las tres líneas de protección para una cabecera llamada driver_pantalla.h y explicá qué ocurre al incluirla dos veces.

Ver solución

#ifndef DRIVER_PANTALLA_H, #define DRIVER_PANTALLA_H al comienzo, y #endif al final. La primera vez, la marca no existe: se define y se procesa el contenido. La segunda vez, la marca ya existe: el preprocesador salta todo hasta el #endif, por lo que los tipos no se declaran dos veces.

3. Ubicar archivos

Ubicá en el árbol de carpetas: (a) la rutina que escribe en GPIOB->BSRR; (b) un controlador PID; (c) la interfaz hal_uart.h; (d) la tabla de pines del producto; (e) la biblioteca CMSIS descargada del fabricante; (f) un banco de pruebas que corre en el PC.

Ver solución

(a) src/hal/stm32/. (b) src/servicios/. (c) src/hal/. (d) config/config_pines.h. (e) vendor/. (f) test/. Notá que (a) es la única que menciona un registro del chip, y está en la carpeta específica de la plataforma.

4. Compilar para el PC

Con el Makefile del apartado 6, ¿qué cambia al ejecutar make PLATAFORMA=pc? ¿Qué archivos se compilan que antes no, y cuáles dejan de compilarse?

Ver solución

Se usa gcc en lugar del compilador cruzado y se prescinde de las opciones del linker script. De la HAL se compilan los archivos de src/hal/pc/ (la simulación) en lugar de los de src/hal/stm32/. La aplicación, los servicios y los drivers son exactamente los mismos. El resultado es un ejecutable para la computadora que corre la lógica real del producto.

5. Un cambio de pin

El LED de estado pasa del pin PC13 al PB5. ¿Cuántos archivos debería tocar el cambio en un proyecto bien estructurado? ¿Y qué indica si tocás más?

Ver solución

Uno solo: config/config_pines.h (y, si la asignación del pin usa registros distintos, la función de inicialización de la HAL de la plataforma). Si hay que editar la aplicación o varios drivers, es señal de que el pin está escrito en el código disperso (un número mágico) o de que el hardware se filtra por las capas.

6. Detectar el defecto

En tmp102.c (un driver) encontrás #include "app/termostato.h" para usar un tipo de la aplicación. ¿Qué regla se rompe y cómo lo resolverías?

Ver solución

Se rompe la regla de que las dependencias apuntan hacia abajo: un driver (capa baja) conoce la aplicación (capa alta). Consecuencias: el driver no se puede reutilizar en otro producto y se corre el riesgo de ciclos de inclusión. Se resuelve definiendo el tipo en el propio driver (o en una cabecera común de tipos que ambos incluyan) y haciendo que la aplicación use el del driver, no al revés.

13 · Síntesis

Ideas para recordar

  • La estructura de archivos y carpetas es la forma concreta del diseño por capas; las decisiones tempranas son baratas y corregirlas después es caro.
  • Un módulo es un par .h (interfaz pública, corta y estable) y .c (implementación); lo privado se declara static, lo público lleva el prefijo del módulo y las variables se exponen mediante funciones.
  • La cabecera se pega como texto en cada archivo que la incluye: lleva declaraciones, tipos y macros públicas, nunca definiciones de variables; todas llevan include guard.
  • El árbol de carpetas refleja las capas (app, servicios, drivers, hal con una carpeta por plataforma, vendor, config, test, build) y cada carpeta tiene dependencias permitidas.
  • La configuración vive en un lugar, sin números mágicos, con verificaciones en tiempo de compilación (_Static_assert) y variantes por construcción.
  • El sistema de construcción encuentra los fuentes, elige la plataforma (placa o PC), recompila lo mínimo con las dependencias de cabeceras (-MMD -MP) y activa los avisos.
  • Las convenciones de nombres, el formato automático, el análisis estático y los avisos como errores mantienen el orden entre varias personas; el repositorio ignora build/, fija vendor/ y se documenta en el README.
  • Las reglas de arquitectura se automatizan (por ejemplo, un revisor de #include): una regla que nadie comprueba es un deseo, y los defectos de diseño compilan sin quejarse.

¿Listo para el tema 12?

Antes de avanzar, explicá por qué una variable definida en una cabecera produce un error de enlace; dibujá el árbol de carpetas de un proyecto con sensor, pantalla y comunicaciones indicando qué puede incluir cada carpeta; y decí qué haría falta cambiar para compilar la lógica en tu PC. Si podés justificar las tres respuestas, tenés la estructura sobre la que construir.

En el próximo tema cerramos el bloque con un taller: vas a armar un esqueleto de firmware desde cero, con arranque, reloj, GPIO y un módulo de aplicación sobre una placa real, aplicando la estructura de este tema.