MCU & FPGA Builds Introdução ao CMake em Projetos STM32 Gerados pelo CubeMX

Introdução ao CMake em Projetos STM32 Gerados pelo CubeMX


A história do CMake e o problema que ele veio resolver

O CMake surge no início dos anos 2000, criado por Bill Hoffman no contexto do Kitware, com um objetivo muito claro: resolver o problema da portabilidade e da complexidade crescente dos sistemas de build. Até então, projetos em C e C++ dependiam fortemente de Makefiles escritos manualmente, profundamente acoplados ao sistema operacional, ao compilador e até mesmo à estrutura de diretórios da máquina do desenvolvedor. Em projetos pequenos isso era administrável, mas à medida que o software crescia — especialmente em ambientes multiplataforma — o custo de manutenção dos Makefiles se tornava proibitivo.

O problema se agravava em projetos embarcados. Cada microcontrolador exige um conjunto específico de flags de compilação, scripts de linkedição, opções de ABI (Application Binary Interface), suporte a assembly, bibliotecas de runtime reduzidas (newlib-nano, nosys, etc.) e, frequentemente, múltiplos perfis de build (Debug, Release, Profiling). Manter tudo isso sincronizado manualmente em Makefiles diferentes era uma fonte constante de erros sutis, builds não reproduzíveis e dependência excessiva do conhecimento tácito do desenvolvedor.

O CMake nasce justamente como uma ferramenta de meta-build. Ele não compila código diretamente. Em vez disso, descreve o projeto em um nível mais alto, declarativo, e gera os arquivos de build adequados para o ambiente alvo: Makefiles, Ninja, projetos para IDEs como Eclipse, Visual Studio ou Xcode. Essa abordagem separa claramente o que o projeto é de como ele será construído em cada plataforma.

No contexto de sistemas embarcados, essa separação é fundamental. O mesmo projeto pode ser compilado com GCC bare-metal (arm-none-eabi-gcc), Clang, ou até toolchains customizadas fornecidas por fabricantes. O CMake permite encapsular essas diferenças em arquivos de toolchain, mantendo o CMakeLists.txt do projeto limpo, legível e focado na arquitetura do software, e não nos detalhes do compilador.

Por que o CMake se encaixa tão bem com projetos STM32

Projetos STM32 gerados pelo CubeMX possuem uma característica importante: eles não são apenas aplicações, mas sim sistemas completos, compostos por código C, assembly, bibliotecas HAL, CMSIS, drivers específicos do microcontrolador, além de um script de linkedição altamente acoplado ao layout de memória do chip. O CubeMX tradicionalmente gera projetos voltados para IDEs específicas (STM32CubeIDE, Keil, IAR), mas isso cria um forte lock-in da ferramenta.

Ao adotar o CMake como sistema de build, o projeto STM32 passa a ser agnóstico de IDE. Ele pode ser compilado via linha de comando, integrado a pipelines de CI/CD, analisado por ferramentas como clangd, clang-tidy e cppcheck, ou aberto em qualquer editor moderno. Além disso, o CMake facilita a modularização do projeto, permitindo separar claramente o código gerado pelo CubeMX do código de aplicação, das bibliotecas externas e das bibliotecas internas reutilizáveis.

Outro ponto crucial é que o CMake trabalha com o conceito de targets. Em vez de pensar apenas em arquivos .c e .o, o desenvolvedor passa a modelar o sistema em termos de executáveis, bibliotecas estáticas, bibliotecas de interface e dependências explícitas entre elas. Isso se encaixa perfeitamente no mundo embarcado moderno, onde reutilização, isolamento de responsabilidades e controle fino de símbolos e includes são essenciais para manter projetos grandes sustentáveis ao longo do tempo.

CMake como fundação para arquiteturas escaláveis em firmware

Quando usamos CMake corretamente em um projeto STM32, deixamos de ter apenas um “script de build” e passamos a ter uma descrição formal da arquitetura do firmware. Bibliotecas como drivers, middlewares, stacks de comunicação ou camadas de abstração de hardware podem ser modeladas como bibliotecas estáticas independentes. Cada uma define seus próprios includes, defines e flags de compilação, que são propagados automaticamente para quem as consome.

Isso elimina uma classe inteira de problemas comuns em projetos embarcados: defines globais espalhados, includes duplicados, dependências implícitas e flags de compilação inconsistentes entre módulos. Além disso, o CMake facilita a criação de perfis de build (Debug, Release, Coverage, Profiling), algo essencial quando se trabalha com análise temporal, WCET, uso de heap, watermark de pilha e instrumentação de RTOS.

Em resumo, o CMake não é apenas uma alternativa ao Makefile tradicional. Ele é uma camada de engenharia de software aplicada ao processo de build, especialmente poderosa quando combinada com projetos STM32 gerados pelo CubeMX.


Estrutura básica de um projeto CMake para STM32 gerado pelo CubeMX

Ao abrir um projeto STM32 baseado em CMake, a primeira impressão costuma ser de estranhamento para quem vem de Makefiles tradicionais ou do ecossistema fechado de IDEs como o STM32CubeIDE. Isso acontece porque o CMake não descreve comandos de compilação de forma direta, mas sim relações estruturais entre os elementos do projeto. Antes de entender cada diretiva isoladamente, é fundamental compreender a estrutura conceitual que sustenta um projeto CMake bem organizado para firmware.

Em projetos STM32 modernos, especialmente aqueles que nascem a partir do CubeMX, o ponto de entrada é sempre um arquivo chamado CMakeLists.txt localizado na raiz do projeto. Esse arquivo funciona como o manifesto do firmware: ele declara qual é o projeto, quais linguagens são usadas, quais bibliotecas fazem parte do sistema, quais arquivos devem ser compilados e como tudo isso se conecta para gerar o binário final, geralmente um arquivo .elf.

Uma característica importante é que esse CMakeLists.txt de nível superior não deve conter tudo. Em projetos bem estruturados, ele atua como um orquestrador, delegando responsabilidades para outros CMakeLists.txt menores espalhados pela árvore do projeto. É exatamente isso que vemos em projetos gerados ou adaptados a partir do CubeMX, onde o código automático fica isolado em uma subpasta, normalmente algo como cmake/stm32cubemx, enquanto o código da aplicação permanece sob controle direto do desenvolvedor.

