Native Interop: Declare and P/Invoke

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. Every example here starts with Option Strict On and was built with warnings treated as errors. The examples that call the C library (libc) are complete console programs that were run in the .NET 10 SDK container (Ubuntu Linux, arm64) with root namespace ShelfApp. The examples that call Windows DLLs (kernel32, user32) were built with EnableWindowsTargeting but could not be run on the machine that produced this page; they are labelled "built, not run" and their behaviour is described from the documentation, not from a run. Messages quoted as compiler or runtime output were printed by the SDK or the runtime.

Sooner or later a program has to call something that is not .NET: an operating-system function, a vendor’s hardware library, a codec, a legacy C DLL. The mechanism is platform invoke, usually called P/Invoke: the runtime loads a native library, finds an exported function by name, and calls it, translating ("marshalling") arguments and results at the boundary. Visual Basic has two ways to ask for it. One is its own Declare statement, inherited from Visual Basic 6.0 and still compiled today; the other is the <DllImport> attribute, shared with every .NET language. They produce the same kind of call, but they do not default to the same settings, and one default of Declare does not work on Linux at all. The C# view of the same machinery is on Native Interop (C#); the native side of the boundary (headers, calling conventions, struct layout) is on the C Reference. COM, the other big interop mechanism on Windows, has its own page: COM Interop and Office Automation.

Four ways to reach native code

Mechanism What you write Available in Visual Basic Notes

Declare statement

Declare Function Name Lib "lib" Alias "entry" (...) As T

yes, Visual Basic only

compiles to a P/Invoke method; implicitly Shared, SetLastError on, ExactSpelling on for Ansi and Unicode

<DllImport> attribute

a method with an empty body carrying the attribute

yes

the same attribute C# and F# use; every field is explicit and defaults to off

<LibraryImport> source generator

a partial method that a generator completes

no: the generator only runs for C#; a Visual Basic Function is rejected (BC31437) and a Sub is silently dropped

see LibraryImport and function pointers

Function pointers

delegate* unmanaged or <UnmanagedCallersOnly>

no: BC37316; use Marshal.GetDelegateForFunctionPointer

see LibraryImport and function pointers

The rest of the page follows a call from the declaration to the failure modes: first the two declaration forms and what the compiler emits for them, then libraries and names, the data types, strings, structures, callbacks, memory, handles and errors, and finally the Windows examples and the platform guards that keep them out of a Linux build.

The Declare statement

[ <attributelist> ] [ accessmodifier ] [ Shadows ] [ Overloads ]
Declare [ Ansi | Unicode | Auto ] [ Sub | Function ] name Lib "libname"
    [ Alias "aliasname" ] [ ( parameterlist ) ] [ As returntype ]

A Declare statement only describes the native function; it contains no code. The rules are:

  • It may appear only at module level: in a class, a structure or a module, never in a namespace, an interface or a procedure. A generic class cannot contain one either.

  • It is implicitly Shared. Writing Shared, Overrides, Overridable, MustOverride, Implements or Handles is an error (BC30244 for Shared and Overrides, the two that were tried).

  • Its default accessibility is Public, as for any member of a module; most real code writes Private or Friend and wraps the call.

  • Lib names the library and is mandatory (BC30218 when it is missing). Under Option Strict On the parameters and the return type need As clauses (BC30211, BC30210). Note that Lib is a reserved word in Visual Basic, so a variable named lib is a compile error (BC30183).

  • Alias gives the exported name when the Visual Basic name differs, which is useful when the native name is a Visual Basic keyword, differently cased, or starts with an underscore; Alias "#123" selects an export by ordinal number.

  • The character set modifier is Ansi (the default), Unicode or Auto.

  • The compiler does not look inside the library. A wrong parameter list is not diagnosed; it fails or corrupts memory when the call runs.

The first program declares four functions of the C library, three with Declare and one with the attribute:

Option Strict On

Imports System
Imports System.Runtime.InteropServices

Namespace E01
    Friend Module Program
        ' Declare statement: Lib names the library, Alias names the entry point
        Private Declare Function GetProcessId Lib "libc" Alias "getpid" () As Integer
        Private Declare Function AbsoluteValue Lib "libc.so.6" Alias "abs" (value As Integer) As Integer
        Private Declare Function StringLength Lib "libc" Alias "strlen" (<MarshalAs(UnmanagedType.LPStr)> text As String) As UIntPtr

        ' The same thing with the attribute: an empty body, in a class or module
        <DllImport("libc", EntryPoint:="labs")>
        Private Function LongAbs(x As Long) As Long
        End Function

        Friend Sub Main()
            Console.WriteLine("getpid() > 0:  " & (GetProcessId() > 0).ToString())
            Console.WriteLine("abs(-42):      " & AbsoluteValue(-42).ToString())
            Console.WriteLine("labs(-7):      " & LongAbs(-7L).ToString())
            Console.WriteLine("strlen(shelf): " & StringLength("shelf").ToString())
        End Sub
    End Module
End Namespace
getpid() > 0:  True
abs(-42):      42
labs(-7):      7
strlen(shelf): 5

Two things in that listing are worth noticing. The library is called "libc" in some declarations and "libc.so.6" in another, and both worked in the container; Finding the library explains why. And the strlen declaration carries <MarshalAs(UnmanagedType.LPStr)> on its String parameter, which is the subject of the next section.

What the compiler emits

The compiler turns every Declare into an ordinary static method with a DllImportAttribute. The attribute’s fields differ from a hand-written one, and they can be read back with reflection. This program declares the same function in all four forms and prints the fields:

Option Strict On

Imports System
Imports System.Reflection
Imports System.Runtime.InteropServices

Namespace E09
    Friend Module Program
        Private Declare Function ByDefault Lib "libc" Alias "getpid" () As Integer
        Private Declare Ansi Function ByAnsi Lib "libc" Alias "getpid" () As Integer
        Private Declare Unicode Function ByUnicode Lib "libc" Alias "getpid" () As Integer
        Private Declare Auto Function ByAuto Lib "libc" Alias "getpid" () As Integer

        <DllImport("libc", EntryPoint:="getpid")>
        Private Function ByAttribute() As Integer
        End Function

        Friend Sub Main()
            Console.WriteLine("{0,-12} {1,-8} {2,-6} {3,-9} {4,-8} {5}", "name", "CharSet", "Exact", "SetLast", "Preserve", "Convention")
            For Each name As String In New String() {"ByDefault", "ByAnsi", "ByUnicode", "ByAuto", "ByAttribute"}
                Dim m As MethodInfo = GetType(Program).GetMethod(name, BindingFlags.NonPublic Or BindingFlags.Static)
                Dim d As DllImportAttribute = m.GetCustomAttribute(Of DllImportAttribute)()
                Console.WriteLine("{0,-12} {1,-8} {2,-6} {3,-9} {4,-8} {5}", name, d.CharSet, d.ExactSpelling, d.SetLastError, d.PreserveSig, d.CallingConvention)
            Next
        End Sub
    End Module
