Os benefícios da digitação forte em C ++: experiência prática

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.

 //  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++; 

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_thtonl() e ntohl() (long é um inteiro longo).

 // :  BE    BE . if (tcp->dst_port == htons(443)) { ... } //   BE     LE,   1, //   LE    BE. tcp->sent_seq = htonl(ntohl(tcp->sent_seq) + 1); 

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?

Source: https://habr.com/ru/post/pt471272/


All Articles