Extensões do GCC que facilitam a vida no firmware

Este post faz parte da série Firmware Moderno em C

Introdução

No artigo anterior, Quanto do C23 já pode ser usado em sistemas embarcados?, vimos que o C23 ainda não chegou inteiro aos toolchains e às bibliotecas usados em firmware. Mas boa parte do que ele trouxe não é novidade para quem usa o GCC: typeof, literais binários, atributos, contagem de bits e detecção de overflow existem como extensões do compilador há muitos anos.

E não é só isso. Muito do firmware que roda por aí depende de extensões que o padrão nunca vai ter: colocar uma variável em uma seção específica da memória, declarar um tratador de interrupção que a aplicação pode substituir, impedir o compilador de reordenar acessos à memória. Os arquivos de startup dos fabricantes, o Zephyr e o kernel Linux estão cheios delas.

Neste artigo vamos ver as extensões do GCC que mais facilitam a vida no firmware, o que o famoso -std=gnu17 faz de verdade, quais extensões é melhor evitar e como usar tudo isso sem amarrar o projeto a um único compilador. Todos os exemplos estão no repositório rdws_examples_articles [1], com um script que testa cada um no GCC, no Clang e no GCC para ARM.

O que -std=gnu17 faz de verdade

Muita gente acredita que as extensões do GCC só funcionam com -std=gnu17 (ou gnu99, gnu11) e que -std=c17 as desliga. Não é bem assim.

Comparando as macros predefinidas pelo compilador nos dois modos (gcc -dM -E), a diferença principal é uma só: em -std=c17, o GCC define a macro __STRICT_ANSI__; em -std=gnu17, não. Ao contrário do que às vezes se imagina, nenhum dos dois modos define _GNU_SOURCE.

Essa macro tem três efeitos práticos.

Palavras-chave sem sublinhado. No modo estrito, typeof e asm não são palavras-chave, porque esses nomes pertencem ao programador. As extensões continuam disponíveis com a grafia reservada, de dois sublinhados: __typeof__, __asm__, __attribute__, __inline__. Por isso um código como o do Zephyr, que por padrão compila com -std=c17, usa __attribute__ em todo lugar sem problema.

A biblioteca esconde funções. Os cabeçalhos da libc consultam __STRICT_ANSI__ para decidir o que expor. É o motivo de a newlib só declarar strdup e memccpy com -std=gnu17 ou gnu23, o “só gnu” da matriz do artigo anterior.

Macros fora do espaço reservado. No host Linux, o modo GNU define as macros linux e unix, ambas com valor 1. Um programa com uma variável chamada linux compila com -std=c17 e falha com -std=gnu17. No GCC para ARM essas macros não existem, mas é um bom exemplo de por que o modo estrito é o mais seguro como ponto de partida.

E o -pedantic? Ele avisa quando o código usa uma extensão de sintaxe, como as statement expressions e os intervalos em case que veremos adiante, mas não reclama de __attribute__ ou __typeof__, que estão no espaço de nomes reservado ao compilador. A palavra-chave __extension__, antes de uma expressão, silencia o aviso naquele ponto; é assim que os cabeçalhos da glibc usam extensões sem incomodar quem compila com -pedantic.

Um mapa das extensões

As extensões do GCC não estão todas no mesmo nível. Para não virar uma lista solta, vale separá-las pelo problema que resolvem:

  • Layout de memória e linker: onde cada coisa fica e como o linker as combina (section, packed, aligned, weak, alias, used).
  • Diagnóstico: o compilador conferindo o seu código (format, nonnull, warn_unused_result, deprecated).
  • Otimização: os builtins e as dicas de inline e de desvio.
  • Baixo nível: inline assembly, a barreira de compilador, naked e constructor.
  • Sintaxe e conveniência: statement expressions, intervalos e cleanup.

E, por fim, as que é melhor evitar. Boa parte delas são atributos, com a sintaxe __attribute__((nome)) ou, a partir do C23, [[gnu::nome]]. A lista completa, com os detalhes de cada um, está no manual do GCC [6].

Layout de memória e linker

section: escolhendo onde a variável fica

__attribute__((section(".noinit"))) static uint32_t boot_count;

O atributo coloca a variável em uma seção com o nome escolhido, e o linker script decide onde essa seção fica na memória. O uso clássico é uma seção .noinit, que o código de inicialização não zera no boot: o contador acima sobrevive a um reset por watchdog, e o firmware pode registrar quantas vezes reiniciou. O mesmo atributo serve para colocar uma função crítica na RAM (para rodar mais rápido ou enquanto a flash está sendo apagada) ou para criar tabelas que o linker junta de vários arquivos, como os comandos de shell do Zephyr.

