Les avantages de la frappe forte en C ++: expérience pratique

Notre programme traite les paquets rĂ©seau, en particulier les en-tĂȘtes TCP / IP / etc. Dans ceux-ci, les valeurs numĂ©riques - dĂ©calages, compteurs, adresses - sont prĂ©sentĂ©es dans l'ordre des octets du rĂ©seau (big-endian); nous travaillons sur x86 (little-endian). Dans les structures standard dĂ©crivant les en-tĂȘtes, ces champs sont reprĂ©sentĂ©s par des types entiers simples ( uint32_t , uint16_t ). AprĂšs plusieurs bugs dus au fait que nous avons oubliĂ© de convertir l'ordre des octets, nous avons dĂ©cidĂ© de remplacer les types de champs par des classes qui interdisent les conversions implicites et les opĂ©rations atypiques. Sous la coupe se trouve un code utilitaire et des exemples spĂ©cifiques d'erreurs que le typage strict a rĂ©vĂ©lĂ©.

Ordre des octets


Likbez pour ceux qui ne connaissent pas l'ordre des octets (endianness, ordre des octets). Plus en détail était déjà sur "Habré" .

Dans la notation habituelle des nombres, ils vont du plus ancien (gauche) au plus jeune (droite) Ă  gauche: 432 10 = 4 × 10 2 + 3 × 10 1 + 2 × 10 0 . Les types de donnĂ©es entiers ont une taille fixe, par exemple 16 bits (nombres de 0 Ă  65535). Ils sont stockĂ©s en mĂ©moire sous la forme de deux octets, par exemple, 432 10 = 01b0 16 , c'est-Ă -dire les octets 01 et b0.

Imprimez les octets de ce numéro:

 #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]); } } 

Sur les processeurs Intel ou AMD (x86) ordinaires, nous obtenons ce qui suit:

 01b0 b0 01 

Les octets en mĂ©moire sont situĂ©s du plus jeune au plus ancien, et non comme lors de l'Ă©criture de nombres. Cet ordre est appelĂ© little-endian (LE). Il en va de mĂȘme pour les nombres Ă  4 octets. L'ordre des octets est dĂ©terminĂ© par l'architecture du processeur. L'ordre «natif» du processeur est Ă©galement appelĂ© ordre du CPU ou de l'hĂŽte (ordre des octets CPU / hĂŽte). Dans notre cas, l'ordre des octets de l'hĂŽte est petit-endien.

Cependant, Internet n'est pas nĂ© sur x86, et l'ordre des octets Ă©tait diffĂ©rent - du plus ancien au plus jeune (big-endian, BE). Ils ont commencĂ© Ă  l'utiliser dans les en-tĂȘtes des protocoles rĂ©seau (IP, TCP, UDP), donc le big-endian est Ă©galement appelĂ© l' ordre des octets du rĂ©seau.

Exemple: le port 443 (1bb 16 ), qui utilise HTTPS, est Ă©crit dans les en-tĂȘtes TCP octets bb 01, qui en lecture donneront 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++; 

L'ordre des octets d'un nombre peut ĂȘtre converti. Par exemple, pour uint16_t il existe une fonction standard htons() ( h ost to n etwork for s hort integer - from host order to network order for short integers) and the reverse ntohs() . De mĂȘme, pour uint32_t il y a htonl() et ntohl() (long est un entier long).

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

Malheureusement, le compilateur ne sait pas d'oĂč provient la valeur spĂ©cifique d'une variable de type uint32_t et ne vous avertit pas si vous mĂ©langez des valeurs avec des ordres d'octets diffĂ©rents et obtenez un rĂ©sultat incorrect.

Typage fort


