How C constructs compile: loops, structs, switch
C syntax is high-level compared with the CPU, but most constructs lower to a few
machine patterns. A loop is a label, a condition, a body, and a branch back. A struct
field is a byte offset from a base pointer. A switch is a dispatch strategy chosen by
the compiler: compare chain, jump table, conditional moves/selects, or a mix. Once you
see those shapes, C control flow and data layout become readable in assembly.
The reset: assembly does not know "for loop," "struct field," or "
switchcase." It knows addresses, offsets, comparisons, jumps, and arithmetic.
How it really works
The source constructs map roughly like this:
| C construct | Machine shape |
|---|---|
for / while |
loop header label, condition, body, increment, branch |
items[i] |
base pointer plus i * sizeof item |
p->field |
load/store at fixed byte offset from p |
switch |
compare chain, jump table, branch tree, or conditional select |
Optimization changes the visible shape. The compiler may invert a condition, combine the increment with the exit test, hoist pointer arithmetic, or replace a branch with a conditional move. Your job is not to reconstruct the exact C syntax. Your job is to recover the dataflow and control-flow edges.
A real loop over structs
The executable artifact for this note lives in
examples/assembly-and-compiler-output/how-c-constructs-compile-loops-structs-switch/.
struct Packet {
int tag;
long value;
int scale;
};
NOINLINE long classify_and_sum(const struct Packet *items, size_t count) {
long total = 0;
for (size_t i = 0; i < count; i++) {
long scaled = items[i].value * items[i].scale;
switch (items[i].tag) {
case 0:
total += scaled;
break;
case 1:
total -= scaled;
break;
case 2:
total += items[i].value;
break;
default:
total += 7;
break;
}
}
return total;
}
On this target, struct Packet has this layout:
| Field | Offset | Reason |
|---|---|---|
tag |
0 | int starts the object |
| padding | 4 | align the next long |
value |
8 | long needs 8-byte alignment |
scale |
16 | int after the long |
| tail padding | 20 | make array stride 24 |
The stride is 24 bytes, so items[i + 1] is 24 bytes after items[i].
On this machine, gcc is Apple clang 15 targeting x86-64 Mach-O. This is the relevant
function body copied from the real output of:
gcc -S -O2 -fno-omit-frame-pointer -masm=intel demo.c -o demo.s
_classify_and_sum: ## @classify_and_sum
.cfi_startproc
## %bb.0:
push rbp
.cfi_def_cfa_offset 16
.cfi_offset rbp, -16
mov rbp, rsp
.cfi_def_cfa_register rbp
test rsi, rsi
je LBB0_10
## %bb.1:
add rdi, 16
xor eax, eax
jmp LBB0_4
.p2align 4, 0x90
LBB0_8: ## in Loop: Header=BB0_4 Depth=1
add rax, rcx
LBB0_3: ## in Loop: Header=BB0_4 Depth=1
add rdi, 24
dec rsi
je LBB0_11
LBB0_4: ## =>This Inner Loop Header: Depth=1
mov rcx, qword ptr [rdi - 8]
mov edx, dword ptr [rdi - 16]
cmp edx, 2
je LBB0_8
## %bb.5: ## in Loop: Header=BB0_4 Depth=1
movsxd r8, dword ptr [rdi]
imul rcx, r8
cmp edx, 1
je LBB0_9
## %bb.6: ## in Loop: Header=BB0_4 Depth=1
test edx, edx
je LBB0_8
## %bb.2: ## in Loop: Header=BB0_4 Depth=1
add rax, 7
jmp LBB0_3
.p2align 4, 0x90
LBB0_9: ## in Loop: Header=BB0_4 Depth=1
sub rax, rcx
jmp LBB0_3
LBB0_10:
xor eax, eax
LBB0_11:
pop rbp
ret
.cfi_endproc
Map the arguments first: items arrives in rdi, count arrives in rsi, and the
return value leaves in rax.
Now decode the source constructs:
test rsi, rsi; je LBB0_10is the empty-loop check. Ifcount == 0, return zero.xor eax, eaxinitializestotal = 0.add rdi, 16makesrdipoint at thescalefield of the current item. This lets the compiler address nearby fields with negative offsets.mov rcx, qword ptr [rdi - 8]loadsvalue, becausescale_offset - 8 == value_offset.mov edx, dword ptr [rdi - 16]loadstag, becausescale_offset - 16 == tag_offset.movsxd r8, dword ptr [rdi]loads signedscaleand widens it to 64 bits.imul rcx, r8computesscaled = value * scale.cmp edx, 2,cmp edx, 1, andtest edx, edximplement theswitch.add rdi, 24advances to the nextstruct Packet.dec rsi; je LBB0_11decrements the remaining count and exits when it reaches zero.
The compiler did not preserve the source order literally. It checks case 2 before it
computes scaled, because that case only needs value. That is legal because C's
observable behavior is unchanged.
Reading the switch
For this small switch, the compiler chose a compare chain:
cmp edx, 2
je LBB0_8
movsxd r8, dword ptr [rdi]
imul rcx, r8
cmp edx, 1
je LBB0_9
test edx, edx
je LBB0_8
add rax, 7
edx holds tag. If tag == 2, the code jumps to the path that adds value. If not,
it computes scaled. tag == 1 subtracts scaled; tag == 0 adds scaled; anything
else adds 7.
For dense switches with many cases, compilers often use jump tables. For tiny switches, branch chains or conditional moves are often cheaper and smaller.
ARM64 appendix
The same demo.c was cross-compiled on this machine with:
clang -S -O2 -fno-omit-frame-pointer -arch arm64 demo.c -o demo.arm64.s
Relevant function body:
_classify_and_sum: ; @classify_and_sum
.cfi_startproc
; %bb.0:
cbz x1, LBB0_4
; %bb.1:
mov x8, x0
mov x0, #0
add x8, x8, #16
LBB0_2: ; =>This Inner Loop Header: Depth=1
ldur x9, [x8, #-8]
ldrsw x10, [x8]
mul x10, x9, x10
ldur w11, [x8, #-16]
add x9, x9, x0
sub x12, x0, x10
add x13, x0, #7
add x10, x10, x0
cmp w11, #0
csel x10, x13, x10, ne
cmp w11, #1
csel x10, x12, x10, eq
cmp w11, #2
csel x0, x9, x10, eq
add x8, x8, #24
subs x1, x1, #1
b.ne LBB0_2
; %bb.3:
ret
LBB0_4:
mov x0, #0
ret
.cfi_endproc
ARM64 makes the same loop/struct facts visible: x0 starts as items, x1 is
count, x8 becomes a pointer to the current scale field, and the pointer advances
by 24 bytes each iteration. ldur x9, [x8, #-8] loads value, ldur w11, [x8, #-16]
loads tag, and ldrsw x10, [x8] loads and sign-extends scale.
The switch lowering differs. Instead of a branch chain, ARM64 uses csel conditional
selects to choose the next total value after comparisons. Same C, different machine
strategy.
Failure modes & trade-offs
- Expecting source order. The compiler may reorder loads, comparisons, and arithmetic as long as observable behavior is preserved.
- Forgetting struct padding.
items[i]advances bysizeof(struct Packet), not by the sum of field sizes you imagined. - Assuming every switch is a jump table. Small switches often compile to branches or conditional selects. Dense large switches often become jump tables.
- Missing signed extension.
movsxdandldrswmatter:scaleis a signedintwidened to 64-bitlongarithmetic. - Confusing loop count with index. Optimized loops may use a decrementing remaining
count instead of an increasing
i. - Reading labels as source blocks. Labels are compiler-generated control-flow targets, not original C block names.
In practice
- Start from layout. Know
sizeofand offsets before reading struct access. - Find the induction variable. It may be a pointer stepping by stride, not an explicit integer index.
- Trace one iteration. Follow loads, case dispatch, accumulator update, pointer advance, and exit test.
- Treat switch lowering as a choice. Compare chain, jump table, and conditional select are all valid outputs for the same source idea.
- Connect fields to addressing modes. This note is where addressing modes and memory operands become source-level C again.
Connects to: Addressing modes and memory operands · Control flow: jumps, conditions, and the flags register · The System V AMD64 calling convention · Structs, unions, and bitfields · Struct layout: alignment & padding
Sources
- Intel 64 and IA-32 Architectures Software Developer's Manual, Vol. 1 and Vol. 2 — instruction semantics for branches, compares, loads, sign extension, and integer multiplication. https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
- System V AMD64 ABI — argument registers and data-layout expectations used to read the generated x86-64 output. https://gitlab.com/x86-psABIs/x86-64-ABI
- Bryant & O'Hallaron — Computer Systems: A Programmer's Perspective (CS:APP), ch. 3 — machine-level loops, conditionals, switch statements, and struct layout. https://csapp.cs.cmu.edu/
- Felix Cloutier — x86 and amd64 instruction reference — quick lookup for
test,cmp,imul,movsxd, and jumps; verify edge cases against Intel SDM. https://www.felixcloutier.com/x86/ - ISO/IEC 9899 (WG14 C standard working drafts) — source-level rules for structs, array indexing, integer conversions, and switch semantics. https://www.open-std.org/jtc1/sc22/wg14/
- Arm Architecture Reference Manual + Apple ARM64 docs — ARM64 load/store addressing,
csel, branches, and platform ABI details for the appendix output. https://developer.arm.com/documentation/ddi0487/latest · https://developer.apple.com/documentation/xcode/writing-arm64-code-for-apple-platforms