Bagaimana C Dikompilasi

Perjalanan pernyataan C menjadi instruksi mesin melalui keluaran compiler yang diperiksa langsung.

Daftar isi

Topik ini menelusuri perjalanan sebuah pernyataan C hingga menjadi instruksi mesin yang benar-benar dijalankan prosesor. Empat tahap penerjemahan diamati langsung melalui perintah gcc, mulai dari keluaran preprocessor, berkas assembly, berkas objek, hingga program utuh hasil kerja linker. Setiap tahap memakai contoh yang sengaja dibuat kecil agar keluarannya dapat dibaca baris demi baris. Setelah topik ini selesai dibaca, opsi -E, -S, -c, dan -o pada gcc dapat dipakai untuk memeriksa hasil penerjemahan pada setiap tahapnya.

Empat tahap penerjemahan C#

Kompilasi C berlangsung melalui empat tahap yang masing-masing menghasilkan bentuk keluaran berbeda. Tahap pertama dijalankan oleh preprocessor, yaitu program yang memproses direktif berawalan # seperti #define dan #include sebelum penerjemahan dimulai. Tahap kedua dijalankan oleh compiler, yaitu program yang menerjemahkan C menjadi bahasa assembly, notasi teks dengan satu baris untuk satu instruksi mesin. Tahap ketiga dijalankan oleh assembler yang mengubah assembly menjadi berkas objek, yaitu berkas berisi kode mesin yang belum siap dijalankan.

Tahap keempat dijalankan oleh linker, program yang menyatukan berkas objek beserta kode library, yaitu kumpulan fungsi siap pakai, menjadi berkas eksekusi. Perintah gcc kalkulator.c -o kalkulator menjalankan keempat tahap itu sekaligus dalam satu langkah. Opsi -E, -S, dan -c menghentikan proses pada akhir tahap pertama, kedua, dan ketiga, sedangkan -o menentukan nama berkas keluarannya. Compiler juga menargetkan satu arsitektur tertentu, sehingga pernyataan C yang sama menghasilkan instruksi berbeda pada x86-64 dan RISC-V, dan seluruh contoh topik ini dikompilasi untuk x86-64 pada sandbox.

Melihat kerja preprocessor#

Berkas kanvas.c berikut hanya berisi dua fungsi kecil dan satu makro tanpa satu pun #include, sehingga keluaran tahap preprocessor tetap pendek. Komentar pada baris pertama menandai berkas contoh yang menjadi bahan pemeriksaan tahap kompilasi, bukan program yang dijalankan.

c kanvas.c
// tidak-dijalankan

#define SISI 8

int tambah_sisi(int sisi) {
    return sisi + SISI;
}

int luas_kanvas(int sisi) {
    return sisi * SISI;
}
bash
# hentikan proses pada akhir tahap preprocessor
gcc -E kanvas.c
text Keluaran
# 0 "kanvas.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "kanvas.c"




int tambah_sisi(int sisi) {
    return sisi + 8;
}

int luas_kanvas(int sisi) {
    return sisi * 8;
}

Keluaran di atas adalah salinan program setelah seluruh direktif selesai diproses, sebelum kompilasi sesungguhnya dimulai. Baris berawalan # pada bagian atasnya bukan kode C, melainkan penanda asal berkas yang ditulis preprocessor untuk membantu pelacakan error. Makro SISI sudah hilang karena setiap penyebutnya diganti menjadi angka 8, terlihat pada badan kedua fungsi, sedangkan komentar sumber ikut dibuang. Penggantian ini menegaskan sifat #define sebagai penggantian teks sederhana yang berlaku pada seluruh baris sesudah direktifnya. Berkas ini sengaja tanpa #include karena satu baris #include <stdio.h> saja akan menyalin ratusan baris deklarasi ke dalam keluaran dan menyembunyikan poin utamanya. Fungsi main memang tidak ada di berkas ini karena peran berkasnya hanya menjadi bahan pemeriksaan, dan berkas tanpa main tetap sah untuk tahap-tahap awal.

Hasil compiler dalam bahasa assembly#

Opsi -S menghentikan proses pada akhir tahap compiler, sehingga keluarannya berupa berkas assembly yang berisi satu baris per instruksi. Opsi -O0 menetapkan tingkat optimasi nol, yaitu compiler menerjemahkan program apa adanya tanpa penghematan, sedangkan -fno-asynchronous-unwind-tables menonaktifkan tabel pelacakan error agar daftar instruksinya tidak tertutup direktif tambahan.

bash
# terjemahkan kanvas.c ke assembly tanpa optimasi
gcc -O0 -S -fno-asynchronous-unwind-tables kanvas.c -o kanvas.s
# tampilkan isi berkas assembly
cat kanvas.s
text Keluaran
	.file	"kanvas.c"
	.text
	.globl	tambah_sisi
	.type	tambah_sisi, @function
