← Back to list

Basic Reverse Engineering Vol. 3: Struct Local Variable

The C abstract machine has no stack frames, no byte offsets, no sign-extension instructions. Those details belong entirely to the…

Yen Wang in RE: Exploit · 2026-03-11 21:53 · 1 claps · 22.4 min read
#ost2 #reverse-engineering #reverse-eningeer-c #c-struct
Open on Medium ↗
Wiki topics: TLS · Design Tools & Workflow

Basic Reverse Engineering Vol. 3: Struct Local Variable

The C abstract machine has no stack frames, no byte offsets, no sign-extension instructions. Those details belong entirely to the implementation.

This post peels back the abstraction completely, taking a single packed struct through a full debug-mode MSVC trace, every instruction, every register mutation, every byte written to the stack. Then explaining why the compiler made each decision it did. The focus is on the three most misread patterns in struct/array assembly: the imul-based index scaling, the movsx/movzx type-boundary crossings, and the layered padding arithmetic that governs where each field lands.

Table of Contents

  • The Program Under the Microscope
  • Part 1: Frame Construction and ABI Alignment Arithmetic
  • Part 2: Struct Field Placement — #pragma pack(1) vs Stack Alignment
  • Part 3: Index Scaling — What imul Is Actually Encoding
  • Part 4: Implicit Widening — movsx, movzx, and movsxd at Type Boundaries
  • Part 5: The Full Instruction Trace with Register State
  • Part 6: The Final Stack Diagram — Every Byte Accounted For
  • Part 7: The Two Truncations — Precision Loss Without a Warning
  • Part 8: Extented Thought: movsx ecx, word ptr [rsp]
  • Part 9: What does #pragma pack(1) do?
  • Part 10: Uncomment #pragma pack(1)

Relevant posts

The Program Under the Microscope

//#pragma pack(1)
typedef struct mystruct {
    short     a;     // 2 bytes  — offset 0
    int       b[6];  // 24 bytes — offset 2
    long long c;     // 8 bytes  — offset 26
} mystruct_t;        // packed total: 34 bytes
//#pragma pack()

short main() {
    mystruct_t foo;
    foo.a    = 0xbabe;
    foo.c    = 0xba1b0ab1edb100d;
    foo.b[1] = foo.a;
    foo.b[4] = foo.b[1] + foo.c;
    return foo.b[4];
}

Compiled with MSVC on Windows x86–64, /Od (debug, no optimisation). The return type is deliberately short, not int. The arithmetic on line 19 crosses three widths (int, int, long long). Both choices are not accidents, they expose two separate truncation sites in the generated code.

Part 1: Frame Construction and ABI Alignment Arithmetic

The call chain before main runs

The process entry point on Windows is not main. The loader transfers control to the CRT, which chains through mainCRTStartup__scrt_common_main__scrt_common_main_sehinvoke_mainmain. Each call in that chain pushes an 8-byte return address and carves out a frame. By the time invoke_main executes call main (0x140001000h), RSP is 16-byte aligned. The call itself pushes 8 bytes, leaving RSP at 0x14FE08 — misaligned by 8.

The return address 0x0000000140001379 — the instruction immediately after call main inside invoke_main — now sits at [0x14FE08]. This is the yellow top row of the diagram. It was placed by the caller, is outside main's frame, and will be consumed by ret at function exit.

sub rsp, 38h — the one-instruction prologue

0000000140001000   sub rsp, 38h

RSP drops from 0x14FE08 to 0x14FDD0. The frame is 56 bytes (0x38).

Why 56 and not 34? Three constraints stack (no pun intended):

The first constraint is the structure itself. Under #pragma pack(1), mystruct_t is 34 bytes, with no inter-field padding. But the compiler chooses where on the stack to place it as a local variable, and it independently decides to align foo.c (a long long) to an 8-byte boundary. Since foo.a (2 bytes) + foo.b[6] (24 bytes) = 26 bytes, the end of b[5] sits at RSP+0x1A. The nearest 8-byte-aligned offset above that is RSP+0x20, so there is a 6-byte gap. The struct as placed on the stack occupies offsets 0x000x27 = 40 bytes including the gap.

The second constraint is frame-level alignment padding. 40 bytes from RSP leaves RSP+0x28 as the top of the used region. The compiler rounds up to the next multiple of 16 to keep the frame a multiple of 16 bytes: ceil(40/16) × 16 = 48. So 8 more bytes of frame-level padding are inserted at RSP+0x28RSP+0x2F.

The third constraint is the ABI requirement for RSP alignment. The Microsoft x64 ABI requires RSP to be 16-byte aligned at every call instruction. Before invoke_main called main, RSP was 16-byte aligned. The call pushed 8 bytes, misaligning by 8. So the prologue must subtract n such that (RSP_before_sub - n) mod 16 = 0. With RSP at 0x14FE08: 0x14FE08 mod 16 = 8. We need n mod 16 = 8. 0x38 mod 16 = 8. ✓