Essa separação é essencial. O código gerado pelo CubeMX pode ser regenerado a qualquer momento, enquanto o código da aplicação e a lógica de build precisam permanecer estáveis. O CMake favorece esse modelo ao permitir a inclusão explícita de subdiretórios, tornando claro o limite entre código gerado e código autoral.

A diretiva cmake_minimum_required: controle de compatibilidade e previsibilidade

Toda configuração CMake começa com a diretiva cmake_minimum_required. Embora muitos desenvolvedores a encarem como uma formalidade, ela desempenha um papel crítico em projetos embarcados. Essa diretiva define a versão mínima do CMake necessária para interpretar corretamente o script, garantindo que comportamentos antigos, obsoletos ou inconsistentes não sejam utilizados de forma implícita.

Em firmware, previsibilidade é tudo. Diferenças sutis no comportamento do sistema de build podem resultar em flags omitidas, bibliotecas ligadas em ordem incorreta ou até mesmo em binários que funcionam em Debug, mas falham em Release. Ao exigir explicitamente uma versão mínima, o projeto estabelece uma linha de base clara para todos os desenvolvedores e para sistemas de integração contínua.

Na prática, projetos STM32 modernos costumam exigir versões relativamente recentes do CMake, pois funcionalidades como FetchContent, presets (CMakePresets.json) e melhor suporte a toolchains cruzadas evoluíram bastante nas últimas versões. Definir essa versão logo no início do arquivo evita erros difíceis de rastrear mais adiante e deixa claro que o projeto não foi pensado para ambientes legados.

Além disso, essa diretiva influencia o modo como o CMake interpreta várias outras instruções. Sem ela, o CMake entra em um modo de compatibilidade que pode alterar silenciosamente o comportamento de comandos fundamentais. Em sistemas embarcados, onde cada byte e cada flag importam, esse tipo de ambiguidade é simplesmente inaceitável.

A diretiva project(): identidade formal do firmware

Logo após definir a versão mínima do CMake, encontramos a diretiva project(). Essa é uma das instruções mais importantes de todo o sistema de build, pois é nela que o firmware passa a existir formalmente como um projeto CMake. Ao declarar o projeto, você não está apenas dando um nome simbólico ao binário final, mas também inicializando internamente todo o modelo de build que o CMake utilizará.

Em projetos STM32, o nome do projeto geralmente coincide com o nome do arquivo final .elf, e frequentemente reflete o hardware alvo ou a função principal do firmware. Isso não é apenas uma convenção estética. O nome do projeto passa a ser utilizado implicitamente em diversas outras diretivas, como na criação do executável, na geração de mensagens de log e na organização dos artefatos de build.

Além do nome, a diretiva project() também declara as linguagens utilizadas. Em firmware STM32, isso quase sempre inclui C e Assembly. Ao fazer isso explicitamente, o CMake passa a configurar corretamente compiladores, regras de build e extensões de arquivos para cada linguagem, algo fundamental quando se trabalha com arquivos .s ou .S de startup e rotinas de baixo nível.

Outro ponto relevante é que a chamada a project() marca o momento em que variáveis globais do CMake passam a estar disponíveis de forma consistente. Por isso, boas práticas recomendam que a maior parte das configurações estruturais do projeto aconteça após essa diretiva, garantindo que o ambiente esteja completamente inicializado.


Definição do padrão da linguagem e controle do compilador

Em sistemas embarcados, definir explicitamente o padrão da linguagem não é um detalhe estético, mas um requisito técnico. O comportamento do compilador, a disponibilidade de recursos da linguagem e até a geração de código variam significativamente entre diferentes padrões do C. O CMake resolve essa questão de forma clara e declarativa por meio de variáveis específicas que descrevem qual versão da linguagem o projeto exige, e não apenas qual compilador será usado.

A variável CMAKE_C_STANDARD estabelece o padrão mínimo do C que o projeto aceita. Em projetos STM32 gerados pelo CubeMX, é comum adotar o C11, pois ele oferece um bom equilíbrio entre modernidade e compatibilidade com toolchains bare-metal. Ao definir esse valor, o CMake garante que o compilador seja invocado com as flags corretas para ativar esse padrão, independentemente de qual GCC esteja instalado no sistema.

Complementando isso, CMAKE_C_STANDARD_REQUIRED força o compilador a respeitar exatamente o padrão definido. Sem essa diretiva, o compilador pode silenciosamente aceitar um padrão diferente, mais antigo ou mais permissivo, o que abre espaço para comportamentos inesperados. Em firmware, onde cada compilação precisa ser reprodutível, esse tipo de ambiguidade não é aceitável.

Já a variável CMAKE_C_EXTENSIONS controla se extensões específicas do compilador — como as extensões GNU — podem ser utilizadas. Em projetos STM32, muitas bibliotecas, incluindo partes do HAL e até do CMSIS, dependem de extensões do GCC. Por isso, permitir essas extensões costuma ser uma decisão consciente, e não um efeito colateral. O CMake torna essa decisão explícita, documentando a dependência do projeto em recursos fora do padrão estrito da linguagem.

Esse conjunto de definições cria um contrato claro entre o firmware e o compilador, algo essencial quando o mesmo projeto pode ser compilado em máquinas diferentes, por pessoas diferentes ou em pipelines automatizados.

Tipos de build: Debug, Release e além

Uma das maiores vantagens do CMake em relação a Makefiles artesanais é o tratamento nativo de tipos de build. Em firmware, isso vai muito além de simplesmente ativar ou desativar otimizações. Um build Debug normalmente inclui símbolos de depuração, otimizações mínimas e, muitas vezes, instrumentação adicional para análise de tempo, uso de memória e comportamento do RTOS. Já um build Release prioriza desempenho, tamanho do binário e previsibilidade temporal.

A variável CMAKE_BUILD_TYPE é o mecanismo básico pelo qual o CMake seleciona esse comportamento. Ao definir um valor padrão quando essa variável não está presente, o projeto garante que o build não ocorra em um estado indefinido. Isso é especialmente importante em ambientes embarcados, onde rodar um binário “meio Debug, meio Release” pode gerar resultados enganosos durante testes de campo.

