המלכודות של יישום תקשורת טורית - עד לתכנון חיבור מחדש ויומן

· עודכן בתאריך: · · תקשורת טורית, RS-232, ‏C#, ‏.NET, פיתוח Windows, חיבור ציוד

חיבור ציוד, מכשירי מדידה,‏ PLC, קורא ברקוד, מתאם USB-טורי. תקשורת טורית נראית כטכנולוגיה ישנה, אבל היא עדיין נפוצה למדי בשטח של יישומי Windows.

מה שקצת מסוכן הוא שאפשר להתחיל תקשורת טורית עם פורט COM אחד ו-Read/Write אחד בלבד. בדיקת החיבור עוברת מיד, אבל כשיוצאים לפרודקשן, נוטים להופיע תסמינים כמו:

  • מדי פעם הפקודה והתשובה לא מתואמות
  • קופא פעם אחת ביום
  • לא מתאושש רק אחרי ניתוק/חיבור USB
  • ה-UI לפעמים נעצר
  • ביומן נשאר רק “Timeout”

הקושי האמיתי ביישום תקשורת טורית הוא לא ה-API של השליחה והקבלה עצמו, אלא הגבול, ה-timeout, מעבר המצב, החיבור מחדש והתצפיתיות.

בדיקת חיבור עוברת, אך נשבר בפרודקשןתרשים המראה שתקשורת טורית מתחילה בקלות עם פורט COM אחד ו-Read/Write אחד ובדיקת החיבור עוברת מיד, אך בפרודקשן מופיעים תסמינים כמו תשובה לא מתואמת או קפיאה, והקושי האמיתי הוא גבול, timeout, מעבר מצב, חיבור מחדש ותצפיתיות.בדיקת חיבור עוברת מידנשבר 'מדי פעם' בפרודקשןתשובה לא מתואמת · קפיאה · אין התאוששותהקושי אינו ה-API עצמוגבול · 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), וסיבית עצירה 1
DTR‏/‏RTS קווי בקרה. במקור מיועדים לאותת מוכנות לתקשורת או בקשת שליחה, אבל בציוד אמיתי לפעמים משתמשים בשינוי שלהם כאיתות להפעלה או החלפת מצב
בקרת זרימה מנגנון שמונע שליחת יתר. RTS/CTS הם קווי בקרה, XON/XOFF מעבירים עצירה והמשך דרך תווים מיוחדים בתוך הנתונים
keepalive פקודה קלה שנשלחת בקביעות כדי לוודא שהצד השני עדיין חי
מסגרת (frame) רצף בייטים של הודעה אחת. איפה מתחילה ואיפה מסתיימת מסגרת אחת נקבע בפרוטוקול
single writer תכנון שמרכז את השליחה ל-worker אחד. משמעו שלא כל אחד יכול לקרוא ל-Write

1. המסקנה קודם

קודם, סיכום בניסוח מעשי.

  • תקשורת טורית היא byte stream מסודר, וגבול ההודעה לא נוצר מעצמו
  • Read(100) לא מבטיח קבלת 100 בייטים בדיוק
  • DataReceived של .NET לא בהכרח מופעל לכל בייט שמתקבל, ובנוסף גם לא ב-thread של ה-UI
  • ReadLine()‏/‏WriteLine() פשוטים רק כשהצד השני באמת מדבר בפרוטוקול טקסט מבוסס שורות
  • ‏timeout אחד לא מספיק. יציב יותר לפצל למשמעויות כמו open,‏ inter-byte,‏ response,‏ reconnect
  • עדיף להטות את השליחה ל-single writer מאשר לאפשר Write מכל מקום
  • ב-USB-טורי, יותר שקט להניח מראש ניתוק/חיבור, רישום מחדש, שינוי מספר COM וכישלון חיבור מחדש

בקיצור, הקושי ביישום תקשורת טורית הוא לא “האם הפורט נפתח”, אלא איך ממירים את רצף הבייטים להודעה בעלת משמעות, ואיך מנהלים את הזמן והמצב סביב זה.

מפת הידע של המאמר

המאמר הוא מדריך תכנון למניעת תקלות שנשברות רק לפעמים ביישום תקשורת טורית ב-C# שמשמש לחיבור ציוד. מכיוון שתקשורת טורית היא רק byte stream מסודר וללא גבול הודעה, לא מומלץ להתייחס לאירוע DataReceived כהתראת הגעת הודעה אחת, אלא לצבור את הקליטה ורק אז לחתוך אותה במנתח מסגרות (frame parser). את השליחה מטים ל-single writer שמרכז אותה ל-worker אחד, ואת ה-timeout מתכננים לפי משמעות - open,‏ inter-byte,‏ response,‏ reconnect backoff. בפרוטוקול שאין למסגרת שלו request ID, עלול להתרחש שיוך שגוי של תשובה אחרי timeout, ולכן דרושה לא רק חיבור מחדש פשוט אלא יצירת session מחדש שכוללת גם בנייה מחדש של חוצץ הקליטה ומצב ה-parser.

