Quase tudo o que você usa hoje roda, em algum nível, sobre código escrito em C. O kernel do Linux, o interpretador oficial do Python (CPython), o SQLite que vive dentro de todo celular e navegador, o Redis, o Git, o PostgreSQL, o nginx, o curl, o firmware do micro-ondas e do controle remoto do portão. C tem mais de 50 anos e continua sendo a língua franca entre o software e o hardware.

Este guia foi escrito para ser a referência que eu gostaria de ter tido quando comecei: ele não começa no printf, começa no transistor. Vamos subir camada por camada, bits, portas lógicas, CPU, cache, memória virtual, até chegar a ponteiros, alocação dinâmica, structs, Makefiles, depuração com GDB e um projeto completo. Tudo em português, com exemplos que compilam de verdade.

1. Por que aprender C (ainda) em 2026

C é chamada de linguagem de "baixo nível" (ou, mais precisamente, de nível médio) porque os conceitos dela se mapeiam quase diretamente no que o hardware faz. Uma variável int é um punhado de bytes em um endereço de memória. Um ponteiro é, literalmente, um número que guarda esse endereço. Uma soma vira uma instrução add da CPU. Não existe coletor de lixo, não existe máquina virtual no meio do caminho, não existe verificação automática de limites de array. Você manda; a máquina obedece, inclusive quando você manda errado.

Isso traz três consequências práticas:

  • Desempenho previsível. O custo de cada linha é visível. Por isso C domina sistemas operacionais, bancos de dados, drivers, compiladores, runtimes de outras linguagens e sistemas embarcados.
  • Portabilidade. Existe um compilador C para praticamente toda CPU já fabricada, do microcontrolador de 8 bits ao supercomputador. C é às vezes chamada de "assembly portátil".
  • A interface universal. Quando Python, Rust, Go, Node.js ou Java precisam conversar com uma biblioteca nativa, eles usam a interface binária (ABI) de C. Aprender C é aprender a língua em que os outros programas conversam entre si.

E há um benefício que vale para qualquer desenvolvedor, mesmo quem nunca vai escrever C profissionalmente: entender C é entender o que acontece por baixo de todas as outras linguagens. Quando você souber o que é a pilha, o heap, uma cache miss ou um ponteiro pendurado, vai escrever JavaScript, Python ou Java melhores.

O que você vai aprender

  • Como bits, transistores, CPU, registradores, caches e RAM funcionam, e por que isso importa para o seu código.
  • Como números inteiros, negativos (complemento de dois), números com vírgula (IEEE 754) e texto (ASCII e UTF-8) são guardados em bits.
  • O que é memória virtual, como um processo é organizado na memória e o que é, fisicamente, um ponteiro.
  • O caminho completo do arquivo .c até o executável: pré-processador, compilador, montador e linker.
  • Como instalar o GCC no Windows, macOS e Linux, e quais flags usar no dia a dia.
  • Toda a linguagem: tipos, operadores, entrada e saída, controle de fluxo, funções, escopo, arrays, strings, ponteiros, memória dinâmica, structs, unions, enums, pré-processador, múltiplos arquivos e arquivos em disco.
  • Tratamento de erros, comportamento indefinido, depuração com GDB, sanitizers e Valgrind.
  • Um mini projeto completo com lista encadeada, malloc/free e persistência em arquivo.
  • Uma cola de consulta rápida no estilo pôster, com os comandos e funções mais usados.

Uma brevíssima história do C

C nasceu no Bell Labs, nos Estados Unidos, entre 1969 e 1973, pelas mãos de Dennis Ritchie. Ela evoluiu da linguagem B, criada por Ken Thompson, que por sua vez derivava do BCPL. O objetivo era muito concreto: reescrever o sistema operacional Unix em uma linguagem de alto nível sem perder o controle sobre o hardware. Em 1973 o kernel do Unix foi reescrito em C, algo ousado para a época, e isso tornou o Unix portável para outras máquinas.

Em 1978, Brian Kernighan e Dennis Ritchie publicaram o livro The C Programming Language, conhecido como "K&R", que virou o padrão de fato por mais de uma década. Depois vieram os padrões oficiais:

PadrãoAnoValor de __STDC_VERSION__Destaques
C89 / C90 (ANSI/ISO)1989 / 1990não definidoPrimeira padronização oficial
C991999199901L//, long long, stdint.h, bool, inline, declarar variáveis no meio do bloco
C112011201112L_Static_assert, _Generic, atomics, threads opcionais, remoção de gets
C172018201710LApenas correções; nenhum recurso novo
C232024202311Lbool/true/false e nullptr como palavras-chave, constexpr, typeof, 0b

O C23 foi publicado pela ISO como ISO/IEC 9899:2024. Neste guia usamos C17 como base, porque é o padrão mais bem suportado por todos os compiladores, e apontamos as novidades do C23 quando elas ajudam.

O que você vai precisar

  • Um computador com Windows, macOS ou Linux.
  • Um terminal (PowerShell, Terminal do macOS ou o terminal da sua distro). Se o terminal ainda te intimida, o guia de comandos do terminal Linux ajuda bastante.
  • Um editor de código (o VS Code é uma ótima escolha, veremos adiante).
  • Nenhum conhecimento prévio de C. Ter noções de lógica de programação em qualquer linguagem ajuda, mas não é obrigatório.

2. Como um computador funciona, de baixo para cima

Para entender C de verdade, precisamos entender a máquina que ele controla. Esta seção sobe do nível físico até a CPU executando instruções. Não é um curso de engenharia elétrica, mas cada conceito aqui vai reaparecer mais adiante, quando falarmos de tipos, ponteiros e desempenho.

Bits: a menor unidade de informação

Um bit (de binary digit, dígito binário) é a menor unidade de informação possível: ele só pode valer 0 ou 1. Fisicamente, um bit é qualquer coisa que tenha dois estados bem distinguíveis:

  • Dentro da CPU: um nível de tensão elétrica baixo (0) ou alto (1) em um fio do circuito.
  • Na memória RAM (DRAM): um capacitor minúsculo carregado ou descarregado.
  • No SSD: elétrons aprisionados (ou não) em uma célula de memória flash.
  • No HD mecânico: a direção da magnetização de uma região do disco.

Por que dois estados e não dez, como os dígitos decimais? Porque distinguir "tem tensão" de "não tem tensão" é robusto contra ruído, variação de temperatura e imprecisão de fabricação. Se o circuito tivesse de diferenciar dez níveis de tensão, qualquer interferência viraria erro. Com dois níveis, há uma margem enorme entre eles.

Além disso, com dois estados podemos usar a álgebra booleana (George Boole, 1854), em que tudo é verdadeiro ou falso. Em 1937, Claude Shannon mostrou em sua dissertação de mestrado que circuitos de relés podiam implementar essa álgebra, e isso abriu caminho para todo computador digital desde então.

Transistores: interruptores controlados por eletricidade

O componente que manipula bits é o transistor. Nos chips modernos, o tipo usado é o MOSFET, que funciona como um interruptor sem partes móveis: uma tensão aplicada em um terminal (a porta, ou gate) decide se a corrente pode ou não passar entre os outros dois terminais. Ligado ou desligado. 1 ou 0.

Um único transistor não faz muita coisa. Mas processadores atuais têm bilhões deles (os maiores chips de consumo já passam de dezenas de bilhões), organizados em estruturas cada vez mais complexas. A primeira dessas estruturas é a porta lógica.

Portas lógicas: o hardware que calcula

Combinando alguns transistores, construímos portas lógicas: circuitos que recebem um ou mais bits e produzem um bit de saída segundo uma regra fixa. As principais:

ABNOT AA AND BA OR BA XOR BA NAND B
0010001
0110111
1000111
1101100
  • NOT inverte o bit.
  • AND só dá 1 se as duas entradas forem 1.
  • OR dá 1 se pelo menos uma entrada for 1.
  • XOR (ou exclusivo) dá 1 se as entradas forem diferentes.
  • NAND é o AND invertido. Curiosidade: ele é universal, ou seja, qualquer outra porta pode ser construída só com NANDs.

Guarde essa tabela: em C, os operadores ~, &, | e ^ fazem exatamente essas operações, bit a bit, sobre números inteiros de 8, 16, 32 ou 64 bits. Veremos isso na seção de operadores.

Agora a mágica: com portas lógicas dá para somar. Um meio somador soma dois bits:

Texto
  A ─┬──────────[ XOR ]────▶ S (soma)        A B │ S  C
     │    ┌────▶[     ]                      ────┼──────
  B ─┼────┤                                  0 0 │ 0  0
     │    └────▶[ AND ]────▶ C (vai-um)      0 1 │ 1  0
     └─────────▶[     ]                      1 0 │ 1  0
                                             1 1 │ 0  1   ← 1 + 1 = 10 em binário

A soma é A XOR B e o "vai-um" (carry) é A AND B. Encadeando 64 somadores completos (que também consideram o vai-um que chega da coluna anterior), temos um circuito que soma dois números de 64 bits, exatamente como você somava na escola, coluna por coluna, só que em base 2.

Com o mesmo princípio de portas realimentando umas às outras, constroem-se circuitos que lembram um bit (os flip-flops e latches). Juntando flip-flops, temos os registradores. E há dois tipos principais de memória feitos de transistores:

  • SRAM (RAM estática): cerca de 6 transistores por bit. Muito rápida, cara e ocupa bastante área do chip. É usada nas caches da CPU.
  • DRAM (RAM dinâmica): 1 transistor e 1 capacitor por bit. Muito mais densa e barata, porém mais lenta, e o capacitor vaza carga, então a memória precisa ser "refrescada" periodicamente (tipicamente a cada 64 ms). É a memória RAM do computador.

A CPU por dentro: controle, ULA, registradores e clock

A CPU (Central Processing Unit, unidade central de processamento) é o circuito que executa as instruções do programa. Simplificando bastante, cada núcleo de uma CPU tem estas partes:

Texto
┌──────────────────────── Núcleo da CPU ────────────────────────┐
│                                                               │
│  ┌──────────────────┐  sinais de  ┌────────────────────────┐  │
│  │ Unidade de       │  controle   │ Registradores          │  │
│  │ controle         │────────────▶│ rax rbx rcx rdx ...    │  │
│  │ busca/decodifica │             │ rip (PC)   rflags      │  │
│  └────────┬─────────┘             └───────────┬────────────┘  │
│           │                                   │ operandos     │
│           ▼                                   ▼               │
│  ┌──────────────────┐             ┌────────────────────────┐  │
│  │ Cache L1         │◀───────────▶│ ULA (ALU)              │  │
│  │ instruções/dados │             │ + - * & | ^ << >> cmp  │  │
│  └────────┬─────────┘             └────────────────────────┘  │
│           │                                                   │
└───────────┼───────────────────────────────────────────────────┘
            ▼
     Cache L2  ──▶  Cache L3 (compartilhada)  ──▶  RAM
  • Unidade de controle: busca a próxima instrução, decodifica o que ela significa e gera os sinais que coordenam o resto do núcleo.
  • ULA (Unidade Lógica e Aritmética, ou ALU em inglês): o circuito que efetivamente soma, subtrai, multiplica, compara e faz as operações bit a bit. CPUs modernas têm várias ULAs e também unidades dedicadas a ponto flutuante e a instruções vetoriais (SIMD).
  • Registradores: pequenas memórias dentro do núcleo, as mais rápidas do computador. Na arquitetura x86-64 há 16 registradores de uso geral de 64 bits (rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp e r8 a r15). Na ARM64 há 31 (x0 a x30), além do ponteiro de pilha.
  • Contador de programa (PC): um registrador especial que guarda o endereço da próxima instrução. No x86-64 ele se chama rip (instruction pointer); no ARM64, pc.
  • Registrador de flags: bits que registram o resultado da última operação, se deu zero (ZF), se houve vai-um (CF), se o resultado é negativo (SF), se houve overflow com sinal (OF). As instruções de desvio condicional (os if do assembly) consultam essas flags.
  • Clock: um sinal que oscila bilhões de vezes por segundo e sincroniza tudo. Uma CPU de 3 GHz tem 3 bilhões de ciclos por segundo, ou seja, um ciclo dura cerca de 0,33 nanossegundo.

Processadores atuais têm vários núcleos (cores), cada um com seus próprios registradores e caches L1 (e geralmente L2), compartilhando a cache L3 e a RAM. Muitos núcleos também executam duas threads de hardware ao mesmo tempo (o Hyper-Threading da Intel, genericamente chamado de SMT).

ISA: o contrato entre software e hardware

A CPU não entende C. Ela entende instruções de máquina: sequências de bytes que codificam operações simples como "some estes dois registradores" ou "carregue 8 bytes deste endereço". O conjunto de instruções que uma família de processadores entende, junto com seus registradores e regras, se chama ISA (Instruction Set Architecture). É o contrato entre software e hardware.

Característicax86-64 (AMD64)ARM64 (AArch64)
Onde aparecePCs e servidores Intel e AMDCelulares, Macs com Apple Silicon, Raspberry Pi, muitos servidores na nuvem
FilosofiaCISC: muitas instruções, algumas complexasEstilo RISC: instruções simples e regulares
Tamanho das instruçõesVariável, de 1 a 15 bytesFixo, 4 bytes
Acesso à memóriaInstruções aritméticas podem ler/escrever na memóriaSó instruções load/store acessam a memória
Registradores de uso geral16 de 64 bits31 de 64 bits

A mesma linha de C vira instruções diferentes em cada ISA. A função int soma(int a, int b) { return a + b; }, compilada com otimização, vira algo como lea eax, [rdi+rsi] no x86-64 e add w0, w0, w1 no ARM64. É isso que torna C portável: você escreve uma vez, e o compilador traduz para a ISA de destino. Existe ainda a RISC-V, uma ISA aberta e livre de royalties que vem ganhando espaço em microcontroladores e pesquisa.

O ciclo buscar–decodificar–executar

Todo processador, do mais simples ao mais moderno, executa programas repetindo o mesmo ciclo básico, bilhões de vezes por segundo:

  1. Buscar (fetch): ler, na memória, os bytes da instrução que está no endereço apontado pelo PC.
  2. Decodificar (decode): interpretar esses bytes, qual operação, quais registradores, qual endereço.
  3. Executar (execute): a ULA realiza a operação (ou calcula um endereço).
  4. Acessar a memória: se a instrução for de leitura ou escrita na memória.
  5. Gravar o resultado (write-back): o resultado vai para um registrador.

Depois disso o PC avança para a próxima instrução, ou salta para outro endereço, se a instrução for um desvio (é assim que if, for e chamadas de função funcionam no nível da máquina). Veja a função soma compilada para x86-64, executando com a = 2 e b = 3:

Texto
endereço  bytes       instrução            o que acontece
────────  ──────────  ───────────────────  ────────────────────────────────────
0x1130    8d 04 37    lea eax, [rdi+rsi]   busca 3 bytes; decodifica; ULA soma
                                           rdi (2) + rsi (3); grava 5 em eax
0x1133    c3          ret                  busca 1 byte; lê o endereço de retorno
                                           da pilha e o coloca no PC

Os endereços são ilustrativos, mas os bytes 8d 04 37 e c3 são exatamente o que o GCC gera para essa função. Programa, no fim das contas, é isso: bytes na memória que a CPU interpreta como instruções.

Pipeline, execução fora de ordem e previsão de desvios

Executar uma instrução por vez, do começo ao fim, desperdiçaria o hardware: enquanto a ULA calcula, a unidade de busca ficaria parada. Por isso CPUs usam pipeline, como uma linha de montagem: enquanto uma instrução executa, a seguinte está sendo decodificada e a próxima está sendo buscada.

Texto
ciclo:        1     2     3     4     5     6     7
instrução 1:  BUSCA DECOD EXEC  MEM   GRAVA
instrução 2:        BUSCA DECOD EXEC  MEM   GRAVA
instrução 3:              BUSCA DECOD EXEC  MEM   GRAVA

Esse é o pipeline clássico de 5 estágios usado para ensino. CPUs modernas vão muito além:

  • Superescalares: executam várias instruções por ciclo, em paralelo.
  • Execução fora de ordem: reordenam instruções independentes para não ficarem esperando dados lentos.
  • Previsão de desvios: quando chegam a um if, "chutam" qual caminho será tomado e começam a executá-lo antes de saber a resposta (execução especulativa). Se o chute estiver errado, o trabalho especulativo é descartado e a CPU perde vários ciclos.

Para quem programa em C, a lição prática é: desvios previsíveis e acessos sequenciais à memória são rápidos; desvios aleatórios e saltos pela memória são lentos. (Curiosidade: as vulnerabilidades Spectre e Meltdown, reveladas em 2018, exploravam justamente efeitos colaterais da execução especulativa.)

A hierarquia de memória: registradores, caches, RAM e SSD

Aqui está um dos fatos mais importantes da computação moderna: a CPU é muito mais rápida que a memória principal. Buscar um dado na RAM custa centenas de ciclos, tempo em que a CPU poderia ter executado centenas de instruções. A solução é uma hierarquia de memórias: pequenas e rápidas perto da CPU, grandes e lentas longe dela.

NívelTamanho típicoLatência aproximadaFeito de
Registradorespoucas centenas de bytes~0,3 ns (1 ciclo)flip-flops
Cache L132–64 KiB por núcleo (dados)~1 ns (~4–5 ciclos)SRAM
Cache L2256 KiB a alguns MiB por núcleo~3–5 ns (~12–15 ciclos)SRAM
Cache L3de alguns MiB a dezenas de MiB, compartilhada~10–20 ns (~30–70 ciclos)SRAM
RAM (DRAM)8–128 GiB~60–100 nsDRAM
SSD NVMecentenas de GB a alguns TB~10–100 µsmemória flash
HD mecânicoalguns TB~5–10 msdiscos magnéticos

Para sentir a diferença, imagine que um ciclo de uma CPU de 3 GHz durasse 1 segundo. Nessa escala, ler da cache L1 levaria uns 4 segundos; da L3, perto de 40 segundos; da RAM, uns 5 minutos; de um SSD rápido, alguns dias; e de um HD mecânico, quase um ano.

As caches funcionam de forma automática e invisível para o programa: quando a CPU pede um byte, o hardware traz da RAM uma linha de cache inteira, tipicamente 64 bytes no x86-64 (os chips Apple M usam linhas de 128 bytes). A aposta é que, se você usou um byte, logo vai usar os vizinhos. Quando o dado já está na cache, temos um cache hit (rápido); quando não está, um cache miss (lento).

Barramentos: como os dados viajam

No modelo clássico de livro-texto, a CPU se comunica com a memória por três barramentos (conjuntos de fios):

  • Barramento de endereços: a CPU informa qual posição de memória quer acessar.
  • Barramento de dados: os bytes viajam por ele, nos dois sentidos.
  • Barramento de controle: sinais que dizem se a operação é leitura ou escrita, além de sincronização.

Nos computadores atuais a realidade é mais sofisticada: o controlador de memória fica dentro da própria CPU e fala diretamente com os módulos DDR4/DDR5 por canais dedicados; os núcleos se comunicam entre si por uma rede interna; e dispositivos como SSDs NVMe, placas de vídeo e placas de rede ficam no barramento PCIe. Muitos dispositivos usam DMA (Direct Memory Access) para copiar dados direto para a RAM sem ocupar a CPU. Os drivers que controlam tudo isso, aliás, são escritos quase sempre em C.

Quando dizemos que uma CPU é "de 64 bits", queremos dizer que seus registradores de uso geral têm 64 bits e que ela trabalha com endereços de 64 bits. (Na prática, os processadores x86-64 atuais usam 48 bits do endereço virtual, ou 57 bits com a paginação de 5 níveis, ainda assim, muito mais memória do que qualquer computador tem.)

Localidade: por que a ordem dos seus loops importa

As caches funcionam porque programas costumam ter localidade:

  • Localidade temporal: um dado usado agora provavelmente será usado de novo em breve (a variável de um loop, por exemplo).
  • Localidade espacial: se você usou o endereço X, provavelmente vai usar X+1, X+2... em seguida (percorrer um array, por exemplo).