End Namespace
name         CharSet  Exact  SetLast   Preserve Convention
ByDefault    Ansi     True   True      True     Winapi
ByAnsi       Ansi     True   True      True     Winapi
ByUnicode    Unicode  True   True      True     Winapi
ByAuto       Auto     False  True      True     Winapi
ByAttribute  None     False  False     True     Winapi
Field Declare <DllImport> without arguments

CharSet

Ansi (default), Unicode or Auto, as written

None, which behaves like Ansi

ExactSpelling

True for Ansi and Unicode, False for Auto

False

SetLastError

always True

False

PreserveSig

True

True

CallingConvention

Winapi (the platform default)

Winapi

SetLastError:=True on every Declare means the runtime saves the native error code (GetLastError on Windows, errno elsewhere) right after the call, at a small cost; it can be read with Marshal.GetLastPInvokeError(). With <DllImport> you opt in yourself. CallingConvention.Winapi means "stdcall on 32-bit Windows, the platform convention elsewhere", so a Declare cannot call a cdecl function on 32-bit Windows; use <DllImport(CallingConvention:=CallingConvention.Cdecl)> for that. On 64-bit Windows, Linux and macOS there is only one convention and the difference disappears.

The ByVal String trap

ByVal text As String is the natural way to pass a C string, and it is the form that every Visual Basic 6.0 declaration used. In a Declare statement it compiles but does not mean what it says. The next program declares strlen in a few ways and asks reflection what the compiler produced for the parameter. It also calls the first form, to see what happens:

Option Strict On

Imports System
Imports System.Reflection
Imports System.Runtime.InteropServices

Namespace E02
    Friend Module Program
        Private Declare Ansi Function LenAnsi Lib "libc" Alias "strlen" (ByVal text As String) As UIntPtr
        Private Declare Function LenMarshalled Lib "libc" Alias "strlen" (<MarshalAs(UnmanagedType.LPStr)> ByVal text As String) As UIntPtr
        Private Declare Unicode Function LenUnicode Lib "libc" Alias "strlen" (<MarshalAs(UnmanagedType.LPWStr)> ByVal text As String) As UIntPtr
        Private Declare Auto Function LenAuto Lib "libc" Alias "strlen" (<MarshalAs(UnmanagedType.LPTStr)> ByVal text As String) As UIntPtr

        Private Class Native
            <DllImport("libc", EntryPoint:="strlen")>
            Friend Shared Function LenDefault(text As String) As UIntPtr
            End Function
            <DllImport("libc", EntryPoint:="strlen", CharSet:=CharSet.Ansi)>
            Friend Shared Function LenAnsi(text As String) As UIntPtr
            End Function
            <DllImport("libc", EntryPoint:="strlen", CharSet:=CharSet.Unicode)>
            Friend Shared Function LenUnicode(text As String) As UIntPtr
            End Function
            <DllImport("libc", EntryPoint:="strlen", CharSet:=CharSet.Auto)>
            Friend Shared Function LenAuto(text As String) As UIntPtr
            End Function
        End Class

        Friend Sub Main()
            For Each name As String In New String() {"LenAnsi", "LenMarshalled", "LenUnicode", "LenAuto"}
                Dim m As MethodInfo = GetType(Program).GetMethod(name, BindingFlags.NonPublic Or BindingFlags.Static)
                Dim p As ParameterInfo = m.GetParameters()(0)
                Dim ma As MarshalAsAttribute = p.GetCustomAttribute(Of MarshalAsAttribute)()
                Console.WriteLine(name & ": " & p.ParameterType.ToString() & " marshal=" & If(ma Is Nothing, "none", ma.Value.ToString()))
            Next
            Console.WriteLine("Declare LPStr:    " & LenMarshalled("héllo").ToString())
            Console.WriteLine("Declare LPWStr:   " & LenUnicode("héllo").ToString())
            Console.WriteLine("Declare LPTStr:   " & LenAuto("héllo").ToString())
            Console.WriteLine("DllImport none:   " & Native.LenDefault("héllo").ToString())
            Console.WriteLine("DllImport Ansi:   " & Native.LenAnsi("héllo").ToString())
            Console.WriteLine("DllImport Unicode: " & Native.LenUnicode("héllo").ToString())
            Console.WriteLine("DllImport Auto:   " & Native.LenAuto("héllo").ToString())
            Try
                Console.WriteLine(LenAnsi("héllo"))
            Catch ex As MarshalDirectiveException
                Console.WriteLine("Declare Ansi: " & ex.Message)
            End Try
        End Sub
    End Module
End Namespace
LenAnsi: System.String& marshal=VBByRefStr
LenMarshalled: System.String marshal=LPStr
LenUnicode: System.String marshal=LPWStr
LenAuto: System.String marshal=LPTStr
Declare LPStr:    6
Declare LPWStr:   1
Declare LPTStr:   1
DllImport none:   6
DllImport Ansi:   6
DllImport Unicode: 1
DllImport Auto:   6
Declare Ansi: Cannot marshal 'parameter #1': Invalid managed/unmanaged type combination (String parameters and return types must be paired with LPStr, LPWStr, or LPTStr).

Source: UnmanagedType

The result, in order of importance:

  1. A plain ByVal text As String in a Declare becomes a ByRef string with UnmanagedType.VBByRefStr (System.String& in the output). That marshalling type is the Visual Basic 6.0 convention that lets native code overwrite a string; the Learn page for UnmanagedType marks it obsolete ("may be unavailable in future releases") and says it is supported only for platform invoke. On this Linux container the call throws MarshalDirectiveException for all of Ansi, Unicode, Auto and the default. The Windows case was not run; Microsoft’s own Declare samples use the plain form with Windows DLLs, so it may work there, but code that has to run on more than one operating system should not depend on it.

  2. Adding an explicit <MarshalAs(...)> attribute to the parameter makes the compiler emit an ordinary by-value String with that marshalling. That is the portable form, and every Declare in the rest of this page that takes a string uses it.

  3. With <DllImport> there is no such conversion: a String parameter is a by-value string, and the CharSet field decides how it is encoded.

  4. The encoding is visible in the result. strlen("héllo") counts bytes up to the first zero: 6 for UTF-8 (the é takes two bytes), but 1 for UTF-16, where the second byte of h is already zero. Auto follows the platform: UTF-8 with DllImport on Unix (6 above), UTF-16 on Windows. The Declare Auto with an explicit LPTStr attribute printed 1, because LPTStr is also UTF-16 here; avoid LPTStr and say LPStr, LPUTF8Str or LPWStr explicitly.

CharSet Windows Unix, .NET Core 3.0 and later Typical native parameter

Ansi

system ANSI code page (char)

UTF-8 (char)

char *, LPCSTR

Unicode

UTF-16 (wchar_t)

UTF-16 (char16_t)

LPCWSTR; not the same as wchar_t on Linux, which is 32 bits

Auto

UTF-16

UTF-8

LPCTSTR style APIs on Windows

DllImport in Visual Basic

A method marked <DllImport> is declared like a normal method but must have an empty body. It can live in a class, structure or module; in a class it is conventionally Friend Shared. Anything in the body is an error:

Option Strict On
Imports System.Runtime.InteropServices
Module Program
    <DllImport("libc", EntryPoint:="getpid")>
    Private Function GetPid() As Integer
        Return 1
    End Function
    Sub Main()
    End Sub
End Module
Program.vb(4,6): error BC31522: 'System.Runtime.InteropServices.DllImportAttribute' cannot be applied to a Sub, Function, or Operator with a non-empty body.

The fields that matter in practice:

Field Meaning

EntryPoint

the exported name when it differs from the Visual Basic name (the equivalent of Alias)

CharSet

string encoding and, with Auto, which of Name, NameW or NameA is looked up

ExactSpelling

True stops the runtime from trying A/W suffixes

SetLastError

save errno or GetLastError immediately after the call

CallingConvention

Winapi, Cdecl, StdCall, ThisCall, FastCall

PreserveSig

False turns an HRESULT return into an exception and the last out parameter into the return value; used for COM methods

BestFitMapping, ThrowOnUnmappableChar

behaviour for characters that do not exist in the target code page (Windows ANSI only)

Finding the library

The name in Lib or DllImport is a logical name. The runtime adds the platform’s extension and, on Unix, the lib prefix, in a fixed order, and loads the first file that exists. For "libc" on Linux it tries libc.so, liblibc.so, libc and liblibc in the application directory and the system library path; for a name that already contains .so. (such as "libc.so.6") the exact name goes first. An absolute path is used as written. Because the file for plain "libc" is not guaranteed to be usable on every distribution, portable code gives the versioned name or uses a resolver.

When nothing is found, or a function is not exported, the exceptions are:

Option Strict On

Imports System
Imports System.Reflection
Imports System.Runtime.InteropServices

Namespace E06
    Friend Module Program
        Private Declare Function Missing Lib "libshelf-does-not-exist" Alias "nothing" () As Integer
        Private Declare Function NoSuchEntry Lib "libc" Alias "shelf_no_such_function" () As Integer
        Private Declare Function GetPid Lib "shelf-libc" Alias "getpid" () As Integer

        <UnmanagedFunctionPointer(CallingConvention.Cdecl)>
        Private Delegate Function IntToInt(value As Integer) As Integer

        Private Function Resolve(libraryName As String, assembly As Assembly, searchPath As DllImportSearchPath?) As IntPtr
            If libraryName = "shelf-libc" Then
                Console.WriteLine("resolver asked for '" & libraryName & "'")
                Return NativeLibrary.Load("libc.so.6")
            End If
            Return IntPtr.Zero    ' fall back to the default probing
        End Function

        Friend Sub Main()
            Try
                Missing()
            Catch ex As DllNotFoundException
                Console.WriteLine("DllNotFoundException: " & ex.Message.Substring(0, ex.Message.IndexOf(". In order", StringComparison.Ordinal) + 1))
            End Try
            Try
                NoSuchEntry()
            Catch ex As EntryPointNotFoundException
                Console.WriteLine("EntryPointNotFoundException: " & ex.Message)
            End Try

            NativeLibrary.SetDllImportResolver(GetType(Program).Assembly, AddressOf Resolve)
            Console.WriteLine("getpid via resolver > 0: " & (GetPid() > 0).ToString())

            ' no P/Invoke declaration at all: load, look up, wrap in a delegate
            Dim libc As IntPtr = NativeLibrary.Load("libc.so.6")
            Dim address As IntPtr = Nothing
            Console.WriteLine("TryGetExport(abs): " & NativeLibrary.TryGetExport(libc, "abs", address).ToString() &
                ", TryGetExport(nope): " & NativeLibrary.TryGetExport(libc, "nope", address).ToString())
            Dim abs As IntToInt = Marshal.GetDelegateForFunctionPointer(Of IntToInt)(NativeLibrary.GetExport(libc, "abs"))
            Console.WriteLine("abs(-5) through a function pointer: " & abs(-5).ToString())
            NativeLibrary.Free(libc)
        End Sub
    End Module
End Namespace
DllNotFoundException: Unable to load shared library 'libshelf-does-not-exist' or one of its dependencies.
EntryPointNotFoundException: Unable to find an entry point named 'shelf_no_such_function' in shared library 'libc'.
resolver asked for 'shelf-libc'
getpid via resolver > 0: True
TryGetExport(abs): True, TryGetExport(nope): False
abs(-5) through a function pointer: 5

That program also shows the three tools Visual Basic has for taking control of loading:

  • NativeLibrary.SetDllImportResolver(assembly, callback) registers a callback that receives the logical name ("shelf-libc") and returns a library handle. Returning IntPtr.Zero falls back to the default probing. It can be set once per assembly. The callback must be a method with the signature (String, Assembly, DllImportSearchPath?) As IntPtr; the third parameter is a nullable enumeration, written DllImportSearchPath?.

  • NativeLibrary.Load, TryLoad, GetExport, TryGetExport and Free work with handles directly.

  • Marshal.GetDelegateForFunctionPointer(Of T) turns an export address into a callable delegate, with no Declare at all. This is the only way to call a function whose name is chosen at run time.

The run-time failures are DllNotFoundException (library not found or one of its dependencies missing), EntryPointNotFoundException (wrong name or Alias), BadImageFormatException (a 32-bit library in a 64-bit process, or the wrong CPU architecture) and MarshalDirectiveException (an impossible type combination, as above).

Types across the boundary

The compiler does not check a declaration against the library, so the type table is the most important part of the page. The column "VB" is the type to write in the declaration:

C or Windows type VB Notes

int, INT, LONG and DWORD on Windows (32 bits)

Integer (UInteger for DWORD, UINT, ULONG)

Long in Visual Basic is 64 bits, not the 32 bits of Visual Basic 6.0

long on Linux and macOS (64 bits)

Long

the same C type has different widths on Windows and Unix; check with sizeof

long long, int64_t, __int64

Long (ULong)

short, WORD

Short, UShort

Integer in Visual Basic 6.0 was 16 bits and is Short now

char, BYTE, unsigned char

Byte (SByte for signed)

wchar_t

Char on Windows, Integer on Linux

16 bits on Windows, 32 bits on Linux and macOS

BOOL (an int)

Boolean

marshalled as a 4-byte value by default, <MarshalAs(UnmanagedType.Bool)> makes that explicit

C99 bool, BOOLEAN

Boolean with <MarshalAs(UnmanagedType.U1)>

a one-byte value

float, double

Single, Double

size_t, uintptr_t, HANDLE, HWND, any pointer, void *

IntPtr, UIntPtr

pointer-sized; do not use Integer or Long, which break on one of 32 or 64 bits

const char * (input)

String with <MarshalAs(UnmanagedType.LPStr)>, or LPUTF8Str, LPWStr

see the ByVal String trap above

char * buffer the callee fills

StringBuilder with a capacity, or Byte()

the capacity is the buffer size

struct *

ByRef structure

initialise with = Nothing (BC42108) or New

struct by value (small)

structure

the platform calling convention decides how it is passed; keep the layout identical

function pointer

a delegate type

keep the delegate alive; see callbacks below

HRESULT

Integer

