פתיחה
יש לי וידוי: אני אוהב מיקרובקרים. אל תגלו את זה ל-PC הביתי שלי, הוא עוד עלול להיעלב ולעשות לי מסכים כחולים בכוונה – אבל מכל הטכנולוגיות שעבדתי עליהן לאורך השנים, מיקרובקרים הם האהובים עלי ביותר. מדוע? כי מצד אחד, מיקרובקר הוא מכונה מספיק מתוחכמת ועשירה כדי שאפשר יהיה להשתמש בה באינספור יישומים שונים ומגוונים – ממקרר ועד רובוט, ומאידך הוא מכונה מספיק פשוטה כדי שאפשר יהיה להבין אותה *לעומק*, מהשכבות הנמוכות ביותר של החומרה ועד התוכנה שרצה על המעבד עצמו. עבורי, כמהנדס, זה הגביע הקדוש של הטכנולוגיה.
הפרויקט שעליו נעבוד יוכיח את האמירה הזו. אנחנו נלמד איך להפעיל את המיקרובקר ולבצע פעולה בסיסית – הבהוב של לד (LED) – מבלי להעזר בספריית הקוד שהיצרן נותן לנו, קוד שלרוב "ממסך" את המורכבות הפנימית של המעבד, אלא באמצעות כתיבה ישירות אל הרגיסטרים של המיקרובקר: תאי הזיכרון ששולטים על פעולתו. מטרתנו תהיה להבין לעומק את תהליך אתחול של המיקרובקר ואת אופן העבודה עם הרגיסטרים שלו – ועל הדרך, נבין גם את תהליך הבנייה של הקוד, ונכיר את הדוקומנטציה שמספק לנו היצרן.
המדריך הזה מיועד למפתחים בתחילת דרכם, כאלה שיש להם היכרות בסיסית יחסית עם מיקרובקרים – אבל סביר להניח שגם מפתחים מנוסים יותר ימצאו בו עניין, מכיוון שחלק מהדברים שאדבר עליהם מהם מסוג הדברים שאנחנו נוטים לומר עליהם "זה עובד, אל תיגע". אני הולך לשלוח ידיים לדברים האלה יותר מינון מגל במסיבה. ראו הוזהרתם.
לצורך הפרויקט בחרתי להשתמש במיקרובקר מסוג STM32. אני רוצה לומר שזה בגלל שזה מיקרובקר נפוץ מאוד שיש עליו הרבה תיעוד, אבל בתכל'ס זה בגלל שבמקרה זה מה שיש לי על השולחן בבית. בתכל'ס, כל מה שאדבר עליו פה רלוונטי לכמעט כל סוגי המיקרובקרים, אז אין לזה הרבה משמעות. את קבצי הפרויקט המלאים תוכלו למצוא בחשבון ה-Github שלי.
תכנות המיקרובקר
נתחיל מהסוף. הקוד הבסיסי בשפת C שנדרש כדי להבהב לד עם מיקרובקר הוא פשוט וקל להבנה. הנה האאוטליין הכללי שלו (הקוד האמיתי שנכתוב בסופו של דבר יהיה שונה מעט, מסיבות שיתבררו בהמשך):
int main(){
while(1){
set_led_on();
delay(1000); //1000 milliseconds = 1 second.
set_led_off();
delay(1000);
}
}
האתגר שלנו טמון בשלושת הפונקציות בהן עשיתי שימוש בקוד לעיל:
set_led_on()
set_led_off()
delay()
בפרויקט רגיל אנחנו נקבל את הפונקציות האלה' כתובות ומוכנות לשימוש, בספריות תוכנה שיצרנית המיקרובקר תעמיד לרשותנו: אנחנו לא צריכים לדעת שום דבר על מה שקורה מתחת למכסה המנוע, דהיינו – איך בדיוק הספריה מדליקה ומכבה את הלד, או איך היא מממשת המתנה של כך וכך מילישניות. אבל בפרויקט שלנו – זה בדיוק מה שאנחנו רוצים לגלות.
ה-Reference Manual של מיקרובקר מסוג STM32 הוא מסמך עב-כרס, אבל מי שיודע לקרוא אותו ימצא בו את כל המידע הדרוש כדי לתכנת את המיקרובקר מבלי להשתמש בספריות. במקרה שלנו, חפירה מעמיקה במסמך הזה (תוך מלמול תפילת 'ברוך המוציא טוקנים מקלוד קוד') תגלה לנו שהאות שמפעיל ומכבה את הלד – אות שיוצא מפין מספר 7 של המיקרובקר – נשלט על ידי בלוק חומרה בשם GPIO Port C, ושכדי שהבלוק הזה יעבוד עלינו לבצע שתי פעולות מקדימות:
- לאתחל את אות השעון שמגיע אל הבלוק הזה.
- לקנפג הגדרות מסוימות בבלוק, שעליהן נרחיב מיד.
במישור המעשי, המשמעות היא שעלינו לאתר מספר רגיסטרים ספציפיים – תאי זיכרון – ולכתוב לתוכם ערכים מתאימים. אתם יכולים לחשוב על הרגיסטרים האלה כעל "לוחות הפיקוד" של הבלוקים השונים בתוך המיקרובקר.
כתובות הזיכרון של הרגיסטרים השונים ניתנות לנו באופן הבא:
- כתובת בסיס (Base Address): זו כתובת ההתחלה של מקטע זיכרון מסוים בו נמצאת קבוצת רגיסטרים.
- כתובת היסט (Offest): זה המספר שעלינו להוסיף לכתובת הבסיס כדי להגיע לרגיסטר ספציפי.
כתובת של רגיסטר ספציפי תחושב כ: כתובת בסיס + כתובת היסט. בואו נראה את זה בפועל, במקרה של אות השעון.
אליבא ד-Reference Manual, כל אותות השעון בתוך המעבד נשלטים על ידי בלוק המכונה RCC, ואנחנו צריכים לגרום לבלוק הזה להפעיל אות שעון שמפעיל מנגנון בשם AHB. כתובת הבסיס של מקטע הזיכרון בו שוכנים הרגיסטרים ששולטים על ה-RCC היא 0x5802 4400, והרגיסטר הספציפי ששולט על אות השעון אל GPIO Port C נמצא בכתובת היסט של 0x0E0. הכתובת המחושבת הסופית, אם כן, היא: 0x5802 4400 + 0x0E0 = 0x5802 44E0.
ומה נכתוב לתוך הרגיסטר? כדי להפעיל את אות השעון, מנחה אותנו המסמך, עלינו לכתוב את הערך 1 לביט מספר 2.
נתחיל בלהגדיר את הכתובות והערכים הדרושים בתוך קובץ mcu.h (אני מניח שאתם מכירים את שפת C, ולו היכרות בסיסית).
#define REG32(addr) (*(volatile unsigned int *)(addr))
#define RCC_BASE 0x58024400U
#define RCC_AHB4ENR REG32(RCC_BASE + 0x0E0U)
#define GPIOCEN_BIT 2U
RCC_BASE הוא השם שהענקנו לכתובת הבסיס של מקטע הרגיסטרים של ה-RCC, ו- GPIOCEN_BIT הוא השם שנתנו לביט השני של הרגיסטר שלנו. למה נתנו להם שמות? כי שמות ברורים הופכים את הקוד לקריא ומובן יותר.
השורה REG32… מגדירה תבנית בסיסית שמשמעותה: "התוכן של רגיסטר שנמצא בכתובת addr", ו- RCC_AHB4ENR מציין את "תוכנו של הרגיסטר שנמצא בכתובת שהיא כתובת הבסיס של RCC ועוד היסט של 0x0E0".
אחרי שסיימנו עם ההגדרות, נדלג אל main.c:
int main(void){
RCC_AHB4ENR |= (1U << GPIOCEN_BIT);
כפי שניתן לראות, הפעולה הראשונה שמבצע הקוד שלנו היא לכתוב את הערך 1 לתוך ביט מספר 2 של רגיסטר RCC_AHB4ENR – פעולה שהתוצאה שלה היא שאות השעון אל GPIO Port C הופך להיות פעיל.
כעת אנחנו עוברים אל השלב הבא: קינפוג הרגיסטרים של בלוק GPIO Port C.
ראשית – מהו פין GPIO? פין מסוג General Purpose Input/Out הוא פין שעקרונית יכול לשמש למגוון של יישומים: הוא יכול להדליק לדים, אבל גם להיות חלק מתקשורת טורית, להיות אות שעון ועוד אפשרויות רבות אחרות. הגמישות הזו היא אחת מכוחות-העל של המיקרובקרים, והיא מאפשרת להם להיות שימושיים במגוון רחב כל כך של יישומים- אבל אנחנו משלמים על הגמישות הזו בכך שאנחנו צריכים לקנפג את התכונות של כל פין בהתאם לשימוש שאנחנו רוצים לעשות בו.
במקרה שלנו, אנחנו רוצים שהפין שמחובר אל הלד יוגדר כ Output (משמע, הוא מוציא אותות מהמיקרובקר), ושהוא יהיה מסוגל להוליך זרם מ- ואל הלד, קונפיגורציה אלקטרונית הקרויה Push-Pull. על פי ה-Reference Manual, כדי לקנפג את פין מס' 7 של GPIO Port C על פי ההגדרות הנ"ל, עלינו לכתוב את הערך 01 אל ביטים 14 ו-15 של רגיסטר בשם GPIOC_MODER.
כמקודם, נתחיל בהגדרת הכתובות והערכים הנ"ל בקובץ mcu.h:
#define GPIOC_BASE 0x58020800U
#define GPIOC_MODER REG32(GPIOC_BASE)
#define MODER7_START_BIT 14U
ואז נבצע את הכתיבה עצמה אל הרגיסטר בתוך main.c:
GPIOC_MODER &= ~(3U << MODER7_START_BIT);
GPIOC_MODER |= (1U << MODER7_START_BIT);
במקרה זה, ביצענו את הכתיבה בשני שלבים: ראשית, איפסנו את הביטים המדוברים (14 ו-15), ואז כתבנו לתוכם את הערך 01. אפשר היה לעשות את הפעולה הזו בשלב אחד, אבל אני אוהב להיות מסודר בקטע הזה. סוג של שריטה.
זהו, סיימנו עם האתחולים וההכנות, ועכשיו אנחנו יכולים להדליק ולכבות את הלד. גם הפעולה הזו נעשית, כמו כל דבר, באמצעות כתיבה אל רגיסטר: במקרה הזה רגיסטר בשם GPIOC_BSRR, שכתיבה של הערך 1 אל ביט 7 שלו מדליקה את הלד, וכתיבה אל ביט 23 מכבה אותו. ההגדרה בקובץ mcu.h תהיה:
#define GPIOC_BSRR REG32(GPIOC_BASE + 0x18U)
#define BS7_BIT 7U
#define BR7_BIT 23U
והכתיבה לביטים המדוברים תראה כך:
GPIOC_BSRR = (1U << BS7_BIT); //LED on
GPIOC_BSRR = (1U << BR7_BIT); //LED off
הפיסה האחרונה שחסרה לנו בפאזל היא ההשהייה: אנחנו רוצים שהמעבד ימתין כשניה בין כיבוי והדלקה של הלד. יש כל מיני דרכים ליצור השהייה שכזו, אבל הדרך הפשוטה ביותר היא לבקש מהמעבד לא לעשות כלום במשך כך וכך מחזורי פעולה. זו בדיוק מטרת הפונקציה הבאה שיצרנו בתוך main.c:
static void delay (volatile unsigned int count){
while (count--){
__asm__("nop");
}
}
הפונקיה delay היא לולאה שסופרת אחורה מערך התחלתי כלשהו, ולא עושה שום דבר – עובדה שבאה לידי ביטוי בפקודת אסמבלי "nop" – קיצור של No Operation.
מהו הערך שעלינו להציב בפונקציה כדי לקבל השהייה של שניה? על פי ה-Reference Manual, ברירת המחדל של תדר העבודה של המעבד היא 64Mhz, או 64 מיליון מחזורים בשניה. אני מנחש (סתם ניחוש, האמת) שכל מחזור של לולאת ה-Delay שלנו דורש משהו כמו עשר פעולות מצד המעבד – ולכן אם נבצע 6.4 מיליון לולאות שכל אחת מהן אורכת עשרה מחזורים – נקבל בערך, פלוס מינוס, פחות או יותר, טפו טפו – השהייה של שנייה. כמובן שאם זה היה פרויקט קצת יותר רציני, לא הייתי עוסק בניחושים אלא מחשב את מספר המחזורים בצורה מתודית יותר.
הלולאה הסופית שלנו, אם כן, נראית כך:
while (1){
GPIOC_BSRR = (1U << BS7_BIT);
delay(6400000);
GPIOC_BSRR = (1U << BR7_BIT);
delay(6400000);
}
זהו. סיימנו לכתוב את הקוד שמאתחל את המערכות הפנימיות של המיקרובקר, ומהבהב את הלד שמחובר לפין 7 של הרכיב.
תהליך הבנייה (build)
אבל כל סוף הוא התחלה חדשה, לא?… די שהמעבד יוכל להריץ את הקוד שלנו צריכים להתרחש שלושה דברים.
- צריך לתרגם את הקוד הזה, שכתוב בשפה עילית המיועדת לבני אדם ומפתחים, לאוסף של אפסים ואחדות שאפשר לכתוב לזיכרון של המיקרובקר.
- צריך להגדיר בצורה מפורשת באילו תאי זיכרון ספציפיים יישמר המידע הזה, כדי שהמעבד יוכל למצוא אותם שם.
- צריך לטעון (או "לצרוב") את המידע לתוך תאי הזיכרון בפועל, כי אחרת אתה והמעבד סתם תשבו ותסתכלו אחד על שני ויהיו המון שתיקות מביכות.
אז איך אנחנו הופכים קוד C למידע בינארי ומגדירים היכן יישב הקוד הזה בזיכרון? זה תפקידו של תהליך ה"בנייה" (build). בתהליך הזה אנחנו מפעילים בזה אחר זה שורה של כלי תוכנה שכל אחד מהם מוציא לפועל צעד אחד בתהליך.
- קימפול ("הידור"): הקומפיילר לוקח את קוד ה-C והופך אותו לקוד אסמבלר (Assembler), שהיא שפת תכנות בסיסית ופשוטה יותר.
- אסמבלי (Aseembly): כלי האסמבלר לוקח את קוד האסמבלר (מסתבר שגיוון בשמות זה אוברייטד) והופך אותו לשפת-מכונה (machine code), שהוא המידע הבינארי שהמעבד יכול לקרוא. המידע הזה נשמר בקבצים המכונים "קבצי אובייקט" (object files) בעלי סיומת .o.
- קישור (linking): המקשר (Linker) לוקח את קבצי האובייקט ויוצר מהם קובץ .elf, שלצד הקוד עצמו מכיל גם את המידע שמגדיר היכן בזיכרון יישב הקוד הזה.
תפקידנו, אם כן, הוא ליצור את הקבצים שינחו את פעולתם של הכלים האלה ויספקו להם את המידע הדרוש להם. שלושת הקבצים האלה הינם:
- startup.s
- linker.ld
- Makefile
קובץ startup.s
קובץ ה-startup.s מכיל את ההנחיות הדרושות עבור האסמבלר, ומספר פונקציות בסיסיות וחשובות שהמעבד זקוק להם במסגרת תהליך האיתחול של התוכנה. בואו נתחיל לעבור על תוכן הקובץ מלמעלה עד למטה.
.cpu cortex-m7
כאן אנחנו מגדירים לכלי התוכנה שלנו עם איזה מעבד אנחנו מתכוונים לעבוד: במקרה זה, המיקרובקר שלנו, STM32, מכיל מעבד מדגם Cortex-M מתוצרת חברת ARM. למה חשוב לכלים לדעת עם איזה מעבד אנחנו עובדים?
כאמור, האסמבלר הופך את הקוד שלנו לשפת מכונה. אבל…איזו שפה? ישנם כמה סוגי מעבדים, וכמה שפות מכונות שונות שמתאימות להם. מעבדי Cortex-M, סצפיפית, תומכים בשתי גרסאות של שפת-מכונה:
- פקודות ARM
- פקודות Thumb
בפועל, אנחנו נשתמש בגרסאת ה-thumb של שפת המכונה – שהיא שפת המכונה היעילה יותר מבין השתיים – אבל בכזו שהיא איחוד של שתי הגרסאות השונות הללו. זו משמעותן של שתי ההנחיות הבאות בקובץ:
.thumb
.syntax unified
החלק הבא מגדיר את שמות המשתנים שבהם נשתמש בהמשך, ובעיקר – איפה האסמבלר יכול למצוא את ההגדרות שלהם. הנה שתי דוגמאות להגדרות טיפוסיות:
.global Reset_Handler
.extern main
Reset_Handler ו-main, בדוגמא שלנו, הם "סמלים" (Symbols): סמל הוא משהו דומה למשתנים (Variables) שאנחנו רגילים אליהם בקוד תוכנה: אנחנו לוקחים פיסה של קוד או מידע ונותנים לה שם. ההגדרה .global אומרת לאסמבלר שהמשתנה (Reset_Handler, בדוגמא שלנו) מוגדר בקובץ הנוכחי (startup.s) ושהוא "גלובלי" – משמע, הוא זמין גם מחוץ להקשר של הקובץ הזה. ההגדרה .extern אומרת שהמשתנה הוגדר במקור בקובץ אחר (בדוגמא שלנו, מדובר בפונקציה main שמוגדרת בקובץ main.c, הקוד שכתבנו כדי להבהב את הלד).
החלק הבא בקובץ מעניין במיוחד, מכיוון שהוא מעניק לנו את ההצצה העמוקה הראשונה שלנו אל האופן שבו המיקרובקר עובד מתחת למכסה המנוע.
על פי הכתוב ב- Reference Manual שמספקת לנו ST, יצרנית המיקרובקר, כשהמעבד מתעורר לחיים כתובת הזיכרון הראשונה שהוא ניגש לקרוא ממנה היא הכתובת 0x0800 0000 (אם לדייק, הכתובת הזו תלויה באופן שבו חיווטנו פינים מסויימים של המעבד – אבל זה לא ממש חשוב לעניין שלנו כרגע). בכתובת הזו המעבד מצפה למצוא את מה שמכונה "טבלת הוקטורים" (Vector table), שהיא רשימה ארוכה של כתובות זיכרון שמשמשת כמעין "מפת דרכים" עבור המעבד: "אם מתרחש אירוע X, קפוץ לכתובת המוגדרת בטבלה עבור אותו אירוע." דוגמא ל'אירוע' שכזה היא מצב שבו הקוד שלנו מנסה לחלק מספר באפס, שזה כידוע אסור ועלול לגרום ליקום לקרוס לתוך עצמו: כשהוא נתקל בתקלה שכזו, המעבד ניגש לשורה הרלוונטית בטבלת הוקטורים, שם הוא מוצא את כתובת האזור בזיכרון (ה'וקטור') שבו נמצאות ההוראות שמגדירות מה עליו לעשות.
.section .isr_vector, "a", %progbits
.type g_pfnVectors, %object
שורות אלו אומרות לאסמבלר שהמקטע הבא הוא טבלת הוקטורים, שמכילה מידע בינארי שיש לכתוב אותו לזיכרון. הסמל g_pfnVectors מייצג את כתובת הזיכרון שבה נמצאת תחילת הטבלה.
כעת אנחנו מגדירים את תוכן הטבלה עצמה, כשכל שורה בטבלה היא 'מילה' (word) – משמע, קטע זכרון ברוחב של 32 סיביות.
g_pfnVectors:
.word _estack /* Initial stack pointer (SP) value */
.word Reset_Handler /* reset handler address */
.word Default_Handler /* NMI exception handled by the Default_Handler */
.word Default_Handler /* HardFault */
השורה הראשונה מכילה את הכתובת של האזור בזכרון שבו יהיה ה-stack. ה-stack הוא "זיכרון העבודה" של המעבד, המקום שבו הוא שומר דברים כמו ערכי משתנים ופויינטרים לפונקציות. כאמור, כשהמעבד מתעורר הוא קורא את תוכנה של השורה הראשונה ושומר אותה כדי להיות מסוגל להריץ בהמשך את כל שאר הקוד שלנו.
נדלג לרגע על השורה השניה – מיד נחזור אליה – ונמשיך אל השורות השלישית, הרביעית וכל עשרות השורות שבאות אחריהן. השורות הללו מגדירות 'אירועים' שונים שעשויים להתרחש – ולאן המעבד צריך לפנות בזיכרון כדי למצוא את הקוד שמטפל בהן: למשל, השורה השלישית מתייחס לאירוע בשם NMI, הרביעית לאירוע בשם HardFault וכן הלאה. רשימת האירועים המלאה והסדר המדויק שבו הם צריכים להופיע בטבלה לקוחה מתוך ה- Reference Manual של היצרן.
חדי העין מבינכם (או קשישים כמוני שצריכים משקפיים מיוחדות בשביל לעבוד מול מסך) הבחינו בכך שבשני המקרים – גם במקרה של NMI וגם במקרה של HardFault – הטבלה משגרת את המעבד אל אותה הכתובת בזיכרון: כתובת שבה נמצאת תוכנה בשם Default_Handler. למעשה, אם תעברו על הטבלה המלאה, תוכלו לראות שלא משנה מה קורה, ב*כל* האירועים אנחנו שולחים את המעבד אל אותה הכתובת. אז מה לעזאזל קורה גם?
בואו נדלג קדימה, אל סוף הקובץ, כדי להבין מהי Default_Handler.
.section .text.Default_Handler, "ax", %progbits
.type Default_Handler, %function
Default_Handler:
Infinite_Loop:
b Infinite_Loop
שתי השורות הראשונות אומרות לאסמבלר שהמקטע הבא מכיל מידע תחת השם Default_Handler, ושהמידע הזה הוא קוד שהמעבד יכול להריץ אותו (executable). השורות הבאות הן הקוד עצמו, וכפי שניתן להבין מדובר בלולאה אינסופית שלא עושה שום דבר.
אז למה אם ישנה תקלה כמו חלוקה באפס, למשל, אנחנו שולחים את המעבד להסתובב סביב עצמו במעגלים ולא לעשות כלום כמו השר לענייני תפוצות ומאבק באנטישמיות? כי אנחנו עושים פרויקט פשוט של הבהוב לד, ואנחנו לא רוצים להיכנס למורכבות של טיפול בכל מיני תקלות ואירועי קצה לא רלוונטיים. בקוד אמיתי – כנראה שהיינו עושים משהו שונה.
כעת נחזור אל השורה השניה בטבלה, שם נמצאת כתובת הזכרון של פונקציה בשם Reset_Handler. אחרי שהמעבד קרא את השורה הראשונה בטבלה, הוא מתקדם אל השורה השניה, מדלג אל המקום בזיכרון שבו נמצאת פונקציית ה-Reset_Handler ומתחיל להוציא אותה לפועל. ומה עושה הפונקציה הזו?
התוכנה שלנו – המידע שהוא התוצר של תהליך הבנייה – נטענת לזיכרון מסוג Flash. זיכרון פלאש הוא זיכרון לא-נדיף, משמע – הוא אינו נמחק כשסוגרים את החשמל (בגלל זה נוח לשמור את התוכנה שם, כדי שלא נצטרך לטעון אותה בכל פעם מחדש) – אבל מצד שני, הוא איטי יחסית ומספר הפעמים שניתן לכתוב אליו מוגבל. מסיבה זו, עלינו להעביר חלק מהמידע שאצור בזיכרון הפלאש – כמו למשל, משתנים שצריך לכתוב ולעדכן אותם – אל זיכרון מסוג RAM: זיכרון שהוא אמנם נדיף ונמחק כשמנתקים את החשמל, אבל אפשר לכתוב ולקרוא ממנו מהר וכמה פעמים שרוצים. תפקידה של פונקציית ה- Reset_Handler הוא לבצע את ההעברה הזו: להעתיק את המידע הרלוונטי מזיכרון הפלאש אל זיכרון ה-RAM.
הבה נראה איך זה מתרחש בפועל. הפונקציה Reset_Handler מוגדרת בקובץ שלנו מיד לאחר טבלת הוקטורים:
.section .text.Reset_Handler, "ax", %progbits
.type Reset_Handler, %function
כמקודם, שתי השורות האלה מגדירות את מקטע הזיכרון ככזה שמוקדש ל-Reset_Handler, ומכיל קוד שהמעבד יכול להריץ. החלק הבא הוא קוד הפונקציה עצמו:
Reset_Handler:
ldr r0, =_sidata
ldr r1, =_sdata
ldr r2, =_edata
CopyData:
cmp r1, r2
bcs ZeroBss
ldr r3, [r0], #4
str r3, [r1], #4
b CopyData
ZeroBss:
ldr r1, =_sbss
ldr r2, =_ebss
movs r0, #0
ZeroBssLoop:
cmp r1, r2
bcs CallMain
str r0, [r1], #4
b ZeroBssLoop
CallMain:
bl main
LoopForever:
b LoopForever
בחלקה הראשון של הפונקציה אנחנו אומרים למעבד מהיכן בזיכרון עליו לקחת את המידע, ולהיכן עליו להעתיק אותו: ארחיב על המיקומים האלה ועל הסמלים שמייצגים אותם (_sidata, _sdata ו- _edata) כשנדבר על תוכן הקובץ linker.ld.
חלקה השני של הפונקציה מבצע את ההעתקה עצמה: לא נתעכב על הקוד עצמו, אבל הוא פשוט למדי ואפילו Groq של אילון מאסק יוכל להסביר לכם איך הוא עובד בחמש דקות, כולל לג'נרט לנו תמונה מגונה על הדרך. קטע הקוד ZeroBss מאפס (כותב אפסים) לתוך אזור מסוים של תאי זיכרון, והקטע שאחר אומר שכשהמעבד מסיים לבצע את ההעתקה, הוא ניגש לכתובת הזיכרון שבה שמורה הפונקציה main() שכתבנו בשפת C – ומתחיל להריץ את התוכנה שלנו.
השורה האחרונה – LoopForever – אומרת למעבד מה עליו לעשות כשהפונקציה main() מסיימת לרוץ. אם תעיפו מבט באאוטליין של הקוד שרשמתי בתחילת המדריך, תראו שלמעשה אין דבר כזה: בתוך main ישנה לולאה אינסופית, כך ש main לעולם לא מסיימת לרוץ. זה אומר שהשורה האחרונה הזו מיותרת – אבל מכיוון שבתאוריה main יכולה, עקרונית, לסיים לרוץ אנחנו חייבים לכלול אותה בקוד שלנו.
קובץ linker.ld
סיימנו לסקור את תוכנו של startup.s. כעת נעבור אל linker.ld: כזכור, 'קישור' הוא התהליך שבו אנחנו מגדירים היכן בזיכרון המיקרובקר נמצא המידע, ובקובץ linker.ld נמצאות ההגדרות שעוזרות למקשר לעשות את עבודתו.
ENTRY(Reset_Handler)
השורה הזו קובעת שנקודת ההתחלה של התוכנה שלנו, הקוד שהמעבד יריץ ראשון, היא הפונקציה Reset_Handler. על פניו, ההגדרה הזו נדמית כמיותרת: כפי שהסברתי קודם, למעבד אין ממש בחירה בעניין הזה: כשהוא מתעורר הוא ניגש לטבלת הוקטורים ושם, בשורה השניה, הוא בכל מקרה מקבל הפנייה לפונקציית Reset_Handler. אז למה בכל זאת צריך את השורה הזו? לא בשביל המעבד אלא בשביל המקשר עצמו, שגם הוא צריך לדעת שזו נקודת ההתחלה של הקוד, כחלק מתהליך העבודה שלו.
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 0x20000
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 0x20000
}
כאן אנחנו מגדירים אילו זיכרונות עומדים לרשותו של המקשר: בפרויקט שלנו מדובר בזיכרון הפלאש (שאליו אנחנו טוענים את התוכנה שלנו) וזיכרון ה-RAM (שאליו ה-Reset_Handler מעתיק חלק מהמידע הזה לאחר שהמעבד מתעורר לחיים). עבור כל אחד מהזכרונות האלה אנחנו מציינים את כתובת ההתחלה ונפח הזיכרון העומד לרשותנו, מידע שאפשר למצוא ב- Reference Manual של היצרן.
_estack = ORIGIN(RAM) + LENGTH(RAM);
שורה זו מגדירה את הסמל _estack, שהוא הפויינטר אל תחילת ה-stack: "זיכרון העבודה" של המעבד. אם תעיפו מבט ב-startup.s תוכלו לראות שזו למעשה השורה הראשונה בטבלת הוקטורים שלנו.
אבל יש כאן משהו חריג: הפויינטר הזה מצביע אל כתובת שהיא בסוף, או בחלקו העליון של אזור ה-RAM. זה קצת מוזר, לא? היינו מצפים לכתובת בהתחלת אזור הזיכרון. הסיבה לכך היא שה-stack גדל כלפי מטה: הוא מתחיל בקצה העליון של ה-RAM ויורד אל תאי זיכרון בכתובות יותר ויותר נמוכות. אני בטוח שיש לזה סיבה, אבל לא חפרתי עמוק מספיק בשביל לגלות אותה.
כעת אנחנו מתחילים להגדיר עבור המקשר אילו חלקים יש לנו בקוד, ואיפה בזיכרון לשים כל חלק.
SECTIONS
{
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
{
בלוק זה מתייחס לטבלת הוקטורים שתיארתי קודם, ומנחה את המקשר לשים אותה בזיכרון הפלאש. שתי הגדרות ספציפיות מושכות את תשומת ליבנו.
הראשונה היא ALIGN(4). יישור (Alignment) היא הנחייה שקובעת שעל המקשר לבחור כתובות בזיכרון שהן כפולות של 4: למשל, הוא יכול לשים מידע כלשהו בכתובות כמו 0x0, 0x4, 0x8 וכדומה – אבל לא בכתובות כמו 0x1, 0x5 ו-0x7. למה? מכיוון שהמעבד שלנו מבצע קריאה וכתיבה לזיכרון במקטעים של 32 סיביות – ארבעה בתים – וכדי שהגישות האלה לזיכרון תהיינה יעילות והמעבד לא יצטרך לקרוא רק בית אחד או שניים בכל פעם, כדאי שהמקשר יידע מראש לסדר דברים בזיכרון כך שהם מיושרים לארבעה בתים. הפקודה ". = ALIGN(4)" פירושה – "מנקודה זו ואילך בבלוק הזיכרון, יש להפקיד על יישור בכפולות של 4."
ההגדרה השניה היא KEEP, והיא אומרת למקשר שעליו לשמור את טבלת הוקטורים ולא להתעלם ממנה או למחוק אותה. על פניו, זה נשמע קצת מוזר: למה שהמקשר יירצה למחוק את הטבלה הכה חשובה הזו? התשובה היא שכחלק מהתהליך הבנייה, כלי התוכנה מבצעים אופטימיזציה לקוד שלנו כדי שהמעבד יוכל להריץ אותו הכי מהר שאפשר. כחלק מהאופטימיזציה הזו, קוד "מיותר", שמסיבה כלשהי המעבד לעולם לא יוכל להריץ אותו בכל מקרה – נמחק. מנקודת מבטו של הלינקר, טבלת הוקטורים נראית בדיוק כמו סוג כזה של מידע: הקוד שכתבנו בשפת C אף פעם לא מתייחס אל טבלת הוקטורים, ולכן נדמה שהמעבד לא יוכל להגיע אל המידע הזה בשום מצב. זו, כמובן, טעות: אמנם קוד ה-C שלנו אכן לא מתייחס לטבלת הוקטורים, אבל זה רק מכיוון שהמעבד נתקל בטבלת הוקטורים עוד לפני שהוא בכלל מגיע לקוד שלנו… כדי לוודא שהמקשר לא מוחק את טבלת הוקטורים בטעות, אנחנו מנחים אותו במפורש לשמור אותה.
.text:
{
. = ALIGN(4);
*(.text)
*(.text*)
*(.rodata)
*(.rodata*)
. = ALIGN(4);
} > FLASH
המקטע הזה מנחה את המקשר לשים מידע שהוא מוצא בקבצי ה- .o שמייצר האסמבלר – ליתר דיוק, בלוקים של קוד מסוגי .text ו- .rodata. – בזכרון הפלאש, כשהוא מיושר לרוחב של ארבעה בתים.
.data :
{
. = ALIGN(4);
_sdata = .;
*(.data)
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM AT > FLASH
המקטע הזה מנחה את המקשר לשים מידע מסוג .data בזיכרון הפלאש אבל בהמשך להעביר אותו לזיכרון ה-RAM – שזה, כזכור, תפקידה של פונקציית ה-Reset_Handler.
עוד מוגדרים במקטע הזה שני סמלים: _sdata, שמייצג את הכתובת של תחילת המקטע בזיכרון, ו- _edata שמייצג את כתובת סוף המקטע. השורה הבאה מגדירה עוד סמל חשוב, שמייצג את הכתובת של התחלת מקטע הזיכרון בפלאש שבו שמור מקטע ה .data:
_sidata = LOADADDR(.data);
נתקלו בשלושת הסמלים האלה בחלקה הראשון של פונקציית ה- Reset_Handler:
Reset_Handler:
ldr r0, =_sidata
ldr r1, =_sdata
ldr r2, =_edata
וכעת אפשר להבין טוב יותר את תפקידם: הם מציינים את גבולות אזורי הזיכרון שהפונקציה זו מעתיקה: בלוק מידע שמתחיל בכתובת _sidata בזיכרון הפלאש, מועתק אל בלוק בזיכרון ה-RAM שמתחיל בכתובת _sdata ומסתיים בכתובת _edata.
המקטע האחרון בזיכרון הוא .bss, שגם אליו פונקציית Reset_Handler מעתיקה מידע מתוך זיכרון הפלאש:
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
כמקודם, המקטע הזה מגדיר את התוכן שייכנס למקטע הזיכרון הזה (משתנים שערכם ההתחלתי הוא אפס) ב-RAM, יישור של כתובות וסמלים להתחלת וסיום מקטע הזיכרון.
קובץ Makefile
כפי שראינו, תהליך הבנייה הוא תהליך מורכב שדורש שימוש בכמה וכמה תוכנות שונות: קומפיילר, אסמבלר ומקשר. אם היינו צריכים להפעיל את כל התוכנות האלה בעצמנו, זה כנראה היה כאב ראש רציני. למזלנו, יש לנו כלי שיעשה לנו את החיים לקצת יותר קלים (אבל רק קצת: בכל זאת, אם זה היה קל, זה לא היה כיף) – תוכנה בשם make, שתפעיל עבורנו את כל שאר התוכנות של תהליך הבנייה בסדר הנכון ותוודא שכל אחת מקבל את הקלט המתאים לו היא זקוקה.
כמו שאר התוכנות במדריך שלנו, גם make זקוקה לקובץ הנחיות והגדרות: זהו קובץ ה-Makefile. נתחיל עם הגדרות של משתנים שישמשו אותנו בהמשך הקובץ. זה, למשל, השם שבחרנו לפרויקט שלנו:
TARGET := blink
ואלו כלי התוכנה שנפעיל: הקומפיילר, האסמלר והלינקר.
CC := arm-none-eabi-gcc
OBJCOPY := arm-none-eabi-objcopy
אלו הם הפרמטרים הדרושים כדי להפעיל אותם: לא אכנס למשמעויות שלהם במסגרת המדריך הזה.
CFLAGS := -mcpu=cortex-m7 -mthumb -Wall -Wextra -O0 -ffreestanding -nostdlib -I$(INC_DIR)
LDFLAGS := -T linker.ld -nostdlib
ולבסוף, אלו שמות התיקיות והקבצים שנפיק. פקודה בסגנון $(BUILD_DIR) פירושה – "התוכן של משתנה BUILD_DIR", כך ש make יודעת להחליף את $(BUILD_DIR) ב- "build".
SRC_DIR := src
INC_DIR := include
BUILD_DIR := build
ELF := $(BUILD_DIR)/$(TARGET).elf
BIN := $(BUILD_DIR)/$(TARGET).bin
MAIN_OBJ := $(BUILD_DIR)/main.o
STARTUP_OBJ := $(BUILD_DIR)/startup.o
OBJS := $(MAIN_OBJ) $(STARTUP_OBJ)
סיימנו עם הגדרת המשתנים. החלק הבא בקובץ מגדיר את ה"כללים" (rules) שינחו את פעולתה של make. התבנית הבסיסית של כלל היא כזו:
Target: Prerequisite
Recipe
- Target: 'המטרה', זה הקובץ שאנחנו רוצים ליצור או הפעולה ש make צריכה לבצע.
- Prerequisite: בתרגום לעברית – 'דרישה מקדימה', משמע התנאים שצריכים להתקיים כדי שניתן יהיה להגשים את ה-Target.
- ה Recipe: ה'מתכון', דהיינו מה צריך לעשות כדי להגשים את ה-Target.
בואו נבחן דוגמא מוחשית. נזכור שהסימן $ מורה לנו להחליף את המשתנה שבתוך הסוגריים בערך שהגדרנו לו קודם.
$(ELF): $(OBJS) linker.ld | $(BUILD_DIR)
$(CC) $(CFLAGS) $(OBJS) $(LDFLAGS) -o $@
פרשנות:
- המטרה שלנו היא ליצור קובץ blink.elf, בתוך תיקייה בשם build.
- ישנן שלוש דרישות מקדימות. הראשונה היא שהקבצים startup.o ו- main.o חייבים להיות נוכחים בתוך תיקיית build. השניה היא שהקובץ linker.ld חייב להיות נוכח באותה התיקייה שממנה אנחנו מריצים את make (במקרה שלנו, תיקיית ה-root של הפרויקט).
הדרישה השלישית היא שתיקיית build חייבת להיות קיימת, והיא מופיעה אחרי הסימן "|". הסימון הזה קשור לעובדה שבמצב רגיל, make בודקת את תאריך השינוי האחרון של הקבצים והתיקיות לפני שהיא מנסה ליצור אותם: אם קבצי הקוד שלנו לא השתנו ביחס לתוצרי הבנייה האחרונה (ז"א, לא עשינו שינוי כלשהו בקוד אחרי הפעם הקודמת שהרצנו את make) – היא לא תעשה כלום, מכיוון ששום דבר בקוד שלנו לא השתנה. הסימן "|" מורה ל-make להתעלם מתאריך השינוי של תיקיית ה build עצמה. - המתכון שלנו הוא הפקודה עצמה שיש להריץ כדי ליצור את קובץ ה .elf: אנחנו נשתמש בכלי arm-none-eabi-gcc עם הפרמטרים שהוגדרו ב CFLAGS, ועם קבצי הקלט main.o ו-startup.o וקובץ ה-linker.ld (עכשיו אפשר להבין מדוע הקבצים האלה הוגדרו כדרישה מקדימה). הסימון $@ הוא המטרה של הכלל הזה – זאת אומרת, הוא מוחלף ב $(ELF) (שבעצמו מוחלף ב- build/blink.elf).
קובץ ה Makefile מכיל כמה וכמה כללים כאלה, שאם תפסתם את החוקיות הנ"ל תוכלו לפרש את תוכנם די בקלות. כלל אחד שמעט יוצא דופן הוא הכלל הבא –
all: $(ELF) $(BIN)
המטרה "all" אומרת ל make שאם אנחנו מריצים את הפקודה "make all" – עליה לייצר את הקבצים שמוגדרים ב-Prerequisite: משמע, לייצר קובץ bin ו-elf. מי שכבר יצא להם להשתמש ב-make בעבר ודאי יודעים שגם הרצה של "make" בלבד (ז"א, ללא all) תבצע את אותה הפעולה בדיוק. איך זה קורה? ובכן, אם תבחנו את הקובץ המלא, תראו שהכלל הזה הוא הראשון ברשימת הכללים – ולא במקרה: הכלל הראשון מוגדר כ"כלל ברירת המחדל" – דהיינו, הכלל ש make תנסה להריץ אם הרצנו אותה ללא פרמטרי כלל. במקרה שלנו, הכלל all הוא הכלל הראשון ולכן make ו make all זהים.
ולסיום, עוד הגדרה אחת אחרונה:
.PHONY: all clean
השורה הזו אומרת ל make שהמטרות "all" ו-"clean" הם לא קבצים שיש לייצר, אלא פקודות או פרמטרים ל make עצמה.
סיכום
זהו: יצרנו את כל הקבצים הדרושים לנו. אם תריצו את פקודת make מהתיקייה הראשית של הפרויקט, ותטענו את קובץ ה- elf המתקבל למיקרובקר, תוכלו לראות את הלד מהבהב. אם אתם כמוני, כנראה שהבהוב הזה יגרום לכם להרגיש סיפוק לפחות כאילו הבקעתם גול בגמר של גביע העולם – הסיפוק של להבין מערכת מורכבת מספיק טוב כדי להיות מסוגלים לתפעל אותה בלי שום מתווכים והפשטות. זו הגדולה של מיקרובקרים: הם מורכבים בדיוק במידה הנכונה 🙂



כתיבת תגובה