3.20. البروتوكولات التسلسلية والتأطير وأكواد CRC

UART في الكود ينقل البايتات بين طرفين. لكن هذا وحده لا يكفي لبناء وصلة موثوقة. تظهر ثلاث مشكلات في اللحظة التي يوجد فيها جهاز حقيقي على الطرف الآخر من السلك:

  • أين تبدأ الرسالة وأين تنتهي؟ تصل البايتات في تدفق دون فاصل مدمج. إذا فات المستقبِل البايت الأول (شُغّل بعد المرسِل؛ أو حدث خلل كهربائي قصير على الخط)، فإن كل بايت بعده يصبح مزاحاً بمقدار واحد إلى أن يجد المستقبِل نقطة إعادة تزامن جديدة.

  • ما طول كل رسالة؟ قراءة مستشعر بطول 32 بايت وردّ حالة بطول 4 بايتات يبدوان متطابقين على مستوى البايت. يحتاج المستقبِل إلى طريقة لمعرفة عدد البايتات التي تنتمي إلى الرسالة الحالية.

  • هل وصلت البايتات سليمة؟ يمكن للضوضاء أن تقلب بتات فردية. ومن دون فحص، يتصرف المستقبِل بكل سرور بناءً على بيانات تالفة.

الجواب القياسي على المشكلات الثلاث جميعاً هو تغليف البيانات في إطار حزمة: تسلسل بايتات معروف في البداية، وحقل طول، والحمولة نفسها، ومجموع تحقق في النهاية.

3.20.1. تأطير الحزم

صيغة تأطير نموذجية:

Six fields drawn in sequence: a two-byte HEADER labelled 0xAA 0x55, a one-byte CMD field selecting which command this packet carries, a two-byte LEN field giving the payload size, a one-byte HCRC field covering HEADER plus CMD plus LEN, a variable-length PAYLOAD of LEN bytes whose format depends on the CMD, and a four-byte DCRC field covering the payload.

حزمة مؤطَّرة بأكواد CRC منفصلة للترويسة والبيانات: ترويسة (بايتات سحرية)، وأمر، وطول، وCRC الترويسة، وحمولة، وCRC البيانات.

كل حقل يؤدي مهمة واحدة:

  • الترويسة (البايتات السحرية). تسلسل بايتات ثابت وغير اعتيادي -- غالباً بايتان مثل 0xAA 0x55 -- يبحث عنه المستقبِل في التدفق الوارد. وعندما يجد التسلسل، يعلم أن حزمة جديدة تبدأ ويمكنه التخلص من أي بيانات عشوائية وردت قبلها.

  • الأمر. بايت واحد يبيّن ما هي الحزمة. تستخدم قيم الأوامر المختلفة صيغ حمولة مختلفة -- قد يعني أمر ما "تعيين زاوية المؤازر" مع بايتين للحمولة، وقد يعني أمر آخر "قراءة المستشعر" بلا حمولة، وقد يكون آخر "رسالة سجل" مع سلسلة نصية. يوزّع المستقبِل بناءً على بايت الأمر ليعرف كيف يفسّر بقية الحزمة.

  • الطول. بايتان يعطيان حجم الحمولة بالبايتات (بترتيب little-endian هنا)، مما يتيح حمولات تصل إلى نحو 64 KiB. يقرأ المستقبِل هذا العدد بالضبط من البايتات بعد التحقق من CRC الترويسة.

  • CRC الترويسة. مجموع تحقق بطول بايت واحد على حقول HEADER وCMD وLEN. يتحقق منه المستقبِل قبل قراءة أي حمولة، بحيث يُكتشف حقل LEN التالف بعد بضع بايتات فقط (راجع قسم CRC أدناه لمعرفة سبب أهمية ذلك).

  • الحمولة. بيانات تطبيقية خاصة بالأمر، طولها LEN بايت بالضبط. تتحدد الصيغة ببايت الأمر: سجل مُحزَّم بـ struct من حقول ثابتة العرض، أو سلسلة نصية، أو ذاكرة خام -- أياً كان ما يتفق عليه الطرفان لذلك الأمر.

  • CRC البيانات. كود CRC بطول أربعة بايتات على بايتات الحمولة. يعيد المستقبِل حسابه من البايتات التي قرأها للتو ويُسقِط الحزمة إذا لم يتطابق.