tambah_sisi:
	pushq	%rbp
	movq	%rsp, %rbp
	movl	%edi, -4(%rbp)
	movl	-4(%rbp), %eax
	addl	$8, %eax
	popq	%rbp
	ret
	.size	tambah_sisi, .-tambah_sisi
	.globl	luas_kanvas
	.type	luas_kanvas, @function
luas_kanvas:
	pushq	%rbp
	movq	%rsp, %rbp
	movl	%edi, -4(%rbp)
	movl	-4(%rbp), %eax
	sall	$3, %eax
	popq	%rbp
	ret
	.size	luas_kanvas, .-luas_kanvas
	.ident	"GCC: (Debian 14.2.0-19) 14.2.0"
	.section	.note.GNU-stack,"",@progbits

Baris yang diawali titik seperti .text dan .globl adalah direktif assembler, yaitu catatan untuk tahap berikutnya dan bukan instruksi. Prolog fungsi tambah_sisi berupa pushq %rbp dan movq %rsp, %rbp, yang menyimpan nilai register basis %rbp ke stack, yaitu wilayah memori untuk data setiap panggilan fungsi, lalu menandai awal wilayah kerja fungsi ini. Dua instruksi movl berikutnya memindahkan argumen ke slot memori lalu mengambilnya kembali, addl $8, %eax menambahkan angka 8 ke hasilnya, dan ret mengakhiri fungsi dengan kembali ke pemanggil.

Tabel berikut merangkum instruksi yang muncul pada keluaran di atas beserta perannya.

InstruksiPeran
pushmenyimpan nilai ke puncak stack
popmengambil kembali nilai dari puncak stack
movmenyalin data antara register dan memori
addmenjumlahkan dua operand
shlmenggeser bit ke kiri, setara dengan perkalian pangkat dua
retmengakhiri fungsi dan kembali ke pemanggil

Akhiran huruf pada notasi AT&T seperti movl hanya menyatakan lebar operasinya, yaitu l untuk 32 bit dan q untuk 64 bit. Fungsi luas_kanvas memperlihatkan penerjemahan lain, yaitu perkalian dengan 8 dikerjakan sebagai geser bit sall $3, %eax karena menggeser ke kiri tiga kali sama artinya dengan mengalikan delapan. Bentuk -O0 semacam ini paling nyaman untuk mempelajari struktur fungsi karena setiap langkah C masih terlihat satu per satu.

Perbedaan keluaran pada tingkat optimasi#

bash
# terjemahkan ulang dengan tingkat optimasi satu
gcc -O1 -S -fno-asynchronous-unwind-tables kanvas.c -o kanvas-o1.s
# tampilkan isi berkasnya
cat kanvas-o1.s
text Keluaran
	.file	"kanvas.c"
	.text
	.globl	tambah_sisi
	.type	tambah_sisi, @function
tambah_sisi:
	leal	8(%rdi), %eax
	ret
	.size	tambah_sisi, .-tambah_sisi
	.globl	luas_kanvas
	.type	luas_kanvas, @function
luas_kanvas:
	leal	0(,%rdi,8), %eax
	ret
	.size	luas_kanvas, .-luas_kanvas
	.ident	"GCC: (Debian 14.2.0-19) 14.2.0"
	.section	.note.GNU-stack,"",@progbits

Pada -O1 seluruh prolog push dan pop hilang karena compiler menemukan cara yang lebih murah tanpa melewati memori. Fungsi tambah_sisi kini hanya dua instruksi, yaitu leal 8(%rdi), %eax dan ret, sedangkan luas_kanvas menjadi leal 0(,%rdi,8), %eax yang menanamkan perkalian delapan langsung pada perhitungan alamatnya. Instruksi lea memang dirancang untuk menghitung alamat efektif, tetapi compiler juga memakainya sebagai penjumlahan murah karena hasilnya langsung berada di register. Kedua fungsi tetap berperilaku sama persis dengan versi -O0, hanya jumlah instruksinya jauh lebih sedikit. Perbandingan dua keluaran di atas bersifat deterministik pada mesin yang sama karena perintah dan masukannya tidak berubah. Angka -O0 dan -O1 karena itu bukan sekadar tombol kecepatan, melainkan pilihan strategi penerjemahan yang mengubah bentuk keluarannya. Sisi teknik pembuatan compiler yang menghasilkan keluaran semacam ini dibahas pada jalur compiler, sedangkan topik ini berhenti pada cara membacanya.

Berkas objek dan isi mesinnya#

