Bölüm 12:Bir ELF Ustasına Dönüşmek
CPU'ya “Sen”i Katmak yazısının parçası: bilgisayarının programları nasıl çalıştırdığına doğru inen uzun bir teknik tavşan deliği.
Tüm bölümler
- Giriş
- Başlamadan Önce
- Temeller
- Kernel, User Mode ve Syscall
- Mimariler: x86, ARM ve Diğerleri
- Bellek Hiyerarşisi
- Cache Nasıl Çalışır
- Zamanı Dilimle
- İşlemciyi Hızlandıran Hileler
- Tahmin, Spekülasyon ve Spectre
- Bir Program Nasıl Çalıştırılır?
- Shell'den Kernel'e
- Bir ELF Ustasına Dönüşmek
- Bellek Aslında Sanal
- Adres Çevirisi ve TLB
- Bellek Güvenliği ve Sertleştirme
- Fork'lar ve COW'lar Hakkında Konuşalım
- Dosya Sistemi ve I/O
- Sanal Makineler ve Container'lar
- Son Söz
Artık execve’ı epey iyi anlıyoruz. Çoğu yolun sonunda kernel, çalıştırılacak makine kodunu içeren son programa ulaşır. Ama koda atlamadan önce genelde bir kurulum süreci gerekir; örneğin programın farklı bölümlerinin bellekte doğru yerlere yüklenmesi gerekir. Her programın farklı türde ve miktarda belleğe ihtiyacı olduğundan, bir programın nasıl hazırlanacağını tarif eden standart dosya formatlarına ihtiyaç duyarız. Linux pek çok formatı destekler ama açık ara en yaygın olanı ELF’dir (Executable and Linkable Format).
Bu bölümde neyi çözüyoruz?
- ELF dosyasının kernel’a “beni belleğe şöyle yerleştir” bilgisini nasıl verdiğini göreceğiz.
- Program header ile section header arasındaki farkı ayıracağız.
- Static/dynamic linking, GOT, PLT ve dynamic loader rollerini basit bir akışa bağlayacağız.

(Bu sevimli çizim için Nicky Case’e teşekkürler.)
Bir not: elfler her yerde mi?
Linux’ta bir uygulama ya da komut satırı programı çalıştırdığında, bunun bir ELF binary olması çok olasıdır. macOS tarafında fiili format Mach-O’dur. Mach-O da temelde ELF ile aynı işi yapar, sadece farklı biçimde düzenlenmiştir. Windows’ta ise
.exedosyaları, yine benzer kavramları farklı bir paketle sunan Portable Executable formatını kullanır.
Bu dosyaları kernel tarafında karşılayan koda binfmt_elf denir ve bu kod, binfmt handler’ları arasında açık ara en kalabalık olanıdır: iki binden fazla satır. Bu kalabalığı aklının bir köşesine not et; bir ELF dosyasını çalıştırmak, onu okuyup belleğe atmaktan çok daha fazlası ve bölümün sonunda neden bu kadar iş çıktığını göreceksin. Bu kod, ELF dosyasındaki ayrıntıları ayrıştırmaktan ve bunları süreci belleğe yerleştirip çalıştırmak için kullanmaktan sorumludur.
Binfmt handler’larını satır sayısına göre sıralamak için biraz command-line kung fu yaptım:
$ wc -l binfmt_* | sort -nr | sed 1d
2181 binfmt_elf.c
1658 binfmt_elf_fdpic.c
944 binfmt_flat.c
836 binfmt_misc.c
158 binfmt_script.c
64 binfmt_elf_test.cDosya Yapısı
binfmt_elf’in ELF dosyalarını nasıl çalıştırdığına daha derin girmeden önce, dosya formatının kendisine bakalım. ELF dosyaları genelde dört ana parçadan oluşur:

ELF Header
Her ELF dosyasının bir ELF header’ı vardır ve her zaman dosyanın en başında durur. Görevi, dosyanın geri kalanının nasıl okunacağını söylemek.
İlk dört bayt: kimlik damgası. 7f 45 4c 46 — yani 0x7F ve ardından E, L, F harfleri. 10. bölümde kernel’ın dosyayı bu dört bayta bakarak tanıdığını görmüştük; file komutunun kararı da buradan geliyor. Kendi gözünle bakabilirsin:
$ xxd /bin/ls | head -1
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............Sonraki iki bayt: sınıf ve bayt sırası. Yukarıdaki çıktıda 02 ve 01 olarak görünüyorlar. 02 dosyanın 64-bit olduğunu, 01 ise little-endian olduğunu söylüyor. Bunların bu kadar başta durması zorunluluk: header’ın geri kalanındaki her sayının kaç bayt olduğunu ve hangi sırayla okunacağını bu iki bayt belirler. Onları okumadan devamını okuyamazsın.
Dosyanın türü (e_type). Üç değeri sık göreceksin:
ET_REL— henüz link edilmemiş ara ürün, yani bir.odosyası.ET_EXEC— sabit bir adrese yüklenen klasik executable.ET_DYN— bellekte herhangi bir adrese yüklenebilen dosya.
Burada küçük bir sürpriz var: bugün dağıtımların çoğu güvenlik gerekçesiyle programları da ET_DYN olarak derliyor — buna PIE (Position Independent Executable) deniyor ve sebebini 15. bölümde ASLR’yi konuşurken göreceğiz. Yani /bin/ls ile libc.so.6’nın tür alanı aynı görünür; ayrımı entry point’in ve PT_INTERP’in varlığı yapar.
Hedef mimari (e_machine). ELF dosyaları ARM, x86-64, RISC-V ve başka mimariler için makine kodu taşıyabilir; bu alan hangisi olduğunu söyler. Yanlış mimarideki bir binary’yi çalıştırmaya kalktığında aldığın Exec format error hatası tam olarak buradan çıkar.
Üç adres (e_entry, e_phoff, e_shoff). Sırasıyla: ilk çalışacak talimatın adresi, program header table’ın dosya içindeki konumu ve section header table’ın konumu. Yani header, “ilk komut nerede” ve “iki tablo dosyanın neresinde” sorularını cevaplayıp kenara çekiliyor.
Bu alanların hepsini okunabilir hâlde tek komutla görebilirsin:
$ readelf -h /bin/lsProgram header table ile section header table’ın dosya içinde nerede olduğunu bildirir. Bu tablolar da dosyanın başka yerlerinde duran veri bloklarına işaret eder.
Program Header Table
Şimdi kernel’ın gerçekten umursadığı tabloya geldik. Program header table’ı, programın kernel’a bıraktığı bir yerleştirme talimatı gibi düşün: “şu parçamı şu adrese koy, şuna yazma izni ver, şunu hiç yükleme.” Tabloyu oluşturan her girdi bu talimatlardan biridir ve her girdinin başında, hangi tür talimat olduğunu söyleyen bir tür alanı bulunur. Örneğin PT_LOAD, belleğe yüklenmesi gereken veri segmentini ifade eder; PT_NOTE ise herhangi bir yere yüklenmesi gerekmeyen serbest biçimli bilgi notlarını temsil eder.

Her giriş, verisinin dosya içinde nerede olduğunu ve bazen belleğe nasıl taşınacağını belirtir:
- Verinin ELF dosyası içindeki konumunu söyler.
- Verinin belleğe hangi sanal adresten yüklenmesi gerektiğini belirtebilir. Segment belleğe yüklenmeyecekse bu alan genelde boş kalır.
- İki ayrı alan verinin boyutunu tutar: biri dosyadaki boyut, diğeri ise bellekte ayrılacak bölgenin boyutudur. Bellek boyutu dosya boyutundan büyükse, eksik kısım sıfırlarla doldurulur. Bu, çalışma zamanında sıfırlanmış bir bellek bölgesi isteyen programlar için kullanışlıdır; bu tür alanlara genelde BSS denir.
- Son olarak, bir bayrak alanı belleğe yüklendiğinde hangi işlemlere izin verileceğini belirtir:
PF_Rokunabilir,PF_Wyazılabilir,PF_Xise çalıştırılabilir demektir.
Section Header Table
Section header table, section’lar hakkında bilgi taşıyan bir dizi girdidir. Bunu, ELF dosyasındaki verileri gösteren bir harita gibi düşünebilirsin. Böylece debugger gibi araçlar, farklı veri bölgelerinin ne işe yaradığını anlayabilir.

