AOS - 11 - KERNEL PROGRAMMING BASICS IV
1 Lecture Info
Data:
Sito corso: link
Progresso unità: 4/6
Argomenti:
Linux versions
System call numerical codes
_syscallN(type, name) macros
Calling conventions
i386 stack allignment
x86-64 stack allignemnt
Homework #1: TLS
Introduzione: Nella scorsa lezione avevamo introdotto i meccanismi base per passare il controllo da una applicazione che gira user-space ad una system call che gira in modalità kernel. In particolare avevamo menzionato che esiste un solo GATE associato alle software traps. Tramite l'utilizzo di un dispatcher e della system call table siamo in grado di offrire un insieme di servizi. Il dispatcher, a seconda dei parametri passati dall'utente, chiama la specifica system call che deve essere eseguita. Continuiamo quindi la nostra discussione con i seguenti argomenti.
2 Linux Versions
Nel corso vedremo vari pezzi di codice presi da varie versioni del kernel di Linux. L'idea è quella di partire da versioni datate per poi passare a quelle più moderne. Possiamo dare una overview generale alle macro versioni del kernel linux:
La versione 2.4 è una versione incentrata all'espandibilità e alla modificabilità.
Dato che la versione 2.4 non era molto efficiente per sistemi multi-core, la versione 2.6 è una versione più scalabile.
Le versioni 3.x sono più strutturato e incentrate sulla sicurezza.
Le versioni 4.x sono ancora più sicure, in quanto sono stati introdotto dei meccanismi come, ad esempio, la Page Table Isolation (PTI).
3 System call (software) Components
A livello user abbiamo un modulo software che deve:
Passare l'input al GATE (e quindi al dispatcher).
Attivare il GATE.
Prendere ed eventualmente processare il valore di ritorno della system call.
A livello kernel invece abbiamo
Un modulo software che implementa il dispatcher
Una system call table.
Il codice della system call.
Per poter aggiungere una nuova system call dobbiamo andare a lavorare su entrambi i lati. Notiamo inoltre che se il formato della system call che vogliamo aggiungere è compliant con la convenzione già utilizzata dal dispatcher, non dobbiamo andare a modificare il dispatcher, altrimenti potrebbe risultare necessario scrivere un nuovo dispatcher. In ogni caso, il punto fondamentale da tenere a mente per quanto riguarda la compatibilità con il dispatcher di default è il modo in cui vengono gestiti i parametri, con particolare attenzione a
Quanti possono essere.
Come vengono passati al dispatcher.
4 System Call Formats
Andiamo adesso ad analizzare il modo predefinito per chiamare le system call che veniva usato nella versione 2.4 del kernel Linux. In particolare quando parliamo di "formati delle system call" stiamo intendendo una serie di regole e convenzioni che vengono utilizzate per attivare le system call.
Le macro che ci permettono di interagire con questi formati standard si trovano nei file
include/asm-xx/unistd.h
asm/unistd.h
/usr/include/x86_64-linux-gnu/asm/unistd.h
/usr/include/linux/unistd.h
In questi file troviamo
L'associazione tra codice numerico e nome della system call visto dal software lato user.
L'implementazione dei moduli user level che avviano il dispatcher al fine di entrare in modo kernel e chiamare la system call di interesse.
4.1 System Call Numerical Codes
I numerical codes sono delle macro che associano ad un valore
simbolico della forma __NR_<system_call_name>
che rappresenta un
particolare servizio offerto dal kernel un numero da 0 fino
all'indice massimo della system call table. In questo modo è
possibile associare ad ogni servizio offerto da kernel ed
eventualmente implementato come system call, un codice numerico.
Queste macro sono definite in vari file, a seconda della particolare versione del kernel. Ad esempio troviamo,
Nel file
/usr/include/asm/unistd_32.h
troviamo i codici numerici per le system call in architetturex86
a 32 bit.#ifndef _ASM_X86_UNISTD_32_H #define _ASM_X86_UNISTD_32_H 1 #define __NR_restart_syscall 0 #define __NR_exit 1 #define __NR_fork 2 #define __NR_read 3 #define __NR_write 4 #define __NR_open 5 #define __NR_close 6 #define __NR_waitpid 7 #define __NR_creat 8 #define __NR_link 9 #define __NR_unlink 10
Nel file
/usr/include/asm/unistd_64.h
invece troviamo i codici numerici per le system call in architetture x86 a 64 bit (x86_64).#ifndef _ASM_X86_UNISTD_64_H #define _ASM_X86_UNISTD_64_H 1 #define __NR_read 0 #define __NR_write 1 #define __NR_open 2 #define __NR_close 3 #define __NR_stat 4 #define __NR_fstat 5 #define __NR_lstat 6 #define __NR_poll 7 #define __NR_lseek 8 #define __NR_mmap 9 #define __NR_mprotect 10
Notiamo che in x86 a 32 bit la system call associata al codice
numero 0 è chiamata restart_syscall
. Questa system call è una
system call "dummy", nel senso che quando viene eseguita non fa
altro che ritornare, senza fare nessun tipo di lavoro effettivo. Il
codice di questa system call viene utilizzato ogni volta che nella
system call table è presente un valore numerico associato ad un
servizio che non è ancora stato implementato all'interno del
kernel. In questo senso la system call "dummy" funzoina da stub,
che blocca i buchi della system call table.
4.2 Macros to Trigger System Calls
All'interno dei file menzionati prima sono anche presenti delle macro che permettono di definire dei blocchi di codice necessari per chiamare una particolare system call. In particolare abbiamo una macro per numero di argomenti, come mostrato a seguire
_syscall0(type, name)
_syscall1(type, name, type1, arg1)
_syscall2(type, name, type1, arg1, type2, arg2)
Andando ad analizzare la macro _syscall0(type, name)
, che permette
di chiamare una qualsiasi system call con \(0\) argomenti, troviamo
il seguente codice
#define _syscall0(type, name) \ type name(void) \ { \ long __res; \ __asm__ volatile ("int $0x80" \ : "=a" (__res) \ : "0" (__NR_##name)); \ __syscall_return(type, __res); \ }
Tale codice non fa altro che chiamare una software trap
indicizzanto il GATE 0x80
, che contiene il codice necessario per
far partire il dispatcher. Al dispatcher poi passiamo in input
tramite il registro RAX
il codice numerico della sys call da
utilizzare. Infine, si prende l'output risultante dalla sys call e
lo si inserisce nella variabile __res
. Il contenuto di questa
variabile è poi controllato attraverso la macro __syscall_return
.
4.3 Manage Return Value of Syscalls
Nelle varie macro che chiamano sys call con diversi argomenti alla
fine è sempre presente la macro __syscall_return(type, __res)
. Il
compito di questa macro è gestire il valore di ritorno in modo
corretto. In particolare controlla se c'è stato qualche errore, e
che tipo di errore c'è stato.
Nello standard POSIX
si è definito che nel caso in cui una system
call fallisce, il suo valore di ritorno dovrebbe essere \(-1\). Negli
ambienti di esecuzione \(C\) poi in caso di fallimento si ha che
tipicamente nella variabile errno
è presente il particolare numero
di errore, che contiene delle informazioni più specifiche sulla
natura del fallimento. Il kernel però ritorna un solo valore dalla
system call. Per poter dire che c'è stato un errore, e anche che
tipologia di errore c'è stata, si è deciso di far ritornare al
kernel un valore a segno negativo. La libreria <asm-i386/errno.h>
contiene tutti gli user-visibile error numbers tra \(-1\) e \(-124\).
Il codice della macro __syscall_return(type, __res)
è il seguente
#define __syscall_return(type, res) \ do { \ if ((unsigned long)(res) >= (unsigned long)(-125)) { \ errno = -(res); \ res = -1; } \ return (type) (res); \ } while 0
Notiamo come il costrutto do/while(0) viene utilizzato per definire delle macro con varie proprietà interessanti, in quanto permette di
Definire una macro (#define) con più statements.
Nettere un ; alla fine.
Utilizzare la macro dentro un blocco if.
4.4 Stub for Syscall with 6 Parameters
Il codice che permette di chiamare una system call a 6 parametri
sulla i386
, ovvero la versione x86 a 32 bit è più complesso, in
quanto non bastano i general purpose registers per passare tutti i
parametri. Infatti su i386 abbiamo solamente \(4\) registri general
purpose, mentre noi dobbiamo utilizzare \(7\) registri: 6 per i
parametri, e uno per il codice della syscall.
Per risolvere questo problema vengono quindi utilizzati altri registri, tra cui il registro EBP. Prima di poterlo utilizzare però il registro EBP deve essere salvato nello stack, e dopo essere ritornati dalla system call il registro deve essere ripristinato con il suo vecchio valore.
Il codice per gestire la sys call è il seguente
#define _syscall0(type, name, type1, arg1, type2, arg2, type3, arg3, type4, arg4, type5, arg5, type6, arg6) \ type name(type1 arg1, type2 arg2, type3 arg3, type4 arg4, type5 arg5, type6 arg6) \ { \ long __res; \ __asm__ volatile ("push %%ebp; movl %%eax, %%ebp ; movl %1 %%eax ; int $0x80 ; pop %%ebp" \ : "=a" (__res) \ : "i" (__NR_##name), "b" ((long) (arg1)), "c" ((long)(arg2)), \ "d" ((long)(arg3)), "S" ((long)(arg4)), "D" ((long)(arg5)), \ "0" ((long)(arg6))); \ __syscall_return(type, __res); \ }
4.5 Calling Conventions
Durante la chiamata del dispatcher, prima che il dispatcher prende il controllo, il firmware può cambiare lo stato del processore. In particolare il dispatcher e il firmware modificando la struttura dello stack che la system call si troverà una volta che viene mandata in esecuzione. Si parla quindi di stack allignment per intendere cosa si deve trovare nello stack e in che ordine i dati devono essere organizzati.
I valori che vengono inseriti nello stack da parte del firmware sono tipicamente utilizzati per gestire il ring model. Ad esempio vengono memorizzate informazioni sul code segment originale e sullo stack pointer originale.
A seconda della versione di x86, abbiamo diversi stack allignments.
Nella modalità a 64 bit, x86-64, è stato introdotto un ulteriore metodo per passare in modo kernel. Questo metodo è stato introdotto specificatamente per l'esecuzione di system call e a differenza del meccanismo delle software traps e degli interrupt, questo nuovo metodo è più performante, anche se necessita un settaggio dello stato del processore più estensivo.
Dato che abbiamo due modalità per entrare nel kernel, il modo e
l'ordine in cui vengono utilizzati i vari registri cambia a seconda
se stiamo utilizzando il metodo classico (quello delle trap), o il
metodo veloce. Per fare un esempio il registro RCX
viene utilizzato
in modo diverso dai due meccanismi appena menzionati: con il
vecchio metodo viene utilizzato per passare il terzo argomento
(arg3), mentre nel nuovo metodo viene utilizzato per passare
l'indirizzo di ritorno syscall/sysret.
Oltre a definire lo stack allignment, la ABI
(Application Binary
Interface) specifica anche chi deve preservare i registri del
processore. Parte di questi registri devono essere infatti
preservati dal "chiamato" e alcuni dal "chiamante". In particolare
se il chiamato vuole utilizzare i registri RBX, RBP e da R12 a R15,
sarà suo il compito di preservarli (registri callee saved). Tutti
gli altri registri sono salvati dal chiamante, se è interessato a
preservane i valori.
4.5.1 ABI vs reality
Notiamo che non ci basta essere compliant con quello che descrive
l'ABI per essere sicuri che i parametri vengano passati in modo
corretto. Molto spesso infatti quando eseguiamo una system call
esistono dei parametri impliciti, che non passiamo direttamente ma
che devono modificare il funzionamento della chiamata di
sistema. Un esempio di questo scenario è durante l'esecuzione
della chiamata di sistema fork()
, che non prende esplicitamente
dei parametri, ma che utilizza lo stato della CPU come parametro
implicito per forkare il processo.
Per garantire il corretto funzionamento di chiamate di sistema come la fork(), il dispatcher deve prendere un completo snapshot dei registri della CPU. Questo snapshot viene salvato nella stack di sistema. Così facendo ogni system call è in grado di leggere il completo stato del processore del processo chiamante.
Per fare questo dobbiamo anche avere un modo per permetterci di specificare il modo in cui una funzione scritta in un linguaggio di alto livello come il C debba prendere i suoi parametri. In particolare dobbiamo specificare se i parametri devono essere presi dai registri oppure dallo stack.
Detto questo, dato però che lo stato che scriviamo sullo stack è uguale allo stato del processore in esecuzione, ci basta leggere le informazioni dalla CPU per migliorare le performance.
4.5.2 i386 stack allignment
La regola di stack allignemnt su i386 è la seguente
Il registro EAX
è utilizzato per impostare il valore di ritorno
della system call, mentre orig_eax
rappresenta il valore originale
contenuto in EAX
, ovvero il codice numerico della system call
utilizzato dal dispatcher.
Lavorando in modalità 32 bit le informazioni sullo stato della CPU
sono memorizzate nella struttura struct t_regs
, che viene passata
ogni volta al codice delle system call che andiamo ad
eseguire. Tale struttura è definita come segue
struct pt_regs { // --------- Software saved --------- // (no distinction between caller save and callee save) unsigned long bx; // Register RBX unsigned long cx; // Register RCX unsigned long dx; // Register RDX unsigned long si; // Register RSI unsigned long di; // Register RDI unsigned long bp; // Register RBP unsigned long ax; // Register RAX unsigned short ds; // Selector DS unsigned short __dsh; // Padding unsigned short es; // Selector ES unsigned short __esh; // Padding unsigned short fs; // Selector FS unsigned short __fsh; // Padding unsigned short gs; // Selector GS unsigned short __gsh; // Padding unsigned long orig_ax; // Original RAX value // --------- Firmware saved --------- unsigned long ip; unsigned long cs; unsigned long __csh; unsigned long flags; unsigned long sp; unsigned long ss; unsigned long __ssh; }
4.5.3 x86-64 stack allignment
Passando da i386 a x86-64, con l'introduzione della fast system call path, la Application Binary Interface (ABI) è stata aggiornata. Nella nuova versione dell'ABI gli argomenti vengono passati al dispatcher non più tramite la stack ma tramite i registri. In particolare abbiamo
Lavorando a 64 bit la struttura pt_regs
è definita come segue
struct pt_regs { /* C ABI says these regs are callee-preserved. They aren't saved on kernel entry unless syscall needs a complete, fully filled "struct pt_regs". */ unsigned long r15; unsigned long r14; unsigned long r13; unsigned long r12; unsigned long pb; unsigned long bx; /* These regs are callee-clobbered. Always saved on kernel entry. */ unsigned long r11; unsigned long r10; unsigned long r9; unsigned long r8; unsigned long ax; unsigned long cx; unsigned long dx; unsigned long si; unsigned long di; /* On syscall entry, this is syscall @- On CPU exception, this is error code. On hw interrupt, its IRQ number. */ unsigned long orig_ax; /* Return frame for iretq (firmware managed)*/ unsigned long ip; unsigned long cs; unsigned long flags; unsigned long sp; unsigned long ss; /* top of stack page */ };
5 Running Examples
Andiamo adesso a vedere qualche esempio di utilizzo delle macro appena descritte.
5.1 SYS-CALL/asm-terminal-echo.c
In questo blocco di codice costruiamo un meccanismo per eseguire
echo senza utilizzare le tipiche system call read()
e write()
offerte dalla libreria C, ma andando a codificare all'interno del
codice C un pezzo di codice ASM che chiama direttamente il
dispatcher attraverso la software trap "int 0x80".
Il corpo principale del codice è un while loop che esegue due funzioni
char c; // putting c here (low addresses) allows correct runs when // using ecx as pointer to c with -D m32 compilation char* newline="\n"; char v[1024]; void instring(char*); void outstring(char*); int _start(int argc){ while(1){ instring(v); outstring(v); outstring(newline); } }
Le funzioni di lettura e scrittura contengono il codice ASM necessari per comunicare al kernel di leggere e scrivere dal terminale e sono implementate come segue
instring(v)
legge una stringa da terminale un byte alla voltavoid instring(char *s){ long i = 0; do{ #ifndef m32 // In modalità x86-64 abbiamo un modo alternativo per passare in // lato kernel per eseguire le system call. Questo medoto si basa // sull'istruzione syscall. asm("mov %%rbx, %%rdi; \n" "mov %%rcx, %%rsi; \n" "mov %%rdx, %%rdx; \n" "syscall " : : "a" (0) , "b" (0) , "c" ((unsigned long)(&c)) , "d" ((long)(1))); #else // In modalità x86 a 32 bit l'unico modo per eseguire le sys call // è attraverso il GATE 0x80. Questo codice non fa altro che // chiamare la system call write() per leggere da STDOUT un byte e // scriverlo nella cella di memoria di c // // read(0, &c, 1); // asm("int $0x80" : : "a" (3) , // Imposta RAX a 3, che è il codice numerico della // systemc all read(). "b" (0) , // Primo parametro della sys call, 0 = STDOUT. "c" ((unsigned long)(&c)) , // Secondo parametro della sys // call, indirizzo della variabile c. "d" ((long)(1))); // Terzo parametro della sys call, numero // di byte da scriveren nel terminale. #endif s[i] = c; i++; } while ( (c!='\n') && (c!=' ') && (c!='\t')); // Ripeti finché non // troviamo uno spazio, // una newline, o una tab. i--; s[i]='\0'; }
outstring(v)
, che scrive a terminale.void outstring(char *s){ long i=0; while (s[i++]!='\0'); // compute length of string to print i--; #ifndef m32 asm("mov %%rbx, %%rdi; \n" "mov %%rcx, %%rsi; \n" "mov %%rdx, %%rdx; \n" "syscall " : : "a" (1) , "b" (1) , "c" ((unsigned long)(s)) , "d" ((long)(i)) ); #else asm("int $0x80" : : "a" (4) , "b" (1) , "c" ((unsigned long)(s)) , "d" ((long)(i)) ); #endif }
5.2 SYS-CALL/sys-call-macro.c
In questo esempio abbiamo definito a mano una macro per poter definire lato user una nuova system call che effettua una fork()
#include <unistd.h> #define __NR_my_fork 2 #define _new_syscall0(name) \ int name(void) \ { \ asm("int $0x80" : : "a" (__NR_##my_fork) ); \ return 0; \ } \ _new_syscall0(my_fork) int main(int a, char** b){ my_fork(); pause(); }
6 Homework #1: TLS
Segue adesso la descrizione di uno dei primi homeworks che sono stati assegnati durante il corso. Il codice dettagliato degli homework è presente nella cartella HOMEWORK 1 - TLS. Qui riportiamo solo una breve discussione sui punti salienti di una possibile soluzione.
6.1 Requirements
This homework deals with the implementation of a TLS support standing aside of the original one offered by gcc. It should be based on a few macros with the following meaning:
PER_THREAD_MEMORY_START and PER_THREAD_MEMORY_END are used to delimitate the variable declarations that will be part of TLS
READ_THREAD_VARIABLE(name) is used to return the value of a TLS variable with a given name
WRITE_THREAD_VARIABLE(name, value) is used to update the TLS variable with a given name to a given value
Essentially, the homework is tailored to implementing an interface for managing per-thread memory resembling the one offered by the Linux kernel for managing per-CPU memory.
6.2 Solution
L'idea base è quella di definire le macro di creazione della TLS in modo da wrappare le variabili che vogliamo all'interno della TLS all'interno di una struct.
#define PER_THREAD_MEMORY_START typedef struct tls_zone { \ unsigned long tls_zone_address; #define PER_THREAD_MEMORY_END } tls_zone;
dove nel campo tls_zone_address
ci andremmo ad inserire l'indirizzo
della tls_zone in modo da poterlo recuperare velocemente. Così
facendo se scriviamo
PER_THREAD_MEMORY_START int x; int y; double z; PER_THREAD_MEMORY_END
La macro verrà risolta durante la compilazione nel seguente modo
typedef struct tls_zone { unsigned long tls_zone_address; int x; int y; double z; } tls_zone;
La creazone della TLS avviene nel seguente modo: nella funzione di
creazione del thread ci andiamo ad allocare della memoria con
mmap()
, ci salviamo le informazioni sull'indirizzo della TLS zone
all'interno della TLS zone, e settiamo la base del segmento puntato
dal selettore GS con la funzione arch_prctl()
.
void *init(void *arg) { // Get ID info unsigned long me = (unsigned long)arg; // Allocate memory for TLS zone void *mem = mmap(NULL, sizeof(struct tls_zone), PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, 0, 0); // Put address information in the TLS zone ((tls_zone *)mem)->tls_zone_address = (unsigned long) mem; // Set base for GS segment arch_prctl(ARCH_SET_GS, mem); printf("(%d) - Base address for GS segment is at %p\n", me, mem); // Do actual work work(me); return NULL; }
Così facendo ogni volta che il thread è in esecuzione possiamo risalire all'indirizzo della TLS utilizzando l'indirisso base del segmento GS.
Al fine di definire le funzioni per la lettura la scrittura delle variabili nella nostra TLS definiamo prima la funzione che ritorna l'indirizzo base del TLS. Dato che il TLS verrà caricato all'indirizzo base del segmento puntato dal selettore GS, possiamo definire tale funzione come segue
unsigned long get_tls_address(void) { unsigned long addr; asm("mov %%gs:(0), %0": "=b" (addr)); return addr; }
Riportiamo quindi l'implementazione delle macro di lettura e scrittura.
#define READ_THREAD_VARIABLE(name) \ ((tls_zone*)(get_tls_address()))->name #define WRITE_THREAD_VARIABLE(name, value) \ ((tls_zone*)(get_tls_address()))->name = value;
Per testare il codice appena scritto possiamo quindi utilizzare la
funzione work()
void work(unsigned long id) { int c; double d; printf("(%d) - Before work\n", id); c = READ_THREAD_VARIABLE(x); printf("(%d) - Variable x has value: %d\n", id, c); // ------ if(id == 1) { WRITE_THREAD_VARIABLE(x, 10); } else if (id == 2) { WRITE_THREAD_VARIABLE(x, 20); } // ------ printf("(%d) - After work\n", id); c = READ_THREAD_VARIABLE(x); printf("(%d) - Variable x has value: %d\n", id, c); }
Infine, come main()
possiamo semplicemente creare due thread e
vedere il risultato
int main(int argc, char **argv) { pthread_t tid1, tid2; pthread_create(&tid1, NULL, init, (void*) 1); pthread_create(&tid2, NULL, init, (void*) 2); pause(); return 0; }
Compilando ed seguendo
gcc tls.c -lpthread ./a.out
otteniamo il seguente risultato
(1) - Base address for GS segment is at 0x7f5c20f56000 (1) - Before work (1) - Variable x has value: 0 (1) - After work (1) - Variable x has value: 10 (2) - Base address for GS segment is at 0x7f5c20f55000 (2) - Before work (2) - Variable x has value: 0 (2) - After work (2) - Variable x has value: 20
Come possiamo vedere, ogni thread ha una propria versione locale della variabile \(x\), anche se il codice utilizzato per accedere alla variabile è lo stesso: l'offset generato dal compilato non cambia, ma durante l'esecuzione l'indirizzo base associato al selettore GS utilizzato dal primo thread è diverso dall'indirizzo base associato al selettore GS utilizzando dal secondo thread.