Paylaşımlı Kütüphanelerin Oluşturulması

Bu bölümde 32 ve 64 bitlik sistemler için paylaşımlı kütüphaneleri nasıl oluşturabileceğimize ve arka planına bakacağız.

32 Bitlik Sistemlerde Paylaşımlı Kütüphanelerin Oluşturulması

Paylaşımlı kütüphane kodunun çalışabilir dosyaya kopyalanmadığını ve prosesin adres alanına sonradan yüklendiğini daha önce söylemiştik. Paylaşımlı kütüphanelerle ilgili temel zorluk derleme zamanında kütüphanenin nereye yükleneceğinin bilinememesidir.

32 bitlik bir sistem için bir prosesin bellekteki görüntü kabaca şekilde gösterildiği gibidir.

Not: Burada prosesin fiziksel bellekteki gerçek görüntüsünü değil sanal bellek görüntüsünü kast ediyoruz.

Paylaşımlı kütüphanelerin Memory Mapping alanı içinde yüklenecekleri alan belli olmasına karşın, prosesten prosese farklılık gösterebilmektedir. Prosesler bu alana farklı sayı ve büyüklükte kütüphaneleri yükleyebilmektedirler. Dolayısıyla bir kütüphanenin tüm proseslerde aynı adrese yükleneceği, kolaylıkla, garanti altına alınamamaktadır. Hatta, adres alanı randomizasyonu (Address Space Layout Randomization) yapan modern sistemlerde, aynı uygulamaya ait prosesler bile paylaşımlı kütüphaneleri farklı adreslere yüklemektedir. İşetim sistemi çekirdeği, güvenlik amaçlı olarak, prosese ait yığın, heap ve kütüphane için ayrılmış alanları farklı adreslerden başlatmaktadır.

Bu noktada, bir kütüphanenin dinamik olarak yüklenebilmesinin onun paylaşımlı olduğu anlamına gelmediğini söyleyelim. Bir kütüphane dinamik olarak yüklenebilmesine karşın kütüphane kodu birden çok proses tarafından paylaşılamayabilir. Örneğin Windows Vista öncesi dll dosyaları paylaşıma izin vermemektedir.

Bir kütüphanenin bir proses tarafından dinamik olarak yüklenerek kullanılabilmesi için genel olarak aşağıdaki yöntemler kullanılabilir.

  • Her kütüphane önceden belirlenmiş değişmez (fixed) bir adrese yüklenebilir
  • Kütüphane yüklenirken kod bölümü üzerinde değişiklikler yapılabilir (Load-time relocation)
  • Kütüphane kodu konumdan bağımsız olarak yazılabilir

Şimdi sırasıyla bu yöntemlere bakalım.

Kütüphanelerin Değişmez Adreslere Yüklenmesi

Daha önceleri Windows sistemlerinde kullanılmasına karşın, günümüzde Linux ve Windows sistemlerinde bu yöntem kullanılmamaktadır. Kütüphanelerin sabit adreslere sahip olması, adres alanı paylaşımında çakışmalara sebep olmaktadır. Bir kütüphane tarafından kullanılan aralık diğer kütüphaneler ve uygulamanının kendisi tarafından kullanılmamalıdır. Bu yöntem kütüphanelerin birbirine olan ve uygulamanın kütüphanelere olan bağımlılığını daha katı bir hale getirmekte ve yönetilmesi zor bir hal almaktadır.

Yükleme Zamanı Konumlandırma (Load-time Relocation)

Çalıştırılabilir dosya oluşturulurken, içsel sembollerin bağlayıcı tarafından yeniden nasıl konumlandırıldığını (relocation) daha önce incelemiştik. Assembler tarafından adresleri belirlenemeyen sembollere nihai adesleri bağlayıcı tarafından verilmekteydi. Çalışabilir dosyanın aksine kütüphanenin nereye yükleneceği derleme zamanında bilinemeyeceği için, bağlayıcı bu durumda tüm sembolleri çözümleyemeyecek ve bazı sembolleri daha sonra çözümlenmeleri için bırakacaktır. Derleme zamanında çözümlenemeyen bu semboller yükleme zamanında dinamik bağlayıcı tarafından çözümlenmektedir.

Bu yöntemde kütüphaneler dinamik olarak yüklenebilmesine karşın, birden çok proses tarafından ortak kullanılmalarında zorluklar barındırmaktadır. Kütüphaneler belleğe yüklendikten sonra kendilerini kullanacak olan prosese uygun olarak konumladırma işleminden geçmektedir.

Windows altındaki dll kütüphaneleri bu yöntemle gerçeklenmekte ve işletim sistemi desteğiyle birden çok proses tarafından paylaşılabilmektedir. Tercih edilmemesine karşın, Linux altında da 32 bitlik sistemler için bu yöntemle dinamik yüklenen kütüphaneler oluşturulabilmekte fakat birden çok proses tarafından paylaştırılamamaktadır.

