Saat kita mengetik gcc hello.c -o hello dan menekan Enter, sebenarnya terjadi empat tahap berurutan di balik layar: preprocessing, compiling, assembling, dan linking. Banyak pemula menganggap tahap tersebut satu kesatuan monolit, padahal setiap tahap menghasilkan artifact antara yang bisa kita periksa. Pada chapter ini kita akan membedah tiap tahap secara eksplisit dengan flag -E, -S, -c, dan akhirnya link dari object file. Pemahaman ini penting karena sebagian besar pesan error compiler (misalnya undefined reference atau missing header) hanya bisa kita artikan dengan benar bila kita tahu di tahap mana error tersebut muncul.

Empat Tahap Build C #

Toolchain GCC secara konseptual memecah pekerjaan menjadi empat tahap, dan setiap tahap dapat dijalankan secara terpisah dengan flag yang sesuai:

  1. Preprocessing — C preprocessor (cpp) menangani direktif #include, #define, #if, dan macro. Output: file .i berisi C source yang sudah diperluas.
  2. Compilation — compiler menerjemahkan C menjadi assembly untuk arsitektur target. Output: file .s berisi teks assembly.
  3. Assembly — assembler (as) menerjemahkan assembly menjadi instruksi mesin. Output: file .o (object file / relocatable).
  4. Linking — linker (ld) menggabungkan satu atau beberapa object file bersama library untuk menghasilkan executable akhir.

Visualisasi Konsep #

Diagram berikut memperlihatkan keempat tahap build C beserta file antara yang dihasilkan pada setiap tahap dan flag GCC yang dipakai untuk menghentikan proses pada tahap tersebut:

Pada diagram di atas, file sumber hello.c mengalir melalui empat tahap berurutan, masing-masing menghasilkan artifact antara yang bisa diperiksa. Flag GCC -E, -S, -c memungkinkan kita menghentikan proses pada tahap tertentu untuk mendiagnosis masalah: preprocessor error (misalnya missing header) muncul di tahap 1, compiler error (misalnya expected ‘;’) muncul di tahap 2, dan linker error (misalnya undefined reference) baru muncul di tahap 4. Pemetaan pesan galat ke tahap yang sesuai sangat mempercepat diagnosis.

Mari kita lihat setiap tahap secara konkret. Kode sumber berikut sangat sederhana tetapi cukup memperlihatkan kerja setiap tahap:

c
#include <stdio.h>

#define SIAPA "Budi"

int main(void) {
    printf("Halo %s!\n", SIAPA);
    return 0;
}

Simpan kode di atas sebagai hello.c lalu jalankan empat perintah berikut secara terpisah di terminal:

bash
gcc -E hello.c -o hello.i    # Tahap 1: preprocess ke file .i
gcc -S hello.c -o hello.s    # Tahap 2: compile ke file .s
gcc -c hello.c -o hello.o    # Tahap 3: assemble ke file .o
gcc hello.o -o hello         # Tahap 4: link object file menjadi executable
./hello                       # Jalankan hasil akhir

Output yang Dihasilkan:

text
Halo Budi!

Setiap tahap menghasilkan file dengan ukuran dan jenis yang berbeda. Tabel berikut merangkum ringkasannya:

FileTahapUkuranJenis
hello.cSumber108 BTeks C
hello.iPreprocess21 KBTeks C (header diperluas)
hello.sCompile545 BTeks assembly x86-64
hello.oAssemble1.4 KBELF relocatable (object file)
helloLink16 KBELF executable x86-64

Memeriksa Hasil Setiap Tahap #

Tahap Preprocess (-E) #

Flag -E menyuruh GCC berhenti setelah menjalankan preprocessor. File hello.i berisi seluruh isi header stdio.h yang sudah disisipkan, dan macro SIAPA sudah diganti dengan nilai "Budi". Mari kita lihat 20 baris pertama hello.i:

bash
head -20 hello.i

Output yang Dihasilkan:

