Bölüm 2:Metin, Bellek ve Kod
CPU'ya “Sen”i Katmak kitabı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ş
- Sayılar: Bit'ten Kayan Noktaya
- Metin, Bellek ve Kod
- Temeller
- Kernel, User Mode ve Syscall
- Mimariler: x86, ARM ve Diğerleri
- Saat, Çekirdek ve Mantıksal İşlemci
- Bellek Hiyerarşisi
- Cache Nasıl Çalışır
- Zamanı Dilimle
- Aynı Anda İki Şey
- İş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
Bir önceki bölümü bir soruyla kapattım: bir byte yalnızca 0 ile 255 arası bir sayıysa, aynı byte nasıl oluyor da bazen bir harf, bazen bir adres, bazen de bir talimat oluyor?
Cevap kısa ve bu kitabın en çok işine yarayacak fikirlerden biri: byte’ın kendisinde bunu söyleyen hiçbir şey yok. Anlamı taşıyan şey sayı değil, o sayıya kimin, hangi kuralla baktığı. Aynı 0x41 değeri metin olarak okunursa A harfidir, sayı olarak okunursa 65’tir; 32 bitlik bir x86 işlemciye talimat diye verilirse de “bir sayıyı bir artır” anlamına gelir.
Bu bölümde o üç yorumu tek tek kuracağız: önce harfleri, sonra adresleri, sonunda da programın kendisini.
Bu bölümde neyi çözüyoruz?
- Metnin bellekte nasıl sayıya döndüğünü, ASCII ile Unicode farkını anlayacağız.
- Çok byte’lı bir sayının bellekte hangi sırayla dizildiğini (endianness) göreceğiz.
- Bellek adresi fikrini kafaya oturtup makine kodunu kendi gözünle gözlemleyeceksin.
- Assembly ile yüksek seviye diller arasındaki farkı ve derleme zincirini netleştireceğiz.
ASCII ve Karakterlerin Sayı Hâli
Bilgisayarlar metni nasıl saklar? Her harf, rakam ve sembole bir sayı atanır. En temel standart ASCII’dir (American Standard Code for Information Interchange) ve 128 karakter tanımlar.
Bu 128 sayı rastgele dağıtılmamış; tablo üç bölgeye ayrılır:
- 0-31: kontrol karakterleri. Ekranda görünmezler, iş yaparlar. Satır sonu
\n= 10, sekme\t= 9, adı NUL olan\0= 0. Bu sonuncusunu muhtemelen “string’in sonu” diye biliyorsun; ama o anlam ASCII’den gelmiyor. ASCII yalnızca sıfır değerli bir kontrol karakteri tanımlar, “string” diye bir kavramı yoktur. String’i “NUL görene kadar oku” diye tanımlamayı seçen C’dir — ve bu tercihin bedelini 18. bölümde göreceğiz. - 32-126: yazdırılabilir karakterler. Boşluk 32’den başlar,
~126’da biter. - 127: DEL. Neden en sonda? Çünkü ASCII delikli bant çağında tasarlandı ve 127 =
1111111, yani bütün delikler açık demek. Yanlış yazdığın bir karakteri iptal etmenin tek yolu üzerindeki tüm delikleri delmekti; geri alınamayan tek işlem, kodun da en sonuna kondu.
Tabloya biraz dikkatli bakarsan tesadüf olmayan iki şey daha görürsün.
‘A’ = 65 ve ‘a’ = 97. Aradaki fark tam olarak 32, yani 2⁵. Bu bir tasarım tercihidir: büyük harfi küçük harfe çevirmek, altıncı biti 1 yapmaktan ibarettir. Yalnızca İngiliz alfabesini bilen en basit toupper ve tolower uygulamaları tam olarak bunu yapar: tek bir bit işlemi.
Kolaylığın nerede bittiğini bilmek de gerekiyor, çünkü bu numara yalnızca ASCII’nin A-Z ve a-z aralığında çalışır. Bunu görmek için uzağa gitmeye gerek yok: Türkçede i harfinin büyüğü I değil İ’dir, I harfinin küçüğü de i değil ı. Bir bit çevirmek bu dönüşümü veremez: dört harften ikisi (İ ve ı) ASCII’nin dışında oturur, üstelik doğru cevap hangi dile baktığına bağlıdır. Aynı sorun Almanca ß için de vardır: büyük hâli tek harf değil, iki harftir (SS). Bu yüzden Unicode dünyasında harf büyütme, bir bit işlemi değil, dilin kurallarına bakan bir tablo işidir.
Bu ayrıntının yazılım dünyasında bir adı bile var: Turkish I problem. Java’da "FILE".toLowerCase() çağrısı, dil ayarı Türkçe olan bir makinede "file" değil "fıle" döndürür; sonucu "file" ile karşılaştıran kod sessizce yanlış yola sapar. Java’nın kendi belgesi bu tuzağı Türkçe bir örnekle anlatır ve çözüm olarak dilden bağımsız dönüşümü (toLowerCase(Locale.ROOT)) önerir.
‘0’ = 48. Bu yüzden C’de karakter - '0' numarası çalışır: '7' karakteri 55’tir, 48’i çıkarınca 7 sayısını elde edersin. Karakterle sayı arasındaki köprü, bir çıkarma işleminden ibaret.
| Karakter | Decimal | Hex |
|---|---|---|
| ‘A’ | 65 | 41 |
| ‘a’ | 97 | 61 |
| ‘0’ | 48 | 30 |
| ’ ’ (boşluk) | 32 | 20 |
ASCII yetersiz kalınca (Türkçe karakterler, Çince vb.) Unicode ve UTF-8 devreye girdi. UTF-8, bir karakteri 1-4 byte arasında değişen uzunlukta temsil eder ve dünya üzerindeki neredeyse tüm yazı sistemlerini destekler.
DerinleşmeUTF-8 neden bu kadar iyi tasarlanmış?
Unicode, her karaktere evrensel bir numara (code point) atayan bir standarttır. Örneğin ‘A’ = U+0041, ‘ö’ = U+00F6, ’𐍈’ = U+10348. UTF-8 ise bu numaraları byte’lara çevirme yöntemidir.
UTF-8 neden değişken uzunluklu? Çünkü 1,1 milyondan fazla karakteri tek byte’la (0-255) ifade edemezsin. Öte yandan dünyadaki dijital metnin çok büyük kısmı — İngilizce metin, HTML etiketleri, JSON anahtarları, kaynak kodun tamamı — zaten ASCII aralığında kalır. Bunları 4 byte’lık sabit kutulara koymak, her karakter için üç byte’ı boşa harcamak demektir. İşte UTF-8’in zekâsı burada:
- U+0000 - U+007F (ASCII): 1 byte.
0xxxxxxx - U+0080 - U+07FF: 2 byte.
110xxxxx 10xxxxxx - U+0800 - U+FFFF: 3 byte.
1110xxxx 10xxxxxx 10xxxxxx - U+10000 - U+10FFFF: 4 byte.
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
ASCII karakterleriyle tam uyumlu olması, eski C programlarının ve protokollerin çalışmaya devam etmesini sağlar. UTF-8’de ayrıca bir karakterin ilk byte’ına bakarak o karakterin kaç byte süreceğini anlarsın. Devam byte’larının hepsi 10 ile başladığı için, metnin tam ortasından rastgele bir yere düşsen bile geriye doğru birkaç byte gidip karakter sınırını bulabilirsin — buna self-synchronizing denir.
UTF-16 ise aynı işi daha kaba yapar: her karakteri 16 bit’le başlatır, ama Unicode 16 bit’e sığmadığı için 65535’in üstündeki karakterlerde iki 16 bitlik birim kullanmak zorunda kalır. Bu ikiliye surrogate pair denir. Yani yaygın kanının aksine UTF-16 sabit genişlikli değildir; üstelik ASCII ile de uyumlu değildir, iki dünyanın da kötü tarafını alır. Gerçekten sabit genişlikli tek kodlama UTF-32’dir — o da her karaktere koşulsuz 4 byte harcadığı için pratikte neredeyse hiç kullanılmaz.
Endianness: Byte Sıralaması
Şimdiye kadar hep tek byte’lık değerlerle çalıştık. Peki sayı bir byte’a sığmıyorsa? 0x12345678 dört byte tutar ve bellekte peş peşe dört adrese yerleşir. İşte burada, ilk bakışta hiç sorulmaması gereken bir soru çıkıyor ortaya: bu dört byte hangi sırayla dizilecek? Cevabın herkes için aynı olmadığını göreceksin — ve bu tercihin bir adı var: endianness (byte sıralaması).
- Little-endian: En düşük değerli byte (LSB), en düşük bellek adresinde saklanır. Intel x86 ve x86-64 mimarileri little-endian kullanır; ARM ve RISC-V de pratikte böyle çalışır.
- Big-endian: En yüksek değerli byte (MSB), en düşük bellek adresinde saklanır. Ağ protokolleri (TCP/IP) ve eski Motorola/PowerPC mimarileri big-endian kullanır.
“Endian” kelimesi, Jonathan Swift’in Gulliver’in Gezileri kitabından gelir: Yumurtanın hangi ucundan kırılacağı konusundaki savaş. 1980’de Danny Cohen bu terimleri bilgisayar bilimine taşımıştır.
Neden x86 little-endian? Cevap tarihte ve beklenmedik bir yerde: bir terminalde. x86 ailesinin atası olan 8008 (1972), Datapoint 2200 adlı masaüstü terminalin işlemcisini tek bir çipe sığdırmak için tasarlandı ve onun talimat setini devraldı. Datapoint’in işlemcisi sayıları bit bit, en düşük anlamlı uçtan başlayarak işliyordu — tıpkı senin kâğıt üzerinde toplamaya en sağdaki basamaktan başlaman gibi. Sırayla gelen parçaların ilki en düşük anlamlı parça olunca, sayıyı bellekte de düşük byte önde tutmak doğaldı. 8080 bu tercihi 8008’den, 8086 da 8080’den devraldı; zincir bugünkü x86-64’e kadar hiç kırılmadı.
Bunun pratik bir faydası da var. Elle toplama yaparken en sağdaki basamaktan başlar, eldeyi sola doğru taşırsın; işlemci de aynısını yapar. Little-endian’da en düşük anlamlı byte zaten en düşük adreste durduğu için, işlemci sayıyı baştan okumaya başlayıp eldeyi ilerledikçe taşıyabilir — sayının kaç byte olduğunu önceden bilmesine bile gerek kalmaz.
Bir örnek: 32 bitlik 0x12345678 sayısı bellekte nasıl durur?
Adres Big-endian Little-endian
0x1000 12 78
0x1001 34 56
0x1002 56 34
0x1003 78 12Dikkat et: temsil edilen sayı ikisinde de aynı (0x12345678) ve kullanılan adresler de aynı. Değişen tek şey hangi byte’ın hangi adrese düştüğü. Yani bellekteki byte’lar gerçekten farklı yerlerde duruyor; bu bir okuma tercihi değil, fiziksel bir yerleşim farkı.
Bunu bilmezsen bir hex dump’a bakıp 78 56 34 12 görürsün ve sayıyı ters okursun — hata ayıklarken en çok vakit yakan şeylerden biri budur. Ağ tarafında ise herkesin anlaşabilmesi için tek bir sıra seçilmiştir: big-endian, yani network byte order. x86 makinen little-endian olduğu için bir sayıyı ağa koymadan önce byte’larını çevirmek zorundadır; htons ve htonl gibi fonksiyonların tek işi budur.
Bellek Adresi: Her Şeyin Bir Numarası Var
Bilgisayarın belleği (RAM), byte’ların yan yana dizildiği dev bir dizi gibidir. Her byte’ın bir adresi (numarası) vardır ve numaralar 0’dan başlar.
Adreslerin nereye kadar gidebileceğini belirleyen şey ise — burası çok sık yanlış bilinir — RAM’inin boyutu değil, işlemcinin adres genişliğidir. 64 bitlik bir makinede adres için 64 bit ayrılmıştır. Bu, RAM’inden kat kat büyük bir numara aralığı demek; o aralığın tamamının karşılığında fiziksel bir RAM hücresi olmak zorunda da değil. Bu tuhaflığın nasıl çözüldüğüne 16. bölümde geleceğiz.
Buradaki “adres genişliği”nin sahibine dikkat: adresi üreten CPU’dur, RAM değil. Bellek çubuğunun kendisinde “ben şu genişlikte adres alırım” diyen bir özellik yoktur — ona giden şey zaten bir adres bile değildir. Adresi bellek denetleyicisi karşılar ve çubuğa yalnızca hangi bank’ta, hangi satır ve sütunu açacağını söyler. Bu yüzden aynı çubuğu 32 bitlik bir makineye taksan çubukta hiçbir şey değişmez; değişen tek şey, karşı tarafın onun ne kadarına ulaşacak numarası olduğudur.
Şimdilik temel resmi şöyle tut: bellek, numaralanmış byte’lardan oluşan dev bir dizidir.
Benzetmeyle bakalım: bir apartman düşün ve her daire bir byte olsun. Üç ayrıntı önemli.
Daireler eşit büyüklükte. Bir byte’lık daireye 32 bitlik bir sayı sığmaz; o sayı dört ardışık daireyi kaplar. Biraz önce konuştuğumuz endianness meselesi tam olarak “hangi parça hangi daireye girecek” sorusudur.
CPU kapı kapı dolaşmaz. Doğrudan numarayı söyler ve o daireye anında ulaşır. RAM’deki “R” harfi buradan geliyor: Random Access, yani rastgele erişim. 5.000’inci daireye erişmenin maliyeti, 1’inci daireye erişmekten farklı değildir; adresi bulmak için aradaki hiçbir şeye bakmak gerekmez. Kasete ya da plağa göre devrimsel olan şey buydu. (Şunu da ekleyeyim: bu söz adresleme düzeyinde geçerli. Gerçek bir makinede iki erişimin süresi eşit çıkmayabilir — ama sebebi adresin büyüklüğü değil, verinin o an nerede bulunduğudur. 7. bölümde bu farkın nereden geldiğini göreceğiz.)
Belleğin umurunda değildir. Bir daireye ne koyduğun — harf mi, sayı mı, talimat mı — bellek açısından hiçbir anlam taşımaz; hepsi sekiz bittir. O sekiz bitin ne anlama geldiği tamamen CPU’nun onu nasıl yorumladığına bağlıdır. 3. bölümde göreceğimiz gibi, bu kayıtsızlık bir kusur değil, bilgisayarın en temel tasarım kararlarından biridir.
| Adres | Değer (Hex) | Değer (ASCII) |
|---|---|---|
| 0x1000 | 48 | ‘H’ |
| 0x1001 | 65 | ‘e’ |
| 0x1002 | 6C | ‘l’ |
| 0x1003 | 6C | ‘l’ |
| 0x1004 | 6F | ‘o’ |
Adresler genelde
0xönekiyle hexadecimal olarak yazılır.0x1000= 4096 decimal.
Makine Kodunu Gözlemlemek
Şimdiye kadar öğrendiklerimizi terminalde doğrulayabiliriz. xxd, hexdump ve od gibi araçlar dosyaları ham byte düzeyinde gösterir.
xxd, hex dump için en yaygın kullanılan araçtır. Sol tarafta offset (adres), ortada hex byte’lar, sağda ASCII karşılığı görünür:
echo -n "AB" | xxdÇıktısı şu şekilde olur:
00000000: 4142 AB41 hex = 65 decimal = ‘A’, 42 hex = 66 decimal = ‘B’. echo normalde çıktının sonuna bir satır sonu karakteri (\n, hex 0a) ekler; -n bunu engeller. Bu yüzden dökümde yalnızca iki byte görüyorsun. -n olmadan denersen üçüncü byte olarak 0a belirir — dene ve gör.
Bir de Türkçe bir harf dene: echo -n "ğ" | xxd. Ekranda tek harf gördüğün hâlde dökümde iki byte çıkar: c49f. UTF-8, ASCII dışındaki karakterleri birden fazla byte’la yazar ve bunu ilk byte’ın baş tarafında ilan eder: c4’ün ikilik hâli 11000100’dır, baştaki 110 “bu karakter iki byte sürecek” demektir; ikinci byte ise 10 ile başlar. Bunun gündelik bir sonucu var: C’de strlen("ğ") 1 değil 2 döndürür. Bir metnin uzunluğu, harf sayısı değil byte sayısıdır.
Windows’ta bunun için ek bir araç kurmana gerek yok; PowerShell’de "AB" | Format-Hex aynı dökümü veriyor.
Bir binary dosyanın içindeki makine talimatlarını objdump ile assembly olarak okuyabiliriz. /bin/ls gibi yaygın bir programın başındaki talimatlar:
objdump -d /bin/ls | head -200000000000004000 <.init>:
4000: f3 0f 1e fa endbr64
4004: 48 83 ec 08 sub $0x8,%rsp
4008: 48 8b 05 c1 ef 01 00 mov 0x1efc1(%rip),%rax
400f: 48 85 c0 test %rax,%rax
4012: 74 02 je 4016 <...>Soldaki adresler (4000, 4004…), ortadaki makine kodu (f3 0f 1e fa), sağdaki assembly (endbr64) aynı talimatın üç farklı temsilidir. Bellekte saklanan şey ortadaki byte’lardır; CPU bunları çözüp sağdaki gibi davranır. Talimatların boyu da eşit değil: biri 3, biri 4, biri 7 byte. Adresler bu yüzden düzenli artmıyor; her talimat, bir öncekinin bittiği yerden başlıyor.
Üçüncü satıra yakından bak. Makine kodunun son dört byte’ı c1 ef 01 00, sağdaki sayı ise 0x1efc1. Byte’ları sondan başa okuyunca sayıyı bulursun: 00 01 ef c1. Az önce konuştuğumuz little-endian, gerçek bir programın içinde.
İlk talimat endbr64 de ilginç bir misafir: hiçbir hesap yapmaz. “Buraya dolaylı yoldan sıçranabilir” diyen bir işarettir ve saldırganın programı kodun rastgele bir ortasına sıçratmasını zorlaştıran bir donanım korumasının parçasıdır; bu korumayı tanımayan eski işlemciler onu hiçbir şey yapmayan bir talimat olarak okuyup geçer. Hangi saldırıya karşı konduğunu 18. bölümde göreceğiz.
Assembly ve Yüksek Seviye Diller
Buraya kadar makine kodunu hep byte olarak gördük. O byte’ları insanın elle yazması işkencedir; bu yüzden her talimata okunabilir bir isim verilmiştir. Assembly tam olarak budur: makine kodunun bire bir insan okunur karşılığı. Sen mov yazarsın, assembler onu 48 c7 gibi byte’lara çevirir — arada kaybolan ya da eklenen hiçbir şey yoktur.
Assembly’nin tek bir “dili” de yoktur. Talimat isimleri doğrudan CPU’nun anladığı komutlar olduğu için, x86’nın assembly’si ile ARM’ınki birbirinden bağımsız iki dildir; RISC-V’ninki bir üçüncüsü.
Aşağıdaki örnekte rax gibi isimler göreceksin. Bunlara register denir: CPU’nun içinde bulunan ve o an üzerinde çalıştığı birkaç değeri tuttuğu isimli kutulardır. RAM’den iki farkı var — RAM’de milyarlarca adres varken register sayısı on-yirmi civarındadır ve register CPU’nun içinde olduğu için erişimi kıyaslanamayacak kadar hızlıdır. 3. ve 7. bölümde ikisine de ayrıntılı döneceğiz.
; x86-64 assembly örneği
mov rax, 5 ; rax register'ına 5 yaz
add rax, 3 ; rax'a 3 ekleYüksek seviye diller (C, Python, Java, JavaScript) insanlar için çok daha kolaydır. Aradaki köprüyü ise bir program kurar ve burada sık karıştırılan bir ayrım var.
Derleyici, kodun tamamını önceden makine koduna çevirir ve ortaya çalıştırılabilir bir dosya çıkarır; C böyle çalışır. Yorumlayıcı ise programını makine koduna çevirmez. Python örneğin kaynağı önce kendi iç biçimine (bytecode) çevirir, sonra o biçimi adım adım okuyup her adımın gerektirdiği işi kendisi yapar. python script.py yazdığında ortada makine koduna dönüşmüş bir dosya yoktur; CPU’nun üzerinde çalışan makine kodu, Python yorumlayıcısının kendi makine kodudur.
(Modern yorumlayıcıların sık çalışan parçaları çalışma anında makine koduna çevirdiği bir üçüncü yol daha var: JIT. Ona şimdilik girmiyoruz.)
// C ile aynı işlem
int x = 5;
x = x + 3;Bir C programı derlendiğinde, önce assembly’ye, oradan da makine koduna (binary) dönüştürülür. Bu makine kodu, CPU’nun talimat olarak okuduğu 0 ve 1 dizisidir.
C’den Makine Koduna: Derleme Pipeline’ı
gcc hello.c -o hello yazdığında aslında tek bir araç çalışmaz; bir pipeline (boru hattı) devreye girer. Aşamaları somut bir dosya üzerinden takip edelim. Az önceki iki satırı, sonucu ekrana yazan tam bir programa çevirdim:
#include <stdio.h>
int main(void) {
int x = 5;
x = x + 3;
printf("%d\n", x);
return 0;
}C kaynağı makine koduna dört aşamada dönüşür. Her aşama bir öncekinin bıraktığı dosyayı alır ve bir sonrakine yeni bir dosya bırakır:
- Kaynak dosya. hello.c senin yazdığın düz metin: sekiz satır C. CPU bu dosyadan tek bir talimat bile çalıştıramaz; önünde dört aşama var.
- Önişlemci metni genişletir. #include satırının yerine stdio.h'nin içeriği yapıştırılır, makrolar açılır, yorumlar silinir. Çıkan hello.i hâlâ C'dir, ama sekiz yüz satırı aşar.
- Derleyici C'yi assembly'ye çevirir. Tip kontrolü, hata mesajlarının çoğu ve optimizasyon bu aşamadadır. hello.s hedef işlemcinin talimatlarını anlatan bir metindir; bundan sonraki hiçbir aşama C bilmez.
- Assembler metni byte'a çevirir. hello.o gerçek makine kodu taşır, ama printf'e giden adres dört sıfır byte olarak bekler. Yanındaki relocation notları o boşluğa neyin yazılacağını söyler.
- Bağlayıcı parçaları birleştirir. hello.o ile libc bir araya gelir, isimler çözülür, boşluklar doldurulur. Çıkan hello artık çalıştırılabilir bir dosyadır.
1. Önişlemci (Preprocessor)
#include, #define gibi direktifleri çözer; header dosyalarını içeri aktarır, makroları genişletir, yorumları siler. C dilinin syntax’ını bilmez; sadece metin değiştirme motorudur.
gcc -E hello.c -o hello.iSonucu wc -l ile saydırırsan şaşırırsın: sekiz satırlık hello.c, sekiz yüz satırı aşan bir hello.i üretebilir. Çünkü <stdio.h> tek başına yüzlerce satır tanım getirir ve o da başka header’ları içeri çeker.
2. Derleyici (Compiler)
İkinci aşamada asıl iş yapılır: derleyici, önişlemciden çıkan hello.i dosyasını okur ve hedef mimarinin assembly diline çevirir. Gördüğün derleme hatalarının neredeyse tamamı buradan çıkar — tip uyuşmazlıkları, tanımlanmamış değişkenler, eksik return’ler. -O2 yazdığında optimizasyon da burada devreye girer. Kısacası C bilgisi gerektiren ne varsa bu aşamada olur; bundan sonraki iki aşama artık C’nin ne olduğunu bilmez.
gcc -S hello.i -o hello.s
cat hello.s.LC0:
.string "%d\n"
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $5, -4(%rbp)
addl $3, -4(%rbp)
movl -4(%rbp), %eax
movl %eax, %esi
leaq .LC0(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
leave
retC kodundaki iki satırı movl $5 ve addl $3 satırlarında tanıyacaksın: x değişkeni, -4(%rbp) diye yazılan bir bellek konumunda yaşıyor. printf satırı ise tek bir call talimatına dönüşmüş; printf’in kendisi bu dosyada yok, yalnızca adı geçiyor. Derleyicinin sürümüne göre birkaç satır farklı çıkabilir, iskelet aynı kalır.
-Sbayrağı (büyük S) derleyiciyi assembly ürettikten sonra durdurur; daha sonraki aşamalara geçmez.
3. Assembler
Assembly metnini (hello.s) ham makine koduna çevirir. Çıktı bir object file (hello.o) olur; bu dosya binary talimatlar içerir ama henüz çalıştırılabilir değildir. Dışarıdan çağrılan fonksiyonların (örn. printf) adresleri henüz boştur.
gcc -c hello.s -o hello.ofile hello.o çalıştırırsan çıktıda relocatable kelimesini görürsün. Anahtar kelime odur: dosya makine kodu içerir ama henüz bir adrese yerleşmemiştir.
O boşluğu kendi gözünle görebilirsin. objdump -d makine kodunu gösterir, -r bayrağı da assembler’ın dosyaya bıraktığı notları:
27: e8 00 00 00 00 call 2c <main+0x2c>
28: R_X86_64_PLT32 printf-0x4e8, “şu adrese git ve işin bitince buraya dön” diyen çağrı talimatıdır. Ardından gelen dört byte gidilecek yeri söylemeli — ve dördü de sıfır. Assembler printf’in nerede olduğunu bilmediği için yeri boş bırakıp altına bir not düşmüş: “28 numaralı byte’tan başlayan dört byte’a printf’e giden yol yazılacak.” Bu nota relocation (yeniden yerleştirme kaydı) denir; dosya türündeki relocatable kelimesi de buradan gelir. Senin çıktında adresler birkaç byte kaymış olabilir, kalıp aynıdır.
4. Bağlayıcı (Linker)
Son aşamada bağlayıcı, ortalıkta duran parçaları birleştirir. Senin hello.o dosyanı libc gibi hazır kütüphanelerle yan yana koyar, her ismin kime ait olduğunu bulur — buna sembol çözümleme denir —, her şeyi son adreslerine oturtur ve relocation notlarını tek tek okuyarak o sıfırlı boşlukları gerçek sayılarla doldurur. Ortaya artık eksiği olmayan, tek başına çalıştırılabilir bir dosya çıkar. (printf gibi, program çalışırken yüklenen paylaşımlı bir kütüphanede duran fonksiyonlarda son adım çalışma anına ertelenir; bunun nasıl yapıldığını 15. bölümde göreceğiz.)
gcc hello.o -o helloŞimdi file hello çıktısında relocatable yerine executable yazar. Aradaki tek fark, sembollerin çözülmüş ve adreslerin sabitlenmiş olmasıdır — 15. bölümde bu iki ELF türünün farkına ayrıntılı bakacağız.
Kendi makinende
executableyerinepie executableya da hattashared objectgörürsen şaşırma. Sebebi, modern derleyicilerin varsayılan olarak konumdan bağımsız (PIE) çalıştırılabilir üretmesi; dosya yine çalıştırılabilirdir.gcc -no-pieile denersen düzexecutableyazdığını görürsün. Bu ayrımın neden var olduğuna 15. bölümde döneceğiz.
Günlük hayatta bu dört aşamayı tek tek çalıştırmazsın; gcc hello.c -o hello yazar geçersin. Ama aşamaları bilmenin çok somut bir getirisi var: bir hata mesajı geldiğinde onun hangi aşamadan çıktığını anlarsın. fatal error: stdio.h: No such file or directory önişlemcidendir — bir header’ı bulamamıştır. undefined reference to 'printf' ise bağlayıcıdan gelir; kod gayet iyi derlenmiştir, sadece fonksiyonun gövdesi bulunamamıştır. İkisi bambaşka sorunlardır ve karıştırmak saatler yakar.
Dış Kaynaklar
Özet
Peki, ne öğrendik?
- ASCII 128 karakterle yetinir; dünya dillerinin tamamı için Unicode ve UTF-8 gerekir.
- UTF-8 değişken uzunlukludur ve ASCII ile geriye dönük uyumlu olacak şekilde tasarlanmıştır.
- Çok byte’lı sayılar bellekte little-endian (x86) veya big-endian (ağ) sırayla saklanabilir.
- Her byte’ın bellekte bir adresi vardır; hex dump araçlarıyla bu adresleri ve içerikleri gözlemleyebilirsin.
- Assembly CPU’ya yakındır, yüksek seviye diller insana. İkisi de sonunda aynı makine koduna iner.
- C kodu derlenirken önişlemci → derleyici → assembler → bağlayıcı zincirinden geçer.
- Hepsi aynı şeye çıkar: makine kodu, yani anlamı yorumlayana bağlı bir sayı dizisi.
Bu temelleri attıktan sonra bilgisayarın beyni olan CPU’yu derinlemesine inceleyebiliriz. Sonraki bölümde CPU’nun nasıl talimat çekip çözdüğünü göreceğiz. Daha ileride ise ELF formatı ve makine kodunun belleğe nasıl yerleştiğini adım adım çözüp, gerçek bir programın hayatını baştan sona takip edeceğiz.
3. bölüme devam et: Temeller