bash
# kompilasi ke berkas objek dengan opsi yang menstabilkan keluaran
gcc -O1 -fno-pie -no-pie -fno-asynchronous-unwind-tables -c kanvas.c -o kanvas.o
# tampilkan disassembly berkas objek
objdump -d kanvas.o
text Keluaran

kanvas.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <tambah_sisi>:
   0:	8d 47 08             	lea    0x8(%rdi),%eax
   3:	c3                   	ret

0000000000000004 <luas_kanvas>:
   4:	8d 04 fd 00 00 00 00 	lea    0x0(,%rdi,8),%eax
   b:	c3                   	ret

Opsi -c menghentikan proses pada akhir tahap assembler dan menghasilkan kanvas.o, yaitu berkas objek yang sudah berisi kode mesin. Alat objdump -d membacanya kembali dalam bentuk disassembly, yaitu daftar byte instruksi disertai mnemoniknya. Setiap baris instruksi memuat tiga hal, yaitu alamat relatif, byte mesin dalam heksadesimal, dan mnemonik. Fungsi tambah_sisi hanya memuat empat byte, yaitu 8d 47 08 yang berarti lea 0x8(%rdi),%eax dan satu byte c3 yang berarti ret. Alamat pada kolom kiri mulai dari 0 karena berkas objek belum mengetahui tempatnya di program utuh, dan angka alamat sebenarnya baru diisi linker. Pasangan opsi -fno-pie dan -no-pie menonaktifkan pembuatan kode posisi-independen, yaitu kode yang bisa diletakkan pada alamat mana pun, sehingga alamat pada keluaran lebih sederhana dan stabil. Kolom byte inilah bentuk yang paling dekat dengan yang dilihat prosesor, yaitu deretan angka tanpa nama fungsi.

Linker dan program utuh#

Berkas kalkulator.c adalah contoh program utuh yang melewati keempat tahap sekaligus. Berkas ini memuat fungsi tambah beserta main, lalu memanggil printf yang kodenya berada di library C standar dan bukan di berkas sumber ini.

c kalkulator.c
#include <stdio.h>

int tambah(int a, int b) {
    return a + b;
}

int main(void) {
    printf("4 + 9 = %d\n", tambah(4, 9));
    return 0;
}
text Keluaran
4 + 9 = 13

Perintah gcc kalkulator.c -o kalkulator mengompilasi berkas hingga tahap linker, yang menyatukan kode objeknya dengan kode printf sehingga terbentuk berkas eksekusi. Tanpa penautan dengan library, panggilan printf tidak memiliki kode yang bisa dieksekusi, dan di situlah peran linker terlihat secara nyata. Angka 13 pada keluaran dihitung oleh instruksi penjumlahan yang sama seperti yang diperiksa pada bagian sebelumnya, kini berada di dalam program yang benar-benar berjalan. Fungsi tambah di dalamnya diterjemahkan dengan pola yang serupa dengan tambah_sisi, hanya nilai tambahannya berasal dari parameter kedua. Apabila -o ditinggalkan, hasilnya dinamai a.out secara default, sehingga menamai keluaran secara eksplisit menjadi kebiasaan yang baik agar direktori kerja tetap rapi.

Kesalahan umum#

Tiga kekeliruan berikut paling sering muncul ketika tahapan kompilasi mulai dipraktikkan.

  1. Menjalankan gcc kanvas.c tanpa -c. Proses berjalan hingga tahap linker, lalu berhenti dengan pesan undefined reference to 'main' karena berkas ini tidak memiliki titik mulai program. Perbaikannya dengan menambahkan -c sehingga proses berhenti pada berkas objek yang memang tidak memerlukan main.
  2. Menganggap alamat pada keluaran objdump sebagai alamat akhir di memori. Angka pada kolom kiri masih alamat relatif per bagian berkas objek, sedangkan alamat sebenarnya ditetapkan linker ketika program utuh dibentuk. Karena itu, nilai itu tidak boleh dipakai sebagai asumsi alamat program yang sedang berjalan.
  3. Menjalankan berkas .s dengan harapan langsung berproses. Berkas assembly adalah teks untuk dibaca manusia, sehingga harus dihantar ke assembler terlebih dahulu, misalnya melalui gcc kanvas.s -o kanvas, sebelum menghasilkan program.

Ringkasan#

  • gcc menjalankan empat tahap penerjemahan, yaitu preprocessor, compiler, assembler, dan linker, yang tiga tahap pertamanya dapat dihentikan dengan opsi -E, -S, dan -c.
  • Keluaran -O0 memuat prolog push dan pop beserta perpindahan memori yang eksplisit, sedangkan -O1 memadatkannya menjadi instruksi lea yang singkat.
  • Berkas objek menyimpan instruksi mesin dengan alamat relatif, dan baru linker mengisi alamat sebenarnya saat menyatukannya dengan library menjadi berkas eksekusi.

Referensi#