Linux altında bu yöntemle dinamik kütüphanelerin nasıl oluşturulduğunda basit bir örnek üzerinden bakalım. Uygulama ve kütüphane kodlarına sırasıyla driver.c, modul.c adlarını verelim.

void foo();

int main() {
    foo();
    return 0;
}
#include <stdio.h>

void bar() {
    puts(__func__);
}

void foo() {
    bar();
}

Kütüphane dosyasını aşağıdaki gibi oluşturabiliriz.

$ gcc -shared -olibmodul.so modul.c -m32

Şimdi kütüphane bağımlı uygulamamızı derleyip çalıştırabiliriz.

$ gcc -odriver driver.c -L. -lmodul -m32
$ LD_LIBRARY_PATH=. ./driver
bar

shared anahtarı ile gcc'ye hedefin bir kütüphane dosyası olduğu bildirilmektedir. Ürettiğimiz kütüphane dosyası şu an için standart dışı bir dizinde bulunduğu dan, LD_LIBRARY_PATH çevre değişkeni ile dinamik bağlayıcıya kütüphane dosyamızın bulunduğu dizini gösteriyoruz. Bu konuya daha sonra değineceğiz.

64 bitlik mimari için kütüphane dosyalarının bu şekilde oluşturulmasına izin verilmemektedir. 64 bitlik mimaride dinamik bağlayıcı kod üzerinde relocation işlemini desteklememektedir. Daha derleme aşamasında bağlayıcı tarafından aşağıdaki gibi bir hata mesajı verilmektedir.