Mais importante do que a existência de Debug e Release é o fato de que o CMake permite estender esse conceito. É comum em projetos STM32 mais maduros existirem perfis adicionais, como builds para análise de WCET, builds com logs detalhados, builds para testes de estresse ou builds voltados exclusivamente para validação de consumo de energia. O sistema de build deixa de ser apenas um meio de gerar um .elf e passa a ser uma ferramenta estratégica de engenharia.

Essa flexibilidade se torna ainda mais poderosa quando combinada com arquivos como CMakePresets.json, que permitem descrever esses perfis de forma padronizada e reutilizável, eliminando a necessidade de longas linhas de comando e reduzindo drasticamente erros humanos.

Ativação explícita das linguagens C e Assembly

Em firmware STM32, o uso de Assembly não é opcional. Arquivos de startup, rotinas de inicialização da pilha, vetores de interrupção e, em alguns casos, otimizações críticas de desempenho exigem suporte explícito a código Assembly. O CMake trata isso de forma direta por meio da diretiva enable_language.

Ao habilitar C e ASM de forma explícita, o projeto informa ao CMake que esses dois tipos de código fazem parte da arquitetura do firmware. Isso permite que o sistema de build configure corretamente as regras de compilação, extensões reconhecidas e até flags específicas para cada linguagem. Ignorar essa etapa pode levar a erros difíceis de diagnosticar, especialmente quando arquivos .s ou .S são tratados incorretamente como texto ou ignorados durante o build.

Outro ponto relevante é que, ao declarar explicitamente as linguagens, o projeto se torna mais claro para ferramentas externas. Editores, analisadores estáticos e sistemas de indexação conseguem entender melhor a natureza do código, o que melhora significativamente a experiência de desenvolvimento em projetos de médio e grande porte.


Criação do firmware com add_executable(): o nascimento do binário .elf

Em projetos embarcados, o executável gerado pelo CMake não é apenas um programa no sentido tradicional, mas sim a imagem completa do firmware, contendo código, dados, vetores de interrupção, tabelas de inicialização e referências explícitas ao layout de memória definido no script de linkedição. No CMake, esse artefato nasce por meio da diretiva add_executable().

Ao chamar add_executable, o projeto declara formalmente a existência de um alvo executável. No contexto STM32, esse alvo representa o firmware que será gravado na flash do microcontrolador. Diferente de ambientes desktop, o executável não será “executado” pelo sistema operacional, mas sim carregado por um programador ou bootloader. Ainda assim, para o CMake, ele é tratado como um executável completo, com regras claras de compilação e linkedição.

Uma característica interessante é que, em projetos bem estruturados, o add_executable normalmente aparece sem listar arquivos fonte diretamente. Isso não é um erro, mas sim uma decisão arquitetural. O alvo é criado vazio e, ao longo do CMakeLists.txt, vai recebendo fontes, includes, defines e bibliotecas por meio de outras diretivas mais específicas. Esse modelo favorece a modularização e evita grandes listas monolíticas de arquivos difíceis de manter.

Essa abordagem se encaixa muito bem com projetos CubeMX, onde o código gerado automaticamente pode ser adicionado ao executável a partir de um subdiretório específico, mantendo o arquivo principal limpo e legível. O executável passa a ser o ponto de convergência de vários módulos independentes, refletindo com clareza a arquitetura do firmware.

Criação de bibliotecas estáticas com add_library()

Se o add_executable representa o firmware final, add_library representa os blocos de construção que compõem esse firmware. Em projetos STM32 profissionais, bibliotecas estáticas são fundamentais para organizar o código, promover reutilização e limitar o acoplamento entre módulos.

Ao criar uma biblioteca estática no CMake, você está definindo um conjunto coeso de funcionalidades que pode ser compilado de forma independente e depois ligado ao firmware principal. Isso é extremamente comum para drivers, camadas de abstração, middlewares e até para o próprio HAL, quando se deseja maior controle sobre o processo de build.

No CMake, bibliotecas estáticas permitem encapsular não apenas arquivos fonte, mas também includes, defines e opções de compilação específicas daquele módulo. Isso significa que um driver pode expor suas dependências de forma explícita, e o executável que o utiliza herda essas informações automaticamente. Esse modelo elimina a necessidade de repetir configurações globais e reduz drasticamente erros de integração.

Em projetos derivados do CubeMX, é comum transformar partes do código gerado em bibliotecas internas, isolando, por exemplo, a inicialização do hardware da lógica da aplicação. O CMake torna esse processo natural, incentivando uma arquitetura mais limpa e sustentável ao longo do tempo.

O papel central de target_sources()

Uma vez criados os alvos — executáveis ou bibliotecas — surge a necessidade de associar arquivos fonte a eles. É aqui que entra a diretiva target_sources. Em vez de listar arquivos diretamente na criação do alvo, o CMake permite adicioná-los de forma incremental e contextualizada.

Essa abordagem é especialmente poderosa em firmware STM32, pois diferentes módulos podem contribuir com arquivos para o mesmo alvo sem que isso gere dependências implícitas ou confusão estrutural. O código gerado pelo CubeMX, por exemplo, pode ser adicionado a partir de um subdiretório específico, enquanto o código da aplicação é adicionado em outro ponto do projeto.

Além disso, target_sources ajuda a documentar a origem dos arquivos. Ao ler o CMakeLists.txt, fica claro quais fontes pertencem a qual módulo e como elas se integram ao firmware final. Isso facilita manutenção, revisão de código e onboarding de novos desenvolvedores.

Outro benefício importante é que essa diretiva respeita o escopo do alvo. Um arquivo fonte adicionado a uma biblioteca não “vaza” automaticamente para outros alvos, o que reforça o isolamento entre módulos e previne dependências acidentais.


Includes e headers: controle preciso com target_include_directories()

Em projetos STM32 tradicionais, especialmente aqueles criados diretamente dentro de IDEs, é muito comum encontrar listas globais de includes, aplicadas indiscriminadamente a todo o projeto. Esse modelo funciona no início, mas rapidamente se torna frágil: headers passam a depender implicitamente de outros headers, conflitos de nomes surgem e o acoplamento entre módulos cresce silenciosamente.

O CMake resolve esse problema ao introduzir um modelo baseado em escopo, e a diretiva target_include_directories() é o coração dessa abordagem. Em vez de dizer “estes diretórios fazem parte do projeto”, você passa a dizer “estes diretórios fazem parte deste alvo”. Essa diferença conceitual muda completamente a forma como o firmware é organizado.

