Ana içeriğe geç

Bölüm 14:Shell'den Kernel'e

19 dakikalık okumaGüncelleme:

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:

Shell oturumu
$ 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/sh yazıp içeride Bash’e özgü bir özellik kullanmak. Pek çok dağıtımda /bin/sh Bash değildir (Debian ve Ubuntu’da dash’tir) ve script, senin makinende çalışıp sunucuda patlar. Bash’e özgü bir şey kullanacaksan shebang’i de #!/bin/bash yap.

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:

  1. Alias — alias ls='ls --color' yazdıysan burada yakalanır.
  2. Shell fonksiyonu — kendi tanımladığın fonksiyonlar.
  3. Built-in — cd, export gibi shell’in içinde olanlar.
  4. Hash tablosu — daha önce bulunmuş bir programın hatırlanan yolu.
  5. PATH araması — hiçbiri tutmazsa diskte aranır.

Bunu tahmin etmene gerek yok; type sana doğrudan söyler:

Shell oturumu
$ 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/python3

Bir 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 cd built-in’dir? Çünkü cd mevcut process’in çalışma dizinini değiştirir. Eğer cd ayrı bir program olsaydı, o program kendi process’inde çalışır ve senin shell’inin dizini hiç değişmezdi. İşte bu yüzden cd shell’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.

Shell oturumu
$ echo $PATH
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

PATH, 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. ./program da 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 kendi ls’ini koyduğunu düşün: sen ls yazdığında /usr/bin/ls değil, onunki çalışır — üstelik hiçbir uyarı almazsın. Bu yüzden modern Linux dağıtımları .’yi varsayılan PATH’ten çıkarmıştır. Güvenlik için ./program kullanı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:

  1. fork(): Shell kendini klonlar. Artık iki shell process’i var.
  2. Child’ta execve(): Child process, shell kodunu yeni programın koduyla değiştirir.
  3. 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.

Shell'in fork(), exec() ve wait() adımlarını gösteren akış diyagramı.

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:

Bir komut çalıştırıldığında shell ne yapar?
SHELLCHILDkomutu çözls -l > out.txtshell'in kopyasıaynı kod, aynı bellek görüntüsüforkfd'leri yeniden bağladup2(fd, 1)artık `ls` programıbellek düzeni baştan kurulduexecvewaitpidçıkış kodunu toplachild bitti · çıkış kodu parent'a
Shell komutu çözer. Satır kelimelere ayrılır, *.txt gibi kalıplar dosya adlarına açılır ve çalıştırılacak programın yolu PATH üzerinden bulunur.
  1. Shell komutu çözer. Satır kelimelere ayrılır, *.txt gibi kalıplar dosya adlarına açılır ve çalıştırılacak programın yolu PATH üzerinden bulunur.
  2. 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şaret fork'un döndürdüğü değerdir.
  3. Child kendi dünyasını kurar. Asıl iş bu küçük pencerede yapılır: dup2 ile 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.
  4. 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.
  5. waitpid: parent bekler. Shell child bitene kadar bekler ve çıkış kodunu toplar. Toplamazsa child ölü ama kayıtlı kalır — buna zombie denir.
Bir shell'in kalbi
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 verme

Dikkat ç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:

KodAnlamı
0Başarı
1, 2, …Programın kendi tanımladığı hatalar
126Dosya bulundu ama çalıştırma izni yok
127Komut bulunamadı (yukarıdaki _exit(127))
130Ctrl-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:

Shell oturumu
$ echo *.txt
notlar.txt rapor.txt taslak.txt

echo 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:

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:

Shell oturumu
$ cat dosya.txt | grep "arama" | wc -l

Arka planda olanlar:

  1. 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.
  2. Shell cat için bir child üretir ve child’ın stdout’unu pipe’ın yazma ucuna bağlar. cat bunu hiç fark etmez; her zamanki gibi stdout’a yazar, ama yazdığı yer artık ekran değil, pipe’tır.
  3. Shell grep için ikinci bir child üretir; onun stdin’ini pipe’ın okuma ucundan besler, stdout’unu ise yeni bir pipe’a bağlar.
  4. wc -l için üçüncü bir child üretir ve girdisini ikinci pipe’dan alır.
  5. 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: fcntl ile F_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 SIGPIPE sinyali gider ve o program varsayılan olarak ölür. yes | head -1 komutunun sonsuz döngüye girmeyip anında bitmesinin sebebi tam olarak budur — head bir satır alıp çıkar, yes de bir sonraki yazmasında SIGPIPE yiyip ö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:

fdAdıVarsayılan olarak nereye bağlı
0stdinklavye
1stdoutekran
2stderrekran

Şimdi işaretler anlam kazanıyor:

Son maddedeki “şu anda” vurgusu boşuna değil ve klasik bir tuzağın kaynağı:

Shell oturumu
$ 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.)

Shell oturumu
$ ls -la > dosyalar.txt    # Çıktıyı dosyaya yaz
$ cat < dosyalar.txt       # Dosyayı girdi olarak oku
$ ./program 2> hatalar.log # Hataları ayrı dosyaya yaz

Dö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 fork eder, yönlendirmeyi child’ın içinde dup2 ile yapar ve ancak ondan sonra execve ç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.

Shell oturumu
$ export MY_VAR="merhaba"
$ echo $MY_VAR
merhaba
$ ./program             # MY_VAR, programın envp'sine gider

export ile tanımlanan değişkenler, shell’in fork ettiği tüm child process’lere aktarılır. export kullanmadan tanımlanan değişkenler sadece shell’in kendisi için geçerlidir.

Önemli environment variable’ları:

DeğişkenAnlamı
PATHKomut arama dizinleri
HOMEKullanıcının ana dizini
USERKullanıcı adı
SHELLVarsayılan shell
LANGDil 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.

Shell oturumu
$ (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ğı:

Shell oturumu
$ 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.c içindeki copy_process fonksiyonunda 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.

ls | wc -l elle kurulursa
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ır

Yukarı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:

ls | wc: iki program, bir pipe
shell (parent)pipe2 → [3, 4]pipe tamponu · kernelforkchild 1 → lsfd 1 → yazma ucudup2(4, 1)forkchild 2 → wcfd 0 ← okuma ucudup2(3, 0)close(3); close(4)ls yazar, wc okur; ls bitince wc EOF görür
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.
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

Terminal
$ 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"], ...) = 0

Gö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:

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şSinyalVarsayılan davranış
Ctrl-CSIGINTNazikçe sonlandır; program yakalayıp temizlik yapabilir
Ctrl-ZSIGTSTPDurdur; program yakalayabilir ama çoğu yakalamaz
Ctrl-\SIGQUITSonlandı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ış kodu 0 başarı, 128 + sinyal numarası ise sinyalle ölüm demektir.
  • *.txt gibi 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çinde dup2 ile file descriptor’ların yeniden bağlanmasıyla kurulur; fd’ler execve’den sağ çıktığı için yeni program bunu fark etmez.
  • Aynı miras istenmeyen fd’leri de taşır; O_CLOEXEC, bir fd’nin execve anında kendiliğinden kapanmasını sağlar.
  • Environment variable’lar export ile 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.
  • SIGKILL ve SIGSTOP yakalanamaz; bu yüzden kill -9 en 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