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
.caté 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/freee 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:
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:
- 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:
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árioA 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:
┌──────────────────────── 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,rsper8ar15). Na ARM64 há 31 (x0ax30), 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
ifdo 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.
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:
- Buscar (fetch): ler, na memória, os bytes da instrução que está no endereço apontado pelo PC.
- Decodificar (decode): interpretar esses bytes, qual operação, quais registradores, qual endereço.
- Executar (execute): a ULA realiza a operação (ou calcula um endereço).
- Acessar a memória: se a instrução for de leitura ou escrita na memória.
- 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:
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 PCOs 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.
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 GRAVAEsse é 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.
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:
#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:
linha a linha: 0.004 s
coluna a coluna: 0.053 s
soma = 137405399040As 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:
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:
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.
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:
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.
Em C, os prefixos dizem a base de um literal. Os quatro valores abaixo são o mesmo número:
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:
#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;
}decimal: 42
hexa: 2a (ou 0X2A)
octal: 52
binário: 0010 1010
42 42 42Inteiros 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.
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:
5 em binário .......... 0000 0101
inverte os bits ....... 1111 1010
soma 1 ................ 1111 1011 ← este é o -5Confira 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):
0000 0101 (+5)
+ 1111 1011 (-5)
───────────
1 0000 0000 → o 9º bit é descartado: resultado 0A 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:
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:
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:
#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;
}s = -5, u = 251
255 + 1 em unsigned char = 0Overflow: 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ódulo2^n:UINT_MAX + 1uvale 0, e0u - 1uvaleUINT_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:
#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;
}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:
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:
- Parte inteira: 5 =
101. Parte fracionária: 0,75 = 0,5 + 0,25 =0,11em binário. Logo 5,75 =101,11₂. - Normalize (mova a vírgula até sobrar um único 1 à esquerda):
101,11₂ = 1,0111₂ × 2². - Sinal: positivo →
0. - Expoente: 2 + 127 (viés) = 129 =
1000 0001. - Mantissa: os bits depois da vírgula,
0111, completados com zeros até 23 bits.
sinal expoente mantissa
0 10000001 01110000000000000000000 = 0x40B80000Você 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):
#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;
}bits = 0x40B80000
sinal = 0
expoente = 129 (real: 2)
mantissa = 0x380000Por 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:
#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;
}0.30000000000000004
a == 0.3? não
quase igual? simRegras 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/doublepara dinheiro. Guarde centavos em um inteiro (long long centavos). - Prefira
doubleafloat, a menos que memória ou vazão sejam críticas (gráficos, aprendizado de máquina, microcontroladores sem unidade dedouble). - O IEEE 754 tem valores especiais:
+infe-inf(por exemplo,1.0 / 0.0),NaN(not a number, por exemplo0.0 / 0.0ousqrt(-1.0)) e até-0.0. UmNaNé 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:
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):
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:
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:
#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;
}strlen = 4
4F 6C C3 A1Endianness: 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:
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):
#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;
}byte 0: 78
byte 1: 56
byte 2: 34
byte 3: 12
little-endianA 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:
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 0x7ffc1004Repare 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.
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:
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 baixosUm 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):
#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):
rodata (literal) : 0x55d4a6a9c004
data (global_iniciada): 0x55d4a6a9e010
bss (global_zerada) : 0x55d4a6a9e01c
heap (malloc) : 0x55d4a7e5c2a0
pilha (local) : 0x7ffc8a1e3b54Os 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
rbpde 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.
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 baixosDuas consequências fundamentais:
- Alocar na pilha é praticamente gratuito: basta mover o
rsp. Por isso variáveis locais são tão rápidas. - 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).
nome endereço conteúdo
x 0x7ffc1000 42 (int, 4 bytes)
p 0x7ffc1008 0x7ffc1000 (int *, 8 bytes) ──▶ aponta para xNo 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)
Instale o MSYS2
Baixe o instalador em msys2.org e execute-o, mantendo a pasta padrão
C:\msys64.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.
Atualize os pacotes
pacman -SyuSe o terminal fechar sozinho durante a atualização, abra-o de novo e rode o comando mais uma vez.
Instale o GCC e as ferramentas
Para instalar só o compilador:
pacman -S mingw-w64-ucrt-x86_64-gccOu, para instalar o conjunto completo (GCC, GDB,
makee utilitários):pacman -S --needed base-devel mingw-w64-ucrt-x86_64-toolchainAdicione o compilador ao PATH
Para usar o
gccno PowerShell ou no terminal do VS Code, adicioneC:\msys64\ucrt64\binà variávelPath: menu Iniciar → "Editar as variáveis de ambiente do sistema" → Variáveis de Ambiente → selecionePath(do usuário) → Editar → Novo → cole o caminho → OK. Feche e reabra os terminais.Confira
gcc --version
Opção B: WSL (Linux dentro do Windows)
Abra o PowerShell como administrador e rode:
wsl --installReinicie 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:
sudo apt update
sudo apt install build-essential gdb valgrindO 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).
Instale as Command Line Tools da Apple
xcode-select --installUma janela vai pedir confirmação. Esse pacote instala compilador,
make,git, o depuradorlldbe os cabeçalhos do sistema, sem precisar do Xcode completo.Entenda o que é o gcc do macOS
gcc --versionA saída começa com
Apple clang version: no macOS, o comandogccé, na verdade, o Clang da Apple. Para aprender C, isso não é problema: ele aceita as mesmas flags usadas neste guia (-std=c17,-Wall,-g,-O2,-fsanitize=address...).(Opcional) Instale o GCC de verdade com o Homebrew
Com o Homebrew instalado:
brew install gcc ls "$(brew --prefix)"/bin/gcc-*O GCC do Homebrew é instalado com o número da versão no nome, como
gcc-15(o número depende da versão atual da fórmula; olsacima mostra o nome exato). O comandogccsem sufixo continua sendo o Clang da Apple. Use, por exemplo,gcc-15 -std=c17 -Wall ola.c -o ola.
Para depurar no macOS, use o LLDB (já instalado com as Command Line Tools). O GDB não funciona nos Macs com Apple Silicon.
A maioria das distribuições já traz o GCC ou o instala com um comando.
Debian, Ubuntu, Linux Mint e derivados, o pacote build-essential traz gcc, g++, make e os cabeçalhos da biblioteca C:
sudo apt update
sudo apt install build-essential gdb valgrindFedora:
sudo dnf install gcc make gdb valgrindArch Linux, Manjaro e derivados, o grupo base-devel inclui gcc e make:
sudo pacman -S --needed base-devel gdb valgrindDepois, confira com gcc --version.
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):
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):
#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çalhostdio.h(standard input/output), que declara funções de entrada e saída comoprintf. Sem essa declaração, o compilador não saberia queprintfexiste nem que argumentos ela recebe.int main(void), a funçãomainé o ponto de entrada: é por ela que seu código começa a executar. Ointantes do nome diz que ela devolve um número inteiro para o sistema operacional. Ovoidentre 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çãoprintfpassando 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;, encerramaine devolve 0 ao sistema operacional, que por convenção significa "tudo certo". Qualquer outro valor indica erro. (Desde o C99, chegar ao fim demainsemreturnequivale areturn 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:
gcc -std=c17 -Wall -Wextra -o ola.exe ola.c
.\ola.exeNo PowerShell é preciso o prefixo .\ para executar um programa da pasta atual. No Prompt de Comando (cmd), basta digitar ola.
No Terminal, dentro da pasta do arquivo:
gcc -std=c17 -Wall -Wextra -o ola ola.c
./olaO ./ diz "o programa ola que está nesta pasta". Por segurança, a pasta atual não fica no PATH em sistemas Unix.
No terminal, dentro da pasta do arquivo:
gcc -std=c17 -Wall -Wextra -o ola ola.c
./olaO ./ diz "o programa ola que está nesta pasta". Por segurança, a pasta atual não fica no PATH em sistemas Unix.
A saída esperada:
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:
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:
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:
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ávelOu, 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:
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:
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):
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 eaxComo 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)copiaedipara a memória. - O sufixo diz o tamanho:
l= 32 bits (long word),q= 64 bits (quad word). -4(%rbp)significa "o endereçorbp − 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,r8er9, e o valor de retorno volta emrax(eaxé a metade de 32 bits derax). O Windows usa outra convenção:rcx,rdx,r8er9.
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:
soma:
leal (%rdi,%rsi), %eax # eax = rdi + rsi, em uma instrução
retA 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:
nm ola.o0000000000000000 T main
U putsT 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:
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:
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.
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:
gcc -std=c17 -Wall -Wextra -Wpedantic -g -fsanitize=address,undefined programa.c -o programaNo Windows com MSYS2 (onde os sanitizers não estão disponíveis no GCC):
gcc -std=c17 -Wall -Wextra -Wpedantic -g programa.c -o programa.exePara a versão final, troque -g e os sanitizers por otimização:
gcc -std=c17 -O2 -Wall -Wextra programa.c -o programaTrate 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:
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:
#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):
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: -128char 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 charouuint8_t. - Ao passar um
charpara as funções de<ctype.h>(isalpha,toupper...), converta paraunsigned char:isalpha((unsigned char)c). Passar umcharnegativo (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:
Para imprimir esses tipos de forma portável, <inttypes.h> oferece macros com o especificador certo para cada plataforma:
#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;
}população: 8200000000
byte: 255
máscara: 0xFF00FF00O 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 xousizeof(x). - O valor é calculado em tempo de compilação; a expressão nem é executada (
sizeof(i++)não incrementai).
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:
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.
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:
As sequências de escape mais usadas dentro de literais de caractere e de string:
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:
- Promoção de inteiros: tipos menores que
int(char,short,bool) viramintantes de qualquer conta. - 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.
- 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):
#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;
}7 / 2 = 3.0
7 / 2.0 = 3.5
soma = 300, truncado = 44
-1 > 1u é verdadeiro!
strlen("") - 1 = 18446744073709551615A 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:
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
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:
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 ax, e a condição fica sempre verdadeira.- Comparações encadeadas:
0 < x < 10não significa o que significa na matemática. É avaliado como(0 < x) < 10, ou seja,0 < 10ou1 < 10: sempre verdadeiro. Escreva0 < 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):
Deslocar n posições para a esquerda multiplica por 2^n; para a direita, divide (para valores sem sinal):
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 << 32comxde 32 bits) é comportamento indefinido. - Deslocar um valor com sinal para a esquerda de forma que o resultado não caiba (como
1 << 31comintde 32 bits) também é. Escreva1u << 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:
O uso mais comum é guardar várias opções liga/desliga (flags) em um único inteiro, exatamente como as permissões rwx do Unix:
#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;
}rw-
rwx
r-x
--x
valor final: 1Incremento e decremento: pré vs pós
++ soma 1 e -- subtrai 1, em duas formas:
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 += 3equivale ax = 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 doif. 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:
As pegadinhas mais comuns:
x & MASCARA == 0é lido comox & (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:
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:
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:
Com largura e alinhamento dá para montar tabelas bem alinhadas:
#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;
}Produto Qtd Total
Coxinha 2 9.00
Pastel 3 21.75
Suco 1 9.00Um 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):
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:
- 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). - Não conferir o retorno:
scanfdevolve quantos itens conseguiu converter (ouEOFse a entrada acabou). Se o usuário digitaabconde se esperava um número, nada é lido, o texto continua no buffer e um loop que chamascanfde novo fica girando para sempre. %ssem limite:scanf("%s", nome)escreve quantos bytes o usuário digitar, estourando o array. Sempre informe a largura:scanf("%49s", nome)para umchar nome[50]. Além disso,%spara no primeiro espaço.- O
\nque sobra: depois descanf("%d", ...), o Enter digitado continua no buffer. Umscanf("%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. %lfparadouble: noscanf,%fé parafloate%lfparadouble(noprintf,%fserve 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:
#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;
}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 umint, não umchar: além dos 256 valores possíveis de um byte, ela precisa conseguir devolverEOF(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.
#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
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:
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
caseprecisam ser constantes inteiras conhecidas em tempo de compilação. Não háswitchcom strings em C. - Sem
break, a execução cai (fall through) para ocaseseguinte. Isso às vezes é o que queremos, mas quase sempre é um bug. defaulttrata 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]];):
#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;
}nível 1: leitor
nível 2: editor leitor
nível 3: admin editor leitorwhile
O while testa a condição antes de cada repetição; se ela começar falsa, o corpo não executa nenhuma vez:
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:
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:
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:
#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;
} 1 2 3 4 5
2 4 6 8 10
3 6 9 12 15
4 8 12 16 20
5 10 15 20 25Sã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
breaksai imediatamente do loop (ou doswitch) mais interno.continuepula o resto do corpo e vai para a próxima repetição (nofor, 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:
#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;
}pares: 3, soma: 28, média: 4.00
primeiro negativo no índice 2, maior: 12O padrão sentinela repete até aparecer um valor especial que marca o fim. Por exemplo, somar números até o usuário digitar 0:
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
unsigneddecrescente: emfor (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ão1.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.
#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:
#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;
}12.57Em 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:
#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;
}dentro: 20
fora: 10Para 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:
#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;
}./args ola "mundo grande" 42argc = 4
argv[0] = ./args
argv[1] = ola
argv[2] = mundo grande
argv[3] = 42argc (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:
#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;
}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):
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:
- Argumentos: quem chama coloca
2emedie3emesi(argumentos extras vão para a pilha). call soma: a CPU empilha o endereço de retorno (o da instrução seguinte aocall) e salta parasoma, trocando o valor derip.- Prólogo:
push rbpemov rbp, rspsalvam o frame anterior e marcam o início do novo; umsub rsp, Nreserva espaço para as variáveis locais, quando há. - Corpo: a função calcula e deixa o resultado em
eax/rax. - Epílogo e
ret: o frame é desfeito (pop rbpouleave) eretdesempilha o endereço de retorno pararip. A execução continua logo após ocall, e quem chamou lê o resultado emeax.
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
staticem 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 comostatic.)inlinesugere ao compilador que substitua a chamada pelo corpo da função. Compiladores modernos decidem sozinhos quando isso compensa. As regras deinlineem C são diferentes das de C++; a forma mais simples e segura é usarstatic inlinepara 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>:
#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;
}60
15A 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:
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:
#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;
}id 1
id 2
id 3Repare 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.
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.
int notas[5] = {7, 9, 6, 10, 8};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
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: 3O 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.
#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.
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.
char nome[] = "Ana"; // 4 bytes: 'A' 'n' 'a' '\0' +-----+-----+-----+------+
nome: | 'A' | 'n' | 'a' | '\0' |
+-----+-----+-----+------+
bytes: 65 110 97 0Literal é somente leitura
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'; // okDeclare ponteiros para literais como const char * e o compilador impedirá o erro.
Funções essenciais de string.h
snprintf: a forma segura de montar strings
#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). Usefgets. - Sempre passe e confira o tamanho do buffer.
- Prefira
snprintfastrcpy/strcat/sprintf. - Compile com
-Wall -Wextrae 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 *
#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;
} idade (int, 4 bytes) p (int *, 8 bytes)
+----------------------+ +----------------------+
| 30 | ◀───── | 0x7ffd5e8c1a4c |
+----------------------+ +----------------------+
endereço 0x7ffd5e8c1a4c endereço 0x7ffd5e8c1a50&xsignifica endereço dex.*p, numa expressão, significa o valor no endereço guardado emp(desreferenciar).- Na declaração,
int *psignifica "p é um ponteiro para int". O asterisco pertence à variável:int *a, b;declara um ponteiroae umintb.
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:
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 byteSubtrair 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:
#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
int valor = 5;
int *p = &valor;
int **pp = &p;
**pp = 9; // valor agora é 9Aparece 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:
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:
#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.
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.
As quatro funções (stdlib.h)
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:
- Sempre confira se o retorno é NULL. A alocação pode falhar.
- Todo
malloctem exatamente umfree. Nem zero (vazamento), nem dois (double free). - Depois do
free, não toque mais no bloco. AtribuirNULLao ponteiro ajuda a evitar acidentes.
Exemplo: um array que cresce sozinho
#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
Duas ferramentas encontram quase tudo isso:
# 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 ./programaO 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
#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
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:
#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;
}Ruim: 12 bytes (b em 4)
Boa: 8 bytesOs 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):
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:
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
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
#define TAMANHO 100
#define QUADRADO(x) ((x) * (x))Macros são substituição textual, sem tipos. Duas armadilhas famosas:
#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 indefinidoPor 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
#ifdef DEBUG
fprintf(stderr, "x = %d\n", x);
#endif
#if defined(_WIN32)
#define SEPARADOR '\\'
#else
#define SEPARADOR '/'
#endifCompile 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:
#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
#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.
#ifndef MATEMATICA_H
#define MATEMATICA_H
int fatorial(int n);
extern int chamadas; // declaração: a variável existe em algum .c
#endif#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);
}#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
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 objetosSe 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:
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 separatorquase sempre é isso). .PHONYmarca 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 *:
#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, ¬a) == 2) {
printf("%-10s %.1f\n", nome, nota);
}
fclose(f);
return EXIT_SUCCESS;
}Arquivos binários
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
intcostumam usar-1ou um valor especial (EOF). - Muitas funções da biblioteca registram o motivo em
errno(errno.h), eperror("contexto")oustrerror(errno)transformam o código em texto.
#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:
#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:
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, oufreeduas 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 << 32emintde 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:
gcc -g -O0 programa.c -o programa
gdb ./programaUma sessão típica:
(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) quitO 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.
inicio
│
▼
+--------------+ +--------------+ +--------------+
| "Ana" | | "Bruno" | | "Carla" |
| "11 9999" | | "21 8888" | | "31 7777" |
| proximo ─────┼──▶ | proximo ─────┼──▶ | proximo: NULL|
+--------------+ +--------------+ +--------------+#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:
gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined agenda.c -o agenda
./agendaRepare 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
= 0ou= NULLcusta nada. - Use
constem parâmetros que não serão alterados. - Use tipos de tamanho fixo (
uint8_t,int32_t) quando o tamanho importar (protocolos, arquivos, hardware) esize_tpara 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-formate analise comclang-tidyoucppcheck. - Rode os sanitizers nos testes.
A linguagem continua evoluindo. Alguns marcos que valem conhecer:
28. Cheat sheet completo de C
Salve esta seção: ela resume o guia inteiro em comandos e funções para consulta rápida.
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
Avisos e depuração
Encontre erros antes do usuário
GDB
Execute passo a passo
Especificadores do printf
Formatando a saída
stdio.h
Entrada, saída e arquivos
string.h
Strings e blocos de memória
stdlib.h
Memória, conversões e utilidades
Ponteiros e memória
Os operadores que mais importam
Operações com bits
Máscaras e flags
Tipos e tamanhos típicos
x86-64 Linux/macOS (Windows difere em long)
Make
Automatize a compilação
Referências e leituras recomendadas
- cppreference: referência da linguagem C: a referência mais usada para a linguagem e a biblioteca padrão.
- Documentação oficial do GCC: manual completo do compilador, incluindo todas as flags.
- WG14, comitê que mantém o padrão C: rascunhos públicos dos padrões C11, C17 e C23.
- MSYS2: como instalar o GCC no Windows.
- Documentação do WSL (Microsoft): Linux dentro do Windows.
- Beej's Guide to C Programming: guia gratuito, didático e bem-humorado.
- Modern C, de Jens Gustedt: livro gratuito sobre C moderno.
- Computer Systems: A Programmer's Perspective: o livro clássico sobre como o hardware executa seus programas.
- Nand2Tetris: construa um computador do zero, das portas lógicas ao sistema operacional.
- Compiler Explorer: veja o assembly gerado pelo seu código em tempo real.
- Manual do GDB: referência completa do depurador.
- Valgrind: guia rápido para caçar erros de memória.
- AddressSanitizer: documentação do sanitizer de memória.
- GNU Make Manual: tudo sobre Makefiles.
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.



