Synchronisation en mode noyau Windows

Ce cours dédiée au développement de drivers en kernel mode sans Framework

Moderator: Rick

Post Reply
Hydraxx
Site Admin
Posts: 124
Joined: Mon Jan 12, 2026 4:04 pm
Location: France
Contact:

Synchronisation en mode noyau Windows

Post by Hydraxx »

Synchronisation en mode noyau Windows

La synchronisation en mode noyau permet de protéger des données partagées et de coordonner l'exécution de plusieurs threads ou chemins d'exécution dans un driver. Dans le noyau Windows, le choix d'une primitive de synchronisation dépend notamment du contexte d'exécution, du niveau IRQL, de la durée pendant laquelle la ressource doit rester protégée et de la possibilité ou non de bloquer le thread courant.

Une primitive de synchronisation mal choisie peut provoquer des corruptions de données, des deadlocks, des attentes impossibles à satisfaire ou un crash système.

1. Le problème de concurrence

Un driver peut être exécuté simultanément depuis plusieurs contextes : plusieurs threads peuvent entrer dans une routine de dispatch, plusieurs processeurs peuvent exécuter du code du driver en parallèle et certaines routines peuvent être appelées à des niveaux IRQL différents.

Une opération apparemment simple telle que :

Code: Select all

Counter++;
n'est pas nécessairement atomique. Conceptuellement, elle peut être décomposée en plusieurs étapes :
  • lecture de Counter ;
  • incrément dans un registre ;
  • écriture du nouveau résultat en mémoire.
Si deux processeurs effectuent ces opérations en parallèle, les deux peuvent lire la même valeur initiale puis écrire le même résultat.

Exemple :

Code: Select all

Counter = 0

CPU 0 lit 0
CPU 1 lit 0

CPU 0 calcule 1
CPU 1 calcule 1

CPU 0 écrit 1
CPU 1 écrit 1
La valeur finale est 1 alors qu'elle devrait être 2.

C'est une race condition.

La synchronisation sert donc à garantir qu'une ressource partagée est manipulée de manière cohérente.

2. Opérations atomiques Interlocked

Pour les opérations simples sur des entiers ou des pointeurs, Windows fournit la famille des fonctions

Code: Select all

Interlocked*
.

Ces opérations sont atomiques vis-à-vis des autres processeurs et évitent d'utiliser un verrou plus lourd pour une modification très courte.

Exemples courants :

Code: Select all

InterlockedIncrement
InterlockedDecrement
InterlockedExchange
InterlockedExchangeAdd
InterlockedCompareExchange
InterlockedCompareExchangePointer
Exemple :

Code: Select all

volatile LONG Counter = 0;

InterlockedIncrement(&Counter);
Même si plusieurs processeurs exécutent l'incrément simultanément, chaque opération est effectuée de manière atomique.

Interlocked est particulièrement adapté aux compteurs, flags, références et petits changements d'état.

Il ne remplace cependant pas un verrou lorsqu'une section critique contient plusieurs opérations qui doivent être considérées comme un ensemble indivisible.

3. Spin locks

Un spin lock protège une ressource partagée sans endormir le thread qui attend.

Si le verrou est déjà détenu, le processeur attend activement dans une boucle jusqu'à ce que le verrou soit libéré.

C'est donc une primitive adaptée aux sections critiques très courtes.

Structure :

Code: Select all

KSPIN_LOCK Lock;
Initialisation :

Code: Select all

KeInitializeSpinLock(&Lock);
Acquisition :

Code: Select all

KIRQL oldIrql;

KeAcquireSpinLock(&Lock, &oldIrql);

// accès à la ressource protégée

KeReleaseSpinLock(&Lock, oldIrql);
Lors de l'acquisition d'un spin lock classique avec

Code: Select all

KeAcquireSpinLock
, Windows élève l'IRQL du processeur à

Code: Select all

DISPATCH_LEVEL
.

Lors de la libération,

Code: Select all

KeReleaseSpinLock
restaure l'ancien IRQL.

Conséquences importantes :
  • la section protégée doit être très courte ;
  • aucune attente bloquante ne doit être effectuée ;
  • le code et les données utilisés doivent être accessibles à cet IRQL ;
  • la mémoire paginée ne doit pas être utilisée dans cette section ;
  • le verrou doit toujours être libéré.
Un spin lock n'est pas un mutex : il ne met pas le thread en sommeil lorsqu'il attend.

4. Spin locks à DISPATCH_LEVEL

Si le code s'exécute déjà à

Code: Select all

DISPATCH_LEVEL
, il est inutile de demander à Windows d'élever à nouveau l'IRQL.

Les variantes correspondantes peuvent être utilisées :

Code: Select all

KeAcquireSpinLockAtDpcLevel(&Lock);

// accès protégé

