Die Vorteile einer starken Eingabe in C ++: praktische Erfahrung

Unser Programm verarbeitet Netzwerkpakete, insbesondere TCP / IP / etc-Header. In ihnen werden numerische Werte - Offsets, Zähler, Adressen - in der Reihenfolge der Netzwerkbytes (Big-Endian) dargestellt. Wir arbeiten an x86 (Little-Endian). In Standardstrukturen, die Header beschreiben, werden diese Felder durch einfache Ganzzahltypen ( uint32_t , uint16_t ) dargestellt. Nach mehreren Fehlern aufgrund der Tatsache, dass wir vergessen haben, die Bytereihenfolge zu konvertieren, haben wir beschlossen, die Feldtypen durch Klassen zu ersetzen, die implizite Konvertierungen und atypische Operationen verbieten. Unter dem Schnitt befindet sich ein nützlicher Code und spezifische Beispiele für Fehler, die durch strikte Eingabe aufgedeckt wurden.

Bytereihenfolge


Likbez für diejenigen, die die Bytereihenfolge nicht kennen (Endianness, Bytereihenfolge). Im Detail war schon auf "Habré" .

In der üblichen Notation von Zahlen gehen sie vom ältesten (links) zum jüngsten (rechts) links: 432 10 = 4 × 10 2 + 3 × 10 1 + 2 × 10 0 . Ganzzahlige Datentypen haben eine feste Größe, z. B. 16 Bit (Zahlen von 0 bis 65535). Sie werden als zwei Bytes im Speicher gespeichert, beispielsweise 432 10 = 01b0 16 , dh Bytes 01 und b0.

Drucken Sie die Bytes dieser Nummer:

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

Auf normalen Intel- oder AMD (x86) -Prozessoren erhalten wir Folgendes:

 01b0 b0 01 

Bytes im Speicher befinden sich vom jüngsten zum ältesten und nicht wie beim Schreiben von Zahlen. Diese Reihenfolge wird Little-Endian (LE) genannt. Gleiches gilt für 4-Byte-Zahlen. Die Bytereihenfolge wird durch die Prozessorarchitektur bestimmt. Die "native" Reihenfolge für den Prozessor wird auch als Reihenfolge der CPU oder des Hosts (CPU / Host-Bytereihenfolge) bezeichnet. In unserem Fall ist die Host-Bytereihenfolge Little-Endian.

Das Internet wurde jedoch nicht auf x86 geboren, und dort war die Bytereihenfolge unterschiedlich - vom ältesten zum jüngsten (Big-Endian, BE). Sie begannen, es in den Headern von Netzwerkprotokollen (IP, TCP, UDP) zu verwenden, daher wird der Big-Endian auch als Netzwerkbyte-Reihenfolge bezeichnet.

Beispiel: Port 443 (1bb 16 ), der HTTPS verwendet, wird in die TCP-Header-Bytes bb 01 geschrieben, die beim Lesen bb01 16 = 47873 ergeben.

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

Die Bytereihenfolge einer Zahl kann konvertiert werden. Für uint16_t gibt es beispielsweise eine Standardfunktion htons() ( h ost bis n etwork für eine kurze Ganzzahl - von der ntohs() zur Netzwerkreihenfolge für kurze Ganzzahlen) und die umgekehrte ntohs() . In ähnlicher Weise gibt es für uint32_t htonl() und ntohl() (long ist eine lange ganze Zahl).

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

Leider weiß der Compiler nicht, woher der spezifische Wert einer Variablen vom Typ uint32_t stammt, und warnt nicht, wenn Sie Werte mit unterschiedlichen Bytereihenfolgen mischen und ein falsches Ergebnis erhalten.

Starkes Tippen


Das Risiko einer Verwechslung der Bytereihenfolge liegt auf der Hand. Wie geht man damit um?

  • Codeüberprüfung. Dies ist ein obligatorisches Verfahren in unserem Projekt. Leider wollen die Tester am allerwenigsten in den Code htons() , der Bytes manipuliert: „Ich sehe htons() - wahrscheinlich hat der Autor über alles nachgedacht“.
  • Disziplin, Regeln wie: BE nur in Paketen, alle Variablen in LE. Es ist beispielsweise nicht immer sinnvoll, Ports anhand einer Hash-Tabelle zu überprüfen. Es ist effizienter, sie in der Reihenfolge der Netzwerkbytes zu speichern und "wie sie sind" zu suchen.
  • Tests. Wie Sie wissen, garantieren sie nicht die Abwesenheit von Fehlern. Daten können schlecht übereinstimmen (1.1.1.1 ändert sich beim Konvertieren der Bytereihenfolge nicht) oder an das Ergebnis angepasst werden.

