Ana içeriğe geç

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

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 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 sizin shell’inizin dizini 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.

Kabuk 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. İlk bulduğu yerde çalıştırır.

./program yazmanı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:

  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.

microshell.c
#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:

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

Arka planda olanlar:

  1. Shell bir pipe (boru hattı) oluşturur. Pipe, iki ucu olan bir veri kanalıdır.
  2. cat için bir child process fork edilir. Çıktısı pipe’ın yazma ucuna (stdout yerine) yönlendirilir.
  3. grep için başka bir child fork edilir. Girdisi pipe’ın okuma ucundan (stdin yerine) gelir; çıktısı yeni bir pipe’a gider.
  4. wc -l için üçüncü bir child fork edilir. Girdisi ikinci pipe’dan gelir.
  5. 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: >, <, >>

Kabuk oturumu
$ 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.

Kabuk 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

Bazı komutlar parantez içinde çalıştırılır; bu, shell’in yeni bir child shell (subshell) başlattığı anlamına gelir.

Kabuk 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

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 + exec motoruna bağlamak için özetliyoruz. fork, Copy-on-Write ve init process zincirini Bölüm 11’de ayrıntılı inceleyeceğiz.

kernel/fork.c
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:

Terminal
$ 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.

pipeline.c
#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 bekleyen wc process’i hiçbir zaman EOF göremez ve asılı kalabilir.

Bir yana: PATH Hijacking ve Güvenlik

Eğer PATH değişkeninizin içinde . (mevcut dizin) bulunuyorsa, saldırganlar zararlı bir betiği oraya yerleştirip sizin komutunuzu gölgede bırakabilir. Örneğin /tmp dizinine girerseniz ve saldırgan oraya zararlı bir ls koymuşsa, . PATH’te varsa siz ls yazdığınızda /tmp/ls çalışabilir. 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.

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çin O_CLOEXEC bayrağını sunar: open(path, O_RDONLY | O_CLOEXEC) ile açılan bir fd, execve() anında otomatik olarak kapatılır. Alternatif olarak fcntl(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 execve syscall’ının userspace arayüzünü ve binfmt yolunu görmüştük. do_execveat_common, kernel tarafındaki uygulama merkezidir.

fs/exec.c
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.

Ö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 ve FD_CLOEXEC, execve sonrası 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_process ile do_execveat_common fonksiyonları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.

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