CPU etiketine sahip kayıtlar gösteriliyor. Tüm kayıtları göster
CPU etiketine sahip kayıtlar gösteriliyor. Tüm kayıtları göster

10 Mart 2019 Pazar

Streaming SIMD Extensions (SSE) ve glibc


Merhaba. Bu yazı, önceki kayar noktalı sayılarla ilgili yazıların kamera arkası gibi olacak. Kayar noktalı sayılarla ilgili yazıyı hazırlarken ilginç bir sorun yaşadım. Oracle Blog'daki kodu derleyip assembly çıktısına baktığımda gcc'nin herhangi bir parametre verilmediğinde kayar nokta işlemlerini SSE komutlarıyla derlediğini farkettim. (Platform: Linux Mint 17.3, Centos 6.x ve 7.x)

...
40054d:  f2 0f 10 45 e8        movsd xmm0,QWORD PTR [rbp-0x18]
400552:  bf 18 06 40 00        mov edi,0x400618
400557:  b8 01 00 00 00        mov eax,0x1
40055c:  e8 af fe ff ff        call 400410 <printf@plt>
400561:  f2 0f 10 45 f8        movsd xmm0,QWORD PTR [rbp-0x8]
400566:  f2 0f 10 0d b2 00 00  movsd xmm1,QWORD PTR [rip+0xb2]
                               # 400620 <_IO_stdin_used+0x10>
40056d:  00
40056e:  f2 0f 5e c1           divsd xmm0,xmm1

Yukarıdaki kod parçası printf ve bölme komutlarını içeriyor.

Bu kodu FPU'da çalışmaya zorlamak için önce -mno-sse derleyici parametresini denediğimde aşağıdaki kodu üretti:

...
40054d:  bf 08 06 40 00      mov    edi,0x400608
400552:  b8 00 00 00 00      mov    eax,0x0
400557:  e8 b4 fe ff ff      call    400410 <printf@plt>
40055c:  dd 45 f8            fld QWORD PTR [rbp-0x8]
40055f:  dd 05 ab 00 00 00   fld QWORD PTR [rip+0xab]
                             # 400610 <_IO_stdin_used+0x10>
400565:  de f9               fdivrp st(1),st

Bu aynı kodun assembly çıktısı ilginç biçimde çıktı olarak 4.940656e-324 dışında birşey üretmiyor. Bu arada çıktıyı kısaltmak için double d = 1.0; ifadesini double d = pow(2.0, -1000); olarak değiştirdim. 1075 satır çıktı üreten kod 75 satır çıktı üretmeye başladı.

-mno-sse'li veya -mno-sse'siz çıktının satır sayısı değişmiyor ve uygulama sonlanıyor. Yani sorun hesaplamada değil ama ekrana yanlış yazılıyor. İki kod parçası karşılaştırıldığında, ilkinde sonuç xmm0'a yazılıyor. edi'de "%e\n"in göstericisi, eax'te printf'e giren ekstra argüman sayısı var. İkinci çıktıyla karşılaştırdığımda, -mno-sse yüzünden xmm0 yazmacına erişilemediği için bu komut yok bu nedenle de eax'te 0 var. Bunun C karşılığı printf("%e\n"); ve başka argüman olmadığından muhtemelen xmm0'de kalmış çöp değer ekrana yazılıyor. Bu arada gdb ile kontrol ettiğimde divsd komutu doğru değerlerle çalışıyor. Bu da hatanın printf'te olduğunu ispatlıyor.

Yukarıdaki kod parçalarını Mint'den aldım (glibc v2.19) ama CentOS'da da fark yok. Denemeleri CentOS sanal makinalarda yaptım. Sanırım xmm yazmaçları 64-bit olduklarından, 32-bit kodlarda böyle bir sorun yok. İstenirse SSE komutları 32-bit kodlarda kullanılmak üzere zorlanabiliyor: https://stackoverflow.com/questions/24386541/gcc-wont-use-sse-in-32bit

CentOS 6.x, glibc 2.12 kullanıyor, CentOS 5.6 ve 5.9'la da denediğimde (glibc v2.5) aynı durum tekrarlandı. 64-bitlik ilk CentOS 3.3, glibc v2.3.2 kullanıyor. Bununla denediğimde sanırım gcc (v3.2.3) sürümündeki bir hatadan ötürü -mno-sse'nin bir etkisi olmadı. Kod çıktısında sonuç yine xmm0'da printf'e gidiyor gibiydi.

Öyle anlaşılıyor ki, glibc derlenirken printf işlevinde SSE kullanılmış ve ondalıklı sayıların printf fonksiyonuna mutlaka xmm0'da girmesi gerekiyor. Bu nedenle -mno-sse parametresi kesinlikle kullanılmamalı. İşlemleri FPU'ya zorlamak için -mno-sse yerine -mfpmath=387 kullanıldığında istenen sonuç elde ediliyor.

Öte yandan önceki yazıda Oracle Blog'da söz edilen -fns parametresinin gcc'de bulunmadığından söz etmiştim. gcc'de aynı işi yapan -ffast-math parametresi bulunuyor. Bu parametre altı ayrı flag'i içeriyor: -fno-math-errno, -funsafe-math-optimizations, -ffinite-math-only, -fno-rounding-math, -fno-signaling-nans ve -fcx-limited-range (man gcc). Bunlardan subnormal sayılar üzerinde etkisi olan -funsafe-math-optimizations parametresi ve bununla derlenen kodda fazladan aşağıdaki kod parçası var:

0000000000400440 <set_fast_math>:
  400440:  0f ae 5c 24 fc        stmxcsr DWORD PTR [rsp-0x4]
  400445:  81 4c 24 fc 40 80 00  or DWORD PTR [rsp-0x4],0x8040
  40044c:  00
  40044d:  0f ae 54 24 fc        ldmxcsr DWORD PTR [rsp-0x4]
  400452:  c3                    ret

MXCSR, SSE komutlarının çalışma şeklini kontrol etmek için kullanılan, FPU Control Word (FCW) benzeri bir yazmaç*. Bu yazmacın 15. ve 6. bitleri yani "Flush to Zero (FZ)" ve "Denormals are Zero (DAZ)" bayraklarına bir veriliyor. Bu parametreyle derlenen kod çalıştırıldığında ekrandaki son çıktı normal double'ların en küçüğü 2.225074e-308 oluyor. MXCSR bir SSE yazmacı olduğundan -ffast-math'in 80387 komutlarına etkisi yok.

80387'de subnormaller için aynı mekanizma bulunmuyor. Önceki yazıda FCW'de subnormallerle ilgili DE biti olduğunu yazmıştım. Bu kullanılarak subnormallerle yapılan işlemler kontrol edilebilir. Kodda FCW'ye erişmek için şu işlevi ekledim:

void setfst()   {
    short FPUCW;
    asm("fstcw %0": "=m" (FPUCW));
    FPUCW = FPUCW & ~0x02;
    asm("fldcw %0":: "m" (FPUCW));
}

Veya diğer seçenek:

void setfst2()   {
    short FPUCW;
    asm("fstcw %0\n\t"
        "and  %1,0xFFFD\n\t"
        "fstcw %1"
        : "=m" (FPUCW): "m" (FPUCW));
}

Bu fonksiyon while döngüsünden hemen önce çağırılmalı ve kod -mfpmath=387 -masm=intel parametreleriyle derlenmeli. Kod çalıştırıldığında en son 2.225074e-308 yazıyor ve ardından "Floating point exception"la sonlanıyor. DE bayrağının elle sıfırlanması subnormal sayılarla yapılan işlemlerin exception oluşturmasına neden oluyor. Bu exception için handler yazılıp; subnormal sayıda yapılan işlem, exception'a neden olduğunda sonuç sıfıra yuvarlanırsa, SSE'deki DAZ bayrağının görevi FPU'ya uyarlanmış olur.

Ek Kaynaklar:
* http://softpixel.com/~cwright/programming/simd/sse.php
* https://software.intel.com/en-us/articles/x87-and-sse-floating-point-assists-in-ia-32-flush-to-zero...

18 Ocak 2019 Cuma

Kayar Noktalı Sayılar ve Kesme Hataları #2


Merhaba. Bir önceki yazıda ondalıklı sayıların bilgisayarda nasıl tutulduğuna değindim ve bilgisayar mimarisine ait kısıtların ilginç davranışlara neden olduğundan bahsettim. Bu yazıda ilginç davranışlara birkaç örnek vereceğim.

Bu ilginçliklerin ilki, bir önceki yazıda söz ettiğim nedenlerden ötürü özellikle sıfıra yakın sonuçları sıfırla karşılaştırmak yerine sıfıra kabul edilebilir uzaklıkta olup olmadığına bakma gereksinimidir. Bununla ilgili bir örneğe bazı ara konulara değindikten sonra yanında yıldız imiyle (*) yer vereceğim.

Önceki yazıda verdiğim örnekler çoğunlukla tek değerlikli (single) sayılarlaydı. Buradan, çift değerlikli (double) sayılarla hesaplamada sorunlar olmadığı anlamı çıkmamalı. Double'da katsayı daha çok bitle temsil edildiğinden (önceki yazıda) hesaplamadaki hata onyedinci ondalıkta ortaya çıkıyordu. Single için hata dokuzuncu ondalıktaydı. Önceki yazının ilk örneğinde Excel'deki sonuçta onaltıncı ondalıkta hata var (hata on kat büyüdü). Ben örneklerde tek duyarlıklı sayıları, hesaplamalar ve hataları göstermek kolay olsun diye seçtim.

Hatalar yalnız sıfıra yakın sayılarda ortaya çıkmaz. Katsayı bitlerinin sınırlı olması büyük sayılarda da hataya neden olur. Katsayı için 23 bit ayrılmış olması 23'ten fazla sayıda kesirle ifade edilebilen sayılarda sorun çıkarır. Hexpert'te bir deney yaptım: 00H ofsetine gidip FP32 hücresine 16 777 215, 04H ofsetine 16 777 216 vb. 16 777 220'ye kadar olan sayıları girdim.


İlk sayıda tüm katsayı bitlerini tükettim. 16 777 215, 23 kesrin toplamı olarak yazılıyor (yukarıda). Ardışığı, 1*224 olarak ifade ediliyor. Ama üs o kadar büyüdü ki 224+1'in 1'ini yazacak bit kalmadı. Dikkat edilirse bir fazlası olsa bile 04H'daki sayıyla 08H'daki sayı ve 0CH'daki sayıyla 10H'daki sayılar aynı. 224 ile 225 arasındaki sayılar bilgisayarda tek değerlikli olarak saklanırken tek sayılar yazılamıyor. Benzer şekilde 225 ile 226 arasındaki sayılarda dördün katı olmayanlar yazılamıyor. Yeterince büyük sayılardaki küçük artımların anlamsız olduğu durumda sorun yok ama problemine göre 224 çok büyük bir sayı olmayabilir. Bu arada 224'ü 32-bit'lik tamsayıyla (integer) hala kesin bir şekilde gösterebilirim.

