Bölüm 16:Bellek Aslında Sanal
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
Şimdiye kadar bellek konusunu kasten basitleştirerek anlattım; bu bölümde perdeyi kaldırıyoruz. ELF dosyaları verilerin yükleneceği belirli bellek adreslerini söylüyor. Peki farklı process’lerde aynı adresleri kullanmaya çalışan yapılar neden çakışmıyor? Neden her process’in kendine ait ayrı bir bellek ortamı varmış gibi görünüyor?
Bu, 3. bölümde sorduğumuz üç sorudan ikincisinin ta kendisi ve cevabı iki bölüme yayılıyor: bu bölümde mekanizmanın ne olduğunu, sonrakinde içinin nasıl kurulduğunu göreceğiz.
Bu bölümde neyi çözüyoruz?
- RAM’in neden her process’e ayrı bir dünya gibi göründüğünü anlayacağız.
- Sayfa, çerçeve, page table ve MMU kavramlarını aynı hikâyeye bağlayacağız.
- Bellek izolasyonunun, higher-half kernel’ın ve ASLR’nin hangi probleme çözüm olduğunu göreceğiz.
Bellek Aslında Sanal
Gelelim belleğe. Bunu önce adres defteri gibi düşün: Program “0x400000 adresindeki veriyi oku” dediğinde, bu adres çoğu zaman RAM çipindeki gerçek konum değildir. Process’in elinde kendine ait bir adres defteri vardır; MMU bu deftere bakıp “bu sanal adres fiziksel RAM’de şu sayfaya karşılık geliyor” çevirisini yapar.
CPU bir bellek adresinden okuma ya da o adrese yazma yaptığında aslında doğrudan physical memory’deki (RAM’deki) o noktaya gitmez; önce virtual memory alanındaki bir konuma erişir.
CPU, memory management unit (MMU) denen bir birimle konuşur. MMU, sanal adresleri RAM’deki fiziksel adreslere çeviren bir sözlük gibi davranır. CPU’ya 0xfffaf54834067fe2 adresinden okuma yap denildiğinde, CPU önce MMU’ya gidip “bunu çevir” der. MMU eşleşen fiziksel adresin 0x52a4b65a90179fe2 olduğunu bulur ve bu sonucu CPU’ya verir. CPU da artık RAM’deki gerçek konuma erişebilir. (Sayılar temsili; gerçek bir x86-64 makinede adreslerin bu kadar geniş bir kısmı kullanılmaz. Ama iki sayının son üç hanesine bak: ikisinde de fe2. Bunun tesadüf olmadığını birazdan göreceğiz.)

Bilgisayar ilk açıldığında bellek erişimleri doğrudan fiziksel RAM’e gider. Çok geçmeden işletim sistemi bu çeviri sözlüğünü kurar ve CPU’ya MMU üzerinden çalışmaya başlamasını söyler.
Her process’in sanal bellek alanı, farklı amaçlara hizmet eden bölgelere ayrılır. Aşağıdaki diyagram tipik bir process’in bellek düzenini gösteriyor: en altta text (program kodu) ve data (global/sabit değişkenler) segmentleri bulunur; yukarı doğru heap dinamik olarak büyür (malloc/mmap ile); en üstte ise stack yerel değişkenler ve fonksiyon çağrıları için kullanılır. Stack ve heap birbirine doğru büyür ama asla çakışmaz — aralarında geniş bir sanal adres boşluğu vardır. Heap’i büyüten şey kernel değil, programın içindeki tahsisçidir (malloc); o da yer gerektiğinde brk/sbrk ya da mmap syscall’ıyla kernel’dan yeni sanal sayfalar ister.

