איך קוראים ל-DLL של C# Native AOT מ-C/‏C++

· עודכן בתאריך: · · C#, .NET, Native AOT, C++, פיתוח Windows, אינטגרציה נייטיבית

בפעם הקודמת, בהסיבה לכך ש-wrapper של C++/CLI הוא האפשרות המובילה כשקוראים ל-DLL נייטיבי מ-C#, סידרנו את פני השטח של הגבול כשקוראים מ-C# ל-C++. הפעם ההפך: הסיפור הוא קריאה מ-C/‏C++ ל-C#.

יש מצבים שבהם רוצים לקרוא מאפליקציית C/‏C++ קיימת לעיבוד שכתוב ב-C#, אבל הכיוון של P/Invoke הפוך, ולהביא עד C++/CLI או COM זה יותר מדי. בייחוד כשרוצים להשאיר את גוף האפליקציה הנייטיבית כמות שהוא, ורק חלקים כמו לוגיקת שיפוט, עיבוד מחרוזות, פענוח הגדרות או כללי חישוב — להעביר ל-C#.

אפשר לגשר גם עם COM, אבל הפעם זו דרך שהיא יותר in-process, יותר בסגנון DLL. ב-Native AOT של ‏.NET אפשר לפרסם ספריית מחלקות כספרייה משותפת נייטיבית, ולחשוף מתודות שמסומנות ב-UnmanagedCallersOnly כנקודת כניסה של C. כלומר אפשר להשתמש ב-C# כ’DLL נייטיבי שנקראים אליו’.

אבל לא הכול חוצה גבול כמו שהוא. string, ‏List<T>, חריגות ובעלות — אם דולפים לגבול, האווירה מתקלקלת מהר. במאמר הזה, דרך דוגמה מינימלית של Windows + C++, נסדר מתי התצורה הזו מתאימה ואיזו צורת API פחות שברירית. ב-Linux / macOS הרעיון דומה כמעט לגמרי, אבל דוגמאות הקוד מניחות DLL של Windows.

ההבדל בכיוון בין הפעם הקודמת להפעם הזובפעם הקודמת עסקנו בגבול שבו C# קורא ל-DLL נייטיבי, ובפעם הזו הכיוון הפוך — אפליקציית C/C++ קוראת בתוך אותו תהליך ל-DLL נייטיבי של C# שפורסם עם Native AOT.בפעם הקודמת: C# קורא ל-C++הסיפור על wrapper של C++/CLIהפעם: C/C++ קורא ל-C#הפיכת C# ל-DLL עם Native AOTUnmanagedCallersOnly הוא נקודת הכניסה

איור 1: הפעם הכיוון הפוך מ-P/Invoke ומ-C++/CLI, ו-C# הופך ל’DLL נייטיבי שנקראים אליו’.

יצוין שהקוד שמופיע במאמר הזה פורסם ב-GitHub כחבילה מלאה שאפשר לבנות ולהריץ (ספריית C# שמתפרסמת עם Native AOT, דוגמת קריאה מ-C++, ובדיקות יחידה).

‏csharp-native-aot-native-dll-from-c-cpp - komurasoft-blog-samples (GitHub)

תוכן העניינים

  1. תחילה המסקנה (בקצרה)
  2. תחילה, איך בוחרים
  3. תרשים מבנה
  4. תצורה מינימלית
    • 4.1. פרויקט C#
    • 4.2. קוד C# שמיוצא
    • 4.3. פקודת הפרסום
    • 4.4. דוגמת קריאה מצד C++
    • 4.5. איך מוודאים ש-export אכן קיים
    • 4.6. כשרוצים לקשר סטטית עם import lib
  5. צורת API שפחות שברירית
    • 5.1. קרבה ל-C ABI
    • 5.2. מחרוזות כמצביע + אורך + קיבולת מאגר
    • 5.3. לא להעביר חריגות מעבר לגבול
    • 5.4. קיבוע מוסכמת הקריאה
    • 5.5. מתודת ה-Export דקה, הגוף במקום נפרד
  6. מקרים שמתאימים
  7. מקרים שבכל זאת לא מתאימים
  8. מוקשים
  9. סיכום
  10. מקורות

מפת הידע של המאמר

‏Native AOT של C# יכול לחשוף מתודות שסומנו במאפיין UnmanagedCallersOnly כנקודת כניסה של C, ולממש תצורה שבה קוראים ללוגיקת C# בלבד, בתוך אותו תהליך, מאפליקציית C/‏C++ קיימת. עם זאת, מכיוון שרק טיפוסי blittable יכולים לחצות את הגבול, בפועל מומלץ תכנון שלא חושף string או List ישירות, אלא מוריד ל-C API מבוסס handle בסגנון create/‏destroy/‏operate, מציין במפורש את מוסכמת הקריאה, ומייצג שגיאות עם status code במקום חריגה. התצורה הזו מתאימה לכיוון שבו C/‏C++ קוראים ללוגיקת C#, ואילו אם רוצים לקרוא מ-C# ל-C עדיף P/‏Invoke, אם רוצים לטפל בטבעיות מ-C# ב-C++ עדיף C++/CLI, ואם רוצים לחצות גבול 32/‏64 סיביות או גבול תהליך, או לפרוק (unload), עדיף COM/‏IPC. שימוש חוזר במספור ה-handle ברוחב 32 סיביות טומן סכנה של מחזור מלא (wraparound) שמצביע על מופע לא קשור, ושיבוץ מספר דור הוא הפתרון.

מפת הידע של קריאה ל-C# כ-DLL נייטיבי מ-C/‏C++ עם Native AOTתרשים שמראה איך Native AOT ו-‏UnmanagedCallersOnly מאפשרים לפרסם C# כ-DLL נייטיבי שנקרא מ-C/‏C++; את נקודות המפתח של הורדת פני השטח של הגבול ל-C ABI — מוסכמת קריאה, טיפוסי blittable, תכנון handle וייצוג שגיאות; ואת החלוקה בין P/‏Invoke, ‏C++/CLI ו-COM.משתמש במחייבמוגדר באמצעותמשתמש במחייבמחייבמחייבמענה מומלץ למענה מומלץ למענה מומלץ למענה מומלץ לשימוש לא מומלץ לשימוש לא מומלץ למענה מומלץ למענה מומלץ לעלול לגרום למונענבדק באמצעותמחייבשימוש לא מומלץ לשימוש לא מומלץ לעלול לגרום לNative AOTהמאפיין UnmanagedCallersOnlyטיפוס blittableמוסכמת קריאה (calling convention)C ABI(Application Binary Interface)שרשרת הכלים הנייטיבית לפרסום Native AOT‏.NET (מ-Core ואילך)דרישה לקרוא ללוגיקת C# מ-C/C++ בתוך אותו תהליךP/Invokeדרישה לקרוא מ-C# לפונקציות CC++/CLIדרישה לעבוד מ-C# עם ספריית C++ בצורה טבעיתCOM(Component Object Model)דרישה לחצות גבול 32/64 סיביות או גבול תהליךדרישה לפרוק תוסף בהמשךתכנון שמחזיר שגיאות כ-status codeתכנון C API מבוסס handle‏ (create/destroy/operate)גלישת המונה שמנפיק את ה-handleהתנגשות עקב מחזור מלא של ה-handleתכנון handle שמשובץ בו מספר דורdumpbinספריית ייבוא (‎.lib)

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 22, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle

1. תחילה המסקנה (בקצרה)

  • אם רוצים לקרוא מ-C/‏C++ לעיבוד של C# בתוך אותו תהליך, Native AOT + UnmanagedCallersOnly הוא מועמד חזק למדי.
  • אבל מה שמיוצא הוא בסופו של דבר רק נקודת כניסה של פונקציית C. זה לא עולם שבו חושפים string או List<T> כמו שהם.
  • בפועל יציב יותר להוריד לרמת C API שטוח בסגנון create / destroy / operate, ולציין במפורש ניהול חיים וקודי שגיאה.
  • אם רוצים לטפל בטבעיות במחלקות C++ וב-STL, עדיף C++/CLI; אם רוצים רישום, אוטומציה או מעבר תהליכים, עדיף COM.

בקיצור: אפשר להשתמש ב-C# כתוכן פנימי של DLL נייטיבי, אבל פני השטח של הגבול צריך לתכנן כ-C ABI ולא כ-‏.NET. אם מקבלים את הפשרה הזו, מקבלים כלי מעניין למדי.

פני השטח של הגבול מתוכננים כ-C ABIתוכן הפנים של C# יכול להישאר מחלקות ואוספים רגילים, אבל פני השטח שחושפים כלפי חוץ אינם string או List, אלא מורדים לרמת C API שטוח בסגנון create / destroy / operate, עם ניהול חיים וקודי שגיאה מפורשים.התוכן הפנימי הוא C# רגילהפנים שחושפים הוא C API שטוחניהול החיים מצוין ב-handleהשגיאה מוחזרת כקוד‏string או List〔T〕 לא נחשפים

איור 2: נקודת הפשרה אחת בלבד. לא ‘להראות את ‏.NET כמו שהוא’ אלא להוריד את פני השטח של הגבול ל-C ABI.

2. תחילה, איך בוחרים

מה רוצים לעשות מועמד חזק הסיבה
לקרוא מ-C# לקבוצת פונקציות C P/Invoke הכיוון פשוט וטבעי ביותר
לטפל בטבעיות מ-C# בספריית C++ C++/CLI קל לספוג בצד C++ טיפוסים, בעלות, חריגות ודברים כמו std::wstring
לחצות גבול 32/64 סיביות או גבול תהליך COM / ‏IPC DLL בתוך אותו תהליך בלבד לא יכול לחצות את זה
לקרוא מ-C/‏C++ ללוגיקת C# כ-DLL נייטיבי Native AOT + UnmanagedCallersOnly אפשר לייצא עצמאית את נקודת הכניסה של C

התצורה הזו מתאימה בייחוד למצב שבו ‘הצד הנייטיבי הוא הכוכב, ו-C# נקרא כרכיב’. זה כיוון הפוך בדיוק לזה של P/Invoke או C++/CLI.

ההבדל בכיוון של מי הכוכבP/Invoke ו-C++/CLI מתאימים לכיוון שבו C# הוא הכוכב שקורא לצד הנייטיבי, לעומת התצורה של Native AOT במאמר הזה שבה הצד הנייטיבי הוא הכוכב שקורא ללוגיקת C# כרכיב — כיוון הפוך בדיוק.קורא לנייטיביקורא ל-C# כרכיבC# הוא הכוכבP/Invoke או C++/CLIהנייטיבי הוא הכוכבייצוא עם Native AOT

איור 3: בוחרים גשר לפי כיוון. התצורה של המאמר הזה היא כיוון שבו ‘הנייטיבי הוא הכוכב ו-C# הרכיב’.

3. תרשים מבנה

מבנה הקריאה מ-C/C++ אל DLL של C#תרשים המראה שאפליקציית C או C++ קוראת בקריאת cdecl ל-DLL של C# שפורסם עם Native AOT, שה-export מסומן ב-UnmanagedCallersOnly, ושמאחוריו יושבות לוגיקת העסק וטבלת ה-handle לניהול מצב.קריאת פונקציה ב-cdeclאפליקציית C / C++DLL של C# שפורסם עם Native AOTexport עם UnmanagedCallersOnlyלוגיקת העסק ב-C#טבלת handle / ניהול מצב

איור 4: מצד אפליקציית C/‏C++ נראים כפונקציית C רק ה-export עם UnmanagedCallersOnly.

המראה פשוט. מה שחשוב הוא ליישר את פני השטח של הגבול לפונקציות C. המימוש הפנימי בצד C# יכול להיות מחלקות, אוספים או LINQ — לא משנה, אבל הפנים שחושפים כלפי חוץ צריך להיות flat.

4. תצורה מינימלית

כאן נשתמש בדוגמה מינימלית: יוצרים ‘מצבר’ (accumulator) מצד C++, מוסיפים אליו ערכים, ובסוף מקבלים את הסכום. בפועל זה יכול להיות גם מנוע שיפוט, גם פענוח הגדרות, גם מפענח פשוט. תחשבו על זה בתור הצד הנייטיבי מחזיק handle וקורא לפונקציות פעולה בזה אחר זה.

4.1. פרויקט C#

קודם כול מכינים ספריית מחלקות.

<!-- NativeAotSample.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <PublishAot>true</PublishAot>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>

שתי הנקודות החשובות:

  • להפעיל publish עם Native AOT
  • לאפשר unsafe, כי משתמשים בארגומנטים של מצביע

הדוגמה במאמר הזה מניחה net8.0, אבל הרעיון עצמו זהה גם ב-‏.NET 9 / ‏10.

4.2. קוד C# שמיוצא

מתודות שמסומנות ב-UnmanagedCallersOnly הופכות לנקודת הכניסה שנראית מצד הנייטיבי. כאן ה-handle מונפק כמספר שלם, והמצב הפנימי מנוהל ב-dictionary בצד C#.

// NativeExports.cs
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace KomuraSoft.NativeAotSample;

internal static class NativeStatus
{
    public const int Ok = 0;
    public const int InvalidArgument = -1;
    public const int InvalidHandle = -2;
    public const int UnexpectedError = -3;
}

internal sealed class Accumulator
{
    public long Total { get; private set; }

    public void Add(int value)
    {
        Total += value;
    }
}

internal static class AccumulatorStore
{
    private static readonly object s_gate = new();
    private static readonly Dictionary<nint, Accumulator> s_instances = new();
    private static long s_nextHandle = 0;

    public static int Create(out nint handle)
    {
        try
        {
            var instance = new Accumulator();
            handle = (nint)System.Threading.Interlocked.Increment(ref s_nextHandle);

            lock (s_gate)
            {
                s_instances.Add(handle, instance);
            }

            return NativeStatus.Ok;
        }
        catch
        {
            handle = 0;
            return NativeStatus.UnexpectedError;
        }
    }

    public static int Add(nint handle, int value)
    {
        try
        {
            lock (s_gate)
            {
                if (!s_instances.TryGetValue(handle, out var instance))
                {
                    return NativeStatus.InvalidHandle;
                }

                instance.Add(value);
                return NativeStatus.Ok;
            }
        }
        catch
        {
            return NativeStatus.UnexpectedError;
        }
    }

    public static int GetTotal(nint handle, out long total)
    {
        try
        {
            lock (s_gate)
            {
                if (!s_instances.TryGetValue(handle, out var instance))
                {
                    total = 0;
                    return NativeStatus.InvalidHandle;
                }

                total = instance.Total;
                return NativeStatus.Ok;
            }
        }
        catch
        {
            total = 0;
            return NativeStatus.UnexpectedError;
        }
    }

    public static int Destroy(nint handle)
    {
        try
        {
            lock (s_gate)
            {
                return s_instances.Remove(handle)
                    ? NativeStatus.Ok
                    : NativeStatus.InvalidHandle;
            }
        }
        catch
        {
            return NativeStatus.UnexpectedError;
        }
    }
}

public static unsafe class NativeExports
{
    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_create",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorCreate(nint* outHandle)
    {
        if (outHandle == null)
        {
            return NativeStatus.InvalidArgument;
        }

        var status = AccumulatorStore.Create(out var handle);
        *outHandle = handle;
        return status;
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_add",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorAdd(nint handle, int value)
    {
        return AccumulatorStore.Add(handle, value);
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_get_total",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorGetTotal(nint handle, long* outTotal)
    {
        if (outTotal == null)
        {
            return NativeStatus.InvalidArgument;
        }

        var status = AccumulatorStore.GetTotal(handle, out var total);
        *outTotal = total;
        return status;
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_destroy",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorDestroy(nint handle)
    {
        return AccumulatorStore.Destroy(handle);
    }
}

מה שקורה כאן די פשוט:

  • מה שנחשף לצד הנייטיבי הוא רק ה-handle מסוג intptr_t
  • גוף המצב עצמו מנוהל בצד C#
  • מפרקים ל-create / add / get / destroy כפונקציות שטוחות (flat)
  • ערך ההחזרה הוא קוד שגיאה, וערכי הפלט מוחזרים כארגומנטים של מצביע

בצורה הזו, גם אם מחליפים אחר כך את המימוש הפנימי בצד C#, ה-ABI בצד C נשאר יציב למדי.

API שטוח מבוסס handleב-create מונפק handle, פונקציות פעולה כמו add ו-get נקראות עם ה-handle, וב-destroy מסודרים. גוף המצב מוחזק בצד C#, וערך ההחזרה הוא קוד שגיאה, וערכי הפלט מוחזרים כארגומנטים של מצביע.create: מנפיק handleadd או get: פעולה עם ה-handledestroy: מסדרגוף המצב מוחזק בצד C#ערך ההחזרה הוא קוד שגיאה

איור 5: מה שנחשף לצד הנייטיבי הוא רק ה-handle ופונקציות הפעולה. גם אם מחליפים מימוש פנימי, ה-ABI יציב.

לגבי הנפקת ה-handle, הערה אחת נחוצה. בדוגמה, המונה להנפקה מוחזק כ-long, ותוצאת ה-Interlocked.Increment נזרקת (cast) ל-nint. יש כאן שתי תכונות שכדאי להכיר.

  • הערך 0 אינו מונפק. המונה מתחיל מ-0, ו-Increment מחזיר את הערך אחרי החיבור, כך שה-handle הראשון הוא 1. בזכות זה צד ה-C++ יכול להשתמש ב-intptr_t handle = 0; כסימן ל’עדיין לא מחזיק’.
  • ב-32 סיביות מתרחשת חיתוך. nint הוא ברוחב מצביע, כך שב-64 סיביות הוא 64 סיביות, אבל בבנייה של 32 סיביות הוא 32 סיביות. הזריקה מ-long ל-nint משליכה בשקט את הביטים העליונים, כך שכשההנפקה חוצה את 2^32 הערך עושה מחזור מלא (wraparound). בשימוש של יצירה/הריסה (create/destroy) רציפים במשך 24 שעות ברצף, זה בהחלט אפשרי מבחינה תאורטית.

מה קורה כשמתרחש מחזור מלא — כאן צריך לדייק. חריגת מפתח כפול ב-s_instances.Add(handle, instance) עוזרת רק כשאותו ערך handle ‘עדיין חי’. השימוש הרגיל ב-API הזה הוא חזרה על create ו-destroy, ו-handle שכבר עבר destroy כבר נמחק מה-dictionary. כלומר, כשהערך חוזר לאחר מחזור מלא לאותו ערך, אין מפתח כזה ב-dictionary, כך שה-Add מצליח. התוצאה היא שה-handle הישן שעדיין מוחזק בצד C מתחיל להצביע על מופע חדש שאין לו שום קשר אליו. לא נזרקת חריגה, ולא מוחזר קוד שגיאה — הערך פשוט מתקלקל בשקט.

עוד נקודה אחת: הערך המדויק 2^32 — כל 32 הביטים התחתונים שלו הם 0 — לכן הערך 0, שהיה אמור לשמש כסימן ל’עדיין לא מחזיק’, מונפק בפועל.

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

  • לשבץ מספר דור בתוך ה-handle. הביטים התחתונים כמספר סידורי, העליונים כדור, ומקדמים את הדור בכל destroy. גם אם אותו מספר סידורי חוזר, הערך לא יהיה זהה
  • לגרום לכישלון קבוע כשההנפקה נגמרת. כשההנפקה מגיעה לתקרה, מגדירים את כל ה-create הבאים ככישלון. במכשיר שממשיך לפעול זה ידרוש אתחול מחדש, אבל זה עדיף על התקלקלות שקטה

בשני המקרים, שומרים גם על כך שהמונה עצמו יוחזק כ-nint כדי לא לחרוג מרוחב ה-nint, וגם על כך שלא יונפק הערך 0.

איך נשבר מחזור מלא של הנפקת handle, ומה התיקוןכשההנפקה עושה מחזור מלא ב-32 סיביות, ה-Add על ערך שכבר נמחק מה-dictionary אחרי destroy מצליח, וה-handle הישן שנשאר בצד C מתחיל להצביע על מופע חדש שאין לו קשר, ונשבר בשקט ללא חריגה או קוד שגיאה. התיקון הוא שיבוץ מספר דור, או כישלון קבוע לאחר הגעה לתקרה.ב-32 סיביות ההנפקה עושה מחזור מלאה-Add על אותו ערך מצליחה-handle הישן מצביע על מופע חדשנשבר בשקט, ללא חריגה או קוד שגיאהתיקון: שיבוץ מספר דורתיקון: כישלון קבוע כשההנפקה נגמרת

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

4.3. פקודת הפרסום

קודם הנחת יסוד אחת. ל-publish של Native AOT דרושה שרשרת כלים נייטיבית בנפרד. אם מריצים dotnet publish רק עם PublishAot, הכישלון לא קורה בקומפילציה של C#, אלא בשלב הקישור הנייטיבי האחרון. זה המכשול הראשון.

סביבה מה נדרש
Windows ‏Visual Studio 2022 ומעלה. להתקין את עומס העבודה ‘פיתוח שולחן עבודה עם C++’ עם כל הרכיבים המוגדרים כברירת מחדל
Ubuntu 18.04 ומעלה sudo apt-get install clang zlib1g-dev
Alpine 3.15 ומעלה sudo apk add clang build-base zlib-dev
Fedora 39 ומעלה / RHEL 8 ומעלה sudo dnf install clang zlib-ng-devel zlib-ng-compat-devel zlib-devel
macOS ‏Xcode Command Line Tools (נתמך מ-‏.NET 8 ואילך)

המאמר הזה מניח Windows + C++, כך שבפועל מדובר קודם כול לבדוק ‘האם עומס העבודה C++ של Visual Studio מותקן’. שגיאות כמו מנתב קישורים (linker) שלא נמצא, או כישלון סביב link.exe, לרוב מקורן כאן.

לאחר מכן, מפרסמים כספרייה משותפת.

dotnet publish -r win-x64 -c Release /p:NativeLib=Shared

בכך יוצא DLL נייטיבי בתוך bin/Release/net8.0/win-x64/publish/. בדוגמת Windows זה .dll, ב-Linux זה .so, ב-macOS זה .dylib.

חשוב לפרסם עבור כל RID בנפרד. מה שנבנה עבור win-x64 אי אפשר להשתמש בו בהנחה של win-arm64, וגם צריך ליישר bitness בין הצד הקורא ל-DLL.

המכשול הראשון של הפרסוםל-publish של Native AOT דרושה שרשרת כלים נייטיבית בנפרד, ובלעדיה הרצת dotnet publish נכשלת לא בקומפילציה של C# אלא בשלב הקישור הנייטיבי האחרון. מפרסמים עבור כל RID בנפרד, ומיישרים גם את ה-bitness.לאכןהרצת dotnet publishהאם קיימת שרשרת כלים נייטיביתנכשל בשלב הקישור הנייטיבי האחרוןיוצא DLL נייטיבי עבור כל RIDמיישרים bitness עם הצד הקורא

איור 7: מה שנכשל הוא לא קומפילציית C# אלא שלב הקישור. המכשול הראשון הוא קיום שרשרת הכלים.

4.4. דוגמת קריאה מצד C++

הפעם נוציא זמנית מהתמונה את import lib, ונקרא בפשטות עם LoadLibrary / ‏GetProcAddress. בצורה הזו קל לראות מה מיוצא ובאיזו חתימה צריך לקבל אותו.

/* native_api.h */
#pragma once
#include <stdint.h>

enum km_status
{
    KM_STATUS_OK = 0,
    KM_STATUS_INVALID_ARGUMENT = -1,
    KM_STATUS_INVALID_HANDLE = -2,
    KM_STATUS_UNEXPECTED_ERROR = -3
};

typedef int (__cdecl *km_accumulator_create_fn)(intptr_t* out_handle);
typedef int (__cdecl *km_accumulator_add_fn)(intptr_t handle, int value);
typedef int (__cdecl *km_accumulator_get_total_fn)(intptr_t handle, int64_t* out_total);
typedef int (__cdecl *km_accumulator_destroy_fn)(intptr_t handle);
// main.cpp
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <windows.h>

#include "native_api.h"

template <typename T>
T LoadSymbol(HMODULE module, const char* name)
{
    FARPROC proc = ::GetProcAddress(module, name);
    if (proc == nullptr)
    {
        std::cerr << "GetProcAddress failed: " << name << '\n';
        std::exit(EXIT_FAILURE);
    }

    return reinterpret_cast<T>(proc);
}

int main()
{
    HMODULE module = ::LoadLibraryW(L"NativeAotSample.dll");
    if (module == nullptr)
    {
        std::cerr << "LoadLibraryW failed" << '\n';
        return EXIT_FAILURE;
    }

    auto create = LoadSymbol<km_accumulator_create_fn>(module, "km_accumulator_create");
    auto add = LoadSymbol<km_accumulator_add_fn>(module, "km_accumulator_add");
    auto getTotal = LoadSymbol<km_accumulator_get_total_fn>(module, "km_accumulator_get_total");
    auto destroy = LoadSymbol<km_accumulator_destroy_fn>(module, "km_accumulator_destroy");

    intptr_t handle = 0;
    if (create(&handle) != KM_STATUS_OK)
    {
        std::cerr << "create failed" << '\n';
        return EXIT_FAILURE;
    }

    if (add(handle, 10) != KM_STATUS_OK)
    {
        std::cerr << "add(10) failed" << '\n';
        return EXIT_FAILURE;
    }

    if (add(handle, 20) != KM_STATUS_OK)
    {
        std::cerr << "add(20) failed" << '\n';
        return EXIT_FAILURE;
    }

    std::int64_t total = 0;
    if (getTotal(handle, &total) != KM_STATUS_OK)
    {
        std::cerr << "get_total failed" << '\n';
        return EXIT_FAILURE;
    }

    std::cout << "total = " << total << '\n';

    if (destroy(handle) != KM_STATUS_OK)
    {
        std::cerr << "destroy failed" << '\n';
        return EXIT_FAILURE;
    }

    handle = 0;

    // אין להשתמש בספרייה המשותפת של Native AOT בהנחת אפשרות לפריקה (unload).
    // FreeLibrary(module);

    return EXIT_SUCCESS;
}

בדוגמה הזו, מה שנראה מצד C++ הוא רק ‘C API שאפשר לקרוא לו דרך מצביע פונקציה’. כמעט לא צריך להיות מודעים לכך שבפנים כתוב C#.

אם שמים את ה-DLL שפורסם באותה תיקייה כמו main.exe ומריצים, מכיוון שמחברים 10 ו-20, הפלט הסטנדרטי הוא רק זה:

total = 30

אם נכשל באמצע, std::cerr מציג באיזה שלב זה קרה. אם LoadLibraryW failed — ה-DLL לא נמצא מלכתחילה; אם GetProcAddress failed: km_accumulator_add — ה-DLL כן נקרא אבל ה-export לא נמצא. זו ההבחנה.

איך מבחינים כשלא מצליחים לקרואאם LoadLibraryW נכשל, ה-DLL לא נטען וצריך לחשוד בנתיב, ב-bitness או ב-DLL תלוי חסר; אם GetProcAddress נכשל, ה-DLL כן נקרא אבל ה-export לא נמצא; אם שניהם עוברים, אפשר לקרוא דרך מצביע פונקציה.נכשלהצליחנכשלהצליחהאם LoadLibraryW הצליח?ה-DLL לא נטעןלחשוד בנתיב, ב-bitness וב-DLL תלויהאם GetProcAddress הצליח?ה-export לא נמצאאפשר לקרוא כ-C API

איור 8: לפי השלב שבו זה נכשל, מבחינים אם הבעיה בטעינת ה-DLL או ב-export.

4.5. איך מוודאים ש-export אכן קיים

כשאי אפשר ‘לקרוא’, קודם כול בודקים אם השם באמת יוצא בצד ה-DLL. שימוש ב-dumpbin בתוך Developer Command Prompt של Visual Studio הוא הדרך המהירה.

dumpbin /exports NativeAotSample.dll

אם ארבעת השמות km_accumulator_create / ‏km_accumulator_add / ‏km_accumulator_get_total / ‏km_accumulator_destroy מופיעים ברשימת ה-name, הפרסום בצד C# הצליח. כדי לסנן לפי שם:

dumpbin /exports NativeAotSample.dll | findstr km_

אם השם לא מופיע כאן — הבעיה בצד C#; אם הוא מופיע אבל GetProcAddress נכשל — הבעיה בצד הקורא. כך מבחינים.

כשה-GetProcAddress מחזיר NULL, לרוב בודקים לפי הסדר הזה:

  1. האם השם מופיע ב-dumpbin /exports (אם לא — זה סיפור בצד C#)
  2. האם המחרוזת שנכתבה ב-EntryPoint תואמת בדיוק למחרוזת שהועברה ל-GetProcAddress (גם רגישות אותיות גדולות/קטנות נשמרת)
  3. האם ה-bitness של ה-EXE הקורא וה-DLL מיושרים
  4. האם המתודה שסומנה ב-UnmanagedCallersOnly היא static ולא נמצאת בתוך generic
  5. האם המאפיין נכתב בצד ה-assembly שהוא יעד ה-publish (אם נכתב בספרייה שרק מוזכרת, הוא לא יופיע כפי שהוא)

יצוין שאם LoadLibraryW עצמו נכשל, זה לא סיפור של export. יש לחשוד קודם בנתיב ה-DLL, ב-bitness וב-DLL תלוי חסר.

סדר הבדיקה כש-GetProcAddress הוא NULLבודקים לפי הסדר: האם השם מופיע ברשימת exports של dumpbin, האם המחרוזת ב-EntryPoint תואמת בדיוק למחרוזת שהועברה, האם ה-bitness מיושר, האם המתודה static ומחוץ ל-generic, והאם המאפיין נכתב בצד ה-assembly שהוא יעד ה-publish.לא מופיעהאם השם מופיע ב-dumpbinהאם המחרוזת תואמת בדיוקהאם ה-bitness מיושרהאם static ומחוץ ל-genericהאם נכתב המאפיין בצד יעד ה-publishבודקים כבעיה בצד C#

איור 9: כשבודקים תחילה האם השם מופיע ב-export, נקבע אם הבעיה בצד C# או בצד הקורא.

4.6. כשרוצים לקשר סטטית עם import lib

עד כאן כתבנו בשיטת LoadLibrary / ‏GetProcAddress. כי כך קל לראות מה מיוצא ובאיזו חתימה צריך לקבל אותו.

מצד שני, בפועל יש הרבה מצבים שרוצים ‘לכלול (include) header ולקרוא לפונקציה ישירות’. במקרה כזה, זו טעינה סטטית באמצעות import library. כשלבים:

  1. אם ל-import library (‏.lib) יצא בפלט ה-publish, מקשרים אותו כמו שהוא
  2. אם לא יצא, מכינים קובץ .def עם רשימת שמות ה-export, ויוצרים את ה-import library עם lib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64
  3. בצד ה-header, במקום טיפוס מצביע פונקציה, כותבים הצהרת פונקציה רגילה
/* native_api_static.h */
#pragma once
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

int __cdecl km_accumulator_create(intptr_t* out_handle);
int __cdecl km_accumulator_add(intptr_t handle, int value);
int __cdecl km_accumulator_get_total(intptr_t handle, int64_t* out_total);
int __cdecl km_accumulator_destroy(intptr_t handle);

#ifdef __cplusplus
}
#endif

בצורה הזו הקוד בצד הקורא הופך פשוט בהרבה. מצד שני, אם ה-DLL לא נמצא, הקריסה מתרחשת כבר בעת עליית התהליך, כך שקשה יותר לתפעל מצב של ‘הגוף פועל, אבל רק היכולת הזו לא זמינה’. אם רוצים לחבר בסגנון תוסף (plugin), נוח יותר להישאר עם שיטת LoadLibrary.

איך בוחרים בין טעינה דינמית לקישור סטטישיטת LoadLibrary טוענת בזמן ריצה ומתאימה לחיבור בסגנון תוסף, ואילו קישור סטטי עם import library מאפשר לכלול header ולקרוא ישירות, אבל בתמורה ה-DLL אם חסר גורם לקריסה כבר בעת עליית התהליך.בסגנון תוסףלקרוא ישירות דרך headerאיך רוצים לחברשיטת LoadLibraryקישור סטטי עם import libאם ה-DLL חסר, קריסה כבר בעת עליית התהליך

איור 10: אפשר לכתוב בפשטות עם קישור סטטי, אבל מה שאפשר להריץ גם ב’בלי היכולת הזו’ הוא טעינה דינמית.

יצוין שפרסום כספרייה סטטית (‏NativeLib=Static) אינו נתמך רשמית, ולכן עדיף לא לסמוך עליו.

5. צורת API שפחות שברירית

מעניין שאפשר לייצא עם Native AOT, אבל בפועל מה לא מייצאים חשוב יותר.

5.1. קרבה ל-C ABI

קודם נסדר את המונחים. עיקר המאמר הזה הוא ‘לתכנן את פני השטח של הגבול כ-C ABI ולא כ-‏.NET’, וה-ABI הוא קיצור של Application Binary Interface — ההסכם שבין בינאריים מקומפלים איך הם מתחברים בזמן ריצה. תחשבו על זה לא כהסכם ברמת קוד המקור, אלא ברמת שפת המכונה. יש בו בעיקר שלושה מרכיבים.

ההסכם מה נקבע מה קורה אם לא שומרים עליו כאן
מוסכמת קריאה (calling convention) דרך העברת ארגומנטים — ברג’יסטר או במחסנית, ואיפה מונח ערך ההחזרה, ומי מחזיר את המחסנית אחרי הקריאה — הקורא או הנקרא הארגומנטים זזים ממקומם, המחסנית מתקלקלת מיד אחרי החזרה
מבנה הפריסה של הטיפוס כמה בייטים כל טיפוס, ואיפה ממוקמים חברי ה-struct (ריפוד (padding) ויישור) לא ניתן יותר לקרוא ערכים באמצע המבנה
השם והקישור האיות של שם הפונקציה שמיוצאת, קיום עיטור (decoration) בשם GetProcAddress לא מוצא את השם

cdecl או stdcall הם השם של המרכיב הראשון — ‘מוסכמת קריאה’. מחלקות וחריגות של C++ אינן חוצות גבול בשלום, כי שלושת ההסכמים האלה שונים מקומפיילר לקומפיילר. ולהפך: אם מצטמצמים לפונקציות C וטיפוסי בסיס בלבד, ההסכמים פשוטים ולכן קל להתחבר. ‘קרבה ל-C ABI’ פירושה להוריד את פני השטח של הגבול עד לטווח ההסכם הפשוט הזה.

מה כלול בהסכם שנקרא ABIABI הוא ההסכם שבין בינאריים מקומפלים איך הם מתחברים בזמן ריצה, וכולל שלושה מרכיבים — מוסכמת קריאה שקובעת איך מעבירים ארגומנטים וערך החזרה, מבנה הפריסה של הטיפוס, והשם והקישור.ABI(הסכם ברמת שפת המכונה)מוסכמת קריאהמבנה הפריסה של הטיפוסהשם והקישורcdecl ו-stdcall הם השם של החלק הזה

איור 11: ‘קרבה ל-C ABI’ פירושה להוריד את פני השטח של הגבול עד לטווח שבו שלושת ההסכמים האלה פשוטים.

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

  • טיפוסי בסיס כמו int32_t / ‏int64_t / ‏double
  • struct עם פריסה קבועה
  • handle בסגנון intptr_t / ‏void*
  • uint8_t* ואורך

ולהפך, מה שכדאי לא להדליף החוצה כבר מההתחלה הוא זה:

  • string
  • object
  • List<T>
  • Task
  • Span<T>
  • מחלקות C++ או std::vector או std::wstring

אם מנסים להעביר את אלה מעבר לגבול כמו שהם, פני השטח של הגבול מתעכרים מהר. לא להדליף את הצרכים של C# ל-C++, וגם לא להדליף יותר מדי את הצרכים של C++ ל-C# — זה החשוב.

טיפוסים שחוצים את הגבול וטיפוסים שלא מדליפיםמה שחוצה את הגבול הם טיפוסי בסיס, struct עם פריסה קבועה, handle, מצביע ואורך; string, object, List, Task, מחלקות C++ ו-STL לא מודלפים החוצה.טיפוסים שחוצים את הגבולטיפוסי בסיס, struct קבוע ו-handleמצביע ואורךטיפוסים שלא מדליפיםstring, List〔T〕 או Taskמחלקות C++ ו-STL

איור 12: מה שחשוב בפועל הוא ‘מה לא לייצא’. לא להדליף לגבול את הצרכים של שני הצדדים.

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

בצד C# בצד C / C++ הערה
byte / ‏sbyte uint8_t / ‏int8_t  
short / ‏ushort int16_t / ‏uint16_t  
int / ‏uint int32_t / ‏uint32_t  
long / ‏ulong int64_t / ‏uint64_t ה-long של C++ הוא 32 סיביות ב-Windows ו-64 סיביות ב-LP64 של Linux, כך שבטוח יותר לכתוב int64_t ולא long
nint / ‏nuint intptr_t / ‏uintptr_t רוחב מצביע. בבנייה של 32 סיביות זה הופך ל-32 סיביות
float / ‏double float / ‏double  
bool לא בשימוש לא blittable. מעבירים int32_t עם 0 / ‏1
char / ‏string לא בשימוש כמתואר ב-5.2, מחרוזות מטופלות כמצביע + אורך
T*(מצביע unsafe) T* ערכי הפלט מוחזרים כך
struct עם פריסה קבועה struct עם אותה פריסה חובה ליישר בין שני הצדדים את סדר החברים, הטיפוס והריפוד

5.2. מחרוזות כמצביע + אורך + קיבולת מאגר

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

int km_parse_utf8(const uint8_t* text, int32_t text_len, int32_t* out_value);
int km_format_utf8(int32_t value, uint8_t* buffer, int32_t buffer_len, int32_t* out_written);

כלומר, לקבוע מראש את קידוד התווים, את האורך, ומי מקצה את המאגר. כיוון ש-Windows, יש גם אפשרות ליישר ל-UTF-16, אבל אם חושבים גם על שפות אחרות, לרוב UTF-8 נוח יותר לטיפול.

5.3. לא להעביר חריגות מעבר לגבול

גבול פונקציה נייטיבי אינו ידידותי במיוחד לייצוג של חריגות. לכל הפחות, בטוח יותר לא לתכנן שחריגה managed תדליף כמו שהיא לצד הקורא.

בפועל, נוח לטפל כך:

  • ערך ההחזרה הוא status code
  • הנתונים בפועל מוחזרים דרך מאגר out או ארגומנט מצביע
  • במידת הצורך, מקבלים מידע נוסף בסגנון get_last_error

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

תכנון שגיאות שלא מעביר חריגות מעבר לגבוללא מדליפים חריגה managed כמו שהיא לצד הקורא, אלא הופכים אותה לצורה שבה ערך ההחזרה הוא status code, הנתונים בפועל מוחזרים דרך מאגר out או ארגומנט מצביע, ובמידת הצורך מידע נוסף מתקבל בסגנון get_last_error.חריגה בתוך C#נתפסת בתוך הגבולערך ההחזרה הוא status codeהנתונים בפועל דרך ארגומנט מצביעמידע נוסף בסגנון get_last_error

איור 13: החריגה לא חוצה את הגבול, אלא מתורגמת לעולם של status code וארגומנטים של מצביע.

5.4. קיבוע מוסכמת הקריאה

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

בייחוד אם קיימת אפשרות להתמודד עם x86, השארת הנקודה הזו מעורפלת מקשה בהמשך. גם אם ב-x64 זה לא מתגלה מיד, עדיף לקבוע את הכלל כבר מההתחלה.

5.5. מתודת ה-Export דקה, הגוף במקום נפרד

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

גם בדוגמה, ניהול המופע בפועל נמצא ב-AccumulatorStore, וה-NativeExports שמיוצא נשאר רק כנקודת כניסה דקה. זה חשוב למדי.

  • מתודת ה-export: דלפק ה-ABI
  • המחלקה הפנימית: לוגיקת C# רגילה

עם החלוקה הזו, אפשר לחשוב בנפרד על הגבול מול C++ ועל גוף הקוד של C#.

ה-export דק, הגוף במקום נפרדמתודת ה-export שמסומנת ב-UnmanagedCallersOnly נשארת רק כדלפק דק של ה-ABI, וניהול המופע בפועל ולוגיקת העסק ממוקמים במחלקה פנימית, כך שאפשר לחשוב בנפרד על הגבול ועל גוף הקוד וגם קל יותר לבדוק.מתודת ה-export〔דלפק דק〕מחלקה פנימית〔C# רגיל〕רק בדיקה והמרה של ה-ABIניתנת לבדיקה כ-C# רגיל

איור 14: לא מתחילים לכתוב לוגיקת עסק בתוך ה-export. חלוקת עבודה בין דלפק לגוף מקלה על התחזוקה והבדיקות.

6. מקרים שמתאימים

התצורה הזו מתאימה בנוחות רבה למצבים כמו אלה:

  • רוצים להשאיר את אפליקציית ה-C/‏C++ הקיימת כמות שהיא, ורק חלק מלוגיקת העסק להעביר ל-C#
  • לא רוצים להניח כתנאי סף התקנה מראש של סביבת ריצה של ‏.NET
  • אפשר לשמור על פני שטח קטנים של הפונקציות שמיוצאות
  • יש סיכוי שבעתיד ירצו לקרוא לאותו C API גם משפות אחרות כמו Rust או Go

בייחוד, זה מתאים היטב למבנה שבו האפליקציה הנייטיבית נשארת כמות שהיא, ורק שכבת הלוגיקה שקל להחליף כתובה ב-C#. ה-UI ובקרת ההתקן נשארים ב-C++, ואילו השיפוט, החישוב וכללי ההגדרות ב-C# — זו החלוקה.

חלוקה שמתאימה היטבה-UI ובקרת ההתקן נשארים ב-C++, ורק שכבת הלוגיקה שקל להחליף — כמו שיפוט, חישוב וכללי הגדרות — כתובה ב-C#, ומחוברת דרך פני שטח קטנים של C API.אפליקציית C/C++ קיימתUI ובקרת התקן נשארים ב-C++שיפוט, חישוב וכללי הגדרות ב-C#מחוברים דרך פני שטח קטנים של C APIאפשר לקרוא גם משפות אחרות דרך אותם פנים

איור 15: הצד הנייטיבי נשאר כוכב, ומביאים את הפרודוקטיביות של C# רק לשכבת הלוגיקה שקל להחליף.

7. מקרים שבכל זאת לא מתאימים

כמובן שזה לא כלי-פלא. יש מקרים שברור שלא מתאימים.

  • רוצים לטפל ישירות במחלקות C++, ב-std::vector או בחריגות
    • במקרים כאלה, C++/CLI או wrapper בצד הנייטיבי טבעיים יותר.
  • רוצים להיכנס לעולם של רישום COM, אוטומציה של VBA / Office, או הרחבות Explorer
    • כאן עדיף לחשוב בהקשר של COM.
  • רוצים לגשר בין 32/64 סיביות, או לחצות גבול תהליך
    • במקום DLL בתוך אותו תהליך, תצורה של COM / ‏IPC / תהליך נפרד טבעית יותר.
  • רוצים לפרוק (unload) תוסף בהמשך
    • אין להשתמש בספרייה המשותפת של Native AOT בהנחת פריקה.
  • ספרייה תלויה מסתמכת חזק על reflection או יצירת קוד דינמית
    • אם יוצאת אזהרת publish AOT, לא כדאי להתעלם ממנה בקלות דעת.

בסופו של דבר, קו פרשת המים הוא האם אפשר להתפשר על C ABI. אם אי אפשר, גשר אחר יהיה נקי יותר.

מקרים שלא מתאימים והגשר החלופיאם רוצים לטפל ישירות במחלקות C++ וחריגות, עדיף C++/CLI; לעולם של רישום COM ואוטומציה, עדיף הקשר של COM; לחצות bitness או גבול תהליך, עדיף COM או IPC; לתוסף שמיועד לפריקה, התצורה הזו לא מתאימה מלכתחילה.טיפוס וחריגות של C++ כמו שהםעולם של רישום ואוטומציהbitness או חציית תהליךרוצים לפרוק בהמשךמה מחפשיםל-C++/CLI או wrapperלהקשר של COMל-COM, ל-IPC או לתהליך נפרדהתצורה הזו לא מתאימה כתנאי מוקדם

איור 16: קו פרשת המים הוא ‘האם אפשר להתפשר על C ABI’. אם לא, גשר אחר נקי יותר.

8. מוקשים

לסיום, סיכום של נקודות שקל להיתקע בהן ב-export של Native AOT.

  • מתודה שמסומנת ב-UnmanagedCallersOnly חייבת להיות static.
  • אי אפשר למקם אותה בתוך מתודת generic או מחלקת generic.
  • אם רוצים named export, מציינים EntryPoint.
  • עדיף לא להשתמש ב-ref / ‏in / ‏out, אלא להחזיר דרך ארגומנטים של מצביע.
  • מה שמיוצא הוא מתודות בצד ה-assembly שהוא יעד ה-publish. אם מסמנים את המאפיין על מתודה בספרייה שרק מוזכרת, הוא לא יופיע כמות שהוא.
  • צריך ליישר bitness בין הצד הקורא ל-DLL.
  • אזהרות publish חשובות למדי. אם יוצאות אזהרות AOT / trimming, בטוח יותר לטפל בהן קודם.

כל אלה עניינים שברגע שיודעים אותם, אומרים ‘כן, ברור’. אבל אם דורכים עליהם בלי לדעת, עובר זמן די מריר.

9. סיכום

כשרוצים לקרוא מ-C/‏C++ ל-C#, האפשרויות הראשונות שעולות לראש הן לרוב COM, C++/CLI, או תהליך נפרד. כולן בחירות נכונות.

אבל אם רוצים להחדיר את העיבוד של C# כ-DLL נייטיבי בתוך אותו תהליך, Native AOT + UnmanagedCallersOnly הוא אפשרות מעניינת למדי.

הנה שוב הנקודות העיקריות:

  • לא לחשוף את C# כמו שהוא, אלא לצמצם (flatten) ל-C ABI
  • לציין במפורש ניהול חיים מבוסס handle
  • לחצות את הגבול עם error code ולא עם חריגה
  • לקבע את מוסכמת הקריאה
  • מתודת ה-export דקה, מופרדת מהלוגיקה הפנימית

מה שעושים כאן לא מרשים. אבל ‘איך חותכים את הגבול’ כזה משפיע מאוד על התחזוקה בהמשך. כשרוצים לנצל נכסים נייטיביים ובו-זמנית להביא את הפרודוקטיביות של C# רק לשכבת הלוגיקה, שווה לזכור את התצורה הזו.

חמשת סעיפי הגבול שפחות שבריריחמש נקודות בונות גבול פחות שברירי — צמצום ל-C ABI, ניהול חיים מבוסס handle, חציית גבול עם error code, קיבוע מוסכמת הקריאה, וייצוא דק שמופרד מהלוגיקה הפנימית.DLL נייטיבי מ-C# שפחות שבריריצמצום(flatten)ל-C ABIציון חיים באמצעות handleחציית גבול עם error codeקיבוע מוסכמת הקריאהה-export דק, מופרד מהגוף

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

10. מקורות

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

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

אפשר לקרוא לקוד C# מ-C++?
כן, אפשר. ב-Native AOT של ‏.NET אפשר לפרסם ספריית מחלקות C# כספרייה משותפת נייטיבית, ולחשוף מתודות שמסומנות ב-UnmanagedCallersOnly כנקודת כניסה של C. כלומר אפשר להשתמש ב-C# כ'DLL נייטיבי שנקראים אליו' מ-C/‏C++, בתוך אותו תהליך.
לאיזה מצב מתאימה התצורה הזו?
למצב שבו רוצים להשאיר את גוף האפליקציה הנייטיבית כמות שהוא, ורק חלקים כמו לוגיקת שיפוט, עיבוד מחרוזות, פענוח הגדרות או כללי חישוב להעביר ל-C#. המאפיין הוא כיוון שבו 'הצד הנייטיבי הוא הכוכב, ו-C# נקרא כרכיב'. ולהפך — אם רוצים לקרוא מ-C# לקבוצת פונקציות C, עדיף P/Invoke; אם רוצים לטפל בטבעיות בטיפוסים ובבעלות של C++, עדיף C++/CLI; ואם רוצים לחצות גבול 32/64 סיביות או גבול תהליך, עדיף COM/‏IPC.
מה נקודות הזהירות בתכנון ה-API?
מה שמיוצא הוא בסופו של דבר רק נקודת כניסה של פונקציית C, כך שאסור לחשוף ישירות בגבול string, ‏List<T> או חריגות. עדיף להוריד לרמת C API שטוח בסגנון create / destroy / operate, לציין במפורש ניהול חיים וקודי שגיאה, לטפל במחרוזות כמצביע + אורך + קיבולת מאגר, לא להעביר חריגות מעבר לגבול, ולקבע את מוסכמת הקריאה. עיקר הדבר הוא לתכנן את פני השטח של הגבול כ-C ABI ולא כ-‏.NET.
יש קוד לדוגמה שאפשר להריץ?
כן, יש. במאגר komurasoft-blog-samples ב-GitHub פורסמה חבילת דוגמה מלאה שאפשר לבנות ולהריץ, כולל ספריית C# שמפורסמת עם Native AOT, דוגמת קריאה מ-C++ ובדיקות יחידה. דוגמת הקוד מניחה DLL של Windows, אבל רעיון הבסיס דומה כמעט לגמרי גם ב-Linux / macOS.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג