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.
// tidak-dijalankan
#define SISI 8
int tambah_sisi(int sisi) {
return sisi + SISI;
}
int luas_kanvas(int sisi) {
return sisi * SISI;
}# hentikan proses pada akhir tahap preprocessor
gcc -E kanvas.c# 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.
# 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 .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,"",@progbitsBaris 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.
| Instruksi | Peran |
|---|---|
push | menyimpan nilai ke puncak stack |
pop | mengambil kembali nilai dari puncak stack |
mov | menyalin data antara register dan memori |
add | menjumlahkan dua operand |
shl | menggeser bit ke kiri, setara dengan perkalian pangkat dua |
ret | mengakhiri 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#
# 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 .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,"",@progbitsPada -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#
# 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
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 retOpsi -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.
#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;
}4 + 9 = 13Perintah 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.
- Menjalankan
gcc kanvas.ctanpa-c. Proses berjalan hingga tahap linker, lalu berhenti dengan pesanundefined reference to 'main'karena berkas ini tidak memiliki titik mulai program. Perbaikannya dengan menambahkan-csehingga proses berhenti pada berkas objek yang memang tidak memerlukanmain. - Menganggap alamat pada keluaran
objdumpsebagai 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. - Menjalankan berkas
.sdengan harapan langsung berproses. Berkas assembly adalah teks untuk dibaca manusia, sehingga harus dihantar ke assembler terlebih dahulu, misalnya melaluigcc 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
-O0memuat prologpushdanpopbeserta perpindahan memori yang eksplisit, sedangkan-O1memadatkannya menjadi instruksileayang singkat. - Berkas objek menyimpan instruksi mesin dengan alamat relatif, dan baru linker mengisi alamat sebenarnya saat menyatukannya dengan library menjadi berkas eksekusi.
Referensi#
- Computer Systems: A Programmer’s Perspective — Carnegie Mellon University — Buku ajar arsitektur komputer dari sudut pandang pemrogram yang mencakup kompilasi C, kode mesin x86-64, dan tata letak memori.
- GCC Online Documentation — GNU Project
— Dokumentasi resmi opsi
-E,-S,-c,-o, dan tingkat optimasi-O0sampai-O1. - objdump — GNU Binutils
— Dokumentasi opsi
-dyang menampilkan isi berkas objek dalam bentuk disassembly.