packed e aligned: controlando o layout

struct __attribute__((packed)) empacotada {
    uint8_t  tipo;
    uint32_t valor;
};

static uint8_t dma_buffer[64] __attribute__((aligned(32)));

packed remove o padding: a struct acima ocupa 5 bytes, em vez dos 8 da versão normal, e o campo valor fica no deslocamento 1. É útil para espelhar o formato de um protocolo. aligned faz o contrário e força um alinhamento mínimo, como os 32 bytes da linha de cache de um Cortex-M7 para buffers de DMA. O C11 trouxe o alignas, mas o atributo continua comum em código legado.

O perigo do packed está nos ponteiros. Ao acessar msg.valor diretamente, o compilador sabe que o campo pode estar desalinhado e gera acessos byte a byte. Mas um ponteiro uint32_t * para o mesmo campo perde essa informação, e um acesso de 32 bits desalinhado gera uma falha (HardFault) em um Cortex-M0, que não suporta acessos desalinhados. O GCC avisa, por padrão:

warning: taking address of packed member of ‘struct mensagem’ may result in an
unaligned pointer value [-Waddress-of-packed-member]

Para trocar dados com outros sistemas, a alternativa mais portável continua sendo serializar campo a campo, como discutimos no primeiro artigo da série.

weak e alias: os tratadores de interrupção padrão

void uart_isr(void)  __attribute__((weak, alias("default_handler")));
void timer_isr(void) __attribute__((weak, alias("default_handler")));

Esse é o padrão dos arquivos de startup de praticamente todo fabricante de Cortex-M. Cada tratador de interrupção é declarado como um apelido (alias) para uma função padrão, que normalmente fica em um laço infinito, e marcado como fraco (weak). Se a aplicação definir uma função uart_isr normal, ela substitui a definição fraca na hora do link; se não definir, a interrupção cai no tratador padrão. No repositório de exemplos, o programa atributos/weak/vetores.c é linkado de duas formas: sozinho, as duas interrupções caem no default_handler; junto com o arquivo uart.c, que define uma uart_isr própria, essa interrupção passa a usar a função da aplicação, e só a outra continua no tratador padrão.

O atributo used completa o grupo: impede que o compilador remova uma função ou variável aparentemente não usada, como uma tabela que só o linker script referencia.

Diagnóstico: o compilador conferindo o seu código

format: o compilador conferindo o seu printf

__attribute__((format(printf, 2, 3)))
static void log_msg(int nivel, const char *fmt, ...)

Todo firmware tem uma função de log no estilo do printf. O atributo diz ao compilador que o segundo parâmetro é uma string de formato e que os argumentos variáveis começam no terceiro. A partir daí, o GCC confere as chamadas como faz com o próprio printf: um %d recebendo uma string vira aviso (ou erro, com -Werror=format). É um dos atributos com melhor relação custo-benefício: uma linha que elimina uma classe inteira de bugs.

nonnull, warn_unused_result e deprecated

nonnull avisa quando um ponteiro nulo é passado para um parâmetro que não o aceita, e warn_unused_result avisa quando o retorno de uma função é ignorado, como o código de erro de uma escrita na flash; ele é o antecessor do [[nodiscard]] do C23. deprecated marca uma função que não deve mais ser usada, e cada chamada a ela gera um aviso, o que ajuda a migrar uma API sem quebrar o build de uma vez.

Otimização e builtins

Os builtins são funções que o compilador conhece e transforma diretamente em instruções:

printf("__builtin_bswap32(0x%08lx) = 0x%08lx\n",
      (unsigned long)reg, (unsigned long)__builtin_bswap32(reg));
printf("mask = 0x%02lx: popcount = %d, ctz = %d, clz = %d\n", (unsigned long)mask,
      __builtin_popcount(mask), __builtin_ctz(mask), __builtin_clz(mask));

