Наблюдая за тем, как бурно развивается операционная система Linux, нельзя не заметить, что кое в чем ее развитие достаточно хаотично. Поговаривают, Уинстон Черчилль в свое время отметил: «Американцы обязательно найдут правильное решение,... по пути попробовав все остальные».
Стиль поиска решений Linux-разработчиками напоминает иногда тех американцев. Очень часто побеждают решения, которые попросту где-то появились раньше, даже, что если они в чем-то слабее других известных. В какой-то степени это напоминает стиль Microsoft: пусть хуже, но немедленно; пусть не совершенно, но дешево и много.
Рассмотрим такие решения на примере реализации сетевых средств.
Лили, лили, лили воду в решето...
Как, в принципе, следует реализовывать сетевые интерфейсы? Попробуйте открыть любой посвященный TCP/IP талмуд, например, [1], и на вас обрушится поток информации о разнообразных RFC, форматах пакетов, уровнях модели OSI и т.п. Тому, кто разберется во всех этих премудростях, в конечном итоге станет ясно, что выданный программой блок информации (сообщение) должен быть несколько раз преобразован, прежде чем попадет на носитель того или иного рода. Формально это можно отобразить последовательностью различных видов пакетов (рис. 1).
Рис. 1. Последовательность разных типов пакетов |
На этом формальном пути потребуется несколько раз скопировать пакет целиком с места на место. Ясно, что данная процедура не прибавляет системе производительности. Хотелось бы действовать рациональнее, но как?
Укажем еще на один момент. Как выглядит традиционный драйвер Unix? Как правило, это монолитный текст с унифицированным набором системных вызовов типа open, close, read, write, ioctl, плюс те, что в данном варианте ОС используются для конфигурирования. Подобный подход в чистом виде плох, поскольку не учитывает возможности наличия общих черт у различных устройств. Во-первых, как только они обнаруживаются, хочется выделить что-то в отдельную подсистему ядра со специальной поддержкой со стороны драйвера. Таким образом, например, оформляется идея буферного кэша. Драйверные процедуры read/write обмениваются «своими» блоками с кэшем, а, когда тому необходимо явным образом что-то записать/прочитать, он обращается к специальной процедуре планирования драйвера. Во-вторых, можно не создавать специальную подсистему, а просто иметь специальную библиотеку, примером подобного рода является так называемая line discipline для терминального драйвера. В-третьих, когда этого мало, в дело вступает и специальная подсистема, и специальная библиотека, и масса процессов для обеспечения «взлета». Тем самым, драйверы сами по себе в настоящее время могут стать существенно проще (они ведь описывают только особенности аппаратуры данного типа), а подсистемы и библиотеки, которые их обслуживают «толще».
В конечном итоге мы приходим к мысли, что вместо классического монолитного драйвера хотелось бы видеть что-то модульное, возможно, многоуровневое, где различные модули и уровни отвечали бы и за фактическое многоуровневое деление протокола, особенности шины, через которую подключено устройство (PCI, VME), тип оконечного устройства (диск, лента), архитектурные особенности подключения (LSB/MSB), способ и размещение регистров и пространства управления устройством и т.д. Задача унификации интерфейсов оказалась настолько сложна, что стандарт, который позволил бы использовать общие тексты в рамках различных реализаций ОС Unix, развивается достаточно долго и без особых видимых результатов. Действительно, инициатива создания унифицированного интерфейса драйверов (Uniform Driver Interface — UDI) была поддержана как компаниями, выпускающими операционные системы, так и разработчиками оборудования (www.projectudi.org). Свое одобрение высказало и сообщество Linux, выразившее надежду на то, что новые спецификации помогут повысить совместимость оборудования, выпускаемого для Unix-систем. Однако воз и ныне там.
Скованные одной цепью
Итак, как решить задачи по оптимизации и унификации драйверов? Одно из возможных решений было предложено лет пятнадцать назад в UNIX System V. Речь идет о технологии Streams [2], которая фактически позволила достичь следующих целей: преемственности, унификации, гибкости, оптимизации.
Вернемся на шаг назад и посмотрим, что представляет собой преобразование пакетов, которое мы ранее отобразили в виде цепочки (рис. 2а). Чаще всего преобразование просто добавляет что-то в конец и начало пакета при посылке его в линию и, соответственно, отбрасывает — при возвращении назад. Если нет желания копировать данные с места на место (особенно обидно за центральную часть пакета), необходимо найти какой-либо способ, который бы позволил этим не заниматься.
Решение первое исходит из того, что поскольку динамически в начало пакета без переписывания ничего добавить нельзя, сам пакет должен быть представлен в виде цепочки блоков. В данном случае один длинный пакет превратился в цепочку блоков. Каждый элемент цепочки представлен в виде провязанного списка описателей и собственно данных. В принципе, конечно, можно было бы хранить описатель вместе с данными, но есть ряд причин хранить их отдельно. Чем, в принципе, может быть плоха эта схема? Несколько сложнее становится просмотр, скажем, всех байтов пакета. Вызывает сомнение возможность использования данной структуры для работы с внешним устройством. Как же это внешнее устройство, да заберет этакую структуру из памяти? А вот этого как раз можно не бояться. Более того, по умолчанию новые контроллеры (и сетевые, и SCSI, и USB, и т.д., и т.п.) сами используют аналогичные структуры для описания отдельных фрагментов тех данных, которые передаются на устройство или принимаются с него. Фактически, каждый обмен с внешним устройством описывается набором подпакетиков с указанием адреса данных и размеров кусочка; часть кусочков может быть использована для передачи данных, а часть — для передачи команд или статуса.
Другое решение предполагает возможность заранее зарезервировать необходимое место для всех требуемых префиксов и постфиксов. Естественно, это возможно только в том случае, когда верхний уровень драйвера знает достаточно о потребностях нижних уровней (рис. 2б). Это жесткое, но не фатальное условие: просто нижние уровни должны сообщить верхним о том, как много данных (максимум) им может потребоваться добавить в начало и конец пакета. Для того чтобы работать с такими объектами, нужно знать начало выделенной области, общий размер и два указателя на текущую использованную область. Особенно полезна такая структура на этапе приема пакета. Если уж пакет получен в виде непрерывного участка памяти, то отбрасывание соответствующих элементов выразится просто в изменении двух указателей.
Таким образом, для нормальной работы могут быть полезны оба механизма. Именно так и поступили с пакетами в Streams.
Однако это не все. Мы поговорили только об особенностях работы с самими данными. Необходимо поддержать и сам многоуровневый механизм. При этом полезно вспомнить, что разным программам необходимо предоставить к сетевым средствам различный уровень доступа. В некоторых случаях необходимо работать на уровне пакетов, а иногда работа идет на уровне потока байт. При этом не хотелось бы, например, требовать переписывания приложения при изменении нижних протоколов, появлении шифрования трафика и т.п. В этом случае оказывается необходимой возможность динамического изменения стека. Как это достигается?
Фактически, основу Streams как раз и составляет стек модулей.
Рис. 3. Стек модулей в механизме Streams |
Каждая из стрелочек на рис. 3 указывает управляемую очередь пакетов. «Управляемостью» в данном случае называется возможность ограничения самого размера очереди, частоты ее перетряски в процессе функционирования, изменения других характеристик. Каждый уровень можно представить одним или большим числом модулей. У каждого модуля два входа и два выхода; все, что он делает, это преобразование пакетов на основе собственных данных.
Ясно, что в этом стиле вполне можно описать почти любое взаимодействие. В зависимости от «низкоуровневости» приложения или «мощности» устройства какое-то количество уровней может отсутствовать; можно добавить или убрать какие-то специфические модули в любую позицию между уже существующими модулями, головой очереди и драйвером устройства. Это позволит добавить шифрование и другие функции — все, что угодно.
Рис. 4. Пример организации взаимодействия в стиле Streams |
Почему «почти любое»? Дело в том, что возможны ситуации, когда необходимо мультиплексировать или демультиплексировать сообщения. В общем случае более правильная картинка должна включать возможность перераспределения пакетов между приложениями и устройствами. При соответствующем желании и наборе модулей никто не мешает начудить что-либо в стиле рис. 4 (верхние квадратики являются головами очередей, а нижние — драйверами устройств). Кусочек справа образует аналог псевдоустройства pipe между процессами.
Приведенная схема демонстрирует, как достигается гибкость и оптимизация. Значительно хуже обстоит дело с преемственностью. Если с такими операциями, как open (создать минимальный стек), close (разрушить стек и, возможно, завершить работу устройства), read/write (чтение и передача пакетов) все более или менее ясно, то ioctl несколько настораживает.
Стандартный интерфейс этой команды — ioctl(id, command, arg) — очень часто в качестве последнего аргумента содержит адрес. Как и кто будет выполнять ioctl в изложенной выше схеме? Решение может быть только одно: вызов ioctl должен превратиться в пакет, который путешествует по нашему «деревцу». Но как быть с параметрами? Они должны превратится в данные пакета. Как много данных нужно копировать (и нужно ли это делать вообще) для конкретного вызова ioctl? Одним из путей, который позволил бы это указать, было кодирование в коде command не только самой команды, но и размера блока параметров, благо они обычно для одной команды одни и те же. Одновременно наши пакетики получают еще и тип — являются ли они служебными и, какие функции управления выполняют. В частности, вставку и извлечение модулей тоже можно выполнять через специальные вызовы ioctl. Вместе с тем, добавив управляющие пакеты и превратив ioctl в пакет, мы решаем только часть проблемы, а именно перенос команды к тому модулю, который готов ее отработать.
Еще один вопрос состоит в том, что и когда должен вернуть вызов ioctl процессу. Опять же, результат должен вернуться от соответствующего модуля в виде пакета, который должен попасть точно обратно процессу; вызов вернет код возврата или ошибку.
На самом деле, в общем случае решение с заданием размера данных в коде команды не является достаточно хорошим, поскольку сам аргумент может быть адресом структуры, которая в свою очередь содержит адреса и так далее, да и смена кода команды по поводу и без повода не желательна, а ioctl, бывает, используется не для посылки данных драйверу, а для их возврата. Существует альтернативное решение. В принципе, кажется странным, что модуль не может сам напрямую общаться с адресным пространством процесса. Однако все не так просто. Доверять выполнение процедур модулей конкретным процессам, заинтересованным в лоббировании собственных пакетов, неразумно, хотя, как правило, значительная часть драйверов «традиционного» толка использует именно этот метод и потому имеет полный доступ к пространству процесса. В нашем случае довольно сложно определить, кого бы это надо разбудить, чтобы он двинул свою фишку. Поэтому обычно это делает отдельный системный процесс; в этом случае говорить о доступе к его адресному пространству бесполезно. Соответственно, единственный путь — вновь обмен специализированными пакетами с головой очереди (она выполняется в рамках соответствующего процесса и «все может») в стиле «вот бы мне данных», «а не записать ли в область процесса результаты?» и т.п.
Такова плата за преемственность ioctl. Есть в преемственности еще один маленький штришок. Уже говорилось, что с read/write все нормально. Это не совсем так: реальный доступ к данным обеспечивают специальные вызовы putmsg/getmsg, позволяющие послать любое сообщение. Вызовы read/write этого не обеспечивают. Более того, они генерируют не пакет, а просто набор байт, который, в принципе, можно объединить с данными другого пакета — главное соблюсти последовательность. Поэтому может существовать отдельный модуль, который обеспечивает возможность записать и прочитать «фрагмент» пакета.
И последний момент — унификация. Как только зафиксированы структуры (или средства доступа к элементам) очередей, набор типов сообщений, набор процедур для работы с очередями и сообщениями, интерфейс самого модуля, появляется возможность писать отдельные модули в стиле, безотносительном к архитектуре, и т.п. Появление стандарта позволяет третьим фирмам разрабатывать разные уровни без знания об особенности системы в целом. В результате Streams стали стандартом для коммерческих Unix-систем. Некоторые же системы этого типа поддерживают это стандартное и эффективное решение давно; желающие могут, например, найти его в ОС VxWorks, загрузив пробную версию с http://www.wrs.com/protoplus/.
Еще один интересный момент: не очень ясно, когда происходит «набивка» стека модулей для «нормальных» программ, которые предпочитают пользоваться стандартными возможностями без знания модульных особенностей. Есть несколько решений. Формирование стека можно упрятать в библиотеки и выполнять в соответствии с файлами настройки. Такой подход реализуется легко и в той или иной мере есть всегда: передача уже открытых и, соответственно «набитых», файлов; клонирование по образцу и использование специальных файлов-устройств (этот метод тоже может реализоваться через библиотеки, при этом появляются устройства для соединений, для выполнения специальных действий, для настройки, и возникают имена типа /dev/arp, /dev/ip, /dev/tcp, /dev/lan.
/dev/lan0, /dev/rawip...); выполнение специальной программы.
На роль специальной программы подходит, в частности, ifconfig. Заметим, что в этом случае она не может перестраивать свою работу на ходу (изменять параметры можно, изменять структуру, как правило, — нет). Как следствие, необходим специальный вариант запуска для разрушения; такая команда может иметь вид:
Ifconfig lan1 unplumb
И имя ему sk_buff
Рис. 5. Структура описания пакета в ОС Linux |
Попробуем посмотреть на реализацию сетевых функций в Linux — опять с точки зрения преемственности, гибкости, унификации, оптимизации и т.п. Поскольку операционная система эта быстро развивающаяся, то с преемственностью иногда возникают проблемы, скажем, при запуске конфигуратора в help появляются сообщения о том, что ту или иную возможность скоро исключат в связи с заменой на ... более свежую. Унификация — очевидно, имеет место среди всех, кто видит код ядра. Видят же все. С оптимизацией и гибкостью и вовсе любопытно. В общем случае побеждает то, что появилось раньше. Поэтому вопрос оптимизации обычно не стоит — пишем, как дышим, и, быть может, так оно и правильно. Однако вернемся к баранам. В этом может помочь очередная книга, посвященная драйверам для Linux [3].
Каждое устройство в ОС Linux описывается структурой net_device, а каждый пакет sk_buff. Это управляющая структура для описания пакета, которая, естественно, в этом плане аналогична пакетам Streams.
В данном случае нет никакой цепочки для элементов пакета, соответственно, добавить что-либо в начало можно только в том случае, если в начале пакета заранее выделено место, либо... мы его перепишем на новое место.
Для того чтобы работать с буфером подобного рода, имеется целый ряд функций, которые синхронно изменяют параметры data, tail и len, и позволяют отбрасывать или добавлять данные в начале или конце пакета. Так, skb_put(skb, len) необходим после добавления len байт в конец пакета, skb_reserve(skb, len) резервирует место в начале для лежащих ниже или выше (и такое бывает) уровней или позиционирования на определенную границу. Достаточно часто можно встретить в процедурах получения сетевого пакета что-либо в стиле skb_reserv(skb,2) с сообщением о том, что это нужно лежащим выше уровням, или о том, что это обеспечивает правильную установку структур на границу. Неужели верхние уровни будут что-то вставлять перед пакетом, зачем иначе нужен это сдвиг на 2 байта? Он приведет, напротив, к ухудшению границы буфера. Если попробовать убрать это резервирование, то (при работе с процессорами определенной архитектуры) можно схлопотать «панику», причина которой весьма прозаична: вышележащие уровни предполагают, что IP-адрес в пакете лежит на границе слова.
Иногда сдвиг позиций в пакете делается достаточно тихо, например:
skb->protocol = eth_type_trans(skb, dev);
Это выражение выглядит обычной проверкой типа пакета, однако, это не совсем так. Побочным эффектом является изменение указателя data (и, естественно, len) перед передачей пакета вышележащему уровню. В общем случае возможна ситуация, когда один и тот же пакет отправляется в два или более адреса. Формально надо бы сделать копию сразу на верхнем (нижнем) уровне. Однако обычно такой сдвоенный пакет может некоторое время путешествовать в целом виде — просто на него появляются две или более ссылки. До тех пор, пока нет необходимости вносить в пакет какие-либо изменения, он путешествует целым. Только в том случае, когда пакет обязан измениться, делается копия, в одной копии количество ссылок уменьшается, в другой их оказывается ровно одна и, следовательно, с ней можно делать что угодно.
Двери открываются
Что необходимо сделать для того, чтобы установить соединение? Для программ это состоит в лихорадочной выдаче всякого рода вызовов socket, bind, listen, accept, connect, иногда ioctl и т.п. Для того чтобы устройство стало доступным, операционная система Linux обязана его сконфигурировать посредством ifconfig. Именно в момент ifconfig происходит открытие устройства. Реально же устройство присутствует только в socket-пространстве и потому открыть его обычным способом нельзя. Соответственно, искать /dev/eth0 не следует. Для того чтобы процедура ifconfig сработала, устройство должно быть зарегистрировано в списке сетевых и появиться в оном пространстве. При «регистрации» происходит доопределение достаточно обширного интерфейса для «сетевого устройства». Интерфейс включает и открытие устройства, его остановку, начало передачи пакета, выдачу статистической информации, установку конфигурации, выполнение ioctl, установку MAC-адреса или даже фильтра пакетов и т.д. и т.п. Регистрация добавляет функции, например, обобщающие требования Ethernet.
Литература
[1] Сидни Фейт. TCP/IP. Архитектура, протоколы, реализация, М.: «Лори», 2000
[2] А. Робачевский. Операционная система UNIX, СПб.: BHV-Санкт-Петербург, 2000
[3] Alessandro Rubini, Jonathan Corbet. Linux Device Drivers, 2nd Edition, O?Reilly & Associates, 2001
Игорь Облаков (ioblakov@rapas.ru), технический директор ЗАО РАПАС (г. Москва), обещает в следующем выпуске журнала продолжить обсуждение темы реализации сетевых средств в Linux.