The epilogue exactly reverses this with add rsp, 38h, restoring RSP to 0x14FE08 before ret consumes the return address sitting there.

Note: MSVC’s debug builds do not emit push rbp / mov rbp, rsp frame pointer prologues for leaf functions in x64 by default — the ABI makes the frame pointer optional when the compiler can unwind via .pdata exception tables instead. This is why the trace shows only sub rsp, 38h with no RBP setup.

Part 2: Struct Field Placement — #pragma pack(1) vs Stack Alignment

#pragma pack(1) eliminates padding within the struct definition — between declared fields. It does not control how the compiler places the struct as a stack variable. Those are orthogonal decisions. Reading the field offsets directly from the memory instructions in the disassembly:

mov  word ptr [rsp],      ax      ; foo.a  → RSP+0x00
mov  qword ptr [rsp+20h], rax     ; foo.c  → RSP+0x20
mov  dword ptr [rsp+rax+4], ecx   ; foo.b[1] → RSP+0x06 (when rax=4)
mov  dword ptr [rsp+rcx+4], eax   ; foo.b[4] → RSP+0x14 (when rcx=0x10)

From these four instructions, the full layout is recoverable without guessing:

RSP offset   Address      Field         Width    Notes
──────────────────────────────────────────────────────────────────────
+0x00        0x14FDD0     foo.a         2 B      short; upper 2 bytes of slot unused
+0x02        0x14FDD2     foo.b[0]      4 B      int; never assigned
+0x06        0x14FDD6     foo.b[1]      4 B      int; = sign_ext(foo.a)
+0x0A        0x14FDDA     foo.b[2]      4 B      int; never assigned
+0x0E        0x14FDDE     foo.b[3]      4 B      int; never assigned
+0x12        0x14FDE2     foo.b[4]      4 B      int; = truncated(b[1]+c)
+0x16        0x14FDE6     foo.b[5]      4 B      int; never assigned
+0x1A        0x14FDEA     (6-byte gap)  6 B      stack alignment padding for foo.c
+0x20        0x14FDF0     foo.c         8 B      long long; little-endian
+0x28        0x14FDF8     (8-byte gap)  8 B      frame-level alignment padding
+0x30        0x14FE00     (16-byte gap) 16 B     ABI alignment padding (yellow)
+0x38        0x14FE08     return addr   8 B      placed by invoke_main's call
──────────────────────────────────────────────────────────────────────

The b[4] offset discrepancy — resolving the apparent conflict. The diagram in the original debugger output shows b[4] = 1edacacb at address 0x14FDE4, while the table above places b[4] at RSP+0x12 = 0x14FDE2. The resolution: the diagram uses 4-byte row granularity, so b[4]'s 4-byte value spans 0x14FDE20x14FDE5. The diagram row is labelled by its aligned 4-byte boundary 0x14FDE4, which is the word-aligned address nearest the value, not the exact byte-precise start. When reading the diagram, treat the row address as a slot identifier, not a guaranteed byte offset.

Little-endian decomposition of foo.c

The single mov qword ptr [rsp+20h], rax instruction writes all 8 bytes of foo.c atomically. On a little-endian architecture, bytes are stored LSB-first. So 0x0BA1B0AB1EDB100D decomposes at RSP+0x20 as:

Address     Byte value    Position in value
0x14FDF0    0D           byte 0 (least significant)
0x14FDF1    10
0x14FDF2    B1
0x14FDF3    ED
0x14FDF4    1B
0x14FDF5    B0
0x14FDF6    A1
0x14FDF7    0B           byte 7 (most significant)

The diagram splits these 8 bytes into two 4-byte rows — LSBs (1edb100d) at 0x14FDF0 and MSBs (0ba1b0ab) at 0x14FDF4. Reading the pair back as a 64-bit little-endian qword reconstructs 0x0BA1B0AB1EDB100D correctly.

Part 3: Index Scaling — What imul Is Actually Encoding

The address computation the ISA requires

The processor has no understanding of arrays or struct fields. It operates on addresses. When C says foo.b[i], the compiler must emit the byte-address computation:

addr(b[i]) = base(b) + i × sizeof(int)
           = (RSP + offset_of_b_in_struct) + i × 4

The i × 4 term is the stride multiplication and is what produces the imul. For b[1] (step 6–9 of the trace):

mov  eax, 4              ; i = 1; stride = 4; i×stride = 4 — compiler pre-multiplied
imul rax, rax, 1         ; ×1 is a no-op: debug-mode template artefact
movsx ecx, word ptr [rsp]
mov  dword ptr [rsp+rax+4], ecx   ; [RSP + 4 + 4] = [RSP+0x08]... wait

