Ana içeriğe geç

Bölüm 2:Metin, Bellek ve Kod

16 dakikalık okumaGüncelleme:

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:

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.

KarakterDecimalHex
‘A’6541
‘a’9761
‘0’4830
’ ’ (boşluk)3220

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ı).

“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?

Bellekte byte sıralaması
Adres     Big-endian    Little-endian
0x1000    12            78
0x1001    34            56
0x1002    56            34
0x1003    78            12

Dikkat 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.

AdresDeğer (Hex)Değer (ASCII)
0x100048‘H’
0x100165‘e’
0x10026C‘l’
0x10036C‘l’
0x10046F‘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:

Shell oturumu
echo -n "AB" | xxd

Çıktısı şu şekilde olur:

xxd çıktısı
00000000: 4142                                     AB

41 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:

Shell oturumu
objdump -d /bin/ls | head -20
objdump çıktısı
0000000000004000 <.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.

hello.asm
; x86-64 assembly örneği
mov rax, 5      ; rax register'ına 5 yaz
add rax, 3      ; rax'a 3 ekle

Yü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.)

hello.c
// 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:

hello.c
#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:

hello.c'den çalıştırılabilir dosyaya
hello.cC · 8 satırönişlemcigcc -Ehello.iC · 800+ satırderleyicigcc -Shello.sassembly metniassemblergcc -chello.omakine kodulibchazır kütüphanebağlayıcıgcc -ohelloçalıştırılabilir
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.
  1. 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.
  2. Ö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.
  3. 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.
  4. 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.
  5. 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.i

Sonucu 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.

Shell oturumu
gcc -S hello.i -o hello.s
cat hello.s
hello.s (sadeleştirilmiş)
.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
        ret

C 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.

-S bayrağı (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.o

file 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ı:

objdump -dr hello.o (kısaltılmış)
  27:   e8 00 00 00 00          call   2c <main+0x2c>
                        28: R_X86_64_PLT32      printf-0x4

e8, “ş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 executable yerine pie executable ya da hatta shared object gö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-pie ile denersen düz executable yazdığı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