מפת הידע של המלכודות ביישום תקשורת טוריתתרשים המראה שתקשורת טורית היא byte stream ללא גבול הודעה כנקודת מוצא, איך מנתח המסגרות ותכנון single writer מטפלים בגבול ובסדר השליחה, ואיך פיצול סוגי timeout וקיום או היעדר request ID מובילים לשיוך שגוי של תשובה וליצירת session מחדש.משתמש במחייבמממש אתשימוש לא מומלץ למענה מומלץ למענה מומלץ למשתמש במשתמש במשתמש במשתמש במשתמש בעלול לגרום למונעמענה מומלץ למשתמש במחייבמענה מומלץ למחייבתקשורת טוריתמנתח מסגרות (צבירה ואז חיתוך)‏single writer (נקודת כתיבה יחידה)byte stream (רצף בייטים מסודר)גבול המסגרתאירוע SerialPort.DataReceivedאימות מסגרת באמצעות CRCinter-byte timeoutפיצול סוגי הפסקות הזמןresponse timeoutחיבור מחדש עם backoffסיכון לשיוך שגוי של תשובהמזהה בקשהיצירת session מחדש (תכנון התחברות חוזרת)בקרת זרימה ו-DTR/RTSיומן שידור וקליטה הכולל hex dump

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 18, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle

2. תקשורת טורית היא לא “הודעה” אלא “byte stream מסודר”

מנקודת המבט של האפליקציה, תקשורת טורית נראית כ”שולחים פקודה אחת ומקבלים תשובה אחת”. אבל בשכבה שמתחת, בפועל זורם רק רצף בייטים מסודר.

גם תוכן שנשלח ב-Write אחד יכול להיראות בצד השני כך:

  • מגיע ב-Read אחד
  • מגיע בשני חלקים
  • מגיע מחובר לנתונים אחרים

אם מפספסים את ההנחה הזו, האפליקציה מתחילה להניח ש’ה-Read הזה בטח מקביל לתשובה הזו’. ההנחה הזו נוטה להיות המוקש הראשון ביישום תקשורת טורית.

שלוש דרכים שבהן Write אחד יכול להגיעתרשים המראה שתוכן שנשלח ב-Write אחד עלול להגיע בצד השני ב-Read אחד, בשני חלקים, או מחובר לנתונים אחרים, ולכן אין ערובה שה-Read הנוכחי מקביל לתשובה הנוכחית.Write אחדמגיע ב-Read אחדמגיע בשני חלקיםמגיע מחובר לנתונים אחרים'ה-Read הזה = התשובה הזו' לא מובטח

איור 2: איך Write אחד ייראה בצד השני - לא ידוע עד שהוא מגיע בפועל.

הנחה נפוצה המציאות
‏Read(16) מחזיר 16 בייטים בדיוק תלוי במצב ההגעה וב-timeout, לפעמים מתקבל רק חלק
‏DataReceived = הגעת הודעה אחת האירוע לא מובטח לכל בייט, וגם לא ב-thread של ה-UI
‏Write חזר = הצד השני סיים לעבד ברוב המקרים, זה קרוב יותר לכך שצד השליחה הצליח לצבור לחוצץ
רשימת COM = האמת של מה שמחובר עכשיו סדר המיפוי לא קבוע, ולפעמים התוצאה כבר לא עדכנית (stale)

בגלל זה, בתקשורת טורית צריך להגדיר בעצמכם את גבול ההודעה כפרוטוקול. מסגרת באורך קבוע, מבוססת תו הפרדה, אורך+payload+checksum - הצורה יכולה להיות כל דבר, אבל אם נכנסים למימוש כשזה מעורפל, כמעט בטוח שזה יהיה קשה בהמשך.

מגדירים בעצמכם את הגבולתרשים המראה שבתקשורת טורית מגדירים בעצמכם את גבול ההודעה כפרוטוקול - מסגרת באורך קבוע, מבוססת תו הפרדה, או אורך+payload+checksum - אך אם זה נשאר מעורפל בזמן המימוש, זה הופך לקשה.מגדירים גבול הודעה בעצמכםמסגרת באורך קבועמבוססת תו הפרדהאורך+payload+checksumאם נשאר מעורפל - קשה

איור 3: גבול ההודעה לא נוצר בשכבה התחתונה, אז קובעים אותו כפרוטוקול מראש.

3. מה צריך להחליט קודם

לפני בניית יישום תקשורת טורית, כדאי להחליט מראש לפחות על הדברים המפורטים כאן.

3.1 גבול המסגרת

קובעים איזה רצף בייטים נחשב להודעה אחת. באורך קבוע? מופרד בשורה חדשה? עם אורך? יש checksum‏/‏CRC? אם זה נשאר מעורפל, צד הקליטה לא יכול להחליט אם זה “עדיין לא מספיק” או “שבור”.

3.2 טקסט, בינארי, או שילוב

קובעים מראש אם זה פרוטוקול שורות ASCII‏/‏UTF-8, בינארי טהור, או שילוב של שניהם. במיוחד שילוב כמו “חלק הפקודה מחרוזת, ה-payload בינארי, ורק בסוף שורה חדשה” - אם לא מפרטים היכן מסתיים ה-decode ומאיפה מתייחסים לבייטים גולמיים, הגבול נשבר מהר.

3.3 המשמעות של ה-timeout

