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 с номером 1000000 и MSS 1460 — ответ SYN+ACK с подтверждением 1000001: сам SYN занимает один номер. MSS соседа — тоже 1460 = 1500 − 20 − 20: MTU минус заголовки IP и TCP.
- Наш ACK открыл соединение, сервер принял его и прислал «hello from 106» — 15 байт с переводом строки.
- Наш запрос «ping 106» — 9 байт, ответ «PING 106» — те же 9 байт.
- Сервер закрыл первым — FIN; мы подтвердили его и послали свой FIN, его подтверждение закрыло соединение. Всего данных пришло 24 байта.
- У соседа соединение осталось в TIME_WAIT: это состояние держит тот, кто закрыл первым, чтобы опоздавшие сегменты не попали в новое соединение с теми же портами.
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 минут, и всё это время приложение считает соединение живым.
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 Итоги
- UDP — 8 байт: порты, длина и сумма с псевдозаголовком; больше ничего.
- TCP — номера и подтверждения; SYN и FIN занимают по номеру; MSS 1460 = 1500 − 20 − 20.
- Рукопожатие, данные и закрытие, собранные вручную, приняло настоящее ядро Linux; TIME_WAIT остался у закрывшего первым.
- Скорость не выше окна, делённого на время ответа: 65 535 байт при 50 мс — 10,49 Мбит/с.
- Тайм-аут повтора следит за временем ответа и удваивается; Linux сдаётся через 924,6 с. Молчащее соединение без поддержки связи обрыва не заметит, а поддержка по умолчанию начинается через 2 часа.
- NAT забывает тишину: у UDP — через 30 с; поддержку связи слать чаще.
Дальше: раздел 106а «Ethernet на F407: RMII, PHY по MDIO и дескрипторы DMA» — как кадры попадают из провода в память микроконтроллера.