Pitfalls באפליקציות serial communication —‏ reconnect ותכנון log

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

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 19 Mar 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173649)

מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.

Go Komura (2026). Pitfalls באפליקציות serial communication —‏ reconnect ותכנון log. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173649 https://comcomponent.com/he/blog/serial-communication-app-pitfalls/

DOI (הגרסה האחרונה)
10.5281/zenodo.22173649
DOI (הגרסה הזו)
10.5281/zenodo.22173650

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

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

  • מדי פעם הפקודה וה-response לא מיושרים
  • freeze פעם ביום
  • אחרי unplug/replug של USB לא מתאושש
  • ה-UI נתקע מדי פעם
  • ב-log נשאר רק “Timeout”

מה שבאמת קשה באפליקציית serial זה לא ה-API של send/receive עצמו, אלא הגבולות, ה-timeout, מעברי ה-state, ה-reconnect וה-observability.

בדיקת connectivity עוברת, אבל ב-production זה נשברתרשים שמראה שאפשר להתחיל serial communication עם COM port אחד ו-Read/Write אחד, בדיקת connectivity עוברת מיד, אבל ב-production מופיעים סימפטומים כמו response לא מיושר או freeze, והקושי האמיתי הוא גבול, timeout, מעבר state, reconnect ו-observability.בדיקת connectivity עוברת מידב-production נשבר 'מדי פעם'response לא מיושר · freeze · לא מתאוששהקושי הוא לא ה-API עצמוגבול · timeout · state · reconnect · observability

איור 1: מעבר לבדיקת connectivity, זה הקושי האמיתי באפליקציית serial.

קהל היעד וההנחות של המאמר

פריט תוכן
קהל היעד מי שבונה אפליקציית Windows שמתחברת ב-serial לציוד או למכשיר מדידה. מיועד למי שבדיקת connectivity עוברת, אבל רוצה לצמצם מצבים שנשברים “מדי פעם” ב-production
ידע מונח מראש יכולת לכתוב אפליקציה ב-C#. אין הנחה של ניסיון ב-serial communication עצמו
סביבה מונחת מראש כתוב על בסיס System.IO.Ports.SerialPort של .NET, אבל דרך החשיבה על גבול, timeout ומעבר state אינה תלוית שפה
מה לא נכלל חיווט חשמלי, מפרט פרוטוקול של ציוד ספציפי

מונחים שהמאמר משתמש בהם

מונח משמעות בשורה אחת
PLC Programmable Logic Controller. בקר תעשייתי לשליטה בציוד ייצור
RS-232 / RS-485 תקנים חשמליים של serial communication. RS-232 הוא אחד-לאחד, RS-485 מאפשר לתלות כמה יחידות על אותו קו. ב-RS-485 חייבים לקבוע מי שולח ומתי, אחרת יש collision
8N1 קיצור להגדרות פורט. שילוב של 8 data bits, בלי parity (None), ו-stop bit אחד
DTR / RTS קווי בקרה. במקור נועדו לאותת מוכנות לתקשורת או בקשת שליחה, אבל בציוד אמיתי לפעמים משתמשים בשינוי שלהם כאות הפעלה או החלפת מצב
flow control מנגנון שמונע לשלוח יותר מדי. RTS/CTS הם קווי בקרה, XON/XOFF מעבירים עצירה והמשך דרך תווים מיוחדים בתוך ה-data
keepalive פקודה קלה שנשלחת בקביעות כדי לוודא שהצד השני עדיין חי
frame רצף bytes של הודעה אחת. איפה מתחיל ואיפה נגמר frame אחד נקבע בפרוטוקול
single writer תכנון שמרכז את ה-send ל-worker אחד. משמעותו: לא לאפשר לכולם לקרוא ל-Write

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

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

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

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

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 18, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

2. Serial communication הוא לא “הודעה” אלא “byte stream סדור”

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

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

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

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

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

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

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

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

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

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

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

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

3.1 גבול ה-frame

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

3.2 Text, binary, או ערבוב

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

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

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

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

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

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

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

