Ana içeriğe geç

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

17 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      # giris shell'in olarak kayitli olan
$ ps -p $$         # su anda gercekten calisan

İ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. Aliasalias ls='ls --color' yazdıysan burada yakalanır.
  2. Shell fonksiyonu — kendi tanımladığın fonksiyonlar.
  3. Built-incd, 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 bölümün sonunda göreceğin gibi, eklemek de ciddi bir güvenlik hatasıdır.

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

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.

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 10. bölümde gördüğümüz “exec bir doğum değil, başkalaşımdır” fikrinden gelir.

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 olarak 64 KiB.

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.

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 calisir; ana shell'in dizini degismez
$ cd /tmp && ls     # Ana shell'in dizini degisir

(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
                    # bos!

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: kernel’ın içinde fork diye ayrı bir kapı yoktur. fork, clone adlı çok daha genel bir kapının belirli bir ayarla çağrılmış hâlidir. 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ı 13. 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 — 16. 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. 10. 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.

Pratik Deney: strace ile Fork, Exec ve Pipe

Shell’in arka planda clone, execve ve dup2 syscall’larını nasıl kullandığını 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=clone,execve,dup2 bash -c "ls | wc"
clone(...) = 2847
[pid  2847] dup2(3, 1)  = 1          # ls: stdout artik pipe'in yazma ucu
[pid  2847] execve("/usr/bin/ls", ["ls"], ...) = 0
clone(...) = 2848
[pid  2848] dup2(3, 0)  = 0          # wc: stdin artik pipe'in okuma ucu
[pid  2848] execve("/usr/bin/wc", ["wc"], ...) = 0

Gözünü üç şeye dikmen yeterli. İki clone var, yani iki child doğdu — biri ls, biri wc için. 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.

İki satırda da 3 numarasını görmen tesadüf değil ama aynı şeyi de göstermiyor. Pipe’ın iki ucu parent’ta iki ayrı fd olarak açılır; her child kendi kopyasında yalnızca ihtiyacı olan ucu tutup diğerini kapatır. Yani 3, ls’in dünyasında yazma ucu, wc’nin dünyasında okuma ucudur.

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 artik pipe'a akiyor
    execlp("ls", "ls", NULL);
}
if (fork() == 0) {                  // sag komut
    dup2(pipefd[0], STDIN_FILENO);  // stdin artik pipe'dan geliyor
    execlp("wc", "wc", "-l", NULL);
}
close(pipefd[0]); close(pipefd[1]); // parent kendi kopyalarini birakir

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.

Bunun neden böyle olduğunu 17. 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.

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.

İş 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 ve Sonraki Adımlar

  • Shell, kullanıcı ile kernel arasındaki köprüdür.
  • Built-in komutlar shell’in içindedir; harici programlar diskten PATH üzerinden bulunur.
  • Her harici komut için shell fork + execve + waitpid üçlüsünü uygular.
  • Pipeline (|), pipe() ve dup2() syscall’larıyla kurulur.
  • Yönlendirme (>, <), shell tarafından file descriptor’ların yeniden atanmasıyla yapılır.
  • Environment variable’lar export ile child process’lere aktarılır.
  • O_CLOEXEC, execve sonrası istenmeyen fd mirasını önler.
  • 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ı.

12. bölüme devam et: Bir ELF Ustasına Dönüşmek