Sonuçta bitlerin sonlu olmasından ötürü sayılar belli bir bitte kesilmektedir. Bu konudaki hata örnekleri çoğaltılabilir. Örn. π ve e gibi rasyonel sayılar kümesinde olmayan, sonlu sayıda ondalıkla gösterilemeyen sayılar hiçbir zaman bilgisayarda tam olarak ifade edilemez. Üstelik sayılamaz sonsuzlukta olan gerçel sayılar kümesinin, sonlu sayıda bitle gösterilmesi teoride zaten olanaksızdır. Bunun pratiğe dökülebilmesinin tek nedeni mühendislik hesaplarındaki sonuçların yuvarlandığında "yeterince" tutarlı olmasıdır. Bu tür hatalara kesme veya yuvarlama hatası denir. Sayısal analiz'de bu iki hata aynı değilse de mikroişlemcinin davranışı bu tür sayılarda yuvarlama yapmak olduğundan bu anlamda birbirlerinin yerine kullanılabilirler.

Kayar noktalı sayıları karşılaştırırken sıfır değil sıfıra yeterince yakın olmasıyla ilgili olarak bir örnek ele alalım*. sin(30) = sin(π / 6) = 0.5 olduğunu biliyoruz, doğal olarak sin(π / 6) - 0.5 = 0 olur. printf("%e\n", sin( M_PI / 6.0 )); ekrana 5.000000e-01 yazar. Yalnız yukarıda yazdığım gibi pi sayısında bir yaklaşıklık var. Sinüs fonksiyonunda da MacLaurin serisinin kesilmesinden dolayı bir yaklaşıklık olmalı. Bunun sonucunda hata birikmesinden ötürü gördüğümüz 5.000000e-01'in aslında 2-1 olmadığı tahmin edilebilir. printf("%e\n", sin( M_PI / 6.0 ) - 0.5); ifadesi ekrana -5.551115e-17 yazar. Farkın sıfır olması değil sıfıra "yeterince" yakın olması dikkate alınmalıdır. Örneğin sonucun sıfıra olan uzaklığının, makina epsilonu gibi yeterince küçük bir sayı kadar olup olmadığına bakılabilir. Yani:

if(fabs(sin(M_PI / 6.0) - 0.5) < DBL_EPSILON)

doğru ifadedir. Makina epsilonunun 1 komşuluğunda anlamlı olduğunu yazmıştım. Bu nedenle aslında bu yaklaşım da yeterince doğru değildir. Yapılmak istenen işlemin hassaslığı yanında makina epsilonu büyük kalabilir. Çift duyarlıklı sayılarda en küçük 2.225 * 10-308 (aslında 4.94 * 10-324 ama bu da ileride açıklamak üzere kenarda dursun**) olduğu halde epsilon 10-16 mertebesindedir. Doğru "yeterince küçük" değeri seçmek, bu değerin sayı doğrusunun her yerinde farklı olması nedeniyle zor bir sorundur. Örneğin, 224 ile 225 arasındaki sayılarda 3 yeterince küçükken 225 ile 226 arası sayılarda değildir.

Üssün 00h ve 0FFh olmasının özel anlamları vardır. Üs 0FFh ve katsayı sıfırsa işaret bitine göre sayı, pozitif veya negatif sonsuz değerini alır. Çok büyük sayıların çok küçük sayılara bölünmesiyle çıkacak sonuç, yazmaca sığmıyorsa sonsuz değeri döner. Üs 0FFh ve katsayı sıfırdan farklıysa bu NaN yani "Not a Number" değeridir. Bu da matematikteki belirsiz işlemlerin sonucunda (Örn. 0/0, 00, ∞ - ∞) veya FPU'da yapılmak istenen işlem tanımsızsa ortaya çıkar ve NaN'la yapılan bütün işlemlerin sonucu da NaN'dır.