Quando um diretório de include é associado a um alvo específico — seja um executável ou uma biblioteca — apenas aquele alvo e seus consumidores diretos terão acesso a esses headers. Isso permite que cada módulo exponha exatamente o que precisa ser exposto, nada mais. Em firmware STM32, isso é essencial para separar, por exemplo, drivers de hardware, código de aplicação e camadas de abstração.

Outro aspecto importante é o nível de visibilidade: PRIVATE, PUBLIC e INTERFACE. Um include marcado como PRIVATE só é usado internamente pelo alvo. PUBLIC é usado pelo alvo e também propagado para quem o linkar. INTERFACE não é usado pelo próprio alvo, mas apenas pelos consumidores. Essa distinção é extremamente poderosa para firmware, pois permite criar bibliotecas que funcionam como contratos bem definidos entre módulos.

Ao adotar esse modelo, o projeto deixa de depender de “includes mágicos” e passa a ter uma estrutura previsível, rastreável e muito mais fácil de manter ao longo dos anos.

Definições de compilação e macros com target_compile_definitions()

Macros de compilação são inevitáveis em sistemas embarcados. Defines como STM32F4xx, USE_HAL_DRIVER, opções de debug, flags de instrumentação ou seleção de periféricos fazem parte do dia a dia. O problema surge quando essas macros são definidas globalmente, sem controle de escopo.

A diretiva target_compile_definitions() resolve exatamente isso. Assim como acontece com includes, as definições passam a pertencer a um alvo específico. Isso significa que um driver pode declarar suas próprias macros, um middleware pode exigir determinadas configurações, e o executável final herda apenas aquilo que faz sentido para ele.

Em projetos STM32 baseados em CubeMX, isso é particularmente relevante porque o código gerado assume a existência de certos defines. Ao propagar essas definições corretamente para bibliotecas externas ou módulos internos, o CMake garante que todo o código seja compilado sob o mesmo contexto, evitando erros sutis e difíceis de diagnosticar.

Outro ponto importante é que essas macros passam a ser documentadas implicitamente pelo sistema de build. Ao inspecionar o CMakeLists.txt, fica claro quais símbolos existem, por que existem e onde são utilizados. Isso é um ganho enorme em projetos de longa duração ou com múltiplos desenvolvedores.

Organização modular com add_subdirectory()

Uma das maiores forças do CMake em projetos STM32 é a possibilidade de fragmentar o sistema de build em múltiplos arquivos pequenos e coesos. A diretiva add_subdirectory() é o mecanismo que permite isso.

Ao adicionar um subdiretório, o CMake passa a processar o CMakeLists.txt contido naquele diretório como parte do projeto principal. Isso é exatamente o que permite isolar o código gerado pelo CubeMX em uma pasta dedicada, mantendo o arquivo principal enxuto e focado na arquitetura do firmware.

Esse modelo também favorece a criação de bibliotecas internas bem definidas. Cada módulo pode ter seu próprio CMakeLists.txt, declarando fontes, includes, defines e dependências de forma local. O projeto principal apenas conecta esses módulos, sem precisar conhecer seus detalhes internos.

Em firmware STM32 mais complexos, essa organização é o que torna viável a manutenção ao longo do tempo. O build deixa de ser um arquivo monolítico e passa a refletir a estrutura real do software.


Linkedição em firmware STM32 com target_link_libraries()

Diferente de aplicações desktop, a linkedição em sistemas embarcados não é apenas uma etapa final automática do build. Ela define o que realmente entra no firmware, como o código é organizado na memória e até quais partes do código serão eliminadas pelo linker. No CMake, o controle desse processo é feito principalmente por meio da diretiva target_link_libraries().

Ao usar target_link_libraries, você declara explicitamente que um alvo depende de outro. No caso de um firmware STM32, isso normalmente significa ligar o executável principal a bibliotecas estáticas que representam o HAL, CMSIS, drivers, middlewares ou módulos internos do projeto. O ponto central é que o CMake transforma essa declaração em ordem correta de linkedição, algo extremamente sensível em ambientes bare-metal.

Em sistemas embarcados, a ordem das bibliotecas importa. Diferente de ambientes com linker dinâmico, o linker bare-metal resolve símbolos em uma única passagem ou em grupos explícitos. O CMake entende essa necessidade e organiza a linha de link automaticamente com base nas dependências declaradas entre os targets. Isso elimina a prática perigosa de “testar ordens até funcionar”.

Outro aspecto importante é que, ao linkar bibliotecas como targets CMake — e não como arquivos .a soltos — o sistema de build passa a conhecer não apenas o binário da biblioteca, mas também seus includes, defines e opções de compilação. Isso cria um encadeamento coerente de dependências, reduzindo drasticamente inconsistências entre módulos.

Bibliotecas estáticas, descarte de código e otimizações agressivas

Em firmware STM32, quase sempre se trabalha com linkedição com garbage collection de seções (--gc-sections). Isso significa que qualquer função ou dado não referenciado será eliminado do binário final. Esse comportamento é desejável para reduzir tamanho, mas exige disciplina arquitetural.

Quando uma biblioteca estática é linkada ao firmware, apenas as partes efetivamente utilizadas entram no .elf. Porém, se a ordem de link estiver errada ou se símbolos forem referenciados apenas indiretamente (por exemplo, via ponteiros de função), o linker pode descartar código essencial. O uso correto de target_link_libraries ajuda a minimizar esse risco, pois o CMake mantém a relação explícita entre quem fornece e quem consome símbolos.

Além disso, o uso de bibliotecas bem definidas permite aplicar opções de compilação diferentes para cada módulo. Drivers críticos podem ser compilados com otimizações específicas, enquanto código de diagnóstico pode ser compilado de forma mais conservadora. O CMake torna esse tipo de ajuste local e controlável, algo praticamente inviável com Makefiles globais.

Diretórios de bibliotecas e o papel de target_link_directories()

Embora o uso de target_link_directories() exista, em projetos STM32 bem estruturados ele deve ser usado com cautela. Essa diretiva adiciona caminhos de busca para bibliotecas durante a linkedição, mas não cria dependências explícitas entre targets. Por isso, ela é considerada uma solução de menor nível.

