לדלג לתוכן

חוזה בינארי וקריאות לממשק טבעי

ספריית C# יכולה לקרוא ל־WinAPI בלי לתרגם את כל המוצר ל־C++, אבל ה־runtime אינו יודע לנחש חתימה בינארית. אם נצהיר שפרמטר הוא int בזמן שהספרייה מצפה למצביע, הקומפיילר יכול להיות מרוצה וה־callee יקבל נתונים שגויים. נתחיל מחוזה מתועד ונמפה רוחב, כיוון העברה, קידוד, שגיאות ובעלות לפני שנבחר attributes.

הדוגמה הבאה משתמשת ב־LibraryImport כדי ליצור את קוד הגישור בזמן הבנייה. היא מיועדת לווינדוס, דורשת AllowUnsafeBlocks בפרויקט, ומשתמשת בשמות exports מפורשים. ה־partial מאפשר ל־source generator להשלים את ההצהרה.

using System.ComponentModel;
using System.Runtime.InteropServices;

if (args.Length != 1) throw new ArgumentException("path required");
uint attributes = Native.GetFileAttributes(args[0]);
if (attributes == uint.MaxValue)
{
    int error = Marshal.GetLastPInvokeError();
    throw new Win32Exception(error);
}
Console.WriteLine($"attributes=0x{attributes:X8}, pid={Native.GetCurrentProcessId()}");

internal static partial class Native
{
    [LibraryImport("kernel32.dll", EntryPoint = "GetFileAttributesW",
        StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
    internal static partial uint GetFileAttributes(string path);

    [LibraryImport("kernel32.dll")]
    internal static partial uint GetCurrentProcessId();
}

הערך DWORD ממופה כאן ל־uint, והכשל של GetFileAttributesW מסומן ב־INVALID_FILE_ATTRIBUTES, כלומר כל הביטים דלוקים. אין לבדוק אם GetLastPInvokeError שונה מאפס אחרי הצלחה כדי להמציא כשל שה־API לא דיווח עליו. ה־SetLastError אומר ל־runtime לשמור את קוד השגיאה של הקריאה, ואנחנו קוראים אותו מיד במסלול הכשל לפני פעולה נוספת שעלולה לבלבל את האבחון.

הבחירה ב־W וב־Utf16 מגדירה את קידוד המחרוזת בגבול. מחרוזת מנוהלת יכולה להכיל תו אפס באמצע, בעוד API שמקבל מחרוזת מסתיימת באפס יראה רק prefix. לכן wrapper שמקבל נתיב חיצוני צריך לדחות תו אפס לפני הקריאה כדי שה־validation והפעולה לא יתייחסו לשני נתיבים שונים. הניסוי כאן ממחיש את הגישור; ה־wrapper בתרגול יסגור גם את הפער הזה.

ה־DllImport הוותיק מייצר stub במנגנון runtime במקום source generation. הוא עדיין כלי שימושי עבור חתימות ומצבים מסוימים, אבל אין לערבב בין שני המודלים בלי להבין אילו יכולות marshalling זמינות. בשני המקרים החוזה הבינארי נשאר אותו חוזה. אזהרה של generator על טיפוס לא נתמך דורשת החלטה על הייצוג, ולא השתקה כללית של diagnostic.

כשנכתוב ספרייה משלנו, נעדיף C ABI קטן עם טיפוסים ברוחב מפורש, buffers עם אורך ותוצאות שאינן זורקות C++ exceptions מעבר לגבול. נוכל להגדיר struct של options עם size ו־version, לבדוק אותם לפני קריאת שדות ולהחזיר status קבוע. הוספת שדה אינה מאפשרת לקרוא מעבר ל־size שהלקוח מסר. גם sizeof של struct יכול להשתנות לפי packing וארכיטקטורה, ולכן נייצר בדיקות layout בשני הצדדים.

ה־calling convention חשוב במיוחד ב־x86, שבו אי־התאמה יכולה לפגוע בניהול המחסנית. ב־x64 של ווינדוס קיים ABI אחיד לרוב הקריאות הרגילות, אבל הוא אינו הופך חתימת פרמטרים שגויה לתקינה. נבנה ונריץ בנפרד לפי RID וארכיטקטורה, ונבדיל בין כשל טעינת DLL, export חסר וחתימה שגויה אחרי שהטעינה הצליחה.

התיעוד על יצירת קוד P/Invoke בזמן הבנייה ועל GetFileAttributesW הוא מקור החוזה. השאלה הראשונה בתחקיר תהיה מה בדיוק ה־callee מצפה לקבל, ורק אחר כך מה הקומפיילר הצליח לבנות.