File biner menyimpan data apa adanya—tanpa translasi karakter, tanpa pembatas newline, tanpa format teks yang bisa dibaca manusia. Keuntungannya: ukuran lebih kecil, akses lebih cepat, dan presisi data terjaga. Sebuah struct kompleks bisa ditulis dengan satu fwrite dan dibaca kembali dengan satu fread. Pada chapter ini kita akan menulis array Mahasiswa ke file biner, membacanya kembali, dan memverifikasi bahwa data tetap utuh. Kita juga akan melihat bagaimana endianness dan alignment memengaruhi portabilitas file biner antar platform.

Mengapa File Biner Berbeda dari File Teks #

Pada file teks, angka 12345 disimpan sebagai 5 byte karakter ASCII "12345". Pada file biner, angka yang sama (bila ditulis sebagai int 32-bit) disimpan sebagai 4 byte—3 byte di antaranya adalah 0x00. Perbedaan ini memiliki beberapa konsekuensi:

  • Ukuran lebih kecil — angka besar disimpan dalam 4 atau 8 byte, bukan sebagai string yang panjangnya proporsional dengan nilai.
  • Akses acak lebih mudah — setiap record memiliki ukuran tetap sizeof(struct), sehingga offset record ke-N adalah N * sizeof(struct).
  • Tidak bisa dibaca manusia — cat pada file biner menghasilkan karakter aneh atau tidak terbaca.
  • Tidak portabel antar arsitektur — endianness, padding, dan ukuran tipe bisa berbeda antar mesin.

Pola Tulis-Lalu-Baca Struct Array #

Mari kita tulis 5 record Mahasiswa ke file biner, lalu membacanya kembali dan memverifikasi setiap field cocok dengan aslinya. Kita pakai tipe dengan ukuran eksplisit (uint32_t, uint16_t) agar ukuran field konsisten antar platform.

c
#include <stdio.h>
#include <string.h>
#include <stdint.h>

typedef struct {
    uint32_t id;
    char     nama[28];
    uint16_t umur;
    uint16_t nilai;
} Mahasiswa;

int main(void) {
    /* Tulis array struct ke file biner */
    Mahasiswa mhs[] = {
        {1, "Budi", 20, 85},
        {2, "Siti", 21, 92},
        {3, "Andi", 19, 78},
        {4, "Dono", 22, 88},
        {5, "Rina", 20, 95},
    };
    size_t count = sizeof(mhs) / sizeof(mhs[0]);

    FILE *f = fopen("/tmp/test_data.bin", "wb");
    if (!f) { perror("fopen wb"); return 1; }
    fwrite(mhs, sizeof(Mahasiswa), count, f);
    fclose(f);
    printf("Menulis %zu record, masing-masing %zu byte\n",
           count, sizeof(Mahasiswa));

    /* Baca kembali dan verifikasi */
    f = fopen("/tmp/test_data.bin", "rb");
    if (!f) { perror("fopen rb"); return 1; }

    Mahasiswa tmp;
    int idx = 0;
    while (fread(&tmp, sizeof(Mahasiswa), 1, f) == 1) {
        printf("  [%d] id=%u nama=%s umur=%u nilai=%u\n",
               idx, tmp.id, tmp.nama, tmp.umur, tmp.nilai);
        /* Bandingkan dengan data asli */
        int sama = (tmp.id == mhs[idx].id &&
                    strcmp(tmp.nama, mhs[idx].nama) == 0 &&
                    tmp.umur == mhs[idx].umur &&
                    tmp.nilai == mhs[idx].nilai);
        printf("       cocok dengan asli: %s\n", sama ? "YA" : "TIDAK");
        idx++;
    }
    fclose(f);
    return 0;
}

Output yang Dihasilkan:

text
Menulis 5 record, masing-masing 36 byte
  [0] id=1 nama=Budi umur=20 nilai=85
       cocok dengan asli: YA
  [1] id=2 nama=Siti umur=21 nilai=92
       cocok dengan asli: YA
  [2] id=3 nama=Andi umur=19 nilai=78
       cocok dengan asli: YA
  [3] id=4 nama=Dono umur=22 nilai=88
       cocok dengan asli: YA
  [4] id=5 nama=Rina umur=20 nilai=95
       cocok dengan asli: YA

Lima record ditulis sebagai 5 × 36 = 180 byte. Setelah dibaca kembali, setiap field cocok dengan data asli—verifikasi menunjukkan "YA" untuk semua record. Ukuran struct Mahasiswa adalah 36 byte (4 byte id + 28 byte nama + 2 byte umur + 2 byte nilai = 36, tidak ada padding karena alignment sudah cocok).

Mari kita telaah baris-baris kode di atas:

  1. Baris 9 (typedef struct { uint32_t id; char nama[28]; uint16_t umur; uint16_t nilai; } Mahasiswa;): kita pakai tipe fixed-width dari <stdint.h> (uint32_t, uint16_t) agar ukuran field tidak bergantung pada platform. Bila kita pakai int, ukuran bisa 4 byte di Linux tetapi 2 byte di sistem embedded lama.
  2. Baris 23 (fwrite(mhs, sizeof(Mahasiswa), count, f);): menulis seluruh array sekaligus. Satu panggilan fwrite menulis 5 × 36 = 180 byte.
  3. Baris 31 (while (fread(&tmp, sizeof(Mahasiswa), 1, f) == 1)): membaca satu record per panggilan. Kondisi == 1 menjamin loop berhenti tepat saat fread tidak bisa membaca record lengkap (karena EOF).
  4. Baris 35-38 (int sama = (tmp.id == mhs[idx].id && ...)): membandingkan setiap field secara individual. Cara ini lebih ketat daripada memcmp karena tidak sensitif terhadap padding—bila ada padding byte yang nilainya berbeda, memcmp akan melaporkan perbedaan meski data sebenarnya sama.
  5. Baris 39 (printf(" cocok dengan asli: %s\n", sama ? "YA" : "TIDAK");): mencetak hasil verifikasi. Pada program yang berjalan di Linux x86-64, semua cocok karena kita menulis dan membaca di mesin yang sama.

Inspeksi Byte File Biner #

Untuk benar-benar memahami apa yang tersimpan, mari kita dump beberapa byte pertama dari file biner yang baru dibuat.

c
#include <stdio.h>

int main(void) {
    /* Asumsi /tmp/test_data.bin sudah ada dari program sebelumnya */
    FILE *f = fopen("/tmp/test_data.bin", "rb");
    if (!f) { perror("fopen"); return 1; }

    /* Baca 36 byte pertama (1 record) */
    unsigned char buf[36];
    size_t n = fread(buf, 1, sizeof(buf), f);
    printf("Membaca %zu byte pertama:\n", n);

    /* Tampilkan sebagai hex dump */
    for (size_t i = 0; i < n; i++) {
        if (i % 16 == 0) printf("  %04zx: ", i);
        printf("%02X ", buf[i]);
        if (i % 16 == 15 || i == n - 1) printf("\n");
    }

    /* Interpretasi: byte 0-3 = id (little-endian), byte 4-31 = nama, byte 32-33 = umur, byte 34-35 = nilai */
    unsigned int id = buf[0] | (buf[1] << 8) | (buf[2] << 16) | (buf[3] << 24);
    printf("\nInterpretasi little-endian:\n");
    printf("  id      = %u\n", id);
    printf("  nama    = \"");
    for (int i = 4; i < 32; i++) {
        if (buf[i] == 0) break;
        printf("%c", buf[i]);
    }
    printf("\"\n");
    printf("  umur    = %u\n", buf[32] | (buf[33] << 8));
    printf("  nilai   = %u\n", buf[34] | (buf[35] << 8));

    fclose(f);
    return 0;
}

Output yang Dihasilkan:

text
Membaca 36 byte pertama:
  0000: 01 00 00 00 42 75 64 69 00 00 00 00 00 00 00 00
  0010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0020: 14 00 55 00

Interpretasi little-endian:
  id      = 1
  nama    = "Budi"
  umur    = 20
  nilai   = 85

Perhatikan hex dump di atas. Empat byte pertama 01 00 00 00 adalah id = 1 dalam little-endian 32-bit (byte paling tidak signifikan 0x01 berada di alamat pertama). Byte 4-7 adalah "Budi" (42 75 64 69), diikuti 24 byte 0x00 hingga akhir field nama[28]. Dua byte terakhir 14 00 adalah umur = 20 (0x14 hex = 20 desimal), dan 55 00 adalah nilai = 85 (0x55 hex = 85 desimal).

Pada mesin big-endian (misalnya SPARC lama atau beberapa mikrokontroler), id = 1 akan ditulis sebagai 00 00 00 01—jika file ini dibaca di mesin big-endian, kita akan dapat id = 0x01000000 = 16777216, bukan 1. Inilah masalah portabilitas file biner antar arsitektur.

Random Access pada File Biner #

Salah satu keunggulan utama file biner adalah dukungan random access langsung—karena setiap record berukuran tetap, kita bisa lompat ke record ke-N dengan perhitungan offset sederhana.

c
#include <stdio.h>
#include <stdint.h>

typedef struct {
    uint32_t id;
    char     nama[28];
    uint16_t umur;
    uint16_t nilai;
} Mahasiswa;

int main(void) {
    FILE *f = fopen("/tmp/test_data.bin", "rb");
    if (!f) { perror("fopen"); return 1; }

    /* Baca record ke-3 (index 2) langsung */
    Mahasiswa rec;
    long offset = 2 * sizeof(Mahasiswa);
    fseek(f, offset, SEEK_SET);
    if (fread(&rec, sizeof(Mahasiswa), 1, f) == 1) {
        printf("Record ke-3 (offset %ld): id=%u nama=%s umur=%u nilai=%u\n",
               offset, rec.id, rec.nama, rec.umur, rec.nilai);
    }

    /* Baca record terakhir dengan lompat dari akhir */
    fseek(f, -(long)sizeof(Mahasiswa), SEEK_END);
    if (fread(&rec, sizeof(Mahasiswa), 1, f) == 1) {
        printf("Record terakhir: id=%u nama=%s umur=%u nilai=%u\n",
               rec.id, rec.nama, rec.umur, rec.nilai);
    }

    fclose(f);
    return 0;
}

Output yang Dihasilkan:

text
Record ke-3 (offset 72): id=3 nama=Andi umur=19 nilai=78
Record terakhir: id=5 nama=Rina umur=20 nilai=95

Pola di atas adalah dasar implementasi database sederhana di C. Untuk membaca record ke-N, kita hitung offset sebagai N * sizeof(Record) lalu fseek. Operasi ini berjalan dalam O(1) berkat dukungan system call lseek(2) di kernel.

Endianness dan Portabilitas #

uint32_t menjamin ukuran 4 byte, tetapi tidak menjamin urutan byte. Endianness ditentukan oleh arsitektur CPU: x86 dan x86-64 adalah little-endian (byte paling tidak signifikan dulu), sedangkan beberapa arsitektur lama (SPARC, MIPS dalam mode tertentu) adalah big-endian.

Untuk file yang akan dipertukarkan antar platform, gunakan fungsi konversi htonl/ntohl (POSIX) yang mengubah ke/dari network byte order (big-endian):

c
#include <arpa/inet.h>   /* POSIX */

uint32_t value = 12345;
uint32_t net_value = htonl(value);   /* host to network (big-endian) */
fwrite(&net_value, sizeof(net_value), 1, f);

/* Saat membaca: */
uint32_t net_read;
fread(&net_read, sizeof(net_read), 1, f);
uint32_t host_value = ntohl(net_read);   /* network to host */