or PreserveSig:=False

Variant, BSTR

Object, String with BStr

COM only

Blittable types (integers, floating point, pointers, and structures made only of them) have the same representation on both sides, so they are passed without copying. Boolean, Char and String are not blittable: they are converted at each call, which is slower and is the reason a mismatch often shows up as a wrong value rather than a crash.

The Visual Basic 6.0 conversion table matters when you port an old Declare: VB6 Integer becomes Short, VB6 Long becomes Integer, As Any has no equivalent (write one overload per type), a VB6 Type becomes a Structure with <StructLayout(LayoutKind.Sequential)>, and AddressOf callbacks need a delegate type.

Structures and fixed buffers

A structure passed to native code needs a known layout. <StructLayout(LayoutKind.Sequential)> lays the fields out in declaration order with the platform’s natural alignment, Pack:=1 removes the padding and LayoutKind.Explicit with <FieldOffset> expresses unions. The next program uses three native structures of Linux (timeval, utsname with six 65-byte character arrays, and the div_t that div returns by value), a StringBuilder output buffer, and a function that returns a pointer:

Option Strict On

Imports System
Imports System.Runtime.InteropServices
Imports System.Text

Namespace E03
    ' struct timeval { time_t tv_sec; suseconds_t tv_usec; } on 64-bit Linux
    <StructLayout(LayoutKind.Sequential)>
    Friend Structure TimeVal
        Public Seconds As Long
        Public Microseconds As Long
    End Structure

    ' struct utsname: six fixed 65-byte character arrays on Linux
    <StructLayout(LayoutKind.Sequential, CharSet:=CharSet.Ansi)>
    Friend Structure UtsName
        <MarshalAs(UnmanagedType.ByValTStr, SizeConst:=65)> Public SysName As String
        <MarshalAs(UnmanagedType.ByValTStr, SizeConst:=65)> Public NodeName As String
        <MarshalAs(UnmanagedType.ByValTStr, SizeConst:=65)> Public Release As String
        <MarshalAs(UnmanagedType.ByValTStr, SizeConst:=65)> Public Version As String
        <MarshalAs(UnmanagedType.ByValTStr, SizeConst:=65)> Public Machine As String
        <MarshalAs(UnmanagedType.ByValTStr, SizeConst:=65)> Public DomainName As String
    End Structure

    ' div_t { int quot; int rem; } is returned by value
    <StructLayout(LayoutKind.Sequential)>
    Friend Structure DivResult
        Public Quotient As Integer
        Public Remainder As Integer
    End Structure

    Friend Module Program
        Private Declare Function GetTimeOfDay Lib "libc" Alias "gettimeofday" (ByRef tv As TimeVal, tz As IntPtr) As Integer
        Private Declare Function Uname Lib "libc" Alias "uname" (ByRef info As UtsName) As Integer
        Private Declare Function Divide Lib "libc" Alias "div" (numerator As Integer, denominator As Integer) As DivResult
        Private Declare Function GetCwd Lib "libc" Alias "getcwd" (buffer As StringBuilder, size As UIntPtr) As IntPtr
        Private Declare Function GetEnv Lib "libc" Alias "getenv" (<MarshalAs(UnmanagedType.LPStr)> name As String) As IntPtr

        Friend Sub Main()
            Dim tv As TimeVal
            Dim rc As Integer = GetTimeOfDay(tv, IntPtr.Zero)
            Console.WriteLine("gettimeofday rc=" & rc.ToString() & ", seconds sane=" & (tv.Seconds > 1700000000L).ToString() &
                ", usec in range=" & (tv.Microseconds >= 0L AndAlso tv.Microseconds < 1000000L).ToString())

            Dim info As UtsName = Nothing
            rc = Uname(info)
            Console.WriteLine("uname rc=" & rc.ToString() & ", sysname=" & info.SysName)
            Console.WriteLine("Marshal.SizeOf(UtsName)=" & Marshal.SizeOf(Of UtsName)().ToString())

            Dim d As DivResult = Divide(17, 5)
            Console.WriteLine("div(17, 5): quotient=" & d.Quotient.ToString() & ", remainder=" & d.Remainder.ToString())

            Dim buffer As New StringBuilder(260)
            Dim p As IntPtr = GetCwd(buffer, New UIntPtr(CUInt(buffer.Capacity)))
            Console.WriteLine("getcwd matches Directory.GetCurrentDirectory: " & (buffer.ToString() = System.IO.Directory.GetCurrentDirectory()).ToString() & ", pointer returned: " & (p <> IntPtr.Zero).ToString())

            Dim value As IntPtr = GetEnv("SHELF_DEMO")
            Console.WriteLine("getenv(SHELF_DEMO) = " & If(value = IntPtr.Zero, "(null)", Marshal.PtrToStringUTF8(value)))
        End Sub
    End Module
End Namespace
gettimeofday rc=0, seconds sane=True, usec in range=True
uname rc=0, sysname=Linux
Marshal.SizeOf(UtsName)=390
div(17, 5): quotient=3, remainder=2
getcwd matches Directory.GetCurrentDirectory: True, pointer returned: True
getenv(SHELF_DEMO) = (null)

Points to take from it:

  • The offsets and sizes follow the C declaration of the same platform. The structures here are 64-bit Linux layouts; on Windows or on a 32-bit system they would be different. Read the header for the platform you target.

  • A fixed-size character array in C is a String field with <MarshalAs(UnmanagedType.ByValTStr, SizeConst:=65)>. Visual Basic has no fixed-size array fields and no fixed statement, so the attribute is the only way.

  • A structure with reference-type fields (String) passed ByRef and left uninitialised triggers warning BC42108. It is an error here because the project treats warnings as errors:

    Program.vb(47,24): error BC42108: Variable 'info' is passed by reference before it has been assigned a value. A null reference exception could result at runtime. Make sure the structure or all the reference members are initialized before use

    The fix used in the program is Dim info As UtsName = Nothing.

  • A StringBuilder parameter is copied into a native buffer of its Capacity before the call and back afterwards. Always construct it with the capacity you promise to the native function.

  • getenv returns a pointer into the process environment, which the caller must neither modify nor free. The program reads it with Marshal.PtrToStringUTF8 only when it is not null.

Layout and unions can be checked without calling anything. This program prints the size and offsets the runtime computes, copies a structure through unmanaged memory with memcpy, receives and frees a string allocated by C, and pins a managed array:

Option Strict On

Imports System
Imports System.Runtime.InteropServices

