לדלג לתוכן

יצירת קוד וחוזה המכונה

נניח שהמשתמש בחר נוסחה, ואנחנו רוצים לייצר עבורה כמה הוראות מכונה. כבר למדנו מה עושה compiler, אבל הפעם קובץ ההפעלה שלנו ייצור קוד אחרי שהתהליך התחיל. הרגע שבו bytes הופכים לקוד מחייב יותר מcast לfunction pointer.

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

מה הפונקציה חייבת לשמור

המעבדה מיועדת לWindows x64. שני הארגומנטים מגיעים דרך החלקים המתאימים של RCX וRDX, והתוצאה מוחזרת דרך EAX. נוסיף הקצאה קטנה במחסנית כדי שנצטרך לתאר גם איך מבטלים אותה במהלך unwind.

sub rsp, 28h
mov eax, ecx
add eax, edx
add rsp, 28h
ret

ההקצאה היא 40 bytes. אחרי ההוראה הראשונה המחסנית מיושרת לפי החוזה של גוף הפונקציה, ואנחנו מחזירים אותה לפני ret. אין כאן קריאה לפונקציה נוספת, ולכן ההקצאה אינה נחוצה לחיבור עצמו; היא נועדה להפוך את בעיית הunwind לגלויה.

זיכרון לכתיבה ואז זיכרון להרצה

הדוגמה מקצה דף לכתיבה, מעתיקה את ההוראות ואת תיאור הunwind, ואז משנה הרשאות לread execute. לאחר יצירת קוד יש לקרוא לFlushInstructionCache לפי חוזה סנכרון קוד חדש. הפרסום לthread אחר, אם נוסיף כזה, ידרוש בנוסף סנכרון של השפה.

הקוד הבא הוא תוכנית שלמה. שומרים בשם tiny_jit.cpp ובונים לx64. תצורה שאוסרת dynamic code רשאית לדחות את הניסוי; במקרה כזה חוקרים את המדיניות ומשתמשים בחישוב רגיל, בלי לבטל את ההגנה כדי להציג הצלחה.

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <array>
#include <cstdint>
#include <cstdio>
#include <cstring>

#if !defined(_M_X64) && !defined(__x86_64__)
#error This experiment requires Windows x64
#endif

int main() {
    PROCESS_MITIGATION_DYNAMIC_CODE_POLICY policy{};
    if (!GetProcessMitigationPolicy(GetCurrentProcess(), ProcessDynamicCodePolicy,
                                    &policy, sizeof policy)) return 1;
    if (policy.ProhibitDynamicCode) {
        std::puts("Dynamic code prohibited; use interpreter fallback"); return 0;
    }
    auto* page = static_cast<unsigned char*>(VirtualAlloc(nullptr, 4096,
        MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE));
    if (!page) return 2;
    const unsigned char instructions[] = {
        0x48,0x83,0xec,0x28, 0x8b,0xc1, 0x03,0xc2,
        0x48,0x83,0xc4,0x28, 0xc3
    };
    const unsigned char unwind[] = {1,4,1,0, 4,0x42,0,0};
    std::memcpy(page, instructions, sizeof instructions);
    std::memcpy(page + 16, unwind, sizeof unwind);
    DWORD previous = 0;
    if (!VirtualProtect(page,4096,PAGE_EXECUTE_READ,&previous) ||
        !FlushInstructionCache(GetCurrentProcess(),page,sizeof instructions)) {
        VirtualFree(page,0,MEM_RELEASE); return 3;
    }
    RUNTIME_FUNCTION entry{};
    entry.BeginAddress = 0;
    entry.EndAddress = sizeof instructions;
    entry.UnwindData = 16;
    const DWORD64 base = reinterpret_cast<DWORD64>(page);
    if (!RtlAddFunctionTable(&entry,1,base)) {
        VirtualFree(page,0,MEM_RELEASE); return 4;
    }

    using Add = std::uint32_t (*)(std::uint32_t,std::uint32_t);
    auto add = reinterpret_cast<Add>(page);
    bool arithmetic_ok = add(20,22) == 42 && add(UINT32_MAX,1) == 0;

    alignas(16) std::array<DWORD64, 8> simulated_stack{};
    simulated_stack[5] = 0x12345678;
    CONTEXT context{};
    context.ContextFlags = CONTEXT_FULL;
    context.Rip = base + 6;
    context.Rsp = reinterpret_cast<DWORD64>(simulated_stack.data());
    PVOID handler_data = nullptr;
    DWORD64 establisher = 0;
    RtlVirtualUnwind(UNW_FLAG_NHANDLER,base,context.Rip,&entry,&context,
                     &handler_data,&establisher,nullptr);
    bool unwind_ok = context.Rip == 0x12345678 &&
        context.Rsp == reinterpret_cast<DWORD64>(simulated_stack.data()+6);
    std::printf("arithmetic=%d unwind=%d\n", arithmetic_ok, unwind_ok);

    if (!RtlDeleteFunctionTable(&entry)) {
        std::fputs("Cannot unregister function table; exiting without reuse\n",stderr);
        ExitProcess(5);
    }
    if (!VirtualFree(page,0,MEM_RELEASE)) return 6;
    return arithmetic_ok && unwind_ok ? 0 : 7;
}

הבנייה משתמשת בפקודה הבאה מתוך סביבת x64 של Visual Studio.

cl /nologo /std:c++20 /EHsc /W4 /Zi tiny_jit.cpp

מפרקים את תיאור המחסנית

המערך unwind מתחיל בגרסה 1, באורך prologue של 4 bytes ובפעולת unwind אחת. הקוד 0x42 מתאר הקצאה קטנה עם ערך מידע 4, ולכן הגודל הוא ארבע כפול שמונה ועוד שמונה. שדה הoffset מצביע לסוף הוראת ההקצאה. המבנה והקידוד מוגדרים בחוזה exception handling של x64.

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

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

מה משתנה כשעוברים למכונה אחרת

ההוראות, הארגומנטים ותיאור הunwind כאן שייכים לx64. בARM64 צריך מחולל מתאים, חוזה registers מתאים ותיאור unwind אחר. המילה extern "C" אינה מתרגמת instruction bytes ואינה הופכת layout פרטי לחוזה נייד.

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

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