- Bahkan satu baris Hello World yang ditulis dalam C harus melewati berurutan file eksekusi hasil kompilasi, pustaka standar C, system call, kernel, dan terminal sebelum tampil di layar
- Hasil dari
gcc hello.c -o hello adalah file eksekusi ELF 64-bit x86-64, dan kode _start dijalankan lebih dulu dari entry point 0x1060 pada header ELF
main() yang ditulis pengguna tidak dimulai secara langsung, melainkan melalui _start dan __libc_start_main, sementara printf("Hello World!\n") disederhanakan lewat optimisasi menjadi pemanggilan puts()
- String disimpan sebagai deretan byte di
0x2004 pada .rodata, dan string C menentukan akhir bukan dengan informasi panjang melainkan dengan terminator NULL
- Jalur output yang sebenarnya berlanjut melalui buffering dan locking libc, system call
write atau writev, kernel Linux, pseudo-terminal, hingga rendering terminal emulator, dan dapat berbeda tergantung lingkungan eksekusi
Berangkat dari C Hello World
- Program contoh ditulis dalam C sebagai kode berikut
#include <stdio.h>
int main() {
printf("Hello World!\n");
return 0;
}
- Hasilnya sama seperti
print('Hello World!') di Python, tetapi program C tidak dijalankan langsung lewat interpreter dan harus dikompilasi terlebih dahulu
gcc hello.c -o hello
./hello
- Hasil eksekusinya adalah sebagai berikut
Hello World!
- Jika memiliki pengetahuan dasar tentang C atau assembly, tingkat penjelasannya cukup mudah untuk diikuti
Identitas file eksekusi
- Inti dari hasil
file hello adalah ELF executable, x86-64
- File eksekusi ELF adalah format program yang dapat dijalankan di Linux
x86-64 berarti program bahasa mesin untuk prosesor x86 64-bit
- Jika melihat header ELF dengan
readelf -h hello, akan tampil Entry point address: 0x1060
- Alamat ini adalah lokasi tempat CPU mulai mengeksekusi setelah program dimuat
_start dan masuk ke pustaka C
- Jika didisassembly dengan
objdump -D hello, ada _start di alamat 0x1060
_start bukan kode yang ditulis langsung oleh pengguna, melainkan kode yang otomatis ditambahkan oleh compiler, lebih tepatnya linker
- Kode ini melakukan inisialisasi lalu menjalankan pemanggilan berikut
call *0x2f53(%rip) # 3fd8 <__libc_start_main@GLIBC_2.34>
- Fungsi tersebut tidak didefinisikan langsung di dalam program, melainkan berada di sisi pustaka standar C
- Pada section dinamis dari
readelf -d hello, akan terlihat dependensi libc.so.6
Shared library: [libc.so.6]
libc.so.6 adalah pustaka standar C pada sistem, dan file .so di Linux menyimpan kode yang bisa dipakai bersama oleh banyak program, mirip .dll di Windows
- Pustaka C menangani inisialisasi seperti argumen command line dan environment variable, lalu memanggil
main() dan mengakhiri program dengan nilai kembalian tersebut
Apa yang sebenarnya terjadi di main()
- Dalam hasil disassembly,
main() berada di 0x1149
- Alur
main() adalah sebagai berikut
- Menyiapkan stack frame
- Menyiapkan argumen pemanggilan fungsi
- Memanggil fungsi untuk mencetak Hello World
- Membersihkan stack frame
- Mengembalikan kode keluar
0
- Bagian kuncinya adalah menyiapkan alamat string sebagai argumen dan memanggil
puts@plt
lea 0xeac(%rip),%rax
call 1050 <puts@plt>
- Pada source code ada
printf(), tetapi compiler mengoptimalkannya menjadi puts()
printf() adalah fungsi kompleks yang mendukung formatted output
- Karena contoh ini tidak memakai fitur formatting seperti penyisipan variabel, ia diganti dengan
puts() yang lebih sederhana
puts() menambahkan newline sendiri di akhir string, sehingga \n pada string asli juga dihapus
Cara string disimpan
- String berada di alamat
0x2004 pada section .rodata
- Byte di lokasi tersebut adalah sebagai berikut
48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00
- Deretan byte ini ditafsirkan sebagai
"Hello World!" dan 0x00 di bagian akhir
0x00 adalah terminator NULL, yang menandai akhir string C
- Karena string C tidak menyimpan informasi panjang, fungsi yang menerima string akan memprosesnya satu byte demi satu byte sampai menemukan terminator NULL
- Jika tidak ada terminator NULL di antara string, fungsi C bisa memproses beberapa string sekaligus atau membaca memori yang tidak diizinkan hingga berakhir dengan Segmentation Fault
Jalur puts() di Glibc
puts@plt pada akhirnya berlanjut ke pustaka standar
- Di Glibc,
puts() dihubungkan ke _IO_puts
_IO_puts melakukan pekerjaan berikut
- Menghitung panjang string
- Mengambil lock untuk stream output
stdout
- Memeriksa kondisi dan memanggil
_IO_sputn
- Mencetak karakter newline
- Melepas lock dan mengembalikan jumlah karakter yang dicetak
- Implementasi internal Glibc besar dan kompleks, sehingga penjelasan berlanjut ke alur pustaka C yang lebih kecil, yaitu musl libc
Proses turunnya output di musl libc
puts() pada musl mengambil lock stdout, memanggil fputs() dan putc_unlocked('\n', stdout), lalu melepas lock
fputs() menghitung panjang string lalu memanggil fwrite()
fwrite() kembali mengambil lock dan memanggil __fwritex()
__fwritex() memeriksa status buffer dan, jika perlu, memanggil function pointer write milik stream output
stdout didefinisikan dengan fd = 1, dan fungsi write awalnya ditetapkan ke __stdout_write
__stdout_write() menjalankan ioctl TIOCGWINSZ lalu memanggil __stdio_write()
__stdio_write() melakukan system call melalui SYS_writev
System call dan kernel
- Pustaka C saja tidak bisa berkomunikasi langsung dengan hardware; akses hardware ditangani oleh kernel sistem operasi
- Permintaan output pada akhirnya berakhir sebagai system call yang meminta sistem operasi menulis teks ke stream output
- Output umum dilakukan dengan system call
write, sementara musl memakai writev yang dapat menulis beberapa buffer sebagai array
- Implementasi system call x86-64 di musl dibagi dari
__syscall0 hingga __syscall6 menurut jumlah argumen
- Masing-masing fungsi menaruh argumen ke register CPU lalu menjalankan instruksi
syscall
- Kontrol berpindah ke kernel
- Kernel membaca parameter di register dan menjalankan system call yang diminta
Setelah kernel hingga terlihat di layar
- Kernel Linux menerima system call
write lalu menulis data ke file atau stream yang terbuka
- System call
write menerima file descriptor, buffer yang akan ditulis, dan jumlah byte yang akan ditulis sebagai argumen
- Dalam lingkungan contoh, program
hello dijalankan di emulator terminal GNOME, dan stdout terhubung ke pseudo-terminal /dev/pts/0
- Kernel menyimpan pesan Hello World di buffer, lalu terminal emulator membacanya dan menampilkannya di layar
- Terminal emulator merender teks menjadi frame, lalu X server atau compositor menggabungkannya dengan layar aplikasi lain sebelum ditampilkan ke display melalui kernel
- Setelah itu, jalurnya bisa berbeda tergantung lingkungan eksekusi
- Pada login jarak jauh, kernel mengirim teks ke
sshd, lalu sshd mengirimkannya kembali ke kernel sebagai paket terenkripsi untuk dikirim melalui internet
- Jika memakai terminal fisik dan adaptor serial-to-USB, kernel mengirim teks sebagai paket USB
- Pada framebuffer console, kernel merender teks menjadi frame lalu menampilkannya ke display
Kompleksitas di balik satu output kecil
- Pengiriman pesan Hello World hanyalah satu system call yang berasal dari satu program
- Software dan hardware modern tersusun dari lapisan yang begitu kompleks dan rinci sehingga satu tindakan kecil pun sulit dilacak sepenuhnya
- Penjelasan ini mengabaikan banyak detail, pengecualian, dan perilaku internal kernel, dan hanya mengikuti alur utamanya
1 komentar
Komentar Hacker News
Iseng-iseng saya mencoba hal serupa di macOS dengan Rust, dan bahkan untuk “Hello, world!” yang memakai
#![no_std],#![no_main], serta memanggil system callWRITE/EXITsecara langsung, kalau dilihat di Ghidra hasilnya tetap sekitar 16KBMungkin masih bisa diperkecil lagi lewat code golf, tetapi kemungkinan besar sudah ada yang pernah mencobanya dan mendokumentasikannya
rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:mainSaya memanggil
ExitProcess,GetStdHandle, danWriteFiledarikernel32secara langsung, dan karena ini cuma hello world, panic handler-nya dibuat seadanya. Di dalam executable masih ada cukup banyak padding, jadi masih bisa ditambah isi tanpa memperbesar ukuran, dan mungkin juga bisa diperkecil lagi dengan cara yang lebih “kriminal”, tapi rasanya tidak terlalu berartiSebagai referensi, database debug PDB terkait berukuran 208.896 byte
mainharus dibuang sama sekali dan memakai_start, serta meneruskan flag linker agar section alignment tidak dilakukanhttps://darkcoding.net/software/a-very-small-rust-binary-ind... menunjukkan bahwa dengan cara seperti ini ukuran bisa dengan mudah ditekan mendekati 500 byte
Untuk bahasa yang punya stack, executable pada akhirnya kemungkinan besar tetap akan memuat setidaknya dua page, yaitu satu read-only dan satu read-write
Kalau semua optimasi diterapkan, setahu saya hello world akhirnya menjadi sekitar 8KB: https://github.com/johnthagen/min-sized-rust
Ada lagi rabbit hole lain yang dilewati Musl. Di Linux, memanggil fungsi sistem tidak selalu berarti menulis
syscallsecara langsungCara yang lebih “sopan” adalah memanggil vDSO. Ini semacam pustaka kecil ajaib yang otomatis dipetakan kernel ke address space, sehingga kernel bisa menyediakan kode yang paling optimal untuk menjalankan system call
Beberapa system call bahkan bisa dijalankan di user space sehingga
syscallitu sendiri tidak diperlukan, dan dulu vDSO juga kadang memilih salah satu mekanisme pemanggilan kernel sepertiint 0x80atausysenterhttps://man7.org/linux/man-pages/man7/vdso.7.html
Di x86-64, cara system call standarnya adalah instruksi
SYSCALL, dan vDSO hanya berisi fungsi-fungsi terkait waktu serta beberapa fungsi terkait SGXTulisan yang membandingkan overhead program “Hello World” di berbagai bahasa juga menarik untuk dibaca: https://drewdevault.com/2020/01/04/Slow.html
Tulisan lanjutannya: https://drewdevault.com/2020/01/08/Re-Slow.html
Ada juga tulisan legendaris tentang membuat program terkecil di Linux. Programnya hanya keluar dengan status code 42: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
Di situs yang sama juga ada program “Hello World” terkecil
Tulisan ini pada dasarnya hampir melewati peran dynamic linker, yang juga bisa dianggap sebagai entry point sebenarnya dari program
Kalau penasaran dengan sudut pandang ini, lihat https://gist.github.com/kenballus/c7eff5db56aa8e4810d39021b2...
Bagi penggemar DOS, “hello, world” yang ditulis dalam assembly/bahasa mesin di DOS dulu bisa diperkecil sampai 23 byte: https://github.com/susam/hello
Dari 23 byte itu, 15 byte dipakai oleh string itu sendiri yang diakhiri tanda dolar, jadi kode mesin aslinya hanya 8 byte yang terdiri dari empat instruksi x86
Tulisannya bagus, tetapi ada dua hal lagi yang sebaiknya ditambahkan. Matikan optimisasi dan inlining yang membuat
printfberubah menjadiputs, atau sekalian tulis hello world yang dari awal langsung memakaiputsSelain itu, akan lebih baik jika tahap kompilasi dibagi menjadi empat langkah: preprocessing, compilation, assembly, dan linking, atau menjelaskan file-file yang dihasilkan dengan menambahkan
--save-tempskecc. Kalau pipeline-nya dilihat langsung, bagian yang terasa seperti sulap akan jauh berkurangTeringat tugas yang saya sukai di kelas pemrograman sistem kampus: diberi potongan
hello worldC++ dan diminta mengumpulkan biner hasil kompilasi sekecil mungkinSaya masih ingat membedah program dengan alat seperti
readelfdanobjdump, lalu mengupas layer dan optimisasi kompilator sedikit demi sedikit sampai ukurannya menyusut menjadi biner terkecil yang masih tetap mencetak “hello world”Tentu saja setelah saya cari, ada orang yang hasilnya jauh lebih baik daripada para mahasiswa: https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....
Bukankah kita bisa langsung membuat biner sekecil mungkin yang hanya mencetak hello world, lalu mengklaim bahwa itu setara secara semantik. Bahkan jika data string-nya ikut dimasukkan, rasanya cukup sekitar sepuluh instruksi x86 saja
Secara pribadi saya senang menghemat ruang di komputer yang saya punya, jadi ini terasa menyenangkan, tetapi sekarang banyak program berukuran lebih dari 10MiB, 20MiB, 50MiB, bahkan 100MiB. Ada yang dibuat untuk tujuan komersial di lingkungan komersial, tetapi banyak juga program yang katanya ditulis murni demi kesenangan. Apa memang tidak ada kesenangan dalam menulis program kecil
Penutup seperti “sudah lewat tengah malam, sebaiknya saya tidur” justru merupakan akhir yang sempurna untuk tulisan ini
Sayangnya, seperti banyak pembahasan mendalam tentang “hello world”, tulisan ini juga berhenti di system call
writelalu melewati sisanya dengan agak kasarSampai sebelum system call, pada dasarnya ini hanya rantai pemanggilan fungsi:
printfmemanggilputs,putsmemanggilwrite, sambil meneruskanchar const*dan melakukan sedikit pembukuan, jadi bagi saya itu bukan bagian yang paling menarikBagian yang benar-benar menarik dan mulai rumit adalah setelah system call. Kernel menghubungkan
stdoutproses ke input milik emulator terminal, lalu terminal menyiapkan framebuffer melalui pustaka rendering font dan driver GPU. Bentuk outline font yang sesuai dengan byte karakter dibaca dari disk, disesuaikan dengan viewport, diterapkan penskalaan, kerning, dan metrik font, lalu GPU melakukan rasterisasi dan anti-aliasingSetelah itu window manager mengomposisikan frame jendela terminal dan desktop, dan jika ada transparansi atau efek kaca buram, itu diproses dengan shader. Framebuffer hasilnya kemudian dipaketkan menjadi sinyal HDMI atau DisplayPort sesuai resolusi dan kedalaman warna monitor, melewati kabel serta rangkaian input layar, lalu diubah menjadi sinyal pengalamatan piksel. Tergantung apakah layarnya LCD, OLED, plasma, atau CRT, cara refresh-nya berbeda, dan misalnya WRGB OLED 3840×2400 harus menangani sekitar 36,86 juta subpiksel
Semua proses ini terjadi dalam 16,67ms, yaitu waktu satu frame pada 60Hz
https://en.wikipedia.org/wiki/Visual_system
Membahas internal CPython, Windows
conhost, rasterisasi font, rendering GPU, dan lain-lain: https://gynvael.coldwind.pl/?id=754/dev/null, hal-hal ini tidak akan terjadi_startjuga belum dibahas. Misalnya bagaimana proses lahir di Linux, khususnyaexecveyang cukup aneh, bagaimana program dimuat ke memori,binfmt_*danbinfmt_miscyang kuat, relokasi, frame penanganan pengecualian, section, keseluruhan ELF loader, serta alokasi sumber daya sistem operasi termasukmallocyang diperlukanPernyataan “berbeda dengan Python, Anda tidak bisa memanggil interpreter untuk menjalankan program ini” tidak sepenuhnya tepat
Dengan
tcc -run hello.c, itu bisa dilakukan. Secara ketat ini bukan interpreter, melainkan lebih mirip kompilator di dalam memoriKalau ingin tambahan poin keanehan, buat saja program itu mengatakan “Hellorld” alih-alih “Hello world”