Namespace E07
    <StructLayout(LayoutKind.Sequential, Pack:=1)>
    Friend Structure CopyRecord
        Public Id As Integer
        Public Price As Double
        Public Flag As Byte
    End Structure

    <StructLayout(LayoutKind.Explicit)>
    Friend Structure IntOrFloat
        <FieldOffset(0)> Public AsInteger As Integer
        <FieldOffset(0)> Public AsSingle As Single
    End Structure

    Friend Module Program
        Private Declare Function MemCopy Lib "libc" Alias "memcpy" (destination As IntPtr, source As IntPtr, count As UIntPtr) As IntPtr
        Private Declare Function Duplicate Lib "libc" Alias "strdup" (<MarshalAs(UnmanagedType.LPUTF8Str)> text As String) As IntPtr
        Private Declare Sub Release Lib "libc" Alias "free" (pointer As IntPtr)

        Friend Sub Main()
            Console.WriteLine("SizeOf CopyRecord (Pack:=1): " & Marshal.SizeOf(Of CopyRecord)().ToString() &
                ", OffsetOf Price: " & Marshal.OffsetOf(Of CopyRecord)("Price").ToString())
            Console.WriteLine("SizeOf IntOrFloat: " & Marshal.SizeOf(Of IntOrFloat)().ToString())
            Dim bits As New IntOrFloat
            bits.AsSingle = 1.0F
            Console.WriteLine("1.0F as bits: 0x" & bits.AsInteger.ToString("X8"))

            ' copy a structure through unmanaged memory
            Dim source As New CopyRecord With {.Id = 7, .Price = 12.5, .Flag = 1}
            Dim sourcePtr As IntPtr = Marshal.AllocHGlobal(Marshal.SizeOf(Of CopyRecord)())
            Dim targetPtr As IntPtr = Marshal.AllocHGlobal(Marshal.SizeOf(Of CopyRecord)())
            Try
                Marshal.StructureToPtr(source, sourcePtr, False)
                MemCopy(targetPtr, sourcePtr, New UIntPtr(CUInt(Marshal.SizeOf(Of CopyRecord)())))
                Dim copy As CopyRecord = Marshal.PtrToStructure(Of CopyRecord)(targetPtr)
                Console.WriteLine("copy: Id=" & copy.Id.ToString() & ", Price=" & copy.Price.ToString() & ", Flag=" & copy.Flag.ToString())
            Finally
                Marshal.FreeHGlobal(sourcePtr)
                Marshal.FreeHGlobal(targetPtr)
            End Try

            ' native allocation returned to us: copy it, then free it with the matching allocator
            Dim native As IntPtr = Duplicate("Café Shelf")
            Try
                Console.WriteLine("strdup: " & Marshal.PtrToStringUTF8(native) & " (" & Marshal.PtrToStringUTF8(native).Length.ToString() & " chars)")
            Finally
                Release(native)
            End Try

            ' pin a managed array so native code can see a stable address
            Dim numbers As Integer() = {10, 20, 30}
            Dim handle As GCHandle = GCHandle.Alloc(numbers, GCHandleType.Pinned)
            Try
                Dim address As IntPtr = handle.AddrOfPinnedObject()
                Console.WriteLine("element 2 read through the pointer: " & Marshal.ReadInt32(address, 2 * Marshal.SizeOf(Of Integer)()).ToString())
                Marshal.WriteInt32(address, 0, 99)
            Finally
                handle.Free()
            End Try
            Console.WriteLine("numbers(0) after the native write: " & numbers(0).ToString())
        End Sub
    End Module
End Namespace
SizeOf CopyRecord (Pack:=1): 13, OffsetOf Price: 4
SizeOf IntOrFloat: 4
1.0F as bits: 0x3F800000
copy: Id=7, Price=12.5, Flag=1
strdup: Café Shelf (10 chars)
element 2 read through the pointer: 30
numbers(0) after the native write: 99

Source: Marshal class

Marshal.AllocHGlobal and FreeHGlobal are a pair; so are StringToCoTaskMemUTF8 and FreeCoTaskMem; memory that C allocated (strdup) must go back to C’s free, not to FreeHGlobal. Passing the wrong deallocator corrupts the heap on Linux and is a silent leak or a crash on Windows. Pinning with GCHandle is only needed when native code keeps the address after the call returns; for the duration of a call the runtime pins arrays and structures passed by reference itself.

Callbacks

A native function that takes a function pointer receives a Visual Basic delegate. The delegate type must match the native signature and should carry <UnmanagedFunctionPointer(CallingConvention.Cdecl)> when the callee is not stdcall. qsort from the C library is the classic example:

Option Strict On

Imports System
Imports System.Runtime.InteropServices

Namespace E04
    Friend Module Program
        ' int (*compar)(const void *, const void *)
        <UnmanagedFunctionPointer(CallingConvention.Cdecl)>
        Friend Delegate Function CompareCallback(left As IntPtr, right As IntPtr) As Integer

        Private Declare Sub QuickSort Lib "libc" Alias "qsort" (<[In], Out> items As Integer(), count As UIntPtr, size As UIntPtr, compare As CompareCallback)

        Private callsMade As Integer

        ' the comparer reads the two elements straight from native memory
        Private Function Descending(left As IntPtr, right As IntPtr) As Integer
            callsMade += 1
            Dim a As Integer = Marshal.ReadInt32(left)
            Dim b As Integer = Marshal.ReadInt32(right)
            Return b.CompareTo(a)
        End Function

        Friend Sub Main()
            Dim loans As Integer() = {14, 3, 9, 27, 1, 8}
            Dim compare As New CompareCallback(AddressOf Descending)
            QuickSort(loans, New UIntPtr(CUInt(loans.Length)), New UIntPtr(CUInt(Marshal.SizeOf(Of Integer)())), compare)
            GC.KeepAlive(compare)    ' the delegate must stay alive while native code can call it
            Console.WriteLine(String.Join(", ", loans))
            Console.WriteLine("comparer called: " & (callsMade > 0).ToString())
        End Sub
    End Module
End Namespace
27, 14, 9, 8, 3, 1
comparer called: True

Rules for callbacks:

  • AddressOf Descending creates the delegate; Visual Basic converts the delegate type implicitly in a call, but writing New CompareCallback(AddressOf Descending) makes the lifetime visible. Under Option Strict On the method must match the delegate’s parameter types exactly (BC36663 for a method with fewer parameters, which lambdas can do and AddressOf cannot).

  • The runtime keeps a delegate alive for the duration of a call, but not after it. If native code stores the pointer (a window procedure, a registered callback), keep the delegate in a field or GC.KeepAlive it as long as the native side can call it; otherwise a collection leads to a crash that is hard to trace.

  • An exception that escapes a callback unwinds through native frames and terminates the process. Catch inside the callback and signal failure through a return value.

  • The callback runs on the thread that called the native function unless the library calls it from its own threads, in which case no SynchronizationContext is available and UI objects must not be touched directly.

Handles and error codes

An integer or IntPtr that identifies an operating-system resource should be wrapped in a SafeHandle, so that it is released once, even when an exception or a thread abort interrupts the program. A subclass supplies ReleaseHandle; SafeHandleMinusOneIsInvalid and SafeHandleZeroOrMinusOneIsInvalid exist for the two common invalid values. Error codes are read after the call with Marshal.GetLastPInvokeError(), but only when SetLastError is on. This program wraps a Linux file descriptor, reads a file through read, shows the handle being closed and, last, shows a pitfall of declaring the wrong return type:

Option Strict On

Imports System
Imports System.ComponentModel
Imports System.IO
Imports System.Runtime.InteropServices
Imports System.Text
Imports Microsoft.Win32.SafeHandles

