3 puan yazan GN⁺ 2024-04-09 | 1 yorum | WhatsApp'ta paylaş
  • C ile yazılmış tek satırlık Hello World bile ekranda görünmek için sırayla derlenmiş çalıştırılabilir dosyadan, C standart kütüphanesinden, sistem çağrılarından, çekirdekten ve terminalden geçer
  • gcc hello.c -o hello ile üretilen çıktı bir ELF 64-bit x86-64 çalıştırılabilir dosyasıdır ve ELF başlığındaki giriş noktası 0x1060ta önce _start kodu çalıştırılır
  • Kullanıcının yazdığı main() doğrudan başlamaz; _start ve __libc_start_main üzerinden geçer. printf("Hello World!\n") ise optimizasyonla daha basit bir puts() çağrısına dönüşür
  • Dize, .rodata içindeki 0x2004 adresinde bayt dizisi olarak saklanır; C dizeleri uzunluk bilgisi yerine NULL sonlandırıcı ile sonunu belirler
  • Gerçek çıktı yolu libc tamponlama ve kilitleme, write veya writev sistem çağrısı, Linux çekirdeği, pseudo-terminal ve terminal emülatörü işlemesine kadar uzanır; çalıştırma ortamına göre değişebilir

C Hello World ile başlangıç

  • Örnek program C ile yazılmış aşağıdaki koddur
#include <stdio.h>

int main() {
    printf("Hello World!\n");
    return 0;
}
  • Python’daki print('Hello World!') ile aynı sonucu üretir, ancak C programı yorumlayıcıyla doğrudan çalıştırılmaz; önce derlenmesi gerekir
gcc hello.c -o hello
./hello
  • Çalıştırma sonucu şöyledir
Hello World!
  • C veya assembly hakkında temel bilgi varsa akışı takip etmek kolay sayılabilecek düzeydedir

Çalıştırılabilir dosyanın kimliği

  • file hello sonucundaki kilit nokta ELF executable, x86-64 ifadesidir
    • ELF çalıştırılabilir dosyası, Linux’ta çalıştırılabilen program biçimidir
    • x86-64, 64 bit x86 işlemciler için makine kodu programı anlamına gelir
  • readelf -h hello ile ELF başlığına bakıldığında Entry point address: 0x1060 gösterilir
    • Bu adres, program yüklendikten sonra CPU’nun yürütmeye başlayacağı konumdur

_start ve C kütüphanesi giriş noktası

  • objdump -D hello ile disassemble edildiğinde 0x1060 konumunda _start bulunur
  • _start, kullanıcının doğrudan yazdığı kod değil; derleyicinin, daha doğru ifadeyle linker’ın otomatik eklediği koddur
  • Bu kod ilklendirmeyi yaptıktan sonra şu çağrıyı çalıştırır
call *0x2f53(%rip)        # 3fd8 <__libc_start_main@GLIBC_2.34>
  • Bu fonksiyon programın içinde doğrudan tanımlı değildir; standart C kütüphanesi tarafındadır
  • readelf -d hello çıktısının dinamik bölümünde libc.so.6 bağımlılığı gösterilir
Shared library: [libc.so.6]
  • libc.so.6, sistemin standart C kütüphanesidir; Linux’taki .so dosyaları, Windows’taki .dll dosyaları gibi birden çok programın paylaşabildiği kodu içerir
  • C kütüphanesi komut satırı argümanları ve ortam değişkenlerinin işlenmesi gibi ilklendirmeleri üstlenir, main()i çağırır ve dönüş değeriyle programı sonlandırır

main() içinde gerçekte olanlar

  • Disassembly sonucunda main() 0x1149 adresindedir
  • main() akışı şöyledir
    • Stack frame’i ayarlar
    • Fonksiyon çağrısı argümanını hazırlar
    • Hello World çıktı fonksiyonunu çağırır
    • Stack frame’i temizler
    • Çıkış kodu 0 ile döner
  • Kilit nokta, dize adresini argüman olarak hazırlayıp puts@plt çağıran kısımdır
