לדלג לתוכן

פירוק מחסנית וספריות ריצה

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

בשיעור הקודם ראינו שהמהדר יכול לשנות את RSP ולשמור רגיסטרים. עכשיו צריך להבין כיצד משחזרים את מצב הקורא אחרי השינויים האלה. החוזה חשוב גם לתחקיר crash וגם לכל מי שכותב קוד מכונה, JIT או אסמבלי.

מייצרים מחסנית עם חיים גלויים

התוכנית הבאה יוצרת שני אובייקטים ומכשילה את הפעולה הפנימית. שומרים אותה בשם unwind.cpp.

#include <cstdio>
#include <stdexcept>

struct Marker {
    const char* name;
    explicit Marker(const char* value) : name(value) {
        std::printf("enter %s\n", name);
    }
    ~Marker() noexcept {
        std::printf("leave %s\n", name);
    }
};

__declspec(noinline)
void inner() {
    Marker object("inner");
    throw std::runtime_error("planned failure");
}

__declspec(noinline)
void outer() {
    Marker object("outer");
    inner();
}

int main() {
    try {
        outer();
    } catch (const std::exception& error) {
        std::printf("caught %s\n", error.what());
    }
}
cl /nologo /std:c++20 /EHsc /W4 /O2 /Zi /FAs /Faunwind.asm unwind.cpp /Feunwind.exe /link /DEBUG
dumpbin /headers unwind.exe

הסדר הצפוי הוא כניסה לחיצוני, כניסה לפנימי, יציאה מהפנימי, יציאה מהחיצוני ולבסוף catch. ההשמדה נובעת מכללי C++ ומקוד העזר שהמהדר יצר. אין צורך שה־runtime ידע מה משמעות השם Marker; הוא צריך להגיע למידע המתאים ולהפעיל את פעולות הניקוי שנוצרו.

מידע על שחזור אינו משתנה מקומי

ב־Windows x64 קיימות רשומות שמתארות טווחי פונקציות ומצביעות אל unwind information. המידע מתאר פעולות כמו הקטנת המחסנית ושמירת רגיסטר nonvolatile. חלק מהמידע נמצא ב־sections שמקובל לראות כ־.pdata ו־.xdata, אך צריך לקרוא את טבלאות ה־image ולא להסתמך רק על שם section.

ה־unwinder משתמש בכתובת ההוראה כדי למצוא את התיאור המתאים. הוא צריך לדעת גם אם ההוראה נמצאת בתוך prolog, בתוך גוף הפונקציה או בתוך epilog, מפני שלא כל השינויים כבר התרחשו בכל נקודה.

המידע לשחזור מצב המכונה והמידע להפעלת destructors הם שכבות קשורות אך שונות. הראשון מאפשר לשחזר את הקורא; מנגנון החריגות של C++ מוסיף מידע על מצבי אובייקטים, handlers וניקוי. לא כל מסגרת מחסנית מכילה אובייקט שצריך להשמיד.

קוראים מחסנית אופטימלית בזהירות

פתחו את התוכנית בדיבאגר ועצרו בזמן זריקת החריגה. בדקו את call stack ואת האסמבלי של outer. נסו לזהות היכן נוצר האובייקט והיכן נמצאת פעולת הניקוי. בגרסה אופטימלית המשתנה object לא חייב להופיע כאזור רציף שניתן לצפות בו בשם המקור.

פונקציה שעברה inlining יכולה להופיע בדיבאגר כמסגרת לוגית בלי קריאת call פיזית נפרדת. לעומת זאת, tail call עשויה להקטין את שרשרת המסגרות הפיזית. לכן אין להסיק שחסרה קריאה בתוכנית רק מפני שלא רואים אותה כפי שנראתה ב־debug.

גם מחסנית שמוצגת יפה אינה הוכחה שכל ה־bytes תקינים. אם כתובת חזרה או מידע אחר הושחתו, חלק מהשרשרת עלול להיות שגוי. צריכים להתאים את הפלט לסמלים, לטווחי modules ולהוראות שמסביב.

חריגת C++ אינה כל תקלה במעבד

האפשרות /EHsc בונה תמיכה בחריגות C++ לפי מודל מסוים. אין להניח ש־catch(...) בדוגמה יתפוס access violation ויהפוך השחתת זיכרון לכשל עסקי רגיל.

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

שינוי אפשרויות EH כדי להעלים crash יכול גם לשנות הנחות אופטימיזציה וליצור תאימות לא מתוכננת בין רכיבים. צריך להבין את החוזה לפני שינוי ההגדרה.

אותה כתובת אינה אותה בעלות

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

ממשק יציב חושף זוג פעולות, כמו buffer_create ו־buffer_destroy, כך שהרכיב המקצה אחראי גם לשחרור. חלופה אחרת היא שהלקוח מספק buffer ואורך. גם במצב שבו רכיבים חולקים CRT תואם, חוזה בעלות מפורש מונע תלות מיותרת בפרטים חבויים.

האפשרויות /MD ו־/MT משנות את דרך השימוש ב־CRT. אין כלל שאומר שכל מעבר pointer בין DLLים אסור, וגם אין כלל שאומר ששימוש באותו שם של פונקציית free הופך כל מעבר למותר. השאלה היא איזה משאב נוצר, באיזה מנהל ואיזה צד מחויב להשמיד אותו.

מסמנים גבול לפני שזורקים

בגבול C facade מתרגמים חריגות לקוד שגיאה. בתוך רכיב C++ אפשר להמשיך להשתמש ב־RAII ובחריגות, אך הלקוח אינו נדרש להכיר את טיפוס החריגה או את runtime החריגות של המימוש.

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

לקריאה נוספת: פירוק מחסנית וחריגות ב־x64, אפשרויות מודל החריגות והעברת אובייקטי CRT בין רכיבים.