איך קוראים ל-DLL של C# Native AOT מ-C/C++
· עודכן בתאריך: · Go Komura · 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.
flowchart TB
accTitle: ההבדל בכיוון בין הפעם הקודמת להפעם הזו
accDescr: בפעם הקודמת עסקנו בגבול שבו C# קורא ל-DLL נייטיבי, ובפעם הזו הכיוון הפוך — אפליקציית C/C++ קוראת בתוך אותו תהליך ל-DLL נייטיבי של C# שפורסם עם Native AOT.
prev["בפעם הקודמת: C# קורא ל-C++"] --> wrap["הסיפור על wrapper של C++/CLI"]
now["הפעם: C/C++ קורא ל-C#"] --> aot["הפיכת C# ל-DLL עם Native AOT"]
aot --> entry["UnmanagedCallersOnly הוא נקודת הכניסה"]
איור 1: הפעם הכיוון הפוך מ-P/Invoke ומ-C++/CLI, ו-C# הופך ל’DLL נייטיבי שנקראים אליו’.
יצוין שהקוד שמופיע במאמר הזה פורסם ב-GitHub כחבילה מלאה שאפשר לבנות ולהריץ (ספריית C# שמתפרסמת עם Native AOT, דוגמת קריאה מ-C++, ובדיקות יחידה).
csharp-native-aot-native-dll-from-c-cpp - komurasoft-blog-samples (GitHub)
תוכן העניינים
- תחילה המסקנה (בקצרה)
- תחילה, איך בוחרים
- תרשים מבנה
- תצורה מינימלית
- 4.1. פרויקט C#
- 4.2. קוד C# שמיוצא
- 4.3. פקודת הפרסום
- 4.4. דוגמת קריאה מצד C++
- 4.5. איך מוודאים ש-export אכן קיים
- 4.6. כשרוצים לקשר סטטית עם import lib
- צורת API שפחות שברירית
- 5.1. קרבה ל-C ABI
- 5.2. מחרוזות כמצביע + אורך + קיבולת מאגר
- 5.3. לא להעביר חריגות מעבר לגבול
- 5.4. קיבוע מוסכמת הקריאה
- 5.5. מתודת ה-Export דקה, הגוף במקום נפרד
- מקרים שמתאימים
- מקרים שבכל זאת לא מתאימים
- מוקשים
- סיכום
- מקורות
מפת הידע של המאמר
Native AOT של C# יכול לחשוף מתודות שסומנו במאפיין UnmanagedCallersOnly כנקודת כניסה של C, ולממש תצורה שבה קוראים ללוגיקת C# בלבד, בתוך אותו תהליך, מאפליקציית C/C++ קיימת. עם זאת, מכיוון שרק טיפוסי blittable יכולים לחצות את הגבול, בפועל מומלץ תכנון שלא חושף string או List
flowchart LR
accTitle: מפת הידע של קריאה ל-C# כ-DLL נייטיבי מ-C/C++ עם Native AOT
accDescr: תרשים שמראה איך Native AOT ו-UnmanagedCallersOnly מאפשרים לפרסם C# כ-DLL נייטיבי שנקרא מ-C/C++; את נקודות המפתח של הורדת פני השטח של הגבול ל-C ABI — מוסכמת קריאה, טיפוסי blittable, תכנון handle וייצוג שגיאות; ואת החלוקה בין P/Invoke, C++/CLI ו-COM.
native_aot["Native AOT"]
unmanagedcallersonly["המאפיין UnmanagedCallersOnly"]
blittable_type["טיפוס blittable"]
calling_convention["מוסכמת קריאה (calling convention)"]
c_abi["C ABI(Application Binary Interface)"]
native_aot_publish_toolchain["שרשרת הכלים הנייטיבית לפרסום Native AOT"]
dotnet[".NET (מ-Core ואילך)"]
native_to_managed_inprocess_call["דרישה לקרוא ללוגיקת C# מ-C/C++ בתוך אותו תהליך"]
p_invoke["P/Invoke"]
csharp_calls_c_functions["דרישה לקרוא מ-C# לפונקציות C"]
cpp_cli["C++/CLI"]
csharp_calls_cpp_library["דרישה לעבוד מ-C# עם ספריית C++ בצורה טבעית"]
com_interop["COM(Component Object Model)"]
cross_process_or_bitness_boundary["דרישה לחצות גבול 32/64 סיביות או גבול תהליך"]
plugin_unload_requirement["דרישה לפרוק תוסף בהמשך"]
status_code_error_convention["תכנון שמחזיר שגיאות כ-status code"]
handle_based_api_design["תכנון C API מבוסס handle (create/destroy/operate)"]
handle_counter_overflow["גלישת המונה שמנפיק את ה-handle"]
handle_wraparound_collision["התנגשות עקב מחזור מלא של ה-handle"]
generation_embedded_handle["תכנון handle שמשובץ בו מספר דור"]
dumpbin["dumpbin"]
import_library["ספריית ייבוא (.lib)"]
native_aot -->|"משתמש ב"| unmanagedcallersonly
unmanagedcallersonly -->|"מחייב"| blittable_type
calling_convention -->|"מוגדר באמצעות"| unmanagedcallersonly
c_abi -->|"משתמש ב"| calling_convention
unmanagedcallersonly -->|"מחייב"| c_abi
native_aot -->|"מחייב"| native_aot_publish_toolchain
native_aot -->|"מחייב"| dotnet
native_aot -->|"מענה מומלץ ל"| native_to_managed_inprocess_call
p_invoke -->|"מענה מומלץ ל"| csharp_calls_c_functions
cpp_cli -->|"מענה מומלץ ל"| csharp_calls_cpp_library
com_interop -->|"מענה מומלץ ל"| cross_process_or_bitness_boundary
native_aot -->|"שימוש לא מומלץ ל"| cross_process_or_bitness_boundary
native_aot -->|"שימוש לא מומלץ ל"| plugin_unload_requirement
status_code_error_convention -->|"מענה מומלץ ל"| unmanagedcallersonly
handle_based_api_design -->|"מענה מומלץ ל"| unmanagedcallersonly
handle_counter_overflow -.->|"עלול לגרום ל"| handle_wraparound_collision
generation_embedded_handle -->|"מונע"| handle_wraparound_collision
native_aot -->|"נבדק באמצעות"| dumpbin
import_library -.->|"מחייב"| native_aot
native_aot -->|"שימוש לא מומלץ ל"| csharp_calls_c_functions
native_aot -->|"שימוש לא מומלץ ל"| csharp_calls_cpp_library
handle_based_api_design -.->|"עלול לגרום ל"| handle_wraparound_collision
בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 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. אם מקבלים את הפשרה הזו, מקבלים כלי מעניין למדי.
flowchart TB
accTitle: פני השטח של הגבול מתוכננים כ-C ABI
accDescr: תוכן הפנים של C# יכול להישאר מחלקות ואוספים רגילים, אבל פני השטח שחושפים כלפי חוץ אינם string או List<T>, אלא מורדים לרמת C API שטוח בסגנון create / destroy / operate, עם ניהול חיים וקודי שגיאה מפורשים.
inner["התוכן הפנימי הוא C# רגיל"] --> face["הפנים שחושפים הוא C API שטוח"]
face --> h["ניהול החיים מצוין ב-handle"]
face --> e["השגיאה מוחזרת כקוד"]
face -.-> ng["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.
flowchart TB
accTitle: ההבדל בכיוון של מי הכוכב
accDescr: P/Invoke ו-C++/CLI מתאימים לכיוון שבו C# הוא הכוכב שקורא לצד הנייטיבי, לעומת התצורה של Native AOT במאמר הזה שבה הצד הנייטיבי הוא הכוכב שקורא ללוגיקת C# כרכיב — כיוון הפוך בדיוק.
cs["C# הוא הכוכב"] -->|"קורא לנייטיבי"| n1["P/Invoke או C++/CLI"]
nat["הנייטיבי הוא הכוכב"] -->|"קורא ל-C# כרכיב"| n2["ייצוא עם Native AOT"]
איור 3: בוחרים גשר לפי כיוון. התצורה של המאמר הזה היא כיוון שבו ‘הנייטיבי הוא הכוכב ו-C# הרכיב’.
3. תרשים מבנה
flowchart LR
accTitle: מבנה הקריאה מ-C/C++ אל DLL של C#
accDescr: תרשים המראה שאפליקציית C או C++ קוראת בקריאת cdecl ל-DLL של C# שפורסם עם Native AOT, שה-export מסומן ב-UnmanagedCallersOnly, ושמאחוריו יושבות לוגיקת העסק וטבלת ה-handle לניהול מצב.
Cpp["אפליקציית C / C++"] -->|"קריאת פונקציה ב-cdecl"| Dll["DLL של C# שפורסם עם Native AOT"]
Dll --> Exports["export עם UnmanagedCallersOnly"]
Exports --> Core["לוגיקת העסק ב-C#"]
Exports --> Store["טבלת 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 נשאר יציב למדי.
flowchart TB
accTitle: API שטוח מבוסס handle
accDescr: ב-create מונפק handle, פונקציות פעולה כמו add ו-get נקראות עם ה-handle, וב-destroy מסודרים. גוף המצב מוחזק בצד C#, וערך ההחזרה הוא קוד שגיאה, וערכי הפלט מוחזרים כארגומנטים של מצביע.
create["create: מנפיק handle"] --> op["add או get: פעולה עם ה-handle"]
op --> destroy["destroy: מסדר"]
op -.-> state["גוף המצב מוחזק בצד C#"]
op -.-> err["ערך ההחזרה הוא קוד שגיאה"]
איור 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.
flowchart TB
accTitle: איך נשבר מחזור מלא של הנפקת handle, ומה התיקון
accDescr: כשההנפקה עושה מחזור מלא ב-32 סיביות, ה-Add על ערך שכבר נמחק מה-dictionary אחרי destroy מצליח, וה-handle הישן שנשאר בצד C מתחיל להצביע על מופע חדש שאין לו קשר, ונשבר בשקט ללא חריגה או קוד שגיאה. התיקון הוא שיבוץ מספר דור, או כישלון קבוע לאחר הגעה לתקרה.
wrapd["ב-32 סיביות ההנפקה עושה מחזור מלא"] --> add["ה-Add על אותו ערך מצליח"]
add --> alias["ה-handle הישן מצביע על מופע חדש"]
alias --> silent["נשבר בשקט, ללא חריגה או קוד שגיאה"]
silent --> g1["תיקון: שיבוץ מספר דור"]
silent --> g2["תיקון: כישלון קבוע כשההנפקה נגמרת"]
איור 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.
flowchart TB
accTitle: המכשול הראשון של הפרסום
accDescr: ל-publish של Native AOT דרושה שרשרת כלים נייטיבית בנפרד, ובלעדיה הרצת dotnet publish נכשלת לא בקומפילציה של C# אלא בשלב הקישור הנייטיבי האחרון. מפרסמים עבור כל RID בנפרד, ומיישרים גם את ה-bitness.
pub["הרצת dotnet publish"] --> q{"האם קיימת שרשרת כלים נייטיבית"}
q -->|"לא"| fail["נכשל בשלב הקישור הנייטיבי האחרון"]
q -->|"כן"| out["יוצא DLL נייטיבי עבור כל RID"]
out -.-> match["מיישרים 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 לא נמצא. זו ההבחנה.
flowchart TB
accTitle: איך מבחינים כשלא מצליחים לקרוא
accDescr: אם LoadLibraryW נכשל, ה-DLL לא נטען וצריך לחשוד בנתיב, ב-bitness או ב-DLL תלוי חסר; אם GetProcAddress נכשל, ה-DLL כן נקרא אבל ה-export לא נמצא; אם שניהם עוברים, אפשר לקרוא דרך מצביע פונקציה.
s1{"האם LoadLibraryW הצליח?"}
s1 -->|"נכשל"| f1["ה-DLL לא נטען"]
f1 -.-> f1a["לחשוד בנתיב, ב-bitness וב-DLL תלוי"]
s1 -->|"הצליח"| s2{"האם GetProcAddress הצליח?"}
s2 -->|"נכשל"| f2["ה-export לא נמצא"]
s2 -->|"הצליח"| ok["אפשר לקרוא כ-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, לרוב בודקים לפי הסדר הזה:
- האם השם מופיע ב-
dumpbin /exports(אם לא — זה סיפור בצד C#) - האם המחרוזת שנכתבה ב-
EntryPointתואמת בדיוק למחרוזת שהועברה ל-GetProcAddress(גם רגישות אותיות גדולות/קטנות נשמרת) - האם ה-bitness של ה-EXE הקורא וה-DLL מיושרים
- האם המתודה שסומנה ב-
UnmanagedCallersOnlyהיאstaticולא נמצאת בתוך generic - האם המאפיין נכתב בצד ה-assembly שהוא יעד ה-publish (אם נכתב בספרייה שרק מוזכרת, הוא לא יופיע כפי שהוא)
יצוין שאם LoadLibraryW עצמו נכשל, זה לא סיפור של export. יש לחשוד קודם בנתיב ה-DLL, ב-bitness וב-DLL תלוי חסר.
flowchart TB
accTitle: סדר הבדיקה כש-GetProcAddress הוא NULL
accDescr: בודקים לפי הסדר: האם השם מופיע ברשימת exports של dumpbin, האם המחרוזת ב-EntryPoint תואמת בדיוק למחרוזת שהועברה, האם ה-bitness מיושר, האם המתודה static ומחוץ ל-generic, והאם המאפיין נכתב בצד ה-assembly שהוא יעד ה-publish.
c1["האם השם מופיע ב-dumpbin"] --> c2["האם המחרוזת תואמת בדיוק"]
c2 --> c3["האם ה-bitness מיושר"]
c3 --> c4["האם static ומחוץ ל-generic"]
c4 --> c5["האם נכתב המאפיין בצד יעד ה-publish"]
c1 -.->|"לא מופיע"| cs["בודקים כבעיה בצד C#"]
איור 9: כשבודקים תחילה האם השם מופיע ב-export, נקבע אם הבעיה בצד C# או בצד הקורא.
4.6. כשרוצים לקשר סטטית עם import lib
עד כאן כתבנו בשיטת LoadLibrary / GetProcAddress. כי כך קל לראות מה מיוצא ובאיזו חתימה צריך לקבל אותו.
מצד שני, בפועל יש הרבה מצבים שרוצים ‘לכלול (include) header ולקרוא לפונקציה ישירות’. במקרה כזה, זו טעינה סטטית באמצעות import library. כשלבים:
- אם ל-import library (
.lib) יצא בפלט ה-publish, מקשרים אותו כמו שהוא - אם לא יצא, מכינים קובץ
.defעם רשימת שמות ה-export, ויוצרים את ה-import library עםlib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64 - בצד ה-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.
flowchart TB
accTitle: איך בוחרים בין טעינה דינמית לקישור סטטי
accDescr: שיטת LoadLibrary טוענת בזמן ריצה ומתאימה לחיבור בסגנון תוסף, ואילו קישור סטטי עם import library מאפשר לכלול header ולקרוא ישירות, אבל בתמורה ה-DLL אם חסר גורם לקריסה כבר בעת עליית התהליך.
q{"איך רוצים לחבר"}
q -->|"בסגנון תוסף"| dyn["שיטת LoadLibrary"]
q -->|"לקרוא ישירות דרך header"| stat["קישור סטטי עם import lib"]
stat -.-> risk["אם ה-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’ פירושה להוריד את פני השטח של הגבול עד לטווח ההסכם הפשוט הזה.
flowchart TB
accTitle: מה כלול בהסכם שנקרא ABI
accDescr: ABI הוא ההסכם שבין בינאריים מקומפלים איך הם מתחברים בזמן ריצה, וכולל שלושה מרכיבים — מוסכמת קריאה שקובעת איך מעבירים ארגומנטים וערך החזרה, מבנה הפריסה של הטיפוס, והשם והקישור.
abi["ABI(הסכם ברמת שפת המכונה)"] --> a1["מוסכמת קריאה"]
abi --> a2["מבנה הפריסה של הטיפוס"]
abi --> a3["השם והקישור"]
a1 -.-> ex["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# — זה החשוב.
flowchart TB
accTitle: טיפוסים שחוצים את הגבול וטיפוסים שלא מדליפים
accDescr: מה שחוצה את הגבול הם טיפוסי בסיס, struct עם פריסה קבועה, handle, מצביע ואורך; string, object, List<T>, Task, מחלקות C++ ו-STL לא מודלפים החוצה.
edge["טיפוסים שחוצים את הגבול"] --> ok1["טיפוסי בסיס, struct קבוע ו-handle"]
edge --> ok2["מצביע ואורך"]
keepx["טיפוסים שלא מדליפים"] --> ng1["string, List〔T〕 או Task"]
keepx --> ng2["מחלקות 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
זה לא מרשים, אבל תכנון צנוע כזה משתלם בהמשך. כלומר לא להתחיל ‘קרב היאבקות’ פתאומי בגבול.
flowchart TB
accTitle: תכנון שגיאות שלא מעביר חריגות מעבר לגבול
accDescr: לא מדליפים חריגה managed כמו שהיא לצד הקורא, אלא הופכים אותה לצורה שבה ערך ההחזרה הוא status code, הנתונים בפועל מוחזרים דרך מאגר out או ארגומנט מצביע, ובמידת הצורך מידע נוסף מתקבל בסגנון get_last_error.
exc["חריגה בתוך C#"] --> stop["נתפסת בתוך הגבול"]
stop --> code["ערך ההחזרה הוא status code"]
stop --> outp["הנתונים בפועל דרך ארגומנט מצביע"]
stop -.-> last["מידע נוסף בסגנון get_last_error"]
איור 13: החריגה לא חוצה את הגבול, אלא מתורגמת לעולם של status code וארגומנטים של מצביע.
5.4. קיבוע מוסכמת הקריאה
בדוגמה ציינו במפורש CallConvCdecl. אם משמיטים, מקבלים את מוסכמת הקריאה שבברירת המחדל של הפלטפורמה, אבל אם רוצים לקבע header וטיפוס מצביע פונקציה, עדיף לציין זאת במפורש כדי לצמצם תקלות.
בייחוד אם קיימת אפשרות להתמודד עם x86, השארת הנקודה הזו מעורפלת מקשה בהמשך. גם אם ב-x64 זה לא מתגלה מיד, עדיף לקבוע את הכלל כבר מההתחלה.
5.5. מתודת ה-Export דקה, הגוף במקום נפרד
מתודה שסומנה ב-UnmanagedCallersOnly אינה מיועדת להיקרא ישירות מקוד managed רגיל. לכן אם מתחילים לכתוב שם את כל לוגיקת העסק, גם קשה יותר לבדוק.
גם בדוגמה, ניהול המופע בפועל נמצא ב-AccumulatorStore, וה-NativeExports שמיוצא נשאר רק כנקודת כניסה דקה. זה חשוב למדי.
- מתודת ה-export: דלפק ה-ABI
- המחלקה הפנימית: לוגיקת C# רגילה
עם החלוקה הזו, אפשר לחשוב בנפרד על הגבול מול C++ ועל גוף הקוד של C#.
flowchart TB
accTitle: ה-export דק, הגוף במקום נפרד
accDescr: מתודת ה-export שמסומנת ב-UnmanagedCallersOnly נשארת רק כדלפק דק של ה-ABI, וניהול המופע בפועל ולוגיקת העסק ממוקמים במחלקה פנימית, כך שאפשר לחשוב בנפרד על הגבול ועל גוף הקוד וגם קל יותר לבדוק.
exp["מתודת ה-export〔דלפק דק〕"] --> core["מחלקה פנימית〔C# רגיל〕"]
exp -.-> abi["רק בדיקה והמרה של ה-ABI"]
core -.-> test["ניתנת לבדיקה כ-C# רגיל"]
איור 14: לא מתחילים לכתוב לוגיקת עסק בתוך ה-export. חלוקת עבודה בין דלפק לגוף מקלה על התחזוקה והבדיקות.
6. מקרים שמתאימים
התצורה הזו מתאימה בנוחות רבה למצבים כמו אלה:
- רוצים להשאיר את אפליקציית ה-C/C++ הקיימת כמות שהיא, ורק חלק מלוגיקת העסק להעביר ל-C#
- לא רוצים להניח כתנאי סף התקנה מראש של סביבת ריצה של .NET
- אפשר לשמור על פני שטח קטנים של הפונקציות שמיוצאות
- יש סיכוי שבעתיד ירצו לקרוא לאותו C API גם משפות אחרות כמו Rust או Go
בייחוד, זה מתאים היטב למבנה שבו האפליקציה הנייטיבית נשארת כמות שהיא, ורק שכבת הלוגיקה שקל להחליף כתובה ב-C#. ה-UI ובקרת ההתקן נשארים ב-C++, ואילו השיפוט, החישוב וכללי ההגדרות ב-C# — זו החלוקה.
flowchart TB
accTitle: חלוקה שמתאימה היטב
accDescr: ה-UI ובקרת ההתקן נשארים ב-C++, ורק שכבת הלוגיקה שקל להחליף — כמו שיפוט, חישוב וכללי הגדרות — כתובה ב-C#, ומחוברת דרך פני שטח קטנים של C API.
app["אפליקציית C/C++ קיימת"] --> keepn["UI ובקרת התקן נשארים ב-C++"]
app --> logic["שיפוט, חישוב וכללי הגדרות ב-C#"]
logic --> api["מחוברים דרך פני שטח קטנים של C API"]
api -.-> multi["אפשר לקרוא גם משפות אחרות דרך אותם פנים"]
איור 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. אם אי אפשר, גשר אחר יהיה נקי יותר.
flowchart TB
accTitle: מקרים שלא מתאימים והגשר החלופי
accDescr: אם רוצים לטפל ישירות במחלקות C++ וחריגות, עדיף C++/CLI; לעולם של רישום COM ואוטומציה, עדיף הקשר של COM; לחצות bitness או גבול תהליך, עדיף COM או IPC; לתוסף שמיועד לפריקה, התצורה הזו לא מתאימה מלכתחילה.
q{"מה מחפשים"}
q -->|"טיפוס וחריגות של C++ כמו שהם"| cli["ל-C++/CLI או wrapper"]
q -->|"עולם של רישום ואוטומציה"| com["להקשר של COM"]
q -->|"bitness או חציית תהליך"| ipc["ל-COM, ל-IPC או לתהליך נפרד"]
q -->|"רוצים לפרוק בהמשך"| ng["התצורה הזו לא מתאימה כתנאי מוקדם"]
איור 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# רק לשכבת הלוגיקה, שווה לזכור את התצורה הזו.
flowchart TB
accTitle: חמשת סעיפי הגבול שפחות שברירי
accDescr: חמש נקודות בונות גבול פחות שברירי — צמצום ל-C ABI, ניהול חיים מבוסס handle, חציית גבול עם error code, קיבוע מוסכמת הקריאה, וייצוא דק שמופרד מהלוגיקה הפנימית.
goal["DLL נייטיבי מ-C# שפחות שברירי"] --> p1["צמצום(flatten)ל-C ABI"]
goal --> p2["ציון חיים באמצעות handle"]
goal --> p3["חציית גבול עם error code"]
goal --> p4["קיבוע מוסכמת הקריאה"]
p1 -.-> p5["ה-export דק, מופרד מהגוף"]
איור 17: חמשת סעיפי הסיכום. תכנון גבול צנוע הוא מה שמשתלם הכי הרבה בתחזוקה בהמשך.
10. מקורות
- חבילת הדוגמה המלאה של המאמר הזה (ספריית C#, דוגמת קריאה מ-C++, בדיקות יחידה) - komurasoft-blog-samples (GitHub)
- Native code interop with Native AOT - Microsoft Learn
- Building native libraries - Microsoft Learn
- Native AOT deployment - Microsoft Learn
- UnmanagedCallersOnlyAttribute Class - Microsoft Learn
- UnmanagedCallersOnlyAttribute.CallConvs Field - Microsoft Learn
- C# compiler breaking changes: ref / ref readonly / in / out are not allowed on methods attributed with UnmanagedCallersOnly
- Building Native Libraries with NativeAOT - dotnet/samples
- DUMPBIN /EXPORTS - Microsoft Learn
- LIB Reference - Microsoft Learn
- קריאה ל-DLL נייטיבי מ-C#: wrapper של C++/CLI מול P/Invoke - KomuraSoft Blog
- דוגמה מעשית לגשר COM שקורא ל-DLL של 64 סיביות מאפליקציית 32 סיביות - KomuraSoft Blog
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
רשימת בדיקה לטיפול בטוח בתהליכי ילד ביישום Windows
כדי לטפל בבטחה בתהליכי ילד ביישום Windows, תכנון הבעלות על עץ התהליכים ונוהל הסיום חשוב יותר מבחירת ה-API להפעלה. המאמר מסדר את Job Objec...
רשימת בדיקה לפני מעבר מ-.NET Framework ל-.NET
רשימת בדיקה מעשית לפני מעבר מ-.NET Framework ל-.NET, שבודקת סוג פרויקט, טכנולוגיות לא נתמכות, תלויות NuGet, סגנון SDK, WPF/WinForms, ...
מה זה .NET Native AOT — ההבדל מ-JIT ומ-trimming
מהו Native AOT — סקירה לפי ההבדל מ-JIT, ReadyToRun, self-contained, single-file, trimming ו-source generator, וסיכום מעשי של המקרים ש...
למה להשתמש ב-Generic Host וב-BackgroundService של .NET באפליקציית desktop
בכלי Windows ובאפליקציות שרצות ברקע, מסודר כאן איך להשתמש ב-Generic Host וב-BackgroundService כדי לסדר הפעלה, עיבוד תקופתי, סיום, לוג, הג...
מדריך מעשי ל-FileSystemWatcher — התמודדות עם פספוסים וכפילויות
המאמר מסדר את אופן השימוש ואת נקודות התשומת לב של FileSystemWatcher, מנקודת המבט של פספוסים, התראות כפולות, מלכודות בקביעת סיום, סריקה חו...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
יכולת פעולה הדדית בין 32 ל-64 סיביות
תאימות 32/64 סיביות, גבולות native והחלטות תכנון ב-Windows.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
מדובר בנושא של מימוש הגבול שבין C# ל-C/C++, כך שזה קשור ישירות לייעוץ תכנון ומימוש במסגרת פיתוח אפליקציות Windows.
שימוש חוזר והעברה של נכסים קיימים
מבחינת האופן שבו בונים גשר בין נכסים נייטיביים קיימים ל-.NET, זה מתאים גם לשימוש בנכסים קיימים ותמיכה במעבר.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- אפשר לקרוא לקוד 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.