אז היום החלטתי להשקיע בזה כמה שעות כדי לראות כמה זה יכול להיות קשה לייצר boot כפול בכרטיס ארדואינו.
הסבר מה זה אומר boot כפול (dual boot):
יש הרבה דברים שחשובים במימוש מערכת בית חכם: תקשורת, אבטחה, זמן תגובה, חיסכון בחשמל וכו'. הכל חשוב כמובן, אבל מבחינתי יש משהו הרבה יותר חשוב כשלב ראשון: יכולת לעדכן את התוכנה מרחוק!
מניסיון, פרוייקטים כאלה לא נולדים מושלמים ברגע, תמיד יש עדכונים, מתחילים ממשהו קטן ופשוט ומשדרגים עם הזמן. כשאיזה שהוא כרטיס יושב במקום לא נגיש (בתוך הארגז של תריס חשמלי כדי לשלוט על התריסים, במרפסת שירות מאחורי דוד חשמלי כדי למדוד את הטמפרטורה, או איפה שהוא בתוך הקיר מאחורי מפסקי חשמל), וצריך לעדכן תוכנה... זה יכול להיות די מסובך...
עדכון תוכנה מרחוק זה לא משהו חדש. יש ספריה ArduinoOTA שמאפשרת לצרוב עדכון לכרטיס שיש לו כתובת IP ברשת, או מדריכים איך לעדכן את התוכנה לכרטיסי ESP32 דרך WiFi. כל אלה הם צעד גדול לכיוון הרצוי, רק שבתכנון שלי הרכיבים מתקשרים דרך רשת RS485 ולכל נקודת קצה אין כתובת ואין יכולת WiFi...
אז מה עושים? מממשים מאפס!
אחת הדרכים היא לחלק את זכרון ה-FLASH של הכרטיס כך שאפשר יהיה להחזיק בו 2 גרסאות של התוכנה, אחת לריצה הנוכחית ואחת לגרסה חדשה שהתוכנה הנוכחית תצרוב וב-reset הבא הכרטיס יעלה עם הגרסה החדשה. וזה בדיוק מה שרציתי לבדוק בכמה שעות שהקצתי לעצמי: לראות איך מחלקים את הזכרון של ארדואינו לאיזורים שונים ואיך אני גורם לפונקציות שונות לתפוס מקום באיזורים החדשים שהקצאתי.
למשימה נבחר כרטיס תואם ארדואינו של חברת SeeedStudio שתכננתי להשתמש בו: Arduino XIAO
XIAO הוא כרטיס קטן (קצת יותר גדול מציפורן עגודל) וזול יחסית, עם יכולות יפות, המבוסס על SAMD21 ועם 256K של זכרון Flash. כמות זכרון יפה למדי לתוכנית פשוטה (בתקווה) שתדרש לנקודת קצה של בית חכם.
כדי לפשט את העניינים, אני לא רוצה לגעת ב-bootloader המקורי שמאפשר לצרוב את הכרטיס דרך ה-USB, אלא שהכרטיס יעלה כרגיל, יריץ את פונקציית ()setup, יגיע לפונקציית ()loop ושם יבדוק משתנה שנמצא באיזור קבוע ב-Flash, אם הוא 0, יריץ פונקציה שנמצאת באיזור זכרון של גרסת "0", ואם הוא "1" יריץ את הפונקציה שנמצאת באיזור זכרון של גרסת "1".
אני בונה על זה שבספרית ה-ArduinoOTA יש כבר קוד שיודע לצרוב את ה-Flash תוך כדי ריצה. כנראה שהצריבה מתבצעת בחתיכות (Blocks) בגודל כלשהו, אז 32K זכרון RAM שיש ל-XIAO יכולים להיות מאוד שימושיים כדי לקבל block מידע, לצרוב למקום הנכון, לקבל עוד block ושוב לצרוב עד שכל התוכנה תצרב. אחרי צריבה אפשר יהיה לבדוק שכל מה שנצרב נראה תקין ע"י בדיקת CRC או HASH על המידע. שלב הבא הוא לשנות את המשתנה שנמצא במקום קבוע מ-0 ל-1 או מ-1 ל-0 כדי לגרום לעליית גרסה שנצרבה, ולעשות reset לכרטיס.
אז בעצם צריך להוסיף 3 איזורי זכרון.
איך עושים את זה?
אזהרה: הנושא הזה מיועד למתקדמים...
בתהליך הקומפילציה של הקוד שלכם נוצרים הרבה קבצים שלרוב לא מעניינים אף אחד, אבל במקרים "לא סטנדרטיים" כמו שלי הם די נחוצים. אחד הקבצים שחיפשתי הוא קובץ MAP, בו מפורטים כתובות פיזיות של כל פונקציה וכל משתנה שאתם מוסיפים לקוד. קבצים נוספים שרציתי לראות הם קבצי assembler, שזו שפה הקרובה יותר למיקרובקר מאשר לשפה אנושית (שפת C מבחינתי היא שפה בה אני מדבר בצורה שוטפת).
לא מצאתי את הקבצים, חיפוש קצר בגוגל נתן לי את הפתרון, צריך להוסיף את השורה הזו לקובץ platformio.ini (אני משתמש ב-PlatformIO בתוך VSCode ב-Linux).
- קוד: בחר הכל
build_flags = -Wl,-Map,output.map, --save-temps
קומפילציה נוספת ועכשיו יש לי את הקבצים output.map והרבה קבצים עם סיומת "s." (הרבה יותר ממה שציפיתי, מסתבר שיש הרבה מאוד דברים שמתקמפלים ברקע). לא אצרף כאן את הקבצים כי הם גדולים ויש הרבה...
מה שמצאתי בקובץ ה-MAP זה פירוט של איזורי זכרון:
- קוד: בחר הכל
Memory Configuration
Name Origin Length Attributes
FLASH 0x0000000000002000 0x000000000003E000 xr
RAM 0x0000000020000000 0x0000000000008000 xrw
*default* 0x0000000000000000 0xffffffffffffffff
אפשר לראות שאיזור ה-FLASH מתחיל בכתובת 0x2000 והגודל שלו הוא 248KByte, שזה 8K פחות ממה שרשום במפרט, בהמשך נראה למה.
עוד כמה חיפושים ברשת ומצאתי את הקובץ המגדיר את איזורי הזכרון ומה לשים בכל איזור (קוד, משתנים, מחרוזות קבועות וכו'). אלה קבצים עם סיומת "ld.". מצאתי כאלה בספריות ההתקנה של ה-PlatformIO, העתקתי את הקובץ שתואם לפלטפורמה שמתאימה לכרטיס (seeed_xiao) ושמרתי אותו בספריית הפרוייקט תחת שם memory_map.ld. והוספתי ל-platformio.ini עוד שורה:
- קוד: בחר הכל
board_build.ldscript = memory_map.ld
דפדוף קצר ב-memory_map.ld מביא אותי לטבלת הגדרת הזכרון:
- קוד: בחר הכל
MEMORY
{
FLASH (rx) : ORIGIN = 0x00000000+0x2000, LENGTH = 0x00040000-0x2000 /* First 8KB used by bootloader */
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 0x00008000
}
והנה ה-8K של הזכרון שהיו חסרים, אפילו יש הערה שאומרת שהם שמורים לטובת ה-bootloader.
מעולה... מצאתי את מה שחיפשתי...
בשביל הבדיקה אוסיף 2 איזורי זכרון נוספים, כל אחד של 16K ואנסה להכניס לתוכם שתי פונקציות. זה לא מכסה את כל המימוש שאני צריך, אבל זה 80-90% ממה שאני צריך וזו רק בדיקה. צריבה של הקוד, מיתוג בין הגרסאות וכו', זה שלב הבא שלא יהיה לי זמן לבדוק עכשיו, אבל כמו שרשמתי, אני סומך על הקוד של ספריית ה-ArduinoOTA שיודעת לצרוב את ה-Flash תוך כדי ריצה.
אז הוספתי 2 שורות להגדרת הזכרון בקובץ memory_map.ld:
- קוד: בחר הכל
MEMORY
{
FLASH (rx) : ORIGIN = 0x00000000+0x2000, LENGTH = 0x00040000-0x2000-0x4000-0x4000 /* First 8KB used by bootloader, last 2x16k for dual boot */
FUNC1 (rx) : ORIGIN = 0x00040000-0x8000, LENGTH = 0x4000 /* 16k for func1 */
FUNC2 (rx) : ORIGIN = 0x00040000-0x4000, LENGTH = 0x4000 /* 16k for func2 */
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 0x00008000
}
2 האיזורים החדשים
FUNC1
ו-FUNC2
, כל אחד 16K והקטנתי את גודל איזור ה-FLASH
בהתאם.הגדרתי גם 2 פונקציות
()testFunc1
ו-()testFunc2
עם קצת תוכן של הבהוב הלד על הכרטיס בקצב שונה (כדי שהאופטימיזציה של הקומפיילר לא תעיף אותן החוצה).מהקריאה באינטרנט ראיתי שיש אפשרות להוסיף attribute להגדרה של פונקציה שיציין לאיזה איזור זכרון היא תלך, אבל מצאתי גם איך לשייך כל מה שיש בקובץ קוד כלשהו לאיזור מסויים, שזו האפשרות המתאימה יותר לדעתי. לכן שתי הפונקציות שהגדרתי הן בקבצים שונים: test1.cpp ו- test2.cpp.
בקובץ memory_map.ld הוספתי הגדרה נוספת שמגדירה את איזור הזכרון לכל אובייקט קומפילציה:
- קוד: בחר הכל
SECTIONS
{
.func1 :
{
*test1.cpp.o (.text .text*)
*test1.cpp.o (.rodata*)
} > FUNC1
.func2 :
{
*test2.cpp.o (.text .text*)
*test2.cpp.o (.rodata*)
} > FUNC2
...
כך שכל הקוד של test1.cpp ילך לאיזור
FUNC1
וכל הקוד של test2.cpp לאיזור FUNC2
.קומפילציה מלאה... ואפשר לראות בקובץ output.map ששתי הפונקציות לפי הכתובות שלהן ממוקמות באיזורים החדשים, בדקתי את זה גם ליתר הבטחון ע"י הדפסה של כתובת הפונקציה מתוך
()loop
כחלק מתהליך המחקר:- קוד: בחר הכל
.func1 0x0000000000038000 0x24
*test1.cpp.o(.text .text*)
.text._Z9testFunc1v
0x0000000000038000 0x24 .pio/build/seeed_xiao/src/test1.cpp.o
0x0000000000038000 testFunc1()
*test1.cpp.o(.rodata*)
.func2 0x000000000003c000 0x24
*test2.cpp.o(.text .text*)
.text._Z9testFunc2v
0x000000000003c000 0x24 .pio/build/seeed_xiao/src/test2.cpp.o
0x000000000003c000 testFunc2()
*test2.cpp.o(.rodata*)
למי שימצא את הפוסט הזה שימושי לצרכים שלו/ה, שימו לב שזוהי רק בדיקה...
הכיוון הנכון יהיה לנתב את כל הקוד לאיזור מסויים, חוץ מקוד שברור שלא אמור ללכת לשם. לדוגמה הפקודות שאנחנו משתמשים בתכנות כרטיס Arduino בלי לחשוב יותר מדי מאיפה הן מגיעות, כמו ()digitalWrite או ()delay זה קוד שגם צריך לעדכן בעדכון תוכנה, לכו תדעו מה ישתנה בו, או אולי בכלל תרצו להחליף את המימוש של הפונקציות האלה למשהו יעיל יותר.
הקוד שלא אמור להיות מוחלף זה מנגנון watchdog כלשהו (כלב שמירה בתרגום מילולי), מנגנון שישגיח שאחרי reset הקוד החדש מתעורר לחיים ועושה משהו שמראה ל-watchdog שהוא חי. אם אין סימני חיים, אז צריך לחזור לגרסה הקודמת ולדווח בצורה כלשהי שיש בעיה.
מסובך? :)