مزالق تطبيقات الاتصالات التسلسلية - حتى إعادة الاتصال وتصميم السجلّ
· آخر تحديث: · 小村 豪 · اتصالات تسلسلية, RS-232, C#, .NET, تطوير Windows, تكامل الأجهزة
سجل التعديلات (3 تحديثات، آخر تحديث 3 Sep، 2026)
سجل بالتغييرات التي أُجريت على هذا المقال. وحيثما حُفظت نسخة سابقة، تبقى متاحة للقراءة عبر رابط دائم يحمل معرّف DOI.
- أُضيفت روابط الاستشارة الموجودة في الأصل الياباني (consultation_services). ولم يتغيّر نصّ المقالة نفسه. قراءة النسخة السابقة لهذا التحديث (DOI: 10.5281/zenodo.22240866)
- أُعيدَت الترجمة العربية كترجمة كاملة عن النص الياباني الأصلي، وأُضيفَت خريطة المعرفة.
- أعيدت الترجمة كترجمة كاملة عن النص الياباني الأصلي. كانت النسخة العربية السابقة مختصراً يسقط أبواباً وجداول ورسوم Mermaid وتعليقات الأشكال وFAQ. أُعيدت هذه العناصر وفق الأصل الياباني، والادّعاءات التقنية مطابقة للنسخة اليابانية.
- النشر الأول
الاستشهاد بهذا المقال(DOI: 10.5281/zenodo.21621424)
هذا المقال محفوظ على Zenodo. يرد أدناه معرّف DOI الذي يشير دائمًا إلى أحدث نسخة، ومعرّف DOI المثبَّت على النسخة التي تقرؤها.
小村 豪 (2026). مزالق تطبيقات الاتصالات التسلسلية - حتى إعادة الاتصال وتصميم السجلّ. شركة كومورا سوفت ذ.م.م.. https://doi.org/10.5281/zenodo.21621424 https://comcomponent.com/ar/blog/2026/03/19/001-serial-communication-app-pitfalls/
- DOI (أحدث نسخة)
- 10.5281/zenodo.21621424
- DOI (هذه النسخة)
- 10.5281/zenodo.22279853
ربط الأجهزة، وأجهزة القياس، وPLC، وقارئ الباركود، ومحوّل USB-serial. تبدو الاتصالات التسلسلية تقنية قديمة، لكنّها ما زالت شائعة جدّاً في عمل تطبيقات Windows.
ما يدعو إلى الحذر أنّ الاتصالات التسلسلية يمكن البدء بها بـمنفذ COM واحد وRead / Write واحد. فحص التوصيل يمرّ فوراً، لكن بعد الإنتاج تظهر عادة أعراض كهذه:
- ينزاح الأمر والاستجابة من حين لآخر
- يتجمّد مرّة واحدة في اليوم
- لا يعود إلّا بعد فصل USB وإعادة وصله
- تتوقّف واجهة المستخدم أحياناً
- لا يبقى في السجلّ سوى “Timeout”
الصعوبة الحقيقية في تطبيق الاتصالات التسلسلية ليست واجهة الإرسال والاستقبال نفسها، بل الحدود، والـ timeout، وانتقالات الحالة، وإعادة الاتصال، وقابلية الرصد.
flowchart TB
accTitle: فحص التوصيل يمرّ لكن الإنتاج ينكسر
accDescr: مخطّط يبيّن أنّ الاتصالات التسلسلية تبدأ بمنفذ COM واحد وRead وWrite وأنّ فحص التوصيل يمرّ فوراً، لكن في الإنتاج تظهر أعراض مثل انزياح الاستجابة أو التجمّد أو عدم العودة، وأنّ الصعوبة الحقيقية هي الحدود والـ timeout وانتقال الحالة وإعادة الاتصال وقابلية الرصد لا واجهة الإرسال والاستقبال نفسها.
a1["فحص التوصيل يمرّ فوراً"] --> a2["في الإنتاج ينكسر «أحياناً»"]
a2 --> a3["انزياح الاستجابة · تجمّد · لا يعود"]
a3 --> a4["الصعوبة ليست واجهة الإرسال والاستقبال"]
a4 -.-> a5["حدود · timeout · حالة · إعادة اتصال · رصد"]
الشكل 1: ما بعد فحص التوصيل هو الصعوبة الحقيقية في تطبيق الاتصالات التسلسلية.
جمهور المقال والافتراضات
| البند | المحتوى |
|---|---|
| الجمهور | من يبني تطبيق Windows يتّصل تسلسليّاً بجهاز أو جهاز قياس. موجّه لمن مرّ فحص التوصيل عنده ويريد تقليل الحالات التي تنكسر «أحياناً» في الإنتاج |
| المعرفة المفترضة | القدرة على كتابة تطبيق بـ C#. لا يُفترَض خبرة سابقة بالاتصالات التسلسلية نفسها |
| البيئة المفترضة | مكتوب على أساس System.IO.Ports.SerialPort في .NET، لكن طريقة التفكير في الحدود والـ timeout وانتقال الحالة لا تعتمد على اللغة |
| ما لا يشمله | التوصيل الكهربائي، ومواصفات بروتوكول جهاز معيّن |
مصطلحات يستخدمها المقال
| المصطلح | المعنى في سطر واحد |
|---|---|
| PLC | Programmable Logic Controller. متحكّم صناعي للتحكّم في معدّات الإنتاج |
| RS-232 / RS-485 | مواصفات كهربائية للاتصالات التسلسلية. RS-232 واحد لواحد، وRS-485 يسمح بتعليق عدّة وحدات على الخطّ نفسه. في RS-485 يجب تحديد من يرسل ومتى وإلّا يحدث تصادم |
| 8N1 | اختصار لإعدادات المنفذ. تركيبة 8 بتات بيانات، بلا زوجية (None)، وبت توقّف واحد |
| DTR / RTS | خطوط تحكّم. أصلها الإشارة إلى جاهزية الاتصال أو طلب الإرسال، لكن الأجهزة الحقيقية تستخدم تغيّرها أحياناً إشارة تشغيل أو تبديل وضع |
| التحكّم بالتدفّق | آلية تمنع الإرسال الزائد. RTS/CTS خطوط تحكّم، وXON/XOFF ينقلان التوقّف والاستئناف بأحرف خاصّة داخل البيانات |
| keepalive | أمر خفيف يُرسَل دوريّاً للتأكّد من أنّ الطرف الآخر ما زال حيّاً |
| إطار (frame) | تسلسل بايتات لرسالة واحدة. أين يبدأ الإطار وأين ينتهي يحدّده البروتوكول |
| single writer | تصميم يجمع الإرسال في عامل واحد. معناه: عدم السماح باستدعاء Write من أيّ مكان |
1. الخلاصة أوّلاً
أوّلاً، ملخّص بصياغة عمليّة.
- الاتصالات التسلسلية byte stream مرتّب، وحدود الرسالة لا تُرفق من تلقاء نفسها
- استدعاء
Read(100)لا يضمن إرجاع 100 بايت بالضبط DataReceivedفي.NETلا يُضمَن إطلاقه لكلّ بايت مستقبَل، وهو أيضاً ليس على خيط واجهة المستخدمReadLine()/WriteLine()يكونان مستقيمين فقط حين يتحدّث الطرف الآخر فعلاً ببروتوكول نصّي قائم على الأسطر- timeout واحد لا يكفي. الأثبت تقسيم المعاني مثل
openوinter-byteوresponseوreconnect - تجميع الإرسال في single writer أثبت من السماح بـ
Writeمن أيّ مكان - في USB-serial أهدأ أن تفترض من اليوم الأوّل الفصل وإعادة التوصيل، وإعادة التعداد، وتغيّر رقم COM، وفشل إعادة الاتصال
باختصار، صعوبة تطبيق الاتصالات التسلسلية ليست «هل يُفتح المنفذ»، بل كيف تحوّل تسلسل البايتات إلى رسالة ذات معنى، وكيف تدير الزمن والحالة حول ذلك.
في المخطّط، يشير الخطّ المتّصل إلى علاقة قائمة دائماً، ويشير الخطّ المتقطّع إلى علاقة مشروطة (شروط قيامها مذكورة في شرح كلّ علاقة في الصفحة التفصيليّة). القائمة الكاملة للعلاقات (المجموع 18، مع الأدلّة ودرجة اليقين) وتعريفات المفاهيم الرئيسة مجمّعة في صفحة تفاصيل خريطة المعرفة (باليابانية). البيانات: JSON-LD / Turtle
2. الاتصالات التسلسلية ليست «رسالة» بل «byte stream مرتّب»
من جهة التطبيق تبدو الاتصالات التسلسلية كأنّك «ترسل أمراً واحداً وتستقبل استجابة واحدة». لكن في الطبقة الأدنى لا يجري سوى تسلسل بايتات مرتّب.
ما تكتبه أنت بـ Write واحدة قد يظهر عند الطرف الآخر هكذا:
- يصل في
Readواحدة - يصل مقسوماً على مرّتين
- يصل ملتحماً ببيانات أخرى
إذا سقط هذا الافتراض، يبدأ التطبيق بالاعتقاد أنّ «Read هذه يجب أن تكون استجابة هذه المرّة». وهذا الاعتقاد غالباً أوّل لغم في تطبيق الاتصالات التسلسلية.
flowchart TB
accTitle: وصول Write واحدة له ثلاثة أشكال
accDescr: مخطّط يبيّن أنّ ما يُكتَب بـ Write واحدة لا يصل بالضرورة في Read واحدة عند الطرف الآخر، بل قد ينقسم على مرّتين أو يلتحم ببيانات أخرى.
b0["Write واحدة"] --> b1["يصل في Read واحدة"]
b0 --> b2["يصل مقسوماً على مرّتين"]
b0 --> b3["يلتحم ببيانات أخرى"]
b2 -.-> b4["«Read هذه = استجابة هذه» ليست مضمونة"]
الشكل 2: كيف تظهر Write واحدة عند الطرف الآخر لا يُعرَف إلّا بعد الوصول.
| وهم شائع | الواقع |
|---|---|
Read(16) يعيد 16 بايت بالضبط |
قد لا يُؤخَذ إلّا جزء بحسب وصول البيانات والـ timeout |
DataReceived = وصول رسالة واحدة |
الحدث غير مضمون لكلّ بايت، وليس على خيط الواجهة |
عودة Write = اكتمال معالجة الطرف الآخر |
في كثير من الحالات أقرب إلى أنّ المرسل أدخل البيانات في المخزن |
| قائمة COM = حقيقة ما هو متّصل الآن | ترتيب التعداد غير ثابت، وقد تكون نتيجة التعداد قديمة (stale) |
لذلك تحتاج الاتصالات التسلسلية إلى تعريف حدود الرسالة بروتوكولاً بنفسك. إطار ثابت الطول، أو قائم على فاصل، أو طول + payload + checksum؛ الشكل حرّ، لكن الدخول في التنفيذ وهو غامض يكاد يضمن المشقّة لاحقاً.
flowchart TB
accTitle: الحدود تُعرَّف بنفسك
accDescr: مخطّط يبيّن أنّ الاتصالات التسلسلية تتطلّب تعريف حدود الرسالة بروتوكولاً بنفسك، وأنّ الشكل قد يكون إطاراً ثابت الطول أو قائماً على فاصل أو طولاً مع payload وchecksum، وأنّ الغموض عند التنفيذ يجعل الأمر شاقاً.
c0["تعريف حدود الرسالة بنفسك"] --> c1["إطار ثابت الطول"]
c0 --> c2["قائم على فاصل"]
c0 --> c3["طول+payload+checksum"]
c0 -.-> c4["الغموض يجعله شاقاً"]
الشكل 3: الطبقة الأدنى لا تضع حدود الرسالة، لذا تُقرَّر بروتوكولاً أوّلاً.
3. ما يجب تقريره أوّلاً
قبل بناء تطبيق الاتصالات التسلسلية، قرّر على الأقلّ ما يلي مسبقاً.
3.1 حدود الإطار
تقرّر أيّ تسلسل بايتات يُعدّ رسالة واحدة. هل الطول ثابت، أم فاصل أسطر، أم طول مضمَّن، وهل يوجد checksum / CRC. إن بقي هذا غامضاً، لا يستطيع طرف الاستقبال أن يحكم: «ما زال ناقصاً» أم «تالف».
3.2 نصّ أم ثنائي أم مزيج
تقرّر مسبقاً: بروتوكول أسطر ASCII / UTF-8، أم ثنائي خالص، أم مزيج. وخصوصاً المزيج من نوع «قسم الأمر نصّ، والـ payload ثنائي، والنهاية سطر جديد» ينهار حدوده سريعاً إن لم تُصرَّح أين ينتهي فكّ الترميز وأين يبدأ التعامل بايتات خام.
3.3 معنى الـ timeout
الـ timeout ليس واحداً؛ الأأمن التفكير فيه مقسوماً حسب المعنى.
- open timeout: حتى فتح المنفذ
- inter-byte timeout: زمن انقطاع البايت في منتصف الإطار
- response timeout: من إصدار الأمر حتى اكتمال الاستجابة
- reconnect backoff: فاصل الانتظار عند إعادة الاتصال
الأثبت اعتبار الـ timeout قاعدة تدفع انتقال الحالة لا «تأميناً ضدّ البطء».
flowchart TB
accTitle: الـ timeout يُقسَم حسب المعنى
accDescr: مخطّط يبيّن تقسيم الـ timeout إلى open حتى فتح المنفذ، وinter-byte لانقطاع البايت في منتصف الإطار، وresponse حتى اكتمال الاستجابة، وreconnect backoff لفاصل إعادة الاتصال، واعتباره قاعدة تدفع انتقال الحالة.
d0["timeout واحد لا يكفي"] --> d1["open: حتى الفتح"]
d0 --> d2["inter-byte: صمت"]
d0 --> d3["response: اكتمال الاستجابة"]
d0 -.-> d4["reconnect backoff"]
d1 --> d5["قاعدة تدفع انتقال الحالة"]
d2 --> d5
d3 --> d5
الشكل 4: تقسيم أربعة أنواع من الـ timeout يسمح بمعاملتها قواعد لانتقال الحالة.
3.4 التحكّم بالتدفّق وحالة الخطّ
الإعدادات التي يستحقّ التصريح بها تقريباً هذه:
BaudRateDataBitsParityStopBitsHandshakeDTR/RTS
إن اكتفيت بـ «8N1 يكفي تقريباً»، يتوقّف الجهاز المقابل بسهولة.
3.5 فصل المسؤوليات
تفرّق من يتولّى ماذا.
- من يقرأ
- من يكتب
- من يحلّل
- من يعكس النتيجة على حالة العمل
كلّما اختلطت واجهة المستخدم بالاتصال، ازداد كسر الاتصالات التسلسلية.
flowchart TB
accTitle: فصل المسؤوليات وعدم خلط الواجهة بالاتصال
accDescr: مخطّط يبيّن فصل من يقرأ ومن يكتب ومن يحلّل ومن يعكس على حالة العمل، وأنّ خلط واجهة المستخدم بالاتصال يزيد الكسر.
e0["فصل المسؤوليات"] --> e1["من يقرأ ومن يكتب"]
e0 --> e2["من يحلّل"]
e0 --> e3["من يعكس على حالة العمل"]
e0 -.-> e4["خلط الواجهة بالاتصال يزيد الكسر"]
الشكل 5: افصل القراءة والكتابة والتحليل والعكس، ولا تخلط الواجهة بالاتصال.
3.6 انتقالات حالة البدء والإيقاف وإعادة الاتصال
كحدّ أدنى، أدخل في التصميم حالات من قبيل Closed وOpening وReady وWaitingResponse وFault وReconnecting. فور الفصل وإعادة الوصل قد يكون الطرف الآخر ما زال يقلع، وقد يكون جرّ الطلبات المعلّقة السابقة ممنوعاً.
stateDiagram-v2
[*] --> Closed
Closed --> Opening: طلب Open
Opening --> Ready: نجاح open + اكتمال تسلسل التهيئة
Opening --> Fault: فشل open / خطأ صلاحيات / timeout التهيئة
Ready --> WaitingResponse: إرسال الأمر
WaitingResponse --> Ready: استلام إطار الاستجابة المطابق
WaitingResponse --> Fault: response timeout
Ready --> Fault: خطأ I/O / اكتشاف فصل الكابل
Fault --> Reconnecting: إفشال الطلبات المعلّقة وبدء backoff
Reconnecting --> Opening: انقضاء backoff
Reconnecting --> Closed: بلوغ الحدّ / إيقاف يدوي
Ready --> Closed: طلب Close
الشكل 6: انتقالات حالة جلسة الاتصال. لا يوجد خطّ يعود مباشرة من Fault إلى Ready.
المهمّ في هذا المخطّط أنّه لا يوجد خطّ يعود مباشرة من Fault إلى Ready.
بعد الخلل تمرّ حتماً بـ Reconnecting وOpening، وتعيد بناء مخزن الاستقبال وحالة المحلّل والطلبات المعلّقة وتسلسل التهيئة قبل العودة إلى Ready. اختصار هذا المسار يسقطك في 4.7: «إعادة Open() وحدها تُوهِم بأنّك أعدت الاتصال».
3.7 السجلّ وقابلية التحقيق
أصعب ما تواجهه لاحقاً يكاد يكون هنا. كحدّ أدنى تريد أن يبقى: أوقات open / close / reopen، وإعدادات المنفذ المستخدمة، وhex dump لإطارات الإرسال والاستقبال، وأخطاء checksum / CRC، وframe timeout / response timeout، وسبب إعادة الاتصال.
4. المزالق الشائعة
4.1 الاعتقاد أنّ Read واحدة = رسالة واحدة
هذا الأكثر شيوعاً. مثلاً يفترض أنّ الطرف الآخر يعيد إطاراً من ترويسة وطول وpayload وCRC. إن استدعيت Read(buffer, 0, expectedLength) مرّة واحدة واعتبرت القيمة المعادة إطاراً واحداً كما هي، ينكسر الأمر بسهولة عند الاستقبال الجزئي.
أشكال الكسر الشائعة ثلاثة:
- يُقرأ الطول بينما الـ payload لم يصل بعد
- يصل إطار ونصف، ويُرحَّل النصف الثاني إلى
Readالتالية - يصل إطاران معاً، فيُعالَج الأوّل ويُرمى الباقي
بالرسم، المسألة أنّ ترتيب ما أرسله الجهاز وترتيب ما يعيده Read لا يتطابقان.
ما أرسله الجهاز
[--- الإطار 1 ---][--- الإطار 2 ---]
النمط 1: لا يصل إلّا جزء
Read الأولى -> [ STX ][ LEN ] <- الـ payload لم يصل بعد
Read الثانية -> [ payload ][ CRC ][--- الإطار 2 ---]
النمط 2: يصل إطار ونصف
Read الأولى -> [--- الإطار 1 ---][ مقدّمة الإطار 2 ]
Read الثانية -> [ تتمّة الإطار 2 ]
النمط 3: يصل إطاران معاً
Read الأولى -> [--- الإطار 1 ---][--- الإطار 2 ---] <- يسهل معالجة واحد ورمي الباقي
الأنماط الثلاثة ليست «تلفاً» بل «موضع القطع لا يطابق عدد مرّات Read» فقط. إن أخطأت هنا وعاملت اختلاف عدد البايتات عن المتوقّع خطأً، تبدأ بعدّ الاتصال السليم أخطاءً.
العلاج بسيط: تكديس الاستقبال أوّلاً، ثمّ يقتطع المحلّل الإطارات من ذلك المخزن. هيكل الشيفرة في 5.1.
flowchart TB
accTitle: تكديس ثمّ اقتطاع
accDescr: مخطّط يبيّن أنّ اعتبار وحدة عودة Read إطاراً واحداً ينكسر عند الاستقبال الجزئي، لذا يُكدَّس الاستقبال أوّلاً في مخزن ثمّ يقتطع المحلّل الإطارات.
f1["عودة Read = إطار واحد"] --> f2["ينكسر بسهولة عند الاستقبال الجزئي"]
f2 -.->|"بدلاً من ذلك"| f3["تكديس الاستقبال أوّلاً في مخزن"]
f3 --> f4["المحلّل يقتطع الإطارات"]
الشكل 7: افصل وحدة عودة Read عن الإطار، وكدّس ثمّ اقتطع.
4.2 معاملة DataReceived حدث عمل كما هو
SerialPort.DataReceived في .NET يبدو مريحاً، لكن اعتباره «إشعاراً بوصول رسالة واحدة» خطر. عمليّاً اكتفِ بـ DataReceived إشعاراً أنّ «شيئاً وصل على الأرجح»، ولا تنفّذ معالجة ثقيلة داخل المعالج، وأعد تحديثات الواجهة دائماً إلى خيط الواجهة.
4.3 الاعتقاد أنّ Write جائز من أيّ مكان
تكوين يستدعي فيه زرّ الواجهة ومؤقّت المراقبة ومعالجة إعادة الاتصال وkeepalive كلٌّ Write مباشرة تكوين هشّ. الاتصالات التسلسلية byte stream، لذا بحسب التصميم قد يتقاطع أمر مع آخر أو يُرسَل أمر لاحق أثناء انتظار الاستجابة. وخصوصاً في بروتوكولات الطلب-الاستجابة وفي أنظمة RS-485، التجميع في single writer أثبت بكثير.
flowchart TB
accTitle: تجميع الإرسال في single writer
accDescr: مخطّط يبيّن أنّ استدعاء Write مباشرة من زرّ الواجهة ومؤقّت المراقبة وkeepalive وإعادة الاتصال يسبّب تقاطع الأوامر والإرسال أثناء انتظار الاستجابة، وأنّ التجميع في single writer يثبّت الأمر.
g1["زرّ الواجهة"] --> g4["كلٌّ يستدعي Write مباشرة"]
g2["مؤقّت المراقبة"] --> g4
g3["keepalive وإعادة الاتصال"] --> g4
g4 --> g5["تقاطع وإرسال لاحق أثناء الانتظار"]
g5 -.->|"بدلاً من ذلك"| g6["التجميع في single writer"]
الشكل 8: لا تُكثِر مواضع Write المباشرة؛ اجمع الإرسال في عامل واحد.
4.4 تمرير كلّ شيء عبر ReadLine() / WriteLine()
في بروتوكول نصّي قائم على الأسطر، ReadLine() / WriteLine() مريحان. لكن الراحة حقيقية فقط عندما يكون بروتوكول أسطر فعلاً. اختلاف NewLine، أو سطر جديد داخل الـ payload، أو اختلاف ترميز الأحرف، أو مزيج ثنائي، ينهار الحدود سريعاً.
4.5 عدم تصميم الـ timeout والإبقاء على الافتراضي
وضع قراءة متزامنة بلا رويّة يتحوّل بسهولة إلى انتظار لا نهائي. والأصعب أنّ الـ timeout الذي ضبطته لا يسري بالضرورة على كلّ طريقة قراءة. قراءة متزامنة على خيط الواجهة، أو التعبير عن كلّ شيء بـ timeout واحد، أو الاكتفاء بزيادة إعادة المحاولة، تكوينات تنسدّ بسهولة.
4.6 الاستخفاف بـ RTS/CTS وXON/XOFF وDTR/RTS
المصافحة وخطوط التحكّم تؤثّر كثيراً أمام جهاز حقيقي. عند اختلاف الإعداد يظهر توقّف الإرسال أحياناً، أو ضياع بعد تجاوز كمّ معيّن، أو سلوك مختلف فور الفتح فقط. وبعض الأجهزة ترى تغيّر DTR/RTS بمعنى تشغيل أو تبديل وضع.
4.7 الإيهام بأنّ إعادة Open() وحدها إعادة اتصال
وخصوصاً في USB-serial، اختفاء المنفذ مؤقّتاً وبطلان المقبض القديم وفقدان معنى الطلبات المعلّقة السابقة أمور شائعة. إعادة الاتصال أأمن إن عولجت جملة: إبطال الجلسة، وإفشال الطلبات المعلّقة، وإيقاف القارئ والكاتب، وإعادة الفتح بعد backoff، وإعادة تشغيل تهيئة الجهاز.
flowchart TB
accTitle: إعادة الاتصال ليست إعادة Open
accDescr: مخطّط يبيّن أنّ USB-serial قد يُخفي المنفذ أو يُبطل المقبض القديم، لذا تُعالَج إعادة الاتصال جملة: إبطال الجلسة، وإفشال الطلبات المعلّقة، وإيقاف القارئ والكاتب، وإعادة الفتح بعد backoff، وإعادة تشغيل تهيئة الجهاز.
h1["إبطال الجلسة"] --> h2["إفشال الطلبات المعلّقة"]
h2 --> h3["إيقاف القارئ / الكاتب"]
h3 --> h4["إعادة الفتح بعد backoff"]
h4 --> h5["إعادة تشغيل تهيئة الجهاز"]
h1 -.-> h6["إعادة Open وحدها لا تكفي"]
الشكل 9: إعادة الاتصال إعادة بناء للجلسة، وتُنفَّذ هذه السلسلة جملة واحدة.
4.8 اعتبار تعداد منافذ COM حقيقة
GetPortNames() مريح، لكن ظهور الاسم في القائمة لا يساوي إمكان الفتح. التصديق الأعمى بـ COM7 السابق، أو اختيار أوّل عنصر في نتيجة التعداد تلقائيّاً، أو اعتبار الظهور في القائمة صلاحية، تطبيقات تتعب في التشغيل.
4.9 سجلّ إرسال واستقبال هزيل
TimeoutException وIOException وPort closed وحدها لا تكاد توضّح شيئاً. إن بقيت أوقات الإرسال والاستقبال، وport profile، وhex dump للإرسال والاستقبال، وأخطاء المحلّل، وأيّ استجابة لأيّ طلب، ومحفّز إعادة الاتصال، يتقدّم التشخيص كثيراً.
إن قرّرت الصيغة مسبقاً، صار البحث بـ grep ومقارنة الفروق ممكناً لاحقاً. مثلاً صيغة سطر واحد كهذه:
2026-03-19T10:23:41.512+09:00 COM3 TX req=00A7 len=5 02 01 10 3F 9C
2026-03-19T10:23:41.518+09:00 COM3 RX req=00A7 len=3 02 01
2026-03-19T10:23:41.531+09:00 COM3 RX req=00A7 len=6 10 00 4B 02 01 11
2026-03-19T10:23:41.532+09:00 COM3 PARSE req=00A7 frame=02 01 10 00 4B result=OK
2026-03-19T10:23:41.532+09:00 COM3 PARSE req=- frame=02 01 11 result=INCOMPLETE need=2
2026-03-19T10:23:43.540+09:00 COM3 ERR req=00A8 reason=response-timeout elapsed=2008ms
2026-03-19T10:23:43.541+09:00 COM3 STATE Ready -> Fault reason=response-timeout
الهدف هنا ثلاثة:
- فصل أسطر RX عن أسطر PARSE. RX هو «كم بايت وصل»، وPARSE هو «كم إطار اقتُطع». في المثال أعلاه يمتدّ إطار واحد عبر RX الثانية والثالثة، والباقي مقدّمة الإطار التالي. إن خلطت النوعين في السجلّ، لن تستطيع لاحقاً الحكم هل وقع انزياح التقسيم في 4.1
- تمكين مطابقة الإرسال والاستقبال بـ
req=. أيّ استجابة لأيّ أمر لا يُستعاد لاحقاً من السجلّ وحده إن غاب ذلك - إبقاء انتقال الحالة في سطر واحد. إن بقي انتقال مثل
Ready -> Faultوسببه، صار محفّز إعادة الاتصال قابلاً للتتبّع كما هو
hex dump يستهلك حجماً، لذا الواقعي سجلّ خام بكمّ محدود في مخزن حلقي، وسجلّ ملخّص للحفظ الطويل على طبقتين.
flowchart TB
accTitle: ثلاثة أهداف لسجلّ الإرسال والاستقبال
accDescr: مخطّط يبيّن ثلاثة أهداف: فصل أسطر RX عن PARSE، ومطابقة الإرسال والاستقبال بـ req، وإبقاء انتقال الحالة في سطر واحد، مع طبقتين: سجلّ خام بكمّ محدود في مخزن حلقي وسجلّ ملخّص للحفظ الطويل.
i1["فصل أسطر RX وPARSE"] --> i4["سجلّ يُشخَّص لاحقاً"]
i2["مطابقة الإرسال والاستقبال بـ req"] --> i4
i3["إبقاء انتقال الحالة في سطر"] --> i4
i4 -.-> i5["خام في مخزن حلقي، ملخّص للحفظ الطويل"]
الشكل 10: فصل عدد البايتات الواصلة عن الإطارات المقتطعة يتيح تتبّع الانزياح لاحقاً.
5. الممارسات الفضلى
الأكثر أثراً فصل المسؤوليات.
reader: يقرأ تسلسل البايتات من المنفذ فقطwriter: يكتب بالترتيب من طابور الصادر فقطparser: يقتطع الإطارات من تسلسل البايتات فقطprotocol: يعالج مطابقة الطلب والاستجابة وchecksumapp state: يحدّث حالة العمل فقط
معالجة الاستقبال أثبت إن لم تُجعَل وحدة عودة Read وحدة عمل، بل كُدِّست أوّلاً في مخزن ثمّ اقتطع المحلّل الإطارات. الإرسال يُجمَع في عامل واحد، وWrite الفعلي يُقرَّب إلى single writer لتقليل اختلال الترتيب.
الـ timeout أيضاً أسهل في التشخيص إن قُسِم حسب معنى open وinter-byte وresponse وreconnect بدل رقم واحد. إعدادات المنفذ تُحمَل ملفاً شخصيّاً (profile) لا قيماً في الشيفرة في موضعها، وتُخرَج إلى السجلّ عند الإقلاع فيسهّل التحقيق الميداني كثيراً.
إعادة الاتصال أثبت إن اعتُبرت إعادة إنشاء جلسة لا مجرّد إعادة فتح. إعادة بناء مخزن الاستقبال وحالة المحلّل والطلبات المعلّقة وتسلسل التهيئة وحكم الجاهزية تقلّل أخطاء إعادة الاتصال التي تنكسر «أحياناً فقط».
أخيراً يُستحسَن الاحتفاظ بسجلّ خام وسجلّ ملخّص معاً. hex dump الخام وتاريخ open / close قويّان في التحقيق، وملخّص معرّف الطلب وعدد إعادة المحاولة قويّ في التشغيل.
flowchart TB
accTitle: أنبوب حسب المسؤولية
accDescr: مخطّط يبيّن فصل المسؤوليات: القارئ يقرأ تسلسل البايتات من المنفذ، والمحلّل يقتطع الإطارات من المخزن، والبروتوكول يعالج المطابقة وchecksum، وحالة التطبيق تحدّث حالة العمل، والإرسال يُكدَّس في طابور من كلّ موضع ويكتب الكاتب وحده.
p0["المنفذ"] --> p1["reader: قراءة فقط"]
p1 --> p2["parser: اقتطاع الإطارات"]
p2 --> p3["protocol: مطابقة وchecksum"]
p3 --> p4["app state: تحديث حالة العمل"]
q1["من كلّ موضع تكديس في الطابور فقط"] --> q2["writer: كتابة بالترتيب فقط"]
q2 --> p0
الشكل 11: أنبوب الاستقبال والإرسال. كلّ دور يؤدّي عملاً واحداً فقط.
من هنا أضع هيكل شيفرة للموضعين الأكثر أثراً فقط. الافتراض .NET 8 / C# 12 مع مرجع حزمة System.IO.Ports.
5.1 الاستقبال: تكديس ثمّ اقتطاع
كمثال نفترض إطاراً: STX(0x02)، LEN(1 byte)، payload(LEN byte)، CRC16(2 byte, little-endian).
الشكل حرّ؛ الموضوع أن تقطع الإطار بهذا التعريف لا بوحدة عودة Read.
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;
public static class Crc16Modbus
{
// CRC-16/MODBUS: 初期値 0xFFFF、多項式 0xA001 の右送り
public static ushort Compute(ReadOnlySpan<byte> data)
{
ushort crc = 0xFFFF;
foreach (var b in data)
{
crc ^= b;
for (var i = 0; i < 8; i++)
{
crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
}
}
return crc;
}
}
public static class Frame
{
public const byte Stx = 0x02;
public const int HeaderLength = 2; // STX + LEN
public const int CrcLength = 2;
public static byte[] Build(ReadOnlySpan<byte> payload)
{
// LEN は 1 byte なので、256 byte 以上はキャストで折り返す。
// それでも payload はまるごとコピーされるため、受け手は切り詰められた
// 長さでフレームを切り、payload の途中を CRC と読む。以降の
// フレーム境界も総崩れになる。分割するか LEN を 2 byte にするかは
// プロトコルの決めごとなので、ここでは弾くだけにする
if (payload.Length > byte.MaxValue)
{
throw new ArgumentOutOfRangeException(
nameof(payload),
$"حدّ payload للإطار الواحد {byte.MaxValue} byte (لأن LEN بايت واحد).");
}
var frame = new byte[HeaderLength + payload.Length + CrcLength];
frame[0] = Stx;
frame[1] = (byte)payload.Length;
payload.CopyTo(frame.AsSpan(HeaderLength));
var body = frame.AsSpan(0, frame.Length - CrcLength);
BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(frame.Length - CrcLength), Crc16Modbus.Compute(body));
return frame;
}
}
public sealed class FrameParser
{
private readonly List<byte> _buffer = new();
/// <summary>3.3 の inter-byte timeout。組み立て中のフレームを諦めるまでの時間。</summary>
private static readonly TimeSpan AssemblyTimeout = TimeSpan.FromMilliseconds(200);
/// <summary>いま組み立て中の候補が、いつから待ち状態になったか(単調増加の値)。</summary>
private long _pendingSince;
/// <summary>CRC が合わずに捨てたフレームを通知する。ログへ落とすために必ず購読する。</summary>
public event Action<byte[]>? FrameDiscarded;
/// <summary>組み立てを諦めて再同期したことを通知する。ここが増え続けるなら結線か設定を疑う。</summary>
public event Action<int>? Resynchronized;
/// <summary>受信した byte 列を溜め込み、切り出せたフレームだけを返す。</summary>
public IReadOnlyList<byte[]> Append(ReadOnlySpan<byte> received)
{
foreach (var b in received)
{
_buffer.Add(b);
}
var frames = new List<byte[]>();
while (true)
{
// 1. 先頭が STX になるまで捨てる。ノイズや前フレームの残りをここで吸収する
var stxIndex = _buffer.IndexOf(Frame.Stx);
if (stxIndex < 0)
{
_buffer.Clear();
_pendingSince = 0; // 候補が無くなったので待ち時間の計測もやめる
break;
}
if (stxIndex > 0)
{
// 候補の先頭が変わった = 別のフレームの組み立てを始める
_buffer.RemoveRange(0, stxIndex);
_pendingSince = 0;
}
// 2. 長さを読めるところまで届いているか
if (_buffer.Count < Frame.HeaderLength)
{
if (GiveUpOnStaleCandidate()) { continue; }
break; // 「壊れている」ではなく「まだ足りない」
}
int payloadLength = _buffer[1];
int frameLength = Frame.HeaderLength + payloadLength + Frame.CrcLength;
// 3. フレーム 1 個分そろっているか
if (_buffer.Count < frameLength)
{
// 「まだ足りない」と「LEN がノイズで壊れている」は、この時点では
// 区別が付かない。ノイズや偽の STX で LEN が 255 になると、
// その後に届く正しいフレームまで payload として吸い込み続け、
// 259 byte そろって CRC で落ちるまで何も上がってこない。
// 通信量の少ない機器では、これが数分の無反応に見える。
// 待ちに上限を置き、超えたら候補を捨てて STX を探し直す
if (GiveUpOnStaleCandidate()) { continue; }
break; // ここで抜けて、次の受信を待つ
}
var frame = _buffer.GetRange(0, frameLength).ToArray();
_buffer.RemoveRange(0, frameLength);
_pendingSince = 0;
// 4. CRC が合わないものは捨てる。捨てたことは必ず外へ出す
var expected = BinaryPrimitives.ReadUInt16LittleEndian(frame.AsSpan(frame.Length - Frame.CrcLength));
if (expected == Crc16Modbus.Compute(frame.AsSpan(0, frame.Length - Frame.CrcLength)))
{
frames.Add(frame);
}
else
{
// ここでフレーム 1 個分まとめて捨てるか、STX 1 byte だけ捨てて読み直すかは設計判断。
// 前者は単純、後者は LEN 自体がノイズだった場合に強い。どちらにするか決めて明文化する。
FrameDiscarded?.Invoke(frame);
}
}
return frames;
}
/// <summary>
/// 組み立て中の候補が AssemblyTimeout を超えていたら、先頭の STX を 1 byte だけ捨てる。
/// 捨てたら true を返し、呼び出し側は次の STX から読み直す。
/// フレームごと捨てないのは、本物の STX がこの候補の中にいる可能性があるため。
/// </summary>
private bool GiveUpOnStaleCandidate()
{
if (_pendingSince == 0)
{
// 待ち始めた瞬間。壁時計だと NTP 同期で飛ぶので、単調増加の値で測る
_pendingSince = Stopwatch.GetTimestamp();
return false;
}
if (Stopwatch.GetElapsedTime(_pendingSince) < AssemblyTimeout)
{
return false;
}
_buffer.RemoveAt(0);
_pendingSince = 0;
Resynchronized?.Invoke(_buffer.Count);
return true;
}
}
GiveUpOnStaleCandidate هو التجسيد العملي لـ inter-byte timeout المذكور في 3.3. بدونه، إذا انقلب LEN إلى قيمة كبيرة (مثلاً 255) بسبب ضوضاء أو STX زائف، يواصل المحلّل معاملته «ما زال ناقصاً». ويمتصّ حتى الإطار الصحيح اللاحق بوصفه جزءاً من الـ payload المنقلب، ولا يصعد شيء حتى تكتمل 259 بايت ويسقط CRC. في جهاز قليل الحركة يبدو هذا صمتاً لدقائق. رمي STX بايت واحد فقط لأنّ STX حقيقيّاً قد يكون مدفوناً داخل المرشّح.
نقطتا افتراض. انتهاء المهلة هذا يُحكَم عليه فقط عند استدعاء Append. إذا صمت الخطّ تماماً لا يحدث شيء في جهة المحلّل، فيلتقطه response timeout لدى المستدعي (5.2). والثانية: قيمة AssemblyTimeout تُقرَّر من معدّل البود وطول الإطار. الحدّ الأدنى زمن نقل بايت واحد × أقصى طول إطار متوقّع مع هامش؛ أقصر من ذلك يرمي إطاراً سليماً في منتصفه. أخرج عدد إطلاقات Resynchronized إلى السجلّ، فهو مادّة للشكّ في التوصيل أو إعداد معدّل البود.
flowchart TB
accTitle: LEN المنقلب يبتلع الإطار الصحيح
accDescr: مخطّط يبيّن أنّ انقلاب LEN إلى قيمة كبيرة بضوضاء أو STX زائف يجعل المحلّل ينتظر بوصفه ناقصاً ويمتصّ الإطار الصحيح اللاحق payload منقلباً فيبدو صامتاً حتى سقوط CRC، لذا يُرمى STX بايت واحد عند انتهاء المهلة لإعادة المزامنة.
j1["ضوضاء أو STX زائف يقلب LEN"] --> j2["المحلّل ينتظر «ما زال ناقصاً»"]
j2 --> j3["يمتصّ حتى الإطار الصحيح"]
j3 --> j4["يبدو صامتاً حتى سقوط CRC"]
j4 -.->|"العلاج"| j5["انتهاء مهلة: رمي STX بايت وإعادة مزامنة"]
الشكل 12: بلا inter-byte timeout يواصل LEN المنقلب ابتلاع الإطارات اللاحقة.
جهة القراءة تقرأ من المنفذ وتمرّر إلى المحلّل فقط. إن بدأت معالجة العمل هنا، تنقلب وحدة عودة Read إلى وحدة عمل.
using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;
public sealed class SerialReader
{
private readonly SerialPort _port;
private readonly FrameParser _parser;
private readonly byte[] _readBuffer = new byte[4096];
public SerialReader(SerialPort port, FrameParser parser)
{
_port = port;
_parser = parser;
}
public event Action<byte[]>? FrameReceived;
public async Task RunAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
int count;
try
{
count = await _port.BaseStream.ReadAsync(_readBuffer.AsMemory(), token);
}
catch (OperationCanceledException)
{
break;
}
if (count <= 0)
{
continue;
}
foreach (var frame in _parser.Append(_readBuffer.AsSpan(0, count)))
{
FrameReceived?.Invoke(frame);
}
}
}
}
عدم استخدام DataReceived مقصود. كما في 4.2 لا يحمل أكثر من «شيء وصل على الأرجح»، لذا امتلاك حلقة قراءة بنفسك يسهّل إدارة الحالة والـ timeout.
5.2 الإرسال: التجميع في single writer
جوهر جهة الإرسال عدم صنع حالة يستطيع فيها أيّ موضع استدعاء Write.
التكديس في الطابور يجوز من أيّ مستدعٍ، والكتابة الفعلية بعامل واحد فقط.
using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
public sealed class SingleWriter
{
private sealed record Outbound(byte[] FrameBytes, TaskCompletionSource<byte[]> Completion);
/// <summary>حدّ طابور الإرسال. زمن ذهاب-إياب الجهاز × طول الانتظار المسموح.</summary>
private const int QueueCapacity = 64;
private readonly SerialPort _port;
private readonly TimeSpan _responseTimeout;
// لا تجعله بلا سقف. إن راكم UI والمؤقّت والعامل أسرع من ذهاب-إياب الجهاز،
// تتراكم الإطارات و TaskCompletionSource بلا حد، والجهاز يستجيب
// بينما الذاكرة وحدها تزداد. حدّد سقفاً، وعند الفيض أعد إلى المرسل
private readonly Channel<Outbound> _queue = Channel.CreateBounded<Outbound>(
new BoundedChannelOptions(QueueCapacity)
{
// إن امتلأ يعيد TryWrite القيمة false. المستدعي يعرف فوراً
// «الآن مسدود». لا نستخدم DropOldest ── المرسل ينتظر
// الـ Task، فإن طرحت صامتاً لا يعود أبداً
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
});
private Outbound? _inFlight;
public SingleWriter(SerialPort port, TimeSpan responseTimeout)
{
_port = port;
_responseTimeout = responseTimeout;
}
/// <summary>UI からでもタイマーからでも呼んでよい。実際の Write はワーカー 1 本だけが行う。</summary>
public Task<byte[]> SendAsync(ReadOnlySpan<byte> payload)
{
var item = new Outbound(
Frame.Build(payload),
new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously));
if (!_queue.Writer.TryWrite(item))
{
// 満杯か、ワーカーが停止済み。どちらの場合も「積めなかった」ことを
// 呼び出し側へ返す。黙って捨てると、待っている Task が永久に返らない
item.Completion.TrySetException(new InvalidOperationException(
$"تعذّر الوضع في طابور الإرسال (الحدّ {QueueCapacity} عنصراً، أو العامل متوقّف)."));
}
return item.Completion.Task;
}
/// <summary>parser がフレームを切り出したら呼ぶ。応答待ちの 1 件へ結び付ける。</summary>
public void OnFrameReceived(byte[] frame)
{
var pending = Interlocked.Exchange(ref _inFlight, null);
pending?.Completion.TrySetResult(frame);
}
public async Task RunAsync(CancellationToken token)
{
try
{
await foreach (var item in _queue.Reader.ReadAllAsync(token))
{
Interlocked.Exchange(ref _inFlight, item);
try
{
await _port.BaseStream.WriteAsync(item.FrameBytes.AsMemory(), token);
}
catch (Exception ex)
{
// 抜線・ポートのクローズ・キャンセルはここで飛んでくる。
// この1件を完了させずに抜けると、SendAsync を await している
// 呼び出し側が永久に待つ
Interlocked.Exchange(ref _inFlight, null);
item.Completion.TrySetException(ex);
throw;
}
// ここで応答まで待つから、次のコマンドが割り込まない
var timeout = Task.Delay(_responseTimeout, token);
var finished = await Task.WhenAny(item.Completion.Task, timeout);
if (finished != item.Completion.Task)
{
// 停止を要求されたときも Task.Delay はキャンセルされて先に終わる。
// ここを見ないと、正常な終了処理がタイムアウト扱いになり、
// 呼び出し側には TimeoutException が返る
token.ThrowIfCancellationRequested();
// WhenAny が timeout を選んだあとに応答が届くことがある。
// そのとき OnFrameReceived は既に _inFlight を取り、この1件を
// 成功で完了させている。取れなかったら応答の勝ちで、ここで
// TrySetException を撃っても不発に終わる。それに気付かず
// throw まで進むと、呼び出し側は結果を受け取っているのに
// ワーカーだけが落ちる。取り合いは CompareExchange で決める
if (Interlocked.CompareExchange(ref _inFlight, null, item) != item)
{
// 応答が勝った。完了は OnFrameReceived が入れる(入れた直後)
await item.Completion.Task;
continue;
}
item.Completion.TrySetException(new TimeoutException("لا استجابة."));
// タイムアウトしたら、この接続はもう信用できない。
// 「諦めて次を送る」で済ませてはいけない理由は下に書いたとおりで、
// このプロトコルにはリクエストIDが無いため、遅れて届いたAの応答が
// 次のBの応答として結び付いてしまう。3.6 の状態遷移図どおり
// Fault へ落として、セッションを作り直す
throw new TimeoutException("لا استجابة، سيُعاد بناء الجلسة.");
}
}
}
finally
{
// ワーカーが止まる理由が何であれ、待たせている件は必ず終わらせる。
// 応答待ちの1件と、キューに積まれたまま送られなかった分の両方
var stopped = new OperationCanceledException("توقّف عامل الإرسال.");
Interlocked.Exchange(ref _inFlight, null)?.Completion.TrySetException(stopped);
_queue.Writer.TryComplete();
while (_queue.Reader.TryRead(out var pending))
{
pending.Completion.TrySetException(stopped);
}
}
}
}
إيقاف العامل بأكمله عند الـ timeout يبدو خشناً وهو إجراء لازم. هذا الإطار لا يحمل معرّف طلب. لذلك لا يستطيع المستقبِل تمييز «أيّ أمر تخصّه هذه الإطار»، وOnFrameReceived لا يملك إلّا الربط الآلي بالطلب الواحد المنتظر للاستجابة.
إن أرسلت التالي بعد الـ timeout كما هو، يحدث الآتي.
- ترسل الأمر A. لا تأتي الاستجابة ضمن الوقت المحدَّد فتُعدّ timeout
- ترسل الأمر التالي B
- استجابة A المتأخّرة تُسلَّم للمستدعي بوصفها استجابة B
من جهة المستدعي، أرسلت B فعادت قيمة A. صيغة القيمة صحيحة فتمرّ الفحوص أيضاً، وهذا أصعب شكل كسر يُكتشَف. سبب عدم رسم خطّ مباشر من Fault إلى Ready في مخطّط 3.6 هو وجود هذا المسار. الـ timeout ليس «فشل عنصر واحد» بل حكم أنّ «هذا الاتصال لم يعد موثوقاً»، والعودة من حالة لا يُعرَف فيها ما تبقّى في مخزن الاستقبال لا تُضمَن إلّا بإعادة إنشاء الجلسة: إغلاق المنفذ وإعادة فتحه.
إن أمكنك تعديل جهة البروتوكول، حمل الإطار معرّف طلب ومطابقة الاستجابة به هو الحلّ الجوهري. عندها تُرمى الاستجابة المتأخّرة لأنّ «المعرّف مجهول»، ولا تحتاج إلى إعادة بناء الاتصال عند كلّ timeout واحد.
sequenceDiagram
accTitle: الخلط بسبب استجابة متأخّرة
accDescr: مخطّط يبيّن أنّ بروتوكولاً بلا معرّف طلب، إذا أرسل الأمر B بعد timeout الأمر A، يسلّم استجابة A المتأخّرة للمستدعي بوصفها استجابة B، لذا بعد الـ timeout يُسقَط إلى Fault وتُعاد إنشاء الجلسة.
participant C as المستدعي
participant W as العامل
participant D as الجهاز
C->>W: إرسال الأمر A
W->>D: إرسال A
Note over W: timeout لأنّ الاستجابة لم تصل في الوقت
C->>W: إرسال الأمر B
W->>D: إرسال B
D-->>W: استجابة A المتأخّرة
W-->>C: تُسلَّم بوصفها استجابة B
Note over W: لذلك بعد الـ timeout ننتقل إلى Fault
الشكل 13: بلا معرّف طلب ترتبط الاستجابة المتأخّرة بالأمر التالي.
أخيراً التجميع. وضع إعدادات المنفذ ملفاً شخصيّاً في موضع واحد وإخراجها إلى السجلّ عند الإقلاع هو حديث 3.4.
using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;
// 3.4 で決めた設定を、その場の値ではなくまとめて置く
using var port = new SerialPort("COM3", 115200, Parity.None, 8, StopBits.One)
{
Handshake = Handshake.None,
DtrEnable = true,
RtsEnable = true,
ReadTimeout = 500,
WriteTimeout = 500,
};
Console.WriteLine($"open {port.PortName} baud={port.BaudRate} data={port.DataBits} parity={port.Parity} " +
$"stop={port.StopBits} handshake={port.Handshake} dtr={port.DtrEnable} rts={port.RtsEnable}");
port.Open();
var parser = new FrameParser();
var writer = new SingleWriter(port, TimeSpan.FromSeconds(2));
var reader = new SerialReader(port, parser);
// 定義したイベントは必ず購読する。ここを忘れると、捨てたフレームも応答も表に出ない
parser.FrameDiscarded += frame => Console.Error.WriteLine($"crc error: {Convert.ToHexString(frame)}");
reader.FrameReceived += writer.OnFrameReceived;
using var cts = new CancellationTokenSource();
var readerTask = reader.RunAsync(cts.Token);
var writerTask = writer.RunAsync(cts.Token);
var request = new byte[] { 0x10, 0x00 };
try
{
var response = await writer.SendAsync(request);
Console.WriteLine($"response: {Convert.ToHexString(response)}");
}
finally
{
// 送信が失敗しても、ワーカーは必ず止めてから抜ける。ここを飛ばすと、
// using の破棄で SerialPort が閉じられたあとも reader/writer が
// そのストリームを触り続け、しかもその例外は誰も観測しない
cts.Cancel();
try
{
await Task.WhenAll(readerTask, writerTask);
}
catch (OperationCanceledException)
{
// 停止要求による終了。ここは正常系として扱う
}
catch (Exception ex)
{
// ワーカー側の失敗。ここで投げ直すと、本来の失敗理由
// (SendAsync の例外)を覆い隠すので、記録に留める
Console.Error.WriteLine($"worker stopped with error: {ex.Message}");
}
}
وجود cts.Cancel() وTask.WhenAll في finally ليس ذوقاً في الكتابة. فشل SendAsync في الاتصالات التسلسلية ليس استثناء نادراً بل أمر يومي: الجهاز لا يستجيب، الكابل انفصل، الكتابة انتهت مهلتها. إن خرجت للأعلى بسذاجة عندها، تصل إلى تدمير using دون إيقاف العاملين. بعد إغلاق SerialPort يواصل القارئ والكاتب لمس ذلك المجرى، والاستثناء هناك لا يرصده أحد. في تطبيق مقيم يتراكم الأمر شيئاً فشيئاً على صورة بقاء عامل العملية الفاشلة وحده. عدم إعادة رمي استثناء جهة finally حتى لا يُغطَّى سبب الفشل الأصلي (استثناء SendAsync).
flowchart TB
accTitle: إيقاف العاملين حتماً حتى عند الفشل
accDescr: مخطّط يبيّن أنّ فشل SendAsync أمر يومي في الاتصالات التسلسلية، وأنّ الخروج للأعلى كما هو يدمّر SerialPort دون إيقاف العاملين فيواصل القارئ والكاتب لمس المجرى المغلق دون أن يرصد أحد الاستثناء، لذا يُنفَّذ الإلغاء والانتظار في finally.
k1["فشل SendAsync (أمر يومي)"] --> k2["إلغاء وانتظار في finally"]
k2 --> k3["إيقاف العاملين ثمّ التدمير"]
k1 -.-> k4["التخطّي يواصل لمس المجرى المغلق"]
k4 -.-> k5["في تطبيق مقيم يبقى عامل عند كلّ فشل"]
الشكل 14: عند فشل الإرسال تحديداً، أوقف العاملين في finally ثمّ اخرج.
بهذا الشكل، ما ترغب في إضافته لاحقاً مثل إعادة المحاولة وkeepalive وإعادة الاتصال ينحصر كلّه إمّا في جهة التكديس في الطابور أو في جهة العامل. لا تزيد مواضع استدعاء Write مباشرة، فلا يزيد سبب اختلال الترتيب.
6. قائمة التحقّق التي تُرى أوّلاً
- هل حدود الرسالة مصرَّح بها؟
- هل الاستقبال تكديس بايتات ثمّ اقتطاع إطار؟
- هل تُعامَل
DataReceivedوصول رسالة؟ - هل يُنفَّذ I/O متزامن على خيط الواجهة؟
- هل الإرسال single writer؟
- هل الـ timeout مقسوم حسب المعنى لا واحداً؟
- هل
Handshake/ DTR / RTS مصرَّح بها؟ - هل تُعاد بناء الجلسة عند إعادة الاتصال؟
- هل يُحفَظ hex dump خام؟
- هل يُختبَر فصل الجهاز وإعادة وصله والقطع في المنتصف؟
إن بدت بنود عدّة هنا مريبة، يستحقّ الأمر التوقّف مرّة قبل الإنتاج.
7. الخلاصة
أخيراً، النقاط مرّة أخرى.
- الاتصالات التسلسلية byte stream لا رسائل
- وحدة
Readووحدة الرسالة لا تتطابقان - الحدود يلزم تعريفها بروتوكولاً
- معاملة
DataReceivedحدث عمل كما هي تجعل الأمر ينهار بسهولة - افصل مسؤوليات الإرسال والاستقبال، وجمّع الإرسال في single writer
- اقسم الـ timeout حسب المعنى، وصمّم إعادة الاتصال بوحدة الجلسة
- سجلّ يشمل hex dump خام يسهّل التحقيق لاحقاً كثيراً
أي أنّ في تطبيق الاتصالات التسلسلية تفسير تسلسل البايتات والتحكّم بالزمن والحالة أهمّ بكثير من فتح المنفذ. إن فصلت هذا في التصميم أوّلاً، تقلّ أعطال الاتصال من نوع «ينكسر أحياناً فقط» كثيراً.
flowchart TB
accTitle: التفسير والتحكّم أهمّ من الفتح
accDescr: مخطّط يبيّن أنّ تفسير تسلسل البايتات والتحكّم بالزمن والحالة أهمّ من فتح المنفذ في تطبيق الاتصالات التسلسلية، وأنّ فصل ذلك في التصميم أوّلاً يقلّل أعطال الاتصال التي تنكسر أحياناً فقط.
m1["فتح المنفذ"] -.-> m2["هذه ليست الصعوبة"]
m3["تفسير تسلسل البايتات"] --> m5["فصل التصميم أوّلاً"]
m4["التحكّم بالزمن والحالة"] --> m5
m5 --> m6["تقلّ الأعطال التي تنكسر «أحياناً فقط»"]
الشكل 15: المهمّ ليس فتح المنفذ بل تصميم التفسير والتحكّم.
8. روابط مرجعية
- Microsoft Learn,
SerialPort.DataReceivedEvent - Microsoft Learn,
SerialPort.ReadMethod - Microsoft Learn,
SerialPort.ReadTimeoutProperty - Microsoft Learn,
SerialPort.BaseStreamProperty - Microsoft Learn,
SerialPort.NewLineProperty - Microsoft Learn,
HandshakeEnum - Microsoft Learn,
SerialPort.DtrEnableProperty - Microsoft Learn,
SerialPort.RtsEnableProperty - Microsoft Learn,
SerialPort.GetPortNamesMethod - Microsoft Learn,
SerialPortClass - Microsoft Learn,
COMMTIMEOUTSstructure - Microsoft Learn,
DCBstructure - Microsoft Learn,
CreateFilefunction - pySerial API, Serial API Reference
مقالات ذات صلة
أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.
لماذا تتعطّل الوسائط ── قواعد وسائط سطر أوامر Windows
في Windows لا توجد مصفوفة وسائط؛ يصل إلى CreateProcess سلسلة واحدة ويتولّى الطرف المستقبل تقسيمها. نشرح قواعد CommandLineToArgvW وCRT و.N...
استخدام WMI/CIM من C# وPowerShell ── دليل عمليّ لاسترجاع معلومات العتاد ومراقبة العمليّات والاستعلام عن بُعد
الإجابة الكلاسيكيّة للحصول على الرقم التسلسليّ للحاسوب، ومراقبة المساحة الحرّة على القرص، وكشف بدء عمليّة هي WMI/CIM. يشرح المقال أوامر C...
إدارة إصدارات مخطّط قاعدة بيانات تطبيقات الأعمال ── ممارسات الترحيل لمنع «اختلاف قاعدة البيانات من عميل لآخر»
دليل عمليّ لإدارة إصدارات مخطّط قاعدة بيانات تطبيقات الأعمال الموزَّعة على عملاء متعدّدين. نشرح PRAGMA user_version وتنفيذ الترحيل الأمام...
CI/CD عمليّ لتطبيقات WinForms / WPF ── أتمتة البناء والتوقيع والتوزيع عبر GitHub Actions
دليل عمليّ لبناء CI/CD لتطبيقات WinForms / WPF عبر GitHub Actions. يشمل YAML الأدنى للبناء والاختبار على windows-latest، وترقيم الإصدار ا...
عندما يُعامَل تطبيق Windows الذي طوّرته شركتك بوصفه فيروساً ── التعامل مع الكشف الخاطئ في Microsoft Defender والتعايش مع أثره على الأداء
نرتّب الإجراء الرسميّ عند كشف Microsoft Defender تطبيق Windows الذي طوّرته شركتك خطأً. نشرح آليّة برامج مكافحة الفيروسات الحديثة، والإبلا...
أين يتصل هذا الموضوع
ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.
تطوير تطبيقات ويندوز
في تطبيقات Windows التي تتضمّن اتّصالاً تسلسليّاً، يزداد الاستقرار إذا شمل التصميم معالجة الاستقبال وانتقالات الحالة وإعادة الاتّصال وفصل واجهة المستخدم.
التحقيق في الأخطاء وتحليل السبب الجذري
موضوع يتلاءم مع تشخيص أعطال الاتّصال من نوع التوقّف المتقطّع، أو عدم العودة إلى العمل بعد فصل USB وإعادة وصله فقط، أو تعذّر تتبّع السببيّة من السجلّات.
الاستشارات التقنية ومراجعة التصميم
ترتيب حدود البروتوكول والتحكّم في التدفّق والمهل الزمنيّة وتصميم single writer قبل التنفيذ يسهّل تقليل الأعطال التي يكلّف التراجع عنها كثيراً.
الأسئلة الشائعة
أسئلة شائعة حول موضوع هذه المقالة.
- هل يضمن استدعاء Read(16) في الاتصالات التسلسلية استلام 16 بايت بالضبط؟
- ليس بالضرورة. الاتصالات التسلسلية byte stream مرتّب، وحدود الرسالة لا تُرفق من تلقاء نفسها. ما تكتبه أنت بـ Write واحدة قد يصل عند الطرف الآخر مقسوماً على قراءتين، أو ملتحماً ببيانات أخرى. الأنماط الشائعة: يُقرأ الطول بينما الـ payload لم يصل بعد، يصل إطار ونصف، أو يصل إطاران معاً. العلاج: تكديس الاستقبال أوّلاً في مخزن، ثمّ يقتطع منه المحلّل الإطارات.
- ما الذي يجب الانتباه إليه عند استخدام حدث SerialPort.DataReceived في .NET؟
- DataReceived لا يُضمَن إطلاقه لكلّ بايت مستقبَل، وهو ليس على خيط واجهة المستخدم. معاملته إشعاراً بـ«وصلت رسالة واحدة» خطر. عمليّاً يُعدّ مجرّد إشارة أنّ «شيئاً وصل على الأرجح»، فلا تُنفَّذ معالجة ثقيلة داخل المعالج، وتُعاد تحديثات الواجهة دائماً إلى خيط الواجهة. البنية المستقرّة: تكديس تسلسل البايتات ثمّ اقتطاع الإطارات بالمحلّل.
- كيف يُصمَّم الـ timeout في الاتصالات التسلسلية؟
- timeout واحد لا يكفي؛ الأثبت تقسيمه حسب المعنى. open timeout حتى فتح المنفذ، وinter-byte timeout لزمن انقطاع البايت في منتصف الإطار، وresponse timeout من إصدار الأمر حتى اكتمال الاستجابة، وreconnect backoff لفاصل الانتظار عند إعادة الاتصال. الأثبت اعتبار الـ timeout قاعدة تدفع انتقال الحالة لا مجرّد تأمين ضدّ البطء. وانتبه أيضاً إلى أنّ القراءة المتزامنة عند إبقائها على الافتراضي بسهولة تتحوّل إلى انتظار لا نهائي.
- لماذا لا يعود محوّل USB-serial بعد فصل الكابل وإعادة وصله؟
- لأنّ USB-serial يجعل اختفاء المنفذ مؤقّتاً، وبطلان المقبض القديم، وتغيّر رقم COM، وفقدان معنى الطلبات المعلّقة السابقة أموراً شائعة. إعادة استدعاء Open() وحدها لا تُعدّ إعادة اتصال. إذا صمّمتها إعادة إنشاء جلسة تشمل إبطال الجلسة، وإفشال الطلبات المعلّقة، وإيقاف القارئ والكاتب، وإعادة الفتح بعد backoff، وإعادة تشغيل تسلسل تهيئة الجهاز، تقلّ أخطاء إعادة الاتصال التي تنكسر أحياناً فقط.
الملف الشخصي للمؤلف
صفحة الملف الشخصي لمؤلف المقالة.
غو كومورا
مؤسّس شركة كومورا سوفت ذ.م.م.
يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.