C Part I · Module 10 · Structures, Unions, Enums and User-Defined Types
Passing and Returning Structures
You now have two ways to hand a structure to a function and the folk rule for choosing between them is easy to state: small ones by value, big ones by pointer, and never return a big one — use an out-parameter. The first clause is right for a reason almost nobody gives, the second is right for a reason that is not the one usually given, and the third is simply wrong. This chapter settles all three by compiling the alternatives and reading the machine code the compiler produced, which is a fact about your build rather than a benchmark with timing noise in it. Then it turns to the half of the decision that no instruction count can answer, because passing by value promises the caller something that a const-qualified pointer cannot.
Intermediate20 min readCPart IFundamentalsTypes
Part I · Module 10 · Chapter 10.4 · Passing and Returning Structures
1. The Two Options
int sum_by_value(struct point p); /* the callee gets a copy */
int sum_by_pointer(const struct point *p); /* the callee gets an address */Both are ordinary C. The first is Chapter 5.3's pass-by-value with a structure in the parameter slot; the second is Chapter 7.5's pointer parameter, with Chapter 7.11's const on it.
The question is which to write, and it has two independent halves that are usually run together:
- Cost — what the machine actually does. §3 to §6.
- Intent — what the signature promises the caller. §7.
They usually agree. When they disagree, §7 wins, and §9 says why.
2. What the Language Guarantees, and What It Does Not
ISO C's rule is simple and complete: a structure argument is passed by value, and a structure return value is returned by value. The callee gets its own object. Assigning to a by-value parameter cannot affect the caller, exactly as for an
int.
That is the whole language rule. How a compiler arranges it — registers, a stack copy, a hidden destination address — is decided by the platform's ABI, the calling convention that compilers on that platform agree to follow. The standard says nothing about it.
This matters for how you should read the rest of this chapter. Every instruction count below was measured on one target: clang 21 at -O2 for arm64, following AAPCS64, with these sizes:
small 8 mid 16 big 80The shape of the result — that small structures can travel without touching memory and large ones cannot — holds broadly, because the reasons are about register width and copy size. The specific numbers and thresholds do not transfer. A 32-bit target, a different ABI, or a different optimisation level will give different figures. This is Chapter 1.3's baseline discipline applied to code generation: measure your own target before you quote a number about it.
With that stated, the measurements are worth seeing, because they overturn two widely repeated rules.
3. A Small Structure: By Value Is Cheaper Than By Pointer
struct small { int x; int y; }; /* 8 bytes */
int sum_small_val(struct small s) { return s.x + s.y; }
int sum_small_ptr(const struct small *s) { return s->x + s->y; }Compiled at -O2, the two functions came out as:
_sum_small_val:
lsr x8, x0, #32
add w0, w8, w0
ret
_sum_small_ptr:
ldp w8, w9, [x0]
add w0, w9, w8
retRead the first instruction of each.
By value, both members arrived in one register. x0 holds the whole structure; lsr x8, x0, #32 shifts the upper member down. There is no memory access at all — no load, no store, nothing on the stack.
By pointer, the first instruction is ldp w8, w9, [x0] — a load of two words from the address in x0. The caller had to have the structure in memory for its address to be taken, and the callee has to go and fetch it.
For a small structure, the by-pointer version does strictly more work than the by-value version. It adds an indirection and forces the object to exist in memory. The common advice "pass structures by pointer to avoid the copy" has it backwards at this size: there is no copy to avoid, and the pointer introduces the only memory traffic in sight.
A 16-byte structure behaves the same way, using two registers:
_sum_mid_val:
lsr x8, x0, #32
lsr x9, x1, #32
add w10, w1, w0
add w8, w10, w8
add w0, w8, w9
retStill no memory. All four members came in through x0 and x1.
4. A Large Structure: The Callee Cannot Tell the Difference
Now the 80-byte structure, the same two ways:
struct big { int a[16]; double d; char t; }; /* 80 bytes */
int sum_big_val(struct big b) { return b.a[0] + b.a[15] + b.t; }
int sum_big_ptr(const struct big *b) { return b->a[0] + b->a[15] + b->t; }_sum_big_val:
ldr w8, [x0]
ldr w9, [x0, #60]
ldrsb w10, [x0, #72]
add w8, w9, w8
add w0, w8, w10
ret
_sum_big_ptr:
ldr w8, [x0]
ldr w9, [x0, #60]
ldrsb w10, [x0, #72]
add w8, w9, w8
add w0, w8, w10
retByte for byte identical. Both functions receive an address in x0 and load from it.
This is the ABI's answer for structures too large to fit in the available registers: the caller puts the object somewhere and passes its address. The by-value function is semantically given a copy — it may freely modify b without the caller seeing it — but the mechanism is a pointer to a copy the caller made.
Which tells you where to look for the cost. It is not in the function.
5. The Cost Is in the Caller
int call_by_value(struct big *src) { return sum_big_val(*src); }
int call_by_pointer(struct big *src) { return sum_big_ptr(src); }_call_by_value:
sub sp, sp, #96
stp x29, x30, [sp, #80]
add x29, sp, #80
ldp q0, q1, [x0, #32]
stp q0, q1, [sp, #32]
ldr q0, [x0, #64]
str q0, [sp, #64]
ldp q1, q0, [x0]
stp q1, q0, [sp]
mov x0, sp
bl _sum_big_val
ldp x29, x30, [sp, #80]
add sp, sp, #96
ret
_call_by_pointer:
b _sum_big_ptr_call_by_value : 14 instructions
_call_by_pointer : 1 instructionThe by-value caller reserves 96 bytes of stack, copies all 80 bytes using five wide load/store pairs, hands over the address of its copy, and makes a call. The by-pointer caller is a single branch — a tail call, with nothing copied and no stack frame at all.
That is the real large-structure argument, and it is not "the function is slower". The function is identical. The cost is one copy per call site, paid by the caller, and it scales with
sizeofthe structure. A function called once in a setup path can afford it; the same function called in a loop over a million records cannot.
6. Returning: The Folk Rule Is Wrong
The advice "do not return a large structure, fill an out-parameter instead" is so common that it is worth testing directly. Here is the same function written both ways:
struct big make_big(int seed)
{
struct big b = { .t = (char)seed };
b.a[0] = seed;
return b; /* return by value */
}
void fill_big(struct big *out, int seed) /* the out-parameter version */
{
struct big b = { .t = (char)seed };
b.a[0] = seed;
*out = b;
}make_small : 3 instructions
make_big : 7 instructions
fill_big : 11 instructionsReturning the 80-byte structure by value produced fewer instructions than the out-parameter rewrite of the same function. The reason is visible in make_big's code:
_make_big:
movi.2d v0, #0000000000000000
stp q0, q0, [x8, #48]
stp q0, q0, [x8, #16]
str q0, [x8]
strb w0, [x8, #72]
str w0, [x8]
retEvery store goes to [x8] — and x8 is the caller's destination address, handed in by the ABI for exactly this purpose. The function constructs the result directly where the caller wants it. There is no temporary and no second copy.
fill_big cannot do that, because it was written to build a local and then assign it to *out. The out-parameter version is longer precisely because it describes an extra step.
And make_small returning an 8-byte structure is three instructions, packed into a register:
_make_small:
mov w8, w0
orr x0, x8, x1, lsl #32
ret7. The Other Axis: What the Signature Promises
Everything above is cost, and cost is the half that is easy to measure and the half that matters less often. Here is the other half.
void a(struct config c); /* cannot affect the caller's object */
void b(const struct config *c); /* promises not to, in the type system */
void c(struct config *c); /* may change the caller's object */All three can be made to compile for the same call site. They say three different things.
By value is a guarantee. The callee has its own object. There is no expression it can write, no cast it can perform, and no mistake it can make that reaches the caller's structure, because it does not have the address. Chapter 5.3's rule is doing real work here.
const is a promise, enforced but defeasible. Writing through it is an error:
error: cannot assign to variable 'p' with const-qualified type 'const struct c *'But Chapter 7.11 §5 established that a cast removes the qualifier, and Chapter 9.8 §7 made the same point about ownership: const makes a violation visible in review, not impossible. A function holding a const struct config * also still holds the address, so it can pass it on to something less scrupulous.
A non-const pointer announces mutation. That is its job, and it should be reserved for it. A function that takes struct config * and does not modify it has mislabelled itself.
On intent: pass by value when the function must not be able to affect the caller's object; pass
const T *when it must not intend to; passT *when changing the caller's object is the point. That ordering is about the strength of the promise, not about speed.
There is one more consideration that the instruction counts cannot show at all.
8. Returning By Value Composes
#include <stdio.h>
struct point { int x; int y; };
static struct point add(struct point a, struct point b)
{
struct point r = { a.x + b.x, a.y + b.y };
return r;
}
static struct point scale(struct point p, int k)
{
struct point r = { p.x * k, p.y * k };
return r;
}
static void add_into(struct point *out, const struct point *a, const struct point *b)
{
out->x = a->x + b->x;
out->y = a->y + b->y;
}
int main(void)
{
struct point a = { 1, 2 }, b = { 10, 20 };
struct point v = scale(add(a, b), 3); /* one expression */
printf("by value : %d,%d\n", v.x, v.y);
struct point t, w; /* the same, by pointer */
add_into(&t, &a, &b);
w = scale(t, 3);
printf("out-parameter: %d,%d\n", w.x, w.y);
printf("sizeof(struct point) = %zu\n", sizeof(struct point));
return 0;
}Output:
by value : 33,66
out-parameter: 33,66
sizeof(struct point) = 8Same answer. Not the same code.
scale(add(a, b), 3) is one expression. A function that returns a value can be used as an argument to another function, and the intermediate result never needs a name. The out-parameter version needs t — a variable whose only purpose is to exist between two statements, which must be declared, which starts out uninitialised (Chapter 9.12's indeterminate value, briefly), and which the reader must track.
A function that returns its result composes; a function that fills a buffer does not. For small value-like structures — points, rectangles, dates, complex numbers, fixed-size identifiers — returning by value is both the cheaper option, as §3 and §6 measured, and the one that produces code you can read in a line.
9. The Decision
| Situation | Pass | Why |
|---|---|---|
| small structure, read only | by value | no memory traffic at all (§3); the strongest non-interference guarantee (§7) |
| the function must not be able to touch the caller's object | by value | a copy has no address to reach back through |
| large structure, read only, called often | const T * | one copy per call site is the cost (§5) |
| the function must modify the caller's object | T * | the only form that can (10.3 §6) |
| the structure must not be copied for its own reasons | T * | e.g. it is the thing an owner owns (9.8) |
| returning a small value-like structure | by value | 3 instructions, and it composes (§6, §8) |
| returning a large structure, once | by value | measured fewer instructions than the out-parameter (§6) |
| returning a large structure, in a hot loop | T * out-parameter | lets the caller reuse one object across iterations |
And the rules that outrank the table:
When cost and intent disagree, follow intent. A wrong promise in a signature misleads every reader of the function forever; a redundant 8-byte copy costs a few cycles in a path you have not measured.
Do not guess about cost. Every number in this chapter came from reading emitted code on one target. If performance is the deciding factor, generate and read your own — -S is one flag — or measure the program. "Big structs are slow to pass" is not a measurement.
const on every pointer parameter you do not modify through. It is free, it is checked, and with structures it is the only part of the signature that tells a reader what the function will do to their object.
10. What You Should Be Able to Say Now
- ISO C passes and returns structures by value; how is the ABI's business and not portable.
- Measured on one target: an 8-byte structure by value arrived in one register with no memory access; the by-pointer version began with a load.
- So for small structures, by pointer does strictly more work — there is no copy being avoided.
- For an 80-byte structure the two callee bodies were byte-for-byte identical: the ABI passes an address either way.
- The large-structure cost is in the caller — 14 instructions against 1, one copy per call site.
- Returning an 80-byte structure by value took 7 instructions; the out-parameter rewrite took 11. The callee builds the result directly in the caller's destination (
x8). - "Never return a big structure" describes an ABI that no longer exists. A performance rule you cannot explain mechanically is one you cannot tell is still true.
- By value is a guarantee;
const T *is an enforced but castable promise;T *announces mutation. - Returning by value composes —
scale(add(a, b), 3)needs no intermediate variable. - When cost and intent disagree, follow intent, and never quote a cost you have not generated.
11. Exercises
Exercise 1 — choose the parameter. For each, give the parameter type you would write and one sentence of justification.
- a function that reports the area of a
struct rect(16 bytes) - a function that moves a
struct window(24 bytes) to a new origin - a function that validates a
struct config(200 bytes) and must not alter it - a function called once per program start that logs a
struct build_info(96 bytes) - a function called per packet that inspects a
struct header(64 bytes)
Exercise 2 — refute the rule. A colleague rejects struct point add(struct point, struct point) because "returning structs copies twice." Using §6, say what was true when that rule was made, what the ABI does now, and what measurement would settle it on your target.
Exercise 3 — predict the shape. Without compiling, say whether each will touch memory in the callee, and why.
int f(struct small s); /* 8 bytes */
int g(const struct small *s); /* 8 bytes */
int h(struct big b); /* 80 bytes */Exercise 4 — find the mislabelled function. Each signature is wrong about what the function does. Correct each one.
void print_config(struct config *c); /* only reads */
int area(const struct rect *r); /* 16 bytes, only reads */
void reset(struct counter c); /* meant to zero the caller's */Exercise 5 — the guarantee. Explain in two sentences why passing by value is a stronger non-interference guarantee than const T *, and name the two chapters that establish the weakness of const.
Exercise 6 — remove the variable. Rewrite this using value-returning functions, and say how many named objects you removed.
struct point t1, t2, result;
add_into(&t1, &a, &b);
scale_into(&t2, &t1, 3);
add_into(&result, &t2, &c);Exercise 7 — the hot loop. A function returns a 4 KB structure by value and is called once per element over a million elements. §6 says returning by value was cheaper for one call. Explain why the table in §9 still recommends an out-parameter here, and what the caller gains.
Exercise 8 — generate the evidence. Write the exact command you would run to see the code your compiler emits for a one-function file, and say why its output is better evidence than timing the program in a loop.
12. Summary
- C passes and returns structures by value; the mechanism is the ABI's, not the language's, and not portable.
- Small by value: one register, no memory. Small by pointer: a load — strictly more work.
- Large structures: the two callee bodies were identical; the cost is in the caller, 14 instructions against 1, per call site.
- Returning a large structure by value beat the out-parameter rewrite, 7 instructions to 11 — the callee builds in the caller's destination.
- "Never return a big struct" is advice about an obsolete ABI.
- By value guarantees non-interference;
const T *promises it;T *announces mutation. - Returning by value composes; an out-parameter forces a named intermediate.
- Intent outranks cost, and never quote a cost you have not generated.
Three chapters have now reported a sizeof without explaining it. A structure of two ints and a char measured 12 bytes rather than 9; a window of two points and eight characters measured 24; the structure this chapter called "big" has sixty-four bytes of array, eight of double and one char — seventy-three — and measured 80. Those gaps are not slack and they are not a compiler being wasteful. They follow from one rule, applied member by member, and once you know it you can predict any structure's size on paper and — far more usefully — you can reorder three members and watch a structure shrink by a third without removing anything from it. That rule is the next chapter, and it is the one chapter in this module that c.ts rates Critical.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