Namespace E05
    ' A SafeHandle owns a native file descriptor and closes it exactly once
    Friend NotInheritable Class FileDescriptor
        Inherits SafeHandleMinusOneIsInvalid

        Public Sub New()
            MyBase.New(True)
        End Sub

        Friend Sub New(descriptor As Integer)
            MyBase.New(True)
            SetHandle(New IntPtr(descriptor))
        End Sub

        Protected Overrides Function ReleaseHandle() As Boolean
            Return Native.Close(handle) = 0
        End Function
    End Class

    Friend NotInheritable Class Native
        Private Sub New()
        End Sub

        ' open() returns a 32-bit int, so the SafeHandle return type is wrong here (see the output)
        <DllImport("libc", EntryPoint:="open", SetLastError:=True)>
        Friend Shared Function OpenAsHandle(<MarshalAs(UnmanagedType.LPStr)> path As String, flags As Integer) As FileDescriptor
        End Function

        <DllImport("libc", EntryPoint:="open", SetLastError:=True)>
        Friend Shared Function OpenRaw(<MarshalAs(UnmanagedType.LPStr)> path As String, flags As Integer) As Integer
        End Function

        <DllImport("libc", EntryPoint:="read", SetLastError:=True)>
        Friend Shared Function Read(fd As FileDescriptor, buffer As Byte(), count As UIntPtr) As IntPtr
        End Function

        <DllImport("libc", EntryPoint:="close")>
        Friend Shared Function Close(fd As IntPtr) As Integer
        End Function

        Friend Shared Function Open(path As String) As FileDescriptor
            Dim raw As Integer = OpenRaw(path, 0)
            If raw < 0 Then
                Throw New Win32Exception(Marshal.GetLastPInvokeError())
            End If
            Return New FileDescriptor(raw)
        End Function
    End Class

    Friend Module Program
        Friend Sub Main()
            Dim path As String = System.IO.Path.GetTempFileName()
            File.WriteAllText(path, "ISBN 978-0-13-468599-1" & vbLf)

            Dim fd As FileDescriptor = Native.Open(path)
            Console.WriteLine("valid: " & (Not fd.IsInvalid).ToString() & ", closed: " & fd.IsClosed.ToString())
            Dim buffer(63) As Byte
            Dim count As Integer = CInt(Native.Read(fd, buffer, New UIntPtr(CUInt(buffer.Length))))
            Console.Write(Encoding.ASCII.GetString(buffer, 0, count))
            fd.Dispose()
            Console.WriteLine("after Dispose, closed: " & fd.IsClosed.ToString())

            Try
                Native.Read(fd, buffer, New UIntPtr(CUInt(buffer.Length)))
            Catch ex As ObjectDisposedException
                Console.WriteLine("read on a closed handle: " & ex.GetType().Name)
            End Try

            ' SetLastError captures errno right after the call
            Dim missing As String = path & ".missing"
            Dim naive As FileDescriptor = Native.OpenAsHandle(missing, 0)
            Console.WriteLine("SafeHandle return type, IsInvalid after a failed open: " & naive.IsInvalid.ToString())
            naive.Dispose()
            Try
                Native.Open(missing)
            Catch ex As Win32Exception
                Console.WriteLine("raw Integer return type: errno=" & ex.NativeErrorCode.ToString() & ", message=" & ex.Message)
            End Try
            File.Delete(path)
        End Sub
    End Module
End Namespace
valid: True, closed: False
ISBN 978-0-13-468599-1
after Dispose, closed: True
read on a closed handle: ObjectDisposedException
SafeHandle return type, IsInvalid after a failed open: False
raw Integer return type: errno=2, message=No such file or directory

Source: SafeHandle

The last two lines are the instructive ones. open returns a 32-bit int (-1 on failure), but a SafeHandle return type makes the runtime read a pointer-sized value from the return register, and the upper half of that register is not defined by the C ABI. Here it held zero, so the handle was 4294967295, not -1, and IsInvalid said False for a failed call. The program shows the robust pattern: declare the return type as Integer, test it, and construct the SafeHandle yourself. The error code comes out as errno 2 ("No such file or directory") through Win32Exception, which formats native error codes on every platform. SafeHandle return types are safe for APIs that return a real pointer-sized handle, such as CreateFile on Windows.

Calls that exist only on Windows

Declare and DllImport compile against any library name, so a program for Windows APIs builds on every operating system and fails at the first call elsewhere. The next two programs call kernel32 and user32. They were built with EnableWindowsTargeting and are built, not run: no Windows machine was available.

Built, not run. Common Windows functions with Declare, the Auto modifier and an out buffer:

Option Strict On

Imports System
Imports System.ComponentModel
Imports System.Runtime.InteropServices
Imports System.Text

Namespace W1
    Friend Module Program
        ' Auto: the runtime looks for MessageBox, then MessageBoxW
        Private Declare Auto Function MessageBox Lib "user32" (hWnd As IntPtr, text As String, caption As String, type As UInteger) As Integer

        ' The same API with explicit marshalling, which does not depend on VBByRefStr
        Private Declare Function MessageBoxW Lib "user32" (hWnd As IntPtr,
            <MarshalAs(UnmanagedType.LPWStr)> text As String,
            <MarshalAs(UnmanagedType.LPWStr)> caption As String, type As UInteger) As Integer

        Private Declare Auto Function GetComputerName Lib "kernel32" (buffer As StringBuilder, ByRef size As UInteger) As Boolean

        Private Declare Function GetTickCount64 Lib "kernel32" () As ULong

        Private Declare Function GetFileAttributesW Lib "kernel32" (<MarshalAs(UnmanagedType.LPWStr)> path As String) As UInteger

        Private Const InvalidFileAttributes As UInteger = &HFFFFFFFFUI
        Private Const MbOk As UInteger = 0UI
        Private Const MbIconInformation As UInteger = &H40UI

        Friend Sub Main()
            Dim name As New StringBuilder(256)
            Dim size As UInteger = CUInt(name.Capacity)
            If GetComputerName(name, size) Then
                Console.WriteLine("Computer: " & name.ToString() & " (" & size.ToString() & " characters)")
            End If

            Console.WriteLine("Uptime: " & TimeSpan.FromMilliseconds(CDbl(GetTickCount64())).ToString("d\.hh\:mm\:ss"))

            If GetFileAttributesW("C:\Shelf\missing.txt") = InvalidFileAttributes Then
                Dim code As Integer = Marshal.GetLastPInvokeError()
                Console.WriteLine("GetFileAttributes failed: " & code.ToString() & " " & New Win32Exception(code).Message)
            End If

            MessageBox(IntPtr.Zero, "The shelf has 2 books.", "Shelf", MbOk Or MbIconInformation)
            MessageBoxW(IntPtr.Zero, "Same call, explicit marshalling.", "Shelf", MbOk)
        End Sub
    End Module
End Namespace

Built, not run. A structure, a callback into EnumWindows and a SafeFileHandle return type:

Option Strict On

Imports System
Imports System.Collections.Generic
Imports System.Runtime.InteropServices
Imports System.Text
Imports Microsoft.Win32.SafeHandles

