Hasan Mahmud
Müh. Hasan MahmudMekatronik & Gömülü Sistemler
← العودة إلى المقالات

Hasan TechLab / دراسة حالة تقنية

مقاطعات العتاد وRing Buffer في الأنظمة المدمجة: تصميم UART لا يفقد البيانات

دراسة تقنية عميقة لبناء مسار استقبال UART يعتمد على ISR وRing Buffer مع تحليل التزامن، الفائض، إدارة الذاكرة، والاختبارات على المتحكمات الدقيقة.

مقدمة: لماذا يستحق UART تصميمًا دقيقًا؟

في الأنظمة المدمجة، يبدو UART واجهة بسيطة: خط استقبال، خط إرسال، وسرعة baud rate محددة. لكن هذا التصور ينهار عندما يعمل الجهاز في بيئة صناعية ويستقبل بيانات مستمرة من حساس أو مشغل أو وحدة اتصال لاسلكية.

قد تصل الرسائل في الوقت نفسه الذي ينفذ فيه المعالج حسابات تحكم، أو يكتب سجلًا في الذاكرة، أو يتعامل مع بروتوكول آخر. إذا صُمم الاستقبال بطريقة حاجزة أو اعتمد على polling بطيء، فسيصل بايت جديد قبل أن يقرأ البرنامج البايت السابق.

النتيجة هي Overrun Error أو رسالة ناقصة أو بروتوكول فقد تزامنه. والأسوأ أن الخطأ قد يظهر مرة كل عدة ساعات، لذلك لا يكفي اختبار الجهاز في ظروف هادئة.

هذا المقال يشرح تصميم طبقة استقبال عملية تعتمد على Interrupt Service Routine ومخزن دائري Ring Buffer، مع توضيح حدود الحل ومتى ننتقل إلى DMA.

1. تعريف المشكلة زمنيًا

لنفترض أن UART يعمل بسرعة 115200 baud مع 8 بت بيانات، وبت بداية، وبت توقف واحد. كل بايت يحتاج تقريبًا 10 بت على الخط.

زمن البايت الواحد يساوي تقريبًا:

10 / 115200 = 86.8 microseconds

هذا يعني أن المعالج قد يملك أقل من 87 ميكروثانية قبل وصول البايت التالي. في متحكم يعمل بسرعة منخفضة أو ينفذ مقطعًا حرجًا، هذه النافذة قصيرة جدًا.

عند سرعة 921600 baud يصبح الزمن قريبًا من 10.8 ميكروثانية فقط. هنا يصبح polling التقليدي مخاطرة حقيقية، حتى لو كان متوسط حمل المعالج منخفضًا.

المهم هو أسوأ زمن استجابة، وليس المتوسط. النظام الذي يستجيب بسرعة في 99% من الحالات قد يفشل في اللحظة التي تكون فيها الرسالة الحرجة قيد النقل.

2. لماذا يفشل Polling؟

في نموذج polling، تنفذ الحلقة الرئيسية شيئًا شبيهًا بهذا:

while (1) {
    if (UART_STATUS_REG & UART_RX_READY) {
        uint8_t byte = UART_DATA_REG;
        process_byte(byte);
    }

    read_sensor();
    update_display();
    run_control_loop();
}

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

إذا استغرقت update_display() وقتًا أطول من زمن بايت واحد، فقد يكتب العتاد البايت الجديد فوق سجل الاستقبال أو يعلن حالة overflow.

يمكن تقليل الخطر بتقصير الحلقة، لكن ذلك يحول المشكلة إلى سباق دائم بين البرنامج والعتاد. الحل الصحيح هو نقل لحظة الالتقاط إلى مقاطعة قريبة من مصدر الحدث.

3. ما الذي يجب أن تفعله ISR؟

القاعدة الذهبية هي أن ISR يجب أن تكون قصيرة وحتمية.

ينبغي أن تقوم بالخطوات التالية فقط:

  1. قراءة سجل الحالة لتثبيت حالة المقاطعة.
  2. قراءة سجل البيانات قبل فقدان البايت.
  3. وضع البايت في المخزن الدائري.
  4. تحريك مؤشر الكتابة.
  5. تسجيل overflow إن لم توجد مساحة.
  6. الخروج بأسرع وقت ممكن.

لا ينبغي أن تقوم ISR بتحليل JSON أو البحث عن نهاية رسالة أو تنفيذ printf أو الانتظار عبر delay.

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

4. نموذج Ring Buffer