if (__builtin_add_overflow((uint16_t)65000, (uint16_t)1000, &total)) {
  • __builtin_bswap16/32/64 invertem a ordem dos bytes. É a peça que faltava na discussão sobre layout de estruturas: o static_assert garante o tamanho e a posição dos campos, mas não a ordem dos bytes de cada um. Em qualquer Cortex-M, inclusive o M0, vira uma única instrução REV.
  • __builtin_popcount, __builtin_ctz e __builtin_clz contam bits em 1, zeros à direita e zeros à esquerda. São o que usar enquanto a libc não tem o stdbit.h do C23, caso da newlib e da picolibc. No Cortex-M3 e superiores, o clz vira a instrução CLZ; no M0, que não a tem, vira uma chamada à função de biblioteca __clzsi2.
  • __builtin_add_overflow (e as versões sub e mul) fazem a operação e informam se o resultado coube no tipo de destino. É exatamente o comportamento do ckd_add do C23, que só existe a partir do GCC 14.
  • __builtin_expect informa qual lado de um if é o mais provável, normalmente por meio das macros likely e unlikely.
  • __builtin_constant_p diz se uma expressão é constante em tempo de compilação, e __builtin_types_compatible_p compara tipos. Os dois aparecem em macros que escolhem um caminho mais rápido quando o valor é conhecido.

Antes de usar um builtin, __has_builtin(__builtin_bswap32) permite testar se o compilador o oferece; o recurso existe desde o GCC 10 e há muito mais tempo no Clang.

Os atributos always_inline e noinline completam o grupo: controlam o inline de uma função independentemente do nível de otimização, por exemplo para garantir que um trecho crítico não ganhe a sobrecarga de uma chamada.

Baixo nível: inline assembly e a barreira de compilador

O inline assembly do GCC permite colocar instruções específicas do processador no meio do código C:

static inline void irq_disable(void)
{
    __asm__ volatile("cpsid i" ::: "memory");
}

O volatile impede que o compilador remova ou mova a instrução, e o “memory” na lista de clobbers avisa que a instrução pode ler ou escrever qualquer posição de memória. Sem ele, o compilador poderia mover para fora da região protegida um acesso que deveria acontecer com as interrupções desligadas.

O caso mais interessante é uma instrução vazia:

#define barrier() __asm__ volatile("" ::: "memory")

Ela não gera nenhuma instrução de máquina, mas obriga o compilador a esquecer o que sabia sobre a memória e a ler de novo qualquer variável depois dela. Para ver por que isso importa, considere uma função que espera uma flag, modificada por uma interrupção, mudar:

int flag;   /* modificada por uma interrupção; não é volatile, de propósito */

void espera_sem_barreira(void)
{
    while (!flag) {
    }
}

Como flag não é volatile e nada dentro do laço a modifica, o compilador conclui que ela não muda e a lê uma única vez. A versão com a barreira só acrescenta uma chamada dentro do laço:

void espera_com_barreira(void)
{
    while (!flag) {
        barrier();
    }
}

A cada volta, a barreira obriga o compilador a esquecer o valor que já conhecia e a ler flag de novo da memória. No repositório de exemplos (asm/barreira.c e asm/espera_main.c), as duas funções são testadas com um sinal do sistema operacional fazendo o papel da interrupção e mudando a flag depois de 200 ms. O resultado depende do compilador:

Sem barreiraCom barreira
GCC 13trava em um laço infinitoretorna com flag = 1
Clang 22retorna na hora, com flag = 0retorna com flag = 1

O GCC transforma o laço em um salto para ele mesmo (.L3: b .L3, no Cortex-M4). O Clang vai além e remove o laço: o C11 (seção 6.8.5, parágrafo 6) [7] permite ao compilador supor que um laço sem efeitos colaterais e sem uma condição constante termina, e como a flag “não muda”, a única forma de terminar é nunca ter entrado. Os dois comportamentos estão dentro do padrão, e os dois quebram o firmware.

Se você está pensando em usar essa barreira para compartilhar dados entre tarefas ou interrupções, cuidado. Ela resolveu este caso, mas barreiras e volatile não substituem os mecanismos de sincronização adequados, como os atomics do C11 e as seções críticas. Esse é o assunto do próximo artigo da série.

Dois atributos fecham o grupo. naked faz a função ser gerada sem prólogo nem epílogo, para código escrito inteiramente em assembly, como a troca de contexto de um RTOS. constructor faz a função rodar antes do main, o que alguns frameworks usam para registrar módulos.

Sintaxe e conveniência

Statement expressions: um MIN que avalia uma vez só

A macro MIN ingênua tem um problema conhecido:

#define MIN_INGENUO(a, b) ((a) < (b) ? (a) : (b))

Se um dos argumentos tiver efeito colateral, como MIN_INGENUO(le_adc(), 1000), a função le_adc() é chamada duas vezes. As statement expressions, um bloco entre ({ e }) cujo valor é o da última expressão, resolvem isso em conjunto com __auto_type:

#define MIN(a, b) ({            \
    __auto_type a_ = (a);       \
    __auto_type b_ = (b);       \
    a_ < b_ ? a_ : b_;          \
})

