בעבר הזכרתי את ה"עז" ש-Microchip דחפו לגרסה החינמית של הקומפיילר XC8, כדי לגרום לגרסה בתשלום להיראות טובה יותר. בפוסט זה אדגים איך אפשר, בעזרת קוד אסמבלי ובמגבלות מסוימות, להתגבר אפילו על המכשול המובנה הזה.
רקע
כזכור, הגרסה החינמית של XC8 מתרגמת כל פקודת השמה בקוד ה-C שלנו (אופרטורים כגון = או ++) לרצף של פקודות אסמבלי, ששתיים מתוכן מיותרות לחלוטין מבחינה פונקציונלית. הסיבה היחידה לתוספת היא לגרום לקוד הסופי לרוץ לאט יותר, וזאת כדי שתוצרי הגרסה בתשלום – שכוללת, חייבים לציין, גם אופטימיזציות אמתיות – ייראו יותר מרשימים בהשוואה, וישכנעו ביתר קלות לקוחות פוטנציאליים להוציא כסף.
בתגובות לאותו פוסט נשאלתי אם אי אפשר לנפות איכשהו את הפקודות המיותרות מפלט הקומפיילר לפני הצריבה. למיטב ידיעתי, חוץ מחוסר הנוחות של תהליך כזה, הוא יהיה קשה מאד מכיוון שקוד אסמבלי מסתמך במידה רבה על הפניות וקפיצות בין כתובות מדויקות בזיכרון, ומחיקה פשוטה של פקודות תשבש את הכתובות המקוריות.
האם יש משהו שאפשר בכל זאת לעשות? ובכן, מכיוון ש-XC8 מאפשר לנו לשלב פקודות אסמבלי בתוך קוד C, אנחנו יכולים בעיקרון לכתוב ידנית את פקודות ההשמה וכך לעקוף את האנטי-אופטימיזציה המובנית. גם זה לא משהו שנרצה לעשות ביומיום, אבל מטעמי סקרנות וכדי לעשות ל-Microchip דווקא, בואו נראה איך זה מתבצע.
בלינק לדוגמה
כדי להדגים את הנושא כתבתי תוכנית קטנה ב-C ל-PIC12F675, שמגדילה מונה גלובלי בשם x מטיפוס char בלולאה הראשית. בכל פעם ש-x עולה על גדותיו וחוזר ל-0, התוכנה משנה את הפלט בפין GP0. המיקרו-בקר מוגדר לעבודה עם המתנד הפנימי שלו, בתדר שעון של 4MHz (שמיתרגם לתדר פעולות של 1MHz, כי ככה זה ב-PIC). הנה החלק המעניין בקוד:
for ( ; ; ) { x++; if (!x) GPIO ^= 1; }
אחרי קימפול, מהתפריט Window->Debugging->Output->Disassembly Listing File אנחנו מגיעים לפלט האסמבלי. הקטע שמקביל לקוד הנ"ל הוא זה:
47: x++; 03EF 3001 MOVLW 0x1 03F0 1283 BCF STATUS, 0x5 03F1 00A1 MOVWF __pcstackBANK0 03F2 0821 MOVF __pcstackBANK0, W 03F3 07A0 ADDWF x, F 48: if (!x) GPIO ^= 1; 03F4 0820 MOVF x, W 03F5 1D03 BTFSS STATUS, 0x2 03F6 2BEF GOTO 0x3EF 03F7 3001 MOVLW 0x1 03F8 00A1 MOVWF __pcstackBANK0 03F9 0821 MOVF __pcstackBANK0, W 03FA 0685 XORWF GPIO, F 49: } 03FB 2BEF GOTO 0x3EF
השורות שממוספרות 47, 48 ו-49 הן קוד ה-C המקורי, ומה שאחרי כל אחת מהן זה קוד האסמבלי שלה: חמש פקודות להגדלת x, שבע פקודות עבור כל אפשרויות ה-if, ופקודת goto יחידה עבור הלולאה האינסופית. רוב פקודות האסמבלי האלה, אם לא כולן, נמשכות מחזור פעולה יחיד. כלומר, אם נתעלם מהפקודות שמתרחשות רק לעתים רחוקות (כשתנאי ה-if מתמלא), כל סיבוב בלולאה אמור לקחת משהו כמו 8 מחזורים. מכאן שפין הפלט אמור לשנות את מצבו כ-488 פעמים בשניה (מיליון מחזורים בשניה, חלקי 8 פקודות, חלקי 256 צעדים לאיפוס המונה), או פעם ב-2.05 אלפיות שניה. בואו נסתכל בלוגי'ק אנלייזר:
שינוי כל 1.917 אלפיות שניה – מהיר בכמה אחוזים מהחישוב התיאורטי, אך בהחלט באותו מגרש (ייתכן שההבדל נובע מחוסר דיוק של המתנד הפנימי).
באסמבלי זה נשמע יותר טוב
בואו נראה, אם כן, איך כותבים קוד קצר יותר באסמבלי. ראשית, כדי להכניס בכלל קוד אסמבלי לתוכנית, אחת האפשרויות היא להשתמש בכאילו-פונקציה asm, שמקבלת מחרוזת ופשוט שותלת אותה בקוד המקומפל. למשל, אם נכתוב
a = 1; asm("NOP"); b = 2;
אז בקוד האסמבלי, בין פקודות ההשמה של a לפקודות ההשמה של b, תופיע הפקודה NOP (שהיא, אגב, פקודת "אל תעשה כלום למשך מחזור אחד" – No OPeration).
מבין חמש הפקודות שזיהינו קודם, הראשונה (MOVLW) שמה את הערך 1 ברגיסטר W. השניה (BCF) מנקה את הדגלים ברגיסטר סטטוס. הדגלים האלה משמשים לזיהוי גלישה של ערכים, איפוס ותוצאות משמעותיות אחרות של חישובים, ובדרך כלל אסור לוותר עליהם – אם כי לדוגמה הספציפית שלנו נתעלם מהכלל הזה ומהדגלים. שתי הבאות הן פקודות ה"עז" המיותרות, והאחרונה (ADDWF) מוסיפה ל-x את הערך שב-W. מאיפה אני יודע את כל זה? קצת היגיון, והרבה קריאה במסמך המפורט של ה-Instruction Set של משפחת המיקרו-בקרים PICmicro mid-range, שה-PIC12F675 שייך אליה.
בלי העז ובלי הדגלים אפשר, אם כן, לרדת לשתי פקודות בלבד – אך למעשה, יש פקודה אחרת, שמאפשרת לנו להגדיל ערך של משתנה ב-1 ישירות. היא נקראת INCF ומקבלת שני פרמטרים: כתובת בזיכרון, וערך שאומר אם לשמור את תוצאת החיבור חזרה באותה כתובת, או להעביר אותה לרגיסטר W. במסמך הנ"ל הערך הזה מוצג כ-1 או 0, אבל בפועל הסתבר שהקומפיילר מזהה רק את הקודים F או W, בהתאמה. כמו כן, הסתבר שכדי לשלוח את הכתובת של המשתנה x במחרוזת של asm, צריך להוסיף לפניו קו תחתון אחד – וגם אז, זה יעבוד אך ורק אם x הוא משתנה גלובלי. קוד ה-C החדש נראה ככה:
for ( ; ; ) { asm("INCF _x, F"); if (!x) GPIO ^= 1; }
ובתרגום של הקומפיילר לאסמבלי, מקבלים:
46: asm("INCF _x, F"); 03F2 0AA0 INCF x, F 47: if (!x) GPIO ^= 1; 03F3 1283 BCF STATUS, 0x5 03F4 0820 MOVF x, W 03F5 1D03 BTFSS STATUS, 0x2 03F6 2BF2 GOTO 0x3F2 03F7 3001 MOVLW 0x1 03F8 00A1 MOVWF __pcstackBANK0 03F9 0821 MOVF __pcstackBANK0, W 03FA 0685 XORWF GPIO, F 48: } 03FB 2BF2 GOTO 0x3F2
בלי שנגענו בכלל ב-if ובפעולת ה-xor (שגם בה הושתלו פקודות מיותרות), ירדנו משמונה פקודות בלולאה טיפוסית לחמש בלבד. לפי הפרופורציה הזו, משך הזמן בין שינוי לשינוי בפין הפלט צריך לרדת עכשיו ל-1.198 אלפיות השניה. נחזור ללוג'יק ונראה:
ירדנו ל-1.275 אלפיות שניה, איטי מעט מהצפוי אבל שוב, בהחלט בסביבות הנכונות.
אז מה?
בשנת 2015, תכנות באסמבלי שמור למשוגעים-לדבר או למקרים קיצוניים ביותר, ויש בו כל כך הרבה מוקשים שברוב המקרים עדיף פשוט לשלם על הקומפיילר וזהו. אף על פי כן, בשביל הפרנציפ ובשביל התרגול, היה נחמד לגלות שאפשר לעקוף את העז – וייתכן שבפרויקט עתידי שיקום או ייפול על שברירי השניה האלה, טריק כמו שעשיתי למעלה יציל את המצב.
נכון, אבל גם בפתרון שלך אתה לא מתחמק מהם לגמרי (3F8 & 3F9) – אני מציע פשוט במקום לנסות להתחמק מהפקודות הללו באמצעות אופרטורים בקוד (שהם לא כל כך נוחים, ומחייבים החלפה של כל קוד שארצה להריץ ולנסות…) להחליף את כל המופעים של צמד הפקודות הללו ב NOP.
וכפי שציינתי קודם לכן – זה מגעיל. אבל זו אפשרות נוספת, וזה כל שרציתי להציע.
זיגי, אתה מדרום ירושלים במקור?
כיון שזהו CONST קבוע (שתי פקודות שמרכיבות DWORD 0x00A10221) אפשר להחליף אותו בקובץ הריצה ל NOPs. מגעיל, ועדין יקח 4 מחזורים ריקים על כל פקודה כזו, אבל גם פתרון.
אולי אפשר גם לשחק עם הקובץ של הלינקר שיעשה את זה אוטומטית.
לא הבנתי מה זה נותן – הבעיה היא לא הזהות של הפקודות אלא עצם הנוכחות שלהן…