3.4 Flow control ומצב הקווים

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

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

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

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

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

  • מי קורא
  • מי כותב
  • מי עושה parse
  • מי מעדכן את ה-business state

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

מפרידים אחריות ולא מערבבים UI עם תקשורתתרשים שמראה שמפרידים בין מי קורא, מי כותב, מי עושה parse, ומי מעדכן business state, וש-serial communication נשבר ביתר קלות ככל שמערבבים בין ה-UI לתקשורת.הפרדת אחריותתפקיד קריאה וכתיבהתפקיד parseתפקיד עדכון business stateערבוב UI ותקשורת — נשבר ביתר קלות

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

3.6 מעברי ה-state של התחלה, עצירה ו-reconnect

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

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

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

מה שחשוב בתרשים הזה הוא שאין קו ישיר מ-Fault ל-Ready. אחרי חריגה, תמיד עוברים דרך Reconnecting ו-Opening, בונים מחדש את ה-receive buffer, מצב ה-parser, ה-pending request ורצף האתחול, ורק אז חוזרים ל-Ready. אם מקצרים כאן, נופלים לתוך 4.7 —‏ “חושבים שחיברתם מחדש רק כי קראתם שוב ל-Open()”.

3.7 Log ויכולת חקירה

זה כמעט תמיד המקום שהכי מטריד בדיעבד. לפחות אלה כדאי לשמור: זמני open / close / reopen, הגדרות הפורט שבשימוש, hex dump של frames ב-TX וב-RX, שגיאות checksum / CRC, frame timeout / response timeout, וסיבת ה-reconnect.

4. Pitfalls נפוצים

4.1 לחשוב ש-“Read אחד = הודעה אחת”

זו הכי נפוצה. נניח שהצד השני מחזיר frame שמורכב מ-header, אורך, payload ו-CRC. אם קוראים פעם אחת ל-Read(buffer, 0, expectedLength) ומניחים שהערך המוחזר הוא frame שלם, זה נשבר בקלות ב-receive חלקי.

שלוש דרכי השבירה הנפוצות:

  • נקרא רק האורך, וה-payload עדיין לא הגיע
  • מגיע frame וחצי, והחצי השני חוזר ב-Read הבא
  • שני frames מגיעים ביחד, ומעבדים רק את הראשון וזורקים את השאר

כשמציירים את זה, זה פשוט: הרצף ששלח הציוד לא מתאים לרצף שמחזיר ה-Read.

מה שהציוד שלח
    [--- frame 1 ---][--- frame 2 ---]

דפוס 1: מגיע רק חלקית
    Read ראשון  -> [ STX ][ LEN ]                     <- payload עדיין לא הגיע
    Read שני    -> [ payload ][ CRC ][--- frame 2 ---]

דפוס 2: מגיע frame וחצי
    Read ראשון  -> [--- frame 1 ---][ חצי ראשון של frame 2 ]
    Read שני    -> [ חצי שני של frame 2 ]

דפוס 3: שני frames מגיעים ביחד
    Read ראשון  -> [--- frame 1 ---][--- frame 2 ---]   <- נוטים לעבד רק אחד ולזרוק את השאר

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

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

צוברים ואז חותכיםתרשים שמראה שההנחה שערך ה-Read המוחזר הוא frame אחד נשברת בקלות ב-receive חלקי, ולכן במקום זאת קודם צוברים ל-buffer ורק אז ה-parser חותך frames.במקום זאתRead מוחזר = frame אחדנשבר בקלות ב-receive חלקיה-receive נצבר ל-buffer קודםה-parser חותך frames

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

4.2 להפוך את DataReceived ישירות ל-event עסקי

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

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

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

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

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

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

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

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

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

4.6 לזלזל ב-RTS/CTS, XON/XOFF, DTR/RTS

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

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

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

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

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

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

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

4.9 ה-log של TX/RX דל