3.20.2. أكواد CRC

أبسط "مجموع تحقق" هو مجموع كل البايتات، باقي القسمة على 256 أو 65536. يلتقط معظم انقلابات البت الفردية لكنه يفوته كثير من أخطاء البتات المتعددة ويتجاهل ترتيب البايتات.

إن فحص التكرار الدوري (CRC) هو الترقية القياسية. فهو يعامل المدخلات كعدد ثنائي طويل واحد ويقسمه (بطريقة خاصة) على كثير حدود ثابت؛ ويكون باقي القسمة هو CRC. تلتقط كثيرات الحدود المختلفة أصنافاً مختلفة من الأخطاء؛ وتلتقط كثيرات الحدود الشائعة بأطوال 8 و16 و32 بت كلٌّ منها كل دفعة أخطاء أقصر من عرضها بالإضافة إلى نسبة كبيرة من الدفعات الأطول.

3.20.2.1. لماذا كودا CRC اثنان

يحمل مخطط الحزمة أعلاه كودَي CRC منفصلين -- أحدهما على الترويسة (HEADER وCMD وLEN) والآخر على الحمولة. هذا ما يحتاجه التأطير المتين فعلاً، بسبب الطريقة التي يفشل بها كود CRC وحيد في الذيل عندما يتلف حقل LEN نفسه أثناء النقل:

  • يتصرف المستقبِل بناءً على حقل LEN التالف ويقرأ هذا العدد من البايتات من السلك -- وربما أكثر بكثير مما قصده المرسِل.

  • لا يخبر إلا كود CRC الذيلي المستقبِلَ في النهاية أن خطأً قد حدث، وذلك فقط بعد أن تكون كل تلك البايتات قد استُهلكت.

  • بينما يكون المحلّل عالقاً في انتظار العدد الخاطئ من البايتات، تُبتلع الحزم الحقيقية الواصلة خلف الحزمة التالفة بوصفها حمولة، ويفقد المستقبِل عدة حزم بدلاً من حزمة واحدة فقط.

تقسيم كود CRC يصلح هذا:

  • يغطي CRC الترويسة حقول HEADER وCMD وLEN. يتحقق منه المستقبِل قبل قراءة أي حمولة، بحيث يُكتشف حقل LEN التالف بعد بضع بايتات ويعيد المحلّل التزامن فوراً، فلا يسقط سوى الحزمة السيئة الواحدة.

  • يغطي CRC البيانات الحمولة. وبمجرد اجتياز CRC الترويسة، يعلم المستقبِل أنه يمكنه الوثوق بحقل LEN، فيقرأ هذا العدد بالضبط من بايتات الحمولة ويتحقق منها مقابل CRC البيانات.

أحد التحجيمات الشائعة -- وهو ما تستخدمه هذه الصفحة -- هو بايت واحد لـ CRC الترويسة (كود CRC-8 يكفي وزيادة لترويسة من خمسة بايتات) وأربعة بايتات لـ CRC البيانات (كود CRC-32 يغطي عدة كيلوبايتات من الحمولة بمعدل تصادم متلاشٍ في الانخفاض).

3.20.2.2. الدوال المساعِدة

يوفّر MicroPython الدالة binascii.crc32() لكود CRC ذي الأربعة بايتات مباشرة. أما لكود CRC الترويسة ذي البايت الواحد، فإن دالة مساعِدة صغيرة تستخدم كثير الحدود الذي تستخدمه أجهزة Maxim التي تعمل بسلك واحد (0x8C في صورته المعكوسة) قصيرة بما يكفي لكتابتها ضمن السطر:

def crc8(data: bytes) -> int:
    crc = 0
    for byte in data:
        crc ^= byte
        for _ in range(8):
            crc = (crc >> 1) ^ 0x8C if crc & 1 else crc >> 1
    return crc & 0xFF

يجمع المُرمِّز الكامل كودَي CRC في دالة واحدة:

import binascii
import struct

