Struttura di un processo

Un programma è un file eseguibile residente sul disco. Può essere eseguito tramite un comando di shell oppure invocando una funzione exec(). Un processo è una istanza del programma che sta eseguendo [1]. Ogni processo ha un identificatore unico nel sistema, detto process ID, che è un intero non negativo.

Quindi, se lanciamo due volte lo stesso programma otteniamo 2 processi distinti, ognuno con il suo process ID. Un processo può ottenere il suo ID invocando la primitiva getpid(). Nel capitolo 4 presenteremo meglio il concetto di processo nel contesto della multi-programmazione. In questo capitolo ci concentreremo sul layout di memoria di un processo, e sulle sue interazioni con il sistema operativo.

Dato che il linguaggio più usato in assoluto nei sistemi Unix è il C, e dato che tutti i prototipi delle chiamate di sistema sono espresse in C, vale la pena fare qualche richiamo sulla struttura di un programma scritto in C.

[1] A volte un processo viene chiamato task: dato che task è un termine più generico, nel seguito cercheremo sempre di usare il termine processo.

 

Struttura di un programma C

Ogni programma scritto in C ha un entry-point che è la funzione main. Il suo prototipo più generale è:

int main (int argc, char *argv[]);

Il primo parametro rappresenta il numero di argomenti passati al programma sulla linea di comando, mentre il secondo parametro è il puntatore a un array di stringhe contenente gli argomenti veri e propri. Il primo argomento (contenuto nella stringa argv[0]) è sempre il nome del programma. Quindi argc vale sempre almeno 1.

Un processo può terminare in 5 modi diversi, 3 sono normali e 2 sono anormali:

  • Terminazione normale:
    • eseguendo l'istruzione return dal main;
    • chiamando exit();
    • chiamando _exit().
  • Terminazione anormale:
    • chiamando direttamente abort();
    • ricevendo un segnale non gestito.

I prototipi delle due funzioni exit sono elencati di seguito:

#include<stdlib.h>

void exit(int status);


#include<unistd.h>

void _exit(int status);

La exit() fa una "pulizia" dello stato del processo prima di uscire (tipicamente, chiude tutti i file e i descrittori aperti), mentre _exit() torna brutalmente al sistema. È anche possibile installare delle funzioni da chiamare prima dell'uscita tramite la funzione atexit().

#include<stdlib.h>

void atexit(void (*func)(void));

Ecco un esempio di utilizzo:

#include <stdlib.h>

void myexit(void);

int main(void)
{
  printf("Hello world\n");
  atexit(myexit);
  printf("just before exiting...");
  return 0;
}

void myexit(void)
{
  printf("inside my exit!!\n");
}

La sequenza di chiamate fatte quando viene lanciato un processo, e mentre esso è in esecuzione, è riassunta in Figura 2.1: quando viene lanciato un processo, per prima cosa parte una routine di inizializzazione (non visibile all'utente), chiamata C Startup Routine. Poi, viene invocata la funzione main(). Se la funzione esce con return, si ritorna alla C Startup Routine, la quale invoca exit() passandogli il valore di ritorno ottenuto dal main. Il main (o una delle funzioni invocate dal main) a sua volta può invocare direttamente la funzione exit(), oppure la _exit(). La differenza è che la exit() fa una serie di cose, tra le quali invocare gli handler, ovvero quelle funzioni che sono state installate con la atexit(). Infine, sia la _exit() che la exit() ritornano al kernel, il quale fa un'ulteriore lavoro di pulizia, cancellando quasi tutte le strutture interne relative al processo. Nel capitolo 4, vedremo che in realtà alcune strutture rimangono comunque presenti fino a che il processo padre non chiama la wait().

Figura 2.1: Sequenza di chiamate fatte all'entrata e all'uscita di un processo.

 

Variabili di ambiente

Ogni shell prevede un modo per settare delle variabili di ambiente. Su bash:

export VARNAME=valore

Ad esempio, una tipica variabile di ambiente è la variabile PATH: si tratta di una sequenza di directory separate dal carattere :, e ogni volta che si digita un comando, la shell cerca il file eseguibile nelle directory elencate nel PATH. Sempre con la bash, per vedere tutto le variabili definite e il loro contenuto, basta digitare il comando export.

È possibile leggere da programma le variabili d'ambiente, che sono memorizzate come un array di stringhe, tramite la funzione getenv():

#include<stdlib.h>
char *getenv(const char *name);

È infine possibile modificare una variabile di ambiente tramite le seguenti chiamate di funzione, dal significato abbastanza intuitivo:

int setenv(const char *name, const char *value, int rewrite);
void unsetenv(const char *name);
int putenv(const char *str);

 

Layout di memoria di un processo

Ogni processo ha un suo spazio di indirizzamento privato e non visibile dall'esterno. Ciò vuol dire che due processi non possono accedere a una stessa zona di memoria [2]. Questa separazione totale degli spazi di indirizzamento è ottenuta sfruttando le caratteristiche hardware del processore (segmentazione, protezione di memoria, ecc.).

Figura 2.2: Tipico layout di memoria di un processo.

Lo spazio di indirizzamento di un processo è di solito logicamente organizzato come in figura 2.2. Naturalmente, l'esatto layout dipende dal processore utilizzato e dalle convenzioni del sistema operativo e del compilatore C. Per esempio, in certi sistemi lo stack cresce vero l'alto, al contrario di come mostrato in figura.

Si distinguono:

  • una zona di memoria (o segmento) TEXT, che contiene il codice del programma;
  • un segmento di dati inizializzati;
  • un segmento di dati non inizializzati (spesso indicato con BSS); di solito il loader si incarica di inizializzarlo a zero;
  • un segmento a comune fra lo stack e lo heap.

In particolare, lo heap è quella zona di memoria a lunghezza variabile in cui vengono allocate le zone di memoria che l'utente alloca dinamicamente con le funzioni malloc e free:

#include<stdlib.h>

void *malloc(size_t size);
void free(void * ptr);

Queste funzioni non sono chiamate di sistema operativo (syscall), ma sono implementate nella C standard library, e dunque nello spazio utente (libcall). Esse comunque si appoggiano su delle chiamate di sistema (tipicamente la sbrk()), che permetteno di allargare o restringere la dimensione dello heap.

Bisogna fare molta attenzione nell'uso di queste due funzioni! Un problema abbastanza grosso è che le due funzioni in questione non sono rientranti, quindi non dovrebbero essere usate all'interno di signal handler, e comunque bisognerebbe utilizzarle con attenzione in presenza di segnali.

[2] Ciò è in realtà possibile possibile tramite la primitiva mmap, che non viene presentata in questo corso per motivi di tempo.