Bu yazıda programlama dünyasının derinliklerine inerek C ve Assembly’nin arasında nasıl bir bağlantı olduğunu ele alacağız. Örnek bir C kodunu parçalarına ayırarak bilgisayarların kodu nasıl algıladığını göreceğiz.

The GNU Compiler Collection (GCC) ücretsiz bir C derleyicisidir. (compiler)

C kodunu işlemcinin anlayabileceği makine koduna dönüştürür.

Dışarı verdiği çalıştırılabilir bir binary dosyasıdır. Default olarak a.out ismiyle gelir.

Disassemble için aşağıdaki kodu kullanacağız.

Açağıdaki komut kodu bizim için derleyecek ve a.out dosyasını aynı klasörde oluşturacaktır.

Çıktı dosyasının oluşturulup oluşturmadığını kontrol ediyorum :

Görüldüğü üzere dosya oluşturulmuş. Aşağıdaki komutla çalıştırdığımda :

Kod derlenip çalıştırılabilir hale gelmeden aslında hiçbir şey yapmaz.

C kaynak kodunu programın kendisi gibi düşünmek yaygın bir yanlıştır.

Binary “a.out” dosyamızın içindekiler aslında makine dilinde yani işlemcinin anlayabileceği temel dilde yazılmış talimatlardır.

Derleyiciler bizim yazdığımız C kodunu işlemci mimarisinin çeşidine göre makina diline çevirir.

Bu durumda işlemcimiz x86 mimarisini kullanan bir ailenin mensubu.

Sun Workstations’larda kullanılan Sparc işlemciler, Intel öncesi Maclerde kullanılan PowerPC işlemciler gibi farklı işlemci mimarileri de mevcut.

Her mimarinin kendine has bir makina dili var ve derleyici bizim için C kodunu hedef mimariye göre makina diline çeviriyor.

Derlenmiş program çalıştığı sürece genelde sadece kaynak kodu hakkında endişelenecektir.

Fakat biz gerçek dünyada nasıl bir etkisi olduğuyla da ilgileneceğiz.

CPU’nun çalışmasını daha iyi kavrayarak CPU üzerinde çalışan programları değiştirmek mümkündür.

objdump

Programın az önce bir kaynak kodu yazıp x86 mimarisi için derledik.

Fakat bu çalıştırılabilir binary dosyası nasıl gözüküyor?

GNU geliştirici araçları objdump adında bir program içerir.

objdump derlenmiş binary dosyalarını incelemek mümkündür.

Örneğin aşağıdaki komutu kullanarak main() fonksiyonumuzun neye çevrildiğini görebiliriz.

objdump bize çok fazla satır vereceğinden grep isimli komut satırı seçeneğini kullanarak çıktıyı main.: ve ardından gelenler ile birlikte yalnızca 20 satırı gösterecek şekilde ayarladık.

Görüldüğü üzere her byte hexadecimal notasyonda yani 16’lık sayı sisteminde gösterilmiş. 16’lık sayı sistemi bildiğimiz üzere 0’dan 9’a normal rakamlar ve ardından A B C D E F ile 10’dan 15’e olan rakamları içeriyordu. Bir byte da 8 bitten oluşuyor. Her birinin 1 veya 0 olması 2üzeri8 den 256 ihtimal eder. Bu da her byte’ı 2 hexadecimal rakamla gösterebileceğimiz anlamına gelir.

Soldakiler ise hafızadaki adresleri göstermektedir. Bitlerden oluşan makina dili talimatlarının bir yerde saklanması gerekir. Bilgisayarın hafızası adreslerle numaralandırılmış geçici depolama alanlarından oluşur. Bir caddede sıralanmış evler gibi her biri kendi numarasına sahiptir. Hafızadaki her bir alan sahibi olduğu adres üzerinden erişilebilir. CPU bu sayede hafızanın istediği parçasına ulaşıp programın çalışmasını sağlar. Daha eski olan Intel x86 işlemciler 32-bit adresleme şeması kullanırken daha yeni olanlar 64-bit kullanır. 32-bit işlemciler 2üzeri32 adres ihtimaline sahipken 64-bit işlemcilerde 2üzeri64 olası adres vardır. 64-bit işlemciler 32-bit uyumluluk modunda çalışarak 32-bit kodu da kolaylıkla çalıştırabilir.

Yukarıda ortada listelenmiş olan hexadecimal byte’lar x86 işlemciler için makine dili talimatlarıdır. Bunlar CPU’nun anlayabileceği 0lar ve 1 lerdan oluşan byteların yalnızca bir gösterim şeklidir. 0101010110001001111001011000001111101100111100001.. şeklinde bir yazım CPU haricinde birşey için pek anlaşılır olmadığından makina kodu her bir talimat kendi satırında olacak şekilde bölünür. Tıpkı paragrafı cümlelere bölmek gibi. Fakat hexadecimal bytelar assembly gibi bir dil olmadan pek kullanışlı değildir. Sağ taraftakiler ise işte bu assembly dilinin kodlarıdır. Assembly dili yalnızca makina dili talimatları ile uyuşan bir koleksiyondur. Yine de ret gibi bir komutu akılda tutmak 0xc3 veya 11000011’i akılda tutmaktan çok daha kolay ve anlamlıdır.

