Disclosure: I'm the author and maintainer of the open-source repo referenced in this piece. It's free, MIT-licensed, and I don't monetize it in any way. This piece was originally published on dev.to; this version has been reworked for HackerNoon's house style, and revised further based on technical feedback from readers of the original post.

What a Single uint32_t Taught Me About Types, Endianness, and Alignment

The question that started this

The first time I wrote int x = 42;, I pictured a box labeled "integer," sitting somewhere in memory, holding the number 42 the way a jar holds marbles.

That mental model is useful. It's also incomplete. Your CPU doesn't store an "integer" - it stores a pattern of bits, and separately, some instruction decides to treat that pattern as a signed number. Change the instruction, and the exact same bits can mean something else entirely.

I wanted to see that separation directly, so I wrote a small C experiment (source on GitHub, linked below): one 32-bit container, reinterpreted five different ways, with nothing hidden beyond what C's own type-punning rules already allow. The original writeup framed this as "types are a lie" - a reader, Paul J. Lucas, correctly pushed back: floats get real dedicated hardware, so calling that a "lie" overstates things, and the piece glossed over endianness and alignment, two things that actually determine whether this kind of reinterpretation is even valid on a given machine. This version fixes both, and walks through each type in more depth than the original did - full bit-level breakdown, not just the punchline.

What actually lives in memory

Declare this in C:

```

include

int main() {
int myInt = 42; // "Integer" type
float myFloat = 2.7f; // "Floating-point" type
char myChar = 'A'; // "Character" type
char myString[] = "ABC"; // "String" type
_Bool myBool = 1; // "Boolean" type
}
```
The compiler doesn't create five different kinds of storage. It allocates bytes and emits instructions that treat those bytes a specific way.

Here's the actual, unedited assembly GCC produces for that function:

"main": push rbp mov rbp, rsp mov DWORD PTR [rbp-4], 42 movss xmm0, DWORD PTR .LC0[rip] movss DWORD PTR [rbp-8], xmm0 mov BYTE PTR [rbp-9], 65 mov DWORD PTR [rbp-14], 4407873 mov BYTE PTR [rbp-10], 1 mov eax, 0 pop rbp ret .LC0: .long 1076677837
Line by line: [rbp-4] gets myInt's raw value, 42, with a plain movmyFloat doesn't get the same treatment - its bit pattern (1076677837, the IEEE-754 encoding of 2.7f) is stashed in a read-only constant (.LC0), loaded into the SSE register xmm0 with movss, and only then written to the stack. myChar is a single-byte mov of 65myString's three characters plus a null terminator are packed into one 32-bit mov as the literal 4407873 - that's "ABC\0" read as one number, which is exactly the trick the rest of this piece is built on. myBool is another single-byte mov of 1. Then the function zeroes eax for its return value and returns.

The float doesn't just get a different mov - it gets routed through an entirely separate register file (xmm0) before it's stored. That's the detail I underplayed the first time around, and it matters: floating-point isn't "the same ALU reading bits differently." x86 (and most modern architectures) ship a genuinely separate floating-point unit with its own registers and its own circuitry for mantissa/exponent arithmetic. Storage is uniform bits; computation is not.

One container, four notations, five readings

The repo's core idea is a single uint32_t:

uint32_t genericContainer;
Two independent things get varied across the experiment, and it's worth separating them clearly:

  • Notation- the same bit pattern written as binary (- 0b...), octal, decimal, or hex. The CLI takes- bin,- oct,- dec, or- hex, and every mode produces identical output, because- 0b00000000000000000000000000101010,- 052,- 42, and- 0x2Aare just four different ways of- writingthe same value. It's a smaller version of the same lesson as the rest of the piece: even before you get to types, text notation is already an interpretation layer on top of the bits.
  • Type- the actual subject here: reinterpreting that identical bit pattern as an int, a float, a character, a string, or a boolean.

Here's each one broken all the way down.

1. As an integer

genericContainer = 42; printf("Integer representation: %d\n", genericContainer);
Getting from 42 to its bit pattern is repeated division by 2, reading the remainders bottom-to-top:

