Продолжим начатые в предыдущем номере [1] исследования сетевой подсистемы в ядре Linux. Пройдемся по следам, которые оставляют пакеты в системе в моменты своего прихода и ухода.

Основной режим работы драйвера — это, естественно, прием и передача пакетов. Прием обеспечивается начальными процедурами инициализации подсистемы приема и, в основном, процедурами обработки прерываний. Все остальные телодвижения пакета определяются процедурами за пределами драйвера. Сам драйвер просто использует интерфейсные процедуры netif_xxxx для уведомления о возникших событиях: остановка процесса передачи (аппаратная очередь полна), исчезновение или появление «несущей», приход пакета и т.п.

Любопытно изучить, как работают драйверы с кэшем. Необходимо гарантировать соответствие между данными, которые пишут и читают процессор и устройство ввода/вывода. Если контроллер использует отдельную память, то необходимо копировать данные из буфера устройства в sk_buff, если же память системы доступна контроллеру, то необходимо поработать с кэшем отдельно. Довольно часто применяется процедура типа eth_copy_and_sum: реально же она просто копирует буфер.

Ошибки при работе с кэшем достаточно распространены при создании драйверов практически в любой операционной системе. Результат ошибки может заключаться не только в том, что драйвер устройства просто не работает; дело может оказаться гораздо интереснее. Например, возможно изменение пакетов в процессе пересылки, ну, скажем, адреса. Хорошо еще, если кое-какие несоответствия удается выявить за счет контрольных сумм. А если они подсчитываются аппаратно или какая-то проверка отключена по соображениям эффективности? В общем случае можно положиться на то, что плохие пакеты удастся отбросить, однако это ведет к потере производительности: соединение есть (формально драйвер работает), однако скорость этой работы низка.

ОК, пусть наш пакет отправляется в путь наверх. Перед отправлением пакета необходимо отбросить начальный заголовок. Как говорилось в предыдущей статье, для этого используется забавный вариант вызова skb->protocol = eth_type_trans(skb, dev), после этого выполняется вызов netif_rx(skb).

Этот вызов не позволяет пакету немедленно «добежать» до самого верха. Если бы доставка происходила в рамках прерывания, то все остальные прерывания были бы блокированы, что может привести к ухудшению скоростных характеристик системы. Впрочем, как всегда, доставка не гарантируется: если пакетов в очереди слишком много, то очередной будет просто выброшен на свалку. Соответственно, вызов netif_rx является просто перекладыванием (если есть возможность) пакета в новый ящик со звонком в службу доставки. Собственно доставка происходит после окончания процедуры обработки прерывания.

Остановимся на этом моменте. Не очень ясно то, как запланировать что-то такое, что было бы запущенно именно после обработки прерывания по приходу пакета. А ведь ситуации подобного типа встречаются достаточно часто. Это типичная ситуация с любым планированием в системе; например, решение о планировании принимается в момент окончания системного вызова, но в том случае это явно перекладывается на последние операции перед возвращением контекста. В нашем случае такое не проходит. Может вообще оказаться, что данное прерывание прервало обработку другого, а следовательно, немедленная передача управления после окончания текущего невозможна. Фактически, лучшее решение заключается в том, чтобы сгенерировать новое прерывание (вообще говоря, оно не обязано быть именно «прерыванием» в классическом смысле) с приоритетом ниже всех остальных или даже приоритетом задач. При таком выборе приоритета можно заняться обработкой пакета, не остановив обработки других прерываний. Именно это и проделывается в процедуре netif_rx (см. linux/net/core/dev.c), которая перепоручает свои действия менее приоритетной процедуре при помощи вызова __cpu_raise_softirq(this_cpu, NET_RX_SOFTIRQ). Для обработки же события NET_RX_SOFTIRQ уже предусмотрена процедура net_rx_action.

После того, как Ethernet-заголовок отброшен при помощи eth_type_trans (linux/net/eternet/eth.c), происходит последовательный анализ самого пакета. Необходимо более точно определиться с протоколом пакета и передать пакет конкретному обработчику. Тип протокола и разбор направления («нам», «не нам», «всем» — в терминах Ethernet), как мы уже знаем, выполнил eth_type_trans. В процедуре же net_rx_action происходит дальнейший анализ пакета с использованием заданного протокола/типа пакета.

Ядро Linux является динамическим и реконфигурируемым; компоненты сетевой подсистемы могут быть подгружены «на ходу». Как в таких условиях обеспечить динамический характер разбора пакета? Для этого на всех уровнях используется метод регистрации. В большинстве случаев для того чтобы зарегистрировать протокол, используется процедура dev_add_pack, которая устанавливает соответствие между кодом типа пакета и набором процедур для работы с ним, например, фрагмент из arp.c:

static struct packet_type arp_packet_type =
{
__constant_htons(ETH_P_ARP),
NULL,		/* All devices */
arp_rcv,
(void*)1,
NULL
};
…
dev_add_pack(&arp_packet_type);

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

Если протокол предполагает дальнейший разбор, то на следующем уровне опять используется регистрация. Пример можно найти в p8022.c и af_ipx.c. Протокол IPX, участвуя в разборе пакетов IEEE 802.2, использует процедуру регистрации p8022_datalink = register_8022_client(ipx_8022_type,ipx_rcv).

Такой подход существенно отличается от технологии STREAMS. Во-первых, не факт, что конкретный вариант Unix со STREAMS поддерживает динамическую загрузку подсистем. Во-вторых, протокольный стек может пересобрать сама программа, но из фиксированного набора элементов.

При более внимательном изучении процедуры net_rx_action можно заметить еще два интересных момента. Один из них заключается в том, что можно зарегистрировать модуль «для всех протоколов» и пользоваться им для анализа всех пакетов. Второй, более тонкий, заключается в том, что пакет не отдается немедленно тому протоколу, тип которого соответствует нужному. Сначала делается попытка найти еще одно соответствие и только потом принимается решение о передаче пакета протокольной процедуре. Зачем? Для того чтобы оптимизировать передачу данных. Если имеется более одной записи для данного пакета (т.е. пакет придется отдать более чем одной процедуре/протоколу), то перед передачей выполняется действие atomic_inc(&skb->users), чтобы установить режим работы copy on write («копирование при записи»). Если одна из процедур решит, что это не ее пакет, то она освободит его, передав в полное распоряжение другой; если же нет, произойдет клонирование пакетов.

Так, пробежав, возможно, по нескольким уровням, мы попадаем на процедуры конкретного протокола. Скажем, на процедуры, которые работают непосредственно с IP-частью пакетов.

Именно на уровне работы с IP можно встретить достаточно неприятный момент, который был частично описан в предыдущем номере журнала [1]. IP-модули работают с IP-адресами как с целыми числами. Ну и что? Да, в принципе ничего, если, конечно, не думать об архитектуре процессора. А если мы используем процессор с RISC-архитектурой? В этом случае все не так просто. Процессоры RISC-архитектуры (а с точки зрения производительности) как правило, предпочитают, чтобы обращения к 32-разрядным данным были бы строго выровнены по границе слова. В этом случае IP-адрес должен лежать на границе слова (что и достигается при помощи skb_reserve(skb,2) в сетевом драйвере для выделяемых при приеме буферов, так как при размере Ethernet-заголовка в 14 байт сдвиг на 2 байта обеспечивает правильное позиционирование IP-адресов). Однако в общем случае нельзя рассчитывать на то, что буфер всегда будет корректно позиционирован, поэтому в случае невыровненного обращения происходит прерывание и выполняется эмуляция операции. Ясно, что эмуляция не способствует росту производительности. Эта разница в положении буфера вполне может быть заметна при профилировании скорости, хотя обработка каждого невыровненного обращения и не отнимает много времени, однако число обращений на пакет, вероятно, оказывается достаточно велико.

Что должен сделать IP-уровень при приеме? Прежде всего, разобрать приходящие пакеты по лукошкам: «свой», «чужой», «общий». Кроме того, нужно выбрать конкретный IP-протокол (TCP, UDP, ICMP и т.п.) для обработки данного пакета. Верхний уровень данного разбора состоит в процедуре ip_rcv (см. linux/net/ipv4/ip_input.c), главное в которой заключено в следующей строке:

NF_HOOK(PF_INET, NF_IP_PRE_ROUTING, skb, 
		dev, NULL, ip_rcv_finish)

Это можно перевести следующим образом: если фильтр пропускает, то надо выполнить ip_rcv_finish. В рамках данной процедуры определяется дальнейший путь пакета. Главное, с чем надо определиться, конечно, заключается в том, должен ли пакет остаться в системе или же его надо куда-то перенаправить (см. ip_route_input в linux/net/ipv4/route.c). Если пакет «свой в доску», его необходимо отдать одному из локальных процессов (точнее, приписать к одному из сокетов). Например, если установлено, что принят TCP-пакет, то «внутренними разборками» занимается tcp_v4_rcv (см. linux/net/ipv4/tcp_ipv4.c). Именно эта процедура пытается определить конкретный сокет доставки для данного пакета, проверяя есть уже установленное соединение с соответствующими IP-адресом и портами, либо есть кто-то еще, слушающий приходящий порт.

Стоит обратить внимание на способ конфигурирования и регистрации конкретных IP-протоколов. Процедуры регистрации и данные для этого можно найти в файле linux/net/ipv4/protocols.c. Рассмотрим фрагмент:

#ifdef CONFIG_IP_MULTICAST
static struct inet_protocol igmp_protocol =
{
igmp_rcv,		/* IGMP handler		*/
NULL,		/* IGMP error control		*/
IPPROTO_PREVIOUS,	/* next			*/
IPPROTO_IGMP,	/* protocol ID		*/
0,		/* copy			*/
NULL,		/* data			*/
"IGMP"		/* name			*/
};
#undef  IPPROTO_PREVIOUS
#define IPPROTO_PREVIOUS &igmp_protocol
#endif

В данном случае набор протоколов фиксируется немедленно в момент конфигурирования источников (например, при выполнении make menucionfig). Хотя, конечно, никто не запрещает поступить иначе... Использование же IPPROTO_PREVIOUS — просто трюк препроцессора, который позволяет автоматически собрать оставленные конфигуратором структуры в список.

Отправка пакета, как кажется, должна быть устроена несколько проще. Нет почти никакого разбора. После того, как интерфейс сокетов настроен, имеется вся информация для формирования «обертки» пакета. Правда, здесь проявляется уже описанное в [1] нежелание переписывать пакет с места на место много раз после прохода последовательных уровней. Для этого надо, как минимум знать, сколько места дополнительно требуется для оформления пакета перед его выдачей на устройство. Когда может стать доступной эта информация? Только после того, как становится известно точное устройство «убытия» пакета. Становится ясной основная идея: выполнить все проверки (если фильтр запрещает отправление, то вообще не понятно зачем заводить в ядре буфер для пакета); определить устройство для отправляемого пакета; определить требования устройства к размеру буфера; выделить место под пакет; скопировать данные на новое место; создать обертку; вызвать процедуры, которые непосредственно и произведут отправку пакета.

Рассмотрим происходящее с точки зрения UDP-пакета (linux/net/ipv4/udp.c). Процедура, которая отвечает за отправление UDP-пакетов (после определения того, что мы работаем с сокетами определенного типа), — udp_sendmsg. Практически построение самого пакета выполняют процедуры ip_build_xmit и ip_build_xmit_slow (обе из linux/net/ipv4/ip_output.c). Это достаточно общие процедуры, их вторым параметром может быть, например, udp_getfrag, процедура, которая отвечает за наполнение приготовленного для пакета места пользовательскими данными. Приведем фрагмент ip_build_xmit:

/*
* Fast path for unfragmented frames
without options.
*/
{
int hh_len = (rt->u.dst.dev-
   >hard_header_len + 15)&~15;
skb = sock_alloc_send_skb(sk, length+hh_len+15,
0, flags&MSG_DONTWAIT, &err);
if(skb==NULL)
goto error;
skb_reserve(skb, hh_len);
}

Как видим, перед фрагментом выделяется место, необходимое для построения заголовка устройством. Реально выделяется возможно большее число байт, кратное 16. Это автоматически решает проблему с позиционированием IP-заголовка. Процедура skb_reserve временно исключает это зарезервированное место из работы. Значение length в данном случае уже учитывает размер IP-заголовка.

Данная схема по необходимости рекурсивна. Так, при создании туннелей (см. linux/net/ipv4/ipip.c) возникают «устройства» с новыми характеристиками:

if (tdev) {
dev->hard_header_len = 
  tdev->hard_header_len + sizeof(struct iphdr);
dev->mtu = tdev->mtu - sizeof(struct iphdr);
}

Мы рассмотрели полный проход по системе, т.е. работу с пакетами, которые должны прийти именно в данную систему или уйти с нее. Однако можно рассматривать и пакеты, которые должны через данную систему пройти (forwarding). В этом случае обнаруживается масса путей, добавленных для того чтобы ускорить процесс прохождения. Самый краткий вариант возможен в том случае, когда драйвер поддерживает режим FASTROUTE. В этом случае решение о перенаправлении может быть принято на уровне драйвера. Соответственно, нет необходимости прохода никаких других уровней; максимальная высота, которой достигает пакет, равняется net_rx_action. Уже с этого уровня происходит уход на новое устройство.

Таков вкратце путь пакетов IP-протоколов в рамках сетевой подсистемы Linux для Ethernet-устройств. Как видно, нельзя говорить об однородности используемых средств, эквивалентности приемов принятых для приема и обработки пакетов, однако многое сделано для того, чтобы можно было динамически менять состав ядра, варианты протоколов различного уровня, фильтры и т.п. Схема же отложенного принятия решений, выбор функций доставки в зависимости от требуемой производительности и тому подобные действия могут оказаться достаточно эффективными.

Игорь Облаков (ioblakov@rapas.ru) — технический директор ЗАО РАПАС (г. Москва)

Литература
  1. Игорь Облаков. Сумма технологий, "Открытые системы", 2001, № 10