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.
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).
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?