Namespace W2
    ' SYSTEM_INFO: the first DWORD is a union, read here as the architecture and reserved words
    <StructLayout(LayoutKind.Sequential)>
    Friend Structure SystemInfo
        Public ProcessorArchitecture As UShort
        Public Reserved As UShort
        Public PageSize As UInteger
        Public MinimumApplicationAddress As IntPtr
        Public MaximumApplicationAddress As IntPtr
        Public ActiveProcessorMask As UIntPtr
        Public NumberOfProcessors As UInteger
        Public ProcessorType As UInteger
        Public AllocationGranularity As UInteger
        Public ProcessorLevel As UShort
        Public ProcessorRevision As UShort
    End Structure

    ' BOOL CALLBACK EnumWindowsProc(HWND hwnd, LPARAM lParam)
    <UnmanagedFunctionPointer(CallingConvention.Winapi)>
    Friend Delegate Function EnumWindowsCallback(hWnd As IntPtr, lParam As IntPtr) As <MarshalAs(UnmanagedType.Bool)> Boolean

    Friend Module Program
        Private Declare Sub GetNativeSystemInfo Lib "kernel32" (ByRef info As SystemInfo)
        Private Declare Function EnumWindows Lib "user32" (callback As EnumWindowsCallback, lParam As IntPtr) As <MarshalAs(UnmanagedType.Bool)> Boolean
        Private Declare Auto Function GetWindowText Lib "user32" (hWnd As IntPtr, buffer As StringBuilder, maxCount As Integer) As Integer
        Private Declare Function IsWindowVisible Lib "user32" (hWnd As IntPtr) As <MarshalAs(UnmanagedType.Bool)> Boolean

        <DllImport("kernel32", EntryPoint:="CreateFileW", CharSet:=CharSet.Unicode, ExactSpelling:=True, SetLastError:=True)>
        Private Function CreateFile(path As String, access As UInteger, share As UInteger, security As IntPtr,
                                    disposition As UInteger, flags As UInteger, template As IntPtr) As SafeFileHandle
        End Function

        Private Const GenericRead As UInteger = &H80000000UI
        Private Const FileShareRead As UInteger = 1UI
        Private Const OpenExisting As UInteger = 3UI

        Friend Sub Main()
            Dim info As SystemInfo = Nothing
            GetNativeSystemInfo(info)
            Console.WriteLine("Processors: " & info.NumberOfProcessors.ToString() & ", page size: " & info.PageSize.ToString())

            Dim titles As New List(Of String)
            Dim callback As New EnumWindowsCallback(
                Function(hWnd As IntPtr, lParam As IntPtr) As Boolean
                    If IsWindowVisible(hWnd) Then
                        Dim text As New StringBuilder(256)
                        If GetWindowText(hWnd, text, text.Capacity) > 0 Then titles.Add(text.ToString())
                    End If
                    Return True
                End Function)
            EnumWindows(callback, IntPtr.Zero)
            GC.KeepAlive(callback)
            Console.WriteLine("Visible top-level windows with a title: " & titles.Count.ToString())

            Using file As SafeFileHandle = CreateFile("C:\Windows\win.ini", GenericRead, FileShareRead, IntPtr.Zero, OpenExisting, 0UI, IntPtr.Zero)
                Console.WriteLine("win.ini handle valid: " & (Not file.IsInvalid).ToString())
            End Using
        End Sub
    End Module
End Namespace

What each part is meant to show:

  • Declare Auto looks the function up as MessageBox, and if that name is not exported, as MessageBoxW (the Declare reference describes this order for a Unicode platform), and marshals String as UTF-16. Windows exports both MessageBoxA and MessageBoxW and no plain MessageBox, which is why Auto is the traditional choice for the Windows API.

  • The second form, MessageBoxW with explicit LPWStr parameters, does not depend on name probing or on VBByRefStr. That is the form to prefer in new code, together with ExactSpelling.

  • EnumWindows and IsWindowVisible return a Win32 BOOL, which is four bytes; <MarshalAs(UnmanagedType.Bool)> on the return type states that explicitly (it is also the default for Boolean). A C99 bool or BOOLEAN would need UnmanagedType.U1.

  • GetFileAttributesW fails with the value &HFFFFFFFF; the error code read with Marshal.GetLastPInvokeError() is only meaningful because Declare turned SetLastError on.

  • Unsigned parameters are declared UInteger, UShort and ULong. Constants such as &H80000000UI need the UI suffix, otherwise the literal is a negative Integer.

Running either program on Linux shows what a non-Windows platform does with a Windows library name:

Unhandled exception. System.DllNotFoundException: Unable to load shared library 'kernel32' or one of its dependencies. In order to help diagnose loading problems, consider using a tool like strace. If you're using glibc, consider setting the LD_DEBUG environment variable:
/usr/share/dotnet/shared/Microsoft.NETCore.App/10.0.13/kernel32.so: cannot open shared object file: No such file or directory
/w/bin/Debug/net10.0/kernel32.so: cannot open shared object file: No such file or directory
/usr/share/dotnet/shared/Microsoft.NETCore.App/10.0.13/libkernel32.so: cannot open shared object file: No such file or directory
/w/bin/Debug/net10.0/libkernel32.so: cannot open shared object file: No such file or directory

This is the same probing as before: kernel32.so, libkernel32.so, kernel32, libkernel32, each in the framework folder and the application folder.

Keeping platform-specific calls out of other platforms

NET marks its own Windows-only APIs with <SupportedOSPlatform("windows")>, and the analyzer rule CA1416 warns about any call site that is reachable on other platforms. You can annotate your own declarations

the same way and use OperatingSystem.IsWindows() and its siblings as guards. The program below declares one native function per platform, guards the calls, and compares the result with the portable Environment.ProcessId:

Option Strict On

Imports System
Imports System.Runtime.Versioning

Namespace E08
    Friend Module Program
        <SupportedOSPlatform("windows")>
        Private Declare Function GetCurrentProcessId Lib "kernel32" () As UInteger

        <SupportedOSPlatform("linux")>
        <SupportedOSPlatform("macos")>
        Private Declare Function GetPid Lib "libc" Alias "getpid" () As Integer

        Private Function ProcessIdFromNative() As Long
            If OperatingSystem.IsWindows() Then
                Return GetCurrentProcessId()
            End If
            If OperatingSystem.IsLinux() OrElse OperatingSystem.IsMacOS() Then
                Return GetPid()
            End If
            Throw New PlatformNotSupportedException("No native process id binding for " & Runtime.InteropServices.RuntimeInformation.OSDescription)
        End Function

        Friend Sub Main()
            Console.WriteLine("native pid equals Environment.ProcessId: " & (ProcessIdFromNative() = Environment.ProcessId).ToString())
        End Sub
    End Module
End Namespace
native pid equals Environment.ProcessId: True

Removing the guard from a call to an annotated declaration produces the warning below (shown with warnings not treated as errors; in these projects it fails the build):

Program.vb(8,27): warning CA1416: This call site is reachable on all platforms. 'Private Declare Ansi Function GetCurrentProcessId Lib "kernel32" () As UInteger' is only supported on: 'windows'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)