Assembly C ya da derlenen herhangi bir dile benzemez. Assembly dilinin talimatları makine dili talimatlarıyla bire bir ilişkiye sahiptir. Bu demek oluyor ki her işlemci mimarisi kendi talimatına sahip olduğuna göre her biri kendi assembly formuna da sahip olacaktır. Assembly programcı için yalnızca işlemciye verilecek makine talimanlarını göstermek için bir yoldur. Genellikle kullanılan iki ana assembly dili syntax’ı(sözdizimi) vardır. AT&T ve Intel. Yukarıda görülen syntax AT&T syntax’ıdır. Tüm Linux diassaembly araçları default olarak bu syntax’ı kullanır. AT&T syntaxını % ve $ işaretlerinin kakafonisinden ve herşeyi prefix olarak (operatörlerin başa alınması) özelliklerinden tanımak kolaydır. Aynı kod Intel syntax’ıyla görüntülemek için bir komut satırı seçeneği olan -M intel’i kullanıyoruz.

Aynı dosyanın Intel syntax’ıyla çıktısı aşağıdaki gibi oldu :

İşlemcinin anlayabildiği komutlar oldukça basittir. Bu komutlar bir operasyondan ve bazı durumlarda hedefi belirden eklenecek argümanlardan oluşur. Bu operasyonlar hafıza üzerinde temel matematik işlemleri yapar. Sonuç olarak işlemcinin bütün yaptığı iş budur denilebilir. Fakat tıpkı küçük bir alfabeyle sonsuz sayıda kitap yazılabileceği gibi bu nispeten kısıtlı makine talimatlarıyla sonsuz sayıda program yazılabilir. İşlemciler aynı zamanda kendilerine ait özel değişken setlerine de sahiptir.  Bunlara register denir. Talimatların çoğu bu register’ları okuma ve yazma işlemleri için kullanır. Bu nedenle işlemcinin register mekanizmasını anlamak genel çalışmasını anlamak için gereklidir.

8086 CPU ilk üretilen x86 işlemciydi. Intel’in 8086 işlemcisinin 1980 yılına kadar ciddi manada kullanılmadığını söylemek hata olmaz. 1980 yılında IBM Intel’in 8086 işlemcisi ile herkesin evinde kullanabileceği bir kişisel bilgisayar yapmak için kolları sıvadı. O zamana kadar 8 bit tabanlı bilgisayarlarda CPM denilen nispeten basit bir işletim sistem kullanılıyordu. IBM yeni kişisel bilgisayarı için tamamen yeni bir işletim sistemi arayışına girişti. İşte bu sırada öyküye Microsoft ve DOS girmektedir. Böylece ilk kişisel bilgisayar 1981 yılında Intel firmasının 8086 mikroişlemcisi, Microsoft firmasının ise DOS işletim sistemiyle piyasaya sürülmüş oldu. Bundan sonra Intel 8086 işlemcisinin üst modellerini çıkarttıkça IBM bunları kullanarak kişisel bilgisayarları geliştirmiş ve hızlandırmıştır.

x86 işlemcinin içsel değişkenlere benzetebileceğimiz çeşitli registerları vardır. Fakat registerlardan soyut olarak bahsetmemiz yerine kendimiz görmemiz daha iyi olacaktır.

GDB

GNU geliştirme araçları GDB adlı bir debugger içerir.
Debugger’lar programcılar tarafından derlenmiş bir programı aşama aşama çalıştırmakta kullanılır.
Bu sayede program memory ve işlemci registerlarına bakmak mümkün olur.
Hiç debugger kullanmamış bir programcı mikroskop kullanmamış bir 17. yüzyıl doktoruna benzer.
Mikroskop örneğine benzer şekilde bir debugger da programcıya makina kodunun mikroskobik dünyasını gözlemleme olanağı sağlar.
Fakat bir debugger bize bu mikroskop metaforunun izin verdiğinden de güçlüdür. Mikroskoba benzemeyen bir şekilde debuggerlar işlemi her açıdan görmenize, durdurmanıza ve yola devam ederken birşeyleri değiştirebilmenize olanak sağlar.

GDB ile az önce yazdığımız programı çalıştıralım :

Yukarıdaki işlemde “break main” diyerek main() fonksiyonuna bir breakpoint bıraktık.
Bu sayede program main() fonksiyonda durdu. Sonra “info registers” diyerek bütün işlemci registerlarının o anki durumunu ekrana yansıttık.

Sanal makina üzerinde çalıştığımızdan ya da 64-bit’den dolayı değerler farklılık gösterebiliyor.

Kitabın orjinalinde çıktı daha farklı :

Bu örnek üzerinden devam edecek olursak. İlk dört register EAX, ECX, EDX ve EBX genel amaçlı registerlar.

  • EAX Accumulator
  • ECX Counter
  • EDX Data
  • EBX Base