المخزن الدائري هو مصفوفة ثابتة مع مؤشرين منطقيين: head للكتابة وtail للقراءة.

buffer: [A][B][C][D][ ][ ][ ][ ]
          ^       ^
        tail     head

تكتب ISR في موضع head ثم تزيده. تقرأ الحلقة الرئيسية من موضع tail ثم تزيده.

عند الوصول إلى نهاية المصفوفة يعود المؤشر إلى الصفر. لهذا السبب يسمى المخزن دائريًا.

يمكن استخدام عملية modulo، لكن في المسارات السريعة يفضل أن يكون الحجم قوة للعدد 2 حتى نستعمل قناعًا بتّيًا:

next = (index + 1u) & (BUFFER_SIZE - 1u);

هذه الطريقة تتطلب أن يكون BUFFER_SIZE مثل 64 أو 128 أو 256، وليس 100.

5. اختيار سياسة الامتلاء

عندما يصبح المخزن ممتلئًا، توجد عدة سياسات ممكنة.

السياسة الأولى: رفض البايت الجديد

يبقى المحتوى القديم سليمًا، ويسجل النظام حالة overflow. هذه السياسة مناسبة للرسائل التي لا يجوز فقد بدايتها.

السياسة الثانية: إسقاط أقدم بايت

يتقدم tail قبل كتابة البايت الجديد. هذه السياسة تحافظ على أحدث البيانات، لكنها قد تجعل الرسالة الحالية غير صالحة.

السياسة الثالثة: إيقاف الاستقبال

تستخدم عندما يكون فقدان البيانات أخطر من إيقاف المصدر وإطلاق إنذار للنظام الأعلى.

لا توجد سياسة عامة صحيحة لكل المشاريع. يجب أن يحددها بروتوكول النظام.

6. تنفيذ C آمن نسبيًا

المثال التالي يوضح بنية مخزن استقبال أحادي المنتج وأحادي المستهلك:

#include <stdbool.h>
#include <stdint.h>

#define UART_RX_CAPACITY 256u
#define UART_RX_MASK (UART_RX_CAPACITY - 1u)

typedef struct {
    uint8_t data[UART_RX_CAPACITY];
    volatile uint16_t head;
    volatile uint16_t tail;
    volatile uint32_t overflow_count;
} uart_ring_t;

static uart_ring_t uart_rx;

يجب التأكد أثناء البناء من أن الحجم قوة للعدد 2:

_Static_assert((UART_RX_CAPACITY & UART_RX_MASK) == 0u,
               "UART_RX_CAPACITY must be a power of two");

وعمليًا من الأفضل استخدام صيغة أوضح:

_Static_assert((UART_RX_CAPACITY & (UART_RX_CAPACITY - 1u)) == 0u,
               "capacity must be a power of two");

7. دالة الكتابة من ISR

static void uart_rx_push_from_isr(uint8_t value) {
    uint16_t head = uart_rx.head;
    uint16_t next = (uint16_t)((head + 1u) & UART_RX_MASK);

    if (next == uart_rx.tail) {
        uart_rx.overflow_count++;
        return;
    }

    uart_rx.data[head] = value;
    uart_rx.head = next;
}

ترتيب التعليمات مهم. يجب كتابة البايت في المصفوفة قبل نشر قيمة head الجديدة.

لو تم تحديث head أولًا، فقد يقرأ المستهلك موضعًا لم تكتب فيه ISR البيانات بعد.

في بعض المعالجات، القراءة والكتابة بحجم 16 بت ليست ذرية إذا كان ناقل البيانات 8 بت. عندها يجب استخدام نوع يناسب المعالج أو حماية الوصول.

8. دالة القراءة من الحلقة الرئيسية

bool uart_rx_pop(uint8_t *value) {
    uint16_t tail = uart_rx.tail;

    if (tail == uart_rx.head) {
        return false;
    }

    *value = uart_rx.data[tail];
    uart_rx.tail = (uint16_t)((tail + 1u) & UART_RX_MASK);
    return true;
}

في نموذج single-producer/single-consumer، يكتب كل مؤشر طرف واحد فقط. هذا يقلل الحاجة إلى أقفال ثقيلة.

لكن يجب مراجعة حجم المؤشر وترتيب الذاكرة وفق نوع المعالج والمترجم.

9. ربط المقاطعة بالعتاد

يختلف اسم سجل UART بين STM32 وAVR وNXP وESP32، لكن التسلسل المنطقي واحد:

void USART1_IRQHandler(void) {
    uint32_t status = USART1->ISR;

    if ((status & USART_ISR_RXNE_RXFNE) != 0u) {
        uint8_t value = (uint8_t)USART1->RDR;
        uart_rx_push_from_isr(value);
    }

    if ((status & USART_ISR_ORE) != 0u) {
        uart_rx_error_count++;
        (void)USART1->RDR;
    }
}

يجب قراءة دليل المتحكم لأن مسح أعلام الأخطاء قد يتطلب ترتيبًا محددًا لقراءة سجل الحالة والبيانات.

الاعتماد على مثال من متحكم مختلف قد يترك العلم مرفوعًا ويعيد تشغيل ISR بلا توقف.

10. التزامن والذاكرة المتطايرة

استخدام volatile يخبر المترجم بأن القيمة قد تتغير خارج التدفق العادي للبرنامج.

لكنه لا يجعل العمليات المركبة ذرية، ولا ينشئ حاجز ذاكرة كاملًا، ولا يحل مشكلة القراءة والكتابة المتزامنة تلقائيًا.

إذا كان المؤشر بحجم يمكن للمعالج قراءته في تعليمة واحدة، فقد يكون الوصول إليه آمنًا في هذا السيناريو. إذا لم يكن كذلك، نحتاج إلى تعطيل مقاطعة UART لحظيًا أو استخدام آلية ذرية مناسبة.

يجب ألا نعطل جميع المقاطعات لفترة طويلة من أجل قراءة بضعة بايتات. مدة المنطقة الحرجة يجب أن تكون قابلة للقياس وبأقل عدد من التعليمات.

11. تحليل حجم المخزن

حجم المخزن ليس رقمًا عشوائيًا. نحسبه من أعلى معدل وصول، وأسوأ زمن لا يستطيع فيه المستهلك معالجة البيانات.

required_bytes >= arrival_rate_bytes_per_second * blocked_time_seconds

إذا كان المصدر يرسل 2000 بايت في الثانية، وقد تتوقف الحلقة الرئيسية 30 ميلي ثانية، فالحد الأدنى هو 60 بايت، ثم نضيف هامشًا للأمان.

اختيار 64 قد يكون ضيقًا، بينما 128 أو 256 يعطي مساحة أفضل للتذبذب.

زيادة الحجم تستهلك RAM، لذلك يجب موازنة الذاكرة مع أسوأ حالة فعلية.

12. فصل النقل عن تحليل البروتوكول

لا تجعل ISR تعرف أن الرسالة تبدأ بـ 0xAA أو أنها JSON.

دورها هو نقل البايتات بأمان. أما آلة الحالات التي تبحث عن header وطول وحقل CRC فتعمل في سياق الحلقة الرئيسية.

typedef enum {
    FRAME_WAIT_SYNC,
    FRAME_READ_LENGTH,
    FRAME_READ_PAYLOAD,
    FRAME_READ_CRC
} frame_state_t;

هذا الفصل يجعل اختبار النقل مستقلًا عن اختبار البروتوكول، ويمنع تضخم ISR.

13. اكتشاف حدود الرسائل

هناك ثلاث طرق شائعة لتحديد نهاية الرسالة:

  • delimiter مثل \n أو 0x7E.
  • طول صريح في رأس الرسالة.
  • مهلة صمت بين البايتات.

الطول الصريح أكثر حتمية، لكن يجب التحقق من أنه ضمن الحد الأقصى قبل نسخ البيانات.

delimiter أبسط، لكنه يحتاج إلى escape إذا ظهرت القيمة داخل payload.

مهلة الصمت حساسة لتغير baud rate وجدولة المهام، لذلك لا تعتمد عليها وحدها في بروتوكول صناعي مهم.

14. CRC والتحقق من السلامة

Ring Buffer يمنع فقدان البايت بسبب بطء الحلقة، لكنه لا يثبت أن البيانات وصلت سليمة عبر السلك.

يجب أن يحتوي الإطار على CRC أو checksum مناسب.

uint16_t crc16_update(uint16_t crc, uint8_t value) {
    crc ^= value;
    for (uint8_t bit = 0u; bit < 8u; ++bit) {
        crc = (crc & 1u) ? (crc >> 1u) ^ 0xA001u : (crc >> 1u);
    }
    return crc;
}

اختيار CRC يعتمد على طول الإطار ونموذج الأخطاء المطلوب. لا تستخدم checksum بسيطًا لمجرد أنه أسهل إذا كان البروتوكول يحتاج كشف أخطاء متجاورة.