Cada argumento é avaliado uma única vez e guardado em uma variável do tipo certo. No exemplo do repositório (sintaxe/statement_expr.c), a versão ingênua chama le_adc() duas vezes e a versão com statement expression, uma vez só.

Intervalos em case e em inicializadores

static const isr_t vetores[16] = {
    [0 ... 15] = default_handler,
    [5] = uart_isr,
};

O intervalo [0 … 15] preenche todas as posições de uma vez, e a posição 5 é sobrescrita em seguida. É uma forma compacta de montar uma tabela de vetores de interrupção. Um detalhe: com -Wextra, o GCC avisa que uma posição foi sobrescrita (-Woverride-init), mesmo sendo intencional. No exemplo do repositório (sintaxe/intervalos.c), o aviso é desligado só em volta da tabela com #pragma GCC diagnostic.

Os intervalos também funcionam em case, como case 200 … 299:, o que deixa mais legível um switch sobre faixas de valores, como códigos de resposta de um protocolo.

cleanup: liberando recursos em todo caminho de saída

static void unlock_cleanup(mutex_t **m)
{
    mutex_unlock(*m);
}

#define SCOPED_LOCK(m) \
    mutex_t *scoped_lock_ __attribute__((cleanup(unlock_cleanup))) = (mutex_lock(m), (m))

O atributo cleanup chama uma função quando a variável sai de escopo, qualquer que seja o caminho: fim do bloco, return antecipado ou break. Com uma macro como a acima, uma função pode travar um mutex na primeira linha e ter a certeza de que ele será liberado em todos os return, o que em C costuma exigir uma cascata de goto. O kernel Linux adotou esse padrão nos últimos anos.

Em código de produto, porém, eu usaria o cleanup só com uma razão forte e muito clara. O fluxo fica escondido: quem lê a função não vê a chamada que libera o recurso. A função de limpeza não roda quando se sai do escopo por longjmp. E a portabilidade é limitada: GCC e Clang suportam, o IAR só no modo de linguagem estendido, e outros compiladores não. Para a maioria das funções de firmware, um único ponto de saída com a liberação explícita continua sendo a opção mais clara.

Extensões a evitar

Nem toda extensão vale a pena.

Funções aninhadas. O GCC permite definir uma função dentro de outra, com acesso às variáveis locais da função externa. O problema aparece quando se passa um ponteiro para essa função: o GCC monta na pilha um pequeno trecho de código, o trampolim, e a pilha passa a precisar ser executável. O linker avisa (“requires executable stack”), e uma pilha executável é exatamente o que as proteções contra exploração tentam impedir. O Clang não implementa o recurso. Em firmware, não use: não há nada que uma função aninhada faça que um ponteiro de função com um parâmetro de contexto não resolva. A opção -Wtrampolines detecta o problema.

Arrays de tamanho zero. Antes do C99, o GCC aceitava uint8_t payload[0] como último membro de uma struct, para pacotes de tamanho variável. O padrão resolveu isso com o flexible array member (uint8_t payload[]), visto no primeiro artigo, e o array de tamanho zero falha com -pedantic-errors.

Labels como valores. O GCC permite guardar o endereço de um rótulo e pular para ele (goto *ptr), o que acelera interpretadores e máquinas de estados. É poderoso, mas de nicho, e torna o código difícil de ler e de analisar.

Portabilidade: isolando as extensões

Usar extensões não precisa significar ficar preso ao GCC. O Clang implementa quase todas as citadas aqui, com exceção das funções aninhadas, e a Arm Toolchain for Embedded é baseada no Clang. O IAR Embedded Workbench for Arm aceita uma lista extensa de atributos no estilo do GCC, entre eles aligned, packed, section, weak, alias, format, cleanup, used e noinit, mas só no modo de linguagem estendido (–language=extended ou –language=gnu) e com ressalvas: nonnull não tem efeito, e packed não gera diagnóstico para acessos desalinhados [2].

A forma de conviver com essas diferenças é isolar as extensões em um único cabeçalho. É o que fazem o kernel Linux, em include/linux/compiler_attributes.h [3], e o Zephyr, que tem um cabeçalho por compilador em include/zephyr/toolchain/, com versões para GCC, LLVM, IAR, armclang e outros [4]. O código do projeto usa __packed ou __printf_like, e o cabeçalho decide o que isso significa em cada compilador. O post Better Firmware with LLVM/Clang, de Chris Coleman, mostra o mesmo padrão aplicado a um projeto de firmware com FreeRTOS, como parte de um argumento maior: compilar o firmware também com o Clang, mesmo mantendo o GCC no produto, traz um segundo conjunto de avisos e o analisador estático do Clang [5].

