Bölüm 1:Başlamadan Önce
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
Bilgisayarlar hakkında konuşurken sürekli karşımıza çıkan birkaç kelime var: bit, byte, binary, hex… Eğer bunlar sana yabancı geliyorsa endişelenme, bu bölüm tam olarak bunun için var. Sonraki bölümlere atlama; buradaki her kavram bir sonrakinin temeli.
Hepsi tek bir soruya bağlanıyor: bilgisayar neden sadece iki şey sayabiliyor ve bu iki şeyle nasıl her şeyi anlatıyor?
Bu bölümde neyi çözüyoruz?
- Bilgisayarların neden sadece 0 ve 1 anladığını göreceğiz.
- Bit, byte ve ikilik sistemi pratikleştirip negatif sayıların nasıl tutulduğunu çözeceğiz.
- Hexadecimal’in neden var olduğunu ve nerede işe yaradığını göreceğiz.
- Metnin bellekte nasıl sayıya döndüğünü, ASCII ile Unicode farkını anlayacağız.
- Bellek adresi fikrini ve byte sıralamasını (endianness) kafaya oturtacağız.
- Assembly ile yüksek seviye diller arasındaki farkı ve derleme zincirini netleştireceğiz.
Neden Sadece 0 ve 1?
Bilgisayarların içinde milyarlarca minik anahtar var: transistörler. Bir transistörün yaptığı iş şaşırtıcı derecede basittir — bir devrede akımın geçmesine izin verir ya da vermez. Açık ve kapalı, o kadar.
Bir ucundan bakınca sıradan bir elektrik anahtarı. Ama bu anahtarları belirli kalıplarda birbirine bağladığında ortaya iki tür devre çıkar: karar verebilenler (lojik kapılar) ve durumunu hatırlayabilenler (bellek hücreleri). Bilgisayar dediğimiz şey, bu iki tür devrenin milyarlarcasının üst üste bindirilmesinden ibaret.
Biz de o iki kararlı duruma bir isim veririz: 0 ve 1. Tüm hesaplamalar, tüm videolar, tüm oyunlar, hatta şu an okuduğun bu cümle bile — hepsi bu iki durumun kontrollü kombinasyonlarından oluşur.
Buradaki en yaygın yanılgıyı baştan kapatalım: “bir transistör = bir bit” değildir. Bir transistör temel tuğladır; bit ise o tuğlalarla kurulan devrenin tuttuğu anlamdır. Hızlı ama pahalı olan SRAM’de tek bir bit, birbirini besleyen birkaç transistörden kurulu küçük bir kilitle (latch) tutulur. Ucuz ve yoğun olan DRAM’de ise tek transistör ve minik bir şarj deposu (kapasitör) yeter — bu yüzden DRAM’in içeriğinin sürekli tazelenmesi gerekir. İkisinin farkına 5. bölümde cache’i konuşurken döneceğiz.
“Milyarlarca” derken abartmıyorum: Apple M3 çipinde yaklaşık 25 milyar transistör var. Hepsi de yukarıda anlattığım tek işi yapıyor — geçir ya da geçirme.
Şimdi bu anahtarlardan mantığın nasıl çıktığına bakalım. Birkaç transistörü öyle bağlarsın ki çıkış ancak her iki giriş de 1 iken 1 olur — buna AND dersin. Öyle bağlarsın ki herhangi biri 1 iken çıkış 1 olur; bu OR. Girişi tersine çeviren tek düzenek NOT, iki giriş birbirinden farklıysa 1 veren düzenek de XOR’dur.
Aşağıdaki tabloda her kapının hangi girişe ne cevap verdiğini göreceksin. Aklında tutmanı istediğim şey şu: bilgisayarın yaptığı her şey — toplama, video çözme, bu sayfayı ekrana çizme — bu birkaç kapının milyarlarca kez üst üste bindirilmesinden ibaret.

