Анатомия драйвера

Опять вернёмся в традиционную область разработки операционных систем (и приложений для микроконтроллеров) — написание драйверов.

Я попробую выделить некоторые общие правила и каноны в этой области. Как всегда — на примере Фантома.

Драйвер — функциональная компонента ОС, ответственная за отношения с определённым подмножеством аппаратуры компьютера.

С лёгкой руки того же Юникса драйвера делятся на блочные и байт-ориентированные. В былые времена классическими примерами были драйвер диска (операции — записать и прочитать сектор диска) и драйвер дисплея (прочитать и записать символ).

В современной реальности, конечно, всё сложнее. Драйвер — типичный инстанс-объект класса, и классов этих до фига и больше. В принципе, интерфейс драйверов пытаются как-то ужать в прокрустово ложе модели read/write, но это самообман. У драйвера сетевой карты есть метод «прочитать MAC-адрес карты» (который, конечно, можно реализовать через properties), а у драйвера USB — целая пачка USB-специфичных операций. Ещё веселее у графических драйверов — какой-нибудь bitblt (startx, starty, destx, desty, xsize, ysize, operation) — обычное дело.

Цикл жизни драйвера, в целом, может быть описан так:

  • Инициализация: драйвер получает ресурсы (но не доступ к своей аппаратуре)
  • Поиск аппаратуры: драйвер получает от ядра или находит сам свои аппаратные ресурсы
  • Активация — драйвер начинает работу
  • Появление/пропадание устройств, если это уместно. См. тот же USB.
  • Засыпание/просыпание аппаратуры, если это уместно. В контроллерах часто неиспользуемая аппаратура выключается для экономии.
  • Деактивация драйвера — обслуживание запросов прекращается
  • Выгрузка драйвера — освобождаются все ресурсы ядра, драйвер не существует.

(Вообще я написал в прошлом году черновик открытой спецификации интерфейса драйвера — см. репозиторий и документ.)

Мне известны три модели построения драйвера:

  • Поллинг
  • Прерывания
  • Нити (threads)


Драйвер на основе поллинга (циклического опроса) устройства


Такие драйвера применяются только с большого горя или по большой необходимости. Или если это простая встроенная микроконтроллерная система, в которой и есть-то всего два драйвера. Например, конвертор интерфейсов serial port TCP, в котором сеть работает по прерываниям, работу с последовательным портом может, в принципе, выполнять и поллингом. Если не жалко избытка тепла и затрат энергии.

Есть и ещё одна причина: такие драйвера практически неубиваемы. Поэтому, например, в ОС Фантом отладочная выдача ядра в последовательный порт сделана именно так.

В цикле проверяем готовность порта принять байт, передаём байт, закончили упражнение.

#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 */
}

Такой драйвер, как нетрудно видеть, пожирает процессор в ожидании готовности устройства. Это можно починить, если скорость работы самого драйвера некритична:

    while(! (inb(PORT+5) & 0x20) )
        yield(); // отдать процессор другим нитям, пока наше устройство не готово

Но, конечно, в целом это никуда не годная (кроме вышеприведённого случая:) модель.

Драйвер на основе прерываний


Общая структура такого драйвера выглядит вот как:


strauct 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++ );
}

Фактически, такой драйвер порождает для себя псевдо-нить: поток управления, который живёт только на поступлении прерываний от устройства.

Как только драйвер получает очередной запрос на запись, он включает прерывания и «вручную» инициирует отправку в устройство первого байта данных. После чего входящая нить засыпает, ожидая конца передачи. А может и вернуться, если нужна асинхронная работа. Теперь драйвер будет ждать прерывания от устройства. Когда устройство «прожуёт» полученный байт, оно сгенерирует прерывание, при обслуживании которого драйвер или отправит очередной байт (и будет ждать очередного прерывания), или закончит работу, выключит прерывания и «отпустит» ждущую внутри dev_write () нить.

Что забыто


Прежде чем мы перейдём к последней модели драйвера, перечислим вещи, которые я (намеренно) пропустил в предыдущем повествовании.Обработка ошибок
В нашем псевдокоде никак не проверяется успешность ввода-вывода. Реальное устройство может отказать или сообщить о неисправности носителя. Вынули кабель из порта локалки, на диске случился плохой блок. Драйвер должен обнаружить и обработать.Таймауты
Устройство может сломаться и просто не ответить на запрос прерыванием, или никогда не выставить бит готовности. Драйвер должен запросить таймерное событие, которое бы вывело его из «ступора» на такой случай.Смерть запроса
Если окружающая нас ОС это позволяет, то надо быть готовым к тому, что вошедшая в драйвер нить, в рамках которой «пришёл» запрос ввода-вывода, может быть просто убита. Это не должно приводить к фатальным последствиям для драйвера.Синхронизация
Для простоты я указываю в качестве примитива синхронизации cond. В реальном драйвере это невозможно — cond требует объемлющего mutex в точке синхронизации, а в прерывании какой уж mutex — нельзя! Вот в последней модели, драйвере с собственной нитью, можно применять cond как средство синхронизации нити пользователя и нити драйвера. А вот синхронизация с прерыванием — только spinlock и семафор, причём реализация семафора должна быть готова к возможности активировать (открыть) семафор из прерывания. (В Фантоме это так и есть)

Драйвер на основе нити


От предыдущего он отличается тем, что имеет собственную нить, которая выполняет ввод-вывод.


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);
}

Преимущество такого драйвера в том, что из нити можно позволить себе куда больше, чем из хендлера прерывания — можно выделять память, управлять таблицами страниц и вообще звать любую функцию ядра. Из прерывания нельзя позволить себе длинные и, тем более, блокирующие операции.

Отметим, что есть третья, промежуточная модель, в которой драйвер не имеет своей нити, а выполняет всё то же самое из нити запроса ввода-вывода. Но, во-первых, см. пункт о том, что её могут убить, во-вторых это жлобство :), а в третьих — не всегда она (нить) такого хочет. Иным бы хотелось асинхронного обслуживания.

Блочный ввод-вывод, сортировка и заборы


Дисковые драйвера обычно имеют на входе очередь запросов — ОС генерирует запросы на ввод-вывод пачками, и все запросы на уровне драйвера асинхронны. При этом хороший драйвер имеет собственную стратегию обслуживания запросов, причём — обслуживания не в порядке поступления.

Действительно, если на обычном дисковом устройстве выполнять запросы в том порядке, в котором они прилетели, головке накопителя придётся совершать хаотичные движения по диску, замедляя ввод-вывод.

Обычно драйвер просто сортирует очередь запросов по номеру блока и обслуживает их так, чтобы головка диска последовательно двигалась от внешней дорожки к внутренней, или наоборот. Это сильно помогает.

Но не всякую пару запросов можно поменять местами. Если файловая система (или её аналог) решила, что ей надо гарантировать целостность данных на диске, она бы очень хотела убедиться в том, что определённая группа запросов завершена. Для этого в очередь запросов вставляется специальный запрос ввода-вывода, который запрещает перемешивать запросы до себя с запросами после себя.

Кроме того, плохая идея переставлять местами запрос на запись блока N и запрос на чтение того же блока. Впрочем, это вопрос договорённостей.

© Habrahabr.ru