42 ÷ 2 = 21 remainder 0 21 ÷ 2 = 10 remainder 1 10 ÷ 2 = 5 remainder 0 5 ÷ 2 = 2 remainder 1 2 ÷ 2 = 1 remainder 0 1 ÷ 2 = 0 remainder 1 → 101010₂ → padded to 32 bits: 00000000000000000000000000101010
In memory, on this little-endian machine, that's stored as bytes 2A 00 00 00 - least significant byte first. The %d specifier tells printf to hand those 32 bits to code that reads bit 31 as a sign bit and the rest as magnitude (two's complement for negative numbers, though 42 is positive so the sign bit is just 0 here). No conversion happens at this point - the value was already sitting there as 42%d just chooses the "signed integer" reading over any other.

2. As a float - the full breakdown

This is the one worth doing properly, because it's also where the "types are a lie" framing breaks down hardest.

Step 1 - split the decimal value. 2.7 is 2 (integer part) plus 0.7 (fractional part).

Step 2 - convert the integer part. 2₁₀ = 10₂. Easy.

Step 3 - convert the fractional part. This is where it gets interesting: multiply by 2 repeatedly, keeping the integer digit each time.

0.7 × 2 = 1.4 → 1, remainder 0.4 0.4 × 2 = 0.8 → 0, remainder 0.8 0.8 × 2 = 1.6 → 1, remainder 0.6 0.6 × 2 = 1.2 → 1, remainder 0.2 0.2 × 2 = 0.4 → 0, remainder 0.4 (back to the start - it repeats forever)
0.7 in binary is 0.10110011001100110011..., repeating - the same way 1/3 doesn't terminate in decimal. This matters: 2.7f stored as a 32-bit float is never exactly 2.7. It's the closest value IEEE-754 single precision can represent, which is why floating-point equality comparisons (if (x == 2.7f)) are a well-known footgun.

Step 4 - normalize to scientific notation. 10.10110011...₂ = 1.010110011...₂ × 2¹.

Step 5 - pack into the IEEE-754 fields. Single precision gives you 32 bits split three ways:

  • Sign(1 bit):- 0- positive.
  • Exponent(8 bits): the actual exponent is- 1, but IEEE-754 stores it with a bias of 127, so it's stored as- 1 + 127 = 128 = 10000000₂.
  • Mantissa(23 bits): the digits after the leading- 1.in the normalized form, rounded to fit:- 01011001100110011001101.

Stitched together: 0 10000000 01011001100110011001101, which as one 32-bit run is 01000000001011001100110011001101, stored in memory as bytes CD CC 2C 40.

Step 6 - read it back.

genericContainer = 1076677837; // that same bit pattern, as a decimal literal printf("Float representation: %.2f\n", *(float*)&genericContainer);
*(float*)&genericContainer tells the compiler "don't read this as an integer - hand these bits to the FPU and decode them as IEEE-754." The FPU reverses steps 4–2: reads the sign, applies 2^(exponent - 127) to the exponent field, multiplies by 1.mantissa, and lands on 2.70 when rounded to two decimal places. The 23-bit mantissa is why you get 2.70 back instead of 2.7000001 or some other nearby value - there's genuinely more precision loss possible here than the integer case, because the fractional conversion never terminated in the first place.

3. As a character

genericContainer = 65; printf("Character representation: %c\n", genericContainer);
Same division-by-2 process as the integer case gets you from 65 to 1000001₂, padded to 00000000000000000000000001000001. The only thing that's different is the interpretation %c applies: instead of printing the magnitude, it treats the low 8 bits as an index into the ASCII table and prints whatever's there - 65 maps to 'A'. This is also why the character case only ever really uses the bottom byte of a 32-bit container: standard ASCII is a 7-bit encoding (0–127), so everything above bit 6 is wasted space when you're storing a single character this way. (Modern text handling - UTF-8 in particular - uses a variable number of bytes per character precisely because fixed-width byte-per-character storage doesn't scale to the rest of Unicode; that's a whole separate rabbit hole this experiment doesn't get into.)

4. As a string - two different methods, and this is where endianness actually shows up

Packing "ABC" into 4 bytes works like this: each character's ASCII byte goes into one byte of the container, and the 4th byte is left 0x00 - the null terminator that tells C where the string ends.

'A' = 65 = 0x41 'B' = 66 = 0x42 'C' = 67 = 0x43 '\0' = 0x00
Laid out little-endian in memory, from the lowest address to the highest: 41 42 43 00. As a single 32-bit value read as a number, that's 0x00434241 - notice the byte order flips when you read it as one number versus reading it as four sequential bytes. That flip is the entire endianness lesson in miniature.

The repo demonstrates extracting the string back out two different ways, and the contrast between them matters:

// Method 1: extract each byte's value with shifts and a mask printf("String extraction: %c%c%c\n", (genericContainer >> 0) & 0xFF, // 'A' (genericContainer >> 8) & 0xFF, // 'B' (genericContainer >> 16) & 0xFF); // 'C' // Method 2: cast the address directly to a char pointer printf("Direct string interpretation: %s\n", (char*)&genericContainer);
Both print ABC on the machine I ran this on, but they get there differently:

  • Method 1 operates on the value.- (0x00434241 >> 8) & 0xFFmathematically equals- 0x42no matter what architecture you're on - the compiler handles wherever the bytes actually sit in memory. This is portable.
  • Method 2 reads raw memory byte by byte.On little-endian x86, the least-significant byte (- 0x41,- 'A') sits at the lowest address, so walking forward through memory gives- A,- B,- Cin order. On a big-endian machine, the same bit pattern has its bytes laid out in the opposite order, and that pointer walk would come out reversed - even though the underlying value never changed.

There's also a quieter safety point buried in Method 2: it only works because the 4th byte happens to be 0x00%s keeps reading memory until it hits a null byte - if you packed four non-null characters into the container instead of three-plus-terminator, (char*)&genericContainer would have no null byte to stop at, and %s would read straight past the end of a 4-byte variable into whatever memory happens to follow it. That's not a hypothetical; it's the exact shape of a classic buffer over-read.

This is also the distinction the earlier version of this piece buried in a parenthetical memory dump instead of explaining. The practical upshot: type-punning through arithmetic is safe across architectures; type-punning through a raw pointer cast is a statement about byte order, and it's the reason formats like PNG and protocols like TCP/IP specify network byte order explicitly rather than trusting whatever the host machine uses.

5. As a boolean

genericContainer = 1; printf("Boolean representation: %d\n", genericContainer);
There's no dedicated "truth" circuit anywhere in the CPU. When I actually compiled a version of this that cast the container to _Bool, GCC didn't even bother with a branch - it emitted:

cmp DWORD PTR [rbp-44], 0 setne al
cmp against 0 sets the processor's zero flag; setne ("set if not equal") writes 1 or 0 directly into al based on that flag, with no jump at all. That's a more honest answer than the branchy version I described in an earlier draft of this piece - modern compilers avoid branches where a flag-to-byte instruction will do, since branches are comparatively expensive. Either way, the point holds: _Bool in C99 is a 1-byte integer type with a naming convention layered on top. Anything nonzero reads as true, zero reads as false, and "boolean logic" is ordinary integer comparison wearing a costume.

Alignment: the other detail that was too brief

Alignment matters here because half the tricks above rely on pointer casts, and pointer casts have rules.

Most architectures want a uint32_t to live at a memory address divisible by 4. Some (older ARM, SPARC) fault outright on a misaligned 4-byte access. x86 tolerates it but pays a real performance penalty - the CPU may need two memory accesses instead of one.

This is also why struct layouts aren't always what you'd naively expect:

struct Example { char a; // 1 byte int b; // 4 bytes };
You might expect 5 bytes total. Most compilers produce 8 - 3 bytes of padding get inserted after a so b lands on a 4-byte boundary. Alignment is why sizeof a struct isn't just the sum of its members, and why casting an arbitrary char* offset to a uint32_t* - tempting after reading this far - isn't automatically safe. On some platforms it's undefined behavior, not just unconventional.

Storage vs. semantics - the more careful version

The CPU never asks "what type is this?" It executes whatever instruction it's given, and the instruction determines the interpretation:

  • The ALUtreats bits as integers.
  • The FPUtreats bits as IEEE-754 floats, using dedicated hardware - not a relabeled ALU.
  • Everything else (character lookups, string extraction, boolean comparison) rides on top of the same integer machinery, with byte order and alignment as the ground rules for whether reinterpreting that storage is even valid.

So the accurate claim isn't "types are a lie." It's narrower, and more useful: storage is a shared substrate, but computation on that substrate genuinely differs by type, and two more rules - endianness and alignment - govern whether reinterpreting that storage is safe on a given machine at all. Paul's grapes-and-marbles comparison from the original comment thread is the right one: types are abstractions, not deceptions. You can eat a grape; you can't eat a marble. The atoms don't care, but the behavior really is different. The container itself is also arbitrary - the repo works the same with uint8_tuint64_t, or void*uint32_t just happens to line up conveniently with intfloat, and a 4-character string.

Checking the theory against a live memory trace

Everything above is a claim about what should happen. I wanted to check it against what actually does, so I extended the experiment with explicit trace/cast pairs - one variable holding the raw uint32_t bits, a second holding the same bits reinterpreted as the target type - and ran it through a memory visualizer to watch the addresses and bytes directly:

```

include

include

include

int main() {
int myInt = 42;
float myFloat = 2.7f;
char myChar = 'A';
char myString[] = "ABC";
_Bool myBool = 1;
uint32_t genericContainer;
genericContainer = 0b00000000000000000000000000101010;
uint32_t genericContainer_intTrace = genericContainer;
int genericContainer_intCast = genericContainer_intTrace;
genericContainer = 0b01000000001011001100110011001101;
uint32_t genericContainer_floatTrace = genericContainer;
float genericContainer_floatCast = (float )&genericContainer_floatTrace;
genericContainer = 0b00000000000000000000000001000001;
uint32_t genericContainer_charTrace = genericContainer;
char genericContainer_charCast = genericContainer_charTrace;
genericContainer = 0b00000000010000110100001001000001;
uint32_t genericContainer_stringTrace = genericContainer;
char genericContainer_stringCast[] = {
genericContainer_stringTrace &
0b00000000000000000000000011111111,
(genericContainer_stringTrace >>
0b00000000000000000000000000001000) &
0b00000000000000000000000011111111,
(genericContainer_stringTrace >>
0b00000000000000000000000000010000) &
0b00000000000000000000000011111111,
'\0'
};
genericContainer = 0b00000000000000000000000000000001;
uint32_t genericContainer_boolTrace = genericContainer;
_Bool genericContainer_boolCast = genericContainer_boolTrace;
}
```
The debugger confirmed exactly what the hand-derived bit patterns earlier in this piece predicted, byte for byte:

  • genericContainer_intTrace=- 42(- 0x0000002A) →- genericContainer_intCast=- 42. Same bytes, both read as an int - no surprise, but a useful sanity check.
  • genericContainer_floatTrace=- 1076677837(- 0x402CCCCD) →- genericContainer_floatCast=- 2.7, at the- same four bytes,- CD CC 2C 40in memory. This is the concrete version of the sign/exponent/mantissa derivation from earlier: the bytes never move, only the register they're loaded into and the instruction that reads them changes.
  • genericContainer_charTrace=- 65(- 0x00000041) →- genericContainer_charCast=- 'A', and only the single lowest byte survives the narrowing from- uint32_tto- char.
  • genericContainer_stringTrace=- 4407873(- 0x00434241) → unpacked byte by byte into a 4-element- chararray reading- 'A',- 'B',- 'C',- '\0'in address order - exactly the- 41 42 43 00layout I derived by hand above.
  • genericContainer_boolTrace=- 1→- genericContainer_boolCast=- 1as- _Bool.

And the compiled assembly for this traced version confirms the mechanism behind each cast, not just the result:

"main": push rbp mov rbp, rsp mov DWORD PTR [rbp-4], 42 movss xmm0, DWORD PTR .LC0[rip] movss DWORD PTR [rbp-8], xmm0 mov BYTE PTR [rbp-9], 65 mov DWORD PTR [rbp-49], 4407873 mov BYTE PTR [rbp-10], 1 mov DWORD PTR [rbp-16], 42 mov eax, DWORD PTR [rbp-16] mov DWORD PTR [rbp-20], eax mov eax, DWORD PTR [rbp-20] mov DWORD PTR [rbp-24], eax mov DWORD PTR [rbp-16], 1076677837 mov eax, DWORD PTR [rbp-16] mov DWORD PTR [rbp-56], eax lea rax, [rbp-56] movss xmm0, DWORD PTR [rax] movss DWORD PTR [rbp-28], xmm0 mov DWORD PTR [rbp-16], 65 mov eax, DWORD PTR [rbp-16] mov DWORD PTR [rbp-32], eax mov eax, DWORD PTR [rbp-32] mov BYTE PTR [rbp-33], al mov DWORD PTR [rbp-16], 4407873 mov eax, DWORD PTR [rbp-16] mov DWORD PTR [rbp-40], eax mov DWORD PTR [rbp-60], 0 mov eax, DWORD PTR [rbp-40] mov BYTE PTR [rbp-60], al mov eax, DWORD PTR [rbp-40] shr eax, 8 mov BYTE PTR [rbp-59], al mov eax, DWORD PTR [rbp-40] shr eax, 16 mov BYTE PTR [rbp-58], al mov DWORD PTR [rbp-16], 1 mov eax, DWORD PTR [rbp-16] mov DWORD PTR [rbp-44], eax cmp DWORD PTR [rbp-44], 0 setne al mov BYTE PTR [rbp-45], al mov eax, 0 pop rbp ret .LC0: .long 1076677837
Four idioms fall out of this, one per "kind" of cast:

  • Int → int: plain- DWORDcopies. Nothing to reinterpret.
  • uint32_t→ float- lea rax, [rbp-56]takes the- addressof the trace variable, then- movss xmm0, DWORD PTR [rax]loads those bytes straight into an SSE register as a float - this is the compiled form of- *(float*)&x, and it's the clearest evidence that "casting to float" means "hand this address to the FPU," not "convert this number."
  • uint32_t→ char- mov BYTE PTR [rbp-33], al- the low byte of- eaxgets copied and everything else is silently dropped. Narrowing a 4-byte value to 1 byte is a truncation, not a lookup.
  • uint32_t→ packed string- shr eax, 8and- shr eax, 16followed by single-byte- movs - this is the shift-and-mask logic from the string section above, compiled almost verbatim. Seeing- shrin the actual output is the closest this piece gets to a proof that the "Method 1" extraction isn't just a convenient mental model - it's literally what the CPU executes.
  • uint32_t→- _Bool- cmp+- setne, as covered above - no branch.

If you want to reproduce this yourself without installing anything, a browser-based memory visualizer (Python Tutor is a good one) will step through the trace variables and show you the address, the decimal value, and the byte-level hex/binary breakdown at each line - which is how I caught that the earlier draft's description of the boolean case was wrong.

Why type systems exist anyway

None of the above means types are unnecessary. They exist because:

  • They catch you adding a float to a string before it becomes a runtime bug.
  • They let the compiler choose the right hardware path - ALU vs. FPU - automatically.
  • They document intent: a char*tells the next person what to expect.
  • They spare you from manually tracking every byte, every alignment boundary, every platform's endianness, by hand.

Type-punning past them, the way this experiment does, is a useful way to see what the compiler normally does for you - not a reason to skip the type system in real code.

Try it yourself

git clone https://github.com/mrasadatik/exploring-the-true-nature-of-variable.git cd exploring-the-true-nature-of-variable gcc main.c -o experiment ./experiment bin # binary notation ./experiment oct # octal notation ./experiment dec # decimal notation ./experiment hex # hexadecimal notation ./experiment help # usage info
All four notation flags produce identical output - that's the point. If you want to see the endianness distinction firsthand, compare the shift-based extraction against the direct (char*)&genericContainer cast on a big-endian machine (or simulate one with a byte-swap); on x86 they'll agree, which is exactly what makes the difference easy to miss.

FAQ

Is this the same as type punning? Yes - treating one type's bit pattern as another without converting the underlying value. It's a real, sometimes-necessary technique, with real rules (strict aliasing, alignment) that this piece originally underplayed.

Are types "a lie," then? No - that framing was the wrong hook. Storage is shared bits, but computation genuinely differs by type: floats use dedicated FPU hardware, not a relabeled integer path. Types are abstractions built on real hardware distinctions, not fictions layered over identical behavior.

Does endianness affect all the examples in this piece? Only the ones that read memory byte-by-byte through a pointer - the (char*)&genericContainer string cast, specifically. The shift-and-mask extraction is endian-safe by construction, since it operates on the value, not the raw byte layout.

Why isn't 2.7f stored exactly as 2.7? Because 0.7 doesn't terminate in binary - same reason 1/3 doesn't terminate in decimal. IEEE-754 stores the closest representable value within 23 mantissa bits, which is why float equality comparisons are unreliable in general.

Is this safe to use in production code? Treat it as a learning exercise. Real code should respect strict aliasing and alignment rules; violating them is undefined behavior, not just unconventional style.

What's the actual takeaway? That "int," "float," and "char" describe how bits are computed on, not how they're stored - and that reinterpreting storage safely depends on things beginners rarely get taught early: byte order, alignment, and the precision limits baked into IEEE-754.