Sistem Çağrıları
Last updated
Was this helpful?
Last updated
Was this helpful?
Modern işletim sistemlerinde, çekirdek kipinde çalışma ve kullanıcı kipinde çalışma modları, sert bariyerlerle birbirinden ayrılmış durumdadır.
Bu şekildeki bir tasarım, sistemin sağlıklı çalışması için elzemdir.
Kullanıcı kipinde çalışan bir uygulamanın, sistem çağrıları aracılığıyla işletim sistemi çekirdeğinden ihtiyaç duyduğu servisleri alabilmesi sağlanır.
Bununla birlikte, sistem çağrıları normal fonksiyon çağrılarına oranla oldukça yüksek maliyetli işlemlerdir.
Her sistem çağrısında uygulamanın o anki durumunun saklanması, çekirdeğin işlemcinin kontrolünü ele alması ve ilgili sistem çağrısı ile çekirdek kipinde talep edilen işlemleri gerçekleştirmesi, sonra ilgili uygulamanın tekrar çalışma sırası geldiğinde, uygulamanın saklanan durumunun yeniden üretilip işlemlerin kaldığı yerden devamının sağlanması gereklidir.
Sistem çağrılarının kernel tarafındaki gerçekleştirimi mimariden mimariye değişkenlik gösterir. Linux çekirdeğinin farklı bir mimariye port edilirken yapılan temel işlem adımlarından biri, sistem çağrılarının en verimli şekilde yapacak şekilde uygun bir kodlamanın mimari spesifik olarak yapılmasıdır.
Her sistem çağrısının 1, 5, 27 gibi ilişkili bir numarası vardır. Bu numaralar da mimariden mimariye değişkenlik göstermektedir. Temel sistem çağrıları tüm mimarilerde bulunmakla birlikte, tüm mimarilerde eşit sayıda sistem çağrısı bulunmaz.
Konunun devamında aksi belirtilmedikçe verilen örnekler 32 bit Intel mimarisi için geçerlidir.
Kullanıcı kipindeyken herhangi bir sistem çağrısı yapıldığında INT 0x80
makine dili kodu ile trap oluşturulur.
Aynı zamanda talep edilen sistem çağrısının numarası, EAX
yazmacına yazılır.
Talep edilen sistem çağrısının parametreleri var ise, bu parametrelerin diğer yazmaçlar kullanılarak belirtilmesi gerekir. Ancak her mimaride bu amaçla kullanılabilecek yazmaç sayısı limitlidir. Bazılarında daha çok genel amaçlı yazmaç var iken bazılarında daha az olduğu görülmektedir.
32 bitlik Intel platformu için Linux çekirdek versiyonu 2.3.31 ve sonrası, maksimum 6 sistem çağrısı parametresini desteklemektedir. Bu parametreler sırasıyla EBX
, ECX
, EDX
, ESI
, EDI
ve EBP
yazmaçlarında saklanır.
Sistem çağrısı için 6'dan fazla parametre gerekli olduğunda, bellekteki bir veri yapısı hazırlanarak parametrelar burada saklanır, sonrasında ilgili bellek adresi sistem çağrısına parametre olarak geçirilir.
Sistem çağrılarının doğrudan işlemci mimarisine bağımlı olduğuna değinmiştik.
Örnek olarak Intel 32 bitlik işlemcilerde INT 0x80
ile trap oluşturulurken, ARM mimarisinde aynı işlem supervisor call SVC
ile yapılır
Benzer şekilde Intel mimarisinde EAX
yazmacına yazılan sistem çağrısının numarası, ARM mimarisinde R8
yazmacına koyulur.
ARM mimarisinde sistem çağrısına ait 4 adede kadar parametre, R9
, R10
, R11
ve R12
yazmaçlarına aktarılır. 4 adetten fazla parametre geçilmesi gerektiğinde, bellek üzerinde veri yapısı hazırlanarak bu bölümün adresi geçirilir.
Genel olarak sistem çağrıları performansının ARM mimarisinde x86'ya göre daha düşük olduğunu söyleyebiliriz (yazmaç/register sayısının azlığı bunda etken olabilir mi düşününüz).
Sistem çağrılarını daha zor bir yoldan doğrudan yapmak mümkün olsa da bu önerilen bir durum değildir.
Sistem çağrıları, glibc
kütüphanesindeki wrapper fonksiyonlar üzerinden kullanılır.
glibc
kütüphanesi, üzerinde çalıştığı çekirdek versiyonuna göre, hangi Linux sistem çağrısını yapacağını belirler.
Bazı durumlarda ise bundan daha fazlasını yaparak, üzerinde çalışılan çekirdek versiyonunda hiç desteklenmeyen bir özelliği de sunuyor olabilir. Örnek olarak, Linux 2.6 versiyonuyla birlikte gelen POSIX Timer API'nin olmadığı Linux 2.4 versiyonu üzerinde çalışan ve aynı anda pek çok timer kullanan bir uygulamanız var ise, glibc çekirdek tarafından alamadığı desteği kullanıcı kipinde her timer için bir thread açarak sağlar. Elbette timer sayınız fazla ise bu çok yavaş bir çözüm olur ancak uygulamanın çalışmasını da mümkün kılar.
Pek çok sistem çağrısı, aynı isimdeki glibc
wrapper fonksiyonları üzerinden çağrılmaktadır.
Not: Bu duruma
strace
çıktılarını okurken de dikkat etmemiz gereklidir.
Örnek olarak strace çıktısındaki open()
çağrısına bakalım:
Burada kastedilen glibc
içerisindeki open()
fonksiyonu değil, open
sistem çağrısıdır.
Strace üzerinden sistem çağrısına geçirilen argümanları ve geri dönüş değerini (3) görmekteyiz.
Genel olarak bu dahil pek çok dokümanda sistem çağrılarının çok yavaş olduğunu okuyabilirsiniz. Her sistem çağrısında kullanıcı kipinden kernel kipine geçiş ve context switch önemli bir yük getirir. Dolayısıyla bu süreç ne kadar verimli bir şekilde yönetilebilirse genel sistem performansı da aynı şekilde doğrudan etkilenecektir.
2002 yılında Linux Kernel eposta listelerine Mike Hayward'ın şaşkınlığını içeren bir eposta düştü. Hayward elindeki Pentium 3 - 850 Mhz dizüstü bilgisayarıyla Pentium 4 - 2 Ghz ve Xeon - 2.4 Ghz sistemlerinin, sistem çağrıları açısından performansını ölçmek için bir test uygulaması yazdı ve 1K'lık buffered dosya okuma testinde aşağıdaki şaşırtıcı sonuçları elde etti:
Sistem
Saniyedeki IO
Pentium 3 - 850 Mhz
149
Pentium 4 - 2 Ghz
108
Xeon - 2.4 Ghz
69
Aynı testi dosya okuma yerine farklı sistem çağrılarıyla da test ettiğinde benzer sonuçların alındığını tespi etti.
Bunun sebebi, bazı x86 serisi işlemcilerde çekirdek kipine daha hızlı geçiş için SYSENTER/SYSEXIT
özel instruction'ının bulunmasıydı. Pentium 3 serisinde varolan bu destek, Pentium 4 ve Xeon işlemcilere yeterince olgunlaşmadığından konulmamıştı. Pentium 3'teki bu imkanı iyi değerlendiren Linux çekirdeği, kendisinden daha üstün Xeon işlemcilerden bile daha iyi performans göstermekteydi.
Benzer zamanlarda AMD de benzer şekilde SYSCALL/SYSRET
özel instruction'ınını sunmaya başlamıştır.
Linux çekirdeği de bu yeni imkanları kullanarak geleneksel INT 0x80
kesme yöntemine göre önemli oranda performans iyileşmesi sağlanmıştır.
Günümüz x86 ve x86_64 işlemcilerinde bu mekanizma tümüyle desteklenmektedir.
Özellikle x86 tabanlı mimarilerde SYSENTER
özel yolu sayesinde sistem çağrılarının hızlanmasını sağladık. Ancak bu yeterli olacak mıdır?
Bir çok uygulamada, özellikle gettimeofday()
gibi sistem çağrılarının çok sık kullanıldığını görürürüz.
Uygulamalarınızı strace
ile incelediğinizde, bilginiz dahilinde olmayan pek çok farklı gettimeofday()
çağrısını yapıldığını görebilirsiniz.
glibc
kütüphanesinden kullandığınız bazı fonksiyonlar, internal olarak bu fonksiyonaliteyi kullanıyor olabilir.
Java Virtual Machine gibi bir VM üzerinde çalışan uygulamalar için de benzer bir durum söz konusudur.
Görece basit bir işlem olmasına rağmen sık kullanılan bu operasyon yüzünden sistemlerde nasıl bir sistem çağrısı yükü oluşmaktadır? sorusunu kendimize sorabiliriz.
ld.so
Paylaşımlı kütüphaneler kullanan uygulamaların çalıştırılması sırasında, Linux Loader tarafından gereken kütüphaneler yüklenerek uygulamanın çalışacağı ortam hazırlanır. En basit Hello World uygulamamız bile libc
kütüphanesine bağımlı olacaktır.
ldd
ile uygulamanın linklenmiş olduğu kütüphanelerin listesini alabiliriz:
Görüldüğü üzere libc
ve ld.so
bağımlılıkları listelendi. Fakat linux-vdso.so.1
kütüphanesi nedir?
find
komutu ile tüm sistemimizi arattığımızda neden bu kütüphaneyi bulamıyoruz?
linux-vdso.so.1
linux-vdso.so.1 sanal bir Dnamically Linked Shared Object dosyasıdır. Gerçekte böyle bir kütüphane dosya sistemi üzerinde yer almaz. Linux çekirdeği, çok sık kullanılan bazı sistem çağrılarını, bu şekilde bir hile kullanarak kullanıcı kipinde daha hızlı gerçekleştirmektedir.
Örnek olarak, sistem saati her değişiminde sonucu tüm çalışan uygulamaların adres haritalarına da eklenmiş olan özel bir bellek alanına koyarsa, gettimeofday()
işlemi gerçekte bir sistem çağrısına yol açmadan kullanıcı kipinde tamamlanabilir.
Şimdi bu konuları biraz daha detaylandıralım.
Not: Konunun bundan sonrası meraklıları için olup, çok gerekli olmayan bu bölümün yeni başlayan kullanıcılar için atlanması önerilir.
/proc/self/maps
Linux proc
dosya sisteminde /proc/<PID>/maps
dosyasında ilgili PID
(process id) için çekirdek tarafından yapılmış olan adres haritalaması gösterilir.
Özel bir durum olarak, <PID>
yerine self
ibaresi kullanıldığında, o an bu dosya erişimini yapan process ile ilgili dizinde işlem yapılmış olur.
vsyscall
/proc/self/maps
dosyasına cat
uygulaması ile bir kaç defa baktığınızda, vsyscall
(Virtual System Call Page) haricindeki bölümlerin başlangıç adreslerinin değiştiğini görmekteyiz.
vsyscall
bölümü, sadece bir uygulama için değil, sistemdeki tüm uygulamalar için aynı statik yeri göstermektedir.
Bu sayede dinamik linkleme (dolayısıyla paylaşımlı kütüphane) kullanmayan, tamamen statik uygulamaların da bu statik adres üzerinden vsyscall
bölümüne erişimi mümkün olmaktadır.
Bu bölgenin uzunluğu kısıtlı olduğundan, sadece belirli sayıda girdiye sahiptir: vgettimeofday(), vtime(), vgetcpu()
Tüm uygulamalar için aynı adrese haritalanması, özellikle return to libc türü ataklarıyla sistem çağrısı yapılabilmesine neden olmaktadır.
Linux 3.0 versiyonuna kadar vsyscall tablosu kullanılmış olmakla birlikte, 3.1 ve sonrasında bu yöntem artık önerilmiyor. vDSO mekanizması hem daha güvenli hem daha hızlı.
vdso
Bölümünü Dışarı Çıkartmakİnceleme amacıyla uygulamanın adres haritasındaki [vdso]
biçiminde işaretlenmiş alanı diske çıkartmaya çalışalım.
Örneğimizde bu bölümün 7fff8cdfc000
ile 7fff8cdfe000
adresleri arasında, 2 adet Page
büyüklüğünde olduğunu görüyoruz.
Acaba dd
komutu ile bu bölümü dışarı çıkartabilir miyiz:
Maalesef bu yöntem artık çalışmıyor. Bunun 2 nedeni var:
/proc
sanal dosya sistemi altındaki girdiler normal bir dosya gibi görünmesine karşılık, stat()
ile bakıldığında st_size
değeri 0 olmaktadır. Bu durum dd
uygulamasının ilgili offset adresine seek yapılamayacağını söylemesine neden oluyor. Çözüm için ufak bir yama gerekiyor
Yeni Linux çekirdek versiyonlarında buradaki başlangıç değer adresi, 7fff8cdfc000
her uygulama için aynı değildir. Return to libc tarzı atakları zorlaştırmak için bu değer ancak çalışan uygulama içerisinden öğrenilebilir. Bunun için dd
kodunun değiştirilmesi veya ufak bir test uygulaması yazılması gerekiyor.
extract_region.c
Bu işlemi yapabilmek için extract_region
adını verdiğimiz aşağıdaki gibi bir uygulama hazırlayalım:
İşlem bitiminde 8192 byte uzunluğunda vdso.out dosyası oluşacaktır.
file
komutu ile dosyanın tipine baktığımızda standart bir kütüphane gibi görünecektir:
vdso
İçine Bakalımobjdump
ile dışarı çıkarttığımız bu bölüme bir bakalım:
100.000 defa gettimeofday()
fonksiyonunu çağıran ve işlem bitiminde başlangıç ve bitiş zamanlarını gösteren örnek bir uygulama yapalım:
X86_64
ve ARM
Üzerinde Test100.000 defa gettimeofday
çağrısı yapan time_test
örnek uygulamasını, 1 Ghz hızına düşürülmüş X86_64 i5 işlemcili platform ile 1 Ghz saat frekansındaki ARM BeagleBoneBlack platformunda karşılaştıralım
Görüldüğü üzere X86_64'te 3-4 milisaniyede gerçekleşen işlem, ARM sistemimizde 70 milisaniyelerde gerçekleşmektedir.
Şimdi test uygulamamımızı bir de strace
kontrolünde her iki platformda çalıştıralım:
X86_64
platformunda strace
ile yaptığımız incelemede, herhangi bir gettimeofday()
sistem çağrısı göremedik
Beklediğimiz şekilde linux-vdso.so
mekanizması sayesinde, işlem tamamen kullanıcı kipinde gerçekleştirildi, herhangi bir sistem çağrısı yapılmadı
Sadece printf()
fonksiyonu nedeniyle write()
sistem çağrısı kullanılarak son çıktı konsola gönderildi
ARM
mimarisindeki örneğimize baktığımızda, benzeri bir mekanizma olmadığı için, her defasında karşılık gelen bir gettimeofday()
sistem çağrısı yapıldığını görüyoruz (örnek ekran çıktımızda ... olarak belirttiğimiz bölümde 100000 adet benzer çağrı bulunmaktadır)
Kendimize şu soruyu soralım: uygulamamız bir sistem çağrısı yaparak çekirdek kipinde kod işletiliyor durumunda iken sinyal (software interrupt) gelirse ne olur?
Bu durumda sistem çağrısı sona erecek ve EINTERRUPTED hatası dönecektir.
Sistem çağrılarını glibc
üzerinden kullandığımız için, glibc tarafında sistem çağrısından EINTR
hatası geldiğinde, uygulamaya geri dönüş değeri olarak -1
dönülür fakat errno
global değişkeni EINTR
şeklinde ayarlanır.
Bu aslında hata olmayan istisnai durum, zaman zaman pek çok uygulama kodunda gözardı edilmektedir.
Bazı kullanım senaryolarında yukarıdaki senaryo istisnai olmaktan çıkıp, ilgili yazılımın doğası gereği sürekli veya sıklıkla da (read, write, open, connect vb.) oluşabilir.
Uygulama perspektifinden baktığımızda tüm sistem çağrılarını sarmalayan fonksiyonlar için aşağıdaki kural geçerlidir:
Eğer bir sistem çağrısının geri dönüş değeri 0
'dan küçükse ve errno
değişkeni EINTR
sabitine eşitse, herhangi bir hata söz konusu değildir.
Bahsedilen senaryo oluştuğunda ilgili fonksiyonun (yani sistem çağrısının) yeniden çağrılması gerekir.
Bu süreç, ilgili sinyallerin oluşturulmasında SA_RESTART
bayrağının işaretlenmesi suretiyle otomatik hale getirilebilir. Peki neden öntanımlı olarak bu şekilde değil?
Esasen bir zamanlar öyleydi. Ancak sistem çağrısının otomatik olarak yeniden başlatılmasını istemeyeceğimiz durumlar da olabilir. Bu yüzden öntanımlı olarak bir aksiyon alınmıyor.