Sistem Çağrıları
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ısı Nasıl Gerçekleşir?
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.
Mimari Bağımlılığı
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ısı Nasıl Yapılır?
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.
Sistem Çağrıları → Glibc Fonksiyonları İlişkisi
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:
open("/tmp/index.jpeg", O_RDONLY) = 3
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.
Şaşırtıcı Bir Durum: Performans
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.
Performans Problemi - Detaylı Bakış
Ö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.
Linux Dynamic Linker/Loader: 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:
$ ldd hello
linux-vdso.so.1 (0x00007fff7d88a000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2fb43a0000)
/lib64/ld-linux-x86-64.so.2 (0x00007f2fb476d000)
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?
Virtual DSO: 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.
$ cat /proc/self/maps
00400000-0040c000 r-xp 00000000 08:02 1703938 /bin/cat
0060b000-0060c000 r--p 0000b000 08:02 1703938 /bin/cat
0060c000-0060d000 rw-p 0000c000 08:02 1703938 /bin/cat
024de000-024ff000 rw-p 00000000 00:00 0 [heap]
7ff7033c5000-7ff70369f000 r--p 00000000 08:02 2362900 /usr/lib/locale/locale-archive
7ff70369f000-7ff70383e000 r-xp 00000000 08:02 393230 /lib/x86_64-linux-gnu/libc-2.19.so
7ff70383e000-7ff703a3d000 ---p 0019f000 08:02 393230 /lib/x86_64-linux-gnu/libc-2.19.so
7ff703c69000-7ff703c6a000 rw-p 00000000 00:00 0
7fff8cd95000-7fff8cdb6000 rw-p 00000000 00:00 0 [stack]
7fff8cdfc000-7fff8cdfe000 r-xp 00000000 00:00 0 [vdso]
7fff8cdfe000-7fff8ce00000 r--p 00000000 00:00 0 [vvar]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
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:
$ dd if=/proc/self/mem of=dso.out bs=1 skip=$((0x7fff8cdfc000)) count=8192
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ığındast_size
değeri 0 olmaktadır. Bu durumdd
uygulamasının ilgili offset adresine seek yapılamayacağını söylemesine neden oluyor. Çözüm için ufak bir yama gerekiyorYeni 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çindd
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:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#define _FILE_OFFSET_BITS 64
int main (int argc, char *argv[])
{
if (argc != 3) {
fprintf(stderr,
"Kullanım: %s <cikti> <section>\n"
"\t<cikti>\t\t: export edilecek dosya\n"
"\t<section>\t: export edilecek map region\n\n", argv[0]);
return 1;
}
off_t start_addr, end_addr;
char buf[4096];
const char *out = argv[1];
const char *region = argv[2];
int found = 0;
FILE *fp = fopen("/proc/self/maps", "r");
while (fgets(buf, sizeof(buf), fp)) {
printf("%s", buf);
if (strstr(buf, region)) {
found = 1;
break;
}
}
fclose(fp);
if (!found) {
fprintf(stderr, "%s bölümü bulunamadı\n", region);
return 1;
}
end_addr = strtoull((strchr(buf, '-') + 1), NULL, 16);
*(strchr(buf, '-')) = '\0';
start_addr = strtoull(buf, NULL, 16);
printf("\nÇıkartılacak Alan Başlangıç: 0x%llx, Bitiş: 0x%llx\n\n", start_addr, end_addr);
FILE *dst = fopen(out, "w+");
if (dst == NULL) {
fprintf(stderr, "%s açılamadı\n", out);
return 1;
}
FILE *src = fopen("/proc/self/mem", "r");
char *tmp = malloc(end_addr - start_addr);
fseeko(src, start_addr, SEEK_SET);
fread(tmp, end_addr - start_addr, 1, src);
fwrite(tmp, end_addr - start_addr, 1, dst);
fclose(src);
fclose(dst);
return 0;
}
Test Uygulamamızı Çalıştıralım
$ ./extract_region vdso.out vdso
...
7ff6db951000-7ff6dbaf0000 r-xp 00000000 08:02 393230 /lib/x86_64-linux-gnu/libc-2.19.so
7ff6dbf19000-7ff6dbf1a000 r--p 00020000 08:02 393236 /lib/x86_64-linux-gnu/ld-2.19.so
7ff6dbf1a000-7ff6dbf1b000 rw-p 00021000 08:02 393236 /lib/x86_64-linux-gnu/ld-2.19.so
7ff6dbf1b000-7ff6dbf1c000 rw-p 00000000 00:00 0
7fffc477d000-7fffc479e000 rw-p 00000000 00:00 0 [stack]
7fffc47fc000-7fffc47fe000 r-xp 00000000 00:00 0 [vdso]
Çıkartılacak Alan Başlangıç: 0x7fffc47fc000, Bitiş: 0x7fffc47fe000
İş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:
$ file vdso.out
vdso.out: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID
[sha1]=538bea2738a229413dcc98af8f4f7127f9bca874, stripped
vdso
İçine Bakalım
objdump
ile dışarı çıkarttığımız bu bölüme bir bakalım:
$ objdump -T vdso.out
vdso.out: file format elf64-x86-64
DYNAMIC SYMBOL TABLE:
0000000000000418 l d .rodata 0000000000000000 .rodata
0000000000000970 w DF .text 000000000000057d LINUX_2.6 clock_gettime
0000000000000000 g DO *ABS* 0000000000000000 LINUX_2.6 LINUX_2.6
0000000000000ef0 g DF .text 00000000000002b9 LINUX_2.6 __vdso_gettimeofday
00000000000011d0 g DF .text 000000000000003d LINUX_2.6 __vdso_getcpu
0000000000000ef0 w DF .text 00000000000002b9 LINUX_2.6 gettimeofday
00000000000011b0 w DF .text 0000000000000015 LINUX_2.6 time
00000000000011d0 w DF .text 000000000000003d LINUX_2.6 getcpu
0000000000000970 g DF .text 000000000000057d LINUX_2.6 __vdso_clock_gettime
00000000000011b0 g DF .text 0000000000000015 LINUX_2.6 __vdso_time
Basit Bir Test Uygulaması
100.000 defa gettimeofday()
fonksiyonunu çağıran ve işlem bitiminde başlangıç ve bitiş zamanlarını gösteren örnek bir uygulama yapalım:
#include <stdio.h>
#include <sys/time.h>
int main ()
{
int i;
struct timeval now;
struct timeval before;
struct timeval after;
gettimeofday(&before, NULL);
for (i = 0; i < 100000; i++) gettimeofday(&now, NULL);
gettimeofday(&after, NULL);
printf("Before: %li.%li\n", before.tv_sec, before.tv_usec);
printf("After : %li.%li\n", after.tv_sec, after.tv_usec);
return 0;
}
X86_64
ve ARM
Üzerinde Test
100.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
(X86_64) $ ./time_test
Before: 1419786575.719463
After : 1419786575.722560
(ARM) $ ./time_test
Before: 1419786909.186960
After : 1419786909.252160
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) $ strace ./time_test
...
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7ff4ee1f1000
write(1, "Before: 1419787456.897162\n", 26Before: 1419787456.897162) = 26
write(1, "After : 1419787456.904669\n", 26After : 1419787456.904669) = 26
exit_group(0)
##########################
(ARM) $ strace ./time_test
...
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x400b3000
gettimeofday({1419787571, 234967}, NULL) = 0
gettimeofday({1419787571, 235064}, NULL) = 0
gettimeofday({1419787571, 235142}, NULL) = 0
gettimeofday({1419787571, 235257}, NULL) = 0
gettimeofday({1419787571, 235394}, NULL) = 0
...
...
write(1, "Before: 1419787571.234967\n", 26) = 26
write(1, "After : 1419787571.976285\n", 26) = 26
exit_group(0) = ?
X86_64
platformundastrace
ile yaptığımız incelemede, herhangi birgettimeofday()
sistem çağrısı göremedikBeklediğ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 nedeniylewrite()
sistem çağrısı kullanılarak son çıktı konsola gönderildiARM
mimarisindeki örneğimize baktığımızda, benzeri bir mekanizma olmadığı için, her defasında karşılık gelen birgettimeofday()
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)
Sistem Çağrılarının Kesintiye Uğraması
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 veerrno
değişkeniEINTR
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.