lea    0xeac(%rip),%rax
call   1050 <puts@plt>
  • Kaynak kodda printf() vardı, ancak derleyici bunu optimize ederek puts() çağrısına dönüştürdü
    • printf(), biçimlendirilmiş çıktı işlevlerine sahip karmaşık bir fonksiyondur
    • Örnek, değişken yerleştirme gibi biçimlendirme özellikleri kullanmadığından daha basit puts() ile değiştirilir
    • puts() dizenin sonuna doğrudan satır sonu eklediği için özgün dizedeki \n de kaldırılır

Dizelerin saklanma biçimi

  • Dize, .rodata bölümündeki 0x2004 adresindedir
  • Bu konumdaki baytlar şöyledir
48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00
  • Bu bayt dizisi "Hello World!" ve sondaki 0x00 olarak yorumlanır
  • 0x00 bir NULL sonlandırıcıdır ve C dizesinin sonunu gösterir
  • C dizeleri uzunluk bilgisini beraberinde taşımadığı için, dize alan fonksiyonlar NULL sonlandırıcıyla karşılaşana kadar bayt bayt işler
  • Dizeler arasında NULL sonlandırıcı yoksa C fonksiyonu birden fazla dizeyi arka arkaya işleyebilir ya da izin verilmeyen belleği okuyup Segmentation Fault ile sonlanabilir

Glibc’in puts() yolu

  • puts@plt eninde sonunda standart kütüphane tarafına bağlanır
  • Glibc’te puts(), _IO_puts ile ilişkilendirilir
  • _IO_puts şu işleri yapar
    • Dize uzunluğunu hesaplar
    • stdout çıktı akışı için kilit alır
    • Koşulları kontrol edip _IO_sputn çağırır
    • Satır sonu karakterini yazar
    • Kilidi bırakır ve yazdırılan karakter sayısını döndürür
  • Glibc’in iç uygulaması büyük ve karmaşıktır; bu nedenle akış daha küçük bir C kütüphanesi olan musl libc üzerinden sürdürülür

musl libc’de çıktının aşağı inme süreci

  • musl’daki puts(), stdout kilidini alır; fputs() ve putc_unlocked('\n', stdout) çağırdıktan sonra kilidi bırakır
  • fputs() dize uzunluğunu hesaplar ve fwrite() çağırır
  • fwrite() tekrar kilit alır ve __fwritex() çağırır
  • __fwritex() tampon durumunu kontrol eder ve gerekirse çıktı akışının write fonksiyon işaretçisini çağırır
  • stdout, fd = 1 olarak tanımlıdır ve write fonksiyonu başlangıçta __stdout_write olarak atanır
  • __stdout_write(), TIOCGWINSZ ioctl işlemini yaptıktan sonra __stdio_write() çağırır
  • __stdio_write(), SYS_writev ile sistem çağrısı yapar

Sistem çağrısı ve çekirdek

  • C kütüphanesi tek başına donanımla doğrudan iletişim kuramaz; donanıma erişim işletim sistemi çekirdeğinin görevidir
  • Çıktı isteği en sonunda işletim sisteminden metni çıktı akışına yazmasını isteyen bir sistem çağrısıyla biter
  • Tipik çıktı write sistem çağrısıyla gerçekleştirilir; musl ise birden çok tamponu dizi olarak yazabilen writev kullanır
  • musl’ın x86-64 sistem çağrısı uygulaması, argüman sayısına göre __syscall0dan __syscall6ya kadar ayrılır
  • Her fonksiyon argümanları CPU register’larına yerleştirir ve syscall komutunu çalıştırır
    • Denetim çekirdeğe geçer
    • Çekirdek register’lardaki parametreleri okur ve istenen sistem çağrısını gerçekleştirir