בטוח יותר לחשוב על timeout לא כאחד, אלא לפצל לפי המשמעות.

  • ‏open timeout: עד לפתיחת הפורט
  • ‏inter-byte timeout: הזמן שבו בייט לא מגיע באמצע מסגרת
  • ‏response timeout: מהוצאת הפקודה עד השלמת התשובה
  • ‏reconnect backoff: מרווח ההמתנה בחיבור מחדש

יציב יותר להחזיק ב-timeout לא כ”ביטוח למקרה של איטיות”, אלא כחוק שמקדם מעבר מצב.

מפצלים timeout לפי משמעותתרשים המראה ש-timeout אחד לא מספיק, ומפצלים ל-open עד הפתיחה, inter-byte לשקט באמצע מסגרת, response להשלמת התשובה, ו-reconnect backoff, וכולם משמשים כחוק שמקדם מעבר מצב.timeout אחד לא מספיקopen: עד הפתיחהinter-byte: שקטresponse: השלמת תשובהreconnect backoffחוק שמקדם מעבר מצב

איור 4: פיצול ארבעה סוגי timeout הופך אותם לחוקים שמקדמים מעבר מצב.

3.4 בקרת זרימה ומצב הקווים

ההגדרות שכדאי לקבוע במפורש:

  • BaudRate
  • DataBits
  • Parity
  • StopBits
  • Handshake
  • DTR‏/‏RTS

אם מסתפקים ב”בערך 8N1 מספיק”, זה נעצר לגמרי אצל חלק מהציוד.

3.5 הפרדת אחריות

מפרידים מי אחראי על מה.

  • מי קורא
  • מי כותב
  • מי מנתח (parse)
  • מי מעדכן את המצב העסקי

תקשורת טורית נשברת ביתר קלות ככל שמערבבים בין ה-UI לתקשורת.

הפרדת אחריות בלי לערבב UI ותקשורתתרשים המראה שמפרידים בין מי קורא, מי כותב, מי מנתח, ומי מעדכן מצב עסקי, ושתקשורת טורית נשברת ביתר קלות ככל שמערבבים בין ה-UI לתקשורת.הפרדת אחריותתפקיד קריאה וכתיבהתפקיד ניתוח(parse)תפקיד עדכון מצב עסקיערבוב UI ותקשורת - נשבר ביתר קלות

איור 5: מפרידים בין קורא, כותב, מנתח ומעדכן, ולא מערבבים UI עם תקשורת.

3.6 מעבר המצב של התחלה, עצירה וחיבור מחדש

לכל הפחות, כדאי לכלול בתכנון מצבים כמו Closed,‏ Opening,‏ Ready,‏ WaitingResponse,‏ Fault,‏ Reconnecting. מיד אחרי ניתוק/חיבור, הצד השני אולי עדיין באתחול, ולפעמים אסור לגרור את ה-pending request הקודם.

מעבר המצב של session החיבורתרשים המראה מצבי Closed, Opening, Ready, WaitingResponse, Fault ו-Reconnecting, כשאין קו ישיר מ-Fault חזרה ל-Ready - אחרי תקלה תמיד עוברים דרך Reconnecting ו-Opening לפני חזרה ל-Ready.בקשת Openopen הצליח + רצף אתחול הסתייםopen נכשל / שגיאת הרשאה / timeout באתחולשליחת פקודההתקבלה מסגרת תשובה מתאימהresponse timeoutשגיאת I/O / זוהה ניתוק כבלמפיל pending request ומתחיל backoffbackoff חלףהגעה לתקרה / עצירה ידניתבקשת CloseClosedOpeningReadyFaultWaitingResponseReconnecting

איור 6: מעבר מצב ה-session של החיבור. אין קו ישיר מ-Fault ל-Ready.

מה שחשוב בתרשים הזה הוא שאין קו ישיר מ-Fault ל-Ready. אחרי חריגה, תמיד עוברים דרך Reconnecting ו-Opening, בונים מחדש את חוצץ הקליטה, מצב ה-parser, ה-pending request ורצף האתחול, ורק אז חוזרים ל-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”. אם מתבלבלים בזה ומטפלים כשגיאה בכל פעם שמספר הבייטים שהתקבלו שונה מהצפוי, מתחילים לספור תקשורת תקינה כשגיאה.

הפתרון פשוט: מפרידים כך שהקליטה נצברת קודם, ומשם parser חותך מסגרות. קוד השלד נמצא בסעיף 5.1.

צוברים ואז חותכיםתרשים המראה שההנחה שערך ה-Read המוחזר הוא מסגרת אחת נשברת בקלות בקליטה חלקית, ולכן במקום זאת קודם צוברים לחוצץ ורק אז ה-parser חותך מסגרות.במקום זאתRead מוחזר = מסגרת אחתנשבר בקלות בקליטה חלקיתהקליטה נצברת לחוצץ קודםה-parser חותך מסגרות

איור 7: מפרידים בין יחידת ה-Read למסגרת, צוברים ואז חותכים.

4.2 הופכים את DataReceived ישירות לאירוע עסקי

SerialPort.DataReceived של .NET נראה נוח, אבל מסוכן לחשוב עליו כ”התראה שהודעה אחת הגיעה”. בעבודה בפועל, מתייחסים ל-DataReceived רק כ”משהו כנראה הגיע”, לא עושים עיבוד כבד בתוך ה-handler, ותמיד מחזירים עדכוני UI ל-thread של ה-UI.

4.3 חושבים שמותר לקרוא ל-Write מכל מקום

תצורה שבה כפתור UI, טיימר ניטור, טיפול בחיבור מחדש, ו-keepalive כל אחד קורא ישירות ל-Write נוטה להישבר. מכיוון שהתקשורת הטורית היא byte stream, תלוי בתכנון, יכולים לקרות ניתוק פקודות באמצע או שליחה נוספת בזמן המתנה לתשובה. במיוחד בפרוטוקולים מסוג request-response או RS-485, הטיה ל-single writer יציבה משמעותית יותר.

מטים את השליחה ל-single writerתרשים המראה שתצורה שבה כפתור UI, טיימר ניטור, keepalive וחיבור מחדש כל אחד קורא ישירות ל-Write נוטה להישבר עקב ניתוק פקודות או שליחה נוספת בזמן המתנה, ושהטיה ל-single writer מייצבת.במקום זאתכפתור UIכל אחד קורא ישירות ל-Writeטיימר ניטורkeepalive · חיבור מחדשניתוק פקודות · שליחה נוספתריכוז ל-single writer

איור 8: לא מרבים מקומות ש-Write נקרא מהם ישירות, אלא מרכזים ל-worker אחד.

4.4 מעבירים הכול דרך ReadLine()‏/‏WriteLine()

עבור פרוטוקול טקסט מבוסס שורות, ReadLine()‏/‏WriteLine() נוחים. אבל הם נוחים רק כשזה באמת פרוטוקול שורות. אי-התאמת NewLine, שורה חדשה בתוך ה-payload, הבדלי קידוד תווים, או ערבוב בינארי - כל אלה שוברים את הגבול מהר.

4.5 לא מתכננים timeout, משאירים בברירת המחדל

הצבת read סינכרוני בפשטות הופכת בקלות להמתנה אינסופית. מה שעוד מסבך זה שה-timeout שהוגדר לא בהכרח משפיע על כל דרכי הקריאה. מימוש שבו קוראים read סינכרוני ב-thread של ה-UI, מנסים לבטא הכול ב-timeout אחד, או רק מוסיפים retry - כל אלה נוטים להיתקע.

4.6 מזלזלים ב-RTS/CTS,‏ XON/XOFF,‏ DTR/RTS

מול ציוד אמיתי, ה-handshake וקווי הבקרה משמעותיים מאוד. אי-התאמת הגדרות נוטה לגרום לתסמינים כמו שליחה שנעצרת מדי פעם, פספוס אחרי כמות מסוימת, או התנהגות שונה רק מיד אחרי הפתיחה. בחלק מהציוד, שינוי DTR/‏RTS מתפרש כאיתות להפעלה או להחלפת מצב.

4.7 חושבים שהתחברתם מחדש רק כי הרצתם שוב Open()

במיוחד ב-USB-טורי, קורה באופן שגרתי שהפורט נעלם זמנית, ה-handle הישן נפסל, וה-pending request הקודם מאבד משמעות. בטוח יותר לתכנן חיבור מחדש כמכלול שכולל לפחות: ביטול session, כשל pending request, עצירת reader/‏writer, פתיחה מחדש אחרי backoff, והרצה חוזרת של אתחול הציוד.

חיבור מחדש הוא לא רק הרצת Open שובתרשים המראה שב-USB-טורי הפורט נעלם וה-handle הישן נפסל, ולכן חיבור מחדש כולל ביטול session, כשל pending request, עצירת reader ו-writer, פתיחה מחדש אחרי backoff, והרצה חוזרת של אתחול הציוד.ביטול sessionכשל ה-pending requestעצירת reader/writerפתיחה מחדש אחרי backoffהרצה חוזרת של אתחול הציודהרצת Open בלבד לא מספיקה

איור 9: חיבור מחדש הוא בניית ה-session מחדש, מכלול אחד שלם.

4.8 חושבים שרשימת פורטי COM היא האמת

GetPortNames() נוח, אבל הופעה ברשימה ויכולת לפתוח (opening) הן לא אותו דבר. מימוש שסומך עיוור על COM7 הקודם, בוחר אוטומטית את הראשון ברשימה, או מניח שברגע שהוא ברשימה הוא תקף - נוטה לגרום לבעיות בתפעול.

4.9 יומן השליחה והקליטה דל

TimeoutException,‏ IOException,‏ Port closed בלבד כמעט לא אומרים כלום. אם שומרים זמני שליחה/קליטה, פרופיל הפורט, hex dump של שליחה וקליטה, שגיאות parser, לאיזה request מתאימה כל תשובה, וסיבת ה-reconnect - הבידוד מתקדם משמעותית.

אם קובעים מראש פורמט, אפשר בהמשך גם grep וגם השוואת diff. לדוגמה, פורמט שורה אחת כזה.

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 תופס מקום, ריאלי לבנות תצורה דו-שכבתית: היומן הגולמי בחוצץ טבעתי בכמות מוגבלת, ויומן התקציר נשמר לטווח ארוך.

