Vediamo come possiamo creare una shell minimale, che :

  1. attende che l’utente digiti un comando
  2. avvia un processo per eseguire il comando
  3. attende che il processo sia terminato

Creazione processo - fork() - wait() - execv()

Per creare un processo possiamo utilizzare :

pid_t fork();
  • duplica il processo corrente
  • restituisce il pid () del figlio al chiamante (genitore)
  • restituisce 0 nel nuovo processo figlio
pid_t pid = fork();
 
if (pid > 0) {
    // Questo codice viene eseguito NEL GENITORE
    // pid contiene il PID del figlio appena creato
} else if (pid == 0) {
    // Questo codice viene eseguito NEL FIGLIO
    // pid è 0
}

Per attendere che un processo termini possiamo utilizzare :

pid_t wait(int *wstatus);
  • attende che i processi figli cambino stato
  • blocca il processo genitore finché un figlio non termina
  • restituisce il pid del figlio terminato
  • scrive lo stato in wstaus
void main(void){
 
	int pid, child_status;
	
	if (fork() == 0){
		do_something_in_child();
	} else {
		wait(&child_status); // wait for child
	}
 
}

Per eseguire un binario (path) nel processo corrente, possiamo usare :

int execv(const char *path, char *constargv[]);
  • carica un binario e rimuove tutte le altre mappature in memoria
  • constargv contiene gli args del programma, dove l’ultimo è sempre NULL (es. {"/bin/ls", "-a", NULL})
  • esistono diverse varianti di exec{v}{p}

Quindi ora se mettiamo insieme i pezzi, possiamo creare la nostra shell minimale :

wait(1){
	char cmd[256], *args[256];
	int status;
	pid_t pid;
	read_command(cmd, args);
	
	pid = fork();
	if (pid == 0){
		execv(cmd, args);
		exit(1);
	} else {
		wait(&status);
	}
}

Gestione dei segnali - signal - alarm - kill

Ci domandiamo ora come funziona CTRL+C e come sia possibile che termini il programma. La risposta sono i segnali come abbiamo già visto in Processi.

Quando un processo deve essere terminato, viene inviato un segnale al processo.

Per registrare un gestore di segnali per un segnale specifico (signum) possiamo utilizzare :

sighandler_t signal(int signum, sighandler_t handler);

Per esempio :

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
 
// La funzione gestore DEVE ricevere un int (il numero del segnale)
void mio_gestore(int segnale_ricevuto) {
    printf("Ho ricevuto il segnale %d\n", segnale_ricevuto);
}
 
int main() {
    // Registro il gestore per SIGINT (CTRL+C)
    signal(SIGINT, mio_gestore);
    
    // Oppure posso ignorare un segnale
    signal(SIGQUIT, SIG_IGN);  // Ignora CTRL+\
    
    while(1) {
        printf("In esecuzione...\n");
        sleep(1);
    }
}

Per inviare un SIGALARM in numero di secondi specificato abbiamo :

unsigned int alarm(unsigned int seconds);

Per esempio :

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
 
void gestore_timer(int sig) {
    printf("\n🔔 DRIN! Sono passati 3 secondi!\n");
    alarm(3);  // Riavvio l'allarme per farlo andare all'infinito
}
 
int main() {
    signal(SIGALRM, gestore_timer);
    
    // Imposto un allarme tra 3 secondi
    alarm(3);
    
    printf("Timer avviato, suonerà ogni 3 secondi\n");
    
    while(1) {
        printf("Lavoro principale...\n");
        sleep(1);
    }
}

Mentre per consegnare un segnale (sig) ad un processo (pid) abbiamo :

int kill(pid_t pid, int sig);

Comunicazione tra processi con pipe

Il concetto di pipe è già noto, serve per avere una sorta di flusso di comunicazione tra due processi. Abbiamo visto l’esempio :

cat file1 | grep "amici"

Possiamo anche avere dei “pipe con i nomi”, e si creano con il comando mkfifo :

mkfifo named.pipe

un utilizzo che si può fare è quello di voler eseguire un comando su un terminale e mostrare l’output su un’altro :

Terminale 1 :

ls > named.pipe

Terminale 2 :

cat < named.pipe 

(questa roba si può fare anche con tail -f <file>)


Per aprire un file specificato (pathname) abbiamo :

int open(const char *pathname, int flags);

Per chiuderlo :

int close(int fd);

