Ana içeriğe geç

Bölüm 1:Başlamadan Önce

20 dakikalık okumaGüncelleme:

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.

Boolean operatörlerin (AND, OR, NOT, NAND, NOR, XOR) doğruluk tabloları ve lojik kapı sembolleri.

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

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

TerimDeğer
1 byte8 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.

DecimalBinary
00
11
210
311
4100
5101
6110
7111
81000

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:

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:

  1. İki tane sıfır olur. 0000 0000 artı sıfır, 1000 0000 eksi sıfır. Birbirine eşit olması gereken iki farklı bit deseni. Her karşılaştırmada bunu ayrıca düşünmek gerekir.
  2. 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.

İşlem8 bit
50000 0101
bitleri ters çevir1111 1010
1 ekle → −51111 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ır

Sonuç 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.

DecimalBinaryHex
000000
101010A
151111F
160001 000010
2551111 1111FF

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:

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.

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ş. 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?

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

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

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

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

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

Shell oturumu
gcc -S hello.i -o hello.s
cat hello.s
hello.s
main:
        pushq   %rbp
        movq    %rsp, %rbp
        movl    $5, -4(%rbp)
        addl    $3, -4(%rbp)
        ret

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

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

Ö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