תחילת תוכנית והטוען¶
נניח שהדיבאגר נעצר בבנאי של אובייקט גלובלי, למרות שעוד לא הגענו ל־main. זו אינה קריאה נסתרת שכתבנו בעצמנו, אלא חלק מהקמת סביבת הריצה. נקודת הכניסה ב־PE והפונקציה main נמצאות בשכבות שונות.
ב־EXE רגיל של C++ ב־Windows יש עבודת טוען, callbacks מסוימים של TLS לפי ה־image, כניסה לקוד startup של ה־CRT, אתחול שנדרש לשפה ואז קריאה לפונקציית המשתמש. הסדר המדויק של רכיבים שונים תלוי גם בסוג ה־image ובתצורת הבנייה. בשיעור הזה נלמד לזהות את השכבות בלי להפוך פרט מימוש ל־API שלנו.
רואים פעולה שמקדימה את main¶
ניצור startup.cpp.
#include <cstdio>
int initialize_value() {
std::puts("initializing global");
return 37;
}
int global_value = initialize_value();
int main() {
std::printf("main: %d\n", global_value);
}
cl /nologo /std:c++20 /EHsc /W4 /Od /Zi /c startup.cpp
link startup.obj /DEBUG /OUT:startup.exe
dumpbin /all startup.obj
שימו breakpoint ב־initialize_value והריצו מהתחלה. במחסנית תראו קוד של runtime שקורא לאתחול שהמהדר רשם. חפשו בקובץ האובייקט section מתאים לאתחול דינמי, ובתוצר הסופי בדקו את entry point.
ה־CRT צריך להכין את סביבתו לפני שימוש רגיל בחלק מהשירותים שלו. לכן קוד שמופעל מוקדם במיוחד אינו יכול להניח שכל אובייקט גלובלי כבר חי. העובדה ש־printf עובד בבנאי גלובלי רגיל אינה הוכחה שהוא מתאים לכל callback מוקדם.
קוד TLS יכול להקדים את נקודת הכניסה¶
טבלת TLS ב־PE יכולה לתאר גם מערך callbacks שהטוען מפעיל. בטעינת EXE טיפוסית הם יכולים לרוץ לפני image entry point, ולכן לפני מסלול CRT startup שמוביל ל־main.
אין צורך להוסיף callback ידני לכל תוכנית כדי לחקור אותו. בדקו את TLS directory של image שבניתם ואת הסמלים המתאימים אם הם קיימים. תצורת מהדר מסוימת עשויה להפיק מנגנוני TLS נוספים בעצמה. אם הטבלה ריקה, זו תוצאה תקפה של הניסוי ולא כשל שצריך להסתיר.
כתיבת entries ידנית ב־sections פנימיים של CRT היא תרגיל תלוי כלים, ולא דרך רגילה לאתחול מוצר. שינוי שם section או סדר קישור עלול לשנות התנהגות. בקוד היישומי נעדיף חוזה מפורש שמופעל אחרי שהטעינה הושלמה.
ה־loader lock הוא חלק ממפת הנעילות¶
במהלך פעולות מסוימות של טעינה, הטוען מחזיק נעילה פנימית שמגנה על מצב ה־modules. פונקציית DllMain פועלת בהקשר הזה. אם היא מנסה לבצע עבודה שמובילה שוב לטעינה, או מחכה ל־thread שזקוק למצב הטוען, אפשר ליצור מעגל המתנה.
נניח ש־thread אחד נמצא ב־DllMain וממתין לאירוע ש־thread חדש אמור לסמן. ה־thread החדש צריך לעבור הודעות אתחול של DLLים לפני תחילת הפונקציה שלו. אם ההודעות האלה מחכות לטוען, הקוד שב־DllMain מחכה לתוצאה שלא יכולה להתרחש עד שיצא. הבעיה אינה מהירות אלא תלות מעגלית.
גם קריאה שנראית תמימה יכולה לטעון רכיב מאחורי הקלעים. לכן לא מספיק להימנע מקריאה ישירה ל־LoadLibrary. צריך לצמצם את העבודה המוקדמת ולהכיר את החוזה של כל פעולה שמבוצעת בה.
דוחים עבודה לנקודה שבה אפשר להיכשל¶
ממשק ספרייה טוב יכול להפריד טעינה, יצירת מופע ושימוש. הקוד הבא הוא מימוש DLL קטן שמקבל קיבולת מפורשת ומקצה state רק כשלקוח מבקש זאת. שומרים אותו כ־context.cpp.
#include <cstddef>
#include <new>
#include <vector>
struct Context {
std::vector<unsigned char> buffer;
explicit Context(std::size_t size) : buffer(size) {}
};
extern "C" __declspec(dllexport)
int __cdecl context_create(std::size_t capacity, void** out) noexcept {
if (!out) return 1;
*out = nullptr;
if (capacity == 0 || capacity > 1024 * 1024) return 2;
try {
*out = new Context(capacity);
return 0;
} catch (const std::bad_alloc&) {
return 3;
} catch (...) {
return 4;
}
}
extern "C" __declspec(dllexport)
void __cdecl context_destroy(void* value) noexcept {
delete static_cast<Context*>(value);
}
לא כתבנו DllMain משלנו. קוד ה־startup הנדרש ל־CRT עדיין קיים, אבל האתחול העסקי אינו נעשה בבנאי גלובלי. הלקוח יכול לקבל שגיאה מסודרת מ־context_create, לתקן הגדרה או לבחור לוותר על היכולת.
המימוש אינו מחזיק מופע גלובלי יחיד. כל context חי עד שהלקוח משמיד אותו. בפרקים הבאים נוסיף עבודה אסינכרונית, ואז חוזה ההשמדה יצטרך לכלול עצירה והמתנה לכל המשתמשים במצב.
סדר אתחול בין יחידות תרגום¶
כאשר שני אובייקטים גלובליים בקובצי מקור שונים תלויים זה בזה, סדר האתחול הדינמי יכול להיות מקור לבאג. מעבר הקוד לקובץ אחר או שינוי אפשרויות link עשויים לחשוף אותו. פתרון פשוט הוא להוציא את התלות לאובייקט שמקבל את מה שהוא צריך בבנאי ונוצר בסדר מפורש ב־main.
אתחול lazy באמצעות local static יכול לפתור חלק מהתלות ולהשתמש בערבות האתחול הבטוח של השפה, אבל אינו פותר אוטומטית בעיות shutdown, recursion באתחול או unloading של DLL. כל פתרון צריך להגדיר גם את סוף החיים.
חקירה מתחילה לפני נקודת הנוחות¶
אם תוכנית נכשלת לפני main, breakpoint ב־main לא יספר את הסיפור. צריך להפעיל את התוכנית תחת דיבאגר מתחילת הטעינה, להתאים symbols ולזהות את הרכיב שבו התקלה מתרחשת. שינוי main לא יכול לתקן בנאי שכבר נכשל לפניו.
לקריאה נוספת: אתחול ה־CRT וכללי תכנון של DLL.