[Из песочницы] Польза строгой типизации в C++: практический опыт

0

Наша программа обрабатывает сетевые пакеты, в частности, заголовки TCP/IP/etc. В них числовые значения — смещения, счетчики, адреса — представлены в сетевом порядке байтов (big-endian); мы же работаем на x86 (little-endian). В стандартных структурах, описывающих заголовки, эти поля представлены простыми целочисленными типами (uint32_t, uint16_t). После нескольких багов из-за того, что порядок байтов забыли преобразовать, мы решили заменить типы полей на классы, запрещающие неявные преобразования и нетипичные операции. Под катом — утилитарный код и конкретные примеры ошибок, которые выявила строгая типизация.

Порядок байтов

Ликбез для тех, кто не в курсе про порядок байтов (endianness, byte order). Более подробно уже было на «Хабре».

При обычной записи чисел слева идут от старшего (слева) к младшему (справа): 43210 = 4×102 + 3×101 + 2×100. Целочисленные типы данных имеют фиксированный размер, например, 16 бит (числа от 0 до 65535). В памяти они хранятся как два байта, например, 43210 = 01b016, то есть байты 01 и b0.

Напечатаем байты этого числа:

#include <cstdio> // printf()
#include <cstdint> // uint8_t, uint16_t int main() { uint16_t value = 0x01b0; printf("%04x\n", value); const auto bytes = reinterpret_cast<const uint8_t*>(&value); for (auto i = 0; i < sizeof(value); i++) { printf("%02x ", bytes[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 <cstdint>
#include <iosfwd> #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<typename T>
struct Raw { T value;
}; template<typename T>
Raw(T) -> Raw<T>; template<typename T>
struct BigEndian { using Underlying = T; using Native = T; constexpr BigEndian() noexcept = default; constexpr explicit BigEndian(Native value) noexcept : _value{bswap(value)} {} constexpr BigEndian(Raw<Underlying> 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<bool>(_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<<(std::ostream& out, const BigEndian& value) { return out << value.native(); } private: Underlying _value{};
} PACKED;

  • Заголовочный файл с этим типом будет включаться повсеместно, поэтому вместо тяжелого <iostream> используется легковесный <iosfwd>.
  • Вместо htons() и т. п. — быстрые интринсики компилятора. В частности, на них действует constant propagation, поэтому конструкторы constexpr.
  • Иногда уже есть значение uint16_t/uint32_t, находящееся в BE. Структура Raw<T> с deduction guide позволяет удобно создать из него BigEndian<T>.

Спорным моментом здесь является PACKED: считается, что упакованные структуры хуже поддаются оптимизации. Ответ один — мерить. Наши бенчмарки кода не выявили замедления. Кроме того, в случае сетевых пакетов положение полей в заголовке все равно фиксировано.

В большинстве случаев над BE не нужны никакие операции, кроме сравнения. Номера последовательностей требуется корректно складывать с LE:

using BE16 = BigEndian<uint16_t>;
using BE32 = BigEndian<uint32_t>; struct Seqnum : BE32 { using BE32::BE32; template<typename Integral> Seqnum operator+(Integral increment) const { static_assert(std::is_integral_v<Integral>); return Seqnum{static_cast<uint32_t>(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<TCPFlag>; 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; } void set_header_length(uint16_t len) { th_off = len >> 2; } uint8_t* payload() { return reinterpret_cast<uint8_t*>(this) + header_length(); } const uint8_t* payload() const { return reinterpret_cast<const uint8_t*>(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<Seqnum>, std::optional<Seqnum>, TCPFlags, bool);

Неожиданно оказалось, что можно неправильно считать размер окна TCP, при этом будут проходить unit-тесты и даже гоняться трафик:

 // меняем window size auto wscale_ratio = options().wscale_dst - options().wscale_src; if (wscale_ratio < 0) {
- auto window_size = header.window_size() / (1 << (-wscale_ratio));
+ auto window_size = header.window_size().native() / (1 << (-wscale_ratio)); if (header.window_size() && window_size < 1) { window_size = WINDOW_SIZE_MIN; } header_out.window_size(window_size); } else {
- auto window_size = header.window_size() * (1 << (wscale_ratio));
+ auto window_size = header.window_size().native() * (1 << (wscale_ratio)); if (window_size > 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-тестах.
  • Рефакторинг заставил разобраться в сомнительных местах, читаемость возросла.
  • Производительность сохранилась, но могла бы снизиться — бенчмарки нужны.

Нам важны все три пункта, поэтому считаем, рефакторинг того стоил.

А вы страхуете себя от ошибок строгими типами?

You might also like More from author