A deep dive into Windows kernel programming guidelines, contrasting it with user-mode development. Explore critical topics like unhandled exceptions (BSOD), resource management, IRQL, C++ limitations, and the Kernel API. Essential reading for driver developers.

In our last blog, we learned how to set up a driver development environment and create a simple tracing driver. Now, we will take a deep dive into the fundamentals of kernel programming.
programming kernel drivers require using windows driver kit WDK. which will provides headers and libraries. kernel APIs, C functions, and more. it's the same as the user-mode APIs but there is some differences:
In user-mode programming, developers sometimes ignore function return values, operating on the assumption that the call will succeed. If an API call fails, the consequence is typically limited: the single process will crash, but the operating system remains stable.
but, in kernel-mode programming, this practice is dangerous. When a kernel API fails, the resulting error is not contained within a single process; it can lead to an unhandled exception that crash the entire operating system, causing a system wide crash (a "Blue Screen of Death" or kernel panic). , the golden rule for kernel development is always check the return status values from every kernel API call.
The Interrupt Request Level (IRQL) is a fundamental concept in the Windows kernel, acting as the processor's internal priority system. It dictates which code can execute at any given moment, ensuring that time-critical operations like handling hardware interrupts are not delayed by less urgent tasks.
think of IRQL as a set of priority lanes on a highway. the higher IRQL the mode critical the task and the fewer interruptions it can tolerate.
when writing C++ code in kernel mode (like Windows drivers), many high-level C++ features are restricted or entirely not allowed because of performance, safety, and memory constraints.
Init function or dynamically allocating instances with overloaded new and delete, we will take example later.try, catch, and throw don’t compile, use Structured Exception Handling (SEH) instead, covered in Chapter 6.std::vector or std::wstring for kernel use.Kernel drivers use exported functions from kernel components. These functions will be referred to as the Kernel API. Most functions are implemented within the kernel module itself NtOskrnl.exe, but some may be implemented by other kernel modules, such the HAL hal.dll.
In Windows kernel programming, Zw* functions are the kernel-mode equivalents of the Nt* functions that exist in user mode.User-mode processes call functions like NtCreateFile, NtOpenProcess, etc., which reside in NtDll.dll These functions perform a system call to enter kernel mode and execute the real implementation inside the Windows Executive (the core of the kernel).In kernel mode, you can call these same system services directly via Zw* functions (e.g. ZwCreateFile, ZwOpenProcess, etc.).
When thread makes a system call from user mode the kerenl need to know who called it, user or kernel. Windows stores this in hidden field in each thread's kernel structure KTHREAD called PreviousMode. this effects how the kernel behaves when executing system calls particularly security checks and pointer validation.
let's say both function call into the same kernel implementation internally.
NtCreateFile, the kernel knows from PreviousMode that this came from user space. It validates all input pointers and performs access checks to avoid trusting unverified user memory.ZwCreateFile, the kernel sees PreviousMode = KernelMode. it skips certain user-mode safety checks, assuming kernel code knows what it’s doing. This makes it faster but also dangerous if misused (passing a user pointer could crash the system).Most kernel API functions return a status indicating success or failure of an operation. This is typed as NTSTATUS a signed 32-bit integer. The value STATUS_SUCCESS (0) indicates success. A negative value indicates some kind of error. You can find all the defined NTSTATUS values in the file <ntstatus.h>.
The macro NT_SUCCESS(status) is used to check whether the returned status indicates success (including informational codes) rather than an error, NT_SUCCESS is defined roughly as:
1#define NT_SUCCESS(Status) (((NTSTATUS)(Status)) >= 0)usage code:
1NTSTATUS status = CallSomeKernelFunction();
2if (!NT_SUCCESS(status)) {
3 KdPrint((L"Error occurred: 0x%08X\n", status));
4 return status;
5}
6// continue …
7return STATUS_SUCCESS;NTSTATUS value indicates success (non-negative).NTSTATUS must often be mapped to a Win32 error code (used by GetLastError).NTSTATUS codes can map to one Win32 error, and some have no direct equivalents.NTSTATUS values; no mapping is needed.NTSTATUS status code.In kernel mode, there are several ways to represent strings. A string might be a simple Unicode pointer such as wchar_t* or one of its typedefs like WCHAR*. These are standard null-terminated Unicode strings similar to those used in user mode. but, most kernel functions that deal with strings expect them to be represented using the UNICODE_STRING structure It stores a wide-character (UTF-16) string (each character is 2 bytes).
1typedef struct _UNICODE_STRING {
2 USHORT Length;
3 USHORT MaximumLength;
4 PWCH Buffer;
5} UNICODE_STRING;
6typedef UNICODE_STRING *PUNICODE_STRING;
7typedef const UNICODE_STRING *PCUNICODE_STRING;The Length member is in bytes (not characters) and does not include a Unicode NULL terminator, if one exists (a NULL terminator is not mandatory). The MaximumLength member is the number of bytes the string can grow to without requiring a memory reallocation.
Manipulating UNICODE_STRING structures is typically done with a set of Rtl functions that deal with strings
as we discussed in first blog, kernel thread stack size is small, so any large chunk of memory should allocated dynamically (heap). the kernel provides three general memory pools for drivers to use.
Page faults cannot be handled at a high IRQL, as we discussed. Therefore, using non-paged memory ensures that a driver can safely access its data at any time. Not only is this necessary to avoid page-fault-related crashes, but it is also faster since the data is always in RAM.
so the rule is only allocate non-paged memory when required (e.g, for code or data accessed at high IRQL)
When a driver needs to allocate memory dynamically, it calls a function like ExAllocatePoolWithTag, and one of the most important parameters it provides is a value from the POOL_TYPE enumeration.
This POOL_TYPE enum tells the memory manager exactly what kind of memory to allocate.
1typedef enum _POOL_TYPE {
2 NonPagedPool,
3 NonPagedPoolExecute = NonPagedPool,
4 PagedPool,
5 NonPagedPoolMustSucceed = NonPagedPool + 2,
6 DontUseThisType,
7 NonPagedPoolCacheAligned = NonPagedPool + 4,
8 PagedPoolCacheAligned,
9 NonPagedPoolCacheAlignedMustS = NonPagedPool + 6,
10 MaxPoolType,
11 NonPagedPoolBase = 0,
12 NonPagedPoolBaseMustSucceed = NonPagedPoolBase + 2,
13 NonPagedPoolBaseCacheAligned = NonPagedPoolBase + 4,
14 NonPagedPoolBaseCacheAlignedMustS = NonPagedPoolBase + 6,
15 NonPagedPoolSession = 32,
16 PagedPoolSession = NonPagedPoolSession + 1,
17 NonPagedPoolMustSucceedSession = PagedPoolSession + 1,
18 DontUseThisTypeSession = NonPagedPoolMustSucceedSession + 1,
19 NonPagedPoolCacheAlignedSession = DontUseThisTypeSession + 1,
20 PagedPoolCacheAlignedSession = NonPagedPoolCacheAlignedSession + 1,
21 NonPagedPoolCacheAlignedMustSSession = PagedPoolCacheAlignedSession + 1,
22 NonPagedPoolNx = 512,
23 NonPagedPoolNxCacheAligned = NonPagedPoolNx + 4,
24 NonPagedPoolSessionNx = NonPagedPoolNx + 32,
25
26} POOL_TYPE;as we can see we have to many types but drivers should use only these three type:
when allocated memory in kernel mode, using function like ExAllocatePoolWithTag (replaced by ExAllocatePool2)you provide a 4 byte tag, this tag used to identify the purpose or origin of the memory allocation.
1DECLSPEC_RESTRICT PVOID ExAllocatePool2(
2 POOL_FLAGS Flags,
3 SIZE_T NumberOfBytes,
4 ULONG Tag
5);it help with debugging and tracking memory leaks. for example if your driver unloads but some allocations are not freed, tools like PoolMon can show that. this is example:
1PVOID buffer = ExAllocatePoolWithTag(NonPagedPoolNx, 1024, 'bufT');
2// 'bufT' is the tag — it helps identify this allocation later
3
4ExFreePoolWithTag(buffer, 'bufT');we will cover more memory management function later of the future blogs.
Kernel users circular doubly linked lists in many of it's internal data. for example all processes on the system are managed by EPROCESS structures, connected in circular doubly linked list. where it's head stored in kernel variable PsActiveProcessHead.
the first pointer store the next entry address. the second Blink it store the previous entry. it allow traversal in both directions and looping back to the head.
1typedef struct _LIST_ENTRY {
2 struct _LIST_ENTRY *Flink;
3 struct _LIST_ENTRY *Blink;
4} LIST_ENTRY, *PLIST_ENTRY;When you traverse a kernel linked list, you are moving between LIST_ENTRY members, not the larger structures that contain them. This leaves you with a pointer to a field inside an object, not a pointer to the object itself. for example
1// This is our user-defined structure that will be part of the list.
2typedef struct _DRIVER_DATA {
3 int DriverId;
4 const char* DriverName;
5 // The LIST_ENTRY field MUST be part of the structure.
6 // It's the "link" in the chain.
7 LIST_ENTRY Link;
8} DRIVER_DATA, *PDRIVER_DATA;
9In this case, we have a linked list of DRIVER_DATA objects. Each object is connected to the next via its Link field, which is a LIST_ENTRY structure. The entire list is managed by a separate 'head' gloable variable (like g_DriverListHead in the example ), not a field within the structure itself. When we traverse the list, we only get pointers to the LIST_ENTRY fields, not the full DRIVER_DATA object. Therefore, we must use CONTAINING_RECORD to get back to the full structure.
This tells the compiler to compute the actual object’s address and cast it to the correct type.
1#define CONTAINING_RECORD(address, type, field) \
2 ((type *)((char*)(address) - (size_t)(&((type *)0)->field)))
3To manage these lists, the kernel provides helper routines that work in constant time. InitializeListHead sets up an empty list where the head points to itself. InsertHeadList and InsertTailList add elements to the start or end. here is all the functions
As we recall, one of the parameters to the DriverEntry function is a pointer to a DRIVER_OBJECT. This object is important, as the driver uses it to configure its properties and to inform the I/O Manager about which operations it supports by populating its function pointers
For example, we previously used this object to set the DriverUnload routine. This registers a cleanup function that the I/O Manager will execute just before the driver is unloaded, as we have discussed."
By default, the kernel fills a driver's I/O dispatch table (MajorFunction array) with a function that automatically fails any request. This is a security measure, ensuring a driver only responds to operations it explicitly supports.
In DriverEntry, the developer's job is to replace the default failure function with their own routines for the I/O requests they want to handle. All other entries can be left alone.
At a bare minimum, any functional driver must handle IRP_MJ_CREATE and IRP_MJ_CLOSE. Without these, no application could even open or close a handle to the driver's device, making communication impossible.
n the Windows kernel, you don't just "open a file" or "create a key." Instead, you interact with generic Objects managed by the Object Manager To create or open any of these objects (like files, registry keys, events, threads, etc.), you must first describe what you want and how you want to open it.
The OBJECT_ATTRIBUTES structure is that description. It's a standardized parameter used by many kernel functions (ZwCreateFile, ZwOpenKey, etc.) to specify the properties of the object you are about to create or open. here is structure defined as:
1typedef struct _OBJECT_ATTRIBUTES {
2 ULONG Length;
3 HANDLE RootDirectory;
4 PUNICODE_STRING ObjectName;
5 ULONG Attributes;
6 PVOID SecurityDescriptor;
7 PVOID SecurityQualityOfService;
8} OBJECT_ATTRIBUTES;
9typedef OBJECT_ATTRIBUTES *POBJECT_ATTRIBUTES;
10typedef CONST OBJECT_ATTRIBUTES *PCOBJECT_ATTRIBUTES;A driver's code is loaded into the kernel, but by itself, it's just a block of code with no way for the outside world to talk to it. A Device Object is the official "front door" or "service window" that a driver creates so that applications can find it and send it requests.
Analogy:
Without a Device Object, a driver is like a business with no storefront it exists, but no one can use its services.
An application can't just walk into the kernel space. There's a security barrier. The connection is made through a clever, three-step process:
\Device\MyDriver.MyDriverLink and it simply points to the internal \Device\MyDriver name. Common examples you see every day are C:, which is a symbolic link to a hard disk device object.CreateFile function to open a connection. This function isn't just for files on disk; it's the universal Windows API for connecting to named kernel objects.When an application calls CreateFile(L"\\\\.\\MyDriverLink", ...):
MyDriverLink.\device\MyDRiver object.IRRMJCREATE request to the driver.CreateFile returns HANDLE. this handle is the application's ticket for all future communication.What if a driver creates a Device Object but doesn't create a public symbolic link? This is rare, but some system devices, like \Device\Beep, do this.
In this case, a standard user-mode application using CreateFile can't find it. To connect, you must use a lower-level, "native" API called NtOpenFile. This function allows you to open a device using its direct, internal kernel name.
Next blog we'll use many of the concepts we learned in the previous blogs and build a simple driver.
Published 27 days ago