Bölüm 14:Shell'den Kernel'e
CPU'ya “Sen”i Katmak kitabının parçası: bilgisayarının programları nasıl çalıştırdığına doğru inen uzun bir teknik tavşan deliği.
Tüm bölümler
- Giriş
- Sayılar: Bit'ten Kayan Noktaya
- Metin, Bellek ve Kod
- Temeller
- Kernel, User Mode ve Syscall
- Mimariler: x86, ARM ve Diğerleri
- Saat, Çekirdek ve Mantıksal İşlemci
- Bellek Hiyerarşisi
- Cache Nasıl Çalışır
- Zamanı Dilimle
- Aynı Anda İki Şey
- İşlemciyi Hızlandıran Hileler
- Tahmin, Spekülasyon ve Spectre
- Bir Program Nasıl Çalıştırılır?
- Shell'den Kernel'e
- Bir ELF Ustasına Dönüşmek
- Bellek Aslında Sanal
- Adres Çevirisi ve TLB
- Bellek Güvenliği ve Sertleştirme
- Fork'lar ve COW'lar Hakkında Konuşalım
- Dosya Sistemi ve I/O
- Sanal Makineler ve Container'lar
- Son Söz
Bir önceki bölümde execve syscall’ının mekaniklerini gördük. Peki terminale ./program yazıp Enter’a bastığında bu çağrıyı senin adına kim yapıyor? ls, cat, echo gibi komutlar neden doğrudan çalışıyor da ./benim-programim yazarken başına ./ koymamız gerekiyor? Bu bölümde, kullanıcı ile kernel arasındaki ilk elçi olan shell dünyasına dalacağız.
Bu bölümde neyi çözüyoruz?
- Shell’in bir komutu nasıl çözümlediğini göreceğiz.
- PATH, built-in komutlar ve harici programlar arasındaki farkı ayıracağız.
- Pipeline (
|), yönlendirme (>,<) ve fork+execve+wait üçlüsünü bağlayacağız.- Environment variable’ların programlara nasıl aktarıldığını netleştireceğiz.
Ctrl-C’nin aslında kime gittiğini ve iş kontrolünün nasıl çalıştığını çözeceğiz.
Shell Nedir?
Terminali açtığında karşına çıkan o yanıp sönen imleç bir programa ait: shell. İşi tek cümleyle şu — yazdığın satırı okur, ne demek istediğini çözer ve kernel’dan doğru şeyi ister.
Bugün en çok karşılaşacakların Bash (Bourne Again Shell), Zsh ve Fish; hepsinin atası olan sade sürümün adı ise sh. Aralarındaki fark ne yaptıkları değil, aynı işi ne kadar konforlu yaptıkları.
Şu anda hangisinin içinde olduğunu merak ediyorsan iki komut yeter:
$ echo $SHELL # giriş shell'in olarak kayıtlı olan
$ ps -p $$ # şu anda gerçekten çalışanİkisi her zaman aynı çıkmaz ve bu şaşırtıcı değil: $SHELL sistemin senin için kayıtlı tuttuğu tercihtir, ps -p $$ ise o anda çalışan process’i gösterir. Bir Bash içinden zsh yazdığında ikincisi değişir, birincisi değişmez.
Script yazarken en sık yapılan hatalardan biri buraya bağlanıyor: dosyanın başına
#!/bin/shyazıp içeride Bash’e özgü bir özellik kullanmak. Pek çok dağıtımda/bin/shBash değildir (Debian ve Ubuntu’dadash’tir) ve script, senin makinende çalışıp sunucuda patlar. Bash’e özgü bir şey kullanacaksan shebang’i de#!/bin/bashyap.
Shell’in kendisi de sıradan bir kullanıcı programıdır. Kernel’ın gözünde bash ile hesap makinesi uygulaması arasında hiçbir fark yoktur; ayrıcalığı yoktur, sihri yoktur.
Komut Çözümleme: PATH ve Built-in’ler
Bir komut girdiğinde shell, onu çalıştırmadan önce “bu isim kime ait?” sorusunu sabit bir sırayla cevaplar. Sıra önemli, çünkü öndeki her basamak arkasındakini gölgeler:
- Alias —
alias ls='ls --color'yazdıysan burada yakalanır. - Shell fonksiyonu — kendi tanımladığın fonksiyonlar.
- Built-in —
cd,exportgibi shell’in içinde olanlar. - Hash tablosu — daha önce bulunmuş bir programın hatırlanan yolu.
- PATH araması — hiçbiri tutmazsa diskte aranır.
Bunu tahmin etmene gerek yok; type sana doğrudan söyler:
$ type cd
cd is a shell builtin
$ type ls
ls is aliased to `ls --color=auto'
$ type -a python3
python3 is /usr/local/bin/python3
python3 is /usr/bin/python3Bir komutun neden beklediğinden farklı davrandığını anlamanın en hızlı yolu
type -açalıştırmaktır. Son örnekteki gibi iki aday çıkıyorsa kazanan her zaman listedeki ilkidir.
Built-in Komutlar
cd, exit, export, alias gibi komutlar shell’in kendisinin içindedir. Bunlar için ayrı bir program çalıştırılmaz; shell doğrudan kendi kodunu çalıştırır.
Neden
cdbuilt-in’dir? Çünkücdmevcut process’in çalışma dizinini değiştirir. Eğercdayrı bir program olsaydı, o program kendi process’inde çalışır ve senin shell’inin dizini hiç değişmezdi. İşte bu yüzdencdshell’in içindedir.
Harici Programlar
ls, grep, node, python gibi komutlar diskteki programlardır. Shell bunları çalıştırmak için önce nerede olduklarını bulmalıdır. Bunun için PATH ortam değişkenini kullanır.
$ echo $PATH
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbinPATH, dizinlerin iki nokta üst üste (:) ile ayrıldığı bir listedir. Shell, komutunu bu dizinlerde sırayla arar ve ilk bulduğunda durur. Yani aynı adı taşıyan iki program varsa kazanan, daha iyi olan değil, PATH’te önce gelen.
Bulamazsa ne olur? Shell command not found mesajını kendisi basar ve 127 çıkış kodu döner. Biraz sonra göreceğimiz kod örneğindeki _exit(127) tam olarak bu kuralı uyguluyor.
Bir de az bilinen bir davranış var: Bash bulduğu yolları bir hash tablosunda saklar, her seferinde baştan aramaz. Bu yüzden bir programı başka bir dizine taşıdığında Bash ısrarla eski yolu denemeye devam edebilir; hash -r komutu bu hafızayı temizler.
./neden gerekli? Yaygın açıklama “şu anki dizinde ara” demektir ama bu tam olarak doğru değil ve yanlış bir model kuruyor. Gerçek kural şu: içinde/geçen her şeyi shell PATH’te aramaz, doğrudan bir yol olarak okur../programda bunu yapar — “arama yapma, şu anki dizindeki şu dosyayı çalıştır” der. PATH’e mevcut dizin eklenmiş falan değildir; nitekim hemen aşağıda göreceğin gibi, eklemek de ciddi bir güvenlik hatasıdır.
Bir yana: PATH Hijacking ve Güvenlik
Eğer
PATH’inin içinde.(mevcut dizin) varsa, oraya dosya bırakabilen herkes senin komutunu gölgeleyebilir./tmp’ye girdiğini ve birinin oraya kendils’ini koyduğunu düşün: senlsyazdığında/usr/bin/lsdeğil, onunki çalışır — üstelik hiçbir uyarı almazsın. Bu yüzden modern Linux dağıtımları.’yi varsayılanPATH’ten çıkarmıştır. Güvenlik için./programkullanımı,PATH’te olmayan dizinlerdeki programları açıkça belirtmenin en temiz yoludur.
Fork + Execve + Wait: Shell’in Motoru
Shell bir harici program çalıştıracağı zaman şu üç adımı uygular:
fork(): Shell kendini klonlar. Artık iki shell process’i var.- Child’ta
execve(): Child process, shell kodunu yeni programın koduyla değiştirir. - Parent’ta
wait(): Parent (orijinal shell), child’ın bitmesini bekler.
Bu üç adımı tek bir bakışta görmek için aşağıdaki diyagrama bakabilirsin. Shell parent process olarak fork() ile bir child üretir; child, exec() ile kendi bellek alanını yeni programın koduyla değiştirir. Parent ise wait() ile child’ın sonlanmasını bekler. Bu model, Unix dünyasında yeni bir program başlatmanın temel kalıbıdır.

Kaynak: Silberschatz, Galvin, Gagne — Operating System Concepts, Ch. 3 — Processes, Slide 21.
Aynı akışı adım adım ilerleterek de izleyebilirsin; üçüncü adım özellikle önemli, çünkü yönlendirme ve pipe kurulumunun tamamı orada yaşanır:
*.txt gibi kalıplar dosya adlarına açılır ve çalıştırılacak programın yolu PATH üzerinden bulunur.- Shell komutu çözer. Satır kelimelere ayrılır,
*.txtgibi kalıplar dosya adlarına açılır ve çalıştırılacak programın yoluPATHüzerinden bulunur. fork: kopya çıkarılır. Kernel shell'in neredeyse birebir kopyasını üretir. İki process de aynı satırdan devam eder; tek ayırt edici işaretfork'un döndürdüğü değerdir.- Child kendi dünyasını kurar. Asıl iş bu küçük pencerede yapılır:
dup2ile file descriptor'lar yeniden bağlanır, yönlendirme ve pipe burada kurulur. Shell bunu child'ın içinde yapar, çünkü kendi fd'lerini bozmak istemez. execve: kimlik değişir. Child'ın bellek düzeni tamamen sökülür ve yerine yeni programınki kurulur. Process numarası ve açık fd'ler kalır; geri kalan her şey gider.waitpid: parent bekler. Shell child bitene kadar bekler ve çıkış kodunu toplar. Toplamazsa child ölü ama kayıtlı kalır — buna zombie denir.
pid_t pid = fork(); // buradan sonrası iki process'te birden akar
if (pid == 0) { // child: kendini yeni programa dönüştür
execvp(argv[0], argv); // başarılıysa aşağısı hiç çalışmaz
perror("execvp"); // buraya düştüysek program bulunamadı
_exit(127);
}
int status;
waitpid(pid, &status, 0); // parent: child bitene kadar prompt'u vermeDikkat çeken bir ayrıntı: execvp başarılı olursa geri dönmez. Dönüş değeri kontrol edilmez, çünkü dönmüş olması zaten başarısızlık demektir. Bu, C’de görmeye alışık olmadığın bir çağrı sözleşmesidir ve doğrudan 13. bölümde gördüğümüz “exec bir doğum değil, başkalaşımdır” fikrinden gelir.
Child Ne Diyerek Öldü?
Yukarıdaki kodda waitpid’in doldurduğu status değişkenini fark ettin mi? İçinde küçük ama günlük hayatta sürekli karşına çıkan bir bilgi var: çıkış kodu.
Sözleşme basit ve evrensel: 0 başarı, sıfırdan farklı her şey hata. Terminalde son komutun ne döndürdüğünü echo $? ile görebilirsin. Birkaç değerin özel anlamı da vardır:
| Kod | Anlamı |
|---|---|
0 | Başarı |
1, 2, … | Programın kendi tanımladığı hatalar |
126 | Dosya bulundu ama çalıştırma izni yok |
127 | Komut bulunamadı (yukarıdaki _exit(127)) |
130 | Ctrl-C ile sonlandırıldı |
Son satır ilk bakışta tuhaf görünüyor. Sebebi şu: bir process sinyalle öldürüldüğünde çıkış kodu 128 + sinyal numarası olarak raporlanır. Ctrl-C, SIGINT gönderir, SIGINT’in numarası 2’dir, 128 + 2 = 130. Bölümün ilerleyen kısmında sinyalleri konuşurken bu sayıya geri döneceğiz.
Geri Kalan Her Şey Konfor
Bu on satır, bash yazdığında olan biten her şeyin özü. Etrafındaki her şey — komut satırını kelimelere ayırmak, *.txt gibi kalıpları genişletmek, alias çözmek, geçmişi tutmak — kolaylık katmanıdır. Çıkarırsan shell kullanışsız olur ama çalışmaya devam eder. fork/exec/wait üçlüsünü çıkarırsan geriye shell diye bir şey kalmaz.
Yine de o “kolaylık katmanı”nın bir parçası var ki, bilmezsen günün birinde başını gerçekten ağrıtır.
* Karakterini Kim Çözüyor?
ls *.txt yazdığında ls programının *.txt diye bir şey gördüğünü sanmak çok yaygın bir yanılgı. Görmez. O kalıbı shell çözer ve ls çalışmaya başlamadan önce iş biter. Kendi gözünle gör:
$ echo *.txt
notlar.txt rapor.txt taslak.txtecho sadece kendisine verilen argümanları yazdırır. Ekranda üç dosya adı görüyorsan, echo’nun argv’sine giren şey *.txt değil, o üç isimdir. Buna glob genişletme denir.
Üç sonucu var ve üçü de pratikte karşına çıkar:
- Tırnak genişletmeyi durdurur.
rm *.txtüç dosyayı siler;rm "*.txt"ise gerçekten*.txtadında bir dosya arar ve muhtemelen bulamaz. - Eşleşme yoksa kalıp olduğu gibi geçer. Hiç
.txtdosyası yoksa Bash varsayılan ayarında*.txt’yi harfi harfine argüman olarak verir; programın aldığı şey anlamsız bir dosya adı olur. - Boşluklu dosya adları tırnaksız değişkenlerde parçalanır.
rm $dosyayazarsan ve$dosyaiçindetatil fotoğrafı.jpgvarsa shell bunu iki ayrı argümana böler. Bu yüzden script’lerde değişkenler her zaman"$dosya"diye tırnak içinde yazılır.
Program
*diye bir şey görmez. Gördüğü an, shell işini yapmamış demektir.
Pipeline: | Karakteri
Pipeline, bir komutun çıktısını diğerinin girdisi yapar:
$ cat dosya.txt | grep "arama" | wc -lArka planda olanlar:
- Shell bir pipe (boru) oluşturur: iki ucu olan tek yönlü bir veri kanalı. Terimleri baştan ayıralım — pipe tek bir kanal, pipeline ise o kanallarla birbirine dizilmiş komut zinciri.
- Shell
catiçin bir child üretir ve child’ın stdout’unu pipe’ın yazma ucuna bağlar.catbunu hiç fark etmez; her zamanki gibi stdout’a yazar, ama yazdığı yer artık ekran değil, pipe’tır. - Shell
grepiçin ikinci bir child üretir; onun stdin’ini pipe’ın okuma ucundan besler, stdout’unu ise yeni bir pipe’a bağlar. wc -liçin üçüncü bir child üretir ve girdisini ikinci pipe’dan alır.- Shell bu üç child’ın da bitmesini bekler.
Pipe’ın içinde ne var? Pipe, kernel tarafında tutulan bir bellek tamponudur; veri diske hiç uğramaz. Ama bu tampon sonsuz değil — Linux’ta varsayılan 16 sayfa, yani 4 KiB’lık sayfa kullanan bir sistemde 64 KiB. Sabit bir sayı değildir:
fcntlileF_SETPIPE_SZçağırarak büyütebilir, sistem sınırlarına takılırsan daha küçüğüne razı olabilirsin.Bunun iki gözlemlenebilir sonucu var. Birincisi: tampon dolduğunda yazan taraf uyutulur, okuyan boşaltana kadar bekler. Bir pipeline’ın hızını en yavaş halkası belirler, çünkü öndekiler ona doğru geri basınç uygular. İkincisi: okuyan taraf kanalı kapatırsa yazan tarafa
SIGPIPEsinyali gider ve o program varsayılan olarak ölür.yes | head -1komutunun sonsuz döngüye girmeyip anında bitmesinin sebebi tam olarak budur —headbir satır alıp çıkar,yesde bir sonraki yazmasındaSIGPIPEyiyip ölür.
Yönlendirme: >, <, >>
Yönlendirme, bir programın nereden okuyup nereye yazdığını — program bundan hiç haberdar olmadan — değiştirmenin yoludur. ls’in “dosyaya yazan” bir sürümü yoktur; ls’i buna ikna eden shell’dir.
Önce üç sayıyı yerine oturtalım, çünkü geri kalan her şey bunların üstüne kuruluyor. Her process üç file descriptor ile doğar:
| fd | Adı | Varsayılan olarak nereye bağlı |
|---|---|---|
0 | stdin | klavye |
1 | stdout | ekran |
2 | stderr | ekran |
Şimdi işaretler anlam kazanıyor:
>: stdout’u bir dosyaya yazar (üzerine yazarak). Aslında1>’ın kısaltmasıdır.>>: stdout’u bir dosyanın sonuna ekler.<: stdin’i bir dosyadan okur.2>: stderr’i bir dosyaya yazar.2>&1: “2 numaralı fd’yi, 1’in şu anda gösterdiği yere bağla.”
Son maddedeki “şu anda” vurgusu boşuna değil ve klasik bir tuzağın kaynağı:
$ komut > log.txt 2>&1 # ikisi de log.txt'ye gider
$ komut 2>&1 > log.txt # stderr EKRANA gider, sadece stdout dosyayaİkinci satırda 2>&1 çalıştığı anda 1 hâlâ ekranı gösteriyordu; stderr ekrana bağlandı. Sonraki > log.txt yalnızca 1’i değiştirdi ve 2’yi olduğu yerde bıraktı. Yönlendirmeler soldan sağa işlenir; sıra gerçekten önemli. (İkisini birden istiyorsan &> log.txt kısayolu da var.)
$ ls -la > dosyalar.txt # Çıktıyı dosyaya yaz
$ cat < dosyalar.txt # Dosyayı girdi olarak oku
$ ./program 2> hatalar.log # Hataları ayrı dosyaya yazDördü de aynı tek numaraya dayanıyor: programı doğurduktan hemen sonra, çalıştırmadan hemen önce onun file descriptor’larını değiştirmek.
Sıraya dikkat: shell önce
forkeder, yönlendirmeyi child’ın içindedup2ile yapar ve ancak ondan sonraexecveçağırır. Eğer bu iş fork’tan önce yapılsaydı parent shell’in kendi çıktısı da dosyaya giderdi ve terminalinde bir daha hiçbir şey göremezdin. Dosyaya yazan, o an doğmuş olan child’dır.
Bu numaranın çalışmasını sağlayan sessiz bir kural var: file descriptor’lar execve’den sağ çıkar. Child’ın kimliği değişir, belleği baştan kurulur, ama açık fd tablosu olduğu gibi yeni programa geçer; ls bu yüzden 1 numaralı fd’sinin bir dosyaya bağlı olduğunu bilmeden ona yazar. Aynı kuralın bir de arka yüzü var: parent’ın açık tuttuğu her fd — yapılandırma dosyaları, veritabanı bağlantıları, dinleyen soketler — yeni programa da miras kalır. Yeni program bunları hiç istemedi, ama artık elinde. Sızan bir descriptor klasik bir ayrıcalık yükseltme yoludur; bu yüzden bir dosyayı açarken O_CLOEXEC bayrağını vermek, kernel’a “bu fd execve anında kendiliğinden kapansın” demektir ve modern kütüphaneler açtıkları dosyaların neredeyse hepsinde bunu kullanır. Shell’in yönlendirme için kullandığı fd’ler ise bilerek açık bırakılır; sağ çıkması istenen tek şey zaten onlardır.
Environment Variables (Ortam Değişkenleri)
Her process doğarken yanında küçük bir not defteri taşır: anahtar-değer çiftlerinden oluşan environment. Shell yeni bir program başlatırken kendi defterinin bir kopyasını child’a verir. PATH’in çalıştırdığın her komuta kadar ulaşmasının sebebi tam olarak bu — kopyalana kopyalana iniyor. Ve dikkat et, verilen şey bir kopya: child kendi defterini değiştirse parent’ınki etkilenmez.
$ export MY_VAR="merhaba"
$ echo $MY_VAR
merhaba
$ ./program # MY_VAR, programın envp'sine gider
exportile tanımlanan değişkenler, shell’in fork ettiği tüm child process’lere aktarılır.exportkullanmadan tanımlanan değişkenler sadece shell’in kendisi için geçerlidir.
Önemli environment variable’ları:
| Değişken | Anlamı |
|---|---|
PATH | Komut arama dizinleri |
HOME | Kullanıcının ana dizini |
USER | Kullanıcı adı |
SHELL | Varsayılan shell |
LANG | Dil ve yerel ayarlar |
PWD | Şu anki çalışma dizini |
Subshell ve Parantezler
Parantez, shell’e “bunu sen değil, senin bir kopyan yapsın” demenin yolu.
Parantezi gördüğü an shell yine fork eder — ama bu kez child’ta execve çağırmaz. Child, shell’in kendisi olarak kalır ve içerideki komutları çalıştırır; işi bitince ölür, üzerinde ne değiştirdiyse onunla birlikte gider. Buna subshell denir. Yani bölümün başındaki üçlünün eksik hâli: fork var, exec yok.
$ (cd /tmp && ls) # Subshell'de çalışır; ana shell'in dizini değişmez
$ cd /tmp && ls # Ana shell'in dizini değişir(Aradaki && şunu söylüyor: soldaki komut 0 döndürürse sağdakini de çalıştır. Az önce konuştuğumuz çıkış kodu sözleşmesinin doğrudan kullanımı.)
Subshell’i tanıdıktan sonra, ilk bakışta anlamsız görünen birkaç davranış yerine oturuyor.
Komut ikamesi de bir subshell’dir. $(...) yazdığında shell içerideki komutu bir subshell’de çalıştırır ve çıktısını yakalayıp yerine koyar.
Pipeline’ın her halkası subshell’de çalışır. Bu, Bash’in en meşhur tuzağının kaynağı:
$ echo merhaba | read x
$ echo $x
# boş!read gerçekten çalıştı ve x’e “merhaba” yazdı — ama bunu kendi subshell’inin içinde yaptı. O subshell öldüğünde değişken de onunla gitti. Ana shell’in x’i hiç dokunulmamış olarak kaldı.
(...) fork eder, { ...; } etmez. Süslü parantez komutları gruplar ama aynı process’te çalıştırır; bu yüzden içeride yapılan cd dışarıyı da etkiler.
Peki Kernel Tarafında Ne Oluyor?
Önce küçük bir sürpriz: bugünkü glibc’de fork() çağırdığında kernel’a giden syscall fork değil, clone ailesinden biridir. fork, clone adlı çok daha genel bir kapının belirli bir bayrak kümesiyle çağrılmış hâlidir; kernel’daki fork syscall’ı hâlâ durur ama içi aynı ortak fonksiyona iner. Aynı kapı, farklı bayraklarla çağrıldığında yeni bir process yerine yeni bir thread üretir — ikisi arasındaki fark kernel açısından yalnızca “neyi paylaşıyorlar” sorusunun cevabıdır.
Bu kapıdan girildiğinde varılan yerin işi tek cümleyle özetlenebilir: çalışan process’i tarif eden her yapıyı kopyala, ama içindeki veriyi kopyalama.
Kaynağa bakmak isteyenler için: bu iş
kernel/fork.ciçindekicopy_processfonksiyonunda yapılıyor.
Kopyalanan şeyler process’in kimliğidir: kernel’ın o process için tuttuğu kayıt yapısı (task_struct), kimlik bilgileri, açık dosya tablosu, sinyal ayarları. Kopyalanmayan şey ise belleğin kendisi.
Burada bir kavramı önden vermem gerekiyor: kernel belleği tek parça hâlinde değil, sabit boyutlu bloklar hâlinde yönetir ve bu bloklara sayfa denir. Nasıl çalıştığını 16. bölümde göreceğiz; şimdilik şunu tut. Fork sonrası iki process aynı sayfalara bakar ve kernel o sayfalara “yazmaya kalkan olursa bana haber ver” işareti koyar. Yani fork pahalı görünen ama pratikte ucuz olan bir çağrıdır — 19. bölümde Copy-on-Write’ı ele alırken bu ucuzluğun tam olarak nereden geldiğini göreceğiz.
execve tarafında ise kontrol fs/exec.c içindeki ortak yardımcıya geçer: dosya açılır, argümanlar ve ortam değişkenleri kernel tarafına kopyalanır, sonra uygun format yükleyicisi aranır. 13. bölümde bu yolun tamamını adım adım izlemiştik.
Buradan çıkarılacak asıl ders şu: shell’in yaptığı şey özel değil. Kernel’da “shell” diye bir kavram yoktur. bash de, bir masaüstü uygulaması da, systemd de yeni program başlatırken tam olarak aynı iki syscall’ı aynı sırayla çağırır.
pipe() ve dup2() ile Pipeline
Shell, | karakteri gördüğünde arka planda pipe() syscall’ını çağırır. pipe() iki uçlu bir kanal oluşturur: pipefd[0] okuma, pipefd[1] yazma ucudur. Sonra sol komut için child fork edilir, dup2 ile stdout pipe’a yönlendirilir; sağ komut için başka bir child fork edilir, dup2 ile stdin pipe’dan okur.
pipe(pipefd); // [0] okuma ucu, [1] yazma ucu
if (fork() == 0) { // sol komut
dup2(pipefd[1], STDOUT_FILENO); // stdout artık pipe'a akıyor
execlp("ls", "ls", NULL);
}
if (fork() == 0) { // sağ komut
dup2(pipefd[0], STDIN_FILENO); // stdin artık pipe'tan geliyor
execlp("wc", "wc", "-l", NULL);
}
close(pipefd[0]); close(pipefd[1]); // parent kendi kopyalarını bırakırYukarıda kısalık için atladım ama gerçek bir programda her child, kullanmadığı ucu da kapatmak zorundadır — ve parent’ın sondaki close çağrıları süs değil. Bir pipe’ın okuma ucu, yazma ucunu tutan son file descriptor kapandığında EOF verir. Parent kendi kopyasını açık bırakırsa kernel’ın gözünde hâlâ yazabilecek biri vardır; wc sonsuza kadar bekler ve komut asılı kalır. Unix’te en sık rastlanan “sebepsiz donan pipeline” hatasının tamamı bu tek cümleden çıkar.
Kanalın kuruluşunu ve kapanışını adım adım izleyelim:
- Shell bir pipe açar. Tek bir pipe2 çağrısı kernel'da bir tampon ve ona bakan iki fd üretir: 3 okuma, 4 yazma ucu. Veri diske hiç uğramayacak.
- Sol komut: ls. Shell fork eder. Child fd tablosunu olduğu gibi miras alır, dup2(4, 1) ile stdout'unu yazma ucuna bağlar, sonra ls'e dönüşür. ls ekrana yazdığını sanır.
- Sağ komut: wc. İkinci child aynı iki numarayı aynı anlamla miras alır ve dup2(3, 0) ile stdin'ini okuma ucuna bağlar. wc klavyeden okuduğunu sanır.
- Parent uçları kapatır. Shell kendi kopyalarını kapatır. Bu süs değil: okuma ucu, yazma ucunu tutan son fd kapanınca EOF verir. Parent 4'ü açık tutsaydı wc sonsuza kadar beklerdi.
- Veri akar. ls yazdıkça tampon dolar, wc okudukça boşalır. ls bitip çıkınca yazma ucunu tutan kimse kalmaz; wc EOF görür, sayar ve biter.
Bunun neden böyle olduğunu 20. bölümde file descriptor’ların üç katmanlı yapısına baktığımızda daha net göreceğiz: fork fd’yi kopyalamaz, aynı açık dosya tanımına ikinci bir referans ekler. Referans sayısı sıfırlanmadan kanal kapanmaz.
Pratik Deney: strace ile Fork, Exec ve Pipe
Yukarıdaki üç syscall’ı gerçek bir shell’in üstünde görmek için strace ile bir pipeline izleyebiliriz. Aşağıdaki komut, bash’in ls | wc komutunu çalıştırırken yaptığı syscall’ları gösterir:
$ strace -f -e trace=pipe,pipe2,clone,clone3,dup2,execve bash -c "ls | wc"
pipe2([3, 4], 0) = 0
clone(...) = 2847
[pid 2847] dup2(4, 1) = 1 # ls: stdout artık pipe'ın yazma ucu
[pid 2847] execve("/usr/bin/ls", ["ls"], ...) = 0
clone(...) = 2848
[pid 2848] dup2(3, 0) = 0 # wc: stdin artık pipe'ın okuma ucu
[pid 2848] execve("/usr/bin/wc", ["wc"], ...) = 0Gözünü dört şeye dikmen yeterli. İlk satırdaki pipe2 kanalı açıyor ve iki ucunu iki fd olarak döndürüyor: 3 okuma, 4 yazma ucu. Ardından iki clone var, yani iki child doğdu — biri ls, biri wc için. (Yeni glibc sürümlerinde clone yerine clone3 görebilirsin; aynı kapının yeni imzası.) Her child kendi dup2’siyle bir fd’yi başka bir fd’nin üstüne kopyalıyor; yönlendirmenin gerçekleştiği an tam olarak burası. Sonra gelen execve ise child’ın kimliğini değiştirdiği an.
Numaralara dikkat et. fork, fd tablosunu olduğu gibi kopyaladığı için iki child da 3’ü ve 4’ü aynı anlamla miras alır: 3 ikisinde de okuma, 4 ikisinde de yazma ucudur. ls’in child’ı 4’ü 1’in üstüne, wc’ninki 3’ü 0’ın üstüne kopyalıyor. İzde görünmeyen close çağrılarıyla da her biri kullanmadığı ucu hemen bırakıyor; biraz yukarıda anlattığım EOF kuralı yüzünden bu zorunlu.
İş Kontrolü: Ctrl-C Aslında Kime Gidiyor?
Şimdiye kadar shell’i tek yönlü bir başlatıcı gibi anlattık: komutu al, çalıştır, bitmesini bekle. Ama terminalde her gün yaptığın bir şey bu resme sığmıyor. Ctrl-C’ye bastığında ne oluyor?
Akla gelen ilk cevap “shell programı durduruyor” olur. Değil. Shell o sırada waitpid içinde uyuyor; hiçbir şey yapmıyor. Ctrl-C’yi gören ve karşılığında bir şey yapan taraf terminal sürücüsüdür.
Mekanizma şöyle işler. Kernel, birbirine bağlı process’leri process group denen kümelerde tutar. Terminalin her an tam olarak bir tane foreground process group’u vardır; klavyeden gelen Ctrl-C, Ctrl-Z, Ctrl-\ gibi tuşlar birer sinyale çevrilip o gruptaki bütün process’lere birden gönderilir.
Bu tek cümle, günlük hayatta tuhaf görünen birkaç şeyi aynı anda açıklar:
ls | wc -lçalışırkenCtrl-Cneden ikisini birden öldürür? Çünkü shell, bir pipeline’ın tüm üyelerini aynı process group’a koyar.SIGINTgruba gider, ikisi de alır.- Shell neden ölmüyor? Çünkü shell o grubun içinde değil. Kendi grubunda, arka planda oturuyor.
sleep 100 &ile arka plana attığın işeCtrl-Cneden işlemiyor? Çünkü artık foreground grupta değil. Terminal sinyali yalnızca ön plandaki gruba yollar.
Ctrl-Z aynı yolu izler ama farklı bir sinyal gönderir: SIGTSTP. Bu sinyal process’i öldürmez, durdurur — bellekte olduğu gibi kalır, sadece scheduler ona bir daha CPU vermez. fg ile ön plana, bg ile arka plana devam ettirmek, kernel’a SIGCONT gönderip terminalin foreground grubunu yeniden ayarlamaktan ibarettir.
| Tuş | Sinyal | Varsayılan davranış |
|---|---|---|
Ctrl-C | SIGINT | Nazikçe sonlandır; program yakalayıp temizlik yapabilir |
Ctrl-Z | SIGTSTP | Durdur; program yakalayabilir ama çoğu yakalamaz |
Ctrl-\ | SIGQUIT | Sonlandır ve core dump al |
| — | SIGKILL | Öldür; yakalanamaz, engellenemez, ertelenemez |
Son satır önemli: SIGKILL ve SIGSTOP process’e hiç ulaşmaz, kernel işi doğrudan halleder. Bu yüzden kill -9 “en güvenilir” seçenektir ve tam da bu yüzden en kötüsüdür — programa kapanırken dosyasını kapatma, kilidini bırakma, geçici dizinini silme şansı vermez.
Bir bağlantı daha: terminal penceresini kapattığında ön plandaki gruba SIGHUP (hang up — telefonu kapatmak) gider. Adı 1970’lerden kalmadır; gerçekten telefon hattı üzerinden bağlanan terminaller için icat edilmişti. Uzun süren bir işi nohup ile başlatmanın ya da disown ile shell’in iş listesinden çıkarmanın tek yaptığı şey, bu sinyalin yolunu kesmektir.
Özet
Peki, ne öğrendik?
- Shell, kullanıcı ile kernel arasındaki köprüdür ve kernel’ın gözünde sıradan bir user mode programıdır.
- Built-in komutlar shell’in içindedir; harici programlar
PATHüzerinden diskte bulunur. İçinde/geçen bir ad PATH’te aranmaz, doğrudan yol olarak okunur.- Her harici komut için shell
fork+execve+waitpidüçlüsünü uygular; çıkış kodu0başarı,128 + sinyal numarasıise sinyalle ölüm demektir.*.txtgibi kalıpları program değil shell çözer; program yalnızca çözülmüş adları görür.- Yönlendirme (
>,<,2>&1) ve pipeline (|), child’ın içindedup2ile file descriptor’ların yeniden bağlanmasıyla kurulur; fd’lerexecve’den sağ çıktığı için yeni program bunu fark etmez.- Aynı miras istenmeyen fd’leri de taşır;
O_CLOEXEC, bir fd’ninexecveanında kendiliğinden kapanmasını sağlar.- Environment variable’lar
exportile child process’lere kopyalanarak aktarılır; subshell fork eder ama exec etmez.- Terminal sinyalleri tek bir process’e değil, foreground process group’una gider;
Ctrl-C’nin pipeline’ın tamamını durdurması bundandır.SIGKILLveSIGSTOPyakalanamaz; bu yüzdenkill -9en güvenilir ve en kaba seçenektir.
Artık shell’in komutları nasıl çözümlediğini, kernel’a nasıl ilettiğini ve terminalin sinyalleri kime yönlendirdiğini biliyoruz. Bir sonraki bölümde, çalıştırılan programların formatına dalıyoruz: ELF dünyası.
15. bölüme devam et: Bir ELF Ustasına Dönüşmek