KeReleaseSpinLockFromDpcLevel(&Lock);
Ces fonctions supposent que le code se trouve déjà au niveau approprié.

Elles sont notamment pertinentes dans du code exécuté par une DPC.

5. Queued spin locks

Windows fournit également des queued spin locks.

Ils sont destinés à réduire certains coûts lorsque plusieurs processeurs sont fortement en compétition pour le même verrou.

Au lieu que tous les processeurs attendent directement sur la même variable de verrouillage, les attentes peuvent être organisées sous forme de file.

Une structure de handle est utilisée lors de l'acquisition :

Code: Select all

KLOCK_QUEUE_HANDLE Handle;
Le principe reste identique :
  • acquérir le verrou ;
  • accéder très brièvement à la ressource ;
  • libérer le verrou.
Le choix entre spin lock classique et queued spin lock dépend principalement du niveau de contention.

6. Objets dispatcher du noyau

Windows possède plusieurs objets de synchronisation appelés dispatcher objects.

Un dispatcher object possède généralement un état :
  • signalé ;
  • non signalé.
Un thread peut attendre qu'un objet devienne signalé.

Parmi les objets courants :
  • event ;
  • mutex ;
  • sémaphore ;
  • timer ;
  • thread ;
  • processus.
L'attente est principalement réalisée avec :

Code: Select all

KeWaitForSingleObject
KeWaitForMultipleObjects
7. KeWaitForSingleObject

Cette fonction permet à un thread d'attendre qu'un objet dispatcher devienne signalé.

Prototype conceptuel :

Code: Select all

KeWaitForSingleObject(
    Object,
    WaitReason,
    WaitMode,
    Alertable,
    Timeout
);
Exemple :

Code: Select all

KeWaitForSingleObject(
    &MyMutex,
    Executive,
    KernelMode,
    FALSE,
    NULL
);
Un timeout

Code: Select all

NULL
correspond à une attente sans limite de temps.

Un timeout peut également être fourni sous forme d'une valeur

Code: Select all

LARGE_INTEGER
.

Les délais relatifs utilisent une valeur négative exprimée en unités de 100 nanosecondes.

Exemple d'attente d'environ une seconde :

Code: Select all

LARGE_INTEGER timeout;

timeout.QuadPart = -10LL * 1000 * 1000;

KeWaitForSingleObject(
    Object,
    Executive,
    KernelMode,
    FALSE,
    &timeout
);
Les fonctions d'attente ne peuvent pas être utilisées arbitrairement à n'importe quel IRQL.

8. KeWaitForMultipleObjects

Code: Select all

KeWaitForMultipleObjects
permet d'attendre plusieurs dispatcher objects.

Deux comportements principaux existent :
  • Code: Select all

    WaitAny
    : l'attente se termine lorsqu'au moins un objet est signalé ;
  • Code: Select all

    WaitAll
    : l'attente se termine lorsque tous les objets sont signalés.
Cette fonction est utile lorsqu'un thread noyau doit coordonner plusieurs événements ou plusieurs sources de travail.

9. Events noyau

Un event représente une condition qui peut être signalée.

Structure :

Code: Select all

KEVENT Event;
Initialisation :

Code: Select all

KeInitializeEvent(
    &Event,
    NotificationEvent,
    FALSE
);
Les deux types principaux sont :
Un

Code: Select all

NotificationEvent
reste signalé jusqu'à sa remise à zéro explicite.

Un

Code: Select all

SynchronizationEvent
peut revenir automatiquement à l'état non signalé lorsqu'une attente est satisfaite.

Signalement :

Code: Select all

KeSetEvent(&Event, IO_NO_INCREMENT, FALSE);
Remise à zéro :

Code: Select all

KeResetEvent(&Event);
Un event convient lorsqu'un thread doit annoncer à un ou plusieurs autres threads qu'un événement s'est produit.

10. Mutex noyau

Le noyau Windows fournit l'objet

Code: Select all

KMUTEX
.

Un mutex garantit qu'un seul thread propriétaire accède à une ressource à un instant donné.

Déclaration :

Code: Select all

KMUTEX Mutex;
Initialisation :

Code: Select all

KeInitializeMutex(&Mutex, 0);
Acquisition :

Code: Select all

KeWaitForSingleObject(
    &Mutex,
    Executive,
    KernelMode,
    FALSE,
    NULL
);
Libération :

Code: Select all

KeReleaseMutex(&Mutex, FALSE);
Un mutex est approprié pour des sections plus longues que celles protégées par un spin lock, car le thread peut attendre au lieu de monopoliser un processeur.

Le mutex est destiné à des contextes où le blocage du thread est autorisé.

11. Fast mutex

Windows fournit également un

Code: Select all

FAST_MUTEX
.

Il est optimisé pour des exclusions mutuelles simples en mode noyau.

Déclaration :

Code: Select all

