כל הביטים גדולים כקטנים

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

בפרויקט שעבדתי עליו לאחרונה הייתי זקוק נואשות לערוץ דיבוג טוב, אבל המיקרו-בקר (שהלקוח בחר), דרישות המערכת ובעיקר לחץ זמן לא התירו שום אופציה פרט לפלט High/Low בסיסי. אמנם הצלחתי להפיק קצת תועלת אפילו מזה, אבל היה לי ברור שלהבא כדאי מאוד שיהיה לי כלי עבודה מוצלח יותר.

איך ייראה ערוץ פלט אופטימלי לדיבוג? הוא צריך להיות מסוגל להעביר מידע מפורט ונוח לעיבוד (כלומר, לכל הפחות בייטים שלמים), הוא חייב להיות סופר-חסכוני במשאבים (זיכרון, עיבוד, זמן וחומרה), ועליו להיות גם אוניברסלי ככל האפשר – קוד שאוכל להעתיק בין ארכיטקטורות ודגמי מיקרו-בקרים שונים עם מינימום שינויים, וצורת פלט אחידה שאוכל לקרוא בעזרת כלי חיצוני בלי קשר לזהות השולח.

מהדרישות האלה אפשר לגזור כמה מגבלות. ראשית, אסור להסתמך על מודול חומרה כגון UART או SPI, כי לפעמים צריך אותם לדברים אחרים ולפעמים אין אותם בכלל. המשמעות היא שערוץ הדיבוג יסתמך בהכרח על bit-banging, כלומר שינוי של פלט בפינים בשליטה בלעדית של תוכנה. שנית, השידור צריך להיות מהיר מאוד, כדי להבטיח מינימום הפרעה לפעילות הרגילה של הקוד, אבל גם המשמעות של "מהיר מאוד" שונה בין מיקרו-בקר שרץ ב-20MHz לכזה שרץ ב-1MHz או פחות. זאת אומרת, אין מצב שקצב השידור יהיה אחיד בכל התסריטים.

הפתרון הקלאסי לתנאים כאלה הוא פלט בשני קווים, אחד לביטים ואחד ל"אות שעון" שאומר מתי לקרוא כל ביט. זהו גם הפתרון הכי קל לתכנות, אלא שבמיקרו-בקרים עם 5 פיני קלט/פלט בלבד, למשל ATtiny85, אנחנו פשוט לא יכולים להרשות לעצמנו להקצות שני פינים (למען האמת, לפעמים גם אחד זה יותר מדי, אבל נגד זה באמת אין הרבה מה לעשות). אז נשארנו עם פין פלט יחיד, ועליו שידור בקצב שאינו ידוע מראש. במקרה כזה נהוג לשדר איזשהו אות סינכרון שאומר לצד המקבל מה המשך של כל ביט במידע שעומד להגיע.

נתחיל מקוד נאיבי לארדואינו. במקום פקודת digitalWrite הבזבזנית נכתוב ישירות לביט 5 של פורט B (פין 13 במספור של ארדואינו). לרוע המזל הארכיטקטורה לא מאפשרת לכתוב לביט בודד, אז כדי לא לשנות בטעות ביטים אחרים בפורט צריך לבצע פעולות לוגיות על בייטים. לדוגמה, כדי לכתוב 1 לביט 5 ב-PORTB נכתוב פקודה כזו:

PORTB |= 0x20; // OR with b00100000

וכדי לכתוב 0 לאותו ביט, פקודה כזו:

PORTB &= 0xDF; // AND with b11011111

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

void sendByte1(byte b) {

  byte n = 8;
  
  while (n--) {
  
    if (b & 1) {
      PORTB |= 0x20;
    } else {
        PORTB &= 0xDF;
      }

    b >>= 1;

  }

}

כדי שנוכל לראות טוב יותר מה קורה בפלט, נשלח בייט שהביט הראשון והאחרון בו הם 1, כגון 0xA5 (בבינארי 10100101). כך זה נראה בסקופ:

פלט ביטים מקוד מבוסס if
פלט ביטים מקוד מבוסס if

קודם כל אנחנו רואים תקלה פוטנציאלית: כיוון שהביט האחרון הוא 1, קו הנתונים נשאר High גם בסיום השידור (ועד לתחילת השידור הבא). למרות זאת קל להבחין בדפוס הביטים "010010" שבאמצע בייט המטרה. אבל תסתכלו טוב: משך הזמן של ביט שערכו 0, כ-0.5 מיקרו-שניות בהערכה גסה, קצר יותר מאשר משך ביט שערכו 1 (בסביבות 0.8 מיקרו-שניות)!

למה זה קורה? במקרה הנ"ל, בגלל ה-if שבודק את ערך הביט, נתיבי פקודות המכונה שמובילים לתנאי הראשון ולתנאי השני אינם זהים. הקומפיילר לא עושה את זה מתוך רוע לב; למעשה הוא פועל בדרך הכי יעילה שהוא יכול בהתחשב בארכיטקטורה, וזו לא אשמתו שמצאנו מקרה נדיר שבו יעילות דווקא מסבכת אותנו.

