Embedded HandbookПриложения
Все разделы › XII. Сети и DevOps для инженера
STM32F103, STM32F407 · уровень средний · 9 мин чтения
Открыть в интерактивном справочнике

106. TCP и UDP изнутри: рукопожатие, окна, повторы, NAT

IP доставляет пакеты как придётся: теряет, путает порядок, повторяет. UDP добавляет к нему только порты и сумму, TCP — поток байтов без потерь: номера, подтверждения, окно и повторы. Раздел разбирает оба по байтам, а опыт идёт дальше, чем в разделе 105: код раздела сам, сырыми кадрами, проводит с ядром Linux в соседнем контейнере рукопожатие TCP, обмен данными и закрытие — без сетевого стека со своей стороны.

Кадры и IP — раздел 105, сокеты — раздел 78.

Как это называется в документации

Оригинал Перевод
segment сегмент TCP
datagram датаграмма UDP
pseudo-header псевдозаголовок: адреса, протокол и длина в сумме TCP и UDP
three-way handshake тройное рукопожатие
initial sequence number (ISN) начальный номер последовательности
acknowledgment number номер подтверждения: следующий ожидаемый байт
maximum segment size (MSS) наибольшие данные в одном сегменте
receive window окно приёма
window scaling масштаб окна
retransmission timeout (RTO) тайм-аут повтора
exponential backoff удвоение тайм-аута при повторах
reset (RST) сброс соединения
TIME_WAIT ожидание после закрытия
network address translation (NAT) трансляция адресов и портов
keepalive поддержка связи

106.1 UDP: порты и сумма

UDP — это 8 байт: порт отправителя, порт получателя, длина и сумма. Сумму считают по псевдозаголовку — адресам, протоколу и длине — и по самой датаграмме:

uint32_t l4_sum(uint32_t src, uint32_t dst, unsigned proto, const uint8_t *seg, unsigned n)
{
    uint32_t s = (src >> 16) + (src & 0xFFFFu) + (dst >> 16) + (dst & 0xFFFFu) + proto + n;
    for (unsigned i = 0; i + 1 < n; i += 2)
        s += rd16(seg + i);
    if (n & 1u)
        s += (uint32_t)seg[n - 1] << 8;
    while (s >> 16)
        s = (s & 0xFFFFu) + (s >> 16);
    return ~s & 0xFFFFu;                /* по сегменту с верной суммой — ноль */
}

Датаграмма «STM32» с 10.106.106.30:40001 на порт 9106 — это 9c 41 23 92 00 0d 84 72 53 54 4d 33 32: длина 13, сумма 0x8472. Адреса входят в сумму, чтобы датаграмма, попавшая не тому узлу, не прошла проверку. Ноль в поле суммы у UDP значит «суммы нет», поэтому посчитанный ноль передают как 0xFFFF:

    uint32_t c = l4_sum(src, dst, 17u, u, 8u + n);
    wr16(u + 6, c ? c : 0xFFFFu);       /* ноль в поле значит «суммы нет» */

Больше UDP ничего не обещает: ни доставки, ни порядка, ни защиты от повторов.


106.2 Заголовок TCP и параметры

Байты Поле
0–3 порты отправителя и получателя
4–7 номер последовательности — номер первого байта данных
8–11 номер подтверждения — следующий ожидаемый байт
12 длина заголовка в словах по 4 байта
13 флаги FIN, SYN, RST, PSH, ACK
14–15 окно приёма
16–17 сумма — как у UDP, с псевдозаголовком
20 … параметры: вид, длина, значение

Параметры разбирают по кругу: вид, длина, значение; вид 1 — однобайтовый заполнитель, вид 0 — конец списка:

    for (unsigned i = 20; i < t->hlen;) {
        unsigned kind = s[i];
        if (kind == 0)
            break;                      /* конец списка */
        if (kind == 1) {
            i++;                        /* заполнитель для выравнивания */
            continue;
        }
        if (i + 1 >= t->hlen || s[i + 1] < 2 || i + s[i + 1] > t->hlen)
            return 0;
        if (kind == 2 && s[i + 1] == 4)
            t->mss = rd16(s + i + 2);
        i += s[i + 1];
    }

SYN, каким его шлёт Linux, — 40 байт заголовка: MSS 1460, разрешение SACK, отметки времени, заполнитель и масштаб окна 7. Разбор нашёл MSS 1460 и окно 64240 — это ровно 44 сегмента по 1460.


106.3 Рукопожатие с настоящим ядром

Опыт: рукопожатие, данные и закрытие, собранные вручную, и стук в закрытый порт

Наш код в контейнере с адресом .10 работает от имени 10.106.106.30 — адреса, которого у его ядра нет, так что своё ядро на ответы не реагирует. Сосед .20 — обычный сервер на Python: здоровается, возвращает принятое заглавными буквами и закрывает соединение. Сегмент собирает tcp_build:

unsigned tcp_build(uint8_t *s, uint32_t src, uint32_t dst, const tcp_t *t, const uint8_t *data, unsigned n)
{
    unsigned hl = (t->flags & TCP_SYN) ? 24u : 20u;
    wr16(s, t->sport);
    wr16(s + 2, t->dport);
    wr32(s + 4, t->seq);
    wr32(s + 8, t->ack);
    s[12] = (uint8_t)((hl / 4u) << 4);  /* длина заголовка в 32-битных словах */
    s[13] = (uint8_t)t->flags;
    wr16(s + 14, t->win);
    wr16(s + 16, 0);
    wr16(s + 18, 0);
    if (hl == 24u) {
        s[20] = 2;                      /* вид 2 — MSS, длина 4 */
        s[21] = 4;
        wr16(s + 22, t->mss);
    }
    if (n)
        memcpy(s + hl, data, n);
    wr16(s + 16, l4_sum(src, dst, 6u, s, hl + n));
    return hl + n;
}

Наш SYN — 24 байта: 9c 40 1f aa 00 0f 42 40 00 00 00 00 60 02 fa f0 b5 f5 00 00 02 04 05 b4. Что было дальше:

SYN на закрытый порт 8107 получил RST+ACK с подтверждением 1000001 — отказ вместо молчания. Датаграмма «STM32» на порт 9106 вернулась задом наперёд — «23MTS».

Ядро соседа проверяет суммы и молча выбрасывает сегмент с неверной — раз оно ответило на каждый наш, суммы и заголовки собраны верно.


106.4 Номера по кругу

int seq_before(uint32_t a, uint32_t b)
{
    return (int32_t)(a - b) < 0;
}

Номера — 32 бита, и после 0xFFFFFFFF идёт 0: 0xFFFFFFF0 раньше 0x10. Сравнение по знаку разности работает, пока номера отстоят меньше чем на 2^31; ровно на 2^31 оно ломается — каждый из двух номеров оказывается «раньше» другого. На 100 Мбит/с номера проходят круг за 343,6 с, на 10 Гбит/с — за 3,4 с: там от старых сегментов защищают отметки времени.


106.5 Окно и скорость

Отправитель держит в пути не больше окна получателя. Значит, скорость не выше окна, делённого на время туда и обратно: 65 535 байт при 50 мс — 10,49 Мбит/с, каким бы быстрым ни был канал. Чтобы загрузить 100 Мбит/с при 50 мс, в пути должно быть 625 000 байт. 16-битному полю окна помогает масштаб из SYN — сдвиг влево: хватило бы сдвига 4 (до 1 048 560 байт), Linux предлагает 7 — до 8 388 480.

Прибор на STM32 с окном в 2048 байт при тех же 50 мс выжмет 0,33 Мбит/с: у маленьких стеков скорость упирается в память, а не в провод.


106.6 Повторы и тайм-аут

Тайм-аут повтора следит за временем ответа: сглаженное среднее плюс четыре разброса.

void rto_sample(rto_t *e, uint32_t rtt)
{
    if (e->srtt == 0) {
        e->srtt = (int32_t)rtt;
        e->rttvar = (int32_t)rtt / 2;
    } else {
        int32_t d = e->srtt - (int32_t)rtt;
        e->rttvar += ((d < 0 ? -d : d) - e->rttvar) / 4;    /* разброс — с весом 1/4 */
        e->srtt += ((int32_t)rtt - e->srtt) / 8;            /* среднее — с весом 1/8 */
    }
    uint32_t r = (uint32_t)(e->srtt + 4 * e->rttvar);
    e->rto = r < RTO_MIN ? RTO_MIN : r > RTO_MAX ? RTO_MAX : r;
}

void rto_backoff(rto_t *e)
{
    e->rto = e->rto > RTO_MAX / 2u ? RTO_MAX : e->rto * 2u;
}

Замеры 100, 100, 100, 100 мс, всплеск 300 и снова 100 дают тайм-аут 300, 252, 216, 200, 389, 346, 312, 282 мс. Нижний предел — 200 мс у Linux, в стандарте — 1 с. Сегмент остался без ответа — тайм-аут вдвое: от 282 мс — 564, 1128, 2256 и так до предела 120 с. Linux сдаётся после 15 повторов: с начального тайм-аута 200 мс это 924,6 с — чуть больше 15 минут, и всё это время приложение считает соединение живым.

Тайм-аут повтора TCP: следит за временем ответа, а без ответа удваивается


106.7 NAT

Домашний роутер подменяет внутренний адрес и порт своим внешним портом и помнит пару в таблице. Ответ снаружи он пропускает, только если запись ещё жива:

int nat_in(const nat_t *t, uint32_t out_port, uint32_t now, uint32_t timeout, uint32_t *ip, uint32_t *port)
{
    for (unsigned i = 0; i < t->used; i++) {
        const nat_entry_t *x = &t->e[i];
        if (x->out_port == out_port && now - x->last <= timeout) {
            *ip = x->in_ip;
            *port = x->in_port;
            return 1;
        }
    }
    return 0;
}

Модель: приборы 192.168.1.20 и .21 шлют с одного порта 5000 — снаружи это порты 40000 и 40001; ответ на 40001 уходит .21:5000. После 35 с тишины запись для 40000 уже забыта — при тайм-ауте UDP 30 с ответ выбрасывают. Прибор .21 шлёт пакет поддержки связи раз в 25 с, и на 70-й секунде его запись жива. Отсюда правило для приборов за NAT: слать что-нибудь чаще тайм-аута. У Linux запись UDP без ответа живёт 30 с, с ответами в обе стороны — 120 с, открытая TCP — 5 суток; у роутеров сроки свои, бывает и полминуты. Поддержку связи шлют по худшему случаю — раз в 20–25 с.


106.8 Грабли

Своё ядро мешает сырому сокету. В первом прогоне опыта после рукопожатия данные не пришли: в контейнере была включена пересылка пакетов, и ядро «пересылало» ответы для чужого адреса. С выключенной пересылкой обмен прошёл. На приборе без ОС такого нет, а на компьютере сырой сокет и стек ядра работают одновременно.

Соединение «живо» через 15 минут после обрыва кабеля. Повторы с удвоением: 924,6 с до отказа — и то лишь когда в пути есть неподтверждённые данные. Молчащее соединение обрыва не заметит вовсе. Поддержку связи TCP включают на сокете (SO_KEEPALIVE) и укорачивают ключами TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT: по умолчанию и у Linux, и у lwIP первая проба уходит после 7200 с тишины, пробы повторяются через 75 с, и после 9 безответных соединение закрывается — через 7875 с, больше двух часов. У lwIP эти ключи есть только при LWIP_TCP_KEEPALIVE=1. Отправителю срок ожидания подтверждения укорачивает TCP_USER_TIMEOUT (Linux). Или своя проверка связи — ответами приложения.

Прибор за NAT перестал получать команды. Запись в таблице истекла: у UDP — после 30 с тишины. Слать поддержку связи чаще.

TCP упирается в сотни килобит на быстром канале. Маленькое окно: 2048 байт при 50 мс — 0,33 Мбит/с.

Сервер после перезапуска не может занять порт. Старые соединения в TIME_WAIT; слушающему сокету — SO_REUSEADDR.

Захват показывает неверные суммы TCP у своих пакетов. Сумму досчитывает сетевая карта уже после захвата, а на виртуальном интерфейсе — вовсе никто.

Сравнение номеров a < b ломается раз в круг. Сравнивать по знаку разности.


106.9 Итоги

Дальше: раздел 106а «Ethernet на F407: RMII, PHY по MDIO и дескрипторы DMA» — как кадры попадают из провода в память микроконтроллера.

Открыть в интерактивном справочнике