Nosso programa processa pacotes de rede, em particular cabeçalhos TCP / IP / etc. Neles, os valores numéricos - compensações, contadores, endereços - são apresentados em ordem de bytes da rede (big-endian); estamos trabalhando no x86 (little-endian). Nas estruturas padrão que descrevem os cabeçalhos, esses campos são representados por tipos inteiros simples (
uint32_t
,
uint16_t
). Após vários bugs devido ao fato de termos esquecido de converter a ordem dos bytes, decidimos substituir os tipos de campo por classes que proíbem conversões implícitas e operações atípicas. Sob o corte, há um código utilitário e exemplos específicos de erros que a digitação estrita revelou.
Ordem de bytes
Likbez para quem não está ciente da ordem dos bytes (endianness, ordem dos bytes). Mais detalhadamente
já estava em "Habré" .
Na notação usual de números, eles vão do mais antigo (esquerda) para o mais jovem (direita) à esquerda: 432
10 = 4 × 10
2 + 3 × 10
1 + 2 × 10
0 . Os tipos de dados inteiros têm um tamanho fixo, por exemplo, 16 bits (números de 0 a 65535). Eles são armazenados na memória como dois bytes, por exemplo, 432
10 = 01b0
16 , ou seja, bytes 01 e b0.
Imprima os bytes deste número:
#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]); } }
Nos processadores Intel ou AMD (x86) comuns, obtemos o seguinte:
01b0 b0 01
Os bytes na memória estão localizados do mais novo ao mais antigo, e não como ao escrever números. Essa ordem é chamada de
little-endian (LE). O mesmo vale para números de 4 bytes. A ordem dos bytes é determinada pela arquitetura do processador. A ordem "nativa" do processador também é chamada de
ordem da CPU ou host (ordem de bytes da CPU / host). No nosso caso, a ordem dos bytes do host é pouco endian.
No entanto, a Internet não nasceu no x86, e a ordem de bytes era diferente -
do mais antigo para o mais novo (big-endian, BE). Eles começaram a usá-lo nos cabeçalhos dos protocolos de rede (IP, TCP, UDP), de modo que o big endian também é chamado de
ordem de bytes da
rede.Exemplo: a porta 443 (1bb
16 ), que usa HTTPS, é escrita nos bytes dos cabeçalhos TCP bb 01, que quando lidos fornecerão bb01
16 = 47873.
A ordem dos bytes de um número pode ser convertida. Por exemplo, para
uint16_t
existe uma função padrão
htons()
(funciona como
htons()
para
um inteiro inteiro - da ordem do host à ordem da rede para números inteiros curtos) e o
ntohs()
reverso
ntohs()
. Da mesma forma, para
uint32_t
há
htonl()
e
ntohl()
(long é um inteiro longo).
Infelizmente, o compilador não sabe de onde veio o valor específico de uma variável do tipo
uint32_t
e não avisa se você misturar valores com diferentes ordens de bytes e obter um resultado incorreto.
Digitação forte
O risco de ordem de bytes confusa é óbvio, como lidar com isso?
- Revisão de código. Este é um procedimento obrigatório em nosso projeto. Infelizmente, os testadores, mais do que tudo, querem se aprofundar no código que manipula bytes: “Eu vejo
htons()
- provavelmente, o autor pensou em tudo”. - Disciplina, regras como: SEJA apenas em pacotes, todas as variáveis em LE. Nem sempre é razoável, por exemplo, se você precisar verificar as portas em uma tabela de hash, é mais eficiente armazená-las na ordem de bytes da rede e pesquisar "como estão".
- Testes. Como você sabe, eles não garantem a ausência de erros. Os dados podem ser mal correspondidos (1.1.1.1 não muda ao converter a ordem dos bytes) ou ajustados ao resultado.
Ao trabalhar com uma rede, você não pode ignorar a ordem dos bytes, por isso, gostaria de tornar impossível ignorá-lo ao escrever código. Além disso, não temos apenas um número no BE - é um número de porta, endereço IP, número de sequência TCP, soma de verificação. Um não pode ser atribuído a outro, mesmo que o número de bits corresponda.
A solução é conhecida - digitação estrita, ou seja, tipos separados para portas, endereços, números. Além disso, esses tipos devem oferecer suporte à conversão BE / LE.
O Boost.Endian não é adequado para nós, porque não há Boost no projeto.
O tamanho do projeto é de cerca de 40 mil linhas em C ++ 17. Se você criar tipos de invólucros seguros e sobrescrever estruturas de cabeçalho neles, todos os locais onde houver trabalho com o BE pararão de compilar automaticamente. Você precisará passar por todos eles uma vez, mas o novo código estará seguro apenas.
Número de classe 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;
- Um arquivo de cabeçalho com esse tipo será incluído em todos os lugares; portanto, em vez de um
<iostream>
pesado, um <iosfwd>
leve <iosfwd>
. - Em vez de
htons()
, etc. - intrínsecas rápidas do compilador. Em particular, eles são constexpr
propagação constante, portanto, construtores constexpr
. - Às vezes, já existe um valor
uint32_t
/ uint32_t
localizado em BE. A estrutura Raw<T>
com o guia de dedução permite criar convenientemente um BigEndian<T>
partir dela.
O ponto controverso aqui é
PACKED
: acredita-se que as estruturas empacotadas sejam menos otimizáveis. A única resposta é medir. Nossos benchmarks de código não revelaram lentidão. Além disso, no caso de pacotes de rede, a posição dos campos no cabeçalho ainda é fixa.
Na maioria dos casos, o BE não precisa de nenhuma operação além da comparação. Os números de sequência precisam ser dobrados corretamente com 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;
Estrutura segura do cabeçalho 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
pode ser transformado em uma enum class
, mas, na prática, apenas duas operações são executadas em sinalizadores: verificando a entrada ( &
) ou substituindo os sinalizadores por uma combinação ( |
) - não há confusão.- Os campos de bits são deixados primitivos, mas métodos de acesso seguro são feitos.
- Os nomes dos campos são deixados clássicos.
Resultados
A maioria das edições foi trivial. O código é mais limpo:
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); }
Em parte, os tipos documentaram o código:
- void check_packet(int64_t, int64_t, uint8_t, bool); + void check_packet(std::optional<Seqnum>, std::optional<Seqnum>, TCPFlags, bool);
De repente, descobriu-se que você pode ler incorretamente o tamanho da janela TCP, enquanto os testes de unidade passam e até o tráfego é perseguido:
// 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; }
Exemplo de um erro lógico: o desenvolvedor do código original pensou que a função aceita BE, embora na verdade não seja. Ao tentar usar o
Raw{}
vez de
0
programa simplesmente não foi compilado (felizmente, este é apenas um teste de unidade). Imediatamente, vemos uma escolha malsucedida de dados: o erro teria sido encontrado mais cedo se 0 não tivesse sido usado, o que é o mesmo em qualquer ordem de bytes.
- auto cookie = cookie_make_inner(tuple, rte_be_to_cpu_32(0)); + auto cookie = cookie_make_inner(tuple, 0);
Um exemplo semelhante: primeiro, o compilador apontou a incompatibilidade entre os tipos
def_seq
e
cookie
, depois ficou claro por que o teste passou anteriormente - essas constantes.
- 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);
Sumário
A linha inferior é:
- Encontrado um bug e vários erros lógicos em testes de unidade.
- A refatoração me fez resolver lugares duvidosos, a legibilidade aumentou.
- O desempenho foi preservado, mas poderia ter diminuído - são necessários benchmarks.
Todos os três pontos são importantes para nós, então achamos que a refatoração valeu a pena.
Você se protege contra erros com tipos estritos?