Em C, uma matriz int m[N][N] é guardada linha por linha na memória (row-major): toda a linha 0, depois toda a linha 1, e assim por diante. Percorrer a matriz linha a linha lê bytes vizinhos; percorrer coluna a coluna salta N * 4 bytes a cada acesso, desperdiçando quase toda linha de cache trazida da RAM. Veja na prática:

localidade.c
C
#include <stdio.h>
#include <time.h>
 
#define N 4096
 
static int matriz[N][N]; /* 4096 x 4096 x 4 bytes = 64 MiB */
 
int main(void) {
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++)
            matriz[i][j] = i + j;
 
    long long soma = 0;
    clock_t inicio = clock();
    for (int i = 0; i < N; i++)        /* linha a linha: acesso sequencial */
        for (int j = 0; j < N; j++)
            soma += matriz[i][j];
    double t1 = (double)(clock() - inicio) / CLOCKS_PER_SEC;
 
    inicio = clock();
    for (int j = 0; j < N; j++)        /* coluna a coluna: saltos de 16 KiB */
        for (int i = 0; i < N; i++)
            soma += matriz[i][j];
    double t2 = (double)(clock() - inicio) / CLOCKS_PER_SEC;
 
    printf("linha a linha:   %.3f s\n", t1);
    printf("coluna a coluna: %.3f s\n", t2);
    printf("soma = %lld\n", soma);
    return 0;
}

Numa máquina de teste (x86-64, GCC 16, compilado com -O2), o resultado foi:

Texto
linha a linha:   0.004 s
coluna a coluna: 0.053 s
soma = 137405399040

As duas metades fazem exatamente as mesmas somas, mas a segunda foi mais de 10 vezes mais lenta, só por causa da ordem de acesso à memória. Os números exatos vão variar no seu computador, mas a diferença costuma ser grande. Não se preocupe se ainda não entende todo o código: static, clock() e printf serão explicados adiante. O que importa agora é a ideia: em C, você controla o layout e a ordem de acesso aos dados, e o hardware recompensa (ou pune) essas escolhas.

3. Bits, bytes e representação de números

Tudo na memória de um computador é uma sequência de bits. Números, textos, imagens e o próprio programa são só interpretações diferentes desses bits. Em C, é o tipo de uma variável que diz ao compilador como interpretar os bits que ela ocupa. Por isso vale a pena entender como cada tipo de dado é codificado.

Bit, byte, nibble e palavra

  • Bit: um dígito binário, 0 ou 1.
  • Nibble: 4 bits. Exatamente um dígito hexadecimal.
  • Byte: 8 bits. É a menor unidade endereçável da memória: cada byte tem seu próprio endereço. (Em C, o número de bits de um byte é CHAR_BIT, que vale 8 em todas as plataformas comuns.)
  • Palavra (word): o tamanho "natural" com que a CPU trabalha. Em uma máquina de 64 bits, 8 bytes. Cuidado: na documentação da Intel, por motivos históricos, word significa 16 bits.

Com n bits, dá para representar 2^n combinações diferentes:

BitsCombinaçõesExemplo de uso
12verdadeiro/falso
416um dígito hexadecimal
8256um byte, um caractere ASCII
1665.536short
324.294.967.296int, endereços IPv4
64~1,8 × 10^19long long, ponteiros em 64 bits

Uma nota sobre unidades, que confunde muita gente: KB (quilobyte) no Sistema Internacional vale 1000 bytes, enquanto KiB (kibibyte) vale 1024 bytes. O mesmo vale para MB/MiB (10^6 contra 2^20), GB/GiB e TB/TiB. Memória RAM é sempre medida em potências de 2; fabricantes de discos usam potências de 10. Por isso um SSD de "1 TB" (10^12 bytes) aparece no Windows com cerca de 931 "GB", o Windows mostra a sigla GB, mas calcula em GiB.

Sistemas de numeração: decimal, binário, hexadecimal e octal

Todo sistema posicional funciona igual: cada posição vale a base elevada a um expoente. No decimal (base 10), 345 = 3×10² + 4×10¹ + 5×10⁰. No binário (base 2), cada posição vale uma potência de 2:

0128
064
132
016
18
04
12
01
101010 em binário = 32 + 8 + 2 = 42dec 42hex 0x2A8 bits

O componente acima mostra cada bit com o seu peso posicional. Os bits ligados valem 32, 8 e 2; somando, 42. É assim que a CPU guarda o número 42 em um byte.

Como escrever 32 ou 64 zeros e uns é impraticável, programadores usam hexadecimal (base 16), com os dígitos 0–9 e A–F. A grande vantagem: cada dígito hexadecimal corresponde a exatamente 4 bits, então a conversão é imediata.

HexBinárioDecimalHexBinárioDecimal
000000810008
100011910019
200102A101010
300113B101111
401004C110012
501015D110113
601106E111014
701117F111115

O octal (base 8) agrupa bits de 3 em 3. Hoje ele aparece quase só nas permissões de arquivos do Unix (chmod 755) e em uma pegadinha clássica de C que veremos já já.

Convertendo entre bases passo a passo

Binário para decimal: some os pesos dos bits ligados. 1101 0110 = 128 + 64 + 16 + 4 + 2 = 214.

Decimal para binário: divida sucessivamente por 2 e leia os restos de baixo para cima. Para 42:

Texto
42 ÷ 2 = 21  resto 0   ↑
21 ÷ 2 = 10  resto 1   │
10 ÷ 2 =  5  resto 0   │  lendo de baixo para cima:
 5 ÷ 2 =  2  resto 1   │  101010
 2 ÷ 2 =  1  resto 0   │
 1 ÷ 2 =  0  resto 1   │

Binário para hexadecimal: agrupe de 4 em 4 bits, a partir da direita, e troque cada grupo pelo dígito da tabela. 1101 0110 → D 6 → 0xD6. Se faltar bit no último grupo, complete com zeros à esquerda: 10 1010 → 0010 1010 → 0x2A.

Hexadecimal para decimal: multiplique cada dígito pela potência de 16 da sua posição. 0x2A = 2×16 + 10 = 42. 0xFF = 15×16 + 15 = 255. 0x7FFFFFFF é o maior int de 32 bits: 2.147.483.647.

1128
164
032
116
08
14
12
01
0xD6 = 1101 0110 = 214dec 214hex 0xD68 bits

Em C, os prefixos dizem a base de um literal. Os quatro valores abaixo são o mesmo número:

C
int a = 42;        /* decimal */
int b = 0x2A;      /* hexadecimal: prefixo 0x */
int c = 052;       /* octal: prefixo 0 (zero)! */
int d = 0b101010;  /* binário: padrão no C23; extensão do GCC antes disso */

O programa a seguir imprime um número em várias bases. Como o especificador %b do printf só existe a partir do C23 (e depende da biblioteca C), escrevemos nossa própria função para imprimir em binário:

bases.c
C
#include <stdio.h>
 
/* imprime os 'largura' bits menos significativos de 'valor' */
static void imprimir_binario(unsigned valor, int largura) {
    for (int i = largura - 1; i >= 0; i--) {
        putchar(((valor >> i) & 1u) ? '1' : '0');
        if (i % 4 == 0 && i != 0)
            putchar(' ');
    }
    putchar('\n');
}
 
int main(void) {
    int n = 42;
    printf("decimal: %d\n", n);
    printf("hexa:    %x (ou %#X)\n", n, n);
    printf("octal:   %o\n", n);
    printf("binário: ");
    imprimir_binario((unsigned)n, 8);
    printf("%d %d %d\n", 0x2A, 052, 42); /* três jeitos de escrever 42 */
    return 0;
}
Texto
decimal: 42
hexa:    2a (ou 0X2A)
octal:   52
binário: 0010 1010
42 42 42

Inteiros sem sinal (unsigned)

Um inteiro sem sinal usa todos os bits para a magnitude. Com n bits, ele vai de 0 a 2^n − 1. Um unsigned char (8 bits) vai de 0 a 255; um unsigned int de 32 bits vai de 0 a 4.294.967.295.

1128
164
032
016
18
04
02
01
200 cabe em um unsigned chardec 200hex 0xC88 bits
1128
164
132
116
18
14
12
11
255: o maior valor de 8 bits (todos os bits ligados)dec 255hex 0xFF8 bits
032768
016384
08192
04096
02048
11024
0512
0256
0128
064
032
016
08
04
02
11
1025 em 16 bits = 1024 + 1dec 1025hex 0x040116 bits

Complemento de dois: como o computador guarda negativos

E os números negativos? A primeira ideia seria reservar um bit para o sinal (sinal e magnitude), mas isso cria dois zeros (+0 e −0) e exige circuitos diferentes para somar e subtrair. Todas as CPUs modernas usam outra representação: o complemento de dois.

A regra é elegante: o bit mais significativo tem peso negativo. Em 8 bits, os pesos são −128, 64, 32, 16, 8, 4, 2 e 1. Assim, um inteiro de 8 bits com sinal vai de −128 a +127.

Para obter o negativo de um número, inverta todos os bits e some 1. Vamos calcular −5 passo a passo:

Texto
 5 em binário .......... 0000 0101
 inverte os bits ....... 1111 1010
 soma 1 ................ 1111 1011   ← este é o -5
0128
064
032
016
08
14
02
11
+5dec 5hex 0x058 bits
1128
164
132
116
18
04
12
11
-5 em complemento de doisdec -5hex 0xFB8 bits · complemento de dois

Confira pelos pesos: −128 + 64 + 32 + 16 + 8 + 2 + 1 = −5. Funciona!

Por que funciona? Um registrador de 8 bits faz contas módulo 256: quando uma soma passa de 255, o vai-um do nono bit simplesmente se perde. Nesse mundo circular, somar 251 é o mesmo que subtrair 5, porque 251 + 5 = 256 ≡ 0. Então faz sentido representar −5 pelo padrão de bits de 251 (que é 1111 1011). E inverter os bits de x resulta em 255 − x; somando 1, temos 256 − x, que é justamente −x módulo 256. Veja a prova com a soma 5 + (−5):

Texto
    0000 0101    (+5)
  + 1111 1011    (-5)
  ───────────
  1 0000 0000    → o 9º bit é descartado: resultado 0

A consequência mais bonita: o mesmo circuito somador serve para números com e sem sinal. A CPU não sabe se os bits representam 251 ou −5; quem decide a interpretação é o tipo que você declarou no C.

Alguns padrões que vale memorizar:

1128
164
132
116
18
14
12
11
-1: todos os bits ligadosdec -1hex 0xFF8 bits · complemento de dois
0128
164
132
116
18
14
12
11
127: o maior signed chardec 127hex 0x7F8 bits
1128
064
032
016
08
04
02
01
-128: o menor signed char (só o bit de sinal)dec -128hex 0x808 bits · complemento de dois

Repare na assimetria: existe −128, mas não existe +128 em 8 bits. Por isso negar o menor valor possível (-INT_MIN, por exemplo) estoura o tipo.

Quando um valor com sinal é convertido para um tipo maior, o bit de sinal é replicado à esquerda (extensão de sinal), preservando o valor:

132768
116384
18192
14096
12048
11024
1512
1256
1128
164
132
116
18
04
12
11
-5 em 16 bits: a extensão de sinal preenche com 1sdec -5hex 0xFFFB16 bits · complemento de dois

Até o C17, o padrão tecnicamente permitia outras representações (sinal e magnitude, complemento de um), embora na prática todo hardware relevante usasse complemento de dois. O C23 passou a exigir complemento de dois. O programa abaixo mostra os mesmos bits interpretados de duas formas:

complemento.c
C
#include <stdio.h>
 
int main(void) {
    signed char s = -5;
    unsigned char u = (unsigned char)s; /* mesmos 8 bits, outra leitura */
    printf("s = %d, u = %d\n", s, u);
 
    unsigned char c = 255;
    c = c + 1; /* sem sinal: "dá a volta" de forma bem definida */
    printf("255 + 1 em unsigned char = %d\n", c);
    return 0;
}
Texto
s = -5, u = 251
255 + 1 em unsigned char = 0

Overflow: quando o número não cabe

Overflow (estouro) acontece quando o resultado de uma operação não cabe no tipo. Em C, as regras são diferentes para os dois mundos:

  • Sem sinal (unsigned): o comportamento é bem definido. O resultado "dá a volta" módulo 2^n: UINT_MAX + 1u vale 0, e 0u - 1u vale UINT_MAX. Isso é útil em hashes, checksums e contadores circulares.
  • Com sinal (int, long...): o overflow é comportamento indefinido (undefined behavior, UB). O padrão não diz o que acontece. Na prática o hardware x86-64 daria a volta, mas o compilador assume que overflow com sinal nunca acontece e otimiza o código com base nisso, o que pode produzir resultados absurdos. A seção 24 mostra um exemplo real.

A forma segura é verificar antes de fazer a conta:

overflow.c
C
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
 
/* devolve true se a + b cabe em um int */
static bool soma_cabe(int a, int b) {
    if (b > 0 && a > INT_MAX - b) return false; /* estouraria para cima */
    if (b < 0 && a < INT_MIN - b) return false; /* estouraria para baixo */
    return true;
}
 
int main(void) {
    unsigned int u = UINT_MAX;
    u = u + 1u; /* bem definido: vira 0 */
    printf("UINT_MAX + 1 = %u\n", u);
 
    if (soma_cabe(INT_MAX, 1))
        printf("cabe\n");
    else
        printf("INT_MAX + 1 estouraria o int!\n");
    return 0;
}
Texto
UINT_MAX + 1 = 0
INT_MAX + 1 estouraria o int!

O GCC e o Clang também oferecem __builtin_add_overflow(a, b, &resultado), e o C23 padronizou essa ideia no cabeçalho <stdckdint.h>, com ckd_add, ckd_sub e ckd_mul.

Ponto flutuante: o padrão IEEE 754

Números com parte fracionária (os tipos float e double) seguem o padrão IEEE 754, uma espécie de notação científica em base 2. O valor é dividido em três campos:

TipoTotalSinalExpoenteMantissa (fração)Precisão aproximada
float (binary32)32 bits18 (viés 127)23~7 dígitos decimais
double (binary64)64 bits111 (viés 1023)52~15–16 dígitos decimais

Para números normais, o valor é (−1)^sinal × 1,mantissa × 2^(expoente − viés). O "1," na frente é implícito (não é armazenado), o que dá um bit de precisão de graça.

Vamos codificar 5,75 como float, passo a passo:

  1. Parte inteira: 5 = 101. Parte fracionária: 0,75 = 0,5 + 0,25 = 0,11 em binário. Logo 5,75 = 101,11₂.
  2. Normalize (mova a vírgula até sobrar um único 1 à esquerda): 101,11₂ = 1,0111₂ × 2².
  3. Sinal: positivo → 0.
  4. Expoente: 2 + 127 (viés) = 129 = 1000 0001.
  5. Mantissa: os bits depois da vírgula, 0111, completados com zeros até 23 bits.
Texto
sinal  expoente   mantissa
  0    10000001   01110000000000000000000   = 0x40B80000
0b31
1b30
0b29
0b28
0b27
0b26
0b25
0b24
1b23
0b22
1b21
1b20
1b19
0b18
0b17
0b16
0b15
0b14
0b13
0b12
0b11
0b10
0b9
0b8
0b7
0b6
0b5
0b4
0b3
0b2
0b1
0b0
5.75f na memória: 0x40B80000dec 1085800448hex 0x40B8000032 bits

Você pode conferir no seu computador, copiando os bytes do float para um inteiro (memcpy e os macros de <inttypes.h> são explicados mais adiante):

float_bits.c
C
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
 
int main(void) {
    float f = 5.75f;
    uint32_t bits;
    memcpy(&bits, &f, sizeof bits); /* copia os 4 bytes do float */
 
    uint32_t sinal = bits >> 31;
    uint32_t expoente = (bits >> 23) & 0xFFu;
    uint32_t mantissa = bits & 0x7FFFFFu;
 
    printf("bits     = 0x%08" PRIX32 "\n", bits);
    printf("sinal    = %" PRIu32 "\n", sinal);
    printf("expoente = %" PRIu32 " (real: %d)\n", expoente, (int)expoente - 127);
    printf("mantissa = 0x%06" PRIX32 "\n", mantissa);
    return 0;
}
Texto
bits     = 0x40B80000
sinal    = 0
expoente = 129 (real: 2)
mantissa = 0x380000

Por que 0.1 + 0.2 != 0.3? Porque 0,1 não tem representação exata em binário. Assim como 1/3 vira 0,3333... em decimal, 0,1 vira 0,000110011001100110011...₂, uma dízima infinita. O computador guarda a aproximação mais próxima que cabe na mantissa, e os pequenos erros de arredondamento se somam:

ponto_flutuante.c
C
#include <math.h>
#include <stdio.h>
 
int main(void) {
    double a = 0.1 + 0.2;
    printf("%.17f\n", a);
    printf("a == 0.3? %s\n", a == 0.3 ? "sim" : "não");
    printf("quase igual? %s\n", fabs(a - 0.3) < 1e-9 ? "sim" : "não");
    return 0;
}
Texto
0.30000000000000004
a == 0.3? não
quase igual? sim

Regras práticas que decorrem disso:

  • Nunca compare números de ponto flutuante com ==. Compare a diferença com uma tolerância adequada ao problema.
  • Nunca use float/double para dinheiro. Guarde centavos em um inteiro (long long centavos).
  • Prefira double a float, a menos que memória ou vazão sejam críticas (gráficos, aprendizado de máquina, microcontroladores sem unidade de double).
  • O IEEE 754 tem valores especiais: +inf e -inf (por exemplo, 1.0 / 0.0), NaN (not a number, por exemplo 0.0 / 0.0 ou sqrt(-1.0)) e até -0.0. Um NaN é diferente de tudo, inclusive de si mesmo.

No Linux, programas que usam funções de <math.h> (como fabs e sqrt) precisam da flag -lm na hora de ligar. Veremos isso na seção de flags do GCC.

Caracteres: ASCII e UTF-8

Para o computador, um caractere é só um número. A tabela ASCII (1963) define 128 códigos de 7 bits:

FaixaConteúdo
0–31 e 127caracteres de controle (\n = 10, \t = 9, \0 = 0)
32espaço
48–57dígitos '0' a '9'
65–90letras maiúsculas 'A' a 'Z'
97–122letras minúsculas 'a' a 'z'

Duas consequências úteis em C: '7' - '0' vale 7 (converte um dígito em número), e letras maiúsculas e minúsculas diferem em um único bit (o de valor 32):

0128
164
032
016
08
04
02
11
'A' = 65 = 0x41dec 65hex 0x418 bits
0128
164
132
016
08
04
02
11
'a' = 97 = 0x61 (só o bit de peso 32 muda)dec 97hex 0x618 bits

Mesmo assim, use toupper e tolower de <ctype.h> em vez de truques com bits: o código fica mais claro.

ASCII não tem acentos. O padrão universal hoje é o Unicode, que atribui um número (code point) a cada caractere de todos os idiomas, de U+0000 a U+10FFFF. A codificação dominante é a UTF-8, que usa de 1 a 4 bytes por caractere e é compatível com ASCII:

Code pointsBytesPadrão de bitsExemplo
U+0000 a U+007F10xxxxxxxA (U+0041) → 41
U+0080 a U+07FF2110xxxxx 10xxxxxxé (U+00E9) → C3 A9
U+0800 a U+FFFF31110xxxx 10xxxxxx 10xxxxxx€ (U+20AC) → E2 82 AC
U+10000 a U+10FFFF411110xxx + 3 × 10xxxxxxemoji U+1F600 → F0 9F 98 80

Em C, char é um byte, não um "caractere" no sentido humano. Com o código-fonte salvo em UTF-8, strlen("Olá") devolve 4, não 3:

utf8.c
C
#include <stdio.h>
#include <string.h>
 
