Connecting Ada with C and C# — Building a DLL and Calling It

· · Ada, GNAT, C, C#, .NET, P/Invoke, DLL

Post on X
Revision history (first version, published Nov 1, 2026)
First published
Cite this article(DOI: 10.5281/zenodo.23856518)

This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.

Go Komura (2026). Connecting Ada with C and C# — Building a DLL and Calling It. KomuraSoft LLC. https://doi.org/10.5281/zenodo.23856518 https://comcomponent.com/en/blog/ada-c-csharp-dll-interop/

DOI (latest version)
10.5281/zenodo.23856518
DOI (this version)
10.5281/zenodo.23856519

Sometimes you want to use logic written in Ada from a C or C# application. In the other direction, you may want to call Windows APIs or an existing DLL from an Ada program. Ada defines how to interface with other languages in Annex B of its Reference Manual, so adding Convention => C to a function declaration lets you export or call a function that uses the same calling style as C.

Exporting the functions is not enough, though. An Ada library has initialization (elaboration) that must be done before it is used, and you also have to decide which side allocates and frees string buffers. If an Ada exception leaves the boundary, the caller’s process may end. This article builds a small library as a shared library with gprbuild and calls it from C and C#, explaining each of these points in turn. At the end, it covers an example of calling a Windows DLL from Ada and what to check when distributing the library as a Windows DLL.

Every example was actually built and run. On Linux (Ubuntu 24.04) I built a shared library (.so) with GNAT 13.3.0 and gprbuild, called it from C with gcc and from C# with .NET 8. For Windows, I built the DLL and executables with the MinGW-w64 build of GNAT 13 on Ubuntu and ran them on Wine 9.0. Because nothing was run on real Windows, the Windows-specific points cite GNAT and Microsoft documentation, and each section states what was measured and what was not. For an introduction to Ada itself, see The Appeal of the Ada Language.

What to decide at the Ada–C boundary

Annex B of the Ada Reference Manual defines Import, which calls subprograms in other languages, and Export, which makes Ada subprograms callable from other languages1. You choose which language’s calling style to follow with Convention; to match C, write Convention => C. The name used at link time is set with External_Name.

The boundary between Ada, C and C#Shows that the Ada library exports functions in the C calling style with Convention => C, that C calls the same functions directly and C# through P/Invoke, and that Ada calls Windows DLLs with Import.Export and Convention => CP/InvokeImportAda libraryFunctions in the C calling styleC programC# programWindows DLL

Figure 1: Ada exports functions in the C calling style, and C and C# call them. To call a Windows DLL from Ada, use Import.

For parameter and return types, use the types in the package Interfaces.C. int, size_t, char_array and the others are defined to correspond to the C types of the same name2. Ada’s Integer and String are not defined to have the same representation as the C types, so do not expose them directly at the boundary. An Ada String carries its index range and has no terminating NUL like a C string. Receive a C char * with chars_ptr from Interfaces.C.Strings3.

There are also rules for how parameters are passed. The Reference Manual advises mapping in scalar parameters to C pass-by-value, out and in out parameters of elementary types (scalar types and the like) to pointers (t*), and array parameters to pointers to their elements2. In this article’s example, the C int *result is received as Result : out int in Ada.

C and Ada type mappings used in this article's examplesShows that C int, size_t, char * and int * are received in Ada as Interfaces.C int, size_t, chars_ptr and an out int parameter, respectively.C intAda intC size_tAda size_tC char *Ada chars_ptrC int *Ada out int parameter

Figure 2: In this article’s examples, C types are received with the same-named types in Interfaces.C and chars_ptr, and int * is received as an out parameter.

