[Из песочницы] Польза строгой типизации в C++: практический опыт
Наша программа обрабатывает сетевые пакеты, в частности, заголовки 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 // 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 < 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. Придется один раз пройтись по ним всем, зато новый код будет только безопасным.
#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<<(std::ostream& out, const BigEndian& value) {
return out << value.native();
}
private:
Underlying _value{};
} PACKED;
- Заголовочный файл с этим типом будет включаться повсеместно, поэтому вместо тяжелого
используется легковесный
. - Вместо
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;
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;
}
void set_header_length(uint16_t len) {
th_off = len >> 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 < 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-тестах.
- Рефакторинг заставил разобраться в сомнительных местах, читаемость возросла.
- Производительность сохранилась, но могла бы снизиться — бенчмарки нужны.
Нам важны все три пункта, поэтому считаем, рефакторинг того стоил.
А вы страхуете себя от ошибок строгими типами?