Em firmware moderno com CMake, a prática recomendada é quase sempre trabalhar com targets reais, criados via add_library(), e ligá-los diretamente ao executável. Isso torna o build mais robusto, mais legível e menos dependente de detalhes implícitos do ambiente.

Ainda assim, existem cenários legítimos para target_link_directories, como a integração com bibliotecas pré-compiladas fornecidas por fabricantes ou com blobs binários. Nesses casos, o CMake permite isolar esse comportamento em pontos bem definidos do projeto, evitando que ele se espalhe de forma descontrolada.

O script de linkedição e sua relação com o CMake

Em projetos STM32, o script de linkedição (.ld) é tão importante quanto o código-fonte. Ele define o mapa de memória do microcontrolador, separa flash, RAM, stack, heap e regiões especiais. O CMake não substitui esse script, mas orquestra seu uso.

Ao configurar corretamente as flags de linkedição no CMake, o script .ld passa a fazer parte formal do processo de build. Isso significa que diferentes perfis de build podem usar scripts diferentes, algo extremamente útil para cenários como bootloaders, aplicações dual-bank ou firmware com atualização segura.

Mais uma vez, o CMake atua como um integrador arquitetural, garantindo que compilador, linker e layout de memória trabalhem de forma coerente.


Coleções de arquivos e BLOBs com file(GLOB …)

O CMake oferece a diretiva file(GLOB …) como uma forma de criar automaticamente listas de arquivos a partir de padrões, algo semelhante ao uso de curingas no shell. Em projetos embarcados, essa funcionalidade costuma gerar debates acalorados, pois envolve um equilíbrio delicado entre conveniência e previsibilidade.

Quando usamos file(GLOB), o CMake percorre um diretório e coleta todos os arquivos que correspondem a um determinado padrão, como todos os .c ou .h. Isso permite reduzir listas extensas e repetitivas de arquivos, especialmente em módulos com grande quantidade de fontes homogêneas, como drivers ou middlewares.

Em projetos STM32 derivados do CubeMX, esse mecanismo pode ser útil para lidar com conjuntos de arquivos que seguem uma estrutura estável, como diretórios de drivers ou bibliotecas externas que não sofrem alterações frequentes. Nesses casos, o GLOB atua como uma forma de declaração implícita de intenção: todo arquivo daquele tipo naquele diretório faz parte do módulo.

No entanto, existe um ponto crítico: o CMake não reavalia automaticamente os GLOBs quando novos arquivos são adicionados. Isso significa que, se um desenvolvedor cria um novo arquivo .c, ele pode não ser incluído no build até que o CMake seja reconfigurado. Em firmware, onde builds automatizados e reprodutibilidade são essenciais, esse comportamento precisa ser compreendido e controlado.

Por isso, a recomendação prática é clara: usar file(GLOB) apenas em diretórios cujo conteúdo é considerado estável, ou em bibliotecas externas que não fazem parte do ciclo de desenvolvimento diário. Para o código principal da aplicação, listas explícitas de arquivos continuam sendo a opção mais segura e transparente.

BLOBs binários: quando o código não é tudo

Em projetos STM32 reais, nem tudo é código-fonte. É comum lidar com BLOBs binários, como tabelas de fontes, imagens, firmware de coprocesso, certificados, ou até microcódigos proprietários fornecidos por terceiros. Integrar esses artefatos ao firmware exige cuidado, e o CMake oferece ferramentas adequadas para isso.

Uma abordagem comum é tratar esses BLOBs como arquivos de entrada do processo de build, convertendo-os em objetos ou seções específicas que serão linkadas ao firmware. O CMake permite gerenciar esses arquivos como fontes do alvo, mesmo que não sejam compilados no sentido tradicional.

O ponto central aqui é que o CMake não faz distinção conceitual entre “código” e “dados”. Ele trabalha com artefatos que participam do processo de geração do binário final. Isso torna possível incorporar recursos binários ao firmware de forma controlada, documentada e reproduzível.

Quando combinado com scripts de conversão e regras customizadas, o CMake se torna uma ferramenta poderosa para integrar dados externos ao firmware STM32, sem recorrer a soluções ad hoc ou etapas manuais frágeis.

Perfis de build e CMakePresets.json

À medida que projetos STM32 crescem, o simples uso de CMAKE_BUILD_TYPE passa a não ser suficiente. Diferentes desenvolvedores, máquinas e pipelines precisam gerar builds consistentes sem depender de longas linhas de comando. É aqui que entram os presets do CMake.

O arquivo CMakePresets.json permite definir perfis de configuração e build de forma declarativa. Em vez de memorizar comandos, o desenvolvedor escolhe um preset, e o CMake aplica automaticamente gerador, diretórios de build, toolchain e variáveis de cache. Isso reduz drasticamente erros humanos e melhora a experiência de uso.

Em projetos STM32, presets são especialmente úteis para encapsular diferentes toolchains, placas-alvo ou modos de operação. Um mesmo projeto pode gerar firmware para múltiplas variantes de hardware sem que isso se torne um pesadelo de manutenção.

Além disso, presets se integram muito bem com editores modernos e ferramentas de automação, tornando o fluxo de desenvolvimento mais profissional e previsível.


Toolchains cruzadas no CMake: separando o “como compilar” do “que compilar”

Em sistemas embarcados, o compilador não roda no mesmo ambiente do software gerado. Compilamos no host (Linux, Windows ou macOS) para executar em um microcontrolador ARM Cortex-M. Esse modelo exige uma toolchain cruzada, normalmente baseada no arm-none-eabi-gcc. O CMake trata essa realidade de forma elegante ao separar completamente o conceito de projeto do conceito de toolchain.

No CMake, a toolchain não deve ser definida dentro do CMakeLists.txt principal. Em vez disso, ela é descrita em um arquivo .cmake dedicado, passado ao CMake no momento da configuração. Essa decisão arquitetural é extremamente importante, pois evita que o projeto fique acoplado a um compilador específico ou a caminhos absolutos do sistema.

Um arquivo de toolchain define coisas como:

  • qual compilador C e ASM será usado
  • qual linker será invocado
  • quais ferramentas auxiliares (objcopy, size, objdump) fazem parte do fluxo
  • quais flags são obrigatórias para aquela arquitetura

