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:
| Area | Example | Risk |
|---|
| Pointer code | int* | Memory safety |
| Native calls | DllImport | ABI mismatch |
| Unmanaged allocation | AllocHGlobal | Memory leaks |
| Buffer handling | Span<T> / pointers | Bounds |
| Function pointers | delegate* | Calling convention |
| Struct interop | StructLayout | Layout 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:
The pointer is used only while it is valid.
The referenced object remains pinned for the required duration.
Pointer arithmetic cannot move outside the allocated buffer.
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:
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:
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:
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:
P/Invoke declarations.
Structure layouts.
Calling conventions.
Pointer lifetimes.
Buffer sizes.
String encoding.
Architecture differences.
Native library versions.
Resource ownership.
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.