def encode_packet(cmd: int, payload: bytes) -> bytes:
    header = b"\xAA\x55" + bytes([cmd]) + struct.pack("<H", len(payload))
    hcrc   = crc8(header)
    dcrc   = binascii.crc32(payload)
    return header + bytes([hcrc]) + payload + struct.pack("<I", dcrc)

تستعيد الدالة العكسية الأمر والحمولة من حزمة كاملة، أو تُرجِع None إذا فشل أي من فحصي CRC:

def decode_packet(packet: bytes):
    # Layout: HEADER(2) + CMD(1) + LEN(2) + HCRC(1) + PAYLOAD(LEN) + DCRC(4)
    if len(packet) < 10 or packet[0:2] != b"\xAA\x55":
        return None

    header = packet[0:5]
    if crc8(header) != packet[5]:
        return None                       # header CRC mismatch

    cmd    = packet[2]
    length = struct.unpack("<H", packet[3:5])[0]
    if len(packet) != 6 + length + 4:
        return None                       # truncated or oversized

    payload       = packet[6:6 + length]
    received_dcrc = struct.unpack("<I", packet[6 + length:])[0]
    if binascii.crc32(payload) != received_dcrc:
        return None                       # data CRC mismatch

    return cmd, bytes(payload)

في الممارسة العملية لا يُسلَّم للمستقبِل حزمة كاملة دفعة واحدة -- بل تصل البايتات واحدة تلو الأخرى عبر UART، والمرسِل الذي يتوقف في منتصف الحزمة (أو خط مشوش يفقد بايتاً) لا يمكن ببساطة قراءته بـ read() إلى مخزن مؤقت بالحجم الصحيح. يشغّل القسم التالي منطق فك الترميز نفسه بايتاً بايتاً بوصفه آلة حالات.

3.20.3. مستقبِل بآلة حالات

لا يمكن للمستقبِل أن يستدعي ببساطة uart.read(N) بعدد ثابت N -- فهو لا يعرف كم سيبلغ عدد بايتات الحزمة التالية، وأي بيانات غير مرغوبة على الخط تخل بالمحاذاة. الحل هو آلة حالات صغيرة تستهلك البايتات واحدة تلو الأخرى وتتفاعل بناءً على موضعها في الحزمة. يستفتي الحلقة الرئيسية الدالة any() لمعرفة كم بايتاً مخزّناً، ثم تستنزفها في استدعاء read() واحد، وتمرّر كل بايت عبر آلة الحالات:

import time
import binascii
import struct
from machine import UART

HEADER = b"\xAA\x55"
HEADER_LEN = len(HEADER)

# States, in the order the receiver walks through them per packet.
HUNT_FOR_HEADER = 0
READ_COMMAND    = 1
READ_LENGTH     = 2
READ_HEADER_CRC = 3
READ_PAYLOAD    = 4
READ_DATA_CRC   = 5

uart = UART(3, baudrate=115200)

# Receiver state plus partial-field buffers.
state        = HUNT_FOR_HEADER
matched      = 0              # bytes of HEADER matched so far
cmd          = 0              # CMD captured in READ_COMMAND
length_bytes = bytearray()    # raw LEN bytes (kept for the header CRC)
length       = 0              # unpacked LEN
payload      = bytearray()    # payload bytes accumulated in READ_PAYLOAD
crc_bytes    = bytearray()    # DCRC bytes accumulated in READ_DATA_CRC

def handle_packet(cmd: int, payload: bytes) -> None:
    print("cmd", cmd, "payload", payload)

while True:
    # Drain whatever bytes have arrived since the last poll. Idle
    # briefly when the line is quiet so the loop is not a busy spin.
    n = uart.any()
    if not n:
        time.sleep_ms(1)
        continue

    for b in uart.read(n):
        if state == HUNT_FOR_HEADER:
            # Walk the magic bytes. On a mismatch, back off by one
            # so a stray HEADER[0] in the noise still counts as a
            # possible start.
            if b == HEADER[matched]:
                matched += 1
                if matched == HEADER_LEN:
                    state = READ_COMMAND
                    matched = 0
            else:
                matched = 1 if b == HEADER[0] else 0

        elif state == READ_COMMAND:
            cmd = b
            length_bytes = bytearray()
            state = READ_LENGTH

        elif state == READ_LENGTH:
            # LEN is two bytes little-endian.
            length_bytes.append(b)
            if len(length_bytes) == 2:
                length = struct.unpack("<H", length_bytes)[0]
                state = READ_HEADER_CRC

        elif state == READ_HEADER_CRC:
            # Verify the CRC over HEADER + CMD + LEN before
            # committing to read LEN payload bytes. A mismatch
            # aborts here, after just five header bytes -- the
            # next valid header re-syncs quickly.
            expected = crc8(HEADER + bytes([cmd]) + length_bytes)
            if b == expected:
                payload = bytearray()
                state = READ_PAYLOAD
            else:
                state = HUNT_FOR_HEADER

        elif state == READ_PAYLOAD:
            payload.append(b)
            if len(payload) == length:
                crc_bytes = bytearray()
                state = READ_DATA_CRC

        elif state == READ_DATA_CRC:
            # Verify the CRC over the payload and either deliver
            # the packet or drop it. Either way, go back to
            # looking for the next header.
            crc_bytes.append(b)
            if len(crc_bytes) == 4:
                expected = binascii.crc32(payload)
                received = struct.unpack("<I", crc_bytes)[0]
                if expected == received:
                    handle_packet(cmd, bytes(payload))
                state = HUNT_FOR_HEADER

يقدّم كل بايت آلة الحالات خطوة واحدة، أو يرتد إلى HUNT_FOR_HEADER بعد حزمة كاملة، أو بعد CRC ترويسة سيئ، أو بعد CRC بيانات سيئ. تُتجاهَل البيانات غير المرغوبة على الخط التي لا تطابق الترويسة بصمت؛ وتعيد الترويسة الصالحة التالية تزامن المستقبِل. تأتي خاصية الأمان الرئيسية من CRC الترويسة: إذا تلف حقل LEN، فإن المحلّل يلتقطه بعد فحص CRC الترويسة (بضع بايتات)، وليس بعد الالتزام بقراءة عدد خاطئ تماماً من بايتات الحمولة.

3.20.4. ما وراء الحد الأدنى

التأطير أعلاه هو الحد الأدنى الذي تحتاجه وصلة تسلسلية للتعافي من ضوضاء الخط: ترويسة سحرية، وطول، وأمر، وكودا CRC. إنه يكشف التلف ويعيد التزامن بعد البايتات المشوشة، لكنه يستسلم تجاه الحزم التالفة بدلاً من تمريرها، ويترك المرسِل دون أي فكرة عما سمعه المستقبِل فعلاً.