הסיבוך טמון בעובדה שאנחנו לא יכולים להיות בטוחים, בכל מיקרו-בקר ובכל קומפיילר, שהביט "1" יהיה ארוך יותר ב-60% מהביט "0". ייתכן שדווקא "0" יהיה ארוך יותר וייתכן שההפרש יהיה שונה, מה שפוגע בדרישת האוניברסליוּת. צריך להאחיד את משכי הזמן של הביטים. לשם כך יש שתי אפשרויות: להאריך את הביטים עד כדי כך שההבדל ביניהם יהפוך לזניח (פתרון גרוע בגלל דרישת המהירוּת), או למצוא פטנט שיגרום לשני הביטים להיות באותו אורך ממש. אפשר להאריך את הביט הקצר בעזרת פקודות NOP (קיצור של "No OPeration") בסיסיות באסמבלי:

  if (b & 1) {
    PORTB |= 0x20;
  } else {

      PORTB &= 0xDF;
      asm volatile ("NOP");
      asm volatile ("NOP"); 

    }
שיפור מסוים של המצב בעזרת שתי פקודות NOP
שיפור מסוים של המצב בעזרת שתי פקודות NOP

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

במיקרו-בקרים בארכיטקטורת ARM Cortex M3/4, למשל, יש טכניקה שנקראת bit-banding, בעזרתה אפשר לכתוב ערך לכתובת רגילה-לכאורה בזיכרון, וזה ישפיע על ביט אחד ויחיד ברגיסטר מסוים. זה בדיוק מה שנחוץ במקרה הנוכחי, אך לצערנו הטכניקה לא זמינה ברוב הארכיטקטורות האחרות. כדי לשנות ביט ברגיסטר ה-PORT נצטרך לכתוב ערך ל-PORT במלואו, ולעשות את זה בלי לפגוע בערכי הביטים האחרים שבו.

פתרון כללי נוח למצבים כאלה הוא טבלת חיפוש (Lookup Table, או בקיצור LUT). רגע לפני השידור אנחנו יכולים להכין ולאחסן בטבלה כזו את שני הערכים הרצויים לפורט (עם 0 ועם 1 בביט הפלט הרצוי, וכל שאר הביטים נשארים במצבם הנוכחי). בזמן השידור נשלוף מהטבלה כל פעם את הערך הנכון על סמך אינדקס – פעולה אשר, בסדרי גודל כאלה של LUT, לוקחת אותו זמן בדיוק בין אם האינדקס הוא 0 או 1. הנה הקוד המפושט והפלט של פונקציה כזו:

void sendByte2(byte b) {

  byte LUT[2];
  byte n = 8;

  LUT[0] = PORTB & 0xDF;
  LUT[1] = LUT[0] | 0x20;

  while (n--) {
    PORTB = LUT[b & 1];
    b >>= 1;
  }
  
}
פלט שמבוסס על טבלת Lookup
פלט שמבוסס על טבלת Lookup

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

אם ניכנס לדקויות, כדאי גם להגדיר את כל המשתנים הפנימיים של הפונקציה ששולחת את הבייטים כ-static, או כגלובליים. זאת מפני שבמקרים קיצוניים מסוימים של ניצול מלוא ה-RAM של המיקרו-בקר, עצם הקריאה לפונקציה עם המשתנים המקומיים עלולה לדרוס ערכים קיימים בזיכרון וליצור בעיות חדשות שלא היו מופיעות בלעדיה. כמו העיקרון המפורסם מתחום האתיקה הרפואית, כך גם בדיבוג: Primum non nocere, "ראשית, אל תזיק".

בפוסט הבא בנושא זה אדבר על אתגר נוסף ולא פשוט – הגדרת הפרוטוקול המלא ויצירת מערכת שמסוגלת לקרוא את המידע בכל מהירות שידור.

להרשמה
הודע לי על
5 תגובות
מהכי חדשה
מהכי ישנה לפי הצבעות
Inline Feedbacks
הראה את כל התגובות

עידו קודם כל באמת תרגיל תכנותי מגניב לאחרונה סיימתי את המשחק 7 billion humans גם שם היה בידיוק שלב אחד שמזכיר את הבעיה שיש פה, אחת המטרות בכל שלב זה לעשות אופטימיזציה של שורות קוד וזמן ריצה, כאשר כל פקודה עלולה לקחת זמן שונה והתוכנית רצה במקביל על כמה פועלים יחד וכל אחד עושה דבר שונה ולכולם יש את אותה תוכנית אז קודם כל ממליץ בחום על המשחק:) דבר נוסף שצריך לציין שהתוכנית לא תעבוד בעיבוד מקבילי במידה ועושים כתיבה לאחד הפינים האחרים בזמן ששליחת הנתונים לדיבאג החלה (בגלל שהפורט יחזיר את כל הפינים האחרים למצב הקודם שלהם) דבר שלישי… לקרוא עוד »

פתרון חביב 🙂
פתרון אחר שתוכל להשתמש בו הוא קידוד דיפרנציאלי, כלומר 0 == הערך הקודם נשמר, ו-1 == הערך הקודם מתהפך. וזה נראה בערך ככה:

אגב, מערכת התגובות מתמודדת רע עם HTML. הסימן >> ואחריו הסימן << חירפנו אותה, והיא לא מצליחה להציג משמאל לימין. יותר ממוזמן למחוק את הניסיונות הכושלים שלי להעלות את הקוד 🙂