O exemplo que acompanha este artigo segue uma regra simples. Extensões que mudam o significado do programa geram um erro quando o compilador não as suporta:

#if TEM_ATRIBUTO(packed)
#define PACKED __attribute__((__packed__))
#else
#define PACKED _Pragma("GCC error \"PACKED: defina para este compilador\"")
#endif

Uma struct que deveria ser empacotada e não é compila sem nenhum aviso e só falha em campo, com pacotes corrompidos; é melhor o build quebrar. Já as extensões que são só diagnóstico ou dica de otimização simplesmente somem:

#if TEM_ATRIBUTO(format)
#define PRINTF_FMT(f, a) __attribute__((__format__(printf, f, a)))
#else
#define PRINTF_FMT(f, a)
#endif

E quando existe um substituto portável, como para o __builtin_bswap32, o cabeçalho o oferece. A macro TEM_ATRIBUTO é um __has_attribute protegido para compiladores que não o conhecem.

O que já tem equivalente no C23

Muitas extensões do GCC viraram padrão no C23. Quando o toolchain do projeto chegar lá, a troca é direta:

Extensão do GCCEquivalente no C23
__typeof__typeof
0b1010 (extensão)0b1010
__attribute__((warn_unused_result))[[nodiscard]]
__attribute__((noreturn))[[noreturn]]
__attribute__((unused))[[maybe_unused]]
__attribute__((fallthrough))[[fallthrough]]
##__VA_ARGS____VA_OPT__
__builtin_unreachable()unreachable()
__builtin_add_overflowckd_add (stdckdint.h)
__builtin_popcount, __builtin_clzstdc_count_ones, stdc_leading_zeros (stdbit.h)
__auto_typeauto

Para os atributos que não viraram padrão, como section, packed e weak, o C23 oferece uma ponte: a sintaxe [[gnu::section(“.noinit”)]] usa a forma padronizada de atributos com o nome do GCC.

Conclusão

Usar extensões do compilador não é um pecado. Boa parte do firmware que funciona hoje depende delas, e várias são a única forma de expressar algo que o padrão não cobre, como a seção de memória de uma variável ou um tratador de interrupção substituível. Outras são o C23 chegando mais cedo.

O cuidado está em três pontos: saber quais extensões o projeto usa (compilar com -std=c17 em vez de -std=gnu17 ajuda a enxergá-las), evitar as que trazem riscos, como as funções aninhadas, e isolá-las em um cabeçalho que diga claramente o que fazer em outro compilador.

Essas ferramentas ajudam a controlar o código gerado, mas não resolvem problemas de sincronização e concorrência. Esse é o assunto do próximo artigo, que volta à flag que travou o laço na seção sobre a barreira de compilador: por que volatile não é sincronização e como os atomics do C11 resolvem a comunicação entre interrupções e o código principal.

Referências

  1. DIAS, Rafael. rdws_examples_articles: exemplos deste artigo. GitHub.
  2. IAR. Supported GCC attributes, IAR Embedded Workbench for Arm 9.7x.
  3. Linux kernel. include/linux/compiler_attributes.h.
  4. Zephyr Project. include/zephyr/toolchain/.
  5. COLEMAN, Chris. Better Firmware with LLVM/Clang. Interrupt (Memfault), 2020.
  6. GCC. Using the GNU Compiler Collection: C Extensions, em especial as seções sobre atributos, builtins, statement expressions, designated initializers e extended asm.
  7. WG14. N1570: rascunho final do C11, seção 6.8.5, parágrafo 6.

Firmware Moderno em C

Quanto do C23 já pode ser usado em sistemas embarcados?
Licença Creative Commons Esta obra está licenciada com uma Licença Creative Commons Atribuição-CompartilhaIgual 4.0 Internacional.
Comentários:
Inscrever-se
Notificar de
0 Comentários
mais recentes
mais antigos Mais votado
Home » Software » Extensões do GCC que facilitam a vida no firmware

EM DESTAQUE

WEBINARS

VEJA TAMBÉM

JUNTE-SE HOJE À COMUNIDADE EMBARCADOS

Talvez você goste:
Nenhum resultado encontrado.