שלוש מטרות יומן השליחה והקליטהתרשים המראה שלוש מטרות - הפרדת שורת RX משורת PARSE, הצלבת שליחה וקליטה עם req, ושמירת מעבר המצב בשורה אחת - ותצורה דו-שכבתית של יומן גולמי בחוצץ טבעתי ויומן תקציר לטווח ארוך.הפרדת RX מ-PARSEיומן שניתן לבודד בדיעבדהצלבה עם reqמעבר מצב בשורה אחתגולמי בחוצץ טבעתי, תקציר לטווח ארוך

איור 10: הפרדה בין בייטים שהגיעו למסגרות שנחתכו, מאפשרת מעקב הזזה בדיעבד.

5. שיטות עבודה מומלצות

הכי יעיל זה להפריד אחריות.

  • reader: קורא רק רצף בייטים מהפורט
  • writer: כותב רק בסדר מתוך outbound queue
  • parser: חותך רק frame מרצף בייטים
  • protocol: מטפל בהתאמת request-response וב-checksum
  • app state: מעדכן רק מצב עסקי

בעיבוד הקליטה, תצורה יציבה היא לא להפוך את יחידת ה-Read ליחידה עסקית ישירות, אלא לצבור קודם לחוצץ ורק אז ה-parser חותך frame. מרכזים את השליחה ל-worker אחד, וההטיה של ה-Write בפועל ל-single writer מפחיתה הזזות סדר.

גם ב-timeout, עדיף לפצל למשמעויות open,‏ inter-byte,‏ response,‏ reconnect מאשר להסתפק במספר יחיד - זה מקל על בידוד הסיבה. אם מחזיקים את הגדרות הפורט כ-profile ולא כערך מקומי, ומדפיסים אותן ליומן בזמן ה-startup, החקירה בשטח קלה בהרבה.

יציב יותר לחשוב על חיבור מחדש לא כ-reopen פשוט, אלא כיצירת session מחדש. אם בונים מחדש את חוצץ הקליטה, מצב ה-parser, ה-pending request, רצף האתחול, וגם קביעת ה-readiness, קל יותר להפחית באגי חיבור מחדש ש”נשברים רק לפעמים”.

לבסוף, מומלץ להחזיק גם יומן גולמי וגם יומן תקציר. ‏raw hex dump והיסטוריית open/‏close חזקים לחקירה, ותקציר של request id ומספר retry חזק לתפעול.

צינור עיבוד לפי אחריותתרשים המראה ש-reader קורא בייטים מהפורט, ה-parser חותך מסגרות מהחוצץ הצבור, protocol מטפל בהתאמה וב-checksum, app state מעדכן מצב עסקי, והשליחה מכל מקום רק נכנסת לתור בעוד רק writer כותב בפועל.הפורטreader: קורא בלבדparser: חיתוך מסגרותprotocol: התאמה ו-checksumapp state: עדכון מצב עסקיכל מקום רק מכניס לתורwriter: כותב לפי סדר בלבד

איור 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 הוא byte אחד, אז 256 בייטים ומעלה עוברים wrap ב-cast.
        // עדיין ה-payload מועתק במלואו, ולכן הצד המקבל חותך מסגרת לפי
        // אורך מקוצץ, וקורא באמצע ה-payload כאילו זה CRC. גם גבולות
        // המסגרות הבאות נשברים לגמרי. האם לפצל או להפוך את LEN ל-2 בייטים
        // זו החלטת פרוטוקול, ולכן כאן רק דוחים
        if (payload.Length > byte.MaxValue)
        {
            throw new ArgumentOutOfRangeException(
                nameof(payload),
                $"1 フレームの payload は {byte.MaxValue} byte までです(LEN が 1 byte のため)。");
        }

        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>inter-byte timeout מסעיף 3.3. הזמן עד לוויתור על מסגרת שבתהליך הרכבה.</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>צובר את רצף הבייטים שהתקבל, ומחזיר רק מסגרות שנחתכו בהצלחה.</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. האם התכנסה מסגרת שלמה אחת
            if (_buffer.Count < frameLength)
            {
                // בשלב הזה אי אפשר להבחין בין "עדיין לא מספיק" ל"LEN שהתקלקל מרעש".
                // אם רעש או STX מזויף הופכים את LEN ל-255, ה-parser ימשיך לבלוע
                // גם את המסגרת התקינה שתגיע אחר כך כחלק מה-payload, ועד שיצטברו
                // 259 בייטים וייפול 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
            {
                // כאן זו החלטת תכנון: לזרוק מסגרת שלמה אחת, או לזרוק רק בייט STX אחד ולקרוא מחדש.
                // הראשון פשוט יותר, השני חזק יותר כשה-LEN עצמו היה רעש. קובעים ומתעדים איזו מהן.
                FrameDiscarded?.Invoke(frame);
            }
        }

        return frames;
    }

    /// <summary>
    /// אם המועמד שבהרכבה חרג מ-AssemblyTimeout, זורקים רק בייט STX אחד בתחילתו.
    /// אם נזרק, מוחזר 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. בלעדיו, כשרעש או STX מזויף הופכים את LEN לערך גדול (למשל 255), ה-parser ימשיך להתייחס לזה כ”עדיין לא מספיק”. הוא יבלע גם את המסגרת התקינה שתגיע אחר כך כחלק מה-payload המקולקל, ועד שיצטברו 259 בייטים וייפול ה-CRC, שום דבר לא יעלה. במכשיר עם תעבורה דלה, זה נראה כמה דקות של חוסר תגובה. הזריקה מוגבלת ל-STX בייט אחד בלבד כי ה-STX האמיתי עשוי להיות טמון בתוך המועמד.

