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

в 20:38, , рубрики: c++, endianess, Программирование, типизация

Наша программа обрабатывает сетевые пакеты, в частности, заголовки 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("%04xn", 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 to network 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-тестах.
  • Рефакторинг заставил разобраться в сомнительных местах, читаемость возросла.
  • Производительность сохранилась, но могла бы снизиться — бенчмарки нужны.

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

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

Автор: Дмитрий Козлюк

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js