Sei sulla pagina 1di 38

Appunti di Sistemi operativi (SETI)

Fabio Fontana

February 14, 2021

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


Contents
1 Introduzione 3

2 Shell e terminali 4
2.1 Shell . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
2.2 Terminale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
2.3 Virtual console e pseudo-terminali . . . . . . . . . . . . . . . . . 4
2.3.1 Standard file descriptor . . . . . . . . . . . . . . . . . . . 5
2.3.2 Disciplina di linea . . . . . . . . . . . . . . . . . . . . . . 5
2.4 Ancora su shell . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
2.5 Job control . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5

3 File e processi 7
3.1 Utilizzo delle system call . . . . . . . . . . . . . . . . . . . . . . . 7
3.1.1 Syscall e funzioni wrapper . . . . . . . . . . . . . . . . . . 7
3.2 File (API) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
3.2.1 File descriptors e permessi . . . . . . . . . . . . . . . . . . 7
3.2.2 API . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
3.2.3 File offset, metadati e limiti . . . . . . . . . . . . . . . . . 9
3.3 Programmi binari e processi . . . . . . . . . . . . . . . . . . . . . 10
3.4 Processi (API) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
3.4.1 API per la gestione dei processi . . . . . . . . . . . . . . . 12
3.4.2 Processi zombie . . . . . . . . . . . . . . . . . . . . . . . . 14
3.5 Redirezione dell’I/O . . . . . . . . . . . . . . . . . . . . . . . . . 14
3.5.1 Dup . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
3.5.2 Relazione fra file descriptor e file aperti: POSIX . . . . . 15
3.5.3 Pipe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15

4 Xv6 16
4.1 Avvio . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
4.2 Programmi utente . . . . . . . . . . . . . . . . . . . . . . . . . . 18
4.3 System call . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
4.4 Scheduling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19

5 Scheduling 21
5.1 Introduzione . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
5.1.1 FIFO . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
5.1.2 Shortest-Job first . . . . . . . . . . . . . . . . . . . . . . . 21
5.1.3 Shortest Time-To-Completion First . . . . . . . . . . . . . 21
5.1.4 Round-Robin . . . . . . . . . . . . . . . . . . . . . . . . . 21
5.1.5 I/O . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
5.2 Multi-level Feedback Queue . . . . . . . . . . . . . . . . . . . . . 22
5.3 Proportional share . . . . . . . . . . . . . . . . . . . . . . . . . . 22

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


Contents

5.3.1 Completely Fair Scheduler . . . . . . . . . . . . . . . . . . 22

6 Multi-threading 24
6.1 Introduzione . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
6.1.1 Creazione, attesa e terminazione di un thread . . . . . . . 24
6.1.2 Sezioni critiche e race-condition . . . . . . . . . . . . . . . 24
6.2 Primitive di sincronizzazione . . . . . . . . . . . . . . . . . . . . 25
6.2.1 Implementazione di lock . . . . . . . . . . . . . . . . . . . 25
6.2.2 Spin-lock . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
6.3 Bug tipici della concorrenza . . . . . . . . . . . . . . . . . . . . . 27
6.3.1 Deadlock . . . . . . . . . . . . . . . . . . . . . . . . . . . 28

7 Sicurezza 29
7.1 Introduzione . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
7.2 Autenticazione . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
7.3 Autorizzazione . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
7.3.1 Principio del minimo privilegio . . . . . . . . . . . . . . . 30
7.3.2 Chroot jails, namespace e container . . . . . . . . . . . . 31

8 Persistenza 32
8.1 Dispositivi a blocchi . . . . . . . . . . . . . . . . . . . . . . . . . 32
8.1.1 Partizionamento . . . . . . . . . . . . . . . . . . . . . . . 32
8.1.2 Gestione file speciali . . . . . . . . . . . . . . . . . . . . . 33
8.2 File system . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
8.2.1 inode . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
8.2.2 Directory, link e cancellazione . . . . . . . . . . . . . . . . 35
8.2.3 Path resolution . . . . . . . . . . . . . . . . . . . . . . . . 36
8.2.4 Consistenza del file-system . . . . . . . . . . . . . . . . . 36

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


Introduzione
Unix è un sistema operativo nato nei Bell Labs di AT&T a metà degli anni
’60, scritto in C (e assembler). Da Unix sono nati dei fork open-source quali:
Linux, xv6, …. Gli standard per interoperabilità sono stati definiti con POSIX e
SUS. Perciò i sistemi che tratteremo (Linux e xv6) si dicono Unix-like e Posix-
oriented.

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


Shell e terminali
2.1 Shell
Nei sistemi Unix-like la shell è un interprete di comandi utilizzabile sia in
modalità iterativa sia tramite degli script. Il nome deriva dal fatto che si tratta di
un programma utente che offre un’interfaccia di alto livello alle funzionalità
del kernel.

2.2 Terminale
Per interagire con la shell si deve utilizzare un terminale. Un terminale è
l’evoluzione di un teletype (TTY). In Unix quasi tutto è un file e file speciali
corrispondono ai terminali collegati. Il nome TTY è rimasto, infatti \dev\tty è
sinonimo del terminale associato a un processo.

2.3 Virtual console e pseudo-terminali


In Linux abbiamo diverse virtual console (ttyn), ovvero terminali virtuali che
condividono la tastiera e lo schermo. Si può cambiare quella attiva con ALT+Fn
oppure dalla modalità grafica con CTRL+ALT+Fn.

Nelle versioni recenti di distro Linux-based tty1 è il login manager, tty2 l’ambiente
grafico e solo le console da tty3 a tty6 sono in modalità testo.

All’interno degli ambienti desktop si può utilizzare un emulatore di termi-


nale, ovvero un programma che emula un terminale testuale. Il collegamento tra
l’emulatore e un’istanza della shell avviene tramite l’uso di pseudo-terminali,
AKA pty.

I pty sono una coppia di dispoditivi a caratteri detti master e slave che permet-
tono l’emulazione di terminali. Creando il lato master un processo (es. Konsole)
crea un nuovo terminale virtuale. Altri processi possono aprire il corrispondende
slave (/dev/pts/^^.) e interagire con esso come fosse un vero terminale.

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


2.4. Ancora su shell

2.3.1 Standard file descriptor


Ogni processo usa tre file descriptor:
• 0 → standard input
• 1 → standard output
• 2 → standard error

Una shell interattiva legge da stdin e scrive su stdout/stderr che corrispondono


a /dev/tty.

2.3.2 Disciplina di linea


Il kernel si occupa della disciplina di linea, ovvero del buffering, dell’editing, dei
caratteri speciali, …. La modalità canonica è disattivabile con stty -icanon.

2.4 Ancora su shell


