Il linguaggio C è stato creato da Dennis Ritchie nel 1972 per sviluppare programmi UNIX. Infatti alcuni caratteristiche originali di UNIX sono ancora visibili, come il concetto di “everything is a file”.

I OS sono normalmente scritti in programmi C (o C++).

I tipi di dato primitivi in C sono gli inerti (includendo short e long), caratteri, e numeri floating-point (con la virgola). Tipi di dati strutturati possono essere costruiti utilizzando array, strutture e unioni.

Una caratteristica particolare di C è l’utilizzo dei puntatori (controllo sulla memoria, perfetti per scrivere OS). I puntatori sono un tipo di variabili che “puntano” (contengono l’indirizzo di) ad una variabile o struttura dati.

Per esempio :

char c1, c2, *p;
c1 = 'c';
p = &c1;
c2 = *p;

I puntatori sono costrutti molto potenti, che però possono portare ad errori quando usati senza attenzione.

Everything is a file

Secondo questa “filosofia”, non solo file di testo, ma anche l’hardware (disco, stampanti), i canali di comunicazione (pipe) e le connessioni di rete (sockets) sono trattati dal OS come file.

Per questo motivo quando un processo viene eseguito, il OS gli assegna automaticamente tre file già aperti :

(file number = file descriptor)

Build process - processo di compilazione

Per fare il build del OS, ogni file .c è compilato in un file oggetto da un compilatore C. Questo file oggetto contiene le istruzioni macchina che andranno eseguite dalla CPU (dopo il linking).

Prima del compilatore, vi è il preprocessore C, che legge i file sorgente e risolve gli #include, espande le macro, gestisce la compilazione condizionale e poi passa il risultato al compilatore.

Una volta che il file oggetto sono pronti, vengono passati al linker che li combinerà tra di loro per ottenere un unico file eseguibile.

Dalle librerie standard alle system call

Vediamo tre modi per stampare “Hello World!” nella console (output standard - stdout), per livello di astrazione :

  1. livello alto (printf) : utilizzando una libreria standard stdio.h
void printf(char *str, ...);
#include <stdio.h>
 
int main(int argc, char **argv){
	printf("Hello World!\n");
	return 0;
}
  1. livello medio (write) : utilizziamo una funzione di sistema che parla direttamente con il kernel, dobbiamo però specificare dove, cosa e quanto scrivere.
   int write(int fd, char *buf, size_t len);
#include <unistd.h>
#define STDOUT 1
 
int main(int argc, char **argv){
 
	char msg[] = "Hello World!\n";
	write(STDOUT, msg, sizeof(msg));
	
	return 0;
}
  1. livello basso (syscall) : invochiamo direttamente il kernel usando l’id della funzione (es. per la write su x86_64 è 1)
int syscall(int number, ...);
#include <unistd.h>
#include <stdio.h>
#inlude <sys/syscall.h>
#define STDOUT 1
 
int main(int argc, char **argv){
	
	char msg[] = "Hello World!\n";
	int nr = SYS_write; //id funzione write
	syscall(nr, STDOUT, msg, sizeof(msg));
	
	return 0;
}
 

Per avere una panoramica migliore di quello che succede possiamo vedere la Syscall table.


Libc fornisce wrapper utili intorno alle syscall, ad esempio con write, read ed exit.

Lunga attraversata da read() a ksys_read()

Vediamo cosa succede quando viene invocata la funzione read().

1 - read come wrapper

La funzione read non chiama direttamente il kernel, ma una funzione della libreria standard glibc chiamata __libc_read, che funge da wrapper per la system call read (vera e propria).

Questa logica è contenuta nel file della libreria C glibc/sysdeps/unix/sysv/linux/read.c, di cui il contenuto rilevante :

ssize_t __libc_read (int fd, void *buf, size_t nbytes){
 
	return SYSCALL_CANCEL(read, fd, buf, nbytes);
}

(discesa verso il kernel con questa macro)

2 - Macro SYSCALL_CANCEL

La ragione per cui glibc non fa direttamente la chiamata, ma usa questa macro specifica è che :

  • cancellazione pulita se un thread viene cancellato mentre sta aspettando che la read() finisca
  • la macro disabilita temporaneamente la capacità di interrompere il thread in quel momento critico
  • evitare che glibc o il programma rimangano in uno stato corrotto se l’operazione viene interrotta bruscamente