$ gcc -shared -olibmodul.so modul.c
/usr/bin/ld: /tmp/cco6wTkV.o: relocation R_X86_64_32 against `.rodata' can not be used when making a shared object; recompile with -fPIC
/tmp/cco6wTkV.o: error adding symbols: Bad value
collect2: error: ld returned 1 exit status

Kütüphane Kodunun Pozisyon Bağımsız Yazılması (PIC, Position Independent Code)

Linux altında, 32 ve 64 bitlik sistemler için, birden çok proses tarafından paylaşılabilen kütüphaneler bu yöntemle oluşturulmaktadır. Çalışabilir dosyaların aksine paylaşımlı kütüphanelerin nereye yükleneceğinin derleme zamanında bilinemediğinden daha önce söz etmiştik.

Her proses gerçekte fiziksel belleğin farklı alanlarını kullanıyor olmasına karşın, sanal bellek kullanımı sayesinde, tüm bellek kendisine aitmiş gibi görmektedir. Program kodu içerisinde gerçek adresler yerine mantıksal adresler yer almakta, bu sayede bağlayıcı tarafından tüm uygulamalara aynı adresler atanabilmekte ve çalışma zamanında herhangi bir yeniden konumlandırma işlemine ihtiyaç duyulmamaktadır. Paylaşımlı kütüphanelerin ise farklı proseslerin farklı adres alanlarına yüklenmesi ve bellekte bir adet kopyası olması istenmektedir. Kütüphane kodunun pozisyondan bağımsız yazılması durumunda bu özellik sağlanabilmektedir.

Aslında kütüphanenin paylaşımlı olması kod bölümüne ilişkindir. Kütüphanenin kod bölümünün, fiziksel olarak, bir örneği olmasına karşın data alanının her proses için bir kopyası çıkarılmaktadır. Kütüphanenin data alanı prosesler arasında paylaşılmaz, bu sebeple Linux altında paylaşımlı kütüphaneler ortak bir data alanı üzerinden haberleşemezler. Başlangıçta tüm prosesler aynı data alanını görmelerine karşın, bu alan üzerine bir yazma işlemi yapmak istediklerinde data alanının fiziksel olarak bir kopyası çıkarılmaktadır. Bu işlem Copy-on-write olarak isimlendirilmekte ve sanal bellek kullanımıyla mümkün olmaktadır. Proses fiziksel bellekteki başka bir alana aynı sanal adreslerle ulaşmaya devam etmektedir. Bu sayede data alanından yalnız okuma yapan prosesler aynı alanı kullanabilmekte gereksiz yere aynı bilgiler kopyalanmamaktadır.

Not: Windows altında prosesler dll'ler üzerinde paylaşımlı bölümler oluşturularak haberleşebilmektedir.

Bir örnek üzerinden kodun pozisyon bağımlılığını inceleyelim. Aşağıdaki örneği main.c adıyla saklayıp derleyebilirsiniz.

int glob;

void foo() {
}

int main() {
    foo();
    int local = 1;
    global = 1;
    return 0;
}
$ gcc -omain main.c -m32 --save-temps

main ve foo fonksiyonları için üretilen gerçek ve sembolik makina komutlarının aşağıdaki gibi olduğunu görmekteyiz.

$ objdump -d main
080483eb <foo>:
 80483eb:       55                      push   %ebp
 80483ec:       89 e5                   mov    %esp,%ebp
 80483ee:       5d                      pop    %ebp
 80483ef:       c3                      ret

080483f0 <main>:
1.  80483f0:       55                      push   %ebp
2.  80483f1:       89 e5                   mov    %esp,%ebp
3.  80483f3:       83 ec 10                sub    $0x10,%esp
4.  80483f6:       e8 f0 ff ff ff          call   80483eb <foo>
5.  80483fb:       c7 45 fc 01 00 00 00    movl   $0x1,-0x4(%ebp)
6.  8048402:       c7 05 20 a0 04 08 01    movl   $0x1,0x804a020
7.  8048409:       00 00 00
8.  804840c:       b8 00 00 00 00          mov    $0x0,%eax
9.  8048411:       c9                      leave
10. 8048412:       c3                      ret

Komut adreslerinin başına, incelememizi kolaylaştırmak için, numaralar verdik. main fonksiyonunu 4. satırda foo fonksiyonunu çağırdığını görüyoruz.

e8 f0 ff ff ff

Daha önce, referansların çözümlenmesinden bahsettiğimiz bölümde, e8 makina komutunun mutlak adres yerine göreli adres aldığını (Relative Call) söylemiştik. Kod içinde foo fonksiyonunun adresi, 80483eb olarak açık bir şekilde yazılmak yerine, bir sonraki komut adresi yani IP (Instruction Pointer) göreli adres yazılmış. Sağ taraftaki sembolik makina kodundaki değer objdump tarafından hesaplanmaktadır, işlemci de benzer bir işlem yaparak gerçek adrese ulaşmaktadır.

Not: Daha önce negatif sayıların bellekte ikiye tümleyen şeklinde tutulduğundan bahsetmiştik. İlk önce 1'e tümleyenini alıyor sonrasında 1 ile topluyorduk. Aslında yapılmak istenen sayının alabileceği maksimumum değerin 1 fazlasıyla olan farkını bulmakdır. Bu örnek için call f0 ff ff ff komutu, temsili olarak, aşağıdaki gibi ele alınmaktadır.

0xffffffff - 0xfffffff0 = 0xf

0xf + 0x1 = 0x10

0x80483fb - 0x10 = 0x80483eb (IP değeri - 0x10)

call 0x80483eb

5. satırda yerel local değişkenine 1 sayısının atandığını görüyoruz. Yerel değişkenler yığın üzerinde gerektiğinde oluşturulmakta ve yazmaç göreli olarak konumlandırılmaktadır. Değişmez (hardcoded) adresleri bulunmamaktadır.

6. satırda ise glob isimli global değişkene 1 değerinin atandığını görüyoruz.

c7 05 20 a0 04 08 01    movl   $0x1,0x804a020

Sembolik makina eşdeğerinde glob değişkenini gösteren adresin gerçek makina kodunda Little Endian olarak kodlandığını görüyoruz.

x86 mimarisinde, kod referanslarının aksine, data referanslarında göreli adresler kullanılamamakta, adresler mutlak olmak zorundadır. Kod içindeki bu değişmez adres kodu pozisyon bağımlı hale getirmektedir. main ve foo fonksiyonlarının yüklendikleri adresler değişse bile aralarındaki uzaklık değişmeyeceğinden bir problem olmayacak, main içinde foo problemsiz bir şekilde çağrılmaya devam edecektir. Benzer şekilde dallanma komutları (jmp,...) ve yerel değişkenler için de bir problem çıkmayacaktır. Dallanma komutları hem göreli hem de mutlak adresleri desteklemektedir.

Özetleyecek olursak, kod içinde data referansları bulunması durumunda bu referanslar .data alanının yükleneceği adrese bağımlı olacaklar ve uygulama istenilen bir bellek alanına yüklenemeyecektir.

Şimdi, paylaşımlı kütüphaneler için, pozisyon bağımsız bir kodun nasıl yazıldığına bakalım. İlk olarak data sonrasında kod referanslarının nasıl ele alındığına bakacağız.

Data Referanslarının Ele Alınması

Global data erişimi, değişmez adresler kullanılarak yapılmak yerine, GOT (Global Offset Table) adı verilen bir tablo üzerinden yapılmaktadır. Bu durumu görsel olarak aşağıdaki gibi gösterebiliriz.

GOT tablosu data alanın başında bulunmaktadır. Şekilde kod, GOT ve data alanı içinde yer alan global değişkenler için ayrılan alanlar gösterilmiştir. Kod içinde global değişkenlere direkt adresleriyle ulaşıldığında, kodun data alanının pozisyonuna bağlı olduğunu hatırlayınız. Burada ise GOT tablosu üzerinden değişken adreslerine ulaşılmaktadır. GOT tablosu kütüphane yüklenirken dinamik bağlayıcı tarafından doldurulmaktadır. Data alanının başlangıç adresine göre, GOT tablosunun girişleri global değişkenlerin adreslerini gösterecek şekilde doldurulmaktadır. Bu soyutlama sayesinde kodun data alanının yerleşimine bağımlılığı ortadan kaldırılmaktadır.

Bu aşamada, GOT tablosuna nasıl ulaşılacağı ayrı bir problem olarak ortaya çıkmaktadır. Data ve kod alanları, dinamik bağlayıcı tarafından, istenilen bir adresten başlamak üzere yüklenecekse GOT tablosunun da değişmez bir adresi olamaz. GOT tablosunun adresine derleyicinin ekstradan yazdığı kodlar sayesinde ulaşılmaktadır. Temelde, kod ve data alanlarının boyutlarının ve birbirlerine göre konumlarının biliniyor olmasından faydalanılmaktadır. Bu durumu örnek bir şekil üzerinden inceleyelim.

GOT tablosu derleme zamanında bağlayıcı tarafından oluşturulmaktadır. Bağlayıcı derleme zamanında kod ve GOT bölümlerinin boyutlarını ve birbirlerine göre olan göreli uzaklıklarını bilmektedir. Yükleme zamanında başlangıç adresleri değişmesine karşın, bu bölümler arasındaki mesafe sabit kalmaktadır. Şekilde dinamik yükleyici tarafından belirlenecek 0xXXXX ile başlayan adresler ne olursa olsun, örnek komut ile GOT başlangıcı arasındaki uzaklık sabit (0xEF60) kalacaktır. Bu bilgi bağlayıcı tarafından kullanılacaktır. Örnek komutun kendi adresinin bilinmesi durumunda GOT adresine ulaşılabilir. Bu aşamada ilk olarak derleyicinin pozisyon bağımsız bir kodu nasıl yazdığına bir örnek üzerinden bakalım. Örnek kodu test.c adıyla saklayıp aşağıdaki gibi derleyebilirsiniz.

int global;

void foo() {
    global = 111;
}
$ gcc -c -fPIC test.c -m32 --save-temps

Pozisyon bağımsız kod üretebilmek için derleyiciye fPIC anahtarını geçirmelisiniz. Sembolik makina kodlarına baktığımızda derleyicinin foo için aşağıdaki gibi bir kod yazdığını görmekteyiz. Ayrıca derleyici tarafından, dışsal bağlanıma kapalı, __x86.get_pc_thunk.cx isimli bir fonksiyon yazılmış. İncelememizi kolaylaştırmak için foo içindeki komutları numaralandırdık.

foo:
.LFB0:
1.    pushl   %ebp
2.    movl    %esp, %ebp
3.    call    __x86.get_pc_thunk.cx
4.    addl    $_GLOBAL_OFFSET_TABLE_, %ecx
5.    movl    global@GOT(%ecx), %eax
6.    movl    $111, (%eax)
7.    popl    %ebp
8.    ret

__x86.get_pc_thunk.cx:
.LFB1:
    movl    (%esp), %ecx
    ret

Kodumuzu fPIC anahtarı geçirmeden derleseydik, derleyici tarafından, aşağıdaki gibi bir sembolik makina kodu üretilecekti. global sembolüne, assembler tarafından geçici bir adres atanacak, sonrasında bağlayıcı tarafından nihai adresi atanacak ve kod data alanının adresine bağımlı olacaktı.

foo:
.LFB0:
    pushl   %ebp
    movl    %esp, %ebp
    movl    $111, global
    popl    %ebp
    ret

Pozisyon bağımsız kodumuzu incelemeye devam edelim. İlk olarak __x86.get_pc_thunk.cx fonksiyonuna bakalım. __x86.get_pc_thunk.cx fonksiyonu, komut göstericisinin değerini yani bir sonraki komutun başlangıç adresini elde etmek için kullanılmaktadır. Komut göstericisinin (eip) değerini direkt olarak öğrenmenin bir yolu bulunmamaktadır. Fonksiyon çağrıldıktan sonra yığının tepesindeki geri dönüş adresini ecx yazmacına yazmış ve sonrasında ret komutu ile bu değeri yığından çekerek geri dönmüştür. call komutuyla bir fonksiyon çağrıldığında, bir sonraki komutun başlangıç adresi gizli bir şekilde yığına atılmaktadır. Bu durumda __x86.get_pc_thunk.cx fonksiyonu döndüğünde ecx yazmacında 4 numaralı satırdaki makina kodunun adresi bulunacaktır.

4 numaralı komutta ecx değeriyle yani komutun kendi adresiyle öntanımlı GLOBAL_OFFSET_TABLE sembolünün değerinin toplandığını görüyoruz. Kod bölümündeki herhangi bir komut ile GOT arasındaki mesafenin bağlayıcı tarafından bilindiğini daha önce söylemiştik. Derleyici bu sembole, sembolün kullanıldığı komut ile GOT arasındaki mesafeyi atayacaktır. Bu mesafenin çalışma zamanında da değişmediğini hatırlayınız. Bu durumda ecx yazmacında GOT başlangıç adresi bulunacaktır.

5 numaralı komutta GOT tablosundan global değişkenine ilişkin girişin değerine yani global değişkeninin adresine ulaşılmakta ve bu değer eax yazmacında saklanmaktadır.

6 numaralı komut ile dereference işlemi yapılarak global değişkenine 111 değeri atanmaktadır.

Elde ettiğimiz pozisyon bağımsız amaç kod ile bir paylaşımlı kütüphaneyi aşağıdaki gibi oluşturabiliriz.

$ gcc -shared -olibtest.so test.o -m32

Pozisyon bağımsız derleme ve bağlama işlemlerini tek seferde aşağıdaki gibi de yapmak mümkündür.

gcc -fPIC -shared -olibtest.so test.c -m32

gcc tarafından, fPIC derleyeciye shared ise bağlayıcıya geçirilmektedir. Son olarak kütüphane içindeki gerçek makina kodlarına bakalım.

$ objdump -d libtest.so
00000515 <foo>:
 515:   55                      push   %ebp
 516:   89 e5                   mov    %esp,%ebp
 518:   e8 14 00 00 00          call   531 <__x86.get_pc_thunk.cx>
 51d:   81 c1 e3 1a 00 00       add    $0x1ae3,%ecx
 523:   8b 81 ec ff ff ff       mov    -0x14(%ecx),%eax
 529:   c7 00 6f 00 00 00       movl   $0x6f,(%eax)
 52f:   5d                      pop    %ebp
 530:   c3                      ret

Bağlayıcının, komutla GOT arasındaki göreli yer değişimini gösteren, GLOBAL_OFFSET_TABLE sembolüne 0x1ae3 değerini atadığını, global değişkeni için ise GOT içinde 14 numaralı girişi ayırdığını görüyoruz. Dinamik bağlayıcı yükleme zamanında GOT tablosunun 14. elemanına global değişkeninin adresini yazmalıdır, bunun için derleme zamanında ELF içindeki relocation bölümüne aşağıdaki gibi bir kayıt eklenmiştir.

$ readelf -r libtest.so
 Offset     Info    Type            Sym.Value  Sym. Name
00001fec  00000906 R_386_GLOB_DAT    0000201c   global

global değişkenine ilişkin kaydın ELF içindeki ofset değerine, ilk olarak ELF içinde GOT tablosunun adresini, sonrasında global için ayrılan girişi bularak ulaşabiliriz.

0x51d + 0x1ae3 = 0x2000
0x2000 - 0x14 = 0x1fec

Fonksiyon Çağrılarının Ele Alınması

Data referanslarının aksine, fonksiyon çağrılarının değişmez mutlak adresler yerine göreli yer değişimi kullanılarak yapıldığını daha önce incelemiştik. Fonksiyonların bellekte nereye yüklendiklerinden bağımsız olarak aralarındaki mesafe korunmakta ve fonksiyon çağrıları sorunsuz olarak yapılabilmektedir. Fonksiyon çağrıları zaten pozisyon bağımsız olduğu için bu durumda birşey yapılmasına gerek olmadığını düşünebilirsiniz. Gerçekte ise ilk bakışta karmaşık görünen bir yöntem kullanılmaktadır.

Gerçekte kullanılan yönteme ve gerekçesine adım adım gidelim. İlk olarak, paylaşımlı kütüphane içindeki fonksiyonların yönlendirilebilmesi istenmektedir. Aşağıdaki şekli inceleyiniz.

Bazı durumlarda lib1.so içindeki foo fonksiyonunun kendi bar fonksiyonu yerine lib2.so içindeki bar fonksiyonunu çağırması istenmektedir. Bu özelliğin öneminden daha sonra bahsedeceğiz. foo kodunda bar fonksiyonunun direkt olarak, aralarındaki yer değişimi ile çağrılması durumunda bu mekanizma sağlanamayacaktır.

Bu durumda data referanslarına benzer şekilde GOT tablosunda fonksiyonlar için girişler ayrılabilir ve yükleme zamanında dinamik bağlayıcı tarafından bu girişlere uygun değerler verilerek fonksiyon yönlendirmesi sağlanabilirdi. Çağrılar direkt yapılmak yerine, GOT üzerinden indirekt yapılarak istenilen soyutlama sağlanmış olurdu. Bu duruma ilişkin aşağıdaki şekli inceleyiniz.

Kütüphane yüklenirken, kullanılacak olan bar fonksiyonunun adresi ilgili GOT girişine yazılabilir ve lib1.so için, sonraki bar fonksiyonu çağrıları bu giriş üzerinden dolaylı olarak yapılabilirdi. Hedeflenen çalışma şekline ulaşmamıza karşın bu yöntemin bir dezavantajı bulunmaktadır. Kütüphane yüklenirken kütüphane içindeki tüm fonksiyon çağrıları için GOT girişleri doldurulmalıdır. Bunun için dinamik bağlayıcı yükleme zamanında ELF tablolarını okumalı, ilgili sembollerin adreslerini çözümlemeli ve girişlere yazmalıdır. Kütüphane içinde özellikle hata işleyen fonksiyonlar olmak üzere birçok fonksiyon çok az veya hiç çağırılmamaktadır. Bu durumda en baştan tüm fonksiyonlarla ilgili işlem yapmak kütüphanenin yüklenme sürecini dolayısıyla uygulamanın açılma süresini arttıracaktır. Bu sebeple fonksiyonlara ilişkin adres çözümlemesinin gerekli olduğu durumda yapılabilmesi istenmiştir. Bu çözümleme işlemi lazy binding olarak isimlendirilmektedir.

Bu amaçla bir soyutlama katmanı daha getirilmiştir. Fonksiyon çağrıları GOT içindeki adresler üzerinden dolaylı yapılmak yerine öncesinde, gerçek fonksiyonu çağırmaktan sorumlu, küçük bir kod çağırılmaktadır. Bu amaçla bağlayıcı tarafından PLT (Procedure Linkage Table) adı verilen bir kod alanı daha oluşturmaktadır. PLT girişlerindeki kodlar trambolin kodu olarak isimlendirilmektedir ve her bir fonksiyon çağırısı için bir giriş tutulmaktadır.

Bir fonksiyon ilk kez çağrıldığında, fonksiyonun gerçek kodu çağrılmak yerine, trambolin kodu üzerinden dinamik bağlayıcı çağrılmaktadır. Trambolin kodu yığına, adres çözümlemesi yapılacak fonksiyon ile ilgili bir kimlik bilgisi geçirmekte ve dinamik bağlayıcının adres çözümleme rutinini çağırmaktadır.

Not: Dinamik bağlayıcının kendisi de bir paylaşımlı kütüphanedir, bu sayede dinamik bağlayıcı rutinlerini çağırmak mümkün olmaktadır.

Dinamik bağlayıcı, ilgili fonksiyona ilişkin adres çözümlemesini yaptıktan sonra fonksiyonun adresinin tutulduğu GOT girişine fonksiyonun adresini yazar ve fonksiyon koduna dallanır. Bir sonraki çağrı ise GOT üzerindeki adres üzerinden, dinamik bağlayıcıya ihtiyaç duyulmaksızın, yapılmaktadır.

Bir fonksiyon ilk kez çağrıldığında gerçekleşecek akış temel olarak şekilde gösterildiği gibidir.

Neler olduğunu adım adım inceleyelim.

1. İlk olarak bar fonksiyonuna ilişkin trambolin kodu çağrılacaktır.

2. Trambolin kodu, bar fonksiyonunun adresini tutmak için ayrılmış GOT girişindeki adrese dallanacaktır. jmp *GOT [bar] sembolik gösterimindeki *, GOT girişinin adresi üzerinden dolaylı dallanma (indirect jump) yapıldığını göstermektedir.

3. Akış tekrak trambolin koduna dönecektir. Bu durum ilk bakışta tuhaf görünmektedir. Dinamik bağlayıcı kütüphane kodunu yüklerken, sembol çözümlemesi yapmaksızın, GOT girişlerine trambolin kodu adreslerini yazmaktadır. Bu sayede, fonksiyonun çağırılıp çağrılmayacağı bilinmeyen bir aşamada, sembol çözümlemesi yapılmamakta ve fonksiyon çağrıldığında trambolin kodu üzerinden bu işlem yapılmaktadır.

4. Üç noktayla gösterdiğimiz bölümde, bar fonksiyonuna ilişkin bir kimlik değeri yığına geçirilip sonrasında PLT'nin ilk girişine dallanılmaktadır. Dinamik bağlayıcı bu bilgi sayesinde hangi fonksiyonu çözümleyeceğini bilmektedir. Bu aşamada, kimlik bilgisiyle ilgili detay kısmını atlıyoruz. PLT'nin ilk girişi, fonksiyon trambolin kodlarını tutan, diğer girişlerden farklı olarak özel bir giriştir ve dinamik bağlayıcının sembol çözümleme fonksiyonunu çağırmaktan sorumludur.

5. Dinamik bağlayıcı bar için sembol çözümlemesini yaptıktan sonra bar fonksiyonunun adresini şekilde GOT [bar] ile gösterilen girişe yazmaktadır.

6. Dinamik bağlayıcı GOT girişini düzenledikten sonra bar fonksiyonuna dallanmakta ve nihayetinde gerçek fonksiyon çağrılmaktadır.

Fonksiyon ikinci kez çağrıldığında ise bu sefer akış aşağıdaki gibi olacaktır.

Bu kez ilgili GOT girişi fonksiyonun adresini tuttuğundan, herhangi bir sembol çözümlemesi yapılmaksızın bar fonksiyonu çağrılmaktadır.

Şimdi basit bir örneği gdb ile çalıştırarak gerçekte neler olduğuna daha yakından bakalım.

Kütüphane koduna ve onu kullanacak koda sırasıyla test.c ve driver.c adlarını verip aşağıdaki gibi derleyebilirsiniz. Kütüphaneyi derlerken debug sembollerini eklemek için -g anahtarını geçiriyoruz.

void bar() {
    puts(__func__);
}

void foo() {
    bar();
}
void foo();

int main() {
    foo();
    return 0;
}
$ gcc -fPIC -shared -olibtest.so test.c -m32 -g
$ gcc -odriver driver.c -L. -ltest -m32
$ LD_LIBRARY_PATH=. ./driver
bar

driver uygulamasını gdb ile çalıştırıp, dinamik bağlayıcının kütüphaneyi bulabilmesi için LD_LIBRARY_PATH çevre değişkenini set ediyoruz.

$ gdb -q driver
Reading symbols from driver...(no debugging symbols found)...done.
(gdb) set environment LD_LIBRARY_PATH=.

main fonksiyonunu kesme noktası olarak işaretliyor ve uygulamayı çalıştırıyoruz.

(gdb) break main
Breakpoint 1 at 0x8048549
(gdb) run
Starting program: /home/serkan/embedded/so/test/29/driver

Breakpoint 1, 0x08048549 in main ()

Bu aşamadan sonra kütüphane dosyası yüklenmiş olacağından, sırasıyla foo ve bar fonksiyonlarını kesme noktaları olarak belirliyoruz.

(gdb) break test.c:foo
Breakpoint 2 at 0xf7fd45b0: file test.c, line 6.
(gdb) break test.c:bar
Breakpoint 3 at 0xf7fd4587: file test.c, line 2.

Uygulamayı devam ettirdiğimizde, kodun bar fonksiyonuna ilişkin trambolin kodu çağrısında durduğunu görüyoruz. foo fonksiyonunun başlangıç kodlarının (prologue) çalıştırıldığına dikkat ediniz. Bu aşamada ebx yazmacında GOT başlangıç adresi bulunmaktadır.

(gdb) continue
Continuing.

Breakpoint 2, foo () at test.c:6
6               bar();
(gdb) disas
Dump of assembler code for function foo:
   0xf7fd459e <+0>:     push   %ebp
   0xf7fd459f <+1>:     mov    %esp,%ebp
   0xf7fd45a1 <+3>:     push   %ebx
   0xf7fd45a2 <+4>:     sub    $0x4,%esp
   0xf7fd45a5 <+7>:     call   0xf7fd4440 <__x86.get_pc_thunk.bx>
   0xf7fd45aa <+12>:    add    $0x1a56,%ebx
=> 0xf7fd45b0 <+18>:    call   0xf7fd4400 <bar@plt>
   0xf7fd45b5 <+23>:    add    $0x4,%esp
   0xf7fd45b8 <+26>:    pop    %ebx
   0xf7fd45b9 <+27>:    pop    %ebp
   0xf7fd45ba <+28>:    ret
End of assembler dump.

Sonrasında makina komutlarını adım adım çalıştırarak trambolin koduna bakalım.

(gdb) stepi
0xf7fd4400 in bar@plt () from ./libtest.so
(gdb) disas
Dump of assembler code for function bar@plt:
=> 0xf7fd4400 <+0>:     jmp    *0xc(%ebx)
   0xf7fd4406 <+6>:     push   $0x0
   0xf7fd440b <+11>:    jmp    0xf7fd43f0
End of assembler dump.

Trambolin kodundaki ilk komut, daha önce söylediğimiz gibi, bar fonksiyonu için ayrılmış alandaki adrese dallanmaktadır. bar fonksiyonuna ilişkin GOT girişinde tutulan değere aşağıdaki gibi ulaşabiliriz. ebx yazmacında hala GOT başlangıç adresi tutulmaktadır.

(gdb) print /x *(0xc + $ebx)
$2 = 0xf7fd4406

Elde edilen adresin, 0xf7fd4406, trambolin kodunun 2. komutunu gösterdiğine dikkat ediniz. Bu aşamadan sonra, daha önce de söylediğimiz gibi, trambolin kodu dinamik bağlayıcıyı çağıracak, dinamik bağlayıcı da ilgili GOT girişini güncelleyip bar fonksiyonunu çağıracaktır. Kodu kaldığı yerden çalıştırmaya devam ettiğimizde bar fonksiyonun çağrıldığını görüyoruz.

(gdb) continue
Continuing.

Breakpoint 3, bar () at test.c:2
2               puts(__func__);
(gdb) disas
Dump of assembler code for function bar:
   0xf7fd4575 <+0>:     push   %ebp
   0xf7fd4576 <+1>:     mov    %esp,%ebp
   0xf7fd4578 <+3>:     push   %ebx
   0xf7fd4579 <+4>:     sub    $0x4,%esp
   0xf7fd457c <+7>:     call   0xf7fd4440 <__x86.get_pc_thunk.bx>
   0xf7fd4581 <+12>:    add    $0x1a7f,%ebx
=> 0xf7fd4587 <+18>:    sub    $0xc,%esp
   0xf7fd458a <+21>:    lea    -0x1a30(%ebx),%eax
   0xf7fd4590 <+27>:    push   %eax
   0xf7fd4591 <+28>:    call   0xf7fd4420 <puts@plt>
   0xf7fd4596 <+33>:    add    $0x10,%esp
   0xf7fd4599 <+36>:    mov    -0x4(%ebp),%ebx
   0xf7fd459c <+39>:    leave
   0xf7fd459d <+40>:    ret
End of assembler dump.

Bu aşamada, GOT girişinde bar fonksiyonunun adresi bulunuyor olmalı. Bu durumu aşağıdaki gibi doğrulayabiliriz. Daha önce ilgili girişte trambolin kodunun adresi olduğunu hatırlayınız.

(gdb) print /x *(0xc + $ebx)
$3 = 0xf7fd4575

(gdb) print bar
$4 = {void ()} 0xf7fd4575 <bar>

bar fonksiyonun sonraki çağrısında bu süreç tekrarlanmayacak ve GOT üzerinden fonksiyonun dolaylı olarak çağrılabilecektir.

64 Bitlik Sistemlerde Paylaşımlı Kütüphanelerin Oluşturulması

64 bitlik mimari için de pozisyon bağımsız kod 32 bitlik mimariye benzer şekilde oluşturulmakta, yine GOT ve PLT tabloları kullanılmaktadır. Temel farklılık GOT girişlerine erişim şeklindedir.

İki mimari arasında data referanslarını ele alış biçiminde farklılık bulunmaktadır. 32 bitlik mimarinin aksine 64 bitlik mimaride data adresleri de göreli olabilir. 64 bitlik mimaride bu adresleme şekli RIP-relative olarak isimlendirilmektedir. Bu sayede 64 bitlik mimaride paylaşımlı kütüphane kodu yazımı daha kolay bir hal almaktadır.

Daha önce incelediğimiz basit bir örnek üzerinden bu duruma bakalım. Kodu test.c adıyla saklayıp aşağıdaki gibi derleyebilirsiniz.

int global;

void foo() {
    global = 111;
}
$ gcc -fPIC -shared -olibtest.so test.c --save-temps

test.s içindeki sembolik makina kodlarına baktığımızda, derleyicinin aşağıdaki gibi bir kod yazdığını görmekteyiz.

foo:
.LFB0:
 1.   pushq   %rbp
 2.   movq    %rsp, %rbp
 3.   movq    global@GOTPCREL(%rip), %rax
 4.   movl    $111, (%rax)
 5.   popq    %rbp
 6.   ret

Aynı kodun 32 bitlik mimari için derlenmesi durumunda aşağıdaki gibi bir kod üretildiğini daha önce görmüştük. Her iki komut listesini de incelemeyi kolaylaştırmak için numaralandırdık.

foo:
.LFB0:
 1.   pushl   %ebp
 2.   movl    %esp, %ebp
 3.   call    __x86.get_pc_thunk.cx
 4.   addl    $_GLOBAL_OFFSET_TABLE_, %ecx
 5.   movl    global@GOT(%ecx), %eax
 6.   movl    $111, (%eax)
 7.   popl    %ebp
 8.   ret

32 bit için, 3, 4 ve 5 numaralı komutlarla yapılan iş 64 bitlik mimaride 3 numaralı komut ile yapılmaktadır. Tek bir komut ile GOT girişinin yani global değişkenin adresine ulaşılabilmektedir. Bu durumda, 64 bitlik mimari için derleyici fazladan __x86.get_pc_thunk.cx gibi bir fonksiyon yazmak zorunda kalmamakta, ayrıca bir yazmacı GOT başlangıç adresini tutması için tahsis etmemektedir.

Bu noktada, data referanslarına neden göreli adres kullanılarak erişilmeyipte GOT üzerinden erişildiğini merak edebilirsiniz. Kod bölümündeki bir kod ile GOT arasındaki mesafe derleme zamanında bilindiği gibi data alanı ile olan mesafe de bilinmektedir. Data referanslarına bu şekilde erişilmemesinin nedeni fonksiyonlardan beklenen özellikle aynıdır. Daha önce paylaşımlı kütüphanelerde fonksiyonların nasıl ele alındığını incelediğimiz bölümde, bir fonksiyonun aynı kütüphane içindeki bir fonksiyon yerine başka bir fonksiyona bağlanabileceğinden bahsetmiştik, aynı durum data referansları için de geçerlidir. Bu mekanizmanın sağlanabilmesi için data alanlarına direkt erişilmek yerine erişim GOT üzerinden dolaylı olarak yapılmaktadır. Aşağıdaki şekli inceleyiniz.

Kod ve data referanslarının öncelik sırasının neye göre belirlendiğine daha sonra bakacağız.

results matching ""

    No results matching ""