La shell:
• in modalità interattiva stampa un prompt
• legge l’input
– lo divide in token: parole e operatori
– espande gli alias
– fa il parsing in comandi semplici e composti
• esegue altre espansioni
• esegue le redirezioni dell’I/O

• esegue il ”comando” e solitamente ne aspetta la terminazione

2.5 Job control


Con il job control si possono gestire più lavori con uno stesso terminale, sospendendo/riprendendo
l’esecuzione di gruppi di processi detti job. I job sono raggruppati in sessioni,
una per terminale, e per ogni pipeline nella shell viene creato un job.
Quando si apre un emulatore di terminale, lui:
• crea un pty e gestisce la parte master
• crea un nuovo processo p, che
– crea una nuova sessione con lui come session-leader

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


2.5. Job control

– apre lo slave, che diventa il terminale di controllo della sessione; p è


detto anche controlling process
– esegue la shell
Quando si chiude la finestra corrispondente viene ”disconnesso”:

• il kernel manda il segnale SIGHUP al session-leader, la shell


• la shell, a sua volta, lo manda a tutti i job che gestisce
• di default, SIGHUP termina i processi
Ogni terminale può avere:

• un job in foreground che può:


– leggere dal terminale
– ricevere segnali (Ĉ, …)
• tanti job in background (& alla fine del comando; $! è il PID del job)

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


File e processi
3.1 Utilizzo delle system call
3.1.1 Syscall e funzioni wrapper
Un processo non può interagire direttamente con l’HW ma si deve appoggiare al
kernel mediante delle system call. Una system call è una chiamata controllata
all’interno del kernel. Per questo quando si effettua una syscall si deve passare
alla modalità kernel per questo è anche molto più costosa rispetto a una sem-
plice chiamata di funzione.

Le system call sono in assembly ma la libreria C offre delle funzioni wrapper


per semplificarne l’utilizzo. Queste funzioni preparano gli argomenti/#-syscall
secondo le convenzioni del sistema:

• Linux: parametri sono caricati in specifici registri


• Xv6: parametri caricati sullo stack (utente)
• entrambi: #-syscall in EAX

Una syscall viene effettuata mediante una trap (Linux INT 0x80, Xv6 INT 0x40).
Il kernel controlla la validità del numero della syscall e degli argomenti, esegue
la richiesta e scrive il risultato in un registro. Infine ritorna alla modalità utente
(IRET).

La funzione wrapper controlla il risultato; in caso di errore imposta errno e


restituisce un codice di errore (tipicamente -1), altrimenti restituisce il risultato
al chiamante.

3.2 File (API)


3.2.1 File descriptors e permessi
Tutto l’I/O avviene tramite i file descriptors. Questi sono degli interi non neg-
ativi che identificano un file aperto. Questo file è in senso abbastanza generale,
può essere un socket, una pipe, dispositivi, ….
Ogni processo ha i suoi FD (in, out e err definiti in unistd.h).

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


3.2. File (API)

Figure 3.1: Info sui file.

3.2.2 API
int open(const char *pathname, int flags[, mode_t mode]);

• flags bitmask che indica se si vuole aprire in lettura, scrittura, …


• i flag si possono recuperare e (qualcuno) modificare con fcntl(2)

– lettura/scrittura
– O_APPEND
– O_NONBLOCK
– FD_CLOEXEC chiude il FD quando una exec ha successo
– …
• mode è utilizzato solo quando viene creato un file
– ovvero se flags contiene O_CREAT oppure O_TMPFILE
– è comodo specificarlo in ottale
– stiamo ignorando umask(2)
– mode vale per le prossime aprture: si può scrivere in un file a sola
lettura se è stato specificato O_WRONLY o O_RDWR
• viene sempre restituito il più piccolo FD disponibile o -1 in caso di errore

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


3.2. File (API)

ssize_t read(int fd, void *buf, size_t count);

ssize_t write(int fd, const void *buf, size_t count);

int close(int fd);

Può fallire se il FD è già stato chiuso o causare race-condition in programmi


multithread.

3.2.3 File offset, metadati e limiti


A ogni file aperto è associato un file offset/pointer che indica in che posizione
leggere/scrivere (all’interno del file). Questo è utilizzato nei file regolari dato
che non ha senso andare avanti/indietro su un socket, dispositivo, ….

Dopo una open l’offset è 0 e viene spostato da read e write oppure da una
syscall apposita off_t lseek(int fd, off_t offset, int whence); dove whence
può essere SEEK_SET, SEEK_CUR o SEEK_END.

L’offset può essere spostato oltre la fine del file ma ciò NON comporta un cam-
bio di dimensione dello stesso. Un file può avere dei buchi che corrispondono
(logicamente) a byte 0.

I metadati sono delle informazioni sul file. Per recuperarli si possono usare
queste syscall:
int stat(const char *pathname, struct stat *statbuf);
int lstat(const char *pathname, struct stat *statbuf); ^/ come stat ma non segue i symlink
int fstat(int fd, struct stat *statbuf);

struct stat {
dev_tst_dev; ^/ major (12 bits) + minor (20 bits)
ino_tst_ino; ^/ Inode numbermode_t
st_mode; ^/ File type and modenlink_t
st_nlink; ^/ Number of hard links
uid_tst_uid; ^/ User ID of owner
gid_tst_gid; ^/ Group ID of owner
dev_tst_rdev; ^/ Device ID (if special file)
off_tst_size; ^/ Total size, in bytes
blksize_t st_blksize; ^/ Block size for filesystem I/O
blkcnt_t st_blocks; ^/ Number of 512B blocks allocated
struct timespec st_atim; ^/ Time of last access
struct timespec st_mtim; ^/ Time of last modification
struct timespec st_ctim; ^/ Time of last status change
}

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

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


3.3. Programmi binari e processi

limiti si può usare getconf(1) oppure fpathconf(3)/pathconf(3).

3.3 Programmi binari e processi


gcc/clang sono programmi che ”pilotano” l’intero processo di compilazionelan-
ciando: preprocessore, compilatore, assemblatore e linker.

Compilatori e assemblatori producono file oggetto/rilocabili:


(cpp)+cc1 as
∗.c + ∗.h −−−−−−→ ∗.s −→ ∗.o

ld, il link editor (AKA linker statico), mette assieme file oggetto/librerie,
eseguendo rilocazione e risoluzione dei simboli:
ld
∗.o (+ ∗ .a + ∗.so + script.ld) −→ a.out

Il linking può essere:


• statico: ld fa tutto creando programmi autocontanuti; le parti di
librerie necessarie vengono copiate dentro al programma e gli eseguibili
funzionano su tutte le macchine (con stesso HW/OS) (Xv6 adotta questo
metodo per semplicità).
• (statico +) dinamico: ld prepara l’eseguibile annotando le dipen-
denze esterne fra cui il suo interprete, ovvero il linker dinamico ld.so;
quest’ultimo mette assieme i pezzi mancanti a runtime; default
sui sistemi moderni, permette di risparmiare spazio sia su disco che in
memoria fisica, facilita l’aggiornamento e la distribuzione globale (anche
se potrebbe essere problematico il porting da una macchina a un’altra).
Gli eseguibili, così come i file rilocabili e le librerie possono contenere:
• codice macchina: sezione .text
• Dati e dati a sola lettura: .data e .rodata
• Metadati: architettura, entry-point (non è il main), spazio da risevare
per variabili globali non inizializzate (.bss), …
In un eseguibile le sezioni vengono raggruppare in segmenti, i pezzi che ven-
gono mappati in memoria dal SO/linker-dinamico per l’esecuzione (che NON
corrispondono necessariamente ai segmenti della CPU/MMU).
Tutte queste informazioni devono stare in un unico file, perciò si segue la speci-
fica ELF (executable and linkable format):
• ELF header
• Program header table, se presente, descrive i segmenti che contribuiscono
a costruire lo spazio di indirizzamento di processo

10

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


3.4. Processi (API)

• Section header table, se presente indica dove trovare le informazioni per il


linking

Figure 3.2: ELF layout.

Ogni processo vede il suo spazio di indirizzamento ovvero un’astrazione della


memoria fisica. I processi utilizzano indirizzi logici/virtuali che vengono
tradotti in indirizzi fisici dalla MMU tramite segmentazione/paginazione.

Per fare girare un programma oltre a mappare codice e dati da un ELF serve
anche:
• stack: variabili locali/temporanei, parametri, indirizzi di ritorno
• heap: memoria dinamica
• kernel: non accessibile in modalità utente
Il codice di programmi e, grazie al linking-dinamico, librerie può essere condiviso
(e mappato a indirizzi diversi) in processi diversi.

3.4 Processi (API)


I processi formano un albero con radice init/systemd (PID=1). I PID sono
riutilizzati, quindi identificano un processo solo in uno specifico momento.

Si può ottenere il PID del processo e del padre con pid_t getpid(void) e
pid_t getppid(void).

Il kernel espone le informazioni tramite lo pseudo-filesystem (interroga il kernel,


non è su disco) /proc:
• una directory per ogni processo

11

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


3.4. Processi (API)

• vari file (maggiormente in sola lettura) per esempio maps mostra lo spazio
di indirizzamento
• vedere proc(5)
Ogni processo ha una:
• directory root che viene usata per tutti i percorsi assoluti (che iniziano
con /), non necessariamente la root del FS; servono privilegi per cambiarla
• directory di lavoro che viene usata per tutti i percorsi relativi, getcwd(2),
chdir(2) e fchdir(2) la modificano

3.4.1 API per la gestione dei processi


Le syscall principali per gestire i processi sono quattro:
• fork: crea un nuovo processo
• exit: termina il processo chiamante
• wait: aspetta la terminazione di un processo figlio (non vero al 100%, vedi
dopo)
• exec*: esegue un nuovo programma nel processo chiamante sostituiendo
l’intero spazio di indirizzamento

pid_t fork(void)

La fork clona un processo creandone uno nuovo detto ”figlio”. In caso di suc-
cesso ritorna due volte restituendo il PID del figlio al padre e 0 al figlio.

Il figlio è una copia quasi identica del processo chiamante:


• cambiano PID e PPID
• i FD (flag compresi) vengono duplicati
• l’intero spazio di indirizzamento viene copiato: copia logica grazie al
copy-on-write (vengono duplicate le pagine solo se vengono modificate)

void exit(int status);

La funzione wrapper chiama le funzione registrate con atexit(3) e on_exit(3),


svuota i buffer di I/O ed elimina i file temporanei creati con tmpfile(3). Inoltre
come la syscall:
• chiude/rilascia le risorse del processo (FD, memory-mapping, …)
• il processo termina con exit-status
• eventuali figli, diventati orfani, vengono adottati da init, PID=1

12

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


3.4. Processi (API)

pid_t wait(int *wstatus)

Attende un ”cambio di stato” in un figlio:


• terminazione
• stop/ripartenza, tramite segnali
Per aspettare un figlio particolare e altre opzioni si usa waitpid(2). Se va a
buon fine wstatus ̸= 0 e:
• WIFEXITED(*wstatus), allora si può recuperare l’exit-status con WEXITSTATUS(*wstatus)
• WIFSIGNALED(*wstatus), allora si può recuperare il segnale con WTERMSIG(*wstatus)
• …

int execve(const char *pathname, char *const argv[], char *const envp[]);

Viene avviato un programma all’interno del processo chiamante, sostituendone


lo spazio di indirizzamento, ma stessi PID, PPID, FD (a meno di FD_CLOEXEC).
Quando eseguiamo il programma il sistema (kernel + linker) prepara:
• codice, mappato r-x (.text)

• dati a sola lettura, mappati r-- (.rodata)


• dati, mappati rw- (.data, .bss, heap)
• stack, mappato rw- dove in fondo ad esso (indirizzi più grandi) il kernel
copia:

– le variabili d’ambiente
– gli argomenti della riga di comando
– altro
• il kernel (”invisibile”)

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

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

Se la syscall ritorna al chiamante qualcosa è andato storto.


Se l’eseguibile ha i bit set-user-ID/set-group-ID abilitato vedremo in seguito il
comportamento.

13

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


3.5. Redirezione dell’I/O

3.4.2 Processi zombie


Un processo terminato, non aspettato dal padre è uno zombie.

Il sistema rilascia alcune risorse ma deve continuare a memorizzare alcune in-


formazioni, per esempio, PID ed exit-status.

Quando un processo termina viene inviato al padre il segnale SIGCHILD, che


di default è ignorato, ma può essere catturato per aspettare i figli. Siccome i
segnali (non realtime) non si accodano, potrebbero esserci più figli terminati per
un singolo segnale.

Processi zombie vs processi orfani


Si ha un processo zombie quando il figlio termina prima che il padre chiami la
wait su di esso.

Si ha un processo orfano quando il padre termina prima di esso. init li adotterà


e si occuperà di effettuare la wait.

3.5 Redirezione dell’I/O


3.5.1 Dup
int dup(int oldfd);

Crea un FD equivalente a oldfd, a livello kernel entrambi i FD punteranno allo


stesso file aperto e potranno essere usati in modo interscambiabile (stessi offset,
flag, inode). Restituisce il più piccolo FD disponibile (come open). Non vengono
copiati i flag del FD (FD_CLOEXEC non sarà attivo per quello nuovo).

int dup2(int oldfd, int newfd);

Funziona come dup ma usa newfd (se necessario lo chiude, se è uguale a oldfd
non fa nulla).

14

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


3.5. Redirezione dell’I/O

3.5.2 Relazione fra file descriptor e file aperti: POSIX

Figure 3.3: Relazione tra file descriptors, descrizioni dei file aperti e inodes.

Si può notare che:


• Più FD possono puntare allo stesso file aperto (dup/dup2)
• FD di processi diversi possono puntare allo stesso file aperto come possono
farlo FD diversi di diversi processi
• Più open possono aprire lo stesso file, quindi stesso inode
Aprire due volte un file e duplicare un FD è diverso (pensare a read, write, lseek).

3.5.3 Pipe
int pipe(int pipefd[2]);

Crea un canale unidirezionale, un byte-stream, tipicamente utilizzato per


fare comunicare processi. 0 lettura, 1 scrittura.

Quello che viene scritto nella pipe finisce in un buffer del kernel. Quando
il buffer è pieno le write vengono messe in attesa o falliscono se O_NONBLOCK
è abilitato (se tutti i FD di lettura sono stati chiusi write provoca SIGPIPE).
Quando il buffer è vuoto le read vengono messe in attesa o falliscono come per
la write (se tutti i FD di scrittura sono stati chiusi restituisce 0 come EOF).

15

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


Xv6
Xv6 è un sistema operativo unix-like utilizzato per scopi pedagogici realizzato
al MIT nel 2006.

4.1 Avvio
A 232 − 16 deve essere presente il firmware, in (EP)ROM, che:
1. testa/inizializza l’HW

2. cerca un dispositivo a blocchi da cui poter fare il boot, il cui primo settore
contenga la firma 0x55 0xAA agli offset 510 e 511
3. carica il primo settore detto boot block all’indirizzo fisico 0x7c00
4. lo esegue

È stato scelto 0x7c00 quando negli anni ’80 il DOS 1.0 richiedeva almeno 32KB.
Gli sviluppatori BIOS hanno pensato di lasciare più spazio possibile per il SO
perciò hanno scelto l’ultimo KB disponibile (512B per il boot-sector e 512 per
dati/stack). Da allora per compatibilità continua ad essere quello.

Sempre per ragioni storiche in RAM:

• 0-640KB è detta memoria convenzionale


• 640KB-1MB è detta memoria alta ed è riservata alle periferiche
• il resto è detto memoria estesa

In Xv6 i sorgenti del boot block sono: kernel/bootasm.S e kernel/bootmain.c.


Il makefile li unisce e firma il boot block creando l’immagine di un disco xv6.img
che contiene il boot block come primo blocco e il kernel dal secondo blocco in poi.

Per il boot si deve eseguire del codice assembly:


• Si parte a 16 bit, in modalità segmentata per poter indirizzare 1MB con
16 bit ((#segmento « 4) + offset).
• Preparazione della modalità 32 bit, caricamento della tabella dei segmenti
(obsoleta, per bypassarla si mette un segmento con base 0 e limite + inf)
seguito dalla paginazione

• Si abilita la modalità protetta abilitando PE in CR0


• Si fa un salto su start32 che ci porta in 32 bit

16

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


4.1. Avvio

• Setup dello stack per il codice C (senza heap, senza libc)


• Salto al codice C
Il codice in C per il boot:
• Controlla che il kernel sia un ELF

• Carica dei segmenti a 1MB parlando direttamente con il controller del


disco
• Si sta ancora lavorando con gli indirizzi fisici, il kernel viene caricato
all’inizio della memoria estesa

• Finite le inizializzazioni lo spazio di indirizzamento sarà diviso in: i primi


2GB per la parte utente e gli altri 2 per il kernel
• Si salta all’entry-point dell’ELF
La paginazione è effettuata con tabelle multi-livello:

• 2 livelli (directory e pagina) con pagine da 4KB


• 1 livello con pagine da 4MB (usato solo in partenza da Xv6)
Quindi si abilita la paginazione con pagine da 4MB mappati agli indirizzi vir-
tuali 0 e KERNBASE = 2GB e prepara uno stack. Una volta saltati alla memoria
alta si può eseguire il main che effettua varie inizializzazioni come l’allocatore
delle pagine, la page-table del kernel, …. Infine crea init e lancia lo scheduler.

Con pagine da 4KB l’MMU prende i primi 10 bit e cerca corrispondenze nella
page directory che da l’indirizzo fisico della page table, si prendono gli altri 10
bit e si indicizza nella page table che ci darà i 20 bit dell’indirizzo del frame al
quale saranno aggiunti i 12 bit di offset.

17

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


4.2. Programmi utente

Figure 4.1: Spazio di indirizzamento.

4.2 Programmi utente


La ”libc” è limitata e non standard. exit non ha parametri, printf usa diret-
tamente i FD e il main corrisponde all’entry-point dell’ELF e per tale ragione
deve uscire con exit.

18

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


4.3. System call

4.3 System call


Quando arriva un interrupt bisogna ricodarsi dove è stata interrotta l’esecuzione
ma in modalità utente ESP potrebbe avere un valore qualsiasi, quindi ogni pro-
cesso ha due stack, uno per la modalità utente e uno per la modalità kernel
salvato in un registro speciale e puntato da TR.

Se l’interruzione arriva in modalità utente la CPU passa allo stack kernel e salva
il valore dello stack utente sullo stack kernel. Successivamente vengono salvati
EFLAGS e CS:EIP.

Qualche interrupt ha un codice di errore associato.

In Xv6 ci sono gli handler per ogni interrupt che si occupa di aggiungere un
error code (0) agli interrupt che non lo hanno e di pusharlo sullo stack insieme
al numero di interrupt. Dopodichè salta a alltraps che:
1. costruisce un trap-frame contentente struct trapframe, il numero di
trap, l’error code e una copia di tutti i registri (ad uso generale e utente
(se cambio di stato)) e lo passa a trap
2. trap usa trapno per decidere cosa effettuare, se è una syscall (T_SYSCALL)
salca il trap-frame nel PCB e chiama syscall
3. al ritorno ripristina i registri ed esce dalla gestione interrupt
Gli stati di un processo possono essere:
enum procstate { UNUSED, EMBRYO ^* AKA: INIT ^/, SLEEPING,
RUNNABLE ^* AKA: READY ^/, RUNNING, ZOMBIE };

4.4 Scheduling
Per virtualizzare la CPU si utilizza il time sharing, ovvero ogni processo riceve
un la CPU per un periodo di tempo. Questa operazione deve essere efficiente
e deve controllare che un processo non esegua operazioni non permesse e che
rilasci la CPU.

Per lo scambio di processi si può adottare un approccio:


• cooperativo: il SO si fida dei processi che dovrebbero chiamare una
syscall ogni tanto (yield)
• non cooperativo: il SO riprende il controllo forzato tramite un timer
interrupt
Quando il SO riprende il controllo deve decidere se far proseguire il processo
corrente o meno e ciò viene stabilito dallo scheduler. Per cambiare processo si
fa un cambio di contesto context switch:

19

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


4.4. Scheduling

1. si salvano i registri del processo che si sta eseguendo


2. si caricano i registri del processo che si vuole mandare in esecuzione (in-
cluso il registro che punta alla tabella delle pagine)
In Xv6 se ne occupa swtch (i registri utente saranno già salvati nel trap-frame
sullo stack kernel del processo corrispondente) che scambia i registri che:
• non vengono automaticamente gestiti dal compilatore
• non sono già salvati altrove
• e cambiano da un processo all’altro

Quindi questa struttura sarà puntata dal valore in esp caricato nel PCB:
struct context { ^* definita in kernel/proc.h ^/
uint edi;
uint esi;
uint ebx;
uint ebp;
uint eip; ^* inserito "automaticamente" da CALL ^/
}; ^* esp usato per puntare a questa struttura ^/

20

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


Scheduling
5.1 Introduzione
I processi che girano in un sistema sono detti workload e nel contesto dello
scheduling i processi sono spesso chiamati job.

Per misurare la bontà di un algoritmo si scheduling ci si concentra sulle per-


formance e sulla fairness.

Definiamo il turn-around time ovvero Tturnaround = Tcompletion − Tarrival .

5.1.1 FIFO
L’algoritmo FIFO esegue i processi in ordine di arrivo. FIFO soffre dell’effetto
convoglio, ovvero se un processo molto lungo arriva insieme/prima di uno
breve il TAT peggiora notevolmente in quanto il secondo processo prima di
essere eseguito deve aspettare tutta la terminazione del primo processo.

5.1.2 Shortest-Job first


Si potrebbe usare lo SJF. Questo infatti si comporta molto bene nel caso in cui
tutti i processi arrivano allo stesso momento, ma se un processo lungo dovesse
arrivare prima degli altri il problema dell’effetto convoglio ritornerebbe.

5.1.3 Shortest Time-To-Completion First


Con STCF si può risolvere questo problema, quando arriva un nuovo job viene
valutato se è più corto di quello in esecuzione/quelli in coda e viene schedulato.

5.1.4 Round-Robin
Nei sistemi iterattivi è importante un’altra metrica, il response time: Tresponse =
Tf irstrun − Tarrival . Gli algoritmi visti precedentemente hanno un pessimo re-
sponse time in quanto se dovessero arrivare 10 job contemporaneamente l’ultimo
dovrebbe aspettare la terminazione degli altri prima di essere mandato in ese-
cuione.

Round-Robin a un ottimo response time in quanto ogni job ottiene un quanto di


tempo. Si può ottimizzare il response time regolando questo quanto ma bisogna
considerare l’overhead del context-switch.

21

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


5.2. Multi-level Feedback Queue

Dal punto di vista del turnaround RR si comporta molto male in quanto il tempo
di completamento si allunga e alcuni job potrebbero non essere mai eseguiti
causando starvation.

5.1.5 I/O
Se un processo deve fare I/O ne viene schedulato un altro ottimizzando gli spazi
buchi invece che riservare la CPU per poi non utilizzarla.

5.2 Multi-level Feedback Queue


Il MLFQ tenta di ottimizzare sia il turnaroundtime facendo eseguire prima i
job corti che il response time. Perciò si utilizzano diverse code con priorità
dove su ciascuna coda viene eseguito Round-Robin. La priorità di un processo
varia in base al comportamento osservato, infatti si usa la sua stora per poterne
predire il futuro.

1. se p(A) > p(B) gira A


2. se p(A) = p(B), A e B in RR
3. un nuovo job entra con la priorità massima

4. quando un job usa un tempo fissato t a una certa priorità x (considerando


la somma dei tempi per evitare che faccia il furbo) allora la sua priorità
viene ridotta
5. ogni s secondi tutti i job vengono spostati alla priorità più alta

5.3 Proportional share


Gli scheduler proportional-share invece che cercare di ottimizzare i tempi di
turnaround o response si basano su un concetto semplice: ogni processo dovrebbe
avere la sua percentuale di tempo di CPU.
Si definiscono calore di nice da -20 a +19, default 0 e classe di ionice da 0 a 3,
default 2. I processi normali possono solo aumentare il valore di nice (root può
diminuirlo). Più il valore è basso e più CPU utilizza.

5.3.1 Completely Fair Scheduler


CFS conteggia il tempo usando virtual runtime: vruntime. Nel caso più sem-
plice il vruntime è proporzionale al tempo vero, mentre se un processo ha molta
priorità gli viene conteggiato meno tempo e viceversa.

22

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


5.3. Proportional share

Quando si deve mandare in esecuzione un processo il CFS seleziona quello con il


vruntime più piccolo, ovvero quello che ha utilizzato meno CPU. CFS usa vari
parametri tra cui sched_latency. Per n processi pronti si manda in esecuzione
il processo per un tempo di sched_latency/n purchè questo non sia inferiore a
un altro parametro: min_granularity (overhead del context-switch).

Si applica un fattore di scala per tenere conto del valore nice (priorità) di ogni
processo, per cui:
wk
slicek = max(sched_latency ∗ ∑i=0 , min_granularity)
n−1 wi

runtimek
vruntimek + =
wk
Per trovare efficientemente il minimo/aggiornare il vruntime dei processi pronti
essi vengono tenuti in un albero binario bilanciato di tipo rosso/nero: 0(log(n)).

Se un processo venisse messo in attesa e lasciassimo inalterato il vruntime


potrebbe monopolizzare la CPU, perciò quando diventa ready (dopo sleep o
init) si prende un vruntime uguale al minimo degli altri. In questo modo si
evita starvation al costo di non essere fair con i processi che fanno frequente-
mente I/O.

23

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


Multi-threading
6.1 Introduzione
I thread permettono di avere più flussi di esecuzione in un processo.
Creare un thread significa creare una ”CPU virtuale”, quindi è possibile avere
più flussi di esecuzione all’interno dello stesso spazio di indirizzamento.

Ogni thread ha il proprio stack e condivide codice e dati con gli altri
thread dello stesso processo (gli stack sono nello stesso spazio di indirizzamento
quindi gli altri thread potrebbero accederci (grave)).

Linux non distingue thread e processi a livello di scheduling. Il context-switch


fra thread costa meno di quello tra processi.

I thread si utilizzano per sfruttare il parallelismo (quando si hanno più core)


e per non bloccarsi quando si fa I/O (un thread fa I/O e altri fanno altro).

6.1.1 Creazione, attesa e terminazione di un thread


Consideriamo le API POSIX, pthread.

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,


void *(*start_routine) (void *), void *arg);

^/ Compile and link with -pthread.

In caso di successo restituisce 0 altrimenti restituisce direttamente il codice di


errore senza settare errno.

int pthread_join(pthread_t thread, void **retval); permette di attendere la


terminazione di un thread.

Mentre per uscire da un thread si può utilizzare return oppure chiamare:


void pthread_exit(void *retval);

6.1.2 Sezioni critiche e race-condition


Una sezione critica è un frammento di codice che accede a una risorsa con-
divisa.

Quando si hanno più flussi di esecuzione si parla di race condition quando il


risultato finale dipende dalla temporizzazione o dall’ordine in cui vengono

24

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


6.2. Primitive di sincronizzazione

schedulati. In questo caso la computazione è non-deterministica. Per esempio


si ha una race condition quando più thread eseguono una sezione critica.

Per evitare questi problemi si devono sincronizzare i thread (anche di processi


diversi). Noi studieremo le primitive per la mutua esclusione (mutex).

6.2 Primitive di sincronizzazione


È necessario che le operazioni di una sezione critica siano atomiche. Per dare
atomicità si introducono dei lock, implementati dai mutex in ptrhead.

Un lock:

• si dichiara con pthread_mutex_t e si può inizializzare tramite l’assegnazione


della costante PTHREAD_MUTEX_INIT o con pthread_mutex_init
• può essere acquisito da un thread alla volta tramite pthread_mutex_unlock
• e va rilasciato il prima possibile tramite pthread_mutex_unlock

Se un programma usa più strutture dati diverse (condivise) conviene avere più
lock:
• aumenta l’efficienza ma complica la gestione
• non si deve esagerare per evitare overhead (non conviene usare un lock
per ogni nodo di una lista, generalmente)

Tutte le strutture dati di Xv6 sono protette da uno spinlock (array che contiene
i file aperti, array dei processi, …).

6.2.1 Implementazione di lock


Si devono considerare:
• mutua esclusione (AKA ”funziona?”)
• fairness/starvation

• performance
Ha senso disabilitare/abilitare le interruzioni? No:
• servono istruzioni privilegiate
• un porcesso potrebbe monopolizzare la CPU

• nei sistemi multiprocessore/core non funziona: anche con gli interrupt


disabilitati le esecuzioni sono parallele

25

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


6.2. Primitive di sincronizzazione

Ha senso una struct con un flag che indica lo stato del lock e una funzione lock
che con un while aspetta che si liberi?

No: un thread dopo aver passato il while potrebbe essere deschedulato prima
di settare il lock, a questo punto un altro thread potrebbe essere rischedulato
(non necessario se multi-core), prendere il lock e poi essere deschedulato per far
tornare in esecuzione il primo thread. A questo punto ci ritroviamo con due
thread che hanno preso il lock e non c’è mutua esclusione.

È necessario supporto hardware. Diversi processori forniscono delle primitive


atomiche quali:

• Test-and-Set (fa quello detto poco sopra in modo atomico)


• scambi atomici
• …
Su x86 abbiamo l’istruzione XCHG che corrisponde a:

^* pseudo-codice (nel x86, istruzione XCHG) ^/


int AtomicExchange(int *ptr, int new) {
int old = *ptr;
*ptr = new;
return old;
}

Con queste istruzioni si possono implementare degli spin-lock.

6.2.2 Spin-lock
typedef struct ^_lock_t {
int is_locked;
} lock_t;

void init(lock_t *lock) {


lock^>is_locked = 0;
}

void lock(lock_t *lock) {


while (AtomicExchange(&lock^>is_locked, 1) ^= 1)
; ^/ spin-wait (do nothing)
}

void unlock(lock_t *lock) {


lock^>is_locked = 0;
}

26

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


6.3. Bug tipici della concorrenza

Funziona?
• Questo metodo garantisce mutua esclusione
• Non è fair, quindi non c’è garanzia che un thread in attesa prima o poi
otterrà il lock; starvation

• In single core se un thread che ha il lock viene deschedulato gli altri spre-
cano tempo e CPU, in multi-core funziona discretamente bene
Ha senso aspettare in un loop (consumando CPU)?

Se si aspetta poco si (si evita il costo dei context-switch, stiamo eseguendo in


modalità utente). Negli anni sono stati proposti degli approcci ibridi, ”lock in
due fasi”: un po di spin e poi eventualmente sleep.

Inversione delle priorità

È un fenomeno che può verificarsi con l’interazione tra scheduling e spin-lock.


Supponiamo di avere uno scheduler a priorità e due thread:
1. T1 a bassa priorità e T2 ad alta priorità

2. T2 è in attesa e va in esecuzione T1
3. T1 acquisisce un lock L
4. T2 torna ready
5. lo scheduler deschedula T1 e manda in esecuzione T2

6. T2 va in spin-wait per il lock L


7. Tanti auguri
Si potrebbe risolvere il problema ”ereditando la priorità” di un thread in attesa
(se maggiore di quella corrente), come è stato fatto per il NASA Pathfinder.

6.3 Bug tipici della concorrenza


Un codice corretto in un mondo single-threaded può avere molti problemi quando
si passa ad uno multi-threaded.

Violazione dell’atomicità
Puntatore globale che viene settato da un thread se non è NULL; dopo il check
qualcuno potrebbe metterlo a NULL; Tanti auguri.
Soluzione: mutex.

27

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


6.3. Bug tipici della concorrenza

Violazione dell’ordine
Creo un thread che esegue una funzione che al suo interno legge un valore del
thread creato (variabile globale); se va subito in esecuzione il nuovo thread la
variabile non è ancora stata inizializzata; Tanti auguri.

6.3.1 Deadlock
Indica una situazione in cui due o più processi o azioni si bloccano a vicenda,
aspettando che uno esegua una certa azione che serve all’altro e viceversa.
Per esempio:
Thread_A ^> lock(m1); lock(m2);
Thread_B ^> lock(m2); lock(m1);

Figure 6.1: The deadlock dependency graph.

Condizioni per il deadlock:


• mutua esclusione
• hold-and-wait: i thread mantengono le risorse acquisite mentre ne richiedono/aspettano
altre
• no preemption: le risorse acquisite non possono essere tolte
• circular wait: ci deve essere un ciclo nelle attese
Modi più semplici per prevenirlo:
• imporre un ordine sui lock e acquisirli in ordine (no circular wait)
• fare in modo che i lock vengano acquisiti tutti assieme, atomicamente (no
hold-and-wait)

28

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


Sicurezza
7.1 Introduzione
Il SO costituisce le fondamenta di ogni applicativo, perciò non possiamo costru-
ire sistemi sicuri se quello che c’è alla base non lo è, così come non si possono
realizzare SO sicuri se l’HW non lo è, ….

Ad alto livello si parla di tre obiettivi per la sicurezza:


• Confidentiality: confidenzialità/segretezza

• Integrity: integrità (nessuno può manomettere/sovrascrivere i dati di un


altro)
• Availability: disponibilità (se c’è un servizio questo deve essere disponibile
per i leggitimi utenti)

In generale si vuole la condivisione controllata.


Prima di eseguire una syscall il kernel deve valutare se la richiesta:
1. è sensata (#-syscall e parametri validi)
2. rispetta la politica di sicurezza; terminologia:

• l’entita che fa la richiesta è chiamato subject


• le richieste sono relative a una risorsa, l’object
• e una modalità di accesso (r/w)
Quando il kernel deve decidere se la richiesta può essere eseguita o meno
deve considerare il contesto (chi fa la richiesta in genere).

Un utente non parla direttamente con il kernel; servono:


• un’associazione fra utenti e processi
• un modo per verificare l’identità degli utenti

Quindi abbiamo tre fasi:


• Authentication: verifica dell’identità
• Authorization: applicazione di una politica di sicurezza; decidere se ac-
cettare/rifiutare le richieste che vengono effettuate da un processo

• Accounting: logging e gestione del consumo di risorse

29

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


7.2. Autenticazione

7.2 Autenticazione
Nei sistemi Unix-like tipicamente gli utenti si identificano con username e
password. In /etc/passwd sono salvati gli utenti del sistema:
login_name : x : UID : GID : user_name : user_home_dir : shell

Una volta erano contenute le password hashate ma il file è leggibile da chiunque.


Così è stato creato /etc/shadow leggibile da root o membri del gruppo shadow.

Il kernel identifica ogni utente con l’UID, analogamente per i gruppi GID (id).
L’UID zero corrisponde a root, l’amministratore di sistema. I processi sono
divisi in privilegiati se UID=0 e non privilegiati con UID̸=0.

Le Linux capabilities permettono una maggiore granularità (capabilities(7)).

7.3 Autorizzazione
Ci sono due approcci generali all’autorizzazione:
• access control list: per ogni object c’è una lista delle coppie di sub-
ject/access; Unix ACL di 9 bit r,w,x
• capabilities (non sono le Linux capabilities): per ogni object ci sono delle
chiavi che ne permettono l’uso
Bisogna anche considerare dove memorizzare queste informazioni, quanto spazio
serve, cosa fare quando si aggiungono/eliminano gli utenti.

Chi decide chi può/non può accedere a una risorsa?


• il proprietario, si parla di DAC: discretionary access control (anche
se root può leggere qualsiasi file)
• una qualche autorità impone le regole, si parla di MAC: mandatory
access control

7.3.1 Principio del minimo privilegio


In ogni momento, ciascuna entità dovrebbe avere il minimo privilegio che gli
permetta di eseguire i suoi compiti. Ovvero, nessun processo dovrebbe poter
accedere a più risorse di quelle necessarie per il suo corretto funzionamento.

In generale, è importante considerare il ruolo (in quel momento): lo stesso


utente può avere diversi ruoti, per esempio per alcune operazioni è necessario
diventare root, ma poi questo privilegio va rilasciato non appena non è più nec-
essario (su(1) (devi conoscere la password di root), sudo(1), trick sudo su).

30

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


7.3. Autorizzazione

Ogni processo ha tre UID:


• real UID: il proprietario del processo, chi può usare kill
• effective UID: identità usata per determinare i permessi di accesso a
risorse condivise come i file

• saved UID
• su Linux anche FS UID che non verrà spiegato (vedi capabilities(7))
Al login tutte tutti e tre coincidono. Tramite setuid(2) un processo può modifi-
care l’effective UID facendolo diventare come il real o il saved (se il chiamante
è privilegiato può modificare tutti e 3).

Un nuovo processo ”eredita” gli id del parent. Di solito execve non cambia gli
id del processo chiamante a meno che il file eseguibile ha il bit set-userid abil-
itato allora: effective UID e saved UID diventano quelli del proprietario del file.

7.3.2 Chroot jails, namespace e container


chroot(2) è una syscall che permette di modificare il significato di ”/” nella
risoluzione dei percorsi assoluti. È necessario che il processo sia privilegiato. La
modifica si applica al processo e ai futuri figli, i FD aperti non vengono toccati.

Questa syscall permette di creare le chroot jail:


• limitano la visibilità di un FS a una directory
• è un’applicazione del minimo privilegio ma se il processo resta privilegiato
o la syscall non è associata a una chdir(2) si può uscire dalla jail.
Questo meccanismo è l’antenato dei namespace, dei meccanismi utilizzati per
partizionare processi, utenti, network, …dentro al sistema e sono una delle tec-
nologie utilizzate dai container.

31

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


Persistenza
8.1 Dispositivi a blocchi
Chiamiamo dischi i dispositivi a blocchi di memoria secondaria, quali HDD,
SSD, ottici, chiavette USB, …. Dal punto di vista astratto sono tutti una se-
quenza di settori tipicamente da 512 byte l’uno. Questa suddivisione è data
dalla formattazione a basso livello (non quella che facciamo solitamente).

Con i dischi veri il costo di accesso ai dati variava in base alla posizione della
testina. Un SSD moderno è composto da dispositivi elettronici senza un vero e
proprio disco.

L’interfaccia è rimasta la stessa, consideriamone una semplificata con un disco


da n settori che possiamo indirizzare con ”indirizzi” da 0 a n − 1. La granularità
di accesso è il settore, 512B.

Tutto I/O su dispositivi di massa si basa su leggere e scrivere un blocco per


volta. Queste operazioni sono più costose dell’accesso in RAM, perciò i sistemi
Unix-like usano una (unified) buffer cache dove vengono portati i settori del
disco. Il SO andrà a scrivere i settori contenuti in questa cache quando lo riterrà
opportuno (su Linux si può forzare con sync(2)).

Per questo motivo è importante smontare i drive prima di scollegarli

8.1.1 Partizionamento
Per varie ragioni si può voler partizionare un disco. Ogni partizione si può usare
come un dispositivo a blocchi a sè stante:
• MBR: boot sector + informazioni sulle partizioni (4 partizioni primarie +
altre estese) (scambiare il bus potrebbe cambiare i nomi)
• GPT: partizioni identificate da un UUID (comodo perchè è univoco e
permette di riconoscere sempre la il disco)
Nei sistemi Unix-like file speciali a caratteri o blocchi corrispondono a
dispositivi di I/O, identificati da un major (classe) e un minor number (par-
ticolare): per esempio /dev/sda è un disco e /dev/sda1 è una partizione (anche
/dev/disk/by-uuid).

Questi file speciali possono essere creati con mknod da root. Per essere utilizzati
vanno montati traminte mount.

32

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


8.2. File system

In un sistema Unix c’è un solo file-system (root FS); mount aggancia l’albero di
file e directory di un dispositivo all’albero globale su un mount-point.

8.1.2 Gestione file speciali


Oggi /dev è un tmpfs, perchè è inizialmente vuoto e si riempie man mano che ri-
conosce i dispositivi collecati. Inoltre solleva degli eventi che creano/rimuovono
i file speciali quando vengono (s)collegati dei dispositivi hot-plug e in base a
questo dispositivo (s)carica moduli kernel (driver).

Alcuni programmi possono montare automaticamente il dispositivo che viene


collegato solitamente in /media/username/label.

8.2 File system


Gli utenti di un SO usano le astrazioni di:
• file: sequenza di byte
• directory: contenitori logici di file e directory (ricorsivamente)

a cui sono associati dei nomi.


Per implementare queste astrazioni su un dispositivo a blocchi si utilizza il file-
system. Un FS è una struttura dati che risiede su un dispositivo a blocchi
e:
• gestisce dati e metadati

• formattare, rispetto a un certo formato, significa preparare questa strut-


tura dati sul disco (mkfs.*)
La struttura che prenderemo in considerazione è una versione di FS alla Unix
semplificata, vsfs.

Sono necessarie anche altre strutture dati per gestire l’accesso ai file da
parte dei processi utente. Per esempio quando un processo fa la open il kernel
deve:
1. recuperare l’inode corrispondente al percorso specificato come stringa

2. allocare una struttura che corrisponde al file aperto che punta a (1)
3. allocare un FD nel PCB del processo che punta a (2)
Spesso i settori da 512B si raggruppano in blocchi logici, i cluster (da 4K).
Non si può utilizzare tutto il disco per i dati in quanto si devono memorizzare
i metadati sui file.

33

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


8.2. File system

Infatti:
• per ogni file abbiamo un inode
• gli inode sono contenuti nella tabella degli inode (numero max di file)
• per ogni inode bisogna sapere se usato o meno: inode bitmap

• analogamente per ogni blocco dati: data bitmap


• infine un superblocco per identificare il tipo di file-system e le sue carat-
teristiche (n. di inode, n. di blocchi dati, …)

Si usa una bitmap perchè si può portare tutta in RAM ed evitare di andare ad
effettuare operazioni costose su disco.

8.2.1 inode
Ogni inode contiene i metadati relativi a un file (TRANNE il nome):
• tipo di file: regolare, directory, symlink, FIFO, socket, dispositivo a
caratteri/blocchi
• UID/GID di proprietario e gruppo

• dimensione in byte
• maschera dei bit relativa ai permessi
• date di crezione/modifica/…

• numero di hardlink
• puntatori ai blocchi dati

34

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


8.2. File system

Figure 8.1: Puntatori ai blocchi dati

8.2.2 Directory, link e cancellazione


Le directory sono file che contengono associazioni nome →inode-#. Contegono
. e ^.. Queste associazioni sono hard link.

Con il comando ln(1) che si appoggia a link(2) e symlink(2) è possibile creare:


• hard link, ovvero un nome a un file esistente
• link simbolici/soft/symlink, ovvero file il cui contenuto corrisponde a
un percorso non necessariamente esistente
Gli hard link hanno senso solo sullo stesso FS perchè la tabella degli inode avrà
un contenuto diverso su FS diversi.

rm(1) che si appoggia a unlink(2) rimuove un hard link, e se tutti gli hard link
dovessero essere cancellati il file viene tenuto in vita finchè ci saranno FD aperti.

I permessi su una directory sono:


• r: posso leggere/mostrare i file contenuti
• w: posso modificarne il contenuto
• x: posso accederci
Per esempio si possono eliminare file read-only di root se la directory è nostra.

35

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


8.2. File system

8.2.3 Path resolution


1. Nel PCB ci sono inode di root r e directory corrente c
• se il percorso inizia con ”/” è assoluto, d = r
• altrimenti è relativo, d = c
2. per ogni componente (separata da /) non-finale, identifichiamo il nome n
(a) si controlla se si hanno i permessi di ricerca in d (no →EACCESS)
(b) si cerca n in d
• non si trova →ENOENT
• altrimenti si recupera l’inode corrispondente i
(c) si controlla se i è una directory, se si riparte da lì, d = i
(d) si controlla se i è un symlink, se si risolve a partire da d
• se il risultato non è una directory →ENOTDIR
• altrimenti, d′ , si continua da lì, d = d′ (un contatore evita loop
infiniti)
(e) ENOTDIR
3. per la componente finale non si pretende che sia una directory
4. . e ^. hanno significato speciale, il FS non li memorizza esplicitamente e
inoltre non si può salire sopra alla radice

8.2.4 Consistenza del file-system


Cosa succede se si devono aggiornare dei dati su disco e salta la corrente prima
di aver completato l’operazione? Per controllare l’integrità di un FS (smontato)
fsck(1):

• controlla che il superblocco contenga dati sensati


• consistenza bitmap blocchi liberi e puntatori ai file
– se più inode puntano allo stesso blocco si può duplicare il blocco e
dare a ciascuno la sua copia
– se un blocco puntato risulta libero si segna come libero sulla bitmap
– se un blocco non puntato risulta usato si segna come usato sulla
bitmap
• stato degli inode (es. tipo valido)
• link count (si possono recuperare file senza nome e verrà messo in /lost+found)
• puntatori fuori dal range dei blocchi
• directory (contiene gli hard link ”classici”, è collegata più volte)
• …

36

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)


8.2. File system

Il controllo di integrità è molto lento, perciò il FS non viene controllato se è


stato smontato in modo pulito (si setta sempre un bit nel superblocco che indica
che è sporco e si rimuove quando viene smontato correttamente).

L’approccio più moderno implementato nei FS moderni, il Journaling. Questo


ha l’obiettivo di rendere atomiche le sequenze di operazioni sul FS: prima di fare
le modifiche vengono segnate in un log (in journal); se c’è un crash vengono
riapplicate al mount.

In questo modo il FS resta sempre consistente.

37

[git] • Branch: (None) @ 91793c3 • Release: (2021-02-15)