WINDOWS KERNEL / C++ RUNTIME · 2026

When We Were Young and Stupid: Bringing C++ Exceptions into the Windows Kernel

We wanted classes, constructors, RTTI, and throw/catch everywhere. To get them, we rebuilt a large part of the C++ runtime inside a driver. It worked. That was the problem.

In the early 2000s, we believed kernel code should feel like an ordinary C++ application. We wanted objects instead of handles, constructors instead of init functions, destructors instead of cleanup labels, and exceptions instead of status codes. The compiler accepted the syntax. The kernel did not provide the runtime machinery behind it. So we built the machinery ourselves.

This is not the story of a small wrapper that made a few classes compile. The archived implementation grew into a private kernel-side C++ runtime covering startup and shutdown, global constructors, new and delete, allocation-failure handlers, exception records, stack checks, frame dispatch, unwinding, construction of catch objects, RTTI, array destruction, terminate handling, and separate x86 and x64 support. It also had compatibility paths for Windows NT/2000, Windows 95 drivers, and native applications.

At the time, it felt like proof that C++ could be made to work anywhere. Looking back, it was proof of something else: if a platform does not provide a language runtime, recreating one inside one of the most failure-sensitive parts of the system is rarely a victory.

The compiler saw one throw statement. We saw an entire private runtime environment that had to exist before that statement could be trusted.

1. The dream: one language from the application to the kernel

The motivation was understandable. We were building storage filters, virtual disks, encryption layers, device abstractions, and cross-version driver code. C++ promised a consistent object model across user mode and kernel mode. A device could be a class. An IRP could be wrapped in an object. Resource ownership and reference counting could be expressed through constructors and destructors. Common code could target several generations of Windows.

The temptation became even stronger as the codebase grew. Once we had accepted classes, the next request followed naturally: global objects should initialize themselves; allocation should use new; type checks should use RTTI; failures should throw; destructors should release resources. Each feature looked local in source code. None of them was local in the binary.

A C++ compiler does not implement these features as isolated instructions. It emits calls into a runtime with a compiler-specific ABI. In user mode, that runtime is standard infrastructure. In a kernel driver, it simply is not there unless someone supplies it.

2. Before exceptions: we first had to invent startup and shutdown

Even a driver that never throws can quietly depend on a C++ runtime. A global object with a constructor requires the loader to discover and call initializer functions. A global destructor requires a termination path. A function-local static may require guard machinery. A pure virtual call needs a failure handler. The ordinary kernel entry point does not perform the work of a C++ process startup routine.

The library therefore created its own initializer ranges using the familiar family of .CRT sections: C initializers, C++ initializers, pre-terminators, and terminators. A small walker iterated over function-pointer tables and invoked each non-null entry. The code merged the CRT section into data on 32-bit targets and into read-only data on 64-bit targets.

DriverEntry explicitly called the private CRT initializer before the driver object was first used. The unload path explicitly called the matching shutdown routine. Similar hooks were wired into Windows 95 critical initialization, dynamic device initialization, and exit paths; native executable and DLL startup; and minifilter unload.

Simplified structure of the runtime lifecycle

extern "C" NTSTATUS DriverEntry(...)
{
    kernel_crt_initialize();      // global C and C++ initializers
    NTSTATUS status = driver_on_load();
    if (!NT_SUCCESS(status))
        driver_unload();
    return status;
}

void driver_unload()
{
    driver_on_unload();
    kernel_crt_shutdown();        // atexit, destructors, terminators
}

The shutdown routine also maintained its own atexit list. Each registration allocated a linked-list node; shutdown popped the nodes in last-in, first-out order, called the registered functions, released the nodes, and then ran the pre-terminator and terminator tables. In other words, unloading a driver now depended on a private, process-like object-lifetime model behaving perfectly inside the kernel.

There were compatibility shims as well. On some targets, _purecall simply returned zero. The stack-check helper _chkesp was reduced to a bare return. These choices were practical enough to make old toolchains link, but they also reveal the real nature of the project: we were not merely using C++. We were impersonating the environment the compiler expected.