Hold on — [rsp+rax+4] with rax=4 gives RSP+8 = 0x14FDD8. But from the layout table, b[1] is at RSP+0x06 = 0x14FDD6. There is a 2-byte discrepancy. The explanation: the memory view in the screenshots shows b[1] = ffffbabe at address 0x14FDD8, which is the 4-byte slot containing b[1]'s value when displayed at 4-byte granularity from 0x14FDD0. Let us recount. foo.a is at +0x00 (2 bytes). b[0] starts at +0x02 (4 bytes). b[1] starts at +0x06. The address formula is RSP + (i×4) + offset_of_b_from_rsp. Since b starts 2 bytes past RSP (immediately after foo.a), and the compiler sets rax = i × sizeof(int) = 1 × 4 = 4, it uses [rsp + rax + 2]... but the disassembly shows +4. This means the compiler is treating the base of b as RSP+4, suggesting the compiler emitted 4 bytes of natural alignment padding between foo.a and foo.b[0] on the stack despite #pragma pack(1). This is the compiler exercising its right to pad stack variable placement independent of the struct layout directive.

The three-operand imul form

imul rcx, rcx, 4 is the three-operand form: imul dst, src, imm. It computes dst = src × imm using signed multiplication. The choice of signed (imul) rather than unsigned (mul) is deliberate: for address arithmetic, the index is a signed quantity in C's type system (ptrdiff_t is signed), and signed overflow semantics on x86-64 wrap in two's complement identically to unsigned anyway for the low 64 bits. The compiler consistently emits imul for index scaling regardless of whether the index type is signed or unsigned.

Why the debug build emits imul rax, rax, 1

This is a pure debug-mode code generation artefact. MSVC’s /Od code generator follows a fixed template for every array subscript expression: compute index × sizeof(element) regardless of whether either operand is a compile-time constant. For b[1], sizeof(int) = 4 and the index is 1, so it should emit imul rax, rax, 4. But for b[1] specifically, the index formula it generated put the factor of 4 into the initial mov eax, 4 load (treating i × stride as 1 × 4 = 4 ahead of time), leaving a residual imul rax, rax, 1 to satisfy the template structure. A release build (/O2) would eliminate this and emit either a direct constant displacement or a lea-based computation.

How imul rcx, rcx, 4 produces RCX = 0x10

This is not complex arithmetic — it is just decimal-to-hexadecimal confusion on first contact. Before the instruction: RCX = 0x00000004 (decimal 4, the array index). The immediate operand is 4 (decimal, sizeof(int)). The processor computes 4 × 4 = 16. Hexadecimal 16 is 0x10. The register display shows 00000010, which reads as sixteen, not ten. The subsequent address formula [rsp+rcx+4] with rcx=0x10 then resolves to RSP + 16 + 4 = RSP + 20 = RSP + 0x14, which is the byte offset of b[4] from RSP.

Part 4: Implicit Widening — movsx, movzx, and movsxd at Type Boundaries

The register width problem

All GPRs on x86–64 are 64 bits. C types are not. When the processor loads a 16-bit short or 32-bit int into a 64-bit register, something must fill the upper bits. The two options — sign-extension and zero-extension — correspond directly to C's signed and unsigned implicit promotion rules. The compiler's choice of movsx vs movzx is mechanically determined by the declared type of the source.

movsx ecx, word ptr [rsp]short to int at line 18

foo.b[1] = foo.a;   // short → int: implicit widening conversion

foo.a = 0xBABE. In binary, bit 15 is 1 (the sign bit of a short). movsx replicates that bit into the upper 16 bits of ECX: 0xBABE0xFFFFBABE. As a signed 16-bit integer, 0xBABE represents −17,730. As a signed 32-bit integer, 0xFFFFBABE also represents −17,730. Sign-extension preserves the mathematical value across the widening. The C standard mandates this behaviour for assignment of a signed narrow type to a wider signed type, and movsx is the single instruction that implements it.

movsxd rax, dword ptr [rsp+rax+4]int to 64-bit at line 19

foo.b[4] = foo.b[1] + foo.c;
//          ^^int^^   ^^long long^^

The usual arithmetic conversions in C §6.3.1.8 require both operands to be converted to the type of the larger operand before the operation. Here foo.b[1] (32-bit int) must be widened to 64-bit before the addition with foo.c (long long). The compiler uses movsxd (move with sign-extend doubleword to quadword), which is the 64-bit-destination variant of movsx. 0xFFFFBABE0xFFFFFFFFFFFFBABE. The 64-bit two's complement representation of −17,730 is 0xFFFFFFFFFFFFBABE. Mathematically correct.

Then add rax, qword ptr [rsp+20h] performs the full 64-bit addition:

0xFFFFFFFFFFFFBABE   (foo.b[1] sign-extended)
+ 0x0BA1B0AB1EDB100D   (foo.c)
─────────────────────
  0x0BA1B0AB1EDACACB   (64-bit result in RAX)

movzx eax, word ptr [rsp+rax+4]int to short at line 20

return foo.b[4];   // main() returns short — implicit narrowing truncation