Kaynak: Silberschatz, Galvin, Gagne — Operating System Concepts, Ch. 3 — Processes, Slide 6.
Derinleşmemalloc bir syscall değildir
malloc’u syscall sanmak yaygın bir yanılgı. Aslında sıradan bir kütüphane fonksiyonudur ve çağrıların ezici çoğunluğunda kernel’a hiç gitmez. Kernel’la konuşan taraf ayrı bir katmandır ve toptan çalışır: tahsisçi işletim sisteminden bir seferde epeyce sayfa alır, sonra o sayfaları kendi içinde perakende dağıtır. Programın gördüğü hız buradan gelir. On bin kez malloc çağıran bir döngü çoğu zaman tek bir syscall bile üretmez.
Toptan alışverişin iki yolu var. Küçük ve orta istekler için tahsisçi heap’in üst sınırını brk/sbrk ile yukarı iter; heap böylece tek bir sınır işaretinin kayması ile büyür. Belirli bir eşiğin üstündeki istekler için ise doğrudan mmap çağrılır ve o tahsis, heap’ten bağımsız kendi bölgesine yerleşir. Eşik implementasyona göre değişir, mertebesi yüz kilobayt civarındadır.
Asıl iş perakende tarafında. Tahsisçi eline geçen sayfayı bloklara böler ve her bloğun hemen önüne birkaç baytlık bir başlık koyar: bu blok kaç bayt, kullanımda mı. free’nin yalnızca bir pointer alıp da neyi serbest bırakacağını bilmesinin sebebi budur; verdiğin adresin birkaç bayt gerisine bakar ve boyutu orada bulur. Bu yüzden tahsisçinin vermediği bir adresi free’ye geçirmek, rastgele baytları başlık diye okumak demektir.
Serbest bırakılan bloklar atılmaz, free list (serbest liste) denen bir zincirde beklemeye alınır. Her istekte bu zinciri baştan sona taramak pahalı olurdu; bu yüzden gerçek tahsisçiler blokları boyut sınıflarına ayırır. 32 baytlık bir istek doğrudan 32 baytlıkların kovasına gider ve oradan neredeyse anında karşılanır.
Bir de birleştirme var. Bir blok serbest kalınca tahsisçi komşusuna bakar; o da serbestse ikisini tek bir büyük blok yapar. Yapılmasa heap zamanla toz hâline gelirdi: toplamda bolca boş yer, ama art arda duran hiç yer yok. Buna dış parçalanma denir. Kardeşi olan iç parçalanma ise blokların içinde saklanır. 40 bayt istediğinde hizalama ve boyut sınıfı yuvarlaması yüzünden 48 bayt alırsın ve aradaki 8 bayt kimseye yaramaz.
Şimdi asıl soru: serbest bıraktığın bellek işletim sistemine neden geri dönmüyor? İki sebepten. Birincisi ölçü birimi. Kernel’a ancak tam sayfa geri verilebilir, oysa senin serbest bıraktığın blok bir sayfanın ortasında duruyor olabilir; aynı sayfada yaşayan tek bir canlı blok bile o sayfayı rehin alır. İkincisi tercih. Tahsisçi birazdan yine bellek isteneceğini tahmin eder ve elindekini saklar, çünkü sayfayı geri verip yeniden almanın bedeli bir syscall ve ardından ilk dokunuşta bir page fault’tur.
Buradan da herkesin bir kez şaşırdığı gözleme varıyoruz. Dev bir listeyi boşaltırsın, free çağrıları sırayla döner, ama top çıktısındaki bellek rakamı kıpırdamaz. Ortada hata yok: free belleği sisteme değil, tahsisçiye iade eder. Gerçekten geri dönen tek kategori genelde mmap ile alınmış büyük tahsislerdir, çünkü onlar munmap ile bütün hâlinde bırakılabilir.
MMU’nun baktığı o sözlüğe aslında page table denir; her bellek erişimini çeviren mekanizmanın adına da paging denir. Page table içindeki girdiler PTE (Page Table Entry — Sayfa Tablosu Girdisi) olarak adlandırılır ve her biri sanal bellek alanındaki belirli bir parçanın RAM’de nereye karşılık geldiğini söyler. Bu parçalar sabit boyuttadır ve boyut mimariye göre değişir. x86-64’ün varsayılan page boyutu 4 KiB’dir; yani her PTE, 4096 baytlık bir blok için eşleme tutar. Page = belleğin 4 KiB’lık bloğu; PTE = page table girdisi; Page Frame = fiziksel RAM’deki karşılık.
Başka bir deyişle, 4 KiB paging kullanıldığında bir adresin en düşük 12 biti MMU çevirisinden önce de sonra da aynı kalır. Bunun sebebi basit: 4096 baytlık bir page içindeki konumu göstermek için 12 bit gerekir.
x86-64 ayrıca işletim sistemlerinin 2 MiB veya 1 GiB gibi daha büyük page boyutlarını etkinleştirmesine de izin verir. Bu, adres çevirisini hızlandırabilir ama bellek parçalanmasını ve israfı artırabilir. Page ne kadar büyükse, adresin MMU tarafından çevrilmesi gereken kısmı o kadar küçülür.

