РАЗБОР
Статья
Сетевой адаптер Virtio-Net [2] - от устройства к драйверу
[ обложка статьи ]
Режим чтения
Это вторая часть разговора о Virtio-Net, в которой мы создадим каркас драйвера для работы с сетью в реальном режиме процессора CPU. Поняв суть, его можно будет портировать и в защищённый режим с плоской моделью вирт.памяти. Первая часть лежит здесь, и рекомендуется к прочтению.
Оглавление - часть(2):
1. Конфигурирование устройства
2. Настройка очередей приёма-передачи данных
3. Работа с прерываниями
4. Тестирование - отправка ARP-запроса на хост
5. Заключение
1. Конфигурирование адаптера Virtio-Net
Мы уже знаем, что порт из BAR[0] открывает доступ к регистрам в режиме Legacy, и теперь нужно согласовать способ взаимодействия с устройством на хосте. Схема здесь простая - девайс в первом регистре "DeviceFeatures" передаёт нашему драйверу список поддерживаемых им функций, а мы должны выбрать из этого списка только те, которые планируем программно у себя реализовать. Другими словами хост говорит "я умею это-это-это", а дров отвечает "мне нужно только это и это". Список представляет собой 32-битную маску (в режиме Modern 64-бита), в которой каждый бит определяет 1 фишку.
В спеке на Virtio описывается каждый бит маски, но это запутанный квест, а не любезная помощь студенту. В нём напрочь отсутствует логика и совсем не понятно, за что именно отвечает тот или иной флаг. А ведь выбирать себе функции нужно с особой осторожностью - если что-то включаешь, то в драйвере обязательно должна быть его программная поддержка, иначе хост войдёт в бесконечный цикл ожидания ответа на нереализованную нами функцию.
Обычно с толку сбивают биты TSO, GSO, UFO, ECN - рассмотрим их подробней.
Известно, что стандартный размер полезной нагрузки Payload в сетевом пакете равен 1500 байт, и если мы хотим передать за один раз например 4КБ данных, потребуется фрагментация сегмента на мелкие пакеты 1500+1500+1500. Параметром MTU хост сообщает макс.поддерживаемый им размер большого сегмента (обычно 9000 байт, Jumbo-Frames).
Так вот биты TSO и UFO выбирают, кто будет заниматься непосредственной фрагментацией больших пакетов - наш драйвер, или хост уже на своей стороне. Разумней переложить эту обязанность на хост, тогда мы сможем (без предварительных ласк) передавать за один раз пакеты размером до 64КБ. Биты подразумевают "TCP segmentation offload" и "UDP fragmentation offload", и при желании можно вообще не включать их, т.к. это просто оптимизация транспортного уровня L4 модели OSI.
В свою очередь хитрый бит GSO (generic) предлагает всем другую идею: "А давайте мы вообще не будем резать большой пакет на множество мелких, а делегируем эту задачу физ.адаптеру NIC". Ну и последний бит ECN выступает просто в роли навигатора, заранее сообщая о создавшихся на дороге пробках. В дефолте, когда входящих пакетов много и сеть перегружена, адаптер может тупо отбрасывать половину из них (дропать), а бит ECN предупреждает об этом - опция нам пока не нужна.
Вот как распределены биты в 32-битной маске "DeviceFeatures". Для удобства я разделит их на тетрады. Например мой хост передал мне значение 0x79BF8064, и теперь я могу сопоставить каждый из 8-ми разрядов этого значения, с соответствующей тетрадой списка. Так, младший разряд здесь 4h=0100, а значит взведён только бит(2) CTRL_OFFLOADS, и я могу отказаться от него, просто вернув в регистре порта "DriverFeatures" сброшенный этот бит. Следующий второй разряд 6h=0110, а это GSO+MAC, и т.д.
Вот несколько важных замечаний по Features:
1. Если вы случайно передадите не поддерживаемый хостом флаг, он его принудительно сбросит. Поэтому после того-как отправили своё значение, можно сразу считать его обратно, и сравнить с предыдущим. Это придаст уверенности, что скрытых ошибок нет и вы всё делаете правильно.
2. Если не нужны никакие навороты, можно запросить у хоста мин.конфигурацию в виде маски 0х00010020 - это установит всего два бита 5 и 16. После тестов базового функционала, позже поочерёдно добавим ещё и ещё битов оптимизации.
3. Помните, что чем проще ваш код, тем стабильнее он в работе. Правило Airplan-rule гласит: "двухмоторный самолёт по сравнению с одномоторным имеет вдвое больше шансов потерпеть крушение". Поэтому перекладывайте все трудоёмкие задачи на хост - у него толстая шея, и он способен решать серьёзные вопросы.
2. Настройка очередей приёма-передачи данных
Самое сложное во всей кухни VirtioNet - это организация приёма/передачи данных. Заставить весь этот механизм работать как часы без глубоко понимания принципов просто невозможно. Вообще-то суть проста, но довольно плохо представлена в документации. Здесь лежит статья от одного из инженеров Д.Палмера, но и он не договаривает, поэтому я попытаюсь описать своё видение картины.
Значит нам нужно создать 2 обязательные очереди VirtQueue - одну на приём данных от хоста (Receive, RX), а другую на передачу от драйвера (Transmit, TX). Чтобы хост мог их отличить, они имеют фиксированные номера: RX=0, а TX=1. На этапе конфигурации, физ.адрес этих очередей передаём хосту в регистрах порта - сначала выбор
Очереди 0 и 1 представляют собой точную ксерокопию друг-друга, и отличаются всего одним битом "Write" в дескрипторах. После регистрации, они отображаются в разделяемую память Virtio, о которой мы говорили в первой части. Такая схема с отображаемй общей памятью позволяет эффективно обмениваться данными, т.к. по факту это просто проекция одного и того-же участка. Очередь включает в себя три таблицы: массив дескрипторов, кольцо
2.1. Основная структура VIRTQ_DESC
Это массив 16-байтных дескрипторов, которые описывают адреса приёмо-передающих буферов в памяти. Кол-во дескрипторов в массиве определяет хост в регистре
На практике такое кол-во буферов используется редко - это просто резерв на случай, если вдруг понадобятся. Можно вообще обойтись всего одним буфером (и соответственно дескриптором), постоянно перезаписывая новыми данными его содержимое.
Обратите внимание на флаг "Write" - именно этот единственный бит отличает очередь RX от TX. Когда мы передаём свой пакет хосту, нужно записать данные в буфер(TX), после чего сбросить этот бит в его дескрипторе, что значит доступ только на чтение ReadOnly. Но когда наоборот посылаем запрос и ждём ответ от хоста, нужно передать ему адрес приёмного буфера(RX), и чтобы хост смог записать в него, взводим флаг Write. Таким образом, буфер может иметь или атрибут R, или W, но не оба сразу R/W.
В свою очередь бит "Next" объединяет несколько дескрипторов так, что можно собрать (используемые в абсолютно всех пакетах передачи) заголовки Ethernet и прочие в один постоянный буфер с фиксированным размером, а полезные данные Payload уже во-второй буфер. Теперь, когда нужно передать пакет хосту, мы берём буфер с готовым заголовком, и через поле Next в дескрипторе добавляем к нему буфер пайлоада. Обнаружив такую цепочку, хост сам соберёт цельный пакет из двух фрагментов.
2.2. Структура VIRTQ_AVAIL - кольцевая очередь доступных дескрипторов.
Буферы от предыдущих задач можно использовать повторно, а значит нужен механизм отслеживания занятых, и уже отработанных. Эту задачу решают две парные структуры Avail и Used - первая доступна только драйверу, а вторая наоборот хосту. То есть мы не можем (и не должны) ничего записывать в Used, а хост не может в Avail. Таким образом, операции с Avail целиком и полностью лежат на совести нашего драйвера.
• flags - управляет прерываниями, которые лучше не отключать (нужно сбросить в нуль).
• index - это общий счётчик индексов, которые мы передали хосту. При каждой отправке новой порции данных, драйвер должен увеличивать его на 1, и никогда не сбрасывать в нуль. Хост сравнивает это значение со своим внутренним счётчиком, чтобы понять, появились новые пакеты для обработки, или нет. При достижении 16-битного значения 65.535 происходит переполнение, счётчик заворачивается в кольцо, и автоматически начинает отсчёт заново с нуля.
• avail_ring - собственно массив с индексами используемых дескрипторов. Кол-во индексов в цепочке совпадает с кол-вом дескрипторов, т.е. тоже 256 штук. Когда на этапе инициализации устройства мы обнуляем всю выделенную для очередей память, это сбрасывает в начальное состояние поле
Теперь приём/передача данных происходит по такому сценарию:
Это означает, что мы должны последовательно использовать буквально все элементы массива avail_ring, от нуля и до последнего 255, хотя 255 это не константа, а значение регистра
Но бесконтрольный +1 рано или поздно приведёт к тому, что
2.3. Структура VIRTQ_USED - очередь обработанных дескрипторов.
Ну и последняя таблица - это used_ring. Как было сказано выше, драйвер не должен ничего записывать в неё, а только считывать элементы кольца. Как только хост обработает наш буфер (прочитает или запишет в него), в структуру Used он сохраняет отчёт о проделанной работе в виде индекса обработанного дескриптора, и размера данных в буфере. Поле len в каждом элементе кольца имеет смысл только для очереди приёма(RX) - это единственный способ узнать размер пакета, который хост отправил драйверу (иначе мы не сможем вытащить из него пайлоад). Таким образом баланс поддерживают 2 кольца - Avail занимает буферы, а Used освобождает.
На рис.ниже схема взаимосвязи всех структур в одной очереди VirtQueue. Вторая очередь полностью идентична, только отличается флагами RX-TX в дескрипторах. Кроме того, все эти три структуры должны располагаться в памяти строго в определённом порядке - сначала таблица дескрипторов 4КБ, за ней кольцо Avail с дополнением до 4КБ, и в самом конце уже кольцо Used. Дело в том, что при регистрации очереди мы передаём хосту только адрес таблицы дексрипторов (как указатель на голову), а оставшиеся две структуры хост уже находит сам придерживаясь спецификации. Если поменять Avail и Used местами - вся конструкция рухнет.
Инициатором в операциях обмена всегда выступает драйвер, поскольку при любых обстоятельствах хосту требуется буфер в памяти, а его может предоставить только драйвер через свою структуру Avail. В результате цепочка будет:
Вот пример регистрации очередей, который состоит из 4-х пунктов, и после каждого, мы должны в регистре
Обратите внимание, что статус - это двоичная маска, а потому нельзя менять уже установленные в ней биты. То есть правильно будет считать предыдущее значение, логикой
3. Обработка прерываний
Прерывания сигнализируют о каком-либо событии. В контексте VirtioNet только хост может отправлять прерывания драйверу в регистре
После того-как драйвер получит сигнал, он должен запустить свою процедуру обслуживания "Interrupt Service Routine" ISR, и здесь всплывает проблема идентификации события, т.е. определения причины, по которой хост отправил нам прерывание. Ситуацию усугубляет и то, что очереди у нас две RX-TX, а регистр
В конструкции любого из внешних устройств предусмотрена линия IRQ (Interrupt Request, запрос на прерывание), по которой логика девайса могла-бы отправлять свои сигналы контроллеру PIC на мат.плате. Каждой такой линии биос назначает свой номер и сохраняет его в поле "IRQ-Line" конфиг-пространства. Моему VirtioNet биос выделил
Биос не знает как нужно работать с внешним девайсом, поэтому вместо реального обработчика прописывает тупо заглушку, которая состоит всего из одной инструкции
3.1. Polling обработка
Мало обратить IRQ в INT и перехватить вектор прерывания, так внутри обработчика "IsrHandler" теперь нужно определить, от какой из очередей RX-TX пришёл сигнал. Когда вектор 1, а условий 2 (как в нашем случае), по любому требуется циклический опрос обоих очередь, или т.н. "Polling". Другими словами отправили хосту запрос + чистый буфер, и смотрим, вернулось что-нибудь в него или нет. При таких раскладах отпадает надобность и в самих прерываниях! Это самый простой и удобный вариант, который используется повместно, а не только в адаптерах сети.
3.2. Обработка с использованием MSI-X
Когда разработчики поняли проблему, на смену традиционным INTx всего с одним разделяемым вектором, в Virtio пришёл современный механизм "Message Signaled Interrupt" MSI-X, который поддерживает уже 2048 векторов на одно прерывание. Это даёт нам возможность внутри одной линии закодировать 2048 разных сообщений, и на каждое повесить свою процедуру ISR. Соглассно спеке, MSI-X рассчитан на работу Virtio в современном режиме Modern, а у нас здесь разговор про Legacy, в котором используется линия INTx с одним вектором. Всё это сложно и требует отдельного разговора, т.ч. кому интересно - можете заглянуть в спецификацию.
4. Формат пакетов
Ну и под занавес рассмотрим, что-да-как передавать.
Известно, что в любом (передаваемом по сети) пакете, помимо полезных данных Payload всегда имеются заголовки, которые зависят от протокола передачи. Так вот устройства VirtioNet вставляют в самое начало кадра свой заголовок размером 10-байт, а если в Features согласован бит(15) "VIRTIO_NET_F_MRG_RXBUF", то добавляются ещё 2-байта. Вот формат этого заголовка, и в случае несогласования оптимизации GSO, можно просто забить его нулями.
В своей демке я посылаю ARP-запрос хосту, а он должен вернуть мне свой МАС. Обмен такого рода подтвердит работоспособность 2-стороннего обмена в локальной сети. Вот структура самого запроса, который я прописываю в свой буфер передачи TX, а он мне отвечает в заранее подготовленный для этих целей мною буфер RX.
Консольное окно не позволяет вывести приёмный буфер на экран, но в него можно заглянуть командой
5. Заключение
Программирование физических и виртуальных устройств методом "проб и ошибок" довольно интересно, т.к. узнаёшь много нового. Полученные знания можно будет использовать в разных сферах, а не только в данном контексте. Однако это весело, только когда есть офицальная спека от производителя, иначе приходится сутками реверсить чужой код, и хорошо, если в итоге что-то получится. В скрепке найдёте весь исходный код драйвера VirtioNet на ассемблере fasm, а так-же готовый img-образ флопика для тестов в эмуляторе QEMU. Всем удачи, пока!
Оглавление - часть(2):
1. Конфигурирование устройства
2. Настройка очередей приёма-передачи данных
3. Работа с прерываниями
4. Тестирование - отправка ARP-запроса на хост
5. Заключение
1. Конфигурирование адаптера Virtio-Net
Мы уже знаем, что порт из BAR[0] открывает доступ к регистрам в режиме Legacy, и теперь нужно согласовать способ взаимодействия с устройством на хосте. Схема здесь простая - девайс в первом регистре "DeviceFeatures" передаёт нашему драйверу список поддерживаемых им функций, а мы должны выбрать из этого списка только те, которые планируем программно у себя реализовать. Другими словами хост говорит "я умею это-это-это", а дров отвечает "мне нужно только это и это". Список представляет собой 32-битную маску (в режиме Modern 64-бита), в которой каждый бит определяет 1 фишку.
Код:
;// Регистры Virtio-Net в порту BAR0
DEVICE_FEATURES = 00h ; 4 байта R. / маска со списком функций, которые предлагает нам хост,
DRIVER_FEATURES = 04h ; 4 байта RW. / а сюда мы возвращаем то, что выбрали (см. "VIRTIO_NET_F_xx" ниже).
QUEUE_ADDRESS = 08h ; 4 байта RW. / адрес очередей приёма-передачи данных
QUEUE_SIZE = 0Ch ; 2 байта R. / размер очереди в 16-байтных дескрипторах (задаёт хост)
QUEUE_SELECT = 0Eh ; 2 байта RW. / выбор очереди: 0=RX (приём), 1=TX (передача)
QUEUE_NOTIFY = 10h ; 2 байта W. / уведомление хосту об операциях с очередями RX/TX (kick = пинок для пробуждения)
DEVICE_STATUS = 12h ; 1 байт RW. / текущий статус драйвера (см. ниже)
ISR_STATUS = 13h ; 1 байт R. / статус прерываний: 1=сработало
NET_MAC = 14h ; 6 байт R. / МАС-адрес сетевого адаптера (можно изменить в bat-файле)
NET_STATUS = 1Ah ; 2 байта RW. / 1 = сетевой кабель подключён
В спеке на Virtio описывается каждый бит маски, но это запутанный квест, а не любезная помощь студенту. В нём напрочь отсутствует логика и совсем не понятно, за что именно отвечает тот или иной флаг. А ведь выбирать себе функции нужно с особой осторожностью - если что-то включаешь, то в драйвере обязательно должна быть его программная поддержка, иначе хост войдёт в бесконечный цикл ожидания ответа на нереализованную нами функцию.
Обычно с толку сбивают биты TSO, GSO, UFO, ECN - рассмотрим их подробней.
Известно, что стандартный размер полезной нагрузки Payload в сетевом пакете равен 1500 байт, и если мы хотим передать за один раз например 4КБ данных, потребуется фрагментация сегмента на мелкие пакеты 1500+1500+1500. Параметром MTU хост сообщает макс.поддерживаемый им размер большого сегмента (обычно 9000 байт, Jumbo-Frames).
Так вот биты TSO и UFO выбирают, кто будет заниматься непосредственной фрагментацией больших пакетов - наш драйвер, или хост уже на своей стороне. Разумней переложить эту обязанность на хост, тогда мы сможем (без предварительных ласк) передавать за один раз пакеты размером до 64КБ. Биты подразумевают "TCP segmentation offload" и "UDP fragmentation offload", и при желании можно вообще не включать их, т.к. это просто оптимизация транспортного уровня L4 модели OSI.
В свою очередь хитрый бит GSO (generic) предлагает всем другую идею: "А давайте мы вообще не будем резать большой пакет на множество мелких, а делегируем эту задачу физ.адаптеру NIC". Ну и последний бит ECN выступает просто в роли навигатора, заранее сообщая о создавшихся на дороге пробках. В дефолте, когда входящих пакетов много и сеть перегружена, адаптер может тупо отбрасывать половину из них (дропать), а бит ECN предупреждает об этом - опция нам пока не нужна.
Код:
| Бит | За что отвечает
+------+-----------------------------------------
| CSUM | Кто считает контрольную сумму пакетов
| TSO | Кто режет большой TCP-пакет на маленькие
| GSO | Кто может работать с большими пакетами до их разбиения
| UFO | Аналогичная оптимизация для UDP
Вот как распределены биты в 32-битной маске "DeviceFeatures". Для удобства я разделит их на тетрады. Например мой хост передал мне значение 0x79BF8064, и теперь я могу сопоставить каждый из 8-ми разрядов этого значения, с соответствующей тетрадой списка. Так, младший разряд здесь 4h=0100, а значит взведён только бит(2) CTRL_OFFLOADS, и я могу отказаться от него, просто вернув в регистре порта "DriverFeatures" сброшенный этот бит. Следующий второй разряд 6h=0110, а это GSO+MAC, и т.д.
Код:
;//*********************************
;//-------> 5.1.3 Feature bits
1: VIRTIO_NET_F_CSUM = 0 ; Хост считает контрольную сумму.
VIRTIO_NET_F_GUEST_CSUM = 1 ; Драйвер тоже умеет ^^^.
VIRTIO_NET_F_CTRL_OFFLOADS = 2 ; Драйвер может динамически менять TSO/GSO через канал управления.
VIRTIO_NET_F_MTU = 3 ; Поддерживается отчет о макс MTU устройства (только Modern).
2: Reserved = 4 ; <----------(!) резерв
VIRTIO_NET_F_MAC = 5 ; MAC-адрес устанавливает хост - обязателен!
VIRTIO_NET_F_GSO = 6 ; Хост обрабатывает пакеты GSO любого типа (до 64Кб)
VIRTIO_NET_F_GUEST_TSO4 = 7 ; Драйвер будет резать большие пакеты TCP/IPv4 на мелкие.
3: VIRTIO_NET_F_GUEST_TSO6 = 8 ; Аналогично ^^^ для IPv6
VIRTIO_NET_F_GUEST_ECN = 9 ; Драйвер может получать ECN
VIRTIO_NET_F_GUEST_UFO = 10 ; Драйвер будет резать большие фрагменты UDP
VIRTIO_NET_F_HOST_TSO4 = 11 ; Хост будет резать большие пакеты IPv4.
4: VIRTIO_NET_F_HOST_TSO6 = 12 ; Аналогично ^^^ для IPv6
VIRTIO_NET_F_HOST_ECN = 13 ; Хост может получать IPv6 с ECN.
VIRTIO_NET_F_HOST_UFO = 14 ; Хост режет UDP.
VIRTIO_NET_F_MRG_RXBUF = 15 ; Драйвер может объединять буферы приёма.
5: VIRTIO_NET_F_STATUS = 16 ; Активировать поле статуса линка (сетевой кабель подключён).
VIRTIO_NET_F_CTRL_VQ = 17 ; Доступен канал управления = доп.очередь(2), к очередям приёма(RX=0), и передачи(TX=1).
VIRTIO_NET_F_CTRL_RX = 18 ; Поддержка режима RX в канале управления.
VIRTIO_NET_F_CTRL_VLAN = 19 ; Фильтрация VLAN в канале управления.
6: Reserved = 20 ; <----------(!) резерв
VIRTIO_NET_F_GUEST_ANNOUNCE = 21 ; Драйвер может отправлять ненужные пакеты.
VIRTIO_NET_F_MQ = 22 ; Хост поддерживает многоочерёдность с авто-управлением приёма.
VIRTIO_NET_F_CTRL_MAC_ADDR = 23 ; Драйвер может изменить MAC-адаптера через канал управления.
7: VIRTIO_F_NOTIFY_ON_EMPTY = 24 ; Получаем ли мы обратные вызовы, когда кольцевые буферы заполнены?
Reserved = 25 ; <----------(!) резерв
Reserved = 26 ; <----------(!) резерв
VIRTIO_F_ANY_LAYOUT = 27 ; Может ли хост обрабатывать любой макет дескриптора?
8: VIRTIO_RING_F_INDIRECT_DESC = 28 ; Флаг поддержки коссвенных дескрипторов.
VIRTIO_RING_F_EVENT_IDX = 29 ; Использовать оповещения через ивенты в Avail и Used (гибкое управление прерываниями).
VIRTIO_F_BAD_FEATURE = 30 ; Драйвер неисправен! Требуется аппаратный сброс адаптера NIC.
VIRTIO_F_FEATURES_HIGH = 31 ; Флаг наличия дополнительных битов(32:63)
Вот несколько важных замечаний по Features:
1. Если вы случайно передадите не поддерживаемый хостом флаг, он его принудительно сбросит. Поэтому после того-как отправили своё значение, можно сразу считать его обратно, и сравнить с предыдущим. Это придаст уверенности, что скрытых ошибок нет и вы всё делаете правильно.
2. Если не нужны никакие навороты, можно запросить у хоста мин.конфигурацию в виде маски 0х00010020 - это установит всего два бита 5 и 16. После тестов базового функционала, позже поочерёдно добавим ещё и ещё битов оптимизации.
3. Помните, что чем проще ваш код, тем стабильнее он в работе. Правило Airplan-rule гласит: "двухмоторный самолёт по сравнению с одномоторным имеет вдвое больше шансов потерпеть крушение". Поэтому перекладывайте все трудоёмкие задачи на хост - у него толстая шея, и он способен решать серьёзные вопросы.
C-подобный:
;// Установка флагов гостевых функций
mov dx,[BAR0] ;// номер порта Virtio-Net
add dx,DRIVER_FEATURES ;// выбираем регистр
;// вариант (1) - константа (посчитать на бумажке или в калькуляторе)
mov eax,0x00010020 ;// мин.конфиг
out dx,eax ;// отправить маску в регистр!
;// вариант (2) - побитовая сборка маски в динамике
mov eax,1 shl 5 ;// установить бит(5) сдвигом влево (shift left)
or eax,1 shl 16 ;// добавить бит(16)
or eax,1 shl 29 ;// добавить бит(29), и т.д...
push eax ;// запомнить
out dx,eax ;// запись в регистр порта!
;// тест на ошибку
in eax,dx ;// считать обратно
pop ebx ;// взять оригинал со-стека
cmp eax,ebx ;// сравнить
jnz @features_error ;// если не равно - ошибка (пересмотреть маску)
;//.....
2. Настройка очередей приёма-передачи данных
Самое сложное во всей кухни VirtioNet - это организация приёма/передачи данных. Заставить весь этот механизм работать как часы без глубоко понимания принципов просто невозможно. Вообще-то суть проста, но довольно плохо представлена в документации. Здесь лежит статья от одного из инженеров Д.Палмера, но и он не договаривает, поэтому я попытаюсь описать своё видение картины.
Значит нам нужно создать 2 обязательные очереди VirtQueue - одну на приём данных от хоста (Receive, RX), а другую на передачу от драйвера (Transmit, TX). Чтобы хост мог их отличить, они имеют фиксированные номера: RX=0, а TX=1. На этапе конфигурации, физ.адрес этих очередей передаём хосту в регистрах порта - сначала выбор
QUEUE_SELECT 0/1, а затем указатель в QUEUE_ADDRESS. Одна очередь занимает в памяти 3 (обязательно последовательные) страницы по 4КБ итого 12, и соответственно обе очереди RX-TX требуют 24КБ физической ОЗУ.Очереди 0 и 1 представляют собой точную ксерокопию друг-друга, и отличаются всего одним битом "Write" в дескрипторах. После регистрации, они отображаются в разделяемую память Virtio, о которой мы говорили в первой части. Такая схема с отображаемй общей памятью позволяет эффективно обмениваться данными, т.к. по факту это просто проекция одного и того-же участка. Очередь включает в себя три таблицы: массив дескрипторов, кольцо
Avail для драйвера, и кольцо Used для хоста.2.1. Основная структура VIRTQ_DESC
Это массив 16-байтных дескрипторов, которые описывают адреса приёмо-передающих буферов в памяти. Кол-во дескрипторов в массиве определяет хост в регистре
QUEUE_SIZE порта (см.выше), и как-правило их 256 штук. Таким образом, драйвер может иметь до 256 безпорядочно разбросанных по всей памяти буферов, а в каждый из дескрипторов мы будем записывать их адреса. Поскольку размер одного 16-байт, а всего их 256, весь массив занимает ровно 4КБ памяти.На практике такое кол-во буферов используется редко - это просто резерв на случай, если вдруг понадобятся. Можно вообще обойтись всего одним буфером (и соответственно дескриптором), постоянно перезаписывая новыми данными его содержимое.
C-подобный:
struct VIRTQ_DESC ;//<----- sizeof 16 байт
addr dq 0 ;// 64-битный физ.адрес буфера в памяти
len dd 0 ;// его длина/размер
flags dw 0 ;// указанные ниже флаги
next dw 0 ;// номер/индекс сл.дескриптора, если флаг = NEXT
ends
VIRTQ_DESC_F_NEXT = 0001 = 1 ;// установить для связи нескольких дескрипторов (объединяет буферы)
VIRTQ_DESC_F_WRITE = 0010 = 2 ;// буфер доступен для записи
Обратите внимание на флаг "Write" - именно этот единственный бит отличает очередь RX от TX. Когда мы передаём свой пакет хосту, нужно записать данные в буфер(TX), после чего сбросить этот бит в его дескрипторе, что значит доступ только на чтение ReadOnly. Но когда наоборот посылаем запрос и ждём ответ от хоста, нужно передать ему адрес приёмного буфера(RX), и чтобы хост смог записать в него, взводим флаг Write. Таким образом, буфер может иметь или атрибут R, или W, но не оба сразу R/W.
В свою очередь бит "Next" объединяет несколько дескрипторов так, что можно собрать (используемые в абсолютно всех пакетах передачи) заголовки Ethernet и прочие в один постоянный буфер с фиксированным размером, а полезные данные Payload уже во-второй буфер. Теперь, когда нужно передать пакет хосту, мы берём буфер с готовым заголовком, и через поле Next в дескрипторе добавляем к нему буфер пайлоада. Обнаружив такую цепочку, хост сам соберёт цельный пакет из двух фрагментов.
2.2. Структура VIRTQ_AVAIL - кольцевая очередь доступных дескрипторов.
Буферы от предыдущих задач можно использовать повторно, а значит нужен механизм отслеживания занятых, и уже отработанных. Эту задачу решают две парные структуры Avail и Used - первая доступна только драйверу, а вторая наоборот хосту. То есть мы не можем (и не должны) ничего записывать в Used, а хост не может в Avail. Таким образом, операции с Avail целиком и полностью лежат на совести нашего драйвера.
C-подобный:
struct VIRTQ_AVAIL ;//<--- sizeof = (256x2)+6 = 518 байт
flags dw 0 ;// 0 = вкл прерывания, 1 = выключить
index dw 0 ;// счётчик операций
avail_ring rw QueueSize ;// массив индексов доступных буферов (длина лежит в регистре QueueSize)
used_event dw 0 ;// опционально (только если в Features согласован F_EVENT_IDX=29)
ends
• flags - управляет прерываниями, которые лучше не отключать (нужно сбросить в нуль).
• index - это общий счётчик индексов, которые мы передали хосту. При каждой отправке новой порции данных, драйвер должен увеличивать его на 1, и никогда не сбрасывать в нуль. Хост сравнивает это значение со своим внутренним счётчиком, чтобы понять, появились новые пакеты для обработки, или нет. При достижении 16-битного значения 65.535 происходит переполнение, счётчик заворачивается в кольцо, и автоматически начинает отсчёт заново с нуля.
• avail_ring - собственно массив с индексами используемых дескрипторов. Кол-во индексов в цепочке совпадает с кол-вом дескрипторов, т.е. тоже 256 штук. Когда на этапе инициализации устройства мы обнуляем всю выделенную для очередей память, это сбрасывает в начальное состояние поле
Index=0, и весь массив Ring.Теперь приём/передача данных происходит по такому сценарию:
1. В элемент[0] массива Avail_Ring мы записываем индекс/номер дескриптора, который хотим использовать для операции обмена (можно выбрать любой).
2. В этом дескрипторе указываем адрес, размер и атрибут буфера (чтение=0, или запись=2).
3. Теперь увеличиваем поле Index на 1, в результате чего он будет смотреть уже на элемент[1] кольца Ring, после текущего элемента[0].
4. И наконец выбираем очередь в регистре
QUEUE_SELECT (RX=0, или TX=1), после чего отправляем хосту оповещение через регистр QUEUE_NOTIFY.
5. Хост от этого просыпается, читает поле
Avail->Index из указанной очереди, и сравнивает его со-своим счётчиком, который прочитал в последний раз. Счётчик хоста всегда будет отставать на 1, указывая таким образом на текущий наш дескриптор из массива Ring.Это означает, что мы должны последовательно использовать буквально все элементы массива avail_ring, от нуля и до последнего 255, хотя 255 это не константа, а значение регистра
QUEUE_SIZE порта Virtio-Net. Важно понять, что поле index мы увеличиваем на 1 для того, чтобы в следующий раз точно определить позицию очередного элемента в длином массиве.Но бесконтрольный +1 рано или поздно приведёт к тому, что
index с макс.значением 65.535 выйдет за границу avail_ring[0..255]. Это приведёт к катастрофе, поэтому драйвер использует ограничение: avail_ring[x] = index % queue_size. То есть мы используем не чистый индекс, а его значение по модулю queue_size (остаток от деления на 256). Как результат, поле index будет продолжать отсчёт до 65.535 для хоста, а внутри драйвера получим замкнутый круг avail_ring[0..255 -> 0..255]. Собственно поэтому VirtQueue и называют кольцевой очередью "Ring", которую можно сравнить с конвейером на заводе.2.3. Структура VIRTQ_USED - очередь обработанных дескрипторов.
Ну и последняя таблица - это used_ring. Как было сказано выше, драйвер не должен ничего записывать в неё, а только считывать элементы кольца. Как только хост обработает наш буфер (прочитает или запишет в него), в структуру Used он сохраняет отчёт о проделанной работе в виде индекса обработанного дескриптора, и размера данных в буфере. Поле len в каждом элементе кольца имеет смысл только для очереди приёма(RX) - это единственный способ узнать размер пакета, который хост отправил драйверу (иначе мы не сможем вытащить из него пайлоад). Таким образом баланс поддерживают 2 кольца - Avail занимает буферы, а Used освобождает.
C-подобный:
struct used
id dd 0 ;// индекс обработанного дескриптора
len dd 0 ;// размер данных в буфере
ends
struct VIRTQ_USED ;<---// sizeof = (256x8)+6 = 2.054 байта
flags dw 0 ;// управление прерываниями (установить в нуль)
index dw 0 ;// счётчик обработанных элементов (макс 65.535)
used_ring used(0),used(1),..,used(255) ;//<---- кольцо
avail_event dw 0 ;// только если согласовано VIRTIO_F_EVENT_IDX
ends
На рис.ниже схема взаимосвязи всех структур в одной очереди VirtQueue. Вторая очередь полностью идентична, только отличается флагами RX-TX в дескрипторах. Кроме того, все эти три структуры должны располагаться в памяти строго в определённом порядке - сначала таблица дескрипторов 4КБ, за ней кольцо Avail с дополнением до 4КБ, и в самом конце уже кольцо Used. Дело в том, что при регистрации очереди мы передаём хосту только адрес таблицы дексрипторов (как указатель на голову), а оставшиеся две структуры хост уже находит сам придерживаясь спецификации. Если поменять Avail и Used местами - вся конструкция рухнет.
C-подобный:
struct VirtQueue
desc VIRTQ_DESC ;// таблица дескрипторов
avail VIRTQ_AVAIL ;// кольцо Avail
padding db ? ;// байты выравнивания до 4КБ
used VIRTQ_USED ;// кольцо Used
ends
Инициатором в операциях обмена всегда выступает драйвер, поскольку при любых обстоятельствах хосту требуется буфер в памяти, а его может предоставить только драйвер через свою структуру Avail. В результате цепочка будет:
Avail -> Desc -> Used. Здесь драйвер использует всего 4 буфера из возможных 256, при этом буфер(0) задействуется повторно уже после того, как хост завершит операцию(1), переместит дескриптор в пул свободных Used, и пошлёт прерывание драйверу.Вот пример регистрации очередей, который состоит из 4-х пунктов, и после каждого, мы должны в регистре
DEVICE_STATUS отправлять хосту отчёт, что именно сделали на текущий момент. Регистр доступен не только на запись, но и на чтение - так мы сможем проверить, принял хост наш статус, или в процессе настройки произошла ошибка. Если хост обнаружит ошибку, в регистре DEVICE_STATUS он вернёт значение, которое мы отправили ему в последний раз.Обратите внимание, что статус - это двоичная маска, а потому нельзя менять уже установленные в ней биты. То есть правильно будет считать предыдущее значение, логикой
OR прибавить к нему новое, и отправить хосту уже их сумму. На заключительном этапе, статус должен быть равен 0Fh, т.е. выставлены в единицу все 4 бита:
C-подобный:
;// Коды статуса, чтобы сообщить хосту о прогрессе, и синхронизировать работу
DEVICE_RESET = 0 ;// сброс устройства в дефолт
ACKNOWLEDGE = 1 ;// мы обнаружили девайс Virtio-Net,
DRIVER = 2 ;// ...и знаем как им управлять
DRIVER_OK = 4 ;// драйвер использовал свои части конфигурации
FEATURES_OK = 8 ;// драйвер завершил настройку всех функций!
NEEDS_RESET = 64 ;// ошибка устройства на хосте, драйвер должен сбросить его в Reset
;//*******************************************************//
;//********** Настраиваем конфиги девайса Net ***********//
;//*******************************************************//
@Configure:
mov dx,[BAR0] ;//<---- номер порта Virtio-Net
add dx,DEVICE_STATUS ;// выбираем в нём регистр
and al,0 ;//
out dx,al ;// сброс устройства в дефолт!
mov al,3 ;//
out dx,al ;// статус ACKNOWLEDGE + DRIVER
;// Узнать размер очередей (в дескрипторах)
mov dx,[BAR0] ;//
add dx,QUEUE_SIZE ;//
in ax,dx ;// прочитать (обычно 256 = 0x100)
movzx ecx,ax ;// будет циклом
shl ecx,4 ;// x16 (умножить на размер одного дескриптора)
shl ecx,1 ;// х2 для обоих очередей RX/TX
mov ebx,0x00120000 ;// базовый адрес для очередей (выбираем на свой вкус)
@@: mov dword[fs:ebx],0 ;// забить область нулями!
add ebx,4 ;// 8 страниц по 4 КБ
loop @b ;//
;// Свойства очереди(0) = RX, приём Receive
mov dx,[BAR0] ;//
add dx,QUEUE_SELECT ;//
xor ax,ax ;//
out dx,ax ;// выбрать очередь(0)
mov dx,[BAR0] ;//
add dx,QUEUE_ADDRESS ;//
mov eax,0x00120000 ;// передаём этот адрес хосту,
shr eax,12 ;// ..как физ в страницах по 4 КБ (pfn).
out dx,eax ;//
;// Уведомление хосту, что очередь(0) была изменена
mov dx,[BAR0] ;//
add dx,QUEUE_NOTIFY ;//
xor ax,ax ;// номер очереди = 0
out dx,ax ;//
;// Определить очередь(1) = TX, передача Transmit
mov dx,[BAR0] ;//
add dx,QUEUE_SELECT ;//
mov ax,1 ;// номер очереди = 1
out dx,ax ;//
mov dx,[BAR0] ;//
add dx,QUEUE_ADDRESS ;//
mov eax,0x00124000 ;// физ.адрес очереди(1) = сразу после RX
shr eax,12 ;// делаем из него PFN (убрать мл.12-бит)
out dx,eax ;//
;// Уведомление хосту, что очередь(1) была изменена
mov dx,[BAR0] ;//
add dx,QUEUE_NOTIFY ;//
mov ax,1 ;//
out dx,ax ;//
;// Cтатус "DRIVER_OK" (завершили настройки)
mov dx,[BAR0] ;//
add dx,DEVICE_STATUS ;//
in al,dx ;// прочитаем предыдущий статус
cmp al,3 ;// это Acknowledge + Driver ?
jnz @errStatus ;// тест на ошибку
or al,4 ;// добавить "Driver_OK"
out dx,al ;//
in al,dx ;//
cmp al,7 ;//
jnz @errStatus ;// тест на ошибку
or al,8 ;// добавить "Features_OK"
out dx,al
in al,dx
cmp al,0x0F ;// если статус 0x0F, значит всё ОК!
jnz @errStatus
3. Обработка прерываний
Прерывания сигнализируют о каком-либо событии. В контексте VirtioNet только хост может отправлять прерывания драйверу в регистре
ISR_STATUS после записи в кольцо used_ring. Однако и драйвер со-своей стороны тоже посылает маяки хосту после подготовки дескрипторов и записи в avail_ring, только делает он это уже через оповещения в регистре QUEUE_NOTIFY. Регистр статуса может принимать сл.значения: 0=чисто, 1=прерывание от очереди, 2=изменилась конфигурация.После того-как драйвер получит сигнал, он должен запустить свою процедуру обслуживания "Interrupt Service Routine" ISR, и здесь всплывает проблема идентификации события, т.е. определения причины, по которой хост отправил нам прерывание. Ситуацию усугубляет и то, что очереди у нас две RX-TX, а регистр
ISR_STATUS один - вот и разберись, из какой именно очереди к нам постучались. Когда трафик течёт непрерывным потоком (что типично для сетевых адаптеров), нужна быстрая реакция на события, иначе получим затор и потерю пакетов.В конструкции любого из внешних устройств предусмотрена линия IRQ (Interrupt Request, запрос на прерывание), по которой логика девайса могла-бы отправлять свои сигналы контроллеру PIC на мат.плате. Каждой такой линии биос назначает свой номер и сохраняет его в поле "IRQ-Line" конфиг-пространства. Моему VirtioNet биос выделил
IRQ-11, и теперь используя формулу ниже я смогу узнать вектор INT, куда биос поместил ISR моего прерывания - как видим это INT-73h.Биос не знает как нужно работать с внешним девайсом, поэтому вместо реального обработчика прописывает тупо заглушку, которая состоит всего из одной инструкции
iret (сразу на выход). Теперь задача перехватить вектор 73h в системной таблице IVT (Interrupt Vector Table), чтобы он указывал на мою процедуру ISR - вот пример:
C-подобный:
;// ***************************************
;// Вычисляем INT по IRQ
;// и перехватываем прерывание
;// ***************************************
xor ax,ax
mov al,[irq] ;// ax = irq
sub al,8 ;// см.формулу выше
add al,0x70 ;// ax = int
shl ax,2 ;// x4 = адрес вектора в IVT
mov di,ax ;//
push es 0 ;// настраиваем ES:DI на IVT
pop es ;//
mov ax,IsrHandler ;// смещение на наш обработчик
mov word[es:di],ax ;// пропись в ivt
mov ax,cs ;// сегмент обработчика
mov word[es:di+2],ax ;// пропись в ivt
pop es ;// восстановить ES
3.1. Polling обработка
Мало обратить IRQ в INT и перехватить вектор прерывания, так внутри обработчика "IsrHandler" теперь нужно определить, от какой из очередей RX-TX пришёл сигнал. Когда вектор 1, а условий 2 (как в нашем случае), по любому требуется циклический опрос обоих очередь, или т.н. "Polling". Другими словами отправили хосту запрос + чистый буфер, и смотрим, вернулось что-нибудь в него или нет. При таких раскладах отпадает надобность и в самих прерываниях! Это самый простой и удобный вариант, который используется повместно, а не только в адаптерах сети.
C-подобный:
hlt ;// ждать до первого прерывания таймера..
mov ebx,0x00123010 ;// проверить приёмный буфер RX
cmp dword[fs:ebx],0 ;//
jnz @found ;// если не нуль, значит данные пришли
mov ebx,0x00125010 ;// проверить приёмный буфер TX
cmp dword[fs:ebx],0 ;//
jnz @found ;//
3.2. Обработка с использованием MSI-X
Когда разработчики поняли проблему, на смену традиционным INTx всего с одним разделяемым вектором, в Virtio пришёл современный механизм "Message Signaled Interrupt" MSI-X, который поддерживает уже 2048 векторов на одно прерывание. Это даёт нам возможность внутри одной линии закодировать 2048 разных сообщений, и на каждое повесить свою процедуру ISR. Соглассно спеке, MSI-X рассчитан на работу Virtio в современном режиме Modern, а у нас здесь разговор про Legacy, в котором используется линия INTx с одним вектором. Всё это сложно и требует отдельного разговора, т.ч. кому интересно - можете заглянуть в спецификацию.
4. Формат пакетов
Ну и под занавес рассмотрим, что-да-как передавать.
Известно, что в любом (передаваемом по сети) пакете, помимо полезных данных Payload всегда имеются заголовки, которые зависят от протокола передачи. Так вот устройства VirtioNet вставляют в самое начало кадра свой заголовок размером 10-байт, а если в Features согласован бит(15) "VIRTIO_NET_F_MRG_RXBUF", то добавляются ещё 2-байта. Вот формат этого заголовка, и в случае несогласования оптимизации GSO, можно просто забить его нулями.
C-подобный:
struct virtio_net_hdr ;//<---- sizeof 10+2 байт.
flags db 0 ;// флаги см.инкдул или спеку
gso_type db 0 ;// 0:Нет 1:TCPv4 3:UDP 4:TCPv6 0x80:ECN
hdr_len dw 0 ;// размер заголовка, который будет использоваться во время сегментации выше
gso_size dw 0 ;// макс.размер сегмента (без заголовока)
csum_start dw 0 ;// позиция начала вычисления контрольной суммы CRC
csum_offset dw 0 ;// позиция после ChecksumStart для хранения контрольной суммы
buff_count dw 0 ;// используется при объединении буферов VIRTIO_NET_F_MRG_RXBUF
ends
В своей демке я посылаю ARP-запрос хосту, а он должен вернуть мне свой МАС. Обмен такого рода подтвердит работоспособность 2-стороннего обмена в локальной сети. Вот структура самого запроса, который я прописываю в свой буфер передачи TX, а он мне отвечает в заранее подготовленный для этих целей мною буфер RX.
C-подобный:
arpRequest:
;// ===== VirtIO Header (10 байт) =====
db 0,0,0,0,0,0,0,0,0,0
;// ===== Ethernet Header (14 байт) =====
db 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF ;// Dest MAC: Broadcast
db 0x52,0x54,0x00,0x12,0x34,0x56 ;// Src MAC
db 08,06 ;// EtherType: ARP = 0x0806
;// ===== ARP Payload (28 байт) =====
db 00,01 ;// Hardware: Ethernet = 0x0001
db 08,00 ;// Protocol: IPv4 = 0x0800
db 06,04 ;// hlen=6, plen=4
db 00,01 ;// Operation: Request = 0x0001
db 0x52,0x54,0x00,0x12,0x34,0x56 ;// Sender MAC (вписать свой мас)
db 0,0,0,0 ;// Sender IP (0.0.0.0)
db 0,0,0,0,0,0 ;// Target MAC (неизвестен)
db 10,00,02,02 ;// Target IP = 10.0.2.2 (шлюз)
Консольное окно не позволяет вывести приёмный буфер на экран, но в него можно заглянуть командой
xp /100hx <адрес буфа>. Здесь видно, что в дескрипторе очереди TX я передаю адрес буфера 0x00127000 и его размер 0x34=52 байта, а хост мне возвращает в очереди RX used_ring размер отправленных им данных 0x4A=74 байта.5. Заключение
Программирование физических и виртуальных устройств методом "проб и ошибок" довольно интересно, т.к. узнаёшь много нового. Полученные знания можно будет использовать в разных сферах, а не только в данном контексте. Однако это весело, только когда есть офицальная спека от производителя, иначе приходится сутками реверсить чужой код, и хорошо, если в итоге что-то получится. В скрепке найдёте весь исходный код драйвера VirtioNet на ассемблере fasm, а так-же готовый img-образ флопика для тестов в эмуляторе QEMU. Всем удачи, пока!
AI-выжимка
сгенерировано ИИ
Тезисы статьи скоро
Статья читается полностью. Тезисы со ссылками на разделы появятся позже.
Содержание
Codeby Academy
Практика и мастерство
От основ до продвинутого — программы для практиков Codeby.
Перейти к курсу →
Поиск в обсуждении
Продолжить чтение
Следующий разбор
IDA Pro дизассемблер: версии, лицензии и Ghidra
Комментарии
0