Örneğin program header table, belleğe birlikte yüklenecek geniş bir veri aralığı tanımlayabilir. Tek bir PT_LOAD girdisi hem kodu hem de global değişkenleri içerebilir. Programın çalışması için bunların ayrıca tek tek işaretlenmesi gerekmez; CPU entry point’ten başlar ve program ne zaman nereye erişmek istiyorsa oraya gider. Ama analiz yapmak isteyen bir debugger’ın her alanın tam olarak nerede başladığını ve bittiğini bilmesi gerekir; yoksa hello yazan bir metni kod sanıp çözmeye çalışabilir ve doğal olarak ortalık dağılır. İşte bu bilgi section header table’da tutulur.
Genelde ELF dosyalarında bulunsa da, section header table aslında isteğe bağlıdır. Tamamen kaldırılmış olsa bile ELF binary’leri düzgün çalışabilir. Kodlarının ne yaptığını zorlaştırmak isteyen geliştiriciler bazen section header table’ı bilerek siler ya da bozar; kötü biçimlendirilmiş ELF başlıklarıyla analizden kaçma diye bir dünya bile var.
Her section’ın bir adı, türü ve nasıl çözümlenip kullanılacağını anlatan bazı bayrakları vardır. Geleneksel isimler çoğu zaman nokta ile başlar. Sık görülen örnekler şunlardır:
.text: belleğe yüklenecek ve CPU’da yürütülecek makine kodu. TürüSHT_PROGBITSolur; çalıştırılabilir olduğunu belirtmek içinSHF_EXECINSTR, belleğe yükleneceğini belirtmek için deSHF_ALLOCbayrakları kullanılır. (İsmi seni şaşırtmasın; bu bölüm hâlâ dümdüz binary makine kodudur. Ad, Unix’ten kalma bir alışkanlık: bir programın makine kodunu taşıyan bölgeye tarihsel olarak text segment denirdi. Format değişti, isim yerinde kaldı.).data: executable içine gömülmüş, başlatılmış veriler. Örneğin bazı metinleri taşıyan global bir değişken burada bulunabilir. Low-level kod yazıyorsanstaticverilerin gittiği yer kabaca burasıdır. Bunun da türüSHT_PROGBITS’tir; bayrakları geneldeSHF_ALLOCveSHF_WRITEolur..bss: Daha önce sıfırla başlatılmış ama dosyada fiziksel olarak yer kaplamayan bellek alanlarından söz etmiştik. ELF dosyasına yığınla boş bayt koymak israf olacağı için bunun için özel bir temsil kullanılır. Debugging açısından bu alanların da görünür olması faydalıdır; bu yüzden section header table’da ayrılacak bellek boyutunu belirten bir giriş bulunur. TürüSHT_NOBITS, bayrakları iseSHF_ALLOCveSHF_WRITEolur..rodata: Yazılabilir olmaması dışında.datagibidir. Çok basit bir C programındaki"Hello, world!"dizesi burada durabilir; onu ekrana yazdıran makine kodu ise.textiçinde olur..shstrtab: Bu hoş bir implementasyon detayıdır. Section isimleri (.textya da.shstrtabgibi) doğrudan section header table içine yazılmaz. Bunun yerine her girdi, dosyada isminin tutulduğu yere giden bir offset saklar. Böylece tablodaki tüm girdiler sabit boyutta kalır ve ayrıştırılmaları kolaylaşır. Bu isim dizelerinin tamamı,SHT_STRTABtüründeki ayrı bir.shstrtabsection’ında tutulur.
Veri
Program ve section header girdilerinin tamamı, ister belleğe yüklenecek veri olsun ister program kodunun nerede durduğunu gösteren referans olsun, ELF dosyası içindeki veri bloklarına işaret eder. Bu parçaların tamamı ELF dosyasının veri alanında bulunur.