שתי הנחות יסוד. תום הזמן הזה נבדק רק כשקוראים ל-Append. אם הקו נעשה שקט לגמרי, כלום לא קורה בצד ה-parser, ואת זה קולטים בצד ה-response timeout של הקורא (5.2). עוד דבר: את הערך של AssemblyTimeout קובעים לפי קצב ה-baud ואורך המסגרת. הגבול התחתון הוא זמן שידור בייט אחד × אורך המסגרת המרבי הצפוי, בתוספת שוליים - אם קצר מזה, זורקים מסגרות תקינות באמצע. מספר ההפעלות של Resynchronized שווה לרשום ביומן - זה מספק חומר לחשוד בחיווט או בהגדרת קצב ה-baud.

LEN שהתקלקל בולע מסגרת תקינהתרשים המראה שכשרעש או STX מזויף הופכים את LEN לערך גדול, ה-parser ממשיך להמתין ל-עדיין לא מספיק ובולע גם את המסגרת התקינה הבאה, וזה נראה חסר תגובה עד שנופל CRC, ולכן פתרון תום הזמן זורק STX אחד ומסנכרן מחדש.פתרוןרעש או STX מזויף מקלקל את LENparser ממתין ל'עדיין לא מספיק'בולע גם מסגרת תקינהנראה חסר תגובה עד נפילת CRCתום זמן: זורק STX אחד ומסנכרן מחדש

איור 12: בלי inter-byte timeout, LEN שהתקלקל ממשיך לבלוע מסגרות תקינות.

צד הקריאה רק קורא מהפורט ומעביר ל-parser. אם מתחילים לכתוב כאן עיבוד עסקי, יחידת ה-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 מכל מקום. הכנסה לתור יכולה להיקרא מכל מקום, אבל רק worker אחד בפועל קורא ל-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, טיימר או worker מוסיפים מהר יותר מהלוך-חזור הציוד,
    // מסגרות ו-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 בפועל מבצע רק worker אחד.</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))
        {
            // מלא, או שה-worker כבר נעצר. בשני המקרים מחזירים לצד הקורא
            // ש'לא הצליח להיכנס'. אם זורקים בשקט, ה-Task הממתין לא יחזור לעולם
            item.Completion.TrySetException(new InvalidOperationException(
                $"送信キューへ積めませんでした(上限 {QueueCapacity} 件、またはワーカー停止済み)。"));
        }

        return item.Completion.Task;
    }

    /// <summary>נקרא כשה-parser חותך מסגרת. מקשר לפריט אחד שממתין לתשובה.</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)
                {
                    // ניתוק כבל, סגירת פורט וביטול מגיעים לכאן.
                    // אם יוצאים בלי להשלים את הפריט הזה, הצד הקורא שמחכה
                    // ב-await ל-SendAsync ימתין לנצח
                    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 מבוטל ומסתיים ראשון.
                    // בלי הבדיקה הזו, תהליך סיום תקין נחשב ל-timeout,
                    // והצד הקורא מקבל TimeoutException
                    token.ThrowIfCancellationRequested();

                    // יש מקרים שהתשובה מגיעה אחרי ש-WhenAny כבר בחר ב-timeout.
                    // אז OnFrameReceived כבר לקח את _inFlight והשלים את הפריט
                    // בהצלחה. אם לא הצלחנו לקחת, התשובה ניצחה, וקריאה כאן ל-
                    // TrySetException לא תעשה כלום. אם ממשיכים ל-throw בלי לשים לב,
                    // הצד הקורא כבר קיבל תוצאה, אבל דווקא ה-worker נופל.
                    // את התחרות מכריעים עם CompareExchange
                    if (Interlocked.CompareExchange(ref _inFlight, null, item) != item)
                    {
                        // התשובה ניצחה. את ההשלמה כבר הכניס OnFrameReceived (מיד לפני כן)
                        await item.Completion.Task;
                        continue;
                    }

                    item.Completion.TrySetException(new TimeoutException("応答がありませんでした。"));

                    // אם קרה timeout, אי אפשר יותר לסמוך על החיבור הזה.
                    // הסיבה שאסור להסתפק ב'לוותר ולשלוח את הבא' מוסברת למטה -
                    // לפרוטוקול הזה אין request ID, ולכן תשובת A שמגיעה באיחור
                    // תיקשר לפקודה הבאה B. כמו במעבר המצב שבסעיף 3.6,
                    // נופלים ל-Fault ובונים את ה-session מחדש
                    throw new TimeoutException("応答がないため、セッションを作り直します。");
                }
            }
        }
        finally
        {
            // לא משנה למה ה-worker נעצר, תמיד מסיימים את מה שממתין.
            // גם הפריט הבודד שממתין לתשובה, וגם מה שנשאר בתור בלי להישלח
            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);
            }
        }
    }
}

עצירת ה-worker כולו בגלל timeout נראית קיצונית, אבל זהו צעד נחוץ. למסגרת הזו אין request ID. לכן הצד המקבל לא יכול לקבוע ‘לאיזו פקודה מתאימה המסגרת שהגיעה עכשיו’, ו-OnFrameReceived יכול רק לקשר באופן מכני לפריט הבודד שממתין לתשובה.