تضيف البروتوكولات التسلسلية في العالم الواقعي طبقات من الميزات فوق ذلك الحد الأدنى. ليست كل وصلة مدمجة تحتاج إليها جميعاً -- اختر ما يتطلبه التطبيق فعلاً:

  • أرقام التسلسل. عدّاد صغير يزداد عند كل إرسال. يكشف المستقبِل الفجوات (فقدان حزمة)، والتكرارات (أعاد المرسِل الإرسال لكن المستقبِل كان قد قبل النسخة الأولى فعلاً)، وكذلك -- حيث يمكن للقناة أن تعيد الترتيب -- الوصول خارج الترتيب.

  • الإقرارات. حزمة ACK مخصصة (أو بت مُضمَّن في رد) يرسلها المستقبِل لتأكيد كل حزمة. من دون الإقرارات لا يملك المرسِل أي وسيلة لمعرفة أن بياناته وصلت.

  • الإقرارات السلبية. يُرسَل إقرار سلبي NACK عندما يرى المستقبِل فشل CRC أو فجوة في التسلسل. يعيد المرسِل الإرسال فوراً، بدلاً من انتظار انقضاء مهلة الإقرار ACK.

  • إعادة الإرسال. يحتفظ المرسِل بكل حزمة غير مُقَرّة في طابور صغير ويعيد إرسالها بعد انقضاء مهلة (أو عند ورود NACK). يمنع حدٌّ لعدد المحاولات وبعض التراجع بين المحاولات وصلةً معطّلة بشكل دائم من الدوران إلى الأبد.

  • النوافذ المنزلقة. السماح بعدة حزم في الطريق قبل اشتراط إقرار يحافظ على الإنتاجية مرتفعة في الوصلات التي يكون فيها زمن الذهاب والإياب طويلاً مقارنةً بزمن إرسال كل حزمة. والتكلفة هي مزيد من الحالة على جانب المرسِل -- خانة واحدة لكل حزمة في الطريق.

  • التحكم في التدفق. إشارة من المستقبِل تخبر المرسِل بالتباطؤ أو التوقف عندما يمتلئ مخزنه المؤقت. تتنوع طرق التنفيذ -- بايتات XON / XOFF صريحة، أو منح قائمة على الأرصدة حيث يرخّص المستقبِل لـ N من الحزم الإضافية في كل مرة، أو خطوط RTS / CTS العتادية على السلك نفسه. من دون تحكم في التدفق، يتجاوز المرسِل السريع في النهاية المستقبِل البطيء وتُسقَط الحزم.

  • إصدار البروتوكول. حقل إصدار في وقت مبكر من الحزمة يتيح للصيغة أن تتطور. يمكن لكل طرف أن يتفاوض على أعلى إصدار يدعمه الطرفان عند بدء التشغيل، أو أن يرفض الحزم من النظائر غير المتوافقة.

  • التجزئة وإعادة التجميع. حقل LEN ذو البايتين يقصر الحزمة عند 64 KiB؛ والرسائل الأكبر من ذلك تُقسَّم إلى عدة حزم ويُعاد تجميعها على الطرف الآخر. تعيش بيانات التجزئة الوصفية (فهرس الجزء، أو العدد الإجمالي، أو راية "مزيد من الأجزاء") داخل الحمولة.

  • نبضات القلب. حزمة دورية صغيرة تقول "ما زلت هنا". يلاحظ الطرف الآخر متى تتوقف نبضات القلب فيعيد الاتصال (أو يفشل بصوت عالٍ) بدلاً من التعليق بصمت.

  • القنوات. معرّف قناة أو تدفق في الترويسة بحيث تحمل وصلة فيزيائية واحدة عدة تدفقات منطقية -- قناة تحكم، وقناة قياس عن بعد، وقناة سجل -- لا يميزها سوى ذلك الحقل.

  • المصادقة. وسم قصير محسوب من الحمولة ومن قيمة سرية لا يعرفها سوى المرسِل والمستقبِل الشرعيين. يحسب المستقبِل الوسم مجدداً من البايتات التي تلقاها ويرفض الحزمة إذا لم يتطابق الاثنان. يلتقط هذا كلاً من العبث (عدّل مهاجم البايتات) وكذلك -- إذا كان رقم تسلسل أو طابع زمني جزءاً مما يغطيه الوسم -- إعادة التشغيل، حيث يسجّل مهاجم حزمة حقيقية من على السلك ويعيد إرسالها لاحقاً لجعل المستقبِل يتصرف بناءً عليها مرتين.

  • التشفير. خلط بايتات الحمولة بمفتاح سري مشترك بحيث لا يرى أي شخص يقرأ الخط دون ذلك المفتاح سوى ضوضاء. يُدمج عادةً مع وسم المصادقة أعلاه -- ومن دونه، يمكن لمهاجم أن يحقن بيانات عشوائية تصادف أن تجتاز فحص CRC، فيهدر المستقبِل دورات في محاولة فك تشفير محتوى لا معنى له.

ينتهي البروتوكول "الجيد" النموذجي للمعدات الصناعية بالتأطير، وكود CRC مزدوج، وأرقام التسلسل، وإقرار ACK / NACK مع إعادة الإرسال، ونبضات القلب. أمثلة من العالم الواقعي تستحق الاطلاع: MAVLink (قياس الطائرات المسيّرة عن بعد، مع أرقام تسلسل، ومعرّفات نظام / مكوّن، وتواقيع حزم اختيارية)، وModbus (وحدات التحكم المنطقية القابلة للبرمجة الصناعية، مع رموز دوال وكود CRC)، وNMEA 0183 (بروتوكول ASCII الذي يتحدث به كل مستقبِل GPS استهلاكي -- رسائل قائمة على الأسطر مع مجموع تحقق بعد فاصل نجمي).