No contexto STM32, esse arquivo encapsula todo o conhecimento sobre a plataforma Cortex-M, deixando o restante do projeto livre para se concentrar na arquitetura do firmware, e não em detalhes do compilador.

Arquivos de toolchain para STM32 e CubeIDE

Quando usamos o GCC fornecido pelo STM32CubeIDE, a toolchain já existe, mas normalmente está escondida dentro da IDE. Ao trabalhar com CMake, precisamos tornar isso explícito. É exatamente para isso que surgem arquivos como cubeide-gcc.cmake.

Esse tipo de arquivo informa ao CMake onde encontrar o compilador arm-none-eabi-gcc, qual prefixo usar, como tratar o assembler e quais ferramentas adicionais fazem parte do toolchain. Além disso, ele define que o sistema alvo não é um sistema operacional tradicional, mas sim um ambiente bare-metal.

Essa distinção é crucial. Ao informar ao CMake que o sistema alvo não possui libc padrão, filesystem ou runtime POSIX, evitamos que ele tente realizar testes de compilação ou linkedição incompatíveis com microcontroladores. O build passa a ser previsível e alinhado com a realidade do hardware.

Outro benefício direto é que o mesmo projeto pode ser compilado com toolchains diferentes apenas trocando o arquivo de toolchain, sem tocar em uma única linha do CMakeLists.txt principal. Isso é extremamente valioso em ambientes profissionais, onde certificações, auditorias ou requisitos de longo prazo exigem controle fino sobre a ferramenta de compilação.

Integração da toolchain com presets e automação

Quando combinamos arquivos de toolchain com CMakePresets.json, o fluxo de build se torna ainda mais robusto. O preset passa a declarar explicitamente qual toolchain deve ser usada, qual gerador será adotado e onde os artefatos de build serão gerados.

Isso elimina a dependência de documentação informal do tipo “compile assim no seu computador” e transforma o processo de build em algo reproduzível por definição. Qualquer desenvolvedor, ou mesmo um sistema de integração contínua, consegue gerar exatamente o mesmo firmware a partir do mesmo código-fonte.

Em projetos STM32 maiores, é comum ter presets separados para diferentes placas, diferentes microcontroladores ou diferentes perfis de memória. O CMake lida com isso de forma natural, sem duplicação de código e sem gambiarras.

Arquivos de apoio: includes, perfis e configuração externa

À medida que o projeto cresce, surge a necessidade de arquivos auxiliares para organizar o build. Em vez de concentrar tudo no CMakeLists.txt principal, o CMake incentiva a criação de arquivos .cmake especializados: um para toolchain, outro para configuração de RTOS, outro para opções de análise, outro para perfis de instrumentação.

Essa abordagem se encaixa perfeitamente com firmware STM32 moderno, onde diferentes builds podem habilitar logs, medições de tempo, análise de stack ou até funcionalidades experimentais. Cada uma dessas variações pode ser descrita de forma limpa e modular, sem poluir a lógica central do projeto.

O resultado final é um sistema de build que deixa de ser um obstáculo e passa a ser um ativo técnico do projeto.


Fluxo completo de build: do CubeMX ao firmware final

Uma vez compreendidos os conceitos fundamentais do CMake, o fluxo de trabalho em um projeto STM32 torna-se claro, previsível e altamente profissional. O ponto de partida continua sendo o STM32CubeMX, responsável por gerar o código de inicialização, HAL, CMSIS e o script de linkedição. A diferença é que, em vez de amarrar o projeto a uma IDE específica, o CubeMX passa a ser apenas um gerador de código, e não o dono do processo de build.

Após gerar ou atualizar o código, o projeto é estruturado de forma que esse conteúdo fique isolado em um subdiretório, normalmente tratado como um módulo independente. O CMakeLists.txt principal atua como o orquestrador, conectando esse código gerado à aplicação, às bibliotecas internas e às dependências externas. Essa separação garante que futuras regenerações do CubeMX não quebrem a arquitetura do projeto.

A etapa seguinte é a configuração do build. Aqui, o CMake lê o CMakeLists.txt, o arquivo de toolchain e, quando presentes, os presets. Esse momento não gera binários; ele apenas constrói o modelo interno do projeto. É nesse ponto que erros de configuração, caminhos incorretos ou inconsistências arquiteturais aparecem — e é exatamente onde eles devem aparecer.

Com a configuração concluída, o processo de compilação e linkedição passa a ser mecânico. O CMake invoca o compilador correto, aplica as flags definidas, executa o linker com o script apropriado e gera o arquivo .elf, que representa o firmware completo. A partir dele, ferramentas auxiliares convertem o binário para .bin ou .hex, prontos para gravação no microcontrolador.

Esse fluxo é repetível, automatizável e independente de IDE, o que representa um salto de maturidade em relação a projetos embarcados tradicionais.

Geração de bibliotecas estáticas reutilizáveis

Um dos maiores ganhos ao adotar CMake em projetos STM32 é a facilidade de criar e reutilizar bibliotecas estáticas. Drivers, middlewares, camadas de abstração e até subsistemas inteiros podem ser encapsulados em bibliotecas independentes, com seus próprios includes, defines e opções de compilação.

Essas bibliotecas deixam de ser “copiadas e coladas” entre projetos e passam a ser componentes versionáveis, facilmente integrados por meio de add_subdirectory() ou mecanismos como FetchContent. O CMake garante que cada biblioteca seja compilada no contexto correto e ligada de forma consistente ao firmware final.

Esse modelo é essencial para quem mantém múltiplos projetos STM32 ou deseja construir uma base técnica sólida ao longo do tempo. O build passa a refletir a arquitetura do software, e não apenas uma sequência de comandos.

Erros comuns e armadilhas em STM32 + CMake

Apesar de poderoso, o CMake não perdoa descuidos conceituais. Um erro frequente é tratar o CMakeLists.txt como um Makefile disfarçado, concentrando tudo em um único arquivo, com variáveis globais e pouca modularização. Isso anula grande parte dos benefícios do sistema.

Outro problema recorrente é misturar configurações de toolchain com lógica do projeto. Sempre que o CMakeLists.txt começa a conter caminhos absolutos para compiladores ou flags específicas de uma máquina, o projeto perde portabilidade imediatamente. A separação entre toolchain e projeto é inegociável em ambientes profissionais.

