Na programação em C, o compilador frequentemente otimiza o código para torná-lo mais rápido ou menor, assumindo que as variáveis não mudam de forma inesperada. No entanto, em sistemas embarcados, programação concorrente ou interação com hardware, tais suposições podem levar a comportamentos incorretos. Para lidar com essas situações, a linguagem C fornece os qualificadores de tipo volatile e restrict. Nesta aula, vamos explorar o propósito de cada um, quando utilizá-los e como eles afetam as otimizações do compilador.

Dominar esses qualificadores é essencial para escrever código C robusto e eficiente, especialmente em áreas como desenvolvimento de drivers, sistemas operacionais e programação de baixo nível. Veremos exemplos práticos e casos de uso reais, além de exercícios para fixar o conhecimento.

volatile (quando usar)

O qualificador volatile informa ao compilador que o valor de uma variável pode mudar a qualquer momento, sem ação explícita do código. Isso impede que o compilador otimize acessos a essa variável, forçando uma leitura ou escrita a cada ocorrência no código. Sem volatile, o compilador pode armazenar o valor em um registrador e reutilizá-lo, ignorando mudanças externas.

Quando usar? Principalmente nos seguintes cenários:

  • Registradores de hardware: Endereços de memória mapeados em hardware (MMIO) que mudam independentemente do software.
  • Variáveis compartilhadas entre threads ou handlers de interrupção: Quando uma variável é modificada por uma interrupção ou outra thread, o compilador não deve otimizar o acesso.
  • Variáveis usadas em signal handlers: Para garantir que o valor lido seja sempre o atualizado.
  • Testes de loops que dependem de uma variável externa: Por exemplo, um loop que espera uma flag ser alterada por um evento externo.

Exemplo típico com registro de hardware:

#include <stdint.h>

// Suponha que o endereço 0x40000000 seja um registrador de status
#define STATUS_REG (*(volatile uint32_t *)0x40000000)

int main() {
    while (!(STATUS_REG & 0x1)) {
        // espera o bit 0 ser setado pelo hardware
    }
    // continua...
    return 0;
}

Sem volatile, o compilador poderia ler o valor uma vez e entrar em loop infinito, pois assumiria que o valor não muda. Com volatile, cada leitura é feita diretamente do endereço.

restrict

O qualificador restrict é usado em ponteiros para indicar que, durante o escopo do ponteiro, ele é o único ponteiro que pode acessar o objeto apontado. Isso permite que o compilador otimize o código agressivamente, pois pode assumir que não há alias (dois ponteiros diferentes apontando para o mesmo objeto).

É importante notar que restrict é um contrato com o compilador: se você violar essa promessa, o comportamento é indefinido. Portanto, só use quando tiver certeza de que o ponteiro é a única referência ao objeto durante o escopo.

Exemplo:

#include <string.h>

void copy_array(int *restrict dest, const int *restrict src, size_t n) {
    for (size_t i = 0; i < n; ++i) {
        dest[i] = src[i];
    }
}

Sem restrict, o compilador não pode assumir que dest e src não se sobrepõem, então pode ter que carregar cada valor a cada iteração. Com restrict, ele pode otimizar o loop, por exemplo, usando vetorização.

Outro exemplo comum é na função memcpy, que é declarada com restrict nos parâmetros, pois não se espera que as áreas de memória se sobreponham (diferente de memmove).

Otimizações

Os qualificadores volatile e restrict têm impactos opostos nas otimizações: volatile inibe otimizações, enquanto restrict as permite. Entender isso é crucial para escrever código eficiente sem sacrificar a corretude.

Com volatile, o compilador não pode:

  • Reordenar operações de leitura/escrita voláteis em relação a outras operações voláteis.
  • Eliminar acessos redundantes a variáveis voláteis.
  • Armazenar o valor em registrador entre usos.

Isso pode reduzir o desempenho, mas é necessário para garantir comportamento correto em interações com hardware ou concorrência.

Com restrict, o compilador pode:

  • Hoistar (mover para fora de loops) leituras de memória.
  • Vetorizar loops.
  • Eliminar carregamentos redundantes.

Exemplo de otimização com restrict:

void add_scalar(int *restrict a, const int *restrict b, int n, int scalar) {
    for (int i = 0; i < n; ++i) {
        a[i] = b[i] + scalar;
    }
}

O compilador pode assumir que a e b não se sobrepõem, permitindo que carregue b[i] e armazene em a[i] sem se preocupar com dependências.

No entanto, é importante não usar restrict se houver chance de alias, pois isso leva a comportamento indefinido e pode causar erros difíceis de depurar.

Casos de uso

Vamos consolidar com casos práticos onde volatile e restrict são aplicados no mundo real.

volatile em sistemas embarcados

Um exemplo clássico é a leitura de um sensor via barramento I2C ou SPI, onde o valor é atualizado pelo hardware. O código deve usar volatile para garantir que cada leitura acesse o registrador real.

