يعالج برنامجنا حزم الشبكة ، على وجه الخصوص ، رؤوس TCP / IP / etc. فيها ، يتم تقديم القيم العددية - الإزاحات ، العدادات ، العناوين - بترتيب بايت الشبكة (big-endian) ؛ نحن نعمل على x86 (القليل endian). في الهياكل القياسية التي تصف الرؤوس ، يتم تمثيل هذه الحقول بأنواع عدد صحيح بسيط (
uint32_t
،
uint16_t
). بعد عدة أخطاء نظرًا لأننا نسينا تحويل ترتيب البايت ، قررنا استبدال أنواع الحقول بفئات تحظر التحويلات الضمنية والعمليات غير التقليدية. تحت القص هو رمز النفعية وأمثلة محددة من الأخطاء التي كشفت الكتابة الصارمة.
ترتيب البايت
Likbez لأولئك الذين ليسوا على علم ترتيب البايت (endianness ، ترتيب البايت). بمزيد من التفاصيل
كان بالفعل على "حبري" .
في التدوين المعتاد للأرقام ، ينتقلون من الأقدم (يسار) إلى الأصغر (يمين) على اليسار: 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
توجد وحدات البايت الموجودة في الذاكرة من الأصغر إلى الأقدم ، وليس كما هو الحال عند كتابة الأرقام. يُطلق على هذا الطلب
القليل endian (LE). وينطبق الشيء نفسه على أرقام 4 بايت. يتم تحديد ترتيب البايت بواسطة بنية المعالج. يسمى الترتيب "الأصلي" للمعالج أيضًا
بترتيب وحدة المعالجة المركزية أو المضيف (ترتيب بايت وحدة المعالجة المركزية / المضيف). في حالتنا ، ترتيب بايت المضيف هو endian قليلاً.
ومع ذلك ، لم يولد الإنترنت على x86 ، وهناك كان ترتيب البايت مختلفًا -
من الأقدم إلى الأحدث (الأقدم-الكبيرة ، BE). لقد بدأوا في استخدامه في رؤوس بروتوكولات الشبكة (IP ، TCP ، UDP) ، لذلك تسمى endian الكبيرة أيضًا
ترتيب بايت الشبكة.مثال: يتم كتابة المنفذ 443 (1bb
16 ) ، والذي يستخدم HTTPS ، في بايت رؤوس TCP رقم bb 01 ، والتي عند القراءة ستعطي bb01
16 = 47873.
يمكن تحويل ترتيب البايت لرقم. على سبيل المثال ، بالنسبة لـ
uint16_t
يوجد
htons()
دالة
htons()
h ost
to n etwork لـ
s hort integer - من ترتيب المضيف إلى ترتيب الشبكة للأعداد الصحيحة القصيرة) و
ntohs()
العكسية
ntohs()
. وبالمثل ، بالنسبة لـ
uint32_t
يوجد
htonl()
و
ntohl()
(يعد عددًا صحيحًا طويلًا).
لسوء الحظ ، لا يعرف المترجم من أين جاءت القيمة المحددة لمتغير النوع
uint32_t
، ولا يحذر إذا قمت بخلط القيم مع أوامر بايت مختلفة وحصلت على نتيجة غير صحيحة.
كتابة قوية
خطر الخلط بين أمر البايت هو واضح ، وكيفية التعامل معها؟
- مراجعة الكود. هذا إجراء إلزامي في مشروعنا. لسوء الحظ ، يرغب المختبرون على الأقل في الخوض في الشفرة التي تتعامل مع البايتات: "أرى
htons()
- ربما ، فكر المؤلف في كل شيء". - الانضباط ، وقواعد مثل: يكون فقط في الحزم ، جميع المتغيرات في جنيه. ليس من المعقول دائمًا ، على سبيل المثال ، إذا كنت بحاجة إلى التحقق من المنافذ مقابل جدول التجزئة ، فمن الأفضل تخزينها في ترتيب بايت الشبكة والبحث "كما هي".
- الاختبارات. كما تعلمون ، فهي لا تضمن عدم وجود أخطاء. يمكن مطابقة البيانات بشكل سيء (1.1.1.1 لا يتغير عند تحويل ترتيب البايت) أو تعديله إلى النتيجة.
عند العمل مع شبكة ، لا يمكنك تجاهل ترتيب البايت ، لذلك أود أن تجعل من المستحيل تجاهله عند كتابة الكود. علاوة على ذلك ، ليس لدينا فقط رقم في BE - إنه رقم منفذ ، عنوان IP ، رقم تسلسل TCP ، المجموع الاختباري. لا يمكن تعيين شخص لآخر ، حتى لو كان عدد البتات مطابقًا.
الحل معروف - الكتابة الصارمة ، أي أنواع منفصلة للمنافذ والعناوين والأرقام. بالإضافة إلى ذلك ، يجب أن تدعم هذه الأنواع تحويل BE / LE.
Boost.Endian لا
يناسبنا ، لأنه لا يوجد Boost في المشروع.
حجم المشروع حوالي 40 ألف خط في C ++ 17. إذا قمت بإنشاء أنواع مجمعة آمنة وكتابة هياكل رأس عليها ، فإن جميع الأماكن التي يوجد فيها عمل مع BE ستتوقف عن الترجمة تلقائيًا. يجب عليك المرور بها مرة واحدة ، لكن الرمز الجديد سيكون آمنًا فقط.
عدد الطبقة 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;
- سيتم تضمين ملف رأس بهذا النوع في كل مكان ، لذا بدلاً من
<iostream>
<iosfwd>
خفيف الوزن. - بدلا من
htons()
، وما إلى ذلك - جوهرية مترجم سريع. على وجه الخصوص ، constexpr
بالانتشار المستمر ، وبالتالي constexpr
. - في بعض الأحيان توجد بالفعل قيمة
uint16_t
/ uint32_t
في BE. تتيح لك بنية 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);
النتائج
خلاصة القول هي:
- وجدت علة واحدة والعديد من الأخطاء المنطقية في اختبارات الوحدة.
- جعل إعادة بيع المنازل لي فرز الأماكن المشكوك فيها ، وزيادة سهولة القراءة.
- تم الحفاظ على الأداء ، ولكن قد يكون انخفض - هناك حاجة إلى معايير.
النقاط الثلاث مهمة بالنسبة لنا ، لذلك نعتقد أن إعادة بيع المباني كانت تستحق العناء.
هل تؤمن نفسك ضد الأخطاء مع أنواع صارمة؟