Come visto nella precedente lezione, prima di entrare in VMX operation è necessario fixare i bit dei registri CR0 e CR4 quindi, in sistemi multi processore, è necessario eseguire il fix su ogni CPU. Inoltre, come si è detto in un'altra lezione, è necessario avere una copia della VMCS per ogni guest su tutti i processori logici quindi, quando si imposta la VMCS associata ad un particolare guest, è necessario effettuare la modifica su tutte le copie mantenute dalle CPU. Normalmente un programmatore non è interessato a sapere su quale processore venga eseguito il codice ma in questo caso serve un meccanismo che garantisca l'esecuzione di alcune operazioni su ogni processore logico.
Ci sono vari modi per ottenere questo risultato ed ognuno ha i suoi vantaggi e svantaggi. In questa lezione presenteremo 3 metodi e, nel corso delle prossime lezioni, si deciderà quale usare in base alle proprie esigenze ed al tipo di operazioni che si vogliono eseguire.
Una maschera di affinità (affinity mask) è una maschera di bit che permette di associare un thread ad uno specifico processore logico. In pratica, nella maschera di affinità, ogni bit corrisponde ad un processore diverso. Questo consente di eseguire un thread su un processore logico specifico semplicemente impostando il relativo bit ad 1 nella maschera di affinità. La funzione KeSetSystemGroupAffinityThread imposta la maschera di affinità del thread corrente. Per ristabilire la maschera originaria si usa invece KeRevertToUserGroupAffinityThread. Viene mostrato ora un breve estratto di codice che verrà spiegato nel paragrafo a seguire.
ULONG LogicalProcessorNumber = KeQueryActiveProcessorCountEx(ALL_PROCESSOR_GROUPS); KIRQL OldIrql; PROCESSOR_NUMBER ProcessorNumber; GROUP_AFFINITY Affinity, OldAffinity; for (ULONG i = 0; i < LogicalProcessorNumber; i++) { // Converte da indice di sistema ad indice locale nel relativo gruppo KeGetProcessorNumberFromIndex(i, &ProcessorNumber); // Imposta affinità del thread corrente per eseguirlo su i-esimo processore RtlSecureZeroMemory(&Affinity, sizeof(GROUP_AFFINITY)); Affinity.Group = ProcessorNumber.Group; Affinity.Mask = (KAFFINITY)(1 << ProcessorNumber.Number); KeSetSystemGroupAffinityThread(&Affinity, &OldAffinity); // Eleva IRQL a DISPATCH_LEVEL per impedire context switch OldIrql = KeRaiseIrqlToDpcLevel(); // // Codice che si vuole eseguire sull' i-esimo processore logico // (Omesso) // Ristabilisce IRQL e maschera. KeLowerIrql(OldIrql); KeRevertToUserGroupAffinityThread(&OldAffinity); }
Il codice mostra come eseguire del codice su ogni processore logico modificando l'affinità del thread corrente ad ogni iterazione.
I processori logici sono organizzati in gruppi da 64. Il numero di processori logici totali nel sistema in uso viene recuperato con una chiamata a KeQuesryActiveProcessorCountEx. Se, ad esempio il vostro sistema ha 128 processori logici, questi saranno divisi in due gruppi e KeQuesryActiveProcessorCountEx ritornerà 128. La variabile $i$ nel ciclo for indica quindi l'indice del processore logico a livello di sistema (cioè senza considerare il raggruppamento nei vari gruppi). Dato che la maschera di affinità è un valore a 64 bit è necessario trovare un modo per convertire l'indice di sistema in indice locale ad ogni gruppo di 64 processori. A tale scopo si può invocare KeGetProcessorNumberFromIndex che, a partire dall'indice di sistema, calcola sia l'indice del gruppo che quello del processore all'interno di tale gruppo. Tornando all'esempio precedente, per il 128-esimo processore tale metodo restituirà indice 1 per il gruppo ed indice 63 per il processore (gli indici partono da zero). Invocando KeSetSystemGroupAffinityThread si imposta l'affinità del thread corrente indicando il gruppo ed il processore logico sul quale tale thread dovrà essere eseguito. Se si devono eseguire una serie di operazioni tutte su un unico processore (come in questo caso) è bene invocare prima il metodo KeRaiseIrqlToDpcLevel, che eleva l'IRQL del processore a DISPATCH_LEVEL in modo da non permettere allo scheduler di switchare il thread per poi piazzarlo su qualche altro processore. Si faccia quindi attenzione alle funzioni che si invocano perché non tutte possono essere invocate a tale livello Alla fine è bene ristabilire IRQL e maschera originari.
Deferred Procedure Call
Una deferred procedure call (DPC) è una invocazione a funzione che avviene in modo particolare. Un oggetto kernel che rappresenta la DPC contiene informazioni sulla procedura di callback e può essere accodato ad un processore logico, il quale esegue la procedura quando il suo IRQL è pari a DISPATCH_LEVEL (2). Per tale motivo sì faccia attenzione alle funzioni che si invocano dalla procedura di callback perché non tutte possono essere invocate a tale livello. E' possibile accodare lo stesso oggetto DPC a tutti i processori logici attraverso la funzione KeGenericCallDpc per far eseguire a tutti la stessa funzione di callback. Purtroppo KeGenericCallDpc non è documentata ma è comunque esportata da NtosKrnl.exe ed esposta in NtosKrnl.lib. Quindi basta dichiararla ed il gioco è fatto.
NTKERNELAPI _IRQL_requires_max_(APC_LEVEL) _IRQL_requires_min_(PASSIVE_LEVEL) _IRQL_requires_same_ VOID KeGenericCallDpc(_In_ PKDEFERRED_ROUTINE Routine, _In_opt_ PVOID Context);
KDEFERRED_ROUTINE KdeferredRoutine; void KdeferredRoutine( PKDPC Dpc, PVOID DeferredContext, PVOID SystemArgument1, PVOID SystemArgument2 ) {...}
KDEFERRED_ROUTINE definisce semplicemente la funzione di callback.
Gli attributi della dichiarazione indicano che KeGenericCall può essere invocata al massimo ad IRQL pari a APC_LEVEL. Per evitare problemi di sincronizzazione tra i vari processori logici, alla fine della procedura di callback è consigliato invocare KeSignalCallDpcSynchronize, che attende che tutte le procedure di callback eseguite sui vari processori logici raggiungano lo stesso punto prima di proseguire. A quel punto si può invocare KeSignalCallDpcDone, che indica che la procedura di callback ha concluso le sue operazioni. Naturalmente, KeSignalCallDpcSynchronize sincronizza specificatamente le procedure di callback accodate con KeGenericCall.
Inter-processor Interrupt
All'interno del processore c'è un componente hardware chiamato Local APIC (Advanced Programmable Interrupt Controller) in grado di ricevere interrupt dalla sua CPU (attraverso i pin di interrupt del processore) o da altre fonti esterne (tra cui gli altri processori logici). Allo stesso modo è capace di inviare interrupt alla sua CPU (sempre attraverso i pin del processore) in modo che quest'ultima li gestisca nel modo più appropriato. Inoltre è in grado di inviare interrupt, per conto della sua CPU, anche alle fonti esterne (compresi gli altri processori logici) attraverso il bus di sistema.
Questo componente permette quindi ad un processore logico anche di inviare interrupt agli altri processori, e persino se stesso. Gli interrupt inviati a tal fine prendono il nome di inter-processor interrupt (IPI). Quando un IPI viene inviato ad un processore destinazione dall'APIC di un processore sorgente, l'APIC di destinazione può inviare l'interrupt ricevuto alla sua CPU, che può gestirlo in base al tipo.
In kernel-mode è possibile sfruttare la funzione KeIpiGenericCall, che invia un interrupt a tutti i processori al fine di fargli eseguire la funzione passata come primo argomento, e con la possibilità di passargli anche un argomento (il secondo parametro di KeIpiGenericCall).
ULONG_PTR KeIpiGenericCall( PKIPI_BROADCAST_WORKER BroadcastFunction, ULONG_PTR Context );
KIPI_BROADCAST_WORKER KipiBroadcastWorker; ULONG_PTR KipiBroadcastWorker( ULONG_PTR Argument ) {...}
KIPI_BROADCAST_WORKER definisce semplicemente la funzione di callback.
La possibilità di sfruttare l'APIC e gli IPI per far eseguire codice specifico su tutti i processori deve essere vagliato attentamente perché la callback indicata da KeIpiGenericCall viene eseguita elevando l'IRQL a IPI_LEVEL (29). Pochissime funzioni di sistema possono essere invocate a tale livello e dunque l'uso o meno dell'IPI dipende dal codice che si vuole far eseguire.
Codice di test
A questo punto si può modificare il codice della lezione precedente per fare in modo che l'abilitazione alla VMX operation sia attivata su un sistema multi-processore. In questo caso verranno mostrate due alternative per eseguire il codice su tutti i processori logici: maschera di affinità e DPC.
Innanzitutto è necessario, a macchina spenta, aumentare il numero di processori logici. In questo caso si è passati da 1 a 4.
A questo punto, nel metodo DriverCreate (invocata dopo che in user-mode esegue CreateFile), si sostituisce la chiamata a CpuFixBits con una a KeGenericCallDpc per accodare un oggetto DPC su tutti i processori logici e fargli eseguire la callback indicata come primo parametro: in questo caso la funzione VmxInitializeDpc, che non accetta parametri (quindi il secondo parametro di KeGenericCallDpc è ininfluente)
NTSTATUS DriverCreate(PDEVICE_OBJECT DeviceObject, PIRP Irp) { UNREFERENCED_PARAMETER(DeviceObject); if (CpuIsVMXSupported()) { //KeGenericCallDpc(VmxInitializeDpc, 0x0); VmxInitializeAffinity(); KdPrint(("VMX Operation Enabled Successfully !\n")); } Irp->IoStatus.Status = STATUS_SUCCESS; Irp->IoStatus.Information = 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_SUCCESS; }
Per verificare che VmxInitializeDpc sia effettivamente eseguita su ogni processore logico è sufficiente impostare un break point su una qualsiasi istruzione e vedere quante volte l'esecuzione viene interrotta. Se si usa WinDBG si noti il command prompt perché ad ogni interruzione verrà indicato l'identificativo del processore sul quale si sta eseguendo il codice. In alternativa, è possibile usare KeGetCurrentProcessorNumberEx, che e restituisce l'indice (sia locale che di sistema) del processore corrente.
VOID VmxInitializeDpc( PKDPC Dpc, PVOID DeferredContext, PVOID SystemArgument1, PVOID SystemArgument2) { UNREFERENCED_PARAMETER(Dpc); UNREFERENCED_PARAMETER(DeferredContext); CpuFixBits(); // In pratica SystemArgument1 e SystemArgument2 sono usati solo come // oggetti di sincronizzazione (hanno valori di default). // Aspetta che tutte le DPC in esecuzione sui vari processori logici // arrivino a questo punto. KeSignalCallDpcSynchronize(SystemArgument2); // Segnala la DPC come completata. KeSignalCallDpcDone(SystemArgument1); }
Se invece si vuole sfruttare la maschera di affinità è sufficiente invocare VmxInitializeAffinity al posto di VmxInitializeDpc.
VOID VmxInitializeAffinity() { ULONG LogicalProcessorNumber = KeQueryActiveProcessorCountEx(ALL_PROCESSOR_GROUPS); KIRQL OldIrql; PROCESSOR_NUMBER ProcessorNumber; GROUP_AFFINITY Affinity, OldAffinity; for (ULONG i = 0; i < LogicalProcessorNumber; i++) { // Converte da indice di sistema ad indice locale nel relativo gruppo KeGetProcessorNumberFromIndex(i, &ProcessorNumber); // Imposta affinità del thread corrente per eseguirlo su i-esimo processore RtlSecureZeroMemory(&Affinity, sizeof(GROUP_AFFINITY)); Affinity.Group = ProcessorNumber.Group; Affinity.Mask = (KAFFINITY)(1 << ProcessorNumber.Number); KeSetSystemGroupAffinityThread(&Affinity, &OldAffinity); // Eleva IRQL a DISPATCH_LEVEL per impedire context switch OldIrql = KeRaiseIrqlToDpcLevel(); // Esegui su ogni processore logico. CpuFixBits(); // Ristabilisce IRQL e maschera. KeLowerIrql(OldIrql); KeRevertToUserGroupAffinityThread(&OldAffinity); } }
Per la verifica in questo caso è sufficiente piazzare un break point su CpuFixBits e controllare l'identificativo del processore logico mostrato sul prompt di WinDBG. In alternativa, è possibile usare KeGetCurrentProcessorNumberEx, che e restituisce l'indice (sia locale che di sistema) del processore corrente.
Il codice completo è disponibile al link seguente.
Repository del progetto: Corso-VT-x (github.com)
Riferimenti:
Nessun commento:
Posta un commento