[Перевод] Путь пакета через ядро Linux
Аннотация: Сетевые стеки являются основой коммуникации и обмена информацией. В данной статье исследуется сетевой стек TCP/IPv4 и UDP/IPv4 в Linux — наиболее распространенной серверной ОС. Мы описываем трассировку наиболее важных сетевых функций ядра Linux 5.10.8. Хотя документация по сетевому коду Linux существует, она часто является устаревшей или охватывает только отдельные аспекты, такие как уровень IP или TCP. Мы рассматриваем эту проблему комплексно, охватывая путь пакета на выходе и входе через сетевой стек Linux. Кроме того, мы освещаем тонкости реализации и показываем, как ядро Linux реализует сетевые протоколы. Наша статья может послужить основой для оптимизации производительности, анализа безопасности, наблюдения за сетью или отладки.
1. Введение
В настоящее время почти всё, от персонального компьютера до холодильника, объединено в сеть. Несмотря на то, что сетевые технологии необходимы для современных вычислений, мало кто знает, насколько сложно получить пакет по проводу и обратно. Учитывая распространенность серверов на базе Linux, пакеты часто проходят через сетевой стек Linux. Однако понимание тонкостей сложной обработки пакетов в Linux требует времени и усилий. Тем не менее, эти знания зачастую очень важны, так как помогают оптимизировать производительность, анализировать безопасность, отлаживать и наблюдать за сетью.
Мы основываем наше исследование пути входящих и исходящих пакетов на версии 5.10.8 ядра Linux. Оно хорошо документировано, стабильно и содержит современные функции, такие как компилятор Just-in-time (JIT) для пакетных фильтров Berkely. В основном мы делаем наблюдения над исходным кодом ядра, который связываем со ссылочными символами ядра.
Хотя сетевые технологии ядра Linux становятся все более разнообразными, например, благодаря добавлению Multipath TCP, большая часть трафика использует стандартный стек протоколов TCP и UDP. Более того, несмотря на ускорение внедрения IPv6, большинство устройств по-прежнему общаются по IPv4. Поэтому в данном анализе мы ограничиваемся TCP/IPv4 и UDP/IPv4.
Оставшаяся часть статьи имеет следующую структуру: Во-первых, в разделе 2 мы сравниваем данную работу с существующей литературой. Затем, в разделе 3, мы объясняем дизайн общего сетевого стека Linux и структуры данных sk_buff
. В разделе 4 мы рассматриваем тонкости путей прохождения пакетов на входе и выходе. Наконец, в разделе 5 мы кратко суммируем наиболее важные результаты.
2. Похожие работы
Мы оценили литературу по сетевому стеку Linux, насколько нам известно. При этом мы сделали следующие замечания.
Устаревшие версии ядра Linux. Более подробные работы появились в 2000-х годах и использовали ядро Linux версии 2 или 3. Хотя реализация старых протоколов в сетевом стеке стабильна, прошло много времени. Поэтому мы исследуем возможные отклонения.
Фрагментарная информация. Многие работы посвящены отдельным уровням, чаще всего реализации TCP и IP. Другие определяют причины накладных расходов сети. В этих случаях отсутствует целостная картина. В частности, даже когда авторы описывают путь пакета через несколько уровней, они опускают UDP — в отличие от данной статьи.
Хотя существует статья, охватывающая весь путь входа и выхода для Linux версии 5, она носит высокоуровневый характер и дает в основном интуитивное представление. Таким образом, мы стремимся найти золотую середину между подробной информацией о конкретном уровне и высокоуровневой трассировкой сетевого стека.
3. Справочная информация
Мы предполагаем базовое знакомство с Linux и сетевыми технологиями. Тем не менее, мы кратко описываем основные сетевые концепции, имеющие отношение к пути передачи пакетов.
3.1. Сетевой стек LinuxСокет (INET)

Как показано на рисунке 1, сокет либо передает пакет приложению пользовательского пространства, либо получает пакет от реализации протокола транспортного уровня, то есть TCP или UDP. Затем IP-уровень направляет пакеты на сетевой уровень. Ниже этого уровня Linux позволяет фильтровать трафик с помощью правил брандмауэра. Плата сетевого интерфейса (NIC) передает ядру пакеты, которые она получает из буфера приема (RX), и передает пакеты, считанные из буфера передачи (TX).
3.2. Буферы сокетов (sk_buff)
Ядро сохраняет пакеты в структурах на языке C, называемых sk_buff
. Почти все функции на пути следования пакетов взаимодействуют с ней. sk_buff
отслеживает метаданные пакета и поддерживает в памяти начальный и конечный указатель на данные пакета. Использование ссылок на данные пакета позволяет эффективно модифицировать пакет путем корректировки указателей, например, при удалении заголовка. Кроме того, структуры sk_buff
могут эффективно использоваться совместно различными процессами с помощью ссылок на память. Следовательно, клонирование пакета также эффективно, поскольку копировать нужно только метаданные, предполагая рабочую нагрузку только для чтения. Мы показываем это на рисунке 2. Эти свойства sk_buff
составляют основу эффективной обработки пакетов в Linux.

4. Поток пакетов
Здесь нас интересуют как входящие, так и исходящие пути. Оба пути работают независимо друг от друга.
4.1. Путь на выходе
Сначала мы проанализируем путь на выходе, то есть то, как Linux отправляет пакеты из пользовательского приложения на сетевую карту, как показано на рисунке 3. По сути, сторона исходящего потока формирует заголовки протоколов, помещая их в структуры sk_buff
, которые и отправляет.
4.1.1. Уровень сокетов Все начинается с сокета, который имеет соответствующий домен, например, AF_UNIX
, AF_XDP
или, как в нашем случае, AF_INET
для IPv4. Функция-обертка системного вызова, например write()
или sendto()
, позволяет нам отправлять данные через сокет, например, как это сделано в библиотеке GNU C. В контексте данной статьи мы выбираем write(filedescriptor, buffer, length)
, чтобы избежать излишней сложности. Запись в дескриптор файла является ярким примером философии UNIX Все есть файл, поскольку дескриптор файла абстрагирует сокет.

Для сокетов функция write()
вызывает функцию sock_sendmsg()
. Она получает сокет struct sock
из дескриптора файла, предоставленного приложением пользовательского пространства. Обычно сокеты работают с управляющими сообщениями сокетов, содержащими идентификатор процесса (PID), идентификатор пользователя (UID) и идентификатор группы (GID). sock_sendmsg()
получает это управляющее сообщение из task_struct
— структуры данных Linux, содержащей эту информацию для вызывающего процесса. Получив эту информацию, sock_sendmsg()
обычно пропускает пакет через модули безопасности Linux (например, SELinux) для фильтрации трафика.
Наконец, он вызывает соответствующий обработчик транспортного уровня, в нашем случае TCP или UDP, с помощью макроса INDIRECT_CALL_INET
. Макрос автономно выбирает соответствующий вариант IPv4 или IPv6 функции ввода транспортного протокола, в зависимости от протокола, указанного в sk_prot
— поле sk_buff
.
4.1.2. Транспортный уровень. Здесь мы переходим к функциям ввода, связанным с IPv4, tcp_sendmsg()
для TCP и udp_sendmsg()
для UDP.
TCP. Сначала tcp_sendmsg()
ожидает установления TCP-соединения. Затем она выделяет структуры sk_buff
для сегментов и заносит их в очередь записи сокета, как показано на рисунке 3. tcp_sendmsg()
также гарантирует соблюдение максимального размера сегмента (MSS). После обработки очереди ядро вызывает tcp_write_queue_tail()
. Она также создает TCP-заголовок и помещает данные из пользовательского пространства в sk_buff
. Если данные помещаются в существующий буфер, используется функция skb_add_data_nocache()
. В противном случае создается новый буфер, что более затратно. Затем он устанавливает указатель transport_header
в начало этого заголовка. Далее создается заголовок протокола сетевого уровня, как указано в опциях сокета, например, IPv4 для AF_INET
. tcp_write_xmit()
гарантирует, что ядро задержит данные в случае ограничений контроля перегрузки. Она также устанавливает таймеры ретрансляции, т.е. повторной отправки пакета, если не был получен ACK вовремя. Наконец, tcp_transmit_skb()
считывает очередь записи, содержащую ранее созданные сегменты, и передает их на сетевой уровень через функцию queue_xmit()
, указанную в сокете.
UDP. Аналогичным образом работает udp_sendmsg()
. Опять же, функция записывает данные в очередь записи сокета. Далее функция ждет, пока не будет ожидающих кадров для UDP-датаграммы. Как и прежде, функция создаёт заголовок, устанавливая порт назначения и другие поля.
Существуют случаи corking и non-corking: corking описывает ожидание кадров для пакетной передачи нескольких дейтаграмм UDP. Случай non-corking подразумевает непосредственное построение sk_buff
. После построения дейтаграммы, ip_route_output_flow()
маршрутизирует пакет и создаёт заголовок протокола сетевого уровня. Наконец, ip_append_data()
создает IP-пакет, объединяющий несколько дейтаграмм UDP. В целом, простота и отсутствие блокировки подтверждают, что реализация UDP более производительна, чем ее аналог TCP.
4.1.3. Уровень IP. Обработка IP начинается с функции __ip_queue_xmit()
. Сначала функция определяет маршрут к месту назначения. Если маршрут уже есть в sk_buff->_skb_refdst
, она пропускает процесс маршрутизации. В этом случае функция сразу создаёт заголовок. Однако если пункт назначения отсутствует, процесс маршрутизации продолжается. Он определяет место назначения по полю socket
в sk_buff
, которое, например, установлено, если сокет ранее получил IP-пакет. Если это невозможно, запрашивается кэш маршрутизации, называемый Forwarding Information Base (FIB) — таблица, формируемая на основе таблицы маршрутизации IP. В конце концов, если маршрут все еще не найден, он возвращает host unreachable и прекращает обработку. В противном случае ядро добавляет IP-заголовок, если находит маршрут.
Теперь вызывается ip_options_build()
для установки IP-опций. Она помечает начало заголовка полем network_header
из sk_buff
. Далее запускается стадия LOCAL_OUT
для механизма брандмауэра Linux — netfilter
. После этого dst_output()
вызывает фактическую функцию маршрутизации через указатель функции.
Затем ядро вызывает функцию маршрутизации ip_output()
для наиболее распространенного одноадресного пакета. Поскольку маршрутизация завершена, этот этап называется POST_ROUTING
. На нем обновляются метаданные пакета и вызывается хук NF_INET_POST_ROUTING
. Он устанавливает метаданные sk_buff
и снова вызывает netfilter
. Кроме того, он фрагментирует пакет, если его длина превышает максимальную (Maximum Transmission Unit).
Затем, после прохождения пакета через хук NF_INET_LOCAL_OUT
, ip_output()
вызывает ip_finish_output()
. Она увеличивает счетчики для многоадресных и широковещательных пакетов. Также проверяется, достаточно ли места в sk_buff
для MAC-заголовка. MAC-адрес назначения либо кэшируется, либо определяется функцией вывода соседней neigh_resolve_output()
. Последняя использует протокол разрешения адресов (ARP). В случае отсутствия ARP-ответа она снова ставит пакет в очередь. После получения MAC-адреса ядро добавляет Ethernet-заголовок, добавляя его в sk_buff
.
4.1.4. Уровень Ethernet. Во-первых, dev_queue_xmit()
устанавливает поле mac_header
в sk_buff
, которое затем передается в tc_egress()
. Она ставит пакет в очередь в порядке очередей (qdisc
). Пока буфер сетевой карты заполнен, __qdisc_run()
удаляет пакеты из буфера. После некоторой постобработки в validate_xmit_skb()
, например, вычисления контрольной суммы Ethernet или добавления тегов VLAN, ядро вызывает ndo_start_xmit
и, соответственно, добавляет пакет в кольцо TX сетевой карты. В конце концов, очередь сетевой карты может быть переполнена. В этом случае ядро останавливает qdisc
и ставит в очередь sk_buff
. Наконец, оно определяет пакет в фиксированное место в памяти для прямого доступа к памяти (DMA) после добавления дополнительных метаданных sk_buff
. dev_direct_xmit
позволяет обойти qdisc
, напрямую записывая пакет в кольцо TX сетевой карты. В качестве примера можно привести eXpress Data Path (XDP). В конце концов, функция уведомляет сетевую карту через прерывание об окончании обработки и освобождает sk_buff
.
4.2. Входной путь
Теперь мы проследим путь пакета, который поступает на сетевую карту, пока пользовательское приложение не прочитает его через сокет, см. Рисунок 4. В частности, оно анализирует заголовки, чтобы определить следующий вызов функции, и удаляет их.

4.2.1. Уровень Ethernet. После проверки и обнуления контрольной суммы Ethernet и применения фильтра MAC-адресов сетевая карта копирует пакет в память системы через DMA. Затем она уведомляет операционную систему с помощью прерывания и указывает местоположение данных пакета. После этого операционная система может выделить sk_buff
. Теперь ядро вставляет в sk_buff
метаданные, такие как поле protocol
(Ethernet), interface
приема и тип packet
, в нашем случае IP.
На этом этапе ядро знает начало заголовка Ethernet, поэтому оно устанавливает поле mac_header
в начало sk_buff
. Наконец, оно удаляет Ethernet-заголовок из sk_buff
, прежде чем передать его дальше по сетевому стеку. Далее пакет поступает в netif_receive_skb()
. Эта функция клонирует sk_buff
и пересылает его виртуальному интерфейсу TAP. Интерфейс TAP обеспечивает связь между виртуальными машинами (ВМ) и хостом в одной сети. Другим важным случаем здесь является пересылка пакетов с метками VLAN на интерфейс VLAN. Кроме того, если у интерфейса есть физический хозяин, то есть он является виртуальным интерфейсом или частью сетевого моста, rx_handler()
забирает пакет. rx_handler()
также устанавливает поле network_header
в sk_buff
. Наконец, она вызывает функцию-обработчик протокола IPv4 ip_rcv()
.
4.2.2. Уровень IP. Уровень Ethernet передает пакет на уровень IP с помощью функции ip_rcv()
. Опять же, ip_rcv()
проверяет MAC-адрес и отбрасывает постороннее. Затем проверяются поля версии, длины и контрольной суммы. Далее функция устанавливает поле transport_header
в sk_buff
. Она также применяет правило PRE_ROUTING
netfilter
. Она реализует фильтр, пересылая пакет хуку NF_INET_PRE_ROUTING
. Хук получает указатель на функцию ip_rcv_finish()
, которую он вызывает после завершения. Если зарегистрировано ведущее устройство сетевого уровня, оно передает sk_buff
своему обработчику. Он вызывает функцию ip_route_input_noref()
, которая считывает IP-заголовок из sk_buff
. Далее ядро обрабатывает IP-опции с помощью ip_rcv_options()
. После этого вызывается выбранная ранее функция маршрутизации через dst_input()
. Существует три варианта маршрутизации пакета:
ip_forward
: Эта функция активируется для пакетов, не адресованных текущей машине. Она пересылает пакет без дополнительной обработки.ip_local_deliver()
: Если мы являемся конечным получателем пакета (localhost), ядро не пересылает пакет, а передает его вверх по сетевому стеку.ip_mr_input()
: Эта функция предназначена для многоадресных пакетов, то есть адресованных многоадресной рассылке.
Поскольку нас в основном интересует, как пакет обрабатывается на конечном приемнике, с учетом всех уровней, мы продолжаем работу с ip_local_deliver()
. Самое важное, что эта функция заботится о фрагментации IP, вызывая ip_defrag()
, ставя пакеты в очередь до получения всех фрагментов. После этого срабатывает событие NF_INET_LOCAL_IN
, которое в свою очередь вызывает ip_local_deliver_finish()
, удаляя IP-заголовок из sk_buff
. Наконец, он передает пакет из IP на уровень TCP/UDP через функцию dst_input()
в функцию tcp_v4_rcv()
. Она определяет соответствующий обработчик протокола, проверяя заголовок, указывающий на sk_buff
.
4.2.3. Транспортный уровень. Теперь мы рассмотрим аналог функций TCP и UDP на выходе.
TCP. Сначала сегмент поступает в функцию транспортного уровня tcp_ipv4_recv()
с указателем заголовка sk_buff
, помещенным в начало заголовка TCP или UDP. Затем он проверяет транспортный заголовок с помощью функции pskb_may_pull()
, проверяя контрольную сумму TCP. Как и раньше, он удаляет TCP-заголовок из sk_buff
. Чтобы передать пакет дальше, он находит соответствующий TCP-сокет с помощью __inet_lookup_skb()
. Он записывает пакет в очередь приема сокета (см. Рисунок 4) и сигнализирует о том, что новые данные доступны, например, через SIGIO
или SIGURG
. Этот механизм уведомления позволяет эффективно опрашивать сокеты. Что касается выхода, то ядро поддерживает механизм состояний TCP во время обработки пакетов. Например, оно не обрабатывает новые пакеты для TCP-соединений, прерванных через TCP_CLOSING
.
Мы кратко остановимся на двух важных случаях во время обработки: TCP_NEW_SYN_RECV
и TCP_TIME_WAIT
. TCP_NEW_SYN_RECV
означает, что появилось новое соединение. В этом случае ядро отказывается от соединения на уровне TCP через tcp_filter()
. Во время TCP_TIME_WAIT
ядро отбрасывает все дальнейшие TCP-сегменты.
Кроме того, существует медленный и быстрый путь. Медленный путь содержит больше проверок на ошибки и поисков. В отличие от него, быстрый путь оптимизирован для скорости, не позволяя проводить интроспекцию и анализ трафика. При медленном пути мы ждем, пока механизм состояний не перейдет в состояние TCP_ESTABLISHED
в tcp_v4_do_rcv()
. После обновления tcp_v4_do_rcv()
вызывает tcp_rcv_established()
, которая обрабатывает пакеты как в быстром, так и в медленном пути. Она также проверяет, чтобы номера последовательностей были возрастающими. Быстрый путь копирует пакет непосредственно в пространство пользователя. Ядро всегда старается использовать быстрый путь, если это возможно. Но когда, например, устанавливается TCP-соединение, это невозможно, поскольку ядру приходится отслеживать новое соединение.
После обработки механизма состояний TCP и выбора пути ядро заносит пакет в очередь сокетов, чтобы пользовательская программа могла его прочитать (см. Рисунок 4). Поскольку TCP очень сложен, рассмотрение дальнейших аспектов выходит за рамки данной статьи.
UDP. По сравнению с TCP реализация UDP менее сложна. Она начинается с udp_rcvmsg()
, вызываемой через dst_input()
на IP-уровне. Сначала функция вызывает __skb_recv_udp()
, чтобы прочитать дейтаграмму из сокета с заранее рассчитанным смещением. В частности, она постоянно пытается считать sk_buff
из сокета, в конечном итоге останавливаясь при поступлении новой UDP-датаграммы. Затем проверяется контрольная сумма дейтаграммы. Затем функция копирует IP-адрес назначения и UDP-порт, чтобы сопоставить дейтаграмму с нужным сокетом. После этого она обрабатывает UDP-датаграмму с помощью skb_consume_udp()
. Наконец, корректируется пиковое смещение, обрабатываются счетчики ссылок и освобождается sk_buff
через __consume_stateless_skb()
.
4.2.4. Уровень сокетов. Здесь ядро собирает новые данные, записанные в сокет TCP или UDP, с помощью функции read()
из сокета, декешируя пакет из очереди приема сокета (см. Рисунок 4). Чтобы соответствовать использованию IPv4 на выходе, мы используем принимающий сокет AF_INET
. Функция sys_recv()
обеспечивает это, сначала вызывая sys_recvfrom()
для поиска сокета. Затем она вызывает sock_recvmsg()
для чтения из сокета и передает полученное сообщение через модули безопасности Linux, аналогично выходу. Для IPv4 inet_recvmsg()
вызывает либо tcp_recvmsg()
, либо udp_recvmsg()
. Они удаляют содержимое пакета из очереди и записывают его в буфер пользовательского пространства, например, в массив на куче. Наконец, они освобождают sk_buff
.
5. Заключение

В этой статье было представлено, как пакет проходит через ядро Linux для TCP/IPv4 и UDP/IPv4. На рисунке 5 показан путь прохождения пакетов на выходе и входе, выделены наиболее важные функции каждого уровня. Кроме того, мы описали тонкости обработки пакетов, включая механизмы маршрутизации, фильтрации и постановки в очередь, используемые ядром Linux. Кроме того, мы увидели, как взаимодействуют различные уровни в ядрах. Используя эти знания, сетевые администраторы и разработчики могут принимать обоснованные решения при оптимизации производительности сети, разработке мер безопасности или устранении сетевых проблем.
В целом, наблюдаемые изменения в существующей литературе — это в основном усовершенствования, а не переписывание, например, рефакторинг или улучшение безопасности. Ярким примером является выбор начальных номеров последовательности для TCP. В целях безопасности авторы ядра несколько раз пересматривали базовый хэш-алгоритм. Консервативные изменения имеют смысл, поскольку протоколы остаются в основном нетронутыми, а влияние ошибок велико. Проведение аналогичного анализа для Multipath TCP или QUIC — это будущая работа.