איך קוראים ל-DLL של C# Native AOT מ-C/C++
· עודכן בתאריך: · Go Komura · C#, .NET, Native AOT, C++, פיתוח Windows, native interop
היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 12 Mar 2026)
- פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173411)
מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.
Go Komura (2026). איך קוראים ל-DLL של C# Native AOT מ-C/C++. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173411 https://comcomponent.com/he/blog/csharp-native-aot-native-dll-from-c-cpp/
- DOI (הגרסה האחרונה)
- 10.5281/zenodo.22173411
- DOI (הגרסה הזו)
- 10.5281/zenodo.22173412
בפעם הקודמת, בהסיבה לכך ש-wrapper של C++/CLI הוא האפשרות המובילה כשקוראים ל-DLL native מ-C#, כיסינו את פני השטח של הגבול כשקוראים מ-C# ל-C++. הפעם ההפך: הסיפור הוא קריאה מ-C/C++ ל-C#.
יש מצבים שבהם רוצים לקרוא מאפליקציית C/C++ קיימת לעיבוד שכתוב ב-C#, אבל הכיוון של P/Invoke הפוך, ולהביא עד C++/CLI או COM זה יותר מדי. בייחוד כשרוצים להשאיר את גוף האפליקציה ה-native כמות שהוא, ורק חלקים כמו לוגיקת שיפוט, עיבוד מחרוזות, פענוח הגדרות או כללי חישוב — להעביר ל-C#.
אפשר לגשר גם עם COM, אבל הפעם זו דרך שהיא יותר in-process, יותר בסגנון DLL. ב-Native AOT של .NET אפשר לפרסם ספריית מחלקות כספרייה משותפת native, ולחשוף מתודות שמסומנות ב-UnmanagedCallersOnly כנקודת כניסה של C. כלומר אפשר להשתמש ב-C# כ-DLL native שנקראים אליו.
אבל לא הכול חוצה גבול כמו שהוא. string, List<T>, exceptions ובעלות — אם דולפים לגבול, זה מתדרדר מהר. במאמר הזה, דרך דוגמה מינימלית של Windows + C++, נסביר מתי התצורה הזו מתאימה ואיזו צורת API פחות שברירית. ב-Linux / macOS הרעיון דומה כמעט לגמרי, אבל דוגמאות הקוד מניחות DLL של Windows.
flowchart TB
accTitle: ההבדל בכיוון בין הפעם הקודמת להפעם הזו
accDescr: בפעם הקודמת עסקנו בגבול שבו C# קורא ל-DLL native, ובפעם הזו הכיוון הפוך — אפליקציית C/C++ קוראת בתוך אותו process ל-DLL native של 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 native שנקראים אליו.
יצוין שהקוד שמופיע במאמר הזה פורסם ב-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. פקודת ה-publish
- 4.4. דוגמת קריאה מצד C++
- 4.5. איך מוודאים ש-export אכן קיים
- 4.6. כשרוצים לקשר סטטית עם import lib
- צורת API שפחות שברירית
- 5.1. קרבה ל-C ABI
- 5.2. מחרוזות כמצביע + אורך + קיבולת מאגר
- 5.3. לא להעביר exceptions מעבר לגבול
- 5.4. קיבוע מוסכמת הקריאה
- 5.5. מתודת ה-Export דקה, הגוף במקום נפרד
- מקרים שמתאימים
- מקרים שבכל זאת לא מתאימים
- מוקשים
- סיכום
- מקורות
ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 22, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle
1. קודם כל, המסקנות
- אם רוצים לקרוא מ-C/C++ לעיבוד של C# בתוך אותו process, Native AOT +
UnmanagedCallersOnlyהוא מועמד חזק למדי. - אבל מה שמיוצא הוא בסופו של דבר רק נקודת כניסה של פונקציית C. זה לא עולם שבו חושפים
stringאוList<T>כמו שהם. - בפועל יציב יותר להוריד לרמת C API שטוח בסגנון
create/destroy/operate, ולציין במפורש ניהול חיים וקודי שגיאה. - אם רוצים לטפל בטבעיות במחלקות C++ וב-STL, עדיף C++/CLI; אם רוצים registration, אוטומציה או מעבר process, עדיף COM.
בקיצור: אפשר להשתמש ב-C# כתוכן פנימי של DLL native, אבל פני השטח של הגבול צריך לתכנן כ-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++ טיפוסים, בעלות, exceptions ודברים כמו std::wstring |
| לחצות גבול 32/64 סיביות או גבול process | COM / IPC | DLL בתוך אותו process בלבד לא יכול לחצות את זה |
| לקרוא מ-C/C++ ללוגיקת C# כ-DLL native | Native AOT + UnmanagedCallersOnly |
אפשר לייצא עצמאית את נקודת הכניסה של C |
התצורה הזו מתאימה בייחוד למצב שבו הצד ה-native הוא העיקרי, ו-C# נקרא כרכיב. זה כיוון הפוך בדיוק לזה של P/Invoke או C++/CLI.
flowchart TB
accTitle: ההבדל בכיוון של מי העיקרי
accDescr: P/Invoke ו-C++/CLI מתאימים לכיוון שבו C# הוא העיקרי שקורא לצד ה-native, לעומת התצורה של Native AOT במאמר הזה שבה הצד ה-native הוא העיקרי שקורא ללוגיקת C# כרכיב — כיוון הפוך בדיוק.
cs["C# הוא העיקרי"] -->|"קורא ל-native"| n1["P/Invoke או C++/CLI"]
nat["ה-native הוא העיקרי"] -->|"קורא ל-C# כרכיב"| n2["ייצוא עם Native AOT"]
איור 3: בוחרים גשר לפי כיוון. התצורה של המאמר הזה היא כיוון שבו ה-native הוא העיקרי ו-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++, מוסיפים אליו ערכים, ובסוף מקבלים את הסכום. בפועל זה יכול להיות גם מנוע שיפוט, גם פענוח הגדרות, גם parser פשוט. תחשבו על זה בתור הצד ה-native מחזיק 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 הופכות לנקודת הכניסה שנראית מצד ה-native. כאן ה-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);
}
}
מה שקורה כאן די פשוט:
- מה שנחשף לצד ה-native הוא רק ה-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: cleanup"]
op -.-> state["גוף המצב מוחזק בצד C#"]
op -.-> err["ערך ההחזרה הוא קוד שגיאה"]
איור 5: מה שנחשף לצד ה-native הוא רק ה-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 שעות ברצף, זה בהחלט אפשרי מבחינה תאורטית.
מה קורה כשמתרחש wraparound — כאן צריך לדייק. חריגת מפתח כפול ב-s_instances.Add(handle, instance) עוזרת רק כשאותו ערך handle עדיין חי. השימוש הרגיל ב-API הזה הוא חזרה על create ו-destroy, ו-handle שכבר עבר destroy כבר נמחק מה-dictionary. כלומר, כשהערך חוזר לאחר wraparound לאותו ערך, אין מפתח כזה ב-dictionary, כך שה-Add מצליח. התוצאה היא שה-handle הישן שעדיין מוחזק בצד C מתחיל להצביע על מופע חדש שאין לו שום קשר אליו. לא נזרק exception, ולא מוחזר קוד שגיאה — הערך פשוט מתקלקל בשקט.
עוד נקודה אחת: הערך המדויק 2^32 — כל 32 הביטים התחתונים שלו הם 0 — לכן הערך 0, שהיה אמור לשמש כסימן ל”עדיין לא מחזיק”, מונפק בפועל.
לכן אל תסמכו על בדיקת המפתח הכפול כאמצעי בטיחות. אם קיימת אפשרות להתמודד עם 32 סיביות, בוחרים אחת משתי האפשרויות:
- לשבץ מספר דור בתוך ה-handle. הביטים התחתונים כמספר סידורי, העליונים כדור, ומקדמים את הדור בכל destroy. גם אם אותו מספר סידורי חוזר, הערך לא יהיה זהה
- לגרום לכישלון קבוע כשההנפקה נגמרת. כשההנפקה מגיעה לתקרה, מגדירים את כל ה-create הבאים ככישלון. במכשיר שממשיך לפעול זה ידרוש אתחול מחדש, אבל זה עדיף על התקלקלות שקטה
בשני המקרים, שומרים גם על כך שהמונה עצמו יוחזק כ-nint כדי לא לחרוג מרוחב ה-nint, וגם על כך שלא יונפק הערך 0.
flowchart TB
accTitle: איך נשבר wraparound של הנפקת handle, ומה התיקון
accDescr: כשההנפקה עושה wraparound ב-32 סיביות, ה-Add על ערך שכבר נמחק מה-dictionary אחרי destroy מצליח, וה-handle הישן שנשאר בצד C מתחיל להצביע על מופע חדש שאין לו קשר, ונשבר בשקט ללא exception או קוד שגיאה. התיקון הוא שיבוץ מספר דור, או כישלון קבוע לאחר הגעה לתקרה.
wrapd["ב-32 סיביות ההנפקה עושה wraparound"] --> add["ה-Add על אותו ערך מצליח"]
add --> alias["ה-handle הישן מצביע על מופע חדש"]
alias --> silent["נשבר בשקט, ללא exception או קוד שגיאה"]
silent --> g1["תיקון: שיבוץ מספר דור"]
silent --> g2["תיקון: כישלון קבוע כשההנפקה נגמרת"]
איור 6: חריגת המפתח הכפול אינה אמצעי בטיחות. ההתקלקלות ב-wraparound שקטה, והתיקון צריך להיות מוטמע בתכנון.
4.3. פקודת ה-publish
קודם הנחה אחת. ל-publish של Native AOT דרושה שרשרת כלים native בנפרד.
אם מריצים dotnet publish רק עם PublishAot, הכישלון לא קורה בקומפילציה של C#, אלא בשלב הקישור ה-native האחרון. זה המכשול הראשון.
| סביבה | מה נדרש |
|---|---|
| Windows | Visual Studio 2022 ומעלה. להתקין את עומס העבודה “Desktop development with 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 native בתוך bin/Release/net8.0/win-x64/publish/. בדוגמת Windows זה .dll, ב-Linux זה .so, ב-macOS זה .dylib.
חשוב לפרסם עבור כל RID בנפרד. מה שנבנה עבור win-x64 אי אפשר להשתמש בו בהנחה של win-arm64, וגם צריך ליישר bitness בין הצד הקורא ל-DLL.
flowchart TB
accTitle: המכשול הראשון של ה-publish
accDescr: ל-publish של Native AOT דרושה שרשרת כלים native בנפרד, ובלעדיה הרצת dotnet publish נכשלת לא בקומפילציה של C# אלא בשלב הקישור ה-native האחרון. מפרסמים עבור כל RID בנפרד, ומיישרים גם את ה-bitness.
pub["הרצת dotnet publish"] --> q{"האם קיימת שרשרת כלים native"}
q -->|"לא"| fail["נכשל בשלב הקישור ה-native האחרון"]
q -->|"כן"| out["יוצא DLL native עבור כל 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, ה-publish בצד 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 לא נמצא, הקריסה מתרחשת כבר בעת עליית ה-process, כך שקשה יותר לתפעל מצב של “הגוף פועל, אבל רק היכולת הזו לא זמינה”. אם רוצים לחבר בסגנון plugin, נוח יותר להישאר עם שיטת LoadLibrary.
flowchart TB
accTitle: איך בוחרים בין טעינה דינמית לקישור סטטי
accDescr: שיטת LoadLibrary טוענת בזמן ריצה ומתאימה לחיבור בסגנון plugin, ואילו קישור סטטי עם import library מאפשר לכלול header ולקרוא ישירות, אבל בתמורה ה-DLL אם חסר גורם לקריסה כבר בעת עליית ה-process.
q{"איך רוצים לחבר"}
q -->|"בסגנון plugin"| dyn["שיטת LoadLibrary"]
q -->|"לקרוא ישירות דרך header"| stat["קישור סטטי עם import lib"]
stat -.-> risk["אם ה-DLL חסר, קריסה כבר בעת עליית ה-process"]
איור 10: אפשר לכתוב בפשטות עם קישור סטטי, אבל מה שאפשר להריץ גם ב”בלי היכולת הזו” הוא טעינה דינמית.
יצוין שפרסום כספרייה סטטית (NativeLib=Static) אינו נתמך רשמית, ולכן עדיף לא לסמוך עליו.
5. צורת API שפחות שברירית
מעניין שאפשר לייצא עם Native AOT, אבל בפועל מה לא מייצאים חשוב יותר.
5.1. קרבה ל-C ABI
קודם נסביר את המונחים. עיקר המאמר הזה הוא “לתכנן את פני השטח של הגבול כ-C ABI ולא כ-.NET”, וה-ABI הוא קיצור של Application Binary Interface — ההסכם שבין בינאריים מקומפלים איך הם מתחברים בזמן ריצה. תחשבו על זה לא כהסכם ברמת קוד המקור, אלא ברמת שפת המכונה. יש בו בעיקר שלושה מרכיבים.
| ההסכם | מה נקבע | מה קורה אם לא שומרים עליו כאן |
|---|---|---|
| מוסכמת קריאה (calling convention) | דרך העברת ארגומנטים — ב-register או במחסנית, ואיפה מונח ערך ההחזרה, ומי מחזיר את המחסנית אחרי הקריאה — הקורא או הנקרא | הארגומנטים זזים ממקומם, המחסנית מתקלקלת מיד אחרי החזרה |
| מבנה הפריסה של הטיפוס | כמה בייטים כל טיפוס, ואיפה ממוקמים חברי ה-struct (padding ויישור) | לא ניתן יותר לקרוא ערכים באמצע המבנה |
| השם והקישור | האיות של שם הפונקציה שמיוצאת, קיום decoration בשם | GetProcAddress לא מוצא את השם |
cdecl או stdcall הם השם של המרכיב הראשון — “מוסכמת קריאה”. מחלקות ו-exceptions של 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*ואורך
ולהפך, מה שכדאי לא להדליף החוצה כבר מההתחלה הוא זה:
stringobjectList<T>TaskSpan<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 עם אותה פריסה | חובה ליישר בין שני הצדדים את סדר החברים, הטיפוס וה-padding |
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. לא להעביר exceptions מעבר לגבול
גבול פונקציה native אינו ידידותי במיוחד לייצוג של exceptions. לכל הפחות, בטוח יותר לא לתכנן ש-exception managed ידלוף כמו שהוא לצד הקורא.
בפועל, נוח לטפל כך:
- ערך ההחזרה הוא status code
- הנתונים בפועל מוחזרים דרך מאגר out או ארגומנט מצביע
- במידת הצורך, מקבלים מידע נוסף בסגנון
get_last_error
זה לא מרשים, אבל תכנון צנוע כזה משתלם בהמשך. כלומר לא להתחיל מאבק פתאומי בגבול.
flowchart TB
accTitle: תכנון שגיאות שלא מעביר exceptions מעבר לגבול
accDescr: לא מדליפים exception managed כמו שהוא לצד הקורא, אלא הופכים אותו לצורה שבה ערך ההחזרה הוא status code, הנתונים בפועל מוחזרים דרך מאגר out או ארגומנט מצביע, ובמידת הצורך מידע נוסף מתקבל בסגנון get_last_error.
exc["exception בתוך C#"] --> stop["נתפס בתוך הגבול"]
stop --> code["ערך ההחזרה הוא status code"]
stop --> outp["הנתונים בפועל דרך ארגומנט מצביע"]
stop -.-> last["מידע נוסף בסגנון get_last_error"]
איור 13: ה-exception לא חוצה את הגבול, אלא מתורגם לעולם של 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
בייחוד, זה מתאים היטב למבנה שבו האפליקציה ה-native נשארת כמות שהיא, ורק שכבת הלוגיקה שקל להחליף כתובה ב-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: הצד ה-native נשאר העיקרי, ומביאים את הפרודוקטיביות של C# רק לשכבת הלוגיקה שקל להחליף.
7. מקרים שבכל זאת לא מתאימים
כמובן שזה לא כלי-פלא. יש מקרים שברור שלא מתאימים.
- רוצים לטפל ישירות במחלקות C++, ב-
std::vectorאו ב-exceptions- במקרים כאלה, C++/CLI או wrapper בצד ה-native טבעיים יותר.
- רוצים להיכנס לעולם של registration COM, אוטומציה של VBA / Office, או הרחבות Explorer
- כאן עדיף לחשוב בהקשר של COM.
- רוצים לגשר בין 32/64 סיביות, או לחצות גבול process
- במקום DLL בתוך אותו process, תצורה של COM / IPC / process נפרד טבעית יותר.
- רוצים לעשות unload לתוסף בהמשך
- אין להשתמש בספרייה המשותפת של Native AOT בהנחת unload.
- ספרייה תלויה מסתמכת חזק על reflection או יצירת קוד דינמית
- אם יוצאת אזהרת publish AOT, לא כדאי להתעלם ממנה בקלות דעת.
בסופו של דבר, קו פרשת המים הוא האם אפשר להתפשר על C ABI. אם אי אפשר, גשר אחר יהיה נקי יותר.
flowchart TB
accTitle: מקרים שלא מתאימים והגשר החלופי
accDescr: אם רוצים לטפל ישירות במחלקות C++ ו-exceptions, עדיף C++/CLI; לעולם של registration COM ואוטומציה, עדיף הקשר של COM; לחצות bitness או גבול process, עדיף COM או IPC; לתוסף שמיועד ל-unload, התצורה הזו לא מתאימה מלכתחילה.
q{"מה מחפשים"}
q -->|"טיפוס ו-exceptions של C++ כמו שהם"| cli["ל-C++/CLI או wrapper"]
q -->|"עולם של registration ואוטומציה"| com["להקשר של COM"]
q -->|"bitness או חציית process"| ipc["ל-COM, ל-IPC או ל-process נפרד"]
q -->|"רוצים unload בהמשך"| 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, או process נפרד. כולן בחירות נכונות.
אבל אם רוצים להחדיר את העיבוד של C# כ-DLL native בתוך אותו process, Native AOT + UnmanagedCallersOnly הוא אפשרות מעניינת למדי.
הנה שוב הנקודות העיקריות:
- לא לחשוף את C# כמו שהוא, אלא לצמצם (flatten) ל-C ABI
- לציין במפורש ניהול חיים מבוסס handle
- לחצות את הגבול עם error code ולא עם exception
- לקבע את מוסכמת הקריאה
- מתודת ה-export דקה, מופרדת מהלוגיקה הפנימית
מה שעושים כאן לא מרשים. אבל “איך חותכים את הגבול” כזה משפיע מאוד על התחזוקה בהמשך. כשרוצים לנצל נכסים native ובו-זמנית להביא את הפרודוקטיביות של C# רק לשכבת הלוגיקה, שווה לזכור את התצורה הזו.
flowchart TB
accTitle: חמשת סעיפי הגבול שפחות שברירי
accDescr: חמש נקודות בונות גבול פחות שברירי — צמצום ל-C ABI, ניהול חיים מבוסס handle, חציית גבול עם error code, קיבוע מוסכמת הקריאה, וייצוא דק שמופרד מהלוגיקה הפנימית.
goal["DLL native מ-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 native מ-C#: wrapper של C++/CLI מול P/Invoke - KomuraSoft Blog
- דוגמה מעשית לגשר COM שקורא ל-DLL של 64 סיביות מאפליקציית 32 סיביות - KomuraSoft Blog
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
למה arguments נשברים — כללי command-line arguments ב-Windows
Windows מעביר ל-CreateProcess מחרוזת אחת שהמקבל מפצל. מכסה את כללי CommandLineToArgvW, CRT ו-.NET, ArgumentList, ובניה ב-C++.
Time Travel Debugging — להקליט ולהריץ אחורה באגים שלא משתחררים באפליקציות ארוכות-ריצה
באג פעם בחודש משאיר ב-crash dump רק את התוצאה. מקליטים ומריצים אחורה את הביצוע עם WinDbg Time Travel Debugging (TTD): TTD.exe, ring buffe...
מה נשאר אחרי שה-parent מת — מחזיקים child processes ב-Job Object
למה SDK helpers שורדים UI שנהרג ומחזיקים את המצלמה או את ה-COM port? מתכננים משך חיים של child process עם Job Objects, KillOnJobClose ו-c...
Named Pipes בפועל — ה-IPC הסטנדרטי של Windows, מתכנון עד אבטחה
מדריך מעשי ל-Named Pipes, ה-IPC הסטנדרטי ב-Windows. המאמר מסדר לפי מקורות ראשוניים את הבחירה בין byte mode ל-message mode, תכנון server ל...
WMI/CIM מ-C# ומ-PowerShell — מדריך מעשי למידע חומרה, ניטור process ושאילתות remote
WMI/CIM הוא הדרך הסטנדרטית לשלוף serial number של מחשב, לנטר דיסק פנוי ולזהות process שהתחיל. המאמר מכסה CIM cmdlets כמו Get-CimInstance,...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
יכולת פעולה הדדית בין 32 ל-64 סיביות
תאימות 32/64 סיביות, גבולות native והחלטות תכנון ב-Windows.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
מדובר בנושא של מימוש הגבול שבין C# ל-C/C++, כך שזה קשור ישירות לייעוץ תכנון ומימוש במסגרת פיתוח אפליקציות Windows.
שימוש חוזר והעברה של נכסים קיימים
מבחינת האופן שבו בונים גשר בין נכסים native קיימים ל-.NET, זה מתאים גם לשימוש בנכסים קיימים ותמיכה במעבר.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- אפשר לקרוא לקוד C# מ-C++?
- כן. ב-Native AOT של .NET אפשר לפרסם ספריית מחלקות C# כספרייה משותפת native, ולחשוף מתודות שמסומנות ב-UnmanagedCallersOnly כנקודת כניסה של C. כלומר אפשר להשתמש ב-C# כ-DLL native שנקראים אליו מ-C/C++, בתוך אותו process.
- לאיזה מצב מתאימה התצורה הזו?
- למצב שבו רוצים להשאיר את גוף האפליקציה ה-native כמות שהוא, ורק חלקים כמו לוגיקת שיפוט, עיבוד מחרוזות, פענוח הגדרות או כללי חישוב להעביר ל-C#. המאפיין הוא כיוון שבו הצד ה-native הוא העיקרי, ו-C# נקרא כרכיב. ולהפך — אם רוצים לקרוא מ-C# לקבוצת פונקציות C, עדיף P/Invoke; אם רוצים לטפל בטבעיות בטיפוסים ובבעלות של C++, עדיף C++/CLI; ואם רוצים לחצות גבול 32/64 סיביות או גבול process, עדיף COM/IPC.
- מה נקודות הזהירות בתכנון ה-API?
- מה שמיוצא הוא בסופו של דבר רק נקודת כניסה של פונקציית C, כך שאסור לחשוף ישירות בגבול string, List<T> או exceptions. עדיף להוריד לרמת C API שטוח בסגנון create / destroy / operate, לציין במפורש ניהול חיים וקודי שגיאה, לטפל במחרוזות כמצביע + אורך + קיבולת מאגר, לא להעביר exceptions מעבר לגבול, ולקבע את מוסכמת הקריאה. עיקר הדבר הוא לתכנן את פני השטח של הגבול כ-C ABI ולא כ-.NET.
- יש קוד לדוגמה שאפשר להריץ?
- כן. במאגר komurasoft-blog-samples ב-GitHub פורסמה חבילת דוגמה מלאה שאפשר לבנות ולהריץ, כולל ספריית C# שמפורסמת עם Native AOT, דוגמת קריאה מ-C++ ובדיקות יחידה. דוגמת הקוד מניחה DLL של Windows, אבל רעיון הבסיס דומה כמעט לגמרי גם ב-Linux / macOS.