Sei sulla pagina 1di 45

SETI- sistemi 5

INTRODUZIONE AI SISTEMI OPERATIVI 5

SHELL E TERMINALI 6
Directory principali 6
Escaping e quoting: 7
Variabili D’ambiente 7
Redirezioni 8
Scripting 8
Job Control 9

PUNTATORI 9
Sintassi della dichiarazione 9
Compito e indicazioni 10
dereferenziazione 10
Array 10
Aritmetica del puntatore 11
Indicizzazione 11
Interludio: Strutture e unioni 12
Indirizzamento multiplo 12
Puntatori e cost 13
Puntatori alle funzioni 13
Stringhe 15

SYSTEM CALL 16
Wrapper 16

DEBUGGER 16

I/O SUI FILE 17


Offset 18
Limiti 18

PROCESSO DI COMPILAZIONE E LINKING 18


Linking Statico 19
Linking Dinamico 19

SPAZI DI INDIRIZZAMENTO 19

COSA CI SERVE PER FAR “GIRARE” I PROGRAMMI? 19

PROCESSI (API) 20
(P)PID 20
System call principali 20
Fork 20
Exit 21
WAIT 21
Processi Zombie 22
Exec 22

Redirezione dell’I/O 24
Dup 24
Pipe anonime 26
Scheduling 26
Stati di un processo 27
Algoritmi 28
Metriche 28
Algoritmo FIFO 28
Shortest-job first 29
Response time 29
Round-robin 30
Multi Level Feedback Queue 31
Proportional share 31
CFS 31

Thread 32
Creazione di Thread 32
Race condition 33

Locks 33
Mutex implementazione 34

Primitive di sincronizzazione – Spin Lock 35


Bug 35

Sicurezza 37
AAA 37
Shadow e PassWD 37
DAC e MAC e Minimo privilegio 38
Minimo privilegio 38
UID 39
Chroot jails e namespace 39

PERSISTENZA 39
Supporti di memoria 39
Dischi 40
Buffer cache 40

Partizioni 40
File e directory 41
Organizzazione 41

INODE 42
Puntatori ai blocchi dati 43

Directory 43

Permessi 43
Risoluzione dei percorsi. 43
Consistenza dei file-system 44
Journaling 44
1/10

SETI- sistemi

INTRODUZIONE AI SISTEMI OPERATIVI

La cpu attraverso le istruzioni di fetch (CPU va a prendersi le istruzioni dalla RAM), decode (CPU
decodifica le istruzioni) ed esecute, fa girare i programmi.

Tramite interrupt la CPU è informata degli eventi che accadono all’esterno.

Il sistema operativo è un software che gestisce una parte di hardware e lo virtualizza, porta inoltre i
programmi in RAM. Il restante dell’hardware e dei dispositivi di I/O è gestito per gli accessi concorrenti
e per i protocolli diversi da device-driver mentre invece le interfacce “scomode” vengono gestite dal
file-system.
Per ogni processo (programma) ho un numero che lo identifica, chiamato PID (Process ID) non
negativo.

In un certo senso per ogni processo, il S.O. crea una macchina virtuale, alcune istruzioni vengono
eseguite dalla vera CPU, mentre possono esserci system call (istruzioni speciali dove il CPU è in
modalità supervisore) eseguibili dal S.O.
Per virtualizzare in modo efficiente le risorse, i sistemi moderni, sono supportati dall’hardware
(istruzioni privilegiate e non) mentre per virtualizzare la memoria, i sistemi moderni hanno supporto
tramite la paginazione.
Bisogna virtualizzare le risorse hardware minimizzando l’overhead fornendo protezione e isolamento
tra processi diversi compreso anche il S.O. stesso tramite meccanismi di sicurezza.
Il S.O. deve essere robusto ed affidabile, nel caso in cui ci fossero bug nel kernel, questi creerebbero
conseguenze più o meno catastrofiche.

I primi sistemi erano i batch processing, la scelta el batch da eseguire era fatta a mano dall’operatore
umano, un grosso problema presente era la protezione che venne risolta creando due modalità di
esecuzione (sistema/kernel ed utente) e imponendo un meccanismo per passare da una modalità
all’altra chiamato “system call” tipicamente implementato tramite trap/eccezione/interrupt ”software”.

Minicomputer: computer che sfruttano i tempi morti di I/O per fare altro, cioè computer in grado di
svolgere più lavori in memoria allo stesso tempo, alternando la CPU tra i diversi lavori. Usare i tempi
morti per portare programmi in memoria viene detto multiprogrammazione.

UNIX: sistema operativo anni ‘60 abbastanza semplice ma potente scritto in C (e assembler), i
sorgenti venivano distribuiti quasi gratis alle università fino al 1984. Oggi il marchio è registrato,
esistono vari UNIX-like open-source (Linux, xv6 etc.).

Standard: a causa dei tanti fork diversi furono creati 2 standard: POSIX e Single UNIX Specification
che furono successivamente fusi nel 2008.

Shell: interprete di comandi, è un programma utente che offre interfaccia ad alto livello alle
funzionalità del kernel senza che l’utente vada a lavorare direttamente sul kernel, per interagire con la
shell si deve utilizzare un terminale. (Noi faremo riferimento alla GNU bash).

Terminale: dispositivo meccanico nato per il telegrafo nel 1800. In Linux ci sono console virtuali che
sono terminali virtuali che condividono tastiera e schermo. Negli ambienti desktop si può utilizzare un
emulatore di terminale, cioè un programma che emula un terminale testuale, per implementarli si
usano gli pseudo-terminali= coppia di dispositivi a carattere (master, slave).

Ogni programma usa tre file descriptor: input (0), output (1, bufferizzato) e error (2, non bufferizzato).

Funzioni, terminologia e chiamate di sistema:


- reptyr: crea altri terminali
- cat: legge file e stampa contenuto in standard output
- | : pipe
- tee: scrive su file ciò che legge da input
- argc: ritorna il numero di stringhe scritte
- argv: array di stringhe scritte
- atoi: converte da stringa a intero
- &num: indirizzo del numero

8/10

SHELL E TERMINALI

Directory principali

/ radice

/bin e /sbin comandi essenziali e quelli per l’amministrazione

/boot file per il boot del sistema

/dev file speciali che corrispondono a dispositivi

/etc file di configurazione del sistema /home e /root home degli utenti (non root) e root

/lib* librerie

/media e /mnt mount-point per i media rimovibili e altri FS

/proc e /sys file system virtuali che forniscono un’interfaccia alle strutture dati del kernel

/tmp file temporanei, spesso un ramdisk nei sistemi moderni

/usr gerarchia secondaria (/usr/[s]bin, /usr/lib*, . . . ), che può essere condivisa in sola lettura fra più
host

Tra (pseudo-)terminale e processi, c’è la disciplina di linea, normalmente si usa la versione


cooked/canonical che gestisce il buffering, l’editing, l’echo, caratteri speciali (backspace,
Ctrl+C/D/H/Q/S/Z. . . ). Alcune applicazioni, per esempio vi, utilizzano la versione raw.

Per i terminali esiste uno


standard per la segnalazione
in banda per controllare la
posizione del cursore, il colore
e altre opzioni sui terminali
video testo e sugli emulatori di
terminale. Alcune sequenze di byte, la maggior parte che iniziano con Esc (carattere ASCII 27) e '[',
sono incorporate nel testo, che il terminale cerca e interpreta come comandi. Alcuni comandi, per
esempio ls, di default usano i colori solo se il loro standard output è un terminale.
La shell in modalità interattiva, stampa un prompt (=una sequenza di caratteri che indica che è in
attesa di comandi), legge l’input (lo spezza in token: parole e operatori, espande gli alias, fa il parsing
in comandi semplici e composti), esegue varie espansioni ({} ~ $ *?[]), esegue le eventuali redirezioni
dell’I/O, esegue il “comando” (tipicamente un eseguibile/script esterno (ma può essere una funzione o
un comando built-in), per capire se il comando è esterno basta chiedere il “type”), tipicamente, ne
aspetta la terminazione, e poi ricomincia.

COMANDI PIÙ’ IMPORTANTI: man, help( usare l’opzione -h o --help) e type.

N.B. il . non è di default nel PATH standard per evitare che il PC esegui cose che non deve.

Escaping e quoting:

alcuni caratteri hanno significati speciali e devono essere messi tra virgolette o preceduti da “\” per
essere usati.

