- 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
Hacker News yorumları
Sıkıldığımdan macOS’te Rust ile benzer bir şey denedim;
#![no_std],#![no_main]ve doğrudanWRITE/EXITsistem ç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.
rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:mainile derlenmişti.kernel32içindekiExitProcess,GetStdHandle,WriteFiledoğ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ı.
maini tamamen bırakıp_startkullanmak 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.
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.
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
Musl’ın atladığı başka bir tavşan deliği daha var. Linux’ta sistem fonksiyonlarını çağırmak yalnızca doğrudan
syscallkullanmak 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 0x80veyasysentergibi çekirdek çağrı mekanizmalarından birini de seçerdi.https://man7.org/linux/man-pages/man7/vdso.7.html
x86-64’te standart sistem çağrısı yöntemi
SYSCALLkomutudur; 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.
printfinputsa dönüşmesine neden olan optimizasyon ve inlining kapatılmalı ya da en baştan doğrudanputskullanan 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-tempsekleyip 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
readelfveobjdumpgibi 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ıyorumTabii arayınca öğrencilerden çok daha iyisini yapan biri olduğunu gördüm: https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....
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
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
writesistem çağrısında durup geri kalanını kabaca geçiştiriyorSistem çağrısına kadar olan kısım özünde
printf’inputs’u çağırdığı,puts’unwrite’ı çağırıpchar const*ilettiği ve biraz kayıt-kuyut yaptığı bir fonksiyon çağrıları zinciri; kişisel olarak en ilginç kısmı bu değilAsı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 yaparSonrası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
https://en.wikipedia.org/wiki/Visual_system
CPython iç yapısı, Windows
conhost, yazı tipi rasterizasyonu, GPU rendering gibi konuları ele alıyor: https://gynvael.coldwind.pl/?id=754/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 olanexecve, 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, gereklimallocdahil 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.ckullanırsanız mümkün. Tam olarak interpreter değil, daha çok bellek içi derleyici sayılır amaEk geek puanı istiyorsanız programın “Hello world” yerine “Hellorld” demesini sağlayabilirsiniz