All’interno di questa “protezione”, viene chiamata un’altra macro ancora più interna : INLINE_SYSCALL_CALL(read, fd, buf, nbytes).

3 - Macro INLINE_SYSCALL_CALL

La macro INLINE_SYSCALL_CALL utilizza la macro INLINE_SYSCALL per costruire l’istruzione assembly che effettua la system call. Infatti la definizione della macro INLINE_SYSCALL dipende dall’architettura, per x86_84 la troviamo in glibc/sysdeps/unix/sysv/linux/x86_64/sysdep.h.

Infatti questa macro prepara i registri con i parametri della system call (syscall) (secondo x86_64) :

  • in %rdi : file descriptor - fd
  • in %rsi : buffer - buf
  • in %rdx : count
  • in %eax : id della system call (_NR_read, ovvero 0)
 
#undef internal_syscall3
#define internal_syscall3(number, arg1, arg2, arg3) \
({                                                                                 \
unsigned long int resultvar;                                                       \
TYPEFY (arg3, __arg3) = ARGIFY (arg3);                                             \
TYPEFY (arg2, __arg2) = ARGIFY (arg2);                                             \
TYPEFY (arg1, __arg1) = ARGIFY (arg1);                                             \                                  
register TYPEFY (arg3, _a3) asm ("rdx") = __arg3;                                  \
register TYPEFY (arg2, _a2) asm ("rsi") = __arg2;                                  \
register TYPEFY (arg1, _a1) asm ("rdi") = __arg1;                                  \
asm volatile (                                                                     \
	"syscall\n\t"                                                                 \
	: "=a" (resultvar)                                                            \
	: "0" (number), "r" (_a1), "r" (_a2), "r" (_a3)                               \
	: "memory", REGISTERS_CLOBBERED_BY_SYSCALL);                                  \
	(long int) resultvar;                                                         \
})

Qui viene caricato il numero (number) della system call in %rax e passa gli argomenti dei registri appropriati secondo la convenzione Linux a 64 bit.

Il parametro number è una costante definita dal kernel e resa disponibile da glibc tramite header come syscall_64.tbl :

0	common	read			sys_read
1	common	write			sys_write
2	common	open			sys_open
3	common	close			sys_close
4	common	stat			sys_newstat

Esegue poi l’istruzione syscall che fa il salto alla modalità kernel :

  • la CPU salva lo stato corrente del processo
  • cambia lo stack e i segmenti per entrare nello spazio kernel
  • il controllo passa ad un indirizzo specifico definito nel registro MSR_LSTAR (label entry_SYSCALL_64 in Linux nel file arch/x86/entry/entry_64.S) Questa istruzione è la prima ad essere eseguita nel kernel come parte della system call read().
5 - Simbolo entry_SYSCALL_64

Quindi questa è l’entry point nel kernel per tutte le system call a 64 bit, un punto di ingresso che il processore trova quando esegue syscall. Il codice in entry_SYSCALL_64 prepara il terreno per eseguire la system call vera e propria.

SYM_CODE_START(entry_SYSCALL_64)
	UNWIND_HINT_ENTRY
	ENDBR
 
	swapgs
	movq	%rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
	SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
	movq	PER_CPU_VAR(cpu_current_top_of_stack), %rsp

Le operazioni sono :

  1. swapgs : scambia il registro GS (puntatore a stack utente/kernel), in modo che la CPU possa usare la struttura dati per il kernel
  2. movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2) : salva lo stack pointer utente nella Task State Segment (TSS) (per tornarci dopo)
  3. SWTICH_TO_KERNEL_CR3 : cambia le tabelle delle pagine (CR3 registro che punta a queste tabelle), quindi passa da quelle utente a quelle del kernel (protezione memoria)
  4. movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp : imposta lo stack pointer (%rsp) nella cima dello stack del kernel e ora il kernel ha il suo stack su cui lavorare

Solo dopo questi passaggi, il kernel può continuare con do_syscall_64().

6 - Funzione do_syscall_64()