Üssün sıfır olması ikinci özel durumdur. Üs sıfır ve katsayı da sıfırsa (yani 00 00 00 00) sayı sıfırdır ama üssün sıfır olması durumu bundan daha karışıktır. Şöyle ki, daha önce her katsayıya 1 eklendiğini söylemiştim. Katsayı her zaman 1'e eşit veya 1'den büyükse hiçbir zaman sıfır elde edilemez. O nedenle üs sıfır olduğunda kural bozulur ve üsten 127 çıkarılmaz. Bunun yerine üs -126 alınıp katsayıya 1 eklenmesi kuralı bozulur yani d0 = 0 olur (d0'ın ne olduğu bir önceki yazıda bulunabilir). Üssün -126 olduğu sayılarla çıkartma yapılırken aradaki fark 2-127 mertebesinde olacaktır:

1.5 * 2-126 - 1.375 * 2-126 = 0.125 * 2-126 = 1.25 * 2-127

Yukarıdaki işlemin taşmaya (aslında doğru terim overflow değil underflow) neden olmaması, aradaki farkın bilgisayarda gösterilebilmesi için küçük sayılara ihtiyaç vardır. Bu nedenle [0, 1] aralığı ±2-126 için ondalıklı sayı standardına eklenmiştir. Bu tür sayılara subnormal sayılar denir (normalize olmayan). Subnormal sayılar üzerinde işlem yapılırken FPU, DE bayrağına 1 verir. Bunun anlamı artık DEnormal (subnormal) sayılar üzerinde işlem yapıldığı ve en küçük değerli ondalıkların kaybolabileceğidir. Başka bir deyişle işlemci, subnormal sayıların fark ve bölüm işlemlerinde sonucun kesinliğini garanti edemez. Yukarıda ** olarak işaretlediğim cümlede, çift duyarlıklı sayılarda iki farklı en küçük değer yazmıştım. Büyük olan normal sayıların en küçüğü, diğeri de subnormal sayıların en küçüğüdür.

Örnek Kod:
Subnormal sayılarda işlemler için şu kodu yazdım:

#include<stdio.h>
#include<stdint.h>
union uSayi {
    unsigned char bayt[8];
    uint64_t q;
    double s;
};

int main()      {
    union uSayi d1, d2, d3;
    double t1, t2, t3;

    d1.q = 0x0018000000000000;
    d2.q = 0x0016000000000000;
    d3.q = 0x0014000000000000;
    //printf("%e %e %e\n", d1.s, d2.s, d3.s);

    // 1.5 * 2^-1022 - 1.25 * 2^-1022 = 0.25 * 2^-1022
    t1 = d1.s - d3.s;
    // 1.5 * 2^-1022 - 1.375 * 2^-1022 = 0.125 * 2^-1022
    t2 = d1.s - d2.s;
    // 0.25 * 2^-1022 - 0.125 * 2^-1022 = 0.125 * 2^-1022
    t3 = t1 - t2;

    return 0;
}

Bir union'la, double değere bir işaretsiz tamsayı veya 8 byte olarak eriştim. d1, d2 ve d3 olarak union uSayi türünde üç değişken tanımlayıp normal ondalıklı sayı sınırının en altına yakın değerler verdim ve birbirlerinden çıkarttım. Ara sonuçları t1, t2 ve t3 değişkenlerinde tuttum. Kodu debugger'la inceleyeceğim için aşağıdaki gibi derledim:

gcc -mfpmath=387 -g kod2.c
gdb -tui a.out

gcc, ondalıklı sayıları normalde SSE komutlarıyla işler. Ben FPU'yu görmek istediğim için 387 komutlarına zorladım.

Programı gdb'nin TUI'siyle açtım. assembly'de intel yazımını tercih ettiğim için şu iki komutu verdim:

(gdb) set disassembly-flavor intel
(gdb) layout asm


Yukarıdaki ekran görüntüsünde d1, d2 ve d3'e yapılan atamalar 0x400574 adresinden itibaren açıkça görülebiliyor. İlk çıkarma işlemi 0x4005a4'te, ikincisi 0x4005af'de, son çıkarma da 0x4005b7 adresindeki fsub ile yapılıyor. break *main+71 komutuyla ilk çıkarmaya bir breakpoint koyup run komutuyla kodu çalıştırdım. Programın durduğu yerde info float komutuyla FPU stack'e baktım. Elle girdiğim sayılar FPU'da işleme hazır :


nexti komutunu verip çıkarmayı gerçekleştirdim. Tekrar info float yazıp stack'te çıkartmanın sonucuna baktım. Diğer çıkartma da aynı şekilde gerçekleşecek. Bu nedenle until *main+90 komutuyla son çıkarmaya ilerledim. Bu adımda info float yazınca FPU Status Word'de önceden 0x3800 olan değer 0x3802 oldu. DE yani Denormalized bayrağı bir olarak ayarlandı. nexti komutuyla son çıkartmayı da yaptım. Sonuç +2,781342323134001729e-309 bulundu. Son olarak cont komutuyla programı tamamladım. Dökümanlarda ve Wikipedia'da, komutların denormalized değerlerle, normal değerlerle olduğundan daha yavaş çalıştığı belirtiliyor. Bu nedenle hızın, sonuçların kesinliğinden daha önemli olduğu uygulamalarda denormalize değerlerin kullanımın kapatılması öneriliyor.

https://blogs.oracle.com/d/subnormal-numbers adresinde derleyicinin -fns parametresinin, çalışma sırasında oluşacak subnormal sonuçları sıfıra yuvarlamasını sağlayacağından söz edilmiş ancak ben gcc'de böyle bir parametre bulamadım.


5 Ocak 2019 Cumartesi

Kayar Noktalı Sayılar ve Kesme Hataları


Merhaba. Bu konuda uzun zamandır yazmak istiyordum ama zaman bulamıyordum. Konu, geniş olduğundan kafamda toparlamak ve yazmak uzun zaman aldı. Aynı nedenden yazıyı iki parça olarak böldüm. Bu yazılarda tek değerlikli (single precision) sayılar üzerinden bilgisayarda ondalıklı (floating point) sayıların nasıl tutulduğunu ve bunun ne tür hatalara yol açtığını anlatacağım. Öncesinde iki ilginç ekran görüntüsü ekledim. İlki Excel 2007'den ama daha güncel sürümlerde de benzer sonuçlar elde edilebilir:


A sütununda formüllerin sonuçları var. B sütununda, A sütunundaki formülleri metin olarak yazdım. Yani A1'de =1-0.2-0.2-0.2-0.2-0.2, A2'de =1-0.1-0.1...-0.1 (10 tane 0.1) var. A2 ve A3'teki sonuçlar ilginç. Sıfır olması gereken iki sonucun da sıfırdan saptığı görülüyor. Bu neden oldu? İkinci sorulması gereken aynı ilginçlik A4 veya A5'te neden tekrarlanmıyor? Aşağıda görüleceği gibi Octave/Matlab'le de aynı sonuçlar alınıyor.



Bu davranışla ilgili bir ipucu vereceğim. Octave'da varsayılan veri türü çift değerlikli (double) sayı. Tamsayı olduğu halde 1 bile çift değerlikli olarak işlem görüyor (yandaki ekran görüntüsüne bakınız). Sayıları 0.2 yerine single(0.2) verip (tek duyarlıklı sayıya dönüştürüp), bunları ardarda 1'den çıkardığımda hata artıyor. Örn. 0.2 için hata 10-17 mertebesinden 10-8 mertebesine yükseldi ama single(0.0625) için hata yine 0.

Sayıların Bilimsel Gösterimi
Hatanın nedenlerine inmeden önce okul yıllarına dönüp normalleştirilmiş sayı ve sayıların bilimsel gösterimini hatırlamak gerekiyor: Çok büyük veya çok ondalıklı sayılar yazılırken sayının anlamlı basamakları yazılıp yanına 10n gibi bir çarpan eklenir. Sıfıra yakın sayılar için n, negatif ve büyük sayılar için n pozitif olur.

Örn.
6 720 000 000 = 6.72 * 109
0.000 000 007 51 = 7.51 * 10-9

m, bir gerçel sayı ve n bir tamsayı olmak üzere, onluk tabandaki bilimsel gösterim, m * 10n olarak genellenir. m'e katsayı, n'e üs diyelim. m'i gerçel sayı seçtim ama bunun üzerine bazı kısıtlamalar daha koymak gerekir. di'ler rakamlar {0, 1, ..., 9} ve d0, sıfır olmamak üzere m aşağıdaki gibi yazılabilir:


d0'ın sıfır olduğunu düşünelim: Örn. 0.672 * 1010 = 6.72 * 109
d0'ın 10dan büyük bir değer olduğunu düşünelim: 67.2 * 108 = 6.72 * 109

Dolayısıyla her sayı yukarıdaki gibi tek bir şekilde yazılabilir. Yukarıda onluk tabanın altını çizdim çünkü herhangi bir sayı sistemine genellersem yukarıdaki ifade şöyle olur:


İkilik sayı sistemi için b = 2 seçilir ve kısıtlar gözönüne alınırsa ikilik sayı sisteminde d0'ın yalnızca 1 olabileceği görülür.

İkilik Sayı Sisteminde Ondalıkların Gösterimi
Bu konu lise matematiğinin dışında ama mantığı basit. Onluk sistemde basamak değerleri şöyle:
İkilik sayı sistemine genellendiğinde basamak değerleri 20, 21, 22... olarak gidiyorsa noktadan sonraki basamak değerleri de benzer biçimde 2-1, 2-2, 2-3... şeklinde devam eder.
Yukarıdaki sayı 23 + 21 + 20 + 2-2 + 2-3 = 11.375'dir. Kısacası ikilik sayı sisteminde de sayılar bilimsel gösterimle tekil olarak yazılabilir. Katsayının tamsayı kısmının her zaman 1 olacağını yazmıştım. Bu gösterimde tamsayılar aşağıdaki gibidir:

1 = 1 * 20
2 = 1 * 21
3 = 1.5 * 21
4 = 1 * 22
5 = 1.25 * 22
6 = 1.5 * 22
7 = 1.75 * 22
8 = 1 * 23
...

Ondalıklı sayıları bilgisayarda göstermek için, Hexpert adında bir Hex düzenleyici (editör) kullanacağım. Bu düzenleyiciyi bilgisayarla tanıştığım zamandan beri kullanıyorum. En sevdiğim düzenleyici ama artık geliştirilmiyor. İkinci sevdiğim düzenleyici HxD ve aktif olarak geliştiriliyor. Bu yazıyı hazırlarken baktığımda HxD'nin yeni sürümünün çıktığını farkettim. Son sürümde dosya düzenleme daha yetenekli duruma gelmiş ve bu düzenleyici de kullanılabilir. Ben ekran görüntülerini Hexpert'den aldım. Aşağıda küçük bir dosyanın ekran görüntüsü var. İçinde boşluklar olan bir metin dosyası oluşturup bunu açmak yeterli.

Yanda dosyanın byte'ları, alttaki kutularda imlecin olduğu yerdeki 8, 16 ve 32 bitlik değerler var. S'ler işaretli U'lar işaretsiz değerler. İşaretli sayılarda sayının ilk biti işaret (birse negatif, sıfırsa pozitif) biti. İşaretsiz sayılarda bu bit sayıya katılıyor. Alttaki kutular değerleri elle girmeye olanak veriyor. Örn. 30h ofsetindeki 8 bit (00h) ve 16 bit (00h 00h) değer sıfır. Gösterim Little Endian olduğundan 32 bitlik değerin byte'ları tersine okunduğunda (3fh 80h 00h 00h) 63 * 2563 + 128 * 2562 + 0 * 256 + 0 = 1 065 353 216 değerini alıyor. Asıl ilgilendiğim FP32 hücresi. Ondalıklı sayılar bilgisayarda her zaman işaretli olarak ele alındıklarından bunların işaretsizi yok. FP32, 32 bit floating point yani tek değerlikli anlamına geliyor. FP64 ise 64-bit floating point (çift değerlikli). Buna şimdilik değinmeyeceğim. Yalnızca 40h offsetinde FP64 1.0 değeri var.

Bu gösterim IEEE-754 standardıyla belirleniyor. 1985'teki standartta 'single' ve 'double' ifadeleri geçerken 2008 revizyonunda bunlar binary32 ve binary64 olarak anılıyor. Standarda göre 32 bitlik ondalıklı sayının ilk biti işaret, sonraki 8 bit [30-23] üs ve geri kalan 22 bit [22-0] katsayı. 1.0'ı buna göre ayrıştıralım:

3F 80 00 00 = 0|011 1111 1|000 0000 0000 0000 0000 0000

[ Hexpert'te Alt+B ile ikilik taban görünümüne geçilebilir. ]

Tamsayı kısmı her zaman 1 olduğundan bu saklanmaz, her hesaplamada kendiliğinden eklenir. Dolayısıyla burada katsayı 0 + 1 = 1. Üs 0111 1111b = 127 ve standart gereği üsten 127 çıkarılır. Yani gerçek üs 127 - 127 = 0. İşaret biti 0 olduğundan pozitif. Hesaplandığında 1 * 2(127-127) = 1 bulunur.

2 = 1 * 21 olarak yazılır. Katsayıdan bir çıkarılırsa 1 - 1 = 0 ve üsse 127 eklenirse 1 + 127 = 128. O halde 2 şöyle ifade edilir: 0|100 0000 0|000 0000 0000 0000 0000 0000 = (40 00 00 00)16. Tabii Little endian olduğundan byte'ların tersine yazılması gerekir.

Katsayı bitlerinin ondalıklı ikilik sayılar olduğuna dikkat edilmelidir. Örn. 200 = 128 + 64 + 8 = 27 + 26 + 23. Üs olarak en büyük üssün değeri seçilmeli. O halde 200 = 27 * (1 + 2-1 + 2-4) = 1.1001b * 27 = 1.5625 * 27. Katsayıdan 1'i atınca kalan .1001 ve üs 7 + 127 = 134 = 1000 0110b. Hepsini toparlayınca sayı: 0|100 0011 0|100 1000 0000 0000 0000 0000 = (43 48 00 00)16.

Ondalıklı sayıları ifade etmek için hiçbir engel kalmadı:

Örn. 0.25 = 1 * 2-2. Üs -2 + 127 = 125 olur : 0|011 1110 1|000 0000 0000 0000 0000 0000

Şimdi periyot seyreden sayıları hatırlayalım. Bölmeleri kesirler yerine ondalıkla ifade etmeye kalktığımızda bölünen bölene tam bölünmüyorsa bölümde ondalıklar birbirini tekrar eder. Örn: 7 / 90 = 0.0707... Ondalık sayının bilgisayarda gösteriminde sürekli ikiye bölme ve ikilik kesirlerle ifade etme var. Peki ondalıklı sayılarda böyle bir periyot durumu olur mu? 0.1'i ele alalım. Bunun karşılığı: 0|011 1101 1|100 1100 1100 1100 1100 1101. Üs 123 - 127 = -4. Katsayıları ayırıp toplarsak aşağıdaki gibi oluyor:


Eğer en düşük değerli 2-23 olmasaydı 0.599 999 904 632 568 359 15. Tam 0.6 (aslında 1.6) hiçbir zaman olmuyor. Sayıya 1 ekleyip 2-4 ile çarpınca elde edilen sayı 0.100 000 001 490 116 119 370 703 125 ama Hexpert, Excel, Matlab ve Octave bu sayının ilk 6-7 basamağını gösterdiklerinde 0.1 görüyoruz (elbette şimdilik Matlab'de sayıların double olmasını görmezden geldim). Bu küçük fark her -0.1 işleminde birikerek artıyor ve sonuç sıfıra yaklaşıkça önem kazanıyor.

Not: Bu durum ikilik sistemin eksikliği olarak görülmemeli. Bilgisayarlar 10'luk sistemi kullanabilseydi ama insanlık 12'lik sayı sistemini kullanıyor olsaydı aynı durum yine yaşanacaktı.

Yukarıdaki sayıda dokuzuncu ondalıktan sonra sıfırdan farklı değerler görüldü ama 10 kere çıkarma yapıldığında aslında yapılan işlem "1-1.000 000 014 901 161 193 707 031 25" olup hata birikerek sekizinci ondalığa taşar. Octave'da tek değerlikli sayılarla aynı işlem tekrarlandığında hatanın 10-8 mertebesinde olduğunu yazmıştım.

>> X=single(0.1)
X =  0.10000
>> 1-X-X-X-X-X-X-X-X-X-X
ans =  -7.4506e-008
>>

Bilgisayardaki katsayının son bitinin bir olması ve olmaması arasındaki fark, bilgisayarın iki ondalıklı sayı arasındaki anlayabileceği en küçük farktır. Eğer bu 1 ile kendisinden büyük ilk sayı arasındaysa bilimsel yazında makina epsilonu (machine epsilon) denir. Matlab'de eps işlevi bu değeri döndürür:

>> eps
ans =   2.2204e-016
>> eps('single')
ans =   1.1921e-007

Wikipedia'daki örneklerde aynı değerler karşılaştırılabilir:
Single-precision floating-point format
0|011 1111 1|000 0000 0000 0000 0000 00012 = 3f80 000116 = 1 + 2-23 ≈ 1.0000001192
(smallest number larger than one)

0|011 1111 1111 | 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 00012 ≙ 1 + 2-52 ≈ 1.0000000000000002, the smallest number > 1


Peki neden 0.0625'te hata olmadı? Çünkü 0.0625 aslında 0.00012 olduğundan işlemcide tam değeriyle ifade edilebiliyor. Haliyle hata olmuyor.

Yazının ikinci bölümünde ortaya çıkabilecek hataları detaylandırıp başka örnekler vereceğim.

21 Nisan 2018 Cumartesi

GCC'de Derleyici Optimizasyonları


Merhaba. Bu yazıda düşük seviye bir konuyu ele alacağımı önceden yazmıştım. Yalnız ilkin takip ettiğim iki blog/vlog'dan bahsedeceğim. Biri C++ ile kendi işletim sistemini yazan adam. Web adresi http://www.wyoos.org. Videolarında bütün kodlama adımlarını anlatıyor. Geçen ay bunu takip etmek epey zamanını aldı. İkincisi de Ben Eater, Youtube kanalında 8-bit bilgisayarını tasarlayıp breadboard üzerinde uygulamasını anlatıyor. Bunu istememe rağmen önceki tutorial gibi adım adım takip edemedim. Maalesef zamanım yok. Geçen ay bir de RH442 Performance Tuning eğitimini takip ettiğim için yeni bir yazıya ancak zaman bulabildim.

Ben Eater'ın videoları arasında, kendi bilgisayarında çalıştırmak için assembly'e çevirdiği bir Fibonacci sayısı hesaplama kodu var. Videosunda kodu assembly çıktısıyla karşılaştırıyor ve sonraki videoda bunu kendi bilgisayarında programlayıp çalıştırıyor. Kod oldukça basit:

#include<stdio.h>

int main(void)  {
    int x,y,z;

    while(1)        {
        x = 0;
        y = 1;
        do      {
            printf("%d\n", x);

            z = x + y;
            x = y;
            y = z;
        } while (x < 255);
    }
}

Videoyu izlerken aklıma, bu kodu farklı GCC optimizasyon seviyeleriyle derleyip çıktılarını incelemek geldi.  

Optimizasyon (enilyileme) Seviyeleri Nedir?
Derleyiciler, kaynak kodda olmadığı halde derlendiğinde işlemcide daha iyi çalışmasını sağlayacak kod değişiklikleri yapar. Değişiklikler kodun çıktısını etkilemez ama satır satır incelendiğinde farklı arasonuçlar veren satırlar bulunabilir. Bu kod değişikliklikleri, işlemci pipeline'ını daha verimli kullanmak için birbirinden bağımsız döngü adımlarının açılması, farklı işlemci birimlerini (ALU, memory fetcher, FPU vb.) kullanan komutların yer değiştirilmesi, etkin tampon bellek (cache) kullanımı için bellek adreslerinin yakınlaştırılması/hizalanması (alignment) gibi algoritmaları içerir. Bunların tam listesi Optimizing Compiler maddesinde bulunabilir.

Programcı ne kadar iyi kod yazarsa yazsın bu iyileştirmelerin hepsine hakim olması zordur. C programcısı gözünden bakıldığında, kod hızlı çalışacaksa bir çıkarma işleminin push'tan önce veya sonra olmasının önemi yoktur. Assembly programcısı gözünden, günümüz bilgisayarında çalışacak kodun satır satır optimizasyonu yorucu bir iştir. Intel'in Optimizasyon Reference Manual'ının 672 sayfa olduğu gözönünde bulundurulursa aynı zamanda karmaşık bir iştir ve neredeyse bu kitabı ezbere bilmeyi gerektirir.

2000'lerin başında işlemci mimarisi bu kadar karmaşık değilken bile, bu konuda konuştuğum bir meslektaşım, aynı kodu hem Delphi hem Win32 Assembly'de yazdığını ve Delphi kodunun belirgin şekilde performanslı çalıştığından sözetmişti. Bir de bu konunun Intel'le ilgili olan tarafı var. Intel'in belgelendirmediği işlemci komutları veya özellikleri varsa bunları yalnızca Intel derleyicileri kullanabilir (keza AMD için de).

GCC'nin temelde dört optimizasyon seviyesi var: 
* O0: Optimizasyonlar kapalıdır. Derleyici kodda ne varsa değiştirmeden assembly'e derler. Hata ayıklamak için kod bu seviyede derlenir. 
* O1: Temel optimizasyonlar yapılır. Branch prediction'la ve stack'le ilgili optimizasyonları içerir. 
* O2: O1'den daha agresif optimizasyonları içerir. Önerilen seviyedir. Thread'ler, fonksiyon çağrılarıyla ilgili kontroller ve karakter dizileriyle ilgili optimizasyonları içerir. 
* O3: O2'den daha agresif optimizasyonları içerir ama her zaman önerilmez. Loop unroll'lar assembly çıktısını büyütüp kodun yavaşlamasına veya FPU optimizasyonları nedeniyle sonuçların hassaslığını kaybetmesine neden olabilir. 

Bunlardan başka Os, Ofast gibi seçenekler de var ama bunları detaylandırmayacağım. Parametrelerle ilgili aşağıdaki kaynaklar incelenebilir:

İkinci kaynakta belirtildiği üzere bu parametreler farklı, tekil optimizasyon parametrelerini içeren paketlerdir. Örn. O2 ile birlikte fno-lra-remat kullanılırsa, lra-remat hariç O2 optimizasyonlarını uygula anlamı taşır.

Yazıyı uzatmadan assembly çıktısına bakalım. Kodu derlemek için gcc ve disassemble etmek için objdump kullandım. objdump normalde çıktıyı AT&T yazım biçimde verir. Ben Intel yazımını daha iyi bildiğim için -M parametresiyle çıktıyı bu şekilde ürettim.

gcc -O0 -o fib fib.c
objdump -M intel -d fib


İlgilendiğim kısım kodun main bloğu.  

  4004c4:  55                   push rbp
  4004c5:  48 89 e5             mov  rbp,rsp
  4004c8:  48 83 ec 10          sub  rsp,0x10
  4004cc:  c7 45 f4 00 00 00 00 mov  DWORD PTR [rbp-0xc],0x0
  4004d3:  c7 45 f8 01 00 00 00 mov  DWORD PTR [rbp-0x8],0x1
  4004da:  b8 18 06 40 00       mov  eax,0x400618
  4004df:  8b 55 f4             mov  edx,DWORD PTR [rbp-0xc]
  4004e2:  89 d6                mov  esi,edx
  4004e4:  48 89 c7             mov  rdi,rax
  4004e7:  b8 00 00 00 00       mov  eax,0x0
  4004ec:  e8 c7 fe ff ff       call 4003b8 <printf@plt>
  4004f1:  8b 45 f8             mov  eax,DWORD PTR [rbp-0x8]
  4004f4:  8b 55 f4             mov  edx,DWORD PTR [rbp-0xc]
  4004f7:  8d 04 02             lea  eax,[rdx+rax*1]
  4004fa:  89 45 fc             mov  DWORD PTR [rbp-0x4],eax
  4004fd:  8b 45 f8             mov  eax,DWORD PTR [rbp-0x8]
  400500:  89 45 f4             mov  DWORD PTR [rbp-0xc],eax
  400503:  8b 45 fc             mov  eax,DWORD PTR [rbp-0x4]
  400506:  89 45 f8             mov  DWORD PTR [rbp-0x8],eax
  400509:  81 7d f4 fe 00 00 00 cmp  DWORD PTR [rbp-0xc],0xfe
  400510:  7e c8                jle  4004da <main+0x16>
  400512:  eb b8                jmp  4004cc <main+0x8>

İlk üç komut main fonksiyonuna girişteki stack ayarlamalarına ait. C'nin kendi içinde yaptığı bir iş ve bir komut karşılığı yok.

40 04CCh ofsetindeki komut rbp-0Ch adresine 0 yazıyor. Bu, C kodundaki x=0 koduna karşılık geliyor. Buradan x değişkeninin rbp-0Ch adresinde saklandığını bulduk.
Sonraki komut rbp-8h adresine 1 yazıyor ve y=1 ifadesine karşılık geliyor. y değişkeni rbp-08h'da saklanıyor.
40 04DAh'daki komut printf'e ait. eax'e 40 0618h değerini yazıyor. Programın 40 0000h adresine yerleştiği gözönüne alınırsa bunun bir gösterici (pointer) olması muhtemel. Bu adres objdump çıktısında yok çünkü data alanında yer alan bir değer. hexdump -C fib komutuyla dosyanın içeriğine baktığımda 618h ofsetinde şu değerler var:

00000610  00 00 00 00 00 00 00 00  25 64 0a 00 01 1b 03 3b  |........%d.....;|

25h 64h 0Ah printf'e verilen biçem dizisi olan "%d\n"ye karşılık geliyor. eax'in değeri bu dizinin göstericisi oluyor.
Hemen sonraki komutlarda sırayla edx'e x değişkeni atanıyor. edx'teki değer esi'ye ve rax'teki değer rdi'ye atanıyor. Ardından eax sıfırlanıyor. Bunun nedeni biraz karışık ve aslında glibc'in sorunu. Özetle, printf'te kayar noktalı sayılar kullanılırsa bu değer farklı olur. Bununla ilgili ayrıntılı bilgi şurada: https://stackoverflow.com/questions/6212665/why-is-eax-zeroed-before-a-call-to-printf

40 04ECh ofsetinde printf çağrısı gerçekleşiyor.
40 04F1h'de y değişkeni rbp-8h adresinden eax'e alınıyor, sonraki komutta x değişkeni edx'e alınıyor ve ardından lea ile rdx ve rax toplanarak eax'e yazılıyor. z=x+y ifadesinin toplama kısmı lea ile yapılmış oluyor. 40 04FAh ofsetinde eax'teki bu değer rbp-4 adresinde -z değişkeni- depolanıyor. Dikkat edilirse değişkenler kodda tanımlandıkları sıranın tersine (sağdan sola) bellekte tutuluyorlar.
40 04FDh adresindeki komutla y değişkeni eax'e alınıyor ve 40 0500h adresindeki komutla eax'teki değer x'i tutan adrese yazılarak x=y ataması yapılıyor.
Benzer şekilde, sonraki iki komutla y=z ataması yapılıyor.
40 0509h adresinde x değeri 254'le karşılaştırılıyor. x bir tamsayı ve C kodunda karşılaştırma x<255 olduğundan bu eşitsizlikte x'in en büyük değeri 254 olabilir. 40 0510h ofsetindeki jle (jump if less or equal) x'in en çok 254 olduğu durumda doğrudur. Main'in ofseti 40 04C4h olduğundan; x, 254'ten küçükse dallanma 40 04DAh adresine yani do ile başlayan satıra yapılacak. Bu komut do döngüsünü bitiren komut. x, 254'ten büyükse altındaki koşulsuz dallanma (jmp) çalışacak ve 40 04CCh adresine gidecek. Bu adres kodun başı olduğuna göre bu jmp while(1) ifadesine karşılık geliyor.

Derleyici optimizasyonundan önce x değişkenini register (yazmaç) olarak tanımladım:

        int y,z;
        register x;

Kodu aynı parametrelerle derleyip assembly çıktısını aldım. 

  4004c4:  55                   push rbp
  4004c5:  48 89 e5             mov  rbp,rsp
  4004c8:  53                   push rbx
  4004c9:  48 83 ec 18          sub  rsp,0x18
  4004cd:  bb 00 00 00 00       mov  ebx,0x0
  4004d2:  c7 45 e8 01 00 00 00 mov  DWORD PTR [rbp-0x18],0x1
  4004d9:  b8 08 06 40 00       mov  eax,0x400608
  4004de:  89 de                mov  esi,ebx
  4004e0:  48 89 c7             mov  rdi,rax
  4004e3:  b8 00 00 00 00       mov  eax,0x0
  4004e8:  e8 cb fe ff ff       call 4003b8 <printf@plt>
  4004ed:  89 d8                mov  eax,ebx
  4004ef:  03 45 e8             add  eax,DWORD PTR [rbp-0x18]
  4004f2:  89 45 ec             mov  DWORD PTR [rbp-0x14],eax
  4004f5:  8b 5d e8             mov  ebx,DWORD PTR [rbp-0x18]
  4004f8:  8b 45 ec             mov  eax,DWORD PTR [rbp-0x14]
  4004fb:  89 45 e8             mov  DWORD PTR [rbp-0x18],eax
  4004fe:  81 fb fe 00 00 00    cmp  ebx,0xfe
  400504:  7e d3                jle  4004d9 <main+0x15>
  400506:  eb c5                jmp  4004cd <main+0x9>


Bu değişiklikle, x değişkeninin göstericisi kodda bulunmadığından hem kod 12 byte kısaldı hem de belleğe erişim azaldı. 40 04C8h adresinde rbx stack'e atılıyor. Stack'te öncekinden farklı olarak rbx de (64 bit) olduğundan, rsp'den 10h yerine 18h çıkarmak gerekiyor. rbx, x değişkeni için kullanılacak. Program sonlanıyor olsaydı en altta bu push'a karşılık gelen bir pop görecektik. 40 04CDh'da ebx'e sıfır atanması x=0'a karşılık geliyor. Bir sonraki komutta y değişkenine (rbp-18h) 1 atanıyor. printf için değişkenin esi'de olması gerekiyordu, 40 04DEh'deki komutla bu yapılıyor. Geri kalan kod bir önceki printf bloğuyla aynı.
40 04EDh ve bir sonraki komut x+y işlemini yapıyor ve altındaki komut da z değişkenine (rbp-14h) bu toplamın sonucunu yazıyor.
40 04F5h adresinde x=y ifadesi var. x register tanımlandığından bu ifade tek komuta indi.
40 04F8h ve bir sonraki komutla y=z ifadesi çalışıyor. Sonraki komutlar önceki örnekle aynı.

Register tanımlaması gerçek anlamda bir derleyici optimizasyonu değil. Sağlıklı bir karşılaştırma için O1'e geçmeden x'in tanımını tekrar int'e döndürdüm. Zaten O1 optimizasyonunda x'i derleyici kendiliğinden yazmaçta tutuyor olacak. Kodu gcc -O1 -o fib fib.c komutuyla derleyip objdump çıktısı aldım.

  4004c4:  41 55             push r13
  4004c6:  41 54             push r12
  4004c8:  55                push rbp
  4004c9:  53                push rbx
  4004ca:  48 83 ec 08       sub  rsp,0x8
  4004ce:  bb 01 00 00 00    mov  ebx,0x1
  4004d3:  bd 00 00 00 00    mov  ebp,0x0
  4004d8:  41 bc 01 00 00 00 mov  r12d,0x1
  4004de:  41 bd 00 00 00 00 mov  r13d,0x0
  4004e4:  eb 06             jmp  4004ec <main+0x28>
  4004e6:  44 89 e3          mov  ebx,r12d
  4004e9:  44 89 ed          mov  ebp,r13d
  4004ec:  89 ee             mov  esi,ebp
  4004ee:  bf 08 06 40 00    mov  edi,0x400608
  4004f3:  b8 00 00 00 00    mov  eax,0x0
  4004f8:  e8 bb fe ff ff    call 4003b8 <printf@plt>
  4004fd:  81 fb fe 00 00 00 cmp  ebx,0xfe
  400503:  7f e1             jg   4004e6 <main+0x22>
  400505:  8d 04 2b          lea  eax,[rbx+rbp*1]
  400508:  89 dd             mov  ebp,ebx
  40050a:  89 c3             mov  ebx,eax
  40050c:  eb de             jmp  4004ec <main+0x28>

Bu kod x'i register tanımladığım koddan 6 byte daha uzun. r12, r13 gibi "scratch" yazmaçlar x ve y'nin ilk değerlerini tutuyor. Böylece sabitler bellekte değil işlemcide tutuluyor. İlk dört push komutu yazmaçların değerini fonksiyonun çıkışında korumak için ama fonksiyon sonlanmadığından bunlara ait pop yok.
40 04CAh'daki sub komutu stack ayarlaması için.
40 04CEh'daki komut y=1 ifadesine, 40 04D3h'daki komut x=0 ifadesine karşılık geliyor.
40 04D8h ve 40 04DEh'daki komutlar yine y=1 ve x=0 ifadelerine karşılık geliyor ama kod boyunca bu yazmaçların değeri değişmiyor. Bunlardan yalnız okuma yapılıyor.
40 04E4h'deki jmp, printf'in karşılığı olan 40 04ECh - 40 04FCh arasındaki dört komutun ilkine dallanıyor.
40 04FDh'daki karşılaştırma y değerini tutan ebx'le yapılıyor. Bu biraz kafa karıştırıcı çünkü koddaki döngüde karşılaştırma x'le yapılıyordu. Tam emin olamamakla birlikte karşılaştırmadan (while'dan) iki satır üstteki x=y nedeniyle x yerine y kullanılabilir gibi bir mantığı olmalı. Karşılaştırmada y (aslında x) 254'ten büyük değilse döngüden çıkılmıyor.
40 0505h'da rbp ile rbx toplanarak eax'e yazılması z=x+y ifadesine karşılık geliyor.
40 0508h'daki komut x=y ifadesine; 40 050Ah'daki komut y=z ifadesinin karşılığı.
Bu işlemlerden sonra tekrar döngünün ilk komutu olan printf'e dallanılıyor.
Eğer 40 0503h'daki karşılaştırmanın sonucunda ebx, 254'ten büyük olsaydı bu durumda r12'deki y'nin ilk değeri (bir) ebx'e ve r13'te olan x'in ilk değeri (sıfır) ebp'ye aktarılarak tüm işlemler tekrarlanıyor.

Bu kod O0 ile karşılaştırıldığında hiç bellek erişiminin olmaması dikkat çekicidir.

Aynı kodu -O2'yle tekrar derleyip çıktısını aldım:

  4004d0:  55                push rbp
  4004d1:  31 ed             xor  ebp,ebp
  4004d3:  53                push rbx
  4004d4:  bb 01 00 00 00    mov  ebx,0x1
  4004d9:  48 83 ec 08       sub  rsp,0x8
  4004dd:  0f 1f 00          nop  DWORD PTR [rax]
  4004e0:  31 c0             xor  eax,eax
  4004e2:  89 ee             mov  esi,ebp
  4004e4:  bf 08 06 40 00    mov  edi,0x400608
  4004e9:  e8 ca fe ff ff    call 4003b8 <printf@plt>
  4004ee:  81 fb fe 00 00 00 cmp  ebx,0xfe
  4004f4:  7f 0a             jg   400500 <main+0x30>
  4004f6:  8d 04 2b          lea  eax,[rbx+rbp*1]
  4004f9:  89 dd             mov  ebp,ebx
  4004fb:  89 c3             mov  ebx,eax
  4004fd:  eb e1             jmp  4004e0 <main+0x10>
  4004ff:  90                nop
  400500:  bb 01 00 00 00    mov  ebx,0x1
  400505:  31 ed             xor  ebp,ebp
  400507:  eb d7             jmp  4004e0 <main+0x10>

İlk göze çarpan, bu kod önceki iki koddan çok daha kısa. Main 40 04D0h ofsetinden başlıyor ve diğerlerinden daha önce bitiyor ve yine hiç bellek erişimi yok. İkinci göze çarpan O0 ve O1'dekinin aksine mov <register>,0 işlemi hiç yok, yerine xor <register>,<register> işlemi var.

40 04D1h'da x=0'ın karşılığı olarak ebp sıfırlanıyor.
40 04D4h'da y=1'in karşılığı olarak ebx'e bir atanıyor.
Koddaki nop'lardan emin değilim ama hizalama amaçlı olabilir. (Kaynak - Asıl kaynak: http://www.agner.org/optimize/optimizing_assembly.pdf )
40 04E0h'dan 40 04EDh'ya kadar olan dört komut printf.
40 04EEh'da bir önceki örnekle aynı şekilde y, 254 ile karşılaştırılıp değer büyük değilse dallanmadan devam ediliyor.
40 04F6h'daki lea, z=x+y'nin, sonraki komutlar da sırayla x=y ve y=z'nin karşılığı.
jmp komutu döngüyü yeniden başlatmak için printf'in başına dallanıyor. Buradan anlaşılıyor ki döngünün mantığı aynı kalmak üzere yapısı değişiyor.
Eğer 40 04EEh'deki karşılaştırmanın sonucu doğruysa içteki do..while döngüsü bitmiştir. Bu durumda 40 04F4h adresindeki koşullu dallanma 40 0500h adresine dallanıyor. Bu adreste değişkenlerin içerikleri karşılık gelen yazmaçlara yeniden yazılıp (ofset 40 0500h - 40 0506h) tekrar kodun başına dallanılıyor (ofset 40 0507h).

Bu örnek O3 ile derlendiğinde O2'den farklı bir kod üretmiyor. Başka bir deyişle O3'ün içerdiği ama O2'de olmayan optimizasyon parametrelerinin* bu koda etkisi olmuyor.

Yazının başlarında O'lu optimizasyon parametrelerinin, tek tek uygulanabilir parametrelerin bir kümesi olduğunu belirtmiştim. Örn. O1, -fomit-frame-pointer adında bir parametre içeriyor. Bunu içermemesi için kod, gcc -O1 -fno-omit-frame-pointer -o fib fib.c komutuyla derlendiğinde main fonksiyonunun (ve diğer tüm fonksiyonların da) başı şöyle oluyor:

  4004c4:  55            push   rbp
  4004c5:  48 89 e5      mov    rbp,rsp
  4004c8:  41 56         push   r14

frame-pointer rbp yazmacı. Bu, fonksiyona girişte saklanıyor ve sonra stack erişimi bu yazmaç üzerinden yapılması için rsp'nin değeri rbp'ye yazılıyor. Derleyiciye omit-frame-pointer parametresi verildiğinde push ve mov ikilisi tüm fonksiyonlardan siliniyor. Bu durumda rbp'nin değeri fonksiyona girişte saklanmadığından, belleğe erişim rbp üzerinden değil rsp üzerinden yapılıyor. 


6 Temmuz 2013 Cumartesi

Eğrisiyle Doğrusuyla Debug.exe


(Bu yazıyı daha önce yazdığım ve kısıtlı bir çevrede dağıttığım bir belgenin küçük bir kısmından derledim. Internet'te bu yazının bazı bölümlerini aynen içeren bir doküman bulursanız o benim olabilir.)

debug.exe, DOS zamanlarından beri herkeste bulunan; Windows'la birlikte hala dağıtılan (32 bit Windows7'de var.) bir araç. Peki ne işe yarar bu araç? Nasıl kullanılır?

Bu araç elbetteki koddaki bug'ları otomatik temizlemiyor. Adından anlaşılacağı üzere bu bir debugger. Yani programın çalışmasını mikroişlemci düzeyinde inceleyip varsa hataları görmek, tek tek komut komut çalıştırıp kısmen de olsa değişiklikler yapmak olanaklı. Windows için güncel debugger'ları takip edemedim ama 2000'lerin başında Win32Dasm ve SoftICE kullanırdık. Bunlara da yeri gelirse kısaca değinirim ancak baştan belirteyim debug.exe DOS1.0 zamanlarında kalma (1980'lerin başından) bir program. 80286 komut setini bile desteklemiyor. EAX, EDI gibi yazmaçlara erişilemiyor. Daha iyi işler yapabilmek için daha güçlü bir debugger'a gereksinimi olanların TASM paketi içerisindeki Turbo Debugger'ı yada FreeDOS'un debugger'ini kullanmaları daha mantıklı.

Komut satırında debug dediğiniz zaman debug'un kendi iptidai - ile belirtilen komut satırına düşülüyor. Debug'dan sonra "debug \WINDOWS\system32\format.com" şeklinde dosya adı da vermek mümkün hatta format.com'un komut satırından aldığı parametreler de dosya adından sonra eklenebilir. Debug.exe'nin komut satırına düştükten sonra kendi tek harfli komutlarını bilmek gerekiyor. Bu komutlar komut satırında ? yazarak ulaşabileceğiniz bir avuç komut. Şimdi bunları sırasıyla açıklayayım.

* a (Assemble): a komutu girildiği zaman debug size CS:0100 (CS: Code Segment. Kodun bellekteki adresini tutan bir yazmaç. Dolayısıyla siz a yazdığınızca CS'nin içeriği kaçsa o sayıyı gösterecek. Bunu işletim sistemi 640Kb'lik bellekteki boş alana göre ayarlar.) adresini gösterir ve sizden x86 assembly dili komutlarını girmenizi bekler. 0100h offset adresi de DOS'ta bir .com çalıştırılabilir dosya yüklendiğinde onun ilk komutunun bulunduğu yerdir. Segmentin 0000h ile 0100h arasında PSP (Program Segment Prefix) adı verilen başlık bulunur. Komut yazma modunda komut yazmadan enter'a basınca çıkılır. a'dan sonra herhangi bir 16 bitlik sayı girilirse komutun girilmesine o satırdan devam edilir.

-a0200
1381:0200 mov bx,b800
1381:0203


* c (Compare): c komutu iki bellek parçasını karşılaştırmak için kullanılır. Yalnızca farklı olan byte'lar gösterilir. İstenirse farklı segmentler de verilebilir. Aşağıdaki örnek 0000:0100h ile 0000:0110h arası parçayı DS:0400h'den itibaren karşılaştırır.

-c 0000:0100 0110 0400
0000:0100  BB  00  1381:0400

0000:0102  B8  00  1381:0402
0000:010A  01  00  1381:040A 
0000:010B  1A  00  1381:040B

* d (Dump): d komutu belleğin bir parçasını görüntüler. Tek parametreyle verilirse segmenti DS olarak seçer  ve 128 byte görüntüler. İki parametreyle verilirse verilen iki offset arasını gösterir. Parametresiz verildiğinde en son görüntülediği bellek parçasının ardışığı 128 byte'ı gösterir. Böylece ardarda verilen d komutlarıyla uzun bir bloğa parça parça bakmak olanaklıdır.

-d0110 011F
1383:0110  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00   ................



* e (Enter): e komutuyla bir bellek bölgesine elle byte'lar iki türlü girilebilir:

Aşağıdaki komutla DS:0120h'den itibaren 13h, 16h, 19h ve 1Ch byte'ları sırasıyla yazılır.
-e 0120 13 16 19 1C

Aşağıdaki komutla ES:0200h'dan itibaren 13h, 16h, 19h ve 1Ch byte'ları tek tek yazılır. Sayılar girildikten sonra boşluk karakterine basmak gerekir:
-e ES:0200
1381:0200  00.13   00.16   00.19   00.1C

* f (Fill): f komutu bellek bölgesini verilen karakterlerle belli uzunlukta doldurur.

Aşağıdaki komutla 0100h offsetinden başlayarak 12h, 13h, 14h byte sırası 16 byte boyunca tekrarlanır:
-f 0100L10 12 13 14

* g (Go): g komutu, CS:IP ile gösterilen bellek adresinde hangi komutlar varsa koşulsuz olarak çalıştırır. Program sonlanıp yada kesilip çalışmayı debug.exe'ye geri bırakana kadar sürer. g'den sonra bir bellek offset adresi verilirse debug onu verilen bellek adresine kadar çalıştırıp ardından r komutunu uygular ve tekrar debug.exe'nin komut satırına düşer. Bu bellek adresine breakpoint denir. Debugger'larda breakpoint'ler belirlenerek program parça parça çalıştırılabilir. g komutuna ayrı bir parametre vermek yerine çalışması kesime uğratılacak noktadaki kod parçası 0CCh (INT 3) ile değiştirilebilir.

g komutu için söylenmesi gereken önemli bir şey daha var: Eğer program DOS 1.0 zamanlarından kalma INT 20h kesmesini kullanarak çıkıyorsa debug.exe "Program terminated normally." diyor ve program sorunsuz debug.exe'ye düşüyor. Yanılmıyorsam bu INT 21h,00h için de geçerli. Ancak DOS 2.0 ve sonrası için önerilen program sonlandırma yöntemi INT 21h,4Ch çağırıldığı zaman bu yöntem debug.exe'yi de sonlandırıp DOS komut satırına düşmenize neden olabiliyor (özellikle birden fazla kere çağırıldığında).

* h (Hex): h komutu verilen iki sayıyı toplar ve çıkarır.

-h 2D1 2E8
05B9  FFE9


* i (Input): Bir porttan veri okur. Komutu port numarası takip eder. Windows NT tabanlı tüm işletim sistemlerinde bu veriye pek güvenmemek gerekiyor. Doğrudan portlara erişmek bazı durumlarda olanaksız, örneğin Windows'un diski kontrol eden sürücüsü diske erişmenize izin vermez. Basit görece zararsız portlar (klavye gibi) işletim sistemi üzerinden erişilebilir durumdalar.

-i 60
1C


60h portu klavye denetleyicisinin portudur.

* l (Load): l komutunun iki kullanımı bulunmaktadır. Birinci kullanımda mantıksal bir sektörü (cluster) belleğe okumakta kullanılır. Bunun kullanımı Windows NT tabanlı sistemlerde yine kısıtlı. XP'de yanlış hatırlamıyorsam çekirdek sürücüleri üzerinden okuma yapılabiliyordu, yazmaya izin yoktu. Windows 7'de şöyle şeyler oluyor:

Bu diyalog kutusunda Close diyince debug.exe sonlandırılıyor, Ignore diyince "Disk error reading drive C" hatası veriyor.

Komutun kullanımında, l'den sonra gelen parametre cluster'ın okunacağı bellek adresi, 02h disk sıra numarası. ( 00h = A: , 01h = B:, 02h = C:, 03h = D: ... ) Sonraki sayılar cluster numarası ve okunacak toplam cluster sayısı.

İkinci kullanımında n ile belirtilen dosyayı (çalıştırılabilir olması şart değil) CS:0100h adresine yükler. Eğer dosya bir .exe dosya ise yüklenecek yer CS:0100h olmayabilir. Bu kullanımla ilgili n komutunun açıklamasına da bakılabilir.

* m (Move): m komutu bir bellek parçasını başka bir bellek parçasının üstüne kopyalar.

DS:0200h'dan DS:0400h'a 128 byte kopyala:
-m 0200 l80 0400

* n (Name): n komutu bir dosya yada parametrelerini debug.exe'ye bildirmeye yarar. l ile bir dosya okumak yada w ile bir dosya yazılmak istendiğinde önce n komutuyla dosya bildirilmesi gerekmektedir. Komut satırında debug'dan sonra dosya adı verilebileceğini belirtmiştim. Eğer bu dosya adı komut satırından verilmezse n komutuyla verilip sonra l komutuyla dosya yüklenebilir. n komutu dosyanın olup olmadığını kontrol etmez, yalnızca bir FCB (File Control Block) oluşturur. l komutu da bu FCB'deki dosyayı açmaya çalışır. Dolayısıyla n komutuna ne girilirse girilsin hata vermeyecektir. İlgili hata l komutu verildiği zaman alınır.

-n\Windows\winhlp32.exe
-l


Yazmaya ilgili bilgiler w komutunda daha etraflıca anlatılacaktır.

* o (Output): Bir porta veri yazar. i komutu gibi bunda da komutu port numarası takip eder. Port numarasından sonra porta gönderilecek veri yazılır. Yine Windows NT tabanlı tüm işletim sistemlerinde i komutundaki gibi güvenlik vb. nedenlerden bu komutun sonucuna pek güvenmemek gerekiyor. Veri porta gönderilmemiş olabilir yada güvenlik hatasıyla debug.exe sonlandırılabilir.

71h portu CMOS adresleme, 70h portu da okuma portudur. Aşağıdaki komutlarla CMOS'un 19h byte'ı okunur:
-o 71 19
-i 70
DD

* p (Proceed): p komutu tek başına kullanıldığında CS:IP'teki bir komutu çalıştırır ancak eğer CS:IP'deki komut bir altprogram yada kesme çağrısıysa (CALL yada INT) içine girmeden tamamını çalıştırılır ve çalışmanın sonunda yazmaçların içeriklerini gösterir (r komutunun çıktısı).

p komutundan sonra bir sayı girilirse (p 3 gibi) debug.exe o kadar sayıda komutu çalıştırarak tek tek her seferinde yazmaç içeriklerini gösterir.

p komutundan sonra başında = bulunan bir offset adresi girildiği zaman IP (Instruction Pointer) bu offset adresine gelinceye kadar program çalışır. O adreste komut bitmiyorsa komutun bittiği son yerde çalışma durur. Yani IP'nin son değeri bu değere eşit yada büyüktür.

-p =0107

AX=0900  BX=FFFF  CX=FE00  DX=0000  SP=00B8  BP=0000  SI=0000  DI=0000
DS=13DA  ES=13DA  SS=13EA  CS=13EA  IP=0107   NV UP EI PL NZ NA PO NC
13EA:0107 CD21          INT     21


* q (Quit): q komutu debug.exe'den çıkar. Herhangi bir parametresi yoktur, verilse de dikkate alınmaz. Yani q, quit yada quake yazılsa da debug.exe'den çıkar.

* r (Register): r komutu yazmaç içeriklerini görüntülemek ve değiştirmek için kullanılır. r komutu tek başına bütün yazmaçları görüntüler.

-r
AX=0900  BX=FFFF  CX=FE00  DX=0000  SP=00B8  BP=0000  SI=0000  DI=0000
DS=13DA  ES=13DA  SS=13EA  CS=13EA  IP=0007   NV UP EI PL NZ NA PO NC
13EA:0007 CD21          INT     21

Çıktıyı kısaca açıklarsam, AX, BX, CX ve DX x86 serisi işlemcilerin çeşitli komutlarla kullandığı genel amaçlı yazmaçlardır. Accumulator, Base, Counter, Data gibi açılımları olsa da ( http://en.wikipedia.org/wiki/AX_register#Purpose ) genel olarak bunların içeriklerini değiştirmenin komutlar işlemediği sürece bir etkisi yoktur.

SP, stack pointer anlamına gelir ve işlemcinin o anda kullandığı stack'in (yığın) offset adresini tutar. BP, base pointer anlamına gelir ve göstericilerin tutulması için yardımcı bir yazmaçtır. Genelde argümanlarına yığın üzerinden erişen C gibi yüksek seviyeli dillerde fonksiyonların en başında şuna benzer bir kod bloğu bulunur:

k = fonk1(0x0202, 0x0101);

karşılığında üretilen kod:

mov ax, 0101
push ax
mov ax, 0202
push ax
call @fonk1

@fonk1:
push bp
mov bp,sp
...
pop bp
ret

biçimindedir. Alt program blogunda kısa (segment içi) çağrı için ilk arguman [bp+04] ikincisi [bp+06] ve uzun (segment dışı) çağrı için ilk arguman [bp+06] ve ikincisi [bp+08] adresinde bulunur. Yani alt programda BP yedek bir SP işi görür ama elbette ki bu bir zorunluluk değildir. SP genelde doğrudan değiştirilmez, push ve pop gibi komutlarla değeri kendiliğinden değişir.

SI, source index ve DI destination index anlamına gelir. MOVS, STOS, SCAS ... gibi karakter dizisi üzerinden yapılan işlemlerde okumalar DS:SI üzerinden, yazma işlemleriyse ES:DI üzerinden yapılır.

DS, data segment anlamına gelir. Genelde veriler üzerinde yapılacak işlemlerde verilerin tutulduğu segment DS'de bulunur. CS, code segment anlamındadır. CS, IP (instruction pointer) yazmacıyla birlikte mikroişlemcinin o an çalıştıracağı komutu tutar. SS'in anlamı stack segment'tir adından da anlaşılacağı üzere SP ile birlikte yığını gösterir. Son olarak ES, extra segment'dir ve bütün bu segment yazmaçlarının haricinde bir yazmaça ihtiyaç duyulması durumunda yardımcı segment yazmacı olarak kullanılır. IP yazmacı programcı tarafından yalnızca jmp ve koşullu dallanma komutlarıyla değiştirilebilir. Her komutun çalıştırılması sonucunda bir sonraki komutu gösterecek şekilde mikroişlemci tarafından değiştirilir. Örneğin 2 byte'lık bir komutun çalıştırılması sonucunda değeri 2, 3 byte'lık bir komutun çalıştırılması sonucunda değeri 3 arttırılır.

NV UP gibi ifadeler FLAGS yazmacına aittir. Bununla ilgili Temmuz 2012 tarihinde "Peki Neden Trap Flag?" başlıklı yazıda ayrıntılı bilgi bulunuyor. En alt satırdaysa o anki CS:IP'nin değeri ve o değerin gösterdiği yerdeki mikroişlemci komutları ve bunların Assembly karşılığı bulunur.

r komutuyla yazmacın içeriğinin değiştirilmesi için r'den sonra yazmacın adı yazılır. Enter'a basıldığında yazmaçtaki o anki değer görüntülenir ve ardından yeni değer girilmesi beklenir. Herhangi bir değer girmeden enter'a basılırsa yazmacın içeriği değişmez. FLAGS yazmacının içeriğine rf komutuyla erişilebilir ve içerik bu komutla değiştirilebilir.

-rax
AX 0202
:0303


* s (Search): s komutu başlangıcı ve bitişi verilen bir aralıkta verilen bir byte yada byte dizisini aramaya yarar.

Aşağıdaki komut DS:0100h ile 0200h arasındaki bütün 0b8h 00h 01h dizisini listeler:
-s 0100 0200 b8 00 01
1381:0100
1381:01A4


İstenirse aranacak veri çift tırnak içerisinde belirtilerek karakterle arama da yapılabilir:
-s 0100 0200 "h"

* t (Trace): t komutu da p komutu gibi tek başına kullanıldığında CS:IP'teki bir komutu çalıştırır ancak p'den farklı olarak t komutu, CALL yada INT gibi komutların içerisine girerek onları da çalıştırır. Çıktı olarak p gibi yazmaç içeriklerini gösterir. t komutu da p komutu gibi komut sayısı belirtilerek yada başında = olan bir offset adresiyle kullanılabilir. Parametrelerin etkisi de p komutuyla aynıdır.

* u (Unassemble): u komutu belleğin bir parçasındaki byte'ları alarak bunları Assembly kodlarına geri çevirir ve ekrana sığdığı kadarıyla listeler. Eğer tek parametreyle verilirse bu değeri offset değeri olarak alıp listelemeye bu offsetten başlar. İki parametreyle verildiğinde birinci değer başlangıç ikinci değer bitiş olmak üzere aradaki kodlar listelenir. Parametresiz verildiğinde en son görüntülediği kod bloğunun bitişiğindeki bloğu listeler. Ardarda verilen u komutlarıyla uzun bir bloğa parça parça bakmak olanaklıdır.

* w (Write): w komutunun da l komutu gibi iki kullanımı bulunmaktadır. l ye benzer olarak bellekten mantıksal sektörlere yazılabilir. (Windows NT de kısıtlamalar var.) Bu kullanımda parametreler l komutuyla aynıdır.

Dosya yazdırılacaksa yine n komutuyla birlikte kullanılır, kaç byte yazılacağı da BX:CX yazmaçlarında tutulur. Yazılacak verinin büyüklüğü 16lık sayı sistemine çevirilir. Düşük değerli 4 basamak CX'te yüksek değerli 4 basamak BX'de olmak üzere 32 bite kadar büyüklükte dosyalar yazılabilir.

Son olarak bunlardan başka X ile başlayan EMS ile ilgili komutlar bulunsa da EMS konusunda bilgim olmadığından bunlara değinmeyeceğim.


Dipnot: Yeni yazı maalesef yine çok uzunca bir zaman gecikti. Mayıs ortasında diskim bozuldu, sonrasında önemli bir sınava girdim ki yedekleme, işletim sistemi kurma, geri yüklemeyle ilgilenmekten sınava doğru dürüst çalışamadım bile. Haziran başında ülkenin içerisinde bulunduğu siyasi durumu izlemekten iş yerinde dahi doğru dürüst çalışamadım. Haziran ortasında bir daha diskim bozuldu falan filan derken Nisan'ın 13ünde başladığım yazıyı tamamlamam bugünü buldu. Gecikmeden ötürü herkesten özür dilerim. 

8 Ağustos 2012 Çarşamba

Programlamabilir Kesme Denetleyicisi Gerçekten Programlanabilir mi?


Programlanabilir kesme denetleyicisi Türkçe windows'un çevirisinden alınma. Kulağa ileri okuma eniyileştirme gibi bir tını veriyor. İngilizcesi programmable interrupt controller yani PIC. Yazarken asıl mikrodenetleyici olan PIC serisiyle karıştırmamaya özen göstereceğim.

Programlanabilir kesme denetleyicisi, standartları neredeyse milattan öncesinden kalmış gibi gelen IBM PC uyumlu bilgisayarlarda mikroişlemci dışındaki aygıtlardan gelen kesme isteklerine cevap verebilmek için kullanılan 8259A çipine verilen ad. Bugün üretilen hiçbir anakart bu entegreyi kullanmıyor. Bunun iki nedeni var; birincisi zaten anakartların üzerinde1990'lardan beri kesme denetleyicisi için ayrı, DMA için ayrı, klavye denetleyicisi için ayrı ayrı entegreler bulunmuyor. Bunun yerine bunların hepsini kapsayan Southbridge çipseti içerisinde bütün entegrelerin karşılıkları var. İkincisi de 2000'lerde kesme çakışması yada başka bir deyişle IRQ çakışması hepimizi üzen bir sorundu. Eğer anakartınızda çok fazla genişleme kartı takılıysa mutlaka başınıza geliyordu çünkü toplamda 15 tane IRQ, zamanla gelişen bilgisayar teknolojisinin artan IRQ ihtiyacına yetmiyordu. Üstelik bir kaç seneye kadar artık masaüstü bilgisayarlarında bile birden fazla işlemci kullanılması kaçınılmaz görünüyordu ama 8259A ile bunu desteklemenin imkanı yoktu. Dolayısıyla Intel gelişmiş kesme denetleyicisini yani APIC'i çıkardı. APIC, 240 tane IRQ'yu destekleyecek şekilde üretilmişti ve mikroişlemcilerin birlikte çalışırken, cache bellek güncellemesi sırasında diğerinin çalışmasını durdurmasına olanak veren işlemciler arası kesmeyi (inter-processor interrupt -- IPI) destekliyordu. Ben genel olarak PIC'i anlatacağım kadar bildiğim kadarıyla APIC'in farklarından da bahsedeceğim.

Yalnız hepsinden önce birbirine benzeyen üç tane terimin bence açıklığa kavuşması gerekiyor:
  1. IRQ (Interrupt Request -- Kesme İsteği): Mikroişlemcinin dışındaki aygıtlar bir durum değişikliği yaşadıkları zaman "yahu birşeyler oldu, çalışmana bi ara ver de benimle ilgilen, ne olduğunu anla" anlamında mikroişlemciyi dürterler. Buna IRQ denir. En açıklayıcı örnek klavyeden bir tuşa basıldığında klavye denetleyicisi PIC'e bir kesme isteği iletir ki basılan tuş sistem tarafından okunsun, kullanılmayacaksa bile BIOS'un basılan tuşları tuttuğu tampon belleğine aktarılabilsin. Henüz kesme isteği PIC'dedir. PIC bunu mikroişlemciye iletir.
  2. INT (Interrupt -- Kesme): Mikroişlemciye PIC aracılığıyla iletilen kesme isteği eğer mikroişlemci daha önemli bir şeyle meşgul değilse bir kesme oluşturması gerekir. Kesme oluşursa adı gereği yapılan işlem kesilir, hangi kesme çağırılacaksa onun numarasına karşılık gelen kesme vektörü tablodan okunur ve CS:(E)IP'e yüklenir. 
  3. ISR (Interrupt Service Routine -- Kesme Hizmet Programı): Bir kesme gerçekleştiğinde devreye giren kesme koduna ISR denir. CS:(E)IP'ye yüklenen adresin başlangıcındaki koddan IRET (Interrupt Return) opkoduna kadar olan bloktur.

Kesme vektörleri Intel x86 işlemcilerinde gerçek modda (real mode) belleğin en başında bulunurlar. Bunlar aslında uzak göstericilerden (far pointer) başka birşey değildir. Örneğin resimde sıfırıncı kesme (sıfıra bölme kesmesi) gerçekleştiği anda mikroişlemci CS yazmacına (code segment) 00A7h değerini ve IP yazmacına (instruction pointer) 1068h değerini yükleyecektir.

Bu arada bir gereksiz bilgi daha: Aslında sıfırıncı kesme yalnızca sıfıra bölme hatası değil işlem sonucunda bulunan bölümün, bu bölümün depolanması için ayrılmış alana sığmaması sonucu da oluşur. DIV ve IDIV komutları bölen 8 bitlikse AX'deki değeri böler, bölümü AL'ye kalanı da AH'ye depolar; 16 bitlikse DX:AX'deki değeri böler ve bölümü AX'e kalanı da DX'e depolar. Örneğin aşağıdaki kod parçası sıfıra bölme olmamasına rağmen sıfırıncı kesmeyi tetikler:

mov dx, 0FFF
mov ax, dx
mov bx, 0001
idiv bx

Bunu yazmakla 'int 0' kodunu çağırmak yada yukarıdaki resim için 'jmp 00A7:1068' yazmak aynı şeydir. Turbo Pascal'ın CRT.TPU birimi, birim zamanda saniye üzerinden bir döngüye girip bir sayacı arttırıyor sonra da bulduğu değer üzerinden bölme işlemi yapıyordu. Pascal'ın CRT birimini kullanan programlar çok hızlı bilgisayarlarda (333 Mhz falan) çalışmamaya başladılar, sıfıra bölme hatası verip çıkıyorlardı. Çünkü bölmenin sonucu yazmaca sığmıyordu. (Sene 90'ların sonu) Sonradan bir CRT yaması çıktı da düzelmişti.

Bir gereksiz bilgi de, elbette ki yukarıdaki resim Windows XP altında alındığından, orada bir işlemcinin gerçek çalışma modundan bahsedemeyiz. Korumalı modda kesme vektörleri yerine kesme betimleyici tabloda (interrupt descriptor table -- IDT) bu vektörler tutuluyor.

Konudan yeterince uzaklaştıktan sonra tekrar PIC'e dönelim. Normalde IBM/PC için hangi IRQ'nun hangi INT tarafından karşılanacağı kabaca bellidir:
  • IRQ0: Programmable Interval Timer (PIT) Zamanlayıcı kesmesi. İstenirse PIT programlanarak belli zaman aralıklarında kesme üretmesi sağlanabilir. Eskiden anakarta bağlı hoparlörden bunu kullanarak ses çıkarırdık.
  • IRQ1: Klavye denetleyicisi. Daha önce de söyledim, her tuşa basıldığında bu kesme çağırılır.
  • IRQ2: Bu kullanılan bir kesme değildir. Aslında 8259A entegresi 8 tane kesmeye kadar bakabiliyordu ama bir tane daha 8259A buna bağlanarak (slave PIC) 15 tane kesmeye bakması sağlanıyordu. İkincil PIC birinciye bu IRQ2 bacağından bağlandığından gerçek anlamda bir IRQ2 bulunmamaktadır.
  • Geri kalan IRQ'lar seri ve paralel portlar, disk denetleyicisi ve ses kartı tarafından kullanılır. Bunları kullanmadığımdan hakkında pek bilgim yok.
IRQ0 oluştuğunda INT 08h çağırılır, IRQ1 oluştuğunda INT 09h çağırılır. İlk IRQ7'ye kadar bu sırada gider. İkincil PIC'in baktığı IRQ8--IRQ15 arasıysa INT 70h -- INT 77h arasındadır. Örneğin BIOS açılışta zamanlayıcıyı saniyenin 18.2'sinde IRQ0 oluşturacak şekilde programlar. (PIT'in zamanlayıcı taşması (Timer Overflow) bacağı PIC'in IRQ0 bacağına bağlıdır.) Her saniye 18.2 kere INT 08h çağırılır. BIOS aynı zamanda INT 08h'ya ait ISR'yi de belleğe ekler. ISR her çağırıldığında 0000:046Ch adresindeki DWORD'u bir arttırıp INT 1Ch'yi çağırır. DOS altında düzenli olarak çağırılan bir TSR yazarken INT 08h'nın kodunu değiştirmek sistemin kararlılığını etkileyeceğinden pek önerilmez, bunun yerine programın INT 1Ch'ya asılması önerilir.

Yalnız kutsal kitap Ralf Brown Interrupt List'e gözatınca INT 08h'nın aynı zamanda çifte hata (double exception) için de çağırıldığını görürüz. Benzer şekilde INT 09h matematik işlemci hatalarında "exception handler" olarak CPU tarafından INT 00h'ya benzer biçimde çağırılmaktadır. Özellikle IRQ5'i karşılaması gereken INT 0Dh aynı zamanda kullanıcılara çok tanıdık gelen genel koruma hatası (general protection fault -- GPF) ve yine IRQ6'yı karşılaması gereken INT 0Eh de aynı zamanda sayfa hatası (page fault) için çalışmaktadır. Şimdi Windows 95'in verdiği mavi ekranda "bir istisna 0E oluştu" ifadesinin ne anlama geldiği daha açıkça anlaşılabiliyordur sanırım.

Çok kötü birşeyler olmuş (Image credit)


Daha önce hizalama hatasından bahsederken aslında bu kesmenin de BIOS kesmeleriyle çakıştığını söylemiştim. Peki bu nasıl olabiliyor?

Birincisi sonradan eklenen INT 08h ile INT 11h arasındaki mikroişlemci istisnaları dikkat edilirse çoğunlukla korumalı moda ait hatalarda tetikleniyor. Dolayısıyla kodu, gerçek mod kodu olan INT 10h ve INT 11h kesmelerini zaten korumalı mod altında kullanmayacağımızdan yada INT 10h ile INT 11h gerçek modda tetiklenecek bir hata olmadığından gerçek moddayken güvendeyiz. Ama gerçek moddan korumalı moda geçtiğimizde bir IRQ5 gerçekleştiğinde ben gerçekte koruma hatası mı alıyorum yoksa sabit disk bir kesme mi istiyor bunu ayırt edebilmem gerçekten çok zor olacaktır. İşte programlanabilir kesme denetleyicisinin programlanabilir olduğu yer burası. Siz kesme denetleyicisine G/Ç portlarından ulaşarak yeniden programlayabilirsiniz. Kesme denetleyicisinin portları birinci denetleyici için 20h ikinci denetleyici için 0A0h'dır.
Bu portlardan denetleyiciyi programlayan kodu aşağıda veriyorum. Kod benim değil, zamanında Utrecht Üniversitesi'nin korumalı mod e-posta listesinde paylaşılmış basit bir kod:

mov    al, 00010001b ; Input Control Word (ICW) 1
out    20h, al       ; Birinci PIC
out    0A0h, al      ; Ikinci PIC

mov    al, 20h       ; IRQ0'i karsilayacak INT
out    21h, al
mov    al, 28h       ; IRQ8'i karsilayacak INT
out    0A1h, al
 
mov    al, 00000100b ; ICW3
out    21h, al       ; Ikinci PIC hangi bacaga bagli?
mov    al, 2
out    0A0h, al

mov    al, 00000001b ; ICW4
out    21h, al
out    0A1h, al

mov    al, 11111011b ; Output Control Word (OCW)1: bir olan IRQ'lari maskele
out    21h, al
mov    al, 11111111b ; hepsini maskele
out    0A1h, al

Benzeri kodlar http://wiki.osdev.org/8259_PIC adresinde de bulunabilir.

PIC'i programlarken dikkat edilmesi gereken ICW1 20h portuna yazılırken ICW2, ICW3 ve ICW4 komutları ICW1'in yazılması bittikten hemen sonra 21h portuna yazılması gerekiyor.

ICW1'in ilk dört biti 0001 olması gerekiyor. Üçüncü bit PIC'i kenar tetiklemeli (edge-triggered) moda alıyor, ikinci bit kesme vektörlerinin 32 bit olduğunu bildiriyor, birinci bit PIC'in birlikte çalıştığı başka bir PIC daha bulunduğunu bildiriyor. Sıfırıncı bitse ICW4'ün verileceğini bildiriyor.

ICW2, birinci ve ikinci PIC'lerin oluşturacağı IRQ'ları mikroişlemcide hangi kesmenin karşılayacağını bildiriyor.

ICW3, birinci PIC'in hangi bacağına bağlı olduğunu bildiriyor. (IRQ2) İkinci PIC içinse ICW3 bu PIC'e bir numara vermemizi sağlıyor. Bu numara birden fazla PIC ardarda bağlanırsa hepsini numaralandırabilmeye olanak tanıyor. IBM PC uyumlularında yalnızca iki tane olduğundan çok da önemli değil.

ICW4'ün sıfırıncı biti 8086 modunda kesmeler üretmesini sağlıyor.

Kodun son parçasında OCW aracılığıyla birinci PIC'de IRQ2 haricindeki IRQ'lar iptal ediliyor ve ikinci PIC'de de bütün IRQ'lar iptal ediliyor. Diğer bitlerin değerleri ve anlamları için yine Ralf Brown kesme listesine bakılabilir.

Elbette ki bu kod çalışırken herhangi bir kesme gerçekleşse bile dikkate alınmaması gerekiyor bu nedenle bu kodun hemen öncesinde bir CLI komutu çalıştırılmalı ve sonrasında gerekmiyorsa (tabi ISR'ler hazırlandıktan sonra) kesmeler yeniden STI ile geri getirilmelidir.

Kesme denetleyici entegreye bakınca 8 bitlik bir veriyolu bağlantısının olduğu görülür. Bir IRQ gerçekleştiği zaman kesme denetleyicisi mikroişlemcinin INTR (Interrupt Request) bacağını set edip veriyoluna kesmenin numarasını sürer. Bundan sonrasında mikroişlemcinin keyfi olursa o kesmeyi çağırır ve INTA bacağını resetler (INTA active low olması lazım) yada kesme isteğine cevap vermez.

Son olarak sinyallemenin daha rahat anlaşılması için google görsellerde "Cascaded 8259" aratıldığında bağlantının nasıl yapıldığını anlatan güzel görseller bulunabiliyor. Ben, başkasının çizimleri olduğundan buraya eklemek istemedim.