Kendi sisteminde dene:
readelf -h /bin/ls,readelf -l /bin/ls,readelf -d /bin/ls | grep NEEDED,ldd /bin/ls
Dosyanın belleğe nasıl döküldüğünü adım adım izlemek istersen:
- Başlık okunur. Kernel dosyanın ilk baytlarına bakar: `0x7F ELF` imzası, mimari, tür ve giriş noktası. İmza tutmuyorsa iş burada biter.
- Program header tablosu okunur. Kernel section header'lara hiç bakmaz — onlar linker'ın işidir. Yükleme için gereken bilgi program header'lardadır: hangi parça, dosyanın neresinden, belleğin neresine, hangi izinlerle.
- LOAD segmentleri eşlenir. Her `PT_LOAD` girdisi sanal belleğe eşlenir. Kod salt okunur ve çalıştırılabilir, veri yazılabilir ama çalıştırılamaz olur. Bu ayrım rastgele değil; bellek güvenliğinin temeli.
- Gerekiyorsa linker de yüklenir. Dosyada `PT_INTERP` varsa program dinamik bağlıdır. Kernel o yoldaki linker'ı da belleğe yükler ve kontrolü ona verir; kütüphaneleri o bulur.
- Kontrol devredilir. Stack kurulur, `argc`, `argv` ve ortam değişkenleri yazılır, register'lar temizlenir. Instruction pointer giriş noktasına ayarlanır ve user mode'a dönülür. Program artık çalışıyor.
Linking’e Kısa Bir Bakış
Şimdi yeniden binfmt_elf koduna dönelim: kernel, program header table’daki iki tür girdiye özellikle dikkat eder.
PT_LOAD girdileri, .text ve .data gibi program verilerinin bellekte nereye yerleştirileceğini belirtir. Kernel bu girdileri okur ama verinin tamamını hemen RAM’e taşımaz; yaptığı şey dosyanın ilgili parçalarını programın adres alanına iliştirmektir. Fiziksel RAM’e gelen kısım, program o adrese gerçekten dokunduğu anda gelir. Yani devasa bir binary’yi çalıştırmak, onu baştan sona belleğe kopyalamak demek değildir — nasıl olduğunu 13. bölümde göreceğiz.
Kernel’ın önem verdiği diğer program header türü ise PT_INTERP’tir; bu alan, “dynamic linking runtime”ı ya da daha pratik adıyla dynamic loader’ı işaret eder.
Dynamic linking’den önce genel olarak “linking”den söz edelim. Programcılar, programlarını yeniden kullanılabilir kütüphaneler üzerine kurar; biraz önce adı geçen libc bunun klasik örneğidir. Kaynak kodunu executable binary’ye dönüştürürken linker adlı program, ihtiyaç duyulan library kodunu bulur ve bunları binary’ye ekler. Dış kodun doğrudan dağıtılan dosyaya dâhil edildiği bu yönteme static linking denir.
Ama bazı kütüphaneler aşırı yaygındır. Libc, sistemle konuşmanın standart yolu olduğu için neredeyse her programda vardır. Bilgisayardaki her programa ayrı bir libc kopyası gömmek hem alan israfıdır hem de bakım açısından kötüdür. Tek bir yerde güncelleme yapıp bunu her programa yansıtabilmek çok daha iyidir. Dynamic linking bu derdin çözümüdür.
Static linked bir programın, bar adlı bir kütüphaneden foo fonksiyonuna ihtiyacı varsa, binary kendi içinde foo’nun bir kopyasını taşır. Dynamic linked durumda ise binary yalnızca “Benim bar kütüphanesindeki foo’ya ihtiyacım var” diyen bir referans tutar. Program çalıştırıldığında bar kütüphanesinin sistemde yüklü olduğu varsayılır ve foo’nun makine kodu gerektiğinde belleğe alınır. Sistemindeki bar güncellenirse, programın kendisini yeniden derlemeye gerek kalmadan bir sonraki çalıştırmada yeni kod kullanılabilir.

