Наша программа обрабатывает сетевые пакеты, в частности, заголовки TCP/IP/etc. В них числовые значения — смещения, счетчики, адреса — представлены в сетевом порядке байтов (big-endian); мы же работаем на x86 (little-endian). В стандартных структурах, описывающих заголовки, эти поля представлены простыми целочисленными типами (
Ликбез для тех, кто не в курсе про порядок байтов (endianness, byte order). Более подробно уже было на «Хабре».
При обычной записи чисел слева идут от старшего (слева) к младшему (справа): 43210 = 4×102 + 3×101 + 2×100. Целочисленные типы данных имеют фиксированный размер, например, 16 бит (числа от 0 до 65535). В памяти они хранятся как два байта, например, 43210 = 01b016, то есть байты 01 и b0.
Напечатаем байты этого числа:
На обычных процессорах Intel или AMD (x86) получим следующее:
Байты в памяти расположены от младшего к старшему, а не как при записи чисел. Такой порядок называется little-endian (LE). То же верно для 4-байтовых чисел. Порядок байтов определяется архитектурой процессора. «Родной» для процессора порядок называется еще порядком ЦП или хоста (CPU/host byte order). В нашем случае host byte order — это little-endian.
Однако интернет рождался не на x86, и там порядок байтов был другой — от старшего к младшему (big-endian, BE). Его и стали использовать в заголовках сетевых протоколов (IP, TCP, UDP), поэтому big-endian еще называют сетевым порядком байтов (network byte order).
Пример: порт 443 (1bb16), по которому ходит HTTPS, в заголовках TCP записан байтами bb 01, которые при чтении дадут bb0116 = 47873.
Порядок байтов в числе можно преобразовывать. Например, для
К сожалению, компилятор не знает, откуда взялось конкретное значение переменной типа
Риск перепутать порядок байтов очевиден, как с ним бороться?
При работе с сетью нельзя абстрагироваться от порядка байтов, поэтому хотелось бы сделать так, чтобы его нельзя было проигнорировать при написании кода. Более того, у нас не просто число в BE — это номер порта, IP-адрес, номер последовательности TCP, контрольная сумма. Одно нельзя присваивать другому, даже если количество бит совпадает.
Решение известно — строгая типизация, то есть отдельные типы для портов, адресов, номеров. Кроме того, эти типы должны поддерживать конвертацию BE/LE. Boost.Endian нам не подходит, так как в проекте нет Boost.
Размер проекта около 40 тысяч строк на C++17. Если создать безопасные типы-обертки и переписать на них структуры заголовков, автоматически перестанут компилироваться все места, где есть работа с BE. Придется один раз пройтись по ним всем, зато новый код будет только безопасным.
Спорным моментом здесь является
В большинстве случаев над BE не нужны никакие операции, кроме сравнения. Номера последовательностей требуется корректно складывать с LE:
Большинство правок были тривиальными. Код стал чище:
Отчасти типы документировали код:
Неожиданно оказалось, что можно неправильно считать размер окна TCP, при этом будут проходить unit-тесты и даже гоняться трафик:
Пример логической ошибки: разработчик оригинального кода думал, что функция принимает BE, хотя на самом деле это не так. При попытке использовать
Аналогичный пример: сначала компилятор указал на несоответствие типов
В сухом остатке имеем:
Нам важны все три пункта, поэтому считаем, рефакторинг того стоил.
А вы страхуете себя от ошибок строгими типами?
uint32_t
, uint16_t
). После нескольких багов из-за того, что порядок байтов забыли преобразовать, мы решили заменить типы полей на классы, запрещающие неявные преобразования и нетипичные операции. Под катом — утилитарный код и конкретные примеры ошибок, которые выявила строгая типизация.Порядок байтов
Ликбез для тех, кто не в курсе про порядок байтов (endianness, byte order). Более подробно уже было на «Хабре».
При обычной записи чисел слева идут от старшего (слева) к младшему (справа): 43210 = 4×102 + 3×101 + 2×100. Целочисленные типы данных имеют фиксированный размер, например, 16 бит (числа от 0 до 65535). В памяти они хранятся как два байта, например, 43210 = 01b016, то есть байты 01 и b0.
Напечатаем байты этого числа:
#include // printf() #include // uint8_t, uint16_t int main() { uint16_t value = 0x01b0; printf("%04x\n", value); const auto bytes = reinterpret_cast(&value); for (auto i = 0; i
На обычных процессорах Intel или AMD (x86) получим следующее:
01b0 b0 01
Байты в памяти расположены от младшего к старшему, а не как при записи чисел. Такой порядок называется little-endian (LE). То же верно для 4-байтовых чисел. Порядок байтов определяется архитектурой процессора. «Родной» для процессора порядок называется еще порядком ЦП или хоста (CPU/host byte order). В нашем случае host byte order — это little-endian.
Однако интернет рождался не на x86, и там порядок байтов был другой — от старшего к младшему (big-endian, BE). Его и стали использовать в заголовках сетевых протоколов (IP, TCP, UDP), поэтому big-endian еще называют сетевым порядком байтов (network byte order).
Пример: порт 443 (1bb16), по которому ходит HTTPS, в заголовках TCP записан байтами bb 01, которые при чтении дадут bb0116 = 47873.
// Все uint16_t и uint32_t здесь в сетевом порядке байтов. struct tcp_hdr { uint16_t th_sport; uint16_t th_dport; uint32_t th_seq; uint32_t th_ack; uint32_t th_flags2 : 4; uint32_t th_off : 4; uint8_t th_flags; uint16_t th_win; uint16_t th_sum; uint16_t th_urp; } __attribute__((__packed__)); tcp_hdr* tcp = ...; // указатель на часть сетевого пакета // Неправильно: dst_port в BE, а 443 в LE. if (tcp->dst_port == 443) { ... } // Неправильно: ++ оперирует LE, а sent_seq в BE. tcp->sent_seq++;
Порядок байтов в числе можно преобразовывать. Например, для
uint16_t
есть стандартная функция htons()
(host tonetwork for short integer — из порядка хоста в сетевой порядок для коротких целых) и обратная ей ntohs()
. Аналогично для uint32_t
есть htonl()
и ntohl()
(long — длинное целое).// Правильно: сравниваем BE поле заголовка с BE значением. if (tcp->dst_port == htons(443)) { ... } // Сначала переводим BE значение из заголовка в LE, увеличиваем на 1, // затем переводим LE сумму обратно в BE. tcp->sent_seq = htonl(ntohl(tcp->sent_seq) + 1);
К сожалению, компилятор не знает, откуда взялось конкретное значение переменной типа
uint32_t
, и не предупреждает, если смешать значения с разным порядком байтов и получить неверный результат.Строгая типизация
Риск перепутать порядок байтов очевиден, как с ним бороться?
- Code review. В нашем проекте это обязательная процедура. К сожалению, проверяющим меньше всего хочется вникать в код, который манипулирует байтами: «вижу
htons()
— наверное, автор обо всем подумал». - Дисциплина, правила наподобие: BE только в пакетах, все переменные в LE. Не всегда разумно, например, если нужно проверять порты по хэш-таблице, эффективнее хранить их в сетевом порядке байтов и искать «как есть».
- Тесты. Как известно, они не гарантируют отсутствие ошибок. Данные могут быть неудачно подобраны (1.1.1.1 не меняется при преобразовании порядка байтов) или подогнаны под результат.
При работе с сетью нельзя абстрагироваться от порядка байтов, поэтому хотелось бы сделать так, чтобы его нельзя было проигнорировать при написании кода. Более того, у нас не просто число в BE — это номер порта, IP-адрес, номер последовательности TCP, контрольная сумма. Одно нельзя присваивать другому, даже если количество бит совпадает.
Решение известно — строгая типизация, то есть отдельные типы для портов, адресов, номеров. Кроме того, эти типы должны поддерживать конвертацию BE/LE. Boost.Endian нам не подходит, так как в проекте нет Boost.
Размер проекта около 40 тысяч строк на C++17. Если создать безопасные типы-обертки и переписать на них структуры заголовков, автоматически перестанут компилироваться все места, где есть работа с BE. Придется один раз пройтись по ним всем, зато новый код будет только безопасным.
Класс числа в big-endian
#include #include #define PACKED __attribute__((packed)) constexpr auto bswap(uint16_t value) noexcept { return __builtin_bswap16(value); } constexpr auto bswap(uint32_t value) noexcept { return __builtin_bswap32(value); } template struct Raw { T value; }; template Raw(T) -> Raw; template struct BigEndian { using Underlying = T; using Native = T; constexpr BigEndian() noexcept = default; constexpr explicit BigEndian(Native value) noexcept : _value{bswap(value)} {} constexpr BigEndian(Raw raw) noexcept : _value{raw.value} {} constexpr Underlying raw() const { return _value; } constexpr Native native() const { return bswap(_value); } explicit operator bool() const { return static_cast(_value); } bool operator==(const BigEndian& other) const { return raw() == other.raw(); } bool operator!=(const BigEndian& other) const { return raw() != other.raw(); } friend std::ostream& operator
- Заголовочный файл с этим типом будет включаться повсеместно, поэтому вместо тяжелого
используется легковесный
.
- Вместо
htons()
и т. п. — быстрые интринсики компилятора. В частности, на них действует constant propagation, поэтому конструкторыconstexpr
. - Иногда уже есть значение
uint16_t
/uint32_t
, находящееся в BE. СтруктураRaw
с deduction guide позволяет удобно создать из негоBigEndian
.
Спорным моментом здесь является
PACKED
: считается, что упакованные структуры хуже поддаются оптимизации. Ответ один — мерить. Наши бенчмарки кода не выявили замедления. Кроме того, в случае сетевых пакетов положение полей в заголовке все равно фиксировано.В большинстве случаев над BE не нужны никакие операции, кроме сравнения. Номера последовательностей требуется корректно складывать с LE:
using BE16 = BigEndian; using BE32 = BigEndian; struct Seqnum : BE32 { using BE32::BE32; template Seqnum operator+(Integral increment) const { static_assert(std::is_integral_v); return Seqnum{static_cast(native() + increment)}; } } PACKED; struct IP : BE32 { using BE32::BE32; } PACKED; struct L4Port : BE16 { using BE16::BE16; } PACKED;
Безопасная структура заголовка TCP
enum TCPFlag : uint8_t { TH_FIN = 0x01, TH_SYN = 0x02, TH_RST = 0x04, TH_PUSH = 0x08, TH_ACK = 0x10, TH_URG = 0x20, TH_ECE = 0x40, TH_CWR = 0x80, }; using TCPFlags = std::underlying_type_t; struct TCPHeader { L4Port th_sport; L4Port th_dport; Seqnum th_seq; Seqnum th_ack; uint32_t th_flags2 : 4; uint32_t th_off : 4; TCPFlags th_flags; BE16 th_win; uint16_t th_sum; BE16 th_urp; uint16_t header_length() const { return th_off > 2; } uint8_t* payload() { return reinterpret_cast(this) + header_length(); } const uint8_t* payload() const { return reinterpret_cast(this) + header_length(); } }; static_assert(sizeof(TCPHeader) == 20);
TCPFlag
можно было бы сделатьenum class
, но на практике над флагами делается всего две операции: проверка вхождения (&
) либо замена флагов на комбинацию (|
) — путаницы не возникает.- Битовые поля оставлены примитивными, но сделаны безопасные методы доступа.
- Названия полей оставлены классическими.
Результаты
Большинство правок были тривиальными. Код стал чище:
auto tcp = packet->tcp_header(); - return make_response(packet, - cookie_make(packet, rte_be_to_cpu_32(tcp->th_seq)), - rte_cpu_to_be_32(rte_be_to_cpu_32(tcp->th_seq) + 1), - TH_SYN | TH_ACK); + return make_response(packet, cookie_make(packet, tcp->th_seq.native()), + tcp->th_seq + 1, TH_SYN | TH_ACK); }
Отчасти типы документировали код:
- void check_packet(int64_t, int64_t, uint8_t, bool); + void check_packet(std::optional, std::optional, TCPFlags, bool);
Неожиданно оказалось, что можно неправильно считать размер окна TCP, при этом будут проходить unit-тесты и даже гоняться трафик:
// меняем window size auto wscale_ratio = options().wscale_dst - options().wscale_src; if (wscale_ratio WINDOW_SIZE_MAX) { window_size = WINDOW_SIZE_MAX; }
Пример логической ошибки: разработчик оригинального кода думал, что функция принимает BE, хотя на самом деле это не так. При попытке использовать
Raw{}
вместо 0
программа просто не компилировалась (к счастью, это лишь unit-тест). Тут же видим неудачный выбор данных: ошибка нашлась бы скорее, если бы использовался не 0, который одинаков в любом порядке байтов.- auto cookie = cookie_make_inner(tuple, rte_be_to_cpu_32(0)); + auto cookie = cookie_make_inner(tuple, 0);
Аналогичный пример: сначала компилятор указал на несоответствие типов
def_seq
и cookie
, затем стало ясно, почему тест проходил раньше — такие константы.- const uint32_t def_seq = 0xA7A7A7A7; - const uint32_t def_ack = 0xA8A8A8A8; + const Seqnum def_seq{0x12345678}; + const Seqnum def_ack{0x90abcdef}; ... - auto cookie = rte_be_to_cpu_32(_tcph->th_ack); + auto cookie = _tcph->th_ack; ASSERT_NE(def_seq, cookie);
Итоги
В сухом остатке имеем:
- Найден один баг и несколько логических ошибок в unit-тестах.
- Рефакторинг заставил разобраться в сомнительных местах, читаемость возросла.
- Производительность сохранилась, но могла бы снизиться — бенчмарки нужны.
Нам важны все три пункта, поэтому считаем, рефакторинг того стоил.
А вы страхуете себя от ошибок строгими типами?