TimeoutException, IOException, Port closed בלבד כמעט לא אומרים כלום. אם שומרים זמני TX/RX, port profile, 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 הוא “כמה bytes הגיעו”, PARSE הוא “כמה frames נחתכו”. בדוגמה למעלה, frame אחד מגיע על פני ה-RX השני והשלישי, והשארית הופכת לתחילת ה-frame הבא. אם מערבבים בין שני הסוגים, אי אפשר לקבוע בדיעבד אם קרתה הזזת חלוקה כמו בסעיף 4.1
  • מאפשרים הצלבה בין TX ל-RX עם req=. אי אפשר לשחזר בדיעבד מה-log בלבד לאיזו פקודה מתאימה כל תשובה
  • שומרים את מעבר ה-state בשורה אחת. אם נשאר רישום כמו Ready -> Fault והסיבה, אפשר לעקוב ישירות אחר הגורם ל-reconnect

מכיוון ש-hex dump תופס מקום, ריאלי לבנות שתי שכבות: ה-raw log ב-ring buffer בכמות מוגבלת, ו-summary log נשמר לטווח ארוך.

שלוש מטרות של log ה-TX/RXתרשים שמראה שלוש מטרות — הפרדת שורת RX משורת PARSE, הצלבת TX ו-RX עם req, ושמירת מעבר ה-state בשורה אחת — ומבנה דו-שכבתי של raw log ב-ring buffer ו-summary log לטווח ארוך.הפרדת RX מ-PARSElog שניתן לבודד בדיעבדהצלבה עם reqמעבר state בשורה אחתraw ב-ring buffer, summary לטווח ארוך

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

5. Best practices

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

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

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

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

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

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

pipeline לפי אחריותתרשים שמראה ש-reader קורא bytes מהפורט, ה-parser חותך frames מה-buffer הצבור, protocol מטפל בהתאמה וב-checksum, app state מעדכן business state, והשליחה מכל מקום רק נכנסת ל-queue בעוד רק writer כותב בפועל.הפורטreader: קורא בלבדparser: חיתוך framesprotocol: התאמה ו-checksumapp state: עדכון business stateכל מקום רק מכניס ל-queuewriter: כותב לפי סדר בלבד

איור 11: ה-pipeline של receive ו-send. כל תפקיד עושה עבודה אחת בלבד.

מכאן, נשים קוד שלד רק לשני המקומות הכי יעילים. בהנחת .NET 8 / C# 12, עם הפניה לחבילת System.IO.Ports.

5.1 Receive: צוברים ואז חותכים

כדוגמה, נניח frame של STX(0x02), LEN(1 byte), payload(LEN byte), CRC16(2 byte, little-endian). הצורה יכולה להיות כל דבר, אבל הנקודה המרכזית היא לחתוך frame לפי ההגדרה הזו, ולא לפי יחידת ה-Read המוחזרת.

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;

public static class Crc16Modbus
{
    // CRC-16/MODBUS: ערך התחלה 0xFFFF, polynomial 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 byte ומעלה עוברים wrap ב-cast.
        // עדיין ה-payload מועתק במלואו, ולכן הצד המקבל חותך frame לפי
        // אורך מקוצץ, וקורא באמצע ה-payload כאילו זה CRC. גם גבולות
        // ה-frames הבאים נשברים לגמרי. האם לפצל או להפוך את LEN ל-2 byte
        // זו החלטת פרוטוקול, ולכן כאן רק דוחים
        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. הזמן עד שמוותרים על frame שבתהליך הרכבה.</summary>
    private static readonly TimeSpan AssemblyTimeout = TimeSpan.FromMilliseconds(200);

    /// <summary>מתי ה-candidate שמרכיבים עכשיו נכנס להמתנה (ערך מונוטוני עולה).</summary>
    private long _pendingSince;

    /// <summary>מודיע על frame שנזרק בגלל אי-התאמת CRC. חובה להירשם אליו כדי לכתוב ל-log.</summary>
    public event Action<byte[]>? FrameDiscarded;

    /// <summary>מודיע על ויתור על הרכבה ו-resync. אם זה ממשיך לעלות, יש לחשוד בחיווט או בהגדרות.</summary>
    public event Action<int>? Resynchronized;

