Pitfalls באפליקציות serial communication — reconnect ותכנון log
· עודכן בתאריך: · Go Komura · 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.
flowchart TB
accTitle: בדיקת connectivity עוברת, אבל ב-production זה נשבר
accDescr: תרשים שמראה שאפשר להתחיל serial communication עם COM port אחד ו-Read/Write אחד, בדיקת connectivity עוברת מיד, אבל ב-production מופיעים סימפטומים כמו response לא מיושר או freeze, והקושי האמיתי הוא גבול, timeout, מעבר state, reconnect ו-observability.
a1["בדיקת connectivity עוברת מיד"] --> a2["ב-production נשבר 'מדי פעם'"]
a2 --> a3["response לא מיושר · freeze · לא מתאושש"]
a3 --> a4["הקושי הוא לא ה-API עצמו"]
a4 -.-> a5["גבול · 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 byteDataReceivedשל .NET לא בהכרח נורה לכל byte שמתקבל, ובנוסף גם לא על ה-UI threadReadLine()/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.
flowchart TB
accTitle: שלוש דרכים שבהן Write אחד יכול להגיע
accDescr: תרשים שמראה שתוכן שנשלח ב-Write אחד עלול להגיע בצד השני ב-Read אחד, בשני חלקים, או מחובר ל-data אחר, ולכן אין ערובה שה-Read הנוכחי מקביל ל-response הנוכחי.
b0["Write אחד"] --> b1["מגיע ב-Read אחד"]
b0 --> b2["מגיע בשני חלקים"]
b0 --> b3["מגיע מחובר ל-data אחר"]
b2 -.-> b4["'ה-Read הזה = ה-response הזה' לא מובטח"]
איור 2: איך Write אחד ייראה בצד השני — לא יודעים עד שהוא מגיע בפועל.
| הנחה נפוצה | המציאות |
|---|---|
Read(16) מחזיר בדיוק 16 byte |
תלוי במצב ההגעה וב-timeout, לפעמים מתקבל רק חלק |
DataReceived = הגיעה הודעה אחת |
ה-event לא מובטח לכל byte, וגם לא על ה-UI thread |
Write חזר = הצד השני סיים לעבד |
ברוב המקרים זה קרוב יותר לכך שצד השליחה הצליח לצבור ל-buffer |
| רשימת COM = האמת של מה שמחובר עכשיו | סדר ה-enumeration לא קבוע, ולפעמים התוצאה כבר stale |
בגלל זה, ב-serial communication צריך להגדיר בעצמכם את גבול ההודעה כפרוטוקול. frame באורך קבוע, מבוסס תו מפריד, אורך+payload+checksum — הצורה יכולה להיות כל דבר, אבל אם נכנסים למימוש כשזה מעורפל, כמעט בטוח שיהיה קשה בהמשך.
flowchart TB
accTitle: מגדירים את הגבול בעצמכם
accDescr: תרשים שמראה שב-serial communication מגדירים בעצמכם את גבול ההודעה כפרוטוקול — frame באורך קבוע, מבוסס תו מפריד, או אורך+payload+checksum — אבל אם זה נשאר מעורפל בזמן המימוש, זה הופך לקשה.
c0["מגדירים גבול הודעה בעצמכם"] --> c1["frame באורך קבוע"]
c0 --> c2["מבוסס תו מפריד"]
c0 --> c3["אורך+payload+checksum"]
c0 -.-> c4["אם נשאר מעורפל — קשה"]
איור 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.
flowchart TB
accTitle: מפצלים timeout לפי משמעות
accDescr: תרשים שמראה ש-timeout אחד לא מספיק, ומפצלים ל-open עד הפתיחה, inter-byte לשקט באמצע frame, response להשלמת התשובה, ו-reconnect backoff, וכולם משמשים כחוק שמקדם מעבר state.
d0["timeout אחד לא מספיק"] --> d1["open: עד הפתיחה"]
d0 --> d2["inter-byte: שקט"]
d0 --> d3["response: השלמת תשובה"]
d0 -.-> d4["reconnect backoff"]
d1 --> d5["חוק שמקדם מעבר state"]
d2 --> d5
d3 --> d5
איור 4: פיצול ארבעה סוגי timeout הופך אותם לחוקים שמקדמים מעבר state.
3.4 Flow control ומצב הקווים
ההגדרות שכדאי לקבוע במפורש:
BaudRateDataBitsParityStopBitsHandshakeDTR/RTS
אם מסתפקים ב”בערך 8N1 מספיק”, זה נעצר לגמרי אצל חלק מהציוד.
3.5 הפרדת אחריות
מפרידים מי אחראי על מה.
- מי קורא
- מי כותב
- מי עושה parse
- מי מעדכן את ה-business state
Serial communication נשבר ביתר קלות ככל שמערבבים בין ה-UI לתקשורת.
flowchart TB
accTitle: מפרידים אחריות ולא מערבבים UI עם תקשורת
accDescr: תרשים שמראה שמפרידים בין מי קורא, מי כותב, מי עושה parse, ומי מעדכן business state, וש-serial communication נשבר ביתר קלות ככל שמערבבים בין ה-UI לתקשורת.
e0["הפרדת אחריות"] --> e1["תפקיד קריאה וכתיבה"]
e0 --> e2["תפקיד parse"]
e0 --> e3["תפקיד עדכון business state"]
e0 -.-> e4["ערבוב UI ותקשורת — נשבר ביתר קלות"]
איור 5: מפרידים בין קורא, כותב, parser ומעדכן, ולא מערבבים UI עם תקשורת.
3.6 מעברי ה-state של התחלה, עצירה ו-reconnect
לכל הפחות, כדאי לכלול בתכנון מצבים כמו Closed, Opening, Ready, WaitingResponse, Fault, Reconnecting. מיד אחרי unplug/replug, הצד השני אולי עדיין באתחול, ולפעמים אסור לגרור את ה-pending request הקודם.
stateDiagram-v2
accTitle: מעברי ה-state של session החיבור
accDescr: תרשים שמראה מצבי Closed, Opening, Ready, WaitingResponse, Fault ו-Reconnecting, כשאין קו ישיר מ-Fault חזרה ל-Ready — אחרי תקלה תמיד עוברים דרך Reconnecting ו-Opening לפני חזרה ל-Ready.
[*] --> Closed
Closed --> Opening: בקשת Open
Opening --> Ready: open הצליח + רצף האתחול הסתיים
Opening --> Fault: open נכשל / שגיאת הרשאה / timeout באתחול
Ready --> WaitingResponse: שליחת פקודה
WaitingResponse --> Ready: התקבל frame תשובה מתאים
WaitingResponse --> Fault: response timeout
Ready --> Fault: שגיאת I/O / זוהה ניתוק כבל
Fault --> Reconnecting: fail ל-pending request והתחלת backoff
Reconnecting --> Opening: backoff חלף
Reconnecting --> Closed: הגעה לתקרה / עצירה ידנית
Ready --> Closed: בקשת Close
איור 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.
flowchart TB
accTitle: צוברים ואז חותכים
accDescr: תרשים שמראה שההנחה שערך ה-Read המוחזר הוא frame אחד נשברת בקלות ב-receive חלקי, ולכן במקום זאת קודם צוברים ל-buffer ורק אז ה-parser חותך frames.
f1["Read מוחזר = frame אחד"] --> f2["נשבר בקלות ב-receive חלקי"]
f2 -.->|"במקום זאת"| f3["ה-receive נצבר ל-buffer קודם"]
f3 --> f4["ה-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 יציבה משמעותית יותר.
flowchart TB
accTitle: מטים את ה-send ל-single writer
accDescr: תרשים שמראה שמבנה שבו כפתור UI, טיימר ניטור, keepalive ו-reconnect כל אחד קורא ישירות ל-Write נוטה להישבר בגלל interleave של פקודות או שליחה נוספת בזמן המתנה, ושהטיה ל-single writer מייצבת.
g1["כפתור UI"] --> g4["כל אחד קורא ישירות ל-Write"]
g2["טיימר ניטור"] --> g4
g3["keepalive · reconnect"] --> g4
g4 --> g5["interleave · שליחה נוספת"]
g5 -.->|"במקום זאת"| g6["ריכוז ל-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, והרצה חוזרת של אתחול הציוד.
flowchart TB
accTitle: reconnect הוא לא רק קריאה חוזרת ל-Open
accDescr: תרשים שמראה שב-USB-serial הפורט נעלם וה-handle הישן הופך ל-invalid, ולכן reconnect כולל ביטול session, fail ל-pending request, עצירת reader ו-writer, reopen אחרי backoff, והרצה חוזרת של אתחול הציוד.
h1["ביטול session"] --> h2["fail ל-pending request"]
h2 --> h3["עצירת reader/writer"]
h3 --> h4["reopen אחרי backoff"]
h4 --> h5["הרצה חוזרת של אתחול הציוד"]
h1 -.-> h6["קריאה ל-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 נשמר לטווח ארוך.
flowchart TB
accTitle: שלוש מטרות של log ה-TX/RX
accDescr: תרשים שמראה שלוש מטרות — הפרדת שורת RX משורת PARSE, הצלבת TX ו-RX עם req, ושמירת מעבר ה-state בשורה אחת — ומבנה דו-שכבתי של raw log ב-ring buffer ו-summary log לטווח ארוך.
i1["הפרדת RX מ-PARSE"] --> i4["log שניתן לבודד בדיעבד"]
i2["הצלבה עם req"] --> i4
i3["מעבר state בשורה אחת"] --> i4
i4 -.-> i5["raw ב-ring buffer, summary לטווח ארוך"]
איור 10: הפרדה בין bytes שהגיעו ל-frames שנחתכו, מאפשרת מעקב הזזה בדיעבד.
5. Best practices
הכי יעיל זה להפריד אחריות.
reader: קורא רק רצף bytes מהפורטwriter: כותב רק בסדר מתוך outbound queueparser: חותך רק frame מרצף bytesprotocol: מטפל בהתאמת request-response וב-checksumapp 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 חזק לתפעול.
flowchart TB
accTitle: pipeline לפי אחריות
accDescr: תרשים שמראה ש-reader קורא bytes מהפורט, ה-parser חותך frames מה-buffer הצבור, protocol מטפל בהתאמה וב-checksum, app state מעדכן business state, והשליחה מכל מקום רק נכנסת ל-queue בעוד רק writer כותב בפועל.
p0["הפורט"] --> p1["reader: קורא בלבד"]
p1 --> p2["parser: חיתוך frames"]
p2 --> p3["protocol: התאמה ו-checksum"]
p3 --> p4["app state: עדכון business state"]
q1["כל מקום רק מכניס ל-queue"] --> q2["writer: כותב לפי סדר בלבד"]
q2 --> p0
איור 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.
flowchart TB
accTitle: LEN שהתקלקל בולע frame תקין
accDescr: תרשים שמראה שכשרעש או STX מזויף הופכים את LEN לערך גדול, ה-parser ממשיך להמתין ל-עדיין לא מספיק ובולע גם את ה-frame התקין הבא, וזה נראה בלי תגובה עד שנופל CRC, ולכן פתרון תום הזמן זורק STX אחד ועושה resync.
j1["רעש או STX מזויף מקלקל את LEN"] --> j2["parser ממתין ל'עדיין לא מספיק'"]
j2 --> j3["בולע גם frame תקין"]
j3 --> j4["נראה בלי תגובה עד נפילת CRC"]
j4 -.->|"פתרון"| j5["timeout: זורק 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, קורה זה:
- שולחים פקודה A. התשובה לא מגיעה בזמן שהוגדר, אז זה נחשב timeout
- שולחים את הפקודה הבאה B
- תשובת A שמגיעה באיחור מועברת לצד הקורא כתשובה של B
מבחינת הצד הקורא, נשלח B אבל התקבל ערך של A. פורמט הערך תקין, אז גם בדיקות עוברות — זו דרך השבירה הכי קשה למצוא. הסיבה שלא נמשך קו ישיר מ-Fault ל-Ready בתרשים מעברי ה-state בסעיף 3.6 היא בדיוק המסלול הזה. timeout הוא לא “כישלון פריט בודד” אלא החלטה ש”לא ניתן יותר לסמוך על החיבור הזה”, ומכיוון שלא ידוע מה נשאר ב-receive buffer, ההתאוששות ניתנת להבטחה רק על ידי סגירת הפורט ופתיחתו מחדש — בניית session מחדש.
אם אפשר לגעת בצד הפרוטוקול, הפתרון המהותי יותר הוא לצייד את ה-frame ב-request ID ולהצליב עם התשובה. כך, תשובה שמגיעה באיחור פשוט “נזרקת כי ה-ID לא מוכר”, ואין צורך לבנות את החיבור מחדש בכל timeout בודד.
sequenceDiagram
accTitle: שיוך שגוי של response שמגיע באיחור
accDescr: תרשים שמראה שבפרוטוקול בלי request ID, אחרי timeout של פקודה A ושליחת פקודה B, תשובת A שמגיעה באיחור מועברת לצד הקורא כתשובת B, ולכן אחרי timeout נופלים ל-Fault ובונים את ה-session מחדש.
participant C as הצד הקורא
participant W as worker
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: בלי 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).
flowchart TB
accTitle: גם בכישלון, תמיד עוצרים את ה-worker
accDescr: תרשים שמראה שכישלון SendAsync הוא שגרה ב-serial communication, ושיציאה ישירה משאירה SerialPort נפטר בלי לעצור worker, כך ש-reader ו-writer ממשיכים לגעת בזרם הסגור בלי שאיש רואה את החריגה, ולכן ב-finally מבטלים וממתינים.
k1["SendAsync נכשל (שגרה)"] --> k2["ב-finally: ביטול והמתנה"]
k2 --> k3["עוצרים worker לפני ההיפטרות"]
k1 -.-> k4["דילוג = ממשיכים לגעת בזרם סגור"]
k4 -.-> k5["באפליקציה ברקע, 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 חשוב הרבה יותר מפתיחת הפורט. רק ההפרדה הזו בתכנון מההתחלה מצמצמת משמעותית תקלות תקשורת מסוג “נשבר רק מדי פעם”.
flowchart TB
accTitle: פירוש ובקרה חשובים יותר מפתיחה
accDescr: תרשים שמראה שבאפליקציית serial, פתיחת הפורט אינה הקושי, אלא פירוש רצף ה-bytes ובקרת הזמן וה-state, ושתכנון נפרד מההתחלה מצמצם תקלות תקשורת מסוג נשבר רק מדי פעם.
m1["פתיחת הפורט"] -.-> m2["זה לא הקושי"]
m3["פירוש רצף ה-bytes"] --> m5["מתכננים בנפרד מההתחלה"]
m4["בקרת זמן ו-state"] --> m5
m5 --> m6["פחות תקלות 'נשבר רק מדי פעם'"]
איור 15: הקושי האמיתי הוא לא פתיחת הפורט, אלא פירוש ה-bytes ובקרת הזמן וה-state.
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
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
למה arguments נשברים — כללי command-line arguments ב-Windows
Windows מעביר ל-CreateProcess מחרוזת אחת שהמקבל מפצל. מכסה את כללי CommandLineToArgvW, CRT ו-.NET, ArgumentList, ובניה ב-C++.
WMI/CIM מ-C# ומ-PowerShell — מדריך מעשי למידע חומרה, ניטור process ושאילתות remote
WMI/CIM הוא הדרך הסטנדרטית לשלוף serial number של מחשב, לנטר דיסק פנוי ולזהות process שהתחיל. המאמר מכסה CIM cmdlets כמו Get-CimInstance,...
צ'קליסט לפני migration מ-.NET Framework ל-.NET
צ'קליסט מעשי לפני migration מ-.NET Framework ל-.NET: סוג פרויקט, טכנולוגיות לא נתמכות, תלויות NuGet, SDK-style, WPF/WinForms, CI/CD ותפעול.
איך קוראים ל-DLL של C# Native AOT מ-C/C++
איך מפרסמים ספריית מחלקות C# כ-DLL native עם Native AOT, וקוראים לנקודות כניסה מסוג UnmanagedCallersOnly מ-C/C++ — לפי מקום השימוש, דפוסי...
למה להכניס Generic Host ו-BackgroundService לאפליקציית desktop ב-.NET
בכלי Windows ובאפליקציות long-running, איך להשתמש ב-Generic Host וב-BackgroundService כדי לרכז הפעלה, עיבוד תקופתי, shutdown, לוג, הגדרות...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
באפליקציות Windows שכוללות serial communication, יותר יציב לתכנן גם את ה-receive, מעברי ה-state, reconnect והפרדה מה-UI.
חקירת תקלות ואיתור גורמים
מתאים לבידוד תקלות תקשורת: freeze מדי פעם, חוסר התאוששות רק אחרי unplug/replug של USB, או log שאי אפשר לשחזר ממנו סיבתיות.
ייעוץ טכני וסקירת תכנון
אם מגדירים לפני המימוש את גבולות הפרוטוקול, flow control, timeout ותכנון single writer, קל יותר לצמצם באגים שדורשים rollback גדול.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- האם קריאה ל-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 שנשברים רק מדי פעם.