int main(void) {
    const char *palavra = "Olá"; /* arquivo salvo em UTF-8 */
    size_t n = strlen(palavra);
    printf("strlen = %zu\n", n);
    for (size_t i = 0; i < n; i++)
        printf("%02X ", (unsigned char)palavra[i]);
    putchar('\n');
    return 0;
}
Texto
strlen = 4
4F 6C C3 A1

Endianness: a ordem dos bytes na memória

Um int de 4 bytes ocupa 4 endereços consecutivos. Mas em que ordem os bytes são guardados? Existem duas convenções:

  • Little-endian: o byte menos significativo fica no menor endereço. É o caso do x86-64 e, na prática, do ARM64 em Linux, macOS, Windows, Android e iOS.
  • Big-endian: o byte mais significativo fica no menor endereço. É a "ordem de rede" (network byte order) usada em protocolos como TCP/IP, e aparece em algumas arquiteturas mais antigas ou embarcadas.

Veja o valor 0x12345678 guardado a partir do endereço 0x1000:

Texto
endereço:        0x1000   0x1001   0x1002   0x1003
                ┌────────┬────────┬────────┬────────┐
little-endian:  │   78   │   56   │   34   │   12   │   (x86-64, ARM64)
                ├────────┼────────┼────────┼────────┤
big-endian:     │   12   │   34   │   56   │   78   │   (ordem de rede)
                └────────┴────────┴────────┴────────┘

Dá para descobrir a ordem da sua máquina olhando o int byte a byte (a sintaxe &x e o * serão explicados na seção de ponteiros):

endianness.c
C
#include <stdint.h>
#include <stdio.h>
 
int main(void) {
    uint32_t x = 0x12345678;
    unsigned char *bytes = (unsigned char *)&x; /* vê o int byte a byte */
 
    for (size_t i = 0; i < sizeof x; i++)
        printf("byte %zu: %02X\n", i, bytes[i]);
 
    puts(bytes[0] == 0x78 ? "little-endian" : "big-endian");
    return 0;
}
Texto
byte 0: 78
byte 1: 56
byte 2: 34
byte 3: 12
little-endian

A ordem dos bytes só importa quando os bytes saem da sua máquina ou são lidos "crus": ao gravar arquivos binários, ao implementar protocolos de rede (em sistemas POSIX, as funções htonl e ntohl convertem para a ordem de rede e de volta) ou ao inspecionar memória no depurador.

4. Memória e endereços

Esta é a seção mais importante para entender ponteiros. Se ela ficar clara, o resto de C fica muito mais fácil.

A memória como um gigantesco array de bytes

Do ponto de vista de um programa, a memória é um array enorme de bytes, em que cada byte tem um número: o seu endereço. Em um processo de 64 bits, os endereços são números de 64 bits, normalmente escritos em hexadecimal (0x7ffc1000, por exemplo).

Uma variável é uma região de bytes consecutivos, e o endereço da variável é o endereço do seu primeiro byte. Veja duas variáveis na memória:

Texto
endereço      conteúdo (1 byte por linha)
0x7ffc1000    2A  ┐
0x7ffc1001    00  │  int x = 42;   ocupa 4 bytes (little-endian)
0x7ffc1002    00  │  o endereço de x (&x) é 0x7ffc1000
0x7ffc1003    00  ┘
0x7ffc1004    41  ─  char c = 'A'; ocupa 1 byte em 0x7ffc1004

Repare que o int começa em um endereço múltiplo de 4. Isso se chama alinhamento: a CPU acessa dados alinhados de forma mais eficiente (e algumas arquiteturas nem aceitam acessos desalinhados), então o compilador posiciona cada tipo em endereços múltiplos do seu alinhamento, normalmente o próprio tamanho: 4 para int, 8 para double e ponteiros. Voltaremos a isso ao falar de struct.

Memória virtual, páginas e a MMU

Os endereços que seu programa enxerga não são endereços físicos da RAM. Cada processo recebe o seu próprio espaço de endereçamento virtual, como se tivesse a memória inteira só para si. Quem faz a tradução é a MMU (Memory Management Unit), um circuito dentro da CPU que, a cada acesso, converte o endereço virtual em físico.

A tradução é feita em blocos chamados páginas, tipicamente de 4 KiB no x86-64 (o macOS em Apple Silicon usa páginas de 16 KiB). O sistema operacional mantém, para cada processo, tabelas de páginas que dizem em qual quadro da RAM física cada página virtual está e com quais permissões. Como consultar essas tabelas é caro, a CPU guarda as traduções recentes em uma cache especial, a TLB.

Texto
 Processo A (virtual)            RAM (física)            Processo B (virtual)
 0x401000 (código de A) ──MMU──▶ quadro 17
                                 quadro 42  ◀──MMU── 0x401000 (código de B)
 0x7f10a000 (libc)      ──MMU──▶ quadro 3   ◀──MMU── 0x7f55c000 (libc)

O mesmo endereço virtual (0x401000) leva a quadros físicos diferentes em cada processo, enquanto a biblioteca C pode ocupar um único quadro físico compartilhado. Esse mecanismo traz vários benefícios:

  • Isolamento: um processo não consegue ler nem escrever na memória de outro.
  • Proteção: cada página tem permissões. O código é somente leitura e execução; os dados são leitura e escrita, mas não execução.
  • Alocação preguiçosa: uma página só ganha memória física quando é tocada pela primeira vez.
  • Swap: páginas pouco usadas podem ir para o disco.
  • Compartilhamento: bibliotecas usadas por vários processos ocupam a RAM uma vez só.
  • ASLR (Address Space Layout Randomization): o sistema sorteia as posições das regiões a cada execução, dificultando ataques.

Quando um programa acessa um endereço que não está mapeado, ou viola uma permissão (escrever no código, por exemplo), a CPU gera uma exceção que o sistema operacional trata encerrando o processo. No Linux e no macOS você vê o famoso Segmentation fault (sinal SIGSEGV); no Windows, uma violação de acesso (código 0xC0000005).

O layout de memória de um processo

Dentro do espaço virtual, um processo é organizado em regiões com funções bem definidas. O desenho clássico, que corresponde ao Linux x86-64, é este:

Texto
 endereços altos
┌────────────────────────────────┐
│ kernel (inacessível)           │
├────────────────────────────────┤
│ pilha (stack)                  │  variáveis locais, endereços de retorno
│   ↓ cresce para baixo          │
│                                │
│         espaço livre           │
│                                │
│ mapeamentos (mmap)             │  bibliotecas compartilhadas (libc),
│                                │  blocos grandes de malloc
│   ↑ cresce para cima           │
│ heap                           │  malloc, calloc, realloc
├────────────────────────────────┤
│ .bss                           │  globais e static sem inicializador (zeradas)
├────────────────────────────────┤
│ .data                          │  globais e static inicializadas
├────────────────────────────────┤
│ .rodata                        │  literais de string e constantes
├────────────────────────────────┤
│ .text                          │  código de máquina (leitura + execução)
└────────────────────────────────┘
 endereços baixos
RegiãoO que guardaPermissõesTempo de vida
.textas instruções do programaleitura e execuçãotodo o programa
.rodataliterais de string, constantessomente leituratodo o programa
.dataglobais e static com valor inicialleitura e escritatodo o programa
.bssglobais e static sem valor inicial (zeradas)leitura e escritatodo o programa
heapmemória pedida com mallocleitura e escritaaté o free
pilhavariáveis locais, parâmetros, endereços de retornoleitura e escritaaté a função retornar

Um detalhe interessante: a .bss não ocupa espaço no arquivo executável. O arquivo só registra "preciso de N bytes zerados", e o sistema os fornece ao carregar o programa. Por isso um static int tabela[1000000]; não aumenta o executável em 4 MB.

Você pode ver essas regiões com um programa que imprime endereços (o especificador %p imprime ponteiros):

layout.c
C
#include <stdio.h>
#include <stdlib.h>
 
int global_iniciada = 42;   /* .data */
int global_zerada;          /* .bss  */
const char *literal = "oi"; /* o texto "oi" fica em .rodata */
 
int main(void) {
    int local = 7;                             /* pilha */
    int *dinamico = malloc(sizeof *dinamico);  /* heap  */
    if (dinamico == NULL)
        return 1;
 
    printf("rodata (literal)        : %p\n", (void *)literal);
    printf("data   (global_iniciada): %p\n", (void *)&global_iniciada);
    printf("bss    (global_zerada)  : %p\n", (void *)&global_zerada);
    printf("heap   (malloc)         : %p\n", (void *)dinamico);
    printf("pilha  (local)          : %p\n", (void *)&local);
 
    free(dinamico);
    return 0;
}

Um exemplo de saída no Linux x86-64 (os valores mudam a cada execução por causa do ASLR):

Texto
rodata (literal)        : 0x55d4a6a9c004
data   (global_iniciada): 0x55d4a6a9e010
bss    (global_zerada)  : 0x55d4a6a9e01c
heap   (malloc)         : 0x55d4a7e5c2a0
pilha  (local)          : 0x7ffc8a1e3b54

Os endereços crescem na ordem do desenho: .rodata, .data, .bss, heap e, lá no alto, a pilha. No Windows as mesmas regiões existem, mas o sistema as posiciona em outra ordem e o %p imprime o endereço sem o prefixo 0x.

A pilha (stack) e os stack frames

A pilha é a região usada para controlar chamadas de função. Cada chamada ganha um bloco na pilha, o stack frame (quadro de pilha), com:

  • o endereço de retorno (para onde voltar quando a função terminar);
  • registradores que a função precisa preservar (por exemplo, o rbp de quem a chamou);
  • as variáveis locais da função;
  • argumentos que não couberam nos registradores.

Um registrador especial, o ponteiro de pilha (rsp no x86-64, sp no ARM64), aponta para o topo da pilha. No x86-64 a pilha cresce para endereços menores: chamar uma função subtrai de rsp, retornar soma de volta.

Texto
 endereços altos
┌──────────────────────────────┐
│ frame de main                │
│   int resultado              │
├──────────────────────────────┤
│ endereço de retorno em main  │ ← empilhado pela instrução call
│ rbp salvo de main            │
│ frame de soma                │
│   int a = 2, int b = 3       │
├──────────────────────────────┤ ← rsp (topo da pilha)
│ (livre)                      │
 endereços baixos

Duas consequências fundamentais:

  1. Alocar na pilha é praticamente gratuito: basta mover o rsp. Por isso variáveis locais são tão rápidas.
  2. Quando a função retorna, o frame dela deixa de existir. Os bytes continuam lá até serem sobrescritos pela próxima chamada, mas não pertencem mais a ninguém. Guardar o endereço de uma variável local e usá-lo depois que a função retornou é um erro grave (veremos na seção de ponteiros).

A pilha é pequena: em geral 8 MiB para a thread principal no Linux (confira com ulimit -s) e 1 MiB por padrão no Windows (o valor padrão do linker da Microsoft). Arrays grandes não devem ser variáveis locais, use malloc ou static.

O que é um ponteiro, no nível do hardware

Com esse modelo em mente, a definição de ponteiro fica simples: um ponteiro é uma variável cujo valor é um endereço de memória. Em plataformas de 64 bits, todo ponteiro ocupa 8 bytes, não importa para que tipo ele aponte (em 32 bits, 4 bytes).

Texto
nome   endereço       conteúdo
x      0x7ffc1000     42               (int, 4 bytes)
p      0x7ffc1008     0x7ffc1000       (int *, 8 bytes) ──▶ aponta para x

No nível da máquina, não há diferença entre um ponteiro e um inteiro: ambos são 64 bits em um registrador. A diferença está no uso: o valor de um ponteiro é usado como endereço por instruções de leitura e escrita. A linha y = *p; vira algo como mov eax, DWORD PTR [rax], que significa "leia 4 bytes a partir do endereço que está em rax".

E o tipo do ponteiro (int *, double *, char *)? Ele só existe para o compilador: diz quantos bytes ler ou escrever no endereço, como interpretar esses bytes e de quanto em quanto avançar na aritmética de ponteiros. Guarde essa frase; ela explica praticamente todo o comportamento de ponteiros que veremos na seção 17.

5. Instalando o GCC no Windows, macOS e Linux

O GCC (GNU Compiler Collection) é o compilador C mais usado do mundo do software livre. Outra opção popular é o Clang, do projeto LLVM, que aceita quase todas as mesmas flags. Escolha o seu sistema nas abas abaixo, a escolha fica lembrada no restante da página.

No Windows há dois caminhos recomendados: o MSYS2, que instala um GCC nativo do Windows (gera arquivos .exe), e o WSL, que roda um Linux de verdade dentro do Windows.

Opção A: MSYS2 (GCC nativo)

  1. Instale o MSYS2

    Baixe o instalador em msys2.org e execute-o, mantendo a pasta padrão C:\msys64.

  2. Abra o terminal MSYS2 UCRT64

    No menu Iniciar, abra o atalho MSYS2 UCRT64. O MSYS2 tem vários ambientes; o UCRT64 é o recomendado para gerar programas modernos de 64 bits.

  3. Atualize os pacotes

    Bash
    pacman -Syu

    Se o terminal fechar sozinho durante a atualização, abra-o de novo e rode o comando mais uma vez.

  4. Instale o GCC e as ferramentas

    Para instalar só o compilador:

    Bash
    pacman -S mingw-w64-ucrt-x86_64-gcc

    Ou, para instalar o conjunto completo (GCC, GDB, make e utilitários):

    Bash
    pacman -S --needed base-devel mingw-w64-ucrt-x86_64-toolchain
  5. Adicione o compilador ao PATH

    Para usar o gcc no PowerShell ou no terminal do VS Code, adicione C:\msys64\ucrt64\bin à variável Path: menu Iniciar → "Editar as variáveis de ambiente do sistema" → Variáveis de Ambiente → selecione Path (do usuário) → Editar → Novo → cole o caminho → OK. Feche e reabra os terminais.

  6. Confira

    PowerShell
    gcc --version

Opção B: WSL (Linux dentro do Windows)

Abra o PowerShell como administrador e rode:

PowerShell
wsl --install

Reinicie o computador quando solicitado. Por padrão, o WSL instala o Ubuntu. Abra o Ubuntu pelo menu Iniciar, crie seu usuário e instale as ferramentas:

Bash
sudo apt update
sudo apt install build-essential gdb valgrind

O WSL é excelente para estudar C: as ferramentas Valgrind e AddressSanitizer, que veremos adiante, funcionam nele sem complicação (no GCC do MSYS2 elas não estão disponíveis).

Conferindo a instalação

Em qualquer sistema, gcc --version deve imprimir a versão do compilador. Um exemplo (o texto entre parênteses e o número da versão variam conforme o sistema e a distribuição):

Texto
gcc.exe (x86_64-posix-seh-rev0, Built by MinGW-Builds project) 16.1.0
Copyright (C) 2026 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Se aparecer "comando não encontrado" (ou "não é reconhecido como nome de cmdlet" no PowerShell), o diretório do compilador não está no PATH: revise o passo correspondente e abra um terminal novo.

Editor: VS Code com a extensão C/C++

Qualquer editor serve, mas o VS Code com a extensão C/C++ da Microsoft é uma combinação excelente e gratuita: realce de sintaxe, autocompletar (IntelliSense), navegação pelo código e integração com depuradores (GDB no Windows e Linux, LLDB no macOS). Instale a extensão pelo painel de extensões (Ctrl + Shift + X) procurando por "C/C++".

Uma dica: aprenda a compilar pelo terminal antes de depender de botões do editor. Entender o comando gcc que roda por trás é metade do aprendizado, e é isso que faremos a seguir.

6. Seu primeiro programa em C

Crie uma pasta para seus estudos, abra-a no editor e salve o arquivo abaixo como ola.c (com codificação UTF-8):

ola.c
C
#include <stdio.h>
 
int main(void) {
    printf("Olá, mundo!\n");
    return 0;
}

O programa linha por linha

  • #include <stdio.h>, uma diretiva do pré-processador (tudo que começa com #). Ela copia para o seu arquivo o conteúdo do cabeçalho stdio.h (standard input/output), que declara funções de entrada e saída como printf. Sem essa declaração, o compilador não saberia que printf existe nem que argumentos ela recebe.
  • int main(void), a função main é o ponto de entrada: é por ela que seu código começa a executar. O int antes do nome diz que ela devolve um número inteiro para o sistema operacional. O void entre parênteses diz que ela não recebe parâmetros (a versão com argumentos de linha de comando, int main(int argc, char *argv[]), aparece na seção 13).
  • { e }, delimitam um bloco: o corpo da função.
  • printf("Olá, mundo!\n");, chama a função printf passando um literal de string. O \n é uma sequência de escape que representa a quebra de linha. O ; termina a instrução, em C, praticamente toda instrução termina em ponto e vírgula.
  • return 0;, encerra main e devolve 0 ao sistema operacional, que por convenção significa "tudo certo". Qualquer outro valor indica erro. (Desde o C99, chegar ao fim de main sem return equivale a return 0;, mas escrever explicitamente é mais claro.)

C diferencia maiúsculas de minúsculas: Main, PRINTF ou Printf não funcionam. Espaços e quebras de linha, por outro lado, são livres; a indentação serve só para humanos.

Compilando e executando em cada sistema

No PowerShell (ou no terminal do VS Code), dentro da pasta do arquivo:

PowerShell
gcc -std=c17 -Wall -Wextra -o ola.exe ola.c
.\ola.exe

No PowerShell é preciso o prefixo .\ para executar um programa da pasta atual. No Prompt de Comando (cmd), basta digitar ola.

A saída esperada:

Texto
Olá, mundo!

Traduzindo o comando: gcc é o compilador; -std=c17 escolhe o padrão C17; -Wall -Wextra ligam os avisos (sempre use!); -o ola define o nome do executável; e ola.c é o arquivo-fonte. Sem -o, o GCC gera a.out no Linux e no macOS e a.exe no Windows.

Códigos de saída (exit codes)

O valor devolvido por main vira o código de saída do processo. Scripts, Makefiles e servidores de integração contínua usam esse número para saber se um programa funcionou. Para vê-lo logo depois de executar um programa:

TerminalComando
bash e zsh (Linux, macOS, WSL, MSYS2)echo $?
Prompt de Comando (cmd)echo %ERRORLEVEL%
PowerShell$LASTEXITCODE

Troque o return 0; por return 3;, recompile, execute e consulte o código: aparecerá 3. Em sistemas Unix, só os 8 bits menos significativos do valor chegam ao shell (de 0 a 255). No bash, dá para encadear comandos pelo resultado: ./ola && echo "deu certo" só imprime a mensagem se o código for 0.

7. Do código-fonte ao executável

O comando gcc ola.c -o ola parece um passo só, mas o gcc é um driver que coordena quatro programas diferentes, em sequência:

Texto
 ola.c   código-fonte C
   │   1. pré-processador (gcc -E): expande #include e #define, remove comentários
   ▼
 ola.i   C "puro", já expandido
   │   2. compilador (gcc -S): traduz C para assembly da ISA de destino
   ▼
 ola.s   assembly (texto)
   │   3. montador (gcc -c): traduz assembly para código de máquina
   ▼
 ola.o   arquivo objeto (código de máquina com "buracos" a preencher)
   │   4. linker (gcc): junta objetos e bibliotecas e resolve os nomes
   ▼
 ola     executável (ola.exe no Windows)

Dá para executar cada etapa separadamente e inspecionar os arquivos intermediários:

Bash
gcc -E ola.c -o ola.i   # 1. só pré-processa
gcc -S ola.i -o ola.s   # 2. compila para assembly
gcc -c ola.s -o ola.o   # 3. monta: gera o arquivo objeto
gcc ola.o -o ola        # 4. liga: gera o executável

Ou, de uma vez só, guardando todos os intermediários: gcc -save-temps ola.c -o ola. E gcc -v ola.c -o ola mostra os programas internos que o driver chama (cc1, as, collect2/ld) e os diretórios onde ele procura cabeçalhos e bibliotecas.

Etapa 1: pré-processamento

O pré-processador trabalha só com texto. Ele substitui cada #include pelo conteúdo do arquivo incluído, expande as macros de #define, avalia os #if e remove os comentários. Abra o ola.i: seu programa de 6 linhas virou centenas de linhas, porque o stdio.h (e os cabeçalhos que ele inclui) foram colados ali. Em algum ponto, com a glibc do Linux, você encontra a declaração:

C
extern int printf (const char *__restrict __format, ...);

É graças a ela que o compilador sabe como chamar printf. As linhas que começam com # 1 "ola.c" são marcadores que ajudam o compilador a reportar erros com o arquivo e a linha originais.

Etapa 2: compilação para assembly

O compilador propriamente dito (no GCC, o programa cc1) analisa o C, verifica tipos, otimiza e gera assembly: uma representação em texto das instruções de máquina. Para ver algo mais interessante que o "olá, mundo", compile esta função:

soma.c
C
int soma(int a, int b) {
    return a + b;
}

Com gcc -S -O0 soma.c (sem otimização), o GCC para Linux x86-64 gera o seguinte (diretivas do montador removidas):

Assembly
soma:
        pushq   %rbp              # salva o rbp de quem chamou na pilha
        movq    %rsp, %rbp        # rbp = topo atual da pilha (início do frame)
        movl    %edi, -4(%rbp)    # copia o 1º argumento (a, em edi) para a pilha
        movl    %esi, -8(%rbp)    # copia o 2º argumento (b, em esi) para a pilha
        movl    -4(%rbp), %edx    # carrega a em edx
        movl    -8(%rbp), %eax    # carrega b em eax
        addl    %edx, %eax        # eax = eax + edx  (a ULA soma)
        popq    %rbp              # restaura o rbp de quem chamou
        ret                       # volta; o resultado fica em eax

Como ler esse assembly (a sintaxe padrão do GCC é a AT&T):

  • Registradores começam com % e constantes com $.
  • A ordem é origem, destino: movl %edi, -4(%rbp) copia edi para a memória.
  • O sufixo diz o tamanho: l = 32 bits (long word), q = 64 bits (quad word).
  • -4(%rbp) significa "o endereço rbp − 4": uma posição na pilha.
  • Pela convenção de chamada do Linux e do macOS em x86-64 (a System V ABI), os primeiros argumentos inteiros chegam em rdi, rsi, rdx, rcx, r8 e r9, e o valor de retorno volta em rax (eax é a metade de 32 bits de rax). O Windows usa outra convenção: rcx, rdx, r8 e r9.

Sem otimização, o compilador copia tudo para a pilha e lê de volta, porque isso facilita a depuração. Agora com gcc -S -O2 soma.c:

Assembly
soma:
        leal    (%rdi,%rsi), %eax   # eax = rdi + rsi, em uma instrução
        ret

A função inteira virou uma instrução (mais o retorno). O lea é uma instrução de cálculo de endereço que o compilador usa como um somador rápido. Se você prefere a sintaxe da Intel (destino primeiro, sem %), use -masm=intel. E para experimentar sem instalar nada, o site Compiler Explorer mostra o assembly de qualquer compilador enquanto você digita.

Uma curiosidade: compile o ola.c com -S e procure a chamada a printf. Você vai encontrar call puts. O GCC conhece a biblioteca padrão e percebeu que um printf de uma string fixa terminada em \n equivale a um puts, que é mais simples, e fez a troca mesmo sem otimizações ligadas.

Etapa 3: montagem e arquivos objeto

O montador (assembler, o programa as) traduz o assembly para código de máquina, gerando um arquivo objeto (.o; .obj no compilador da Microsoft). Ele já contém os bytes das instruções, mas ainda tem "buracos": o ola.o chama puts, mas não sabe onde puts vai estar. O comando nm lista os símbolos de um objeto:

Bash
nm ola.o
Texto
0000000000000000 T main
                 U puts

T significa "definido na seção de código (text)" e U significa "indefinido: alguém precisa fornecer". Para ver as instruções desmontadas, use objdump -d ola.o.

Etapa 4: ligação (linking)

O linker (ld, chamado pelo GCC) junta todos os objetos do programa com as bibliotecas necessárias e com o código de inicialização da biblioteca C, resolve os símbolos (liga cada U puts à definição real de puts) e produz o executável.

Um detalhe que surpreende: main não é a primeira coisa a executar. O linker inclui um código de inicialização (no Linux com glibc, o símbolo _start) que prepara o ambiente, chama main e, quando ela retorna, passa o valor devolvido para a função exit, que encerra o processo com aquele código.

Ligação estática vs dinâmica

Existem duas formas de incluir o código de uma biblioteca no programa:

AspectoDinâmica (padrão)Estática (-static)
Onde fica o código da bibliotecaEm um arquivo separado, carregado ao executarCopiado para dentro do executável
Tamanho do executávelPequenoGrande
Correções de segurança na bibliotecaValem ao atualizar a biblioteca no sistemaExigem recompilar o programa
Dependências na máquina de destinoPrecisa ter as bibliotecas certasAutossuficiente
Extensões dos arquivos.so (Linux), .dylib (macOS), .dll (Windows).a (GCC/Clang), .lib (MSVC)

Para ver de quais bibliotecas dinâmicas um executável depende: ldd ./ola no Linux (e no MSYS2), otool -L ./ola no macOS. No Linux, ligar estaticamente com a glibc exige a versão estática da biblioteca instalada (no Fedora, o pacote glibc-static); no macOS, executáveis totalmente estáticos não são suportados.

Formatos de executável: ELF, PE e Mach-O

Um executável não é só código de máquina: ele tem um cabeçalho que descreve as seções (.text, .data...), onde o programa começa, de quais bibliotecas depende e como carregá-lo na memória. Cada sistema usa um formato:

SistemaFormatoExtensões comuns
Linux, BSDs, AndroidELF (Executable and Linkable Format)sem extensão, .o, .so
WindowsPE (Portable Executable), derivado do COFF.exe, .dll, .obj
macOS, iOSMach-Osem extensão, .o, .dylib

O comando file identifica o formato. No Linux, file ola responde algo como ola: ELF 64-bit LSB pie executable, x86-64, ...; no macOS com Apple Silicon, ola: Mach-O 64-bit executable arm64. É por isso que um executável compilado no Linux não roda no Windows, mesmo que os dois computadores tenham a mesma CPU: o formato do arquivo, a convenção de chamada e as bibliotecas do sistema são diferentes. O código C é portável; o executável, não.

8. Flags essenciais do GCC

O GCC tem centenas de opções, mas um conjunto pequeno resolve 95% do dia a dia. As mesmas flags funcionam no Clang.

