Bölüm 7: Shell'den Kernel'e
CPU'ya“Sen”iKatmak yazısının parçası: bilgisayarının programları nasıl çalıştırdığına doğru inen uzun bir teknik tavşan deliği.
Bir önceki bölümde execve syscall’ının mekaniklerini gördük. Peki kullanıcı terminalde ./program yazdığında, bu çağrıyı 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.
Shell Nedir?
Shell, kullanıcı ile işletim sistemi çekirdeği (kernel) arasındaki bir arayüzdür. Komutlarını okur, yorumlar ve kernel’a iletir. En bilinen shell’ler: Bash (Bourne Again Shell), Zsh, Fish, sh (Bourne Shell).
Shell aslında kendisi de sıradan bir kullanıcı programıdır. Terminal açtığında, seninle kernel arasında duran ve komutlarını çeviren bir tercümandır.
Komut Çözümleme: PATH ve Built-in’ler
Bir komut girdiğinde shell önce şunu kontrol eder: Bu komut benim içimde mi (built-in), yoksa diskte bir program mı?
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 sizin shell’inizin dizini 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:/sbin
PATH, dizinlerin iki nokta üst üste (:) ile ayrıldığı bir listedir. Shell, komutunu bu dizinlerde sırayla arar. İlk bulduğu yerde çalıştırır.
./programyazmanın nedeni: Eğer programın bulunduğu dizin PATH’te yoksa, shell onu bulamaz../“şu anki dizinde ara” demektir.
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.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#define MAX_LEN 1024
int main(void) {
char line[MAX_LEN];
while (1) {
printf("microshell> ");
fflush(stdout);
if (!fgets(line, sizeof(line), stdin))
break;
line[strcspn(line, "\n")] = '\0';
if (strlen(line) == 0)
continue;
char *argv[64];
int argc = 0;
char *token = strtok(line, " ");
while (token && argc < 63) {
argv[argc++] = token;
token = strtok(NULL, " ");
}
argv[argc] = NULL;
pid_t pid = fork();
if (pid < 0) {
perror("fork");
continue;
}
if (pid == 0) {
execvp(argv[0], argv);
perror("execvp");
exit(1);
} else {
int status;
waitpid(pid, &status, 0);
}
}
return 0;
}
fork()ile child oluşturulur,execvp()ile program yüklenir,waitpid()ile senkronize olunur. Gerçek shell’ler (bash, zsh) bu temele alias çözümleme, wildcard genişletme, history, job control ve sinyal yönetimi ekler.
Pipeline: | Karakteri
Pipeline, bir komutun çıktısını diğerinin girdisi yapar:
$ cat dosya.txt | grep "arama" | wc -l
Arka planda olanlar:
- Shell bir pipe (boru hattı) oluşturur. Pipe, iki ucu olan bir veri kanalıdır.
catiçin bir child process fork edilir. Çıktısı pipe’ın yazma ucuna (stdoutyerine) yönlendirilir.grepiçin başka bir child fork edilir. Girdisi pipe’ın okuma ucundan (stdinyerine) gelir; çıktısı yeni bir pipe’a gider.wc -liçin üçüncü bir child fork edilir. Girdisi ikinci pipe’dan gelir.- Shell bu üç child’ı bekler.
Pipe’lar aslında kernel tarafında bir bellek arabelleğidir. Veriyi diskte geçici dosya olarak yazmazlar; doğrudan RAM üzerinden aktarırlar.
Yönlendirme: >, <, >>
>: Standart çıktıyı (stdout) bir dosyaya yazar (üzerine yazar).>>: Standart çıktıyı bir dosyanın sonuna ekler.<: Standart girdiyi (stdin) bir dosyadan okur.2>: Standart hatayı (stderr) bir dosyaya yazar.
$ ls -la > dosyalar.txt # Çıktıyı dosyaya yaz
$ cat < dosyalar.txt # Dosyayı girdi olarak oku
$ ./program 2> hatalar.log # Hataları ayrı dosyaya yaz
>,<,>>shell tarafından yönetilir; çalıştırılan program bunun farkında bile olmaz. Shell child process fork etmeden önce dosya tanımlayıcılarını (file descriptor’ları) yeniden yönlendirir.
Environment Variables (Ortam Değişkenleri)
Environment variables, process’e aktarılan anahtar-değer çiftleridir. Shell, yeni bir program başlatırken mevcut environment’ı child’a aktarır.
$ 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
Bazı komutlar parantez içinde çalıştırılır; bu, shell’in yeni bir child shell (subshell) başlattığı anlamına gelir.
$ (cd /tmp && ls) # Subshell'de çalışır; ana shell'in dizini değişmez
$ cd /tmp && ls # Ana shell'in dizini değişir
Kernel’da Fork: copy_process
fork() syscall’ı userspace’den çoğu modern Linux/glibc yolunda clone() ailesine denk gelen bir kernel yoluna ulaşır. Kernel tarafında önemli merkezlerden biri kernel/fork.c içindeki copy_process fonksiyonudur. Bu fonksiyon, mevcut task_struct‘ı kopyalar, bellek alanını COW’a hazırlanacak şekilde ayarlar ve dosya tanımlayıcı tablosunu çoğaltır.
Bunu burada sadece shell’in
fork + execmotoruna bağlamak için özetliyoruz.fork, Copy-on-Write veinit processzincirini Bölüm 11’de ayrıntılı inceleyeceğiz.
static __latent_entropy struct task_struct *copy_process(
struct pid *pid,
int trace,
int node,
struct kernel_clone_args *args)
{
struct task_struct *p;
int retval;
p = dup_task_struct(current, node);
if (!p)
return ERR_PTR(-ENOMEM);
retval = copy_creds(p, args->flags);
if (retval < 0)
goto bad_fork_free;
retval = copy_mm(args->flags, p);
if (retval)
goto bad_fork_cleanup_signal;
retval = copy_files(args->flags, p, args->no_files);
if (retval)
goto bad_fork_cleanup_semundo;
retval = copy_sighand(args->flags, p);
if (retval)
goto bad_fork_cleanup_files;
/* PID atama ve scheduler hazırlığı */
p->pid = pid_nr(pid);
/* ... */
return p;
}
dup_task_struct stack ve task_struct için bellek ayırır. copy_mm ise Bellek Yönetim Birimi’ni (mm_struct) klonlar; CLONE_VM bayrağı yoksa COW sayfa tablolarını oluşturur. copy_files ise açık dosya tanımlayıcılarını (files_struct) kopyalar veya paylaşı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:
$ strace -f -e trace=clone,execve,dup2 bash -c "ls | wc"
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD,
child_tidptr=0x7fabc1234560) = 2847
strace: Process 2847 attached
[pid 2847] dup2(3, 1) = 1
[pid 2847] close(3) = 0
[pid 2847] execve("/usr/bin/ls", ["ls"], 0x55aabbccdd00 /* 45 vars */) = 0
[pid 2847] +++ exited with 0 +++
strace: Process 2848 attached
[pid 2848] dup2(3, 0) = 0
[pid 2848] close(3) = 0
[pid 2848] execve("/usr/bin/wc", ["wc"], 0x55aabbccdd00 /* 45 vars */) = 0
[pid 2848] +++ exited with 0 +++
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=2847, si_status=0} ---
Çıktıdaki dup2(3, 1), ls process’inin standart çıktısını (fd 1) pipe’ın yazma ucuna (fd 3) yönlendirdiğini gösterir. dup2(3, 0) ise wc process’inin standart girdisini (fd 0) pipe’ın okuma ucuna bağladığını gösterir. clone çağrısı ise fork‘un modern karşılığıdı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.
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void) {
int pipefd[2];
pid_t pid1, pid2;
if (pipe(pipefd) == -1) {
perror("pipe");
return 1;
}
pid1 = fork();
if (pid1 == 0) {
close(pipefd[0]);
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
execlp("ls", "ls", NULL);
perror("execlp ls");
_exit(1);
}
pid2 = fork();
if (pid2 == 0) {
close(pipefd[1]);
dup2(pipefd[0], STDIN_FILENO);
close(pipefd[0]);
execlp("wc", "wc", "-l", NULL);
perror("execlp wc");
_exit(1);
}
close(pipefd[0]);
close(pipefd[1]);
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);
return 0;
}
close()adımları kritiktir. Parent ve child’lar kullanmadıkları uçları kapatmazsa, okuma ucunda bekleyenwcprocess’i hiçbir zaman EOF göremez ve asılı kalabilir.
Bir yana: PATH Hijacking ve Güvenlik
Eğer
PATHdeğişkeninizin içinde.(mevcut dizin) bulunuyorsa, saldırganlar zararlı bir betiği oraya yerleştirip sizin komutunuzu gölgede bırakabilir. Örneğin/tmpdizinine girerseniz ve saldırgan oraya zararlı birlskoymuşsa,.PATH’te varsa sizlsyazdığınızda/tmp/lsçalışabilir. 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.
Derinleşme: File Descriptor Inheritance ve
O_CLOEXEC
fork()sonrası child process, parent’ın tüm açık dosya tanımlayıcılarını (file descriptor) miras alır.execve()sonrası da bu miras devam eder… eğer fd’ler kapatılmamışsa. Bu durum, yanlışlıkla açık kalan bir soketin veya dosyanın başka bir programa sızdırılmasına (leaked fd) yol açabilir. Linux, bu sorunu çözmek içinO_CLOEXECbayrağını sunar:open(path, O_RDONLY | O_CLOEXEC)ile açılan bir fd,execve()anında otomatik olarak kapatılır. Alternatif olarakfcntl(fd, F_SETFD, FD_CLOEXEC)kullanılabilir.O_CLOEXEC, güvenlik açısından kritik bir savunma hattıdır.
Kernel’da Execve: do_execveat_common
Shell child process’te execve() çağırdığında, kontrol fs/exec.c içindeki do_execveat_common fonksiyonuna geçer. Bu fonksiyon, çalıştırılacak dosyayı açar, linux_binprm yapısını hazırlar ve uygun ikili format yükleyicisini (binfmt_elf, binfmt_script, vb.) çağırır.
Bölüm 6’da
execvesyscall’ının userspace arayüzünü ve binfmt yolunu görmüştük.do_execveat_common, kernel tarafındaki uygulama merkezidir.
static int do_execveat_common(int fd, struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp,
int flags)
{
struct linux_binprm *bprm;
struct file *file;
struct files_struct *displaced;
int retval;
bprm = alloc_bprm(fd, filename);
if (IS_ERR(bprm))
return PTR_ERR(bprm);
retval = bprm_stack_limits(bprm);
if (retval)
goto out_free;
retval = copy_string_kernel(bprm->filename, bprm);
if (retval < 0)
goto out_free;
bprm->exec = bprm->p;
retval = copy_strings(bprm->envc, envp, bprm);
if (retval < 0)
goto out_free;
retval = copy_strings(bprm->argc, argv, bprm);
if (retval < 0)
goto out_free;
retval = bprm_execve(bprm, fd, filename, flags);
out_free:
free_bprm(bprm);
return retval;
}
bprm_execve, dosyayı açar (do_open_execat), bellek haritasını (exec_mmap) yeni programın imajıyla değiştirir ve son olarak search_binary_handler ile uygun yükleyiciyi çalıştırır. Başarı yolunda eski program image’ı sökülür; sonraki bölümlerde gördüğümüz gibi CPU önce ELF entry point’e veya dynamic loader’ın entry point’ine döner, main() ise C runtime kurulumundan sonra çağrılır.
8. bölüme devam et: Bir ELF Ustasına DönüşmekÖ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()vedup2()syscall’larıyla kurulur.- Yönlendirme (
>,<), shell tarafından file descriptor’ların yeniden atanmasıyla yapılır.- Environment variable’lar
exportile child process’lere aktarılır.O_CLOEXECveFD_CLOEXEC,execvesonrası istenmeyen fd mirasını önler.Artık shell’in komutları nasıl çözümlediğini, kernel’a nasıl ilettiğini ve kernel içinde
copy_processiledo_execveat_commonfonksiyonlarının bu süreçte nasıl rol oynadığını biliyoruz. Bir sonraki bölümde, çalıştırılan programların formatına — ELF dünyasına — dalacağız.