C# Unsafe Code — The Stride Alignment Bug
A 3-pixel color stripe from unsafe pointer arithmetic ignoring bitmap stride.
20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- Unsafe code in C# bypasses CLR memory safety for direct pointer access
- fixed blocks pin managed objects so GC won't move them during pointer operations
- stackalloc allocates on the thread stack with zero GC pressure
- Pointer arithmetic follows C rules: incrementing int* moves 4 bytes
- Raw pointer loops can be 1.5–3× faster than safe array access, but Span
often gets within 5% - Pinning objects too long fragments the GC heap — use native memory for long-lived buffers
- Returning a pointer from a fixed block is invalid — pointers are only valid inside the block
Imagine the .NET runtime is a responsible hotel manager who handles every guest's room key for them — you never touch the key directly, and the manager makes sure no one gets into the wrong room. Unsafe code is like convincing the manager to hand you the actual master key and step aside. You can now open any door instantly, without asking permission — but if you walk into the wrong room, nobody's stopping you. That raw, direct access is exactly what C# unsafe code gives you: maximum speed, maximum responsibility.
Most C# developers spend their careers happily inside the managed sandbox the CLR provides. The garbage collector moves memory around, the runtime validates every array index, and type safety prevents you from accidentally treating an integer as a pointer. That safety net is wonderful — until it becomes a bottleneck. Game engines rendering at 120 fps, image-processing pipelines crunching gigabyte bitmaps, financial systems doing microsecond-latency calculations, and high-performance network stacks all hit a wall where the cost of managed abstractions is simply too high.
Unsafe code exists to break through that wall. It lets you drop a pointer directly onto a block of memory and manipulate bytes at the hardware level — no bounds checking, no GC pressure, no abstraction overhead. The keyword unsafe is C#'s explicit contract: 'I know what I'm doing; runtime, step aside.' It unlocks fixed blocks to pin objects in memory, stackalloc to allocate directly on the stack, pointer arithmetic, and direct struct-to-pointer casting — the same tools C and C++ developers use every day.
By the end of this article you'll understand exactly how the CLR's memory model interacts with unsafe code, how to write and compile pointer-based C# that's both fast and correct, when unsafe code is the right tool versus a premature optimisation, and the production-level mistakes that cause silent data corruption. We'll go from the mechanics of pinning memory to real benchmark scenarios, and finish with the interview questions that actually get asked when companies hire for performance-critical .NET work.
What Unsafe Code Actually Does in C#
Unsafe code in C# allows direct memory manipulation via pointers, bypassing the managed runtime's safety guarantees. You declare a block or method as unsafe, then use pointer types (e.g., int) and operators like &, , and ->. This gives you raw access to memory addresses, enabling operations impossible in safe code, such as pointer arithmetic or casting between unrelated types.
When you enter an unsafe context, the compiler emits IL that can read/write arbitrary memory. The runtime still runs under the CLR, but the JIT trusts your pointer operations. No bounds checking, no null checks, no type safety. This means a single off-by-one pointer increment can corrupt adjacent heap objects, leading to memory corruption, access violations, or silent data corruption.
Use unsafe code only when performance demands it—like interop with native libraries, high-performance image processing, or custom memory allocators. In production systems, the trade-off is raw speed versus safety: a bug in safe code throws an exception; a bug in unsafe code corrupts memory, often manifesting hours later as a crash or wrong result. Profile first; unsafe is rarely the bottleneck.
How the CLR Memory Model Makes Unsafe Code Necessary
The CLR manages memory through a generational garbage collector. Objects live on the managed heap, and the GC is free to compact that heap at any time — physically moving objects to different addresses to reduce fragmentation. This compaction is invisible to managed code because every object reference is a tracked handle, not a raw address. The runtime updates all references automatically during a collection.
Now suppose you want to pass a pointer to a managed byte array into a native library, or walk bytes in a pixel buffer with pointer arithmetic. The moment you take a raw address of a managed object, you have a problem: the GC might move that object mid-operation, leaving your pointer dangling — pointing at whatever now occupies that old address. That's not a crash you'll reproduce reliably; it's silent corruption.
Unsafe code solves this with two mechanisms. First, the fixed statement tells the GC: 'Don't move this object while I'm inside this block — pin it.' Second, stackalloc allocates memory directly on the current thread's stack, which the GC never touches at all. Both approaches give you stable addresses. The trade-off is that pinned heap objects can fragment the heap over time, and stack memory is tiny (typically 1 MB per thread). Knowing which tool to reach for is the first skill you need.
GCHandle.Alloc(buffer, GCHandleType.Pinned) or use MemoryMarshal with NativeMemory.Alloc so the buffer lives outside the managed heap entirely.GC.GetGCMemoryInfo().Pointer Arithmetic, Structs and Reinterpreting Memory
Once you have a raw pointer, you're working at the same level as C. Pointer arithmetic in C# follows the same rules: incrementing a byte moves one byte forward, incrementing an int moves four bytes forward. The compiler scales arithmetic by sizeof(T) automatically. This makes walking a pixel buffer — where RGBA channels are laid out sequentially in memory — dramatically faster than indexed array access, because there's zero bounds-check overhead and the CPU's prefetcher can steam ahead without interruption.
The really powerful — and dangerous — feature is reinterpreting memory. If you have a byte pointing at a network packet, you can cast it to a custom struct and read fields directly from the wire bytes with zero copying. This is exactly how low-latency financial systems parse market data feeds. The struct must be unmanaged (no reference-type fields) and ideally decorated with [StructLayout(LayoutKind.Sequential, Pack = 1)] to prevent the runtime from inserting padding bytes that would misalign your fields with the actual wire format.
The Unsafe static class in System.Runtime.CompilerServices is the modern, partially-managed way to do the same thing — methods like Unsafe.As and Unsafe.Read perform zero-copy reinterpretation without requiring a full unsafe context in every caller. Understanding both the raw pointer approach and the Unsafe class API makes you dangerous in a good way.
Production Gotchas: Fixed Blocks, Async Code and Security
Unsafe code and async/await do not mix. You cannot use a fixed statement across an await point. The compiler enforces this — you'll get CS4013: 'Object of type cannot be used in an async method.' The reason is that after an await, the continuation might run on a different thread, and the pinned GC handle is tied to the original thread's GC root tracking. More fundamentally, the CLR cannot guarantee the pin is maintained across the scheduling boundary.
The correct pattern is to do all your pointer work inside a synchronous helper method called from your async code, or to use GCHandle.Alloc with GCHandleType.Pinned for cases where you genuinely need the pin to outlive a single synchronous call. The GCHandle must be freed in a finally block — a leaked pinned handle is a permanent heap fragment until the process dies.
From a security angle, unsafe code can bypass .NET's type safety entirely — you can read memory outside your own allocations if you get arithmetic wrong. In high-trust desktop applications that's usually just a crash. In server applications running untrusted input, a pointer overrun is a potential security vulnerability. Always validate lengths before entering an unsafe block, treat every pointer offset as an assertion that needs proving, and audit unsafe code paths differently from managed code — they need the same scrutiny you'd give C code.
Debugging Unsafe Code: Tools and Techniques
When unsafe code goes wrong, the runtime often gives you an AccessViolationException (AV) or silent data corruption. Unlike managed exceptions, AVs from native code can crash the entire process, and the stack trace may not point to the exact line. The first step is to enable native debugging. In .NET, you can use dotnet run --native-debug or set COMPlus_EnableLinuxDump=1 on Linux. For deep inspection, SOS (Son of Strike) extension with WinDbg or dotnet-dump allows you to examine managed heap and pinning status.
- Dereferencing a pointer after the fixed block ended (the GC compacted the object).
- Arithmetic overflow in pointer increment causing access outside the buffer.
- Misalignment on ARM processors when casting byte to int.
- Leaked GCHandle causing heap fragmentation.
Use G to detect fragmentation from long-lived pins. Use C.GetGCMemoryInfo().FragmentedBytesMemoryMarshal.GetArrayDataReference to get a ref without pinning where possible.
For cross-platform diagnostics, dotnet-counters and dotnet-trace can monitor GC events and JIT statistics. Validate all pointer arithmetic by adding defensive range checks in Debug builds using #if DEBUG blocks.
Unsafe Interop: When P/Invoke Won't Cut It
You've got a native DLL spewing raw byte buffers. Marshalling allocates a managed array, copies data, and jams the GC every call. For high-frequency interop—think audio pipelines, network packet capture, or camera SDKs—the overhead kills throughput. Unsafe lets you pin a pointer to a pooled buffer and hand that pointer directly to the native function. Zero copy. Zero allocation.
The pattern: allocate a fixed-size byte array on the GC heap, pin it with fixed, pass the pointer. When the native side writes, you read from the same memory without marshalling overhead. You must ensure the pinned object survives for the call duration—otherwise you're reading freed memory. Wrap it in a struct that implements IDisposable, release the pin in Dispose. This isn't clever, it's necessary for real-time systems where marshalling overhead shows up as dropped frames or missed interrupts.
The Stack-Only Struct Trick: Avoiding GC Pressure Entirely
Every 'new byte[1024]' inside a hot loop forces a GC allocation. You can't avoid it with managed arrays—they're always heap objects. But a struct containing a fixed-size buffer lives on the stack when declared locally. The GC never sees it. This is your secret weapon for allocation-free hot paths.
Declare a struct with the 'fixed' keyword to embed a raw buffer inline. No heap, no pinning, no collection. This is how high-perf C# libraries like System.IO.Pipelines and SignalR’s internal packet parser work. The struct itself is value-typed—pass it by reference if it's large. The buffer exists for the method's lifetime, then evaporates. Perfect for temporary packet headers, hash computations, or encryption primitives where allocating a class would trash generation-0 collections.
One killer use case: reading socket chunks into a stack buffer, wrapping them in a ReadOnlySpan<byte> for processing. The span zero-allocates into your stack buffer. The entire receive loop runs without touching the heap until you commit the data elsewhere. Your latency jitter disappears.
SkipLocalsInit for Stack-Allocated Types
When working with stack-allocated memory in C#, the runtime typically initializes all local variables to their default values. This zero-initialization ensures type safety but incurs a performance cost, especially in high-performance scenarios where you plan to overwrite every byte anyway. The SkipLocalsInit attribute, introduced in .NET 5, instructs the JIT compiler to skip this initialization for a method, leaving stack memory in its previous state. This can yield significant speedups in tight loops or when using stackalloc with Span. However, it comes with risks: uninitialized memory may contain sensitive data from previous stack frames, leading to information leaks. Always pair SkipLocalsInit with explicit initialization of all memory you read. The attribute is applied at the method level using [SkipLocalsInit]. It's particularly useful in unsafe code contexts where you manage memory manually, such as when implementing custom serialization or cryptographic algorithms. Note that SkipLocalsInit only affects locals and stackalloc; heap allocations are unaffected. Combine it with unsafe blocks for maximum control. In production, use it sparingly and only after profiling confirms a bottleneck. A common pattern is to mark a private helper method with SkipLocalsInit and ensure all stackalloc buffers are fully written before reading.
MemoryMarshal to safely reinterpret uninitialized spans.FunctionPointers and UnmanagedCallersOnly for Native Interop
Traditional P/Invoke relies on metadata and marshaling, which adds overhead and limits scenarios like callbacks from native code. C# 9 introduced function pointers (delegate) and the UnmanagedCallersOnly attribute to enable low-level, high-performance interop. Function pointers allow you to represent native function addresses as first-class types, usable in unsafe contexts. They are blittable and can be passed directly to native functions without delegate allocation. UnmanagedCallersOnly marks a method as callable only from unmanaged code, allowing the JIT to skip marshaling and security checks. This is ideal for implementing native callbacks, such as in Win32 EnumWindows or custom allocators. To use, declare a function pointer type: delegate unmanaged. Methods marked with [UnmanagedCallersOnly] must be static and have only blittable parameters. They cannot be called from managed code directly. Combine with fixed or GCHandle to pass managed state. In production, this pattern reduces GC pressure and improves latency in interop-heavy applications like game engines or real-time systems. However, misuse can cause crashes or security holes—validate all inputs from native code. Always test with native debuggers. This approach is more performant than P/Invoke but requires careful memory management.
CallConvs to match calling conventions. For state passing, use GCHandle or ThreadStatic fields.Inline Arrays for Stack-Allocated Collections (C# 12+)
C# 12 introduced inline arrays, a language feature that allows declaring fixed-size arrays directly on the stack without heap allocation. Unlike traditional arrays, inline arrays are value types and are embedded directly in the containing struct or stack frame. They are declared using the [InlineArray] attribute on a struct with a single field. The compiler generates indexer accessors and ensures the array is stack-allocated when used as a local. This is a game-changer for high-performance code that needs small, fixed-size buffers without GC overhead. For example, a 3D math library can store a 4x4 matrix inline. Inline arrays support slicing via Span and ReadOnlySpan, enabling safe interop with spans. They are ideal for interop scenarios where native code expects a contiguous buffer. However, inline arrays are limited to small sizes (typically under 1KB) due to stack space constraints. They cannot be resized. Use them for fixed-size collections like transformation matrices, lookup tables, or small caches. In production, inline arrays reduce GC pressure and improve cache locality. They are particularly useful in game development, real-time audio, and embedded systems. Combine with unsafe code for pointer access when needed. Note that inline arrays are not CLS-compliant, but that's rarely an issue in performance-critical code.
Silent Image Corruption from Off-by-One Pointer Write
- Pointer arithmetic based on assumed layout is fragile; always use official stride/offset values.
- Test with non-power-of-two widths to catch alignment bugs.
- Wrap unsafe code in safe API that validates lengths at entry.
GC.GetGCMemoryInfo().FragmentedBytes. Switch to NativeMemory.Alloc for long-lived buffers.dotnet run --native-debugCheck bounds: print pointer range using &pinnedObject[0] and &pinnedObject[length]| File | Command / Code | Purpose |
|---|---|---|
| MemoryPinningDemo.cs | using System; | How the CLR Memory Model Makes Unsafe Code Necessary |
| PointerArithmeticAndReinterpret.cs | using System; | Pointer Arithmetic, Structs and Reinterpreting Memory |
| ProductionSafeUnsafePatterns.cs | using System; | Production Gotchas |
| UnsafeRangeValidation.cs | using System; | Debugging Unsafe Code |
| ZeroCopyInterop.cs | using System.Runtime.InteropServices; | Unsafe Interop |
| StackBufferStruct.cs | using System; | The Stack-Only Struct Trick |
| SkipLocalsInitExample.cs | using System; | SkipLocalsInit for Stack-Allocated Types |
| FunctionPointerExample.cs | using System; | FunctionPointers and UnmanagedCallersOnly for Native Interop |
| InlineArrayExample.cs | using System; | Inline Arrays for Stack-Allocated Collections (C# 12+) |
Key takeaways
Span<T> cannot solve.fixed statement pins managed objects to prevent GC relocation during pointer operations, but overuse fragments the heap; prefer stackalloc for small temporary buffers.sizeof(T) automatically; a byte increments by 1 byte, an int by 4 bytes!VerifyHeap, !DumpObj) rather than relying on exceptions, which often appear far from the root cause.Interview Questions on This Topic
Explain the difference between `fixed` and `stackalloc` in C# unsafe code. When would you use each?
fixed pins a managed object so the GC won't move it, returning a pointer to its data. Use it when you need a pointer to an existing managed array or string. stackalloc allocates a new buffer on the call stack, which the GC never touches, so no pinning is needed. Use it for small temporary buffers (e.g., 256 bytes) that don't need to outlive the method call. stackalloc is faster and avoids heap fragmentation but is limited by stack size.Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.
That's C# Advanced. Mark it forged?
8 min read · try the examples if you haven't