המלכודות של יישום תקשורת טורית - עד לתכנון חיבור מחדש ויומן
· עודכן בתאריך: · Go Komura · תקשורת טורית, RS-232, C#, .NET, פיתוח Windows, חיבור ציוד
חיבור ציוד, מכשירי מדידה, PLC, קורא ברקוד, מתאם USB-טורי. תקשורת טורית נראית כטכנולוגיה ישנה, אבל היא עדיין נפוצה למדי בשטח של יישומי Windows.
מה שקצת מסוכן הוא שאפשר להתחיל תקשורת טורית עם פורט COM אחד ו-Read/Write אחד בלבד. בדיקת החיבור עוברת מיד, אבל כשיוצאים לפרודקשן, נוטים להופיע תסמינים כמו:
- מדי פעם הפקודה והתשובה לא מתואמות
- קופא פעם אחת ביום
- לא מתאושש רק אחרי ניתוק/חיבור USB
- ה-UI לפעמים נעצר
- ביומן נשאר רק “Timeout”
הקושי האמיתי ביישום תקשורת טורית הוא לא ה-API של השליחה והקבלה עצמו, אלא הגבול, ה-timeout, מעבר המצב, החיבור מחדש והתצפיתיות.
flowchart TB
accTitle: בדיקת חיבור עוברת, אך נשבר בפרודקשן
accDescr: תרשים המראה שתקשורת טורית מתחילה בקלות עם פורט COM אחד ו-Read/Write אחד ובדיקת החיבור עוברת מיד, אך בפרודקשן מופיעים תסמינים כמו תשובה לא מתואמת או קפיאה, והקושי האמיתי הוא גבול, timeout, מעבר מצב, חיבור מחדש ותצפיתיות.
a1["בדיקת חיבור עוברת מיד"] --> a2["נשבר 'מדי פעם' בפרודקשן"]
a2 --> a3["תשובה לא מתואמת · קפיאה · אין התאוששות"]
a3 --> a4["הקושי אינו ה-API עצמו"]
a4 -.-> a5["גבול · timeout · מעבר מצב · חיבור מחדש · תצפיתיות"]
איור 1: מעבר לבדיקת החיבור, זהו הקושי האמיתי של יישום תקשורת טורית.
קהל היעד וההנחות של המאמר
| פריט | תוכן |
|---|---|
| קהל היעד | מי שבונה יישום Windows שמתחבר בתקשורת טורית לציוד או למכשיר מדידה. מיועד למי שבדיקת החיבור עוברת אך רוצה להפחית מצבים ש”מדי פעם” נשברים בפרודקשן |
| ידע מונח מראש | יכולת לכתוב יישום ב-C#. אין הנחת ניסיון בתקשורת טורית עצמה |
| סביבה מונחת מראש | כתוב על בסיס System.IO.Ports.SerialPort של .NET, אבל דרך החשיבה על גבול, timeout ומעבר מצב אינה תלוית שפה |
| מה לא נכלל | חיווט חשמלי, מפרט פרוטוקול של ציוד ספציפי |
מונחים שהמאמר משתמש בהם
| מונח | משמעות בשורה אחת |
|---|---|
| PLC | Programmable Logic Controller. בקר תעשייתי לבקרת ציוד ייצור |
| RS-232/RS-485 | תקנים חשמליים של תקשורת טורית. RS-232 הוא אחד-לאחד, RS-485 מאפשר לתלות כמה יחידות על אותו קו. ב-RS-485 חייבים לקבוע מי שולח ומתי, אחרת יש התנגשות |
| 8N1 | קיצור להגדרות פורט. שילוב של 8 ביטי נתונים, ללא זוגיות (None), וסיבית עצירה 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.
flowchart LR
accTitle: מפת הידע של המלכודות ביישום תקשורת טורית
accDescr: תרשים המראה שתקשורת טורית היא byte stream ללא גבול הודעה כנקודת מוצא, איך מנתח המסגרות ותכנון single writer מטפלים בגבול ובסדר השליחה, ואיך פיצול סוגי timeout וקיום או היעדר request ID מובילים לשיוך שגוי של תשובה וליצירת session מחדש.
serial_communication["תקשורת טורית"]
frame_parser["מנתח מסגרות (צבירה ואז חיתוך)"]
single_writer["single writer (נקודת כתיבה יחידה)"]
byte_stream["byte stream (רצף בייטים מסודר)"]
frame_boundary["גבול המסגרת"]
datareceived_event["אירוע SerialPort.DataReceived"]
crc_check["אימות מסגרת באמצעות CRC"]
inter_byte_timeout["inter-byte timeout"]
timeout_taxonomy["פיצול סוגי הפסקות הזמן"]
response_timeout["response timeout"]
reconnect_backoff["חיבור מחדש עם backoff"]
response_mismatch_risk["סיכון לשיוך שגוי של תשובה"]
request_id["מזהה בקשה"]
session_regeneration["יצירת session מחדש (תכנון התחברות חוזרת)"]
flow_control_lines["בקרת זרימה ו-DTR/RTS"]
hex_dump_logging["יומן שידור וקליטה הכולל hex dump"]
serial_communication -->|"משתמש ב"| byte_stream
byte_stream -->|"מחייב"| frame_boundary
frame_parser -->|"מממש את"| frame_boundary
datareceived_event -->|"שימוש לא מומלץ ל"| frame_boundary
frame_parser -->|"מענה מומלץ ל"| byte_stream
single_writer -->|"מענה מומלץ ל"| serial_communication
frame_parser -.->|"משתמש ב"| crc_check
frame_parser -.->|"משתמש ב"| inter_byte_timeout
timeout_taxonomy -->|"משתמש ב"| inter_byte_timeout
timeout_taxonomy -->|"משתמש ב"| response_timeout
timeout_taxonomy -->|"משתמש ב"| reconnect_backoff
response_timeout -.->|"עלול לגרום ל"| response_mismatch_risk
request_id -->|"מונע"| response_mismatch_risk
session_regeneration -->|"מענה מומלץ ל"| response_mismatch_risk
session_regeneration -->|"משתמש ב"| reconnect_backoff
serial_communication -.->|"מחייב"| flow_control_lines
hex_dump_logging -->|"מענה מומלץ ל"| serial_communication
single_writer -.->|"מחייב"| response_timeout
בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 18, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle
2. תקשורת טורית היא לא “הודעה” אלא “byte stream מסודר”
מנקודת המבט של האפליקציה, תקשורת טורית נראית כ”שולחים פקודה אחת ומקבלים תשובה אחת”. אבל בשכבה שמתחת, בפועל זורם רק רצף בייטים מסודר.
גם תוכן שנשלח ב-Write אחד יכול להיראות בצד השני כך:
- מגיע ב-
Readאחד - מגיע בשני חלקים
- מגיע מחובר לנתונים אחרים
אם מפספסים את ההנחה הזו, האפליקציה מתחילה להניח ש’ה-Read הזה בטח מקביל לתשובה הזו’. ההנחה הזו נוטה להיות המוקש הראשון ביישום תקשורת טורית.
flowchart TB
accTitle: שלוש דרכים שבהן Write אחד יכול להגיע
accDescr: תרשים המראה שתוכן שנשלח ב-Write אחד עלול להגיע בצד השני ב-Read אחד, בשני חלקים, או מחובר לנתונים אחרים, ולכן אין ערובה שה-Read הנוכחי מקביל לתשובה הנוכחית.
b0["Write אחד"] --> b1["מגיע ב-Read אחד"]
b0 --> b2["מגיע בשני חלקים"]
b0 --> b3["מגיע מחובר לנתונים אחרים"]
b2 -.-> b4["'ה-Read הזה = התשובה הזו' לא מובטח"]
איור 2: איך Write אחד ייראה בצד השני - לא ידוע עד שהוא מגיע בפועל.
| הנחה נפוצה | המציאות |
|---|---|
| Read(16) מחזיר 16 בייטים בדיוק | תלוי במצב ההגעה וב-timeout, לפעמים מתקבל רק חלק |
| DataReceived = הגעת הודעה אחת | האירוע לא מובטח לכל בייט, וגם לא ב-thread של ה-UI |
| Write חזר = הצד השני סיים לעבד | ברוב המקרים, זה קרוב יותר לכך שצד השליחה הצליח לצבור לחוצץ |
| רשימת COM = האמת של מה שמחובר עכשיו | סדר המיפוי לא קבוע, ולפעמים התוצאה כבר לא עדכנית (stale) |
בגלל זה, בתקשורת טורית צריך להגדיר בעצמכם את גבול ההודעה כפרוטוקול. מסגרת באורך קבוע, מבוססת תו הפרדה, אורך+payload+checksum - הצורה יכולה להיות כל דבר, אבל אם נכנסים למימוש כשזה מעורפל, כמעט בטוח שזה יהיה קשה בהמשך.
flowchart TB
accTitle: מגדירים בעצמכם את הגבול
accDescr: תרשים המראה שבתקשורת טורית מגדירים בעצמכם את גבול ההודעה כפרוטוקול - מסגרת באורך קבוע, מבוססת תו הפרדה, או אורך+payload+checksum - אך אם זה נשאר מעורפל בזמן המימוש, זה הופך לקשה.
c0["מגדירים גבול הודעה בעצמכם"] --> c1["מסגרת באורך קבוע"]
c0 --> c2["מבוססת תו הפרדה"]
c0 --> c3["אורך+payload+checksum"]
c0 -.-> c4["אם נשאר מעורפל - קשה"]
איור 3: גבול ההודעה לא נוצר בשכבה התחתונה, אז קובעים אותו כפרוטוקול מראש.
3. מה צריך להחליט קודם
לפני בניית יישום תקשורת טורית, כדאי להחליט מראש לפחות על הדברים המפורטים כאן.
3.1 גבול המסגרת
קובעים איזה רצף בייטים נחשב להודעה אחת. באורך קבוע? מופרד בשורה חדשה? עם אורך? יש checksum/CRC? אם זה נשאר מעורפל, צד הקליטה לא יכול להחליט אם זה “עדיין לא מספיק” או “שבור”.
3.2 טקסט, בינארי, או שילוב
קובעים מראש אם זה פרוטוקול שורות ASCII/UTF-8, בינארי טהור, או שילוב של שניהם. במיוחד שילוב כמו “חלק הפקודה מחרוזת, ה-payload בינארי, ורק בסוף שורה חדשה” - אם לא מפרטים היכן מסתיים ה-decode ומאיפה מתייחסים לבייטים גולמיים, הגבול נשבר מהר.
3.3 המשמעות של ה-timeout
בטוח יותר לחשוב על timeout לא כאחד, אלא לפצל לפי המשמעות.
- open timeout: עד לפתיחת הפורט
- inter-byte timeout: הזמן שבו בייט לא מגיע באמצע מסגרת
- response timeout: מהוצאת הפקודה עד השלמת התשובה
- reconnect backoff: מרווח ההמתנה בחיבור מחדש
יציב יותר להחזיק ב-timeout לא כ”ביטוח למקרה של איטיות”, אלא כחוק שמקדם מעבר מצב.
flowchart TB
accTitle: מפצלים timeout לפי משמעות
accDescr: תרשים המראה ש-timeout אחד לא מספיק, ומפצלים ל-open עד הפתיחה, inter-byte לשקט באמצע מסגרת, response להשלמת התשובה, ו-reconnect backoff, וכולם משמשים כחוק שמקדם מעבר מצב.
d0["timeout אחד לא מספיק"] --> d1["open: עד הפתיחה"]
d0 --> d2["inter-byte: שקט"]
d0 --> d3["response: השלמת תשובה"]
d0 -.-> d4["reconnect backoff"]
d1 --> d5["חוק שמקדם מעבר מצב"]
d2 --> d5
d3 --> d5
איור 4: פיצול ארבעה סוגי timeout הופך אותם לחוקים שמקדמים מעבר מצב.
3.4 בקרת זרימה ומצב הקווים
ההגדרות שכדאי לקבוע במפורש:
-
BaudRate -
DataBits -
Parity -
StopBits -
Handshake -
DTR/RTS
אם מסתפקים ב”בערך 8N1 מספיק”, זה נעצר לגמרי אצל חלק מהציוד.
3.5 הפרדת אחריות
מפרידים מי אחראי על מה.
- מי קורא
- מי כותב
- מי מנתח (parse)
- מי מעדכן את המצב העסקי
תקשורת טורית נשברת ביתר קלות ככל שמערבבים בין ה-UI לתקשורת.
flowchart TB
accTitle: הפרדת אחריות בלי לערבב UI ותקשורת
accDescr: תרשים המראה שמפרידים בין מי קורא, מי כותב, מי מנתח, ומי מעדכן מצב עסקי, ושתקשורת טורית נשברת ביתר קלות ככל שמערבבים בין ה-UI לתקשורת.
e0["הפרדת אחריות"] --> e1["תפקיד קריאה וכתיבה"]
e0 --> e2["תפקיד ניתוח(parse)"]
e0 --> e3["תפקיד עדכון מצב עסקי"]
e0 -.-> e4["ערבוב UI ותקשורת - נשבר ביתר קלות"]
איור 5: מפרידים בין קורא, כותב, מנתח ומעדכן, ולא מערבבים UI עם תקשורת.
3.6 מעבר המצב של התחלה, עצירה וחיבור מחדש
לכל הפחות, כדאי לכלול בתכנון מצבים כמו Closed, Opening, Ready, WaitingResponse, Fault, Reconnecting. מיד אחרי ניתוק/חיבור, הצד השני אולי עדיין באתחול, ולפעמים אסור לגרור את ה-pending request הקודם.
stateDiagram-v2
accTitle: מעבר המצב של 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: התקבלה מסגרת תשובה מתאימה
WaitingResponse --> Fault: response timeout
Ready --> Fault: שגיאת I/O / זוהה ניתוק כבל
Fault --> Reconnecting: מפיל pending request ומתחיל backoff
Reconnecting --> Opening: backoff חלף
Reconnecting --> Closed: הגעה לתקרה / עצירה ידנית
Ready --> Closed: בקשת Close
איור 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.
flowchart TB
accTitle: צוברים ואז חותכים
accDescr: תרשים המראה שההנחה שערך ה-Read המוחזר הוא מסגרת אחת נשברת בקלות בקליטה חלקית, ולכן במקום זאת קודם צוברים לחוצץ ורק אז ה-parser חותך מסגרות.
f1["Read מוחזר = מסגרת אחת"] --> f2["נשבר בקלות בקליטה חלקית"]
f2 -.->|"במקום זאת"| f3["הקליטה נצברת לחוצץ קודם"]
f3 --> f4["ה-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 יציבה משמעותית יותר.
flowchart TB
accTitle: מטים את השליחה ל-single writer
accDescr: תרשים המראה שתצורה שבה כפתור UI, טיימר ניטור, keepalive וחיבור מחדש כל אחד קורא ישירות ל-Write נוטה להישבר עקב ניתוק פקודות או שליחה נוספת בזמן המתנה, ושהטיה ל-single writer מייצבת.
g1["כפתור UI"] --> g4["כל אחד קורא ישירות ל-Write"]
g2["טיימר ניטור"] --> g4
g3["keepalive · חיבור מחדש"] --> g4
g4 --> g5["ניתוק פקודות · שליחה נוספת"]
g5 -.->|"במקום זאת"| g6["ריכוז ל-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, והרצה חוזרת של אתחול הציוד.
flowchart TB
accTitle: חיבור מחדש הוא לא רק הרצת Open שוב
accDescr: תרשים המראה שב-USB-טורי הפורט נעלם וה-handle הישן נפסל, ולכן חיבור מחדש כולל ביטול session, כשל pending request, עצירת reader ו-writer, פתיחה מחדש אחרי backoff, והרצה חוזרת של אתחול הציוד.
h1["ביטול session"] --> h2["כשל ה-pending request"]
h2 --> h3["עצירת reader/writer"]
h3 --> h4["פתיחה מחדש אחרי backoff"]
h4 --> h5["הרצה חוזרת של אתחול הציוד"]
h1 -.-> h6["הרצת 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 תופס מקום, ריאלי לבנות תצורה דו-שכבתית: היומן הגולמי בחוצץ טבעתי בכמות מוגבלת, ויומן התקציר נשמר לטווח ארוך.
flowchart TB
accTitle: שלוש מטרות יומן השליחה והקליטה
accDescr: תרשים המראה שלוש מטרות - הפרדת שורת RX משורת PARSE, הצלבת שליחה וקליטה עם req, ושמירת מעבר המצב בשורה אחת - ותצורה דו-שכבתית של יומן גולמי בחוצץ טבעתי ויומן תקציר לטווח ארוך.
i1["הפרדת RX מ-PARSE"] --> i4["יומן שניתן לבודד בדיעבד"]
i2["הצלבה עם req"] --> i4
i3["מעבר מצב בשורה אחת"] --> i4
i4 -.-> i5["גולמי בחוצץ טבעתי, תקציר לטווח ארוך"]
איור 10: הפרדה בין בייטים שהגיעו למסגרות שנחתכו, מאפשרת מעקב הזזה בדיעבד.
5. שיטות עבודה מומלצות
הכי יעיל זה להפריד אחריות.
reader: קורא רק רצף בייטים מהפורטwriter: כותב רק בסדר מתוך outbound queueparser: חותך רק frame מרצף בייטיםprotocol: מטפל בהתאמת request-response וב-checksumapp 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 חזק לתפעול.
flowchart TB
accTitle: צינור עיבוד לפי אחריות
accDescr: תרשים המראה ש-reader קורא בייטים מהפורט, ה-parser חותך מסגרות מהחוצץ הצבור, protocol מטפל בהתאמה וב-checksum, app state מעדכן מצב עסקי, והשליחה מכל מקום רק נכנסת לתור בעוד רק writer כותב בפועל.
p0["הפורט"] --> p1["reader: קורא בלבד"]
p1 --> p2["parser: חיתוך מסגרות"]
p2 --> p3["protocol: התאמה ו-checksum"]
p3 --> p4["app state: עדכון מצב עסקי"]
q1["כל מקום רק מכניס לתור"] --> q2["writer: כותב לפי סדר בלבד"]
q2 --> p0
איור 11: צינור העיבוד של הקליטה והשליחה. כל תפקיד עושה עבודה אחת בלבד.
מכאן, נשים קוד שלד רק לשני המקומות הכי יעילים. בהנחת .NET 8/C# 12, עם הפניה לחבילת System.IO.Ports.
5.1 קליטה: צוברים ואז חותכים
כדוגמה, נניח מסגרת של STX(0x02), LEN(1 byte), payload(LEN byte), CRC16(2 byte, little-endian).
הצורה יכולה להיות כל דבר, אבל הנקודה המרכזית היא לחתוך מסגרת לפי ההגדרה הזו, ולא לפי יחידת ה-Read המוחזרת.
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;
public static class Crc16Modbus
{
// CRC-16/MODBUS: ערך התחלתי 0xFFFF, פולינום 0xA001, הזזה ימינה
public static ushort Compute(ReadOnlySpan<byte> data)
{
ushort crc = 0xFFFF;
foreach (var b in data)
{
crc ^= b;
for (var i = 0; i < 8; i++)
{
crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
}
}
return crc;
}
}
public static class Frame
{
public const byte Stx = 0x02;
public const int HeaderLength = 2; // STX + LEN
public const int CrcLength = 2;
public static byte[] Build(ReadOnlySpan<byte> payload)
{
// LEN הוא 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.
flowchart TB
accTitle: LEN שהתקלקל בולע מסגרת תקינה
accDescr: תרשים המראה שכשרעש או STX מזויף הופכים את LEN לערך גדול, ה-parser ממשיך להמתין ל-עדיין לא מספיק ובולע גם את המסגרת התקינה הבאה, וזה נראה חסר תגובה עד שנופל CRC, ולכן פתרון תום הזמן זורק STX אחד ומסנכרן מחדש.
j1["רעש או STX מזויף מקלקל את LEN"] --> j2["parser ממתין ל'עדיין לא מספיק'"]
j2 --> j3["בולע גם מסגרת תקינה"]
j3 --> j4["נראה חסר תגובה עד נפילת CRC"]
j4 -.->|"פתרון"| j5["תום זמן: זורק 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, קורה זה:
- שולחים פקודה A. התשובה לא מגיעה בזמן שהוגדר, אז זה נחשב timeout
- שולחים את הפקודה הבאה B
- תשובת A שמגיעה באיחור מועברת לצד הקורא כתשובה של B
מבחינת הצד הקורא, נשלח B אבל התקבל ערך של A. פורמט הערך תקין, אז גם בדיקות עוברות - זו דרך השבירה הכי קשה למצוא. הסיבה שלא נמשך קו ישיר מ-Fault ל-Ready בתרשים מעבר המצב בסעיף 3.6 היא בדיוק המסלול הזה. timeout הוא לא “כישלון פריט בודד” אלא החלטה ש”לא ניתן יותר לסמוך על החיבור הזה”, ומכיוון שלא ידוע מה נשאר בחוצץ הקליטה, ההתאוששות ניתנת להבטחה רק על ידי סגירת הפורט ופתיחתו מחדש - יצירת session מחדש.
אם אפשר לגעת בצד הפרוטוקול, הפתרון המהותי יותר הוא לצייד את המסגרת ב-request ID ולהצליב עם התשובה. כך, תשובה שמגיעה באיחור פשוט “נזרקת כי ה-ID לא מוכר”, ואין צורך לבנות את החיבור מחדש בכל timeout בודד.
sequenceDiagram
accTitle: שיוך שגוי של תשובה שמגיעה באיחור
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 במקום אחד, ומודפסות ליומן בזמן ה-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).
flowchart TB
accTitle: גם בכישלון, תמיד עוצרים את ה-worker
accDescr: תרשים המראה שכישלון SendAsync הוא שגרה בתקשורת טורית, ושיציאה ישירה משאירה SerialPort נפטר בלי לעצור worker, כך ש-reader ו-writer ממשיכים לגעת בזרם הסגור בלי שאיש רואה את החריגה, ולכן ב-finally מבטלים וממתינים.
k1["SendAsync נכשל(שגרה)"] --> k2["ב-finally: ביטול והמתנה"]
k2 --> k3["עוצרים worker לפני ההיפטרות"]
k1 -.-> k4["דילוג = ממשיכים לגעת בזרם סגור"]
k4 -.-> k5["ביישום ברקע, 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 מקל מאוד על חקירה בדיעבד
כלומר, ביישום תקשורת טורית, איך מפרשים את רצף הבייטים ואיך שולטים בזמן ובמצב חשוב הרבה יותר מפתיחת הפורט. רק ההפרדה הזו בתכנון מההתחלה מפחיתה משמעותית תקלות תקשורת מסוג “נשבר רק לפעמים”.
flowchart TB
accTitle: פירוש ובקרה חשובים יותר מפתיחה
accDescr: תרשים המראה שביישום תקשורת טורית, פתיחת הפורט אינה הקושי, אלא פירוש רצף הבייטים ובקרת הזמן והמצב, ושתכנון נפרד מההתחלה מפחית תקלות תקשורת מסוג נשבר רק לפעמים.
m1["פתיחת הפורט"] -.-> m2["זה לא הקושי"]
m3["פירוש רצף הבייטים"] --> m5["מתכננים בנפרד מההתחלה"]
m4["בקרת זמן ומצב"] --> m5
m5 --> m6["פחות תקלות 'נשבר רק לפעמים'"]
איור 15: הקושי האמיתי הוא לא פתיחת הפורט, אלא פירוש הבייטים ובקרת הזמן והמצב.
מקורות
- 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
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
איך בוחרים בין WinForms, WPF ו-WinUI - טבלת החלטה מהשטח
המאמר מסדר את הבחירה בין WinForms, WPF ו-WinUI מנקודות המבט של פיתוח חדש, נכסים קיימים, הפצה, ביטוי ה-UI ומבנה הצוות.
המלכודות של הזיכרון המשותף ושיטות העבודה המומלצות בפועל
המאמר מסכם את המלכודות בשימוש בזיכרון משותף בעבודה בפועל, ואת התכנון שמוריד את שיעור התקלות - כולל סנכרון, נראות (visibility), אורך חיים,...
תכנון שמירת יומנים ו-dump בקריסת יישום Windows
המאמר מסדר איך לשלב יומן רגיל, סמן קריסה סופי, WER LocalDumps ותהליך ניטור, כדי שגם כשיישום Windows קורס מחריגה בלתי צפויה או מבאג בתוכני...
עיצוב UX ליישומי Windows - סדרי עדיפות לפי סביבת שימוש
המאמר מסדר מה צריך להעדיף בעיצוב UX ליישומי Windows, בטבלת החלטה לפי ToC/ToB, אמצעי קלט, סביבת שימוש ועלות טעות.
מבוא לפרופיל המשתמש ב-Windows - AppData ו-NTUSER.DAT
המאמר מסדר את יסודות פרופיל המשתמש ב-Windows, החלוקה של AppData, מקומי/נודד/Mandatory/Temporary, Folder Redirection, FSLogix, ואיך בודק...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
ביישומי Windows שכוללים תקשורת טורית, יציב יותר לתכנן גם את עיבוד הקליטה, מעברי המצב, חיבור מחדש והפרדת ה-UI.
חקירת תקלות ואיתור גורמים
מתאים היטב לבידוד תקלות תקשורת כמו קפיאה מדי פעם, אי-התאוששות רק אחרי ניתוק/חיבור USB, או קושי לעקוב אחר הסיבתיות מהיומן.
ייעוץ טכני וסקירת תכנון
סידור גבולות הפרוטוקול, בקרת הזרימה, ה-timeout ותכנון ה-single writer לפני המימוש מקל על הפחתת תקלות שדורשות חזרה גדולה לאחור.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- האם קריאה ל-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, והרצה חוזרת של רצף אתחול הציוד - ניתן להפחית באגי חיבור מחדש שנשברים רק לפעמים.