Çekirdekten sonra ekranda görünene kadar

  • Linux çekirdeği write sistem çağrısını alır ve veriyi açık dosyaya ya da akışa yazar
  • write sistem çağrısı argüman olarak dosya tanıtıcısı, yazılacak tampon ve yazılacak bayt sayısını alır
  • Örnek ortamda hello programı GNOME terminal emülatöründe çalıştırılır ve stdout, /dev/pts/0 pseudo-terminal’ine bağlanır
  • Çekirdek Hello World mesajını tampona kaydeder; terminal emülatörü bunu okuyup ekranda gösterir
  • Terminal emülatörü metni frame’lere işler; X sunucusu veya compositor bunu diğer uygulama ekranlarıyla birleştirdikten sonra çekirdek üzerinden ekranda gösterir
  • Çalıştırma ortamına göre bundan sonraki yol değişebilir
    • Uzak oturum açmada çekirdek metni sshdye gönderir; sshd bunu şifrelenmiş paketler olarak tekrar çekirdeğe aktarır ve internet üzerinden yollar
    • Fiziksel terminal ve serial-to-USB adaptörü kullanıldığında çekirdek metni USB paketleri olarak gönderir
    • framebuffer console’da çekirdek metni frame olarak işler ve ekrana çıktı verir

Küçük bir çıktının ardındaki karmaşıklık

  • Hello World mesajının gönderilmesi, tek bir programda gerçekleşen tek bir sistem çağrısından ibarettir
  • Modern yazılım ve donanım, küçük bir eylemi bile tamamen izlemeyi zorlaştıracak kadar karmaşık ve ince katmanlardan oluşur
  • Bu açıklama birçok ayrıntıyı, istisnayı ve çekirdeğin iç işleyişini atlayarak yalnızca ana akışı takip eder

