לדלג לתוכן

חקירת קריסה וסמלים

המשתמש שולח צילום של הודעת קריסה. על המחשב שלנו התוכנית עובדת. כדי להתקדם צריך לשמר ראיות מהמקום שבו הכשל התרחש: הקוד המדויק שרץ, מצב הthread, האוגרים, המחסנית ולעיתים גם תוכן הזיכרון.

הקובץ dump הוא snapshot עם מידע שנבחר בזמן האיסוף. הוא אינו הקלטה של כל ההיסטוריה. אם כתיבה לא תקינה התרחשה לפני עשר דקות ורק עכשיו קראנו את המצביע הפגום, המחסנית הנוכחית מציגה את הקורבן ולא בהכרח את הכותב.

נייצר כשל שאנחנו מבינים

נתחיל מתוכנית מעבדה שמקצה דף ללא הרשאת גישה ומנסה לכתוב אליו. יש בה גם מסלול של חריגת C++ שנתפסת כראוי. ההבדל מאפשר לראות מדוע לא כל exception שמופיע בdebugger הוא קריסה.

שמרו כfault.cpp. הדוגמה מיועדת לMSVC על Windows x64. מסלול fault מסיים את תהליך המעבדה בכוונה ומנסה ליצור dump בתיקיית העבודה שלו. לא מריצים אותו בתוך תהליך של יישום אחר.

#include <windows.h>
#include <dbghelp.h>
#include <strsafe.h>
#include <cstdio>
#include <cstring>
#include <stdexcept>

LONG WINAPI collect_dump(EXCEPTION_POINTERS* exception) {
    static volatile LONG entered = 0;
    if (InterlockedCompareExchange(&entered, 1, 0) != 0)
        return EXCEPTION_EXECUTE_HANDLER;
    wchar_t name[80]{};
    StringCchPrintfW(name, 80, L"course-crash-%lu.dmp", GetCurrentProcessId());
    HANDLE file = CreateFileW(name, GENERIC_WRITE, 0, nullptr, CREATE_NEW,
                              FILE_ATTRIBUTE_NORMAL, nullptr);
    if (file != INVALID_HANDLE_VALUE) {
        MINIDUMP_EXCEPTION_INFORMATION info{};
        info.ThreadId = GetCurrentThreadId();
        info.ExceptionPointers = exception;
        info.ClientPointers = FALSE;
        const auto type = static_cast<MINIDUMP_TYPE>(
            MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithFullMemoryInfo);
        const BOOL saved = MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(),
                                            file, type, &info, nullptr, nullptr);
        const DWORD error = saved ? ERROR_SUCCESS : GetLastError();
        CloseHandle(file);
        std::printf("dump_saved=%d error=%08lx\n", saved != FALSE, error);
    }
    return EXCEPTION_EXECUTE_HANDLER;
}

__declspec(noinline) int write_byte(volatile unsigned char* address) {
    *address = 0x5a;
    return 7;
}

__declspec(noinline) int process_page(volatile unsigned char* address) {
    return write_byte(address) + 3;
}

int main(int argc, char** argv) {
    if (argc != 2 || (std::strcmp(argv[1], "caught") && std::strcmp(argv[1], "fault"))) {
        std::fputs("usage: fault.exe caught|fault\n", stderr);
        return 2;
    }
    if (std::strcmp(argv[1], "caught") == 0) {
        try { throw std::runtime_error("expected application failure"); }
        catch (const std::exception& error) {
            std::printf("handled: %s\n", error.what());
            return 0;
        }
    }
    SetUnhandledExceptionFilter(collect_dump);
    auto page = static_cast<volatile unsigned char*>(
        VirtualAlloc(nullptr, 4096, MEM_RESERVE | MEM_COMMIT, PAGE_NOACCESS));
    if (!page) return 3;
    std::printf("protected address=%p\n", static_cast<void*>(const_cast<unsigned char*>(page)));
    std::fflush(stdout);
    const int result = process_page(page);
    VirtualFree(const_cast<unsigned char*>(page), 0, MEM_RELEASE);
    return result;
}

נבנה תחילה גרסה ללא אופטימיזציה, ונשמור exe וPDB יחד בתיקיית build נפרדת.

cl /std:c++20 /EHsc /Zi /Od /W4 fault.cpp dbghelp.lib /link /DEBUG:FULL
fault.exe caught
fault.exe fault

הדף מוקצה בגודל המעוגל שהמערכת דורשת, והכתיבה הראשונה אמורה להיכשל בגלל ההגנה. אנחנו משתמשים בכתובת מוחזרת מVirtualAlloc ובגישה volatile כדי שהניסוי ייצור גישת זיכרון אמיתית בסביבת הקומפיילר שנבחרה. ההקצאה אינה דליפה מתמשכת במוצר: התהליך מסתיים כחלק מניסוי הקריסה, והמערכת מפרקת את מרחב הכתובות שלו.

האוסף עצמו יכול להיפגע