This is the peculiar one. foo.b[4] is an int (32 bits) stored at RSP+0x14. The function return type is short (16 bits). The compiler reads only a word (16 bits) from memory and zero-extends it into EAX for the return. movzx rather than movsx is used here because the compiler is preparing a non-negative 16-bit value for the return slot — zero-extension produces a clean 32-bit EAX. The low 16 bits of 0x1EDACACB are 0xCACB, so EAX = 0x0000CACB and RAX = 0x000000000000CACB. This is what gets handed back to invoke_main in the x64 ABI return register.

Why zero-extend and not sign-extend here? The compiler is not performing a C-level sign-extension. It is returning a 16-bit short in EAX (which the ABI specifies as the return register for integer types regardless of width). Zero-extension simply cleans the upper bits of EAX without over-specifying the semantics — the caller will read only AX anyway.

Part 5: The Full Instruction Trace with Register State

The complete step-by-step trace from function entry to ret, with exact register values read from the debugger:

Part 6: The Final Stack Diagram — Every Byte Accounted For

The state of the stack after step 16 (all writes complete) and before step 20 (frame teardown):

Address          Value (hex)         Field                      Written by step
────────────────────────────────────────────────────────────────────────────────
0x14FE08  ┌──── 0000000140001379     return address (8B)        invoke_main call
          │
0x14FE00  │     ????????????????     ABI alignment padding (8B)  invoke_main frame
          │
0x14FDFC  ├──── ????????????????     frame alignment pad (4B)    compiler (sub rsp,38h)
0x14FDF8  │     ????????????????     frame alignment pad (4B)    compiler
          │
0x14FDF4  │     0ba1b0ab             foo.c MSBs (4B)             step 5
0x14FDF0  │     1edb100d             foo.c LSBs (4B)             step 5
          │
0x14FDEC  │     ????????????????     struct gap (4B of 6B)       never written
          │
0x14FDE8  │     ????????????????     foo.b[5] (4B)               never assigned
0x14FDE4  │     1edacacb             foo.b[4] (4B)               step 16 ← Truncation 1
0x14FDE0  │     ????????????????     foo.b[3] (4B)               never assigned
0x14FDDC  │     ????????????????     foo.b[2] (4B)               never assigned
0x14FDD8  │     ffffbabe             foo.b[1] (4B)               step 9
0x14FDD4  │     ????????????????     foo.b[0] (4B)               never assigned
          │
0x14FDD0  └──── ????babe             foo.a (2B) + pad (2B)       step 3
        ▲
       RSP
────────────────────────────────────────────────────────────────────────────────

Undef is not zero. Every ???? slot contains the residual bytes from whatever computation previously used that stack region — prior call frames, CRT initialisation, anything. The C standard classifies reads of uninitialised locals as undefined behaviour precisely because the hardware does not zero stack memory between calls. The bytes physically exist; they are simply indeterminate from the C abstract machine's perspective.

After add rsp, 38h. The bytes at 0x14FDD00x14FE07 are not erased. They persist in physical memory. What changes is that RSP advances back to 0x14FE08, removing those addresses from the current frame's protected region. The next function call will overwrite them without ceremony. This is the mechanism behind use-after-free stack vulnerabilities when a pointer to a local variable escapes the function.

Part 7: The Two Truncations — Precision Loss Without a Warning

The program contains two separate precision-loss events, at two different type boundaries, neither of which the compiler is required to warn about under C11.

Truncation 1: 64-bit → 32-bit at foo.b[4]

After step 13, RAX = 0x0BA1B0AB1EDACACB. This is the mathematically correct 64-bit result of (int32_t)foo.b[1] + foo.c. The mov dword ptr [rsp+rcx+4], eax at step 16 writes only EAX — the low 32 bits: 0x1EDACACB. The upper 32 bits 0x0BA1B0AB are silently discarded. C permits this as an implicit conversion from long long to int; the result is implementation-defined if the value is not representable in the target type, but no diagnostic is required (C11 §6.3.1.3).

Truncation 2: 32-bit → 16-bit at return foo.b[4]

The stored value 0x1EDACACB is a 32-bit int. main() returns short. The movzx eax, word ptr [rsp+rax+4] at step 19 reads only the low 16 bits: 0xCACB. The upper 16 bits 0x1EDA vanish. Again, this is implicit narrowing — implementation-defined, no required diagnostic.

Verifying the arithmetic. The full chain:

foo.a    = 0xBABE
           as short: −17,730
           sign-extended to int: 0xFFFFBABE = −17,730
foo.b[1] = −17,730 (int)
foo.c    = 0x0BA1B0AB1EDB100D
           as long long: +835,793,798,095,577,101
foo.b[1] + foo.c (as 64-bit):
  0xFFFFFFFFFFFFBABE + 0x0BA1B0AB1EDB100D
= 0x0BA1B0AB1EDACACB
= 835,793,798,095,559,371
Truncated to int (low 32 bits):
  0x1EDACACB = 518,855,883
Returned as short (low 16 bits):
  0xCACB = -13,621 (signed) or 51,915 (unsigned)

