Skip to content
← Prism
Prism
Overview
Features
defer orelse zeroinit raw bounds-check auto-unreachable auto-static check
C in practice
goto skips init tag namespace struct padding strict aliasing char signedness signed overflow VLAs
Spec Draft Releases Blog
GitHub ↗

Does = {0} zero the padding?what really sits between your struct members

Not the padding. = {0} zeroes every member and guarantees nothing about the bytes between them. A designated initializer leaves those bytes holding whatever was last on the stack, which is how struct padding ends up in network packets. C23 added exactly one initializer that carries a real guarantee, and it is not {0}.

#The question

Take a struct with a hole in it:

struct S { char a; int b; };
/* sizeof(struct S) == 8, b at offset 4, so bytes 1..3 are padding */

Four ways to initialize it. Which ones leave bytes 1 through 3 deterministic?

struct S w = { .a = 1, .b = 2 };   /* designated  */
struct S x = {0};                  /* all-zero    */
struct S y = {};                   /* C23         */
struct S z; memset(&z, 0, sizeof z);

#What GCC actually does

Each case below runs after a helper fills 256 bytes of stack with 0xAA, so leftover data is visible rather than incidentally zero. GCC 11.4, x86_64, byte dump of the whole object:

-O0
designated {.a,.b}  01 aa aa aa 02 00 00 00
= {0}               00 00 00 00 00 00 00 00
C23 = {}            00 00 00 00 00 00 00 00
memset then assign  01 00 00 00 02 00 00 00
static storage      01 00 00 00 02 00 00 00
-O2
designated {.a,.b}  01 e1 8c 0c 02 00 00 00
= {0}               00 00 00 00 00 00 00 00
C23 = {}            00 00 00 00 00 00 00 00
memset then assign  01 00 00 00 02 00 00 00
static storage      01 00 00 00 02 00 00 00

Read the first row. At -O0 the padding is aa aa aa, the exact bytes the helper wrote. At -O2 it is e1 8c 0c, a different leftover. The designated initializer set both members correctly and left three bytes of unrelated stack memory sitting inside the object. Re-running the same program on GCC 11.4.0 aarch64 gives aa aa aa at both levels, so which leftover you get depends on target and build, and the fact that you get one does not.

-Wall -Wextra reports nothing about this.

#What the standard guarantees

Less than four zeroed rows suggest, and more than C11 offered. C23 changed the answer for one of the five cases.

InitializerMembersPadding, automatic storageBacked by
= { .a = 1, .b = 2 }setunspecifiedC23 6.2.6.1p6
= {0}zerounspecified between membersC23 6.7.11p11, p22
= {} (C23)zerozero bits, guaranteedC23 6.7.11p11
memset then assignsetzero at the memset, then unspecifiedC23 6.2.6.1p6
static or thread storagesetzero bits, guaranteedC23 6.7.11p11

The C23 empty initializer is the row that is new and the row most people have not internalised. 6.7.11p11 reads: if an object has static or thread storage duration and is not initialized explicitly, or any object is initialized with an empty initializer, it is subject to default initialization, under which "if it is an aggregate, every member is initialized (recursively) according to these rules, and any padding is initialized to zero bits". There is no storage-duration condition on the empty-initializer clause. struct S y = {}; on a local zeroes the padding, by the standard, and that is the only initializer syntax of which this is true.

= {0} does not get there, and the reason is worth following. It is an initializer list with one element, not an empty initializer. It sets a explicitly; b is the remainder, and 6.7.11p22 makes the remainder subject to default initialization. So padding inside a sub-aggregate member that falls in the remainder is zeroed, while padding between top-level members is not covered by anything. For struct S { char a; int b; } the hole sits between a and b, which is precisely the uncovered case. GCC's Jakub Jelinek reached the same reading when implementing this, on the gcc-patches list.

C11 said the same thing about static storage in 6.7.9p10 and about stores in 6.2.6.1p6. It had no empty initializer at all, so under C11 the static row really was the only guaranteed one. If you are targeting C11, the original conclusion stands.

One rule cuts across all five rows. C23 6.2.6.1p6: "When a value is stored in an object of structure or union type, including in a member object, the bytes of the object representation that correspond to any padding bytes take unspecified values." That applies to stores, not just initialization, so assigning to a member after a memset is permitted to re-dirty the padding. GCC does not, as the fourth row shows, but the standard allows it, and struct assignment is explicitly called out as free to copy padding or not.

#GCC 15 took one of them away

The argument that a compiler's generosity is not a guarantee is easier to make now that GCC withdrew some. Unions are not aggregates; C23 6.2.5 says "array and structure types are collectively called aggregate types", and 6.7.11p11 handles unions with a separate clause covering only the first named member. GCC 15 started following that literally: = {0} on a union initializes the first member and leaves the rest of the object alone.

union U { char c; long l; };
union U u = {0};   /* GCC 11.4: all 8 bytes zero.
                      GCC 15:    byte 0 zero, bytes 1..7 whatever was there. */

The Linux kernel had depended on the old behaviour. Its stackinit KUnit selftest started failing on GCC 15, and rather than revert, GCC added -fzero-init-padding-bits= and told projects to opt back in. The kernel now passes -fzero-init-padding-bits=all from kbuild when the compiler supports it.

This is the whole point of the page in one changelog entry. Code that read = {0} and concluded "zeroed" was correct on every compiler anyone had tried, for years, and then was not.

#Why this becomes a security bug

Padding is invisible in source and included in sizeof. Any operation that moves the whole object moves the padding with it:

struct msg { char kind; int len; };          /* 3 bytes of padding */

void reply(int fd, char kind, int len) {
    struct msg m = { .kind = kind, .len = len };
    write(fd, &m, sizeof m);                 /* sends 3 bytes of stack */
}

Three bytes per message, drawn from whatever the previous call left behind. The same applies to fwrite to a file, hashing a struct by its bytes, and memcmp between two structs that compare unequal despite identical members. SEI CERT has a rule for each: DCL39-C for the trust boundary, EXP42-C for the comparison.

The kernel version of this is not hypothetical and not historical:

The fix in those advisories is almost always the same: zero the whole object first.

#Getting deterministic padding

Zero the object, then assign. The one approach that works on every compiler and every standard version:

struct msg m;
memset(&m, 0, sizeof m);
m.kind = kind;
m.len  = len;

The memset writes all eight bytes including the hole. Strictly, a later store to m.len is permitted to disturb the padding again; no mainstream compiler does this, and if you need a guarantee rather than an observation, serialize field by field instead of writing the struct.

Use = {} if you are on C23. It is the only initializer the standard says zeroes padding, it needs no header, and it survives a compiler deciding to be less generous than it used to be. GCC accepts it back to GCC 11 and warns under -std=c11 -pedantic with ISO C forbids empty initializer braces, so you will know if you have compiled it against the wrong standard.

Clear the padding directly. __builtin_clear_padding zeroes the holes and nothing else, after the members are already set. GCC has had it since GCC 11, Clang since Clang 18:

struct S w = { .a = 1, .b = 2 };
/* 01 aa aa aa 02 00 00 00 */
__builtin_clear_padding(&w);
/* 01 00 00 00 02 00 00 00 */

It recurses into nested structs and arrays, which a hand-written memset of a specific offset range does not.

Do not rely on = {0} for anything crossing a trust boundary. It is fine for ordinary program state, where only the members are read. It is not a substitute for memset when the bytes leave your process, and on a union under GCC 15 it is not even close.

Remove the padding. Ordering members widest-first often eliminates the hole entirely. struct { char a; int b; char c; } is 12 bytes; the same three members as struct { int b; char a; char c; } is 8. -Wpadded reports every struct that has a hole, which is noisy but useful once when auditing a wire format.

Do not reach for __attribute__((packed)) to fix this. It removes the padding and introduces unaligned member access, which is slower on x86 and a fault on some other targets. Reorder instead, or serialize explicitly.

One tool that does not solve this: C23's memset_explicit. It exists to stop the compiler eliding a wipe of memory that is about to die, not to reach padding a plain memset would miss. For clearing a struct before you fill it, ordinary memset is the right call.

#Compiler flags that cover it

FlagAvailableWhat it does
-fzero-init-padding-bits=allGCC 15.1+, not in ClangZeroes struct and union padding that was never guaranteed. What the Linux kernel switched to.
-Wzero-init-padding-bits=GCC 15.1+Flags initializers that might leave padding unzeroed, so you can find what needs the flag above.
-ftrivial-auto-var-init=zeroGCC 12+, Clang 8+Zeroes uninitialized automatic variables, including padding of struct and union locals. Does not reach objects that do have an initializer.
-Wanalyzer-exposure-through-uninit-copyGCC 12+, with -fanalyzerReports copies across a trust boundary that carry uninitialized bytes, padding included. Needs a plugin to know where your boundaries are.
-WpaddedGCC, ClangNames every struct with a hole. Too noisy for a default build, right for a wire-format audit.

None of these is portable across both major compilers today, which is the argument for fixing it in the source with = {}, memset, or explicit serialization.

#How Prism handles it

Prism makes zero-initialization the default for locals, which closes the members half of this problem across a whole codebase without touching a line of source. Prism 1.1.0 fixed the union case in its own output. = {0} on a union initializes only the first named member, and the implicit-zeroing rule in C11 6.7.9p21 applies to aggregates, which by 6.2.5p21 means arrays and structs and not unions, so unions route to __builtin_memset instead:

union U { char c; long l; };
long g(void) { union U u; return u.l; }

/* prism transpile emits: */
long g(void) { union U u; __builtin_memset(&u, 0, sizeof(u)); return u.l; }

The rewrite follows typedef chains and _Atomic(union U), not just the literal spelling. Off a stack deliberately filled with 0xAA, that function returns 0 through Prism and 208765191391472 through plain gcc -O0. GCC 15 made the fix load-bearing rather than defensive.

Structs are a different story, and it is the same story as the rest of this page. Prism emits struct S s = {0}; for an uninitialized struct local, which covers every member and leaves the standard saying nothing about the bytes between them. Zero-init is not a padding fix for anyone, Prism included.

The padding half stays a design question regardless of tooling. If a struct crosses a trust boundary, zero it explicitly or serialize it field by field. No compiler default should be the thing standing between your stack and a socket.

#Further reading

Byte dumps re-measured on GCC 11.4.0, Ubuntu 22.04, and reproduced on aarch64. Padding values vary by machine, build and run. GCC 15 and Clang behaviour is cited from vendor sources, not run here.