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

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

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

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

В современной реальности, конечно, всё сложнее. Драйвер — типичный инстанс-объект класса, и классов этих до фига и больше. В принципе, интерфейс драйверов пытаются как-то ужать в прокрустово ложе модели 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(); // отдать процессор другим нитям, пока наше устройство не готово


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

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


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


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


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

Как только драйвер получает очередной запрос на запись, он включает прерывания и «вручную» инициирует отправку в устройство первого байта данных. После чего входящая нить засыпает, ожидая конца передачи. А может и вернуться, если нужна асинхронная работа. Теперь драйвер будет ждать прерывания от устройства. Когда устройство «прожуёт» полученный байт, оно сгенерирует прерывание, при обслуживании которого драйвер или отправит очередной байт (и будет ждать очередного прерывания), или закончит работу, выключит прерывания и «отпустит» ждущую внутри 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 и запрос на чтение того же блока. Впрочем, это вопрос договорённостей.

Комментарии (13)


  1. pfactum
    27.04.2016 17:42
    +1

    Насчёт поллинга.

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

    А как быть с Linux NAPI? Там наоборот отказались от прерываний из-за того, что поллингом дешевле вычитывать большие объёмы трафика, чтобы не сжигать процессорное время на обработку прерываний от миллионов пакетов.


    1. dzavalishin
      27.04.2016 23:23
      +2

      Вопрос соотношения времени обмена данными и простоя. И — цены свободного ядра процессора. Плюс несколько повышается реактивность. Прерывание тоже требует времени.

      Был такой процессор — propeller, если не ошибаюсь — там восемь синхронных ядер и нет прерываний. Весь обмен — поллингом. Принципиально.

      Кроме того, в жёстком риалтайме драйвера поллинговые — считается, что иначе невозможно гарантировать время реакции. (Хотя у меня лично есть смутные сомнения на этот счёт.)

      В целом — согласен, есть в жизни место поллингу.


    1. dzavalishin
      27.04.2016 23:26

      Но с миллионами пакетов мне как-то не очевидно: нормальный драйвер на одно прерывание читает все данные, которые есть в буфере железа, или пишет все, которые можно запихать в буфер за один раз. Совсем нормальный вообще делает железу в памяти scatter-gather list и железо само его обходит и делает ввод-вывод чисто аппаратно. В любом случае никто не делает прервание на байт, пакет или сектор — работают пачками.


      1. pfactum
        27.04.2016 23:33

        Возможно, будет место удивлению, но к работе пачками в Линуксе (что в сетевой подсистеме, что в дисковой) пришли совсем недавно, причём, в дисковой — позже, по образу и подобию того, что было сделано в сетевой.


        1. Kolyuchkin
          28.04.2016 07:45

          Может у нас с вами разное «недавно» или я не правильно понимаю термин «пачками», но еще когда я писал блочный драйвер для ядра 2.4.32 по LDD-2 (~2001 год выпуска), то уже там были рекомендации и примеры реордеринга очереди запросов (накопил, отсортировал и выплюнул в устройство), если конечно был асинхронный режим.


      1. pfactum
        27.04.2016 23:40

        Чтобы не быть голословным: https://lwn.net/Articles/663879/


      1. Kolyuchkin
        27.04.2016 23:51
        +1

        Недавно в одном аппаратно-программном проекте на базе как раз ОС РВ (Embox) для моментальной реакции на принятые сетевые пакеты нам пришлось настраивать EMAC в драйвере следующим образом: по-умолчанию прерывания приходили от каждого принятого пакета, по интенсивности приема на лету производилась корректировка контроллера прерываний, чтобы он реагировал не на каждый принятый пакет. Далее, по таймеру, производилась обратная перенастройка контроллера прерываний. Так же приходилось играться и с приоритетами обслуживающих потоков (клиентов).


      1. jcmvbkbc
        28.04.2016 06:26
        +2

        нормальный драйвер на одно прерывание читает все данные, которые есть в буфере железа

        В линуксе есть пара тонких моментов. Один момент в том, что в обработчике аппаратного прерывания никто старается не работать, потому что все прерывания в этот момент запрещены, и если делать что-то тяжёлое — будет заметно. Поэтому вся работа делается в softirq или в выделенном потоке, а между приходом прерывания и началом обработки возникает зазор. И вот тут уже гонка: кто быстрее — поток выгребает пакеты, или сеть набрасывает. Чтобы в этом месте не гоняться драйвер отключает прерывания от устройства, а по окончании обработки пакета проверяет, не пришёл ли ещё один.
        Второй момент в том, что большАя часть работы сетевого стека по обработке входящих пакетов в NAPI выполняется синхронно. Т.е. драйвер, вызывая функцию netif_receive_skb может пропихнуть пришедший пакет до выходного интерфейса бриджа или маршрутизатора, если пакет не предназначен локальной машине, а это дополнительная задержка, за время которой может прийти новый пакет.


        1. dzavalishin
          28.04.2016 09:23

          напрашивается multithreaded driver :)


          1. lorc
            28.04.2016 16:19

            Ну в линуксе так в некотором смысле и есть. Код одного и того же драйвера может выполняться в несколько потоков:
            — кто-то (может и не один) дернул из юзерспейса и мы бежим в контексте пользовательского потока
            — одновременно обрабатываем в прерывание в ядерном потоке
            — где-то вдруг сработал наш таймаут и второй процессор в другом ядерном потоке хендлит его
            — третий процессор обрабатывает аппаратное прерывание от нашего устройства.

            При чем, запросы на доступ к устройству (т.е. собственно input/output) могут поступать из любого из этих потоков, а сераилизировать их будет драйвер(а) шины.


            1. jcmvbkbc
              28.04.2016 22:12

              запросы на доступ к устройству (т.е. собственно input/output) могут поступать из любого из этих потоков, а сераилизировать их будет драйвер(а) шины

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


              1. lorc
                29.04.2016 14:01

                Ну в случае каких-то сложных команд — да. А если это, например, драйвер тупого расширителя GPIO, то можно целиком положиться на драйвер шины.


            1. dzavalishin
              29.04.2016 08:57

              В этом смысле все драйвера multithreaded. Я о порождении нитей внутри драйвера и отработку порождённых драйвером событий внутрь остальной ОС.