DLLs in Kernel Mode
July 15, 2003
Tim Roberts

Copyright 2003, Tim Roberts. All rights reserved

Win32 user-mode programmers are accustomed to using and creating dynamic link libraries, or DLLs, to compartmentalize their applications and enable efficient code reuse. The typical application includes many DLLs, and careful design allows those DLLs to be reused many times over.

Kernel driver writers are often not aware that they can use exactly the same concept in kernel mode. The standard DDK even includes several samples (for example, storage/changers/class). In this article, I will show you a working (though trivial) example of a kernel DLL.

The Basics

In terms of the C source code, a kernel DLL is virtually identical to a user-mode DLL. The primary difference is that you may not call any user-mode APIs from a kernel DLL. That should not be surprising.

You use a kernel DLL just like a user-mode DLL: the linker builds an import library when it builds your DLL, and you include that library in the target library list for any driver that needs to use the DLL. No registry magic is required, and no special action is needed to start or stop the DLL. Your kernel DLL will be automatically loaded as soon as any other driver makes a reference to it, and is automatically unloaded when the last referring driver unloads.1

You can export DLL entry points from a normal WDM driver as well. There are many drivers in the operating system that export entry points for other drivers to use. For example, the ubiquitous NTOSKRNL.EXE, which contains all of the Ex, Fs, Io, Ke, Mm, Nt, and Zw entry points used by virtually every driver, is nothing more than a standard kernel driver with exports, exactly like the DLL we will describe here.

Digging In

OK, now let's get in to a few of the details. All of the source files for this project are available at http://www.wd-3.com/downloads/kdll.zip.

The most important step to take when creating an export driver is to specify the TARGETTYPE macro in the "sources" file:

TARGETTYPE=EXPORT_DRIVER

This type tells the build system that our project will build a kernel-mode driver that is exporting functions. If you leave TARGETTYPE set to DRIVER, as with a normal kernel-mode driver, your exports will not be available to other drivers.

Your DLL must include the standard DriverEntry entry point, but the system won't actually call that entry point. This requirement is an artifact of the build system, which adds /ENTRY:DriverEntry to the linker options for every kernel driver. An EXPORT_DRIVER can also function as a normal driver, and the build system cannot tell whether we want to do that or not, so we have to supply this dummy entry point for an export-only DLL.

If you do need to take special one-time action on loading and unloading, you should export two special enty points called DllInitialize and DllUnload:

NTSTATUS DllInitialize(IN PUNICODE_STRING RegistryPath)
  {
  DbgPrint("SAMPLE: DllInitialize(%wZ)\n", RegistryPath);
  return STATUS_SUCCESS;
  }

NTSTATUS DllUnload()
  {
  DbgPrint("SAMPLE: DllUnload\n");
  return STATUS_SUCCESS;
  }

The RegistryPath string passed to DllInitialize is of the form:

\Registry\Machine\System\CurrentControlSet\Services\SAMPLE

You would only include a DllInitialize routine in an export-only DLL -- that is, in a driver that's used exclusively as a DLL and not as a real driver for hardware. You do not need to define a service key for such a driver. Consequently, the RegistryPath string is not probably not going to be useful to you, because it likely names a registry key that doesn't even exist.

Compatibility caution: A bug in Windows 98 Gold will prevent your DLL from loading if you include a DllInitialize entry point. Further, Windows 98 Second Edition and Windows Millennium will never call the DllUnload entry point. A kernel DLL in these systems, once loaded, is permanent.

Declaring Exports

Beyond those two special entry points, you can create whatever entry point names you find convenient. You just need to identify those entry point names to the linker. There are two ways to do that. For our sample purposes, I will be exporting one functional entry point from our DLL:

NTSTATUS SampleDouble(int* pValue)
  {
  DbgPrint("SampleDouble: %d\n", *pValue);
  *pValue *= 2;
  return STATUS_SUCCESS;
  }

There are two ways to tell the linker that you want to export a function. The first method is to enumerate the names in a .DEF file. The .DEF file is familiar to anyone who has done Win16 or Win32 programming. It is a special file used to give instructions to the linker that cannot be easily included on the command line. In this case, it enumerates the names of the routines we want to export from the DLL. The linker uses this list to create the symbol tables in the DLL, and to create an import library that we can use in other projects to call into our DLL. Our .DEF file looks like this:

NAME SAMPLE.SYS

EXPORTS
  DllInitialize PRIVATE
  DllUnload PRIVATE
  SampleDouble

DllInitialize and DllUnload must both be marked as PRIVATE. This tells the linker to export the symbol from the DLL executable file, but not to include it in the import library it builds. The build system will flag an error if these are not marked PRIVATE.

The import library is the fundamental mechanism used to map a function's name to the DLL that contains that function. Almost all of the libraries you use in Win32 program are import libraries, including kernel libraries such as ntdll.lib and ntoskrnl.lib, and user-mode libraries such as kernel32.lib, user32.lib, and gdi32.lib. Such libraries do not actually contain any code. Instead, they contain a set of linker tables that contains information that means something like, "The name MySampleFunction maps to _MySampleFunction@4 in MY.DLL".

The linker embeds this information into the executable file, so that the operating system can tie all of the loose ends together when the EXE or DLL is finally loaded into memory.

We have to use the special DLLDEF macro in the "sources" file to identify the name of our .DEF file:

DLLDEF=sample.def

The second way to identify your exported entry points is to use a declspec attribute in your source code:

__declspec(dllexport) NTSTATUS SampleDouble(int* pValue)
  {
  ...
  }

This has the same effect as listing the name in the .DEF file. In general, I am in favor of reducing the number of files in my project, since that automatically reduces the number of chances for error. However, in this case, there is a catch: DllInitialize and DllUnload must be marked as PRIVATE exports, and to my knowledge, there is no way to mark an export PRIVATE without using a .DEF file. Thus, you will HAVE to use the .DEF file at least for those two names. Whether you include your other exports in the .DEF file or mark them with __declspec(dllexport) is completely up to you.

The sample source code for this article is in C. If you wish to export functions from a DLL written in C++, you have an additional complication to consider. Because C++ allows multiple functions with the same name but different argument lists, C++ compilers "decorate" their symbol names with extra characters that specifically identify the return type and argument list. For example, the actual name of the SampleDouble function when compiled in a C++ module is ?SampleDouble@@YGJPAH@Z. If you try to call this function from another C++ driver, it would work, but if you try to call it from a C driver, the external names won't line up.

The way to fix this is to use a special language modifier on the extern declaration, like this:

extern "C" NTSTATUS SampleDouble(int* pValue)
  {
  ...
  }

With that information, we can now bring up a DDK command shell and do a build. For this example, we build a file called sample.sys. We copy this file to the traditional location for drivers, %WINDIR%\SYSTEM32\DRIVERS, and we are ready to use our DLL. We can verify the exports using the "dumpbin" command, just like you would with a user-mode DLL:

C:\Dev\KernDLL>dumpbin /exports objfre_w2k_x86\i386\sample.sys

Microsoft (R) COFF/PE Dumper Version 7.00.9210
Copyright (C) Microsoft Corporation.  All rights reserved.

Dump of file objfre_w2k_x86\i386\sample.sys

File Type: EXECUTABLE IMAGE

  Section contains the following exports for SAMPLE.SYS

  00000000 characteristics
  3EEEB656 time date stamp Mon Jun 16 23:33:58 2003
  0.00 version
    1 ordinal base
    3 number of functions
    3 number of names
        
  ordinal hint RVA      name
        
    1    0 0000031B DllInitialize
    2    1 00000347 DllUnload
    3    2 00000368 SampleDouble
    ...

Alternatively, you can use the Dependency Walker applet (DEPENDS.EXE) from the Platform SDK to view the file:

Notice that the Subsystem displayed for all the modules in the bottom pane is "Native". If you ever see a "Win32" subsystem when you're looking at the dependencies for a driver or kernel DLL you've created, it means that you've called a user-mode API function.

Calling Into the DLL

To make it convenient to call the entry points in our DLL, we probably want to create a header file to include in our calling driver. For this sample, we can use this simple sample.h:

#pragma once
EXTERN_C DECLSPEC_IMPORT NTSTATUS SampleDouble(int* pValue);

We use several custom macros here to make the file flexible and easier to read. These macros are defined in <ntdef.h>, which is included automatically in most drivers via <wdm.h>

EXTERN_C expands to extern "C" in a C++ source file, and to plain old extern in a C source file. This ensures that no unwanted decoration will occur in the calling program.

DECLSPEC_IMPORT expands to the Visual C++ specifier __declspec(dllimport). This is the complement of the __declspec(dllexport) macro we used above, and tells the compiler that the call will be satisfied from a DLL at run-time, rather than being loaded at link-time. This allows the compiler and the linker to optimize the runtime linkage to SampleDouble.2

When you define a header file like this that contains function prototypes for a DLL, it's a good idea to include that header in the DLL project too. Doing that gives you a compile-time check that you've correctly prototyped the functions. All those DECLSPEC_IMPORT directives will cause warning messages from the compiler, however. You can cure that minor problem by putting some conditional compilation into the header:

#pragma once
#ifdef SAMPLE_INTERNAL
  #define SAMPLE_IMPORT
#else
  #define SAMPLE_IMPORT DECLSPEC_IMPORT
#endif

EXTERN_C SAMPLE_IMPORT SampleDouble(int* pValue);

You define the symbol SAMPLE_INTERNAL in your DLL project, which causes the header to not have any __declspec directives. Other projects that include the header don't define this symbol, which means that the header does contain the directives.

Testing

To test our sample, I added the following code to the DriverEntry of a kernel driver I have been working on:

#include "sample.h"

NTSTATUS DriverEntry(...)
  {
  PDEVICE_OBJECT deviceObject = NULL;
  NTSTATUS       ntStatus;
  WCHAR          deviceNameBuffer[] = L"\\Device\\dbgdrvr";
  UNICODE_STRING deviceNameUnicodeString;
  WCHAR          deviceLinkBuffer[] = L"\\DosDevices\\DBGDRVR";
  UNICODE_STRING deviceLinkUnicodeString;
  int xxx = 19;
 
  KdPrint(("HELPER.SYS: entering DriverEntry\n"));
  KdPrint (("Helper: before is %d\n", xxx));
  SampleDouble(&xxx);
  KdPrint(("Helper:  after is %d\n", xxx));
  ...
  }

I copied "sample.lib" from my sample build directory into the test build directory, and added "sample.lib" to the TARGETLIBS macro in "sources". In fact, because my test driver is so simple, the entire sources file is here:

TARGETNAME=dbgdrvr
TARGETPATH=obj
TARGETTYPE=DRIVER

TARGETLIBS=sample.lib

SOURCES=dbgdrvr.c

I then built my driver and copied the binary to SYSTEM32\DRIVERS. This is an old-style NT 4 driver, so I started it up with "net start" and stopped it with "net stop". The resulting debug log looked like this:

SAMPLE: DllInitialize(\REGISTRY\MACHINE\SYSTEM\CURRENTCONTROLSET\SERVICES\SAMPLE)
HELPER.SYS: entering DriverEntry
Helper: before is 19
SampleDouble: 19
Helper:  after is 38
HELPER.SYS: unloading
SAMPLE: DllUnload

Note that our DLL loads before the calling driver starts to execute, and unloads after the calling driver shuts down. This, again, is similar to the way a Win32 user-mode DLL operates: the system does not know whether we intend to call the DLL within our DriverEntry or not, so it ensures that all DLLs are in place an initialized before launching the referring driver.

You can see the registry path being passed to the DllInitialize entry point of my kernel DLL. You'll have to trust me when I tell you there is no such path in my registry; the string is just for decoration.

Conclusion

This is a lot of work just to double an integer, but it demonstrates a powerful and little-known concept. With a little forethought, you can build a centralized repository for all of your interesting overhead routines, hiding the sometimes daunting complexity of the kernel APIs in a simple wrapper that you can use over and over again.

About the author:

Tim Roberts is a hopeless software engineer who programs both for fun and for profit. Tim has been programming computers for more than a third of a century, on everything from microcontrollers to mainframes.

Tim is a partner in Providenza & Boekelheide, Inc., a technology consulting company in the Silicon Forest just outside of Portland, Oregon. P&B provides all kinds of hardware and software consulting, specializing in graphics, video, and multimedia.


1 -- Kernel DLLS are never unloaded in Windows 98 Second Edition or in Windows Millennium, though. I'll say more about platform compatibility later on in the article.

2 -- If you give the compiler the clue that a given function will be imported from another DLL, it generates an indirect call through the module's indirect address table. If you don't give the compiler this clue, it generates a call to an external function. The linker then includes a function thunk (taken from the import library) that contains an indirect call through the indirect address table. Thus, using __declspec(dllimport) eliminates the thunk in the middle and saves a few machine cycles at run time.