The checksum was hashing padding, not your fields.
That fact explains both the mismatch and the crash.
I rebuilt a small harness so you can see it.
What failed
Two processes hashed the same header and still disagreed.
Source, flags, and sizeof(Header) all matched on paper.
So which byte actually moved between the two runs?
I printed twelve bytes for sizeof on this x86_64 box.
Named fields used seven bytes in total.
Five bytes had no name in my source.
The first hypotheses were noise
Was it endianness across the two hosts?
Was it ARM alignment versus x86 alignment?
Those questions feel mature, but they were still wrong.
I swapped byte order in a unit test.
The mismatch did not move an inch.
I added a packed attribute next, and hashes matched.
Did packing fix the hash, or hide the holes?
Packing also misaligned uint32_t len on this ABI.
An aligned load of that field becomes undefined.
Treat model output as a queue
I wanted a cheap second list of guesses.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
I used MonkeyCode's free models and free server for that guess loop.
I asked why two header digests would diverge.
The reply named endianness, then packing, then memset.
I compiled each guess, ran it, and threw it out.
The model does not get a merge vote here.
A failing harness does, and that is the method.
Would you trust a packing attribute you did not measure?
Root cause
The compiler inserted padding after the first field.
I never initialized those bytes or named them.
A plain Header h; left stack garbage in the holes.
I set type, then len, then flags by hand.
The digest walked sizeof(Header) with a byte loop.
Process A hashed zeros; process B hashed leftover 0x7f bytes.
Same fields produced different object bytes and a different digest.
Packing deleted the holes, so the garbage vanished.
It also broke alignment for the 32-bit length field.
A memset of the struct can paper over this.
It still hashes ABI wallpaper, which I do not want.
The digest should see fields, not padding policy.
Debug workflow I will reuse
Follow this order and do not skip the dump.
- Print
sizeof(Header)and everyoffsetoffor the fields. - Hexdump the whole object, not only the named fields.
- Compare those dumps from both processes, byte by byte.
- Park model guesses in a list and do not apply them yet.
- Hash each field in a defined order and ignore holes.
- Rebuild with ASan, then rebuild with packing turned off.
Step two is the one people skip.
They print fields with operator<< and call it done.
Padding never goes through operator<<, so remember that dump.
Lab harness
This reconstructed example is not a production dump.
Compile the file and run it twice in a row.
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
struct Header {
uint8_t type;
uint32_t len;
uint16_t flags;
};
static uint32_t fnv1a(const void* p, size_t n) {
const auto* b = static_cast<const uint8_t*>(p);
uint32_t h = 2166136261u;
for (size_t i = 0; i < n; ++i) {
h ^= b[i];
h *= 16777619u;
}
return h;
}
static uint32_t checksum_fields(const Header& h) {
uint32_t x = fnv1a(&h.type, sizeof(h.type));
uint32_t y = fnv1a(&h.len, sizeof(h.len));
uint32_t z = fnv1a(&h.flags, sizeof(h.flags));
x ^= y;
x *= 16777619u;
x ^= z;
return x;
}
static void dump(const char* tag, const Header& h) {
const auto* b = reinterpret_cast<const uint8_t*>(&h);
std::printf("%s:", tag);
for (size_t i = 0; i < sizeof(Header); ++i) {
std::printf(" %02x", b[i]);
}
std::printf("\n");
}
int main() {
alignas(Header) unsigned char junk[256];
std::memset(junk, 0x7f, sizeof(junk));
Header a;
a.type = 1;
a.len = 32;
a.flags = 0x10;
Header b;
b.type = 1;
b.len = 32;
b.flags = 0x10;
std::printf("sizeof=%zu\n", sizeof(Header));
std::printf("off type=%zu len=%zu flags=%zu\n",
offsetof(Header, type),
offsetof(Header, len),
offsetof(Header, flags));
dump("a", a);
dump("b", b);
std::printf("raw a=%08x raw b=%08x\n",
fnv1a(&a, sizeof(a)),
fnv1a(&b, sizeof(b)));
std::printf("fields a=%08x fields b=%08x\n",
checksum_fields(a),
checksum_fields(b));
}
On many stacks the two raw hashes will differ.
The field hashes should match on every run.
That split is the bug on one screen.
Build it with this boring compiler command.
c++ -std=c++17 -O2 -Wall -Wextra -Wpadding -o pad pad.cpp
./pad
./pad
Run the binary twice and compare the raw lines.
If they match, paint the stack with junk first.
I fill a throwaway buffer before the headers.
The junk buffer sits above Header a on purpose.
Now the holes have a color and the dump gets loud.
Ask one question while you stare at those 7f bytes.
Which of those bytes did your protocol actually define?
The fix I actually kept
I value-initialize the object, then hash fields only.
Header h{};
h.type = 1;
h.len = 32;
h.flags = 0x10;
auto digest = checksum_fields(h);
Header h{} zeros padding on this platform.
I still refuse to hash sizeof(Header) for a protocol.
Wire formats need a field list, not an ABI snapshot.
I also added a static layout check for surprises.
static_assert(offsetof(Header, type) == 0);
static_assert(offsetof(Header, len) == 4);
static_assert(offsetof(Header, flags) == 8);
That assert does not freeze padding across ABIs.
It only catches a surprise move in this build.
If you need a wire struct, write an explicit encoder.
Do not ship a packed attribute as your hash function.
A tiny encoder is boring, and that is the point.
std::array<uint8_t, 7> encode(const Header& h) {
std::array<uint8_t, 7> out{};
out[0] = h.type;
std::memcpy(&out[1], &h.len, 4);
std::memcpy(&out[5], &h.flags, 2);
return out;
}
Hash out.data() and out.size(). Never hash &h.
Endianness now lives in one function you can test.
Padding does not get a vote anymore. See the difference?
Decision table
Use this table before you touch attributes.
| Move | Hash stable? | Alignment safe? | Use as protocol? |
|---|---|---|---|
Initialize, then hash sizeof
|
maybe | yes | no |
Packed struct, hash sizeof
|
often | no | no |
memset, then hash sizeof
|
often | yes | no |
| Field-by-field digest | yes | yes | yes |
| Explicit encoder buffer | yes | yes | yes |
Maybe means it depends on leftover stack bytes.
Often means you hid the holes without defining them.
Only the last two rows count as a design.
Limitations
This is not a cryptography article at all.
The FNV-1a loop is a detector, not a boundary.
Do not paste proprietary headers into any remote tool.
Layout is ABI specific, even for this tiny struct.
x86_64 System V is not your MCU firmware ABI.
ASan will not always flag padding in a byte loop.
Defaulted equality can also read those padding bytes.
The free compile loop is for throwaway guesses only.
It is not an SLA and not your release builder.
Secrets, customer traces, and keys stay on your machine.
Who should not use this approach
Skip the remote guess loop if the struct is private.
Skip packing if you load fields with SIMD or atomics.
Skip memset if a constructor must actually run.
If you need a stable wire hash, write a schema.
If you need speed, hash the encoder buffer you built.
Do not fix padding by deleting it in a public header.
What I keep from this
Print the holes, name them, then stop hashing them.
Models will offer packing first, so ask for a dump instead.
Did the digest match because fields matched, or because garbage matched?
Your local compiler is enough to run the harness.
I only needed a disposable queue of wrong answers.
Top comments (0)