Le risque de confondre l'ordre des octets est Ă©vident, comment y faire face?

  • RĂ©vision du code. Il s'agit d'une procĂ©dure obligatoire dans notre projet. Malheureusement, les testeurs veulent surtout se plonger dans le code qui manipule les octets: "Je vois htons() - probablement, l'auteur a pensĂ© Ă  tout".
  • Discipline, rĂšgles comme: BE uniquement dans les packages, toutes les variables dans LE. Il n'est pas toujours raisonnable, par exemple, si vous devez vĂ©rifier les ports par rapport Ă  une table de hachage, il est plus efficace de les stocker dans l'ordre des octets du rĂ©seau et de rechercher «tels quels».
  • Tests. Comme vous le savez, ils ne garantissent pas l'absence d'erreurs. Les donnĂ©es peuvent ĂȘtre mal appariĂ©es (1.1.1.1 ne change pas lors de la conversion de l'ordre des octets) ou ajustĂ©es au rĂ©sultat.

Lorsque vous travaillez avec un rĂ©seau, vous ne pouvez pas ignorer l'ordre des octets, donc je voudrais qu'il soit impossible de l'ignorer lors de l'Ă©criture de code. De plus, nous n'avons pas seulement un numĂ©ro en BE - c'est un numĂ©ro de port, une adresse IP, un numĂ©ro de sĂ©quence TCP, une somme de contrĂŽle. Un ne peut pas ĂȘtre affectĂ© Ă  un autre, mĂȘme si le nombre de bits correspond.

La solution est connue - typage strict, c'est-à-dire types distincts pour les ports, les adresses et les numéros. De plus, ces types doivent prendre en charge la conversion BE / LE. Boost.Endian ne nous convient pas, car il n'y a pas de Boost dans le projet.

La taille du projet est d'environ 40 000 lignes en C ++ 17. Si vous crĂ©ez des types d'encapsuleurs sĂ»rs et Ă©crasez les structures d'en-tĂȘte dessus, tous les endroits oĂč il y a du travail avec BE arrĂȘteront automatiquement la compilation. Vous devrez les parcourir tous une fois, mais le nouveau code ne sera sĂ»r.

Numéro 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; 


  • Un fichier d'en-tĂȘte avec ce type sera inclus partout, donc au lieu d'un <iostream> lourd, un <iosfwd> lĂ©ger <iosfwd> .
  • Au lieu de htons() , etc. - intrinsĂšque rapide du compilateur. En particulier, ils sont constexpr une propagation constante, donc des constructeurs constexpr .
  • Parfois, il existe dĂ©jĂ  une valeur uint16_t / uint32_t situĂ©e dans BE. La structure Raw<T> avec le guide de dĂ©duction vous permet de crĂ©er facilement un BigEndian<T> partir de celle-ci.

Le point controversĂ© ici est PACKED : les structures packagĂ©es sont considĂ©rĂ©es comme moins optimisables. La seule rĂ©ponse est de mesurer. Nos benchmarks de code n'ont rĂ©vĂ©lĂ© aucun ralentissement. De plus, dans le cas des paquets rĂ©seau, la position des champs dans l'en-tĂȘte est toujours fixe.

Dans la plupart des cas, BE n'a besoin d'aucune opĂ©ration autre que la comparaison. Les numĂ©ros de sĂ©quence doivent ĂȘtre pliĂ©s correctement avec 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; 

Structure d'en-tĂȘte TCP sĂ©curisĂ©e
 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 pourrait ĂȘtre transformĂ© en enum class , mais en pratique, seules deux opĂ©rations sont effectuĂ©es sur les indicateurs: vĂ©rifier l'entrĂ©e ( & ) ou remplacer les indicateurs par une combinaison ( | ) - il n'y a pas de confusion.
  • Les champs de bits restent primitifs, mais des mĂ©thodes d'accĂšs sĂ©curisĂ©es sont crĂ©Ă©es.
  • Les noms de champs restent classiques.

RĂ©sultats


La plupart des modifications ont été triviales. Le code est plus propre:

  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); } 

En partie, les types ont documenté le code:

 - void check_packet(int64_t, int64_t, uint8_t, bool); + void check_packet(std::optional<Seqnum>, std::optional<Seqnum>, TCPFlags, bool); 

Du coup, il s'est avĂ©rĂ© que vous ne pouviez pas lire correctement la taille de la fenĂȘtre TCP, alors que les tests unitaires passeraient et mĂȘme le trafic serait poursuivi:

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

Exemple d'erreur logique: le dĂ©veloppeur du code d'origine pensait que la fonction acceptait BE, bien qu'en fait ce ne soit pas le cas. Lorsque vous essayez d'utiliser Raw{} au lieu de 0 programme n'a tout simplement pas compilĂ© (heureusement, ce n'est qu'un test unitaire). On voit immĂ©diatement un choix de donnĂ©es infructueux: l'erreur aurait Ă©tĂ© trouvĂ©e plus tĂŽt si elle n'avait pas Ă©tĂ© utilisĂ©e 0, ce qui est le mĂȘme dans n'importe quel ordre d'octets.

 - auto cookie = cookie_make_inner(tuple, rte_be_to_cpu_32(0)); + auto cookie = cookie_make_inner(tuple, 0); 

Un exemple similaire: tout d'abord, le compilateur a souligné l'inadéquation entre les types def_seq et cookie , puis il est devenu clair pourquoi le test a réussi plus tÎt - de telles 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); 

Résumé


L'essentiel est:

  • TrouvĂ© un bogue et plusieurs erreurs logiques dans les tests unitaires.
  • Le refactoring m'a fait trier les endroits douteux, la lisibilitĂ© a augmentĂ©.
  • Les performances ont Ă©tĂ© prĂ©servĂ©es, mais auraient pu diminuer - des repĂšres sont nĂ©cessaires.

Les trois points sont importants pour nous, nous pensons donc que le refactoring en valait la peine.

Vous assurez-vous contre les erreurs de types stricts?

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


All Articles