Pinpointing a 0xC0000005 at a P/Invoke Boundary: StructLayout Mismatch Signatures in a Minidump and the Native Frame That Names the Fault

A 0xC0000005 is an access violation. It tells you the CPU touched an address it was not allowed to touch. It does not tell you why. At a P/Invoke boundary the faulting instruction is almost always in native code, and the managed stack alone cannot prove a layout mismatch. Treat the crash as a layout-verification problem: read the native frame first, name the module and offset, then test the managed declaration against the native header. Do not start from the managed stack and assume the marshaller is wrong.

Why the managed stack is not enough

dotnet-dump is the right tool for SOS commands on Windows, Linux, and macOS, but it is not a native debugger. Microsoft’s documentation is explicit: it “allows you to run SOS commands to analyze crashes and the garbage collector (GC), but it isn’t a native debugger so things like displaying native stack frames aren’t supported” (dotnet-dump). The clrstack command “provides a stack trace of managed code only.” That is the boundary of what the managed view can tell you.

So the first move is to split the thread into two views. The managed view names the call site. The native view names the faulting module and offset. Neither alone is sufficient, and the correlation between them is the diagnosis.

Step 1: Read the faulting frame from the dump

On Windows, the exception record in the minidump carries the exception code (0xC0000005), the faulting address, and the instruction pointer. The native stack walk names the module and offset at that instruction pointer. On Linux, the signal (SIGSEGV) and the native frame do the same job. You need a native debugger for this step: WinDbg or cdb on Windows, lldb or gdb on Linux. dotnet-dump will not show you the native frame.

Write down three values before you interpret anything:

  • The faulting module and offset (for example, mylib.dll+0x1a3c).
  • The faulting address from the exception record.
  • The instruction pointer and the register state at the fault.

Those three values are evidence. Everything after this is interpretation, and you should be able to point back to them.

Step 2: Map the native frame back to the managed call site

Open the same dump in dotnet-dump analyze and run clrthreads to find the managed thread that matches the native thread ID, then clrstack on that thread. You are looking for the managed method that called the P/Invoke. The frame immediately below the transition is your candidate call site.

Now correlate. Does the faulting module match the library named in the DllImport or LibraryImport attribute on that method? If the native frame is in mylib.dll and the managed frame is your wrapper around mylib.dll, you have a real correlation. If the native frame is in a different module, the layout hypothesis is weaker and you should look at the call path before the P/Invoke.

Step 3: Compare the managed struct to the native header

This is where the layout mismatch either survives or dies. Microsoft’s interop guidance states the requirement plainly: “use StructLayoutAttribute to control the layout of managed structures so they match the unmanaged equivalents” (Marshalling Data with Platform Invoke). The comparison is field by field, not eyeball by eyeball.

Check each of these against the native header:

  • LayoutKind. LayoutKind.Sequential forces members into declaration order. For blittable types it controls both managed and unmanaged layout. For non-blittable types it controls the layout only when the struct is marshaled to unmanaged code, not the layout in managed memory (StructLayoutAttribute). If the native side uses explicit offsets or a union, LayoutKind.Explicit with FieldOffset is the only honest declaration.
  • Field order and size. Walk the native header top to bottom and match each field to a managed type of the same width. The type-mapping tables in the interop docs are the reference, not memory.
  • CharSet. The CharSet field “indicates whether string data fields within the class should be marshaled as LPWSTR or LPSTR by default.” A native char* field declared against a managed string with the wrong charset is a pointer-width and encoding mismatch in one line.
  • Pack. The Pack field “controls the alignment of data fields of a class or structure in memory.” If the native header uses #pragma pack(1) and the managed struct does not, every field after the first is at the wrong offset.
  • Size. The Size field “indicates the absolute size of the class or structure.” Use it when the native struct has trailing padding or a fixed buffer that the managed fields do not naturally produce.
  • Fixed buffers. ByValArray and ByValTStr change the size and layout of the struct. A missing SizeConst is a size mismatch, not a cosmetic warning.

Three marshaling traps account for a large share of these mismatches, and each one is documented:

  • bool. “By default, a .NET bool is marshalled to a Windows BOOL, where it’s a 4-byte value. However, the _Bool, and bool types in C and C++ are a single byte” (Native interoperability best practices). A one-byte native bool followed by an int shifts every subsequent field.
  • wchar_t. “The wchar_t type is UTF-16 (2 bytes) on Windows but is compiler-defined on other platforms—typically UTF-32 (4 bytes) on Linux and macOS” (Type marshalling). A struct that is correct on Windows can be wrong in a Linux container with the same source.
  • long. “When calling a C function that uses long, use CLong or CULong (.NET 6+) instead of C# long” (Type marshalling). C# long is 8 bytes; C long is 4 bytes on Windows and 8 on 64-bit Linux. The same declaration is wrong on one platform and right on the other.

Step 4: Distinguish a layout mismatch from the other causes

A layout mismatch is one branch of a differential diagnosis, not the default verdict. The same 0xC0000005 signature can come from a lifetime bug, a bad handle, or a calling-convention mismatch. Use the faulting address and the native frame to separate them.

Layout mismatch signatures. The fault reads or writes at an offset that the native struct expects but the managed struct does not provide. A string field that should be a pointer arrives as a corrupted value because the preceding field was the wrong width. A field that should be an int is read from padding. The faulting address is often near a valid object but past its end, and the native frame is inside the library that consumes the struct.

Lifetime and handle signatures. The faulting address is null or a small integer, or it points into freed memory. The native frame is in a library that dereferences a handle or pointer the managed side passed in. A pinned handle that was freed too early, or a SafeHandle that was disposed before the call completed, produces this pattern. The struct layout can be perfect and the crash still happens.

Calling-convention signatures. The fault appears on the first instruction of the native function or immediately after the return, and the stack pointer is misaligned. On x86, a stdcall function called as cdecl leaves the stack unbalanced; the crash often surfaces in the caller or the next call, not in the callee. The struct layout is irrelevant here. Check the CallingConvention on the declaration against the native export’s convention.

If the native frame is inside the library and the faulting address is a plausible field offset, the layout hypothesis is strong. If the faulting address is null or freed, test lifetime first. If the fault is at the call boundary itself, test the calling convention first.

Step 5: Verify with a controlled comparison

Do not fix by inspection. Build a minimal reproduction that prints the managed size and field offsets and the native size and field offsets, and compare them. The following is a hypothetical example of the practice, not a record of a real incident.

// Managed side
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct NativeRecord
{
    public int Id;          // offset 0, 4 bytes
    public bool Active;     // marshals as 4-byte BOOL by default
    public IntPtr Name;     // pointer-width
}

// Print managed layout
Console.WriteLine(Marshal.SizeOf<NativeRecord>());
Console.WriteLine(Marshal.OffsetOf<NativeRecord>(nameof(NativeRecord.Active)));
Console.WriteLine(Marshal.OffsetOf<NativeRecord>(nameof(NativeRecord.Name)));
// Native side
printf("%zu\n", sizeof(NativeRecord));
printf("%zu\n", offsetof(NativeRecord, active));
printf("%zu\n", offsetof(NativeRecord, name));

If the native header declares bool active; as a one-byte C++ bool, the managed offset for Name will be 12 on x64 while the native offset is 9 (or 16 after alignment, depending on the compiler). That difference is the crash. Fix the managed declaration to match the native one, or fix the native header to match the managed contract, and then re-run the comparison.

This comparison is cheap and it is the only step that turns a hypothesis into a fact. Do it before you change production code.

Step 6: Fix and prevent

The fix is to make the managed declaration match the native header exactly, field by field, including CharSet, Pack, and any fixed buffers. Then add an assertion so the mismatch cannot return silently.

Microsoft’s guidance recommends LibraryImport over DllImport when targeting .NET 7+: “DO use [LibraryImport], if possible, when targeting .NET 7+. There are cases when using [DllImport] is appropriate. A code analyzer with ID SYSLIB1054 tells you when that’s the case” (Native interoperability best practices). Source generation makes the marshaling contract explicit and analyzable, which reduces silent drift between the managed and native declarations.

The same guidance recommends blittable structs: “DO make your structures blittable when possible.” Blittable types “have the same bit-level representation in managed and native code” and do not need conversion. A blittable struct removes the marshaller from the equation, and the layout comparison becomes a direct memory comparison.

Add a test that computes Marshal.SizeOf and Marshal.OffsetOf for every field and compares them to the native header’s values. Run it on every platform you ship to, because wchar_t and long differ between Windows and Linux. A test that passes on Windows and is never run in the Linux container is not a test of the Linux container.

What the dump can and cannot prove

The dump proves where the CPU faulted. It proves the module and offset of the faulting instruction. It proves the faulting address. It does not prove why. The layout mismatch is a hypothesis that you test against the native header and a controlled size-and-offset comparison. If the comparison matches, the layout is not your bug, and you should move to lifetime, handles, or calling convention. If the comparison differs, you have found the fault and the fix is mechanical.

The native frame names the fault. The header comparison names the cause. Keep them separate until the evidence joins them.

FAQ

Can I find the native frame with dotnet-dump alone?

No. Microsoft’s documentation states that dotnet-dump is not a native debugger and does not support displaying native stack frames. Use WinDbg or cdb on Windows, or lldb or gdb on Linux, for the native frame, and use dotnet-dump for the managed call site.

Is a 0xC0000005 at a P/Invoke boundary always a StructLayout mismatch?

No. The same exception code can come from a lifetime bug, a bad handle, or a calling-convention mismatch. The layout hypothesis is one branch of a differential diagnosis. The faulting address and the native frame are what separate the branches.

Why does the same struct work on Windows and crash in a Linux container?

wchar_t is 2 bytes on Windows and typically 4 bytes on Linux and macOS, and C long is 4 bytes on Windows and 8 bytes on 64-bit Linux. A declaration that is correct on one platform can be wrong on the other. Run the size-and-offset comparison on every platform you ship to.

Should I switch to LibraryImport to fix this?

LibraryImport with source generation makes the marshaling contract explicit and analyzable, and Microsoft recommends it for .NET 7+ where possible. It does not fix a wrong struct declaration by itself. Fix the declaration first, then adopt LibraryImport and the SYSLIB1054 analyzer to keep it correct.