15. أخطاء شائعة في التنفيذ

تحديث المؤشر قبل البيانات

يؤدي إلى قراءة مستهلكة لبيانات غير مكتملة.

استخدام حجم غير مناسب مع modulo

المؤشر قد يقفز خارج المصفوفة إذا استعملنا قناعًا مع حجم ليس قوة للعدد 2.

تنفيذ printf داخل ISR

قد تكون printf حاجزة وثقيلة وتستخدم قفلًا داخليًا أو مخزنًا مشتركًا.

تجاهل overflow

الخطأ الذي لا يسجل لا يمكن تشخيصه. يجب عدّه وإظهاره في telemetry.

نسخ البيانات إلى مصفوفة غير محدودة

طول الرسالة القادم من الطرف الآخر لا يُوثق به. تحقق من الطول قبل النسخ.

افتراض أن volatile يحل كل شيء

هو أداة للملاحظة، وليس بروتوكول تزامن كاملًا.

16. الاختبارات الوحدوية

اختبر المخزن دون العتاد أولًا.

الحالات الأساسية هي:

  1. القراءة من مخزن فارغ.
  2. كتابة بايت واحد ثم قراءته.
  3. الالتفاف من نهاية المصفوفة إلى بدايتها.
  4. الامتلاء واحتساب overflow.
  5. كتابة وقراءة متناوبتان.
  6. كتابة متواصلة مع مستهلك أبطأ.
  7. عدم تغيير البيانات بعد إخراجها.

يمكن تشغيل هذه الاختبارات على الحاسوب مع تنفيذ C عادي، ثم إضافة اختبارات تكامل على اللوحة.

17. اختبار الضغط

في اختبار الضغط، أرسل نمطًا متسلسلًا مثل 0, 1, 2 ... 255 لفترة طويلة.

يقرأ المستقبل البيانات ويقارن كل بايت بالمتوقع. عند حدوث فجوة يمكن معرفة هل الخطأ من النقل أو من parser أو من overflow.

يجب تنفيذ الاختبار أثناء تشغيل مهام تشبه الواقع، مثل تحديث شاشة أو قراءة ADC أو تسجيل Flash.

الاختبار الساكن في حلقة فارغة لا يمثل المنتج الحقيقي.

18. متى نستخدم DMA؟

إذا ارتفع baud rate أو كبرت الرسائل أو كان المعالج يتعامل مع عدة قنوات، قد تصبح مقاطعة لكل بايت مكلفة.

هنا نستخدم DMA مع مخزن دائري أو مخزن مزدوج.

DMA ينقل البيانات من سجل UART إلى RAM دون تدخل المعالج لكل بايت، ثم نستخدم مقاطعة عند نصف المخزن أو نهايته.

لكن DMA لا يلغي الحاجة إلى تصميم صحيح؛ يجب معرفة عدد البايتات الجديدة، ومعالجة التفاف المؤشر، وتزامن cache في المعالجات التي تحتوي على data cache.

19. مقارنة ISR لكل بايت مع DMA

العامل ISR لكل بايت DMA دائري
البساطة أعلى متوسطة
حمل المعالج يزيد مع baud rate منخفض
زمن الاستجابة ممتاز للبايت يعتمد على حجم الدفعة
التعقيد منخفض أعلى
مناسب لـ رسائل قصيرة تدفق مستمر وكبير

ابدأ بالحل الأبسط القابل للقياس، ثم انتقل إلى DMA عندما تثبت الأرقام الحاجة.

20. المراقبة في المنتج

أضف عدادات لا تستهلك وقتًا كبيرًا:

  • عدد البايتات المستلمة.
  • عدد overflow.
  • عدد أخطاء framing وparity.
  • أكبر استخدام للمخزن.
  • عدد الإطارات المرفوضة بسبب CRC.
  • متوسط زمن معالجة الإطار.

هذه العدادات تختصر ساعات من التخمين عند ظهور مشكلة في موقع العميل.

21. الخلاصة

تصميم UART الجيد ليس مجرد تفعيل interrupt. هو عقد واضح بين العتاد وISR وRing Buffer وparser.

تحافظ ISR على البايت، ويحافظ المخزن على الترتيب، وتتحقق آلة الحالات من الإطار، ويقرر البروتوكول ما يجب فعله عند الخطأ.

عندما تقاس أسوأ الحالات ويُختبر overflow وتُفصل طبقات النقل عن التحليل، تصبح الاتصالات مستقرة وقابلة للصيانة حتى في البيئات الصناعية الصعبة.