    /// <summary>צובר את רצף ה-bytes שהתקבל, ומחזיר רק frames שנחתכו בהצלחה.</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. כאן נבלעים רעש ושארית של ה-frame הקודם
            var stxIndex = _buffer.IndexOf(Frame.Stx);
            if (stxIndex < 0)
            {
                _buffer.Clear();
                _pendingSince = 0;   // ה-candidate נעלם, אז מפסיקים גם למדוד זמן המתנה
                break;
            }

            if (stxIndex > 0)
            {
                // תחילת ה-candidate השתנתה = מתחילים להרכיב frame אחר
                _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. האם התכנס frame שלם אחד
            if (_buffer.Count < frameLength)
            {
                // בשלב הזה אי אפשר להבחין בין "עדיין לא מספיק" ל"LEN שהתקלקל מרעש".
                // אם רעש או STX מזויף הופכים את LEN ל-255, ה-parser ימשיך לבלוע
                // גם את ה-frame התקין שתגיע אחר כך כחלק מה-payload, ועד שיצטברו
                // 259 byte וייפול CRC, שום דבר לא יעלה. במכשיר עם תעבורה דלה,
                // זה נראה כמו כמה דקות בלי תגובה.
                // שמים תקרה להמתנה, ואם חורגים, זורקים את ה-candidate ומחפשים STX מחדש
                if (GiveUpOnStaleCandidate()) { continue; }
                break;   // יוצאים כאן וממתינים ל-receive הבא
            }

            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
            {
                // כאן זו החלטת תכנון: לזרוק frame שלם אחד, או לזרוק רק byte STX אחד ולקרוא מחדש.
                // הראשון פשוט יותר, השני חזק יותר כשה-LEN עצמו היה רעש. קובעים ומתעדים איזו מהן.
                FrameDiscarded?.Invoke(frame);
            }
        }

        return frames;
    }

    /// <summary>
    /// אם ה-candidate שמרכיבים חרג מ-AssemblyTimeout, זורקים רק את ה-STX הראשון (byte אחד).
    /// אם נזרק, מוחזר true, והקורא קורא מחדש מה-STX הבא.
    /// לא זורקים frame שלם, כי ה-STX האמיתי עשוי להיות בתוך ה-candidate הזה.
    /// </summary>
    private bool GiveUpOnStaleCandidate()
    {
        if (_pendingSince == 0)
        {
            // הרגע שבו התחילה ההמתנה. שעון קיר קופץ עם NTP sync, אז מודדים בערך מונוטוני עולה
            _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 ימשיך להתייחס לזה כ”עדיין לא מספיק”. הוא יבלע גם את ה-frame התקין שתגיע אחר כך כחלק מה-payload המקולקל, ועד שיצטברו 259 byte וייפול ה-CRC, שום דבר לא יעלה. במכשיר עם תעבורה דלה, זה נראה כמו כמה דקות בלי תגובה. הזריקה מוגבלת ל-STX byte אחד בלבד כי ה-STX האמיתי עשוי להיות טמון בתוך ה-candidate.

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

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

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

צד הקריאה רק קורא מהפורט ומעביר ל-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, הוא לא נושא משמעות מעבר ל”כנראה הגיע משהו”, אז החזקת לולאת קריאה עצמאית מקלה על ניהול ה-state וה-timeout.

5.2 Send: הטיה ל-single writer

בצד ה-send, המפתח הוא לא ליצור מצב שבו אפשר לקרוא ל-Write מכל מקום. הכנסה ל-queue יכולה להיקרא מכל מקום, אבל רק 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>תקרת ה-send queue. נקבעת לפי זמן round-trip אחד של הציוד × אורך התור המותר.</summary>
    private const int QueueCapacity = 64;

    private readonly SerialPort _port;
    private readonly TimeSpan _responseTimeout;

    // לא בלי תקרה. אם UI, טיימר או worker מוסיפים מהר יותר מה-round-trip של הציוד,
    // frames ו-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 חותך frame. מקשר לפריט אחד שממתין ל-response.</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;
                }

                // ממתינים כאן ל-response, כדי שהפקודה הבאה לא תתפרץ באמצע
                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();

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

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

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

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

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

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

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

שיוך שגוי של response שמגיע באיחורתרשים שמראה שבפרוטוקול בלי 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 במקום אחד, ומודפסות ל-log בזמן ה-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);

