לדלג לתוכן

קישור דינמי וזהות סימבולית

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

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

בונים גבול שאפשר לראות

ניצור calc.cpp עם פונקציה אחת. הניסוי מיועד לכלי x64 של MSVC.

extern "C" __declspec(dllexport)
int __cdecl scale_checked(int value, int* out) noexcept {
    if (!out || value < -1000 || value > 1000)
        return 1;
    *out = value * 3;
    return 0;
}
cl /nologo /std:c++20 /EHsc /W4 /MD /LD calc.cpp /link /OUT:calc.dll /IMPLIB:calc.lib
dumpbin /exports calc.dll

ה־DLL מכיל את המימוש ואת טבלת ה־exports. קובץ ה־import library שנוצר לצדו עוזר ל־linker לבנות לקוח שמייבא את הפונקציה; הוא אינו עותק מלא של מימוש הפונקציה כמו ספרייה סטטית רגילה.

ניצור linked.cpp.

#include <cstdio>

extern "C" __declspec(dllimport)
int __cdecl scale_checked(int, int*) noexcept;

int main() {
    int result = 0;
    const int status = scale_checked(12, &result);
    if (status != 0) return 1;
    std::printf("%d\n", result);
}
cl /nologo /std:c++20 /EHsc /W4 /MD linked.cpp calc.lib /Felinked.exe
dumpbin /imports linked.exe
.\linked.exe

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

בוחרים את רגע הטעינה

אפשר לטעון ספרייה במפורש ולבדוק כשל בתוך קוד היישום. הלקוח הבא מקבל נתיב מוחלט ל־DLL שנבנה בניסוי.

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <cstdio>
#include <filesystem>

int wmain(int argc, wchar_t** argv) {
    if (argc != 2 || !std::filesystem::path(argv[1]).is_absolute()) {
        std::fprintf(stderr, "supply an absolute DLL path\n");
        return 1;
    }
    HMODULE module = LoadLibraryExW(argv[1], nullptr,
        LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
    if (!module) {
        const DWORD error = GetLastError();
        std::fprintf(stderr, "load error=%lu\n", error);
        return 1;
    }

    const auto address = GetProcAddress(module, "scale_checked");
    if (!address) {
        const DWORD error = GetLastError();
        FreeLibrary(module);
        std::fprintf(stderr, "export error=%lu\n", error);
        return 1;
    }
    using Scale = int (__cdecl*)(int, int*) noexcept;
    const auto scale = reinterpret_cast<Scale>(address);
    int result = 0;
    const int status = scale(12, &result);
    std::printf("status=%d result=%d\n", status, result);
    FreeLibrary(module);
    return status == 0 ? 0 : 1;
}

המרת מצביע הפונקציה היא חלק מתבנית השימוש המתועדת ב־Windows, וה־typedef חייב להתאים למימוש. ההמרה אינה בודקת חתימה בזמן ריצה. שמרנו את קוד השגיאה לפני FreeLibrary והדפסה, מפני שפעולות נוספות יכולות לשנות את last error של ה־thread.

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

חיים של פונקציה תלויים בחיים של module

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

במערכת עם callbacks או עבודה אסינכרונית, סוף הקריאה הראשונה אינו בהכרח סוף השימוש בקוד. ייתכן ש־thread אחר עדיין נמצא בתוך ה־DLL או שמצביע callback שמור במערכת. לכן unloading מחייב פרוטוקול shutdown: לעצור עבודה חדשה, להמתין לעבודה קיימת, לבטל רישומים ורק אז לשחרר את ה־module.

שם מיוצא אינו תמיד קוד במקום הזה

טבלת exports יכולה להכיל forwarder שמפנה למימוש בספרייה אחרת. לכן כתובת הפונקציה שחוזרת יכולה להימצא ב־module אחר מזה שחשבנו עליו. זו התנהגות תקינה, ולא בהכרח hook או השחתה.

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

מנגנון delay load דוחה פתרון של תלות מיובאת עד השימוש המתאים באמצעות תמיכת כלים ו־helper. הוא אינו זהה לממשק שמחזיר כשל רגיל מכל טעינה, ואינו מגדיר בשבילנו מדיניות fallback. צריך לבדוק איך הכשל מגיע לקוד ובאיזה שלב כבר התבצעו פעולות אחרות.

מפרידים שלושה סוגי כשל

כשל טעינת module יכול לנבוע מהקובץ הראשי, מתלות חסרה או מארכיטקטורה לא תואמת. כשל מציאת export אומר שה־module נטען אך הסמל המבוקש אינו נמצא בחוזה שנבדק. כשל מהפונקציה עצמה הוא כבר תוצאה של API הספרייה.

החקירה אוספת את שלושת השלבים בנפרד. בודקים את נתיב הקובץ בפועל, את Machine בכותרת PE ואת טבלת exports. החלפת קובץ אקראית או הוספת תיקיות ל־PATH יכולה לטשטש את הסיבה ולהכניס תלות חדשה שאינה מתועדת.

לקריאה נוספת: טעינה באמצעות LoadLibraryExW וחיפוש סמל באמצעות GetProcAddress.