Di POSIX, hampir semua sumber daya I/O—file biasa, pipe, socket, device, terminal—diabstraksikan sebagai file descriptor: integer kecil yang menjadi indeks ke tabel di kernel proses. Kita sudah memakai file descriptor secara implisit di chapter sebelumnya (pipe mengembalikan fd, socket mengembalikan fd). Pada chapter ini kita melihat operasi low-level langsung: open, read, write, close, dan dup2. Memahami file descriptor adalah kunci untuk menulis kode yang efisien dan untuk membaca dokumentasi syscall POSIX.

Anatomi File Descriptor #

Setiap proses memiliki tabel file descriptor di kernel. Tiga entri pertama sudah terisi saat proses dimulai:

  • fd 0 (STDIN_FILENO): standard input
  • fd 1 (STDOUT_FILENO): standard output
  • fd 2 (STDERR_FILENO): standard error

Setiap pemanggilan open, pipe, atau socket yang berhasil mendapat fd baru—biasanya nomor terkecil yang belum terpakai. Saat fd ditutup dengan close, slot di tabel dibebaskan dan bisa dipakai fd baru.

c
/* File: fd_table.c - menunjukkan alokasi fd */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main(void) {
    printf("stdin  fd = %d\n", STDIN_FILENO);
    printf("stdout fd = %d\n", STDOUT_FILENO);
    printf("stderr fd = %d\n", STDERR_FILENO);

    /* Buka beberapa file - fd akan terus bertambah */
    int fd1 = open("/tmp/fd_a.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    int fd2 = open("/tmp/fd_b.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    int fd3 = open("/tmp/fd_c.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);

    printf("fd1 = %d, fd2 = %d, fd3 = %d\n", fd1, fd2, fd3);

    /* Tutup fd2 - fd number bebas untuk dipakai ulang */
    close(fd2);
    printf("fd2 (%d) ditutup\n", fd2);

    int fd4 = open("/tmp/fd_d.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    printf("fd4 = %d (reuses fd2)\n", fd4);

    /* Cleanup */
    close(fd1); close(fd3); close(fd4);
    unlink("/tmp/fd_a.txt"); unlink("/tmp/fd_b.txt");
    unlink("/tmp/fd_c.txt"); unlink("/tmp/fd_d.txt");
    return 0;
}

Compile dan jalankan:

bash
gcc -std=c11 -Wall -Wextra -o fd_table fd_table.c
./fd_table

Output yang Dihasilkan:

text
stdin  fd = 0
stdout fd = 1
stderr fd = 2
fd1 = 3, fd2 = 4, fd3 = 5
fd2 (4) ditutup
fd4 = 4 (reuses fd2)

Mari kita telaah baris-baris kode di atas:

  1. Baris 7-9 (STDIN_FILENO, dll.): Konstanta dari <unistd.h>. STDIN_FILENO selalu 0, STDOUT_FILENO selalu 1, STDERR_FILENO selalu 2. Konvensi ini berlaku di semua sistem POSIX.
  2. Baris 12-14 (open(...) x3): Setiap open yang sukses mengembalikan fd terkecil yang belum terpakai. Karena 0, 1, 2 sudah dipakai std streams, fd pertama yang baru adalah 3, lalu 4, lalu 5.
  3. Baris 18 (close(fd2)): Menutup fd 4. Slot tabel di kernel dibebaskan. File /tmp/fd_b.txt tetap ada di filesystem—close hanya melepas association fd-file, tidak menghapus file.
  4. Baris 21 (open(...)): Membuka file baru. Karena fd 4 sekarang bebas, kernel mengembalikan 4 lagi. Inilah mengapa output menunjukkan fd4 = 4.
  5. Cleanup: close semua fd yang masih terbuka, lalu unlink file demo untuk membersihkan filesystem. Lupa close menyebabkan kebocoran fd—bisa mencapai batas ulimit -n (default 1024 di Linux).

Operasi Low-Level: open, read, write, close #

Empat fungsi inti untuk I/O file di POSIX:

c
/* File: fd_io.c - operasi low-level: open, read, write, close */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main(void) {
    const char *path = "/tmp/fd_demo.txt";

    /* Buka file untuk tulis (create + truncate, mode 0644) */
    int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) {
        perror("open write");
        return 1;
    }
    printf("File dibuka untuk tulis, fd=%d\n", fd);

    const char *pesan = "Halo, ini ditulis dengan write()!\n";
    ssize_t ditulis = write(fd, pesan, strlen(pesan));
    printf("Menulis %zd byte\n", ditulis);
    close(fd);
    printf("fd ditutup.\n");

    /* Buka file untuk baca */
    fd = open(path, O_RDONLY);
    if (fd < 0) {
        perror("open read");
        return 1;
    }
    printf("File dibuka untuk baca, fd=%d\n", fd);

    char buf[128];
    ssize_t dibaca = read(fd, buf, sizeof(buf) - 1);
    if (dibaca > 0) {
        buf[dibaca] = '\0';
        printf("Membaca %zd byte: %s", dibaca, buf);
    }
    close(fd);

    /* Hapus file demo */
    unlink(path);
    printf("File %s dihapus.\n", path);
    return 0;
}

Compile dan jalankan:

bash
gcc -std=c11 -Wall -Wextra -o fd_io fd_io.c
./fd_io

Output yang Dihasilkan:

text
File dibuka untuk tulis, fd=3
Menulis 34 byte
fd ditutup.
File dibuka untuk baca, fd=3
Membaca 34 byte: Halo, ini ditulis dengan write()!
File /tmp/fd_demo.txt dihapus.

Mari kita telaah baris-baris kode di atas:

  1. Baris 9 (open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644)): Membuka file untuk ditulis. O_WRONLY mode tulis saja. O_CREAT buat file bila belum ada. O_TRUNC hapus isi bila sudah ada. Argumen ketiga 0644 adalah permission mode (owner: read+write, group: read, other: read)—hanya dipakai bila file baru dibuat.
  2. Baris 16 (write(fd, pesan, strlen(pesan))): Menulis strlen(pesan) byte dari buffer pesan ke fd. Mengembalikan jumlah byte yang benar-benar ditulis. Bila return lebih kecil dari yang diminta, terjadi partial write—umum pada pipe atau socket. Untuk file biasa, write biasanya menulis semua byte yang diminta.
  3. Baris 17 (ssize_t ditulis = ...): Tipe ssize_t adalah signed size_t—bisa negatif untuk menandakan error. Selalu cek return value: bila -1, baca errno untuk diagnosa.
  4. Baris 21 (close(fd)): Menutup fd. Setelah ini, fd tidak valid dan bisa dipakai ulang oleh open berikutnya.
  5. Baris 25 (open(path, O_RDONLY)): Membuka file untuk dibaca. Tidak perlu argumen mode karena tidak membuat file baru.
  6. Baris 31 (read(fd, buf, sizeof(buf) - 1)): Membaca hingga sizeof(buf) - 1 byte. Mengembalikan jumlah byte yang dibaca, 0 bila EOF, atau -1 bila error. Kita kurangi 1 untuk menyisakan tempat null terminator.
  7. Baris 38 (unlink(path)): Menghapus file dari filesystem. unlink menghapus nama file; bila tidak ada proses lain yang membuka file, kernel benar-benar menghapus data. Bila ada fd yang masih terbuka, file tetap hidup hingga fd terakhir ditutup—ini adalah mekanisme anonymous file yang dipakai untuk temporary file.

dup2: Mengalihkan File Descriptor #

Fungsi dup2(old_fd, new_fd) menggandakan old_fd ke nomor new_fd. Bila new_fd sudah terbuka, ia ditutup dulu sebelum diganti. Trik ini dipakai untuk redirect stdin/stdout/stderr—tepat seperti yang dilakukan shell saat menulis > file.txt.

c
/* File: fd_dup2.c - dup2 untuk redirect stdout ke file */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main(void) {
    int fd = open("/tmp/fd_redirect.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) { perror("open"); return 1; }

    /* Simpan stdout lama */
    int stdout_lama = dup(STDOUT_FILENO);

    /* Redirect stdout ke file */
    dup2(fd, STDOUT_FILENO);
    close(fd);

    /* printf sekarang menulis ke file, bukan terminal */
    printf("Baris ini masuk ke file, bukan terminal.\n");
    fflush(stdout);

    /* Kembalikan stdout ke terminal */
    dup2(stdout_lama, STDOUT_FILENO);
    close(stdout_lama);

    printf("Baris ini tampil di terminal.\n");

    /* Baca file untuk verifikasi */
    fd = open("/tmp/fd_redirect.txt", O_RDONLY);
    char buf[128];
    ssize_t n = read(fd, buf, sizeof(buf) - 1);
    if (n > 0) {
        buf[n] = '\0';
        printf("Isi file: %s", buf);
    }
    close(fd);
    unlink("/tmp/fd_redirect.txt");
    return 0;
}

Compile dan jalankan:

bash
gcc -std=c11 -Wall -Wextra -o fd_dup2 fd_dup2.c
./fd_dup2

Output yang Dihasilkan:

text
Baris ini tampil di terminal.
Isi file: Baris ini masuk ke file, bukan terminal.

Mari kita telaah baris-baris kode di atas:

  1. Baris 9 (int fd = open(...)): Membuka file target. Dapatkan fd baru (misalnya 3).
  2. Baris 13 (int stdout_lama = dup(STDOUT_FILENO)): dup menggandakan fd 1 (stdout) ke fd baru (misalnya 4). Sekarang fd 1 dan fd 4 menunjuk ke entri tabel yang sama: terminal. Ini cara menyimpan referensi stdout asli sebelum kita meng-overwrite fd 1.
  3. Baris 16 (dup2(fd, STDOUT_FILENO)): Menggandakan fd 3 (file) ke fd 1 (stdout). Setelah ini, printf yang menulis ke fd 1 sebenarnya menulis ke file.
  4. Baris 17 (close(fd)): Menutup fd 3 yang asli. Tabel fd 1 sekarang menunjuk ke file—referensi fd 3 sudah tidak diperlukan.
  5. Baris 20-21 (printf(...); fflush(stdout);): printf ke stdout (fd 1) sekarang masuk ke file. fflush memaksa buffer library C ditulis ke kernel—penting sebelum kita kembalikan stdout, agar pesan tidak tertinggal di buffer.
  6. Baris 24 (dup2(stdout_lama, STDOUT_FILENO)): Kembalikan fd 1 ke terminal (meng-overwrite referensi file yang sebelumnya dipasang). Sekarang printf kembali ke terminal.
  7. Baris 28-32 (baca file verifikasi): Membaca file yang baru saja ditulis, untuk membuktikan bahwa printf baris 20 benar-benar masuk ke file.

Batas dan Manajemen fd #

Setiap proses punya batas jumlah fd yang bisa dibuka—default 1024 di Linux, dapat ditingkatkan lewat ulimit -n atau setrlimit(RLIMIT_NOFILE, ...). Untuk server yang menangani ribuan koneksi, batas ini bisa menjadi kendala—kernel Linux menyediakan epoll untuk efisiensi monitoring fd dalam jumlah besar.