Hidden dependency #1: object lifetime Before the first exception could be caught, the runtime already had to guarantee constructor order, destructor order, driver unload integration, atexit behavior, and failure handling for invalid virtual dispatch.

3. new was not new until we implemented it

The compiler also expected the allocation operators to exist. The library supplied operator new, operator new[], operator delete, and operator delete[] for kernel mode, Windows 95, and native applications. On NT kernel targets, operator new allocated from a selectable pool and tagged the allocation. The default pool was pageable, although callers could change the pool type.

Allocation failure was not handled as a simple null return. The implementation supported retry counts, including an infinite-retry mode, and a custom new-handler. The new-handler was associated with the current driver and, on kernel targets, tracked in per-thread context resources. Debug builds counted outstanding allocations so the unload path could report leaks.

The real allocator was more than a call to a pool function

void* operator new(size_t bytes)
{
    void* p = pool_allocate(current_pool, bytes, allocation_tag);

    for (unsigned attempt = 0; !p && retry_allowed(attempt); ++attempt)
        p = pool_allocate(current_pool, bytes, allocation_tag);

    if (!p)
        if (auto handler = current_new_handler())
            handler();

    return p;
}

This meant that ordinary-looking object creation depended on pool selection, current execution context, retry policy, and a handler that could itself run while the system was already under memory pressure. It also meant that exception handling and memory management were entangled: constructing a thrown object, copying it into a catch object, or unwinding an array could allocate or free memory through this private layer.

The lesson is not that custom allocators are bad. Kernel software often needs them. The lesson is that C++ syntax can hide a far larger set of policies than the source line suggests. In system code, hidden policy is expensive.

4. A throw is a protocol, not an instruction

The visible operation in source code is tiny: construct an object, then throw it. The generated code initiates a protocol among compiler metadata, runtime helpers, the operating system’s unwinder, and every stack frame that may need cleanup.

The library supplied a RaiseException-compatible path. It built an exception record, copied the caller-supplied parameters, checked the remaining kernel stack, and, if fewer than three pages of kernel stack remained, attempted to grow the kernel stack before calling RtlRaiseException. The abort path did not return: on an NT kernel target it deliberately triggered a bug check with KMODE_EXCEPTION_NOT_HANDLED.

Simplified exception-raising path

EXCEPTION_RECORD record = {};
record.ExceptionCode = code;
record.ExceptionFlags = flags;
record.ExceptionAddress = raise_exception;
record.NumberParameters = count;
copy_parameters(record.ExceptionInformation, info, count);

if (remaining_kernel_stack() < 3 * PAGE_SIZE)
    try_to_grow_kernel_stack();

RtlRaiseException(&record);

But raising the operating-system exception was only the entry point. C++ still had to locate a matching catch clause, match types, construct the catch object, track the active exception, unwind intervening frames, invoke destructors, handle rethrows, and transfer control to the continuation point selected by the compiler.

The static exception library implemented _CxxThrowException, __CxxFrameHandler, __CxxFrameHandler2, __InternalCxxFrameHandler, frame-unwind routines, catch-block helpers, exception-object registration and destruction, nested-frame unwinding, array-unwind helpers, and the state needed to track the active exception and execution context.

5. What the exception runtime actually had to do

The runtime inventory is the part that most developers never see. The archived libraries show that full C++ exception support required all of the following areas.