Wenn Sie mit einem Netzwerk arbeiten, können Sie die Bytereihenfolge nicht ignorieren, daher möchte ich es unmöglich machen, sie beim Schreiben von Code zu ignorieren. Darüber hinaus haben wir nicht nur eine Nummer in BE - es ist eine Portnummer, IP-Adresse, TCP-Sequenznummer, Prüfsumme. Eines kann keinem anderen zugewiesen werden, selbst wenn die Anzahl der Bits übereinstimmt.

Die Lösung ist bekannt - strikte Typisierung, dh separate Typen für Ports, Adressen, Nummern. Darüber hinaus müssen diese Typen die BE / LE-Konvertierung unterstützen. Boost.Endian passt nicht zu uns, da das Projekt keinen Boost enthält.

Die Projektgröße beträgt in C ++ 17 ungefähr 40.000 Zeilen. Wenn Sie sichere Wrapper-Typen erstellen und Header-Strukturen überschreiben, werden alle Stellen, an denen mit BE gearbeitet wird, automatisch nicht mehr kompiliert. Wir müssen sie alle einmal durchgehen, aber der neue Code ist nur sicher.

Big-Endian-Klassennummer
 #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; 


  • Eine Header-Datei dieses Typs wird überall enthalten sein, sodass anstelle eines schweren <iostream> ein leichtes <iosfwd> .
  • Anstelle von htons() usw. - schnelle Compiler-Eigenschaften. Insbesondere sind sie constexpr konstanten Ausbreitung constexpr , daher constexpr Konstruktoren.
  • Manchmal befindet sich in BE bereits ein Wert für uint16_t / uint32_t . Mit der Raw<T> -Struktur mit dem BigEndian<T> können Sie bequem einen BigEndian<T> daraus erstellen.

Der umstrittene Punkt hier ist PACKED : PACKED Strukturen werden als weniger optimierbar angesehen. Die einzige Antwort ist zu messen. Unsere Code-Benchmarks zeigten keine Verlangsamungen. Außerdem ist bei Netzwerkpaketen die Position der Felder im Header immer noch fest.

In den meisten Fällen benötigt BE keine anderen Operationen als den Vergleich. Sequenznummern müssen mit LE korrekt gefaltet werden:

 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; 

Sichere TCP-Header-Struktur
 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 könnte zu einer enum class , aber in der Praxis werden nur zwei Operationen an Flags ausgeführt: Überprüfen des Eintrags ( & ) oder Ersetzen der Flags durch eine Kombination ( | ) - es gibt keine Verwirrung.
  • Bitfelder bleiben primitiv, es werden jedoch sichere Zugriffsmethoden erstellt.
  • Feldnamen bleiben klassisch.

Ergebnisse


Die meisten Änderungen waren trivial. Der Code ist sauberer:

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

Zum Teil dokumentierten die Typen den Code:

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

Plötzlich stellte sich heraus, dass Sie die Größe des TCP-Fensters falsch lesen können, während Unit-Tests bestanden werden und sogar der Datenverkehr verfolgt wird:

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

Beispiel für einen logischen Fehler: Der Entwickler des ursprünglichen Codes dachte, dass die Funktion BE akzeptiert, obwohl dies tatsächlich nicht der Fall ist. Beim Versuch, Raw{} anstelle von 0 Programm einfach nicht kompiliert (zum Glück ist dies nur ein Komponententest). Sofort sehen wir eine erfolglose Auswahl von Daten: Der Fehler wäre früher gefunden worden, wenn er nicht 0 verwendet worden wäre, was in jeder Bytereihenfolge gleich ist.

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

Ein ähnliches Beispiel: Zuerst wies der Compiler auf die def_seq zwischen den Typen def_seq und cookie , dann wurde klar, warum der Test früher bestanden wurde - solche Konstanten.

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

Zusammenfassung


Das Fazit lautet:

  • In Unit-Tests wurden ein Fehler und mehrere logische Fehler gefunden.
  • Durch das Refactoring konnte ich zweifelhafte Stellen aussortieren und die Lesbarkeit verbessern.
  • Die Leistung wurde beibehalten, hätte aber sinken können - Benchmarks sind erforderlich.

Alle drei Punkte sind uns wichtig, daher denken wir, dass sich das Refactoring gelohnt hat.

Versichern Sie sich gegen Fehler mit strengen Typen?

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


All Articles