Again, back to the traditional area of operating system development (and applications for microcontrollers) - writing drivers.
I will try to highlight some general rules and canons in this area. As always - on the example of the Phantom.
A driver is a functional component of an OS that is responsible for relations with a certain subset of computer hardware.
')
With a light hand of the same Unix, drivers are divided into block and byte-oriented. In the old days, the classic examples were a disk driver (operations — write and read a sector of a disk) and display driver (read and write a character).
In modern reality, of course, everything is more complicated. A driver is a typical instantiated object of a class, and these classes to the fig and more. In principle, the interface drivers are trying to somehow squeeze in the Procrustean bed model read / write, but this is self-deception. The network card driver has a method “read the MAC address of the card” (which, of course, can be implemented through
properties ), and the USB driver has a whole bundle of USB-specific operations. Even more fun with graphics drivers is that some bitblt (startx, starty, destx, xsize, ysize, operation) is common.
The life cycle of the driver, in general, can be described as:
- Initialization: the driver receives resources (but not access to its hardware)
- Hardware search: the driver receives from the kernel or finds its own hardware resources
- Activation - the driver starts working
- The appearance / disappearance of devices, if appropriate. See the same USB.
- Sleeping / waking up the equipment, if appropriate. In controllers, often unused hardware is turned off to save.
- Driver deactivation - queuing stops
- Driver unloading - all kernel resources are released, the driver does not exist.
(Actually, I wrote a draft open driver interface specification last year — see the
repository and the
document .)
I know of three models for building a driver:
- Polling
- Interruptions
- Threads
Driver based on polling (cyclic polling) device
Such drivers are used only with great grief or for great need. Or if it is a simple built-in microcontroller system, in which there are only two drivers. For example, the serial port <-> TCP interface converter, in which the network operates on interrupts, can, in principle, work with the serial port with polling. If you do not mind the excess heat and energy costs.
There is one more reason: such drivers are practically indestructible. Therefore, for example, in Phantom OS, debugging the output of the kernel to the serial port is done this way.
In the loop, we check the readiness of the port to receive bytes, transfer the bytes, and finish the exercise.
#define PORT 0x3F8 int dbg_baud_rate = 115200; void debug_console_putc(int c) { while(! (inb(PORT+5) & 0x20) ) ; if( c == '\n' ) outb(PORT, '\r' ); outb(PORT, c); } void arch_debug_console_init(void) { short divisor = 115200 / dbg_baud_rate; outb( PORT+3, 0x80 ); outb( PORT, divisor & 0xf ); outb( PORT+1, divisor >> 8 ); outb( PORT+3, 3 ); }
Such a driver, as it is easy to see, devours the processor in anticipation of the readiness of the device. This can be repaired if the speed of the driver itself is noncritical:
while(! (inb(PORT+5) & 0x20) ) yield();
But, of course, in general, this is not suitable anywhere (except for the above case :) model.
Interrupt Based Driver
The general structure of such a driver looks like this:
struct device_state dev; dev_write( buf ) { dev.buf = buf; if( !dev.started ) dev_start(); cond_wait( &dev.ready ); } dev_interrupt() { dev_output(); } dev_start() { dev.started = 1; dev_enable_interrups( &dev_interrupt ); dev_output(); } dev_output() { if( buffer_empty() || (!dev.started) ) { dev.started = 0; dev_disable_interrupts(); cond_signal( &dev.ready );
In fact, such a driver generates a pseudo-thread for itself: a control flow that lives only on receipt of interrupts from the device.
As soon as the driver receives the next write request, it enables interrupts and “manually” initiates sending the first byte of data to the device. After which the incoming thread falls asleep, waiting for the end of the transfer. And maybe come back if you need asynchronous work. Now the driver will wait for an interrupt from the device. When the device “burns through” the received byte, it will generate an interrupt, during which the driver will either send the next byte (and wait for the next interrupt), or finish its work, turn off the interrupts and “release” the thread waiting for dev_write ().
What is forgotten
Before we get to the latest driver model, we will list the things that I (intentionally) missed in the previous narration.
Error processing
In our pseudo-code, I / O success is not checked. A real device may refuse or report a media failure. They took out the cable from the LAN port, there was a bad block on the disk. The driver must detect and process.
Timeouts
The device may break and simply not interrupt the request, or never set the ready bit. The driver should request a timer event that would lead him out of the “stupor” to such an event.
Death request
If the OS around us allows this, then we must be prepared for the fact that the thread that entered the driver, within which the I / O request "came", may simply be killed. This should not lead to fatal consequences for the driver.
Synchronization
For simplicity, I specify cond as the synchronization primitive. In a real driver, this is not possible - cond requires an enclosing mutex at the synchronization point, and in the interruption, what kind of mutex is impossible! Here in the latest model, a driver with its own thread, you can use cond as a means of synchronizing user threads and driver threads. But synchronization with interruption is only a spinlock and a semaphore, and the semaphore implementation must be ready for the opportunity to activate (open) the semaphore from the interrupt. (In the Phantom it is)
Thread Based Driver
It differs from the previous one in that it has its own thread that performs I / O.
dev_thread() { while(dev.active) { while(!dev_buffer_empty()) cond_wait(&dev.have_data); while( ) cond_wait(&dev.interrupt); dev_enable_interrupts();
The advantage of such a driver is that you can afford much more from a thread than from an interrupt handler — you can allocate memory, manage page tables, and generally call any kernel function. From the interruption one cannot afford long and, especially, blocking operations.
Note that there is a third, intermediate model in which the driver does not have its own thread, but does the same from the I / O request thread. But, first, see the point that they can kill her, secondly, this is a redneck :), and in the third - not always she (the thread) wants this. Others would like asynchronous service.
Block I / O, sorting and fences
Disk drivers usually have a request queue as input - the OS generates I / O requests in batches, and all requests at the driver level are asynchronous. At the same time, a good driver has its own query service strategy, and service is not in the order of receipt.
Indeed, if on a regular disk device to perform requests in the order in which they arrived, the drive head will have to make random movements on the disk, slowing down the input-output.
Usually, the driver simply sorts the request queue by block number and services them so that the disk head consistently moves from the outer track to the inner track, or vice versa. It helps a lot.
But not every couple of requests can be swapped. If the file system (or its equivalent) decided that it needed to guarantee the integrity of the data on the disk, she would really like to make sure that a certain group of requests was completed. To do this, a special I / O request is inserted into the request queue, which prohibits mixing requests to itself with requests after itself.
In addition, it’s a bad idea to interchange the request for writing a N block and a read request for the same block. However, this is a matter of agreement.