FlagO que faz
-o arquivoDefine o nome do arquivo de saída
-WallLiga um conjunto de avisos importantes (apesar do nome, não liga todos)
-WextraLiga avisos adicionais: comparação entre com e sem sinal, parâmetros não usados...
-WpedanticAvisa sobre tudo que foge do padrão ISO escolhido (extensões do GCC)
-WerrorTransforma avisos em erros (bom para integração contínua)
-std=c17Escolhe o padrão: c99, c11, c17, c23; as variantes gnu17, gnu23 incluem extensões GNU
-gInclui informações de depuração para o GDB/LLDB
-O0Sem otimização (é o padrão); ideal para depurar
-OgOtimiza sem atrapalhar a depuração
-O1, -O2Otimizações; -O2 é a escolha mais comum para produção
-O3Otimizações agressivas (mais inlining, vetorização); meça antes de adotar
-Os, -OzOtimiza para tamanho (-Oz só existe em versões recentes)
-fsanitize=address,undefinedInstrumenta o programa para detectar erros de memória e comportamento indefinido
-I dirAdiciona um diretório à busca de cabeçalhos (#include)
-L dirAdiciona um diretório à busca de bibliotecas
-lnomeLiga com a biblioteca libnome (ex.: -lm, -lpthread); coloque depois dos arquivos-fonte
-D NOME=valorDefine uma macro, como um #define (ex.: -DDEBUG, -DNDEBUG)
-cCompila e monta sem ligar: gera .o
-ESó pré-processa (a saída vai para o terminal, ou para -o)
-SGera o assembly (.s)
-save-tempsGuarda os arquivos intermediários .i, .s e .o
-lmLiga a biblioteca matemática; necessária com a glibc ao usar <math.h>
-staticLigação estática
-vMostra os comandos internos e os caminhos de busca
-march=nativeUsa todas as instruções da sua CPU (o binário pode não rodar em outra máquina)
-fanalyzerAnálise estática que procura bugs sem executar (GCC 10 ou mais novo)

Dois avisos que valem ouro e não estão em -Wall -Wextra: -Wconversion (conversões que podem perder dados, como double para int) e -Wshadow (uma variável local escondendo outra de mesmo nome).

O comando de estimação para o dia a dia

Durante o desenvolvimento, no Linux, no macOS ou no WSL:

Bash
gcc -std=c17 -Wall -Wextra -Wpedantic -g -fsanitize=address,undefined programa.c -o programa

No Windows com MSYS2 (onde os sanitizers não estão disponíveis no GCC):

PowerShell
gcc -std=c17 -Wall -Wextra -Wpedantic -g programa.c -o programa.exe

Para a versão final, troque -g e os sanitizers por otimização:

Bash
gcc -std=c17 -O2 -Wall -Wextra programa.c -o programa

Trate avisos como erros de verdade. Quase todo aviso do GCC aponta para um bug real ou para um código que vai virar bug depois.

9. Tipos de dados

Em C, toda variável tem um tipo, fixo durante a vida dela. O tipo determina quantos bytes ela ocupa, como esses bytes são interpretados e quais operações são permitidas. Declarar é simples: tipo nome = valor_inicial;.

Inteiros e seus tamanhos

O padrão C não fixa o tamanho dos tipos inteiros; ele define apenas mínimos e uma ordem (char ≤ short ≤ int ≤ long ≤ long long). Cada plataforma escolhe os valores concretos, seguindo um modelo de dados:

TipoMínimo garantidoLinux e macOS 64 bits (LP64)Windows 64 bits (LLP64)
char8 bits1 byte1 byte
short16 bits2 bytes2 bytes
int16 bits4 bytes4 bytes
long32 bits8 bytes4 bytes
long long64 bits8 bytes8 bytes
ponteiros,8 bytes8 bytes
size_t,8 bytes8 bytes
float,4 bytes4 bytes
double,8 bytes8 bytes

Os nomes dos modelos contam a história: no LP64, Long e Pointer têm 64 bits; no LLP64, só Long Long e Pointer. A diferença no long é uma fonte clássica de bugs em código que precisa rodar nos dois sistemas: não use long quando precisar de exatamente 64 bits, use long long ou int64_t. (O long double é ainda mais variável, de 8 a 16 bytes conforme o sistema e o compilador; evite depender dele.)

Todos os inteiros existem em versão com sinal (o padrão) e sem sinal (unsigned int, unsigned long...). Descubra os tamanhos da sua máquina:

tamanhos.c
C
#include <limits.h>
#include <stdio.h>
 
int main(void) {
    printf("char:      %zu byte(s)\n", sizeof(char));
    printf("short:     %zu\n", sizeof(short));
    printf("int:       %zu\n", sizeof(int));
    printf("long:      %zu\n", sizeof(long));
    printf("long long: %zu\n", sizeof(long long));
    printf("double:    %zu\n", sizeof(double));
    printf("ponteiro:  %zu\n", sizeof(void *));
    printf("INT_MAX:   %d\n", INT_MAX);
    printf("LLONG_MAX: %lld\n", LLONG_MAX);
    printf("CHAR_MIN:  %d\n", CHAR_MIN);
    return 0;
}

Saída no Linux x86-64 (no Windows, a única diferença é long: 4):

Texto
char:      1 byte(s)
short:     2
int:       4
long:      8
long long: 8
double:    8
ponteiro:  8
INT_MAX:   2147483647
LLONG_MAX: 9223372036854775807
CHAR_MIN:  -128

char e o mistério do sinal

char, signed char e unsigned char são três tipos diferentes. Se o char "puro" tem sinal ou não é uma escolha da plataforma (implementation-defined): ele tem sinal no x86-64 (Linux, Windows e macOS) e também nos Macs com Apple Silicon, mas não tem sinal no Linux em ARM64, por exemplo. O CHAR_MIN: -128 acima mostra que, naquela máquina, char tem sinal.

Regras práticas:

  • Para texto, use char.
  • Para bytes crus (arquivos binários, buffers de rede), use unsigned char ou uint8_t.
  • Ao passar um char para as funções de <ctype.h> (isalpha, toupper...), converta para unsigned char: isalpha((unsigned char)c). Passar um char negativo (um byte de UTF-8, por exemplo) para essas funções é comportamento indefinido.

Tipos de largura fixa: stdint.h

Quando o tamanho importa (formatos de arquivo, protocolos, hardware, hashes), use os tipos de <stdint.h> (C99), que têm largura exata:

TipoSignificado
int8_t, int16_t, int32_t, int64_tinteiros com sinal de exatamente 8, 16, 32 e 64 bits
uint8_t, uint16_t, uint32_t, uint64_tas versões sem sinal
int_least32_t, int_fast32_t"pelo menos 32 bits" e "o mais rápido com pelo menos 32 bits"
intptr_t, uintptr_tinteiros capazes de guardar um ponteiro
intmax_t, uintmax_tos maiores inteiros da plataforma

Para imprimir esses tipos de forma portável, <inttypes.h> oferece macros com o especificador certo para cada plataforma:

larguras_fixas.c
C
#include <inttypes.h>
#include <stdio.h>
 
int main(void) {
    int64_t populacao = 8200000000; /* não cabe em 32 bits */
    uint8_t byte = 255;
    uint32_t mascara = 0xFF00FF00u;
 
    printf("população: %" PRId64 "\n", populacao);
    printf("byte:      %" PRIu8 "\n", byte);
    printf("máscara:   0x%08" PRIX32 "\n", mascara);
    return 0;
}
Texto
população: 8200000000
byte:      255
máscara:   0xFF00FF00

O truque é que strings literais vizinhas são concatenadas: "%" PRId64 "\n" vira algo como "%ld\n" no Linux e "%lld\n" no Windows.

size_t e o operador sizeof

sizeof é um operador (não uma função) que devolve o tamanho em bytes de um tipo ou de uma expressão. O resultado tem o tipo size_t: um inteiro sem sinal grande o bastante para representar o tamanho de qualquer objeto (8 bytes em sistemas de 64 bits). Use size_t para tamanhos, comprimentos e índices de arrays, e imprima com %zu.

  • Com tipos, os parênteses são obrigatórios: sizeof(int).
  • Com expressões, são opcionais: sizeof x ou sizeof(x).
  • O valor é calculado em tempo de compilação; a expressão nem é executada (sizeof(i++) não incrementa i).

Para diferenças entre ponteiros existe o tipo com sinal ptrdiff_t, impresso com %td.

Limites: limits.h e float.h

Em vez de decorar números, use as constantes da biblioteca padrão:

ConstanteCabeçalhoSignificado
CHAR_BIT<limits.h>bits em um byte (8)
INT_MIN, INT_MAX<limits.h>faixa do int
UINT_MAX<limits.h>maior unsigned int
LONG_MAX, LLONG_MAX<limits.h>maiores long e long long
SIZE_MAX<stdint.h>maior size_t
FLT_MAX, DBL_MAX<float.h>maiores float e double finitos
DBL_EPSILON<float.h>diferença entre 1.0 e o próximo double representável
DBL_DIG<float.h>dígitos decimais que um double preserva (15)

bool: de stdbool.h à palavra-chave do C23

O C original não tinha tipo booleano: 0 é falso e qualquer outro valor é verdadeiro. É por isso que if (n) e if (ponteiro) funcionam. O C99 criou o tipo _Bool e o cabeçalho <stdbool.h>, com os apelidos bool, true (1) e false (0). No C23, bool, true e false viraram palavras-chave e o #include deixou de ser necessário (mas continua funcionando).

Converter qualquer valor para bool resulta em true se ele for diferente de zero. Cuidado com a diferença: (bool)0.5 vale true, mas (int)0.5 vale 0.

const

const marca uma variável como somente leitura: o compilador recusa qualquer atribuição a ela depois da inicialização.

C
const double TAXA = 0.15;
/* TAXA = 0.2;  erro: assignment of read-only variable 'TAXA' */

Uma pegadinha de C (que não existe em C++): uma variável const não é uma constante de compilação. const int TAM = 10; não pode ser usada como rótulo de case nem como tamanho de array global. Para constantes inteiras "de verdade", use enum { TAM = 10 };, #define TAM 10 ou, no C23, constexpr int TAM = 10;. Veremos ainda o const em parâmetros e ponteiros, onde ele documenta que uma função não altera os dados recebidos.

Literais

Um literal é um valor escrito diretamente no código. O tipo dele depende da forma como é escrito:

LiteralTipoObservação
42intse não couber, vira long ou long long
42u, 42l, 42ll, 42ullunsigned, long, long long, unsigned long longsufixos combináveis
0x2A, 052, 0b101010inteiroshexadecimal, octal, binário (C23)
3.14doubletodo literal com ponto é double
3.14f, 3.14Lfloat, long double
1e-3, 6.02e23doublenotação científica
'A'int em Cvale 65; sizeof('A') é 4 em C (e 1 em C++)
"texto"array de chartermina com um '\0' invisível
1'000'000intseparador de dígitos do C23

As sequências de escape mais usadas dentro de literais de caractere e de string:

EscapeSignificadoEscapeSignificado
\nquebra de linha\\barra invertida
\ttabulação\'aspas simples
\rretorno de carro\"aspas duplas
\0caractere nulo (valor 0)\x41byte em hexadecimal ('A')

Literais de string vizinhos são concatenados pelo compilador: "Olá, " "mundo" é o mesmo que "Olá, mundo", útil para quebrar strings longas em várias linhas.

Conversões implícitas e promoção de inteiros

Quando uma expressão mistura tipos, o C converte automaticamente os operandos para um tipo comum. As regras, simplificadas:

  1. Promoção de inteiros: tipos menores que int (char, short, bool) viram int antes de qualquer conta.
  2. Conversões aritméticas usuais: se um operando é de ponto flutuante, o outro vira ponto flutuante. Senão, o de menor "posto" vira o de maior posto.
  3. Com sinal contra sem sinal: se os postos forem iguais (ou o sem sinal for maior), o valor com sinal vira sem sinal.

Essas regras geram algumas das armadilhas mais famosas de C. O programa abaixo demonstra quatro delas, e o GCC, com -Wextra, avisa sobre a comparação da terceira (um aviso proposital, que é justamente o ponto):

conversoes.c
C
#include <stdio.h>
#include <string.h>
 
int main(void) {
    /* 1. divisão inteira: os dois operandos são int */
    double media = 7 / 2;
    printf("7 / 2   = %.1f\n", media);  /* 3.0, não 3.5! */
    printf("7 / 2.0 = %.1f\n", 7 / 2.0); /* 3.5 */
 
    /* 2. promoção: unsigned char vira int antes da conta */
    unsigned char a = 200, b = 100;
    int soma = a + b;                         /* 300, sem estouro */
    unsigned char trunc = (unsigned char)(a + b); /* 300 % 256 = 44 */
    printf("soma = %d, truncado = %d\n", soma, trunc);
 
    /* 3. com sinal x sem sinal: -1 vira UINT_MAX */
    if (-1 > 1u)
        printf("-1 > 1u é verdadeiro!\n");
 
    /* 4. size_t não tem sinal: 0 - 1 dá a volta */
    printf("strlen(\"\") - 1 = %zu\n", strlen("") - 1);
    return 0;
}
Texto
7 / 2   = 3.0
7 / 2.0 = 3.5
soma = 300, truncado = 44
-1 > 1u é verdadeiro!
strlen("") - 1 = 18446744073709551615

A quarta armadilha causa bugs reais: um loop como for (size_t i = 0; i < strlen(s) - 1; i++) com uma string vazia vai tentar percorrer 18 quintilhões de posições. Ligue -Wall -Wextra e leia os avisos de comparação entre com e sem sinal com atenção.

Casts explícitos

Um cast força uma conversão: (tipo) expressão. O uso mais comum é evitar a divisão inteira:

C
int soma = 7, n = 2;
double media = (double)soma / n; /* 3.5: soma vira double antes da divisão */

Três cuidados: converter double para inteiro trunca em direção a zero ((int)-2.7 vale -2); converter um double grande demais para o tipo inteiro é comportamento indefinido; e um cast silencia avisos sem corrigir nada. Use casts para expressar uma intenção, nunca para calar o compilador.

10. Operadores

Aritméticos

OperadorExemploResultado
+, -, *7 * 321
/ (inteiros)7 / 2 e -7 / 23 e -3 (trunca em direção a zero)
/ (ponto flutuante)7.0 / 23.5
% (resto)7 % 3 e -7 % 31 e -1 (o sinal segue o dividendo)
%7 % -31

Desde o C99, a divisão inteira sempre trunca em direção a zero, e vale a identidade (a / b) * b + a % b == a. O operador % só funciona com inteiros (para double, use fmod de <math.h>). Dividir um inteiro por zero, ou calcular INT_MIN / -1, é comportamento indefinido, normalmente o programa é derrubado.

Relacionais e lógicos

Os operadores relacionais (==, !=, <, >, <=, >=) e lógicos (&&, ||, !) produzem um int que vale 1 (verdadeiro) ou 0 (falso).

Os operadores && e || fazem curto-circuito: o operando da direita só é avaliado se for necessário. Isso é usado o tempo todo como proteção:

C
if (p != NULL && p->idade >= 18) { /* só acessa p->idade se p não for NULL */ }
if (n == 0 || total / n > 10) { /* nunca divide por zero */ }

Duas armadilhas clássicas (o GCC avisa sobre ambas com -Wall):

  • = no lugar de ==: if (x = 5) atribui 5 a x, e a condição fica sempre verdadeira.
  • Comparações encadeadas: 0 < x < 10 não significa o que significa na matemática. É avaliado como (0 < x) < 10, ou seja, 0 < 10 ou 1 < 10: sempre verdadeiro. Escreva 0 < x && x < 10.

Operadores bit a bit

Os operadores bit a bit aplicam as portas lógicas da seção 2 a cada par de bits de dois inteiros. Usando a = 12 (1100) e b = 10 (1010):

OperadorNomeExemploResultado
&ANDa & b1000 = 8
|ORa | b1110 = 14
^XORa ^ b0110 = 6
~NOT (inverte todos)~a em 8 bits1111 0011
<<desloca à esquerdaa << 111000 = 24
>>desloca à direitaa >> 20011 = 3
0128
064
032
016
18
14
02
01
a = 12dec 12hex 0x0C8 bits
0128
064
032
016
18
04
12
01
b = 10dec 10hex 0x0A8 bits
0128
064
032
016
18
04
02
01
a AND b = 8: só os bits ligados nos doisdec 8hex 0x088 bits
0128
064
032
016
18
14
12
01
a OR b = 14: os bits ligados em qualquer umdec 14hex 0x0E8 bits
0128
064
032
016
08
14
12
01
a XOR b = 6: os bits diferentesdec 6hex 0x068 bits

Deslocar n posições para a esquerda multiplica por 2^n; para a direita, divide (para valores sem sinal):

0128
064
032
016
08
04
12
11
3dec 3hex 0x038 bits
0128
064
032
116
18
04
02
01
3 deslocado 3 casas à esquerda = 24 (3 × 8)dec 24hex 0x188 bits

Regras importantes sobre deslocamentos:

  • Faça operações de bits com tipos sem sinal sempre que possível.
  • Deslocar por um valor negativo ou maior ou igual à largura do tipo (x << 32 com x de 32 bits) é comportamento indefinido.
  • Deslocar um valor com sinal para a esquerda de forma que o resultado não caiba (como 1 << 31 com int de 32 bits) também é. Escreva 1u << 31.
  • Deslocar um negativo para a direita é implementation-defined: o GCC replica o bit de sinal (deslocamento aritmético).

Máscaras na prática: ligar, desligar, inverter e testar bits

Uma máscara é um número com bits ligados nas posições que interessam. Com ela, as quatro operações fundamentais sobre o bit n de uma variável x:

OperaçãoCódigoPor que funciona
Ligar o bit nx |= 1u << nOR com 1 liga; OR com 0 preserva
Desligar o bit nx &= ~(1u << n)AND com 0 desliga; AND com 1 preserva
Inverter o bit nx ^= 1u << nXOR com 1 inverte; XOR com 0 preserva
Testar o bit n(x >> n) & 1utraz o bit para a posição 0 e isola
x é potência de 2?x != 0 && (x & (x - 1)) == 0potências de 2 têm um único bit ligado

O uso mais comum é guardar várias opções liga/desliga (flags) em um único inteiro, exatamente como as permissões rwx do Unix:

flags.c
C
#include <stdio.h>
 
enum {
    PERM_LER = 1u << 2,      /* 100 */
    PERM_ESCREVER = 1u << 1, /* 010 */
    PERM_EXECUTAR = 1u << 0, /* 001 */
};
 
static void mostrar(unsigned p) {
    printf("%c%c%c\n",
           (p & PERM_LER) ? 'r' : '-',
           (p & PERM_ESCREVER) ? 'w' : '-',
           (p & PERM_EXECUTAR) ? 'x' : '-');
}
 
int main(void) {
    unsigned perm = PERM_LER | PERM_ESCREVER; /* rw- */
    mostrar(perm);
    perm |= PERM_EXECUTAR;  /* liga o x:    rwx */
    mostrar(perm);
    perm &= ~PERM_ESCREVER; /* desliga o w: r-x */
    mostrar(perm);
    perm ^= PERM_LER;       /* inverte o r: --x */
    mostrar(perm);
    printf("valor final: %u\n", perm);
    return 0;
}
Texto
rw-
rwx
r-x
--x
valor final: 1
0128
064
032
016
08
14
12
01
rw- = PERM_LER OR PERM_ESCREVER = 6dec 6hex 0x068 bits
0128
064
032
016
08
14
12
11
rwx = 7 (o famoso chmod 7)dec 7hex 0x078 bits

Incremento e decremento: pré vs pós

++ soma 1 e -- subtrai 1, em duas formas:

C
int a = 5;
int b = a++; /* pós: b recebe o valor ANTIGO (5); depois a vira 6 */
int c = ++a; /* pré: a vira 7 primeiro; c recebe 7 */

Quando o operador aparece sozinho em uma instrução (i++;, como no for), as duas formas são equivalentes.

Atribuição composta, ternário, vírgula e sizeof

  • Atribuição composta: x += 3 equivale a x = x + 3. Existem para todos os operadores binários: -=, *=, /=, %=, &=, |=, ^=, <<=, >>=.
  • Ternário: condição ? valor_se_verdadeiro : valor_se_falso é uma expressão (produz um valor), ao contrário do if. Exemplo: const char *palavra = (n == 1) ? "item" : "itens";.
  • Vírgula: avalia a expressão da esquerda, descarta o resultado e avalia a da direita. Quase só aparece no for: for (i = 0, j = n - 1; i < j; i++, j--). (As vírgulas que separam argumentos de função não são esse operador.)
  • sizeof: além de tamanhos de tipos, calcula o número de elementos de um array: sizeof v / sizeof v[0] (só funciona com arrays de verdade, não com ponteiros, veremos por quê).

Precedência e associatividade

A precedência decide quais operadores "grudam" primeiro. Uma tabela simplificada, da maior para a menor:

NívelOperadoresAssociatividade
1() [] -> . x++ x--esquerda para direita
2++x --x +x -x ! ~ (tipo) *p &x sizeofdireita para esquerda
3* / %esquerda para direita
4+ -esquerda para direita
5<< >>esquerda para direita
6< <= > >=esquerda para direita
7== !=esquerda para direita
8&esquerda para direita
9^esquerda para direita
10|esquerda para direita
11&&esquerda para direita
12||esquerda para direita
13?:direita para esquerda
14= += -= *= /= &= ...direita para esquerda
15,esquerda para direita

As pegadinhas mais comuns:

  • x & MASCARA == 0 é lido como x & (MASCARA == 0), porque == tem precedência maior que &. Escreva (x & MASCARA) == 0.
  • *p++ é *(p++): incrementa o ponteiro, não o valor apontado. Para incrementar o valor, (*p)++.
  • a << 1 + 1 é a << 2, porque + vem antes de <<.

A regra de ouro: na dúvida, use parênteses. Eles não custam nada e deixam a intenção explícita.

Modificações sem sequenciamento (i = i++)

Em C, a ordem de avaliação dos operandos de uma expressão e dos argumentos de uma função, na maioria dos casos, não é especificada. Por isso, modificar uma variável duas vezes na mesma expressão, ou modificá-la e lê-la em outro ponto da mesma expressão, é comportamento indefinido:

C
i = i++;                     /* UB */
v[i] = i++;                  /* UB */
printf("%d %d\n", i, i++);   /* UB */

O GCC avisa com -Wall (operation on 'i' may be undefined). Não tente adivinhar o resultado: quebre em instruções separadas.

11. Entrada e saída no terminal

As funções de entrada e saída ficam em <stdio.h>. Todo programa C começa com três fluxos (streams) abertos: stdin (entrada, normalmente o teclado), stdout (saída, normalmente o terminal) e stderr (saída de erros, também o terminal). Mensagens de erro devem ir para stderr com fprintf(stderr, ...): assim elas continuam aparecendo mesmo quando a saída normal é redirecionada para um arquivo.

printf e seus especificadores

printf recebe uma string de formato e uma lista de valores. Cada % na string é um especificador que diz como formatar o próximo valor:

EspecificadorTipo do argumentoExemploSaída
%d ou %iintprintf("%d", 42)42
%uunsigned intprintf("%u", 42u)42
%ld, %lldlong, long longprintf("%lld", 1LL << 40)1099511627776
%zusize_tprintf("%zu", sizeof(int))4
%fdouble (e float, promovido)printf("%f", 3.14159)3.141590
%.2fdouble com 2 casasprintf("%.2f", 3.14159)3.14
%enotação científicaprintf("%e", 1234.5)1.234500e+03
%go mais curto entre %f e %eprintf("%g", 0.0001)0.0001
%ccaractere (int)printf("%c", 'A')A
%sstring (char *)printf("%s", "oi")oi
%pponteiro (void *)printf("%p", (void *)&x)0x7ffc...
%x, %Xhexadecimalprintf("%x", 255)ff
%ooctalprintf("%o", 8)10
%%um % literalprintf("100%%")100%

Passar um tipo diferente do que o especificador espera (um double para %d, por exemplo) é comportamento indefinido. O GCC confere os formatos literais com -Wall (opção -Wformat): mais um motivo para nunca desligar os avisos.

Largura, precisão e flags

O formato completo de um especificador é %[flags][largura][.precisão]tipo:

FormatoValorResultadoO que faz
[%5d]42[ 42]largura mínima 5, alinhado à direita
[%-5d]42[42 ]alinhado à esquerda
[%05d]42[00042]completa com zeros
[%+d]42[+42]sempre mostra o sinal
[%.3f]3.14159[3.142]3 casas decimais (arredonda)
[%8.2f]3.14159[ 3.14]largura 8, 2 casas
[%.3s]"abcdef"[abc]no máximo 3 caracteres da string
[%#x]255[0xff]inclui o prefixo 0x
[%*d]6, 42[ 42]a largura vem de um argumento

Com largura e alinhamento dá para montar tabelas bem alinhadas:

tabela.c
C
#include <stdio.h>
 
int main(void) {
    const char *produtos[] = {"Coxinha", "Pastel", "Suco"};
    double precos[] = {4.5, 7.25, 9.0};
    int qtd[] = {2, 3, 1};
 
    printf("%-10s %5s %10s\n", "Produto", "Qtd", "Total");
    for (int i = 0; i < 3; i++)
        printf("%-10s %5d %10.2f\n", produtos[i], qtd[i], precos[i] * qtd[i]);
    return 0;
}
Texto
Produto      Qtd      Total
Coxinha        2       9.00
Pastel         3      21.75
Suco           1       9.00

Um detalhe: a largura conta bytes, não letras. Uma palavra com acento em UTF-8 tem mais bytes do que letras, e a coluna sai desalinhada.

scanf e suas armadilhas

scanf lê e converte a entrada segundo um formato, e precisa do endereço das variáveis onde vai escrever (o operador &, que veremos em detalhes na seção de ponteiros):

C
int idade;
printf("Idade: ");
if (scanf("%d", &idade) != 1) {
    fprintf(stderr, "Entrada inválida\n");
}

Ele é prático para exercícios, mas cheio de armadilhas:

  1. Esquecer o &: scanf("%d", idade) passa o valor em vez do endereço, e o programa escreve em um lugar aleatório da memória (o GCC avisa).
  2. Não conferir o retorno: scanf devolve quantos itens conseguiu converter (ou EOF se a entrada acabou). Se o usuário digita abc onde se esperava um número, nada é lido, o texto continua no buffer e um loop que chama scanf de novo fica girando para sempre.
  3. %s sem limite: scanf("%s", nome) escreve quantos bytes o usuário digitar, estourando o array. Sempre informe a largura: scanf("%49s", nome) para um char nome[50]. Além disso, %s para no primeiro espaço.
  4. O \n que sobra: depois de scanf("%d", ...), o Enter digitado continua no buffer. Um scanf("%c", &opcao) seguinte lê esse \n. A solução é um espaço antes do %c: scanf(" %c", &opcao) pula espaços e quebras de linha.
  5. %lf para double: no scanf, %f é para float e %lf para double (no printf, %f serve para os dois).

Leitura segura com fgets e strtol

Em programas interativos de verdade, a abordagem robusta é ler a linha inteira com fgets e depois converter com strtol (inteiros) ou strtod (ponto flutuante). Assim o buffer de entrada nunca fica em um estado inconsistente e você tem controle total sobre os erros:

leitura_segura.c
C
#include <errno.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
/* lê uma linha e converte para int; devolve false se for inválida */
static bool ler_inteiro(const char *mensagem, int *saida) {
    char linha[64];
    printf("%s", mensagem);
    fflush(stdout); /* garante que a mensagem apareça antes da leitura */
    if (fgets(linha, sizeof linha, stdin) == NULL)
        return false; /* fim da entrada ou erro */
 
    char *quebra = strchr(linha, '\n');
    if (quebra != NULL) {
        *quebra = '\0'; /* remove o \n do final */
    } else if (!feof(stdin)) {
        int c; /* linha longa demais: descarta o resto e rejeita */
        while ((c = getchar()) != '\n' && c != EOF) {}
        return false;
    }
 
    char *fim;
    errno = 0;
    long valor = strtol(linha, &fim, 10);
    if (fim == linha || *fim != '\0')
        return false; /* nenhum dígito, ou lixo depois do número */
    if (errno == ERANGE || valor < INT_MIN || valor > INT_MAX)
        return false; /* fora da faixa do int */
    *saida = (int)valor;
    return true;
}
 
int main(void) {
    int idade;
    while (!ler_inteiro("Sua idade: ", &idade)) {
        if (feof(stdin))
            return 1; /* a entrada acabou (Ctrl+D ou Ctrl+Z) */
        puts("Valor inválido, tente de novo.");
    }
    printf("Daqui a 10 anos você terá %d anos.\n", idade + 10);
    return 0;
}
Texto
Sua idade: abc
Valor inválido, tente de novo.
Sua idade: 32
Daqui a 10 anos você terá 42 anos.

strtol informa, pelo ponteiro fim, onde parou de ler; se fim não avançou, não havia número. Se o número não cabe em um long, ela devolve LONG_MAX ou LONG_MIN e coloca ERANGE em errno. As funções atoi e atof parecem mais simples, mas não informam erros: atoi("abc") devolve 0, igualzinho a atoi("0"). Evite-as.

puts, putchar e getchar

  • puts(s) escreve a string e uma quebra de linha.
  • putchar(c) escreve um único caractere.
  • getchar() lê um caractere e devolve um int, não um char: além dos 256 valores possíveis de um byte, ela precisa conseguir devolver EOF (fim da entrada, normalmente −1).

Guardar o retorno de getchar em um char é um bug clássico: dependendo do sinal do char, o loop nunca termina ou termina antes da hora ao encontrar o byte 0xFF.

contador.c
C
#include <stdio.h>
 
int main(void) {
    int c; /* int, não char: precisa caber o EOF */
    long caracteres = 0, linhas = 0;
    while ((c = getchar()) != EOF) {
        caracteres++;
        if (c == '\n')
            linhas++;
    }
    printf("%ld caracteres, %ld linhas\n", caracteres, linhas);
    return 0;
}

Rode com ./contador < algum_arquivo.txt (o < redireciona um arquivo para a entrada) ou digite um texto e sinalize o fim da entrada com Ctrl + D no Linux e no macOS, ou Ctrl + Z seguido de Enter no Windows.

Por fim, saiba que stdout tem um buffer: quando conectado ao terminal, ele normalmente é esvaziado a cada \n; quando redirecionado para um arquivo, só quando o buffer enche. Um printf sem \n pode não aparecer na hora, por isso o fflush(stdout) no exemplo de leitura segura. O stderr, por sua vez, não tem buffer.

12. Controle de fluxo

if, else if e else

C
if (nota >= 9.0) {
    puts("Conceito A");
} else if (nota >= 7.0) {
    puts("Conceito B");
} else {
    puts("Conceito C");
}

A condição pode ser qualquer valor escalar: zero é falso e o resto é verdadeiro. Por isso if (contador) e if (ponteiro) são comuns em código C.

switch

O switch compara um valor inteiro (incluindo char e enum) com uma lista de constantes:

C
switch (opcao) {
case 1:
    puts("Novo cadastro");
    break;
case 2:
case 3: /* 2 e 3 executam o mesmo bloco */
    puts("Editar");
    break;
default:
    puts("Opção inválida");
    break;
}

Regras:

  • Os rótulos de case precisam ser constantes inteiras conhecidas em tempo de compilação. Não há switch com strings em C.
  • Sem break, a execução cai (fall through) para o case seguinte. Isso às vezes é o que queremos, mas quase sempre é um bug.
  • default trata qualquer valor não listado.

Quando a queda for intencional, deixe isso explícito. O GCC, com -Wextra, avisa sobre quedas implícitas e aceita um comentário /* fall through */ como marcação (no C23, use [[fallthrough]];):

switch.c
C
#include <stdio.h>
 
int main(void) {
    for (int nivel = 1; nivel <= 3; nivel++) {
        printf("nível %d:", nivel);
        switch (nivel) {
        case 3:
            printf(" admin");
            /* fall through */
        case 2:
            printf(" editor");
            /* fall through */
        case 1:
            printf(" leitor");
            break;
        default:
            printf(" nenhum");
            break;
        }
        putchar('\n');
    }
    return 0;
}
Texto
nível 1: leitor
nível 2: editor leitor
nível 3: admin editor leitor

while

O while testa a condição antes de cada repetição; se ela começar falsa, o corpo não executa nenhuma vez:

C
int n = 1;
while (n <= 5) {
    printf("%d ", n);
    n++;
}
/* 1 2 3 4 5 */

do-while

O do-while testa a condição depois: o corpo executa pelo menos uma vez. É a escolha natural para menus e para "pergunte até a resposta ser válida". Repare no ; depois do while:

C
int opcao;
do {
    printf("1) Jogar  2) Sair: ");
    if (scanf("%d", &opcao) != 1)
        break; /* entrada inválida ou fim da entrada */
} while (opcao != 2);

for: anatomia de um loop

O for reúne as três partes de um loop com contador em uma linha:

Texto
for (inicialização; condição; atualização) {
    corpo;
}
 
        ┌───────────────┐
        │ inicialização │  executa uma única vez
        └───────┬───────┘
                ▼
        ┌───────────────┐   falsa
   ┌──▶ │   condição    │ ─────────▶ sai do loop
   │    └───────┬───────┘
   │            │ verdadeira
   │            ▼
   │    ┌───────────────┐
   │    │     corpo     │
   │    └───────┬───────┘
   │            ▼
   │    ┌───────────────┐
   └─── │  atualização  │
        └───────────────┘

Ele equivale a um while com a inicialização antes e a atualização no fim do corpo. Uma variável declarada na inicialização (for (int i = 0; ...), permitido desde o C99) só existe dentro do loop. Qualquer uma das três partes pode ficar vazia; for (;;) é um loop infinito.

Loops aninhados

Um loop dentro de outro executa o interno inteiro a cada volta do externo:

tabuada.c
C
#include <stdio.h>
 
int main(void) {
    for (int i = 1; i <= 5; i++) {
        for (int j = 1; j <= 5; j++)
            printf("%4d", i * j);
        putchar('\n');
    }
    return 0;
}
Texto
   1   2   3   4   5
   2   4   6   8  10
   3   6   9  12  15
   4   8  12  16  20
   5  10  15  20  25

São 5 × 5 = 25 execuções do printf. Com n em cada nível, o custo cresce com n², algo a observar quando os dados crescem.

break e continue

  • break sai imediatamente do loop (ou do switch) mais interno.
  • continue pula o resto do corpo e vai para a próxima repetição (no for, a atualização ainda é executada).

Para sair de vários loops aninhados de uma vez, use uma variável de controle, coloque os loops em uma função e use return, ou use goto (veja abaixo).

Padrões de loop: contar, acumular, buscar e sentinela

Quase todo loop do dia a dia é uma variação de poucos padrões:

padroes.c
C
#include <stdio.h>
 
int main(void) {
    int v[] = {4, 7, -2, 9, 12, -5, 3};
    int n = (int)(sizeof v / sizeof v[0]);
 
    int pares = 0; /* contar */
    for (int i = 0; i < n; i++)
        if (v[i] % 2 == 0)
            pares++;
 
    int soma = 0; /* acumular */
    for (int i = 0; i < n; i++)
        soma += v[i];
    double media = (double)soma / n;
 
    int indice = -1; /* buscar: primeiro negativo */
    for (int i = 0; i < n; i++) {
        if (v[i] < 0) {
            indice = i;
            break; /* achou: não precisa continuar */
        }
    }
 
    int maior = v[0]; /* máximo */
    for (int i = 1; i < n; i++)
        if (v[i] > maior)
            maior = v[i];
 
    printf("pares: %d, soma: %d, média: %.2f\n", pares, soma, media);
    printf("primeiro negativo no índice %d, maior: %d\n", indice, maior);
    return 0;
}
Texto
pares: 3, soma: 28, média: 4.00
primeiro negativo no índice 2, maior: 12

O padrão sentinela repete até aparecer um valor especial que marca o fim. Por exemplo, somar números até o usuário digitar 0:

C
int x, soma = 0;
while (scanf("%d", &x) == 1 && x != 0)
    soma += x;

Loops infinitos e erros de "um a mais"

Loops infinitos intencionais (for (;;) ou while (1)) são comuns em servidores e sistemas embarcados, sempre com um break ou return lá dentro. Os acidentais costumam vir de:

  • Esquecer a atualização da variável de controle.
  • Um ; perdido: while (i < 10); tem corpo vazio e nunca termina.
  • Contador unsigned decrescente: em for (size_t i = n - 1; i >= 0; i--), a condição é sempre verdadeira, porque um valor sem sinal nunca é negativo (o GCC avisa com -Wextra). A forma correta é for (size_t i = n; i-- > 0; ).
  • Contador de ponto flutuante: for (double x = 0; x != 1.0; x += 0.1) nunca termina, porque somar 0,1 dez vezes dá 0.9999999999999999, não 1.0. Use contadores inteiros.

O erro mais frequente, porém, é o erro de um a mais (off-by-one). Um array de n elementos tem índices de 0 a n - 1, então a condição correta é i < n. Escrever i <= n acessa v[n], fora do array, comportamento indefinido que o C não detecta sozinho.

goto: só para limpeza

Em 1968, Edsger Dijkstra publicou a famosa carta Go To Statement Considered Harmful, e desde então o goto tem má fama, merecida quando ele é usado para criar código espaguete. Em C, porém, existe um uso aceito e muito comum (o kernel do Linux está cheio dele): centralizar a limpeza de recursos quando uma função pode falhar em vários pontos.

goto_limpeza.c
C
#include <stdio.h>
#include <stdlib.h>
 
static int processar(const char *caminho) {
    int status = -1;
    char *buffer = NULL;
    FILE *arq = fopen(caminho, "r");
    if (arq == NULL)
        goto fim;
 
    buffer = malloc(4096);
    if (buffer == NULL)
        goto fechar_arquivo;
 
    size_t lidos = fread(buffer, 1, 4096, arq);
    printf("li %zu bytes\n", lidos);
    status = 0;
 
    free(buffer);
fechar_arquivo:
    fclose(arq);
fim:
    return status;
}
 
int main(void) {
    return processar("goto_limpeza.c") == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}

Cada falha salta para o ponto que libera exatamente o que já foi adquirido, na ordem inversa. As funções fopen, malloc e fread são explicadas nas seções 18 e 22. Fora desse padrão, prefira break, continue, return e funções pequenas.

13. Funções

Funções dividem o programa em partes com nome, que recebem dados (parâmetros), fazem um trabalho e devolvem um resultado. Em C, a sintaxe é tipo_de_retorno nome(parâmetros) { corpo }.

Declaração (protótipo) e definição

O compilador lê o arquivo de cima para baixo, e uma função precisa ser declarada antes de ser usada. A declaração, chamada de protótipo, informa o nome, o tipo de retorno e os tipos dos parâmetros. A definição traz o corpo:

funcoes.c
C
#include <stdio.h>
 
double area_circulo(double raio); /* protótipo: termina com ; */
 
int main(void) {
    printf("%.2f\n", area_circulo(2.0));
    return 0;
}
 
double area_circulo(double raio) { /* definição */
    const double PI = 3.14159265358979;
    return PI * raio * raio;
}
Texto
12.57

Em projetos maiores, os protótipos ficam em arquivos de cabeçalho (.h), como veremos na seção 21. Um detalhe histórico importante: até o C17, int f(); significava "parâmetros não especificados" e desligava a checagem dos argumentos. Para declarar uma função sem parâmetros de forma inequívoca em qualquer padrão, escreva int f(void);.

Passagem por valor

Em C, todo argumento é passado por valor: a função recebe uma cópia. Alterar o parâmetro não altera a variável de quem chamou:

por_valor.c
C
#include <stdio.h>
 
static void tentar_dobrar(int x) {
    x = x * 2; /* altera só a cópia local */
    printf("dentro: %d\n", x);
}
 
int main(void) {
    int n = 10;
    tentar_dobrar(n);
    printf("fora:   %d\n", n);
    return 0;
}
Texto
dentro: 20
fora:   10

Para que uma função modifique uma variável de quem a chamou, passamos o endereço dela, um ponteiro, que também é copiado, mas aponta para o dado original. É o assunto da seção 17.

return e void

return valor; encerra a função e entrega o valor a quem chamou. Uma função void não devolve nada; nela, return; (sem valor) é opcional e serve para sair mais cedo. Se uma função não void termina sem return e quem chamou usa o resultado, o comportamento é indefinido, o GCC avisa com -Wall. E uma função devolve um valor: para devolver vários, use ponteiros como parâmetros de saída ou devolva uma struct.

main e os argumentos da linha de comando

main também pode receber os argumentos digitados no terminal:

args.c
C
#include <stdio.h>
 
int main(int argc, char *argv[]) {
    printf("argc = %d\n", argc);
    for (int i = 0; i < argc; i++)
        printf("argv[%d] = %s\n", i, argv[i]);
    return 0;
}
Bash
./args ola "mundo grande" 42
Texto
argc = 4
argv[0] = ./args
argv[1] = ola
argv[2] = mundo grande
argv[3] = 42

argc (argument count) conta os argumentos, incluindo o nome do programa em argv[0]. argv (argument vector) é um array de strings, e argv[argc] é sempre NULL. Tudo chega como texto: o 42 é a string "42" e precisa ser convertido com strtol se você quiser um número.

Recursão: fatorial e Fibonacci

Uma função recursiva chama a si mesma para resolver uma versão menor do problema. Toda recursão precisa de um caso base, que para a descida:

recursao.c
C
#include <stdio.h>
 
static unsigned long long fatorial(unsigned n) {
    if (n <= 1) /* caso base */
        return 1;
    return n * fatorial(n - 1); /* caso recursivo: problema menor */
}
 
static unsigned long long fib_recursivo(unsigned n) {
    return n < 2 ? n : fib_recursivo(n - 1) + fib_recursivo(n - 2);
}
 
static unsigned long long fib_iterativo(unsigned n) {
    unsigned long long a = 0, b = 1;
    for (unsigned i = 0; i < n; i++) {
        unsigned long long proximo = a + b;
        a = b;
        b = proximo;
    }
    return a;
}
 
int main(void) {
    printf("5!  = %llu\n", fatorial(5));
    printf("20! = %llu\n", fatorial(20)); /* o maior que cabe em 64 bits */
    printf("fib(40) = %llu (recursivo)\n", fib_recursivo(40));
    printf("fib(90) = %llu (iterativo)\n", fib_iterativo(90));
    return 0;
}
Texto
5!  = 120
20! = 2432902008176640000
fib(40) = 102334155 (recursivo)
fib(90) = 2880067194370816120 (iterativo)

Cada chamada ganha seu próprio frame na pilha, com sua própria cópia de n. Veja a pilha no momento mais fundo de fatorial(3):

Texto
                     pilha (cresce para baixo)
                   ┌───────────────────────────┐
 main              │ frame de main             │
                   ├───────────────────────────┤
 fatorial(3)       │ n = 3   espera 3 * f(2)   │  ◀── recebe 2, devolve 6
                   ├───────────────────────────┤
 fatorial(2)       │ n = 2   espera 2 * f(1)   │  ◀── recebe 1, devolve 2
                   ├───────────────────────────┤
 fatorial(1)       │ n = 1   caso base         │  ──▶ devolve 1
                   └───────────────────────────┘ ← topo da pilha (rsp)

Recursão é elegante, mas nem sempre eficiente. fib_recursivo(40) recalcula os mesmos valores repetidamente e faz mais de 330 milhões de chamadas; a versão iterativa faz 40 voltas de um loop. Em C, prefira loops quando a recursão não trouxer clareza real (percorrer árvores é um bom exemplo de onde ela traz).

O que acontece no hardware durante uma chamada

No x86-64 com a convenção System V (Linux e macOS), uma chamada r = soma(2, 3); segue estes passos:

  1. Argumentos: quem chama coloca 2 em edi e 3 em esi (argumentos extras vão para a pilha).
  2. call soma: a CPU empilha o endereço de retorno (o da instrução seguinte ao call) e salta para soma, trocando o valor de rip.
  3. Prólogo: push rbp e mov rbp, rsp salvam o frame anterior e marcam o início do novo; um sub rsp, N reserva espaço para as variáveis locais, quando há.
  4. Corpo: a função calcula e deixa o resultado em eax/rax.
  5. Epílogo e ret: o frame é desfeito (pop rbp ou leave) e ret desempilha o endereço de retorno para rip. A execução continua logo após o call, e quem chamou lê o resultado em eax.

No ARM64, a instrução de chamada (bl) guarda o endereço de retorno no registrador x30 em vez de empilhá-lo, e os argumentos vão em x0 a x7. No Windows x64, os quatro primeiros argumentos inteiros vão em rcx, rdx, r8 e r9. Os detalhes mudam; a ideia é a mesma. Chamar uma função custa poucas instruções, barato, mas não gratuito, e por isso o otimizador frequentemente copia o corpo de funções pequenas para o local da chamada (inlining).

Estouro de pilha (stack overflow)

Como a pilha é limitada (8 MiB é o padrão comum no Linux; 1 MiB no Windows), duas coisas a esgotam:

  • Recursão sem caso base ou profunda demais: cada chamada consome um frame.
  • Arrays locais enormes: int dados[10000000]; dentro de uma função pede 40 MB de pilha.

O resultado é o processo abortado: Segmentation fault no Linux e no macOS, e a exceção 0xC00000FD (stack overflow) no Windows. As soluções são algoritmos iterativos, alocação no heap com malloc ou, para dados de tamanho fixo que vivem o programa todo, static.

Funções static e inline

  • static em uma função a torna visível apenas no próprio arquivo .c (ligação interna). Use em toda função auxiliar que não precisa ser chamada de fora: evita colisão de nomes e deixa claro o que é interno. (Por isso os exemplos deste guia marcam as funções auxiliares como static.)
  • inline sugere ao compilador que substitua a chamada pelo corpo da função. Compiladores modernos decidem sozinhos quando isso compensa. As regras de inline em C são diferentes das de C++; a forma mais simples e segura é usar static inline para funções pequenas definidas em cabeçalhos.

Funções variádicas (stdarg.h)

Funções como printf aceitam um número variável de argumentos. Você pode escrever as suas com <stdarg.h>:

variadica.c
C
#include <stdarg.h>
#include <stdio.h>
 
/* soma 'quantidade' inteiros passados depois dele */
static int somar(int quantidade, ...) {
    va_list args;
    va_start(args, quantidade);
    int total = 0;
    for (int i = 0; i < quantidade; i++)
        total += va_arg(args, int);
    va_end(args);
    return total;
}
 
int main(void) {
    printf("%d\n", somar(3, 10, 20, 30));
    printf("%d\n", somar(5, 1, 2, 3, 4, 5));
    return 0;
}
Texto
60
15

A função não tem como saber quantos argumentos recebeu nem de que tipos são: alguém precisa informar (um contador, uma string de formato como no printf, ou um valor sentinela no fim). Além disso, nos argumentos variádicos, char e short são promovidos a int e float a double, então va_arg(args, float) está errado. Como não há verificação de tipos, prefira parâmetros fixos ou arrays sempre que possível.

14. Escopo, tempo de vida e classes de armazenamento

Três conceitos independentes definem o comportamento de uma variável:

  • Escopo: em que parte do código o nome é visível.
  • Tempo de vida (storage duration): durante quanto tempo o objeto existe na memória.
  • Ligação (linkage): se o nome é compartilhado com outros arquivos do programa.

Escopo de bloco e de arquivo

Uma variável declarada dentro de um bloco { } é visível da declaração até o fim do bloco (escopo de bloco). Uma declarada fora de qualquer função é visível da declaração até o fim do arquivo (escopo de arquivo). Um nome interno pode esconder um externo com o mesmo nome (shadowing), o que confunde; a flag -Wshadow avisa:

C
int x = 1;         /* escopo de arquivo */
 
void f(void) {
    int x = 2;     /* esconde o x global dentro de f */
    {
        int x = 3; /* esconde o x de f dentro deste bloco */
        printf("%d\n", x); /* 3 */
    }
    printf("%d\n", x);     /* 2 */
}

Variáveis automáticas e static locais

Variáveis locais comuns têm tempo de vida automático: nascem quando a execução entra no bloco e morrem quando sai (vivem na pilha). E, atenção, não são inicializadas: contêm o "lixo" que estava naquela região da pilha. Ler uma variável local antes de atribuir um valor a ela é um erro, sempre inicialize.

Uma variável local marcada com static tem tempo de vida estático: é criada uma única vez, antes de main, dura o programa inteiro, começa zerada (se você não inicializar) e mantém o valor entre as chamadas:

static_local.c
C
#include <stdio.h>
 
static int proximo_id(void) {
    static int contador = 0; /* inicializado uma única vez */
    return ++contador;
}
 
int main(void) {
    for (int i = 0; i < 3; i++)
        printf("id %d\n", proximo_id());
    return 0;
}
Texto
id 1
id 2
id 3

Repare que static tem dois significados em C: em uma variável local, muda o tempo de vida; em uma função ou variável global, muda a ligação (visível só no arquivo). O nome é o mesmo; os efeitos, diferentes.

extern

extern declara uma variável que foi definida em outro lugar, sem reservar memória para ela. O padrão em projetos com vários arquivos é: definir a variável em exatamente um .c (int total_conexoes = 0;) e declará-la com extern int total_conexoes; no cabeçalho, para que outros arquivos possam usá-la. Funções já são extern por padrão.

register (obsoleto)

register era uma dica para manter a variável em um registrador da CPU. Os compiladores modernos ignoram a dica (alocam registradores muito melhor que humanos), e o único efeito que sobrou é proibir o uso de & na variável. Não use.

Os perigos das variáveis globais

Variáveis globais parecem convenientes, mas cobram caro:

  • Qualquer função pode alterá-las, e um bug em um canto do programa aparece em outro.
  • Criam acoplamento escondido: a função depende de algo que não está nos seus parâmetros, o que dificulta testes e reuso.
  • Não são seguras com threads (acessos simultâneos geram data races).
  • Colidem com nomes de outros arquivos se não forem static.

Prefira passar dados por parâmetros e devolver resultados. Quando um estado global for inevitável, deixe-o static no arquivo e ofereça funções para acessá-lo.

DeclaraçãoEscopoTempo de vidaValor inicial padrãoLigação
Local comumblocoaté sair do blocolixo (indeterminado)nenhuma
Local staticblocoprograma inteirozeronenhuma
Parâmetrofunçãodurante a chamadao argumento recebidonenhuma
Globalarquivoprograma inteirozeroexterna (outros arquivos veem)
Global staticarquivoprograma inteirozerointerna (só o próprio arquivo)
Memória de mallocacessada por ponteiroaté o freelixo (calloc zera),

15. Arrays: memória contígua

Um array é um bloco contíguo de elementos do mesmo tipo. Não existe mágica: int notas[5]; reserva 5 * sizeof(int) bytes, um colado no outro.

C
int notas[5] = {7, 9, 6, 10, 8};
Texto
endereço:  0x1000   0x1004   0x1008   0x100C   0x1010
          +--------+--------+--------+--------+--------+
notas:    |   7    |   9    |   6    |   10   |   8    |
          +--------+--------+--------+--------+--------+
índice:      [0]      [1]      [2]      [3]      [4]

Indexar é aritmética de ponteiros

notas[i] é, por definição, *(notas + i): pegue o endereço inicial, avance i elementos (o compilador multiplica por sizeof(int)) e leia. Por isso o primeiro índice é 0: é o deslocamento zero a partir do início.

Inicialização

C
int a[5] = {1, 2};          // {1, 2, 0, 0, 0}: o que faltar vira zero
int b[] = {4, 5, 6};        // tamanho deduzido: 3 elementos
int c[10] = {0};            // tudo zerado
int d[5] = {[2] = 7};       // inicializador designado (C99): {0, 0, 7, 0, 0}
 
size_t n = sizeof b / sizeof b[0];  // quantidade de elementos: 3

O truque sizeof arr / sizeof arr[0] só funciona onde o array foi declarado. Veja o motivo a seguir.

Não existe verificação de limites

C não confere se o índice é válido. notas[5] ou notas[-1] compilam e acessam a memória vizinha: comportamento indefinido. Pode "funcionar", pode travar, pode corromper outra variável silenciosamente. É a origem de boa parte das falhas de segurança em software escrito em C.

Arrays em funções: o decaimento

Ao passar um array para uma função, ele decai para um ponteiro para o primeiro elemento. A função não sabe o tamanho; você precisa passá-lo.

C
#include <stdio.h>
 
double media(const int *valores, size_t n) {
    long soma = 0;
    for (size_t i = 0; i < n; i++) soma += valores[i];
    return n ? (double)soma / n : 0.0;
}
 
int main(void) {
    int notas[] = {7, 9, 6, 10, 8};
    size_t n = sizeof notas / sizeof notas[0];
    printf("Média: %.2f\n", media(notas, n));   // Média: 8.00
    return 0;
}

Dentro de media, sizeof valores seria o tamanho de um ponteiro (8 bytes em 64 bits), não do array. O const promete que a função não altera os dados.

Arrays multidimensionais

int m[3][4]; são 3 linhas de 4 inteiros, guardadas linha após linha (row-major): m[1][0] vem logo depois de m[0][3] na memória. Percorrer por linhas aproveita o cache; por colunas, salta 16 bytes a cada acesso.

C
int m[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12},
};
 
for (int i = 0; i < 3; i++) {        // linhas por fora: amigo do cache
    for (int j = 0; j < 4; j++) {
        printf("%3d", m[i][j]);
    }
    putchar('\n');
}

VLAs

Desde o C99 é possível declarar int v[n]; com n conhecido só em tempo de execução (variable length array). A partir do C11 o suporte virou opcional e o MSVC nunca implementou. Como ficam na pilha, um n grande estoura o stack sem aviso. Para tamanhos dinâmicos, prefira malloc.

16. Strings: arrays de char terminados em zero

Em C, uma string é apenas um array de char terminado pelo byte zero ('\0'). Não há tipo string, não há tamanho guardado em lugar nenhum: as funções percorrem os bytes até achar o zero.

C
char nome[] = "Ana";   // 4 bytes: 'A' 'n' 'a' '\0'
Texto
          +-----+-----+-----+------+
nome:     | 'A' | 'n' | 'a' | '\0' |
          +-----+-----+-----+------+
bytes:      65    110    97     0

Literal é somente leitura

C
char *p = "texto";   // aponta para memória somente leitura
p[0] = 'T';          // comportamento indefinido (costuma dar segfault)
 
char s[] = "texto";  // cópia em um array seu: pode alterar
s[0] = 'T';          // ok

Declare ponteiros para literais como const char * e o compilador impedirá o erro.

Funções essenciais de string.h

FunçãoO que fazCuidado
strlen(s)Conta caracteres até o '\0' (sem contá-lo)Percorre a string inteira: O(n)
strcpy(dst, src)Copia src para dstNão verifica o tamanho de dst
strncpy(dst, src, n)Copia no máximo n bytesPode não colocar o '\0'
strcat(dst, src)Concatena src no fim de dstEstouro se dst for pequeno
strcmp(a, b)Compara: 0 se iguais, negativo/positivo pela ordemNão use == para comparar strings
strchr(s, c)Ponteiro para a primeira ocorrência de cRetorna NULL se não achar
strstr(s, sub)Ponteiro para a primeira ocorrência de subRetorna NULL se não achar
memcpy(dst, src, n)Copia n bytesRegiões não podem se sobrepor
memmove(dst, src, n)Copia n bytesFunciona com sobreposição
memset(p, v, n)Preenche n bytes com vv é um byte, não um int inteiro

snprintf: a forma segura de montar strings

C
#include <stdio.h>
 
int main(void) {
    char msg[32];
    int escritos = snprintf(msg, sizeof msg, "Olá, %s! Você tem %d anos.", "Gabriel", 28);
 
    if (escritos >= (int)sizeof msg) {
        puts("Aviso: a mensagem foi truncada");
    }
    puts(msg);
    return 0;
}

snprintf nunca escreve mais que sizeof msg bytes, sempre termina com '\0' e devolve quantos caracteres teria escrito. Se esse número for maior ou igual ao buffer, houve truncamento.

Buffer overflow: por que isso importa

Escrever além do fim de um buffer sobrescreve o que vem depois: outras variáveis, o endereço de retorno da função na pilha, estruturas do alocador. Foi assim que o worm Morris, em 1988, explorou a função gets do serviço fingerd. Em 2014, o Heartbleed no OpenSSL veio de uma falta de verificação de tamanho: o servidor confiava num comprimento enviado pelo cliente e devolvia bytes da própria memória, incluindo chaves e senhas.

Hábitos que evitam a maioria desses bugs:

  • Nunca use gets (foi removida no C11). Use fgets.
  • Sempre passe e confira o tamanho do buffer.
  • Prefira snprintf a strcpy/strcat/sprintf.
  • Compile com -Wall -Wextra e teste com -fsanitize=address.

17. Ponteiros

Chegamos ao capítulo que dá fama (e medo) ao C. A boa notícia: depois de tudo que vimos sobre memória, ponteiro é um conceito simples. Um ponteiro é uma variável que guarda um endereço de memória.

& e *

C
#include <stdio.h>
 
int main(void) {
    int idade = 28;
    int *p = &idade;   // p guarda o endereço de idade
 
    printf("valor de idade:     %d\n", idade);
    printf("endereço de idade:  %p\n", (void *)&idade);
    printf("valor de p:         %p\n", (void *)p);
    printf("valor apontado *p:  %d\n", *p);
 
    *p = 30;           // escreve no endereço guardado em p
    printf("idade agora:        %d\n", idade);   // 30
    return 0;
}
Texto
     idade (int, 4 bytes)              p (int *, 8 bytes)
   +----------------------+          +----------------------+
   |          30          |  ◀─────  |    0x7ffd5e8c1a4c    |
   +----------------------+          +----------------------+
   endereço 0x7ffd5e8c1a4c           endereço 0x7ffd5e8c1a50
  • &x significa endereço de x.
  • *p, numa expressão, significa o valor no endereço guardado em p (desreferenciar).
  • Na declaração, int *p significa "p é um ponteiro para int". O asterisco pertence à variável: int *a, b; declara um ponteiro a e um int b.

NULL

NULL é um ponteiro que não aponta para nada válido. Inicialize ponteiros com NULL quando não tiver um endereço ainda e confira antes de usar. Desreferenciar NULL é comportamento indefinido; na prática, um segmentation fault.

Tamanho de um ponteiro

Todo ponteiro de dados tem o mesmo tamanho numa plataforma: 8 bytes em sistemas de 64 bits, 4 em 32 bits. O tipo apontado não muda o tamanho do ponteiro, mas muda duas coisas: quantos bytes são lidos ao desreferenciar e quanto o ponteiro anda na aritmética.

Aritmética de ponteiros

Somar 1 a um ponteiro avança um elemento, não um byte:

C
int v[3] = {10, 20, 30};
int *p = v;          // mesmo que &v[0]
 
printf("%d\n", *p);        // 10
printf("%d\n", *(p + 1));  // 20: o endereço andou 4 bytes
p++;
printf("%d\n", *p);        // 20
 
char *c = (char *)v;
c++;                       // andou 1 byte

Subtrair dois ponteiros do mesmo array dá a distância em elementos (tipo ptrdiff_t). Comparar ou subtrair ponteiros de arrays diferentes é indefinido.

Arrays e ponteiros não são a mesma coisa

Arrays decaem para ponteiros na maioria das expressões, mas continuam diferentes: sizeof de um array é o tamanho total, um array não pode receber atribuição (v = outro; não compila) e o nome do array não é uma variável que guarda endereço, ele é a memória.

Passagem "por referência": o swap

C sempre passa argumentos por valor (cópia). Para uma função alterar variáveis de quem chamou, passe os endereços:

C
#include <stdio.h>
 
void troca(int *a, int *b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}
 
int main(void) {
    int x = 1, y = 2;
    troca(&x, &y);
    printf("x=%d y=%d\n", x, y);   // x=2 y=1
    return 0;
}

É o mesmo motivo pelo qual scanf("%d", &n) precisa do &: a função escreve no endereço que você passa.

Ponteiro para ponteiro

C
int valor = 5;
int *p = &valor;
int **pp = &p;
 
**pp = 9;   // valor agora é 9

Aparece quando uma função precisa alterar um ponteiro de quem chamou (por exemplo, alocar memória e devolver pelo parâmetro) e em arrays de strings, como o char **argv do main.

const e ponteiros

Leia da direita para a esquerda:

DeclaraçãoLeituraPode mudar o valor?Pode mudar o ponteiro?
const int *pponteiro para int constantenãosim
int *const pponteiro constante para intsimnão
const int *const pponteiro constante para int constantenãonão

void *: o ponteiro genérico

void * guarda qualquer endereço de dados, mas não pode ser desreferenciado nem usado em aritmética sem conversão. É o tipo devolvido por malloc e recebido por memcpy e qsort. Em C, a conversão de void * para outro ponteiro é implícita.

Ponteiros para função e o qsort

Funções também têm endereço. Um ponteiro para função permite passar comportamento como argumento:

C
#include <stdio.h>
#include <stdlib.h>
 
int compara_int(const void *a, const void *b) {
    int x = *(const int *)a;
    int y = *(const int *)b;
    return (x > y) - (x < y);   // -1, 0 ou 1 sem risco de overflow
}
 
int main(void) {
    int v[] = {42, 7, 19, 3, 88};
    size_t n = sizeof v / sizeof v[0];
 
    qsort(v, n, sizeof v[0], compara_int);
 
    for (size_t i = 0; i < n; i++) printf("%d ", v[i]);   // 3 7 19 42 88
    putchar('\n');
    return 0;
}

A sintaxe da declaração assusta, e um typedef ajuda: typedef int (*Comparador)(const void *, const void *);.

Os erros clássicos

  • Ponteiro não inicializado (wild pointer): int *p; *p = 1; escreve num endereço aleatório.
  • Ponteiro pendurado (dangling pointer): aponta para memória que já não existe, como uma variável local de uma função que retornou ou um bloco já liberado com free.
  • Desreferenciar NULL.
  • Confundir *p++ com (*p)++: o primeiro avança o ponteiro, o segundo incrementa o valor apontado.
C
int *perigo(void) {
    int local = 10;
    return &local;   // ERRO: local deixa de existir ao retornar
}

18. Memória dinâmica: malloc, calloc, realloc e free

Variáveis locais vivem na pilha e somem ao fim da função. Quando você precisa de memória cujo tamanho só é conhecido em execução, ou que sobreviva à função que a criou, pede ao heap.

Pilha (stack)Heap
Quem gerenciaO compilador, automaticamenteVocê, com malloc e free
Tempo de vidaAté o fim do blocoAté o free
VelocidadeMuito rápida (só move o ponteiro de pilha)Mais lenta (o alocador procura espaço)
Tamanho típicoPoucos MB (8 MB no Linux por padrão, 1 MB no Windows)Limitado pela memória disponível
Erro típicoStack overflowVazamento, uso após liberar, double free

As quatro funções (stdlib.h)

C
int *a = malloc(10 * sizeof *a);     // 10 ints, conteúdo indeterminado
int *b = calloc(10, sizeof *b);      // 10 ints, todos zerados
a = realloc(a, 20 * sizeof *a);      // redimensiona (veja o cuidado abaixo)
free(a);                             // devolve ao sistema
free(b);

Três regras de ouro:

  1. Sempre confira se o retorno é NULL. A alocação pode falhar.
  2. Todo malloc tem exatamente um free. Nem zero (vazamento), nem dois (double free).
  3. Depois do free, não toque mais no bloco. Atribuir NULL ao ponteiro ajuda a evitar acidentes.

Exemplo: um array que cresce sozinho

C
#include <stdio.h>
#include <stdlib.h>
 
typedef struct {
    int *dados;
    size_t tamanho;
    size_t capacidade;
} Vetor;
 
int vetor_adiciona(Vetor *v, int valor) {
    if (v->tamanho == v->capacidade) {
        size_t nova = v->capacidade ? v->capacidade * 2 : 4;
        int *tmp = realloc(v->dados, nova * sizeof *tmp);
        if (tmp == NULL) return -1;          // v->dados continua válido
        v->dados = tmp;
        v->capacidade = nova;
    }
    v->dados[v->tamanho++] = valor;
    return 0;
}
 
int main(void) {
    Vetor v = {0};
 
    for (int i = 1; i <= 10; i++) {
        if (vetor_adiciona(&v, i * i) != 0) {
            fputs("Sem memória\n", stderr);
            free(v.dados);
            return EXIT_FAILURE;
        }
    }
 
    for (size_t i = 0; i < v.tamanho; i++) printf("%d ", v.dados[i]);
    printf("\n(capacidade final: %zu)\n", v.capacidade);
 
    free(v.dados);
    return EXIT_SUCCESS;
}

Dobrar a capacidade a cada estouro faz o custo médio de cada inserção ser constante: é exatamente o que fazem o std::vector do C++ e o array do JavaScript.

Como o malloc funciona por baixo

O malloc não fala com o hardware diretamente. Ele pede grandes regiões ao sistema operacional (no Linux, com as chamadas brk para aumentar o heap ou mmap para blocos grandes) e as divide em pedaços menores, guardando metadados (tamanho, livre ou ocupado) ao lado de cada bloco. O free devolve o pedaço para as listas internas do alocador, que pode reaproveitá-lo depois. Por isso escrever fora dos limites de um bloco do heap costuma corromper o alocador e gerar travamentos longe do lugar do bug.

Os bugs de memória e como caçá-los

BugO que acontece
Vazamento (leak)Memória alocada e nunca liberada; o programa cresce sem parar
Uso após liberar (use-after-free)Ler ou escrever num bloco já liberado
Liberação dupla (double free)free duas vezes no mesmo bloco; corrompe o alocador
Estouro de heapEscrever além do tamanho alocado
Leitura não inicializadaUsar o conteúdo de malloc antes de escrever nele

Duas ferramentas encontram quase tudo isso:

Bash
# AddressSanitizer (GCC e Clang; Linux e macOS, e GCC/Clang no Windows com limitações)
gcc -g -fsanitize=address,undefined -fno-omit-frame-pointer programa.c -o programa
./programa
 
# Valgrind (Linux): roda o binário normal numa CPU virtual e vigia cada acesso
gcc -g programa.c -o programa
valgrind --leak-check=full --show-leak-kinds=all ./programa

O ASan interrompe o programa no primeiro erro e mostra a linha exata do acesso inválido, onde o bloco foi alocado e onde foi liberado. O LeakSanitizer, incluído nele, lista os vazamentos ao final da execução.

19. Structs, unions, enums e typedef

struct: agrupando dados

C
#include <stdio.h>
#include <string.h>
 
struct Contato {
    char nome[50];
    int idade;
    double altura;
};
 
int main(void) {
    struct Contato c = {"Ana", 30, 1.68};
    struct Contato *p = &c;
 
    c.idade = 31;          // acesso direto com .
    p->altura = 1.70;      // acesso via ponteiro com -> (o mesmo que (*p).altura)
    strcpy(p->nome, "Ana Souza");
 
    printf("%s, %d anos, %.2f m\n", c.nome, c.idade, c.altura);
    return 0;
}

Structs podem conter outras structs, arrays e ponteiros, e podem ser copiadas com = (cópia rasa: ponteiros internos são copiados, não o que eles apontam). Passe structs grandes para funções por ponteiro (const struct Contato *) para evitar copiar tudo.

Inicializadores designados e literais compostos

C
struct Ponto { int x, y; };
 
struct Ponto a = {.y = 5, .x = 2};          // ordem livre, campos omitidos viram 0
struct Ponto b = (struct Ponto){.x = 1};    // literal composto (C99)

Padding e alinhamento

A CPU acessa dados mais rápido (e algumas arquiteturas só conseguem) quando cada valor está num endereço múltiplo do seu tamanho. Por isso o compilador insere bytes de enchimento entre campos:

C
#include <stdio.h>
#include <stddef.h>
 
struct Ruim  { char a; int b; char c; };   // 1 + 3 (padding) + 4 + 1 + 3 (padding) = 12
struct Boa   { int b; char a; char c; };   // 4 + 1 + 1 + 2 (padding) = 8
 
int main(void) {
    printf("Ruim: %zu bytes (b em %zu)\n", sizeof(struct Ruim), offsetof(struct Ruim, b));
    printf("Boa:  %zu bytes\n", sizeof(struct Boa));
    return 0;
}
Texto
Ruim: 12 bytes (b em 4)
Boa:  8 bytes

Os valores acima são os típicos de x86-64 e ARM64; o padrão da linguagem só garante que o primeiro campo começa no offset 0. Ordenar campos do maior para o menor costuma reduzir o desperdício. Existe __attribute__((packed)) (GCC/Clang) e #pragma pack para eliminar o padding, mas acessos desalinhados ficam mais lentos ou até inválidos em algumas CPUs; use só para formatos binários de rede ou arquivo.

union: vários tipos no mesmo espaço

Uma union reserva espaço para o maior membro, e todos compartilham os mesmos bytes. Só um membro é válido por vez; por isso o padrão é guardar ao lado uma etiqueta dizendo qual (a tagged union):

C
enum TipoValor { INTEIRO, REAL, TEXTO };
 
struct Valor {
    enum TipoValor tipo;
    union {
        long i;
        double d;
        const char *s;
    } como;
};
 
struct Valor v = {.tipo = REAL, .como.d = 3.14};

enum e typedef

enum cria constantes inteiras com nome. typedef cria apelidos para tipos e deixa o código mais legível:

C
typedef enum { VERMELHO, VERDE, AZUL } Cor;   // valores 0, 1, 2
 
typedef struct No {
    int valor;
    struct No *proximo;   // dentro da própria struct, use o nome com "struct"
} No;

Campos de bits

C
struct Flags {
    unsigned int ativo : 1;
    unsigned int admin : 1;
    unsigned int nivel : 4;   // 0 a 15
};

Economizam memória, mas a ordem dos bits na memória depende do compilador. Para protocolos e registradores de hardware, máscaras com operadores bit a bit são mais portáveis.

20. O pré-processador

Antes do compilador, o pré-processador faz substituições de texto em todas as linhas que começam com #.

#include

  • #include <stdio.h> procura nos diretórios do sistema.
  • #include "util.h" procura primeiro no diretório do arquivo atual.

#define: constantes e macros

C
#define TAMANHO 100
#define QUADRADO(x) ((x) * (x))

Macros são substituição textual, sem tipos. Duas armadilhas famosas:

C
#define DOBRO_ERRADO(x) x * 2
DOBRO_ERRADO(1 + 1)   // vira 1 + 1 * 2 = 3, não 4
 
#define QUADRADO(x) ((x) * (x))
QUADRADO(i++)         // vira ((i++) * (i++)): incrementa duas vezes, comportamento indefinido

Por isso: parênteses em cada parâmetro e no resultado, e nunca passe expressões com efeito colateral. Para constantes tipadas, prefira const, enum ou (C23) constexpr; para "macros função", prefira funções static inline.

Compilação condicional

C
#ifdef DEBUG
    fprintf(stderr, "x = %d\n", x);
#endif
 
#if defined(_WIN32)
    #define SEPARADOR '\\'
#else
    #define SEPARADOR '/'
#endif

Compile com gcc -DDEBUG para ativar o bloco de depuração.

Include guards

Evitam que um cabeçalho seja incluído duas vezes na mesma unidade de compilação:

util.h
C
#ifndef UTIL_H
#define UTIL_H
 
int soma(int a, int b);
 
#endif

#pragma once faz o mesmo em uma linha. Não é padrão ISO, mas GCC, Clang e MSVC suportam.

Macros predefinidas e assert

C
#include <assert.h>
#include <stdio.h>
 
void log_erro(const char *msg) {
    fprintf(stderr, "%s:%d (%s): %s\n", __FILE__, __LINE__, __func__, msg);
}
 
int divide(int a, int b) {
    assert(b != 0);   // aborta com mensagem se a condição for falsa
    return a / b;
}

assert some quando você compila com -DNDEBUG. Use-o para verificar suposições do programador, não para validar entrada do usuário.

21. Projetos com vários arquivos e Makefile

Programas reais são divididos em módulos: um .h com a interface (declarações) e um .c com a implementação.

matematica.h
C
#ifndef MATEMATICA_H
#define MATEMATICA_H
 
int fatorial(int n);
extern int chamadas;   // declaração: a variável existe em algum .c
 
#endif
matematica.c
C
#include "matematica.h"
 
int chamadas = 0;                 // definição da global
 
static int auxiliar(int n) {      // static: invisível fora deste arquivo
    return n <= 1 ? 1 : n * auxiliar(n - 1);
}
 
int fatorial(int n) {
    chamadas++;
    return auxiliar(n);
}
main.c
C
#include <stdio.h>
#include "matematica.h"
 
int main(void) {
    printf("5! = %d\n", fatorial(5));
    printf("chamadas: %d\n", chamadas);
    return 0;
}

Compilando e ligando

Bash
gcc -Wall -Wextra -c matematica.c    # gera matematica.o
gcc -Wall -Wextra -c main.c          # gera main.o
gcc matematica.o main.o -o app       # o linker junta os objetos

Se o linker não encontra a definição de algo declarado, você vê o famoso undefined reference to 'fatorial': o compilador aceitou a declaração, mas nenhum objeto trouxe a função. Causas comuns: esqueceu de compilar um .c, esqueceu uma biblioteca (-lm para math.h em algumas plataformas) ou o nome está diferente.

Makefile

O make recompila só o que mudou, comparando datas de modificação:

Makefile
Makefile
CC      = gcc
CFLAGS  = -std=c17 -Wall -Wextra -Wpedantic -g
LDLIBS  = -lm
OBJS    = main.o matematica.o
 
app: $(OBJS)
	$(CC) $(CFLAGS) $(OBJS) -o $@ $(LDLIBS)
 
%.o: %.c matematica.h
	$(CC) $(CFLAGS) -c $< -o $@
 
.PHONY: clean run
clean:
	rm -f app $(OBJS)
 
run: app
	./app
  • $@ é o alvo, $< é a primeira dependência.
  • As linhas de comando começam com TAB, não com espaços (o erro missing separator quase sempre é isso).
  • .PHONY marca alvos que não são arquivos.

Rode make, make run e make clean. Para projetos maiores e multiplataforma, o padrão da indústria é o CMake, que gera Makefiles ou projetos de IDE a partir de um CMakeLists.txt.

22. Arquivos

A stdio.h trata arquivos por meio de um FILE *:

ModoSignificado
"r"Leitura; o arquivo precisa existir
"w"Escrita; cria ou apaga o conteúdo existente
"a"Acrescenta no fim; cria se não existir
"r+"Leitura e escrita; precisa existir
"w+"Leitura e escrita; cria ou apaga
"rb", "wb"...Modo binário
C
#include <stdio.h>
#include <stdlib.h>
 
int main(void) {
    FILE *f = fopen("notas.txt", "w");
    if (f == NULL) {
        perror("fopen");          // ex.: "fopen: Permission denied"
        return EXIT_FAILURE;
    }
    fprintf(f, "Ana;9.5\nBruno;7.0\n");
    fclose(f);
 
    f = fopen("notas.txt", "r");
    if (f == NULL) {
        perror("fopen");
        return EXIT_FAILURE;
    }
 
    char nome[50];
    double nota;
    while (fscanf(f, "%49[^;];%lf\n", nome, &nota) == 2) {
        printf("%-10s %.1f\n", nome, nota);
    }
    fclose(f);
    return EXIT_SUCCESS;
}

Arquivos binários

C
typedef struct { int id; double saldo; } Conta;
 
Conta contas[2] = {{1, 100.0}, {2, 250.5}};
FILE *f = fopen("contas.bin", "wb");
if (f) {
    fwrite(contas, sizeof contas[0], 2, f);   // grava os bytes crus
    fclose(f);
}

fread/fwrite gravam a memória como ela é. É rápido, mas o arquivo depende do tamanho dos tipos, do padding e do endianness da máquina: para trocar dados entre sistemas, prefira formatos de texto ou serialização explícita.

Para se mover dentro do arquivo: fseek(f, 0, SEEK_END) vai para o fim, ftell(f) diz a posição atual e rewind(f) volta ao início.

23. Tratamento de erros

C não tem exceções. Os erros viajam por valores de retorno e pela variável global errno.

  • Funções que devolvem ponteiro sinalizam erro com NULL (fopen, malloc).
  • Funções que devolvem int costumam usar -1 ou um valor especial (EOF).
  • Muitas funções da biblioteca registram o motivo em errno (errno.h), e perror("contexto") ou strerror(errno) transformam o código em texto.
C
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
int main(void) {
    FILE *f = fopen("nao-existe.txt", "r");
    if (!f) {
        fprintf(stderr, "Erro ao abrir: %s (errno=%d)\n", strerror(errno), errno);
        return EXIT_FAILURE;
    }
    fclose(f);
    return EXIT_SUCCESS;
}

Convertendo texto em número com verificação completa:

C
#include <errno.h>
#include <limits.h>
#include <stdlib.h>
 
int le_int(const char *texto, int *saida) {
    char *fim;
    errno = 0;
    long valor = strtol(texto, &fim, 10);
 
    if (fim == texto || *fim != '\0') return -1;               // não era número
    if (errno == ERANGE || valor < INT_MIN || valor > INT_MAX) return -1;  // fora do intervalo
 
    *saida = (int)valor;
    return 0;
}

Boas práticas: confira todo retorno que pode falhar, libere recursos nos caminhos de erro (o padrão goto cleanup que vimos antes existe para isso) e devolva EXIT_SUCCESS ou EXIT_FAILURE do main, que o shell enxerga em $?.

24. Comportamento indefinido

O padrão C lista situações em que "o comportamento é indefinido": o programa pode fazer qualquer coisa, inclusive parecer funcionar. Existe porque permite ao compilador gerar código rápido para qualquer hardware sem inserir verificações.

O perigo é que os otimizadores assumem que UB nunca acontece e raciocinam a partir disso:

C
int cresce(int x) {
    return x + 1 > x;   // com -O2, o GCC costuma compilar como "return 1"
}

Como overflow de int com sinal é indefinido, o compilador conclui que x + 1 > x é sempre verdadeiro e remove a comparação. Um teste de overflow escrito assim simplesmente desaparece do binário.

Lista dos comportamentos indefinidos mais comuns:

  • Overflow de inteiro com sinal.
  • Acessar array fora dos limites.
  • Desreferenciar NULL, ponteiro não inicializado ou pendurado.
  • Usar memória depois do free, ou free duas vezes.
  • Ler variável local não inicializada.
  • Modificar um literal de string.
  • Modificar a mesma variável duas vezes sem ponto de sequência (i = i++ + 1).
  • Deslocar bits por um valor negativo ou maior ou igual à largura do tipo (1 << 32 em int de 32 bits).
  • Acessar um objeto por um ponteiro de tipo incompatível (violar o strict aliasing).

A defesa é a mesma de sempre: -Wall -Wextra, -fsanitize=address,undefined nos testes e código simples.

25. Depurando com GDB

Compile com símbolos de depuração e sem otimização:

Bash
gcc -g -O0 programa.c -o programa
gdb ./programa

Uma sessão típica:

Texto
(gdb) break main              ← pausa ao entrar em main
(gdb) run                     ← executa
(gdb) next                    ← próxima linha (não entra em funções)
(gdb) step                    ← próxima linha, entrando em funções
(gdb) print x                 ← mostra o valor de x
(gdb) print *p                ← mostra o valor apontado por p
(gdb) print v[0]@5            ← mostra 5 elementos a partir de v[0]
(gdb) info locals             ← todas as variáveis locais
(gdb) watch total             ← pausa sempre que total mudar
(gdb) backtrace               ← pilha de chamadas (onde estou?)
(gdb) finish                  ← roda até a função atual retornar
(gdb) continue                ← segue até o próximo breakpoint
(gdb) quit

O uso mais valioso é depois de um segmentation fault: rode o programa dentro do GDB, deixe travar e digite backtrace. Você vê exatamente qual função, em qual linha, estava executando.

No macOS, o depurador que acompanha o Xcode é o LLDB (comandos parecidos: breakpoint set, run, bt). No VS Code, a extensão C/C++ da Microsoft oferece depuração visual por cima do GDB ou do LLDB.

26. Projeto completo: agenda de contatos com lista ligada

Para juntar tudo (structs, ponteiros, malloc/free, strings, arquivos e erros), uma pequena agenda que guarda contatos numa lista ligada e persiste em arquivo.

Texto
 inicio
   │
   ▼
+--------------+    +--------------+    +--------------+
| "Ana"        |    | "Bruno"      |    | "Carla"      |
| "11 9999"    |    | "21 8888"    |    | "31 7777"    |
| proximo ─────┼──▶ | proximo ─────┼──▶ | proximo: NULL|
+--------------+    +--------------+    +--------------+
agenda.c
C
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
#define MAX_TEXTO 64
 
typedef struct Contato {
    char nome[MAX_TEXTO];
    char telefone[MAX_TEXTO];
    struct Contato *proximo;
} Contato;
 
/* Insere em ordem alfabética. Recebe Contato ** para poder mudar o início. */
int adicionar(Contato **inicio, const char *nome, const char *telefone) {
    Contato *novo = malloc(sizeof *novo);
    if (novo == NULL) return -1;
 
    snprintf(novo->nome, sizeof novo->nome, "%s", nome);
    snprintf(novo->telefone, sizeof novo->telefone, "%s", telefone);
 
    Contato **atual = inicio;
    while (*atual && strcmp((*atual)->nome, nome) < 0) {
        atual = &(*atual)->proximo;
    }
    novo->proximo = *atual;
    *atual = novo;
    return 0;
}
 
Contato *buscar(Contato *inicio, const char *nome) {
    for (Contato *c = inicio; c != NULL; c = c->proximo) {
        if (strcmp(c->nome, nome) == 0) return c;
    }
    return NULL;
}
 
int remover(Contato **inicio, const char *nome) {
    for (Contato **atual = inicio; *atual; atual = &(*atual)->proximo) {
        if (strcmp((*atual)->nome, nome) == 0) {
            Contato *alvo = *atual;
            *atual = alvo->proximo;
            free(alvo);
            return 0;
        }
    }
    return -1;
}
 
void listar(const Contato *inicio) {
    for (const Contato *c = inicio; c != NULL; c = c->proximo) {
        printf("%-20s %s\n", c->nome, c->telefone);
    }
}
 
void liberar(Contato *inicio) {
    while (inicio) {
        Contato *proximo = inicio->proximo;
        free(inicio);
        inicio = proximo;
    }
}
 
int salvar(const Contato *inicio, const char *caminho) {
    FILE *f = fopen(caminho, "w");
    if (!f) return -1;
    for (const Contato *c = inicio; c; c = c->proximo) {
        fprintf(f, "%s;%s\n", c->nome, c->telefone);
    }
    return fclose(f) == 0 ? 0 : -1;
}
 
int carregar(Contato **inicio, const char *caminho) {
    FILE *f = fopen(caminho, "r");
    if (!f) return -1;
 
    char linha[2 * MAX_TEXTO + 2];
    while (fgets(linha, sizeof linha, f)) {
        linha[strcspn(linha, "\r\n")] = '\0';     // remove a quebra de linha
        char *separador = strchr(linha, ';');
        if (!separador) continue;                  // linha inválida: ignora
        *separador = '\0';
        if (adicionar(inicio, linha, separador + 1) != 0) {
            fclose(f);
            return -1;
        }
    }
    fclose(f);
    return 0;
}
 
int main(void) {
    Contato *agenda = NULL;
 
    if (carregar(&agenda, "agenda.txt") != 0) {
        puts("(nenhuma agenda salva ainda)");
    }
 
    adicionar(&agenda, "Carla", "31 7777-0000");
    adicionar(&agenda, "Ana", "11 9999-0000");
    adicionar(&agenda, "Bruno", "21 8888-0000");
 
    puts("== Contatos ==");
    listar(agenda);
 
    Contato *c = buscar(agenda, "Bruno");
    if (c) printf("\nTelefone do Bruno: %s\n", c->telefone);
 
    remover(&agenda, "Carla");
    puts("\n== Depois de remover a Carla ==");
    listar(agenda);
 
    if (salvar(agenda, "agenda.txt") != 0) perror("salvar");
 
    liberar(agenda);
    return EXIT_SUCCESS;
}

Compile com todas as proteções e confira que não há vazamentos:

Bash
gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined agenda.c -o agenda
./agenda

Repare nos detalhes: Contato ** permite inserir no início da lista sem casos especiais, snprintf impede estouro nos campos, strcspn remove a quebra de linha lida pelo fgets, e toda memória alocada é liberada em remover ou liberar. Execute duas vezes e veja a agenda sendo recarregada do arquivo.

27. Boas práticas e C moderno

  • Compile sempre com avisos: -Wall -Wextra -Wpedantic, e trate avisos como erros em projetos seus (-Werror).
  • Inicialize tudo ao declarar. Um = 0 ou = NULL custa nada.
  • Use const em parâmetros que não serão alterados.
  • Use tipos de tamanho fixo (uint8_t, int32_t) quando o tamanho importar (protocolos, arquivos, hardware) e size_t para tamanhos e índices.
  • Confira todo retorno que pode falhar.
  • Um dono para cada bloco de memória: deixe claro quem aloca e quem libera.
  • Funções pequenas, com um propósito só.
  • Formate automaticamente com clang-format e analise com clang-tidy ou cppcheck.
  • Rode os sanitizers nos testes.

A linguagem continua evoluindo. Alguns marcos que valem conhecer:

PadrãoDestaques
C99Comentários //, stdint.h, bool via stdbool.h, declarações no meio do bloco e no for, inicializadores designados, snprintf
C11_Generic, _Static_assert, threads (threads.h, opcional), atômicos, gets removida
C17Correções do C11, sem recursos novos
C23bool, true, false, nullptr como palavras-chave, constexpr, typeof, literais binários 0b1010, separador de dígitos 1'000'000, atributos [[nodiscard]] e [[maybe_unused]], #embed

28. Cheat sheet completo de C

Salve esta seção: ela resume o guia inteiro em comandos e funções para consulta rápida.

Cheat sheet

Linguagem C: comandos e funções essenciais

GCC, depuração, memória, strings, bits e Make em um só lugar.

Compilação com GCC

Do código ao executável

ComandoDescrição
gcc main.c -o appCompila e gera o executável app
gcc -std=c17 main.cEscolhe o padrão da linguagem
gcc -c util.cSó compila: gera util.o
gcc main.o util.o -o appLiga objetos num executável
gcc -E main.cMostra o código após o pré-processador
gcc -S main.cGera o assembly main.s
gcc -save-temps main.cGuarda os arquivos intermediários
gcc -O2 main.cOtimiza para desempenho
gcc -Os main.cOtimiza para tamanho
gcc -I include main.cAdiciona diretório de cabeçalhos
gcc main.c -L lib -lfooLiga com libfoo de lib/
gcc main.c -lmLiga com a biblioteca matemática
gcc -DDEBUG main.cDefine a macro DEBUG
gcc --versionMostra a versão instalada

Avisos e depuração

Encontre erros antes do usuário

ComandoDescrição
-Wall -WextraLiga os avisos mais úteis
-WpedanticAvisa sobre extensões fora do padrão
-WerrorTransforma avisos em erros
-gInclui símbolos para o depurador
-O0Sem otimização (melhor para depurar)
-fsanitize=addressDetecta erros de memória em execução
-fsanitize=undefinedDetecta comportamento indefinido
-fno-omit-frame-pointerPilhas de chamadas mais legíveis
valgrind --leak-check=full ./appProcura vazamentos (Linux)

GDB

Execute passo a passo

ComandoDescrição
gdb ./appAbre o programa no depurador
break mainBreakpoint numa função
break arquivo.c:42Breakpoint numa linha
runInicia a execução
nextPróxima linha, sem entrar em funções
stepPróxima linha, entrando em funções
continueSegue até o próximo breakpoint
print xMostra o valor de uma expressão
print v[0]@10Mostra 10 elementos de um array
info localsLista as variáveis locais
watch xPausa quando x mudar
backtraceMostra a pilha de chamadas
finishRoda até a função retornar
quitSai do GDB

Especificadores do printf

Formatando a saída

ComandoDescrição
%d / %iint com sinal
%uunsigned int
%ld / %lldlong / long long
%zusize_t
%f / %.2fdouble (2 casas decimais)
%eNotação científica
%cUm caractere
%sString terminada em '\0'
%pEndereço (converta para void *)
%x / %XHexadecimal
%oOctal
%5d / %-5dLargura 5, à direita / à esquerda
%%O próprio símbolo %

stdio.h

Entrada, saída e arquivos

ComandoDescrição
printf(fmt, ...)Escreve formatado na saída padrão
fprintf(stderr, ...)Escreve na saída de erro
snprintf(buf, n, ...)Escreve num buffer com limite
fgets(buf, n, stdin)Lê uma linha com limite
scanf("%d", &x)Lê formatado (confira o retorno)
puts(s) / putchar(c)Escreve string / caractere
getchar()Lê um caractere
fopen(caminho, "r")Abre arquivo (NULL em erro)
fclose(f)Fecha o arquivo
fread / fwriteLê / grava blocos binários
fseek / ftell / rewindMove e consulta a posição
perror("contexto")Imprime o erro de errno

string.h

Strings e blocos de memória

ComandoDescrição
strlen(s)Tamanho sem o '\0'
strcmp(a, b)Compara (0 se iguais)
strncmp(a, b, n)Compara até n caracteres
strcpy(d, s)Copia (sem checar tamanho)
strcat(d, s)Concatena (sem checar tamanho)
strchr(s, c)Primeira ocorrência de um caractere
strrchr(s, c)Última ocorrência de um caractere
strstr(s, sub)Primeira ocorrência de uma substring
strcspn(s, "\r\n")Posição do primeiro caractere da lista
memcpy(d, s, n)Copia n bytes sem sobreposição
memmove(d, s, n)Copia n bytes com sobreposição
memset(p, 0, n)Preenche n bytes com um valor
memcmp(a, b, n)Compara n bytes

stdlib.h

Memória, conversões e utilidades

ComandoDescrição
malloc(n)Aloca n bytes não inicializados
calloc(qtd, tam)Aloca e zera
realloc(p, n)Redimensiona (use variável temporária)
free(p)Libera memória do heap
strtol(s, &fim, 10)Texto para long com verificação
strtod(s, &fim)Texto para double
atoi(s)Texto para int sem detectar erros
qsort(v, n, tam, cmp)Ordena um array
bsearch(&k, v, n, tam, cmp)Busca binária em array ordenado
rand() / srand(seed)Números pseudoaleatórios
abs(x)Valor absoluto de int
exit(EXIT_FAILURE)Encerra o programa com código
getenv("PATH")Lê variável de ambiente

Ponteiros e memória

Os operadores que mais importam

ComandoDescrição
&xEndereço de x
*pValor no endereço guardado em p
p->campoCampo de struct via ponteiro
p + 1Avança um elemento (não um byte)
p[i]O mesmo que *(p + i)
int **ppPonteiro para ponteiro
const int *pNão altera o valor apontado
int *const pNão altera o ponteiro
void *Ponteiro genérico
sizeof *pTamanho do tipo apontado
p = NULLMarca como "não aponta para nada"

Operações com bits

Máscaras e flags

ComandoDescrição
x | (1u << n)Liga o bit n
x & ~(1u << n)Desliga o bit n
x ^ (1u << n)Inverte o bit n
(x >> n) & 1uLê o bit n
x & (x - 1)Apaga o bit 1 mais baixo
x & -xIsola o bit 1 mais baixo
(x & (x - 1)) == 0Testa potência de 2 (para x > 0)
x << nMultiplica por 2 elevado a n (sem overflow)
x >> nDivide por 2 elevado a n (sem sinal)

Tipos e tamanhos típicos

x86-64 Linux/macOS (Windows difere em long)

ComandoDescrição
char1 byte (sinal depende da plataforma)
short2 bytes
int4 bytes
long8 bytes (4 no Windows 64 bits)
long long8 bytes
float / double4 / 8 bytes
size_t8 bytes, sem sinal
void *8 bytes em 64 bits
int32_t / uint8_tTamanho exato garantido
INT_MAXMaior int (em limits.h)

Make

Automatize a compilação

ComandoDescrição
makeCompila o primeiro alvo do Makefile
make appCompila um alvo específico
make cleanRoda o alvo de limpeza
make -j8Compila em paralelo com 8 tarefas
make -nMostra os comandos sem executá-los
make -BRecompila tudo
$@ / $< / $^Alvo / primeira dependência / todas

Referências e leituras recomendadas

Teste seus conhecimentos
1 / 7

7 perguntas sobre o que você acabou de ler.

Quantos valores diferentes cabem em 8 bits?

Não foi dessa vez. Explicação:

2 elevado a 8 = 256 combinações, de 0 a 255 sem sinal.