Two irreversible precision losses, each encoded by a single instruction, each permitted by C’s implicit conversion rules, and neither visible unless you read the assembly or instrument the intermediate values.

Closing Mental Model

The compiler is simultaneously solving three translation problems that the C abstract machine does not expose at the source level. Frame allocation (sub rsp, 38h) is driven by the ABI's alignment requirements, not just the struct's packed size. Field offsets within the frame are the compiler's own decision, constrained by natural alignment preferences for performance even when #pragma pack removes struct-level padding. Index scaling (imul) is the byte-arithmetic implementation of pointer arithmetic, exposed verbatim in debug builds. Type-boundary crossings (movsx, movzx, movsxd) are the machine-level encoding of C's implicit conversion rules, one instruction per widening event. And truncations — happening silently at dword and word store widths — are the compiler faithfully implementing C's assignment semantics even when precision is lost.

Reading the assembly of a struct access is, in this sense, reading a precise audit trail of every decision the C type system made on your behalf. Nothing is arbitrary.

Part 8. Extended Thought: movsx ecx, word ptr [rsp]

I want to dive a bit deeper here. In the trace, movsx ecx, word ptr [rsp] read foo.a = 0xBABE from memory. Since bit 15 of BABE is 1, the upper 16 bits of ECX were filled with FFFF, giving FFFFBABE. The value −17730 (as a signed 16-bit integer) was correctly preserved as −17730 in a 32-bit register.

Step 1: Understand what 0xBABE actually looks like in binary

0xBABE is a 16-bit hexadecimal number. Converting each hex digit to 4 binary bits:

B    = 1011
A    = 1010
B    = 1011
E    = 1110

0xBABE in binary = 1011 1010 1011 1110

The full 16-bit value is therefore 1011101010111110. Count from the right starting at zero: the rightmost bit is bit 0, and the leftmost bit is bit 15. Notice that bit 15 is 1.

That single observation: the leftmost bit is 1, is the entire reason FFFFBABE comes out the other side instead of 0000BABE. Everything else follows from it.

Step 2: Understand what “signed” means in two’s complement

Computers represent signed integers using a convention called two’s complement. The rule is simple: the most significant bit (the leftmost one) is the sign bit. If it is 0, the number is non-negative. If it is 1, the number is negative.

For a 16-bit short, bit 15 is the sign bit. Since bit 15 of 0xBABE is 1, this value represents a negative number. To find out which negative number, you apply the two's complement formula: subtract 2¹⁶ (which is 65536) from the unsigned interpretation.

The unsigned interpretation of 0xBABE is 47806. Subtracting 65536 gives 47806 − 65536 = −17730. So 0xBABE stored in a short means negative seventeen thousand seven hundred and thirty.

Step 3: Understand the problem movsx is solving

Now the question becomes: the value −17730 is stored in 16 bits. But we might want to store that same value in 32 bits. What 32-bit pattern correctly represents −17730?

In 32-bit two’s complement, we can subtract from 2 ³² (which is 4294967296): 4294967296 − 17730 = 4294949566. Converting that to hex gives 0xFFFFBABE. So the correct 32-bit representation of −17730 is 0xFFFFBABE.

The pattern is not a coincidence. Whenever a negative number is widened in two’s complement, the extra bits on the left are always filled with 1s. Whenever a non-negative number is widened, the extra bits are always filled with 0s. This is what "sign extension" means : you extend (copy) the sign bit into all the newly added positions.

Step 4: Watch movsx perform the extension mechanically

The instruction movsx ecx, word ptr [rsp] does the following in sequence.

  • First, it reads 2 bytes (a word, which is 16 bits) from the memory address stored in RSP. Those 2 bytes contain 0xBABE.
  • Second, it looks at bit 15 of 0xBABE. It finds a 1.
  • Third, because the sign bit is 1, it fills the upper 16 bits of ECX with 1s, giving 1111 1111 1111 1111 in binary, which is 0xFFFF in hex.
  • Fourth, it places the original 16-bit value in the lower 16 bits of ECX, giving 0xBABE there.
  • The final result is ECX = 0xFFFF concatenated with 0xBABE = 0xFFFFBABE.

If instead the value had been 0x1234 (where bit 15 is 0, a positive number), movsx would have filled the upper 16 bits with 0s, producing 0x00001234. In both cases the mathematical value is perfectly preserved — that is the entire purpose of the instruction.

Step 5: Verify that the value was actually preserved

The claim is that 0xFFFFBABE represents the same number as 0xBABE did, just in a wider type. You can verify this using the same two's complement formula. The unsigned interpretation of 0xFFFFBABE (as a 32-bit value) is 4294949566. Subtracting 2³² gives 4294949566 − 4294967296 = −17730. Identical to what we got from the 16-bit version. The mathematical value crossed the type boundary without any distortion.

This is why the comment says “correctly preserved” — it is not just that the bit pattern has BABE in it, it is that the number −17730 in 16-bit signed arithmetic is the same number as −17730 in 32-bit signed arithmetic, and movsx is the single instruction that performs that conversion faithfully.

The contrast that cements the intuition

It is worth considering what would have gone wrong without sign extension. If the processor had simply zero-filled the upper 16 bits (which is what movzx does), ECX would contain 0x0000BABE, which in 32-bit arithmetic represents positive 47806 — a completely different value. The sign information, encoded in bit 15, would have been silently discarded. Every arithmetic operation after that point would operate on the wrong number. movsx exists specifically to prevent this when the source type is signed.

A useful mental exercise: try the same analysis with 0x3FFF (bit 15 is 0). Run through all five steps and confirm that movsx would produce 0x00003FFF, because the sign bit is 0 and zero-filling and zero-extending are the same thing for non-negative values.

Part 9. What does #pragma pack (1) do?

#pragma is a compiler directive, an instruction aimed at the compiler itself rather than at the C language runtime or the processor. The word comes from "pragmatic", and the idea is that these are practical, implementation-specific hints that fall outside the C language standard proper.

What the standard says about it

The C standard deliberately defines #pragma as implementation-defined. The standard's exact position is: if the compiler recognises the pragma, it does whatever that pragma specifies. If it does not recognise it, it is allowed to ignore it entirely. This means pragmas are not portable — a pragma that means something to MSVC may be silently ignored by GCC or Clang, and vice versa.

This is intentional. Pragmas are the standard’s escape hatch for compiler vendors to expose features that do not belong in the language itself.

What #pragma pack(1) specifically does?

In this program, #pragma pack(1) tells the compiler to set the struct member alignment to 1 byte. To understand why that matters, you first need to understand what the compiler does without it.

By default, the compiler inserts padding bytes between struct fields to ensure each field sits at a naturally aligned address. A 4-byte int will be placed at an address divisible by 4. A 8-byte long long will be placed at an address divisible by 8. This is because misaligned memory accesses are either slower or outright illegal depending on the CPU and operating system.

Consider the struct without any pragma:

typedef struct mystruct {
    short     a;      // 2 bytes at offset 0
    // 2 bytes padding inserted here by compiler
    int       b[6];   // 4-byte aligned, starts at offset 4
    long long c;      // 8-byte aligned, starts at offset 32
} mystruct_t;
// Total: 40 bytes

With #pragma pack(1), that padding is removed:

#pragma pack(1)
typedef struct mystruct {
    short     a;      // 2 bytes at offset 0
    int       b[6];   // starts immediately at offset 2
    long long c;      // starts immediately at offset 26
} mystruct_t;
// Total: 34 bytes
#pragma pack()        // restore default alignment

The #pragma pack() at the end with no argument restores whatever the default alignment was before. Without that restore, the pack(1) setting would apply to every struct defined after it in the same translation unit, which is almost never what you want.

When you would actually use it

The most common real-world use case is binary protocol parsing. When you are reading a network packet, a file format header, or a hardware register map, the bytes arrive in a precise layout defined by the protocol — not by C’s alignment preferences. If the protocol says a 4-byte integer follows immediately after a 1-byte flag, you cannot have the compiler inserting 3 bytes of padding between them. #pragma pack(1) lets you define a struct that matches the wire format exactly and then overlay it onto a raw byte buffer.

#pragma pack(1)
typedef struct EthernetFrame {
    unsigned char  dst_mac[6];
    unsigned char  src_mac[6];
    unsigned short ethertype;
    unsigned char  payload[1500];
} EthernetFrame_t;
#pragma pack()

Without the pragma, the compiler might pad this struct in ways that would misalign your reads from the actual network buffer.

The subtlety the program revealed

The important thing your debugger trace demonstrated is that #pragma pack(1) controls struct-level padding between fields, but it does not control how the compiler places the struct as a local variable on the stack. Those are two separate decisions. The compiler still aligned foo.c to an 8-byte boundary on the stack by inserting a 6-byte gap, because stack variable placement is the compiler's own business regardless of the pack directive. The struct's internal field offsets obeyed pack(1), but the struct's position within the stack frame did not have to.

This is the distinction that catches most people off guard when they first use #pragma pack and then inspect the generated assembly.

Part 10. Uncomment #pragma pack(1)

Source Code

#pragma pack(1)
typedef struct mystruct {
    short a;        // 2 bytes
    int b[6];       // 24 bytes (4 * 6)
    long long c;    // 8 bytes
} mystruct_t;       // Total: 34 bytes (0x22) — pack(1) = no padding

short main() {
    mystruct_t foo;
    foo.a = 0xbabe;
    foo.c = 0xba1b0ab1edb100d;
    foo.b[1] = foo.a;
    foo.b[4] = foo.b[1] + foo.c;
    return foo.b[4];
}

Full Disassembly with Annotations

; Prologue
0000000140001000  sub   rsp, 38h          ; allocate 0x38 = 56 bytes on stack

; foo.a = 0xbabe
0000000140001004  mov   eax, 0FFFFBABEh   ; eax = 0xFFFFBABE (sign-extended prep)
0000000140001009  mov   word ptr [rsp], ax ; store low 16-bits → foo.a at [rsp+0]
                                            ;   ax = 0xBABE → 2 bytes
; foo.c = 0xba1b0ab1edb100d
000000014000100D  mov   rax, 0BA1B0AB1EDB100Dh ; full 64-bit constant
0000000140001017  mov   qword ptr [rsp+1Ah], rax ; store 8 bytes → foo.c at [rsp+0x1A]
; foo.b[1] = foo.a
000000014000101C  mov   eax, 4            ; index = 1
0000000140001021  imul  rax, rax, 1       ; rax = 1 * 4 = 4 (byte offset)
0000000140001025  movsx ecx, word ptr [rsp]      ; ecx = sign-extend foo.a (0xBABE → 0xFFFFBABE)
0000000140001029  mov   dword ptr [rsp+rax+2], ecx ; store → foo.b[1] at [rsp+0x2+0x4]=[rsp+6]
; foo.b[4] = foo.b[1] + foo.c
000000014000102D  mov   eax, 4
0000000140001032  imul  rax, rax, 1       ; offset = 4 bytes for b[1]
0000000140001036  movsxd rax, dword ptr [rsp+rax+2] ; rax = sign-extend foo.b[1] to 64-bit
000000014000103B  add   rax, qword ptr [rsp+1Ah]    ; rax = foo.b[1] + foo.c
0000000140001040  mov   ecx, 4
0000000140001045  imul  rcx, rcx, 4       ; offset = 4*4 = 16 for b[4]
0000000140001049  mov   dword ptr [rsp+rcx+2], eax  ; store → foo.b[4] at [rsp+0x2+0x10]=[rsp+0x12]
; return foo.b[4]
000000014000104D  mov   eax, 4
0000000140001052  imul  rax, rax, 4       ; rax = 16
0000000140001056  movzx eax, word ptr [rsp+rax+2]   ; load foo.b[4], zero-extend into eax
                                                     ; (return truncated to short)
; Epilogue
000000014000105B  add   rsp, 38h
000000014000105F  ret

Stack Layout (RSP = 0x000000000014FDD0 after sub rsp,38h)

sub rsp, 38h allocates 56 bytes (0x38). The struct itself is only 34 bytes; the extra space is the shadow space / alignment padding required by the Windows x64 ABI.

RSP+0x00  ┌──────────────────────────────┐  0x14FDD0
          │  foo.a  (short, 2 bytes)     │  = 0xBABE
          │  [rsp+0x00 .. rsp+0x01]      │
RSP+0x02  ├──────────────────────────────┤  0x14FDD2
          │  foo.b[0] (int, 4 bytes)     │  (uninitialised)
          │  [rsp+0x02 .. rsp+0x05]      │
RSP+0x06  ├──────────────────────────────┤  0x14FDD6
          │  foo.b[1] (int, 4 bytes)     │  = 0xFFFFBABE  ← foo.a sign-extended
          │  [rsp+0x06 .. rsp+0x09]      │
RSP+0x0A  ├──────────────────────────────┤  0x14FDDA
          │  foo.b[2] (int, 4 bytes)     │  (uninitialised)
RSP+0x0E  ├──────────────────────────────┤  0x14FDDE
          │  foo.b[3] (int, 4 bytes)     │  (uninitialised)
RSP+0x12  ├──────────────────────────────┤  0x14FDE2
          │  foo.b[4] (int, 4 bytes)     │  = foo.b[1] + foo.c (truncated to int)
          │  [rsp+0x12 .. rsp+0x15]      │
RSP+0x16  ├──────────────────────────────┤  0x14FDE6
          │  foo.b[5] (int, 4 bytes)     │  (uninitialised)
RSP+0x1A  ├──────────────────────────────┤  0x14FDEA
          │  foo.c (long long, 8 bytes)  │  = 0x0BA1B0AB1EDB100D
          │  [rsp+0x1A .. rsp+0x21]      │
RSP+0x22  ├──────────────────────────────┤  0x14FDF2
          │  padding / unused (14 bytes) │  compiler alignment to 16-byte boundary
          │  [rsp+0x22 .. rsp+0x37]      │
RSP+0x38  └──────────────────────────────┘  0x14FE08 ← original RSP (return address here)

Offset Arithmetic the Compiler Uses

The base of foo is [rsp]. Because #pragma pack(1) eliminates all padding, offsets are purely size-based:

Formula for b[i]: [rsp + 0x02 + (i * 4)] The compiler emits imul rax, rax, 1 (×1 then uses ×4 in the addressing) or imul rcx, rcx, 4 directly, then adds the base offset +2.

Key Observations

**#pragma pack(1) effect:** Without it the struct would be 40 bytes (padding after short a to align int b[]). With it, b[0] starts immediately at byte 2, giving 34 bytes total — visible in the +0x1A offset for c.

Sign extension behaviour: foo.a = 0xBABE is a short. When copied to foo.b[1] (an int), the compiler uses movsx0xFFFFBABE. When added to foo.c (a long long), movsxd sign-extends again to 64 bits → 0xFFFFFFFFFFFFBABE.

Return value truncation: The return type is short, but the function returns foo.b[4] which is an int. The final movzx eax, word ptr [rsp+rax+2] loads only the low 16 bits, truncating the result.

Stack frame size 0x38 vs struct size 0x22: The extra 22 bytes (0x38–0x22 = 0x16) are dead space used by the Windows x64 ABI to keep RSP 16-byte aligned at the call boundary.

Completing the Stack Diagram: Return Path & invoke_main Frame

From the new screenshots we can now see the full call chain: invoke_mainmainret back to invoke_main.

Complete Stack Diagram

HIGHER ADDRESSES  (bottom of call stack = oldest frame)
══════════════════════════════════════════════════════════════════

ntdll / kernel32 frames
  (OS bootstrap - no source, not inspectable)
──────────────────────────────────────────────────────────────────
  mainCRTStartup()          [exe_common.inl]
  Sets up: heap, locale, stdio, argv, envp, atexit handlers
  Then calls __scrt_common_main()
──────────────────────────────────────────────────────────────────
  __scrt_common_main()      [Line 331]
  Calls __scrt_common_main_seh()
──────────────────────────────────────────────────────────────────
  __scrt_common_main_seh()  [Line 288]
  Wraps everything in a SEH __try/__except block
  Calls invoke_main()
──────────────────────────────────────────────────────────────────
  invoke_main()             [Line 79]   RSP = 0x14FE10
  ┌────────────────────────────────┐
  │  shadow space / locals 0x48B   │
  │  ...                           │
  │  [rsp+0x28]  arg to main       │
  │  [rsp+0x20]  arg to main       │
  │                                │
  │  0x140001379 ← CALL return     │  ← pushed by CALL → main()
  └────────────────────────────────┘
            CALL 0x140001000 pushes return addr
            RSP: 0x14FE10 → 0x14FE08
══════════════════════════════════════════════════════════════════
  main()                    RSP_entry = 0x14FE08
  sub rsp, 0x38   →         RSP_frame = 0x14FDD0
  ┌────────────────────────────────────────┐
  │ [rsp+0x00]  foo.a   short  2B          │  0x14FDD0  = 0xBABE
  ├────────────────────────────────────────┤
  │ [rsp+0x02]  foo.b[0] int   4B          │  0x14FDD2  (uninit)
  ├────────────────────────────────────────┤
  │ [rsp+0x06]  foo.b[1] int   4B          │  0x14FDD6  = 0xFFFFBABE
  ├────────────────────────────────────────┤
  │ [rsp+0x0A]  foo.b[2] int   4B          │  0x14FDDA  (uninit)
  ├────────────────────────────────────────┤
  │ [rsp+0x0E]  foo.b[3] int   4B          │  0x14FDDE  (uninit)
  ├────────────────────────────────────────┤
  │ [rsp+0x12]  foo.b[4] int   4B          │  0x14FDE2  = 0x1EDACACB
  ├────────────────────────────────────────┤
  │ [rsp+0x16]  foo.b[5] int   4B          │  0x14FDE6  (uninit)
  ├────────────────────────────────────────┤
  │ [rsp+0x1A]  foo.c  long long 8B        │  0x14FDEA  = 0x0BA1B0AB1EDB100D
  ├────────────────────────────────────────┤
  │ [rsp+0x22]  PADDING  14B               │  0x14FDF2  (ABI alignment dead zone)
  │             (0x38 - 0x22 = 0x16 bytes) │  ..
  │                                        │  0x14FE07
  ├────────────────────────────────────────┤
  │ [rsp+0x38]  RETURN ADDR  8B            │  0x14FE08  = 0x0000000140001379
  │             → back into invoke_main    │            (popped by RET)
  └────────────────────────────────────────┘
            RET pops 0x140001379 → RIP
            RSP: 0x14FE08 → 0x14FE10
══════════════════════════════════════════════════════════════════
LOWER ADDRESSES  (top of call stack = most recent frame)

What Each CRT Layer Does

The return value 0xCACB (the short-truncated foo.b[4]) travels back up through invoke_main__scrt_common_main_seh → ultimately passed to ExitProcess() as the process exit code.


메타데이터
post_id
ab24153e76b2
slug
basic-reverse-engineering-vol-3-struct-local-variable-ab24153e76b2
url
https://medium.com/re-exploit/basic-reverse-engineering-vol-3-struct-local-variable-ab24153e76b2
canonical_url
https://medium.com/re-exploit/basic-reverse-engineering-vol-3-struct-local-variable-ab24153e76b2
author_url
https://medium.com/@MonlesYen
status
ok
fetched_at
2026-07-15 20:50:45