m11. librerie dinamiche

Transcript

m11. librerie dinamiche
M11. LIBRERIE DINAMICHE
Fino adesso abbiamo creato programmi a partire dai suoi sorgenti, ciascuno dei quali è
compilato separatamente. I singoli file .cpp del nostro progetto sono unità di compilazione, a cui è possibile inserire riferimenti a tutto ciò che è definito (con la direttiva include).
Il processo di compilazione verifica la correttezza formale delle istruzioni del linguaggio
scelto e produce file .obj, detti anche moduli oggetto. Essi contengono assembler specifico del processore su cui è effettuata la compilazione. Si noti però che l’assembler non
è definitivo: questo perché nei singoli .cpp facciamo riferimento a funzioni contenute
all’esterno. Un esempio su tutti: il codice della
printf non è contenuto nei file .obj, che contengono invece solo un riferimento.
I vari .obj dunque non sono direttamente eseguibili, in quanto pieni di “buchi”. Vengono
quindi collegati insieme con il linker, che produce un file .exe. In questo file finale tutti i riferimenti pendenti sono risolti, in parte perché alcuni file .obj utilizzano qualcosa contenuto in altri file .obj e in parte perché qualche riferimento è contenuto in una libreria.
Librerie
L’eseguibile finale dunque non dipende solo dai vari file oggetto collegati insieme, ma
può prendere in considerazione l’utilizzo di una libreria. Una libreria è definita come un
insieme di moduli oggetto archiviati in un unico file. Offrono funzionalità comuni e permettono – dato che sono compilati una volta sola – di ridurre i tempi di compilazione, favorendo l’incapsulamento.
Dentro le librerie sono contenute funzioni e variabili esportate. Altre funzioni sono accessibili solo all’unità di compilazione (con la parola chiave static). Le librerie possono inoltre contenere costanti e altre risorse, come ad esempio le icone in un programma a finestre o le stringhe per gestire il multi-language.
Per rendere la libreria digeribile al programmatore, è fornito un file header. Se non includessimo stdio.h, il compilatore non saprebbe che pesci prendere quando trova scritto
printf: dentro stdio.h trova la definizione di printf, preceduta dalla parola chiave extern, che serve a far capire al compilatore che nel file non è contenuta nessuna definizione. Tale definizione sarà poi fornita da qualcuno nel processo di linking: se il simbolo
non si trova da nessuna parte, il linker restituisce un linkage error.
Caricamento delle librerie
“Attaccare” il codice al punto giusto è fattibile in fase di linking, producendo un .exe che
contiene tutto il necessario.
E’ anche possibile dire che non si vuole conoscere il codice delle funzioni in fase di linking, in modo da ritardare l’acquisizione del codice fin quando il programma non andrà
in esecuzione: il linker produce quindi un file con “buchi” e sarà il loader a riempire i buchi che mancano, oltre a creare un nuovo spazio di indirizzamento e mappare l’eseguibile da un certo indirizzo.
Si può andare ancora oltre, caricando il codice solo se il programma avrà bisogno di fare
una certa operazione. Il codice viene quindi aggiunto dinamicamente dal programma
stesso.
Librerie a collegamento statiche
In una libreria statica, i moduli contenuti all’interno
sono immediatamente collegati all’interno del file
eseguibile, che quindi contiene già il codice che andrà in esecuzione. E’ un file archivio (in Linux è un
file .a, in Windows è un file .lib).
Come vengono inserite le varie funzionalità all’interno di una determinata libreria? Ogni sistema operativo ha un suo tool chiamato archiver, ovvero un file eseguibile che fa parte della suite di
compilazione: prende in ingresso dei file oggetto e li aggiunge alla libreria.
In questo caso il linker capisce in quali moduli della libreria sono presenti le funzioni
chiamate, prendendo solo quelle necessarie. Il codice è caricato al fondo del nostro codice e i puntatori sono “corretti”.
Un primo vantaggio di questo approccio è che il codice necessario è sicuramente contenuto nell’eseguibile e non ci sono dubbi sulla versione della libreria adottata. Di contro,
siccome alcune funzioni sono utilizzate in tanti file, l’occupazione sul disco cresce (perché ogni .exe ha una copia della funzione) ed è potenzialmente allocato in tante pagine
fisiche differenti. Inoltre se la funzione è modificata a causa di bug, bisogna ricompilare
gli eseguibili.
Implementazioni
In Linux il tool archiver si chiama ar, che consente di prendere più file .o e produrre in
uscita un file .a il cui nome comincia con lib. In Windows le librerie statiche possono essere create a partire dai moduli oggetto con il programma lib. Visual Studio offre un
procedimento guidato.
Librerie condivise a collegamento dinamico
I problemi descritti nelle librerie a collegamento statico
hanno portato all’uso di librerie a collegamento dinamico. Se io uso la printf non ho bisogno del suo codice
all’interno dell’eseguibile: è sufficiente che, all’atto della
creazione del processo (= mapping in memoria) io mi
renda conto che manchi la printf e la mappi.
Nell’.exe – che non contiene più tutto il codice dei moduli della libreria – è presente una
import table che serve affinché il loader, dopo aver creato uno spazio di indirizzamento
e mappato il file eseguibile, sia in grado di fornire i pezzi necessari ( .so in Linux, .dll in
Windows). Se un determinato file non è già mappato, viene mappato nello spazio di memoria del processo, seleziona i moduli necessari e valorizza la import table con gli opportuni puntatori.
In questo modo il loader può sapere che un determinato file .so è già stato allocato in
un certo processo, in modo da usare un’unica pagina fisica (= una sola copia della funzione) per servire N processi. Sostituendo il codice della funzione, tutti i nuovi processi
avranno la versione giusta senza ricompilazione. Se però la versione sostituita non ha
più un’interfaccia conforme a quella vecchia non funziona più niente.
Nel collegamento dinamico è però noto in qualche modo fin dall’inizio che un dato eseguibile usi una certa funzione.
Implementazioni
In Linux il dynamic linker è il programma ld.so, che a sua volta è una libreria senza riferimenti a nessun altro. Quando l’eseguibile è mappato nello spazio di indirizzamento, si
verifica se fa riferimento a simboli dinamici. In tal caso viene mappato ld.so e il controllo viene passato al suo entry point, che va a leggere la tabellina e cerca tutti i riferimenti che gli servono (mappando nuovi file o rimappando pagine esistenti).
In Windows il dynamic linker fa parte del kernel stesso: alla creazione di un processo, i
passi descritti avvengono in maniera automatica.
Librerie condivise a caricamento dinamico
Non è sempre gradevole che il programma sappia in anticipo tutto quello che deve fare:
i browser, ad esempio, implementano nuove funzionalità ad ogni versione. Si pensi ad
esempio a un lettore video in un formato ancora non inventato: con le librerie a collegamento dinamico non potrei mai inserire nuove funzionalità.
E’ quindi possibile pensare di scrivere codice capace di fare riferimento a moduli da caricare al bisogno in memoria, spostando il caricamento durante la vita del processo. Il caricamento dinamico non gode più del supporto del sistema operativo e dei tool di compilazione, ma dev’essere fatto esplicitamente dal programmatore.
Condivisione dati DLL
Sia i file .so che le .dll contengono funzioni e variabili. Finché è il codice a finire su pagine condivise in sola lettura non c’è problema. Diverso è il discorso delle variabili globali, siano esse pubbliche o private: per questo motivo, all’interno della struttura
dell’eseguibile della .dll sono ben segnati i due segmenti codice e dati, in modo da
condividere solo le pagine codice (ed eventualmente quelle che contengono risorse in
sola lettura) e dare a ogni processo una copia indipendente delle variabili, utilizzando
pagine fisiche diverse.
Normalmente è così. In Windows c’è la possibilità di non fare così, creando delle .dll
che hanno variabili globali condivise da tutte le istanze. In questo modo possiamo creare
una forma di interprocess communication, entrando in un ginepraio pazzesco che però
risolve tutta una serie di altri problemi.
Direttive chiave
Finché le nostre .dll sono fatte in C, tutto è tranquillo. Questo perché in C non è possibile avere due versioni di una stessa funzione. In C++ invece questo è possibile, perché il
compilatore trasforma i nomi, aggiungendone al fondo un suffisso che specifica tipo e
numero dei parametri ricevuti.
Questa capacità del compilatore di “pacioccare” i nomi prende il nome di name mangling. Ogni compilatore lo fa a modo suo, il che è un grosso problema: se ho compilato
con Visual Studio una libreria che voglio poi usare in gcc++ è un macello. Diventa fondamentale fare librerie solo C oppure fare versioni diverse se programmo in C++.
Implementazioni
Linux espone le Dynamic Loading API. Per caricare dinamicamente un modulo si invoca
la funzione dlopen(), che prende un riferimento a un file .so e lo mappa all’interno dello
spazio di indirizzamento, restituendo un token per avere un riferimento.
Con questo token si può utilizzare la funzione dlsym(), che consente di cercare un simbolo (es.: printf) rispetto a un certo token. Se nel fare una di queste operazioni si è verificato un errore, con dlerror() ho traccia dell’ultimo errore occorso.
Fatte tutte le operazioni necessarie, uso dlclose() per chiudere il file object aperto e
smapparlo.
#include <stdio.h>
#include <dlfcn.h>
void invoke (char *lib, char *m, float arg) {
void* dl_handle = dlopen(lib, RTLD_LAZY);
if (!dl_handle) return;
float (*func)(float) = dlsym(dl_handle, m);
if (func == NULL) return;
printf("Result: %f\n", (*func)(arg));
dlclose(dl_handle);
}
/* Puntatore alla funzione */
int main(int argc, char *argv[]){
invoke("libm.so", "cosf", 3.14156f);
}
In Windows funziona tutto più o meno allo stesso modo, con qualche variazione sul
tema. Il formato fisico delle .dll è lo stesso degli .exe, a differenza di un byte. All’interno delle .dll può essere presente una funzione particolare ( DllMain) che, se presente,
ha il compito di fare da inizializzatore e da distruttore del modulo: questo dà l’opportunità di scrivere del codice nelle .dll da eseguire ogni volta che la libreria verrà mappata
nello spazio di indirizzamento e subito prima dell’unmapping dallo spazio di indirizzamento.
Con la funzione LoadLibrary() si carica la libreria indicata mappandola nello spazio di
indirizzamento del processo, chiamando un’eventuale funzione di ingresso e restituendo
la handle associata alla libreria, in modo da poter utilizzare la .dll.
Se è presente un DllMain, questo verrà chiamato in quattro situazioni diverse distinguibili dai parametri. La signature del metodo è infatti void DllMain(HINSTANCE h, DWORD
r, PVOID unused), dove
• HINSTANCE h è l’indirizzo di dove la DLL è mappata in memoria
• DWORD r è la ragione per cui è in corso la chiamata a DllMain
◦ DLL_PROCESS_ATTACH: DLL mappata nello spazio di indirizzamento del processo
▪ è un simil-costruttore
◦ DLL_PROCESS_DETACH: qualcuno ha chiesto di liberare la memoria
▪ è un simil-distruttore
◦ DLL_THREAD_ATTACH: mai da gestire (mal progettato)
◦ DLL_THREAD_DETACH: mai da gestire (mal progettato)
• PVOID unused non serve a niente
Per poter creare .dll che condividono dei dati tra tutti i processi che le utilizzano, si
usano notazioni non-standard e specifiche di Visual Studio. Di base, se creiamo un progetto con una .dll, i simboli contenuti al suo interno verranno inclusi nell’eseguibile ma
non nella tabellina finale. Se vogliamo che un certo simbolo sia chiamabile dall’esterno,
è necessario anteporre __declspec(dllexport). Il duale è specificato con la sintassi
__declspec(dllexport) e serve a “mangiare” funzioni che vengono da altre .dll.
/* File header SampleDLL.h della libreria SampleDLL.c */
#ifdef EXPORTING_DLL
extern __declspec(dllexport) void HelloWorld();
#else
extern __declspec(dllimport) void HelloWorld();
#endif
Nell’esempio, se EXPORTING_DLL è definito allora HelloWorld() è segnata come funzione
esportata. Se il simbolo non è definito, allora HelloWorld() è importata. Questo perché il
file .h è usato sia per produrre la .dll in uscita, che per utilizzare HelloWorld() nei nostri programmi. Questi ultimi non definiranno il simbolo, quindi passeranno al ramo else
e importeranno HelloWorld() dalla .dll.
/* SampleDLL.c */
#define EXPORTING_DLL
#include "SampleDLL.h"
BOOL APIENTRY DllMain(...)
void HelloWorld() {printf("Hello world");}
Se io voglio costruire un file che usa la funzione HelloWorld(), creo un file .cpp in cui è
incluso l’header della libreria e chiamo la funzione (esempio a sinistra). Questo basta,
perché nel momento in cui l’eseguibile prodotto viene mappato in memoria è risolto il
simbolo. In altri casi voglio fare le cose manualmente, sulla falsa riga del Dynamic Loading in Linux (esempio a destra). In alcune situazioni particolari vogliamo garantire che
il rilascio sia l’ultima cosa che andiamo a fare: in questo è utile l’uso della funzione
FreeLibraryAndExitThread().
/* Uso DLL: caso statico */
#include "SampleDLL.h"
void someMethod() {
HelloWorld();
}
/* Uso DLL: caso dinamico */
HINSTANCE hDLL = LoadLibrary(L"sampleDLL.dll");
if (hDLL != NULL) {
DLLPROC Hw = (DLLPROC) GetProcAddress(hDLL,L"HelloWorld");
if (Hw != NULL) (*Hw)();
FreeLibrary(hDLL);
}
Per essere tranquilli che HelloWorld() non vada incontro a mangling, la definizione può
essere scritta come extern “C” {void HelloWorld()}. C’è un analogo con gcc++.
Quando si costruisce con Visual Studio un progetto libreria DLL, è generato automaticamente – oltre al file .dll – anche un file .lib, che dentro di sé contiene tanti entry point
quante sono le funzioni che la .dll esporta, con una chiamata al caricamento dinamico.
Questo ci dà la possibilità di linkare staticamente il file .lib, ma ottenere di fatto il comportamento dinamico.
***