לא מזמן, נשמה טובה שלחה לי שאלה שעלתה באחד הפורומים לגבי מקרה מוזר: פקודת delay של ארדואינו, שמוקמה בתוך פונקציית פסיקה (שהוגדרה באמצעות הפקודה attachInterrupt), רצה מהר מדי – ליתר דיוק, היא סיימה את פעולתה בערך ב-1/200 מהזמן הצפוי. הסתבר שכמה מהמגיבים בפורום השיבו, ובצדק, שמלכתחילה אסור לשים delay בתוך פונקציית פסיקה, ואף הפנו לתיעוד הרשמי של ארדואינו, שבו נכתב ש-delay לא תעבוד כלל במצב כזה. אבל עובדה שמשהו כן עבד, רק לא בקצב הנכון. אז מה באמת קרה שם?
כיוון שלא הצלחתי למצוא הסבר סביר ב"שלוף", הדבר הראשון שעשיתי היה לנסות לשחזר את הממצא. לא חסרים מקרים בהיסטוריה שבהם המדווח לא נתן את כל הקוד הרלוונטי או העדכני, לא דיווח בצורה מדויקת, או אפילו סתם לא הבין נכון מה הוא בעצם רואה. כתבתי קוד בסיסי עם פונקציית פסיקה שמבצעת blink עם delay של מאה שניות ON ומאה שניות OFF, ואכן, פרק הזמן של כל מצב התקצר באורח פלא ל-0.5 שניות בלבד.
השלב הבא היה להבין טוב יותר את הממצא: לשם כך התחלתי לשחק עם פרק הזמן המבוקש ובדקתי אם ואיזה קשר יש לו לפרק הזמן בפועל. האם תמיד נקבל 0.5 שניות בלי קשר למה שנבקש? האם הזמנים ישתנו אבל היחס ביניהם יישאר קבוע? מידע כזה יכול לעתים לתת אינדיקציה מצוינת למקור הבעיה. בדקתי בכפולות של 10 וגיליתי שעד לערך מבוקש של 100 אלפיות שנייה היחס הוא לינארי, 1/200. בערכים נמוכים יותר, כמו 10 או 1 אלפיות שנייה, מתגלה גבול תחתון של כאלפית שנייה אחת – ויותר מזה, הגבול הוא עבור ההשהיה הראשונה (מצב ה-ON), ואילו השנייה יכולה להתקצר לפעמים עד כדי מיקרו-שניות.
במבט ראשון קשה לראות מה הנתונים האלה אומרים, אבל דבר אחד ברור: בניגוד להצהרה שבתיעוד הרשמי, ובניגוד להנחה המקובלת לפיה הפונקציה delay מבוססת על פסיקות ולכן לא תפעל בתוך פונקציית פסיקה, אנחנו רואים שהיא בהחלט פועלת – ואיכשהו, במקום לספור אלפיות שנייה, היא סופרת ביחידות של 5 מיליוניות שנייה!
בואו נסתכל על קוד המקור של delay, שנמצא אי-שם בקובץ wiring.c בתיקיות של התקנת ארדואינו. הפונקציה עצמה קצרה וברורה למדי:
על הפונקציה המסתורית yield שמופיעה שם דיברתי בסרטון הזה, והיא לא רלוונטית לשאלה שעל הפרק. זו גם לא פונקציה ש"נוגעת" באיזשהו רגיסטר פנימי מסתורי או משפיעה על הפסיקות: כל מה שקורה כאן נובע אך ורק מהערכים שמוחזרים מהפונקציה micros. אז ניגש ל-micros ונסתכל על הקוד שלה, שנמצא באותו הקובץ:
זה כבר קוד קצת יותר מבלבל, בגלל הרגיסטרים ובגלל אופציות ה-"#" שקשורות להתאמה למיקרו-בקרים שונים בלוחות ארדואינו שונים. בכל אופן, עיקר התהליך הוא כזה:
- הפונקציה קוראת ושומרת בצד את הערך של SREG, הרגיסטר שקובע בין השאר אם פסיקות רשאיות לפעול
- מנטרלת את הפסיקות (פקודת cli)
- קוראת את ערכי הזמן הרלוונטיים מהטיימר – הערך הנוכחי (למשתנה t) וערך היסטורי מצטבר (למשתנה m)
- בודקת את ה"דגל" שמעיד על פסיקה ממתינה של אותו טיימר. אם הדגל הזה מורם, סימן שאירעה פסיקה ממש לאחרונה, אבל היא טרם טופלה – בגלל מה שעשינו בשלב 2. במקרה כזה מוסיפים 1 למשתנה m
- מחזירה ל-SREG את ערכו המקורי
- מחזירה את המספר המחושב של מיקרו-שניות שעברו
הטיימר ממשיך לרוץ בכל מקרה, בין אם הפסיקות מופעלות או לא. הוא סופר מ-0 עד 255, מרים את הדגל שלו ומתחיל לספור שוב מ-0. במצב רגיל בארדואינו, הפסיקות מופעלות תמיד וההרמה של הדגל גורמת מיידית לריצה של פונקציית הפסיקה המתאימה. היא מורידה את הדגל (זה נעשה אוטומטית מאחורי הקלעים) ומעדכנת את המשתנה timer0_overflow_count שראינו ב-micros. הנה הפונקציה הזו:
אם במקרה הפסיקות מנוטרלות לרגע, בגלל פקודות ישירות בקוד (כפי שקרה בין שלב 2 לשלב 5 ב-micros) או בגלל שפסיקה אחרת רצה, פונקציית הפסיקה הזו של הטיימר לא תרוץ: היא תחכה בסבלנות עד שהפסיקות יופעלו שוב, ורק אז תחזור לפעול. כלומר, אין מצב ש-timer0_overflow_count יתקדם בזמן ה-delay-בתוך-פונקציית-פסיקה שלנו.
בשלב זה ביצעתי בדיקה נוספת כדי לוודא שהבנתי נכון: הדפסתי, באמצעות Serial, את הערכים שמתקבלים מהפונקציה micros בקוד הראשי, לפני ואחרי הריצה של פונקציית הפסיקה החיצונית. גיליתי שאפילו אם אני מהבהב בלד במשך שניות שלמות עם ה-delay המהיר-מדי, הזמן שעובר לא מתועד כלל ב-micros: אחרי הפסיקה היא ממשיכה פחות או יותר מאותו ערך שבו הייתה לפני הפסיקה, כאילו שלא היה שום הבהוב.
נחזור למעלה בשרשרת הפונקציות ונראה שזה מוזר. אם הערך ש-micros רואה לא מתקדם, אז הבדיקה בלולאה הפנימית של delay (שורה 112 בצילום המסך למעלה) לא אמורה לצאת חיובית לעולם. אפילו אם כן יצאה חיובית פעם אחת, בגלל שהדגל של הטיימר הורם בין בדיקה אחת לשנייה, המשתנה start גדל ב-1000 ו-micros לא אמורה להיות מסוגלת להמשיך להתקדם ולהדביק אותו.
כתבתי פונקציה תואמת delay משלי, על בסיס הקוד של זו הקיימת, והוספתי לה קוד לדיבוג, ששמר של כל מיני ערכים בנקודות שונות כדי שאוכל להציג אותם בהמשך ולהסיק מהם מה קרה בפנים. ככה גיליתי שלפעמים, הערך ש-micros החזירה בתחילת הפונקציה delay שלי היה גדול יותר מהערך שהחזירה בסופה… כאילו הזמן רץ לאחור!
הזמן כמובן לא רץ לאחור, אז איך זה יכול להיות? אם נסתכל שוב בקוד של micros, נראה שהערך שמוחזר ממנה תלוי בסופו של דבר בשני משתנים: timer0_overflow_count, שכבר ראינו שהוא תקוע, ו-TCNT0 שהוא המונה של הטיימר. המונה הזה, כפי שהזכרתי, רץ במחזוריות מ-0 ל-255 כל הזמן. אז אם נחכה מספיק בין קריאה לקריאה נוכל להגיע לעתים קרובות למדי למצב כמו 200 בקריאה הראשונה ו-40 בקריאה השנייה: הערך ש-micros תחזיר ייראה כמו חזרה בזמן.
אבל איך זה עוזר לנו? הערך להשוואה start רק הולך וגדל, אז מה זה נותן אם micros הולכת לאחור? כאן צריך להיזכר שבמצב רגיל, הערך מ-micros תמיד יהיה גדול או שווה ל-start, כי גם כשמקפיצים את start ב-1000 זה רק אחרי שההפרש ביניהם גדול או שווה ל-1000. אבל כש-micros חוזרת לאחור בזמן, כביכול, נוצר מצב שבו הערך מ-micros קטן מ-start. ההפרש בין שני הערכים האלה מחושב בפעולת מינוס על משתנים מטיפוס uint32_t, חסרי סימן, מה שגורם לערכים שאמורים להיות שליליים להתבטא כערכים חיוביים גדולים מאוד. הבדיקה יוצאת חיובית, start גדל ומגדיל עוד יותר את הפער בינו לבין micros, ומאותו רגע הלולאה רצה בפול גז עד שספירת "אלפיות השנייה" המבוקשות מסתיימת. כמה זמן לוקח כל סיבוב של הלולאה? נכון, 5 מיליוניות השנייה.
הממצא הנוסף, של הגבול התחתון של אלפית שנייה, נובע מהחישוב השונה כש-micros מזהה את הרמת הדגל של הטיימר. כדי שהתסריט שתיארתי למעלה יעבוד, הדגל צריך להגיע למצב מורם (הוא יישאר שם כי אנחנו בתוך פונקציית פסיקה), והטיימר צריך להשלים מחזור ולהתחיל שוב מ-0. שני אלה דברים שלוקחים ביחד, פלוס מינוס, אלפית שנייה בארדואינו אונו.