CPU'ya“Sen”iKatmak 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.
Bölüm 4:
Bir ELF Ustasına Dönüşmek
GitHub'da düzenle
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 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.
Linux kernel’ında ELF binary’leri, diğer birçok handler’dan çok daha karmaşık olan ve binlerce satır koda sahip binfmt_elf handler’ı tarafından işlenir. 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.c
Dosya 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. Bunun görevi, binary hakkında temel bilgileri taşımaktır:
- Hangi işlemci mimarisi için üretildiği. ELF dosyaları ARM, x86 ve başka mimariler için makine kodu taşıyabilir.
- Dosyanın kendi başına çalıştırılabilir bir executable mı olduğu, yoksa başka programlar tarafından yüklenecek bir shared library mi olduğu.
- Executable’ın entry point’i. ELF’nin sonraki bölümleri, verilerin bellekte nereye yükleneceğini tarif eder; entry point ise tüm süreç hazır olduğunda ilk makine kodu talimatının hangi adreste olduğunu söyler.
ELF header her zaman dosyanın başında bulunur. Program 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
Program header table, binary’nin çalışma zamanında nasıl yükleneceğini ve yürütüleceğini anlatan bir dizi girdiden oluşur. Her girdinin, ne tür bilgi taşıdığını belirten bir tür alanı vardır. Ö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. “Metin” olmadığı hâlde adına.textdenmesi bana her zaman biraz tuhaf gelmiştir.).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.

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, programı CPU’nun yürütebilmesi için bu girdileri okuyup ilgili verileri belleğe yükler.
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. Ayrıca shared object’lerin section header table’ında, hangi sembollerin dışa açıldığını ve dynamic link edilebildiğini anlatan .dynsym adlı bir section bulunur.
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 belleğe yüklenir. Dynamic linking’de ise kütüphanenin tamamı yüklenir. İlk bakışta bu daha verimsiz görünebilir, ama modern işletim sistemleri aynı kütüphaneyi belleğe bir kez yükleyip ardından kod bölümünü birden çok process arasında paylaşabildiği için aslında daha fazla alan tasarrufu sağlar. Durum bilgisi process’e özel olduğu için yalnızca kod paylaşılır, ama buna rağmen tasarruf onlarca hatta yüzlerce megabayt RAM seviyesine çıkabilir.
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 işletim sistemi 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 genelde kernel’ın içinde değil, ayrı bir user-space programda yürür. ELF dosyaları, program header table’daki PT_INTERP girdisinde hangi loader’ı kullanmak istediklerini belirtir; tipik örneklerden 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 belleğe yerleştirir; böylece programın statik verileri, BSS alanı ve makine kodu hazır olur. Program dynamic linked ise, kernel ayrıca ELF interpreter’ı (PT_INTERP) da yüklemek zorundadır; yani onun verileri, BSS alanı ve kodu da belleğe taşınır.
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.
Bir de register temizliği yapılır. Kernel, syscall işlenmeden önce user space register’larının değerlerini, dönüşte geri yüklenmek üzere stack’te saklar. User space’e dönmeden hemen önce bu alanı temizler.
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.
5. bölüme devam et: Bilgisayarındaki Çevirmen