volatile não é sincronização: usando atomics do C11 em firmware
Introdução
Quase todo mundo que escreve firmware já viu, ou escreveu, algo assim:
static uint16_t amostras[64];
static volatile bool pronto;
void adc_isr(void)
{
for (unsigned i = 0; i < 64; i++) {
amostras[i] = le_adc();
}
pronto = true;
}
void tarefa(void)
{
while (!pronto) {
}
pronto = false;
processa(amostras, 64);
}
A interrupção do ADC preenche o buffer e avisa que ele está pronto; a tarefa espera o aviso e processa as amostras. A flag é volatile, como manda o figurino. Isso está correto?
Na bancada, quase sempre funciona. Mas nada no código impede o compilador de reordenar as escritas em amostras em relação a pronto, e nada impede a próxima conversão de sobrescrever o buffer enquanto processa() ainda está lendo. O volatile não protege nenhuma das duas coisas. Este artigo é sobre a diferença entre “quase sempre funciona” e “está correto”.
No artigo anterior, Extensões do GCC que facilitam a vida no firmware, terminamos com uma função que espera uma flag, modificada por uma interrupção, mudar de valor. Sem nenhum cuidado, o GCC transformou a espera em um laço infinito e o Clang simplesmente removeu o laço. A barreira de compilador resolveu aquele caso, mas ficou a pergunta: qual é a ferramenta certa para comunicar uma interrupção com o código principal?
A resposta que quase todo mundo aprende primeiro é volatile. E, para aquela flag, ela funciona. O problema é que o volatile resolve bem menos do que parece, e as falhas aparecem justamente nos casos mais comuns em firmware: um contador compartilhado, um conjunto de flags em uma palavra, um timestamp de 64 bits, um buffer de recepção.
Se você chegou à série por este artigo, não é preciso ler os anteriores, mas dois deles ajudam: o primeiro, que apresenta os atomics do C11 entre as novidades da linguagem, e o anterior, de onde vem a barreira de compilador usada aqui.
Neste artigo vamos ver o que o volatile garante, três armadilhas em que ele não ajuda, o que os atomics do C11 oferecem no lugar e o que fazer quando nem os atomics resolvem. Todos os experimentos estão no repositório rdws_examples_articles [1]. Os que rodam no PC usam um sinal do sistema operacional, disparado a cada 20 µs, para fazer o papel da interrupção; os de Cortex-M conferem o código que o GCC gera para o M0 e o M4. No fim, rodamos os mesmos experimentos em um Cortex-M emulado pelo QEMU, com uma interrupção de verdade.
O que volatile garante (e o que não garante)
O padrão diz que os acessos a um objeto volatile são avaliados estritamente segundo as regras da máquina abstrata [2]. Na prática, isso significa duas coisas: o compilador não pode eliminar nem juntar acessos a uma variável volatile, e precisa fazê-los na ordem do programa em relação aos outros acessos volatile.
É exatamente o que um registrador de periférico precisa. Ler o registrador de status de uma UART duas vezes tem efeito (a leitura pode limpar uma flag), e escrever em dois registradores de configuração na ordem errada pode deixar o periférico em um estado inválido. Para registradores mapeados em memória, volatile é a ferramenta certa.
E para a flag do artigo anterior? Uma variável de uma palavra, escrita por uma interrupção e lida pelo código principal, em um Cortex-M de um núcleo, também funciona com volatile: o compilador passa a ler a flag a cada volta do laço, e a leitura de uma palavra alinhada é feita em uma única instrução.
O que o volatile não garante:
- Atomicidade. Uma operação sobre uma variável volatile pode virar várias instruções, e uma interrupção pode acontecer entre elas.
- Ordem em relação aos acessos que não são volatile. O compilador continua livre para mover, juntar ou eliminar os acessos às variáveis comuns em volta.
- Visibilidade entre núcleos. Em um processador com mais de um núcleo, ou com caches, volatile não gera nenhuma barreira de hardware.
As três seções a seguir mostram o que acontece em cada caso.
De onde vem o mito
Se o volatile não resolve, por que tanta gente aprendeu que ele resolve? A resposta é histórica.
O volatile existe desde o C89, e foi criado para registradores de periféricos, para variáveis locais que precisam sobreviver a um longjmp e para a comunicação com tratadores de sinal. Até o C11, o padrão da linguagem simplesmente não tinha a noção de threads ou de concorrência: quem precisava disso usava as bibliotecas do sistema, como as threads POSIX, ou as APIs de um RTOS, e contava com o compilador não otimizar demais em volta delas. Os compiladores da época eram bem menos agressivos, e o volatile “funcionava” na maioria dos casos.
Em 2005, Hans Boehm mostrou, em um artigo que ficou famoso, que threads não podem ser implementadas corretamente só como uma biblioteca, sem que a própria linguagem defina o que acontece quando duas linhas de execução acessam a mesma memória [11]. Esse trabalho levou ao modelo de memória e aos atomics que entraram no C++11 e no C11, em 2011. Desde então, a linguagem tem as ferramentas certas, mas o hábito ficou. Os desenvolvedores do kernel Linux, por exemplo, mantêm um documento inteiro explicando por que o volatile não deve ser usado para sincronização [12].
Armadilha 1: leitura-modificação-escrita
Considere um conjunto de flags em uma palavra, em que o código principal liga um bit e uma interrupção liga outro:
volatile uint32_t flags;
void liga_bit(void)
{
flags |= 0x4;
}
O |= parece uma operação só, mas no Cortex-M vira três instruções, tanto no M0 quanto no M4: LDR (lê a palavra), ORR (liga o bit em um registrador) e STR (grava a palavra de volta). Se a interrupção ligar outro bit entre o LDR e o STR, o STR grava o valor antigo da palavra, e o bit ligado pela interrupção desaparece.
O mesmo vale para um contador incrementado pelos dois lados. Para ver isso acontecer, o experimento do repositório (host/rmw.c) faz o código principal incrementar um contador 300 milhões de vezes:
for (unsigned i = 0; i < n; i++) {
if (usa_atomic) {
atomic_fetch_add(&contador_a, 1);
} else {
contador_v++;
}
}
Enquanto isso, a “interrupção”, um sinal do sistema operacional disparado a cada 20 µs, faz o mesmo incremento no mesmo contador. O programa roda duas vezes: uma com o contador volatile (contador_v) e outra com o atômico (contador_a), e no fim compara o valor obtido com o esperado, que é o número de incrementos do laço mais o número de interrupções. Com o GCC, o resultado de uma execução típica foi:
volatile: esperado 300009032, obtido 300005893, incrementos perdidos: 3139
atomic: esperado 300062980, obtido 300062980, incrementos perdidos: 0
Milhares de incrementos perdidos em cerca de 20 mil interrupções. A versão com atomic_fetch_add, que veremos adiante, não perde nenhum.
Um detalhe curioso: compilado com o Clang no PC, o contador volatile não perde nada. O motivo é que, no x86, o Clang faz o incremento com uma única instrução que lê, soma e grava direto na memória (incl), e uma interrupção não acontece no meio de uma instrução. É uma sorte da arquitetura, não uma garantia da linguagem, e não se repete no Cortex-M: como toda arquitetura load/store, ele não tem instruções que modificam a memória diretamente, e o mesmo Clang gera LDR, ADDS e STR.
Armadilha 2: a torn read
Um contador de ticks de 64 bits, incrementado por uma interrupção de timer, é comum para ter um timestamp que não dá a volta em poucos dias:
volatile uint64_t ticks;
uint64_t le_ticks(void)
{
return ticks;
}
Em um processador de 32 bits, a leitura de 64 bits não cabe em uma instrução. No Cortex-M0, o GCC gera dois LDR, um para cada metade. Se a interrupção incrementar o contador entre as duas leituras, e esse incremento fizer a parte baixa dar a volta, o resultado mistura a parte alta antiga com a parte baixa nova. O contador parece andar para trás (ou dar um salto de 4 bilhões de ticks). É o que em inglês se chama de torn read: uma leitura feita em pedaços, que mistura partes de dois valores diferentes.
No experimento do repositório (host/torn_read.c), as duas metades são lidas uma de cada vez, como no M0, enquanto a interrupção incrementa o contador com um passo grande, para que a parte baixa dê a volta com frequência. Em 200 milhões de leituras, o contador pareceu andar para trás algumas centenas de vezes.
E no Cortex-M4? Ali o GCC lê os 64 bits com uma única instrução, LDRD. Mas o próprio GCC não a trata como uma leitura atômica: quando se pede uma leitura atômica de 64 bits com atomic_load, ele gera uma chamada à função de biblioteca __atomic_load_8, e a macro ATOMIC_LLONG_LOCK_FREE vale 1 (“às vezes”) no M0, no M4 e no M7. A arquitetura ARMv7-M garante atomicidade para acessos de uma palavra, não de duas [3].
Há duas correções clássicas. A primeira é ler a parte alta, a baixa e a alta de novo, repetindo enquanto a parte alta mudar no meio:
static uint64_t le_repete(void)
{
uint32_t hi, lo;
do {
hi = ticks_hi;
lo = ticks_lo;
} while (hi != ticks_hi);
return ((uint64_t)hi << 32) | lo;
}
Ela funciona quando só a interrupção escreve no contador e não precisa desligar nada. A segunda é uma seção crítica, que veremos mais adiante. No mesmo experimento, as duas eliminaram completamente os recuos.
Armadilha 3: a reordenação
O padrão produtor e consumidor mais simples é escrever um dado e depois ligar uma flag avisando que ele está pronto. Se só a flag for volatile:
int dado;
volatile int pronto;
void publica(int v)
{
dado = v;
pronto = 1;
dado = v + 1;
}
O exemplo é artificial de propósito, mas o efeito aparece em código real de forma menos óbvia. O GCC e o Clang, no x86 e no Cortex-M4, fazem a mesma coisa com essa função: eliminam a primeira escrita em dado, que é sobrescrita logo depois, e fazem a única escrita que sobra depois de pronto = 1. Quem vê pronto == 1 em uma interrupção lê um dado antigo.
O compilador pode fazer isso porque o volatile só ordena os acessos volatile entre si. Para ele, dado é uma variável comum, e escritas em variáveis comuns podem ser movidas ou eliminadas à vontade, desde que o resultado seja o mesmo para um programa de uma só linha de execução. Tornar dado volatile também resolveria, mas isso não escala: o “dado” de verdade costuma ser um buffer ou um struct inteiro.
Atomics do C11
O cabeçalho stdatomic.h, do C11, oferece tipos atômicos (atomic_int, atomic_uint, atomic_bool, ou qualquer tipo qualificado com _Atomic) e operações sobre eles: atomic_load, atomic_store, atomic_fetch_add e seus parentes, atomic_exchange e atomic_compare_exchange_strong. Cada operação é indivisível: uma interrupção vê o valor antes ou depois dela, nunca no meio.
Além da atomicidade, cada operação tem uma ordem de memória, que diz como os acessos comuns em volta podem ser reordenados. Sem entrar na teoria, três ordens resolvem quase tudo em firmware:
- memory_order_relaxed: só a atomicidade, sem ordem em relação ao resto. Serve para contadores de estatística.
- memory_order_release (em uma escrita) e memory_order_acquire (em uma leitura): tudo o que foi escrito antes do release fica visível para quem fizer o acquire e ler o valor escrito. É exatamente o que faltou na armadilha 3: o dado escrito antes da flag passa a estar garantido para quem vê a flag.
- memory_order_seq_cst: a mais forte, e a padrão quando não se especifica nada.
No Cortex-M4, a diferença aparece no número de barreiras de memória (DMB) que o GCC coloca em volta de cada acesso:
| Ordem | Escrita | Leitura |
|---|---|---|
| relaxed | STR | LDR |
| release / acquire | DMB + STR | LDR + DMB |
| seq_cst | DMB + STR + DMB | DMB + LDR + DMB |
Em um Cortex-M de um núcleo, um DMB custa poucos ciclos. Mais importante que a instrução em si é o efeito no compilador: as ordens mais fortes também o impedem de mover acessos através da operação.
Exemplo: um ring buffer entre a interrupção e o código principal
O uso mais comum de atomics em firmware é uma fila em que a interrupção produz (bytes recebidos pela UART, amostras de um ADC) e o código principal consome.
Um bom ponto de partida é o buffer circular que o Felipe Neves publicou aqui mesmo no Embarcados, em 2016 [7], e que vamos chamar de buffer_FN. Simples e didático, ele foi pensado para uso geral, e o artigo não fala em interrupções; justamente por isso, é um ótimo exercício ver o que muda quando quem insere é uma ISR. Além dos índices de escrita e leitura, o buffer mantém um contador de itens, atualizado pelos dois lados:
b->items++; /* em buffer_insert(), chamada pela ISR */
b->items--; /* em buffer_retrieve(), chamada pelo código principal */
É a armadilha 1 em outra roupa. O código principal lê items para decrementá-lo; antes de gravar o resultado, a interrupção chega, insere um byte e incrementa items; ao voltar, o código principal grava o valor que tinha calculado, e o incremento da ISR desaparece. A partir daí, o contador diz que há um item a menos do que realmente há, e o produtor passa a enxergar espaço que não existe. No Cortex-M, cada uma dessas linhas vira três instruções (LDR, ADD ou SUB, STR), e a janela tem uma instrução de largura.
E o detalhe que torna esse tipo de defeito traiçoeiro: rodamos o próprio buffer_FN no PC, com a “interrupção” inserindo a cada 20 µs e o código principal retirando em rajadas: foram três milhões de leituras sem nenhuma falha. A janela existe, mas é tão estreita que o teste de bancada passa; o problema aparece em campo, meses depois, em um dispositivo que trava sem explicação. Mais adiante, vamos ver essa mesma corrida acontecer em um Cortex-M emulado.
Agradeço ao Felipe Neves, que autorizou o uso do buffer_FN neste artigo.
A solução é eliminar o contador compartilhado. Com um produtor e um consumidor, cada índice pode ser escrito por um lado só, e a fila pode ser feita sem nenhuma trava:
typedef struct {
uint32_t dados[RB_TAMANHO];
atomic_uint cabeca; /* escrito só pelo produtor */
atomic_uint cauda; /* escrito só pelo consumidor */
} ringbuf_t;
/* Chamado pela ISR. */
static inline bool rb_poe(ringbuf_t *rb, uint32_t v)
{
unsigned cab = atomic_load_explicit(&rb->cabeca, memory_order_relaxed);
unsigned cau = atomic_load_explicit(&rb->cauda, memory_order_acquire);
if (cab - cau == RB_TAMANHO) {
return false; /* cheio */
}
rb->dados[cab % RB_TAMANHO] = v;
atomic_store_explicit(&rb->cabeca, cab + 1, memory_order_release);
return true;
}
Cada índice é escrito por um lado só, e o produtor publica o dado com um release na cabeça. O consumidor, simétrico, faz um acquire na cabeça antes de ler o dado e um release na cauda depois. No experimento do repositório (host/ring.c), a “interrupção” produziu uma sequência de números e o código principal conferiu 50 mil leituras, todas em ordem.
Há um bônus importante: como o ring buffer só usa leituras e escritas atômicas, e nenhuma operação de leitura-modificação-escrita, ele compila e linka até no Cortex-M0, sem nenhuma função auxiliar, apenas com algumas instruções DMB. Isso nos leva ao próximo ponto.
Quando o atomic não é lock-free
O padrão garante que uma operação atômica é indivisível, mas não diz como. Na verdade, ele só exige que um tipo seja lock-free, o atomic_flag, e diz explicitamente que os demais podem ser emulados a partir dele [2]. No Cortex-M3, M4 e M7, um atomic_fetch_add vira um laço com as instruções LDREX e STREX, direto no código. O Cortex-M0 não tem essas instruções, e o GCC gera uma chamada a uma função auxiliar, __atomic_fetch_add_4, que alguém precisa fornecer. Com o toolchain do Zephyr SDK, ninguém fornece, e o resultado é um erro de link:
undefined reference to `__atomic_fetch_add_4'
Se a função vier de outro lugar, de uma biblioteca ou do próprio projeto, é preciso saber como ela foi implementada. O padrão não define o mecanismo: ela pode desligar interrupções, o que só funciona em um núcleo, ou usar uma trava, e uma interrupção que espere por uma trava segurada pelo código que ela interrompeu fica parada para sempre.
O C11 oferece uma forma de verificar isso em tempo de compilação. As macros ATOMIC_INT_LOCK_FREE, ATOMIC_LLONG_LOCK_FREE e semelhantes valem 0 (nunca lock-free), 1 (às vezes) ou 2 (sempre):
_Static_assert(ATOMIC_INT_LOCK_FREE == 2,
"atomic_int precisa ser lock-free para uso em ISR");
Os valores variam bastante de um núcleo para outro. Com o GCC 14 do Zephyr SDK, e comparando com o PC:
| Núcleo | ATOMIC_BOOL_LOCK_FREE | ATOMIC_INT_LOCK_FREE | ATOMIC_LLONG_LOCK_FREE |
|---|---|---|---|
| Cortex-M0 e M0+ | 1 | 1 | 1 |
| Cortex-M3, M4, M7 e M33 | 2 | 2 | 1 |
| x86-64 | 2 | 2 | 2 |
Dois detalhes chamam a atenção: no M0, nem o atomic_bool é garantidamente lock-free; e em nenhum Cortex-M o atomic de 64 bits é, o que confirma que o contador de ticks da armadilha 2 precisa de outra solução. A verificação com _Static_assert acima falha na compilação para o M0.
Barrar o build é uma forma de usar essas macros; a outra é decidir. Como elas são avaliadas pelo pré-processador, dá para escolher a implementação conforme o núcleo:
#if ATOMIC_INT_LOCK_FREE == 2
static atomic_uint rx_count;
static inline void conta_rx(void)
{
atomic_fetch_add_explicit(&rx_count, 1, memory_order_relaxed);
}
#else
static volatile unsigned rx_count;
static inline void conta_rx(void)
{
uint32_t primask;
__asm__ volatile("mrs %0, primask\n\tcpsid i" : "=r"(primask) : : "memory");
rx_count++;
__asm__ volatile("msr primask, %0" : : "r"(primask) : "memory");
}
#endif
A ISR e a tarefa chamam conta_rx() sem saber qual versão foi escolhida. No Cortex-M4, a função vira o laço com LDREX e STREX; no M0, vira uma seção crítica, que desliga as interrupções durante o incremento, e o firmware compila e linka nos dois. A seção crítica é o assunto da próxima seção. Para verificar um objeto específico em tempo de execução, há ainda a função atomic_is_lock_free().
O próprio padrão reconhece o problema em um contexto parecido: em um tratador de sinal, o equivalente mais próximo de uma ISR em um sistema operacional, só o acesso a atomics lock-free e a volatile sig_atomic_t tem comportamento definido [2].
Seções críticas: quando os atomics não bastam
Há casos em que os atomics não resolvem: o estado tem mais de uma palavra (o contador de 64 bits, um struct com várias medidas que precisam ser lidas juntas) ou o processador não tem operações lock-free. A solução clássica é a seção crítica: desligar as interrupções, fazer o acesso e religá-las.
No Cortex-M, o cuidado é restaurar o estado anterior, e não simplesmente religar as interrupções, para que a função funcione mesmo quando chamada com as interrupções já desligadas:
static inline uint32_t irq_salva_e_desliga(void)
{
uint32_t primask;
__asm__ volatile("mrs %0, primask\n\tcpsid i" : "=r"(primask) : : "memory");
return primask;
}
static inline void irq_restaura(uint32_t primask)
{
__asm__ volatile("msr primask, %0" : : "r"(primask) : "memory");
}
O CMSIS oferece o mesmo com __get_PRIMASK, __disable_irq e __set_PRIMASK. O clobber “memory” é a barreira de compilador do artigo anterior: impede que o compilador mova acessos para fora da região protegida.
Os RTOS encapsulam isso. No Zephyr, irq_lock() e irq_unlock(); no FreeRTOS, taskENTER_CRITICAL() e taskEXIT_CRITICAL(), com as variantes FromISR para uso dentro de interrupções.
Duas ressalvas. Seções críticas aumentam a latência das interrupções, então devem ser curtas: copiar o contador de 64 bits, não processar o buffer inteiro. E desligar interrupções só protege contra o próprio núcleo. Em um chip com dois núcleos, como o RP2040, com dois Cortex-M0+, o outro núcleo continua rodando; ali é preciso uma trava de verdade, e o RP2040 oferece spinlocks em hardware para isso.
E as APIs do RTOS?
Os dois RTOS mais usados em Cortex-M têm APIs próprias de operações atômicas, e as duas ilustram o que vimos:
- O Zephyr tem o tipo atomic_t, que, segundo a documentação, pode ser usado tanto por threads quanto por ISRs [4]. No Cortex-M3 e superiores, as operações usam os builtins atômicos do compilador. No ARMv6-M (Cortex-M0 e M0+), o Kconfig seleciona uma implementação em C, em lib/os/atomic_c.c, protegida por um spinlock global do kernel, que em um núcleo único desliga as interrupções [5].
- O FreeRTOS tem o cabeçalho atomic.h, com funções como Atomic_Increment_u32 e Atomic_CompareAndSwap_u32, implementadas com uma seção crítica que mascara as interrupções [6].
Ou seja: no M0, “atômico” nos dois RTOS significa seção crítica. É uma escolha razoável, mas é bom saber que é isso que acontece por trás do nome.
Barreiras de hardware, em poucas palavras
O Cortex-M tem três instruções de barreira: DMB (ordena acessos à memória), DSB (espera os acessos terminarem) e ISB (descarta as instruções já buscadas). Em um Cortex-M de um núcleo, que executa as instruções em ordem, o problema principal deste artigo é o compilador, não o processador. As barreiras de hardware importam em situações específicas: depois de reconfigurar registradores de sistema, como o VTOR e a MPU; antes de entrar em modo de baixo consumo com WFI; e no Cortex-M7, que tem caches, ao compartilhar buffers com o DMA. Cada um desses casos merece seu próprio artigo.
E num Cortex-M de verdade? Os experimentos no QEMU
Até aqui, os experimentos que rodam usaram o PC, com um sinal no papel da interrupção. Para ver as armadilhas em um Cortex-M, com uma interrupção de verdade e o código ARM gerado pelo GCC, dá para usar o QEMU, que vem junto com o Zephyr SDK. O Zephyr tem placas prontas para ele, como a qemu_cortex_m0 (que emula um nRF51, da BBC micro:bit) e a qemu_cortex_m3 (um Stellaris LM3S6965) [8].
Com o ambiente do Zephyr instalado, seguindo o guia de primeiros passos do projeto [9], o roteiro é o da documentação da placa:
west build -b qemu_cortex_m0 zephyr
west build -t run
O app do repositório roda as mesmas experiências do PC: o contador volatile e o atomic_t do Zephyr incrementados pelo código principal e pela interrupção, a leitura do contador de 64 bits e o buffer_FN. A “interrupção” é a expiração de um k_timer, que no Zephyr roda no contexto da interrupção do relógio do sistema, configurado para 10 kHz.
O primeiro resultado, na qemu_cortex_m0, parece desmentir o artigo: nenhuma falha, em nenhuma experiência, nem com volatile. A explicação está em como o QEMU funciona. Ele traduz o código ARM em blocos de instruções e só verifica se há interrupção pendente entre um bloco e outro. O LDR e o STR de um contador++ ficam no mesmo bloco, e a interrupção nunca cai entre eles. No emulador, o bug simplesmente não existe.
Para chegar mais perto do hardware, o QEMU tem a opção -accel tcg,one-insn-per-tb=on, que coloca uma única instrução em cada bloco [10]. A emulação fica mais lenta, mas a interrupção passa a poder acontecer entre quaisquer duas instruções. No Zephyr, opções extras para o QEMU entram pela variável de ambiente QEMU_EXTRA_FLAGS, com um detalhe que custa algum tempo para descobrir: ela é lida na configuração do build, e não no -t run. É preciso definir a variável já no west build:
QEMU_EXTRA_FLAGS=”-accel tcg,one-insn-per-tb=on” west build -p always -b qemu_cortex_m0 zephyr
west build -t run
Com a opção, o mesmo firmware, na mesma placa emulada, conta outra história:
| qemu_cortex_m0 | Modo padrão | Uma instrução por bloco |
|---|---|---|
| Contador volatile | nenhum incremento perdido | 23.208 perdidos (em cerca de 105 mil interrupções) |
| Contador atomic_t | nenhum perdido | nenhum perdido |
| Leitura de 64 bits | nenhum recuo | 3.384 recuos |
| buffer_FN | contador correto | items = 0, com 112 bytes no buffer |
O último resultado é a corrida da seção anterior acontecendo de verdade: o contador de itens perdeu incrementos e passou a dizer que o buffer está vazio, com 112 bytes parados dentro dele.
A qemu_cortex_m3 mostra as falhas já no modo padrão, porque a configuração da placa liga a opção -icount do QEMU, que conta as instruções executadas e dispara o timer em um ponto exato dessa contagem, inclusive no meio de um bloco. Nela, o contador volatile perdeu cerca de 100 mil incrementos, o buffer_FN entregou 2.160 bytes fora de ordem e terminou dessincronizado, e o atomic_t não perdeu nada. A execução é determinística: rodando de novo, os números são exatamente os mesmos. A leitura de 64 bits não teve nenhuma torn read, porque no M3 ela é uma única instrução LDRD, e o QEMU não interrompe no meio de uma instrução; o que vale para o hardware, como vimos, é a garantia da arquitetura, e não o comportamento do emulador.
A lição vale para qualquer forma de teste: o mesmo binário passou ou falhou dependendo de um detalhe de como as interrupções são entregues. Um teste que passa não prova que a corrida não existe; só o raciocínio sobre o código, e as ferramentas certas, resolvem.
A regra de ouro
O volatile responde à pergunta: “o compilador pode remover ou juntar este acesso?”.
Atomics, seções críticas e mutexes respondem a outra: “este acesso é seguro na presença de concorrência?”.
Resumo: qual ferramenta para qual problema
| Situação | Ferramenta |
|---|---|
| Registrador de periférico | volatile |
| Flag de uma palavra, escrita pela ISR e lida pelo código principal | volatile ou atomic_bool |
| Contador ou flags com leitura-modificação-escrita | atomic, se ATOMIC_*_LOCK_FREE == 2; senão, seção crítica |
| Dado publicado por uma flag (produtor e consumidor) | atomics com release e acquire |
| Valor de 64 bits ou struct inteiro | seção crítica (ou releitura, se só a ISR escreve) |
| Dois núcleos | trava de verdade (spinlock) |
Conclusão
O volatile é a ferramenta certa para registradores de periféricos e funciona para a flag mais simples, mas não é sincronização: não torna operações atômicas, não ordena os acessos comuns em volta e não protege valores que não cabem em uma palavra. Os atomics do C11 resolvem os três problemas de forma portátil, com a ressalva de que, em núcleos sem operações lock-free como o Cortex-M0, o mais seguro é verificar com ATOMIC_*_LOCK_FREE e recorrer a seções críticas.
A verificação com _Static_assert que usamos aqui é um exemplo de algo maior: deixar o compilador pegar o erro antes de o firmware chegar à placa. No próximo artigo da série, vamos explorar esses diagnósticos em tempo de compilação: static_assert, [[nodiscard]], -Wswitch, o atributo error do GCC e o [[fallthrough]].
Referências
- DIAS, Rafael. rdws_examples_articles: exemplos deste artigo. GitHub.
- WG14. N1570: rascunho final do C11: seções 5.1.2.3 (execução do programa e volatile), 6.7.3 (qualificadores), 7.14.1.1 (tratadores de sinal) e 7.17 (atomics, em especial 7.17.5, sobre a propriedade lock-free, e 7.17.8, sobre o atomic_flag).
- Arm. ARMv7-M Architecture Reference Manual, seção sobre atomicidade dos acessos à memória.
- Zephyr Project. Atomic Services.
- Zephyr Project. lib/os/atomic_c.c.
- FreeRTOS. include/atomic.h.
- NEVES, Felipe. Buffer circular para sistemas embarcados. Embarcados, 2016.
- Zephyr Project. QEMU Emulation for ARM Cortex-M0 e índice das placas QEMU.
- Zephyr Project. Getting Started Guide.
- QEMU. Invocation: opções do acelerador TCG (one-insn-per-tb).
- BOEHM, Hans-J. Threads Cannot Be Implemented as a Library. PLDI 2005. DOI 10.1145/1065010.1065042.
- Linux kernel. Why the “volatile” type class should not be used.
Esta obra está licenciada com uma Licença Creative Commons Atribuição-CompartilhaIgual 4.0 Internacional.