FAST_MUTEX FastMutex;
Initialisation :

Code: Select all

ExInitializeFastMutex(&FastMutex);
Acquisition :

Code: Select all

ExAcquireFastMutex(&FastMutex);
Libération :

Code: Select all

ExReleaseFastMutex(&FastMutex);
Les fast mutex ont des contraintes d'IRQL particulières et ne doivent pas être employés comme des spin locks.

Ils conviennent lorsque l'on veut protéger une section critique dans un contexte où une attente est acceptable.

12. Sémaphores noyau

Un sémaphore maintient un compteur.

Il permet à plusieurs threads d'accéder simultanément à une ressource, jusqu'à une limite définie.

Structure :

Code: Select all

KSEMAPHORE Semaphore;
Initialisation :

Code: Select all

KeInitializeSemaphore(
    &Semaphore,
    InitialCount,
    Limit
);
Exemple :

Code: Select all

KSEMAPHORE Semaphore;

KeInitializeSemaphore(
    &Semaphore,
    2,
    2
);
Deux threads peuvent alors obtenir simultanément le sémaphore.

L'attente se fait avec :

Code: Select all

KeWaitForSingleObject(
    &Semaphore,
    Executive,
    KernelMode,
    FALSE,
    NULL
);
La libération s'effectue avec :

Code: Select all

KeReleaseSemaphore(
    &Semaphore,
    IO_NO_INCREMENT,
    1,
    FALSE
);
Le sémaphore est utile pour gérer :
  • un pool limité de ressources ;
  • une file de travail ;
  • un nombre maximal d'opérations concurrentes.
13. Timers utilisés pour la synchronisation

Un timer noyau est également un dispatcher object.

Structure :

Code: Select all

KTIMER Timer;
Initialisation :

Code: Select all

KeInitializeTimer(&Timer);
Il peut être armé avec une échéance puis attendu comme un autre dispatcher object.

Les timers peuvent être :
  • one-shot ;
  • périodiques.
Ils peuvent aussi être associés à une DPC, mais ce mécanisme appartient davantage au traitement différé qu'à la synchronisation pure.

14. Executive Resources

Une executive resource, représentée par

Code: Select all

ERESOURCE
, fournit un verrou de type lecteurs/rédacteur.

Elle permet :
  • plusieurs lecteurs simultanés ;
  • un seul écrivain exclusif.
C'est utile pour une structure de données lue très fréquemment mais modifiée plus rarement.

Le principe est :

Code: Select all

ERESOURCE Resource;
Initialisation :

Code: Select all

ExInitializeResourceLite(&Resource);
Acquisition partagée :

Code: Select all

ExAcquireResourceSharedLite(
    &Resource,
    TRUE
);
Acquisition exclusive :

Code: Select all

ExAcquireResourceExclusiveLite(
    &Resource,
    TRUE
);
Libération :

Code: Select all

ExReleaseResourceLite(&Resource);
Des régions critiques sont souvent utilisées autour de ces opérations afin de contrôler la livraison de certains APC.

15. Régions critiques et APC

Certaines opérations de synchronisation doivent empêcher la livraison d'APC noyau normaux pendant une section critique.

Windows permet d'entrer et sortir d'une région critique.

Principe :

Code: Select all

KeEnterCriticalRegion();

// opération protégée

KeLeaveCriticalRegion();
Cela ne constitue pas un verrou sur une ressource partagée.

Le but est de contrôler l'interruption logique du thread par certains APC pendant l'exécution d'une séquence sensible.

16. Deadlocks

Un deadlock apparaît lorsque plusieurs chemins d'exécution attendent mutuellement des ressources qui ne seront jamais libérées.

Exemple classique :

Code: Select all

Thread A :
    acquiert Lock1
    attend Lock2

Thread B :
    acquiert Lock2
    attend Lock1
Les deux threads restent bloqués.

Pour réduire les risques :
  • toujours acquérir plusieurs verrous dans le même ordre global ;
  • éviter les sections critiques trop longues ;
  • ne pas appeler du code inconnu en tenant un verrou ;
  • éviter de bloquer en tenant un spin lock ;
  • libérer systématiquement tous les verrous ;
  • documenter clairement les règles de verrouillage.
L'ordre d'acquisition des verrous est une règle essentielle dans un driver complexe.

17. Spin lock ou mutex ?

Le choix dépend du contexte.

Spin lock :
  • attente active ;
  • adapté aux sections extrêmement courtes ;
  • utilisable à IRQL élevé selon la variante ;
  • pas d'opération bloquante ;
  • pas de mémoire paginée dans la section protégée lorsqu'on exécute à DISPATCH_LEVEL.
Mutex / fast mutex :
  • peut bloquer le thread ;
  • adapté aux sections plus longues ;
  • réservé aux contextes où une attente est autorisée ;
  • évite de faire tourner inutilement le processeur pendant une attente longue.
