Diagnósticos em tempo de compilação em C
Introdução
No artigo anterior, volatile não é sincronização, uma linha de código impediu que um firmware com um bug sutil chegasse à placa:
_Static_assert(ATOMIC_INT_LOCK_FREE == 2,
"atomic_int precisa ser lock-free para uso em ISR");
Compilado para um Cortex-M0, onde as operações atômicas não são lock-free, o build simplesmente falhou, com uma mensagem dizendo exatamente qual premissa não valia. Sem essa linha, o firmware compilaria, rodaria na bancada e, com sorte, só travaria em campo.
Este artigo é sobre fazer isso de propósito: transformar premissas, contratos e erros comuns em avisos e erros de compilação. O compilador lê todo o código, em todos os builds, sem cansar; cada classe de bug que ele consegue pegar é uma classe de bug que nunca chega à placa. Vamos ver o static_assert, os atributos que viram avisos, os avisos valiosos que o -Wall não liga, erros sob medida, a análise estática e, no fim, como montar uma política de avisos para um projeto.
Como nos artigos anteriores, cada diagnóstico tem um exemplo no repositório rdws_examples_articles [1], com um script que confere que o aviso aparece com a opção certa e não aparece sem ela, no GCC e no Clang.
static_assert: premissas que quebram o build
O static_assert avalia uma expressão constante na compilação e para o build se ela for falsa [2]. No primeiro artigo da série, nós o usamos para proteger o layout de uma struct e para garantir que uma tabela de configuração tem uma entrada por sensor. O mesmo recurso protege premissas de configuração e de plataforma:
/* O ring buffer do artigo 03 usa cab % RB_TAMANHO: com potência de 2, vira um AND. */
static_assert((RB_TAMANHO & (RB_TAMANHO - 1)) == 0, "RB_TAMANHO precisa ser potência de 2");
/* Premissas da plataforma. */
static_assert(CHAR_BIT == 8, "este código supõe bytes de 8 bits");
static_assert(ATOMIC_INT_LOCK_FREE == 2, "atomic_int precisa ser lock-free para uso em ISR");
static_assert(sizeof(pacote_t) <= MTU_PAYLOAD, "pacote_t não cabe no payload");
A primeira linha protege uma decisão de desempenho: o ring buffer do artigo anterior calcula o índice com %, que, com um tamanho potência de 2, o compilador transforma em um AND. Se alguém mudar o tamanho para 100, o código continuaria correto, só que mais lento; com o static_assert, o build avisa. A última protege uma relação entre duas partes do sistema: se alguém acrescentar um campo ao pacote, o erro aparece na compilação, e não como um quadro truncado no receptor.
O limite é que o static_assert só aceita expressões constantes. Para o que o pré-processador enxerga, como as macros de configuração passadas pelo sistema de build, a ferramenta é o #error, e o C23 padronizou também o #warning:
#ifndef UART_BAUD
#error "defina UART_BAUD (por exemplo, -DUART_BAUD=115200)"
#endif
#if UART_BAUD > 921600
#warning "UART_BAUD acima de 921600: confira o clock do periférico"
#endif
Atributos que viram avisos
Os atributos padronizados no C23 são, em boa parte, ferramentas de diagnóstico. [[nodiscard]] avisa quando o retorno de uma função é ignorado, e aceita uma mensagem; [[deprecated]] avisa a cada uso de uma função que não deve mais ser chamada, o que ajuda a migrar uma API sem quebrar o build de uma vez:
[[nodiscard("verifique o código de erro")]]
int flash_write(uint32_t addr, const void *data, size_t len);
[[deprecated("use sensor_read_v2(), que retorna o código de erro")]]
int sensor_read(void);
Um detalhe prático: o GCC, pelo menos até a versão 16, mostra os caracteres acentuados dessas mensagens como sequências de escape octais (o “ó” de “código” aparece como \37777777703\37777777663). O Clang mostra certo. Se o projeto compila com o GCC, mensagens sem acento são mais legíveis.
fallthrough: o atributo, e não o comentário
O aviso -Wimplicit-fallthrough acusa um case que cai no seguinte sem break, um erro clássico. Quando a queda é intencional, há duas formas de calar o aviso: o comentário /* fall through */, que o GCC reconhece há muito tempo, e o atributo [[fallthrough]]. Elas não são equivalentes:
| Sem nada | /* fall through */ | [[fallthrough]]; | |
|---|---|---|---|
| GCC, -Wextra (nível 3, o padrão) | avisa | silencia | silencia |
| GCC, -Wimplicit-fallthrough=5 | avisa | avisa | silencia |
| Clang, -Wimplicit-fallthrough | avisa | avisa | silencia |
O comentário só funciona no GCC, e mesmo lá ele é frágil: depende de expressões regulares que reconhecem variações como “fall through” e “FALLTHRU”, e some quando o código é pré-processado antes de ser compilado (no repositório de exemplos, com atributos/fallthrough_comentario.c, rodar o gcc -E antes faz o aviso voltar). O atributo funciona nos dois compiladores e ainda é conferido: um [[fallthrough]] que não está logo antes de um case gera um aviso. Antes do C23, a forma equivalente é __attribute__((fallthrough)).
Outro detalhe: no GCC, o -Wimplicit-fallthrough vem com o -Wextra; no Clang, não, e é preciso ligá-lo explicitamente.
Os atributos do GCC que vimos no artigo sobre extensões completam o grupo: format, que faz o compilador conferir as chamadas de uma função de log no estilo do printf, nonnull e warn_unused_result.
Avisos que valem ouro (e que o -Wall não liga)
Muita gente acredita que -Wall liga “todos os avisos”. Não liga. Com -Wall -Wextra, o GCC 13 deixa desligados vários avisos que pegam bugs típicos de firmware. Cinco deles, com um exemplo cada:
-Wconversion: truncamento silencioso.
uint16_t media(const uint32_t *v, unsigned n)
{
uint32_t soma = 0;
for (unsigned i = 0; i < n; i++) soma += v[i];
return soma / n; /* uint32_t -> uint16_t, sem aviso no -Wall */
}
A média de valores de 32 bits é devolvida em 16 bits, e o compilador trunca sem dizer nada. Com -Wconversion, o GCC avisa que a conversão “pode mudar o valor”; no Clang, o mesmo aviso aparece como -Wimplicit-int-conversion, que faz parte do -Wconversion. A comparação entre int e unsigned, outro clássico (-1 >= 10u é verdadeiro), já é pega pelo -Wsign-compare, que vem no -Wextra. O -Wconversion é o aviso mais ruidoso desta lista em código existente, e por isso o mais trabalhoso de adotar; em código novo, ele se paga.
-Wdouble-promotion: o double que sai da FPU.
float celsius(float f)
{
return (f - 32) / 1.8; /* 1.8 é double: a divisão vira double */
}
O literal 1.8 é double, e a conta inteira é promovida a double. Em um Cortex-M4, cuja FPU só faz precisão simples, o GCC gera três chamadas de biblioteca (__aeabi_f2d, __aeabi_ddiv e __aeabi_d2f): uma divisão em double feita por software, muito mais lenta e que ainda traz o código da biblioteca para a flash. Com 1.8f, a mesma função vira uma divisão na FPU. O -Wdouble-promotion denuncia a promoção. Em alguns casos o GCC consegue manter a conta em float mesmo com o literal double, quando o valor é exatamente representável (como 2.5), mas o aviso aparece de qualquer forma, e é bom que apareça.
-Wundef: a macro que não existe vale zero.
#if CONFIG_USA_DMA /* esqueceu de definir: vira 0, sem aviso */
int modo = 1;
#else
int modo = 0;
#endif
Em um #if, um identificador que não é macro vale 0. Um erro de digitação no nome de uma macro de configuração, ou um arquivo de configuração que não foi incluído, desliga um recurso sem nenhum aviso. O -Wundef acusa: “CONFIG_USA_DMA is not defined, evaluates to 0”.
-Wswitch-enum: o default que esconde um valor novo.
enum estado { OCIOSO, MEDINDO, ERRO, CALIBRANDO };
const char *nome(enum estado e)
{
switch (e) {
case OCIOSO: return "ocioso";
case MEDINDO: return "medindo";
case ERRO: return "erro";
default: return "?";
}
}
O -Wswitch, que vem no -Wall, avisa quando um valor do enum não é tratado, mas só se o switch não tiver default. Com um default, o estado CALIBRANDO, acrescentado depois, cai em silêncio no “?”. O -Wswitch-enum avisa mesmo com default. Uma alternativa, quando todos os valores são tratados, é simplesmente não escrever o default e deixar o -Wswitch fazer o trabalho.
-Wshadow: a variável que esconde outra.
static int erro;
int envia(int n)
{
int erro = 0; /* esconde a variável global */
Uma variável local com o mesmo nome de uma global, ou de outra local em um bloco externo, esconde a primeira. Quem lê erro dentro da função pode achar que está lendo a global. O -Wshadow acusa.
Outros avisos que valem a leitura do manual do GCC [3]: -Wcast-align (conversão de ponteiro que pode gerar acesso desalinhado), -Wnull-dereference, -Wformat=2 e -Wvla.
Erros sob medida
O GCC permite criar erros de compilação com mensagem própria por meio do atributo error, aplicado a uma função que nunca é definida:
[[gnu::error("ponto alcançável")]]void fw_unreachable_ct(void);
Se o otimizador provar que a chamada é inalcançável e removê-la, o código compila; se a chamada sobreviver, a compilação falha com a mensagem do atributo. É o mesmo princípio de macros do kernel Linux, como a BUILD_BUG_ON [5]. A técnica tem uma limitação importante: depende do nível de otimização. No exemplo do repositório (erros/build_bug.c), uma verificação que o GCC elimina em -O2 sobrevive em -O0, e o build de depuração falha com um falso positivo. Por isso, ela serve para verificações que o compilador sempre consegue resolver, como o tamanho de um tipo passado a uma macro; para o resto, o static_assert é a ferramenta certa.
Na mesma linha está o unreachable() do C23, visto no primeiro artigo: uma promessa ao compilador de que um ponto nunca é alcançado. Ele não gera diagnóstico (se o ponto for alcançado, o comportamento é indefinido), e por isso vale envolvê-lo em uma macro do projeto que, em desenvolvimento, registre onde o problema aconteceu.
Além do compilador: análise estática
Os avisos olham uma função, ou uma expressão, de cada vez. Os analisadores estáticos seguem os caminhos de execução e pegam erros que só aparecem em um deles. Um exemplo comum em firmware, que não usa malloc, mas um alocador de blocos próprio:
void pool_free(void *p);
__attribute__((malloc, malloc(pool_free, 1)))
void *pool_alloc(size_t n);
if (envia(buf, strlen(buf)) < 0) {
return -2; /* esqueceu o pool_free: o bloco vaza */
}
pool_free(buf);
Com -Wall -Wextra, nada. Com o -fanalyzer do GCC, a partir da versão 14, o vazamento é acusado: “leak of ‘buf’ [CWE-401]”. O detalhe está no atributo malloc(pool_free, 1), disponível desde o GCC 11, que diz ao compilador que o bloco devolvido por pool_alloc deve ser liberado por pool_free [4]. Sem ele, nenhuma versão acusa nada: o analisador só enxerga o alocador que você ensina a ele. E o -fanalyzer do GCC 13 não pega este caso nem com o atributo, o que mostra o quanto a ferramenta tem evoluído.
Do lado do Clang, há o clang –analyze (e o scan-build, que o roda em um projeto inteiro) e o clang-tidy; fora dos compiladores, o cppcheck. Para quem quer ir além, o post Better Firmware with LLVM/Clang, de Chris Coleman, citado no artigo sobre extensões, é um ótimo roteiro em um projeto com FreeRTOS: ele roda o scan-build, usa atributos para ensinar o analisador a entender um alocador próprio e aplica a Thread Safety Analysis do Clang aos mutexes do RTOS [6].
Montando a política do projeto
Uma base razoável para firmware, no GCC:
-Wall -Wextra -Wshadow -Wundef -Wdouble-promotion -Wswitch-enum
-Wconversion -Wformat=2 -Wcast-align -Wvla -Wimplicit-fallthrough
Em um projeto novo, vale compilar com -Werror desde o primeiro dia: um aviso que não quebra o build tende a ser ignorado e, depois de algum tempo, ninguém mais lê a saída do compilador. Em uma base de código existente, ligar tudo de uma vez costuma gerar centenas de avisos, e o caminho mais realista é outro:
- ligar um aviso por vez, começando pelos que acusam pouca coisa e pegam bugs reais (-Wundef, -Wdouble-promotion, -Wshadow), e deixar o -Wconversion por último;
- usar -Werror só no CI, para não bloquear quem está experimentando localmente;
- para um aviso que ainda não dá para corrigir em um arquivo, usar -Wno-error= naquele arquivo, ou um #pragma GCC diagnostic em volta do trecho, em vez de desligá-lo no projeto inteiro.
No Zephyr, a opção CONFIG_COMPILER_WARNINGS_AS_ERRORS transforma os avisos em erros, e o build aceita níveis extras de aviso no estilo do kernel Linux, com west build — -DW=1 (até 3) [7]. O nível 1 acrescenta, por exemplo, -Wshadow e -Wcast-align; o nível 2, -Wconversion e -Wvla.
Conclusão
O compilador é a primeira e mais barata ferramenta de verificação de um projeto de firmware. O static_assert transforma premissas em erros de build; os atributos transformam contratos em avisos; um punhado de opções além do -Wall pega truncamentos, promoções para double, macros indefinidas e estados esquecidos; e os analisadores estáticos seguem os caminhos que nenhum teste de bancada percorre. Nada disso substitui os testes, mas cada bug pego na compilação é um bug a menos para caçar com o osciloscópio.
No próximo artigo da série, vamos ver o _Generic, que permite escolher, em tempo de compilação, a função certa para cada tipo, e transformar o tipo errado em mais um erro de compilação.
Referências
- DIAS, Rafael. rdws_examples_articles: exemplos deste artigo. GitHub.
- WG14. N3096: rascunho de trabalho do C23: static_assert, atributos e #warning.
- GCC. Warning Options.
- GCC. Common Function Attributes e Static Analyzer Options.
- Linux kernel. include/linux/build_bug.h.
- COLEMAN, Chris. Better Firmware with LLVM/Clang. Interrupt (Memfault), 2020.
- Zephyr Project. Kconfig: CONFIG_COMPILER_WARNINGS_AS_ERRORS e o arquivo CMakeLists.txt do Zephyr (opção -DW).
Esta obra está licenciada com uma Licença Creative Commons Atribuição-CompartilhaIgual 4.0 Internacional.
