Usando _Generic para criar APIs genéricas em C

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

Introdução

No artigo anterior, Diagnósticos em tempo de compilação em C, vimos como transformar premissas e contratos em erros de compilação. Falta um tipo de erro muito comum em firmware: passar para uma função um valor do tipo errado, que o compilador converte em silêncio.

O C não tem sobrecarga de funções. Em C++, write(uint8_t) e write(uint32_t) podem ter o mesmo nome; em C, cada versão precisa de um nome próprio, e as APIs de firmware se multiplicam: uart_write_u8, uart_write_u16, log_int, log_uint, put_be16, put_be32. Quem chama precisa escolher a versão certa, e, se escolher errado, o valor é convertido sem aviso.

Desde o C11, a linguagem tem uma ferramenta para isso: o _Generic, que escolhe uma expressão em tempo de compilação conforme o tipo de outra. Neste último artigo da série, vamos ver como ele funciona, as pegadinhas que importam em firmware (uma delas faz o mesmo código se comportar de um jeito no PC e de outro na placa) e quatro aplicações práticas. Os exemplos estão no repositório rdws_examples_articles [1], testados no GCC, no Clang e no GCC para ARM.

Como funciona

O primeiro artigo da série mostrou um exemplo clássico:

#define abs_val(x) _Generic((x), \
    int:    abs,                 \
    long:   labs,                \
    float:  fabsf,               \
    double: fabs)(x)

O _Generic olha o tipo da expressão de controle (o primeiro argumento, (x)), procura esse tipo na lista de associações e é substituído pela expressão associada: aqui, o nome de uma função, que em seguida é chamada com (x). Três detalhes do funcionamento [2]:

  • A expressão de controle não é avaliada. Ela só serve para o compilador descobrir o tipo; efeitos colaterais nela não acontecem.
  • Os qualificadores são descartados. O tipo considerado é o de depois da conversão de lvalue: um const int é escolhido como int, e um array, como ponteiro.
  • O default é opcional. Sem ele, um tipo que não está na lista é um erro de compilação. Como veremos, isso é uma qualidade.

As pegadinhas

Para ver exatamente qual tipo o _Generic enxerga, o exemplo do repositório usa uma macro que devolve o nome do tipo. Os resultados, no GCC para x86:

'a'      -> int
ch       -> char
ch + 1   -> int
const int-> int
uint8_t  -> unsigned char
int32_t  -> int
uint32_t -> unsigned int
1 == 1   -> int

Cada linha esconde uma pegadinha.

Literais e comparações são int. Em C, um literal de caractere como ‘a’ é int, e não char, e o resultado de 1 == 1 também é int.

Não há promoções inteiras. Um char é escolhido como char, e um short como short. Mas ch + 1 já é int, porque a soma aplica as promoções. Um _Generic que só lista int não aceita um int16_t. O truque conhecido é usar (x) + 0 como expressão de controle, o que aplica as promoções antes da escolha, sem mudar ponteiros nem números de ponto flutuante.

Os typedefs não são tipos novos. uint8_t é só outro nome para unsigned char, e por isso uma lista não pode ter os dois: seriam duas associações para o mesmo tipo, o que é um erro.

O int32_t não é o mesmo em toda parte. Esta é a pegadinha que mais importa em firmware. No PC, com a glibc, int32_t é int e uint32_t é unsigned int. Nos compiladores para Cortex-M, como o arm-none-eabi-gcc e o do Zephyr SDK, int32_t é long e uint32_t é unsigned long. As consequências:

int lista_os_dois(int32_t x)
{
    return _Generic(x, int: 1, int32_t: 2, default: 3);
}

Esta função não compila no PC (“’_Generic’ specifies two compatible types”), porque int e int32_t são o mesmo tipo. No ARM, compila, e o int32_t escolhe a segunda associação. E o contrário também acontece: um _Generic que só conhece int e unsigned int, testado no PC, manda um uint32_t para o default quando compilado para a placa. Código testado só no PC pode quebrar, ou mudar de comportamento, no firmware.