1 yorum

 
GN⁺ 2024-04-09
Hacker News yorumları
  • Sıkıldığımdan macOS’te Rust ile benzer bir şey denedim; #![no_std], #![no_main] ve doğrudan WRITE/EXIT sistem çağrılarını kullanan bir “Hello, world!” bile Ghidra ile bakıldığında çıktı nasıl olursa olsun yaklaşık 16 KB civarındaydı.
    Daha da küçültmek için code golf yapmak mümkün olabilir, ama büyük olasılıkla birileri bunu zaten denemiş ve belgeleyip yazmıştır.

    • Windows’ta benzer bir program 3072 bayttı ve rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:main ile derlenmişti.
      kernel32 içindeki ExitProcess, GetStdHandle, WriteFile doğrudan çağrıldı; hello world olduğu için panic işleyicisi kabaca bırakıldı. Çalıştırılabilir dosyanın içinde hâlâ epey padding bulunduğundan boyutu artırmadan daha fazlası eklenebilir; daha “suça yakın” yöntemlerle küçültmek de mümkün olabilir ama pek anlamlı görünmüyor.
      Bu arada ilgili PDB debug veritabanı 208.896 bayttı.
    • En küçük hâle getirmek için maini tamamen bırakıp _start kullanmak gerekir; ayrıca section alignment yapılmaması için linker flag’leri de geçirmek gerekir.
      https://darkcoding.net/software/a-very-small-rust-binary-ind... adresine bakarsanız bu yöntemle kolayca 500 bayt civarına inilebiliyor.
    • Code golf eğlenceli, ama günümüzde sayfa boyutunun ne olduğuna da bakmak gerekiyor.
      Stack’i olan bir dilde çalıştırılabilir dosya sonuçta salt okunur/okunur-yazılır sayfalar olarak en az iki sayfa kadar belleğe yüklenme olasılığı taşıyor.
    • min-sized-rust projesinde Rust ikili dosya boyutunu azaltmaya yönelik birçok optimizasyon derlenmiş durumda.
      Tüm optimizasyonlar uygulandığında hello world’ün sonunda yaklaşık 8 KB civarında olduğunu hatırlıyorum: https://github.com/johnthagen/min-sized-rust
    • XNU, bir sayfadan küçük Mach-O dosyalarını yüklemediği için, o platformda ne yazık ki küçük ikili dosya oyunlarına pek alan kalmıyor.
  • Musl’ın atladığı başka bir tavşan deliği daha var. Linux’ta sistem fonksiyonlarını çağırmak yalnızca doğrudan syscall kullanmak demek değil.
    Daha “usulüne uygun” yöntem vDSO’yu çağırmaktır. Bu, çekirdeğin adres uzayına otomatik olarak eşlediği sihirli küçük bir kütüphane olduğundan, çekirdek sistem çağrısını gerçekleştirmek için en uygun kodu sağlayabilir.
    Bazı sistem çağrıları kullanıcı alanında çalıştırılarak syscallın kendisine gerek kalmayabilir; eskiden vDSO, int 0x80 veya sysenter gibi çekirdek çağrı mekanizmalarından birini de seçerdi.
    https://man7.org/linux/man-pages/man7/vdso.7.html

    • vDSO yalnızca 32 bit x86 üzerinde genel amaçlı hızlı sistem çağrısı shim’i içerir.
      x86-64’te standart sistem çağrısı yöntemi SYSCALL komutudur; vDSO’da ise yalnızca zamanla ilgili fonksiyonlar ve SGX ile ilgili birkaç fonksiyon bulunur.
  • Dillere göre “Hello World” programlarının overhead’ini karşılaştıran yazı da bakmaya değer: https://drewdevault.com/2020/01/04/Slow.html
    Devam yazısı: https://drewdevault.com/2020/01/08/Re-Slow.html
    Linux’ta en küçük programı yapan efsanevi bir yazı da var. Program yalnızca durum kodu 42 ile sonlanıyor: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
    Aynı sitede en küçük “Hello World” programını da bulabilirsiniz.

  • Bu yazı, aslında programın gerçek giriş noktası olarak da görülebilecek dinamik linkerın rolünü neredeyse tamamen atlıyor.
    Bu bakış açısını merak ediyorsanız https://gist.github.com/kenballus/c7eff5db56aa8e4810d39021b2... adresine bakabilirsiniz.

  • DOS meraklıları için: DOS’ta assembly/makine koduyla yazılmış “hello, world” 23 bayta kadar küçülebiliyordu: https://github.com/susam/hello
    Bu 23 baytın 15 baytı dolar işaretiyle biten dizgenin kendisi olduğundan, gerçek makine kodu yalnızca dört x86 komutundan oluşan 8 bayttır.

  • Yazı iyiydi ama iki şey daha yapılmasını isterdim. printfin putsa dönüşmesine neden olan optimizasyon ve inlining kapatılmalı ya da en baştan doğrudan puts kullanan bir hello world yazılmalıydı.
    Ayrıca derleme aşamasını önişleme, derleme, assembly’ye çevirme ve linkleme olarak dört adıma ayırmak ya da ccye --save-temps ekleyip oluşan dosyaları açıklamak iyi olurdu. Pipeline’ı doğrudan görünce büyü gibi görünen kısımlar çok daha azalıyor.

  • Üniversitedeki sistem programlama dersinde sevdiğim bir ödevi hatırlattı: “Bir C++ hello world parçası verilecek, mümkün olan en küçük derlenmiş ikiliyi teslim edin” ödeviydi
    readelf ve objdump gibi araçlarla programın içine bakıp katmanları ve derleyici optimizasyonlarını yavaş yavaş soyarak hâlâ “hello world” yazdıran en küçük ikiliye kadar indirdiğimi hatırlıyorum
    Tabii arayınca öğrencilerden çok daha iyisini yapan biri olduğunu gördüm: https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....

    • O parçanın C++ olmasının bir anlamı var mı emin değilim
      Sadece hello world yazdıran en küçük ikiliyi yapıp anlamsal olarak eşdeğer olduğunu iddia edemez miyiz? Dize verisi dahil olsa bile x86’da on kadar komut yeter gibi geliyor
    • Sevilen bir ödevse, neden “hello world” dışındaki programlarda da mümkün olan en küçük ikiliyi yapan daha fazla insan görmediğimizi merak ediyorum
      Kişisel olarak bilgisayarımdaki alanı korumayı sevdiğim için bu bana eğlenceli geliyor, ama bugünlerde 10MiB, 20MiB, 50MiB, 100MiB’ı aşan çok program yazılıyor. Ticari ortamlarda ticari amaçlarla yapılanlar var, ama sırf keyif için yazıldığı söylenen programlar da çok. Küçük program kullanmanın keyfi yok mu?
  • “Gece yarısını geçti, artık uyumalıyım” tarzı kapanış, aksine bu yazı için mükemmel bir final olmuş

  • Ne yazık ki birçok “hello world” derinlemesine incelemesi gibi, bu yazı da write sistem çağrısında durup geri kalanını kabaca geçiştiriyor
    Sistem çağrısına kadar olan kısım özünde printf’in puts’u çağırdığı, puts’un write’ı çağırıp char const* ilettiği ve biraz kayıt-kuyut yaptığı bir fonksiyon çağrıları zinciri; kişisel olarak en ilginç kısmı bu değil
    Asıl ilginç ve karmaşık olan şey sistem çağrısından sonra başlıyor. Çekirdek, sürecin stdout’ını terminal emülatörünün girişine bağlar; terminal de yazı tipi işleme kütüphanesi ve GPU sürücüsüyle framebuffer’ı hazırlar. Karakter baytlarına karşılık gelen yazı tipi konturlarını diskten okur, viewport’a uydurur, boyutlandırma, kerning ve yazı tipi metriklerini uygular; ardından GPU rasterizasyon ve anti-aliasing yapar
    Sonrasında pencere yöneticisi terminal pencere çerçevesiyle masaüstünü birleştirir; saydamlık ya da buzlu cam efekti varsa bunlar shader ile işlenir. Ortaya çıkan framebuffer, monitör çözünürlüğüne ve renk derinliğine göre HDMI ya da DisplayPort sinyali olarak paketlenir; kablodan ve ekran giriş devresinden geçip piksel adresleme sinyaline dönüşür. LCD, OLED, plazma, CRT’ye göre yenileme yöntemi farklıdır; örneğin 3840×2400 WRGB OLED yaklaşık 36,86 milyon alt pikseli yönetmek zorundadır
    Tüm bu süreç, 60Hz temelinde tek kare süresi olan 16,67ms içinde gerçekleşir

    • Açıklama güzel, ama sonuçta insanın görsel sisteminde durmuş oluyor; orası da gerçekten ilginç ve karmaşık kısım :)
      https://en.wikipedia.org/wiki/Visual_system
    • Bu derinlikte incelemeleri seviyorsanız, Gynvael Coldwind’in Python hello world’ün Windows’ta nasıl çalıştığını kurcaladığı yazısını da sevebilirsiniz
      CPython iç yapısı, Windows conhost, yazı tipi rasterizasyonu, GPU rendering gibi konuları ele alıyor: https://gynvael.coldwind.pl/?id=754
    • Bunların çoğu programın kendisiyle alakasız. Örneğin çıktıyı /dev/null’a pipe etseydiniz bunlar olmazdı
    • _start’tan önce olanlar da eksik. Örneğin Linux’ta bir sürecin nasıl doğduğu, özellikle de epey tuhaf olan execve, programın belleğe yüklenme süreci, binfmt_* ve güçlü binfmt_misc, relocation, exception handling frame’leri, section’lar, genel olarak ELF loader, gerekli malloc dahil işletim sistemi kaynak ayırmaları gibi şeyler var
  • “Python’dan farklı olarak bu programı çalıştırmak için bir interpreter çağıramazsınız” sözü tam doğru değil
    tcc -run hello.c kullanırsanız mümkün. Tam olarak interpreter değil, daha çok bellek içi derleyici sayılır ama
    Ek geek puanı istiyorsanız programın “Hello world” yerine “Hellorld” demesini sağlayabilirsiniz