A 2.6 Driver and the Trail of Evidence
I ported a small driver experiment to a newer 2.6 kernel this week. The old code compiled after a few mechanical edits, so I congratulated myself much too early. Loading the module produced an oops during device removal, which is the kernel’s way of reviewing code without softening the language.
My first attempt was to stare at the final faulting line and add a null check. That stopped one crash but left the design wrong. The pointer was not unexpectedly null; it referred to an object whose lifetime had ended. A defensive condition would merely make the race less visible.
The 2.6 device model asks drivers to express relationships among buses, devices, and drivers. Registration is not just bookkeeping. It establishes ownership and determines which callbacks can run as devices appear and disappear. I had treated remove as the reverse spelling of probe, but not as the reverse sequence of acquired resources.
I wrote down each successful step in probe: enable the device, reserve its region, allocate private state, initialize synchronization, and expose the interface. Then I made every failure path unwind only the steps already completed. remove performs the same cleanup in reverse order after preventing new work from entering.
static int sample_probe(struct device *device)
{
struct sample *sample;
int error;
sample = kmalloc(sizeof(*sample), GFP_KERNEL);
if (!sample)
return -ENOMEM;
memset(sample, 0, sizeof(*sample));
error = reserve_hardware(sample);
if (error)
goto free_sample;
/* Published callbacks must be able to find fully initialized state. */
dev_set_drvdata(device, sample);
error = register_interface(sample);
if (error)
goto clear_drvdata;
return 0;
clear_drvdata:
dev_set_drvdata(device, NULL);
release_hardware:
release_hardware(sample);
free_sample:
kfree(sample);
return error;
}
This is shortened code, but the order matters: private data is attached before register_interface() makes callbacks possible. If publication fails, the failure path clears that pointer before releasing and freeing the state. In C, a linear chain of labels is often clearer than nested cleanup conditionals.
Removal uses the other half of the same rule:
static int sample_remove(struct device *device)
{
struct sample *sample = dev_get_drvdata(device);
unregister_interface(sample);
stop_hardware(sample);
cancel_deferred_work(sample);
dev_set_drvdata(device, NULL);
release_hardware(sample);
kfree(sample);
return 0;
}
unregister_interface() first prevents new user callbacks. The hardware is then stopped and outstanding work is drained while drvdata still points to live memory. Only after no callback can use it do I detach and free it.
For evidence, I enabled the kernel debugging options relevant to memory and locking and built a kernel specifically for this work. They cost performance, but a development kernel is a measuring instrument, not a racing bicycle. I also kept the unstripped vmlinux and the exact .config beside the test build.
When the next oops appeared, the symbolic trace identified both the callback and its caller. printk() markers around acquisition and release confirmed that queued work could still run after teardown began. The repair was to stop new requests, cancel or drain deferred activity, and only then free the state it could reference.
The order now looks like this:
block new work
stop device activity
wait for deferred work
remove user-visible interface
release hardware resources
free private state
I tested failure paths too. Returning an artificial error after each acquisition showed whether cleanup restored the previous state. Module load and unload in a loop then exercised the unremarkable path repeatedly. Repetition is valuable because races dislike appointments.
I included reference counts in the review even though the first crash did not mention them. A reference is a claim that an object must remain alive, not merely an integer to increment near a pointer. Every path acquiring one needs a visible release, and teardown must prevent new claims before waiting for old ones. Counting without a shutdown rule only measures how confused the lifetime has become.
Lock choice followed the same reasoning. I did not replace every lock with a larger one to make the warning disappear. I listed which fields formed one invariant and which contexts accessed them. A spinlock suited the short state transition shared with interrupt context; work that could sleep stayed outside it. Narrow critical sections made both the rules and the traces easier to read.
One subtle lesson was to distrust logs as timing-neutral. A printk() can alter scheduling enough to hide a race. I used it to establish broad ordering, then relied on assertions, debug checks, and repeated tests rather than declaring victory when extra logging made the crash vanish.
An oops after testing still required a disciplined capture. I saved the complete report, not only the final address, and matched it to the exact kernel image. A decoded call chain is context: it shows how execution arrived, which often matters more than the instruction that finally touched invalid memory.
The kernel APIs will continue to evolve, and copying a driver from an older tree will continue to be tempting. The durable knowledge is not a particular field name. It is the discipline of checking the current in-tree callers, understanding callback context, defining object lifetime, and making cleanup the exact inverse of setup.
I still prefer user-space debugging, where a bad pointer usually ruins one process rather than the afternoon. The kernel does leave a trail if the build preserves symbols and the code preserves invariants. That’s better than adding null checks until the machine goes quiet.