O uso indiscriminado de file(GLOB) também merece atenção. Embora conveniente, ele pode comprometer a reprodutibilidade do build se usado sem critério. Em firmware, previsibilidade sempre deve pesar mais do que comodidade.

Por fim, a falta de entendimento sobre escopos — PRIVATE, PUBLIC e INTERFACE — costuma gerar projetos frágeis, cheios de dependências implícitas. O CMake oferece as ferramentas para evitar isso, mas cabe ao engenheiro usá-las corretamente.


Exemplo ideal de CMakeLists.txt para STM32 (CubeMX) + arquivo .cmake de toolchain

Abaixo vai um exemplo de referência (um “padrão ouro” pragmático) para projetos STM32 gerados pelo CubeMX. Ele aplica todas as diretivas que discutimos e deixa claro, por comentários, por que cada parte existe. Em seguida, incluo um arquivo .cmake de toolchain para arm-none-eabi-gcc (modelo compatível com GCC do CubeIDE ou GNU Arm Embedded).

Observação importante: este exemplo assume uma árvore típica:

  • Core/ e Drivers/ vindos do CubeMX
  • startup/ com startup assembly
  • linker/ com o .ld
  • app/ para seu código autoral
  • cmake/ para toolchain e helpers

CMakeLists.txt (raiz do projeto)

# ============================================================
#  CMakeLists.txt - Projeto STM32 (CubeMX) com arquitetura limpa
# ============================================================

# 1) Garante previsibilidade de comportamento do CMake.
cmake_minimum_required(VERSION 3.22)

# 2) Declara formalmente o projeto e as linguagens.
#    Em STM32 quase sempre é C + ASM (startup).
project(stm32_firmware
  VERSION 1.0.0
  LANGUAGES C ASM
)

# 3) Controle do padrão de linguagem (contrato explícito com o compilador).
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
# Em STM32, frequentemente extensões GNU são úteis (HAL/CMSIS às vezes dependem).
set(CMAKE_C_EXTENSIONS ON)

# 4) Define um build type padrão quando o gerador é single-config (Makefiles/Ninja).
#    Em multi-config (ex.: Visual Studio), isso é ignorado.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
  set(CMAKE_BUILD_TYPE Debug CACHE STRING "Build type" FORCE)
endif()

# 5) Diretórios principais do projeto
set(APP_DIR       ${CMAKE_SOURCE_DIR}/app)
set(CORE_DIR      ${CMAKE_SOURCE_DIR}/Core)
set(DRIVERS_DIR   ${CMAKE_SOURCE_DIR}/Drivers)
set(STARTUP_DIR   ${CMAKE_SOURCE_DIR}/startup)
set(LINKER_DIR    ${CMAKE_SOURCE_DIR}/linker)

# 6) Script de linker (ajuste para o .ld do seu MCU)
set(LINKER_SCRIPT ${LINKER_DIR}/STM32xxxx_FLASH.ld)

