Arquivos binários
Nesta aula, você aprenderá a trabalhar com arquivos binários em C, usando as funções fread e fwrite para leitura e escrita de dados em formato binário. Também exploraremos o posicionamento em arquivos com fseek e ftell, o armazenamento de estruturas em arquivos binários e questões de portabilidade que envolvem o uso de binários.
Até agora, você provavelmente lidou com arquivos de texto em C, usando funções como fprintf e fscanf. Mas muitos programas precisam armazenar dados de forma mais compacta e eficiente, preservando exatamente a representação binária das variáveis. É aí que entram os arquivos binários. Eles são essenciais para trabalhar com dados não textuais, como imagens, áudio, ou até mesmo para serializar estruturas complexas de forma direta. Nesta aula, vamos mergulhar no mundo dos arquivos binários: como escrever e ler dados com fwrite e fread, como navegar pelo arquivo com fseek e ftell, como gravar e recuperar estruturas inteiras e, por fim, discutir a portabilidade desses arquivos entre diferentes sistemas.
Vamos começar entendendo a diferença fundamental entre arquivos de texto e binários. Em arquivos de texto, os dados são convertidos em caracteres legíveis e há certas transformações, como a conversão de quebras de linha. Em arquivos binários, os dados são gravados exatamente como estão na memória, byte a byte. Isso torna a leitura e escrita muito mais rápidas e o arquivo resultante geralmente menor. No entanto, essa praticidade vem com o custo da portabilidade: um arquivo binário gerado em uma máquina pode não ser compreendido em outra se houver diferenças na representação de tipos, como o tamanho de um int ou a ordem dos bytes (endianness). Vamos explorar esses detalhes ao longo da aula.
fread e fwrite
As funções fread e fwrite são as principais ferramentas para leitura e escrita de dados binários em C. Elas são declaradas em <stdio.h> e têm as seguintes assinaturas:
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
Ambas retornam o número de elementos lidos ou escritos com sucesso. O parâmetro ptr é um ponteiro para a área de memória de onde os dados serão lidos (em fread) ou escritos (em fwrite). size é o tamanho em bytes de cada elemento, e nmemb é o número de elementos. O stream é o ponteiro para o arquivo aberto. Um ponto importante: para usar essas funções, o arquivo deve ser aberto em modo binário, adicionando a letra 'b' ao modo de abertura, por exemplo, "rb" para leitura binária e "wb" para escrita binária. Em sistemas Unix, essa distinção é ignorada, mas em Windows é crucial para evitar que o sistema faça conversões de caracteres.
Vamos ver um exemplo simples: gravar um vetor de inteiros em um arquivo binário e depois lê-lo de volta.
#include <stdio.h>
#include <stdlib.h>
int main() {
int dados[] = {10, 20, 30, 40, 50};
int num_elementos = 5;
FILE *arquivo = fopen("dados.bin", "wb");
if (arquivo == NULL) {
perror("Erro ao abrir arquivo");
return 1;
}
// Escreve o vetor inteiro de uma vez
size_t escritos = fwrite(dados, sizeof(int), num_elementos, arquivo);
if (escritos != num_elementos) {
fprintf(stderr, "Erro ao escrever dados\n");
fclose(arquivo);
return 1;
}
fclose(arquivo);
// Agora vamos ler de volta
arquivo = fopen("dados.bin", "rb");
if (arquivo == NULL) {
perror("Erro ao abrir arquivo");
return 1;
}
int buffer[5];
size_t lidos = fread(buffer, sizeof(int), 5, arquivo);
if (lidos != 5) {
fprintf(stderr, "Erro ao ler dados\n");
fclose(arquivo);
return 1;
}
fclose(arquivo);
// Imprime para conferir
for (int i = 0; i < 5; i++) {
printf("%d ", buffer[i]);
}
printf("\n");
return 0;
}
Note que usamos sizeof(int) como o tamanho de cada elemento. Isso garante que estamos gravando o número exato de bytes que um int ocupa na memória. Se você estiver gravando um único valor, pode usar nmemb = 1. Uma boa prática é sempre verificar o retorno de fread e fwrite para garantir que a operação foi bem-sucedida. Em caso de erro, você pode usar ferror para verificar se houve um erro de leitura/escrita.
Posicionamento (fseek, ftell)
Em arquivos binários, é comum precisar acessar dados em posições específicas, como ler o 3º registro de um arquivo de estruturas. Para isso, usamos fseek e ftell. A função fseek permite mover o ponteiro de posição do arquivo para um local específico, enquanto ftell retorna a posição atual desse ponteiro, em bytes a partir do início do arquivo.
As assinaturas são:
int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
O parâmetro whence define a referência a partir da qual o deslocamento é aplicado. Pode ser SEEK_SET (início do arquivo), SEEK_CUR (posição atual) ou SEEK_END (fim do arquivo). O offset é o número de bytes a deslocar. Por exemplo, para ir para o início do arquivo, usamos fseek(arquivo, 0, SEEK_SET); para ir para o final, fseek(arquivo, 0, SEEK_END). Para pular 10 bytes a partir da posição atual, fseek(arquivo, 10, SEEK_CUR).
Um uso comum é ler um registro específico de um arquivo de estruturas. Suponha que você tenha um arquivo com 100 registros de Pessoa e queira ler o registro de índice 42. Você pode calcular o deslocamento como 42 * sizeof(Pessoa) e usar fseek para ir até lá. Veja um exemplo:
#include <stdio.h>
#include <string.h>
typedef struct {
char nome[50];
int idade;
} Pessoa;
int main() {
FILE *arquivo = fopen("pessoas.bin", "rb");
if (arquivo == NULL) {
perror("Erro ao abrir");
return 1;
}
// Pega o tamanho do arquivo
fseek(arquivo, 0, SEEK_END);
long tamanho = ftell(arquivo);
printf("Tamanho do arquivo: %ld bytes\n", tamanho);
int indice = 42;
if (indice * (long)sizeof(Pessoa) >= tamanho) {
fprintf(stderr, "Índice fora dos limites\n");
fclose(arquivo);
return 1;
}
// Posiciona no início do registro desejado
fseek(arquivo, indice * sizeof(Pessoa), SEEK_SET);
Pessoa p;
size_t lidos = fread(&p, sizeof(Pessoa), 1, arquivo);
if (lidos != 1) {
fprintf(stderr, "Erro ao ler registro\n");
fclose(arquivo);
return 1;
}
printf("Nome: %s, Idade: %d\n", p.nome, p.idade);
fclose(arquivo);
return 0;
}
Note que usamos ftell para obter o tamanho do arquivo, o que é útil para validar se o índice é válido. Além disso, fseek retorna 0 em caso de sucesso e -1 em erro. Verificar esse retorno é uma boa prática.
Estruturas em arquivo
Gravar estruturas em arquivos binários é uma das aplicações mais comuns de fwrite e fread. Isso permite salvar coleções de dados estruturados de forma eficiente e fácil de carregar. A ideia é gravar a estrutura inteira de uma só vez, usando sizeof(estrutura) como tamanho.
Porém, é preciso ter cuidado com o alinhamento e os padding bytes que o compilador pode inserir entre os campos da estrutura. Por exemplo, em uma estrutura com um char e um int, o compilador pode adicionar bytes de preenchimento para alinhar o int em um endereço múltiplo de 4. Isso significa que o tamanho real da estrutura pode ser maior do que a soma dos tamanhos dos campos. Isso é importante para a portabilidade e para o tamanho do arquivo.
Vamos ver um exemplo de gravação e leitura de um vetor de estruturas:
#include <stdio.h>
#include <string.h>
typedef struct {
char nome[30];
int idade;
float altura;
} Aluno;
int main() {
Aluno alunos[3] = {
{"Ana", 20, 1.65},
{"Bruno", 22, 1.80},
{"Carla", 19, 1.70}
};
FILE *arquivo = fopen("alunos.bin", "wb");
if (arquivo == NULL) {
perror("Erro ao abrir");
return 1;
}
size_t escritos = fwrite(alunos, sizeof(Aluno), 3, arquivo);
if (escritos != 3) {
fprintf(stderr, "Erro ao gravar alunos\n");
fclose(arquivo);
return 1;
}
fclose(arquivo);
// Leitura
arquivo = fopen("alunos.bin", "rb");
if (arquivo == NULL) {
perror("Erro ao abrir");
return 1;
}
Aluno buffer[3];
size_t lidos = fread(buffer, sizeof(Aluno), 3, arquivo);
if (lidos != 3) {
fprintf(stderr, "Erro ao ler alunos\n");
fclose(arquivo);
return 1;
}
for (int i = 0; i < 3; i++) {
printf("Aluno %d: %s, %d anos, %.2f m\n", i+1, buffer[i].nome, buffer[i].idade, buffer[i].altura);
}
fclose(arquivo);
return 0;
}
Uma boa prática é usar sizeof(Aluno) em vez de calcular manualmente, pois o compilador já considera o alinhamento. Se você precisar garantir que não haja padding, pode usar diretivas de compilação como #pragma pack ou atributos __attribute__((packed)) em GCC, mas isso pode afetar o desempenho em algumas arquiteturas. Em geral, para arquivos que serão lidos pelo mesmo programa, o padding é aceitável, mas se você for trocar dados com outros sistemas, é melhor serializar campo a campo ou usar uma biblioteca de serialização.
Portabilidade
Arquivos binários são inerentemente menos portáveis do que arquivos de texto, porque dependem da representação interna dos dados. Dois fatores principais afetam a portabilidade: o tamanho dos tipos de dados e a ordem dos bytes (endianness).
O tamanho de tipos como int, long e float pode variar entre plataformas. Por exemplo, em sistemas de 32 bits, int geralmente tem 4 bytes, mas em alguns sistemas embarcados pode ter 2 bytes. Para minimizar problemas, você pode usar tipos de tamanho fixo da biblioteca <stdint.h>, como int32_t, uint16_t etc. Esses tipos garantem o mesmo tamanho em qualquer plataforma que os suporte.
A ordem dos bytes (endianness) é outra questão. Processadores diferentes armazenam números inteiros em ordem de bytes diferente: os big-endian armazenam o byte mais significativo primeiro, enquanto os little-endian armazenam o menos significativo primeiro. Isso significa que um arquivo gravado em uma máquina little-endian pode ser lido de forma incorreta em uma máquina big-endian. Para resolver isso, você pode converter explicitamente os dados para uma ordem padronizada, como a ordem de rede (big-endian) usando funções como htonl e ntohl (disponíveis em <arpa/inet.h> em sistemas Unix).
Outra consideração é o alinhamento das estruturas, como mencionado anteriormente. Para garantir a portabilidade, você pode serializar cada campo separadamente, usando funções de escrita de inteiros que convertem para um formato canônico. Exemplo:
#include <stdio.h>
#include <stdint.h>
#include <arpa/inet.h> // para htonl, ntohl
// Função para gravar um int32_t em big-endian
void escrever_int32(FILE *arquivo, int32_t valor) {
uint32_t be = htonl((uint32_t)valor);
fwrite(&be, sizeof(be), 1, arquivo);
}
// Função para ler um int32_t big-endian
int32_t ler_int32(FILE *arquivo) {
uint32_t be;
if (fread(&be, sizeof(be), 1, arquivo) != 1) {
// tratar erro
}
return (int32_t)ntohl(be);
}
int main() {
FILE *arquivo = fopen("dados.bin", "wb");
if (arquivo == NULL) return 1;
int32_t valor = 123456;
escrever_int32(arquivo, valor);
fclose(arquivo);
return 0;
}
Esse tipo de abordagem é comum em protocolos de rede e em formatos de arquivo que precisam ser intercambiáveis. Em resumo, para criar arquivos binários portáveis, você deve usar tipos de tamanho fixo, controlar a endianness e evitar depender de padding de estruturas.
Boas práticas e observações
Ao trabalhar com arquivos binários, sempre verifique o retorno das funções fread, fwrite, fseek e ftell. Isso ajuda a detectar erros precocemente. Além disso, é recomendável abrir os arquivos em modo binário explícito ("wb", "rb" etc.) para evitar problemas em sistemas Windows. Quando for gravar estruturas, considere o alinhamento e, se necessário, use diretivas de empacotamento ou serialize campo a campo para garantir compatibilidade. Por fim, lembre-se de que arquivos binários não são legíveis por humanos, então documente bem o formato do arquivo no código e, se possível, crie funções de leitura e escrita específicas para cada tipo de dado.
Referências
- fread - cppreference.com
- fwrite - cppreference.com
- fseek - cppreference.com
- ftell - cppreference.com
- Tipos inteiros de tamanho fixo - cppreference.com
- htonl(3) - Linux manual page
- GNU C Library: Opening Streams
Exercícios
- Escreva um programa que crie um arquivo binário chamado
numeros.bine grave os números de 1 a 10 (comoint). Depois, leia o arquivo e imprima os números. - Usando
fseekeftell, escreva um programa que exiba o tamanho de um arquivo binário qualquer (recebido como argumento) e o conteúdo do byte na posição central (se o tamanho for ímpar) ou dos dois bytes centrais (se par). - Crie uma estrutura
Produtocom camposcodigo(int),nome(char[30]) epreco(float). Grave um vetor de 3 produtos em um arquivo binário e depois leia apenas o produto de código 2 (índice 1) usandofseek. - Explique o que é endianness e como ela pode afetar a leitura de arquivos binários entre sistemas diferentes. Dê um exemplo de como converter um inteiro para big-endian antes de gravar.
- Escreva um programa que leia um arquivo binário contendo uma sequência de inteiros de 4 bytes (em big-endian) e imprima a soma de todos eles. Assuma que o arquivo foi gerado em uma máquina big-endian ou que os dados já estão em big-endian.
#include <stdio.h>
int main() {
FILE *arquivo = fopen("numeros.bin", "wb");
if (arquivo == NULL) { perror("Erro"); return 1; }
for (int i = 1; i <= 10; i++) {
fwrite(&i, sizeof(int), 1, arquivo);
}
fclose(arquivo);
arquivo = fopen("numeros.bin", "rb");
if (arquivo == NULL) { perror("Erro"); return 1; }
int num;
while (fread(&num, sizeof(int), 1, arquivo) == 1) {
printf("%d ", num);
}
printf("\n");
fclose(arquivo);
return 0;
}
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
if (argc != 2) { fprintf(stderr, "Uso: %s arquivo\n", argv[0]); return 1; }
FILE *arquivo = fopen(argv[1], "rb");
if (!arquivo) { perror("Erro"); return 1; }
fseek(arquivo, 0, SEEK_END);
long tamanho = ftell(arquivo);
printf("Tamanho: %ld bytes\n", tamanho);
if (tamanho == 0) { fclose(arquivo); return 0; }
long meio = tamanho / 2;
if (tamanho % 2 == 1) {
// ímpar: um byte central
fseek(arquivo, meio, SEEK_SET);
unsigned char b;
if (fread(&b, 1, 1, arquivo) == 1) printf("Byte central: %u\n", b);
} else {
// par: dois bytes centrais
fseek(arquivo, meio - 1, SEEK_SET);
unsigned char b1, b2;
if (fread(&b1, 1, 1, arquivo) == 1 && fread(&b2, 1, 1, arquivo) == 1) {
printf("Bytes centrais: %u %u\n", b1, b2);
}
}
fclose(arquivo);
return 0;
}
#include <stdio.h>
#include <string.h>
typedef struct {
int codigo;
char nome[30];
float preco;
} Produto;
int main() {
Produto produtos[3] = {
{1, "Caneta", 1.50},
{2, "Caderno", 12.90},
{3, "Borracha", 0.80}
};
FILE *arquivo = fopen("produtos.bin", "wb");
if (!arquivo) { perror("Erro"); return 1; }
fwrite(produtos, sizeof(Produto), 3, arquivo);
fclose(arquivo);
arquivo = fopen("produtos.bin", "rb");
if (!arquivo) { perror("Erro"); return 1; }
// Lê o produto de índice 1
fseek(arquivo, sizeof(Produto), SEEK_SET);
Produto p;
if (fread(&p, sizeof(Produto), 1, arquivo) == 1) {
printf("Código: %d, Nome: %s, Preço: %.2f\n", p.codigo, p.nome, p.preco);
}
fclose(arquivo);
return 0;
}
Endianness se refere à ordem em que os bytes de um valor multi-byte são armazenados na memória. Little-endian armazena o byte menos significativo primeiro, enquanto big-endian armazena o mais significativo primeiro. Se um arquivo binário for gravado em uma máquina little-endian e lido em uma big-endian, os valores inteiros serão interpretados de forma invertida, resultando em dados incorretos. Para garantir a portabilidade, você pode converter os valores para uma ordem padronizada (como big-endian) antes de gravar, usando funções como htonl para inteiros de 32 bits.
#include <arpa/inet.h>
void gravar_int32(FILE *arquivo, int32_t valor) {
uint32_t be = htonl((uint32_t)valor);
fwrite(&be, sizeof(be), 1, arquivo);
}
#include <stdio.h>
#include <stdint.h>
#include <arpa/inet.h>
int main() {
FILE *arquivo = fopen("dados.bin", "rb");
if (!arquivo) { perror("Erro"); return 1; }
uint32_t be;
int64_t soma = 0;
while (fread(&be, sizeof(be), 1, arquivo) == 1) {
int32_t valor = (int32_t)ntohl(be); // converte de big-endian para host
soma += valor;
}
fclose(arquivo);
printf("Soma: %lld\n", (long long)soma);
return 0;
}