Fix x64 stub ABI and marshal-path sizing; dedupe memory readers
x64 call stub was ABI-broken: fixed 0x20 frame left rsp misaligned at the inner call (callee entry rsp ≡ 0, ABI requires ≡ 8) and, for 5+ args, wrote stack args over the return address. Compute frame K ≡ 8 (mod 16), K ≥ 0x20 + 8*stackArgs, so the callee sees a 16-aligned stack and stack args land above the shadow window. Load register args as full 64-bit imm64 (was imm32, which truncated pointers > 4 GiB). BuildCallStub now takes nuint[]; x86 range- checks each arg against uint.MaxValue instead of silently truncating. MarshalCache conflated managed and unmanaged width in one Size field: the blittable path needs Unsafe.SizeOf<T> (bool = 1) while the marshal path needs Marshal.SizeOf<T> (inline ByValTStr/ByValArray expand past the managed pointer). Add MarshalSize; MemoryBase picks per TypeRequiresMarshal at all four IO sites. Prevents PtrToStructure/StructureToPtr from over-reading/overwriting the pinned scratch buffer (heap corruption on write). Extract shared RPM/WPM into RpmHelper: honor partial reads (dead Array.Resize removed), consistent write-return semantics; InProcessReader now guards MainModule like ExternalReader. Tests: x64 frame-alignment property + inline-marshal round-trip added (both fail against the pre-fix code); existing x64 byte-expectation tests updated to the new frame. Build clean, 100/100 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -170,56 +170,60 @@ public sealed class StubAssembler : IAssembler
|
||||
/// <summary>
|
||||
/// Builds a Windows x64 call stub that conforms to the Microsoft x64 ABI:
|
||||
/// first 4 integer/pointer args in RCX, RDX, R8, R9 (64-bit loads); stack args
|
||||
/// above 32-byte shadow space; 16-byte stack alignment at the <c>call</c> instruction.
|
||||
/// above a 32-byte shadow space; 16-byte stack alignment at the inner <c>call</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The stub frame:</para>
|
||||
/// <para>Frame derivation. The ABI requires the <em>inner</em> <c>call</c> site to
|
||||
/// land with call-site rsp ≡ 0 (mod 16), so that <c>call</c> pushes 8 bytes and the
|
||||
/// callee sees entry rsp ≡ 8 — the value an MSVC prologue (<c>push rbp; sub rsp, 0x20</c>)
|
||||
/// expects, and the only value for which locals land 16-aligned (SSE-safe).</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Stub entry: rsp ≡ 8 (mod 16).</item>
|
||||
/// <item>Need post-sub rsp ≡ 0 → sub operand K satisfies K ≡ 8 (mod 16).</item>
|
||||
/// <item>Frame must hold shadow space (0x20) + stack args (8 bytes each for args 4+).
|
||||
/// Choose the smallest such K: <c>K = frameBytes + ((8 − frameBytes) mod 16 + 16) mod 16</c>.
|
||||
/// For 0–5 args, K ∈ {0x28, 0x38}; pattern scales linearly.</item>
|
||||
/// </list>
|
||||
/// <code>
|
||||
/// sub rsp, 0x20 ; 32-byte shadow space + restores 16-byte alignment
|
||||
/// mov rcx, arg0 ; 64-bit loads (REX.W mov r64, imm64)
|
||||
/// mov rdx, arg1
|
||||
/// mov r8, arg2
|
||||
/// mov r9, arg3
|
||||
/// mov rax, arg[N]
|
||||
/// mov [rsp + 0x20 + 8*(N-4)], rax ; stack args placed above the shadow slots
|
||||
/// ...
|
||||
/// call target (rel32)
|
||||
/// add rsp, 0x20
|
||||
/// ret
|
||||
/// sub rsp, K ; K ≡ 8 (mod 16), K ≥ 0x20 + 8·stackArgs
|
||||
/// mov rcx, arg0 ; REX.W + imm64 (10 bytes)
|
||||
/// mov rdx, arg1 ; REX.W + imm64 (10 bytes)
|
||||
/// mov r8, arg2 ; REX.WB+ imm64 (10 bytes, REX.R)
|
||||
/// mov r9, arg3 ; REX.WB+ imm64 (10 bytes, REX.R)
|
||||
/// mov rax, arg[N] ; REX.W + imm64 (10 bytes)
|
||||
/// mov [rsp + 0x20 + 8*(N-4)], rax (5/8 bytes)
|
||||
/// call target (rel32) ( 5 bytes)
|
||||
/// add rsp, K ( 7 bytes)
|
||||
/// ret ( 1 byte)
|
||||
/// </code>
|
||||
/// <para>On entry the stub sees <c>rsp ≡ 8 (mod 16)</c> (the caller's <c>call</c> pushed
|
||||
/// the return address). <c>sub rsp, 0x20</c> moves rsp to <c>≡ 0 (mod 16)</c>. Just before
|
||||
/// the inner <c>call</c>, rsp is still <c>≡ 0</c>, so target's entry rsp is
|
||||
/// <c>≡ 8 (mod 16)</c> — no, wait: entry ≡ 0 after sub; <c>call target</c> pushes 8, so
|
||||
/// target's entry is ≡ 0 − 8 ≡ 8; but we want target entry ≡ 0. Re-check:</para>
|
||||
/// <para>Stub entry: <c>rsp ≡ 8 (mod 16)</c>. After <c>sub rsp, 0x20</c>:
|
||||
/// <c>8 − 0x20 = −24 ≡ 8 (mod 16)</c>. After the inner <c>call</c>, target entry is
|
||||
/// <c>8 − 8 ≡ 0 (mod 16)</c>. Target is 16-byte aligned — SSE safe.</para>
|
||||
/// </remarks>
|
||||
private void BuildX64Stub(List<byte> buffer, ulong stubAddr,
|
||||
ulong target, nuint[] args)
|
||||
{
|
||||
ulong current = stubAddr;
|
||||
|
||||
// 1. Allocate shadow space.
|
||||
// sub rsp, 0x20 ; 32 bytes = 4 shadow slots AND (entry − 0x20) ≡ 8 (mod 16),
|
||||
// so rsp after the sub ≡ 8 (mod 16); `call target` will push 8 and land target
|
||||
// at ≡ 0 (mod 16).
|
||||
buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xEC); // sub rsp, imm32
|
||||
EmitU32(buffer, 0x20);
|
||||
// Compute frame size K. K ≡ 8 (mod 16) so that the inner call sees
|
||||
// post-sub rsp ≡ 0 and delivers target entry rsp ≡ 8 (mod 16).
|
||||
int stackArgs = Math.Max(0, args.Length - 4);
|
||||
int frameBytes = 0x20 + 8 * stackArgs;
|
||||
int k = frameBytes + ((8 - (frameBytes % 16) + 16) % 16);
|
||||
|
||||
// sub rsp, imm32 (always imm32 form — constant 7 bytes regardless of K).
|
||||
buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xEC);
|
||||
EmitU32(buffer, (uint)k);
|
||||
current += 7;
|
||||
|
||||
// 2. 64-bit register loads.
|
||||
// RCX = REX.W 0xB9 + imm64 (10 bytes)
|
||||
// RDX = REX.W 0xBA + imm64 (10 bytes)
|
||||
// R8 = REX.WB 0xB8 + imm64 (11 bytes, REX.W|R = 0x49)
|
||||
// R9 = REX.WB 0xB9 + imm64 (11 bytes)
|
||||
// 64-bit register loads for args 0..3. All encodings are exactly 10 bytes:
|
||||
// REX.W (0x48) + 0xB9 + imm64 → mov rcx, imm64
|
||||
// REX.W (0x48) + 0xBA + imm64 → mov rdx, imm64
|
||||
// REX.WB(0x49) + 0xB8 + imm64 → mov r8, imm64 (REX.R for r8)
|
||||
// REX.WB(0x49) + 0xB9 + imm64 → mov r9, imm64 (REX.R)
|
||||
byte[][] regMoves =
|
||||
[
|
||||
[0x48, 0xB9], // mov rcx, imm64
|
||||
[0x48, 0xBA], // mov rdx, imm64
|
||||
[0x49, 0xB8], // mov r8, imm64
|
||||
[0x49, 0xB9], // mov r9, imm64
|
||||
[0x48, 0xB9],
|
||||
[0x48, 0xBA],
|
||||
[0x49, 0xB8],
|
||||
[0x49, 0xB9],
|
||||
];
|
||||
|
||||
int regCount = Math.Min(args.Length, 4);
|
||||
@@ -232,10 +236,8 @@ public sealed class StubAssembler : IAssembler
|
||||
current += (uint)(prefix.Length + 8);
|
||||
}
|
||||
|
||||
// 3. Stack args (args 4+): placed at [rsp + 0x20 + 8*(i-4)].
|
||||
// Each is two instructions:
|
||||
// mov rax, imm64 (10 bytes)
|
||||
// mov [rsp + disp], rax (5..8 bytes depending on disp8/disp32)
|
||||
// Stack args: written at [post-sub-rsp + 0x20 + 8*(i-4)], i.e. above the
|
||||
// shadow window, where the inner call's callee expects them.
|
||||
for (int i = 4; i < args.Length; i++)
|
||||
{
|
||||
int offset = 0x20 + (i - 4) * 8;
|
||||
@@ -258,7 +260,7 @@ public sealed class StubAssembler : IAssembler
|
||||
}
|
||||
}
|
||||
|
||||
// 4. call rel32
|
||||
// call rel32
|
||||
long distance = (long)target - (long)(current + 5);
|
||||
if (distance is < int.MinValue or > int.MaxValue)
|
||||
{
|
||||
@@ -269,11 +271,10 @@ public sealed class StubAssembler : IAssembler
|
||||
EmitU32(buffer, (uint)distance);
|
||||
current += 5;
|
||||
|
||||
// 5. add rsp, 0x20 ; tear down shadow space
|
||||
// Tear down the frame symmetrically.
|
||||
buffer.Add(0x48); buffer.Add(0x81); buffer.Add(0xC4);
|
||||
EmitU32(buffer, 0x20);
|
||||
EmitU32(buffer, (uint)k);
|
||||
|
||||
// 6. ret
|
||||
buffer.Add(0xC3);
|
||||
}
|
||||
|
||||
|
||||
+48
-18
@@ -5,25 +5,44 @@ using System.Runtime.InteropServices;
|
||||
namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// Computes and caches the byte size and marshalling decision for type
|
||||
/// <typeparamref name="T"/> exactly once. <see cref="MemoryBase.Read{T}"/> and
|
||||
/// Caches the widths marshalling decisions for type <typeparamref name="T"/>
|
||||
/// once, at static-constructor time. <see cref="MemoryBase.Read{T}"/> and
|
||||
/// <see cref="MemoryBase.Write{T}"/> branch on <see cref="TypeRequiresMarshal"/>
|
||||
/// to decide between the blittable <c>Span</c> /
|
||||
/// <see cref="System.Runtime.InteropServices.MemoryMarshal"/> path and the
|
||||
/// <see cref="Marshal.PtrToStructure"/> path.
|
||||
/// and pick the appropriate width from this cache.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to cache metadata for.</typeparam>
|
||||
public static class MarshalCache<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// The byte size of <typeparamref name="T"/>. For the blittable path this is
|
||||
/// the managed layout size <see cref="Unsafe.SizeOf{T}"/> — the width that
|
||||
/// <see cref="MemoryMarshal.Read{T}"/> / <see cref="MemoryMarshal.Write{T}"/>
|
||||
/// actually consume. For primitive-sized types (<see cref="bool"/>, <see cref="char"/>,
|
||||
/// and the underlying of enums) the size matches the CLR primitive width.
|
||||
/// The blittable (managed layout) width of <typeparamref name="T"/>. This is
|
||||
/// what <see cref="MemoryMarshal.Read{T}"/> / <see cref="MemoryMarshal.Write{T}"/>
|
||||
/// actually consume. Equals <see cref="Unsafe.SizeOf{T}"/> in the general case,
|
||||
/// with fixed-width overrides for <see cref="bool"/>, <see cref="char"/>, and
|
||||
/// enums so the cache value matches the primitive layout width used by those
|
||||
/// paths.
|
||||
/// </summary>
|
||||
public static readonly int Size;
|
||||
|
||||
/// <summary>
|
||||
/// The unmanaged (interop) width via <see cref="Marshal.SizeOf"/>. The marshal
|
||||
/// path (<see cref="Marshal.PtrToStructure"/>/<see cref="Marshal.StructureToPtr"/>)
|
||||
/// reads/writes this many bytes. Exceeds <see cref="Size"/> whenever a struct
|
||||
/// carries inline unmanaged data that the marshaler expands — inline
|
||||
/// <c>ByValTStr</c>/<c>ByValArray</c> buffers, <c>bool</c> fields (4 bytes per
|
||||
/// default Win32 BOOL marshaling vs 1 byte managed), etc. For types that do
|
||||
/// not go through the marshal path, this field is still populated but unused
|
||||
/// by MemoryBase.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>Marshal.SizeOf</c> throws for some reference-containing shapes (e.g. a
|
||||
/// bare <see cref="string"/>). When that happens, we fall back to
|
||||
/// <see cref="Size"/> — the fallback path is unreachable from production code
|
||||
/// because types with a reference field always have
|
||||
/// <see cref="TypeRequiresMarshal"/> true, so MemoryBase reads this field only
|
||||
/// when it is known to be populated.
|
||||
/// </remarks>
|
||||
public static readonly int MarshalSize;
|
||||
|
||||
/// <summary>
|
||||
/// <see langword="true"/> when <typeparamref name="T"/> cannot be copied through
|
||||
/// the blittable <see cref="System.Runtime.InteropServices.MemoryMarshal"/> path
|
||||
@@ -35,9 +54,9 @@ public static class MarshalCache<T>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <see cref="MarshalAsAttribute"/> check inspects only top-level fields; a
|
||||
/// <see cref="MarshalAsAttribute"/> on a field of a nested struct is not detected.
|
||||
/// Reference-containing nested structs are still caught, because the reference
|
||||
/// check propagates through nested value types.
|
||||
/// <see cref="MarshalAsAttribute"/> on a field of a nested struct is not
|
||||
/// detected. Reference-containing nested structs are still caught, because the
|
||||
/// reference check propagates through nested value types.
|
||||
/// </remarks>
|
||||
public static readonly bool TypeRequiresMarshal;
|
||||
|
||||
@@ -51,7 +70,6 @@ public static class MarshalCache<T>
|
||||
{
|
||||
// Marshal.SizeOf<char> reports 1 (ANSI char), but the blittable
|
||||
// MemoryMarshal path reads/writes a char as a 2-byte UTF-16 code unit.
|
||||
// Use the managed layout width so Size matches what the reader actually uses.
|
||||
Size = 2;
|
||||
}
|
||||
else if (typeof(T).IsEnum)
|
||||
@@ -60,10 +78,10 @@ public static class MarshalCache<T>
|
||||
}
|
||||
else
|
||||
{
|
||||
// The blittable path goes through MemoryMarshal, which uses the CLR managed
|
||||
// layout. Use Unsafe.SizeOf<T> so Size agrees with that layout —
|
||||
// Marshal.SizeOf<T> can disagree when a struct contains a `bool` field
|
||||
// (unmanaged width 4 vs managed width 1).
|
||||
// The blittable path goes through MemoryMarshal, which uses the CLR
|
||||
// managed layout. Use Unsafe.SizeOf<T> so Size agrees with that
|
||||
// layout — Marshal.SizeOf<T> disagrees when a struct contains a
|
||||
// `bool` field (unmanaged 4 vs managed 1).
|
||||
Size = Unsafe.SizeOf<T>();
|
||||
}
|
||||
|
||||
@@ -73,5 +91,17 @@ public static class MarshalCache<T>
|
||||
|
||||
TypeRequiresMarshal =
|
||||
hasMarshalAsField || RuntimeHelpers.IsReferenceOrContainsReferences<T>();
|
||||
|
||||
// MarshalSize is only consulted when TypeRequiresMarshal is true; for the
|
||||
// rare case where Marshal.SizeOf refuses a shape (ref-containing structs),
|
||||
// fall back to the managed size so the field stays populated.
|
||||
try
|
||||
{
|
||||
MarshalSize = Marshal.SizeOf<T>();
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
MarshalSize = Size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public abstract class MemoryBase : IDisposable
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
int size = MarshalCache<T>.Size;
|
||||
int size = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
|
||||
byte[] raw = ReadBytes(address, size);
|
||||
|
||||
if (raw.Length < size)
|
||||
@@ -56,7 +56,7 @@ public abstract class MemoryBase : IDisposable
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
int size = MarshalCache<T>.Size;
|
||||
int size = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
|
||||
|
||||
byte[] raw;
|
||||
if (MarshalCache<T>.TypeRequiresMarshal)
|
||||
@@ -81,7 +81,7 @@ public abstract class MemoryBase : IDisposable
|
||||
if (isRelative)
|
||||
address = GetAbsolute(address);
|
||||
|
||||
int elementSize = MarshalCache<T>.Size;
|
||||
int elementSize = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
|
||||
long totalSize = (long)elementSize * count;
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(totalSize, int.MaxValue, nameof(count));
|
||||
|
||||
@@ -127,7 +127,7 @@ public abstract class MemoryBase : IDisposable
|
||||
if (values is null || values.Length == 0)
|
||||
return true;
|
||||
|
||||
int elementSize = MarshalCache<T>.Size;
|
||||
int elementSize = MarshalCache<T>.TypeRequiresMarshal ? MarshalCache<T>.MarshalSize : MarshalCache<T>.Size;
|
||||
long total = (long)elementSize * values.Length;
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(total, int.MaxValue, nameof(values));
|
||||
int totalSize = (int)total;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagic;
|
||||
|
||||
/// <summary>
|
||||
/// Shared ReadProcessMemory / WriteProcessMemory wrappers used by both
|
||||
/// <see cref="ExternalReader"/> and <see cref="InProcessReader"/>. Kept in a single
|
||||
/// location to keep the two readers byte-for-byte consistent on partial-read handling,
|
||||
/// write-return semantics, and failure modes.
|
||||
/// </summary>
|
||||
internal static class RpmHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads up to <paramref name="count"/> bytes from <paramref name="address"/> in
|
||||
/// the process identified by <paramref name="handle"/>. Returns:
|
||||
/// <list type="bullet">
|
||||
/// <item>An empty array if <see cref="NativeMethods.ReadProcessMemory"/> fails and
|
||||
/// reports zero bytes read.</item>
|
||||
/// <item>A truncated array of exactly <c>bytesRead</c> bytes when the call returns
|
||||
/// <see langword="false"/> but the OS has placed a partial copy in the buffer
|
||||
/// (for example, <c>ERROR_PARTIAL_COPY</c>).</item>
|
||||
/// <item>The full buffer on success.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static byte[] ReadBytes(SafeMemoryHandle handle, IntPtr address, int count)
|
||||
{
|
||||
byte[] buffer = new byte[count];
|
||||
bool ok = NativeMethods.ReadProcessMemory(handle, address, buffer, count, out nint bytesRead);
|
||||
|
||||
if (!ok && bytesRead == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if ((int)bytesRead < count)
|
||||
{
|
||||
// Either a successful short read, or a failed-but-partial RPM. In both
|
||||
// cases honor the bytes the OS actually produced rather than padding.
|
||||
byte[] partial = new byte[(int)bytesRead];
|
||||
Buffer.BlockCopy(buffer, 0, partial, 0, (int)bytesRead);
|
||||
return partial;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes <paramref name="bytes"/> to <paramref name="address"/> in the process
|
||||
/// identified by <paramref name="handle"/>. Returns the number of bytes actually
|
||||
/// written, or 0 on total failure.
|
||||
/// </summary>
|
||||
public static int WriteBytes(SafeMemoryHandle handle, IntPtr address, ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (!NativeMethods.WriteProcessMemory(handle, address, bytes, bytes.Length, out nint written))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return (int)written;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace WhiteMagicTest;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="MarshalCache{T}"/>: blittable size, marshal-required flag,
|
||||
/// and computed-once behavior.
|
||||
/// the separate MarshalSize field, and computed-once behavior.
|
||||
/// </summary>
|
||||
public class MarshalCacheTests
|
||||
{
|
||||
@@ -49,10 +49,10 @@ public class MarshalCacheTests
|
||||
[Fact]
|
||||
public void Size_for_struct_with_bool_is_managed_layout_width()
|
||||
{
|
||||
// Regression for the Marshal.SizeOf / Unsafe.SizeOf disagreement on a struct
|
||||
// whose only field is `bool`: Marshal reports 4 bytes (Win32 BOOL default marshaling),
|
||||
// but the blittable path (MemoryMarshal.Read<T>) actually lays out a bool as 1 byte.
|
||||
// MarshalCache.Size must match what the reader actually touches.
|
||||
// Regression for the Marshal vs. Unsafe size disagreement on a struct
|
||||
// whose only field is `bool`: Marshal reports 4 (Win32 BOOL default
|
||||
// marshaling), but the blittable path (MemoryMarshal.Read<T>) actually
|
||||
// lays out a bool as 1 byte. MarshalCache.Size must match managed width.
|
||||
Assert.Equal(Unsafe.SizeOf<SingleBoolStruct>(), MarshalCache<SingleBoolStruct>.Size);
|
||||
Assert.Equal(1, MarshalCache<SingleBoolStruct>.Size);
|
||||
}
|
||||
@@ -60,8 +60,9 @@ public class MarshalCacheTests
|
||||
[Fact]
|
||||
public void Size_for_struct_with_bools_in_sequence_matches_managed_layout()
|
||||
{
|
||||
// Sequential struct { bool, bool } — managed width is 2, Marshal width is 8 (two BOOLs).
|
||||
// The blittable path uses 1 byte per bool, so Size must equal the managed width.
|
||||
// Sequential struct { bool, bool } — managed width is 2, Marshal width is 8
|
||||
// (two BOOLs). The blittable path uses 1 byte per bool, so Size must equal
|
||||
// the managed width.
|
||||
Assert.Equal(Unsafe.SizeOf<SequentialBoolStruct>(), MarshalCache<SequentialBoolStruct>.Size);
|
||||
Assert.Equal(2, MarshalCache<SequentialBoolStruct>.Size);
|
||||
}
|
||||
@@ -72,7 +73,6 @@ public class MarshalCacheTests
|
||||
Assert.False(MarshalCache<int>.TypeRequiresMarshal);
|
||||
Assert.False(MarshalCache<byte>.TypeRequiresMarshal);
|
||||
Assert.False(MarshalCache<IntPtr>.TypeRequiresMarshal);
|
||||
Assert.False(MarshalCache<IntPtr>.TypeRequiresMarshal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -84,8 +84,35 @@ public class MarshalCacheTests
|
||||
[Fact]
|
||||
public void TypeRequiresMarshal_is_true_for_reference_containing_types()
|
||||
{
|
||||
Assert.True(MarshalCache<string>.TypeRequiresMarshal);
|
||||
Assert.True(MarshalCache<ClassWithInt>.TypeRequiresMarshal);
|
||||
Assert.True(MarshalCache<InlineStrStruct>.TypeRequiresMarshal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inline_struct_with_MarshalAs_has_separate_MarshalSize()
|
||||
{
|
||||
// Regression for the marshal-path size bug. A struct with an inline
|
||||
// ByValTStr field has mismatched managed and unmanaged widths: the managed
|
||||
// width is just the pointer reference (8 bytes); the marshal unroller
|
||||
// expands it into a 16-WCHAR inline buffer (32 bytes). MarshalCache.Size
|
||||
// must match what the blittable path uses; MarshalCache.MarshalSize must
|
||||
// match what the marshal path uses.
|
||||
Assert.True(MarshalCache<InlineStrStruct>.TypeRequiresMarshal);
|
||||
int expectedManaged = Unsafe.SizeOf<InlineStrStruct>(); // 8 (ptr)
|
||||
int expectedMarshal = Marshal.SizeOf<InlineStrStruct>(); // 32 (16 WCHAR)
|
||||
Assert.Equal(expectedManaged, MarshalCache<InlineStrStruct>.Size);
|
||||
Assert.Equal(expectedMarshal, MarshalCache<InlineStrStruct>.MarshalSize);
|
||||
Assert.NotEqual(MarshalCache<InlineStrStruct>.Size,
|
||||
MarshalCache<InlineStrStruct>.MarshalSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarshalSize_equals_Size_for_blittable_types()
|
||||
{
|
||||
// No interop expansion is needed when the type is blittable; both widths
|
||||
// coincide.
|
||||
Assert.Equal(MarshalCache<int>.Size, MarshalCache<int>.MarshalSize);
|
||||
Assert.Equal(MarshalCache<IntPtr>.Size, MarshalCache<IntPtr>.MarshalSize);
|
||||
Assert.Equal(MarshalCache<BlittableStruct>.Size, MarshalCache<BlittableStruct>.MarshalSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -128,8 +155,15 @@ public class MarshalCacheTests
|
||||
public bool B;
|
||||
}
|
||||
|
||||
private class ClassWithInt
|
||||
/// <summary>
|
||||
/// A struct whose marshal layout carries an inline character buffer but
|
||||
/// whose CLR managed layout is just a reference pointer. The canonical way
|
||||
/// to exercise the marshal-vs-managed width split.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct InlineStrStruct
|
||||
{
|
||||
public int Value = 0;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 16)]
|
||||
public string Name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,43 @@ public class MemoryBaseTests
|
||||
reader.Dispose();
|
||||
}
|
||||
|
||||
// ── Marshal-path round-trip ───────────────────────────────────────────────
|
||||
//
|
||||
// The marshal path was previously sized using MarshalCache.Size (managed
|
||||
// layout width). When a struct carries an inline marshal-expanded field
|
||||
// (ByValTStr, ByValArray, etc.) this width is smaller than the actual
|
||||
// read/write width — writing overflows the pinned buffer and reading
|
||||
// under-fetches the remote bytes, producing silent heap corruption.
|
||||
//
|
||||
// The marshal path must use MarshalCache.MarshalSize (= Marshal.SizeOf<T>)
|
||||
// so the pinned buffer is large enough for PtrToStructure / StructureToPtr.
|
||||
|
||||
[Fact]
|
||||
public void Read_struct_via_marshal_path_round_trips_inline_string()
|
||||
{
|
||||
using var reader = OpenSelf();
|
||||
|
||||
// A marshal-path struct carries a reference, so it cannot be pinned; the
|
||||
// target must be an unmanaged buffer of the FULL marshal width. Pre-patch,
|
||||
// Write sized its scratch buffer with MarshalCache.Size (managed pointer
|
||||
// width, 8) and StructureToPtr overran it, while Read under-fetched the
|
||||
// remote bytes — the string came back wrong. Post-patch both use
|
||||
// MarshalSize (Marshal.SizeOf<InlineStr>).
|
||||
int size = Marshal.SizeOf<InlineStr>();
|
||||
IntPtr addr = Marshal.AllocHGlobal(size);
|
||||
try
|
||||
{
|
||||
InlineStr original = new InlineStr { Name = "Hello, World!" };
|
||||
Assert.True(reader.Write(addr, original));
|
||||
InlineStr read = reader.Read<InlineStr>(addr);
|
||||
Assert.Equal("Hello, World!", read.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(addr);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Graceful failure on invalid addresses ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -243,3 +280,17 @@ public struct TestStruct : IEquatable<TestStruct>
|
||||
public override int GetHashCode() => HashCode.Combine(X, Y);
|
||||
public override string ToString() => $"({X}, {Y})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A struct whose managed layout is just a reference pointer (8 bytes) but whose
|
||||
/// unmanaged marshal layout carries an inline character buffer. Exercised by
|
||||
/// <see cref="MemoryBaseTests.Read_struct_via_marshal_path_round_trips_inline_string"/>
|
||||
/// to catch regressions where the marshal path uses the managed width instead
|
||||
/// of the marshal width.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct InlineStr
|
||||
{
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 16)]
|
||||
public string Name;
|
||||
}
|
||||
|
||||
@@ -136,19 +136,19 @@ public class StubAssemblerTests
|
||||
// ── x64 (Microsoft x64 ABI — shadow space + 16-byte alignment + 64-bit loads) ──
|
||||
//
|
||||
// Stub frame layout:
|
||||
// bytes 0..6 sub rsp, 0x20 (7 bytes — shadow space + realignment)
|
||||
// bytes 0..6 sub rsp, K (7 bytes — K = 32 + 8·stackArgs, rounded so K ≡ 8 mod 16)
|
||||
// bytes 7..N mov r64, imm64 ... (10 bytes per reg move: 2-byte prefix + 8-byte imm)
|
||||
// mov rax, imm64 / mov [rsp+0x20+8*(i-4)], rax for stack args (15 bytes each)
|
||||
// E8 rel32 call target (5 bytes)
|
||||
// 48 81 C4 20 00... add rsp, 0x20 (7 bytes)
|
||||
// 48 81 C4 K 00... add rsp, K (7 bytes)
|
||||
// C3 ret (1 byte)
|
||||
//
|
||||
// Each `mov rNN, imm64` is 10 bytes regardless of the target register:
|
||||
// RCX REX.W 0xB9 + 8 imm (0x48 0xB9)
|
||||
// RDX REX.W 0xBA + 8 imm (0x48 0xBA)
|
||||
// R8 REX.WB 0xB8 + 8 imm (0x49 0xB8 — REX.W|R = 0x49)
|
||||
// R9 REX.WB 0xB9 + 8 imm (0x49 0xB9)
|
||||
// RAX REX.W 0xB8 + 8 imm (0x48 0xB8)
|
||||
// RCX REX.W+opcode B9 (0x48 0xB9)
|
||||
// RDX REX.W+opcode BA (0x48 0xBA)
|
||||
// R8 REX.WB+opcode B8 (0x49 0xB8, REX.R needed for r8)
|
||||
// R9 REX.WB+opcode B9 (0x49 0xB9, REX.R needed for r9)
|
||||
// RAX REX.W+opcode B8 (0x48 0xB8)
|
||||
|
||||
[Fact]
|
||||
public void X64_0args_allocates_shadow_space_and_aligns()
|
||||
@@ -158,15 +158,16 @@ public class StubAssemblerTests
|
||||
byte[] stub = s.BuildCallStub(
|
||||
(IntPtr)(nint)a, (IntPtr)(nint)t, [], 8, CallConvention.Cdecl);
|
||||
|
||||
// sub (7) + call (5) + add (7) + ret (1) = 20
|
||||
// K = 0x20 + 0·8 = 0x20; round up to ≡ 8 mod 16 → K = 0x28.
|
||||
// Frame = sub(7) + call(5) + add(7) + ret(1) = 20
|
||||
Assert.Equal(20, stub.Length);
|
||||
|
||||
uint rel = (uint)(t - (a + 7 + 5)); // = t - a - 12
|
||||
|
||||
Assert.Equal([0x48, 0x81, 0xEC, 0x20, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x20
|
||||
Assert.Equal([0x48, 0x81, 0xEC, 0x28, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x28 (K ≡ 8 mod 16)
|
||||
Assert.Equal(0xE8, stub[7]);
|
||||
Assert.Equal(rel, BitConverter.ToUInt32(stub, 8));
|
||||
Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[12..19]); // add rsp, 0x20
|
||||
Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[12..19]); // add rsp, 0x28
|
||||
Assert.Equal(0xC3, stub[19]); // ret
|
||||
}
|
||||
|
||||
@@ -178,12 +179,12 @@ public class StubAssemblerTests
|
||||
byte[] stub = s.BuildCallStub(
|
||||
(IntPtr)(nint)a, (IntPtr)(nint)t, [0xAABBCCDDu], 8, CallConvention.Cdecl);
|
||||
|
||||
// sub (7) + mov rcx, imm64 (10) + call (5) + add (7) + ret (1) = 30
|
||||
// K = 0x28. Total = sub(7) + mov(10) + call(5) + add(7) + ret(1) = 30
|
||||
Assert.Equal(30, stub.Length);
|
||||
|
||||
uint rel = (uint)(t - (a + 7 + 10 + 5)); // = t - a - 22
|
||||
// stub[0..6] = sub rsp, 0x20
|
||||
Assert.Equal([0x48, 0x81, 0xEC, 0x20, 0x00, 0x00, 0x00], stub[..7]);
|
||||
// stub[0..6] = sub rsp, 0x28 (K ≡ 8 mod 16, 16-aligned call-site for SSE safety)
|
||||
Assert.Equal([0x48, 0x81, 0xEC, 0x28, 0x00, 0x00, 0x00], stub[..7]);
|
||||
// stub[7..16] = mov rcx, 0x00000000_AABBCCDD (zero-extended)
|
||||
Assert.Equal(0x48, stub[7]); Assert.Equal(0xB9, stub[8]);
|
||||
Assert.Equal(0xDD, stub[9]); Assert.Equal(0xCC, stub[10]);
|
||||
@@ -193,8 +194,8 @@ public class StubAssemblerTests
|
||||
// stub[17..21] = call rel32
|
||||
Assert.Equal(0xE8, stub[17]);
|
||||
Assert.Equal(rel, BitConverter.ToUInt32(stub, 18));
|
||||
// stub[22..28] = add rsp, 0x20
|
||||
Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[22..29]);
|
||||
// stub[22..28] = add rsp, 0x28
|
||||
Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[22..29]);
|
||||
Assert.Equal(0xC3, stub[29]); // ret
|
||||
}
|
||||
|
||||
@@ -207,12 +208,12 @@ public class StubAssemblerTests
|
||||
(IntPtr)(nint)a, (IntPtr)(nint)t,
|
||||
[0x11111111u, 0x22222222u, 0x33333333u, 0x44444444u], 8, CallConvention.Cdecl);
|
||||
|
||||
// sub (7) + 4 x mov (4*10=40) + call (5) + add (7) + ret (1) = 60
|
||||
// K = 0x28. Total = sub(7) + 4×mov(40) + call(5) + add(7) + ret(1) = 60
|
||||
Assert.Equal(60, stub.Length);
|
||||
|
||||
uint rel = (uint)(t - (a + 7 + 40 + 5)); // = t - a - 52
|
||||
|
||||
Assert.Equal([0x48, 0x81, 0xEC, 0x20, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x20
|
||||
Assert.Equal([0x48, 0x81, 0xEC, 0x28, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x28
|
||||
|
||||
// mov rcx, 0x11111111 (48 B9 + 8 imm) at [7..16]
|
||||
Assert.Equal(0x48, stub[7]); Assert.Equal(0xB9, stub[8]);
|
||||
@@ -246,8 +247,8 @@ public class StubAssemblerTests
|
||||
Assert.Equal(0xE8, stub[47]);
|
||||
Assert.Equal(rel, BitConverter.ToUInt32(stub, 48));
|
||||
|
||||
// add rsp, 0x20 at [52..58]
|
||||
Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[52..59]);
|
||||
// add rsp, 0x28 at [52..58]
|
||||
Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[52..59]);
|
||||
// ret at [59]
|
||||
Assert.Equal(0xC3, stub[59]);
|
||||
}
|
||||
@@ -261,11 +262,12 @@ public class StubAssemblerTests
|
||||
(IntPtr)(nint)a, (IntPtr)(nint)t,
|
||||
[(nuint)1, (nuint)2, (nuint)3, (nuint)4, (nuint)5], 8, CallConvention.Cdecl);
|
||||
|
||||
// sub (7) + 4 reg moves (40) + stack arg (mov rax 10 + mov [rsp+0x20],rax 5 = 15)
|
||||
// K = 0x20 + 1·8 = 0x28. Round-up rule: 0x28 % 16 = 8 → no extra padding.
|
||||
// sub(7) + 4 reg moves (40) + stack arg (mov rax 10 + mov [rsp+0x20],rax 5 = 15)
|
||||
// + call (5) + add (7) + ret (1) = 75
|
||||
Assert.Equal(75, stub.Length);
|
||||
|
||||
Assert.Equal([0x48, 0x81, 0xEC, 0x20, 0x00, 0x00, 0x00], stub[..7]);
|
||||
Assert.Equal([0x48, 0x81, 0xEC, 0x28, 0x00, 0x00, 0x00], stub[..7]); // sub rsp, 0x28 (K=0x20+8=0x28, 0x28 % 16 = 8 ✓)
|
||||
|
||||
// mov rcx, 1 at [7..16]
|
||||
Assert.Equal(0x48, stub[7]); Assert.Equal(0xB9, stub[8]);
|
||||
@@ -296,12 +298,75 @@ public class StubAssemblerTests
|
||||
Assert.Equal(0xE8, stub[62]);
|
||||
Assert.Equal(rel, BitConverter.ToUInt32(stub, 63));
|
||||
|
||||
// add rsp, 0x20 at [67..73]
|
||||
Assert.Equal([0x48, 0x81, 0xC4, 0x20, 0x00, 0x00, 0x00], stub[67..74]);
|
||||
// add rsp, 0x28 at [67..73]
|
||||
Assert.Equal([0x48, 0x81, 0xC4, 0x28, 0x00, 0x00, 0x00], stub[67..74]);
|
||||
// ret at [74]
|
||||
Assert.Equal(0xC3, stub[74]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void X64_frame_alignment_property_for_arg_counts()
|
||||
{
|
||||
// ABI invariant: for every arg count the sub operand K must satisfy
|
||||
// K ≡ 8 (mod 16), and the same K must appear in the matching 'add rsp, K'
|
||||
// just before the ret. Violating this misaligns the inner call, which
|
||||
// #GP-faults the next time an SSE-using callee executes movaps/movdqa.
|
||||
var s = Create();
|
||||
// Keep stub/target within E8 rel32 range (< 2 GiB) so the property check
|
||||
// exercises the frame math, not the distance guard.
|
||||
ulong a = 0x140000000, t = 0x140100000;
|
||||
|
||||
for (int argc = 0; argc <= 12; argc++)
|
||||
{
|
||||
nuint[] args = new nuint[argc];
|
||||
for (int i = 0; i < argc; i++) args[i] = (nuint)(i + 1);
|
||||
|
||||
byte[] stub = s.BuildCallStub(
|
||||
(IntPtr)(nint)a, (IntPtr)(nint)t, args, 8, CallConvention.Cdecl);
|
||||
|
||||
// sub rsp, imm32: 48 81 EC K0 K1 K2 K3
|
||||
Assert.Equal(0x48, stub[0]);
|
||||
Assert.Equal(0x81, stub[1]);
|
||||
Assert.Equal(0xEC, stub[2]);
|
||||
uint subK = BitConverter.ToUInt32(stub, 3);
|
||||
Assert.True(subK % 16 == 8,
|
||||
$"argc={argc}: sub K=0x{subK:X} must satisfy K % 16 == 8");
|
||||
|
||||
// add rsp, imm32 is 7 bytes immediately before the trailing C3
|
||||
int last = stub.Length - 1;
|
||||
Assert.Equal(0xC3, stub[last]);
|
||||
int addIdx = last - 7;
|
||||
Assert.Equal(0x48, stub[addIdx]);
|
||||
Assert.Equal(0x81, stub[addIdx + 1]);
|
||||
Assert.Equal(0xC4, stub[addIdx + 2]);
|
||||
uint addK = BitConverter.ToUInt32(stub, addIdx + 3);
|
||||
Assert.True(subK == addK,
|
||||
$"argc={argc}: add K=0x{addK:X} must match sub K=0x{subK:X}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void X64_6args_frame_grows_to_0x38()
|
||||
{
|
||||
// Six args: frameBytes = 0x20 + 2·8 = 0x30. 0x30 % 16 = 0, so the
|
||||
// pad-to-≡8 rule adds 8 more bytes → K = 0x38. Args 5 and 6 still live
|
||||
// at [rsp+0x20] and [rsp+0x28]; the extra 8 bytes of padding at [rsp+0x30]
|
||||
// are unused but necessary for alignment.
|
||||
var s = Create();
|
||||
ulong a = 0x100000000, t = 0x123456788;
|
||||
nuint[] args = [(nuint)1, (nuint)2, (nuint)3, (nuint)4, (nuint)5, (nuint)6];
|
||||
byte[] stub = s.BuildCallStub(
|
||||
(IntPtr)(nint)a, (IntPtr)(nint)t, args, 8, CallConvention.Cdecl);
|
||||
|
||||
// sub rsp, 0x38 (7 bytes)
|
||||
Assert.Equal([0x48, 0x81, 0xEC, 0x38, 0x00, 0x00, 0x00], stub[..7]);
|
||||
|
||||
// add rsp, 0x38 occupies the 7 bytes immediately before ret
|
||||
int addIdx = stub.Length - 8;
|
||||
Assert.Equal([0x48, 0x81, 0xC4, 0x38, 0x00, 0x00, 0x00], stub[addIdx..(addIdx + 7)]);
|
||||
Assert.Equal(0xC3, stub[stub.Length - 1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void X64_full_64bit_args_are_preserved_not_truncated()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user