irql: Compile-Time IRQL Safety for Windows Kernel Drivers in Rust
It started with a different problem entirely.
I was writing a Windows kernel driver in Rust and hit something that bothered me: when Rust’s alloc fails, it panics. In user-mode, that’s a crash. In kernel-mode, a panic is a blue screen. Every Box::new, every Vec::push – any allocation that runs out of memory takes down the entire machine. That’s not acceptable for a driver that might run on millions of endpoints.
So I started building fallible allocation types. IrqlBox and IrqlVec – wrappers around kernel pool memory that return Result instead of panicking. No OOM? No problem. OOM? You get an Err, handle it gracefully, and the system keeps running.
But kernel pool allocation has a constraint that user-mode code never has to think about: IRQL.
The Rabbit Hole
In the Windows kernel, every function executes at an Interrupt Request Level (IRQL). It’s a priority mechanism – code at a higher level can’t be preempted by code at a lower level. The kernel defines a hierarchy, from PASSIVE_LEVEL (normal thread execution) up through APC_LEVEL, DISPATCH_LEVEL, device interrupt levels, all the way to HIGH_LEVEL.
The rules are strict:
- At
DISPATCH_LEVELor above, you cannot access paged memory. The page fault handler runs at a lower IRQL and would deadlock. - You can raise your IRQL but must not lower it arbitrarily.
- Certain kernel APIs are only callable at specific levels.
Break any of these rules and you get a bugcheck. A blue screen. No exception handling, no graceful recovery.
So my “simple” allocation types immediately had a problem. The kernel has two memory pools:
| Pool | Allocable at | Accessible at |
|---|---|---|
| PagedPool | Passive, Apc | Passive, Apc |
| NonPagedPool | Passive, Apc, Dispatch | Any IRQL |
If my IrqlBox allocates from PagedPool at PASSIVE_LEVEL and someone accesses it at DISPATCH_LEVEL – blue screen. If someone drops a paged-pool allocation at elevated IRQL – blue screen. If someone allocates from paged pool while already at DISPATCH_LEVEL – you guessed it.
I needed a way to make these mistakes impossible. Not “hard to make” – impossible. And that’s when it clicked: Rust’s type system is powerful enough to encode the entire IRQL hierarchy as compile-time constraints.
What started as a small allocator crate turned into something much bigger.
The Idea
What if every function in your driver carried its IRQL level as a type parameter? What if the compiler could check every call, every transition, and reject the ones that would blue-screen?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
use irql::{irql, Dispatch, Passive};
#[irql(max = Dispatch)]
fn acquire_spinlock() { /* ... */ }
#[irql(max = Passive)]
fn driver_routine() {
call_irql!(acquire_spinlock()); // OK: Passive can raise to Dispatch
}
#[irql(at = Passive)]
fn driver_entry() {
call_irql!(driver_routine());
}
Try to call a Passive-only function from Dispatch code:
1
2
3
4
#[irql(max = Dispatch)]
fn dispatch_work() {
call_irql!(driver_routine()); // driver_routine requires max = Passive
}
1
2
error[E0277]: IRQL violation: cannot reach `Passive` from `Dispatch`
-- would require lowering
No runtime check. No binary overhead. The compiler catches it, you fix it, and your driver never blue-screens from an IRQL violation.
The traditional defense in C drivers is PAGED_CODE() – a runtime assert that fires when you hit a paged function at the wrong level. But it only catches bugs you actually trigger during testing. The violations hiding three call levels deep in a code path you test once a quarter? Those ship to production.
I wanted something better.
How It Works Under the Hood
The mechanism has three parts: marker types, hierarchy traits, and macro rewriting.
Marker Types
Each IRQL level is a zero-sized struct. Nine of them, matching the Windows hierarchy:
1
2
3
4
5
6
7
8
9
pub struct Passive; // PASSIVE_LEVEL (0)
pub struct Apc; // APC_LEVEL (1)
pub struct Dispatch; // DISPATCH_LEVEL (2)
pub struct Dirql; // DIRQL (3-26)
pub struct Profile; // PROFILE_LEVEL (27)
pub struct Clock; // CLOCK_LEVEL (28)
pub struct Ipi; // IPI_LEVEL (29)
pub struct Power; // POWER_LEVEL (30)
pub struct High; // HIGH_LEVEL (31)
Zero-sized means they exist only at compile time. No runtime cost, no binary footprint.
Hierarchy Traits
Two traits encode which transitions are legal:
IrqlCanRaiseTo<Target>– implemented whenSelf <= Target. Passive can raise to Dispatch. Dispatch cannot lower to Passive.IrqlCanLowerTo<Target>– the reverse, for minimum-level constraints.
A macro generates all valid combinations. The compiler does the rest – if a trait impl doesn’t exist for a transition, the bound fails, and you get an error. Not a cryptic generic error either. The crate uses #[diagnostic::on_unimplemented] to produce messages that actually tell you what went wrong:
1
2
3
4
5
error[E0277]: IRQL violation: cannot reach `Passive` from `Dispatch`
-- would require lowering
--> src/main.rs:8:5
|
= note: IRQL can only stay the same or be raised, never lowered
The #[irql] Attribute
This is a proc macro that rewrites your function signature. When you write:
1
2
#[irql(max = Dispatch)]
fn acquire_spinlock() { /* ... */ }
The macro transforms it into:
1
2
3
4
fn acquire_spinlock<IRQL>()
where
IRQL: IrqlCanRaiseTo<Dispatch>,
{ /* ... */ }
You never see the IRQL type parameter. You never write it. But the compiler sees it and enforces the bound at every call site.
Three forms:
| Form | Meaning |
|---|---|
#[irql(at = Level)] | Fixed entry point – IRQL is known (e.g., DriverEntry is always Passive) |
#[irql(max = Level)] | Callable from this level or below |
#[irql(min = A, max = B)] | Callable only within the range [A, B] |
It works on standalone functions, entire impl blocks (every method gets the constraint), and even trait impl blocks.
The call_irql! Macro – Threading IRQL Through the Call Graph
This is the part I’m most proud of. When you write:
1
call_irql!(acquire_spinlock());
The macro rewrites it to:
1
acquire_spinlock::<IRQL>()
It injects the caller’s IRQL type as a turbofish argument. The type flows from caller to callee through the entire call graph. Every transition is checked. If any link in the chain violates the hierarchy, compilation fails.
call_irql! isn’t something you import – the #[irql] attribute injects it as a local macro into every annotated function body. It’s just there, ready to use.
The key insight is that IRQL becomes a type-level value propagating through your program. The same type checker that validates your generics and lifetimes now validates your IRQL transitions. It’s not a lint, not a static analysis pass – it’s the type system itself.
Back to Where It Started: The Allocation Problem
With the IRQL type system in place, the allocation problem that started this whole journey became solvable. IrqlBox and IrqlVec know what IRQL they’re being used at, and the compiler enforces the pool rules.
Automatic Pool Selection
IrqlBox::new picks the cheapest legal pool for the current IRQL automatically:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[irql(max = Passive)]
fn at_passive() -> Result<(), AllocError> {
// PagedPool -- cheapest option at Passive
let data = call_irql!(IrqlBox::new(42))?;
let val = call_irql!(data.get());
Ok(())
}
#[irql(max = Dispatch)]
fn at_dispatch() -> Result<(), AllocError> {
// NonPagedPool -- only legal option at Dispatch
let data = call_irql!(IrqlBox::new(99))?;
let val = call_irql!(data.get());
Ok(())
}
Try to allocate at DIRQL or above? Compile error. No pool supports it.
You can also force a specific pool when you know the memory will cross IRQL boundaries:
1
let data = call_irql!(IrqlBox::<_, NonPagedPool>::new_in(42))?;
Everything returns Result. No panics, no OOM blue screens. That was the whole point.
Drop Safety – The Hardest Part
This one took me a while to figure out. Consider: you allocate paged-pool memory at PASSIVE_LEVEL, then pass it by value to a function at DISPATCH_LEVEL. When that function returns, Rust drops the value. That drop deallocates paged-pool memory at DISPATCH_LEVEL. Blue screen.
The problem is that Rust’s Drop trait has no generic parameters. You can’t say “this type is only droppable at certain IRQL levels” through Drop alone.
The solution uses nightly auto traits. PagedPool opts out of SafeToDropAtDispatch (and all higher levels) via negative impls:
1
2
3
impl !SafeToDropAtDispatch for PagedPool {}
impl !SafeToDropAtDirql for PagedPool {}
// ... and so on up to High
Because auto traits propagate through struct fields, IrqlBox<T, PagedPool> – which contains PhantomData<PagedPool> – automatically inherits these opt-outs. Any struct containing a paged-pool allocation inherits them too. No manual annotation needed.
The #[irql] macro injects SafeToDropAt<Level> bounds on all by-value parameters. So passing an IrqlBox<T, PagedPool> into a Dispatch-level function is a compile error:
1
2
error[E0277]: `PagedPool` cannot be safely dropped at IRQL Dispatch
-- it contains paged-pool memory
References are fine – &IrqlBox doesn’t drop anything. And if you need to transfer ownership across IRQL boundaries, leak() and into_raw() let you do it safely:
1
2
3
4
5
#[irql(max = Passive)]
fn prepare_for_dpc() -> Result<&'static mut u32, AllocError> {
let b = call_irql!(IrqlBox::new(0u32))?;
Ok(b.leak()) // Memory survives the IRQL transition
}
Under the hood, real kernel builds use ExAllocatePool2 / ExFreePool from wdk-sys. Outside the WDK (for testing), the global allocator kicks in as a fallback. So you can develop and test your driver logic on your laptop without a full kernel build environment.
What It Doesn’t Do
I want to be honest about the boundaries. irql verifies the consistency of your annotations. If you say a function runs at max = Dispatch, the compiler ensures every function it calls is compatible. But it trusts your entry points. If you mark driver_entry as #[irql(at = Dispatch)] when it actually runs at PASSIVE_LEVEL, the compiler won’t save you.
It also doesn’t model runtime IRQL changes like KeRaiseIrql / KeLowerIrql. If your function explicitly raises the IRQL, you need to reflect that in your annotations. The crate gives you the language to express constraints – you bring the knowledge of what your code actually does.
Try It
1
2
[dependencies]
irql = "0.1.6"
For IRQL-aware allocation (requires nightly):
1
2
[dependencies]
irql = { version = "0.1.6", features = ["alloc"] }
The crate builds on stable without the alloc feature. The core IRQL checking – levels, hierarchy, macro rewriting – all works on stable Rust. Only the allocation types and drop-safety auto traits need nightly.
I started this project because I didn’t want my allocations to panic. I ended up building a type-level encoding of the entire Windows IRQL hierarchy. Sometimes the rabbit hole is worth following.
If you’re writing Windows kernel drivers in Rust, give it a try. Blue screens are bad for everyone.
- GitHub: naorhaziz/irql
- Crates.io: irql
- Docs: docs.rs/irql
- License: MIT or Apache 2.0
