C ++中强类型键入的好处:亲身体验

我们的程序处理网络数据包,特别是TCP / IP / etc标头。 其中,数值(偏移量,计数器,地址)以网络字节顺序(大端)显示; 我们正在研究x86(小端)。 在描述标头的标准结构中,这些字段由简单的整数类型( uint32_tuint16_t )表示。 由于我们忘记了转换字节顺序的事实,在经历了几次错误之后,我们决定将字段类型替换为禁止隐式转换和非典型操作的类。 削减的是一个实用代码和严格键入所揭示的错误的特定示例。

字节顺序


Likbez用于那些不知道字节顺序(字节顺序,字节顺序)的用户。 有关“哈布雷”的更多详细信息。

在通常的数字表示法中,它们从左边的最旧的(左)到最小的(右):432 10 = 4×10 2 + 3×10 1 + 2×10 0 。 整数数据类型具有固定大小,例如16位(0到65535之间的数字)。 它们作为两个字节存储在内存中,例如432 10 = 01b0 16 ,即字节01和b0。

打印此数字的字节:

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

在普通的Intel或AMD(x86)处理器上,我们得到以下信息:

 01b0 b0 01 

内存中的字节从最小到最旧,而不是写数字时。 此顺序称为小尾数(LE)。 4字节数字也是如此。 字节顺序由处理器体系结构确定。 处理器的“本机”顺序也称为CPU或主机顺序(CPU /主机字节顺序)。 在我们的情况下,主机字节顺序是小字节序。

但是,Internet并不是基于x86诞生的,字节顺序是不同的- 从最早到最小(比利时big-endian)。 他们开始在网络协议(IP,TCP,UDP)的报头中使用它,因此big-endian也称为网络字节顺序。

示例:将使用HTTPS的端口443(1bb 16 )写入TCP头字节bb 01中,读取时将得到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++; 

数字的字节顺序可以转换。 例如,对于uint16_t有一个标准函数htons() (对于短整数,它是主机到网络-对于短整数,是从主机顺序到网络顺序ntohs() 。 同样,对于uint32_thtonl()ntohl() (long是一个长整数)。

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

不幸的是,编译器不知道uint32_t类型的变量的特定值来自何处,并且如果您将值与不同的字节顺序混合使用,并不会得到错误的结果,则不会发出警告。

强大的打字


混淆字节顺序的风险是显而易见的,如何处理?

  • 代码审查。 这是我们项目中的强制性程序。 不幸的是,测试人员最不希望钻研操纵字节的代码:“我看到htons() -可能是作者考虑了一切”。
  • 纪律,规则如下:仅在软件包中,所有变量在LE中。 例如,如果您需要根据哈希表检查端口,则并非总是合理的,将其以网络字节顺序存储并按“原样”搜索会更有效。
  • 测试。 如您所知,它们不保证没有错误。 数据可能匹配不良(转换字节顺序时1.1.1.1不会更改)或调整为结果。

使用网络时,您不能忽略字节顺序,因此我想使在编写代码时无法忽略它。 而且,我们在BE中不仅有一个数字-它是端口号,IP地址,TCP序列号,校验和。 即使位数匹配,也不能将一个分配给另一个。

解决方案是众所周知的-严格键入,即端口,地址,数字的单独类型。 另外,这些类型必须支持BE / LE转换。 Boost.Endian不适合我们,因为该项目中没有Boost。

在C ++ 17中,项目大小约为4万行。 如果创建安全的包装器类型并覆盖它们的头结构,则所有使用BE的地方都会自动停止编译。 您必须全部检查一次,但是新代码只会很安全。

大端编号
 #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; 


  • 这种类型的头文件将随处包含,因此<iosfwd>了重量轻的<iosfwd>代替了沉重的<iostream>
  • 代替htons()等-快速编译器内部函数。 特别是,它们constexpr恒定传播的constexpr ,因此constexpr构造函数的影响。
  • 有时,BE中已经有一个uint16_t / uint32_t值。 Raw<T>结构和推导指南使您可以方便地从中创建BigEndian<T>

此处的争议点是PACKED :打包的结构被认为不太容易优化。 唯一的答案就是测量。 我们的代码基准测试没有发现任何减速。 此外,在网络数据包的情况下,标头中字段的位置仍然是固定的。

在大多数情况下,BE只需要比较即可。 序列号需要使用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; 

安全的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设为enum class ,但实际上仅对标志执行两项操作:检查条目( & )或将标志替换为组合( | )-不会造成混淆。
  • 位字段保留为原始字段,但是制定了安全的访问方法。
  • 字段名称保留为经典。

结果


大多数编辑都是微不足道的。 代码更干净:

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

这些类型部分地记录了代码:

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

突然,事实证明您可以错误地读取TCP窗口的大小,而单元测试将通过,甚至流量也将被追逐:

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

逻辑错误的示例:原始代码的开发人员认为该函数接受BE,尽管实际上不是。 当尝试使用Raw{}而不是0程序只是没有编译(幸运的是,这只是一个单元测试)。 立即我们发现数据选择不成功:如果未使用0,则将很快发现错误,这在任何字节顺序中都是相同的。

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

一个类似的例子:首先,编译器指出def_seqcookie类型之间的不匹配,然后很清楚为什么测试较早通过了-这样的常量。

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

总结


底线是:

  • 在单元测试中发现一个错误和几个逻辑错误。
  • 重构使我理清了可疑的地方,可读性得到了提高。
  • 性能可以保留,但可能会降低-需要基准。

这三点对我们都很重要,因此我们认为重构是值得的。

您是否确保自己不会遇到严格类型的错误?

Source: https://habr.com/ru/post/zh-CN471272/


All Articles