Benefits of Device Drivers vs Direct I/O Register Access

cdriverslinux

I was interested in what modern, C-based, Linux device driver development looked like, and so I took a good gander at this excellent article.

I didn't read every single line, but perused the majority of it, and my take away is this: writing device drivers for Linux seems like an incredibly painful process. Now that's my opinion, and I'm sure many Linux/C gurus out there would disagree, but there's one thing that I just can't wrap my head around here.

Every CPU/MCU exposes a C library that defines I/O registers, ports, etc. for accessing (both reads and writes) every single pin on the chip. On AVR controllers, for instance, there is <avr/io.h>. For ARM chips there is CMSIS.

So my question is this:

Why would I go to all the 'trouble' (more work) of following the guidelines in that article and building a proper device driver in Linux, when I could just write a C library that uses one of these processor-provided I/O registry accessors (again, avr/io.h, CMSIS, etc.) to access all the pins on the chip (which seems like less work)?

I'm sure there's a reason: I'm just not seeing it. What is gained by using a proper device driver? What is lost by not using one and just using one of these libs directly?

Best Answer

Asking why microcontrollers like the AVR family let you twiddle the pins directly when an operating system like Linux requires device drivers is an apples-to-suspension-bridges comparison.

Microcontrollers are typically single-threaded free-for-alls with no supervisor (OS) and all code has full access to memory, I/O and anything else the chip offers. This means that if one part of the application sets GPIO pin 3 high and needs to see that it stays that way, there's nothing that prevents some other part of the code from setting it low again. The developer is responsible for putting those kinds of interlocks into place. There's also no notion of devices: the MCU has a set of registers that control I/O that's the same on every copy of the chip and that's all there is. If you want to run your program on a different chip in the same family with different I/O, you're likely going to have to rewrite parts of it or, at the very least, recompile it with chip-specific headers or libraries.

General-purpose processors like the one in your desktop PC don't have the I/O features built in, opting instead to communicate with external peripherals whose registers are mapped somewhere in memory. The model is the same, though. If you plop an x86 CPU into a board where you know the I/O mappings, it's entirely possible to write AVR-style programs that do their own I/O, but you're going to be doing it on bare metal without any of the facilities an operating system provides.

A lot of software was done this way, but at some point in history, it became apparent that the model wasn't going to scale. People wanted to write programs to run on a given processor without having to fuss with building a separate version for each combination of peripherals. This spawned operating systems, which let a program say things like "send the character C across serial port 2" using a standard software interface. The code behind that standard interface was built up for the specific set of hardware on that system and knew that serial port 2 was a Brand X UART mapped at address 1234. In microcomputing, this kind of thing became most evident with the appearance of the CP/M operating system in the 1970s. Vendors selling computers would provide a version of the OS with custom drivers for their specific hardware. Do-it-your-selfers had to patch up generic versions by hand to work with what they had. The result was that they all ended up with systems that could run off-the-shelf versions of software without having to modify it.

Modern operating systems do pretty much the same thing but with a much higher level of sophistication. Systems (PC-style and even many embedded ones) are built around I/O architectures that are enumerable and dynamic, meaning that the software can figure out what's present, assign it a memory mapping and use the right functions to communicate with it. Along with that comes driver infrastructure that lets hardware developers leverage all of that sophistication without having to re-invent it each time and without risk that driver A was going to conflict with something driver B is doing.

If you decide to try going the poke-the-device-directly route on a full-blown operating system like Linux, the first question you'll ask is how a userland process gains access to the memory area where the device's registers are mapped. The answer ("you can't") is what brings up the other reason: multi-user, multi-tasking operating systems don't trust user programs to be well-behaved. They're designed to protect the system from harm and the programs running on the system from each other. Making this happen brings with it a complex model of interlocks and permissions, all of which contribute to whether or not a program can ask a device driver to do something on its behalf. The device driver code put into the kernel is given a much greater amount of trust because, presumably, those who wrote it did so carefully.

The functions you put into a device driver for most flavors of Unix are just the essentials (load, unload, open, close, read, write, ioctl) and are called at the end of a very long chain of decision making. If a process wants to write to a serial port, the driver code isn't called until the OS is sure there's a driver present and functioning for that device, the process has permission to use the device and that the port has been opened by that process.

This, at long last, is why you write a device driver: The amount of code you have to develop for a device driver is minuscule compared to the amount of sophistication the rest of the OS provides to go with it.

Really, it isn't painful. It's just unfamiliar. Once you've done a couple, you'll wonder why you were making such a fuss about it. (And trust me, it's a lot less work now than it was 25 years ago.)

Related Topic