registerlar. Çeşitli amaçlarla kullanılabilirler fakat esas olarak CPU makina talimatlarını işlerken geçici değişkenler olarak rol alırlar.

Diğer dörlü ESP, EBP, ESI ve EDI bunlarda genel amaçlı registerlardır. Fakat bazen pointer veya index olarak bilinirler.

  • ESP Stack Pointer
  • EBP Base Pointer
  • ESI Source Index
  • EDI Destination Index

İlk ikisi pointer olarak adlandırılır çünkü hafızadaki 32-bit adresleri gösterirler. Bu registerlar programın çalışmasında ve hafıza yönetiminde oldukça etkilidirler.

Son ikisi ise yine teknik olarak pointer’dır. Genellikle okunmak veya yazılmak istenen bir data için kaynak ve hedefi işaret ederler. Bu registerları kullanan load ve store talimatları vardır fakat genel olarak bu registerlar sadece genel amaçlı registerlar olarak düşünülebilir.

EIP register’ı Instruction Pointer registerıdır. İşlemcinin o an için okuduğu talimatı(instruction) işaret eder. Tıpkı okuduğu her bir kelimeyi parmağıyla gösteren bir çocuk gibi işlemci de her talimatı parmağı yerine geçen EIP registerı ile okur. Doğal olarak bu register çok önemlidir ve debugging işlemi sırasında çokca kullanılır. Şu an için 0x804838a hafıza adresini işaret etmekte.

Geri kalan EFLAGS register’ı karşılaştırma ve hafızayı bölümlendirme için(memory segmentation) kullanılan bit flag’lardan oluşur. Hafıza gerçekte birden fazla segment’te bölümlenmiştir. Bu registerlar işte o bölümlerin kaydını tutar. Genellikle bu registerlar gözardı elilebilir çünkü onlara direk olarak erişmeye çok az ihtiyacımız olur.

Assembly Dili

Kitapda Assembly Dili için Intel Syntax’ını kullanacağımızdan kullanacağımız araçlar da bu syntax’a göre ayarlanmalıdır. GDB içerisinden diassembly syntax’ı basitçe “set dissasembly intel” ya da kısaca “set dis intel” diyerek ayarlanabilir. Bu komutu home directory içerisindeki .gdbinit dosyası içine koyarak GDB her çalıştığında çalışmasını sağlayabilirisiniz.

Not Kitapta Bu Şekilde Yazıyor fakat yalnızca şu çalıştı : set disassembly-flavor intel

Sonuç olarak GDB intex syntax’ına göre ayarlanmış oldu. Şimdi bu syntax’ı anlamaya çalışalım.
Intex syntax’ında talimatlar genellikle şu şekildedir :

Destination ve Source bir register bir hafıza adresi veya bir değer olabilir.
Operation ise genellikle kısalma (mnemonik)’lerden oluşur.
mov operasyonu bir değeri kaynaktan hedefe taşıyacaktır.
sub (substract)çıkarır, inc arttırır (increment).
Örneğin ilk kodumuzun assembly versiyonundan alınan aşağıdaki talimatlar ESP den EBP’ye değeri taşır ve sonucu 8 çıkararak ESP içerisinde saklar.

Ayrıca çalışma akışını kontrol eden operasyonlar da mevcuttur.
cmp operasyonu değerleri karşılaştırmak (compare) için kullanılır ve temel olarak j ile başlayan her operasyon karşılaştırmanın sonucuna bağlı olarak kodun başka bir kısmına atlama (jump) gerçekleştirir. Aşağıdaki örnekte ilk satır EBP’nin gösterdiği 4-byte değer eksi 4’ü 9 ile karşılaştırıyor. İkinci talimat ise bir önceki karşılaştırmanın sonucunu referans alıyor ve eğer 9’dan küçük veya eşitse 0x8048393‘e bir atlama(jump) gerçekleştiriyor. Aksi halde çalıştırma bir sonraki talimata geçecek. Bu talimatta ise herhangi bir koşula bağlı olmaksızın 0x80483a6’ya atlaması söyleniyor.

Daha önceki diassembly örneklerimizde debuggerımızı Intel syntax’ına ayarlamıştık
Şimdi assembly talimatları seviyesinde bu kodu inceleyelim
-g parametresi ile GCC compiler’ın kaynak kodumuza erişmesine de izin verebiliriz.

Not : Invalid register eip hatası aldım 64-bit programsa rip oluyormuş eip değil.
Kitaptaki kod ise şu şekilde :

Yukarıda GDB üzerinden yaptığımız işlemler şunlardı :

1. Gdb ile programı aç.

2. Kaynak kodunu listele.

3. main fonksiyonun diassemble edilmiş halini ekrana bastır.

4. Main’e bir breakpoint bırak. Program bu noktada duracak.

5. EIP yani Instruction Pointer registerını ekrana bastır.


Yazının 2. bölümü için buraya bakınız.