Besides types, three more things have to be decided at the boundary: who initializes the library and when, which side allocates and frees string and buffer memory, and how to handle Ada exceptions. The examples below show how to decide each of them.

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (15 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

Writing Ada functions in a form C can call

As an example, we build a package Greet_API with four functions: addition, division, building a greeting, and counting calls. In the spec (.ads), add Export, Convention => C and External_Name to the functions to be exported.

with System;
with Interfaces.C;         use Interfaces.C;
with Interfaces.C.Strings; use Interfaces.C.Strings;

package Greet_API is

   --  int greet_add (int a, int b);
   function Add (A, B : int) return int
     with Export, Convention => C, External_Name => "greet_add";

   --  int greet_divide (int a, int b, int *result);
   --  Return value: 0 = success, 1 = division by 0, 2 = result does not fit in int
   function Divide (A, B : int; Result : out int) return int
     with Export, Convention => C, External_Name => "greet_divide";

   --  size_t greet_format (const char *name, char *buffer, size_t length);
   --  Returns the length (including the terminating NUL) needed for "Hello, <name>!".
   --  Writes to buffer only when buffer is not NULL and length is large enough.
   function Format
     (Name   : chars_ptr;
      Buffer : System.Address;
      Length : size_t) return size_t
     with Export, Convention => C, External_Name => "greet_format";

   --  int greet_counter (void);  Returns the value incremented by 1 on each call
   function Counter return int
     with Export, Convention => C, External_Name => "greet_counter";

end Greet_API;

The body (.adb) is as follows. Divide handles exceptions inside the function and returns an error code, and Format writes the string into the memory passed in by the caller. The variable that Counter counts with is set to 100 after the begin at the end of the package body, that is, in the initialization code. This value is used later to tell whether initialization has run.

with System; use type System.Address;

package body Greet_API is

   --  Set to 100 during package initialization (elaboration)
   Calls : int := 0;

   function Add (A, B : int) return int is
   begin
      return A + B;
   end Add;

   function Divide (A, B : int; Result : out int) return int is
   begin
      Result := 0;
      Result := A / B;
      return 0;
   exception
      when Constraint_Error =>
         if B = 0 then
            return 1;
         else
            return 2;
         end if;
   end Divide;

   function Format
     (Name   : chars_ptr;
      Buffer : System.Address;
      Length : size_t) return size_t
   is
   begin
      if Name = Null_Ptr then
         return 0;
      end if;

      declare
         Text : constant char_array := To_C ("Hello, " & Value (Name) & "!");
      begin
         if Buffer /= System.Null_Address and then Length >= Text'Length then
            declare
               --  Treat the caller's memory as a char_array of the same length as Text
               Target : char_array (Text'Range)
                 with Import, Address => Buffer;
            begin
               Target := Text;
            end;
         end if;
         return Text'Length;
      end;
   exception
      when others =>
         return 0;  --  Do not let the exception reach the C side
   end Format;

   function Counter return int is
   begin
      Calls := Calls + 1;
      return Calls;
   end Counter;

begin
   Calls := 100;
end Greet_API;
Processing flow of greet_formatShows that greet_format returns 0 if name is NULL; otherwise it builds the string, writes it only when buffer is not NULL and long enough, and then returns the required length.NULLNot NULLLong enoughToo shortIs name NULL?Return 0Build Hello, name!Buffer given and long enough?Write to bufferDo not writeReturn the required length

Figure 3: greet_format writes only when the buffer is large enough, and always returns the required length.

Format writes by overlaying an Ada array on the caller’s memory through Address. Interfaces.C.Strings also has Update, which rewrites what a chars_ptr points to. According to the Reference Manual, when Check => True, Update raises Update_Error if the write goes past the length of the existing string. With Check => False, a write that would raise Update_Error under True is erroneous execution3. Memory the caller has just allocated does not yet contain a string, so Update is not used here.

Making a shared library with gprbuild

The shared library is built from a gprbuild project file. Setting Library_Kind to "dynamic" produces an independent library that is loaded at run time4. Listing the units to expose in Library_Interface makes it a stand-alone library. A stand-alone library is the form suited to using the Ada part from programs written in languages other than Ada4.

library project Greet is
   for Source_Dirs use ("src");
   for Object_Dir use "obj";
   for Library_Name use "greet";
   for Library_Dir use "lib";
   for Library_Kind use "dynamic";
   for Library_Interface use ("Greet_API");
   for Library_Auto_Init use "true";
end Greet;

Building with gprbuild -p -P greet.gpr produces lib/libgreet.so.

Setup
   [mkdir]        object directory for project Greet
   [mkdir]        library directory for project Greet
Compile
   [Ada]          greet_api.adb
Build Libraries
   [gprlib]       greet.lexch
   [bind SAL]     greet
   [Ada]          b__greet.adb
   [link library] libgreet.so

Listing the exported names with nm -D shows greetinit and greetfinal in addition to the four names given with External_Name. These are the initialization and finalization procedures that the binder creates from the library name4.

000000000000233a T greet_add@@SYMS
00000000000028ba T greet_api___elabb@@SYMS
000000000000287e T greet_counter@@SYMS
0000000000002380 T greet_divide@@SYMS
0000000000002460 T greet_format@@SYMS
00000000000029c0 T greetfinal@@SYMS
00000000000029e4 T greetinit@@SYMS
How gprbuild builds a stand-alone libraryShows that the Ada sources are compiled, the binder creates a file containing the initialization procedure, and they are linked into a shared library.CompileBindgreet_api.ads and .adbObject filesInitialization procedure (greetinit)Linklibgreet.so (libgreet.dll on Windows)

Figure 4: gprbuild compiles, binds and links, producing a shared library that includes the initialization procedure.

This library depends on the GNAT runtime. The output of ldd lib/libgreet.so included libgnat-13.so. When distributing to another machine, provide this runtime as well. The GPRbuild documentation also describes Library_Standalone use "encapsulated", which pulls in the runtime to remove the dependency4. However, when I tried it in this environment (Ubuntu’s GNAT 13), libgnat-13.so was still in the ldd output even with encapsulated. Check the dependencies with your own toolchain before distributing.

Who performs initialization (elaboration)

Ada packages need initialization (elaboration) before they are used5. This covers the statements after begin in a package body and the computation of variables’ initial values. For an Ada main program, the code generated by the binder does this at startup. For a library called from C or C#, the library side has to do it.

If Library_Auto_Init is not specified or is set to "true", initialization runs when the library is loaded on platforms that support automatic initialization4. If it is set to "false", it does not run automatically, and the initialization procedure (here, greetinit) must be called before using the library’s functions4.

To check the difference, I ran a C program that calls only Counter, linked against a library built with Library_Auto_Init set to "true" and one set to "false". With "true" the result was counter: 101; with "false" it was counter: 1. In the "false" case, Calls := 100; in the package body has not run.

counter: 1     (Library_Auto_Init = "false", greetinit not called)
counter: 101   (Library_Auto_Init = "true")

When greet_format was called without initialization, the process ended with a segmentation fault in this environment (I did not investigate the cause). Forgetting initialization will not always fail this obviously, so if you do not use automatic initialization, always call the initialization procedure from the caller.

#include <stdio.h>
#include <stdlib.h>

void   greetinit(void);
void   greetfinal(void);
size_t greet_format(const char *name, char *buffer, size_t length);
int    greet_counter(void);

int main(void)
{
    greetinit();   /* Explicitly run the Ada-side initialization (elaboration) */

    size_t needed = greet_format("Ada", NULL, 0);
    char *buffer = malloc(needed);
    greet_format("Ada", buffer, needed);
    printf("format: %s\n", buffer);
    free(buffer);
    printf("counter: %d\n", greet_counter());

    greetfinal();  /* Finalization */
    return 0;
}

When greetinit was called first, the "false" library also worked correctly.

format: Hello, Ada!
counter: 101
The Library_Auto_Init setting and the initialization flowShows that if Library_Auto_Init is true, initialization runs when the library is loaded; if false, the caller calls greetinit before using the functions, and without that call the package body initialization does not run.truefalseCalledNot calledLibrary_Auto_InitInitialized at load timeWas greetinit called?InitializedBody initialization does not runCall the functions

Figure 5: Without automatic initialization, the caller calls the initialization procedure before using the functions.

On Windows, automatic initialization has a restriction. According to the GNAT documentation, threads cannot be created inside DllMain on Windows, so tasks declared at library level cannot be used in a stand-alone library6. The same documentation also says that if there are library-level tasks, initialization cannot be called from DllMain6. Ada tasks are covered in Safe Concurrency with Ada.

Calling from C

On the C side, write the C declarations from the Ada spec as the function declarations, and link the library. Because this uses the library built with Library_Auto_Init set to "true", greetinit is not called.

#include <limits.h>
#include <stdio.h>
#include <stdlib.h>

int    greet_add(int a, int b);
int    greet_divide(int a, int b, int *result);
size_t greet_format(const char *name, char *buffer, size_t length);
int    greet_counter(void);

int main(void)
{
    printf("add: %d\n", greet_add(2, 3));

    int q = 0;
    int rc = greet_divide(7, 2, &q);
    printf("divide 7/2: rc=%d q=%d\n", rc, q);
    printf("divide 1/0: rc=%d\n", greet_divide(1, 0, &q));
    printf("divide INT_MIN/-1: rc=%d\n", greet_divide(INT_MIN, -1, &q));

    /* Ask for the required length on the first call, allocate on the caller side, then have the second call write */
    size_t needed = greet_format("Ada", NULL, 0);
    char *buffer = malloc(needed);
    greet_format("Ada", buffer, needed);
    printf("format: %s (needed=%zu)\n", buffer, needed);
    free(buffer);

    printf("counter: %d\n", greet_counter());
    printf("counter: %d\n", greet_counter());
    return 0;
}
gcc -Wall -o main main.c -L../greet/lib -lgreet
LD_LIBRARY_PATH=../greet/lib ./main

At build time, the library is linked with -L and -lgreet; at run time, LD_LIBRARY_PATH points to the folder containing libgreet.so.

Using the Ada library from a C programShows that the program is linked with -lgreet by gcc, libgreet.so is loaded at run time from the LD_LIBRARY_PATH folder, initialization runs at load time, and then the functions are called.Link with -lgreet using gccRunLoad libgreet.so from LD_LIBRARY_PATHInitialized at load timeCall greet_add and others

Figure 6: The C program loads the linked libgreet.so at run time and calls the functions after initialization.

Here is the output. Dividing by 0, and a result that does not fit in int such as INT_MIN / -1, each raised Constraint_Error inside Ada, and the exception handler in Divide returned 1 and 2 respectively. Because counter starts at 101, initialization ran at load time.

add: 5
divide 7/2: rc=0 q=3
divide 1/0: rc=1
divide INT_MIN/-1: rc=2
format: Hello, Ada! (needed=12)
counter: 101
counter: 102

Calling from C#

From C#, call through P/Invoke. Since .NET 7, the LibraryImport attribute is available and generates the marshalling code at compile time7. The traditional DllImport generates an IL stub at run time7. The following example declares three functions with LibraryImport and one with DllImport. The full picture of writing P/Invoke is in Safely Calling Win32 APIs from C#.

How LibraryImport and DllImport produce marshalling codeShows that LibraryImport generates the marshalling code at compile time, DllImport generates an IL stub at run time, and both call the Ada library's functions.Compile timeRun timeLibraryImportGenerate marshalling codeDllImportGenerate IL stubAda library function

Figure 7: LibraryImport creates the calling code at compile time, and DllImport at run time.

using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;

Console.WriteLine($"add: {Greet.Add(2, 3)}");

int rc = Greet.Divide(7, 2, out int q);
Console.WriteLine($"divide 7/2: rc={rc} q={q}");
Console.WriteLine($"divide 1/0: rc={Greet.Divide(1, 0, out _)}");

// Ask for the required length on the first call, then have the second call write into an array allocated on the C# side
nuint needed = Greet.Format("C#", null, 0);
byte[] buffer = new byte[(int)needed];
Greet.Format("C#", buffer, needed);
Console.WriteLine($"format: {Encoding.UTF8.GetString(buffer, 0, buffer.Length - 1)}");

Console.WriteLine($"counter: {Greet.CounterOld()}");

static partial class Greet
{
    // "greet" is searched for as libgreet.so on Linux and greet.dll on Windows
    private const string Lib = "greet";

    [LibraryImport(Lib, EntryPoint = "greet_add")]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
    internal static partial int Add(int a, int b);

    [LibraryImport(Lib, EntryPoint = "greet_divide")]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
    internal static partial int Divide(int a, int b, out int result);

    [LibraryImport(Lib, EntryPoint = "greet_format",
                   StringMarshalling = StringMarshalling.Utf8)]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
    internal static partial nuint Format(string name, byte[]? buffer, nuint length);

    // The traditional DllImport can call the same function
    [DllImport(Lib, EntryPoint = "greet_counter",
               CallingConvention = CallingConvention.Cdecl)]
    internal static extern int CounterOld();
}

Because the Ada side exports with Convention => C, the C# side also sets the calling convention to Cdecl. On x86 Windows the P/Invoke default is Stdcall, so without this setting the calling conventions would not match. On x64, ARM and ARM64 there is only one calling convention, and this setting does not change the result8. The C size_t is received as nuint in C#. Because the string parameter specifies StringMarshalling.Utf8, it is passed to Ada as a NUL-terminated UTF-8 string. Projects that use LibraryImport need AllowUnsafeBlocks enabled7.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>

</Project>

Building without AllowUnsafeBlocks produced the error SYSLIB1062: LibraryImportAttribute requires unsafe code. Here is the result of building with it enabled and running with the library folder given in LD_LIBRARY_PATH.

dotnet build -c Release
LD_LIBRARY_PATH=../greet/lib dotnet bin/Release/net8.0/cs.dll
add: 5
divide 7/2: rc=0 q=3
divide 1/0: rc=1
format: Hello, C#!
counter: 101

The "greet" written in DllImport and LibraryImport is not the file name itself. .NET searches while varying the name to suit the platform. On Linux and macOS it also tries names with a lib prefix in addition to the extension, but on Windows it tries only greet and greet.dll9. On the other hand, building for Windows with gprbuild produces libgreet.dll, as shown later. To use it on Windows, either write "libgreet" or rename the DLL.

File names .NET searches for from a library nameShows that when DllImport is given greet, Linux tries greet.so, libgreet.so and others, while Windows tries only greet and greet.dll, so the libgreet.dll built by gprbuild is not found.LinuxWindowsDllImport (greet)OS it runs onTry greet.so, libgreet.so, etc.Try greet and greet.dlllibgreet.so is foundlibgreet.dll is not foundWrite libgreet

Figure 8: On Windows, .NET does not add a lib prefix when searching, so write libgreet for the libgreet.dll that gprbuild builds.

Which side allocates and which side frees strings and memory

For strings and buffers that cross the boundary, decide which side allocates the memory and which side frees it. Memory must be freed by the same mechanism that allocated it.

In this article, greet_format has the caller allocate the memory. On the first call, the caller passes NULL as buffer and receives the required length; it then allocates memory of that length and has the second call write into it. C allocates with malloc and free, C# with byte[], and the Ada side only writes. Because allocation and freeing are completed on the same side, you do not have to worry about differences between runtimes.

Passing a string in memory allocated by the callerShows that the caller passes NULL as buffer to ask for the required length, allocates that length and calls again, Ada writes, and finally the caller frees the memory itself.Caller calls with NULLAda returns the required lengthCaller allocatesCaller calls againAda writesCaller frees

Figure 9: The caller is responsible for allocating and freeing, and Ada only writes into the memory it is given.

If the Ada side allocates and returns a string, the library should also export a function to free it. The Reference Manual says that memory allocated by New_String in Interfaces.C.Strings is to be freed with Free, not with a C function3. An implementation of New_String may use either the C allocation function or Ada’s allocation, so freeing it with the caller’s free is not guaranteed to be safe. Conversely, passing to Free a chars_ptr obtained from anything other than New_String or New_Char_Array is also erroneous execution3.

with Ada.Text_IO;          use Ada.Text_IO;
with Interfaces.C;         use Interfaces.C;
with Interfaces.C.Strings; use Interfaces.C.Strings;

procedure Use_Libc is

   --  size_t strlen (const char *s);
   function C_Strlen (S : chars_ptr) return size_t
     with Import, Convention => C, External_Name => "strlen";

   --  int abs (int j);
   function C_Abs (J : int) return int
     with Import, Convention => C, External_Name => "abs";

   S : chars_ptr := New_String ("Interfaces.C");
begin
   Put_Line ("strlen:" & size_t'Image (C_Strlen (S)));
   Put_Line ("abs:" & int'Image (C_Abs (-42)));
   Free (S);  --  Return memory allocated by New_String with Free
end Use_Libc;

This example calls strlen and abs from the C standard library from Ada with Import. It passes a string created with New_String to the C function and frees it with Ada’s Free when done. Building with gnatmake use_libc.adb on Linux and running it printed the following.

strlen: 12
abs: 42

Ada memory management as a whole is covered in Memory Management in Ada.

Keep exceptions from leaving the boundary

I tested what happens when an exception not handled inside an Ada function occurs during a call from C. I ran a C program that passes INT_MAX and 1 to the greet_add above. Because Add does not handle exceptions, a Constraint_Error occurs inside Ada.

#include <limits.h>
#include <stdio.h>

int greet_add(int a, int b);

int main(void)
{
    printf("before\n");
    fflush(stdout);
    printf("after: %d\n", greet_add(INT_MAX, 1));
    return 0;
}
before

raised CONSTRAINT_ERROR : greet_api.adb:10 overflow check failed

The GNAT runtime printed a message, and the process exited with exit code 1. after was not printed. Calling the same function from C# wrapped in try and catch (Exception) gave the same result: control never entered catch, and the process exited with the same message.

What happens when an Ada exception occurs inside a C# tryShows that when greet_add is called from inside a C# try and a Constraint_Error occurs in Ada, the GNAT runtime prints a message and the process exits, and the exception never reached the C# catch.Never reachesCall from inside a C# tryConstraint_Error in AdaGNAT runtime prints a messageProcess exitsC# catch

Figure 10: In the experiment on Linux, the Ada exception never reached the C# catch, and the process exited.

This is the result on Linux. The .NET documentation states that exceptions from unmanaged code can be handled only on Windows, and that on other platforms they can lead to unpredictable behavior or crashes10. I have not tried the same thing on Windows, but on either platform, a design that keeps Ada exceptions from leaving the boundary is not affected by this difference.

Handling exceptions in exported functionsShows that if an exported function handles the exception internally, it returns an error code and the caller can decide what to do, and if it does not, the process exits.HandledNot handledCalled from C or C#Exception in the Ada functionHandled inside the function?Return an error codeProcess exitsCaller decides

Figure 11: Handle exceptions inside the exported functions and return the result as an error code.

Divide and Format contain exception handlers and return an error code or 0. Give every exported function this form. Even a short function like Add raises Constraint_Error on overflow. Either enumerate which exceptions each function can raise, or catch them with when others and turn them into error codes.

Calling a Windows DLL from Ada

Calling Windows APIs from Ada also uses Import. The following example calls GetCurrentProcessId, GetTickCount64 and GetComputerNameW in kernel32.dll111213.

with Ada.Text_IO;  use Ada.Text_IO;
with Interfaces;   use Interfaces;
with Interfaces.C; use Interfaces.C;

procedure Use_Kernel32 is

   --  DWORD GetCurrentProcessId (void);
   function Get_Current_Process_Id return unsigned_long
     with Import, Convention => Stdcall, External_Name => "GetCurrentProcessId";

   --  ULONGLONG GetTickCount64 (void);
   function Get_Tick_Count_64 return Unsigned_64
     with Import, Convention => Stdcall, External_Name => "GetTickCount64";

   --  BOOL GetComputerNameW (LPWSTR lpBuffer, LPDWORD nSize);
   function Get_Computer_Name
     (Buffer : out wchar_array;
      Size   : in out unsigned_long) return int
     with Import, Convention => Stdcall, External_Name => "GetComputerNameW";

   Name   : wchar_array (0 .. 63);
   Length : unsigned_long := Name'Length;
begin
   Put_Line ("pid:" & unsigned_long'Image (Get_Current_Process_Id));
   Put_Line ("ticks > 0: " & Boolean'Image (Get_Tick_Count_64 > 0));

   if Get_Computer_Name (Name, Length) /= 0 then
      --  On success, Length is the number of characters, not counting the terminating NUL
      Put_Line ("computer name length:" & unsigned_long'Image (Length));
   else
      Put_Line ("GetComputerNameW failed");
   end if;
end Use_Kernel32;

The Windows API functions are given Convention => Stdcall. Win32 API functions are called with the __stdcall calling convention14. According to the GNAT documentation, 64-bit Windows has only one calling convention, and all convention specifications are ignored6. On platforms other than Windows, Stdcall is treated as C6. Writing Stdcall lets the same declaration work on both 32-bit and 64-bit.

The out wchar_array and in out unsigned_long of GetComputerNameW are passed, as the Reference Manual’s mapping says, as a pointer to the first element of the array and a pointer to a DWORD2. DWORD is a 32-bit unsigned integer declared as unsigned long15, so Ada receives it with Interfaces.C.unsigned_long.

This example was built with the MinGW-w64 build of GNAT 13 on Ubuntu (x86_64-w64-mingw32-gnatmake) and run on Wine 9.0. It built without specifying kernel32.dll for linking.

pid: 32
ticks > 0: TRUE
computer name length: 2

The process ID and the length of the computer name are values returned by Wine, not values from running on real Windows. The 32-bit version was not tested.

How Windows treats Convention => StdcallShows that Convention => Stdcall is treated as stdcall on 32-bit Windows, as the single calling convention on 64-bit Windows, and as C on platforms other than Windows.32-bit Windows64-bit WindowsNot WindowsConvention => StdcallPlatformCall with stdcallIgnored: only one conventionTreated as C

Figure 12: The Stdcall specification changes the calling style only on 32-bit Windows.

Distributing as a Windows DLL

Building the same greet.gpr with the MinGW-w64 build of GNAT produced lib/libgreet.dll. The GNAT documentation also says that the procedure for building a DLL on Windows has nothing Windows-specific6. However, passing a cross-compilation configuration directly to Ubuntu’s gprbuild selected the wrong binder, so in this environment I built it with the binder swapped. That step is specific to the Ubuntu environment, so this article does not cover it. If you build on Windows, use GNAT for Windows (for installing GNAT with Alire, see Starting an Ada Project with Alire).

What this article verified for WindowsShows that libgreet.dll and a C program were built with the MinGW-w64 build of GNAT on Ubuntu and checked by running them on Wine, and that real Windows and .NET on Windows were not tested.Not testedMinGW-w64 GNAT (Ubuntu)libgreet.dllC program (main.exe)Run on Wine 9.0Real Windows and .NET

Figure 13: The Windows DLL was built on Ubuntu and checked by running it on Wine. Calls from real Windows and .NET were not tested.

Listing the DLLs it depends on with x86_64-w64-mingw32-objdump -p showed these four.

	DLL Name: KERNEL32.dll
	DLL Name: msvcrt.dll
	DLL Name: libgcc_s_seh-1.dll
	DLL Name: libgnat-13.dll

libgnat-13.dll and libgcc_s_seh-1.dll are runtime DLLs installed with GNAT. With these two in the same folder as libgreet.dll, a C program built with the MinGW-w64 gcc (the main.c above) produced the same output on Wine as on Linux. When libgnat-13.dll was removed, the Wine loader reported that libgnat-13.dll could not be found, and the program did not start, with status code c0000135.

The folders Windows searches for DLLs, and their order, are fixed16. Decide whether to put the DLLs in the application folder or somewhere else in the search order, and distribute the Ada runtime DLLs with them. The details of the search order are in How Windows DLL Name Resolution Works.

Files to ship together when distributing an Ada DLLShows that a C or C# application loads libgreet.dll, which depends on libgnat-13.dll and libgcc_s_seh-1.dll, so all three go in a folder Windows searches.C or C# applicationlibgreet.dlllibgnat-13.dlllibgcc_s_seh-1.dllFolder Windows searches

Figure 14: When distributing an Ada DLL, also put the GNAT runtime DLLs in a folder Windows searches.

Test environment and what was not verified

The code in this article was run on November 1, 2026, in the following environments.

  • Ubuntu 24.04, GNAT 13.3.0, Ubuntu’s gprbuild package (2024.1) (building the shared library, calling from C, calling libc from Ada)
  • Calls from C# were built for net8.0 with .NET SDK 10.0.114 and run on the .NET 8.0.33 runtime
  • MinGW-w64 build of GNAT 13.2.0 and Wine 9.0 (the Windows DLL and executables)

Running on real Windows, 32-bit Windows, calling from .NET on Windows, and the behavior when an Ada exception crosses the boundary on Windows were not verified. Those points are based on the GNAT and Microsoft documentation cited in the text.

References

  1. Ada Reference Manual, B.1 Interfacing Aspects. Definitions of Import, Export, Convention and External_Name. ↩

  2. Ada Reference Manual, B.3 Interfacing with C and C++. The types in Interfaces.C and the mapping of parameter passing to C (Implementation Advice). ↩ ↩2 ↩3

  3. Ada 2012 Reference Manual, B.3.1 The Package Interfaces.C.Strings. Definitions of and cautions for New_String, Free, Value and Update. ↩ ↩2 ↩3 ↩4

  4. AdaCore, GPRbuild and GPR Companion Tools User’s Guide: GNAT Project Manager. Descriptions of Library_Kind, Library_Interface, Library_Auto_Init and Library_Standalone. ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  5. Ada Reference Manual, 10.2 Program Execution. Execution of a partition and initialization (elaboration) of library units. ↩

  6. AdaCore, GNAT User’s Guide: Platform-Specific Information. Calling conventions on Windows, building and initializing DLLs, and restrictions on Ada DLLs. ↩ ↩2 ↩3 ↩4 ↩5

  7. Microsoft Learn, P/Invoke source generation. Differences between LibraryImport and DllImport, and the AllowUnsafeBlocks requirement. ↩ ↩2 ↩3

  8. Microsoft Learn, Calling conventions. The default on x86 Windows is Stdcall, and Cdecl libraries must specify it explicitly. x64, ARM and ARM64 have one calling convention. ↩

  9. Microsoft Learn, Native library loading. How the library names tried vary by platform. ↩

  10. Microsoft Learn, Exceptions Interoperability. How the handling of exceptions from unmanaged code differs by platform. ↩

  11. Microsoft Learn, GetCurrentProcessId function. ↩

  12. Microsoft Learn, GetTickCount64 function. ↩

  13. Microsoft Learn, GetComputerNameW function. ↩

  14. Microsoft Learn, __stdcall. The convention used to call Win32 API functions. On x64 and ARM, the compiler accepts and ignores it. ↩

  15. Microsoft Learn, Windows Data Types. Definitions of DWORD and WINAPI. ↩

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These are topic pages close to this theme. Starting from the article, you can go on to related services and other articles.

This article leads to the following service pages. Start from the entry point closest to your situation.

Windows App Development

If you want to package calculation or control logic written in Ada as a DLL that existing C or C# applications can call, we can take this on as part of Windows application development.

Frequently Asked Questions

Questions commonly asked at the time of consultation about the topic of this article.

What should I decide before calling an Ada DLL from C#?
Export the public functions with Convention => C and write their parameters with the types from Interfaces.C. Then decide who initializes the library and when, which side allocates and frees string buffers, and that Ada exceptions must not leave the boundary.
Can C#'s try/catch catch an Ada exception?
In this article's experiment on Linux, it could not. An unhandled Constraint_Error made the GNAT runtime print a message, and the whole process exited. Handle exceptions inside the exported functions and return the result as an error code.
Why can't C#'s DllImport load a Windows DLL built with gprbuild?
A difference in names is one cause. gprbuild builds libgreet.dll from the library name greet, but on Windows .NET does not search with a lib prefix. Write "libgreet" in DllImport or LibraryImport. The DLLs it depends on, such as libgnat-13.dll, also need to be in a folder from which they can be loaded.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and bug investigation, with particular strength in projects where existing assets remain and in investigating failures whose cause is hard to see.

Back to the Blog