לדלג לתוכן

ארכיטקטורות וחוזי WinAPI

נניח שפונקציה מבקשת גודל buffer. כתבנו sizeof(buffer), וזה עבד עם מחרוזת צרה. אחרי מעבר ל־Unicode התוכנית התחילה לכתוב מעבר לגבול. הבעיה אינה ב־Unicode עצמו, אלא בכך שהעברנו מספר בתים לפונקציה שמצפה למספר יחידות טקסט.

ב־WinAPI השמות הקצרים מסתירים חוזים חשובים: יחידות, ערך כשל, בעלות, ארכיטקטורה ומשך חיים. קריאת חתימה בלי קריאת תיעוד return value והפרמטרים אינה מספיקה.

טיפוס בגודל צפוי אינו טיפוס חלופי

ב־Windows x64 המודל הוא LLP64. המצביעים ו־size_t הם בני 64 ביט, אך long ו־DWORD נשארים בני 32 ביט. העתקת pointer ל־long יכולה לאבד מידע גם אם התוכנית עובדת במקרה כשהכתובת נמוכה.

הטיפוס SIZE_T מתאים לרוחב כתובת בתהליך, ואילו DWORD משמש שדות וחוזים בעלי רוחב קבוע. ה־BOOL של WinAPI אינו bool של C++; הוא טיפוס שלם עם מוסכמת אפס מול ערך שאינו אפס. מבנה שעובר דרך ABI צריך להשתמש בטיפוסים שהחוזה דורש.

ה־HANDLE אטום מבחינת היישום. הוא אינו מצביע שניתן לקרוא ממנו שדות. גם ערך הכשל אינו אחיד: פונקציות מסוימות מחזירות null ואחרות INVALID_HANDLE_VALUE. ה־pseudo handle של התהליך הנוכחי אינו רכישה רגילה שצריך לשחרר כמו handle שחזר מ־OpenProcess.

קריאה בשני שלבים אינה עסקה אטומית

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

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <cstdio>
#include <stdexcept>
#include <string>
#include <system_error>
#include <vector>

std::wstring expand(const wchar_t* input) {
    DWORD capacity = ExpandEnvironmentStringsW(input, nullptr, 0);
    if (capacity == 0)
        throw std::system_error(GetLastError(), std::system_category());

    for (unsigned attempt = 0; attempt < 4; ++attempt) {
        if (capacity > 65536)
            throw std::runtime_error("expanded value is too large");
        std::vector<wchar_t> buffer(capacity);
        const DWORD count =
            ExpandEnvironmentStringsW(input, buffer.data(), capacity);
        if (count == 0) {
            const DWORD error = GetLastError();
            throw std::system_error(error, std::system_category());
        }
        if (count <= capacity)
            return std::wstring(buffer.data(), count - 1);
        capacity = count;
    }
    throw std::runtime_error("environment keeps changing");
}

int main() {
    try {
        const auto value = expand(L"%TEMP%\\amittech-probe");
        std::wprintf(L"%ls\n", value.c_str());
    } catch (const std::exception& error) {
        std::fprintf(stderr, "%s\n", error.what());
        return 1;
    }
}

הגודל כאן נמדד ב־wchar_tים, וב־Windows הם יחידות UTF-16 בנות 16 ביט. הערך המוחזר בהצלחה כולל את ה־null המסיים, ולכן בניית ה־wstring משתמשת ב־count - 1. אין להעתיק את הכלל הזה לפונקציה אחרת בלי לבדוק: יש APIs שמחזירים אורך בלי null.

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

טקסט הוא רצף יחידות, לא מספר תווים חזותיים

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

הסימון W ב־API בוחר ממשק רחב. הממשק A משתמש בהמרות בהתאם לחוזה ה־API ולתצורת הקידוד, ואינו בהכרח UTF-8. בגבול מול רשת או פורמט קובץ מגדירים קידוד מפורש ומבצעים המרה מבוקרת.

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

שלוש שפות של שגיאה

פונקציות Win32 רבות מחזירות BOOL או sentinel, ורק אחרי כשל מותר לפרש את GetLastError לפי התיעוד שלהן. ערך שגיאה ישן יכול להישאר גם אחרי הצלחה. פונקציה אחרת יכולה להחזיר ישירות קוד שגיאה, ואז קריאת last error אינה מקור המידע הנכון.

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

ה־NTSTATUS שייך למשפחת ממשקים אחרת ומכיל קטגוריות מצב משלו. אין להמיר אותו ל־HRESULT או ל־DWORD באמצעות cast ולצפות לקבל משמעות זהה. בפרקי הקרנל נשתמש בכללי הבדיקה וההמרה המתועדים של אותה משפחה.

התהליך והמכונה אינם תמיד אותה ארכיטקטורה

מערכת x64 יכולה להריץ תהליך x86 דרך WOW64. מערכת ARM64 יכולה לכלול מסלולי תאימות נוספים, והפרטים תלויים בגרסה ובסוג הבינארי. ארכיטקטורת מערכת ההפעלה אינה אומרת אוטומטית מה רוחב המצביע בתהליך שלנו.

הניסוי הבא מצטרף לתוכנית עם Windows headers ומחליף את main. הוא משתמש ב־API שזמין בבסיס המערכת שנבחר לקורס.

int main() {
    USHORT process_machine = 0;
    USHORT native_machine = 0;
    if (!IsWow64Process2(GetCurrentProcess(),
                        &process_machine, &native_machine)) {
        std::printf("error=%lu\n", GetLastError());
        return 1;
    }
    std::printf("pointer=%zu process=%04x native=%04x\n",
        sizeof(void*), static_cast<unsigned>(process_machine),
        static_cast<unsigned>(native_machine));
}

ערך IMAGE_FILE_MACHINE_UNKNOWN עבור process machine מציין בחוזה הזה שהתהליך אינו תהליך WOW64, ולא שהבדיקה נכשלה. הערך המוחזר של הפונקציה קובע אם אפשר לפרש את הפלטים.

חוזה לפני עטיפה

כשנבנה wrapper ל־WinAPI, נכתוב לידו יחידות גודל, invalid sentinel, פונקציית סגירה, מקור שגיאה וחיי buffers. רק אחר כך נבחר RAII וחריגות. עטיפה יפה של חוזה שגוי נשארת באג, ולעיתים מסתירה אותו היטב יותר.

לקריאה נוספת: חוזה ExpandEnvironmentStringsW, זיהוי ארכיטקטורת תהליך וטיפול ב־HRESULT.