📜 ⬆️ ⬇️

Driver anatomy

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:



(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:



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 ); /* set up to load divisor latch */ outb( PORT, divisor & 0xf ); /* LSB */ outb( PORT+1, divisor >> 8 ); /* MSB */ outb( PORT+3, 3 ); /* 8N1 */ } 


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 ); // done return; } // send to device next byte from buffer out( DEV_REGISTER, *dev.buf++ ); } 


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( /* device busy */ ) cond_wait(&dev.interrupt); dev_enable_interrupts(); // send to device next byte from buffer out( DEV_REGISTER, *dev.buf++ ); } } dev_interrupt() { dev_disable_interrupts(); cond_signal(&dev.interrupt); } 


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.

Source: https://habr.com/ru/post/282564/


All Articles