Les bibliothèques dynamiques (DLL)

Jessee Michaël C. Edouard

Ce tutoriel présente toutes les notions que tout développeur Windows doit savoir concernant les fichiers DLL.

Commentez cet article : Commentez ♪

- Titre : Les bibliothèques dynamiques (DLL)

- Auteur : Jessee Michaël C. Edouard

- Parution : 21 décembre 2009

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2008 Melem. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts. Droits de diffusion permanents accordés à Developpez LLC.

Sommaire

- I. Introduction

- II. Les concepts de base

- II-A. Le chargement

- II-B. Point d'entrée

- II-C. Convention d'appel

- II-D. Décoration de nom

- II-D-1. Généralités

- II-D-2. Premier exemple : décoration de nom d'une fonction cdecl

- II-D-3. Décoration de nom d'une fonction stdcall

- II-E. Les fichiers de définition de module (*.def)

- II-E-1. Définition

- II-E-2. Premiers exemples

- II-E-3. Syntaxe complète

- II-F. L'outil Dependency Walker

- III. Techniques intermédiaires et avancées

- III-A. La programmation avec différents langages

- III-B. Les DLL dans tous ses états

- III-B-1. Introduction

- III-B-2. Les DLL de ressources

- III-B-3. Les hooks

- III-B-4. Les AppInit_DLL

- III-B-5. Les serveurs COM

- III-B-6. Les assemblies

- IV. Conclusion

- V. Remerciements

I - Introduction

Dans le tutoriel sur la compilation séparée en C, nous avons déjà appris à créer et utiliser des DLL sans pour autant étudier en profondeur leur fonctionnement ni leur rôle dans Windows, ce que nous allons découvrir à l'instant, mais avant cela, parlons un peu de l'organisation de la mémoire sous Windows (NT).

Sous Windows, les applications n'adressent pas directement la mémoire physique, mais une mémoire virtuelle. Lorsqu'une application est lancée, Windows la place dans un espace mémoire (virtuel) de 4 Go (limite imposée par le système 32 bits) où l'application croit qu'elle est la seule application en cours d'exécution sur le système. Toutes les adresses que vous manipulez dans vos applications Windows sont donc des adresses virtuelles. Le système fait toujours la conversion adresse virtuelle - adresse physique chaque fois que vous accédez à une case mémoire. C'est d'ailleurs un des rôles attribués aux systèmes d'exploitation modernes. Ce qu'il fallait surtout retenir de ce paragraphe, c'est donc que chaque processus (vous pouvez considérer les termes processus et application comme synonymes pour le moment) possède son propre espace d'adressage qui vaut 4 Go sur un processeur 32 bits.

Lorsqu'une application demande d'être liée à une DLL donnée, une image de la DLL (dont il n'existe au plus qu'une seule instance en mémoire physique) sera placée dans la mémoire virtuelle de l'application. Chaque application se comporte donc comme si elle était la seule à utiliser la DLL. Lorsqu'une application libère la DLL, cette dernière est juste supprimée de son espace d'adressage, pas immédiatement de la mémoire physique. La DLL sera supprimée de la mémoire physique seulement lorsque le nombre d'applications qui l'utilisent atteint 0.

Une application peut se lier à une DLL de trois manières différentes :

- par chargement explicite : l’application appelle la fonction LoadLibrary pour charger la DLL dans son espace d'adressage. La fonction FreeLibrary doit être appelée aussitôt que l'application n'utilise plus la DLL afin de la supprimer de sa mémoire virtuelle ;

- par liaison implicite : Chargement et déchargement automatiques respectivement au démarrage et à l'arrêt de l'application. Cette dernière doit avoir été liée à la bibliothèque d'importation associée à la DLL pendant sa compilation ;

- par liaison implicite différée : semblable à la liaison implicite sauf que la DLL n'est pas chargée au démarrage de l'application, mais à la première utilisation d'une fonction ou d'un objet contenu dans la DLL.

Dans ce document, nous ne parlerons que des deux premières méthodes.

II - Les concepts de base

II-A - Le chargement

Une application peut charger une DLL à tout moment à l'aide de la fonction LoadLibrary qui reçoit en unique argument le nom de la DLL à charger. C'est ce qu'on appelle faire un chargement explicite. Si seul le nom du fichier est donné (par exemple « mylib.dll », c'est-à-dire que le chemin du répertoire n'est pas précisé), Windows cherchera le fichier dans les répertoires suivant, en suivant l'ordre : le répertoire contenant l'application, le répertoire courant de l'application (que l'on peut connaître à l'aide de CurrentDirectory), le répertoire système (que l'on peut connaître à l'aide de GetSystemDirectory et qui correspond normalement au répertoire %systemroot%\system32), le dossier d'installation de Windows (que l'on peut connaître à l'aide de GetWindowsDirectory et qui correspond au répertoire %systemroot%) et enfin, les dossiers listés dans la variable d'environnement PATH.

LoadLibray retourne un HMODULE, handle du module (en gros un module est soit une DLL, soit un exe) qui vient d'être chargé en cas de succès et NULL en cas d'erreur. La valeur de ce handle n'est autre que l'adresse de base du module dans l'espace d'adressage du processus l'ayant chargé, mais on peut le plus souvent continuer à programmer tout en ignorant cette information. Ce handle servira bien évidemment à identifier le module chaque fois qu'il est besoin. GetModuleHandle(NULL) retourne le HMODULE de l'exe ayant créé le processus, le même handle que celui placé par Windows dans le paramètre hInstance de WinMain. Pour récupérer l'adresse d'une fonction contenue dans le module et dont on connaît le nom, on utilise la fonction GetProcAddress.

FARPROC GetProcAddress(HMODULE hModule, LPCSTR lpProcName);

Pour libérer la DLL, il faudra appeler la fonction FreeLibrary qui reçoit en unique argument le handle de module de la DLL.

Une fois une DLL chargée (qu'elle ait été explicitement ou implicitement chargée), on peut à tout moment récupérer son handle de module à l'aide de la fonction GetModuleHandle qui reçoit en unique argument le nom du module dont on veut récupérer le handle. Nous verrons bientôt des exemples d'utilisation.

II-B - Point d'entrée

Une DLL peut optionnellement avoir un point d'entrée. Le point d'entrée d'une DLL est une fonction appelée DllMain qui retourne BOOL. Lorsqu'elle est présente, elle est entre autres appelée pendant le chargement et le déchargement de la DLL. Voici un exemple de code de DLL avec un point d'entrée et une fonction exportée.

Fichier : test.c

#include <windows.h>

BOOL WINAPI DllMain(HMODULE hModule, DWORD dwReasonForCall, LPVOID lpvReserved)
{
switch(dwReasonForCall)
{
case DLL_PROCESS_ATTACH:
MessageBox(NULL, "Chargement de la DLL", "DLL de test", MB_OK);
break;

case DLL_PROCESS_DETACH:
MessageBox(NULL, "Dechargement de la DLL", "DLL de test", MB_OK);
break;
}

return TRUE;
}

__declspec(dllexport) int TestDLL(void)
{
return MessageBox(NULL, "La DLL fonctionne !", "DLL de test", MB_OK);
}

Le paramètre hModule de DllMain contient toujours le handle de module de la DLL. Le paramètre dwReasonForCall indique la raison pour laquelle la fonction a été appelée. Les 4 valeurs possibles sont DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH, DLL_THREAD_ATTACH et DLL_THREAD_DETACH. Avant d'expliquer leurs sens, remarquons déjà la présence des termes « process » et « threads ». Ce sont ces termes-là qu'il faut tout d'abord définir.

Un processus (« process ») est, grossièrement, l'image en mémoire d'une application. Autrement dit, lorsqu'une application est lancée, elle constitue un processus.

Dans les anciens systèmes tels que le DOS primitif, un processus ne peut exécuter qu'une seule tâche à la fois (pour mieux comprendre le concept, n'hésitez pas à associer une « tâche » à une « fonction »). Les systèmes modernes sont capables de faire tourner des applications exécutant plusieurs tâches (c'est-à-dire plusieurs fonctions) en même temps. Ces applications sont dites multithreadées, thread étant un terme anglais signifiant tâche (qu'il est rare de voir être traduit en français). Un processus contient donc au moins un thread, c'est le thread principal (le thread qui a appelé le point d'entrée du programme). Pour créer un nouveau thread, il suffit d'appeler la fonction CreateThread.

Maintenant, pour revenir à notre DllMain, dwReasonForCall vaut :

- DLL_PROCESS_ATTACH lorsqu'un processus a demandé le chargement de la DLL. La fonction doit retourner TRUE pour indiquer que la DLL s'est bien initialisée et FALSE pour indiquer une erreur (le chargement sera annulé et il est préférable de toujours supposer que DLL_PROCESS_DETACH ne sera pas appelé) ;

- DLL_PROCESS_DETACH lorsqu'un processus a demandé le déchargement de la DLL ;

- DLL_THREAD_ATTACH lorsqu'un thread (autre que le thread principal) vient d'être créé par un processus lié à la DLL ;

- DLL_THREAD_DETACH lorsqu'un thread d'un processus lié à la DLL ayant appelé la fonction avec DLL_THREAD_ATTACH (donc certainement pas le thread principal) est sur le point de se terminer.

La valeur retournée par DllMain n'est importante que lorsque dwReasonForCall vaut DLL_PROCESS_ATTACH. Dans les autres cas, elle est tout simplement ignorée.

II-C - Convention d'appel

Comme nous le savons très bien, un programme est constitué de code exécutable et de données manipulées, tout cela résidant en mémoire pendant l'exécution et ce ne sont pas les corps des fonctions qui font exception à cela. Ce qu'on appelle « adresse d'une fonction », c'est l'adresse de l'emplacement mémoire contenant la première instruction constituant la fonction. Soit myfunc notre fonction et supposons qu'elle accepte des paramètres et ne retourne aucune valeur. Lorsqu'on écrit myfunc(liste des paramètres), voici les grandes étapes qui auront lieu pendant l'exécution :

- transfert des paramètres dans la zone mémoire où la fonction s'attend à les trouver ;

- appel de la fonction (avec une instruction du type : call myfync;). Si les paramètres n'ont pas été correctement transférés avant l'appel à myfunc, la fonction se comportera évidemment de manière bizarre.

Une convention d'appel est un ensemble de règles qui dicte la manière d'effectuer les entrées/sorties avec une fonction (comment spécifier les paramètres, comment lire la valeur de retour, etc.). Si une fonction suit une convention d'appel donnée et qu'un programme l'appelait en utilisant une convention d'appel différente, alors il n'y aura rien d'autre à espérer qu'être témoin d'un bug des plus spectaculaires !

Les conventions d'appel les plus utilisées sous Windows sont les suivantes :

- la convention d'appel C (__cdecl), convention d'appel utilisée par défaut par les programmes écrits en C ;

- la convention d'appel dite standard (__stdcall), utilisée par les fonctions de l'API Windows (exception faite des fonctions acceptant un nombre variable d'arguments qui sont obligés d'utiliser la convention d'appel C). Sachez en particulier que WINAPI est juste une macro qui se développe en __stdcall.

Comme les API de Windows utilisent la convention d'appel stdcall, tous les langages qui désirent être pleinement utilisables sous Windows doivent la supporter. C'est le cas de toutes les implémentations des langages C et C++ pour Windows, les langages Pascal, VB, etc.

Nous avons jusqu'ici vu assez de théorie, passons un peu à la pratique. Nous allons récupérer l'adresse de la fonction Beep (qui se trouve dans kernel32.dll et qui utilise la convention d'appel WINAPI) à l'aide de GetProcAddress puis l'utiliser. Comme kernel32.dll est liée à toutes les applications Win32 (aucune application Win32 ne peut fonctionner correctement sans cette DLL), il suffit d'utiliser GetModuleHandle pour récupérer son handle au lieu d'appeler LoadLibrary.

#include <stdio.h>
#include <windows.h>

typedef BOOL WINAPI BEEPFUNC(DWORD, DWORD);

int main()
{
HMODULE hKernel32 = GetModuleHandle("kernel32.dll");
BEEPFUNC * lpBeep = (BEEPFUNC *)GetProcAddress(hKernel32, "Beep");

if (lpBeep == NULL)
fprintf(stderr, "L'adresse de la fonction Beep n'a pas pu etre lue.\n");
else
lpBeep(1000, 100);

return 0;
}

La précision de la convention d'appel WINAPI dans la définition du type BEEPFUNC est obligatoire sinon le compilateur conclura par défaut qu'une fonction de type BEEPFUNC utilise la convention d'appel par défaut à savoir __cdecl.

II-D - Décoration de nom

II-D-1 - Généralités

La décoration de nom est l'action effectuée par certains compilateurs qui consiste à ajouter des symboles additionnels au nom des fonctions et/ou des variables. Ces symboles représentent souvent des informations qui sont indispensables pour le langage et/ou le compilateur. De manière générale, chaque compilateur a sa propre manière de décorer les noms. Ainsi, pour nous fixer les idées, nous supposons que nous travaillons avec Visual C++, le compilateur de référence en matière de développement Windows, mais les conclusions que nous tirerons seront applicables que vous travailliez avec ou non.

II-D-2 - Premier exemple : décoration de nom d'une fonction cdecl

Visual C++ ajoute toujours un underscore (_) devant le nom d'une fonction cdecl. Cela signifie que si votre fonction s'appelle myfunc dans le fichier source, elle sera renommée en _myfunc dans le fichier objet. Sans commandes spéciales, c'est cependant le nom original qui sera utilisé dans le fichier final (c'est-à-dire l'exe ou la dll). Ainsi, dans le fichier test.dll de l'exemple II-B, le nom de la fonction exportée sera toujours TestDll (et non _TestDLL comme dans l'obj). Voici un programme test pour s'en convaincre :

#include <stdio.h>
#include <windows.h>

typedef int TESTDLLFUNC(void);

int main()
{
HMODULE hTest = LoadLibrary("test.dll");

if (hTest == NULL)
fprintf(stderr, "Le fichier test.dll n'a pa pu etre charge.\n");
else
{
TESTDLLFUNC * lpTestDll = (TESTDLLFUNC *)GetProcAddress(hTest, "TestDLL");

if (lpTestDll == NULL)
fprintf(stderr, "L'adresse de la fonction TestDLL n'a pas pu etre lue.\n");
else
lpTestDll();

FreeLibrary(hTest);
}

return 0;
}

Encore plus loin, lorsqu'une fonction f (toujours supposée être cdecl) est exportée par une DLL, lors de l'édition des liens, deux « fonctions » sont placées dans la bibliothèque d'importation de la DLL : _f et _f préfixé de __imp_ soit __imp__f. Dans la DLL, il n'y a cependant bel et bien qu'une et une seule fonction, f, qu'il ne faut surtout pas confondre avec le symbole de même nom dans la bibliothèque d'importation. Pour preuve, on peut contrôler le nom de la fonction à l'intérieur de la DLL, mais on ne peut pas en faire autant pour les symboles de la bibliothèque d'importation.

Le préfixe __imp_ indique qu'il s'agit juste d'un « lien » (ou un « renvoi ») vers une fonction dont le code est situé dans la DLL associée. Et que fait au juste la « fonction » _f ? Et bien, elle ne fait rien de plus que donner la main à __imp__f ! Mais alors lorsqu'on fait référence à f dans le code source, cela génère-t-il en code machine une référence à _f ou à __imp__f ? Cela dépend de la manière dont vous avez déclaré f !

En effet, supposons que le prototype de f soit int f(void). Lorsqu'on déclare f avec :

int f(void);

Le compilateur, qui ne saura jamais deviner qu'il s'agit d'une fonction se trouvant dans une quelconque DLL, va se contenter de générer à chaque référence à f dans le code source une référence à _f dans le code machine.

Lorsqu'on déclare f avec :

__declspec(dllimport) int f(void);

Le compilateur saura qu'il s'agit d'une fonction se trouvant dans une DLL et génèrera dans ce cas, à chaque référence à f dans le code source, une référence à __imp__f dans le code machine. C'est pourquoi, lorsque vous vous liez à une bibliothèque d'importation, déclarez toujours les fonctions à importer avec __declspec(dllimport).

II-D-3 - Décoration de nom d'une fonction stdcall

Visual C++ décore le nom d'une fonction stdcall en le préfixant d'un underscore et en ajoutant un suffixe constitué du caractère @ et de la quantité totale de mémoire requise par les paramètres de la fonction. Par exemple, si le prototype de votre fonction est int __stdcall myfunc(int, int), dans les fichiers objet, le nom de la fonction sera _myfunc@8. Si la fonction est exportée, alors on aura dans la bibliothèque d'importation les fonctions __myfunc@8 et __imp__myfunc@8. La fonction dans la DLL sera par défaut nommée _myfunc@8 (c'est-à-dire que c'est le nom décoré qui est utilisé).

II-E - Les fichiers de définition de module (*.def)

II-E-1 - Définition

Un fichier de définition de module (*.def) est un fichier texte permettant de lister entre autres les fonctions exportées par un module, en offrant plus d'options d'exportation qu'en utilisant __declspec(dllexport).

II-E-2 - Premiers exemples

Voici un exemple de fichier def (qu'il faudra donc ajouter au projet) qui ordonne l'exportation de deux fonctions func1 et func2 :

test.def

EXPORTS
func1
func2

Exportées ainsi, les deux fonctions se trouveront nommées telles quelles (c'est-à-dire sans aucune décoration de nom !) dans la DLL. Dans la bibliothèque d'importation associée à la DLL, on aura par contre toujours les noms décorés (qui ne sont que des relais vers les véritables fonctions dans la DLL) selon les principes vus dans II-D.

On peut même spécifier les noms qu'on veut obtenir dans la DLL comme le montre l'exemple suivant :

EXPORTS
DllFunc1=func1
DllFunc2=func2

Dans ce cas les fonctions func1 et func2 seront nommées, dans la DLL, DllFunc1 et DllFunc2. Cette syntaxe permet également de créer ce qu'on appelle un relais (en anglais : forwarder), c'est-à-dire une fonction qui ne fait rien de plus que de donner directement la main à une autre. Par exemple, avec :

EXPORTS
KernelBeep=kernel32.Beep

On obtiendra dans notre DLL une fonction nommée KernelBeep qui n'est juste qu'un relais menant à la fonction Beep se trouvant dans kernel32.dll.

II-E-3 - Syntaxe complète

La syntaxe complète d'une entrée de la section EXPORTS est la suivante :

entryname[=internalname] [@ordinal [NONAME]] [PRIVATE] [DATA]

- entryname est le nom de l'entrée qu'on veut créer dans la DLL.

- internalname est le nom de la fonction ou de la variable à exporter sous le nom entryname. Par défaut, internalname = entryname.

- ordinal est un numéro qu'on veut associer à l'entrée. L'ordinal peut être utilisé dans l'argument lpProcName de GetProcAdress (par exemple : GetProcAddress(hMod, « 1 »)).

- Le flag NONAME qui n'est disponible que lorsqu'un ordinal est spécifié indique qu'on ne veut pas avoir le nom de la fonction dans la DLL, juste l'ordinal. Cela permet de minimiser la taille des fichiers.

- Le flag PRIVATE indique qu'on ne veut pas avoir d'entrée pour la fonction ou la variable dans le .lib.

- Et enfin, le flag DATA indique qu'on est en train d'exporter une variable et non une fonction.

II-F - L'outil Dependency Walker

Dependency Walker (depends.exe) est un outil gratuit permettant d'ouvrir un module (dll ou exe par exemple) et de voir entre autres les fonctions exportées par ce module et les modules requis pour que le module puisse être chargé (c'est-à-dire les modules capitaux pour son fonctionnement). Il est toujours livré avec les outils de développement Windows Microsoft, mais si vous ne l'avez pas encore, vous n'avez qu'à le télécharger gratuitement ici.

III - Techniques intermédiaires et avancées

III-A - La programmation avec différents langages

Les DLL étant des fichiers contenant du code machine, elles peuvent être utilisées depuis n'importe quel langage supportant leur format (en pratique, tous les langages de programmation disponibles sous Windows). Cela signifie que si vous écrivez une DLL en C, rien ne vous empêche de l'utiliser par la suite en C++, Visual Basic ou ActionScript, etc. C'est l'un des intérêts de l'utilisation des DLL. Il y a cependant deux conditions à respecter pour qu'une DLL puisse bien servir un maximum de langages :

1. Que les noms des fonctions ne soient décorés. En effet certains caractères ajoutés par la décoration peuvent ne pas être supportés par certains langages ;

2. Que les fonctions utilisent la convention d'appel standard (stdcall), car il n'y a que très peu de langages de programmation sous Windows qui ne la supportent pas.

En pratique, cela signifie que vous devez exporter vos fonctions à l'aide d'interfaces C (c'est-à-dire prototypes C), d'un fichier de définition de module et utiliser la convention d'appel stdcall. Les DLL de l'API Windows par exemple ont été construites en respectant ce principe. Nous n'allons cependant pas donner ici d'exemples d'utilisation de DLL depuis différents langages, car cela est un problème spécifique à chaque langage et ne rentre donc pas dans le cadre de cet ouvrage. Néanmoins, voici quelques liens qui pourraient vous intéresser :

- Créer des DLL en C compatibles avec VB6

- Appels de DLL natives en .NET

- Exécuter du code natif en Java

III-B - Les DLL dans tous ses états

III-B-1 - Introduction

Dans les paragraphes suivants, nous allons mettre en évidence l'importance des DLL dans le système Windows (en plus du fait qu'elles sont utilisées pour exposer les API bien entendu…).

III-B-2 - Les DLL de ressources

Savez-vous quelle est la différence entre un fichier EXE et un fichier DLL ? Un bit ! Pas même un octet, mais un bit. La seule chose qui permet de distinguer un fichier EXE d'un fichier DLL est un bit dans l'en-tête du fichier qui indique si le fichier est un EXE ou une DLL. En clair, ces fichiers ont exactement le même format.

Un fait qui en découle est bien sûr qu'on peut donc aussi exporter des fonctions depuis un EXE. Un autre, ce qui nous intéressera ici, est qu'il possible de mettre des ressources dans une DLL. Le fichier shell32.dll par exemple contient les icônes utilisées par l'explorateur Windows. Il y a aussi comdlg32.dll qui contient les modèles des boîtes de dialogue communes de Windows et bien d'autres exemples encore.

En conclusion, si vous développez des applications qui utilisent totalement ou en partie les mêmes ressources (icônes, bitmaps, boîtes de dialogue, etc.), alors vous tirerez beaucoup avantage en créant une ou plusieurs DLL de ressources.

III-B-3 - Les hooks

Un hook est un programme notifié par Windows de chaque nouveau message dans un thread donné ou dans le système tout entier avant que le message n'atteigne son véritable destinataire.

Il existe plusieurs de types de hooks. Par exemple, un hook clavier est un hook qui n'est notifié que des messages provenant du clavier. Selon le type du hook, il peut examiner, modifier et/ou supprimer le message.

On peut aussi classer les hooks selon leur étendue. Ainsi, un hook global est un hook qui surveille le système tout entier et un hook local est un hook qui ne surveille qu'un processus voire juste un thread. Dans tous les cas, un hook a besoin d'un installeur (généralement un EXE) pour s'installer. L'installation se fait juste en appelant la fonction SetWindowsHookEx. Lorsqu'on n'a plus besoin du hook, il faut le désinstaller. Cela se fait en appelant la fonction UnhookWindowsHookEx.

HHOOK SetWindowsHookEx(int idHook, HOOKPROC lpfn, HINSTANCE hMod, DWORD dwThreadId);

idHook indique évidemment le type de hook qu'on veut installer. Ce sont des commandes commençant généralement par WH_. Par exemple, on a WH_KEYBOARD pour un hook clavier de haut niveau et WH_KEYBOARD_LL pour un hook clavier de bas niveau. Ce ne sont pas ce qui nous intéresse.

lpfn indique la procédure principale (la « Hook Proc ») qu'on veut associer au hook (c'est-à-dire la procédure qui « hooker »).

hMod indique le handle du module contenant la fonction pointée par lpfn. Mettre NULL si la fonction se trouve dans le module EXE lui-même.

dwThreadId indique, si hMod vaut NULL, l'ID du thread qu'on veut surveiller. Si hMod est différent de NULL, ce paramètre doit être mis à 0 et tous les threads du système seront surveillés.

La valeur retournée est le handle du hook et NULL en cas d'erreur. La fonction UnhookWindowsHookEx ne requiert que ce handle en argument.

La procédure principale (HookProc) d'un hook global doit être définie dans une DLL externe tandis que celle d'un hook local à un processus peut être définie dans l'EXE (l'installeur) lui-même. Dans ce tutoriel, nous allons prendre pour exemple un hook global. Une raison pourrait être que si la HookProc d'un hook global était implémentée dans l'installeur lui-même, les threads qui n'appartiennent pas au processus installeur ne pourront pas l'appeler, car ils appartiennent à un espace d'adressage différent de celui où la fonction existe (qui n'est autre que l'espace d'adressage de l'installeur). Il faut donc effectivement que la HookProc soit définie dans une DLL afin que tous les processus puissent s'y lier et accéder à la fonction.

LRESULT CALLBACK HookProc(int code, WPARAM wParam, LPARAM lParam);

Le rôle de chaque paramètre dépend du type de hook.

Après qu'un hook s'est correctement installé, il est placé dans ce qu'on appelle la chaîne de hooks. Le dernier hook installé est celui qui recevra en premier les notifications. Lorsqu'un hook reçoit un message, il peut, après avoir terminé tous ses traitements, passer le message au prochain hook de la chaîne (ou alors à son destinataire s'il n'y a plus de hook dans chaîne) en appelant la fonction CallNextHookEx et retournant ce que la fonction retourne, passer directement le message au destinataire en retournant 0 ou encore arrêter la progression du message (c'est-à-dire le supprimer) en retournant une valeur différente de 0. Rappelons cependant que les hooks n'ont pas tous les mêmes droits sur les messages qu'ils reçoivent et que vous n'avez donc pas parfois beaucoup de choix quant à la valeur que votre HookProc doit retourner.

Les hooks les plus utilisés sont :

- les Keyboard Hooks (WH_KEYBOARD et WH_KEYBOARD_LL), notifiés de tous les messages provenant du clavier. Ils sont réputés maléfiques, car ils sont principalement utilisés dans la fabrication de logiciels d'espionnage appelés keyloggers (enregistreurs de frappe). Ces hooks peuvent examiner et supprimer un message ;

- les Mouse Hooks (WH_MOUSE et WH_MOUSE_LL), notifiés de tous les messages provenant de la souris. Ils sont utilisés la plupart du temps pour créer des extensions de l'environnement graphique ou dans des applications maniaques du contrôle. Ces hooks peuvent examiner et supprimer un message ;

- les Get Message Hooks (WH_GETMESSAGE), notifiés des messages lus par GetMessage ou PeekMessage avant que ces fonctions ne retournent. Ils ne peuvent donc être notifiés que des messages issus d'une queue de messages (messages clavier, souris, WM_PAINT, WM_TIMER, WM_QUIT et messages postés à l'aide de PostMessage par exemple). Ces hooks peuvent examiner et modifier un message ;

- les Call Window Proc Hooks (WH_CALLWNDPROC), notifiés des messages qui sont sur le point d'être directement passés à une procédure de fenêtre, que le message ait été envoyé par Windows lui-même ou par une application (via SendMessage par exemple). Ce type de message regroupe la majorité des messages Windows. Ces hooks ne peuvent qu'examiner un message.

Utilisés en même temps, un Get Message Hook et un Call Window Proc Hook permettent donc de hooker tous les messages transitant dans le système tout entier. C'est ainsi que fonctionnent basiquement les logiciels de surveillance comme Spy++, un programme livré avec Visual Studio.

Créons donc, pour avoir du concret, un hook tout simple, un Call Window Proc Hook. Pour un tel hook, les paramètres de la HookProc ont les significations suivantes :

- code vaut HT_ACTION lorsqu'il y a un message à traiter. Lorsque code est inférieur à 0, il faut immédiatement retourner la valeur retournée par CallNextHookEx ;

- wParam vaut VRAI si le message vient du thread courant (c'est-à-dire le thread qui a installé le hook) et FAUX dans les autres cas ;

- lParam contient l'adresse d'une structure CWPSTRUCT contenant les informations sur le message (hwnd, message, wParam et lParam).

Voici donc, à titre d'exemple, le code d'un hook global qui surveille les messages WM_CREATE et WM_DESTROY - hook.dll - et celui de son gestionnaire (lanceur/stoppeur) : hookman.exe.

hook.c

#include <stdio.h>
#include <windows.h>

struct {
char FileName[MAX_PATH];
FILE * fOut;
} MyApp; /* Représente le processus lié à la DLL. */

LRESULT CALLBACK CallWndProc(int code, WPARAM wParam, LPARAM lParam);

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
BOOL ret = TRUE;

switch(fdwReason)
{
case DLL_PROCESS_ATTACH:
strcpy(MyApp.FileName, "<unknown>");
GetModuleFileName(NULL, MyApp.FileName, sizeof(MyApp.FileName));
MyApp.fOut = fopen("c:\\hooklog.txt", "a");
if (MyApp.fOut == NULL)
ret = FALSE;
break;

case DLL_PROCESS_DETACH:
if (MyApp.fOut != NULL)
fclose(MyApp.fOut);
break;
}

return ret;
}

LRESULT CALLBACK CallWndProc(int code, WPARAM wParam, LPARAM lParam)
{
if (code == HC_ACTION)
{
PCWPSTRUCT lpcwps = (PCWPSTRUCT)lParam;

switch(lpcwps->message)
{
case WM_CREATE:
/* On va s'intéresser qu'aux fenêtres qui n'ont pas de parent. */
if (GetParent(lpcwps->hwnd) == NULL)
{
char lpBuffer[256];

fprintf(MyApp.fOut, "[WM_CREATE]\n");
fprintf(MyApp.fOut, "App=%s\n", MyApp.FileName);

*lpBuffer = '\0';
GetClassName(lpcwps->hwnd, lpBuffer, sizeof(lpBuffer));
fprintf(MyApp.fOut, "Class=%s\n", lpBuffer);

*lpBuffer = '\0';
GetWindowText(lpcwps->hwnd, lpBuffer, sizeof(lpBuffer));
fprintf(MyApp.fOut, "Name=%s\n", lpBuffer);

fprintf(MyApp.fOut, "Handle=%#010x\n\n", (unsigned)lpcwps->hwnd);
}
break;

case WM_DESTROY:
if (GetParent(lpcwps->hwnd) == NULL)
{
fprintf(MyApp.fOut, "[WM_DESTROY]\n");
fprintf(MyApp.fOut, "Handle=%#010x\n\n", (unsigned)lpcwps->hwnd);
}
break;
}
}

return CallNextHookEx(NULL, code, wParam, lParam);
}

hookman.c

#include <stdio.h>
#include <windows.h>

int main()
{
HMODULE hmodHook = LoadLibrary("hook.dll");
HHOOK hHook = SetWindowsHookEx(WH_CALLWNDPROC, (HOOKPROC)GetProcAddress(hmodHook, "CallWndProc"), hmodHook, 0);
if (hHook)
{
printf("Appuyez sur ENTREE pour terminer.\n");
getchar();
UnhookWindowsHookEx(hHook);
}
FreeLibrary(hmodHook);
return 0;
}

III-B-4 - Les AppInit_DLL

Lors de son chargement, user32.dll charge également dans le processus des DLL additionnelles appelées AppInit_DLL. Ce sont des DLL tout à fait normales à part qu'elles sont inscrites dans la valeur AppInit_DLL de la clé :

HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Windows

Si on veut inscrire plusieurs DLL, il faut les séparer par un point-virgule.

Les AppInit_DLL sont le plus souvent utilisées pour créer des classes de fenêtre globales. En effet, en enregistrant une classe pendant l'initialisation d'une AppInit_DLL, on est sûr de pouvoir utiliser cette classe depuis n'importe quelle application graphique (car toutes les applications graphiques se lient avec au moins user32.dll, en plus de kernel32.dll qui est obligatoire pour toutes les applications bien sûr). Il y a cependant une petite différence entre les classes globales et les classes locales, c'est tout simplement que les premières doivent posséder le style CS_GLOBALCLASS.

Depuis Windows 7, les valeurs suivantes ont également été ajoutées dans la même clé :

- LoadAppInit_DLL (DWORD) qui indique à user32.dll si les AppInit_DLL doivent être chargées ou non (1 pour oui et 0 pour non) ;

- RequireSignedAppInit_DLL (DWORD) qui indique si seules les DLL numériquement signées peuvent être chargées en tant qu'AppInit_DLL ou si toutes les DLL peuvent l'être.

Cela évidemment pour des raisons de sécurité. Les valeurs par défaut pour ces entrées sont respectivement 1 et 0 pour des raisons de compatibilité avec les versions antérieures.

Pour ajouter une signature numérique à vos fichiers, utilisez les outils fournis par Microsoft.

III-B-5 - Les serveurs COM

Les langages à objets ou orientés objets tels que VB, C++, Java, etc. permettent généralement de développer plus rapidement des applications que les langages procéduraux tels que le Pascal ou le C. Cela est notamment dû à leur support de ce qu'on appelle classes (qui sont des versions plus perfectionnées de ce qu'on appelle structures en C) qui facilitent énormément la réutilisabilité des codes sources. Malheureusement, la compilation des codes écrits dans ces langages nécessite des techniques relativement complexes et à priori spécifiques au langage. Cela signifie que si vous créez une DLL de classes C++, il vous sera pratiquement impossible de l'utiliser dans un langage autre que le C++ lui-même. Microsoft a donc développé une spécification appelée COM (Component Object Model) permettant à une bibliothèque écrite dans un langage donné d'être utilisable depuis d'autres langages pour peu que la DLL soit bien conforme à la spécification COM et que le langage avec lequel on veut l'utiliser supporte cette spécification. COM permet donc la réutilisabilité du code jusqu'au niveau binaire. Un EXE ou une DLL hébergeant des composants COM est appelé un serveur COM.

Pratiquement tous les langages disponibles sous Windows (C, C++, VB, Delphi, Java, C#, etc.) supportent COM. COM en soi est cependant indépendant de tout langage de tout système d'exploitation bien qu'il est rare d'en entendre parler en dehors de Windows. Chaque fois que Microsoft doit sortir une API orientée objet (certaines API seraient trop difficiles à utiliser si elles étaient procédurales), l'API sera le plus souvent basé sur COM.

Voici la section sur MSDN pour en savoir plus sur COM : Component Object Model (COM).

III-B-6 - Les assemblies

Un assembly est un ensemble de composants (généralement des DLL) constituant une version donnée d'une bibliothèque. Cela permet d'avoir multiples versions d'une même bibliothèque sur un même système. Avec de « simples » bibliothèques, on ne peut avoir qu'une seule version par système ce qui réduit la capacité du système à héberger différentes applications.

Les applications utilisent un fichier MANIFEST pour indiquer les versions des différentes bibliothèques avec lesquelles elles désirent se lier. Les assemblies aussi utilisent des fichiers MANIFEST pour se décrire. Nous avons déjà eu l'occasion de les utiliser dans les tutoriels antérieurs. Lorsque les bibliothèques requises par une application sont toutes décrites par un MANIFEST, alors l'application n'a aucun risque d'utiliser une mauvaise version d'une de ces bibliothèques. On dit alors qu'elle est isolée. La technologie qui permet cette gestion élégante et ultra sécurisée des bibliothèques sous Windows est appelée la technologie Side-by-side (SxS), introduite pour la première fois avec Windows XP. Un side-by-side assembly est un assembly qui ne peut être utilisée qu'avec la technologie side-by-side, c'est-à-dire à l'aide d'un fichier MANIFEST. Les versions de la CRT depuis Visual Studio .NET en sont des exemples.

Voici la section sur MSDN pour en savoir plus sur la technologie SxS : Isolated Applications and Side-by-side Assemblies.

IV - Conclusion

La familiarité avec les DLL est une chose très importante pour bien programmer Windows. Ce tutoriel vous a permis de savoir construire et utiliser proprement des DLL et de se rendre compte de leur importance au sein du système d'exploitation Windows.

V - Remerciements

Merci à Vincent Rogier, à shawn12 et à ram-0000 pour leur relecture, leurs conseils et leur soutien.