Çeviriyi baştan sona tek bir akış hâlinde izlemek istersen:
- Program bir adres ister. Kod sanal bir adrese erişir. Bu adres process'e özeldir; fiziksel RAM'de nerede olduğunu program bilmez.
- Adres ikiye ayrılır. Üst bitler hangi sayfaya ait olduğunu, alt 12 bit ise o sayfa içindeki konumu (offset) söyler.
- TLB'ye bakılır. Bu çeviri yakın zamanda yapıldıysa TLB'de hazırdır ve iş burada biter. Isabet edilemezse page table yürünür.
- Page table yürünür. MMU, sayfa numarasını kullanarak tabloyu okur ve karşılık gelen fiziksel çerçeveyi bulur.
- Fiziksel adres kurulur. Bulunan çerçeve numarasının sonuna, hiç değişmemiş olan offset eklenir. Erişim artık gerçek RAM üzerinde yapılır.
Page table’ın kendisi de RAM’de tutulur ve her girdisi yalnızca 8 bayttır. Ama bu küçüklük aldatıcı: 48 bitlik bir sanal adres alanının her 4 KiB’lık sayfası için tek bir düz tabloda girdi tutmaya kalksan, tablo her process için 512 GiB ederdi. Bunun nasıl çözüldüğü bir sonraki bölümün konusu.
Boot sırasında paging’i etkinleştirmek için kernel önce RAM’de page table’ı kurar. Ardından page table’ın başlangıç adresini, page table base register (PTBR) denen register’a yazar. Son adımda da tüm bellek erişimlerinin MMU üzerinden çevrilmesi için paging’i etkinleştirir. x86-64’te bu register’ın adı CR3’tür; 9. bölümde context switch’i anlatırken “kernel tek bir register’ı değiştirir” derken kastettiğim register tam olarak bu.
Paging’in asıl büyüsü, bilgisayar çalışırken page table’ın değiştirilebilmesidir. Her process’in izole bir bellek alanına sahip olması tam da böyle mümkün olur. İşletim sistemi context switch yaparken yaptığı kritik işlerden biri, sanal bellek alanını fiziksel bellekte başka bir yere yeniden eşlemektir. Diyelim iki process var: A process’inin kodu ve verileri 0x0000000000400000 adresinden erişiliyor olabilir; B process’i de kendi kodunu ve verisini aynı sanal adresten görüyor olabilir. Bu iki process aslında aynı adres aralığı için kavga etmez, çünkü bu sanal adresler fiziksel bellekte farklı yerlere çözülür. Kernel, process değişiminde bu eşlemeyi değiştirir.

Bir not: lanetli bir ELF gerçeği
Belirli koşullarda
binfmt_elf, belleğin ilk page’ini sıfırlarla eşlemek zorunda kalır. ELF’yi destekleyen ilk sistemlerden biri olan 1988 tarihli UNIX System V Release 4.0 (SVr4) için yazılmış bazı programlar, null pointer’ın okunabilir olmasına dayanır. Ve bir şekilde bazı programlar hâlâ bu davranışı bekliyor.Görünüşe göre bunu uygulayan Linux kernel geliştiricisi pek de mutlu değildi:
“Bunun nedenini soruyorsunuz??? Çünkü SVr4, page 0’ı salt okunur şekilde eşliyor ve bazı uygulamalar buna ‘bağımlı’. Bunları yeniden derleme şansımız olmadığı için SVr4 davranışını taklit ediyoruz. İç çek.”
Evet. İç çek.
Paging ile Güvenlik
Paging’in sağladığı process izolasyonu yalnızca kod ergonomisini iyileştirmez; aynı zamanda güçlü bir güvenlik katmanı oluşturur. Process’lerin birbirinin belleğine erişememesi, kitabın başındaki önemli sorulardan birini cevaplar:
Programlar doğrudan CPU üzerinde çalışıyorsa ve CPU doğrudan RAM’e erişebiliyorsa, neden başka process’lerin belleğine ya da aman kernel belleğine erişemiyorlar?
Bunu sanki haftalar önce sormuşuz gibi geliyor, değil mi?
Peki kernel belleği ne olacak? Öncelikle kernel’ın, çalışan tüm process’leri ve page table’ın kendisini takip etmek için kendi verilerine ihtiyacı var. Bir hardware interrupt, software interrupt ya da syscall CPU’yu kernel mode’a geçirdiği anda, kernel kodunun bu belleğe erişebiliyor olması gerekir.
Linux’un yaklaşımı, sanal bellek alanının üst yarısını kalıcı olarak kernel’a ayırmaktır; bu yüzden Linux için higher-half kernel ifadesi kullanılır. Windows da benzer bir yaklaşım izler. macOS tarafı ise Mach mirası yüzünden belirgin biçimde daha karmaşık; temel fikir aynı olsa da katman sayısı fazla.

