C#  

C# 15 Unsafe Model: Auditing Existing Native Interop Code for .NET 11

Introduction

Unsafe C# code is not something most application developers touch every day. It becomes important when managed .NET code needs to work directly with memory, native libraries, operating-system APIs, device drivers, or performance-sensitive native components.

That makes an unsafe-code change more important than an ordinary language-syntax change. A small change in pointer handling or native interop can affect memory safety, application stability, and security.

C# 15 introduces changes to the unsafe model that are relevant when existing native interop code is moved to newer .NET and C# versions. For teams maintaining older applications, this is a good reason to audit unsafe code rather than simply changing the language version and assuming everything is fine.

This article focuses on how to review existing unsafe and native interop code, identify common risk areas, and build a practical migration checklist.

What Is Unsafe Code in C#?

C# normally protects developers from direct memory manipulation.

Unsafe code allows operations such as:

  • Working with pointers

  • Taking the address of variables

  • Performing pointer arithmetic

  • Calling native APIs

  • Interacting with unmanaged memory

For example:

unsafe
{
    int value = 42;

    int* pointer = &value;

    Console.WriteLine(*pointer);
}

The unsafe keyword tells the compiler that the code contains operations that require explicit memory-safety assumptions.

To compile unsafe code, the project must enable it:

<PropertyGroup>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

This should be enabled deliberately rather than simply added to every project.

Why Native Interop Requires Extra Care

Consider a simple native API:

[DllImport("native-library")]
private static extern int GetValue();

The managed application is crossing a boundary:

Managed C#
    |
    v
P/Invoke
    |
    v
Native Code
    |
    v
Operating System / Native Library

The .NET runtime cannot fully protect you from mistakes inside that boundary.

Problems can arise from:

  • Incorrect parameter types

  • Incorrect calling conventions

  • Invalid pointers

  • Incorrect structure layouts

  • Buffer-size mistakes

  • Lifetime errors

  • Character encoding mismatches

  • Incorrect ownership assumptions

A migration is therefore a good opportunity to review these assumptions.

Understanding the C# 15 Unsafe Model

The C# language continues to evolve how unsafe operations interact with modern language features and compiler safety checks.

The important practical lesson for existing applications is that unsafe code should not be treated as "legacy code that can be ignored."

When upgrading a project, compile the complete unsafe surface and inspect compiler diagnostics rather than suppressing them immediately.

A simple project configuration might look like:

<PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <LangVersion>preview</LangVersion>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

Because C# 15 features are being introduced alongside the .NET 11 development cycle, teams evaluating preview functionality should verify the exact behavior against the SDK version they are using.

Start With an Unsafe-Code Inventory

The first step in an audit is finding where unsafe code actually exists.

Search the solution for:

unsafe
fixed
stackalloc
IntPtr
nint
nuint
Marshal
DllImport
LibraryImport
delegate*

Also look for APIs that interact with unmanaged memory.

For example:

Marshal.AllocHGlobal
Marshal.FreeHGlobal
Marshal.Copy

A useful inventory might look like this:

AreaExampleRisk
Pointer codeint*Memory safety
Native callsDllImportABI mismatch
Unmanaged allocationAllocHGlobalMemory leaks
Buffer handlingSpan<T> / pointersBounds
Function pointersdelegate*Calling convention
Struct interopStructLayoutLayout mismatch

The purpose is to understand the entire unsafe surface before changing the project.

Auditing Pointer Usage

Consider:

unsafe
{
    int[] values = [10, 20, 30];

    fixed (int* pointer = values)
    {
        for (int i = 0; i < values.Length; i++)
        {
            Console.WriteLine(pointer[i]);
        }
    }
}

The fixed statement prevents the garbage collector from moving the array while the native pointer is being used.

An audit should verify:

  1. The pointer is used only while it is valid.

  2. The referenced object remains pinned for the required duration.

  3. Pointer arithmetic cannot move outside the allocated buffer.

  4. The pointer does not escape its valid scope.

A dangerous pattern is keeping a pointer after the object it references is no longer safely pinned.

Auditing stackalloc

stackalloc allocates memory on the current stack.

For example:

Span<byte> buffer = stackalloc byte[256];

buffer.Clear();

This can be useful for small temporary buffers because the memory does not require a managed heap allocation.

But stack memory has limits.

Avoid using a user-controlled size directly:

Span<byte> buffer =
    stackalloc byte[userProvidedSize];

A large value can create stack-pressure problems.

A safer approach is to impose a reasonable upper limit and use pooled or heap memory when the requested size is larger.

Auditing Unmanaged Memory

Consider:

IntPtr memory =
    Marshal.AllocHGlobal(1024);

try
{
    // Use unmanaged memory.
}
finally
{
    Marshal.FreeHGlobal(memory);
}

The finally block is important because unmanaged memory is outside normal garbage-collection management.

Without proper cleanup:

Allocate
   |
   v
Exception
   |
   v
Cleanup skipped
   |
   v
Unmanaged memory leak

An audit should verify that every allocation has a clearly defined ownership and cleanup path.

Auditing P/Invoke Declarations

Native interop declarations deserve particular attention.

For example:

[DllImport("native-library",
    CallingConvention = CallingConvention.Cdecl)]
private static extern int Calculate(
    int value);

The managed declaration must match the native function's ABI.

Review:

  • Library name

  • Entry-point name

  • Calling convention

  • Parameter types

  • Return type

  • Character encoding

  • Structure layout

  • Pointer types

  • Ownership rules

A declaration that compiles successfully can still be incorrect.

Prefer Source-Generated Interop Where Appropriate

Modern .NET provides LibraryImport as a source-generated alternative to many traditional P/Invoke declarations.

For example:

[LibraryImport(
    "native-library",
    EntryPoint = "Calculate")]
private static partial int Calculate(int value);

This can provide a more modern interop model and can reduce some runtime marshalling work.

However, migration should be performed based on the actual native API contract.

Do not mechanically convert every DllImport declaration without verifying its parameters, strings, structures, and memory ownership.

Auditing Struct Layout

Native structures often require exact memory layouts.

For example:

[StructLayout(LayoutKind.Sequential)]
public struct NativePoint
{
    public int X;
    public int Y;
}

The audit should compare the C# structure with the native definition.

A mismatch in:

  • Field order

  • Field size

  • Alignment

  • Packing

  • Character representation

can result in corrupted data.

For explicitly packed structures:

[StructLayout(
    LayoutKind.Sequential,
    Pack = 1)]
public struct NativeHeader
{
    public byte Version;
    public int Length;
}

The Pack value should be based on the native ABI rather than chosen because it makes a test pass.

Function Pointers

Modern C# also supports unmanaged function pointers.

For example:

unsafe
{
    delegate* unmanaged<int, int> functionPointer;
}

Function pointers are powerful, but the calling convention is part of their type.

A mismatch between the managed declaration and the native function can cause serious runtime failures.

An audit should verify:

  • Parameter count

  • Parameter types

  • Return type

  • Calling convention

  • Lifetime of the function pointer

Do not treat function pointers like ordinary managed delegates.

Auditing Buffer Sizes

Buffer handling is one of the most important areas to review.

Consider:

unsafe
{
    byte* buffer = stackalloc byte[256];

    NativeRead(buffer, 256);
}

The native function must never write more than the allocated 256 bytes.

A better API boundary can make the buffer size explicit:

private static unsafe int ReadData(
    Span<byte> buffer)
{
    fixed (byte* pointer = buffer)
    {
        return NativeRead(pointer, buffer.Length);
    }
}

Now the managed caller owns the buffer size, and the native call receives the actual capacity.

The native implementation still needs to honor that contract.

Auditing String Interop

Strings are another common source of bugs.

Native APIs may expect:

  • UTF-8

  • UTF-16

  • ANSI

  • Null-terminated strings

  • Fixed-size character arrays

Do not assume that string automatically maps to the correct native representation.

For example:

[LibraryImport(
    "native-library",
    StringMarshalling = StringMarshalling.Utf8)]
private static partial int SendMessage(
    string message);

The correct marshalling strategy depends on the native API.

An audit should document the expected encoding instead of leaving it implicit.

Testing Native Interop

Unit tests are useful, but integration tests are particularly important for native boundaries.

A test should verify:

Managed input
     |
     v
Interop layer
     |
     v
Native library
     |
     v
Expected native result

Test both normal and failure conditions.

For example:

  • Null or empty input

  • Maximum supported buffer

  • Invalid input

  • Native error code

  • Unexpected native shutdown

  • Large data

  • Repeated calls

  • Concurrent calls

If the native library is platform-specific, run tests on every supported operating system and architecture.

Testing Memory Ownership

Every unmanaged resource should have a clear owner.

Ask:

  • Who allocated it?

  • Who frees it?

  • Can native code retain it?

  • Can managed code retain it?

  • How long is it valid?

  • What happens when an exception occurs?

For disposable native handles, a SafeHandle is often preferable to manually managing IntPtr lifetime.

For example:

public sealed class NativeHandle
    : SafeHandle
{
    public NativeHandle()
        : base(IntPtr.Zero, true)
    {
    }

    public override bool IsInvalid =>
        handle == IntPtr.Zero;

    protected override bool ReleaseHandle()
    {
        NativeFree(handle);
        return true;
    }

    [LibraryImport("native-library")]
    private static partial void NativeFree(
        IntPtr handle);
}

This lets the runtime's resource-management mechanisms participate in cleanup.

Common Mistakes

Suppressing Unsafe Compiler Warnings

A warning should be investigated before being suppressed.

The purpose of an audit is to understand why the code is unsafe, not simply to make the build green.

Assuming Existing Interop Is Correct

Old native declarations may have worked for years while still containing assumptions that are fragile on another architecture or runtime.

Ignoring 32-bit and 64-bit Differences

Native pointer sizes differ between architectures.

Use nint and nuint where pointer-sized values are actually required, and verify the native ABI.

Treating IntPtr as a Generic Data Type

IntPtr often represents a handle or address, but its meaning depends on the native API.

Document what the value represents and who owns it.

Forgetting Cleanup

Unmanaged resources are not automatically cleaned up just because a managed object becomes unreachable.

Troubleshooting

If an application crashes after upgrading the runtime, investigate the native boundary first when unsafe code is involved.

Useful areas include:

  1. P/Invoke declarations.

  2. Structure layouts.

  3. Calling conventions.

  4. Pointer lifetimes.

  5. Buffer sizes.

  6. String encoding.

  7. Architecture differences.

  8. Native library versions.

  9. Resource ownership.

  10. Exception and error-code handling.

Native crashes may appear as access violations or process termination rather than normal managed exceptions.

That is why native integration tests should run as part of the migration process.

Production Audit Checklist

Before shipping a runtime upgrade involving unsafe or native code, verify:

[ ] All unsafe blocks have been identified
[ ] All P/Invoke declarations have been reviewed
[ ] Native structures match managed layouts
[ ] Calling conventions are documented
[ ] Buffer sizes are validated
[ ] String encoding is explicit
[ ] Unmanaged resources have clear ownership
[ ] Native handles have safe cleanup
[ ] 32-bit and 64-bit builds are tested
[ ] All supported operating systems are tested
[ ] Native libraries are version-compatible
[ ] Integration tests cover failure scenarios
[ ] Compiler warnings have been reviewed

This checklist is more valuable than simply confirming that the project compiles.

Advantages

  • Finds hidden assumptions in legacy interop code.

  • Reduces the risk of memory-related failures during runtime upgrades.

  • Makes native API contracts clearer.

  • Encourages explicit ownership and cleanup.

  • Can identify opportunities to modernize older P/Invoke declarations.

  • Provides better confidence across architectures and operating systems.

Disadvantages

  • Native interop audits can require significant investigation.

  • Some problems only appear on specific platforms or architectures.

  • Unsafe code remains harder to reason about than ordinary managed code.

  • Native libraries can introduce dependencies outside the .NET runtime.

  • Migrating old interop code may require changes to both managed and native components.

Conclusion

Unsafe C# and native interop deserve special attention during a .NET and C# upgrade because the compiler cannot protect an application from every mistake across the managed-to-native boundary.

A good audit starts by identifying every unsafe operation, P/Invoke declaration, unmanaged allocation, function pointer, and native structure. From there, verify pointer lifetimes, buffer sizes, calling conventions, structure layouts, string encoding, and resource ownership.

Modern .NET provides tools such as source-generated interop and SafeHandle that can make some native boundaries easier to maintain, but modernization should be based on the actual native API contract rather than mechanical replacement.

Most importantly, compile and test the complete native surface on every supported architecture and operating system.

The objective of an unsafe-code audit is not to eliminate unsafe code at any cost. Native interop is sometimes necessary. The goal is to make every unsafe boundary explicit, controlled, tested, and understandable before moving the application to a newer C# and .NET runtime.