#include <stdint.h>

// Endereço de um registrador de dados do sensor
#define SENSOR_DATA (*(volatile uint16_t *)0x20000000)

uint16_t read_sensor() {
    return SENSOR_DATA;
}

volatile em programação concorrente

Em aplicações com múltiplas threads, uma variável compartilhada usada como flag de parada deve ser volatile (ou melhor, atômica, mas em C padrão, volatile é uma abordagem comum em código legado).

#include <stdbool.h>
#include <pthread.h>

volatile bool running = true;

void* worker(void* arg) {
    while (running) {
        // faz trabalho
    }
    return NULL;
}

int main() {
    pthread_t t;
    pthread_create(&t, NULL, worker, NULL);
    // ...
    running = false;
    pthread_join(t, NULL);
    return 0;
}

restrict em funções de manipulação de memória

Funções como memcpy usam restrict para permitir otimizações, pois não se espera sobreposição.

void* my_memcpy(void *restrict dest, const void *restrict src, size_t n) {
    unsigned char *d = dest;
    const unsigned char *s = src;
    for (size_t i = 0; i < n; ++i) {
        d[i] = s[i];
    }
    return dest;
}

restrict em cálculos numéricos

Em operações de vetores, como multiplicação de matrizes, restrict pode melhorar muito o desempenho.

void matmul(int n, double *restrict C, const double *restrict A, const double *restrict B) {
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < n; ++j) {
            double sum = 0.0;
            for (int k = 0; k < n; ++k) {
                sum += A[i*n + k] * B[k*n + j];
            }
            C[i*n + j] = sum;
        }
    }
}

Boas práticas e observações finais

Ao usar volatile, lembre-se de que ele não garante atomicidade. Para operações atômicas, use stdatomic.h (C11) ou mecanismos de sincronização. volatile apenas evita otimizações, mas não impede corridas de dados.

Para restrict, use-o com moderação e apenas quando houver certeza de não haver alias. Ele é um contrato com o compilador; violá-lo resulta em comportamento indefinido. Em C++, restrict não é padrão (mas alguns compiladores suportam __restrict), então em C++ prefira usar referências ou outras estratégias.

Sempre documente o uso desses qualificadores para que outros programadores entendam as suposições feitas.

Referências

Exercícios

  1. Explique por que o uso de volatile é necessário ao ler um registrador de hardware que pode ser alterado por um dispositivo externo. Dê um exemplo de código que falharia sem volatile.

    ✓ Resposta: O compilador pode otimizar o acesso a uma variável comum, mantendo seu valor em um registrador e não relendo da memória. Se o hardware altera o valor no endereço, o código leria um valor desatualizado. Exemplo: um loop que espera uma flag ser setada pelo hardware. Sem volatile, o compilador pode ler o valor uma vez e entrar em loop infinito.
  2. Qual a diferença entre memcpy e memmove em relação ao uso de restrict? Por que memcpy usa restrict e memmove não?

    ✓ Resposta: memcpy assume que as áreas de origem e destino não se sobrepõem, portanto pode usar restrict para permitir otimizações. memmove deve funcionar mesmo com sobreposição, então não pode usar restrict (ou usa internamente uma lógica que lida com sobreposição).
  3. Escreva uma função void vector_add(int *restrict a, const int *restrict b, int n) que soma os elementos de b em a. Explique como restrict pode ajudar o compilador a otimizar.

    ✓ Resposta:
    void vector_add(int *restrict a, const int *restrict b, int n) {
        for (int i = 0; i < n; ++i) {
            a[i] += b[i];
        }
    }
    O compilador pode assumir que a e b não se sobrepõem, permitindo vetorização e evitando recarregar b[i] a cada iteração, melhorando o desempenho.
  4. Considere o seguinte código:

    int x = 0;
    void handler(int sig) {
        x = 1;
    }
    int main() {
        signal(SIGINT, handler);
        while (x == 0) {
            // faz nada
        }
        printf("Saiu do loop\n");
        return 0;
    }

    Este código tem um problema de otimização. Qual é e como corrigir?

    ✓ Resposta: O compilador pode otimizar o loop while (x == 0) para um loop infinito, pois assume que x não muda. Para corrigir, declare x como volatile int x para forçar a leitura de x a cada iteração.
  5. O que acontece se você usar restrict em dois ponteiros que apontam para o mesmo objeto? Dê um exemplo de comportamento indefinido.

    ✓ Resposta: Se dois ponteiros restrict apontam para o mesmo objeto, o comportamento é indefinido. Por exemplo:
    void f(int *restrict p, int *restrict q) {
        *p = 10;
        *q = 20;
        printf("%d\n", *p); // pode imprimir 10 ou 20, indefinido
    }
    O compilador pode otimizar assumindo que p e q não se sobrepõem, causando resultados imprevisíveis.