Şeritteki sınırlar bir sadeleştirme. 64 bitlik alanın tamamı kullanılmaz: gerçekte user space’in son adresi 0x00007FFFFFFFFFFF, kernel space’in ilk adresi ise 0xFFFF800000000000’dır. İkisinin arasında kalan devasa bölgedeki her adres donanım tarafından geçersiz sayılır; bu boşluğun nereden geldiğini bir sonraki bölümde göreceğiz.
User-space process’lerin kernel belleğini okuyabilmesi ya da yazabilmesi çok kötü olurdu; bu yüzden paging ikinci bir güvenlik katmanı daha sağlar: her page için izin bayrakları tutulur. Bir bayrak, page’in yazılabilir mi yoksa yalnızca okunabilir mi olduğunu söyler. Başka bir bayrak ise bu page’e yalnızca kernel mode’dan erişilebileceğini belirtir. Kernel space’in tamamı, işte bu izinler sayesinde user-space programları için fiilen erişilemez hâle gelir. Teknik olarak bazı sistemlerde eşleme vardır, ama izin yoktur. NX (No-eXecute) biti, belirli bellek sayfalarında kod çalıştırılmasını engeller — W^X (write XOR execute) güvenlik politikasının temelidir.
DerinleşmeMeltdown sonrası: kernel sayfa tablosu izolasyonu
Eskiden performans için kernel adreslerinin user process page table’larında görünmesi yaygındı; izin bitleri user mode erişimini engelliyordu. Meltdown gibi donanım açıklarından sonra Linux tarafında KPTI/PTI gibi tekniklerle user ve kernel page table’ları daha sert ayrılabildi. Ana fikir değişmiyor: user programı kernel belleğini normal yollarla okuyamaz; sadece implementasyon ayrıntısı ve performans maliyeti değişiyor.
Bir PTE’nin içinde, adresin yanında duran bayraklardan en önemlileri şunlar:
| Bit | Örnek değer | Ne anlama gelir? |
|---|---|---|
| Present | 1 | Sayfa şu an RAM’de. 0 ise bu sayfaya dokunmak bir page fault üretir. |
| Read/Write | 0 | 0 salt okunur, 1 yazılabilir. |
| User/Supervisor | 1 | 1 ise user mode da erişebilir, 0 ise yalnızca kernel. |
| Accessed | 1 | Sayfaya son sıfırlamadan beri dokunuldu mu? |
| Dirty | 0 | Sayfaya yazıldı mı, yani diske geri yazılması gerekiyor mu? |
| NX | 1 | Bu sayfadaki byte’lar talimat olarak çalıştırılamaz. |
Bu bayrakların çoğu ileride yeniden karşına çıkacak: Present, Accessed ve Dirty bitleri bir sonraki bölümde page fault’ları ve kernel’ın hangi sayfayı diske atacağına nasıl karar verdiğini anlatırken, NX biti ise 18. bölümde saldırganın yolunu keserken.
Bellek güvenliğinin bir diğer temel taşı da ASLR’dir (Address Space Layout Randomization). Stack, heap ve kütüphanelerin başlangıç adresleri her çalıştırmada rastgele kaydırılır; böylece bir saldırganın bellekteki belirli adresleri tahmin etmesi zorlaşır.
Page table’ın kendisi de aslında kernel belleğinde durur. Timer chip bir hardware interrupt tetikleyip context switch başlattığında, CPU ayrıcalık seviyesini kernel mode’a çıkarır ve Linux kernel koduna atlar. Kernel mode’da olduğu için CPU artık korumalı kernel bellek bölgesine erişebilir. Kernel, CR3’e yeni process’in page table’ının adresini yazar; böylece sanal belleğin alt yarısı artık yeni process’in dünyasını gösterir, kernel’ın oturduğu üst yarı ise her process’te aynı kalır. User mode’a geri dönüldüğünde kernel yarısına erişim tekrar kapanır.
Kısacası, neredeyse her bellek erişimi MMU’dan geçer. 4. bölümde kernel’ın hazırladığı o numaralı olay listesindeki adresler bile fiziksel adres değil, kernel’ın sanal adres alanındaki adreslerdir.
Özet
Peki, ne öğrendik?
- Bir program hiçbir zaman fiziksel RAM’i görmez. Gördüğü her adres sanaldır ve MMU tarafından çevrilir.
- Bellek sabit boyutlu sayfalara bölünür; RAM tarafındaki karşılıklarına çerçeve denir. İkisi arasındaki eşleme sayfa tablosunda durur.
- İki process aynı sanal adresi kullanabilir, çünkü o adresler farklı çerçevelere çözülür. Process izolasyonunun temeli budur.
- Her eşlemenin izin bitleri vardır: yazılabilir mi, çalıştırılabilir mi, user mode’dan erişilebilir mi.
- Linux, sanal adres alanının üst yarısını kalıcı olarak kernel’a ayırır (higher-half kernel). User programı oraya eşlenmiş olsa bile izin bitleri yüzünden erişemez.
- ASLR, adresleri her çalıştırmada kaydırarak tahmin edilebilirliği kırar.
Şimdiye kadar çeviriyi bir kutu gibi anlattım: sanal adres giriyor, fiziksel adres çıkıyor. Sıradaki bölümde o kutuyu açacağız — ve içeride tek bir tablo olmadığını göreceğiz.
17. bölüme devam et: Adres Çevirisi ve TLB