הfilter בתוך התהליך הוא דרך לימודית לראות את EXCEPTION_POINTERS ואת מבנה הבקשה לMiniDumpWriteDump. הוא אינו מנגנון איסוף שאפשר לסמוך עליו לכל קריסה. כשל מסוג stack overflow, heap פגום או loader lock יכולים למנוע ממנו לעבוד. גם printf בתוך מסלול קריסה אינו logging אמין למוצר.

התיעוד ממליץ לבצע איסוף מתהליך אחר כשאפשר, ומציין שקריאות DbgHelp דורשות סנכרון. נבדוק זאת בחוזה כתיבת dump. המנגנון המקומי שלנו משתמש בשער כניסה יחיד, אבל זה אינו פותר מצב שבו כל התהליך כבר אינו יציב.

לניסוי איסוף חיצוני אפשר להשתמש בProcDump של Sysinternals, לאחר התקנת הכלי במעבדה. הפקודה הבאה מפעילה רק את תוכנית הבדיקה ואוספת על חריגה שלא טופלה. נגדיר מראש תיקיית dumps קיימת ונשמור את התוצר עם קובצי הbuild.

procdump -ma -e -x C:\AmitTechLab\dumps C:\AmitTechLab\build\fault.exe fault

האיסוף החיצוני והfilter הפנימי הם שני ניסויים חלופיים. כאשר debugger מחובר, מסלול הדיווח משתנה ואין לצפות בהכרח להפעלת הfilter שלנו. הפרטים של flags האיסוף מופיעים בתיעוד ProcDump.

פותחים את הראיות

נפתח את הdump בWinDbg ונגדיר symbols של המערכת ושל הbuild המדויק. נתיב הPDB המקומי בדוגמה צריך להצביע לתיקייה ששמרנו, לא לתוצאה חדשה של build שביצענו אחרי הקריסה.

.symfix C:\AmitTechLab\symbols
.sympath+ C:\AmitTechLab\build
.reload /f
!analyze -v
.exr -1
.ecxr
r
kv
lmvm fault
u @rip-20 @rip+20

הפקודה .exr -1 מציגה את רשומת החריגה. הפקודה .ecxr מעבירה את ההקשר המוצג לאוגרים של החריגה, כדי שלא נחקור בטעות את המחסנית של קוד האיסוף. אחרי המעבר נבדוק את ההוראה בRIP, את הכתובת שאליה היא מנסה לגשת ואת סוג הגישה.

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

התאמה של symbols אינה עניין של שם

קובץ PDB בשם fault.pdb אינו בהכרח הקובץ הנכון. ההתאמה מבוססת על זהות הdebug information של הבינארי. נשמור build ID, hash של exe וDLLים וPDBים המתאימים. שימוש בsymbols של גרסה אחרת יכול לתת שמות ושורות מטעים דווקא כשהפלט נראה משכנע.

בגרסה עם אופטימיזציה משתנים יכולים להימחק, לחיות באוגרים או להשתנות לאורך אותה פונקציה. פונקציות יכולות להיות inlined, וtail calls יכולים לשנות את מראה המחסנית. אין הבטחה שכל משתנה מקור יהיה זמין בdump. נבנה גם עם /O2 /Zi ונבדוק כיצד ההסבר משתנה, תוך שמירה על artifact נפרד לכל build.

חריגת שפה מול חריגת מערכת

במסלול caught, חריגת C++ עוברת לcatch שמכיר אותה. הdebugger יכול לעצור בfirst chance לפני שהקוד מקבל הזדמנות לטפל בה. אם ממשיכים והיא מטופלת, אין קריסה.

בsecond chance התשתית לא מצאה טיפול שממשיך את הביצוע לפי המסלול הרגיל, והdebugger מקבל הזדמנות נוספת. גישה לדף אסור היא חריגת מערכת, וcatch (std::exception&) תחת /EHsc אינו דרך כללית לתפוס אותה. שינוי ל/EHa אינו הוכחה שהמצב בטוח להמשך אחרי השחתת זיכרון.

יש שימושים מכוונים בSEH, למשל גבול מצומצם של בדיקה במערכת, אבל לא נהפוך כל access violation לתוצאה עסקית רגילה. קודם נבין איזה invariant הופר, אילו writes כבר התרחשו והאם יש דרך תקינה להחזיר את המערכת למצב ידוע.

מה חסר בקובץ

הדגל MiniDumpWithFullMemoryInfo מוסיף מידע על אזורי זיכרון; הוא אינו שקול לMiniDumpWithFullMemory, שמבקש את תוכן הזיכרון. לכן העובדה שאפשר לראות protection וגבולות של אזור אינה אומרת שאפשר לקרוא את כל הbytes שלו מהdump.

נבחר סוג איסוף לפי השאלה. מחסנית ואוגרים יכולים להספיק לכשל המקומי שלנו. חקירת graph של אובייקטים או heap corruption עשויה לדרוש מידע נוסף. יותר זיכרון גם אומר קובץ גדול יותר ויותר מידע של המשתמש, ולכן חבילת האבחון תציין מה נאסף, מי ניגש אליו ואיך הוא נשמר. בפרויקט נשתמש בנתוני מעבדה קבועים כדי שאפשר יהיה לשתף את הראיות בלי לחשוף תוכן אמיתי.