Questa è una funzione C del kernel Linux e rappresenta il vero dispatcher delle system call a 64 bit ed è chiamata direttamente da entry_SYSCALL_64 (in assembly) dopo il passaggio in modalità kernel.

Riceve 2 parametri :

  • struct pt_regs *regs : puntatore alla struttura che contiene tutti i registri della CPU
  • int nr : id (numero) della system call (es. _NR_read = 0)

Secondo il file arch/x86/entry/syscall_64.c il suo flusso principale è il seguente :

  1. syscall_enter_from_user_mode() : gestisce il contesto di ingresso nel kernel
  2. do_syscall_x64() : sceglie (dispatcher) la funzione appropriata consultando la sys_call_table
  3. syscall_exit_to_user_mode() : prepara il ritorno allo user space

La funzione do_sycall_x64() :

  • controlla il numero della system call sia valido (nr)
  • se valido chiama la funzione x64_sys_call(regs, nr)
7 - Funzione x64_sys_call()

Questa funzione contiene uno switch generato automaticamente dal file asm/syscalls_64.h, dove ogni case corrisponde ad un numero di system call e richiama la funzione kernel corrispondente.

Per esempio :

  • case 0 : __x64_sys_read(regs)
  • case 1 : __x64_sys_write(regs)

Quindi nel caso nostro di read(), il kernel chiama __x64_sys_read(), definita nel file fs/read_write.c.

La funzione __x64_sys_read() a sua volta chiama ksys_read(fd, buf, cont) che implementa la logica reale : - accede al file descriptor e lookup del file descriptor - controlla i permessi - legge i dati nel buffer kernel tramite vfs_read() - copia i dati nello user space

La chiamata vfs_read() chiama file->f_op->read_iter (legge i byte), vedremo poi come funziona a questo livello.


Da dove viene __x64_sys_read() e chi la crea?

Questa funzione non esiste scritta nel codice sorgente, ma viene generata automaticamente dalla macro SYSCALL_DEFINE3().

Come abbiamo visto prima tutte le system call sono registrate in una tabella nel file arch/x86/entry/syscalls/syscall_64.tbl.

  • uno script scripts/syscalltbl.sh genera il file header asm/syscalls_64.h che contiene per ogni system call __SYSCALL(nr, sys_ni_syscall).
  • questo file header viene incluso in arch/x86/entry/syscall_64.c (come visto prima) che poi all’interno contiene i case di questo tipo case 0: return __x64_sys_read(regs)

La macro SYSCALL_DEFINE3() implementata nel file fs/read_write.c :

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
    return ksys_read(fd, buf, count);
}

e definita in include/linux/syscalls.h, che quello che fa è in fase di compilazione espande (trasforma) il codice in :

asmlinkage long __x64_sys_read(unsigned int fd, char __user *buf, size_t count)
{
    return ksys_read(fd, buf, count);
}

Tra gli esempi di SYSCALL_DEFINE3 del kernel linux abbiamo :

Lista completa


Riassumiamo ora i passaggi :

  1. User space
    • funzione C read(fd, buf, count) invoca la system call read
    • numero della system call viene caricato in %rax
  2. Entry point in assembly
    • file : arch/x86/entry/entry_64.S
    • istruzione syscall fa il salto in kernel mode
    • controllo passa a entry_SYSCALL_64()
    • controllo passa a do_syscall_64()
  3. Preparazione contesto kernel
    • funzione syscall_enter_from_user_mode()
    • salva registri
    • verifica privilegi
    • prepara lo stack kernel
  4. Dispatcher della system call
    • file : arch/x86/entry/syscall_64.c
    • do_syscall_64() chiama do_syscall_x64()
    • do_syscall_x64() invoca x64_sys_call(regs, nr)
    • switch-case generato automaticamente che invoca __x64_sys_read(regs)
  5. Definizione della system call vera
    • file : fs/read_write.c
    • macro SYSCALL_DEFINE3 che implementa (espande/trasforma) __x64_sys_read()
    • __x64_sys_read() chiama ksys_read(fd, buf, count)
  6. Implementazione kernel pura
    • file : fs/read_write.c
    • ksys_read() esegue la logica della read
  7. File system layer
    • funzione : vfs_read() che chiama file->f_op->read_iter

Processi_Management