Prefer the managed API whenever one exists: Environment.ProcessId, System.IO, System.Diagnostics.Process and Microsoft.Win32.Registry cover most of what older code used Declare for. Reach for P/Invoke when no managed equivalent exists or when you are talking to your own native library.

LibraryImport and function pointers

C# 11 and .NET 7 added [LibraryImport], a source generator that writes the marshalling code at compile time, which allows trimming and native ahead-of-time compilation. The generator is part of the C# compiler pipeline; Visual Basic has no counterpart. The attribute exists in the base class library, so the code below compiles, but nothing completes it:

Option Strict On
Imports System.Runtime.InteropServices
Module Program
    <LibraryImport("libc", EntryPoint:="getpid")>
    Private Partial Function GetPid() As Integer
    End Function
    Sub Main()
    End Sub
End Module
Program.vb(5,30): error BC31437: 'GetPid' cannot be declared 'Partial' because partial methods must be Subs.

With a Sub the declaration compiles, because Visual Basic partial methods may lack an implementation, and the call is removed without a diagnostic:

Option Strict On
Imports System
Imports System.Runtime.InteropServices
Module Program
    <LibraryImport("libc", EntryPoint:="exit")>
    Private Partial Sub NativeExit(code As Integer)
    End Sub
    Sub Main()
        Console.WriteLine("before")
        NativeExit(3)
        Console.WriteLine("after: the call above was silently removed")
    End Sub
End Module
before
after: the call above was silently removed

The program printed both lines and never exited with code 3. For the same reason [UnmanagedCallersOnly] (a method that native code can call directly) is rejected:

Program.vb(4,6): error BC37316: 'UnmanagedCallersOnly' attribute is not supported.

The practical consequences: in Visual Basic you use Declare or DllImport with run-time marshalling, you pass callbacks as delegates, you call exports by address with Marshal.GetDelegateForFunctionPointer, and if you need a source-generated or function-pointer binding you put it in a small C# project of the same solution and reference it (see Interop with C# and Other .NET Languages).

Compiler and runtime messages

Each declaration-level problem was built in its own program, because one declaration error hides the others. Messages are as printed by the SDK:

Code Cause Message

BC31522

DllImport method with a body

'System.Runtime.InteropServices.DllImportAttribute' cannot be applied to a Sub, Function, or Operator with a non-empty body.

BC30244

Shared or Overrides on a Declare

'Shared' is not valid on a Declare. and 'Overrides' is not valid on a Declare.

BC30218

Declare without Lib

'Lib' expected.

BC30603

Declare in an interface

Statement cannot appear within an interface body.

BC30001

Declare in a namespace

Statement is not valid in a namespace.

BC30289

Declare inside a procedure

Statement cannot appear within a method body. End of method assumed. (together with BC30026 'End Sub' expected. and BC30429)

BC32075

Declare in a generic class

'Declare' statements are not allowed in generic types or types contained in generic types.

BC30210, BC30211

missing As clauses under Option Strict On

Option Strict On requires all Function, Property, and Operator declarations to have an 'As' clause. and Option Strict On requires that all method parameters have an 'As' clause.

BC30512

argument of the wrong type, for example Long to an Integer parameter

Option Strict On disallows implicit conversions from 'Long' to 'Integer'.

BC31437

LibraryImport on a Function

'GetPid' cannot be declared 'Partial' because partial methods must be Subs.

BC37316

UnmanagedCallersOnly

'UnmanagedCallersOnly' attribute is not supported.

BC42108

uninitialised structure passed ByRef

Variable 'info' is passed by reference before it has been assigned a value. followed by a sentence about initialising the structure

BC30183

a variable named lib

Keyword is not valid as an identifier.

CA1416

call to a platform-restricted declaration

This call site is reachable on all platforms. and the declaration is only supported on: 'windows'

The run-time exceptions seen on this page: MarshalDirectiveException (its message starts "Cannot marshal 'parameter #1': Invalid managed/unmanaged type combination"), DllNotFoundException, EntryPointNotFoundException and ObjectDisposedException (a disposed SafeHandle passed to a call).

Visual Basic versus C#

Topic Visual Basic C#

Declaration

Declare statement, or <DllImport> on a method with an empty body

[DllImport] on a static extern method

Default string handling

Declare: Ansi, with by-value strings turned into VBByRefStr; DllImport: None (like Ansi)

None (like Ansi)

SetLastError

always on for Declare

off unless set

Source-generated P/Invoke

not supported (BC31437, silent removal for a Sub)

[LibraryImport] generates the stub

Function pointers

none; delegates and Marshal.GetDelegateForFunctionPointer

delegate* unmanaged, &Method, [UnmanagedCallersOnly]

Pointers and stackalloc

none; IntPtr, Marshal, GCHandle

unsafe pointers, fixed, stackalloc

Output parameters

ByRef, with <Out> where it matters

out, ref

Fixed-size buffers in a struct

<MarshalAs(ByValTStr)> and ByValArray attributes

fixed buffers or the same attributes

SafeHandle as a return type

same rules

same rules

Native AOT export of managed methods

not available

[UnmanagedCallersOnly(EntryPoint = ...)]

Native Interop (C#) gives the C# column in detail, and Unsafe Code, Spans and Performance shows the pointer-based style that Visual Basic does not have.

Older material you may meet

  • Visual Basic 6.0 and early .NET code declares ByVal x As String with As Any, uses Long for a 32-bit value, and relies on GetLastError(). On .NET use Marshal.GetLastPInvokeError(); a direct call to GetLastError is not reliable because the runtime may call other native functions in between.

  • Books and blog posts about .NET Framework describe the Windows-only Declare behaviour, such as the lookup of A and W names and VBByRefStr, as if it were universal. On .NET 10 the same program must run on more than one operating system, and an explicit MarshalAs removes the dependency.

  • Parameter names in a Declare are documentation for Visual Basic callers only; the native library never sees them. abs calls its parameter j in the C library, and the declarations above name it value, which is correct. Named arguments in a call use the Visual Basic name.

  • Declare with Alias "#nn" selects a Windows export by ordinal; it has no meaning on Linux or macOS.

Pitfalls checklist

  • Never write a bare ByVal text As String in a Declare that has to run on Linux or macOS; add <MarshalAs(...)> or use <DllImport>.

  • Match integer widths to the target platform: long is 32 bits on Windows and 64 bits on Linux and macOS.

  • Use IntPtr and UIntPtr for handles, pointers and size_t.

  • Initialise structures with New or = Nothing before passing them ByRef, and size every StringBuilder.

  • Return Integer from an API that returns a C int, and wrap it in a SafeHandle yourself.

  • Free memory with the allocator that produced it.

  • Keep the delegate of a callback alive for as long as native code can call it, and never let an exception leave it.

  • Read the error code immediately after the call, and only when SetLastError is on.

  • Wrap every platform-specific declaration in OperatingSystem.Is...() guards and <SupportedOSPlatform>.

  • Prefer a managed API; prefer a small C# project for LibraryImport or function pointers.