אם ממשיכים לשלוח את הבא גם אחרי timeout, קורה זה:

  1. שולחים פקודה A. התשובה לא מגיעה בזמן שהוגדר, אז זה נחשב timeout
  2. שולחים את הפקודה הבאה B
  3. תשובת A שמגיעה באיחור מועברת לצד הקורא כתשובה של B

מבחינת הצד הקורא, נשלח B אבל התקבל ערך של A. פורמט הערך תקין, אז גם בדיקות עוברות - זו דרך השבירה הכי קשה למצוא. הסיבה שלא נמשך קו ישיר מ-Fault ל-Ready בתרשים מעבר המצב בסעיף 3.6 היא בדיוק המסלול הזה. ‏timeout הוא לא “כישלון פריט בודד” אלא החלטה ש”לא ניתן יותר לסמוך על החיבור הזה”, ומכיוון שלא ידוע מה נשאר בחוצץ הקליטה, ההתאוששות ניתנת להבטחה רק על ידי סגירת הפורט ופתיחתו מחדש - יצירת session מחדש.

אם אפשר לגעת בצד הפרוטוקול, הפתרון המהותי יותר הוא לצייד את המסגרת ב-request ID ולהצליב עם התשובה. כך, תשובה שמגיעה באיחור פשוט “נזרקת כי ה-ID לא מוכר”, ואין צורך לבנות את החיבור מחדש בכל timeout בודד.

שיוך שגוי של תשובה שמגיעה באיחורתרשים המראה שבפרוטוקול ללא request ID, אחרי timeout של פקודה A ושליחת פקודה B, תשובת A שמגיעה באיחור מועברת לצד הקורא כתשובת B, ולכן אחרי timeout נופלים ל-Fault ובונים את ה-session מחדש.הציודworkerהצד הקוראהציודworkerהצד הקוראהתשובה לא הגיעה בזמן - timeoutלכן אחרי timeout נופלים ל-Faultשולח פקודה Aשולח Aשולח פקודה Bשולח Bתשובת A מגיעה באיחורמועברת בטעות כתשובת B

איור 13: בלי request ID, תשובה שמגיעה באיחור מתחברת לפקודה הבאה.

לבסוף, מרכיבים הכול. הגדרות הפורט מונחות כ-profile במקום אחד, ומודפסות ליומן בזמן ה-startup - זה עד כאן הנושא של סעיף 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
{
    // גם אם השליחה נכשלת, תמיד עוצרים את ה-worker לפני היציאה. אם מדלגים
    // על זה, אחרי שה-using סוגר את ה-SerialPort, reader/writer
    // ממשיכים לגעת בזרם, והחריגה שם לא נצפית על ידי אף אחד
    cts.Cancel();
    try
    {
        await Task.WhenAll(readerTask, writerTask);
    }
    catch (OperationCanceledException)
    {
        // סיום עקב בקשת עצירה. מתייחסים לזה כמסלול תקין
    }
    catch (Exception ex)
    {
        // כישלון בצד ה-worker. אם זורקים שוב כאן, זה מסתיר את סיבת
        // הכישלון האמיתית (החריגה של SendAsync), אז רק רושמים
        Console.Error.WriteLine($"worker stopped with error: {ex.Message}");
    }
}

cts.Cancel() ו-Task.WhenAll בתוך finally הם לא עניין של סגנון כתיבה. בתקשורת טורית, כישלון SendAsync הוא לא חריג אלא שגרה יומיומית - הציוד לא מגיב, הכבל התנתק, הכתיבה עברה timeout. אם יוצאים החוצה בפשטות, מגיעים ל-using שמשליך בלי לעצור את ה-worker. גם אחרי ש-SerialPort נסגר, reader/‏writer ממשיכים לגעת בזרם, והחריגה שנוצרת שם לא נצפית על ידי אף אחד. ביישום שרץ ברקע, זה מצטבר לאט - נשארים רק ה-worker-ים של פעולות שנכשלו. אי-זריקה מחדש בצד ה-finally נועדה לא להסתיר את סיבת הכישלון האמיתית (החריגה של SendAsync).

גם בכישלון, תמיד עוצרים את ה-workerתרשים המראה שכישלון SendAsync הוא שגרה בתקשורת טורית, ושיציאה ישירה משאירה SerialPort נפטר בלי לעצור worker, כך ש-reader ו-writer ממשיכים לגעת בזרם הסגור בלי שאיש רואה את החריגה, ולכן ב-finally מבטלים וממתינים.SendAsync נכשל(שגרה)ב-finally: ביטול והמתנהעוצרים worker לפני ההיפטרותדילוג = ממשיכים לגעת בזרם סגורביישום ברקע, worker-ים נשארים בכל כישלון

איור 14: דווקא כשהשליחה נכשלת, חשוב לעצור את ה-worker ב-finally לפני היציאה.

אם משאירים את זה בצורה הזו, כל מה שירצו להוסיף בהמשך - כמו retry,‏ keepalive,‏ reconnect - נכנס תמיד לצד הכנסה לתור או לצד ה-worker. מספר המקומות שקוראים ישירות ל-Write לא גדל, אז לא נוספות סיבות להזזת סדר.

