3 poin oleh GN⁺ 2024-04-09 | 1 komentar | Bagikan ke WhatsApp
  • 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

 
GN⁺ 2024-04-09
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 call WRITE/EXIT secara langsung, kalau dilihat di Ghidra hasilnya tetap sekitar 16KB
    Mungkin masih bisa diperkecil lagi lewat code golf, tetapi kemungkinan besar sudah ada yang pernah mencobanya dan mendokumentasikannya

    • Di Windows, program serupa berukuran 3072 byte, dan dikompilasi dengan rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:main
      Saya memanggil ExitProcess, GetStdHandle, dan WriteFile dari kernel32 secara 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 berarti
      Sebagai referensi, database debug PDB terkait berukuran 208.896 byte
    • Kalau ingin membuatnya sekecil mungkin, main harus dibuang sama sekali dan memakai _start, serta meneruskan flag linker agar section alignment tidak dilakukan
      https://darkcoding.net/software/a-very-small-rust-binary-ind... menunjukkan bahwa dengan cara seperti ini ukuran bisa dengan mudah ditekan mendekati 500 byte
    • Code golf itu menyenangkan, tetapi sekarang ukuran page juga perlu diperhatikan
      Untuk bahasa yang punya stack, executable pada akhirnya kemungkinan besar tetap akan memuat setidaknya dua page, yaitu satu read-only dan satu read-write
    • Proyek min-sized-rust merangkum banyak optimasi untuk mengecilkan ukuran biner Rust
      Kalau semua optimasi diterapkan, setahu saya hello world akhirnya menjadi sekitar 8KB: https://github.com/johnthagen/min-sized-rust
    • XNU tidak memuat Mach-O yang lebih kecil dari satu page, jadi di platform itu sayangnya tidak banyak ruang untuk permainan biner kecil
  • Ada lagi rabbit hole lain yang dilewati Musl. Di Linux, memanggil fungsi sistem tidak selalu berarti menulis syscall secara langsung
    Cara 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 syscall itu sendiri tidak diperlukan, dan dulu vDSO juga kadang memilih salah satu mekanisme pemanggilan kernel seperti int 0x80 atau sysenter
    https://man7.org/linux/man-pages/man7/vdso.7.html

    • Hanya di x86 32-bit vDSO berisi shim fast system call yang bersifat umum
      Di x86-64, cara system call standarnya adalah instruksi SYSCALL, dan vDSO hanya berisi fungsi-fungsi terkait waktu serta beberapa fungsi terkait SGX
  • Tulisan 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 printf berubah menjadi puts, atau sekalian tulis hello world yang dari awal langsung memakai puts
    Selain 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-temps ke cc. Kalau pipeline-nya dilihat langsung, bagian yang terasa seperti sulap akan jauh berkurang

  • Teringat tugas yang saya sukai di kelas pemrograman sistem kampus: diberi potongan hello world C++ dan diminta mengumpulkan biner hasil kompilasi sekecil mungkin
    Saya masih ingat membedah program dengan alat seperti readelf dan objdump, 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....

    • Entah ada makna khusus dari fakta bahwa potongan itu adalah C++
      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
    • Kalau ini tugas yang menyenangkan, saya jadi penasaran kenapa tidak lebih banyak orang yang mencoba membuat biner sekecil mungkin untuk program selain “hello world”
      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 write lalu melewati sisanya dengan agak kasar
    Sampai sebelum system call, pada dasarnya ini hanya rantai pemanggilan fungsi: printf memanggil puts, puts memanggil write, sambil meneruskan char const* dan melakukan sedikit pembukuan, jadi bagi saya itu bukan bagian yang paling menarik
    Bagian yang benar-benar menarik dan mulai rumit adalah setelah system call. Kernel menghubungkan stdout proses 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-aliasing
    Setelah 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

    • Penjelasannya bagus, tetapi pada akhirnya tetap berhenti di sistem visual manusia, dan justru itu bagian yang benar-benar menarik dan rumit :)
      https://en.wikipedia.org/wiki/Visual_system
    • Kalau suka pembahasan sedalam ini, mungkin juga akan suka tulisan Gynvael Coldwind yang membedah bagaimana Python hello world dijalankan di Windows
      Membahas internal CPython, Windows conhost, rasterisasi font, rendering GPU, dan lain-lain: https://gynvael.coldwind.pl/?id=754
    • Sebagian besar dari ini sebenarnya tidak berkaitan dengan program itu sendiri. Misalnya, kalau output-nya di-pipe ke /dev/null, hal-hal ini tidak akan terjadi
    • Hal-hal yang terjadi sebelum _start juga belum dibahas. Misalnya bagaimana proses lahir di Linux, khususnya execve yang cukup aneh, bagaimana program dimuat ke memori, binfmt_* dan binfmt_misc yang kuat, relokasi, frame penanganan pengecualian, section, keseluruhan ELF loader, serta alokasi sumber daya sistem operasi termasuk malloc yang diperlukan
  • Pernyataan “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 memori
    Kalau ingin tambahan poin keanehan, buat saja program itu mengatakan “Hellorld” alih-alih “Hello world”