Runtime areaRepresentative machineryWhy it existed
Throw and dispatch_CxxThrowException, exception records, active-exception and context trackingTurn a language-level throw into an OS-level dispatch operation.
Frame handlers__CxxFrameHandler, __CxxFrameHandler2, __InternalCxxFrameHandlerInterpret compiler-generated function metadata and locate try/catch regions.
Type matchingThrowInfo, CatchableType, handler matching, construction of catch objectsDecide whether a catch clause accepts the thrown object and build the caught value.
Unwinding__FrameUnwindToState, destructor and vector-destructor helpersDestroy fully constructed objects while moving through partially executed frames.
Exception lifetimeregistration, deregistration, destruction, size queries, rethrow detectionKeep the thrown object alive for exactly as long as required.
RTTItype_info, dynamic_cast, typeid, cast-to-void, bad_cast, bad_typeidSupport compiler-generated run-time type operations used by matching and user code.
Failure policyterminate, unexpected, abort, unhandled-exception filtersDefine what happens when the runtime cannot complete normal exception semantics.
Arraysvector constructors, vector destructors, array unwindClean up only the elements that were successfully constructed.
Validation and translationread/write/execute validation, SE translator hooksBridge structured exceptions and validate runtime metadata accesses.
Diagnostics and namingtype names and undecoration supportRepresent and format compiler-generated type information.

This list matters because it changes the meaning of “we enabled exceptions.” We did not enable one language feature. We accepted responsibility for a compiler ABI, object lifetime, frame metadata, architecture-specific unwind behavior, and all the failure paths created by their interaction.

6. x86: down the FS:[0] rabbit hole

On 32-bit x86, exception registration was represented by a linked chain anchored at FS:[0]. The Windows 95 path in the library implemented its own exception records, context structure, registration nodes, dispositions, dispatcher, handler wrappers, and unwind routine.

RtlRaiseException captured the machine context manually. It saved general-purpose registers, segment registers, flags, the return address as the exception instruction pointer, the pre-call stack pointer, and the caller frame pointer. RtlDispatchException then walked the registration chain until it reached the sentinel value or found a malformed entry.

Executing a handler meant temporarily linking a small registration frame into FS:[0], invoking the callback, and unlinking the frame afterward. Unwinding walked the same chain, marked the exception record as unwinding, handled nested and collided unwinds, removed frames, and eventually restored a captured context.

Conceptual x86 registration-frame manipulation

registration.previous = read_fs0();
registration.handler = compiler_frame_handler;
write_fs0(&registration);

result = registration.handler(exception, frame, context, dispatcher);

write_fs0(registration.previous);

The implementation also shows where ambition met reality. Several invalid-unwind, invalid-disposition, and bad-stack branches logged that another exception should be raised, but the code that would raise it remained commented out. SetUnhandledExceptionFilter returned null. Some compatibility helpers were placeholders. This was not simply careless work; it was the natural outcome of rebuilding undocumented and semi-documented runtime behavior across old kernels and compiler generations. The surface area was simply too large.

Every corner case that the normal C++ runtime had accumulated over the years now belonged to a driver team whose real product was storage and system software, not an exception engine.

7. x64: a different architecture, a different nightmare

The move to x64 did not eliminate the problem; it changed the model. The x64 library relied on table-driven unwind information in .pdata and .xdata sections and exposed helpers for deriving unwind state from an instruction pointer or control PC, locating the establisher frame, tracking image bases, and interpreting the dispatcher context.

That required a second runtime implementation rather than a straightforward port of the x86 chain walker. The 64-bit archive had its own throw object, frame handler, state helpers, handler wrappers, notification stubs, RTTI code, vector construction and destruction, unhandled-exception logic, and validation helpers.

Supporting two architectures therefore doubled far more than the assembly syntax. It doubled the set of assumptions about metadata format, frame state, unwind tables, control transfer, and compiler behavior. A compiler upgrade could change the very contract on which the driver depended.

Hidden dependency #2: the compiler version Exception handling is not just a C++ language promise. It is an ABI contract implemented by a particular compiler and runtime generation. Changing compilers can invalidate metadata layouts, helper expectations, and unwind behavior even when the source still builds.

8. RTTI came along for the ride

Once typed exceptions were supported, run-time type information became difficult to avoid. The libraries implemented type_info, type name handling, dynamic_cast, typeid, cast-to-void, and standard exception classes for bad casts and invalid typeid operations.

