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
SPAZI DI INDIRIZZAMENTO 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
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
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.
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).
8/10
SHELL E TERMINALI
Directory principali
/ radice
/etc file di configurazione del sistema /home e /root home degli utenti (non root) e root
/lib* librerie
/proc e /sys file system virtuali che forniscono un’interfaccia alle strutture dati del kernel
/usr gerarchia secondaria (/usr/[s]bin, /usr/lib*, . . . ), che può essere condivisa in sola lettura fra più
host
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’
Comandi utili:
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
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).
15/10
● [ 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:
[[ ... ]]
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.
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].
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!
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;
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
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]);
Indirizzamento multiplo
int a = 3;
int *b = &a;
int **c = &b;
int ***d = &c;
Puntatori e cost
La parola chiave const viene utilizzata in modo leggermente diverso quando sono coinvolti i puntatori.
Queste due dichiarazioni sono equivalenti:
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.
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:
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;
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(vuoto);
int *fip(); Funzione che restituisce il puntatore int
int(*pfi)(); Puntatore alla funzione che restituisce int
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:
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:
Stringhe
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:
Ecco una possibile implementazione della semplice funzione strlen, che restituisce la lunghezza di
una stringa (escluso il terminatore NUL):
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.
Quello usa l'indicizzazione. Che utilizza un puntatore (non un array e sicuramente non una stringa).
19/10
SYSTEM CALL
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.
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
● 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); ”
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:
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
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.
● 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?
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)
● 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”.
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.
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!
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
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.
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
Pipe anonime
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.
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.
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.
Metriche
Algoritmo FIFO
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:
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:
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”?
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.
Thread
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);
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.
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
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.
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:
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.
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.
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
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.
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:
Shadow e PassWD
● 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.
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.
● 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).
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:
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.
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
Supporti di memoria
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 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
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.
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
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.
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.
Directory
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.
FSCK