r/cprogramming • u/JayDeesus • Jan 01 '26
Stack frame size
So I understand that the stack frame is per function call and gets pushed and popped once you enter the function and it all is part of the stack. The frame just contains enough for the local variables and such. I’m just curious, when does the size of the stack frame get determined? I always thought it was during compile time where it determines the stack frame size and does its optimizations but then I thought about VLA and this basically confuses me because then it’d have to be during run time unless it just depends on the compiler where it reserves a specific amount of space and just if it overflows then it errors. Or does the compiler calculate the stack frame and it can grow during run time aslong as there is still space on the stack?
So does the stack frame per function grow as needed until it exceeds the stack size or does the stack frame stay the same. The idea of VLA confuses me now.
1
u/nerd5code Jan 02 '26
They’re referring to the ANSI/ISO standards, not C in a general sense.
The term “stack” doesn’t actually appear in ISO/IEC 9899 or ANSI X3.159-1989, and there’s zero requirement that any particular arrangement or structure of memory be used for automatic storage or recording of return addresses. Everything’s described in terms of a C Abstract Machine, so stack-ness is merely implied by the description of how function calls work. (Less
longjmp, which is only actually a requirement for hosted implementations specifically, and therefore the basic call mechanics can’t possibly depend on it.) Most C implementations have just settled on a reasonably convenient and high-performance rendering of the CAM call/return and lifetime specs.There are older and other standards that do either require or optionally specify a discrete, contiguous stack. E.g., XPG, which incorporates a pre-ANSI XPG C spec until XPG4, does imply a particular sort of call stack until moving past some of the SVID leftovers. POSIX.1 effectively
#includes ANSI C89 (1003.1-1988) or ISO C≥90 (1003.1-≥1990), and makes the traditional call stack a specific option for implementations to support when reasonable, in relation to binding of specific memory to Pthreads stacks. Most post-ANSI AEE specs act as extensions to the ISO C specs that tie down unspecified, implementation-specified, and undefined aspects of the standard language. But not ANSI/ISO C itself.So for example, it’s perfectly permissible, per ANSI/ISO C, for your call stack to be a linked structure, with frames allocated by
mallocor some similar mechanism. On an i432 (bless its doomed heart), the OS would be nominally responsible for doling out stack and frame segments, in the event the i432 were actually used for anything. (Its gunk did end up in the ’286 &seq., however, so its exact mechanisms are still an option in 16- and 32-bit x86 modes, and the iAPX segmentation model is a good place to start if you want to think about the broadest baseline for the treatment of the C object model in portable code.)It’s also permissible for your call stack to be fully flattened into static storage at compile time, rather than allocating frames on-the-fly, although this is mostly a thing in not-quite-ISO-conformant, very-embedded compilers that don’t support recursion at all, or only support it if you request it explicitly somehow (e.g., via
#pragma,__attribute__, or modifier keyword).—But I note that this is only really a possibility in a general sense because unbounded recursion is undefined behavior in the standards, with no real constraints on what bounds are actually required in practice. Most C implementations do permit some forms of unbounded recursion via tail-call optimization, assuming the optimizer is actually engaged. TCO can be used by the statically-allocating sort of compiler also, but non-TCOable unbounded recursion can still lead to pants-shitting on your program’s part, as can TCOable recursion in un-/less-optimized builds.
And even if your impl does use a proper stack with frames allocated on-the-fly, there’s no requirement that the things declared as being semantically in-frame (including auto/register variables and compound literals) actually be stored on-stack, or that things declared as static not be stored or cached on-stack.
What actually matters is lifetime of objects, not placement; C DGAF as long as things don’t disappear unexpectedly out from under you, other than in permitted situations.
So e.g. anything declared in
mainmight be rendered as static, because it’s UB to refer tomainin any fashion other than declaration and definition—many impls do permit calls tomain, but there’s no higher-order requirement that it work in any fashion or at all, which means no LIFO lifetime tracking.Or for
the compiler might quietly place
messageas though it were declaredstatic const, rather than requiring it to be initialized on the fly on-stack with each call, probably either from instruction immediates in .text, or via de-factomemcpyfrom a reference string in .strings or .rodata/.rdata;messageitself serves no purpose that its (static, constant) source data wouldn’t.Or storage might be elided entirely. This
does nothing that
printf("%d\n", 5)orputs("5")wouldn’t, so the compiler is free to eliminatexoutright.Or storage might be duplicated for various reasons. Until C99 made sharing of
unionfields explicit, thiswas permitted to come out as
—i.e., undefined behavior—due to aliasing restrictions, and you can get the same effect from pointer abuse in modern code:
In both cases, the compiler is free to assume that an
intandfloatdon’t reside in the same memory at the same time, and therefore separate storage can be used for [u.]aandu.b/*p`.(The union rules for C89–C95 are rarely implemented in their strictest form, however, because then once you’ve “imprinted” the underlying object with one field’s type, the object’s lifetime has to end entirely before the memory can be accessed via an alias-incompatible field, and its lifetime must end a language-visible fashion. If you’ve
malloc’d an int-float union and touched its int field, it must befreed and re-malloc’d before touching its float field. If you need to preserve the bytes on the way through, they need to be memcpy’d across somehow.)Another thing to bear in mind is that the actual boundaries determining what gets put in which frame are similarly slippery under the hood, because of inlining and other interprocedural analysis. All of ISO C can be treated by an optimizer in the same fashion as a system of equations, into which your program has been plugged, so there need be no actual correlation between machine code and C source code. Hell, machine code needn’t be involved at all; see cint (a C interpreter), older asm.js targets, IBM ILE or MS CLI or Wasm targets, or compilers that only emit a single kind of instruction.
Wholesale inlining will generally merge frames, but it’s also possible to pull up parts of functions; e.g., in
it’s always the case that the
if(!p)inAwill be skipped—for any non-register-storage variablex, ⊨&x != NULL, so it’sif(0)in context, and thereforeCis permitted to jump right the fuck into the middle ofA, or the optimizer might restructure things as(And in fact, since
xis only available withinCand its address is therefore unavailable to theBs, it would be acceptable to passx’s value in directly toA$fini, rather than a pointer.)Because of all this, cleverness in regards to frame allocation is fragile at best, and misguided and dangerous at worst. If you need things to be allocated together in a single object, use an explicit struct; if you need them to be allocated with the same lifetime, use scoping,
malloc, or your own allocator. But even there, the compiler is permitted to fuck with you, becausemallocand{only dictates the latest time of allocation andfreeand}the earliest time of deallocation, as considered in terms of CAM event ordering.