A recomendação prática: listar os tipos básicos (int, long, long long e as versões unsigned), que cobrem todos os typedefs de tamanho fixo em qualquer plataforma, e compilar os testes também com o toolchain da placa. É o mesmo conselho do artigo sobre o C23: testar em mais de um toolchain pega cedo esse tipo de diferença.

O default engole o erro. Um default que devolve uma versão “genérica” transforma um tipo inesperado em um comportamento silencioso. Sem default, o tipo inesperado vira um erro de compilação, que é o que queremos.

Quatro aplicações em firmware

Em firmware, o _Generic costuma ser mais útil para criar interfaces seguras do que para reduzir linhas de código. Os exemplos abaixo usam essa característica para eliminar erros comuns no acesso a registradores, na depuração, na serialização e na verificação de tipos.

Escrita em registrador pela largura

Periféricos têm registradores de 8, 16 e 32 bits, e escrever com a largura errada pode não ter efeito ou afetar o registrador vizinho. Com _Generic, a largura vem do tipo do ponteiro:

#define REG_WRITE(r, v) _Generic((r), \
    volatile uint8_t *:  reg_wr8,     \
    volatile uint16_t *: reg_wr16,    \
    volatile uint32_t *: reg_wr32)((r), (v))
void uart_configura(void)
{
    REG_WRITE(&UART->CR, 0x200C);
    REG_WRITE(&UART->BRR, 0x1A1);
    REG_WRITE(&UART->DR, 'A');
}

No Cortex-M0, as três chamadas geram str, strh e strb, exatamente as instruções de cada largura. E, como não há default, passar um ponteiro de outro tipo, como um volatile int *, é um erro de compilação: “’_Generic’ selector of type ’volatile int ’ is not compatible with any association”.

Log com o formato escolhido pelo tipo

Errar a string de formato do printf é um clássico, e o problema do int32_t piora as coisas: %d serve para um int32_t no PC, mas não no ARM, onde ele é long. A solução padrão são as macros PRId32 e semelhantes, que funcionam, mas exigem que quem escreve saiba o tipo exato. Com _Generic, o formato vem do tipo:

/* (x) + 0 aplica as promoções inteiras: short e char viram int. */
#define FMT(x) _Generic((x) + 0, \
    int: "%d", unsigned int: "%u", long: "%ld", unsigned long: "%lu", \
    long long: "%lld", unsigned long long: "%llu", double: "%f", char *: "%s")
#define LOG_VAL(nome, x) do {  \
    printf(nome " = ");        \
    printf(FMT(x), (x));       \
    printf("\n");              \
} while (0)

No PC, um uint32_t recebe %u; compilado para o ARM, a mesma macro escolhe %lu, sem nenhuma mudança no código. E o melhor: como o _Generic resulta em uma string literal, o -Wformat do GCC e do Clang continua conferindo o formato contra o argumento. Um mapeamento errado na macro gera o aviso de sempre.

Serialização em big-endian

Protocolos de rede usam big-endian, e gravar um campo com o tamanho errado corrompe o pacote. Com _Generic, o tamanho vem do tipo do valor, e os builtins do artigo sobre extensões fazem a troca de bytes:

#define PUT_BE(p, v) _Generic((v), \
    uint16_t: put_be16,            \
    uint32_t: put_be32,            \
    uint64_t: put_be64)((p), (v))

No Cortex-M4, um PUT_BE de 16 bits vira a instrução rev16, e um de 32 bits, rev. Um detalhe que parece limitação, mas é proteção: PUT_BE(buf, 0x1234) não compila, porque o literal é int, e não um dos tipos da lista. O código é obrigado a dizer o tamanho do campo, com uma variável ou um cast.

Verificação de tipo com static_assert

Combinado com o static_assert do artigo anterior, o _Generic verifica tipos em tempo de compilação:

#define EXIGE_TIPO(x, T) \
    static_assert(_Generic((x), T: 1, default: 0), "tipo errado: " #x " precisa ser " #T)

Um contador declarado como uint16_t, onde o resto do código supõe uint32_t, para o build com a mensagem “tipo errado: contador precisa ser uint32_t”. É útil para proteger premissas sobre variáveis compartilhadas entre módulos, como o tipo de um timestamp que outro arquivo lê.

A biblioteca padrão faz o mesmo

O _Generic foi padronizado no C11, mas a ideia é mais antiga na própria biblioteca: desde o C99, o cabeçalho tgmath.h oferece versões “genéricas” das funções matemáticas, que escolhem a variante certa pelo tipo do argumento [3]. A diferença aparece com força em um Cortex-M4, cuja FPU só faz precisão simples:

#include <math.h>
float modulo(float x, float y)
{
    return sqrt(x * x + y * y);   /* math.h: sqrt é sempre double */
}

Com math.h, sqrt recebe e devolve double: o GCC converte o argumento para double, chama a versão em software e converte o resultado de volta, três chamadas de biblioteca. Trocando o cabeçalho por tgmath.h, o mesmo código, com um argumento float, chama sqrtf, e a função inteira vira uma única instrução vsqrt.f32. E um detalhe que liga este artigo ao anterior: o -Wdouble-promotion não avisa nesse caso, porque a conversão acontece na passagem do argumento para o protótipo double sqrt(double), e não em uma expressão.

Os limites

O _Generic não é sobrecarga de verdade, e tem custos:

  • É escrito com macros. As mensagens de erro, quando algo dá errado dentro da macro, podem ser longas e pouco claras, e a depuração passo a passo não “entra” na macro.
  • A lista de tipos é fechada. Cada tipo novo precisa ser acrescentado na macro; um módulo não consegue estender o _Generic definido em outro.
  • As pegadinhas de tipo exigem cuidado, principalmente a diferença do int32_t entre plataformas.

Para duas ou três variantes usadas em poucos lugares, funções com nomes diferentes continuam sendo a opção mais simples. O _Generic compensa quando a mesma escolha se repete por todo o código, como no log, no acesso a registradores e na serialização, e quando o erro que ele evita é caro.

Conclusão

O _Generic traz para o C uma parte do que a sobrecarga de funções oferece em outras linguagens: escolher a implementação certa pelo tipo, em tempo de compilação, sem custo em tempo de execução. Usado sem default, ele ainda transforma o tipo errado em erro de compilação. As pegadinhas são poucas, mas uma delas, o int32_t, é exatamente o tipo de diferença entre o PC e a placa que só aparece quando o código é compilado para o alvo.

Com este artigo, fechamos a série. Começamos com os 25 anos de evolução da linguagem, vimos quanto do C23 já pode ser usado em firmware, as extensões do GCC que preenchem as lacunas, a diferença entre volatile e sincronização, os diagnósticos em tempo de compilação e, agora, o _Generic. Em comum, todos os artigos tiveram uma ideia: o C moderno oferece ferramentas para deixar o compilador pegar erros que antes só apareciam na placa, e vale a pena conhecê-las. Os exemplos de toda a série, com os testes, continuam no repositório para quem quiser experimentar.

Referências

  1. DIAS, Rafael. rdws_examples_articles: exemplos deste artigo. GitHub.
  2. WG14. N1570: rascunho final do C11, seção 6.5.1.1 (seleção genérica).
  3. WG14. N1256: rascunho do C99, seção 7.22 (tgmath.h).

Firmware Moderno em C

Diagnósticos em tempo de compilação em C
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 » Usando _Generic para criar APIs genéricas em C

EM DESTAQUE

WEBINARS

VEJA TAMBÉM

JUNTE-SE HOJE À COMUNIDADE EMBARCADOS

Talvez você goste:
Nenhum resultado encontrado.