Kaynak: William Stallings, Computer Organization and Architecture, Ch. 11 — Digital Logic, Slide 5.
İki kapının ayrıca not düşmeye değer bir özelliği var.
XOR kendi kendini geri alır. Bir değeri aynı anahtarla iki kez XOR’larsan orijinali geri elde edersin: A ⊕ K ⊕ K = A. Bu küçük özellik, en basit şifreleme yöntemlerinin, grafik programlarındaki “ters çevir” işlemlerinin ve hata tespit algoritmalarının temelidir.
NAND tek başına yeter. NAND (AND’in tersi) evrensel bir kapıdır: yalnızca NAND kapıları kullanarak AND’i de, OR’u da, NOT’u da, dolayısıyla herhangi bir devreyi kurabilirsin. Üretimde bunun karşılığı çok somut: tek tip bir kapıyı milyarlarca kez basmak, farklı tipleri karıştırmaktan hem ucuz hem güvenilirdir.
Bit ve Byte
- Bit: En küçük bilgi birimi. 0 veya 1.
- Byte: 8 bit’in bir araya gelmesi. 1 byte = 8 bit.
Neden 8? Aslında temiz bir sebebi yok; bu bir tasarım zorunluluğu değil, tarihsel bir uzlaşma.
Hikâye şöyle gelişti. ASCII 7 bit ile 128 karakteri temsil edebiliyordu, yani metin için yedi bit yetiyordu. Birçok sistem kalan sekizinci biti parity için ayırdı. Parity fikri çocukça basittir: diğer yedi bitteki 1’lerin sayısı tek mi çift mi, onu söyleyen bir bit eklersin. Hat üzerinde bir bit bozulursa sayı tutmaz ve hatayı yakalarsın. Tek bitlik hatayı fark eder, düzeltemez — ama 1960’ların gürültülü hatlarında bu bile büyük kazançtı.
Sonra IBM 1964’te System/360’ı çıkardı ve 8 bitlik byte’ı kendi mimarisinin merkezine koydu. Bu makine dönemin sektör standardı oldu; ardından gelen donanım da yazılım da bu ölçüye göre büyüyünce, 1 byte = 8 bit artık tartışılmayan bir alışkanlığa dönüştü.
| Terim | Değer |
|---|---|
| 1 byte | 8 bit |
| 1 kilobyte (kB) | 1.000 byte |
| 1 kibibyte (KiB) | 1.024 byte |
| 1 megabyte (MB) | 1.000.000 byte |
| 1 mebibyte (MiB) | 1.024 × 1.024 byte |
Bu kitapta bellek boyutları bağlamında genelde kibibyte (KiB), mebibyte (MiB) gibi ikili tabanlı birimleri kullanacağız. Çünkü bilgisayarlar 2’nin kuvvetleriyle düşünür.
İkilik (Binary) Sistem
Sen on parmağınla saydığın için onluk sistemi doğal buluyorsun: 0’dan 9’a on rakam, sonra basamak atlarsın. Donanımın parmağı yok; elinde güvenilir biçimde ayırt edebildiği yalnızca iki durum var. O yüzden bilgisayar ikilik sistemle sayar: sadece 0 ve 1. Bu iki değere yazılım tarafında false ve true, devre tarafında düşük ve yüksek voltaj dersin — isim değişir, iş değişmez. Önemli olan voltajın kaç volt olduğu değil, iki durumun birbirine karışmamasıdır.
| Decimal | Binary |
|---|---|
| 0 | 0 |
| 1 | 1 |
| 2 | 10 |
| 3 | 11 |
| 4 | 100 |
| 5 | 101 |
| 6 | 110 |
| 7 | 111 |
| 8 | 1000 |
Bu sistemi zaten biliyorsun, sadece farkında değilsin. Onluk sistemde 523 yazdığında aslında şunu kastediyorsun: 5×10² + 2×10¹ + 3×10⁰. Her basamak, tabanın bir kuvveti. İkilik sistemde değişen tek şey taban: 10 yerine 2.
Yani binary’deki her basamak 2’nin bir kuvvetini temsil eder. Sağdan sola: 2⁰, 2¹, 2², 2³…
Örneğin 1011 binary:
- 1 × 2³ = 8
- 0 × 2² = 0
- 1 × 2¹ = 2
- 1 × 2⁰ = 1
- Toplam: 11 (decimal)
Peki Eksi Sayılar?
Buraya kadar her şey pozitif. Ama -5 nasıl saklanıyor? Elimizde eksi işareti diye bir şey yok; yalnızca 0’lar ve 1’ler var.
Akla gelen ilk çözüm en soldaki biti işaret olarak kullanmaktır: 0 ise artı, 1 ise eksi. Mantıklı görünür ve çalışmaz. İki sorunu vardır:
- İki tane sıfır olur.
0000 0000artı sıfır,1000 0000eksi sıfır. Birbirine eşit olması gereken iki farklı bit deseni. Her karşılaştırmada bunu ayrıca düşünmek gerekir. - Toplama bozulur.
5 + (-5)yapmak istersen devrenin önce işaret bitlerine bakıp “aslında bu bir çıkarma” diye karar vermesi gerekir. Yani ayrı bir çıkarma devresi kurman gerekir.
Gerçekte kullanılan çözüm çok daha zarif: ikiye tümleyen (two’s complement). Bir sayının negatifini bulmak için iki adım yeter — tüm bitleri ters çevir, sonra 1 ekle.
| İşlem | 8 bit |
|---|---|
| 5 | 0000 0101 |
| bitleri ters çevir | 1111 1010 |
| 1 ekle → −5 | 1111 1011 |
Şimdi sihri gör. 5 + (-5) toplamasını sıradan bir toplayıcıya verelim:
0000 0101 (5)
+ 1111 1011 (-5)
-------------
1 0000 0000 → taşan bit atılır, geriye 0000 0000 kalırSonuç sıfır. Üstelik devre “bunlardan biri negatifmiş” diye bir şey bilmiyor; sadece topladı. İkiye tümleyenin bütün amacı budur: çıkarma diye ayrı bir işleme gerek kalmaz, negatifini alıp toplarsın. Donanımda tek bir toplayıcı hem toplamayı hem çıkarmayı yapar.
Bir de sıfır artık tek: 0000 0000. Karşılığında küçük bir tuhaflık ortaya çıkar — aralık simetrik değildir. 8 bit ile temsil edilebilen sayılar −128 ile +127 arasındadır. Negatif tarafta bir sayı fazladır, çünkü artık ikinci bir sıfıra yer harcanmıyor.
DerinleşmeO eksik bir sayının gerçek hayattaki bedeli
Aralığın simetrik olmaması akademik bir ayrıntı gibi durur. Değildir.
-128’in mutlak değeri +128’dir ve +128, 8 bitlik işaretli bir sayıya sığmaz. Yani abs(-128) sorusu 8 bit dünyasında cevapsızdır. Aynı şey her genişlikte tekrarlanır: 32 bitlik bir tam sayıda abs(INT_MIN) tanımsızdır.
Bu, teorik bir merak değil, gerçek bir açık sınıfıdır. Bir programın “uzunluk negatif olamaz, mutlak değerini alayım” diye yazılmış tek satırı, saldırganın INT_MIN göndermesiyle negatif kalır ve ardından gelen sınır kontrolünü sessizce geçer.
C ve C++ standartları işaretli tam sayı taşmasını tanımsız davranış ilan eder; taşmayı “sarma” olarak varsayan kod, derleyici optimizasyonu açıldığında bambaşka davranabilir. Derleyici “taşma olamaz” varsayımıyla kontrolü tamamen silebilir — ki bu, gerçekte yazılmış güvenlik kontrollerinin optimizasyonla yok olduğu meşhur hataların kaynağıdır.
Modern diller bu sınıfı kapatmayı seçer: Rust hata ayıklama derlemesinde taşmada panik eder, üretimde açıkça wrapping_* / checked_* demeni ister. Yani “hangi davranışı istediğini söyle” der; varsayım bırakmaz.
Hexadecimal (Onaltılık) Sistem
İkilik sistemin bir bedeli var: yazması ve okuması dayanılmaz derecede uzun. 255 sayısını 11111111 diye yazmak zorunda kalırsın; birkaç basamak sonra gözün kayar ve fazladan bir 1 saydığını fark bile etmezsin.
Bunu çözmek için onaltılık (hexadecimal) sisteme geçeriz: 0-9’un devamına A’dan F’ye altı harf ekleriz, böylece tek karakterle 0 ile 15 arasını anlatabiliriz.
Hex’i asıl kullanışlı yapan şey ise şu eşitlik: 16 = 2⁴. Yani her hex karakteri tam olarak 4 bit’e karşılık gelir. Bunun pratik sonucu çok büyük — binary ile hex arasında çevirmek bir hesap işi değil, sadece gruplama işidir:
1101 0110 → sağdan dörderli böl → 1101 = D, 0110 = 6 → D6
Hiç toplama çıkarma yapmadın; sadece grupları okudun. 4 bitlik bu gruba nibble denir (byte’ın yarısı olduğu için “kemirik”). Sekizlik sistem (octal) bu yarışı neden kaybetti diye sorarsan: 8 = 2³’tür ve 3, 8’i tam bölmez — bir byte octal’de iki buçuk basamak eder, ki bu kimsenin işine yaramaz.
| Decimal | Binary | Hex |
|---|---|---|
| 0 | 0000 | 0 |
| 10 | 1010 | A |
| 15 | 1111 | F |
| 16 | 0001 0000 | 10 |
| 255 | 1111 1111 | FF |
Bir byte (8 bit) her zaman iki hex karakteriyle gösterilebilir. Bu yüzden bellek dökümleri ve makine kodları genelde hex formatında yazılır.
Sayıları hallettik. Ama bilgisayarın belleğinde sadece sayı yok: metin de var, adresler de var, programın kendisi de var. Şimdi sıra bunların hepsinin aslında yine sayı olduğunu görmekte — aradaki tek fark, o sayıyı nasıl yorumladığın.
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, string sonunu işaretleyen\0= 0. - 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. Onlarca yıl boyunca toupper ve tolower fonksiyonları tam olarak bunu yaptı — tek bir bit işlemi.
‘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.
- 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ş. 1981’de Danny Cohen bu terimleri bilgisayar bilimine taşımıştır.
Neden x86 little-endian? Cevap tarihte. Intel’in ilk mikroişlemcisi olan 4004 (1971) 4 bitlik bir çipti ve aritmetiği tıpkı senin kâğıt üzerinde toplama yapman gibi, en sağdaki basamaktan başlayarak yürütüyordu. En düşük anlamlı parçayı önce işlediği için, sayıyı bellekte de en düşük anlamlı byte önde tutmak işleri kolaylaştırdı. Bir yıl sonraki 8008 bu tercihi devraldı, 8080 ondan devraldı ve 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 13. bölümde geleceğiz.
Ş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şmek, 1’inci daireye erişmekle tam olarak aynı sürer. Kasete ya da plağa göre devrimsel olan şey buydu.
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. 2. 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 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
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.
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. 2. ve 5. 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 hiçbir şeyi makine koduna çevirmez — programını satır satır okuyup gereken işi kendisi yapar. python script.py yazdığında ortada makine koduna dönüşmüş bir dosya yoktur; çalışan şey Python yorumlayıcısının kendisidir.
(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. C kaynağı makine koduna dört aşamada dönüşü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: beş satırlık bir 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.smain:
pushq %rbp
movq %rsp, %rbp
movl $5, -4(%rbp)
addl $3, -4(%rbp)
ret
-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.
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; bir önceki aşamada boş bırakılan printf adresini bulup yerine yazar — buna sembol çözümleme denir — ve her şeyi son adreslerine oturtur. Ortaya artık eksiği olmayan, tek başına çalıştırılabilir bir dosya çıkar.
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 — 12. 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 12. 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. 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
- ASCII — Vikipedi
- Unicode — Vikipedi
- Endianness — Vikipedi
- Transistör — Vikipedi
- UTF-8 ve Unicode Standardı — unicode.org
Ö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.
2. bölüme devam et: Temeller