This was genuinely useful. A catch handler for a base class could accept a derived exception. User code could ask for dynamic type information. The same runtime could support code written as if it were in a normal application.

It also broadened the binary contract. RTTI requires compiler-generated type descriptors, class hierarchy metadata, object locators, base-class arrays, pointer adjustment rules, and exception objects for failure cases. Multiple and virtual inheritance make dynamic_cast substantially more than comparing vtable pointers. The archive contains separate search paths for single inheritance, multiple inheritance, and virtual inheritance.

Again, the source-level feature looked small. The runtime consequence was an additional subsystem with its own validation, metadata, error handling, and architecture dependencies.

9. Exception safety in a driver is not the same as in an application

In application code, an exception can unwind to a boundary that logs an error, converts it into a user-visible failure, or terminates the process. In kernel code, the same control transfer may cross frames that own spin locks, IRPs, MDLs, remove locks, pool allocations, mapped buffers, device references, completion callbacks, or hardware state.

For exceptions to be safe, every frame in the propagation path must be exception-safe. Every partially constructed object must have a well-defined state. Every destructor must be legal at the current IRQL and in the current execution context. No destructor may unexpectedly block, touch pageable memory at the wrong IRQL, or call an API that is illegal in that context. Cleanup order must still match the driver’s ownership and I/O protocols, not merely follow C++ object nesting.

The runtime itself also consumes stack while handling a failure. The implementation explicitly checked the remaining kernel stack and tried to grow it before raising an exception. That check is a confession embedded in the code: while trying to recover from failure, the runtime risked exhausting one of the kernel’s scarcest resources.

The most dangerous bugs are not the obvious unhandled exceptions. They are the paths that appear to recover but release resources in a sequence that violates a driver contract, leave ownership of an IRP ambiguous, or execute a destructor under conditions its author never considered.

10. What we gained

It would be dishonest to say the experiment had no benefits. It gave the codebase a consistent object model. Constructors and destructors reduced some routine lifecycle boilerplate. Common abstractions could be shared across driver generations. Typed exceptions could carry more context than a bare status code. The library supported Windows NT/2000 drivers, Windows 95 VxDs, native applications, x86, and later x64 components.

The implementation also forced us to understand the compiler and operating system to a depth few developers ever need. We learned how compiler-generated code represented try blocks, unwind states, catchable types, object copies, array construction, nested exceptions, and RTTI. That knowledge remained valuable long after the design itself stopped being attractive.

And yes: the code worked well enough to be used. That success made it more dangerous, not less. A mechanism that fails immediately is easy to reject. A mechanism that survives years of routine testing can become an architectural dependency before its worst corner case appears.

11. What it cost us

The obvious cost was code volume. The more important cost was responsibility. We had made the driver dependent on behavior normally implemented by the compiler vendor’s runtime library.

  • Compiler ABI coupling. Internal helper names, metadata layouts, prologs, frame handlers, and RTTI structures became part of the driver architecture.
  • Architecture duplication. x86 linked registration chains and x64 table-driven unwinding required fundamentally different implementations.
  • Opaque control flow. A throw could execute many destructors and helpers that were invisible at the failure site.
  • Kernel-stack pressure. The recovery mechanism needed enough stack to analyze frames, match types, build catch objects, and unwind.
  • Lifetime complexity. Global constructors, atexit callbacks, and global destructors had to be wired into load and unload correctly on every target.
  • Hidden allocation policy. new silently inherited the current pool type, retry policy, per-thread handler, and memory-pressure behavior.
  • Debugging difficulty. A crash could originate in user code, generated metadata, a runtime helper, a destructor, or the bridge into OS exception dispatch.
  • False confidence. Familiar C++ syntax made kernel code look safer and more ordinary than the execution environment really was.

The implementation was a technical achievement. It was also an expensive way to discover that language convenience is not free simply because the compiler accepts the syntax.

12. The moment of clarity