Per creare una pipe con due file (fd) alle sue estremità (write - read) abbiamo :

int pipe(int pipefd[2]);

dove :

  • pipefd[0] : estremità di read
  • pipefd[1] : estremità di write

Per creare una copia del file descriptor (oldfd) utilizzando un file descriptor con il numero più basso disponibile, abbiamo :

int dup(int oldfd);

che :

  • prende un fd già esistente oldfd
  • crea un nuovo fd che punta alla stessa risorsa
  • restituisce il nuovo fd (o -1 per errore)

Abbiamo anche :

int dup2(int oldfd, int newfd);

che duplica oldfd su un fd specifico newfd.

Vengono utilizzati per reindirizzare l’output, per esempio per un programma che logga su un file :

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
 
int main() {
    // Apro file di log
    int log_fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    
    // Salvo stdout
    int salva_stdout = dup(1);
    
    // Reindirizzo stdout al file
    dup2(log_fd, 1);
    
    // Tutti i printf finiscono nel log!
    printf("Programma avviato\n");
    printf("Log Log Log Log\n");
    
    
    fflush(1); // libera il buffer 
    
    // Ripristino stdout
    dup2(salva_stdout, 1);
    close(salva_stdout);
    
 
    printf("Programma terminato (questo a schermo)\n");
    
    close(log_fd);
    return 0;
}

Vediamo un’altro esempio dove cloniamo il comportamento di ps -aux | grep obsidian :

#include <stdio.h>
#include <unistd.h>
 
int main() {
 
  int fd[2];
  pipe(fd); // fd[0] <-------> fd[1]
 
  if (fork() == 0) {
    // figlio
    close(fd[0]); // chiusura lettura per sicurezza
    dup2(fd[1], 1);
    execlp("ps", "ps", "aux", NULL);
 
  } else {
    // padre
    close(fd[1]); // chiusura scrittura per sicurezza
    dup2(fd[0], 0);
    execlp("grep", "grep", "obsidian", NULL);
  }
 
  //dead code here
 
  return 0;
}

Notiamo che dopo execlp è “dead code”, siccome il comando in execlp sostituisce il processo che lo chiama e quindi non torna indietro (se senza errori).

Vediamo ora un’altro esempio :

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
 
#define STDIN  0
#define STDOUT 1
 
#define PIPE_RD 0
#define PIPE_WR 1
 
int main(int argc, char** argv)
{
    pid_t cat_pid, sort_pid;
    int fd[2];
    
    pipe(fd);
    
    cat_pid = fork();
    if (cat_pid == 0) {
        close(fd[PIPE_RD]);
        close(STDOUT);
        dup(fd[PIPE_WR]);
        execl("/bin/cat", "cat", "names.txt", NULL);
    }
    
    sort_pid = fork();
    if (sort_pid == 0) {
        close(fd[PIPE_WR]);
        close(STDIN);
        dup(fd[PIPE_RD]);
        execl("/usr/bin/sort", "sort", NULL);
    }
    
    close(fd[PIPE_RD]);
    close(fd[PIPE_WR]);
    
    return 0;
}

Notiamo che dup(fd[PIPE_WR]) copierà un nuovo fd che punterà al lato di scrittura della pipe, ma il più piccolo, dopo la chiusura di stdout (1) è proprio 1 (stdout) (identica cosa per stdin per sort)

Ci chiediamo se possiamo evitare le chiusure dei lati di lettura e scrittura della pipe? :

  • Evitare blocchi : chiudere uno dei lati inutilizzati della pipe per impedire al processo di bloccarsi
  • Ricezione EOF : se sort non chiude il suo lato di scrittura, la pipe ha uno scrittore attivo (se stesso) e quindi sort aspetterà finché qualcuno non chiude il lato di scrittura per inviare un EOF
  • Evitare letture accidentali : cat chiude il lato lettura per evitare di leggere cose inaspettate dalla pipe
  • Reindirizzamento di stdout in cat : dopo la duplicazione chiudiamo il fd[PIPE_WR] (originale) in modo da avere solo quello creato con il dup che punta verso il lato di scrittura (forma corretta non presente nel codice perché ci pensa il kernel dopo execlp)
  • Reindirizzamento di stdout in sort : identica cosa ma per lettura
  • Descriptor nel processo padre : dopo la fork, il processo padre dovrebbe chiudere entrambe le estremità della pipe