# ------------------------------------------------------------
#  BLOBS / Coleções de arquivos (file(GLOB))
#  Use com critério: recomendado para diretórios estáveis (ex.: Drivers).
#  Para app/ prefira listas explícitas (reprodutibilidade).
# ------------------------------------------------------------
file(GLOB HAL_SOURCES
  ${DRIVERS_DIR}/STM32*/Src/*.c
  ${DRIVERS_DIR}/CMSIS/Device/ST/*/Source/Templates/*.c
)

# Exemplo de “BLOB”: um binário que você quer embutir no firmware
# (pode ser fonte, lookup table, certificado, etc.)
set(BLOB_BIN ${CMAKE_SOURCE_DIR}/assets/blob.bin)

# ------------------------------------------------------------
#  7) Biblioteca estática para HAL/CMSIS (reutilizável)
# ------------------------------------------------------------
add_library(stm32_hal STATIC)

# Adiciona fontes do HAL/CMSIS (coleção via GLOB; diretório estável)
target_sources(stm32_hal PRIVATE
  ${HAL_SOURCES}
  # Se você tem system_stm32xxxx.c gerado pelo CubeMX, inclua aqui.
  ${CORE_DIR}/Src/system_stm32xxxx.c
)

# Includes do HAL/CMSIS
target_include_directories(stm32_hal PUBLIC
  ${CORE_DIR}/Inc
  ${DRIVERS_DIR}/CMSIS/Include
  ${DRIVERS_DIR}/CMSIS/Device/ST/STM32xxxx/Include
  ${DRIVERS_DIR}/STM32xxxx_HAL_Driver/Inc
)

# Defines exigidos pelo CubeMX/HAL (escopo bem definido)
target_compile_definitions(stm32_hal PUBLIC
  USE_HAL_DRIVER
  STM32xxxx
)

# Flags específicas por target (ex.: warnings do HAL)
# (Você pode ajustar e mover para um helper .cmake se quiser.)
target_compile_options(stm32_hal PRIVATE
  -Wall -Wextra
)

# ------------------------------------------------------------
#  8) Biblioteca estática para sua camada de aplicação (opcional, mas ideal)
# ------------------------------------------------------------
add_library(app_lib STATIC)

# Para o código autoral, prefira listar explicitamente as fontes:
target_sources(app_lib PRIVATE
  ${APP_DIR}/src/app_main.c
  ${APP_DIR}/src/app_tasks.c
  ${APP_DIR}/src/app_drivers.c
)

target_include_directories(app_lib PUBLIC
  ${APP_DIR}/include
)

target_compile_definitions(app_lib PUBLIC
  APP_VERSION=\"${PROJECT_VERSION}\"
)

# Dependência explícita: app usa HAL
target_link_libraries(app_lib PUBLIC stm32_hal)

# ------------------------------------------------------------
#  9) Firmware final (ELF)
# ------------------------------------------------------------
add_executable(${PROJECT_NAME}.elf)

# Startup (ASM) e fontes CubeMX “Core”
target_sources(${PROJECT_NAME}.elf PRIVATE
  ${STARTUP_DIR}/startup_stm32xxxx.s
  ${CORE_DIR}/Src/main.c
  ${CORE_DIR}/Src/stm32xxxx_it.c
  ${CORE_DIR}/Src/stm32xxxx_hal_msp.c
  ${CORE_DIR}/Src/syscalls.c
  ${CORE_DIR}/Src/sysmem.c
)

# Inclui o BLOB no build (como dependência para empacotar/gerar header, se necessário)
# Aqui mostramos como gerar um .o a partir de .bin (técnica comum).
# Isso requer objcopy da toolchain.
set(BLOB_OBJ ${CMAKE_BINARY_DIR}/blob.o)

add_custom_command(
  OUTPUT ${BLOB_OBJ}
  COMMAND ${CMAKE_OBJCOPY} -I binary -O elf32-littlearm -B arm ${BLOB_BIN} ${BLOB_OBJ}
  DEPENDS ${BLOB_BIN}
  COMMENT "Convertendo BLOB binário em objeto linkável: blob.o"
)

# Adiciona o blob.o ao firmware
target_sources(${PROJECT_NAME}.elf PRIVATE ${BLOB_OBJ})

# Includes do firmware (o executável também pode precisar de headers específicos)
target_include_directories(${PROJECT_NAME}.elf PRIVATE
  ${CORE_DIR}/Inc
  ${APP_DIR}/include
)

# Defines do firmware (ex.: debug, features)
target_compile_definitions(${PROJECT_NAME}.elf PRIVATE
  # Exemplo: liga logs em Debug
  $<$<CONFIG:Debug>:APP_LOG_ENABLE=1>
)

# Linka bibliotecas internas (ordem e dependências ficam claras)
target_link_libraries(${PROJECT_NAME}.elf PRIVATE
  app_lib
  stm32_hal
)

# 10) Flags de link: script .ld, map, gc-sections
target_link_options(${PROJECT_NAME}.elf PRIVATE
  -T${LINKER_SCRIPT}
  -Wl,-Map=${PROJECT_NAME}.map
  -Wl,--gc-sections
  -Wl,--print-memory-usage
)

# (Opcional) Se você precisa apontar diretórios extras de bibliotecas pré-compiladas:
# Use com cautela. Prefira targets CMake quando possível.
# target_link_directories(${PROJECT_NAME}.elf PRIVATE ${CMAKE_SOURCE_DIR}/third_party/lib)

# ------------------------------------------------------------
#  11) Geração de .bin e .hex a partir do .elf
# ------------------------------------------------------------
add_custom_command(TARGET ${PROJECT_NAME}.elf POST_BUILD
  COMMAND ${CMAKE_OBJCOPY} -O ihex   $<TARGET_FILE:${PROJECT_NAME}.elf> ${PROJECT_NAME}.hex
  COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${PROJECT_NAME}.elf> ${PROJECT_NAME}.bin
  COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${PROJECT_NAME}.elf>
  COMMENT "Gerando artefatos: .hex, .bin e exibindo size"
)

Arquivo de toolchain: cmake/toolchains/arm-none-eabi-gcc.cmake

# ============================================================
#  arm-none-eabi-gcc.cmake - Toolchain STM32 (GCC bare-metal)
#  Use com: cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=...
# ============================================================

# Diz ao CMake que estamos compilando para um sistema "genérico" (bare-metal).
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)

# Evita que o CMake tente rodar executáveis gerados (não rodam no host).
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

# Prefixo padrão da toolchain GNU Arm Embedded / CubeIDE
set(TOOLCHAIN_PREFIX arm-none-eabi)

# Permite sobrescrever via ambiente, útil em CI:
# export ARM_GCC_PATH=/caminho/para/bin
if(DEFINED ENV{ARM_GCC_PATH})
  set(ARM_GCC_BIN "$ENV{ARM_GCC_PATH}")
else()
  set(ARM_GCC_BIN "") # usa PATH
endif()

# Compiladores
set(CMAKE_C_COMPILER   ${ARM_GCC_BIN}${TOOLCHAIN_PREFIX}-gcc)
set(CMAKE_ASM_COMPILER ${ARM_GCC_BIN}${TOOLCHAIN_PREFIX}-gcc)

# Ferramentas auxiliares (usadas para .bin/.hex e size)
set(CMAKE_OBJCOPY ${ARM_GCC_BIN}${TOOLCHAIN_PREFIX}-objcopy CACHE FILEPATH "")
set(CMAKE_SIZE    ${ARM_GCC_BIN}${TOOLCHAIN_PREFIX}-size    CACHE FILEPATH "")

# Flags de arquitetura (AJUSTE conforme seu MCU: cortex-m4/m7/m33 etc.)
set(ARCH_FLAGS "-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard")

# Flags comuns
set(COMMON_FLAGS
  "${ARCH_FLAGS} -ffunction-sections -fdata-sections -fno-common"
)

# Debug vs Release pode ser refinado no projeto ou via presets
set(CMAKE_C_FLAGS_INIT "${COMMON_FLAGS}")
set(CMAKE_ASM_FLAGS_INIT "${COMMON_FLAGS}")

# Flags típicas de link em bare-metal (você complementa no target_link_options)
set(CMAKE_EXE_LINKER_FLAGS_INIT
  "${ARCH_FLAGS} -Wl,--gc-sections"
)

# (Opcional) Se você usa newlib-nano e nosys
# set(CMAKE_EXE_LINKER_FLAGS_INIT
#   "${CMAKE_EXE_LINKER_FLAGS_INIT} -specs=nano.specs -specs=nosys.specs"
# )

CMake como ferramenta de engenharia, não apenas de build

Adotar CMake em projetos STM32 não é apenas uma escolha técnica; é uma decisão de engenharia de software. Ele transforma o processo de build em algo declarativo, previsível e alinhado com boas práticas modernas, aproximando o desenvolvimento embarcado do rigor encontrado em sistemas de maior escala.

Quando bem utilizado, o CMake deixa de ser um obstáculo e passa a ser um mapa formal da arquitetura do firmware. Ele documenta dependências, explicita decisões técnicas e reduz drasticamente o custo de manutenção ao longo do tempo.

Para projetos STM32 gerados pelo CubeMX, essa combinação representa o melhor dos dois mundos: a produtividade do gerador de código da ST e a flexibilidade de um sistema de build profissional, independente de IDEs proprietárias.

0 0 votos
Classificação do artigo
Inscrever-se
Notificar de
guest
0 Comentários
mais antigos
mais recentes Mais votado
Feedbacks embutidos
Ver todos os comentários

Related Post

0
Adoraria saber sua opinião, comente.x