● i singoli apici permettono l’inserimento di qualsiasi carattere, a parte gli apici stessi
● le doppie virgolette trattano in maniera speciale $ ` e \
● la forma $’...’ permette di espandere i \... alla ANSI-C; per esempio: echo $’ciao\nmondo\x21’
→ ciao<newline>mondo!
● echo -e’ ‘ = è compito del programma espandere sequenze di escape
● echo $’ = è compito della bash svolgere tutto il lavoro’

Nella shell ho variabili alle quali posso espandere il valore.

Comandi utili:

- tab = serve per completare in automatico un comando


- ctrl + r oppure “history” = serve per vedere la storia/ history
- !! = serve per ripetere l’ultimo comando
- !”...” = serve per ripetere il comando tra gli apici scritto in precedenza
- $? = mi da il codice di uscita del comando precedente
- shopt -s autocd
- set -o nounset o, equivalentemente, set -u per far sì che sia un errore leggere una variabile
inesistente
- set -o errexit o, equivalentemente, set -e per far sì che uno script termini al primo errore
- ~ = home dell’utente corrente
- echo ~ “...” = home dell’utente “...”
- echo A{1,2,3}B= A1B A2B A3B → utile per rinominare i file
- $ ((espressione)) = valuta in shell espressioni matematiche
- $(comando) = output comando sostituito sulla riga di comando da un altro comando

La shell è molto sensibile agli spazi, infatti considera come dichiarazione di variabile “A=10” e “A = 10”
come se le chiedessimo di cercare di invocare “A” passando come argomenti “=” e “10”

Variabili D’ambiente

Export name[=value] per specificare una variabile d’ambiente senza export si definisce una variabile
della shell, a meno che la variabile sia già di ambiente, le variabili d’ambiente vengono usate per
specificare impostazioni, come per esempio PATH che specifica alla shell dove cercare i comandi
(quando il nome non contiene /) o
MANPAGER che indica a man quale programma utilizzare per visualizzare le pagine di manuale, in
ogni caso
ogni processo ha la sua copia delle variabili d’ambiente e il comando env, invocato senza argomenti,
le elenca.

Per fare in modo che gli export (o altri comandi) vengano eseguiti ogni volta che aprite una shell,
dovete aggiungerli a uno script.

Redirezioni

● redirezione input: [n]<fname


● redirezione output: [n]>fname e [n]>>fname
● duplicazione fd: [n]<&fd e [n]>&fd; invece di fd si può usare - per chiudere n
● scorciatoia: &>fname equivalente a >fname 2>&1.
● due file speciali che possono tornare comodi:
● /dev/null = tutto quello che viene scritto viene buttato
● /dev/zero = legge tutti gli zeri

Scripting

● Comandi:
● semplici: una sequenza di parole, separate da blank
● pipeline: sequenza di comandi, separati da | o |&
● liste: sequenze di pipeline, separati da ;, &, &&, o ||, opzionalmente terminate da ;, &, o
newline
● possono essere racchiuse fra ( e ), o { e }, per applicare un’unica
redirezione a tutti i comandi nella lista

I comandi possono essere tipicamente interrotti premendo Ctrl+C quando un terminale è in modalità
canonica, coi settaggi di default, Ctrl+C corrisponde a inviare al comando un segnale SIGINT (2) , il
comportamento di default, in risposta al segnale, è la terminazione. Per inviare segnali, si può usare il
comando kill, fra i vari segnali, SIGKILL (9), termina il processo non può essere catturato, bloccato o
ignorato. L’’exit-status di un processo terminato per un segnale s è (s + 128).

Il SEGNALE è un evento asincrono corrispondente ad un interrupt software, per esempio SIGSEGV


(11) che corrisponde al segnale di “segmentation fault”.

Fork-bomb: sequenza di carattere che fa impazzire e finire il pc al CIM

15/10

Il vecchissimo comando per fare i test era: test e [

● [ 1 -eq 2 ]
● [1=2]
● [ 1 == 2 ]
● [ -f /etc/passwd ] [ -w /etc/passwd ] test 1 -ne 2

Il modo moderno per farlo invece, è fatto attraverso bash built-in (e non più un comando esterno), si
fa tramite:

[[ ... ]]

● si possono usare < e > per confrontare stringhe, attenzione ai tipi:


● [ 10 < 2 ], non va bene perché sta cercando di aprire un file, [[ 10 < 2 ]], non va bene perché
si usa solo per le stringhe e infine [[ 10 -lt 2 ]] mi confronta i due e -lt lo uso per confrontare i
numeri. La sintassi non è super intuitiva ma è questa e questa ci teniamo.
● le stringhe vuote non sono un problema; per esempio, provate: [ -f $VAR_NON_ESISTENTE ]
vs [[ -f $VAR_NON_ESISTENTE ]]

Essendo un linguaggio di programmazione posso usare gli if, i cicli for etc. La shell comunque non è
un buon linguaggio per scrivere programmi complicati.

La bash è nata in un periodo storico diverso dal nostro e la convenzione è cambiata, ci sono cose che
ad oggi dovrebbero avere un risultato che sulla bash possono avere seri problemi.

Job Control

Permette di gestire più lavori da un solo terminale. E’ uno strumento molto importante per i terminali
veri (non per gli emulatori).

I job (cioè i processi) sono raggruppati in sessioni (una per terminale) e per ogni pipeline che si
inserisce nella shell, viene creato un job.

-slide 34 esempio di job control-

ogni terminale può avere un job in foreground che può leggere dal terminale e ricevere segnali (se
l’utente usa ˆC, ˆZ, . . .) e tanti job in background (una pipeline terminata da & viene eseguita in
background; $! è il PID del job)

Si usa ˆZ sospende un processo, che si può continuare la sua esecuzione in foreground usando fg [n]
mentre si manda in background con bg [n].

Il comando jobs elenca i job attivi.

Con alcuni comandi built-in (es. kill, wait, disown) i job possono essere identicati con %n; l’ultimo
fermato quando era in foreground, o fatto direttamente partire in background, è %%

PUNTATORI

I puntatori sono strumenti che servono per appunto puntare a una zona di memoria a loro riservata.

int *p; = dichiarazione di un puntatore intero, viene allocata memoria tanta quanta ne necessita il tipo
intero!

int *foo_ptr = &foo → dentro foo_ptr ci va l’indirizzo di foo

Bisogna pensare a ogni variabile come a una scatola.


foo è una casella di dimensioni di (int) byte. La posizione
di questa casella è il suo indirizzo. Quando si accede
all'indirizzo, si accede effettivamente al contenuto della
casella a cui punta.

Questo è vero per tutte le variabili, indipendentemente


dal tipo. Infatti, grammaticalmente parlando, non esiste
una cosa come una "variabile puntatore": tutte le
variabili sono uguali. Esistono però variabili di diverso tipo. il tipo di foo è int. Il tipo di foo_ptr è int *.
(Quindi, "variabile puntatore" significa in realtà "variabile di un tipo puntatore".)

A proposito, anche il puntatore ha un tipo. Il suo tipo è int. Quindi è un "puntatore int" (un puntatore a
int). Il tipo di un int ** è int * (punta a un puntatore a int). L'uso di puntatori a puntatori è chiamato
indiretto multiplo.
Sintassi della dichiarazione

Il modo più ovvio per dichiarare due variabili puntatore in una singola dichiarazione è: int* ptr_a, ptr_b;

Se il tipo di una variabile contenente un puntatore a int è int *,


e una singola dichiarazione può dichiarare più variabili dello stesso tipo semplicemente fornendo un
elenco separato da virgole (ptr_a, ptr_b),
quindi puoi dichiarare più variabili puntatore int semplicemente dando il tipo puntatore int (int *)
seguito da un elenco di nomi separati da virgole da utilizzare per le variabili (ptr_a, ptr_b).
Detto questo, qual è il tipo di ptr_b? int*, giusto?
*bzz* Sbagliato!
Il tipo di ptr_b è int. Non è un puntatore.
La sintassi della dichiarazione del C ignora gli asterischi del puntatore quando si riporta un tipo su più
dichiarazioni. Se dividi la dichiarazione di ptr_a e ptr_b in più dichiarazioni, ottieni questo:
int *ptr_a;
int ptr_b;
Pensalo come assegnare a ciascuna variabile un tipo base (int), più un livello di indiretto, indicato dal
numero di asterischi (ptr_b è zero; ptr_a è uno).
È possibile fare la dichiarazione su una riga in modo chiaro. Questo è il miglioramento immediato:
int *ptr_a, ptr_b;
Notare che l'asterisco si è spostato. Ora è proprio accanto alla parola ptr_a. Una sottile implicazione
di associazione.
È ancora più chiaro mettere prima le variabili non puntatore: int ptr_b, *ptr_a;
Il più chiaro in assoluto è mantenere ogni dichiarazione sulla propria riga, ma ciò può occupare molto
spazio verticale. Usa solo il tuo giudizio.
Infine, vorrei sottolineare che puoi farlo bene:
int *ptr_a, *ptr_b;
Non c'è niente di sbagliato in questo.
Per inciso, C consente zero o più livelli di parentesi attorno al nome della variabile e all'asterisco:
int ((not_a_pointer)), (*ptr_a), (((*ptr_b)));

Compito e indicazioni

Ora, come si assegna un int a questo puntatore? Questa soluzione potrebbe essere ovvia:
pippo_ptr = 42;
È anche sbagliato.
Qualsiasi assegnazione diretta a una variabile puntatore cambierà l'indirizzo nella variabile, non il
valore a quell'indirizzo. In questo esempio, il nuovo valore di foo_ptr (ovvero, il nuovo "puntatore" in
quella variabile) è 42. Ma non sappiamo che questo punta a qualcosa, quindi probabilmente non è
così. Il tentativo di accedere a questo indirizzo probabilmente comporterà una violazione della
segmentazione (leggi: arresto anomalo).
(Per inciso, i compilatori di solito avvertono quando si tenta di assegnare un int a una variabile
puntatore. gcc dirà "avvertimento: l'inizializzazione rende il puntatore da intero senza un cast".)
Quindi, come si accede al valore in un puntatore? Devi dereferenziarlo.

dereferenziazione

int bar = *pippo_ptr;

In questa dichiarazione, l'operatore di dereferenziazione (prefisso *, da non confondere con


l'operatore di moltiplicazione) cerca il valore che esiste in un indirizzo. (Questa è chiamata operazione
di "caricamento".)
È anche possibile scrivere su un'espressione di dereferenziazione (il modo C per dirlo:
un'espressione di dereferenziazione è un lvalue, il che significa che può apparire sul lato sinistro di
un'assegnazione):
*pippo_ptr = 42; Imposta foo a 42
(Questa operazione è chiamata "store".)
Array
Ecco una dichiarazione di un array di tre int:
int array[] = { 45, 67, 89 };
Nota che usiamo la notazione [] perché stiamo dichiarando un array. int *array sarebbe illegale qui; il
compilatore non accetterebbe che gli assegnassimo l'inizializzatore { 45, 67, 89 }.
Questa variabile, array, è una scatola extra-grande: tre interi di spazio di archiviazione.
Una bella caratteristica del C è che, nella maggior parte dei casi, quando usi di nuovo l'array name,
utilizzerai effettivamente un puntatore al suo primo elemento (in termini C, &array[0]). Questo è
chiamato "decadimento": l'array "decade" a un puntatore. La maggior parte degli usi di array è
equivalente a se l'array fosse stato dichiarato come puntatore.
Ci sono, ovviamente, casi che non sono equivalenti. Uno è l'assegnazione all'array name da solo
(array = …) – questo è illegale.
Un altro è passarlo all'operatore sizeof. Il risultato sarà la dimensione totale dell'array, non la
dimensione di un puntatore (ad esempio, sizeof(array) usando l'array sopra verrebbe valutato a
(sizeof(int) = 4) × 3 = 12 su un Mac OS X corrente sistema). Questo dimostra che stai realmente
gestendo un array e non semplicemente un puntatore.
Nella maggior parte degli usi, tuttavia, le espressioni array funzionano come le espressioni puntatore.
Quindi, ad esempio, supponiamo di voler passare un array a printf. Non puoi: quando passi un array
come argomento a una funzione, passi davvero un puntatore al primo elemento dell'array, perché
l'array decade in un puntatore. Puoi dare a printf solo il puntatore, non l'intero array. (Questo è il
motivo per cui printf non ha modo di stampare un array: sarebbe necessario che tu gli dicessi il tipo di
cosa c'è nell'array e quanti elementi ci sono, e sia la stringa di formato che l'elenco degli argomenti
diventerebbero rapidamente confusi.)
Il decadimento è un implicito &; matrice == &array == &array[0]. In inglese, queste espressioni
leggono "array", "pointer to array" e "pointer to the first element of array" (l'operatore pedice, [], ha una
precedenza maggiore dell'operatore address-of). Ma in C, tutte e tre le espressioni significano la
stessa cosa. (Non significherebbero tutti la stessa cosa se "array" fosse effettivamente una variabile
puntatore, poiché l'indirizzo di una variabile puntatore è diverso dall'indirizzo al suo interno, quindi
l'espressione centrale, &array, non sarebbe uguale alle altre due espressioni.Le tre espressioni sono
tutte uguali solo quando array è davvero un array.)

Aritmetica del puntatore

Supponiamo di voler stampare tutti e tre gli elementi dell'array.


int *array_ptr = matrice;
printf("primo elemento: %i\n", *(array_ptr++));
printf("secondo elemento: %i\n", *(array_ptr++));
printf("terzo elemento: %i\n", *array_ptr);

Nel caso in cui non si abbia familiarità con l'operatore ++: aggiunge 1 a una variabile, come la
variabile += 1 (ricorda che poiché abbiamo usato l'espressione postfissa array_ptr++, anziché
l'espressione prefisso ++array_ptr, l'espressione valutata al valore di array_ptr da prima che fosse
incrementato invece che dopo).
Ma cosa ne abbiamo fatto qui? Bene, il tipo di puntatore è importante. Il tipo del puntatore qui è int.
Quando aggiungi o sottrai da un puntatore, l'importo per cui lo fai viene moltiplicato per la dimensione
del tipo di puntatore. Nel caso dei nostri tre incrementi, ogni 1 che hai aggiunto è stato moltiplicato per
sizeof(int).
A proposito, sebbene sizeof(void) sia illegale, i puntatori void vengono incrementati o decrementati di
1 byte.
Nel caso ti stia chiedendo 1 == 4: ricorda che prima, ho detto che gli int sono quattro byte sugli attuali
processori Intel. Quindi, su una macchina con un tale processore, aggiungendo 1 o sottraendo 1 da
un puntatore int lo cambia di quattro byte. Quindi, 1 == 4. (Umorismo del programmatore.)

Indicizzazione
printf("%i\n", array[0]);
OK...cosa succede? Semplice, stampa 45.
Beh, probabilmente l'hai capito. Ma cosa c'entra questo con i puntatori?
Questo è un altro di quei segreti di C. L'operatore pedice (il [] in array[0]) non ha nulla a che fare con
gli array.
Oh, certo, questo è il suo uso più comune. Ma ricorda che, nella maggior parte dei contesti, gli array
decadono in puntatori. Questo è uno di questi: è un puntatore che hai passato a quell'operatore, non
un array.
A titolo di prova:
int array[] = { 45, 67, 89 };
int *array_ptr = &array[1];
printf("%i\n", array_ptr[1]);

Ecco uno schema:

Il secondo elemento di array_ptr è il terzo elemento di array. array


punta al primo elemento dell'array; array_ptr è impostato su
&array[1], quindi punta al secondo elemento dell'array. Quindi
array_ptr[1] è equivalente a array[2] (array_ptr inizia dal secondo
elemento dell'array, quindi il secondo elemento di array_ptr è il terzo
elemento dell'array).
Inoltre, poiché il primo elemento è sizeof(int) byte wide (essendo un
int), il secondo elemento è sizeof(int) byte avanti rispetto all'inizio
dell'array. array[1] è equivalente a *(array + 1). (N.B. il numero aggiunto o sottratto da un puntatore
viene moltiplicato per la dimensione del tipo del puntatore, in modo che "1" aggiunga sizeof(int) byte
al valore del puntatore.)

Interludio: Strutture e unioni


Due dei tipi più interessanti in C sono le strutture e le unioni. Si crea un tipo di struttura con la parola
chiave struct e un tipo di unione con la parola chiave union.
Le definizioni esatte di questi tipi esulano dall'ambito di questo articolo. Basti dire che una
dichiarazione di una struttura o di un'unione si presenta così:
struttura foo {
size_t size;
char name[64];
int answer_to_ultimate_question;
unsigned shoe_size;
};
Ognuna di queste dichiarazioni all'interno del blocco è chiamata membro. Anche i sindacati hanno
membri, ma sono usati in modo diverso. L'accesso a un membro è simile a questo:
struct pippo mio_pippo;
my_foo.size = sizeof(struct foo); L'espressione my_foo.size accede alla dimensione del membro di
my_foo.
Quindi cosa fare se si ha un puntatore a una struttura? Un modo per farlo: (*pippo_ptr).size =
new_size; Ma c'è un modo migliore, specifico per questo scopo: l'operatore pointer-to-member.
foo_ptr->size = new_size;
Sfortunatamente, non sembra così buono con più indiretti.
(*pippo_ptr_ptr)->size = new_size;
(**foo_ptr_ptr).size = new_size;
Rant: Pascal lo fa molto meglio. Il suo operatore di dereferenziazione è un suffisso ^:
foo_ptr_ptr^^.size := new_size;

Indirizzamento multiplo

Voglio spiegare un po' di più l'indiretto multiplo.

Considera il seguente codice:

int a = 3;
int *b = &a;
int **c = &b;
int ***d = &c;

Ecco come i valori di questi puntatori si equivalgono tra loro:

*d == c; Dereferenziando un (int ***) una volta si ottiene un (int **) (3 - 1 = 2)


**d == *c == b; Dereferenziare un (int ***) due volte, o un (int **) una volta, ottiene un (int *) (3 - 2 = 1;
2 - 1 = 1)
***d == **c == *b == a == 3; Dereferenziando un (int ***) tre volte, o un (int **) due volte, o un (int *)
una volta, si ottiene un int (3 - 3 = 0; 2 - 2 = 0; 1 - 1 = 0)
Pertanto, l'operatore & può essere pensato come l'aggiunta di asterischi (aumentando il livello del
puntatore, come lo chiamo io) e gli operatori *, -> e [] come rimuovendo gli asterischi (diminuendo il
livello del puntatore).

Puntatori e cost

La parola chiave const viene utilizzata in modo leggermente diverso quando sono coinvolti i puntatori.
Queste due dichiarazioni sono equivalenti:

const int *ptr_a;


int const *ptr_a;

Questi due, tuttavia, non sono equivalenti:

int const *ptr_a;


int *const ptr_b;

Nel primo esempio, l'int (cioè *ptr_a) è const; non puoi fare *ptr_a = 42. Nel secondo esempio, il
puntatore stesso è const; puoi cambiare *ptr_b bene, ma non puoi cambiare (usando l'aritmetica del
puntatore, ad esempio ptr_b++) il puntatore stesso.

Puntatori alle funzioni

Nota: la sintassi di tutto ciò sembra un po' esotica. È. Confonde molte persone, anche i maghi del C.
Sopportami.

È anche possibile prendere l'indirizzo di una funzione. E, analogamente agli array, le funzioni
decadono in puntatori quando vengono utilizzati i loro nomi. Quindi, se volessi l'indirizzo di, diciamo,
strcpy, potresti dire strcpy o &strcpy. (&strcpy[0] non funzionerà per ovvi motivi.)

Quando chiami una funzione, usi un operatore chiamato operatore di chiamata di funzione.
L'operatore di chiamata di funzione prende un puntatore a funzione sul lato sinistro.

In questo esempio, passiamo dst e src come argomenti all'interno e strcpy come funzione (ovvero il
puntatore alla funzione) da chiamare:

enum { str_length = 18U }; Ricorda il terminatore NUL!


char src[str_length] = "Questa è una stringa.", dst[str_length];

strcpy(dst, src); L'operatore di chiamata di funzione in azione (notare il puntatore alla funzione sul lato
sinistro).

Esiste una sintassi speciale per dichiarare le variabili il cui tipo è un puntatore a funzione.

char *strcpy(char *dst, const char *src); Una normale dichiarazione di funzione, per riferimento
char *(*strcpy_ptr)(char *dst, const char *src); Puntatore a una funzione simile a strcpy
strcpy_ptr = strcpy;
strcpy_ptr = &strcpy; Funziona anche questo
strcpy_ptr = &strcpy[0]; Ma non questo

Nota le parentesi intorno a *strcpy_ptr nella dichiarazione precedente. Questi separano l'asterisco che
indica il tipo restituito (char *) dall'asterisco che indica il livello del puntatore della variabile (*strcpy_ptr
— un livello, puntatore alla funzione).

Inoltre, proprio come in una normale dichiarazione di funzione, i nomi dei parametri sono facoltativi:

char *(*strcpy_ptr_noparams)(char *, const char *) = strcpy_ptr; Nomi dei parametri rimossi — sempre
dello stesso tipo

Il tipo del puntatore a strcpy è char *(*)(char *, const char *); potresti notare che questa è la
dichiarazione dall'alto, meno il nome della variabile. Lo useresti in un cast. Per esempio:
strcpy_ptr = (char *(*)(char *dst, const char *src))my_strcpy;

Come ci si potrebbe aspettare, un puntatore a un puntatore a una funzione ha due asterischi


all'interno delle parentesi:

char *(**strcpy_ptr_ptr)(char *, const char *) = &strcpy_ptr;

Possiamo avere un array di puntatori a funzione:

char *(*strcpies[3])(char *, const char *) = { strcpy, strcpy, strcpy };


char *(*strcpies[])(char *, const char *) = { strcpy, strcpy, strcpy }; La dimensione dell'array è
facoltativa, come sempre

strcpies[0](dst, src);

Ecco una dichiarazione patologica, tratta dallo standard C99. "[Questa dichiarazione] dichiara una
funzione f senza parametri che restituisce un int, una funzione fip senza alcuna specifica di parametro
che restituisce un puntatore a un int e un puntatore pfi a una funzione senza specifica di un parametro
che restituisce un int." (6.7.5.3[16])

int f(void), *fip(), (*pfi)();

In altre parole, quanto sopra equivale alle seguenti tre dichiarazioni:

int f(vuoto);
int *fip(); Funzione che restituisce il puntatore int
int(*pfi)(); Puntatore alla funzione che restituisce int

Ma se pensavi che fosse strabiliante, preparati...

Un puntatore a funzione può anche essere il valore di ritorno di una funzione. Questa parte è davvero
strabiliante, quindi allunga un po' il cervello per non rischiare di farti male.

Per spiegarlo, riassumerò tutta la sintassi della dichiarazione che hai imparato finora. Innanzitutto,
dichiarando una variabile puntatore:

carattere *ptr;

Questa dichiarazione ci dice il tipo di puntatore (char), il livello del puntatore (*) e il nome della
variabile (ptr). E gli ultimi due possono andare tra parentesi:

carattere (*ptr);
Cosa succede se sostituiamo il nome della variabile nella prima dichiarazione con un nome seguito
da un insieme di parametri?

char *strcpy(char *dst, const char *src); eh. Una dichiarazione di funzione

Ma abbiamo anche rimosso il * che indica il livello del puntatore: ricorda che * in questa dichiarazione
di funzione fa parte del tipo restituito della funzione. Quindi, se aggiungiamo di nuovo l'asterisco a
livello di puntatore (usando le parentesi):

char *(*strcpy_ptr)(char *dst, const char *src); Una variabile puntatore a funzione!

Ma aspetta un minuto. Se questa è una variabile e anche la prima dichiarazione era una variabile,
non possiamo sostituire il nome della variabile in QUESTA dichiarazione con un nome e un insieme di
parametri? SÌ POSSIAMO! E il risultato è la dichiarazione di una funzione che restituisce un puntatore
a funzione:

char *(*get_strcpy_ptr(void))(char *dst, const char *src);

Ricorda che il tipo di un puntatore a una funzione che non accetta argomenti e restituisce int è int
(*)(void). Quindi il tipo restituito da questa funzione è char *(*)(char *, const char *) (con, ancora,
l'interno * che indica un puntatore e l'esterno * che fa parte del tipo restituito della funzione puntata) .
Potresti ricordare che questo è anche il tipo di strcpy_ptr.

Quindi questa funzione, che viene chiamata senza parametri, restituisce un puntatore a una funzione
simile a strcpy:

strcpy_ptr = get_strcpy_ptr();

Poiché la sintassi del puntatore a funzione è così stravagante, la maggior parte degli sviluppatori usa i
typedef per astrarre:

typedef char *(*strcpy_funcptr)(char *, const char *);

strcpy_funcptr strcpy_ptr = strcpy;


strcpy_funcptr get_strcpy_ptr(void);

Stringhe

Non esiste un tipo di stringa in C.


Le stringhe C sono in realtà solo array di caratteri:

char str[] = "I am the Walrus";

Questo array è lungo 16 byte: 15 caratteri per "I am the Walrus", più un terminatore NUL (valore byte
0). In altre parole, str[15] (l'ultimo elemento) è 0. È così che viene segnalata la fine della “stringa”.

Questo idioma è la misura in cui C ha un tipo stringa. Ma questo è tutto: un idioma. Tranne che è
supportato da:

● la suddetta sintassi letterale stringa


● la libreria di stringhe
Le funzioni in string.h sono per la manipolazione delle stringhe. Ma come può essere, se non esiste
un tipo di stringa? Perché, lavorano sui puntatori.

Ecco una possibile implementazione della semplice funzione strlen, che restituisce la lunghezza di
una stringa (escluso il terminatore NUL):

size_t strlen(const char *str) { //Nota la sintassi del puntatore qui


size_t lunghezza = 0U;
while(*(str++)) ++len;
restituire la len;
}

Si noti l'uso dell'aritmetica del puntatore e del dereferenziamento. Questo perché, nonostante il nome
della funzione, non c'è nessuna "stringa" qui; c'è semplicemente un puntatore ad almeno un carattere,
l'ultimo è 0.

Ecco un'altra possibile implementazione:

size_t strlen(const char *str) {


taglia_t io;
for(i = 0U; str[i]; ++i); //Quando il ciclo termina, i è la lunghezza della stringa
return;
}

Quello usa l'indicizzazione. Che utilizza un puntatore (non un array e sicuramente non una stringa).

19/10

SYSTEM CALL

Le system call vengono usate per gestire file e processi.


Come si sa i processi non possono parlare direttamente con il Sistema Operativo, quindi usano le
system call, che fanno passare il Sistema Operativo dalla modalità utente a quella supervisore, per far
passare il tutto in mano al kernel, che si occupa dell’esecuzione dei comandi richiesti dai processi.
Ad ogni system call è assegnato un numero per il riconoscimento.

Wrapper

La libreria C, per ogni system call, offre le funzioni “wrapper”, sono le funzioni al loro interno
contengono il codice macchina necessario per far funzionare le system call e che preparano gli
argomenti per le system call salvandoli in registri.
Le wrapper inoltre eseguono le trap che permettono di passare dalla modalità utente a quella
supervisore ( in questo modo il kernel può controllare la validità delle richieste ed eseguirle salvando i
risultati. Una volta fatto il Sistema Operativo torna in modalità utente tramite un’istruzione speciale
chiamata “IRET” ), controllano il risultato e in caso di errori impostano errno a -1, altrimenti
restituiscono il risultato. Nel caso in cui la system call non fallisca, non è detto che errno venga
annullata/azzerata o modificata.

Si dovrebbe preferire per quanto possibile l’uso di funzioni alle system call perché queste ultime sono
molto più costose. Per diminuire il costo alcune librerie bufferizzano le richieste e ne inviano una
unica al kernel per farlo lavorare meno.

N.B. controllare il valore di ritorno di una funzione è una buona abitudine che può evitare di perdere
tempo nel ricercare eventuali errori nel codice. (vedere errno(3), perrno(3), strerror(3), ricorda che il
“3” significa che si deve guardare la sezione tre del manuale).

Unix diversi utilizzano interi di dimensione diversa per dare numeri alle system call (PID), per evitare
eventuali problemi che potrebbe causare se si volesse scrivere del codice portabile, vengono usati
alias tramite “getpid”. L’utilizzo di getpid potrebbe dare problemi nel caso in cui si volessero stampare
i PID (tramite printf etc.), un buon approccio potrebbe essere quello di utilizzare i cast di un tipo molto
grande come i long oppure “intmax_t” e “uintmax_t”, stampandoli con %jd e %ju, dal C99 in poi.
DEBUGGER

Per trovare errori come i segmentation fault si possono usare i debugger, meglio ancora utilizzando le
informazioni di debug (-ggdb) e lanciare il debugging con gbd nomeprogramma. Così facendo il
debugger riesce a mostrare a video l’esatto punto di schianto del processo.
Attraverso il debugger si può eseguire e nel mentre controllare il processo, per migliorare il controllo si
possono inserire nel codice “breakpoint”, cioè punti nel quale il debugger fa fermare il programma per
verificare eventuali risultati intermedi. I breakpoint possono avere condizioni di esecuzione, è
possibile mettere breakpoint anche per la modifica della memoria (si cambiano valori a caso che non
dovrebbero) attraverso una “watch”.

Si possono eseguire i singoli comandi digitando “s” o “n“, la differenza tra i due è che s permette di
entrare nella funzione e seguirne lo sviluppo manualmente mentre n non da il controllo della funzione,
la svolge autonomamente e poi ri da il controllo manuale.

I/O SUI FILE

Tutto l’I/O avviene sui file descriptors, un file descriptor è un int non negativo che identifica un file
aperto, in unix molte cose vengono viste come file, si ha una visione generale (chiamano molti file
generali ma possono essere visti come file anche connessioni, socket etc. ).
Ogni processo ha i suoi file descriptor, i numeri a loro dedicati hanno senso solo per quel processo.
Esistono tre file descriptor “speciali”:
0 stdin/cin → standard input
1 stdout/cout → standard output
2 stderr/ cerr → standard error

Per aprire i file si usa la system call “open()” che prende due o tre argomenti (fa un po’ schifo ma va
bene - hackerino), se non si vuole creare un nuovo file, si usano due argomenti altrimenti tre.
Il primo argomento da passare è il percorso del file(il nome), il secondo argomento serve per
specificare il tipo di uso che se ne deve fare. Tutti questi flag hanno a che fare con il file, per il file
descriptor si ha a disposizione solo FD_CLOEXEC.
Il terzo argomento, lo si mette solo se si vuole creare il file, e serve per dare i permessi alla prossima
apertura del file.
Per esempio se si volessero dare i permessi di lettura/scrittura al proprietario, sola lettura al gruppo di
appartenenza e sola lettura a tutti gli altri utenti che voglio aprire il file, al terminale si vedrebbe una
cosa del genere: -rw-r-r----
I bit sono a gruppi da tre → risulta comodo esprimerli in ottale(https://chmodcommand.com/
lo si può usare per vedere a quali permessi corrisponde un numero in ottale).

Esiste un utente “speciale” chiamato ROOT che può eseguire ed avere accesso a tutti i file, purchè
questi siano eseguibili. Ha ID=0.

ESERCIZIO

Scrivere un programma che crea un file (regolare) chiamato pippo

● Che sia leggibile solo dal proprietario (non scrivibile da nessuno, proprietario
compreso)
○ Cosa succede se il file esiste già?
○ La syscall va a buon fine?
○ Il contenuto del file esistente viene preservato?
○ Potete fare in modo che un file già esistente non venga ri-creato per errore?
● Modificare quel file con un editor di testo (potete farlo? come?)

ssize_t read(int fd, void *buf, size_t count); viene utilizzata per leggere file, cerca di leggere il numero
di byte inseriti, se ne legge meno allora ritorna il numero di byte letti. Se fallisce restituisce -1.
ssize_t write(int fd, const void *buf, size_t count); è assolutamente identica alla read solo che scrive
invece che leggere.

int close(int fd); può fallire (per esempio, fd già chiuso, errori specifici di alcuni FS) in ogni caso, il file
descriptor viene rilasciato → ritentare la chiusura può portare a oscure race-condition in programmi
multithread.

ESERCIZIO

Scrivere un “cat dei poveri”, ovvero un programma che se invocato senza parametri legge da
standard-input altrimenti, dal/dai file specificati da riga di comando scrivendo tutto quello che riesce a
leggere su standard-output.

Offset

Quando si apre un file, al file viene assegnato un cursore/offset, un pointer che indica la posizine
dove si sta leggendo/scrivendo, l’offset si sposto di byte in byte. L’offset di un file appena aperto è =0,
non ha senso usarlo per file descriptor di tipo socket etc. mentre per tutti gli altri file viene usata una
system call chiamata “lseek(in fd, off_t offset, int whence); ”

Per recuperare i metadati di un file:


● int stat(const char *pathname, struct stat *statbuf);
● int lstat(const char *pathname, struct stat *statbuf);
○ come stat, ma non segue i link simbolici
● int fstat(int fd, struct stat *statbuf);

(vedi struct stat )

Limiti

Ogni tipo di filesystem ha dei limiti, per cui, per esempio, la lunghezza massima di un nome file può
variare a seconda della directory considerata. Per leggere i limiti si può usare:

● getconf(1); per esempio:


○ getconf NAME_MAX /bin
● fpathconf(3)/pathconf(3); per esempio:
○ printf("%ld\n", pathconf("/bin", _PC_NAME_MAX));

N.B. Esistono delle costanti _POSIX_qualcosa che, nonostante abbiano “MAX” nel nome,
corrispondono alla misura minima che deve essere garantita. Per esempio, _POSIX_NAME_MAX è
14, mentre sul mio Linux la lunghezza massima di un nome di file è 255.

29/10
PROCESSO DI COMPILAZIONE E LINKING

gcc/clang sono programmi che “pilotano” il processo di compilazione lanciando il processore, il


compilatore, l’assemblatore e il linker. Non è quindi gcc il compilatore stesso.

Il file oggetto contiene il codice macchina (con buchi) con i metadati che specificano i nomi delle cose
e di quello che è stato usato, contiene inoltre le informazioni di rilocazione.

Gli eseguibili infatti, così come i file rilocabili (.o) e le librerie possono contenere:

● Codice macchina
● Dati
● Metadati

Tutto questo viene messo insieme dai linker attraverso l’unione di tutti i file .o che permettono di
mandare in esecuzione il programma,infatti il link editor (AKA linker statico), mette assieme
file-oggetto/librerie, eseguendo rilocazione e risoluzione dei simboli.

In un eseguibile le sezioni vengono raggruppate in segmenti, i “pezzi” che vengono mappati in


memoria dal sistema operativo/linker-dinamico per l’esecuzione, questi segmenti NON
necessariamente corrispondono ai segmenti della CPU/MMU.

Il linking può essere fatto in modo:

● Dinamico
● Statico

Linking Statico

Il link editor fa tutto il lavoro, creando programmi autocontenuti, i “pezzi” di librerie necessari vengono
copiati dentro al programma e gli eseguibili funzionano su “tutte” le macchine (con stesso HW/s.o.).
(per eseguire aggiungere -static)

Linking Dinamico

In questo caso il linker non include il codice di libreria all’interno del codice macchina ma segna le
dipendenze esterne, prima di mandare in esecuzione il programma il linker va a cercare le librerie
richieste, fra cui, il suo interprete, ovvero, il linker dinamico: ld.so, e poi fa partire il programma
mettendo assieme i pezzi mancanti via via che va avanti il programma.
Il linking dinamico è di default sui sistemi moderni, è migliore rispetto allo statico perché fa risparmiare
spazio, sia su disco, sia in memoria fisica e perché facilita l’aggiornamento (ma anche la “distruzione
globale”), tuttavia può essere problematico portare l’eseguibile da una macchina a un’altra.

SPAZI DI INDIRIZZAMENTO

Ogni processo “vede” il suo spazio di indirizzamento (address space), un’astrazione della memoria
fisica.
Ovvero, i processi usano indirizzi logici/virtuali che vengono tradotti in indirizzi fisici dalla MMU
tramite: segmentazione e/o paginazione.
COSA CI SERVE PER FAR “GIRARE” I PROGRAMMI?

Oltre che mappare i dati e i codici da un ELF, sono necessari anche:


● stack — variabili locali/temporanei, parametri, indirizzi di ritorno
○ per ragioni “storiche”, cresce verso indirizzi decrescenti
● heap — memoria dinamica: malloc, free, etc
○ non serve davvero un altro segmento, può crescere il segmento dati
● il kernel, non accessibile in modalità utente
● etc.

(proc/$$/maps → mostra l’indirizzamento della bash in uso)

Il codice di programmi e, grazie al linking dinamico, librerie può essere condiviso (e mappato
da indirizzi diversi) in processi diversi.

PROCESSI (API)

(P)PID

Il PID è unico (in quel momento, cioè se il processo muore, un processo successivo può riprendere lo
stesso numero), per vedere il PID di un processo si può usare la system call “pid_t getpid(void)”
mentre “pid_t getppid(void)” mostrerà il PID del padre del PID.
I processi formano un albero, tramite “fork” si possono distinguere padre da figlio.
La radice dell’albero è chiamata “init” che è avviata durante la fase di bootstrap.
Il kernel espone le informazioni tramite lo pseudo-filesystem /proc una directory per ogni processo,
vari file ( la maggior parte a sola lettura, per esempio, maps mostra lo spazio di indirizzamento)

Ogni processo ha due directory particolari:

● Directory root → Questo tipo di directory iniziano per “/qualcosa” e indicano il cammino dove
cercare le cose dopo “/”. Non è necessariamente la root del file-system, bisogna avere dei
privilegi per cambiarla.
● Directory di lavoro → Questa directory viene usata per tutti i percorsi relativi, per cambiarla si
può usare “chdir” oppure più semplicemente “cd”.

System call principali

Le principali system call per gestire i processi sono 4:

● fork, crea un nuovo processo


● _exit (standard C: exit), termina il processo chiamante
○ quando non ci saranno ambiguità, diremo semplicemente exit wait, aspetta la
terminazione di un processo figlio
● execve, esegue un nuovo programma nel processo chiamante, sostituendo l’intero spazio di
indirizzamento, viene spesso invocata dopo fork.

Fork

pid_t fork(void)
E’ una system call che clona un processo creando un nuovo processo, detto “figlio” del processo
chiamante. Il nuovo processo è quasi identico al precedente, ha un PID diverso come anche il (P)PID.
L’intero spazio di indirizzamento viene duplicato (nei sistemi moderni, copia logica grazie al
copy-on-write), la cosa particolare di fork è che quando va a buon fine ritorna due volte, una per il
padre con il PID del figlio e una per il figlio con valore 0.
Normalmente gdb continua a seguire solo uno dei processi (per esempio Clion fa casini), per
scegliere quale seguire: set follow-fork-mode [child|parent] mentre per seguirli entrambi si può
usare set detach-on-fork off (per vedere i processi collegati a gdb: info inferiors mentre per
selezionare quello corrente: inferior id ).

Esempio:

Quanti pippo
vengono
stampati ?
Vengono
stampati 4
pippo, perché
abbiamo un
processo che
fa “fork, fork e
poi print”:

Exit

Esistono due
tipi di system
call Exit:
● void exit(int status) → standard C → Chiama le funzioni registrate con atexit(3) e on_exit(3),
svuota i buffer di I/O ed elimina file temporanei creati con tmpfile(3)
● void _exit(int status) → Posix

Entrambe le system call chiudono/rilasciano risorse del processo, il processo termina con
exit-status.Dalla bash si può vedere il valore di ritorno con “echo $?”.
Quando si termina un processo che ha figli, questi vengono etichettati come orfani e vengono adottati
da “init” (PID=1).
WAIT

E’ una system call utilizzata per vedere cosa succede ad un processo figlio, questa system call
attende un cambio di stato di un figlio qualsiasi (terminazione oppure stop/ripartenza, tramite segnali),
ritorna il primo figlio che capita .
Se si vuole aspettare un figlio specifico si deve utilizzare waitpid.

Se wait va a buon fine, wstatus!=0 e

● WIFEXITED(*wstatus), allora potete recuperare lo exit-status con WEXITSTATUS(*wstatus)


● WIFSIGNALED(*wstatus), allora potete recuperare il segnale con WTERMSIG(*wstatus)

2/11

Processi Zombie

E’ un processo che è terminato e con il padre che non ha ancora fatto la wait, sino ad allora il
processo rimarrà zombie. Prima o poi il parent farà la chiamata wait, il processo rimarrà appeso sino
ad allora e non consumerà la CPU.
Per far si che un processo sia considerato zombie si hanno due condizioni:
● Essere morti
● Avere il parent vivo che non ha ancora fatto la wait
Quando il processo termina, al padre, arriva un segnale (SIGCHILD) che viene solitamente ignorato
ma che può essere usato per aspettare i figli.
In Unix i segnali non si accodano, per esempio se morissero due figli a distanza molto ravvicinata
arriverebbe un solo segnale di SIGCHILD.

Exec

Di Exec a livello di system call c’è ne è solo una : execve, tuttavia esistono delle varianti in C.
La system call prende come argomento:
- percorso → file eseguibile (file ELF o script di shell)
- array di stringhe → argomento della riga di comando (arg[ ])
- array di stringhe → stringhe di ambiente, le si possono usare per personalizzare l’ambiente

In generale alla execv servono questi tre parametri, nelle varianti invece si possono passare per
argomento non, per esempio, l’intero percorso ma il nome del file. In base alle caratteristiche della
variante di execve cambia il nome, se in questo sono contenuti:
- v → significa che viene usato un vettore
- l → indica che gli argomenti solo listati/elencati
- p → indica che la funzione cerca automaticamente il path ricevendo come argomento il nome
del file
- e → le funzioni che la contengono nel nome specificano anche l’ambiente, sempre tramite
vettore. N.B. se non si specifica l’ambiente, utilizza quello del padre.

Infatti, per esempio, execvp cerca automaticamente il path dopo aver chiesto come argomento il
nome del file.
Bisogna sapere inoltre che la system call exec cambia l’indirizzamento ma non il PID, PPID e i file
descriptor, è infatti importante che la exec non tocchi i file descriptor 0,1,2 (standard input, standard
output e standard error) a meno che non ci sia specificato tramite un flag specifico (FD_CLOEXEC)
che chiude i file descriptor.
N.B. FD_CLOEXEC è un flag molto utile per la sicurezza!

Quando eseguiamo un programma il sistema (kernel+linker) prepara:

● codice, mappato r-x (.text)


● dati a sola lettura, mappati r-- (.rodata)
● dati, mappati rw- (.data, .bss e heap)
● stack, mappato rw-
○ in fondo allo stack (quindi, a indirizzi più grandi) il kernel copia:
■ le variabili d’ambiente
■ gli argomenti della riga di comando (altra “roba”)
● (il kernel, “invisibile”)

Poi,

● in caso di linking dinamico fa la stessa cosa, ricorsivamente, per codice e dati delle
librerie necessarie (+linking vero e proprio)
● quando viene creato un thread, viene anche allocato un nuovo stack

Nei sistemi moderni, codice/dati vengono portati in RAM on-demand.


Solitamente è sconsigliato eseguire su bash, anche perchè non fa la fork.

Esempio:
$ ls -l execlp(“/s”,”-l”, null); → da prompt bash

N.B. execlp(“/s”,”-l”, null) non è una system call, è una funzione di libreria che chiama la system call
passando i parametri richiesti in modo corretto → execve(“/bin/ls”, array1, array2). A questo punto la
system call va a vedere se sul disco è presente quello che sta cercando, nel caso in cui la ricerca non
va a buon fine fallisce e ritorna -1, la system call fallisce anche nel caso in cui si ha un file con quel
nome ma non si hanno i permessi giusti per leggere/modificare oppure se il file non è eseguibile.
Tuttavia supponendo che il file (ELF) esista sul disco allora avremo che attraverso l’ELF si andrà a
creare il nuovo spazio di indirizzamento.

Se qualcosa fallisce si butta tutto via e restituisce errore, se invece va tutto bene si butta via il vecchio
spazio di indirizzamento e lo si sostituisce con il nuovo.

$ ls/usr/bin & → indica di aspettare (wait)


$ [1] numero → indica il PID
→ indica il numero di job

Le variabili d’ambiente sono scritte nello stack come anche gli argomenti passati da riga di comando.

ls -l | wc -l :così facendo l’output della prima parte (prima della barretta verticale) diventa l’input della
seconda parte.

Redirezione dell’I/O

Dup

Le system call per fare redirezione sono:


open → che restituisce il file descriptor più piccolo che può. Attraverso la open si può reindirizzare in
modo brusco un file descriptor chiudendolo e riaprendolo con open. Questo è un approccio non ideale
perchè in caso di errori si perde per sempre il file da reindirizzare, tuttavia se la cosa va a buon fine
funziona bene.
Si può fare la stessa cosa in modo più “elegante” attraverso la system call “dup”, questa system call
duplica il file descriptor da reindirizzare e ne crea uno nuovo identico al primo. Come la open la
system call dup crea il file descriptor più piccolo possibile.

Relazione tra file descriptor e file aperti in posix


Questa struttura dati è chiamata PCD (Posix Conformance Document). Per ogni processo è presente
una tabella contenente i file descriptors con i relativi flag e i puntatori ai file.
File descriptor diversi possono puntare allo stesso file offset e allo stesso inode (= alla struttura dati
che corrisponde al disco), quando più file puntano allo stesso inode è possibile che sia stato aperto lo
stesso file più volte sul disco. Per ognuno di questi file ho un offset diverso e quindi li si considera file
totalmente diversi uno dall’altro. Usando la dup la situazione cambia in quanto i due file sono
esattamente uguali, non c’è dunque differenza nell’usare uno o l’altro file.
La system call dup permette di salvare il file descriptor aperto, chiuderlo e riportarlo. Se la chiamata
fallisce non c’è problema, la si rifà sulla duplicazione.
Esempio:

Nel momento in cui 4 creato da dup punta allo stesso


file di 1, quest’ultimo si può chiudere liberandolo. Si può
fare la open e se fallisce allora si può tornare alla
situazione iniziale altrimenti si può aprire il file e liberare
4.
N.B. quando si elimina un file in realtà si sta eliminando il nome, per cancellare un file si devono
chiudere tutti i file descriptor. Se si cancellano tutti i nomi ma il file era già aperto, questo rimarrà
aperto, una volta chiuso però non sarà più utilizzabile.

Pipe anonime

int pipe(int pipefd[2])

Crea un canale unidirezionale (anonimo), il canale è un byte-stream: non c’è un concetto di


messaggio e/o corrispondenza fra numero di scritture e letture, viene tipicamente usato per far
comunicare processi.

A|B|C|D → utilizzare una pipe in questo modo fa sì che l’output di A diventi input di B e che il suo
output sia l’input di C e così via.
Per redirigere l’output si possono usare dup, open etc. visti in precedenza. Le pipe sono usate per
comunicare ed utilizzano due file descriptor:
- leggere
- scrivere
A livello di kernel questi due file sono dei buffer evitando così di scrivere e leggere sul disco.
N.B. pipefd[0] è il “lato” lettura, pipefd[1] quello scrittura
Quando si cerca di leggere una pipe vuota possono succedere cose diverse in base ai fd presenti, se
i fd di scrittura sono aperti si viene messi in attesa altrimenti, se il flag O_NONBLOCK è attivo,
la write provoca SIGPIPE. In modo analogo se si prova a leggere e il flag O_NONBLOCK è abilitato
fallisce, nel caso in cui tutti i fd siano stati chiusi read restituisce 0 come EOF.

Disegno PIPE

Scheduling

Per virtualizzare la CPU si utilizza il time sharing, cioè ogni processo riceve l’uso (esclusivo) della
CPU per un po’, poi si passa a un altro (per farlo bene il sistema operativo deve virtualizzare la CPU
in modo efficiente mantenendo il controllo sul sistema. Per fare ciò, sarà necessario il supporto sia
dell'hardware che del sistema operativo. Il sistema operativo utilizza spesso un discreto supporto
hardware per svolgere il proprio lavoro in modo efficace).
Il meccanismo che permette di cambiare processo, è chiamato cambio di contesto (context
switch).
Lo scheduler è quella parte di kernel che decide chi è il prossimo processo da mandare in
esecuzione, secondo una certa politica (policy).
I principali problemi sono:
- context switch efficiente
- garantire che un processo rilasci l’uso della CPU

Vedere:

http://pages.cs.wisc.edu/~remzi/OSTEP/cpu-mechanisms.pdf

Context switch
Per implementare il context switch in modo efficiente esistono due tipi di approccio:
- cooperativo, significa quindi che il Sistema Operativo si fida dei processi che ogni tanto
eseguono system call
- Non cooperativo, il Sistema Operativo riprende il controllo con la forza tramite un timer
interrupt. L'aggiunta di timer interrupt dà al sistema operativo la possibilità di essere eseguito
di nuovo su una CPU anche se i processi agiscono in modo non cooperativo. Pertanto,
questa funzionalità hardware è essenziale per aiutare il sistema operativo a mantenere il
controllo della macchina.

Un context switch è concettualmente semplice: tutto ciò che il sistema operativo deve fare è salvare
alcuni valori di registro per il processo attualmente in esecuzione (sul suo stack del kernel, per
esempio) e ripristinarne alcuni per il processo che sta per essere eseguito (da il suo stack del kernel).
In tal modo, il sistema operativo assicura che quando l'istruzione return-from-trap viene finalmente
eseguita, invece di tornare al processo che era in esecuzione, il sistema riprende l'esecuzione di un
altro processo.
L’implementazione è sicuramente meno semplice, molti dettagli dipendono dall’hardware.

In generale, ogni processo ha uno user stack e un kernel stack:

Stati di un processo

Durante la sua vita un processo può essere in diversi stati, alcuni li abbiamo già usati.
- Processo in esecuzione su CPU (running)
- Processo non in esecuzione su CPU
- ready/runnable: è un processo pronto all’esecuzione che però ha bisogno di CPU per andare
avanti (se per esempio si ha una sola CPU oppure CPU con un solo core si può avere un
solo processo in running)
- Stato di inizializzazione (INIT): se ne potrebbe avere bisogno tra lo stato ready e running
- Waiting/blocked: corrisponde all'idea de ”il processo è in attesa della periferica e non gli si da
CPU perché non saprebbe cosa farsene”. Questo stato si introduce perché il processo
quando interagisce con I/O ha bisogno di dati, finché non li riceve il processo non è né ready
ne running.

N.B. Del passaggio da ready a running se ne occupa la scheduling.


Un processo (facendo I/O) può passare da running a waiting, una volta terminato tramite interrupt, il
kernel lo vede e può riportare il processo in stato ready.
Da running può diventare in stato ZOMBIE come anche da ready e waiting, tramite un kill
probabilmente, non fa killare durante il waiting ma questo è un dettaglio a parte.

Algoritmi

Per scegliere il prossimo processo si utilizzano diversi tipi di algoritmi di scheduling, per scegliere
quello più adatto ci si basa sulle metriche. Molto spesso i processi sono chiamati job mentre i processi
che girano in un sistema sono i workjob.

Partiamo con assunzioni irrealistiche

1. ciascun job dura lo stesso tempo


2. tutti i job arrivano allo stesso momento
3. una volta iniziato un job lo si porta fino in fondo (senza interruzioni)
4. tutti i job usano solo CPU, no I/O
5. il tempo di ciascun job è noto a priori

e le elimineremo man mano.

Metriche

Per misurare la “bontà” di un algoritmo di scheduling, rispetto a un altro, abbiamo bisogno di


metriche

● inizialmente ci concentreremo sulle performance


● ma un altro aspetto da tenere in considerazione è la fairness

Algoritmo FIFO

Supponiamo di avere 3 job lunghi 10 e andiamo


a calcolare il tempo medio.

- 1° terminerà dopo 10 sec


- 2° terminerà dopo 20 sec
- 3° terminerà dopo 30 sec

Il tempo medio di completamento dei job sarà


20.

Rilassiamo quindi la prima assunzione, se A dura 100, B e C 10, questo crea l’effetto
convoglio e aumenta di davvero molto il tempo medio (110).
Shortest-job first

Con l’assunzione che tutti i job arrivano allo stesso momento, si può provare che SJF è
ottimo.

Con l’assunzione che tutti i job arrivano allo stesso momento, si può provare che SJF è
ottimo.

Response time

Nei sistemi batch il turnaround può bastare, ma nei sistemi interattivi un’altra metrica è molto
importante

negli algoritmi visti, il response time non è buono; pensate a cosa succede se arrivano n job
della stessa lunghezza: l’ultimo deve aspettare la terminazione degli altri (n − 1) prima di
essere mandato in esecuzione.
Round-robin

Nel Round-Robin ogni job ottiene una “fetta/quanto di tempo” e poi si passa al prossimo job:

Si può ottimizzare il response-time


restringendo il quanto di tempo, ma bisogna
tenere in considerazione l’overhead del
context-switch.

Come si comporta RR rispetto al turnaround?


Malissimo! L’esecuzione viene “diluita” e il
tempo di completamento si allunga.

In generale gli algoritmi di scheduling “equi” (fair) evitano la starvation ma “penalizzano” i job corti.

I/O

Come abbiamo, già discusso, sarebbe stupido tenere allocata la CPU per un processo che attende
l’I/O:

E’ molto più furbo assegnare CPU a chi ne ha bisogno.


Il tempo di ciascun job non è possibile calcolarlo in anticipo. Il tempo di esecuzione raramente (mai) si
può sapere a priori.
Multi Level Feedback Queue

Il MLFQ tenta di ottimizzare sia il turnaround time, facendo eseguire prima i job corti che il response
time. In genere uno scheduler non sa a priori le caratteristiche di un processo, come fa, quindi, a
“impararle”?

Si usano diverse code, a differente priorità, RR sulla stessa coda.


La priorità di ogni processo varia dinamicamente in base al comportamento osservato, si può usare la
storia di un processo per predire il comportamento futuro.
Descriviamo l’algoritmo con delle regole:

● Se p(A) > p(B), gira A (e non gira B)


● Sep(A)=p(B),AeBinRR
● Un nuovo job entra con la priorità massima
● Quando un job usa un tempo fissato t a una certa priorità x (considerando la somma dei
tempi usati nella coda x), allora la sua priorità viene ridotta
● Ogni s secondi, spostiamo tutti i job alla priorità più alta

Con queste regole le cose più o meno funzionano; problemi:


Quante code? Quanto vale t? Ogni quanto si resetta tutto?
La “soluzione” può essere lasciar scegliere i parametri a un admin.

12/11

Proportional share

Gli scheduler proportional-share invece di ottimizzare i tempi si basano sul concetto di dare ad ogni
processo la sua percentuale di tempo di CPU. Ovvio che se abbiamo troppi processi non ha senso
utilizzare questa modalità, anche perché ci saranno comunque processi più veloci di altri ed è
giusto dare più CPU maggiore ad essi.

CFS

La CFS conteggia il tempo utilizzando virtual runtime, quindi dà in qualche modo, priorità a quello
che ha tempo virtual runtime più piccolo.
Inizialmente su UNIX si aveva un valore chiamato nice (priorità) di processo, per la quale si
applicava un fattore in scala per tenerne conto di ciò:

Il valore di nice viene definito di default, perché facendo al fork viene ereditato quello del processo
padre. Solo l’amministratore del processo può modificarlo. Più è basso tale valore più priorità ha il
processo.

Per trovare efficientemente il minimo/aggiornare il


valore runtime
dei processi pronti, essi vengono tenuti in un albero
binario
bilanciato di tipo rosso/nero.
Quando però arriva un nuovo processo lo si inserisce
nell’albero e gli viene dato il minimo del virtual realtime possibile, questo perché se quando un
processo torna ready lasciassimo inalterato il valore runtime, potrebbe monopolizzare a lungo la
CPU.

Un tipo di scheduler che sceglie i processi da eseguire randomicamente esiste ed è


chiamato Lottery scheduling.

Thread

I thread permettono di avere più flussi di esecuzione in un processo.


I processi separati sono isolati e hanno spazi di indirizzamenti diversi, mentre, thread separati
condividono comunque lo stesso spazio di indirizzamento.
A livello di Kernel se introduciamo i thread deve esistere un equivalente dove vengono salvati i
processi detto thread control block (esso agisce come una libreria di informazioni sui thread in un
sistema. Informazioni specifiche sono memorizzate nel blocco di controllo del thread che evidenzia
informazioni importanti su ciascun processo.).
Windows, un processo è un contenitore che può avere molte risorse e queste possono anche essere
thread, Linux invece non fa differenze tra loro, semplicemente sa che alcuni condividono lo stesso
spazio di indirizzamento e altri no.
Logicamente ogni thread ha il suo stack e condivide codice e dati con altri thread dello stesso
processo.
Ogni thread ha la sua variabile “globale” errno contenuta nel Thread Local Storage (anch’esso creato
per ogni thread).
Una ragione per creare dei thread è il poter sviluppare il parallelismo, avendo 8 core potremmo dare
ad ogni core un qualcosa da fare, moltiplicando per 8 le cose che possiamo fare, ovviamente se il
tutto ha senso, altrimenti si sfocia in overhead e non ha senso far ciò.
Altro vantaggio è il non doversi bloccare per fare operazioni di Input/Output.

Creazione di Thread

Utilizziamo la libreria API POSIX quindi quando compiliamo dobbiamo aggiungere pthread così che
compili includendo la libreria. In caso di errore restituirà direttamente il codice errno ma non lo scrive
in esso, mentre, se tutto va bene restituisce 0.

int pthread_create (pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *),
void *arg);
Il primo parametro è un parametro di uscita, quindi se la chiamata è andata buon fine troveremo lì il
suo identificatore, il secondo parametro possiamo passare attributi addizionali, ma tendenzialmente
non se ne fa uso, il terzo parametro è un puntatore a funzione che restituisce un puntatore a
qualcosa, infine, abbiamo un argomento passato alla funzione specificata nel terzo parametro.

Potete uscire, con un certo valore, da un thread possiamo fare un return dalla funzione
iniziale, oppure possiamo chiamare la funzione: void pthread_exit(void *retval);

Per attendere la terminazione di un thread utilizziamo la funzione:


int pthread_join(pthread_t thread, void **retval); Qui il secondo parametro è un puntatore che
restituisce il risultato.
Esempio uno:
In questo processo il main crea due thread e li
aspetta.

Notiamo che una volta creati i thread non è detto che


per forza vengano eseguiti in ordine, funziona un po’
come con i processi e potrebbero intervallarsi tra di loro.

Esempio
due:
si aspetta un argomento a riga di comando che è il
numero di loop che farà, quindi, stampa il valore
iniziale del contatore, crea un thread worker,
l’argomento non viene utilizzato e rimane a 0, per il
numero di volte loop incrementeranno il contatore.
Alla fine, aspettiamo che terminino e poi stampa il
risultato.
Questo però ci da qualche problema con numeri più
numeri elevati passati come argomenti, perché più è
elevato più avrò timelise (?) elevato.

Grazie a https://gcc.godbolt.org/ possiamo capire


facilmente la ragione del comportamento bizzarro.

Race condition

Una sezione critica è un frammento di codice che accede a una risorsa condivisa.
Quando si hanno più flussi di esecuzione, si parla di race condition (alle volte data race) quando
il risultato finale dipende dalla temporizzazione o dall’ordine con cui vengono schedulati. Il
risultato della
computazione non è deterministico in questi casi. Si ha una race-condition quando più thread
eseguono (più o meno allo stesso tempo) una sezione critica.
Esistono delle primitive di sincronizzazione che servono per la mutua esclusione; quindi, accede
un thread uno per volta.
Il problema dell’esempio era la mancanza di atomicità nell’incrementare il contatore.
Una soluzione è introdurre dei lock, implementati dai mutex in pthread;

Locks

Un lock si dichiara con pthread_mutex_t e si può inizializzare tramite l’assegnazione della


costante
PTHREAD_MUTEX_INITIALIZER oppure con pthread_mutex_init.
Può essere acquisito (preso/tenuto), da un thread alla volta, tramite pthread_mutex_lock e va
rilasciato, il prima possibile, tramite pthread_mutex_unlock.
Esempio analogo al precedente ma che utilizza i mutex:

Ciò che cambia è la parte iniziale,


mentre la
parte di sotto nel main è identica a
quella vista
nell’esempio 2.

Notiamo anche la presenza di


static, che fa
permette il funzionamento
corretto, perché
appunto fa si che si sincronizza
correttamente
le mutex.

Questa versione funziona, e


possiamo
notare, mandandolo in
esecuzione,
che il tempo di esecuzione cambia
e si
allunga rispetto all’esempio 2.

Se un programma usa più


strutture dati (condivise) diverse, probabilmente è meglio avere più lock perché questo aumenta
l’efficienza ma può complicare la gestione.
Diciamo quindi che, in generale avere più lock ha senso, anche perché se in un programma
abbiamo strutture dati separate e pezzi di codice che accedono a pezzi di codici separati non ha
senso utilizzare un unico lock perché i thread attendono inutilmente. Ma, all’interno della stessa
struttura dati non avrebbe senso mettere troppi lock, ad esempio avendo una lista linkata non ha
senso inserire un lock su ogni nodo della lista linkata.

16/11

Mutex implementazione

Dobbiamo controllare che l’implementazione della mutex sia corretta, altrimenti non ha senso farla.
Se funziona dobbiamo vedere se l’implementazione permetta, prima o poi, di acquisire il mutex da
parte di tutti e tre i thread in attesa. Se un thread non riesce ad acquisire un mutex si parla di
starvation, quindi si preferiscono le fairness (anche se alle volte potrebbero avere un costo).
Quando dobbiamo fare un lock disabilito le
interrupts e quando finisco il lock le abilito.
Ma va bene? No, perché: Un processo non
può disabilitare le interrupt, per mancanza di
privilegi, supponendo però che possa farlo
vorrebbe dire che un processo abbia a
disposizione la CPU per un tempo indefinito,
no buono.
In realtà non funziona ciò, perché se il
processo fosse multiplo, disabilitare le
interrupt su un processore non significa
disabilitare sulle altre.
Non è un qualcosa che va bene per implementare i lock.
Definiamo una struttura dati per i lock,
con
flag iniziale a 0. Se il flag è 1 significa
che
è stato acquisito, allora aspetto finché
non
torna a 0 sarà in attesa.
Questa cosa funziona? In realtà no.
Se abbiamo due thread in attesa
entrambi
credono di essere unici, non va bene.

Questa situazione non è atomica,


perché da quando stabilisco il mutex a
quando effettivamente entriamo nel
mutex, passa del tempo e potremmo essere interrotti nel mentre, non va bene per questo.

Se non abbiamo un supporto hardware che permetta di effettuare supporto non riusciamo a uscire da
questa situazione.
Diversi processori forniscono soluzioni atomiche diverse (es. TestAndSet), essendo atomico non
può essere interrotto.
Altri processori permettono scambio di
celle con altre che restituiscono ciò che
c’era prima es:

Avendo a disposizione una primitiva del tipo →


fa uno scambio di memoria, dopo aver
cambiato il flag, se è riuscito a cambiare il flag
vuol dire che era unico e quindi è stato
atomico e non abbiamo di che preoccuparci.

Se un thread chiama due volte un lock rimane


fermo li, con questa implementazione (altre
implementazioni rendono il mutex più
semplice per il programmatore perché non si
pone il problema di aver acquisito già prima il
lock, queste si basano sul conto dei lock fatti).

Diciamo che funziona se è atomica, e quindi dovrebbe garantire la mutua esclusione, ma non è fair
perché non essendoci coda potrebbe “perdere” il posto durante l’attesa. Cioè, un thread in attesa
ha qualche garanzia di ottenere, prima o poi, il lock? no; è possibile starvation.
Per quanto riguarda le performance dobbiamo distinguere rispetto al numero di core
- singolo core: se un thread che ha il lock viene deschedulato, gli altri sprecano tempo e CPU
- core multipli: funziona discretamente bene
Su un singolo core non ha molto senso perché è uno spreco di CPU, ma se abbiamo più core sì
perché si può sfruttare il parallelismo, è più efficiente fare un busy wait.
Quindi ha senso aspettare un loop se l’attesa è minima, altrimenti meglio sospenderlo e riprenderlo
dopo.
Primitive di sincronizzazione – Spin Lock

Supponendo di avere uno scheduling a priorità, con T1 a bassa priorità e T2 ad alta priorità e
supponendo che T2 sia in attesa ma va in esecuzione T1 acquisendo il lock, lo scheduling a questo
punto manda in esecuzione T2 che ha precedenza maggiore ma non può far altro che aspettare
perché T1 non sgancia il lock e il T2 va in spin-wait per il lock.
Ci sono modi per risolvere il problema, per esempio “ereditare la priorità” di un thread in
attesa (se maggiore di quella corrente);

Bug

Ci possono essere bug usando multi thread, che però su singolo core potrebbero
funzionare. Ha senso utilizzare il multi thread ma non sempre.

In un’applicazione single thread va


benissimo, ma in multi thread potrebbero
esserci problemi, tipo:

- Due thread potrebbero “scrivere” sullo


stesso parametro.
- L’if può avere successo ma quando
descheduliamo il thread a null, la zona
di memoria in cui vorrebbe scrivere
potrebbe non essere adatta. In
sostanza l’atomicità non c’è.

Quando un thread viene creato, la


sua funzione può essere eseguita
e non abbiamo alcuna idea sul
quando la, global thread verrà
assegnata e quando verrà letta.
A seconda di come lo schedular
agisce sul thread potremmo avere
situazioni diverse.
Per garantire che venga assegnata
prima la variabile dobbiamo
utilizzare la primitiva di
sincronizzazione.

Deadlock
Situazione che capita quando abbiamo più
flussi di esecuzione. Il primo thread e il
secondo si scambiano i lock (A fa prima m1 e
poi m2, ma B fa m2 e poi m1). Entrambi
eseguono il primo lock e poi ci si ferma perché
per proseguire B dovrebbe aver a disposizione
il lock preso da A, ma questo non lo rilascerà
mai perché attende la stessa cosa, gli serve il
lock di B per proseguire. Non si esce da
questo ciclo. Situazione di deadlock.

Il deadlock può accadere se tutte le condizioni


elencate :
- mutual exclusion: i thread hanno controllo esclusivo
- hold-and-wait: i thread mantengono le risorse acquisite mentre ne
richiedono/aspettano altre - no preemption: le risorse non possono essere tolte
- circular wait: ci deve essere un ciclo nelle attese

Come prevenirlo? I modi più semplici sono:


- Imporre un ordine su lock e acquisirli in ordine (no circular wait)
- Fare in modo che i lock vengano sempre acquisiti tutti assieme, atomicamente (no hold-and-wait)

Siccome tutte e quattro devono essere vere, riuscendo a bloccarne una non avremo un
deadlock. Se un programma finisce in deadlock, il programma sembra in stallo ma l’uso
della CPU è a 0. La soluzione migliore è uccidere il processo.

In sostanza:
Spesso un processo che possiede una o più risorse non le rilascia fin quando non completa
l’esecuzione. E spesso per completare l’esecuzione un processo ha bisogno di altre risorse (oltre
quelle che già possiede).

Questo scenario porta ad una situazione in cui un gruppo di processi si mette in attesa di una
risorsa occupata che non potrà mai essere acquisita perché il processo che la occupa deve
acquisire altre risorse occupate prima di rilasciare quella che già ha in uso.
Le risorse restano bloccate all’interno di uno loop: un circolo di richiesta-attesa insoddisfatto in cui
nessuno si muove e tutti attendono.
Un deadlock può interferire sull’esecuzione di parti di programma/i, di un intero programma/i fino
ad un intero sistema.

Sicurezza

Il Sistema Operativo costituisce le fondamenta di ogni programma, i programmi effettivamente


girano su di esso, se Hardware non implementasse correttamente il sistema Kernel e User, non si
riuscirebbe a fare separazione tra essi e non si avrebbe un sistema sicuro.
In generale, quando parliamo di sicurezza si pensa all’anello debole della catena (secondo
principio di Denning), cioè avendo più risorse viene attaccata la più debole.

Ovvio, che anche il concetto di sicurezza è fondamentale da conoscere, si basa su:


● Confidentiality (Confidenza/segretezza)
● Integrity (Integrità)
● Availability (Disponibilità)

In generale per un SO ad uso comune si vuole la condivisione controllata, siamo noi proprietari a
scegliere chi può leggere/avere accesso ai nostri file.

Prima di eseguire una Syscall il kernel deve valutare la richiesta:


● Deve avere parametri validi, ad esempio un processo non deve eccedere dallo spazio
dedicato ad esso;
● Deve rispettare la politica di sicurezza, ad esempio si devono avere i permessi per leggere
un file

Terminologia:
● Principal o sobject è l’entità che fa la richiesta
● Object richieste relative a una risorsa
● Modalità di accesso

19/11

Chi fa la richiesta della system call deve avere i permessi, deve esserci quindi un modo per
verificarlo.
AAA

Spesso si parla, in ambito di sicurezza, di AAA. Quando il sistema riceve una system call il
kernel “media” la richiesta e controlla se è sensata, se è lecita , etc. Quindi:

● Autenticazione → verifica dell’identità


● Autorizzazione → applicazione di una politica di sicurezza, decide se accettare o meno
le richieste (lo fa per esempio il kernel)
● Contabilità (accounting) → logging e gestione del consumo delle risorse.

Shadow e PassWD

Di default gli username e le password sono contenuti in file:

● passwd
● shadow

Passwd è storicamente il file che conteneva le password, è leggibile da tutti ma scrivibile solo
da root.
Quando utilizziamo passwd le password non ci sono poiché sono state spostate in un altro file
che ha bisogno di un determinato permesso per accedere, mentre passwd è leggibile da
chiunque.

Una riga di passwd è così composta:


● login name
● password
● user ID → UID
● group ID → GID
● user name
● user home directory
● optional user command interpreter

Utilizzando shadow avremo le password salvate con funzioni di Hash (lente), ogni volta che il
processo di Hashing ci restituisce qualcosa restituirà valori diversi, non può essere
deterministico. Shadow non è accessibile in lettura da chiunque.
C’è anche una parte random all’interno della funzione di Hash, detta salt e serve per generarla
in quanto si “mescola” con la psw. Essendo randomico il salt allora avrò sempre, pur usando la
stessa psw, funzioni di Hash diverse.
Il kernel identifica ogni utente con un numero intero UID, non negativi:
● Lo 0 indica l’amministratore di sistema.
● Quelli non privilegiati, utenti normali, hanno numeri diversi da 0.
I vari poteri di root sono, in Linux, spezzati in varie capabilities; esse permettono una maggiore
granularità.

N.B. L’amministratore di sistema è chiamato root ma anche la radice del file system che
ATTENZIONE, sono due cose diverse.

Abbiamo due approcci generali diversi sulle autorizzazioni:

● Access control list per ogni oggetto si ha una lista delle copie subject/access (in unix
semplificazione: r,w,x per proprietario, gruppo e altri utenti).
● Capabilities per ogni object/access ci sono delle “chiavi” che ne permettono l’uso (non
c’entrano con quelle citate sopra per Linux).

DAC e MAC e Minimo privilegio

Il proprietario DAC può accedere/leggere/scrivere a una risorsa.


MAC, quando una qualche autorità impone le regole (ad esempio in ambito militare).

Minimo privilegio

Il principio del minimo privilegio ci dice che un utente non dovrebbe mai avere permessi non
necessari, o meglio, è inutile dargli permessi che non gli servono per funzionare.

Un utente può avere diversi ruoli e tale ruolo deve essere considerato.

Per ogni servizio su UNIX viene creato un utente, nel momento dell’accesso il processo gira
come root perché deve poter leggere shadow, ma dopo aver verificato l’account i privilegi
vengono rimossi.

Se, invece, vogliamo aumentare i privilegi prima vi era un utente detto SU, super user, adesso si
utilizza il comando “sudo” che permette i privilegi solo nel momento in cui svolgiamo
quell’operazione e chiede la mia password per procedere, la nostra non del root perché così
ogni utente gestisce la cosa da sé.

UID

Ogni processo ha al suo interno tre UID, e al login tutti e tre coincidono:

● real UID, utente reale e proprietario del processo;


● effective UID, utilizzato per svolgere processi. Modificabile attraverso setuid. Controlla
se un file si può aprire o meno.
● saved UID, controlla chi può terminare un processo (infatti, kill controlla real UID invece
di effective UID). Esiste una system call (setuid) che il processo può usare per
modificare l’effettivo UID facendolo diventare come l’UID real o saved, se il chiamante è
privilegiato può modificare tutti e tre come vuole.

Un nuovo processo eredita gli ID del parent, non c’è altro che un processo possa fare per
diventare root se non utilizzare execve anche se di solito non cambia gli ID del processo
chiamante.

Se il bit set-userid è abilitato allora gli UID saved ed effective diventano quelli del proprietario del
file.

A seconda di come è implementato il sistema, se vi siano bug o meno, ciò potrebbe far eseguire
codice arbitrario e non va bene.

Chroot jails e namespace

La system call chroot permette di modificare il significato di “/” nella risoluzione dei percorsi
assoluti (non si parte quindi dalla radice ma da una sotto cartella che viene impostata come
nuova root), la modifica è per il processo e tutti i futuri figli, è necessario che il processo sia
privilegiato. I file descriptor aperti non vengono toccati.

Inoltre, chroot permette di creare le chroot jail, limitando al visibilità del file-system a un
directory, applica il minimo privilegio ma se la il processo rimane privilegiato e/o la system call
non è correttamente associata a una chdir(2), è possibile “uscire di prigione” per esempio,
anche in caso di bug, in incApache un client non può “sbirciare” fuori dalla www-root

Le chroot jails sono il meccanismo “antenato” dei namespace, limitato al file-system.

Il meccanismo moderno per creare isolamento sono i container.


PERSISTENZA

Supporti di memoria

Chiamiamo dischi i dispositivi a blocchi di memoria secondaria, anche se si suddividono in:


● dischi magnetici
● dischi ottici
● pennette USB
● SSD
Tutti questi dispositivi hanno in comune il fatto che lavorino in blocchi, dal punto di vista logico sono
una sequenza di settore.
Il lavorare a blocchi nasce da esigenze, i blocchi sono logici creati dalla formattazione di basso
livello.

Dischi

Una volta erano letteralmente dei dischi che ruotavano, accedere ai dati costava più o meno in base a
dov’era la testina. Da qui deriva il costo rotazionale, se si vuole leggere un tipo di dato diverso si deve
spostare la testina(altro costo di tempo).
Ha senso usare i dischi perché con i blocchi
mi costa poco leggere subito dopo la
posizione della testina, conviene meno se i
file sono lontani. Per questo motivo si
faceva la deframmentazione dei dischi, per
far si che trovato il primo pezzo dei dati
ricercati , gli altri fossero i successivi al
primo.

L’interfaccia del SSD è rimasta la stessa dei


dischi, per compatibilità.
Altra ragione per indirizzare la memoria a
blocchi è la quantità di byte che si possono
indirizzare, per risparmiare byte si è deciso quindi di indirizzare a blocchi.

L’interfaccia hardware di SSD è lo stesso di un disco, il Sistema Operativo può usare SSD senza
saper che lo sia, tuttavia se lo sa (ad oggi lo sanno) ne ottimizza l’utilizzo.
L’interfaccia che consideriamo è semplificata, è un disco che ha n settori/blocchi, che possiamo
indirizzare con “indirizzi” da 0 a (n − 1) da notare che la “granularità” di accesso è il settore: 512 byte.

Buffer cache

L’I/O su disco è ordini di grandezza più costoso dell’accesso in RAM.


I sistemi Unix-like utilizzano una (unified) buffer cache. Una volta:

● buffer-cache per i blocchi disco acceduti tramite read/write


● page-cache per i file mappati in memoria; che, a sua volta, si appoggiava sulla buffer-cache

La presenza di cache è la ragione per cui è importante “espellere” i drive prima di scollegarli.

26/11
Partizioni

A volte ha senso vedere più dischi separati come un unico disco, invece è molto comune dover
partizionare un disco per lavorare. Si devono quindi definire delle partizioni, cioè “separare”parti di
disco.
Esistono due standard per partizionare i dischi:
● MBR (Master Boot Record)
● GPT (GUID Partition Table)

Lo standard più vecchio è MBR, composto da boot sector ed informazioni disco. Era ideato per fare al
massimo 4 partizioni, che oggi sono chiamate primarie, successivamente fu introdotta la possibilità di
fare più partizioni, chiamate partizioni estese.
GPT non ha limitazioni sul numero di partizioni, può identificare una partizione con identificatore
univoco,è molto comodo perché si possono spostare i dischi senza dare problemi nel trovare le info.
In Unix i dispositivi vengono modellati da file speciali, che possono essere di due tipi:
● a caratteri
● a blocchi
quando parliamo dei dischi ovviamente parliamo dei file a blocchi, i file a caratteri sono usati da altri
dispositivi, per esempio la stampante.
I file sono identificati da due numeri :
- major number
- minor number
su un sistema linux quello che troviamo sono dei device che si chiamano dev/sda che identifica
l’intero disco, mentre dev/sda1 identificano le singole partizioni.
Sotto dev si identificano anche i dischi che permetteno di identificare una partizione con un numero
che è unico.
Questi dispositivi vengono creati attraverso il comando
“mknod” che va a chiamare la system call mknod,
comando e syscall hanno lo stesso nome, per poter
fare questa operazione si deve essere loggati come
root. Sono dei file a tutti gli effetti, se si hanno i
privilegi si possono leggere e scrivere i file, cioè si può
andare a leggere e scrivere il disco. Solitamente però
si vuole andare a vedere il file system, per essere
usati si devono montare, cioè si deve agganciare il file
system che sta nella chiavetta per esempio all’albero
di file e directory del mio sistema.

Con l’aumentare dei dispositivi supportati dal kernel e


l’arrivo dei dispositivi hot-plug il sistema mostrava i suoi limiti, allora sui sistemi moderni quando si
collega il dispositivo, il kernel avvisa udev che riconosce il dispositivo e crea file sotto /dev.
A questo punto viene notificato il file manager che nei sistemi moderni, fa montare in automatico il
dispositivo.

Quando facciamo boot sistema,si ha bisogno di disco, la partizione da montare viene passata coma
parmatro al kernel, dentro sstab si possono specificare altri.

File e directory

Noi siamo abituati a pensare al file system come un insieme di cartelle, noi sappiamo che dietro a ciò
c’è un disco, il file system, che è una struttura dati, permette di usarlo in modo più comodo e gestisce
i dati ed i metadati.
Questo implica che sul disco non ci saranno solo i dati ma anche i metadati (nome file, nome cartella,
ultima mod, proprietario etc), queste info devono essere memorizzate in disco, quando noi andiamo a
formattare il disco, andiamo a preparare la struttura dati come se fosse vuota.
A seconda del dispositivo usato si potrebbe essere costretti a formattare le chiavette in formati vecchi
e maffati.
Quello che vedremo nel corso è una versione molto semplificata del file system unix.
Quello che descriveremo sono le strutture su disco, però quando andiamo ad aprire un file sul kernel,
dobbiamo comunque ricordarci che esistono altre strutture in memoria del kernel senza
corrispondenze sul disco.
Esempio
● inode→ su disco e ram quand sistema funziona

Organizzazione

Supponiamo di avere un disco da 64 blocchi da


4k, usare i blocchi da 4k non è assurdo. Può
essere comunque possibile che i settori vengano
raggruppati in blocchi più grossi per:
- indirizzamento
- (valida più che altro su dischi veri) leggere 4 settori alla volta, è più rapido!

Non possiamo usare tutto il


disco per i dati.

Dobbiamo comunque usare


una parte per i metadati. In

Unix per ogni file c’è una struttura dati chiamata


inode, tutti gli inode sono contenuti dentro la
tabella degli inode, questa tabella ci dice quanti
file possono essere memorizzati in macchina.

Vengono identificati in base alla posizione della


tabella, dobbiamo sapere se ogni inode è usato o meno, per farlo viene usata una bitmap, cioè altro
blocco di disco, ogni pezzo identifica un inode e dice se è usato oppure no. Analogamente abbiamo
una bitmap per ogni blocco dati.
Infine abbiamo un super blocco che serve per identificare il tipo di file system e le sue caratteristiche.

INODE

Dentro l’inode ci sono i metadati relativi ad un file eccetto il nome (lo vediamo dopo dove sta)
Prima di tutto c’e scritto il tipo del file:
Di solito il file classico è il file regolare, anche la directory è un file, che contiene un insieme di nomi e
l’inode corrispondente, ecco dove vengono memorizzati i nomi del file.

Un altro importante tipo di file è il link simbolico, il nome corrisponde ad un percorso.


FIFO e socket sono sostanzialmente delle pipe.

Oltre a questo viene specificato il UID e il GID del proprietario e del gruppo, dopo c'è la dimensione
dei file, la maschera di bit relativi ai permessi, la data di creazione e di modifica, ultimo accesso etc.
Il numero di hard link e i puntatori ai blocchi altri, dei numeri di blocco che contengono i blocchi dati.
L’inode nel file system è sempre lungo uguale.

Puntatori ai blocchi dati

Per i primi blocchi abbiamo la connessione al


blocco ati , dal tredicesimo dobbiamo fare una
doppia lettura perché l’inode punta a un puntatore
ai blocchi dati che punta a sua volta ai blocchi dati,
aumentando il numero di inode la cosa degenera e
diventa ricorsiva.

Directory

Le directory sono file, che contengono l’associazione


tra il nome e l’inode.
Dentro ad ogni directory c'è un nome “.” che contiene il
collegamento all’inode e il nome “..”.
Queste associazioni sono chiamate hard ink. aggiungere hard link significa aggiungere un nome allo
stesso inode.
i link simbolici sono dei file che contengono un percorso, che non deve necessariamente esistere.
anche se il numero di link va a 0 ma ci sono file aperti di inode, inode viene mantenuto aperto.

Permessi

Per le directory r specifica il poter leggere, w per modificarne il contenuto ed x se posso accedere alla
directory. Quando si vanno a cancellare i file non sono importanti i proprietari del file quanto il
proprietario della directory.

Quindi, per creare/eliminare nomi da una directory d, dovete avere il permesso di scrittura su d, es:
potete cancellare file read-only di
root se la directory è vostra.

Risoluzione dei percorsi.

Per la risoluzione dei file.


Bisogna capire se il percorso è
assoluto, cioè un percorso che
inizia con /, tutti gli altri percorsi
sono relativi. Entrambi gli inode
stanno nel PCB.

Consistenza dei file-system

FSCK

file system check, serve per controllare integrità di un file system.


Journaling

Potrebbero piacerti anche