Event :
  • sert surtout à signaler qu'une condition ou une opération est terminée.
Sémaphore :
  • permet un nombre limité d'accès simultanés.
Executive resource :
  • adaptée aux scénarios lecteurs/rédacteur.
Interlocked :
  • idéal pour une opération atomique très courte sur une valeur simple.
18. Synchronisation et IRQL

Le niveau IRQL impose des restrictions directes sur les primitives utilisables.

En particulier :
  • les attentes bloquantes nécessitent un niveau suffisamment bas ;
  • les spin locks classiques élèvent généralement l'exécution à

    Code: Select all

    DISPATCH_LEVEL
    ;
  • à

    Code: Select all

    DISPATCH_LEVEL
    , une faute de page ne peut pas être traitée normalement ;
  • le code et les données accessibles dans une section protégée par spin lock doivent donc être résidents ;
  • une fonction de synchronisation doit toujours être utilisée conformément à ses contraintes IRQL.
Le fonctionnement détaillé des IRQL, des interruptions, des ISR et des DPC doit être étudié séparément, car il constitue un mécanisme plus large que la synchronisation elle-même.

19. Exemple simple : compteur partagé

Pour un simple compteur partagé, un spin lock serait souvent excessif.

Code: Select all

volatile LONG Counter = 0;

void IncrementCounter()
{
    InterlockedIncrement(&Counter);
}
Pour plusieurs champs qui doivent être modifiés ensemble :

Code: Select all

typedef struct _MY_DATA
{
    LONG A;
    LONG B;
    KSPIN_LOCK Lock;
} MY_DATA;

void UpdateData(MY_DATA* Data)
{
    KIRQL oldIrql;

    KeAcquireSpinLock(&Data->Lock, &oldIrql);

    Data->A++;
    Data->B += 10;

    KeReleaseSpinLock(&Data->Lock, oldIrql);
}
Ici, le spin lock protège la cohérence de l'ensemble des modifications.

20. Exemple simple : mutex

Code: Select all

KMUTEX Mutex;

void Initialize()
{
    KeInitializeMutex(&Mutex, 0);
}

void AccessSharedData()
{
    KeWaitForSingleObject(
        &Mutex,
        Executive,
        KernelMode,
        FALSE,
        NULL
    );

    // accès à une ressource partagée

    KeReleaseMutex(&Mutex, FALSE);
}
Ce modèle convient à une section pouvant être plus longue et exécutée dans un contexte où le thread peut être bloqué.

21. Exemple simple : event

Code: Select all

KEVENT WorkFinished;

void Initialize()
{
    KeInitializeEvent(
        &WorkFinished,
        NotificationEvent,
        FALSE
    );
}
Un producteur signale la fin du travail :

Code: Select all

KeSetEvent(
    &WorkFinished,
    IO_NO_INCREMENT,
    FALSE
);
Un thread consommateur attend :

Code: Select all

KeWaitForSingleObject(
    &WorkFinished,
    Executive,
    KernelMode,
    FALSE,
    NULL
);
22. Règles pratiques à retenir
  • Utiliser

    Code: Select all

    Interlocked*
    pour les opérations atomiques simples.
  • Utiliser un spin lock pour une section extrêmement courte devant être protégée entre plusieurs processeurs.
  • Ne jamais effectuer une attente bloquante en tenant un spin lock.
  • Ne pas accéder à de la mémoire paginée lorsqu'une section s'exécute à

    Code: Select all

    DISPATCH_LEVEL
    .
  • Utiliser mutex, fast mutex ou executive resource lorsque le code peut attendre.
  • Utiliser un event pour signaler un changement d'état ou la fin d'une opération.
  • Utiliser un sémaphore pour limiter un nombre d'accès simultanés.
  • Toujours respecter les contraintes IRQL de chaque primitive.
  • Toujours libérer les verrous sur tous les chemins de sortie.
  • Définir un ordre d'acquisition des verrous afin d'éviter les deadlocks.
Conclusion

La synchronisation en mode noyau repose sur plusieurs familles de mécanismes répondant à des besoins différents.

Les opérations

Code: Select all

Interlocked*
fournissent des modifications atomiques simples. Les spin locks protègent des sections très courtes à IRQL élevé. Les dispatcher objects permettent aux threads d'attendre efficacement. Les mutex assurent une exclusion mutuelle avec possibilité de blocage, les sémaphores limitent la concurrence, les events signalent des changements d'état et les executive resources fournissent un mécanisme lecteurs/rédacteur.

Le point essentiel n'est pas seulement de savoir utiliser une primitive, mais de choisir la bonne en fonction du contexte d'exécution, de la durée de la section critique et du niveau IRQL.

Who is online

Users browsing this forum: No registered users and 0 guests