// חובה להירשם ל-events שהוגדרו. אם שוכחים, גם frames שנזרקו וגם תשובות לא יוצגו
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
{
    // גם אם ה-send נכשל, תמיד עוצרים את ה-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 הם לא עניין של סגנון כתיבה. ב-serial communication, כישלון SendAsync הוא לא חריג אלא שגרה יומיומית — הציוד לא מגיב, הכבל התנתק, הכתיבה עברה timeout. אם יוצאים החוצה בפשטות, מגיעים ל-using שמשליך בלי לעצור את ה-worker. גם אחרי ש-SerialPort נסגר, reader / writer ממשיכים לגעת בזרם, והחריגה שנוצרת שם לא נצפית על ידי אף אחד. באפליקציה שרצה ברקע, זה מצטבר לאט — נשארים רק ה-workers של פעולות שנכשלו. אי-זריקה מחדש בצד ה-finally נועדה לא להסתיר את סיבת הכישלון האמיתית (החריגה של SendAsync).

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

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

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

6. Checklist ראשוני

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

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

7. סיכום

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

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

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

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

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

8. מקורות

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

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

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

שאלות נפוצות

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

האם קריאה ל-Read(16) ב-serial communication מבטיחה בדיוק 16 byte?
לא בהכרח. Serial communication הוא byte stream סדור, וגבול ההודעה לא מגיע מעצמו. גם מה ששלחתם ב-Write אחד יכול להגיע בצד השני בשני חלקים, או מחובר ל-data אחר. הדפוסים האופייניים: נקרא רק האורך וה-payload עדיין לא הגיע, מגיע frame וחצי, או שני frames מגיעים ביחד. הפתרון: קודם צוברים את ה-receive ל-buffer, ומשם parser חותך frames.
על מה להיזהר בשימוש ב-SerialPort.DataReceived של .NET?
DataReceived לא בהכרח נורה לכל byte שמתקבל, וגם לא רץ על ה-UI thread. מסוכן לחשוב עליו כהתראה ש'הודעה אחת הגיעה'. בפועל מתייחסים אליו רק כ-'כנראה הגיע משהו', לא עושים עיבוד כבד בתוך ה-handler, ומעדכנים UI תמיד בחזרה ל-UI thread. מבנה יציב יותר: צוברים את רצף ה-bytes ורק אז parser חותך frames.
איך מתכננים timeout ב-serial communication?
timeout אחד לא מספיק. יותר יציב לפצל לפי משמעות: open timeout עד לפתיחת הפורט, inter-byte timeout לזמן שבו byte לא מגיע באמצע frame, response timeout מהוצאת הפקודה עד שהתשובה הושלמה, ו-reconnect backoff למרווח ההמתנה ב-reconnect. יותר יציב להתייחס ל-timeout לא כביטוח לאיטיות, אלא כחוק שמקדם מעבר state. שימו לב גם ש-read סינכרוני בברירת המחדל, אם שמים אותו בלי מחשבה, הופך בקלות להמתנה אינסופית.
למה אחרי unplug/replug של כבל בממיר USB-serial אין התאוששות?
כי ב-USB-serial זה שגרתי שהפורט נעלם זמנית, ה-handle הישן הופך ל-invalid, מספר ה-COM משתנה, וה-pending request הקודם מאבד משמעות. לקרוא שוב ל-Open() זה לא reconnect. אם מתכננים את זה כבניית session מחדש שכוללת ביטול session, fail ל-pending request, עצירת reader ו-writer, reopen אחרי backoff, והרצה חוזרת של רצף האתחול של הציוד — אפשר לצמצם באגי reconnect שנשברים רק מדי פעם.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג