Skip to content
chrncl
Writing04 / 09
Kernel Dev-about 18 min read

Writing Your First Kernel Driver

Ring-0 is a different world. IRPs, dispatch routines, IRQL constraints, and why forgetting to call IoCompleteRequest is a thread-hanging bug not a crash.

Why Bother with Ring-0

Most developers will never need to write a kernel driver. The question is whether that's because their work doesn't require it or because they've never needed to do anything that requires it. For reverse engineering, security research, and performance monitoring, the kernel is where the interesting work happens.

The kernel has capabilities user-mode simply doesn't: you can intercept system calls, hook kernel structures, map physical memory directly, set hardware breakpoints via debug registers, and observe things that user-mode code is specifically designed not to be able to observe. The cost is that mistakes don't give you an exception dialog. They give you a bugcheck with a hex stop code and a minidump, and you have to figure out from that what you did wrong. The feedback loop is slow and brutal. That's also what makes it an effective way to learn.

DriverEntry: Your Driver's main()

DriverEntry is the entry point the OS calls when your driver loads. It receives a DRIVER_OBJECT pointer and a registry path. Your job: populate the DRIVER_OBJECT's dispatch table, create your device object, and create a symbolic link so user-mode code can open it.

c
NTSTATUS DriverEntry(
    DRIVER_OBJECT*  DriverObject,
    UNICODE_STRING* RegistryPath)
{
    DriverObject->DriverUnload = DriverUnload;

    DriverObject->MajorFunction[IRP_MJ_CREATE]         = DispatchCreateClose;
    DriverObject->MajorFunction[IRP_MJ_CLOSE]          = DispatchCreateClose;
    DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchIoControl;

    UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\MyDriver");
    PDEVICE_OBJECT devObj;
    NTSTATUS status = IoCreateDevice(DriverObject, 0,
        &devName, FILE_DEVICE_UNKNOWN,
        FILE_DEVICE_SECURE_OPEN, FALSE, &devObj);
    if (!NT_SUCCESS(status)) return status;

    UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\DosDevices\\MyDriver");
    status = IoCreateSymbolicLink(&symLink, &devName);
    if (!NT_SUCCESS(status)) {
        IoDeleteDevice(devObj);
        return status;
    }
    return STATUS_SUCCESS;
}

The device name lives in the kernel's object namespace under \Device. The symbolic link maps it into the Win32 namespace where it becomes \.\MyDriver from user mode. Without the symbolic link, user-mode code can't open the device with CreateFile. Always set a DriverUnload routine. Without one the driver can't be unloaded without rebooting, which gets annoying fast during development.

I/O Request Packets

IRPs are how the Windows I/O manager communicates with drivers. Every operation (open, read, write, IOCTL) arrives as an IRP. The IRP contains a stack of IO_STACK_LOCATION structures, one per driver in the device stack. Your driver processes its stack location and either completes the IRP or passes it down the stack.

c
NTSTATUS DispatchCreateClose(
    DEVICE_OBJECT* DeviceObject, IRP* Irp)
{
    Irp->IoStatus.Status      = STATUS_SUCCESS;
    Irp->IoStatus.Information = 0;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return STATUS_SUCCESS;
}

The critical rule: every IRP your dispatch function receives must be completed exactly once via IoCompleteRequest. Not zero times (the calling thread hangs forever). Not twice (immediate BSOD, double complete is a verifier violation). Every code path, including error paths and early returns, must call it. This was my most common early mistake and the hardest to debug because the symptom (a thread hanging waiting on a file handle) can appear far from the driver code that caused it.

Designing an IOCTL Interface

IOCTL codes are how user-mode sends typed commands to a driver. They're 32-bit values encoding device type, function code, transfer method, and required access. The WDK provides CTL_CODE to construct them and you define them in a shared header between the driver and user-mode controller.

Always validate input buffer lengths before accessing the buffer. The I/O manager verifies METHOD_BUFFERED transfers are readable, but it doesn't check that the size matches what your code expects. Reading past the end of a user-supplied buffer in the kernel is a privilege escalation vector. Check the length, return STATUS_BUFFER_TOO_SMALL if it fails, and move on.

IRQL: The Constraint Everything Else Follows From

IRQL (Interrupt Request Level) is the most important concept in Windows kernel development. It's a hardware-enforced priority level for the current thread. At PASSIVE_LEVEL (0) you can do almost anything: allocate paged memory, call blocking functions, access paged-out memory. At DISPATCH_LEVEL (2) you can't: the scheduler runs at DISPATCH_LEVEL, so you can't reschedule or block. At DIRQL you're handling a hardware interrupt and the constraints are even tighter.

The practical consequence is that many kernel functions are documented with an IRQL requirement. ExAllocatePoolWithTag with PagedPool requires IRQL below DISPATCH_LEVEL. If you call it at DISPATCH_LEVEL the system may bugcheck immediately or corrupt memory silently. Spinlocks raise IRQL to DISPATCH_LEVEL when acquired, which is why you can't call any pageable function while holding one. Violating IRQL constraints is the most common cause of intermittent kernel crashes that only reproduce under load.

Debugging a Kernel Driver

Kernel debugging requires either kernel mode WinDbg attached over a serial or network connection to a test machine, or a VM with kernel debugging enabled. The most useful commands: !process 0 0 lists all processes, !thread shows the current thread, dt nt!_EPROCESS dumps the EPROCESS structure layout, !irp <address> shows IRP details. When you get a bugcheck, !analyze -v is the first command to run.

The Windows Driver Kit includes the driver verifier, which enables additional checks: pool corruption detection, IRQL violation checking, deadlock detection. Running with verifier enabled during development catches entire classes of bugs before they become intermittent production crashes. I run it on every driver I write from the first test. The slower performance under verifier is worth the caught bugs.