ATTENZIONE
La traduzione potrebbe contenere ERRORI.
Inoltre potrebbero essere presenti OMISSIONI, CAMBIAMENTI o APPUNTI presenti per rendere il testo più accessibile... a "me" in primis...
Bug-check:
Chi ha scaricato e utilizzato il codice del primo esempio si sarà ritrovato di fronte ad un bug:
Avviando due volte il programma di scrittura sul driver si causa un bug-check di Windows (BSOD) riportante l'errore DRIVER_LEFT_LOCKED_PAGES_IN_PROCESS. Ciò accade solo utilizzando la modalità di Direct I/O perchè dopo la gestione dell'IRP_MJ_WRITE non viene richiamata la funzione IoCompleteRequest. La routine và quindi modificata come segue:
NTSTATUS Example_WriteDirectIO(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
NTSTATUS NtStatus = STATUS_SUCCESS;
PIO_STACK_LOCATION pIoStackIrp = NULL;
PCHAR pWriteDataBuffer;
DbgPrint("Example_WriteDirectIO Called \r\n");
/*
* Each time the IRP is passed down the driver stack a new stack location is added
* specifying certain parameters for the IRP to the driver.
*/
pIoStackIrp = IoGetCurrentIrpStackLocation(Irp);
if(pIoStackIrp)
{
pWriteDataBuffer = MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority);
if(pWriteDataBuffer)
{
/*
* We need to verify that the string is NULL terminated. Bad things can happen
* if we access memory not valid while in the Kernel.
*/
if(Example_IsStringTerminated(pWriteDataBuffer, pIoStackIrp->Parameters.Write.Length))
{
DbgPrint(pWriteDataBuffer);
}
}
}
IoCompleteRequest(Irp,IO_NO_INCREMENT); // BUG-FIX
return NtStatus;
}
Introduzione
Lo scopo di questo tutorial è quello di descrivere come sviluppare un semplice driver per Windows NT.
[Per architettura NT, utilizzando il DDK di Microsoft].
Su internet possiamo trovare varie risorse e tutorial che spiegano come scrivere i driver, tuttavia, sono un pò spartane[scarce scrive l'autore] o di difficile comprensione se comparate con i vari esempi che permettono di far comparire la famosa frase "hello, world" sullo schermo, o su una finestra. [Win32].
Questo rende la ricerca delle informazioni per addentrarsi nello sviluppo dei driver (ring0,kernel-mode) un po più difficile rispetto alla programmazione classica.
Lo scopo dell'introduzione è dare al lettore alcune linee guida su come addentrarsi nel mondo dello sviluppo dei driver e sopratutto incitarlo a non fermarsi a questo tutorial.
Infatti si potrebbe pensare: "Se esiste un tutorial perchè cercarne altri?"
La risposta è semplice... è molto importante avere quante più informazioni possibili durante l'apprendimento e lo studio di un concetto nuovo.
Inoltre fà sempre bene studiare informazioni che analizzano il concetto da diversi punti di vista.
Ognuno ha un proprio modo di scrivere e di descrivere i concetti. In particolar modo i punti di vista sono influenzati da alcuni fattori soggettivi:
- Familiarità con i concetti
- Metodi personali
- Punti di vista personali.
Studiare da diverse prospettive aiuta a vedere anche i dettagli più piccoli che potrebbero sfuggire alla prima occhiata.
In questo tutorial si vuole raccomandare (l'autore del testo raccomanda scrivendo i would recommend) a tutte le persone che lo leggeranno per scrivere il primo driver di non fermarsi qui ma di cercare sempre nuovi esempi, nuovi snippets e nuove informazioni per analizzarle e trovarne le differenze.
Verrà mostrato come creare un semplice driver caricabile dinamicamente e utilizzabile da un programma in user-mode.
Creazione di un Device Driver Semplice
- Cosa sono i sottosistemi?
PENSIERO PERSONALE:
"Prima di mostrare il codice per creare un driver l'autore fà una panoramica sui concetti collegati soffermandosi sui sottosistemi presenti nell'architettura NT, sul compilatore, sul linker, sugli IRQL e sugli IRP.
Effettivamente tutti questi concetti sono indispensabili per poter capire come un Driver viene caricato e gestito dal sistema operativo.
Io personalmente dopo aver letto il tutorial sono andato su MSDN per cercare altre info sugli IRQL e sugli IRP e mi sono accorto che è necessaria almeno una panoramica (per sapere che esistono) sullo Stack dei driver, sulle interface Class e sui device class (che nel tutorial vengono solo menzionati senza essere utilizzati).
Consiglio quindi di scaricare il DDK per poter usare la guida integrata senza dover saltare da una pagina all'altra su MSDN."
Vogliamo definire (dice l'autore i need to define) una base di partenza prima di iniziare a spiegare il metodo per scrivere un driver di periferca.
I punti di partenza saranno il COMPILATORE, il LINKER, il formato PE e i SUBSYSTEM presenti nell'architettura NT.
Il compilatore ed il linker generano un file binario in un formato che il sistema operativo può gestire.
In Windows, questo formato è il PE (portable executable).
Per il momento il nostro interesse sarà concentrato sull'header di questo file.
Nell'header del file PE possono essere specificate diverse opzioni tra le quali :
- SUBSYSTEM associato all'eseguibile. (CONSOLE,WINDOWS,NATIVE)
- Entry Point dell'eseguibile
- Opzioni di caricamento ( usate dal loader )
Il compilatore ed il linker non sono nè molto conosciuti nè molto utilizzati (direttamente) dai programmatori e ciò determina la causa per la quale molti dei concetti descritti non risulteranno familiari.
Molte persone usano gli IDE (vc++, VS) per creare velocemente progetti. Gli IDE determinano in modo automatico la maggior parte delle informazioni che servono al compilatore ed al linker per funzionare.
Il compilatore ed il linker vengono comunque utilizzati dalla riga di comando solo che l'IDE nasconde tutto il procedimento.
Come esempio del fatto che linker, compilatore e subsystem non sono molto noti l'autore descrive le differenze tra applicazioni CONSOLE e GUI.
L'entry point dell'applicazione CONSOLE è main e il SUBSYSTEM è CONSOLE
L'entry point dell'applicazione WINDOWS è WinMain e il SUBSYSTEM è WINDOWS
L'entry point di una DLL caricabile è DllMain, il SUBSYSTEM è WINDOW ed e presente un flag /DLL passato al linker per informare il loader che è una libreria a caricamento dinamico.
L'entry point per un driver è DriverEntry mentre il SUBSYSTEM è NATIVE
Come detto in precedenza, l'entry-point di un binario è definito nell'header del file PE quindi è sempre possibile modificarne il nome passando al linker un parametro -entry:<NomeFunzione> ed informandolo che l'entry point avrà nome NomeFunzione.
L'entry point dei Driver ( e in generale )
PENSIERO PERSONALE:
"L'autore inizia questa sezione usando questa frase : The first section lied a little bit about the subsystem ovvero la prima sezione ha mentito un pò sui sottosistemi. (non ha descritto in modo totalmente corretto)
Evito di tradurre tutta la prima parte di paragrafo perchè in realtà vuole arrivare a dire che l'entry-point è ridefinibile e che in realtà i vari subsystem dipendono tutti da NATIVE:
Gli eseguibili, di defaul, per architettura NT sono definiti come SUBSYSTEM:NATIVE e non come CONSOLE o WINDOWS.
L'entry point per ogni tipo di eseguibile viene determinato in base alla FIRMA della funzione di entry point. Per ogni tipo di applicazione quindi ci lascia pensare che esista un PROTOTIPO definito in qualche header.
Se ad esempio usiamo SUBSYSTEM:CONSOLE il linker cerchera una firma del tipo int main void(arg) all'interno del file compilato. E' possibile avviare un eseguibile in user-mode usando SUBSYSTEM:NATIVE è definendo l'entry point NtProcessStartup (per il quale possiamo trovare info su MSDN)
Il nome di default per l'entry-point dei driver è DriverEntry. Questo nome è usato da Microsoft e dai partner che sviluppano driver. E' possibile cambiare questo nome ma, essendo i driver compilati nell'ambiente DDK è consigliato lasciarlo così in modo da non trovare problemi quando si utilizzano i comandi build,make etc.
Il linker costruisce il binario finale ed in base alle opzioni specificate il file PE risultante potrà essere eseguito ( EXE ) , caricato dinamicamente ( DLL) oppure caricato come driver di sistema ( SYS ). Con l'avvento di .NET il file potrà essere eseguito in modalità gestita ma a noi questo aspetto proprio non interessa. L'autore consiglia di andarsi a studiare il formato PE in modo da scoprire quante opzioni diverse sono disponibili.
Negli esempi che seguiranno il linker sarà istruito con gli switch seguenti
/SUBSYTEM:NATIVE /DRIVER:WDM -entry:DriverEntry
Alcuni accorgimenti prima di creare il DriverEntry
Ci sono alcune cose che bisogna fare prima di sederci (simply sit down - nel senso di sederci senza pensare?) e scrivere la nostra bella Entry.
Ci sono molte persone (i know that a lot of people dice l'autore) che iniziano a scrivere il driver e lo fanno girare direttamente senza fermarsi e ragionare.
Questo è il caso in cui un programmatore prende del codice funzionante scritto da qualcuno, lo modifica per i propri scopi lo compila e lo testa. La maggior parte delle persone che si avvicinano al mondo della programmazione windows procedono come descritto prima.
Le applicazioni probabimente non funzionano bene, crashano oppure scompaiono. Tutto ciò potrebbe sembrare divertente perchè con il debug jit il problema si risolve subito e probabilmente si imparano tante cose facendo così.
Programmare i driver è cosa diversa.
Non sapere cosa si sta facendo probabilmente causerà un BSOD (crash del kernel) e se il driver viene caricato durante il BOOT si renderà necessario avviare il s.o. in modalità provvisoria per ripristinare la configurazione precedente.
Stando così le cose si rende necessario introdurre altri concetti prima di scrivere il driver per educarci a capire cosa fare e come farlo ... prima di farlo!
La prima cosa da non fare e prendere un driver, cambiare il codice e ricompilarlo.
Se non è chiaro come il driver lavora o come programmare l'ambiente è molto probabile che si causeranno problemi. I Driver possono corrompere l'integrità dell'intero sistema, possono avere bug che non si
presentano mai salvo in rari casi.
A titolo di esempio:
In kernel-mode ci sono momenti in cui il driver non può accedere alla memoria paginata. [omiss: funzionamento paginazione]
Tuttavia anche se a priori non è possibile capire se una pagina è in memoria o meno il driver potrebbe SEMPRE riuscire ad accedere anche se in quel momento l'accesso non serebbe permesso.
Il bug potrebbe non verificarsi perchè il sistema operativo tenta sempre di mantenere tutte le pagine in memoria.
Le API per la programmazione in kernel-mode sono associate ad un parametro IRQL che indica quando possono essere richiamate. In realtà il concetto di IRQL è complesso come dimostrano le oltre 20 pagine che MSDN gli dedica. Vedremo nella sezione successiva il significato di questo argomento.
Un ultimo appunto prima di passare alla sezione successiva:
Le applicazioni che girano in user-mode possono avere gli stessi problemi "di comportamento" ma
essendo protette non causano il crash di sistema. (Non possono corrompere altri processi).
IRQL (Interrupt Request Level)
IRQL è l'acronimo di Interrupt ReQuest Level.
Il processore, durante l'esecuzione del codice dei vari thread si troverà ad un determinato livello IRQ. (mantenuto dal kernel)
Il valore dell'IRQL, determina in che modo il thread in esecuzione può essere interrotto.
"Aggiungo un pò di semplificazioni... Il concetto di IRQL è legato principalmente alla gestione degli Interrupt (hardware e software).
Il valore IRQL, associato al processore in fase di elaborazione, è mantenuto dal Kernel del sistema operativo che si occupa di sincronizzare i vari thread.
Ogni interrupt Hardware ha associato un valore che permette di stabilirne la priorità. (quale è più importante gestire e quale è meno importante). Essendo gli interrupt hardware in numero FINITO il kernel li conosce e quindi può gestirli come meglio crede. Per gli interrupt software il discorso è un pò diverso ma per il momento evitiamo di parlarne.
Durante l'elaborazione di un generico insieme di istruzioni il processore avrà quindi associato un valore per IRQL.
Se ad esempio sta elaborando il calcolo di una somma per Excel e si verifica un interrupt del tipo (inventato il nome) POWER_DOWN (caduta di tensione, è andata via la luce) il sistema interromperà il thread di Excel (che per il momento ipotizziamo ha priorità minima) per effettuare un context-switch alla routine di gestione dell'interrupt hardware POWER_DOWN per tentare di chiudere i file aperti ed evitare perdita di dati.
Possiamo schematizzare in maniera molto semplificata un ordinamento per gli interrupt :
EXCEL <= INTERRUPT_TASTIERA <= POWER_DOWN
sta ad indicare che il thread di excel potrà essere interrotto da un interrupt della tastiera ( la pressione di un tasto ) e da una caduta di tensione.
Il concetto di Interrupt e di sistema event-driven sarebbe lungo da spiegare per cui vi rimando ad un qualsiasi testo di Sistemi Operativi, al link che segue oppure su MSDN.
Ci sono quattro livelli di IRQL con i quali avremo a che fare e sono: ( I primi tre sono livelli riferiti ad interrupt sia software che hardware).
- PASSIVE_LEVEL: E' il livello di priorità più basso. Un thread che gira a questo livello, cioè tutti i thread user-mode e quindi tutti i programmi applicativi standard, viene interrotto al verificarsi di qualsiasi interrupt. A questo livello la memoria PAGINATA è accessibile!
- APC_LEVEL : Gli Interrupt riferiti alle chiamate a procedure asincrone (Asynchronous Procedure Call) sono mascherati. Un thread che gira con IRQL = APC_LEVEL non sarà interrotto da una chiamata APC o perchè già ne sta eseguendo una (una chiamata APC porta il processore a questo livello e quindi disabilità altre chiamate APC) oppure perchè un driver si è portato a questo livello. (Un driver può portarsi manualmente sia in APC_LEVEL, sia in DISPATCH_LEVEL)
- DISPATCH_LEVEL: Quando il processore gira su questo livello ha gli interrupt DPC (dispatch-procedure-call?) e gli interrupt di priorità inferiore mascherati. (stà facendo qualcosa di discretamente importante).
Se si trova in DISPATCH_LEVEL ha mascherati anche gli APC_LEVEL. Il thread in esecuzione verrà interrotto solo da un interrupt hardware veramente importante come un POWER_DOWN.
La memoria pagina NON è accessibile a questo livello mentre è accessibile quella non paginata. Il numero di API utilizzabili a questo livello cala drasticamente perchè è possibile usare solo la memoria non paginata.
* * * * * IMPORTANTE!! * * * * * *
- DIRQL : Device IRQL Level.
Prima, nell'appunto personale, parlavo di priorità tra i vari devices e i vari interrupt hardware che potevano verificarsi. Con DIRQL si identifica un range di IRQL (il famoso range FINITO di interrupt hardware) che potrebbero verificarsi. Un thread che gira a questo livello potrebbe essere una routine del sistema operativo DEDICATA ALLA GESTIONE DELL'INTERRUPT HARDWARE, quindi un DRIVER per il BUS, del processore, degli slot PCI e così via. Gli interrupt di livello inferiore vengono tutti mascherati. DIRQL viene usato per settare la priorità degli interrupt tra i vari devices "Fisici" presenti nel pc.
IRP ( I/O Request Packet)
Gli IRP ( pacchetti di richiesta I/O) sono passati dall'alto verso il basso nello stack dei driver. Questa struttura permette ai driver due tipi di operazione:
- Comunicare tra di loro
- Richiedere ad un driver di svolgere una determinata operazione.
I pacchetti IRP possono essere generati dal sottosistema di I/O ( I/O Manager), ad esempio quando si verifica un interrupt a livello hardware, oppure da un generico driver. Ogni IRP include al proprio interno informazioni sull'operazione richiesta.
Descrivere l'utilizzo e il contetto di IRP, in un primo momento, potrebbe sembrare semplice ma ben presto il concetto diventa complesso e quindi noi lo descriveremo solo per linee generali e solo per l'aspetto che interessa questo esempio. C'è un articolo su MSDN dove si descrivono tutti gli aspetti degli IRP (oltre 20 pagine) dai concetti base fino ai metodi di gestione.
Ogni IRP contiene anche una lista di sub-requests, (sottorichieste) che sono conosciute come IRP Stack Location. Ogni driver nello stack in linea generale ha una propria lista di sottorichieste che utilizza per interpretare e gestire l'IRP. La struttura che contiene le sub-request è IO_STACK_LOCATION.
"L'autore a questo punto, con un esempio, lega gli IRP e una ditta di costruzioni di case, in cui ogni operaio è un DRIVER, il risultato finale (IRP_REQUEST) da ragginugere è la costuzione della CASA, e le sottorichieste sono i vari compiti di ogni operaio. (SUB-REQUESTS).
L'insieme degli operai è LO STACK dei DRIVER che COOPERANO tra loro per raggiungere LO SCOPO COMUNE.
Purtroppo l'utilizzo di MSDN, per la comprensione degli IRP è necessaria perchè nel tutorial vengono utilizzati ma vengono solo accennati alcuni concetti.
Leggendo l'esempio ricordiamo queste associazioni:
(IRP_REQUEST) -> Obbiettivo Finale ==> Costruzione CASA
(DRIVER) -> Operai
(COOPERAZIONE) -> Stack dei Driver ==> Compiti propedeutici
(SUB-REQUESTS) -> Compiti Singoli ==> Fai fondamenta, pittura pareti ... ==>
Per creare un'analogia tra gli IRP e IO_STACK_LOCATION, immaginiamo di avere tre persone (tre DRIVER) che svolgono compiti diversi come ad esempio un carpentiere, un idraulico e un saldatore. Se loro vogliono inziare a costruire una casa, devono avere un progetto comune e possibilimente un insieme di strumenti comune.
Il progetto e gli strumenti comuni possono essere visti come L'IRP. Ognuno di essi inoltre, richiede degli oggetti particolari per lavorare e portare a termine il proprio compito. Ad esempio l'idraulico richiede una pianta delle tubature, il numero di tubi richiesti etc. Questi requisiti possono essere intepretati come l'IO_STACK_LOCATION (sub-requests) per il lavoro specifico dell'idraulico. Il carpentiere avrà bisogno dei propri oggetti ance ess definiti nel suo IO_STACK_LOCATION.
Mentre l'IRP sarà la richiesta di costruire la casa, persona (driver nello stack) avrà un insieme di operazioni da svolgere definite nel proprio IO_STACK_LOCATION. SOLO QUANDO TUTTI HANNO COMPLETATO IL LORO LAVORO L'IRP ( la casa ) E' COMPLETO.
Il driver che scriveremo noi non sarà complesso ma, semplicemente, sarà l'unico driver dello stack.
Cose da Evitare
Ci sono un sacco di trappole che dovrete evitare durante la scrittura dei vostri driver. Nel nostro caso non c'è ne sono molte e dove ci sono verranno analizzate e spiage. Essere informati è l'unico modo per scrivere driver robusti e stabili.
Creazione dell'Entry-Routine
"Prima di iniziare l'analisi di come dovrebbe funzionare la routine di entry è bene capire un concetto fondamentale dei driver per windows.
Driver e Periferiche (device) sono cose separate. Un Driver può gestire più periferiche. Il Driver viene caricato una sola volta (se avviato durante il boot del sistema), ricaricato se è un driver PnP che supporta lo scaricamento, oppure viene caricato a runtime da un programma user-mode.(come farà l'autore del tutorial).
Durante il caricamento del Driver in kernel-mode viene richiamata la routine di Entry che DEVE OCCUPARSI di inizializzare la struttura dati che il kernel gli passa in argomento. In questa struttura (DRIVER_OBJECT) il driver andrà a scrivere i puntatori (ma non solo) alle funzioni di gestione degli IRP. (Notiamo la similitudine con la tavola degli interrupt).
Inoltre, il Driver è responsabile della creazione degli oggetti rappresentanti i DEVICES da gestire. Anche se nell'esempio non viene utilizzata, perchè la creazione dei devices avviene nella entry, esiste una ruotine particolare, AddDevice che viene richiamata dall' I/O manager quando un devices associato al driver è disponibile nel sistema.
Ultimo appunto, un eventuale Driver che gestisce più periferiche DEVE TENER PRESENTI quali periferiche sta servendo e deve essere scritto in modo da saperle gestire.
Ci sarebbero altre cose da analizzare, tuttavia è arrivato il momento di iniziare lo sviluppo del driver e di spiegare come fare. E' difficile digerire la teoria o il fatto che un driver debba funzionare senza passare alla pratica. Durante la vostra esperienza con i driver ricordate di cercare sempre nuove info e di progettare prima di scrivere.
Il prototipo dell'entry-point per i driver è :
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject,PUNICODE_STRING pRegistryPath);
La struttura DRIVER_OBJECT è utilizzata per rappresentare un driver. La routine DriverEntry riceve questa struttura dal kernel durante il caricamento del driver e la popola inserendo i puntatori alle routine di gestione degli IRP.
La struttura DRIVER_OBJECT deve mantenere un riferimento anche ad una struttura (una o più) che rappresenta i Device: DEVICE_OBJECT.
Se il driver gestisce più periferiche dovrà mantenere i riferimenti a tutti i DEVICE_OBJECT necessari. Nel nostro caso il DEVICE_OBJECT sarà uno solo.
La routine DriverEntry riceve in input una stringa che punta alla locazione nel registro dove le informazioni per il driver sono registrate. Il driver può utilizzare questa locazione ( che è una locazione del registro riservata al driver) per salvare informazioni.
"Ho omesso alcuni esempi che l'autore fà per distinguere tra i vari tipi di driver perchè, a grandi linee, mette in evidenza il fatto che alcuni tipi di driver non seguono la logica dello stack classica, come esempio porta le schede video che hanno uno stack strutturato in modo particolare, oppure il sottosistema di network che usa l'architettura ISO/OSI.
Inoltre la sezione seguente è strutturata in modo un pò diverso rispetto all'originale perchè ho voluto insistere su alcuni concetti che, penso, sia importante tenere a mente prima di passare a studiare su MSDN.
L'autore descrive a grandi linee uno stack per il filesystem. Fa notare come a livello più alto esiste un driver di comunicazione con l'utente che si occupa della struttura (file,cartelle). Al livello più basso c'è il driver che si occupa di scrivere fisicamente sul disco. L'autore ci tiene a precisare che i livelli più bassi nascondono le implementazioni a quelli più alti.
Bisogna ora vedere cosa mettere nella funzione DriverEntry. La prima cosa che faremo è la creazione del device, tenendo bene a mente che esistono vari modi per creare un device e vari tipi di device. (e qui son cazzi... wdm definisce bus,filter,functional... bisogna per forza andare li per capire bene questa parte del tutorial)
Un driver, di solito, è associato ad un hardware presente nel sistema ma non è questo l'unico tipo di Driver che può essere scritto. Un Driver per windows è un programma che può girare in kernel mode, che risponde agli IRP, li genera per comunicare con gli altri driver e utilizza le funzioni in kernel-mode per operare. Inoltre un driver è legato ai livelli IRQL per determinare lo stato in cui si trova il sistema.
Ogni driver può operare ad un livello diverso nello stack e non tutti i driver comunicano direttamente con l'hardware. Nel nostro caso ad esempio il driver sarà caricato a livello kernel ma non interagirà con apparti hardware ma solo con la console del kernel e con un programma utente.
Qualcuno potrebbe chiederi perchè non scrivere i driver in user-mode invece che in kernel mode. In effetti esistono anche i driver in user-mode (i servizi di windows) ma sono limitati dal fatto che non possono essere performanti perchè dovrebbero girare solo a PASSIVE_LEVEL e richiamare il s.o. per ogni richiesta.
La prima bozza di codice presentata dall'autore è la seguente:
Notiamo che ci viene passato il parametro di input PDRIVER_OBJECT. Questa struttura rappresenta il Driver caricato in memoria...
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath)
{
NTSTATUS NtStatus = STATUS_SUCCESS;
UINT uiIndex = 0;
PDEVICE_OBJECT pDeviceObject = NULL;
UNICODE_STRING usDriverName, usDosDeviceName;
DbgPrint("DriverEntry Called \r\n");
RtlInitUnicodeString(&usDriverName, L"\\Device\\Example");
RtlInitUnicodeString(&usDosDeviceName, L"\\DosDevices\\Example");
NtStatus = IoCreateDevice(pDriverObject, 0,
&usDriverName,
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE, &pDeviceObject);
La prima cosa che notiamo è la funzione DbgPrint. Questa funzione è simile alla printf ma a differenza di quest'ultima non stampa sullo standard output bensì nel debugger. E' possibile scaricare il programma DBGVIEW dal sito www.sysinternals.com per visualizzare i messaggi stampati con DbgPrint.
Per inizializzare le stringhe UNICODE, in kernel mode, si utilizza la funzione RtlInitUnicodeString che ritorna una struttura di tipo UNICODE_STRING.
Questa struttura contiene 3 importanti informazioni. La dimensione della stringa Unicode, la massima dimensione che può raggiungere la stringa ed un puntatore alla stringa stessa. UNICODE_STRING è utilizzata pe descrivere stringhe UNICODE nei driver.
UNICODE_STRING non richiede il carattere \0 o NULL alla fine della stringa proprio perchè contiene la dimensione della stringa nella struttura. Ciò causa molti problemi ai programmatori che si avvicinano allo sviluppo dei driver perchè essi assumono che ogni stringa sia null-terminated.
Il device può essere identificato da un nome. In genere il nome è \Device\<deviceName>. (Per i driver compatibili con il modello WDM il nome non và specificato perchè si utilizzano le interface class, ma vanno studiate su MSDN)
Una stringa (unicode) viene creata per contenere questo valore e poi viene passata alla routine IoCreateDevice. Possiamo vedere nell'esempio che vengono definiti due nomi.. un nome per l'architettura NT ed un nome DOS per il driver. La routine IoCreateDevice accetta un unico parametro per il nome del driver ma esiste un'altra funzione che permette di associare più nomi agli oggetti (creando link simbolici).
Esistono diverse categorie di devices, ad esempio i MOUSE, le TASTIERE, gli HID in generale oppure le porte COM. Noi non sappiamo che tipo di driver stiamo scrivendo perchè per quello che faremo non esiste una categoria generica... alla funzione IoCreateDevice passiamo, quindi, FILE_DEVICE_UNKNOWN. Infine passiamo un puntatore ad una struttura DEVICE_OBJECT dove I/O Manager ci restituirà il puntatore all'oggetto che rappresenta il devices reale.
Un pò di attenzione và fatta sul secondo parametro. Esso definisce, in byte, la dimensione della struttura contenente le device-extension. Questa struttura può essere definita da chi scrive il driver ed è univoca per ogni devices. (nel senso che ad ogni chiamata IoCreateDevice ne viene istanziata una nuova, non è condivisa tra i vari devices). Noi non la useremo e non menzioneremo informazioni che la riguardino.
Per maggiori informazioni sula funzione IoCreateDevice è necessario fare riferimento a MSDN.
ATTENZIONE: In questo momento abbiamo due oggetti che potrebbero essere confusi. DRIVER_OBJECT e DEVICE_OBJECT. Per ogni driver esiste un'unica istanza di DRIVER_OBJECT ma potrebbero esistere più istanze di DEVICE_OBJECT in base al numero di device che il driver è in grado di gestire. ATTENZIONE A NON FARE CONFUSIONE.
Bene, utilizzando IoCreateDevice abbiamo creato il nostro devices, identificandolo come \Device\Example. Addesso è necessario impostare i puntatori alle routine di gestione degli IRP che vogliamo gestire. Come detto in precedenza, queste impostazioni vanno fatte sull'oggetto DRIVER_OBJECT.
AttenzioNe: Da questo momento distingueremo gli IRP in due tipi IRP_MAJOR_REQUEST ( La costruzione della casa ) e IRP_MINOR_REQUEST (i vari sub-requests).
Nella funzione DriverEntry aggiungiamo :
for(uiIndex = 0; uiIndex < IRP_MJ_MAXIMUM_FUNCTION; uiIndex++)
pDriverObject->MajorFunction[uiIndex] = Example_UnSupportedFunction;
pDriverObject->MajorFunction[IRP_MJ_CLOSE] = Example_Close;
pDriverObject->MajorFunction[IRP_MJ_CREATE] = Example_Create;
pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = Example_IoControl;
pDriverObject->MajorFunction[IRP_MJ_READ] = Example_Read;
pDriverObject->MajorFunction[IRP_MJ_WRITE] = USE_WRITE_FUNCTION; // è una MACRO!
in modo che tutti gli IRP (MAJOR) vengano gestiti dalla routine Example_UnSupportedFunction. Poi scegliamo alcuni IRP che gestiremo e li puntiamo sulle routine che andremo a scrivere di seguito.
Puntiamo anche la routine che verra richiamata quando il driver sarà scaricato dal kernel.
pDriverObject->DriverUnload = Example_Unload;
// La routine di UNLOAD elimina tutti gli oggetti usati dal driver stesso.
VOID Example_Unload(PDRIVER_OBJECT DriverObject)
{
UNICODE_STRING usDosDeviceName;
DbgPrint("Example_Unload Called \r\n");
RtlInitUnicodeString(&usDosDeviceName, L"\\DosDevices\\Example");
IoDeleteSymbolicLink(&usDosDeviceName);
IoDeleteDevice(DriverObject->DeviceObject);
}
Tecnicamente la funzione di unload potrebbe essere omessa ma se vogliamo che il driver possa essere scaricato dinamicamente è necessario definirla. Se questo puntatore non viene definito il sistema NON PERMETTERA' lo scaricamento del driver.
Gli IRP che verrano gestiti sono CREATE,CLOSE,IOCONTROL,READ,WRITE. A cosa sono riferiti questi IRP? Per una descrizione dettagliata è possibile fare riferimento a MSDN ma per quanto concerne il nostro esempio guardiamo queste relazioni :
CreateFile
-> IRP_MJ_CREATE
CloseHandle
-> IRP_MJ_CLEANUP & IRP_MJ_CLOSE
WriteFile
-> IRP_MJ_WRITE
ReadFile
-> IRP_MJ_READ
DeviceIoControl
-> IRP_MJ_DEVICE_CONTROL
Le API che useremo nel nostro programma USER-MODE utilizzano le API Win32 CreateFile,WriteFile etc..
Queste API richiamano routine in kernel-mode, in particolare API del Sottosistema di I/O, che a loro volta utilizzano questi IRP per comunicare con i DRIVER.
Quindi, nel momento in cui il nostro programma compilato userà CreateFile Win32 il sottosistema di I/O invierà un IRP di tipo IRP_MJ_CREATE al nostro Driver.
Ci si potrebbe chiedere perchè le API in user-mode, per l'I/O usano funzioni che nel nome contengono la parola file quando invece non stiamo scrivendo veramente sui dei file. In questo constesto dobbiamo vedere il FILE come un stream di INPUT e di OUTPUT sul quale è possibile scrivere. (un pò come nel mondo unix). Aprire \device\example in I/O con la CreateFile in realtà significa prendere i due stream I/O e prepararli alla scrittura/lettura.
Per completare l'inizializzazione del device creato si impostano i flag seguenti...
pDeviceObject->Flags |= IO_TYPE; // è una MACRO.
pDeviceObject->Flags &= (~DO_DEVICE_INITIALIZING);
IO_TYPE è una macro, collegata alla macro USER_WRITE_FUNCTION per la gestione dell'IRP, definita dall'autore come segue:
#ifdef __USE_DIRECT__
#define IO_TYPE DO_DIRECT_IO
#define USE_WRITE_FUNCTION Example_WriteDirectIO
#endif
#ifdef __USE_BUFFERED__
#define IO_TYPE DO_BUFFERED_IO
#define USE_WRITE_FUNCTION Example_WriteBufferedIO
#endif
#ifndef IO_TYPE
#define IO_TYPE 0
#define USE_WRITE_FUNCTION Example_WriteNeither
#endif
Specifica il tipo di I/O che il driver utilizzerà per le comunicazioni con l'esterno.
DO_DEVICE_INITIALIZING è un flag che indica all'I/O manager che la periferica è inizializzata. Questo FLAG va sempre pulito quando si crea un device con la funzione IoCreateDevice altrimenti il device non sarà caricato correttamente. In realtà il clear del flag (~DO_DEVICE_INITIALIZING) và effettuato (MUST) quando il device viene richiamato esternamente alla DriverEntry. Nel nostro caso quando DriverEntry termina viene pulito in automatico ma per abituarci bene lo inseriamo:)
L'autore infine mostra la creazione di un link simbolico per dare più di un nome al device (il nome Dos) appena creato. La funzione da utilizzare è IoCreateSymbolicLink.
IoCreateSymbolicLink(&usDosDeviceName, &usDriverName);
Bene, a questo punto non resta che scrivere le funzioni di gestione dell'I/O. L'autore ha definito tre tipi di gestori ( con la macro USE_WRITE_FUNCTION ) in base al valore di IO_TYPE scelto. Quando la api Win32 WriteFile verrà richiamata sarà gestita da una di queste routine.
Tenete presente che SOLO UNA di queste tre routine verra richiamata ed è necessario usare quella adatta al valore IO_TYPE che si imposta nel flag. Se al sottosistema di I/O si comunica di voler utilizzare un metodo Buffer I/O non si può utilizzare la routine per il Direct I/O.
DIRECT I/O
L'autore descrive il metodo di lettura tramite I/O diretto.
NTSTATUS Example_WriteDirectIO(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
NTSTATUS NtStatus = STATUS_SUCCESS;
PIO_STACK_LOCATION pIoStackIrp = NULL;
PCHAR pWriteDataBuffer;
DbgPrint("Example_WriteDirectIO Called \r\n");
pIoStackIrp = IoGetCurrentIrpStackLocation(Irp);
if(pIoStackIrp)
{
pWriteDataBuffer =
MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority);
if(pWriteDataBuffer)
{
if(Example_IsStringTerminated(pWriteDataBuffer,
pIoStackIrp->Parameters.Write.Length))
{
DbgPrint(pWriteDataBuffer);
}
}
}
return NtStatus;
}
Il primo parametro che viene passato ad una routine di gestione degli IRP è un puntatore all'oggetto device-object (cioè l'oggetto che rappresenta la periferica) associata alla richiesta stessa. Ricordiamo che un driver può creare device multipli per cui il primo parametro è necessario per determinare il device che verrà servito dal driver. Il secondo parametro è una struttura IRP. Questa struttura è parzialmente opaca quindi non tutti i membri sono descritti su MSDN.
La prima cosa da fare è chiamare IoGetCurrentIrpStackLocation che ci fornisce la nostra struttura IO_STACK_LOCATION ( abbiamo detto prima cos'è questa struttura). Nel nostro esempio l'unica informazione di cui necessitiamo (e che useremo) è la lunghezza del buffer fornito al driver. Questo parametro si trova nel membro Parameters.Write.Length. (quanto spazio abbiamo a disposizione per scrivere)
Per utilizzare un I/O Diretto è necessario appoggiarsi ad una struttura denominata MdlAddress ( Memory Description List) che viene passata al driver dal sottosistema di I/O, tramite la struttura IRP, ogni volta che è necessaria un'operazione di I/O.
Questa struttura contiene una descrizione degli indirizzi in user-mode e di come sono mappati in indirizzi fisici. Per recuperare l'indirizzo sul quale operare (un puntatore a CHAR in realtà) useremo la funzione, del sottosistema di gestione della memoria, MmGetSystemAddressForMdlSafe.
Questa funzione, richiamata sul membro Irp->MdlAddress, ci darà un Indirizzo Virtuale di Sistema (un puntatore a char) che potremo usare per leggere dal flusso di I/O.
Il ragionamento che stà dietro tutta questa conversione di indirizzi è semplice. Il driver il più delle volte gira in un contesto diverso rispetto a quello del processo che ha avviato la richiesta di I/O. Quindi gli indirizzi virtuali dell'user-mode, durante due context-switch saranno gli stessi ma potrebbero trovarsi in locazioni fisiche diverse dopo diversi context-switch. Ecco perchè ci appoggiamo al sistema operativo, in particolare al sottosistema di gestione della memoria che CONOSCE SEMPRE IL MAPPING (lo fà lui) tra indirizzi logici e fisici di ogni thread.
Una volta recuperato il buffer dal quale leggere, possiamo operare normalmente, ricordandoci di utilizzare SEMPRE funzioni KERNEL e mai funzioni Win32. (Per manipolare le stringhe ad esempio NON VANNO USATE LE FUNZIONI IN str etc)
Questo metodo di operare, Direct I/O, è generalmente usato per buffer di grandi dimensioni che non RICHIEDONO la copia della memoria e la successiva elaborazione. Infatti il Driver ACCEDE DIRETTAMENTE alla memoria (buffer) del processo, che rimane bloccata finchè l'IRP non viene completato.
BUFFERED I/O
NTSTATUS Example_WriteBufferedIO(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
NTSTATUS NtStatus = STATUS_SUCCESS;
PIO_STACK_LOCATION pIoStackIrp = NULL;
PCHAR pWriteDataBuffer;
DbgPrint("Example_WriteBufferedIO Called \r\n");
pIoStackIrp = IoGetCurrentIrpStackLocation(Irp);
if(pIoStackIrp)
{
pWriteDataBuffer = (PCHAR)Irp->AssociatedIrp.SystemBuffer;
if(pWriteDataBuffer)
{
if(Example_IsStringTerminated(pWriteDataBuffer,
pIoStackIrp->Parameters.Write.Length))
{
DbgPrint(pWriteDataBuffer);
}
}
}
return NtStatus;
}
Come detto in precedenza, l'idea dello stack e quella di passare i dati verso il basso, di driver in driver, in modo che possano essere accessibili in ogni contesto.
Un motivo per il quale potrebbe essere usato questo tipo di I/O è quello di voler mappare la memoria in modo che possa essere letta anche a IRQL alti. (la memoria paginabile non è accessibile in DISPATCH_LEVEL e DIRQL LEVEL)
Un altro motivo potrebbe essere quello di voler accedere alla memoria al di fuori del contesto corrente da thread che i driver creano associandoli al processo SYSTEM.
Gli svantaggi associati a questo metodo sono presto detti.
-E' necessario allocare memoria non paginabile e copiarci dentro i dati che bisogna processare. Ciò chiaramente determina un overhead durante i processi di lettura e scrittura. Questa è la principale causa che determina l'utilizzo dell'I/O bufferizzato per buffer di piccole dimensioni.
- E' poco efficiente con i buffer di grandi dimensioni perchè necessita l'allocazione di grandi porzioni di memoria non paginabile. (non scaricabile sul disco e quindi il consumo di memoria è elevato).
Il vantaggio nasce dal fatto che l'intera memoria usata in user-mode non resterà bloccata durante l'operazione come invece accade con il Direct I/O.
Neither Buffered nor Direct ( Nè bufferizzata, nè diretta )
NTSTATUS Example_WriteNeither(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
NTSTATUS NtStatus = STATUS_SUCCESS;
PIO_STACK_LOCATION pIoStackIrp = NULL;
PCHAR pWriteDataBuffer;
DbgPrint("Example_WriteNeither Called \r\n");
pIoStackIrp = IoGetCurrentIrpStackLocation(Irp);
if(pIoStackIrp)
{
__try {
ProbeForRead(Irp->UserBuffer,
pIoStackIrp->Parameters.Write.Length,
TYPE_ALIGNMENT(char));
pWriteDataBuffer = Irp->UserBuffer;
if(pWriteDataBuffer)
{
if(Example_IsStringTerminated(pWriteDataBuffer,
pIoStackIrp->Parameters.Write.Length))
{
DbgPrint(pWriteDataBuffer);
}
}
} __except( EXCEPTION_EXECUTE_HANDLER ) {
NtStatus = GetExceptionCode();
}
}
return NtStatus;
}
Se viene scelta questa modalità di access il driver accede semplicemente agli indirizzi in user-mode direttamente. L'I/O manager NON copia dati, NON blocca la memoria ma SEMPLICEMENTE PASSA L'INDIRIZZO dove poter leggere/scrivere al driver.
Il vantaggio è che nessun dato viene copiato, nessuna pagina di memoria viene bloccata e non viene allocata ulteriore memoria per il buffer.
Lo svantaggio è che bisognera processare la richiesta NEL CONTESTO del thread chiamante per poter accedere agli indirizzi in user-mode correttamente. Inoltre il processo può fare quello che vuole durante l'accesso del driver. Può pulire la memoria, può cambiarne il valore...
Ecco perchè, utilizzando questo metodo di I/O bisogna usare le funzioni ProbeForRead e ProbeForWrite ( probe = dimmi se posso, sonda il terreno:)) incapsulate in un exception handler. Non c'è nessuna garanzia che le pagine siano invalide o valide... prima di fare qualsiasi cosa è necessario assicurarsi di poter leggere e scrivere. Il buffer da usare, in questo caso è in Irp->UserBuffer.
Caricamento dinamico del Driver
int _cdecl main(void)
{
HANDLE hSCManager;
HANDLE hService;
SERVICE_STATUS ss;
hSCManager = OpenSCManager(NULL, NULL, SC_MANAGER_CREATE_SERVICE);
printf("Load Driver\n");
if(hSCManager)
{
printf("Create Service\n");
hService = CreateService(hSCManager, "Example",
"Example Driver",
SERVICE_START | DELETE | SERVICE_STOP,
SERVICE_KERNEL_DRIVER,
SERVICE_DEMAND_START,
SERVICE_ERROR_IGNORE,
"C:\\example.sys",
NULL, NULL, NULL, NULL, NULL);
if(!hService)
{
hService = OpenService(hSCManager, "Example",
SERVICE_START | DELETE | SERVICE_STOP);
}
if(hService)
{
printf("Start Service\n");
StartService(hService, 0, NULL);
printf("Press Enter to close service\r\n");
getchar();
ControlService(hService, SERVICE_CONTROL_STOP, &ss);
DeleteService(hService);
CloseServiceHandle(hService);
}
CloseServiceHandle(hSCManager);
}
return 0;
}
Comunicazione con il Driver
int _cdecl main(void)
{
HANDLE hFile;
DWORD dwReturn;
hFile = CreateFile("\\\\.\\Example",
GENERIC_READ | GENERIC_WRITE, 0, NULL,
OPEN_EXISTING, 0, NULL);
if(hFile)
{
WriteFile(hFile, "Hello from user mode!",
sizeof("Hello from user mode!"), &dwReturn, NULL);
CloseHandle(hFile);
}
return 0;
}
Attenzione: Il loader e il programma user-mode sono separati. Il loader và lanciato dopo aver compilato il driver con il DDK.
PENSIERO PERSONALE:
"Il tutorial finisce qui. L'autore ha scritto le altre funzioni di gestione degli IRP nel file zippato che ha reso scaricabile su sourceForge.NET. Non essendo scritte nel tutorial non ritengo opportuno metterle a disposizione qui. Il link per scaricare il file è http://www.codeproject.com/KB/system/driverdev.aspx"