With enough time spent in low-level systems, a pattern becomes clear: the closer code is to hardware, scheduling, memory ownership, or recovery boundaries, the more valuable explicitness becomes.

C makes error propagation visible. A function returns a status. The caller decides whether ownership changed. Cleanup is centralized and ordered. The binary has fewer hidden runtime obligations. The interfaces naturally match the operating system’s APIs. The code may be more verbose, but the verbosity documents the control flow that must remain correct under pressure.

Boring C makes ownership and cleanup order visible

NTSTATUS process_request(REQUEST* request)
{
    NTSTATUS status;
    RESOURCE_A* a = NULL;
    RESOURCE_B* b = NULL;

    status = acquire_a(&a);
    if (!NT_SUCCESS(status))
        goto cleanup;

    status = acquire_b(a, &b);
    if (!NT_SUCCESS(status))
        goto cleanup;

    status = execute_request(request, a, b);

cleanup:
    if (b) release_b(b);
    if (a) release_a(a);
    return status;
}

The cleanup label is not fashionable, but it does not hide a compiler-directed walk through metadata and destructors. A reviewer can see every resource, every release, and the exact order. A debugger can stop on each branch. A static analyzer can follow the status value. A crash dump does not require reconstructing an extra private runtime before the driver logic can even be examined.

C does not prevent mistakes. Nothing does. But for system-level development, it minimizes the amount of invisible machinery between the code we review and the machine behavior we have to guarantee.

C is boring. In kernel code, boring is a feature.

13. Would we use any C++ in a kernel today?

A restricted C++ subset can be useful in some controlled environments: classes without exceptions, deterministic object lifetimes, no RTTI, no global-lifetime tricks, no implicit allocation, no standard library dependencies, and a build that exposes every generated runtime symbol. But at that point, the main value is namespaces, templates, and compile-time structure—not the application-style runtime we once tried to recreate.

Our default choice today is simpler: write the system-level core in C. Keep interfaces explicit. Return status codes. Make ownership visible. Use small wrappers or generated code only where they reduce repetition without creating hidden control flow. Put richer C++ abstractions above the kernel boundary, where process isolation and a supported runtime exist.

This is not nostalgia for old syntax. It is the result of seeing how much machinery was required to make one elegant language feature work in an environment that never promised to support it.

14. Advice to our younger selves

  1. Do not confuse “the compiler accepts it” with “the platform supports it.”
  2. Before enabling a language feature, list every runtime symbol and metadata format it introduces.
  3. Treat global initialization and destruction as part of kernel architecture, not as conveniences.
  4. Never let a failure mechanism depend on more stack, memory, or hidden control flow than the normal path.
  5. Do not own a compiler ABI unless that ABI is your actual product.
  6. Prefer explicit status propagation and explicit cleanup in the system-level core.
  7. If the code must survive compiler, architecture, and operating-system changes, minimize assumptions about private runtime behavior.

Conclusion: it worked; never repeat it

When we were young and stupid, we wanted everything to work in C++, including kernel code. We made it happen. We built the startup path, shutdown path, allocators, frame handlers, exception dispatcher, unwinder, type system, array cleanup, standard exception objects, and architecture-specific runtime support that the language quietly assumed.

The result was impressive, educational, and far more fragile than the source code suggested. It solved a language problem by moving a runtime problem into the kernel.

So the final advice is deliberately blunt: do not repeat it. For system-level development, C remains the best default. It is close to the operating-system contract, honest about control flow, stable across toolchains, and easier to audit when the machine is already failing.

Use C++ where its runtime is supported and failures can be contained. Use C where one bad memory write, lock operation, stack-frame assumption, or ownership transition can determine whether the whole system stays alive.

KERNEL AND LEGACY CODE ARCHITECTURE

Does a legacy driver depend on a private runtime that nobody can safely change?

KernelSoft reconstructs undocumented architecture in Windows and Linux kernels, audits compiler and runtime dependencies, and produces a staged modernization plan before any risky changes begin.

Discuss a kernel or legacy-code audit