Gerçek Hayatta Dynamic Linking
Linux’ta bar gibi dynamic link edilebilen kütüphaneler genelde .so (Shared Object) uzantılı dosyalar hâlinde paketlenir. Bu .so dosyaları da programlar gibi ELF dosyalarıdır; ELF header’ın dosyanın executable mı yoksa library mi olduğunu belirten bir alan taşıdığını hatırlarsın. Shared object’lerde ayrıca .dynsym adlı bir tablo bulunur; dışarıya hangi sembolleri açtığını burada listeler. Dikkat et: az önce section header table’ın silinebileceğini ve programın yine de çalıştığını söylemiştik. Dynamic linking bundan etkilenmez, çünkü linker bu tabloyu section header’lardan değil, PT_DYNAMIC program header’ının gösterdiği adresten bulur. Kural şu: section header’lar araçlar için, program header’lar çalışma zamanı içindir.
Windows’ta bar gibi kütüphaneler .dll (dynamic link library) dosyaları olarak paketlenir. macOS ise .dylib (dynamically linked library) uzantısını kullanır. Bunlar, tıpkı macOS uygulamaları ve Windows .exe dosyaları gibi ELF’den biraz farklı biçimlendirilmiştir; ama temel fikir aynıdır.
Static linking ile dynamic linking arasındaki ilginç farklardan biri şudur: Static linking’de kütüphanenin yalnızca gerçekten kullanılan bölümleri binary’ye girer ve programın parçası olur. Dynamic linking’de ise kütüphane binary’nin içine hiç girmez; program çalışırken kütüphane dosyası, o programın bellek görüntüsüne iliştirilir. İlk bakışta bu daha savurgan görünebilir ama tersi olur: aynı kütüphaneyi kullanan yirmi program, kütüphanenin salt okunur kod bölgelerinin RAM’de tek bir kopyasını paylaşır. Yazılabilir veriler paylaşılmaz, her programın kendine ait olur. Bunun bellekte tam olarak nasıl mümkün olduğunu bir sonraki bölümde göreceğiz.
GOT ve PLT: Tembel Bağlamanın Sırrı
Dynamic linking’in çalışma zamanında nasıl işlediğini anlamak için iki kritik yapıyı bilmek gerekir: GOT (Global Offset Table) ve PLT (Procedure Linkage Table).
PLT, her paylaşımlı kütüphane fonksiyonu için bir “trambolin” görevi görür:
- Program
printfçağırdığında, aslında PLT’deki bir stub’a atlar. - İlk çağrıda PLT stub’ı, GOT’taki girişi kontrol eder — henüz çözümlenmemiştir.
- Dynamic linker’a (
ld.so) geri döner.ld.so,printf’in gerçek adresini bulur ve GOT’a yazar. - Sonraki tüm
printfçağrılarında PLT artık GOT’taki gerçek adrese gider — resolver maliyeti kalkar, yalnızca küçük bir dolaylı atlama maliyeti kalabilir.
Buna lazy binding (tembel bağlama) denir. Program başlarken tüm fonksiyon adreslerini çözmek yerine, sadece gerçekten çağrılan fonksiyonlar çözümlenir. Bu davranış her sistemde ve her binary’de açık olmak zorunda değildir; LD_BIND_NOW, linker bayrakları ve güvenlik sıkılaştırma ayarları binding zamanını program başlangıcına çekebilir.
GOT yalnızca fonksiyonlar için değil, global değişkenler için de çalışır. İçinde tuttuğu şey her zaman aynı: bir adres. Kod, bir global değişkene doğrudan adresiyle değil, “GOT’un şu kaçıncı satırındaki adres” diyerek erişir.
Bu dolaylılık her şeyin anahtarı. Sayesinde aynı makine kodu, kütüphane bellekte hangi adrese düşerse düşsün çalışır — çünkü kod hiçbir mutlak adres içermez, yalnızca GOT’a göre sabit offset’ler içerir. Buna PIC (Position Independent Code) denir ve her shared library’nin kendi GOT’u vardır.
Ama burada bir bedel var, üstelik güvenlik bedeli. GOT’un çalışma anında doldurulabilmesi için yazılabilir olması gerekir. Bir saldırgan bellekte istediği yere yazabiliyorsa, GOT’taki bir fonksiyon adresini kendi kodunun adresiyle değiştirerek programı ele geçirebilir; bu saldırıya GOT overwrite denir.
Savunması da lazy binding’den vazgeçmektir: program başlarken bütün adresleri hemen çöz, sonra GOT’u salt okunur yap. Bu moda Full RELRO denir ve -Wl,-z,relro,-z,now bayraklarıyla açılır; ELF tarafındaki karşılığı da bölümün başındaki şemada gördüğün PT_GNU_RELRO segmentidir. Modern dağıtımların çoğu bugün sistem binary’lerini bu şekilde derliyor — yani anlattığım tembel bağlama, kendi makinende çoğu program için fiilen kapalı. Farkı görmek istersen bir programı LD_BIND_NOW=1 ile çalıştırıp LD_DEBUG=bindings çıktısındaki değişikliğe bak.
Kütüphaneler Nerede Aranır?
Dynamic linker bir kütüphane adı gördüğünde onu sabit bir yerde aramaz; belirli bir sıra izler ve bu sıra hem hata ayıklamada hem güvenlikte önemlidir:
DT_RPATH— binary’ye gömülü eski usul yol. YalnızcaDT_RUNPATHyoksa dikkate alınır.LD_LIBRARY_PATH— ortam değişkeniyle verilen dizinler.DT_RUNPATH— binary’ye gömülü yeni usul yol./etc/ld.so.cache—ldconfigtarafından üretilen önbellek; asıl hızlı yol budur./libve/usr/libgibi varsayılan sistem dizinleri.
İlk üç maddedeki sıra tesadüf değil ve RPATH ile RUNPATH arasındaki tek gerçek fark burada: RPATH ortam değişkenini ezer, RUNPATH ezmez. Yani RPATH gömülmüş bir binary’nin kütüphane seçimini LD_LIBRARY_PATH ile değiştiremezsin. Hata ayıklamayı zorlaştırdığı için RPATH artık önerilmiyor.
Bir programın hangi kütüphaneleri, hangi dosyalardan çözdüğünü görmek için:
ldd /bin/lsÇözümlemenin adım adım nasıl ilerlediğini görmek istersen dynamic linker’ın kendi izleme kipini açabilirsin:
LD_DEBUG=libs ls /tmpÇıktı, her kütüphane için hangi dizinlerin denendiğini ve hangisinde bulunduğunu satır satır yazar. “Kütüphane bulunamadı” hatalarını teşhis etmenin en doğrudan yolu budur.
LD_PRELOAD ve Sembol Araya Girmesi
Arama sırasının başına, herhangi bir kütüphaneden önce yüklenecek bir nesne koyabilirsin:
LD_PRELOAD=./benim.so ./programÖnce yüklenen nesnedeki semboller kazanır. Yani kendi malloc’unu yazıp preload edersen, program ve kullandığı tüm kütüphaneler artık seninkini çağırır — kaynak koda dokunmadan, yeniden derlemeden. Buna symbol interposition denir.
Bu, hata ayıklama ve profilleme araçlarının temel numarasıdır: bellek sızıntısı bulucular, sahte zaman fonksiyonları ve ağ trafiği izleyicileri çoğunlukla bu yolla çalışır. Aynı güç, kötü amaçlı kullanıldığında bir programın davranışını sessizce değiştirmenin de yoludur; bu yüzden setuid bir program çalıştırıldığında LD_PRELOAD ve LD_LIBRARY_PATH yok sayılır. Aksi hâlde ayrıcalıklı bir programa istediğin kodu enjekte etmek önemsiz bir iş olurdu.
Dikkat: bunu yapan kernel değildir. Kernel yalnızca “bu process ayrıcalık kazandı” bilgisini AT_SECURE bayrağıyla stack’e bırakır — birazdan göreceğimiz auxiliary vector’ün içinde. O bayrağı görüp tehlikeli ortam değişkenlerini görmezden gelen ld.so’nun kendisidir.
Derinleşme"GLIBC_2.34 not found": sembol sürümlemenin hikâyesi
Yeni bir dağıtımda derlediğin binary’yi eski bir sunucuya kopyaladın ve şu hatayı aldın:
./program: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not foundBu hata, dinamik bağlamanın en görünür pratik sonucudur ve arkasında zarif bir mekanizma vardır: sembol sürümleme (symbol versioning).
Sorun şu: glibc otuz yıldır geriye dönük uyumluluk sözü veriyor. Ama bazen bir fonksiyonun davranışını değiştirmek gerekir. Adını değiştirirsen eski programlar bozulur; değiştirmezsen davranışı değişen fonksiyon eski programları bozar. Çıkmaz gibi görünür.
Çözüm, aynı isimden birden fazla sürümü aynı anda dışa açmaktır. libc.so.6 içinde hem memcpy@GLIBC_2.2.5 hem memcpy@GLIBC_2.14 bulunur; ikisi farklı makine kodudur. Her binary, bağlandığı anda hangi sürümü istediğini kaydeder ve çalışma zamanında tam olarak onu alır.
Klasik örnek memcpy’nin kendisidir. glibc 2.14’te performans için kopyalama yönü değiştirildi ve bu, üst üste binen bölgelerde memcpy kullanan kodu bozdu — ki bu kullanım zaten tanımsız davranıştı ama yaygındı. En görünür kurbanı Adobe Flash oldu; ses bozuk çıkmaya başladı. Sürümleme sayesinde eski binary’ler eski davranışı almaya devam etti.
Buradan asimetrik bir kural doğar: yeni glibc eski binary’yi çalıştırır, eski glibc yeni binary’yi çalıştıramaz. Çünkü ikincisinin istediği sürüm henüz mevcut değildir. Bu yüzden dağıtım paketleri en eski desteklenen sisteme yakın bir ortamda derlenir.
Bir binary’nin ne istediğini görmek için:
objdump -T ./program | grep GLIBC_ | sort -uÇıktıdaki en yüksek sürüm numarası, o programın çalışabileceği en eski glibc’yi belirler.
Bu kısıt, ekosistemdeki birkaç eğilimi doğrudan açıklar: container imajlarının kendi libc’lerini taşıması, Go’nun varsayılan olarak statik bağlaması, Rust’ın musl hedefi ve Zig’in glibc sürümünü hedef olarak seçebilmesi. Hepsi aynı soruna verilmiş farklı cevaplardır: hangi libc ile karşılaşacağımı bilmiyorsam, hiç karşılaşmayayım.
Bir yana: statik bağlamanın geri dönüşü
Dinamik bağlama disk ve bellek tasarrufu için tasarlandı, ama container çağında bu denklem değişti. Her imaj kendi kütüphanelerini taşıdığı için paylaşım avantajı büyük ölçüde kayboldu; buna karşılık “hangi glibc sürümüyle çalışır” sorunu canlı kaldı. Go varsayılan olarak statik bağlar, Rust
muslhedefiyle statik bağlayabilir ve sonuç tek dosyalık, hiçbir sistem kütüphanesine ihtiyaç duymayan bir binary olur. Sıfırdan (FROM scratch) container imajları tam olarak bunu kullanır.
Yürütme
Şimdi ELF dosyalarını çalıştıran kernel’a geri dönelim: Eğer yürütülen binary dynamic linked ise, işletim sistemi onun koduna doğrudan atlayamaz; çünkü ihtiyaç duyduğu kodun bir kısmı henüz yerinde değildir. Unutma: dynamic linked programlar, ihtiyaç duydukları library fonksiyonlarına yalnızca referans taşır.
Programı gerçekten başlatabilmek için dynamic linker (ld.so) hangi kütüphanelere ihtiyaç duyulduğunu bulmalı, onları yüklemeli, isim olarak duran referansları gerçek adreslere çözmeli ve sonra gerçek program kodunu başlatmalıdır. Bu iş, ELF formatının ayrıntılarıyla yoğun şekilde uğraşan karmaşık bir süreçtir; bu yüzden kernel’ın ana işi değildir. Kernel bu işi kendisi üstlenmez, taşeronluk verir. Yaptığı şey basit: PT_INTERP’te adı yazan dosyayı — yani dynamic linker’ı — programın belleğine, programın kendisinin yanına yerleştirir. Sonra “ilk çalışacak kod” olarak programın kendi başlangıcını değil, dynamic linker’ın başlangıcını işaretler. Yani CPU user space’e döndüğünde önce linker uyanır, kütüphaneleri toplar ve işi bittiğinde sırayı asıl programa devreder. Tipik interpreter örneklerinden biri /lib64/ld-linux-x86-64.so.2’dir.
Kernel, ELF header’ı okuyup program header table’ı taradıktan sonra yeni programın bellek düzenini hazırlayabilir. İlk iş olarak tüm PT_LOAD segmentlerini sanal belleğe yerleştirir; böylece programın statik verileri, BSS alanı ve makine kodu adreslenebilir olur. Program dynamic linked ise, kernel ayrıca ELF interpreter’ı (PT_INTERP) da map eder; yani dynamic loader’ın verileri, BSS alanı ve kodu da yeni process’in adres alanında görünür hâle gelir.
Sonrasında kernel, user space’e dönerken CPU’nun geri yükleyeceği instruction pointer’ı ayarlar. Executable dynamic linked ise, instruction pointer ELF interpreter’ın bellekteki giriş noktasına konur. Aksi takdirde doğrudan executable’ın entry point’ine ayarlanır.
Kernel artık syscall’dan dönmeye neredeyse hazırdır. Unutma, hâlâ execve içindeyiz. Program başlarken okuyabilsin diye argc, argv ve environment değişkenlerini stack’e yazar.
Kernel stack’e yalnızca
argc/argv/envpdeğil, bir de auxiliary vector yazar. Bu vektörAT_PHDR(program header adresi),AT_ENTRY(entry point),AT_PAGESZ(sayfa boyutu),AT_HWCAP(CPU özellikleri) gibi sistem bilgilerini taşır.
Bir de register temizliği var. Bir syscall’a girerken kernel, user space register’larının değerlerini kendi kernel stack’ine kopyalar ve dönüşte oradan geri yükler. (Bu, biraz önce argc/argv yazdığı user space stack’i değil; ikisi ayrı yerler.) Ama execve özel bir durum: dönülecek yer artık eski program değil. Bu yüzden kernel, geri yükleyeceği o kopyayı sıfırlar. Böylece yeni program, kendisinden önce çalışan programın register’larda ne bıraktığını asla göremez — hem temiz bir başlangıç, hem de bilgi sızıntısına karşı bir önlem.
Sonunda syscall biter ve kernel tekrar user space’e döner. Kayıtları geri yükler ve saklı instruction pointer’a atlar. Bu instruction pointer artık yeni programın ya da gerekiyorsa ELF interpreter’ın giriş noktasıdır; yani mevcut süreç fiilen değiştirilmiş olur.
Program aslında
main()’den başlamaz. Entry point_start’tır; CRT (C Runtime) başlatma kodu__libc_start_main’i çağırır, o da gerekli kurulumları yapıpmain()’e geçer — bu yüzdenmain()’den önce global değişkenler ilklendirilir.
Özet
Peki, ne öğrendik?
- ELF, Linux’ta çalıştırılabilir dosyaların, paylaşılan kütüphanelerin ve nesne dosyalarının ortak formatıdır.
- Aynı dosya iki farklı gözle okunur: program header’lar kernel’a “belleğe neyi nereye koyacaksın” der, section header’lar ise linker’a “hangi parça nerede” der.
- Static linking her şeyi dosyanın içine kopyalar; dynamic linking çalışma anında bulur. İkisi arasındaki seçim boyut, güncellenebilirlik ve başlangıç maliyeti arasındaki bir takastır.
- Dinamik bağlamada çağrılar PLT üzerinden gider ve gerçek adres GOT’a yazılır. Bu dolaylılık, adresin ancak ilk çağrıda çözülmesini (lazy binding) mümkün kılar.
- Bir programın giriş noktası
maindeğil_start’tır; aradaki çalışma zamanı kodu global değişkenleri hazırlayıpmain’e geçer.
Peki kernel bu segmentleri belleğe yüklerken nasıl bir dünya kuruyor? Her program aynı sanal adresleri kullanıyor gibi görünüyor ama neden çakışmıyor? Sıradaki bölümde bilgisayarındaki o gizli çevirmeni tanıyacağız.
13. bölüme devam et: Bellek Aslında Sanal