text
# 0 "hello.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "hello.c"
# 1 "/usr/include/stdio.h" 1 3 4
# 28 "/usr/include/stdio.h" 3 4
# 1 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 1 3 4
# 33 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 3 4
# 1 "/usr/include/features.h" 1 3 4
# 415 "/usr/include/features.h" 3 4
# 1 "/usr/include/features-time64.h" 1 3 4
# 20 "/usr/include/features-time64.h" 3 4
# 1 "/usr/include/x86_64-linux-gnu/bits/wordsize.h" 1 3 4
# 21 "/usr/include/features-time64.h" 2 3 4
# 1 "/usr/include/x86_64-linux-gnu/bits/timesize.h" 1 3 4
# 19 "/usr/include/x86_64-linux-gnu/bits/timesize.h" 2 3 4
# 1 "/usr/include/x86_64-linux-gnu/bits/wordsize.h" 1 3 4
# 20 "/usr/include/features-time64.h" 2 3 4

Baris-baris yang dimulai dengan # di hello.i adalah line marker yang dicatat preprocessor untuk keperluan debugging — baris ini memberi tahu compiler baris mana di file asli yang sesuai dengan blok kode berikutnya. Lihat sepuluh baris terakhir hello.i untuk memastikan kode main() kita muncul dengan macro SIAPA sudah diganti:

bash
tail -10 hello.i

Output yang Dihasilkan:

text
# 2 "hello.c" 2




# 5 "hello.c"
int main(void) {
    printf("Halo %s!\n", "Budi");
    return 0;
}

Perhatikan bahwa baris printf("Halo %s!\n", SIAPA); sekarang berbunyi printf("Halo %s!\n", "Budi"); — macro SIAPA sudah diganti dengan nilai literal string "Budi". Inilah inti tahap preprocessing: semua direktif # diselesaikan sehingga compiler hanya melihat C murni.

Tahap Compile (-S) #

Flag -S menghentikan GCC setelah compiler menghasilkan assembly. File hello.s berisi teks assembly x86-64:

bash
cat hello.s

Output yang Dihasilkan:

text
        .file   "hello.c"
        .text
        .section        .rodata
.LC0:
        .string "Budi"
.LC1:
        .string "Halo %s!\n"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        leaq    .LC0(%rip), %rax
        movq    %rax, %rsi
        leaq    .LC1(%rip), %rax
        movq    %rax, %rdi
        movl    $0, %eax
        call    printf@PLT
        movl    $0, %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Debian 14.2.0-19) 14.2.0"
        .section        .note.GNU-stack,"",@progbits

Mari kita telaah baris-baris assembly yang dihasilkan compiler di atas:

  1. Baris .file "hello.c": metadata yang mencatat nama file sumber, dipakai oleh debugger untuk memetakan balik instruksi mesin ke baris kode C.
  2. Baris .section .rodata + .LC0: + .string "Budi": literal string "Budi" (nilai macro SIAPA) ditempatkan di section read-only data (.rodata) agar tidak dapat dimodifikasi saat runtime.
  3. Baris .LC1: + .string "Halo %s!\n": format string untuk printf juga disimpan sebagai data konstan di .rodata.
  4. Baris .globl main: deklarasi simbol main sebagai global, sehingga linker bisa menemukannya dari file lain.
  5. Baris pushq %rbp / movq %rsp, %rbp: prolog fungsi standar System V AMD64 — menyimpan base pointer lama dan menetapkan base pointer baru sebagai stack frame fungsi main.
  6. Baris leaq .LC0(%rip), %rax / movq %rax, %rsi: alamat string "Budi" dimuat ke register rsi (argumen kedua untuk printf).
  7. Baris call printf@PLT: pemanggilan printf melalui Procedure Linkage Table (PLT) — mekanisme lazy binding untuk shared library.
  8. Baris movl $0, %eax / popq %rbp / ret: nilai kembali 0 diletakkan di register eax, lalu stack frame dipulihkan dan fungsi kembali ke pemanggil.

Tahap Assemble (-c) #

Flag -c menjalankan assembler terhadap output compiler. Hasilnya adalah file hello.o berformat ELF relocatable. Kita bisa memeriksa jenis file tersebut:

bash
file hello.o

Output yang Dihasilkan:

text
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

Kata kunci relocatable menandakan file ini belum siap dijalankan — alamat-alamat simbol masih perlu diselesaikan oleh linker pada tahap berikutnya. Kita akan membedah isi hello.o lebih dalam pada chapter Object Files.

Tahap akhir menjalankan linker. Perintah gcc hello.o -o hello menyuruh GCC memanggil ld untuk menggabungkan hello.o dengan C runtime (crt0.o, crti.o, crtbegin.o) dan library C dinamis (libc.so), menghasilkan executable hello. File inilah yang akhirnya bisa kita jalankan dengan ./hello.