6. רשימת בדיקה ראשונית

  • האם גבול ההודעה מתועד במפורש
  • האם הקליטה בנויה כ-צבירת בייטים → חיתוך frame
  • האם DataReceived לא מטופל כהגעת הודעה
  • האם אין I/O סינכרוני ב-thread של ה-UI
  • האם השליחה בנויה כ-single writer
  • האם ה-timeout מפוצל למשמעויות ולא רק אחד
  • האם Handshake‏/‏DTR‏/‏RTS מוגדרים במפורש
  • האם reconnect בונה מחדש את ה-session
  • האם נשמר raw hex dump
  • האם נבדק ניתוק/חיבור אמיתי או ניתוק באמצע

אם כמה פריטים כאן מעוררים חשד, שווה לעצור פעם אחת לפני היציאה לפרודקשן.

7. סיכום

לבסוף, שוב רק העיקר בשורה.

  • תקשורת טורית היא byte stream, לא הודעה
  • יחידת Read ויחידת הודעה לא זהות
  • הגבול חייב להיות מוגדר כפרוטוקול
  • הפיכת DataReceived ישירות לאירוע עסקי נוטה להישבר
  • מפרידים אחריות בין קליטה לשליחה, ומטים שליחה ל-single writer
  • מפצלים timeout למשמעויות, ומתכננים חיבור מחדש ברמת session
  • יומן עם raw hex dump מקל מאוד על חקירה בדיעבד

כלומר, ביישום תקשורת טורית, איך מפרשים את רצף הבייטים ואיך שולטים בזמן ובמצב חשוב הרבה יותר מפתיחת הפורט. רק ההפרדה הזו בתכנון מההתחלה מפחיתה משמעותית תקלות תקשורת מסוג “נשבר רק לפעמים”.

פירוש ובקרה חשובים יותר מפתיחהתרשים המראה שביישום תקשורת טורית, פתיחת הפורט אינה הקושי, אלא פירוש רצף הבייטים ובקרת הזמן והמצב, ושתכנון נפרד מההתחלה מפחית תקלות תקשורת מסוג נשבר רק לפעמים.פתיחת הפורטזה לא הקושיפירוש רצף הבייטיםמתכננים בנפרד מההתחלהבקרת זמן ומצבפחות תקלות 'נשבר רק לפעמים'

איור 15: הקושי האמיתי הוא לא פתיחת הפורט, אלא פירוש הבייטים ובקרת הזמן והמצב.

מקורות

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

‏האם קריאה ל-Read(16) בתקשורת טורית מבטיחה קבלת 16 בייטים בדיוק?
לא בהכרח. תקשורת טורית היא byte stream מסודר, וגבול ההודעה לא נוצר מעצמו. גם תוכן שנשלח ב-Write פעם אחת עלול להגיע בצד השני בשני חלקים, או מחובר לנתונים אחרים. הדפוסים האופייניים הם: האורך בלבד נקרא ו-payload עדיין לא הגיע, מגיעה מסגרת וחצי, או שתי מסגרות מגיעות יחד. הפתרון הוא להפריד - הקליטה נצברת קודם לחוצץ, ומשם parser חותך מסגרות.
‏מה נקודות הזהירות בשימוש באירוע SerialPort.DataReceived של .NET?
DataReceived לא בהכרח מופעל לכל בייט שמתקבל, וגם לא ב-thread של ה-UI. מסוכן לחשוב עליו כ'התראה שהודעה אחת הגיעה'. בעבודה בפועל, כדאי להתייחס אליו רק כהתראת 'משהו כנראה הגיע', לא לעשות עיבוד כבד בתוך ה-handler, ותמיד להחזיר עדכוני UI ל-thread של ה-UI. תצורה יציבה יותר היא לצבור את רצף הבייטים ורק אז לחתוך מסגרות ב-parser.
‏איך מתכננים timeout בתקשורת טורית?
timeout אחד לא מספיק - יציב יותר לפצל לפי משמעות: open timeout עד לפתיחת הפורט, inter-byte timeout לזמן שבו בייט לא מגיע באמצע מסגרת, response timeout מהוצאת הפקודה עד השלמת התשובה, ו-reconnect backoff למרווח ההמתנה בחיבור מחדש. יציב יותר להתייחס ל-timeout לא כביטוח למקרה של איטיות, אלא כחוק שמקדם את מעבר המצב. חשוב גם לשים לב שהצבת read סינכרוני בברירת המחדל בפשטות הופכת בקלות להמתנה אינסופית.
‏למה אין התאוששות אחרי ניתוק/חיבור כבל במתאם USB-טורי?
כי במתאם USB-טורי קורה באופן שגרתי שהפורט נעלם זמנית, ה-handle הישן נפסל, מספר ה-COM משתנה, וה-pending request הקודם מאבד משמעות. הרצה חוזרת של Open() בלבד לא מספיקה כחיבור מחדש. אם מתכננים את זה כ'יצירת session מחדש' שכוללת ביטול session, כשל pending request, עצירת reader ו-writer, פתיחה מחדש אחרי backoff, והרצה חוזרת של רצף אתחול הציוד - ניתן להפחית באגי חיבור מחדש שנשברים רק לפעמים.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג