DllMain ונעילת הטוען — הסיבה האמיתית שאומרים לכם "לא לעשות כלום באתחול DLL"
· Go Komura · Windows, DLL, פיתוח Windows, C++, חקירת תקלות, ריבוי תהליכונים, Win32 API
“היישום נתקע באתחול, אבל רק בסביבה מסוימת.” “כשאנחנו טוענים DLL משלנו, LoadLibrary לפעמים לא חוזר.” “זה מקפיא רק בתזמון של התחלת שירות.” — עקבו אחרי חקירות כאלה רחוק מספיק, ויותר פעמים משנדמה מגיעים לאותו מקום. קוד האתחול של ה-DLL — כלומר DllMain.
התיעוד של מיקרוסופט מזהיר על DllMain בטון חריג בחוזקו. אל תקראו ל-LoadLibrary. אל תסתנכרנו עם תהליכונים אחרים. אל תקראו לפונקציות User, Shell או COM. DllMain האידיאלי הוא stub ריק — למה השפה כל כך חזקה? הסיבה מתרכזת במנגנון פנימי אחד, נעילת הטוען. מיועד למפתחות ולמפתחים שכותבים DLL, תוספים ועטיפות C++/CLI ב-Windows, המאמר הזה מסביר ממקורות ראשוניים איך נעילת הטוען עובדת, המבנה שגורם לקיפאון להחזיק, והתכנון הבטוח והליך החקירה.
1. השורה התחתונה קודם
DllMainנקרא בזמן שמחזיקים את נעילת הטוען, נעילה משותפת שיש ממנה בדיוק אחת לתהליך. לכן קריאה, מתוךDllMain, לעבודה שמנסה לקחת את נעילת הטוען (ישירות או בעקיפין) יוצרת אפשרות לקיפאון, או לקריסה ממגע ב-DLL שעוד לא אותחל.1- קריאה ל-
LoadLibrary/FreeLibraryאסורה. היא יוצרת תלות מעגלית בסדר הטעינה ויכולה לגרום לקוד אתחול לרוץ מול DLL שהאתחול שלו עצמו עוד לא רץ.2 - סנכרון עם תהליכונים אחרים גם אסור. הודעות DLL מסודרות בסדרה, כך שהמתנה בתוך
DllMainלתחילת תהליכון או ליציאתו משאירה את התהליכון עצמו עצור בהמתנה לנעילת הטוען, ואתם מקפיאים.23 - מה שאפשר לקרוא בבטחה הוא, בפועל, רק תת-קבוצה של Kernel32.dll. והתיעוד הרשמי קובע במפורש ש”רשימה מלאה של פונקציות בטוחות אינה קיימת”. פונקציות User, Shell ו-COM טוענות רכיבים אחרים וגורמות להפרות גישה.2
- ב-DLL שמקושר עם CRT, אותן מגבלות חלות על בונים והורסים של גלובלים. הם רצים כחלק דה-פקטו מ-
DllMain.2 - התכנון הנכון הוא “לדחות”. עשו מה שאפשר באתחול בזמן קומפילציה (סטטית); דחו מה שאי אפשר עד השימוש הראשון. זו שיטת העבודה הרשמית.1
- DLL מעורבים של C++/CLI מסוכנים במיוחד. כדי להימנע מהרצת MSIL תחת נעילת הטוען,
DllMainועץ הקריאות שלו חייבים להיות מתורגמים נייטיב.4
2. מתי ואיך DllMain נקרא
DllMain הוא נקודת הכניסה שטוען מערכת ההפעלה קורא לה כש-DLL נכנס לתהליך או יוצא ממנו, או כשנכנס תהליכון או יוצא. יש ארבע הודעות.
| הודעה | תזמון |
|---|---|
| DLL_PROCESS_ATTACH | כשה-DLL נטען לתהליך |
| DLL_THREAD_ATTACH | כשתהליכון חדש מתחיל בתהליך |
| DLL_THREAD_DETACH | כשתהליכון יוצא באופן רגיל |
| DLL_PROCESS_DETACH | כשה-DLL נפרק, או כשהתהליך יוצא |
שתי עובדות קל לפספס. ראשית, בכל פעם שתהליכון בודד נוצר, DllMain של כל DLL שכבר טעון נקרא עם DLL_THREAD_ATTACH. כלומר DllMain אינו “משהו שרץ פעם אחת כשה-DLL שלי נטען”; זה קוד שממשיך להיקרא לפעילות התהליכונים של התהליך. אם אינכם צריכים את זה, אפשר לעצור את זה בקריאה ל-DisableThreadLibraryCalls בתוך DLL_PROCESS_ATTACH (אל תקראו לזה מ-DLL שמקושר עם CRT סטטי).5
שנית, ב-DLL שמקושר עם CRT (זמן הריצה של C/C++), בונים והורסים של אובייקטי C++ גלובליים וסטטיים רצים, דרך נקודת הכניסה של ה-CRT, כחלק מ-DllMain.2 גם אם אתם חושבים “DllMain שלנו ריק, אז אנחנו בטוחים”, אובייקט גלובלי עם אתחול משוכלל זהה להרצת העבודה הזו ב-DllMain.
flowchart TB
accTitle: ארבעת התזמונים שבהם DllMain נקרא
accDescr: DLL_PROCESS_ATTACH רץ בטעינת DLL; DLL_THREAD_ATTACH ו-DETACH רצים על כל DLL שכבר טעון בכל התחלת תהליכון ויציאה בתהליך; DLL_PROCESS_DETACH רץ בפריקה או ביציאת תהליך; ובונים של אובייקטים סטטיים רצים גם כאן דרך ה-CRT
load["טעינת DLL"] --> pa["DLL_PROCESS_ATTACH"]
pa --> ta["DLL_THREAD_ATTACH(בכל התחלת תהליכון)"]
ta --> td["DLL_THREAD_DETACH(בכל יציאת תהליכון)"]
td --> pd["DLL_PROCESS_DETACH(בפריקה או ביציאה)"]
pa -.-> crt["בניית אובייקטים סטטיים רצה גם כאן"]
איור 1: DllMain נקרא לא רק בטעינה אלא בכל התחלת תהליכון ויציאה, ואתחול אובייקטים סטטיים רץ גם כחלק מזה.
3. נעילת הטוען — נעילה אחת שמסדרת כל הודעה
למה המגבלות על DllMain לבדו חמורות כל כך? התשובה במבנה של הטוען.
כדי לשמור על עקביות של סדרת פעולות — טעינת DLL, פריקה, וההודעות השונות — טוען מערכת ההפעלה מסדר את העבודה בנעילת טוען אחת לתהליך. והנקודה החשובה היא שDllMain נקרא בזמן שנעילת הטוען הזו מוחזקת.1 כל עוד אתם בתוך DllMain, כל טעינת DLL אחרת בתהליך, וכל הודעת התחלת תהליכון, ממתינות לשחרור הנעילה הזו.
מהמבנה הזה הסיבות לאיסורים נובעות זו אחר זו.
- אסור לקרוא ל-
LoadLibraryכי זה יוצר כניסה חוזרת לנעילת הטוען, או תלות מעגלית בסדר הטעינה. זה יכול גם לגרום לקריאה לפונקציה ב-DLL שהאתחול שלו עוד לא הסתיים.2 - סנכרון עם תהליכונים אחרים מסוכן כי התהליכון שאתם ממתינים לו יש רגעים שבהם הוא צריך את נעילת הטוען (הודעות בהתחלה וביציאה, קריאות ל-API ממשפחת
GetModuleHandleוכדומה). אתם מחזיקים את נעילת הטוען וממתינים לצד השני; הצד השני ממתין לנעילת הטוען — היפוך סדר נעילות קלאסי.6 - פונקציות User, Shell ו-COM מסוכנות כי הן טוענות רכיבי מערכת אחרים בפנים. אתם נוגעים ברכיב לפני שהוא אותחל, או אחרי שפורק, ומקבלים הפרת גישה.2
sequenceDiagram
accTitle: למה המתנה לתהליכון בתוך DllMain מקפיאה
accDescr: DllMain, שמחזיק את נעילת הטוען, ממתין ליציאת תהליכון עובד, אבל העובד שמנסה לצאת ממתין לשחרור נעילת הטוען כדי לקבל DLL_THREAD_DETACH, ולכן הם ממתינים זה לזה ומקפיאים
participant L as טוען(מחזיק נעילה)
participant D as DllMain
participant W as עובד
L->>D: DLL_PROCESS_DETACH
D->>W: בקשת יציאה והמתנה
W->>W: סיום עבודה, ואז יציאה
Note over W: הודעת יציאה צריכה את הנעילה
Note over D,W: DllMain מחזיק נעילה, W ממתין
איור 2: “DllMain ממתין ליציאת תהליכון” הוא קיפאון מבני, כי יציאת תהליכון עצמה צריכה את נעילת הטוען.
הנקודה היא שזה אינו מסוג הדברים ש”קורים אם אתם לא בני מזל”; זה מובטח מבנית להחזיק. התיעוד אומר לכם להתייחס לנעילת הטוען כאל ראש היררכיית הנעילות שהיישום מגדיר (זו שנלקחת ראשונה). בתוך DllMain אתם כבר מחזיקים את הנעילה העליונה הזו, כך שכל מעשה להמשיך משם ולהמתין למשהו אחר מסוכן — זו דרך שימושית לזכור זאת.6
flowchart TB
accTitle: היפוך סדר נעילות בין נעילת הטוען לנעילה פרטית
accDescr: DllMain, שמחזיק את נעילת הטוען, הולך לקחת נעילה פרטית, בזמן שעובד, שמחזיק את הנעילה הפרטית הזו, הולך לקחת את נעילת הטוען ל-GetModuleHandle או דומה, כך שסדר הלקיחה מתהפך והם מקפיאים
d["DllMain: מחזיק את נעילת הטוען"] --> dg["הולך לקחת נעילה פרטית G"]
w["עובד: מחזיק נעילה פרטית G"] --> wl["הולך לקחת את נעילת הטוען"]
dg -.-> dead["קיפאון מסדר לקיחה הפוך"]
wl -.-> dead
wl -.-> api["GetModuleHandle ודומים דורשים אותה בפנים"]
איור 3: גם API תמים כמו GetModuleHandle דורש את נעילת הטוען בפנים, כך שהיפוך סדר עם נעילה פרטית יכול להחזיק.
גם קריאה ל-CreateThread מתוך DllMain עצמו אינה מומלצת. התהליכון שנוצר צריך את נעילת הטוען כדי לעבד את הודעת DLL_THREAD_ATTACH, ולכן אינו יכול להתחיל לרוץ עד שה-DllMain שרץ כרגע חוזר ומשחרר את הנעילה. לכן המתנה בתוך DllMain לתחילת התהליכון או לסיומו היא קיפאון מיידי. יש גם בעיית אורך חיים — אם, אחרי ש-DllMain חוזר, ה-DLL נפרק בזמן שתהליכון שעוד לא התחיל לרוץ נשאר מאחור, כתובת ההתחלה של התהליכון עדיין מצביעה על קוד שכבר שוחרר ואתם קורסים.3
4. שני מוקשים שמפתחי C++ דורכים עליהם בקלות
מוקש 1: אתחול דינמי של אובייקטים גלובליים. כפי שאמר פרק 2, בונים של אובייקטים סטטיים רצים תחת מגבלות DllMain. קריאת קובץ הגדרות, הקמת מתקן רישום, אתחול COM, הפעלת תהליכון — ברגע ששמים ב-DLL גלובל שהבנאי שלו עושה עבודה כזו, אתם מריצים “דברים שאסור לעשות ב-DllMain”. אתחול קבוע שנקבע בזמן קומפילציה (כל מה שאפשר לעשות constexpr) בטוח; אתחול שכולל קריאת פונקציה צריך להידחות.
flowchart TB
accTitle: הנתיב שבו אתחול אובייקט גלובלי הופך למוקש
accDescr: נעילת הטוען נלקחת בטעינת DLL, ובונים של אובייקטים גלובליים רצים דרך נקודת הכניסה של CRT, כך ש-LoadLibrary, סנכרון תהליכונים ואתחול COM בתוך הבונים האלה הם הרצות של איסורי DllMain
load["טעינת DLL(נעילת הטוען נלקחה)"] --> crt["נקודת הכניסה של CRT"]
crt --> ctor["בנאי של אובייקט גלובלי"]
ctor --> ng1["עבודה שקולה ל-LoadLibrary"]
ctor --> ng2["הפעלת תהליכון והמתנה לסיומו"]
ctor --> ng3["שימוש ב-COM או User32"]
ng1 -.-> risk["כל אלה נופלים תחת איסורי DllMain"]
ng2 -.-> risk
ng3 -.-> risk
איור 4: גם “DllMain ריק, אז אנחנו בטוחים” מחיה את אותה סכנה ברגע שיש גלובל עם אתחול משוכלל.
מוקש 2: C++/CLI (assemblies מעורבים). בתצורה שעוטפת DLL נייטיב ב-C++/CLI (הצורה שמכוסה במאמר העטיפה), יש סכנה להריץ MSIL (קוד מנוהל) תחת נעילת הטוען. הרצת MSIL יכולה להפעיל אתחול CLR או טעינת assembly אחר. המהדר פולט אזהרת C4747 על קוד שבו DllMain מריץ MSIL ישירות, אבל הוא לא יכול לזהות הרצה עקיפה דרך פונקציה במודול אחר. תרגמו את DllMain ואת הפונקציות שנקראות ממנו כנייטיב עם #pragma unmanaged, או השתמשו בתצורה שאין בה DllMain בכלל.4
flowchart TB
accTitle: האם אפשר לזהות הרצת MSIL תחת נעילת הטוען
accDescr: קוד שבו DllMain מריץ MSIL ישירות המהדר יכול לזהות באזהרת C4747, אבל הרצה עקיפה דרך פונקציה במודול אחר לא, לכן צריך למנוע זאת בסקירת עץ הקריאות ובהתעקשות על תרגום נייטיב
d2["קריאות מ-DllMain"] --> dir["הרצת MSIL ישירות"]
d2 --> ind["הרצה דרך מודול אחר"]
dir --> c47["ניתן לזיהוי באזהרת C4747"]
ind --> nc["המהדר לא יכול לזהות"]
nc -.-> rv["מונעים בסקירה וב-#pragma unmanaged"]
איור 5: C4747 מגן עליכם רק מפני הרצה ישירה. נתיבים עקיפים אפשר לתפוס רק בסקירה.
5. התכנון הנכון — עשו מ”דחייה” את מדיניות ברירת המחדל
המלצת שיטת העבודה הרשמית ברורה.1
- סיימו מה שאתחול אפשר בזמן קומפילציה (סטטית). קודם שאלו אם אפשר להחליף אתחול דינמי בסטטי.
- דחו את השאר עד השימוש הראשון. כל עוד השימוש הראשון קורה מ-API רגיל שנקרא אחרי שה-DLL סיים להיטען, האתחול רץ מחוץ לנעילת הטוען ואפשר להשתמש בבטחה כמעט בכל Windows API. להדרה בגישה ראשונה אפשר להשתמש ב-
INIT_ONCE(אתחול חד-פעמי) או ב-magic statics של C++ (סטטיים מקומיים-לפונקציה). דחייה אינה תרופת פלא — אם הגישה הראשונה עצמה נעשית מ-DllMainאו ממאתחל סטטי, המאתחל עדיין רץ תחת נעילת הטוען ואתם שוב תחת אותן מגבלות. - עשו חריג רק לכשלים שחייבים לזהות מוקדם. יכולה להיות דרישה שקובץ הגדרות שבור יכשיל את הטעינה עצמה. גם אז, השאירו זאת למינימום של “נסה וכישל מיד”.
- שקלו
DisableThreadLibraryCallsב-DLL_PROCESS_ATTACH. אם ה-DLL לא משתמש בהודעות תהליכון, אפשר להסיר את עלות ההודעה עצמה (מלבד כשמשתמשים ב-CRT סטטי או ב-TLS סטטי).5 - בדקו עם Application Verifier. רבות מהקריאות המסוכנות בתוך
DllMainהן כאלה ש-Application Verifier יזהה בזמן ריצה.1
flowchart TB
accTitle: הנחיית תכנון לאתחול DLL
accDescr: קודם שוקלים אם האתחול יכול להיות אתחול סטטי בזמן קומפילציה; אם לא, ברירת המחדל היא לדחות לשימוש ראשון, ולהשאיר ב-DllMain רק את המינימום שחייב להיות מזוהה מוקדם ככשל טעינה
q1{"אפשר להחליט בזמן קומפילציה?"} -->|"כן"| s["הופכים לאתחול סטטי"]
q1 -->|"לא"| q2{"חייבים לזהות את הכשל בטעינה?"}
q2 -->|"לא"| lazy["דוחים לשימוש ראשון(ברירת המחדל)"]
q2 -->|"כן"| min["עושים רק את המינימום ב-DllMain"]
lazy -.-> once["הדרה עם INIT_ONCE או סטטי מקומי-לפונקציה"]
איור 6: סדר ההחלטה הוא “אפשר סטטי → אפשר לדחות”, ומה שמשאירים ב-DllMain הוא רק המינימום שחייב להיות מזוהה מוקדם.
האם להחיל DisableThreadLibraryCalls אפשר להחליט מכנית עם הענף הבא.
flowchart TB
accTitle: האם לקרוא ל-DisableThreadLibraryCalls
accDescr: אל תקראו מ-DLL שמקושר עם CRT סטטי; אם TLS סטטי בתוקף הקריאה עצמה נכשלת ולכן לא קוראים; אם אף אחד מהם לא חל וה-DLL לא משתמש בהודעות תהליכון, קוראים ב-DLL_PROCESS_ATTACH, בודקים את ערך ההחזרה, כדי לחתוך את עלות ההודעה
q1{"מקושר עם CRT סטטי?"} -->|"כן"| no2["אסור לקרוא"]
q1 -->|"לא"| q2{"משתמשים ב-TLS סטטי?"}
q2 -->|"כן"| eff["הקריאה נכשלת בכל מקרה(FALSE)"]
q2 -->|"לא"| q3{"צריך הודעות תהליכון?"}
q3 -->|"לא"| yes["קוראים ב-ATTACH(בודקים החזרה)"]
q3 -->|"כן"| keep["לא קוראים; מטפלים בהודעות"]
איור 7: שלושת התנאים של CRT סטטי, TLS סטטי, והאם צריך הודעות מחליטים באופן ייחודי אם כדאי לקרוא.
לעצירת תהליכונים בפריקה, התיעוד הרשמי נותן פרוטוקול קונקרטי. במקום “להמתין” ליציאת תהליכוני עובד ב-DLL_PROCESS_DETACH (בפריקה דרך FreeLibrary), הצורה היא (1) אות יציאה עם אירוע, (2) צד התהליכון מקפל את עבודתו למצב עקבי, שולח אות חזרה, ונכנס להמתנה אינסופית, (3) צד DllMain מאשר את המצב העקבי ואז מקפל את התהליכון עם TerminateThread.3 זה נראה גס, אבל הוא מתועד כתשובה המעשית בתוך האילוץ “אסור להמתין ליציאה טבעית של תהליכון בתוך DllMain”.
sequenceDiagram
accTitle: פרוטוקול לעצירת תהליכון בפריקה
accDescr: DllMain שולח לתהליכון העובד אות יציאה באירוע; העובד מקפל את עבודתו למצב עקבי, שולח אות חזרה, ונכנס להמתנה אינסופית; DllMain מאשר את המצב העקבי ואז מסיים את התהליכון
participant D as DllMain(טיפול DETACH)
participant W as תהליכון עובד
D->>W: אות יציאה באירוע
W->>W: קיפול העבודה למצב עקבי
W->>D: אות עקביות הושלמה והמתנה לנצח
D->>W: סיום עם TerminateThread
Note over D,W: אין המתנה ליציאה טבעית, ולכן אין קיפאון
איור 8: במקום “להמתין ליציאה טבעית”, “להמתין לאות עקביות ואז לחתוך” נמנע מהתנגשות עם נעילת הטוען.
כעקרון ראשון, התכנון הבטוח ביותר הוא להימנע מבעלות על תהליכונים ב-DLL שאפשר לפרוק, ולהשאיר את בעלות התהליכון בצד ה-EXE.
DLL_PROCESS_DETACH ביציאת תהליך הוא ההפך: לא לעשות כלום ולחזור הוא האידיאל. בנקודה הזו כל תהליכון אחר כבר הסתיים בכפייה, ואי אפשר לסמוך גם על מצב של DLL תלויים או של זמן הריצה. עבודה משוכללת כאן רק גורמת לקיפאון ולקריסות. נתונים שחייבים להישמר צריכים להיכתב בנתיב הכיבוי של היישום עצמו; אל תסמכו על ההודעה הזו.3
6. איך לחקור כשנתקלים בזה
תקיעות של נעילת טוען יש טביעת אצבע מזוהה.
מסתכלים על המחסניות ב-dump של תקיעה. לוקחים dump של הרגע הקפוא ובודקים את המחסנית של כל תהליכון. אם מוצאים זוג של תהליכון שממתין לנעילה בתוך פונקציות טוען של ntdll.dll (המשפחה ששמותיה מתחילים ב-Ldr) ותהליכון שממתין למשהו אחר בתוך DllMain או מאתחל סטטי (dynamic initializer), כמעט בטוח. תהליכון שנעצר באמצע קריאת LoadLibrary הוא עוד דמות טיפוסית.
flowchart TB
accTitle: טביעת האצבע של תקיעת נעילת טוען
accDescr: ב-dump של תקיעה, אם מוצאים גם תהליכון שממתין לנעילה בתוך פונקציות טוען של ntdll וגם תהליכון שממתין למשהו אחר בתוך DllMain או מאתחל סטטי, אפשר לטפל בזה כקיפאון נעילת טוען כמעט בוודאות
dump["dump של תקיעה"] --> t1["תהליכון שממתין לנעילה בפונקציות משפחת Ldr"]
dump --> t2["תהליכון שממתין בתוך DllMain או מאתחל סטטי"]
t1 --> pair{"שניהם נוכחים?"}
t2 --> pair
pair -->|"כן"| conf["כמעט בוודאות קיפאון נעילת טוען"]
pair -->|"לא"| other["חוקרים כתקיעה מסוג אחר"]
איור 9: לתקיעות נעילת טוען יש טביעת אצבע מזוהה “המתנה ב-Ldr + המתנה בתוך DllMain”.
חשדו באופי “תלוי-תזמון”. קיפאון נעילת טוען מחזיק רק ברגע שטעינת DLL חופפת להתחלת תהליכון או ליציאה. תנאי שחזור כמו “לפעמים באתחול”, “רק במחשב מסוים”, ו”רק כשרצים כשירות” הם סימנים לבעיה מהסוג הזה.
הריצו בדיקה מונעת. הפעילו Application Verifier והריצו את הבדיקות, ותוכלו לזהות קריאות מסוכנות בתוך DllMain בזמן ריצה.1 ל-C++/CLI, אל תתעלמו מאזהרת C4747; בסקירות של פונקציות נגישות מ-DllMain, הוסיפו את הזווית של “פונקציות שקוראות ל-LoadLibrary בעקיפין” (אתחול COM, חלק מתכונות CRT, ייבוא בטעינה מאוחרת וכדומה) לרשימת הסקירה, ותתפסו תאונות לפני המשלוח. הקריאה הראשונה של ייבוא בטעינה מאוחרת שהופכת ל-LoadLibrary בפנים היא נקודה קלה לפספוס.
7. סיכום
DllMainנקרא בזמן שמחזיקים את נעילת הטוען (אחת לתהליך, הנעילה שמסדרת כל הודעת DLL). כל מגבלה נובעת מזה.- ליבת האיסורים היא “אל תקראו ל-
LoadLibrary/FreeLibrary”, “אל תסתנכרנו עם תהליכונים אחרים”, ו”אל תקראו לפונקציות שתלויות ב-DLL שאינו Kernel32”. בונים והורסים של אובייקטים סטטיים שרצים דרך ה-CRT נופלים תחת אותן מגבלות. - מדיניות התכנון הבסיסית היא דחייה. עשו סטטי את האתחול שאפשר לעשות סטטי; דחו את השאר לשימוש ראשון. השתמשו ב-
DisableThreadLibraryCallsוב-Application Verifier. - עצירת תהליכונים בפריקה עוקבת אחרי הפרוטוקול הרשמי (אות → אישור עקביות → סיום). DLL_PROCESS_DETACH ביציאת תהליך אידיאלית ריק.
- ב-C++/CLI, הרצת MSIL תחת נעילת הטוען היא מוקש בפני עצמו. התעקשו על תרגום נייטיב של עץ הקריאות של
DllMain.
מגבלות DllMain נראות, בהתחלה, כמו רשימת איסורים בלתי סבירה. אבל ברגע שמחזיקים את הנקודה האחת ש”זה נקרא בזמן שמחזיקים את נעילת הטוען, הנעילה העליונה”, כל איסור הוא ניסוח מחדש של אותו עיקרון. זכרו אותו כעיקרון, וכשתפגשו מקרה קצה שאינו בתיעוד, עדיין תוכלו לשאול את השאלה הנכונה: “האם זו עבודה שמותר לי לעשות בזמן שאני מחזיק את הנעילה?”
מאמרים קשורים
- How Windows DLL Name Resolution Works - Search Order and SxS
- קריאה מ-C# ל-DLL נייטיב: עטיפת C++/CLI מול P/Invoke
- Practical Multithreading Best Practices: C++ Edition — Eliminating Accidents by Structure with RAII and jthread
- התעוררויות ספונטניות — למה משתני תנאי מתעוררים “בלי שהודיעו להם” ואיך להמתין נכון ב-Windows
- קריאת crash dumps עם WinDbg + SOS — מדריך מעשי לניתוח אחרי איסוף
- ידע בסיסי על STA/MTA ב-COM — מודל השרשור ואיך נמנעים מתקיעה
תחומי ייעוץ קשורים
KomuraSoft LLC מטפלת בחקירת שורש של תקיעות וקיפאונות באתחול או בטעינת DLL (ניתוח dump), בסקירות תכנון סביב DllMain ואתחול סטטי, ובתיקון עטיפות C++/CLI ו-DLL של תוספים לכיוון תכנון אתחול בטוח. אפשר להתייעץ גם בשלב הקשה לשחזור של “זה נתקע באתחול רק בסביבה מסוימת”.
קישורים
-
Microsoft Learn, Dynamic-Link Library Best Practices. על כך ש-DllMain נקרא בזמן שנעילת הטוען מוחזקת, כך שהפונקציות שאפשר לקרוא להן מוגבלות בחומרה; ש-DllMain האידיאלי הוא stub ריק ואתחול נדחה ככל האפשר; המלצה על אתחול סטטי בזמן קומפילציה; עשיית המינימום בלבד לכשלים שחייבים לזהות מוקדם; וזיהוי טעויות DllMain טיפוסיות עם Application Verifier. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, DllMain entry point. על ביצוע אתחול וסיום פשוטים בלבד בנקודת הכניסה; למה אסור לקרוא ל-LoadLibrary / FreeLibrary (סדר טעינה מעגלי ושימוש ב-DLL לפני אתחול או אחרי סיום); היות Kernel32.dll מובטח כבר טעון, כך שאפשר לקרוא לו בטווח שלא טוען DLL אחרים; היעדר רשימה ממצה של פונקציות בטוחות; פונקציות User, Shell ו-COM שגורמות להפרות גישה; הודעות DLL מסודרות בסדרה, כך שתקשורת עם תהליכונים או תהליכים אחרים גורמת לקיפאון; ואותן מגבלות חלות על בונים והורסים של אובייקטים סטטיים כשמקושר CRT. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. על המבנה שמקפיא אם ממתינים ליציאת תהליכון בתוך DllMain (הודעת DLL_THREAD_DETACH של יציאת תהליכון צריכה את נעילת הטוען); הפרוטוקול לעצירת תהליכון בפריקה (אות באירוע, אישור מצב עקבי, ואז סיום); DLL_PROCESS_DETACH ביציאת תהליך שבו התהליכונים האחרים כבר הסתיימו בכפייה ואין ערובה לעקביות מרחב הכתובות, כך שהמטפל האידיאלי ריק; ויצירת תהליכון ב-DllMain שמשאירה הודעות בתור עם אתחול לא שלם וגורמת לבעיות. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Initialization of Mixed Assemblies. על אי-הרצת MSIL תחת נעילת הטוען; אי-תרגום DllMain ועץ הקריאות שלו ל-MSIL והטיפול בזה דרך #pragma unmanaged; אזהרת C4747 נפלטת כש-DllMain מנסה להריץ MSIL ישירות, אבל הרצה עקיפה דרך מודול אחר אינה ניתנת לזיהוי; ומאתחלים דינמיים של אובייקטים סטטיים יכולים לגרום לאותה בעיה. ↩ ↩2
-
Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). על השבתת הודעות DLL_THREAD_ATTACH / DLL_THREAD_DETACH כדי להקטין תקורה ביצירת תהליכון ובהריסתו; אי-קריאה מ-DLL שמקושר עם CRT סטטי; והאופטימיזציה לא מבוצעת כש-TLS סטטי (thread_local או __declspec(thread)) בתוקף. ↩ ↩2
-
Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. על הגדרת היררכיית נעילות ולקיחה תמיד באותו סדר; הטוען לוקח את נעילת הטוען לפני קריאה ל-DllMain, כך שנעילת הטוען צריכה לשבת בראש היררכיית הנעילות; שמירה על סדר לקיחה בין API שלוקחים את נעילת הטוען בעקיפין, כמו GetModuleFileName, לנעילות פרטיות; ודוגמה קונקרטית לקיפאון מהיפוך סדר נעילות. ↩ ↩2
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
Win32 Thread Pool API — מקביליות בלי ליצור תהליכונים, דרך CreateThreadpoolWork
מפזרים קריאות CreateThread בכל הקוד הנייטיב? המאמר מסביר את ה-API של מאגר התהליכונים של Win32 שעוצב מחדש ב-Vista — ארבעת האובייקטים work,...
מה "לא מגיב" באמת — איך Windows מחליט שיישום נתקע, ואיך לתכנן יישומים שלא
"לא מגיב" של Windows הוא מנגנון שבו מערכת ההפעלה קובעת שחלון לא שלף הודעה במשך 5 שניות ומחליפה אותו בחלון רפאים. המאמר מכסה את פנים השיפו...
התעוררויות ספונטניות — למה משתני תנאי מתעוררים "בלי שהודיעו להם" ואיך להמתין נכון ב-Windows
המתנה על משתנה תנאי יכולה לחזור גם כשלא הגיעה הודעה (התעוררות ספונטנית). המאמר מסביר ממימוש Windows למה המפרט מתיר זאת, ומציג את צורת ההמ...
Named Pipes בפועל — ה-IPC הסטנדרטי של Windows, מתכנון ועד אבטחה
מדריך מעשי ל-Named Pipes, התקשורת הסטנדרטית בין תהליכים ב-Windows. המאמר מסדר ממקורות ראשוניים את הבחירה בין מצב בתים למצב הודעות, תכנון ...
יישומים שנשברים בחזרה משינה — איך אירועי צריכת החשמל של Windows עובדים ואיך לבנות יישומים עסקיים ששורדים אותם
פתחתם את המחשב הנייד והחיבורים של היישום העסקי היו מתים — הסיבה היא תכנון שלא חשב על שינה. המאמר מכסה את זרימת ההודעות WM_POWERBROADCAST,...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
יישומים עסקיים, חיבור התקנים וכלי תקשורת, מהגדרת הדרישות ועד הפיתוח.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- האם באמת אי אפשר לעשות כלום ב-DllMain?
- "לא לעשות כלום" אינו הגזמה; זו עמדת התכנון הרשמית, ומיקרוסופט עצמה אומרת ש-DllMain האידיאלי הוא כמעט stub ריק. מה שבטוח הוא תת-קבוצה של פונקציות Kernel32.dll — Kernel32 מובטח טעון עד ש-DllMain רץ — בטווח שלא טוען DLL אחרים. יצירת critical section או mutex, ושימוש ב-TLS, הם דוגמאות למה שאפשר. לעומת זאת LoadLibrary/FreeLibrary, סנכרון עם תהליכונים אחרים, וקריאה לפונקציות ב-User32, Shell, COM וכדומה אסורים כי הם גורמים לקיפאון ולהפרות גישה. אתחול שאתם לא בטוחים לגביו לא צריך להיעשות ב-DllMain; לדחות אותו עד השימוש הראשון.
- האם בונים של גלובלים ב-C++ (אובייקטים סטטיים) גם נופלים תחת מגבלות DllMain?
- כן. כשה-DLL מקושר עם CRT (זמן הריצה של C++), בונים והורסים של אובייקטים גלובליים וסטטיים רצים, דרך נקודת הכניסה שה-CRT מספק, כחלק דה-פקטו מ-DllMain. כלומר קריאה ל-LoadLibrary מתוך בנאי, הפעלת תהליכון אחר והמתנה לסיומו, אתחול COM וכדומה נושאים את אותה סכנה כמו לעשות אותם ב-DllMain. לאובייקט גלובלי עם אתחול לא-טריוויאלי, שמרו מצביע ובנו אותו בגישה ראשונה, או השתמשו בסטטי מקומי-לפונקציה, כדי שהעבודה תרוץ מחוץ ל-DllMain.
- כדאי לקרוא ל-DisableThreadLibraryCalls?
- בתנאי, כן. אם ה-DLL לא צריך הודעות DLL_THREAD_ATTACH/DETACH, קריאה ל-DisableThreadLibraryCalls ב-DLL_PROCESS_ATTACH עוצרת את ההודעות לכל יצירת תהליכון וליציאה ממנו ומקטינה תקורה בתהליך שיוצר תהליכונים לעיתים קרובות. יש שני חריגים. אל תקראו לזה מ-DLL שמקושר עם CRT סטטי (ה-CRT הסטטי צריך את הודעות התהליכון). ואם TLS סטטי דרך thread_local או __declspec(thread) בתוקף, הקריאה עצמה נכשלת ומחזירה FALSE, לכן הרגילו לבדוק את ערך ההחזרה. השתמשו בזה ב-DLL טיפוסי שמשתמש ב-CRT מקושר דינמית, אחרי שאישרתם ששום דבר לא תלוי בהודעות התהליכון.
- למה DLL מעורב של C++/CLI נתקע באתחול?
- הסיבה הטיפוסית היא ניסיון להריץ MSIL (קוד מנוהל) בזמן שנעילת הטוען מוחזקת. ב-assembly מעורב של C++/CLI, אם DllMain, פונקציות שנקראות ממנו, או מאתחלים דינמיים של גלובלים מתורגמים ל-MSIL, אתחול CLR או טעינת assembly אחר יכולים להידרש תחת נעילת הטוען, וזה יכול לקפוא. המהדר פולט אזהרת C4747 כש-DllMain עצמו מנסה להריץ MSIL ישירות, אבל הוא לא יכול לזהות הרצה עקיפה דרך מודול אחר. ההקלה היא לתרגם את DllMain ואת עץ הקריאות שלו כנייטיב עם #pragma unmanaged — או לא לקיים DllMain בכלל.
- מותר לנקות משאבים ב-DLL_PROCESS_DETACH?
- התשובה משתנה בין "יציאת תהליך" לבין "פריקה דרך FreeLibrary". ב-DLL_PROCESS_DETACH ביציאת תהליך, התהליכונים האחרים כבר הסתיימו, ואין ערובה שמרחב הכתובות עדיין עקבי, כך שניקוי כמו שחרור זיכרון למעשה מסוכן; ההנחיה הרשמית היא ש"המטפל האידיאלי ריק". כתבו החוצה כל נתון שחייב להישמר בנתיב הכיבוי של היישום עצמו, וכאן בעצם אל תעשו כלום וחזרו. בפריקה דרך FreeLibrary התהליך ממשיך, ולכן כן צריך ניקוי מלא — עצירת תהליכונים, סגירת handles וכדומה. המתנה ליציאת תהליכון בתוך DllMain מקפיאה, ולכן חייבים לעקוב אחרי הפרוטוקול הרשמי: אות, המתנה עד מצב עקבי, וסיום העבודה מחוץ ל-DllMain.