我们的程序处理网络数据包,特别是TCP / IP / etc标头。 其中,数值(偏移量,计数器,地址)以网络字节顺序(大端)显示; 我们正在研究x86(小端)。 在描述标头的标准结构中,这些字段由简单的整数类型(
uint32_t
,
uint16_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
有一个标准函数
htons()
(对于短整数,它是主机到网络-对于短整数,是从主机顺序到网络顺序
ntohs()
。 同样,对于
uint32_t
有
htonl()
和
ntohl()
(long是一个长整数)。
不幸的是,编译器不知道
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_seq
和
cookie
类型之间的不匹配,然后很清楚为什么测试较早通过了-这样的常量。
- 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);
总结
底线是:
- 在单元测试中发现一个错误和几个逻辑错误。
- 重构使我理清了可疑的地方,可读性得到了提高。
- 性能可以保留,但可能会降低-需要基准。
这三点对我们都很重要,因此我们认为重构是值得的。
您是否确保自己不会遇到严格类型的错误?