Memory X-Ray: See How C Stores Data

Build a C utility that dumps raw bytes of any variable to explore memory.

Introduction

30 Second Summary

When you declare a variable in C, your computer doesn't store the number the way you picture it in your head. It stores a pattern of raw bytes that only makes sense if you know the encoding rules.

In this project, you will build a memory X-ray utility that dumps the raw hexadecimal bytes of any C variable. You'll use it to explore how integers, floats, chars, and pointers actually live in your computer's memory.

What You'll Build

You will write a single C file (memory_xray.c) that prints the raw byte representation of every type you declare, turning invisible memory layouts into visible hex output.

By the end of this project, you'll have:

  • A reusable print_bytes() utility function that reveals the raw bytes of any variable in memory.
  • Hands-on proof of how two's complement integers, IEEE 754 floats, and ASCII chars are stored.
  • A working demonstration of pointer mechanics: dereferencing, aliasing, arithmetic, and out-of-bounds danger.
  • Secret Mission: Reinterpret a float's bits as an integer using pointer casts and unions, proving that C's type system is just a lens over raw bytes.

Are there any prerequisites?

You should be comfortable writing single-file C programs and have basic knowledge of variables and types. Your environment (VS Code with the C/C++ extension, GCC, and GDB) should already be set up and working.

Before We Start

Before We Start...

Set Up the Byte-Printing Utility

Before you can explore how C stores data in memory, you need a way to see the raw bytes. Your computer stores everything as sequences of bytes, but C's printf normally shows you the human-readable interpretation, not the underlying storage.

In this step, you'll write a print_bytes() utility function that acts as a memory X-ray. It takes any variable, reads its raw bytes one by one, and prints each byte as a two-digit hexadecimal value.

In this step, get ready to:
  • Create a print_bytes() utility function that reveals any variable's raw bytes.
  • Verify the utility by printing the byte pattern of the integer 42.
Create the project folder and file

You need a dedicated folder for this project and a single C source file where all your experiments will live.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir memory-xray
cd memory-xray
  • Create the C source file by running:
touch memory_xray.c
  • Confirm the file exists by running:
ls

You should see memory_xray.c in the output.

  • Open the memory-xray folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the memory-xray folder on your Desktop, then click Open.
  • In the Explorer sidebar, click memory_xray.c to open it in the editor.
Write the byte-printing utility function

The print_bytes() function needs to work on any data type. To achieve this, it accepts a void* pointer (a generic address) and a length, then walks through memory byte by byte.

  • In memory_xray.c, type the following code:
#include <stdio.h>
#include <stdint.h>
#include <limits.h>

/* ========== UTILITY ========== */

void print_bytes(const void *ptr, size_t len) {
    const unsigned char *byte = (const unsigned char *)ptr;
    for (size_t i = 0; i < len; i++) {
        printf("%02x ", byte[i]);
    }
    printf("\n");
}

What does this code do?

The three #include lines pull in standard libraries. <stdio.h> provides printf, <stdint.h> provides fixed-width integer types like int8_t, and <limits.h> provides constants like UINT_MAX (used in later steps).

The print_bytes() function casts the incoming void* to an unsigned char*. This lets it read memory one byte at a time regardless of the original data type. The %02x format prints each byte as a two-digit hexadecimal value, zero-padded.

You won't see any output yet. The next chunk adds a main() function that actually calls print_bytes().

Write a main function to test the utility

Every C program needs a main() function as its entry point. This one declares a simple integer and feeds it to your new utility.

  • Below the print_bytes() function in memory_xray.c, add the following code:
/* ========== MAIN ========== */

int main(void) {
    printf("===== STEP 1: Verify print_bytes =====\n");
    int x = 42;
    printf("int x = 42: ");
    print_bytes(&x, sizeof(x));
    printf("\n");

    return 0;
}

What does this code do?

The main() function declares int x = 42 and passes its address (&x) and size (sizeof(x)) to print_bytes().

The & operator gets the memory address of x. sizeof(x) tells print_bytes() how many bytes to read (4 bytes for an int on this platform).

  • Save memory_xray.c by pressing Ctrl+S (Windows) or Cmd+S (macOS).

See a red squiggly underline?

If VS Code shows warnings under size_t or printf, make sure your #include <stdio.h> line is at the very top of the file with no typos.

help me fix red underlines in my C file in VS Code

Compile and run the program

Now you'll use GCC to compile your source file into an executable and run it.

  • In your VS Code terminal, compile the program by running this command:
gcc -Wall -std=c17 -o memory_xray.exe memory_xray.c

What does this command do?

  • gcc invokes the C compiler.
  • -Wall enables all common warnings so you catch mistakes early.
  • -std=c17 tells GCC to use the C17 language standard.
  • -o memory_xray.exe places the compiled output into a file called memory_xray.exe.
  • memory_xray.c is the source file to compile.
  • Run the compiled program:
./memory_xray.exe

You should see the following output:

===== STEP 1: Verify print_bytes =====
int x = 42: 2a 00 00 00

Why is 42 stored as 2a 00 00 00?

The number 42 in hexadecimal is 0x2a. An int is 4 bytes wide, so the full value is 0x0000002a.

Your x86 processor uses little-endian byte order, which stores the least significant byte at the lowest memory address. That is why you see 2a first (the smallest part of the number) followed by three 00 bytes.

Seeing 'gcc: command not found'?

Make sure you are running this command in your MSYS2 UCRT64 terminal, not a plain PowerShell or CMD window. GCC is only available in the MSYS2 environment where it was installed.

help me get GCC working in my VS Code terminal

✔️ Awesome, I've got everything!

Great. Double check you've saved memory_xray.c and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete memory_xray.c file at the end of this step:

#include <stdio.h>
#include <stdint.h>
#include <limits.h>

/* ========== UTILITY ========== */

void print_bytes(const void *ptr, size_t len) {
    const unsigned char *byte = (const unsigned char *)ptr;
    for (size_t i = 0; i < len; i++) {
        printf("%02x ", byte[i]);
    }
    printf("\n");
}

/* ========== MAIN ========== */

int main(void) {
    printf("===== STEP 1: Verify print_bytes =====\n");
    int x = 42;
    printf("int x = 42: ");
    print_bytes(&x, sizeof(x));
    printf("\n");

    return 0;
}

Your memory X-ray utility is working. Next up, you'll use it to explore how signed and unsigned integers are stored, and what happens when they overflow.

Explore Integer Representation

Your print_bytes() utility works. Now you can use it as a microscope to peer into how your machine actually stores numbers.

Computers represent negative numbers using a system called two's complement. In this step, you will use raw byte output to prove that signed and unsigned integers share identical bit patterns, observe what happens when values overflow, and see how different integer types use different amounts of memory.

In this step, get ready to:
  • Print the byte patterns of signed and unsigned integers to see two's complement in action.
  • Demonstrate unsigned overflow (defined wraparound) and signed overflow (undefined behavior).
  • Show how different integer types vary in byte size.
Display signed and unsigned integers

In two's complement, -1 is represented by setting all bits to 1. That means a signed -1 and the maximum unsigned value (UINT_MAX) share the exact same bytes. Let's prove it.

  • In memory_xray.c, find the line return 0; at the bottom of your main() function, then copy the following code above it:
    printf("===== STEP 2: Integer Representation =====\n");
    int a = 1;
    int b = -1;
    unsigned int c = UINT_MAX;

    printf("int 1 (%zu bytes):        ", sizeof(a));
    print_bytes(&a, sizeof(a));
    printf("int -1 (%zu bytes):       ", sizeof(b));
    print_bytes(&b, sizeof(b));
    printf("UINT_MAX (%zu bytes):     ", sizeof(c));
    print_bytes(&c, sizeof(c));
    printf("-1 and UINT_MAX are the same bits!\n\n");

What does this code do?

You declare three variables that are all 4 bytes wide: a positive int, a negative int, and the maximum unsigned int.

  • UINT_MAX comes from <limits.h> (already included) and equals 4,294,967,295 on a 32-bit unsigned int.
  • %zu is the format specifier for size_t values, which is what sizeof returns.
  • The key insight: if -1 and UINT_MAX produce identical hex output (ff ff ff ff), it proves the hardware doesn't know "signed" from "unsigned." The type system is just interpretation.

You won't see output yet. The next chunk adds overflow demonstrations, then you will compile and run everything together.

Demonstrate overflow behavior

What happens when a number exceeds the maximum value its type can hold? For unsigned types, the result is defined: it wraps around modulo 2^N. For signed types, it is undefined behavior. The compiler can do anything it wants.

  • Still in memory_xray.c, add the following code below the block you just pasted (still above return 0;):
    /* Unsigned overflow: defined, wraps modulo 2^8 */
    unsigned char counter = 255;
    printf("unsigned char 255: ");
    print_bytes(&counter, sizeof(counter));
    counter = counter + 1;
    printf("after +1 (wraps): ");
    print_bytes(&counter, sizeof(counter));

    /* Signed overflow: undefined behavior, but typically wraps on this platform */
    int8_t s = 127;
    printf("int8_t 127:        ");
    print_bytes(&s, sizeof(s));
    s = s + 1;  /* undefined behavior! */
    printf("after +1 (UB):     ");
    print_bytes(&s, sizeof(s));
    printf("(Platform shows wrap to -128, but this is NOT guaranteed)\n\n");

What does this code do?

  • unsigned char counter = 255 sets a 1-byte unsigned variable to its maximum value (ff). Adding 1 wraps it to 0 (00). This is guaranteed by the C standard for unsigned types.
  • int8_t s = 127 sets a 1-byte signed variable to its maximum (7f). Adding 1 is undefined behavior. On this platform it wraps to -128 (80), but a compiler with optimizations enabled could produce a different result.

Why is signed overflow undefined?

The C standard leaves signed overflow undefined so that compilers can optimize aggressively. For example, a compiler may assume x + 1 > x is always true for signed x. If overflow were defined to wrap, that assumption would break.

You won't see output yet. The next chunk finishes the integer exploration, then you will compile and run.

Show size variation across integer types

Different integer types use different numbers of bytes. The two's complement rule still applies to all of them. A short -1 fills its 2 bytes with ff ff, just as an int -1 fills its 4 bytes with ff ff ff ff.

  • Add the following code below the overflow section (still above return 0;):
    /* Size variation */
    printf("sizeof(char)      = %zu\n", sizeof(char));
    printf("sizeof(short)     = %zu\n", sizeof(short));
    printf("sizeof(int)       = %zu\n", sizeof(int));
    printf("sizeof(long long) = %zu\n", sizeof(long long));
    short sv = -1;
    printf("short -1: ");
    print_bytes(&sv, sizeof(sv));
    printf("\n");

What does this code do?

  • sizeof returns how many bytes each type occupies. On your platform: char = 1, short = 2, int = 4, long long = 8.
  • short sv = -1 declares a 2-byte signed integer set to -1. Its byte dump will show ff ff. Same pattern as a 4-byte int -1, just shorter.
  • Save memory_xray.c.
  • Compile and run your updated program by running these commands in your terminal:
gcc -Wall -std=c17 -o memory_xray.exe memory_xray.c
.\memory_xray.exe
  • In the output, look for the STEP 2 section. You should see ff ff ff ff for both int -1 and UINT_MAX. You should also see unsigned char 255 wrap from ff to 00, and the size values 1, 2, 4, 8 printed for each type.

Seeing a compile error?

  • If GCC reports undeclared UINT_MAX, make sure #include <limits.h> is at the top of your file.
  • If GCC reports undeclared int8_t, make sure #include <stdint.h> is at the top of your file.
  • Check that each new code block is placed before return 0; and after the closing printf("\n"); from Step 1.

Help me fix a compile error in my memory_xray.c integer representation code.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete memory_xray.c file at this point:

#include <stdio.h>
#include <stdint.h>
#include <limits.h>

/* ========== UTILITY ========== */

void print_bytes(const void *ptr, size_t len) {
    const unsigned char *byte = (const unsigned char *)ptr;
    for (size_t i = 0; i < len; i++) {
        printf("%02x ", byte[i]);
    }
    printf("\n");
}

/* ========== MAIN ========== */

int main(void) {
    printf("===== STEP 1: Verify print_bytes =====\n");
    int x = 42;
    printf("int x = 42: ");
    print_bytes(&x, sizeof(x));
    printf("\n");

    printf("===== STEP 2: Integer Representation =====\n");
    int a = 1;
    int b = -1;
    unsigned int c = UINT_MAX;

    printf("int 1 (%zu bytes):        ", sizeof(a));
    print_bytes(&a, sizeof(a));
    printf("int -1 (%zu bytes):       ", sizeof(b));
    print_bytes(&b, sizeof(b));
    printf("UINT_MAX (%zu bytes):     ", sizeof(c));
    print_bytes(&c, sizeof(c));
    printf("-1 and UINT_MAX are the same bits!\n\n");

    /* Unsigned overflow: defined, wraps modulo 2^8 */
    unsigned char counter = 255;
    printf("unsigned char 255: ");
    print_bytes(&counter, sizeof(counter));
    counter = counter + 1;
    printf("after +1 (wraps): ");
    print_bytes(&counter, sizeof(counter));

    /* Signed overflow: undefined behavior, but typically wraps on this platform */
    int8_t s = 127;
    printf("int8_t 127:        ");
    print_bytes(&s, sizeof(s));
    s = s + 1;  /* undefined behavior! */
    printf("after +1 (UB):     ");
    print_bytes(&s, sizeof(s));
    printf("(Platform shows wrap to -128, but this is NOT guaranteed)\n\n");

    /* Size variation */
    printf("sizeof(char)      = %zu\n", sizeof(char));
    printf("sizeof(short)     = %zu\n", sizeof(short));
    printf("sizeof(int)       = %zu\n", sizeof(int));
    printf("sizeof(long long) = %zu\n", sizeof(long long));
    short sv = -1;
    printf("short -1: ");
    print_bytes(&sv, sizeof(sv));
    printf("\n");

    return 0;
}

You have seen integers from the inside out. Next up, you will do the same for floating-point numbers and characters, revealing how your machine encodes decimals and text as raw bytes.

Explore Float and Char Representation

Integers map to bytes in a fairly predictable way. Floats are a different story entirely.

Your computer uses a clever encoding called IEEE 754 to squeeze decimal numbers into a fixed number of bits. In this step, you will use print_bytes() to see this encoding firsthand and discover why floats can surprise you.

In this step, get ready to:
  • Examine IEEE 754 float encoding and the sign bit.
  • Demonstrate floating-point imprecision with 0.1 + 0.2.
  • Prove that chars are just numbers mapped to symbols.
Examine IEEE 754 float encoding

IEEE 754 single-precision floats pack a decimal number into 32 bits using three fields: 1 sign bit, 8 exponent bits (biased by 127), and 23 mantissa bits. Seeing the raw bytes makes this layout tangible.

  • In memory_xray.c, find the return 0; line at the bottom of main().
  • Add the following code above return 0;:
    printf("===== STEP 3: Float and Char Representation =====\n");
    float f = 1.0f;
    printf("float 1.0:  ");
    print_bytes(&f, sizeof(f));
    float g = -1.0f;
    printf("float -1.0: ");
    print_bytes(&g, sizeof(g));
    printf("(Sign bit flipped: 3f -> bf in highest byte)\n\n");

What does this code do?

You are printing the raw 4 bytes of two floats to see how IEEE 754 encodes them.

  • float 1.0f produces bytes 00 00 80 3f on little-endian x86. The 3f in the highest byte encodes the sign (0) and part of the biased exponent (127).
  • float -1.0f flips just the sign bit, changing the highest byte from 3f to bf. Everything else stays identical.

What is IEEE 754?

IEEE 754 is the standard that defines how computers represent decimal numbers in binary. A single-precision float uses 32 bits split into three fields.

  • Bit 31: the sign bit (0 = positive, 1 = negative).
  • Bits 23-30: the exponent, biased by 127 so it can represent both large and tiny numbers.
  • Bits 0-22: the mantissa (fractional part), giving 23 bits of binary precision.

You won't see a change yet. The next chunk adds more float exploration before you compile.

Demonstrate floating-point imprecision

Because floats have a finite number of mantissa bits, many decimal values cannot be represented exactly. The classic example is 0.1 + 0.2, which does not equal exactly 0.3 in floating-point.

  • Add the following code directly below the block you just wrote (still above return 0;):
    /* Float imprecision */
    float h = 0.1f;
    float i = 0.2f;
    float j = h + i;
    printf("0.1f bytes: ");
    print_bytes(&h, sizeof(h));
    printf("0.2f bytes: ");
    print_bytes(&i, sizeof(i));
    printf("0.1f + 0.2f = %.20f\n", j);
    printf("(Not exactly 0.3!)\n\n");

What does this code do?

  • You store 0.1 and 0.2 in floats, then add them together into j.
  • printf("%.20f\n", j) prints 20 decimal places, revealing the tiny error that accumulates because 0.1 and 0.2 cannot be stored exactly in binary.
  • print_bytes() on each float shows the approximated byte patterns the hardware actually stores.

You won't see a change yet. The next chunk completes this step before you compile.

Show that chars are just numbers

A char in C is a 1-byte integer. The letter you see on screen is just a number mapped to a symbol via ASCII.

  • Add the following code below the float imprecision block (still above return 0;):
    /* Char is just a number */
    char letter = 'A';
    printf("char 'A': ");
    print_bytes(&letter, sizeof(letter));
    printf("'A' + 1 as char: %c\n", letter + 1);
    char digit = '7';
    printf("char '7': ");
    print_bytes(&digit, sizeof(digit));
    printf("'7' - '0' = %d\n\n", digit - '0');

What does this code do?

  • char letter = 'A' stores the value 65 (hex 41) in one byte. Adding 1 gives 66, which maps to 'B' in ASCII.
  • char digit = '7' stores hex 37 (decimal 55). Subtracting '0' (decimal 48) yields the integer 7. This is the classic trick for converting digit characters to actual numbers.
  • Save memory_xray.c.
  • Recompile and run by entering these commands in your terminal:
gcc -Wall -std=c17 -o memory_xray.exe memory_xray.c
.\memory_xray.exe

In the Step 3 section of the output, you should see:

  • float 1.0: followed by 00 00 80 3f.
  • float -1.0: followed by 00 00 80 bf (the sign bit flipped the highest byte).
  • 0.1f + 0.2f = followed by a long decimal that is NOT exactly 0.3.
  • char 'A': showing byte 41, and 'A' + 1 as char: B.
  • char '7': showing byte 37, and '7' - '0' = 7.

Not seeing the Step 3 output?

  • Make sure all the new code is above return 0; and below the Step 2 content. Code after return 0; will never execute.
  • Check for mismatched quotes or missing semicolons. GCC will print the line number of any syntax error.
  • Verify you saved memory_xray.c before recompiling. Unsaved changes will not be included.

Why isn't my Step 3 float and char output appearing when I run memory_xray.exe?

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete memory_xray.c at this point:

#include <stdio.h>
#include <stdint.h>
#include <limits.h>

/* ========== UTILITY ========== */

void print_bytes(const void *ptr, size_t len) {
    const unsigned char *byte = (const unsigned char *)ptr;
    for (size_t i = 0; i < len; i++) {
        printf("%02x ", byte[i]);
    }
    printf("\n");
}

/* ========== MAIN ========== */

int main(void) {
    printf("===== STEP 1: Verify print_bytes =====\n");
    int x = 42;
    printf("int x = 42: ");
    print_bytes(&x, sizeof(x));
    printf("\n");

    printf("===== STEP 2: Integer Representation =====\n");
    int a = 1;
    int b = -1;
    unsigned int c = UINT_MAX;

    printf("int 1 (%zu bytes):        ", sizeof(a));
    print_bytes(&a, sizeof(a));
    printf("int -1 (%zu bytes):       ", sizeof(b));
    print_bytes(&b, sizeof(b));
    printf("UINT_MAX (%zu bytes):     ", sizeof(c));
    print_bytes(&c, sizeof(c));
    printf("-1 and UINT_MAX are the same bits!\n\n");

    /* Unsigned overflow: defined, wraps modulo 2^8 */
    unsigned char counter = 255;
    printf("unsigned char 255: ");
    print_bytes(&counter, sizeof(counter));
    counter = counter + 1;
    printf("after +1 (wraps): ");
    print_bytes(&counter, sizeof(counter));

    /* Signed overflow: undefined behavior, but typically wraps on this platform */
    int8_t s = 127;
    printf("int8_t 127:        ");
    print_bytes(&s, sizeof(s));
    s = s + 1;  /* undefined behavior! */
    printf("after +1 (UB):     ");
    print_bytes(&s, sizeof(s));
    printf("(Platform shows wrap to -128, but this is NOT guaranteed)\n\n");

    /* Size variation */
    printf("sizeof(char)      = %zu\n", sizeof(char));
    printf("sizeof(short)     = %zu\n", sizeof(short));
    printf("sizeof(int)       = %zu\n", sizeof(int));
    printf("sizeof(long long) = %zu\n", sizeof(long long));
    short sv = -1;
    printf("short -1: ");
    print_bytes(&sv, sizeof(sv));
    printf("\n");

    printf("===== STEP 3: Float and Char Representation =====\n");
    float f = 1.0f;
    printf("float 1.0:  ");
    print_bytes(&f, sizeof(f));
    float g = -1.0f;
    printf("float -1.0: ");
    print_bytes(&g, sizeof(g));
    printf("(Sign bit flipped: 3f -> bf in highest byte)\n\n");

    /* Float imprecision */
    float h = 0.1f;
    float i = 0.2f;
    float j = h + i;
    printf("0.1f bytes: ");
    print_bytes(&h, sizeof(h));
    printf("0.2f bytes: ");
    print_bytes(&i, sizeof(i));
    printf("0.1f + 0.2f = %.20f\n", j);
    printf("(Not exactly 0.3!)\n\n");

    /* Char is just a number */
    char letter = 'A';
    printf("char 'A': ");
    print_bytes(&letter, sizeof(letter));
    printf("'A' + 1 as char: %c\n", letter + 1);
    char digit = '7';
    printf("char '7': ");
    print_bytes(&digit, sizeof(digit));
    printf("'7' - '0' = %d\n\n", digit - '0');

    return 0;
}

You now have visible proof that floats use a completely different encoding scheme than integers, and that chars are secretly just small numbers. Next up, you will explore pointers and see actual memory addresses, mutation through aliases, and the danger of dangling references.

Understand Pointer Fundamentals

So far you have explored how values are stored as bytes in memory. But C gives you something more powerful: the ability to reach into memory directly using a pointer.

A pointer is a variable that holds a memory address. In this step, you will declare pointers, read and write through them, create aliases, and see first-hand why pointers are both powerful and dangerous.

In this step, get ready to:
  • Print a variable's memory address and read its value through a pointer.
  • Mutate a variable through pointer aliasing.
  • Observe a dangling pointer warning from the compiler.
Print an address and dereference a pointer

Every variable lives at a specific memory address. The & operator gives you that address, and a pointer stores it. The * operator (dereferencing) lets you read or write the value at that address.

  • In memory_xray.c, find the line return 0; at the bottom of main(). Add this code above it:
    printf("===== STEP 4: Pointer Fundamentals =====\n");
    int value = 100;
    int *ptr = &value;
    printf("address of value: %p\n", (void*)ptr);
    printf("value via *ptr:   %d\n", *ptr);
    printf("pointer itself (%zu bytes): ", sizeof(ptr));
    print_bytes(&ptr, sizeof(ptr));
    printf("\n");

What does this code do?

int *ptr = &value; declares a pointer to an integer and stores the address of value in it. The pointer itself is just a number (a memory address).

printf("%p", (void*)ptr) prints that address in hex. *ptr dereferences the pointer to read the value stored at that address. Finally, print_bytes(&ptr, sizeof(ptr)) shows you the raw bytes of the pointer itself, which is 8 bytes on a 64-bit system.

  • Save memory_xray.c.
  • Compile and run by entering these commands in your terminal:
gcc -Wall -std=c17 -o memory_xray.exe memory_xray.c
.\memory_xray.exe

Scroll to the Step 4 section of the output. You should see a hex address, value via *ptr: 100, and 8 bytes of hex representing the pointer itself.

Not seeing the Step 4 output?

  • Make sure you added the code above return 0;, not below it. Code after return never executes.
  • If you see a compile error, check that the printf format strings have matching parentheses and semicolons.

Help me debug my pointer printing code.

Mutate memory through a pointer and an alias

Pointers do not just read memory. They can write to it. When two pointers hold the same address, they become aliases for the same variable. Changing one changes all of them.

  • In memory_xray.c, add this code directly below the printf("\n"); line you just added (still above return 0;):
    /* Mutation through pointer */
    *ptr = 999;
    printf("After *ptr = 999, value = %d\n", value);

    /* Aliasing */
    int *alias = ptr;
    *alias = 42;
    printf("After *alias = 42:\n");
    printf("  value  = %d\n", value);
    printf("  *ptr   = %d\n", *ptr);
    printf("  *alias = %d\n", *alias);
    printf("(All the same memory location!)\n\n");

What does this code do?

*ptr = 999; writes 999 directly into the memory that value occupies. After this, printing value shows 999, even though you never wrote value = 999.

int *alias = ptr; creates a second pointer to the same address. Writing through alias mutates value and is visible through ptr too. This is the core danger of pointers: multiple names for one memory location.

  • Save memory_xray.c. You will compile after the next substep.
Create a dangling pointer demo

A dangling pointer points to memory that no longer belongs to the variable it originally referenced. This happens when a function returns the address of a local variable, because that variable's memory is freed when the function exits.

  • In memory_xray.c, scroll up to find the comment /* ========== MAIN ========== */.
  • Add this new function directly above that comment:
/* ========== DANGLING POINTER DEMO ========== */

int* dangling(void) {
    int x = 123;
    return &x;  /* WARNING: returning address of local variable */
}

What does this function do?

The dangling() function declares a local variable x on the stack and returns its address. The moment dangling() returns, that stack frame is gone. The address is now invalid.

You will not dereference this pointer. The purpose is to show that the compiler can detect this mistake and warn you about it.

  • Now scroll back down to the bottom of main(). Add this code below the aliasing printf statements (still above return 0;):
    /* Dangling pointer warning demo */
    int *bad = dangling();
    printf("Dangling pointer returned: %p\n", (void*)bad);
    printf("(DO NOT dereference - undefined behavior!)\n\n");

Why not dereference it?

Dereferencing a dangling pointer is undefined behavior. The memory might contain garbage, or the program might crash. The compiler warns you so you can avoid this bug in real projects.

  • Save memory_xray.c.
  • Compile with -Wall to see the compiler warning by running:
gcc -Wall -std=c17 -o memory_xray.exe memory_xray.c

You should see a warning message mentioning that the function returns the address of a local variable, with the flag [-Wreturn-local-addr]. This is GCC catching the dangling pointer for you.

  • Run the program to see the full Step 4 output:
.\memory_xray.exe

You should see the address, dereferenced value, mutation results, aliasing proof, and the dangling pointer address all printed in the Step 4 section.

Seeing a compile error instead of a warning?

  • Make sure the dangling() function is defined above main(), not inside it. C requires functions to be declared before they are called.
  • Check that you have the /* ========== DANGLING POINTER DEMO ========== */ comment and function between the utility section and the /* ========== MAIN ========== */ comment.
  • Verify that the function signature is int* dangling(void) with a closing brace } on its own line.

Help me fix my dangling pointer function placement.

✔️ Awesome, I've got everything!

Great. Double check you have saved memory_xray.c and confirmed the warning plus the runtime output.

ⓧ I'd like to double check the full code

Here is the complete memory_xray.c at this point:

#include <stdio.h>
#include <stdint.h>
#include <limits.h>

/* ========== UTILITY ========== */

void print_bytes(const void *ptr, size_t len) {
    const unsigned char *byte = (const unsigned char *)ptr;
    for (size_t i = 0; i < len; i++) {
        printf("%02x ", byte[i]);
    }
    printf("\n");
}

/* ========== DANGLING POINTER DEMO ========== */

int* dangling(void) {
    int x = 123;
    return &x;  /* WARNING: returning address of local variable */
}

/* ========== MAIN ========== */

int main(void) {
    printf("===== STEP 1: Verify print_bytes =====\n");
    int x = 42;
    printf("int x = 42: ");
    print_bytes(&x, sizeof(x));
    printf("\n");

    printf("===== STEP 2: Integer Representation =====\n");
    int a = 1;
    int b = -1;
    unsigned int c = UINT_MAX;

    printf("int 1 (%zu bytes):        ", sizeof(a));
    print_bytes(&a, sizeof(a));
    printf("int -1 (%zu bytes):       ", sizeof(b));
    print_bytes(&b, sizeof(b));
    printf("UINT_MAX (%zu bytes):     ", sizeof(c));
    print_bytes(&c, sizeof(c));
    printf("-1 and UINT_MAX are the same bits!\n\n");

    /* Unsigned overflow: defined, wraps modulo 2^8 */
    unsigned char counter = 255;
    printf("unsigned char 255: ");
    print_bytes(&counter, sizeof(counter));
    counter = counter + 1;
    printf("after +1 (wraps): ");
    print_bytes(&counter, sizeof(counter));

    /* Signed overflow: undefined behavior, but typically wraps on this platform */
    int8_t s = 127;
    printf("int8_t 127:        ");
    print_bytes(&s, sizeof(s));
    s = s + 1;  /* undefined behavior! */
    printf("after +1 (UB):     ");
    print_bytes(&s, sizeof(s));
    printf("(Platform shows wrap to -128, but this is NOT guaranteed)\n\n");

    /* Size variation */
    printf("sizeof(char)      = %zu\n", sizeof(char));
    printf("sizeof(short)     = %zu\n", sizeof(short));
    printf("sizeof(int)       = %zu\n", sizeof(int));
    printf("sizeof(long long) = %zu\n", sizeof(long long));
    short sv = -1;
    printf("short -1: ");
    print_bytes(&sv, sizeof(sv));
    printf("\n");

    printf("===== STEP 3: Float and Char Representation =====\n");
    float f = 1.0f;
    printf("float 1.0:  ");
    print_bytes(&f, sizeof(f));
    float g = -1.0f;
    printf("float -1.0: ");
    print_bytes(&g, sizeof(g));
    printf("(Sign bit flipped: 3f -> bf in highest byte)\n\n");

    /* Float imprecision */
    float h = 0.1f;
    float i = 0.2f;
    float j = h + i;
    printf("0.1f bytes: ");
    print_bytes(&h, sizeof(h));
    printf("0.2f bytes: ");
    print_bytes(&i, sizeof(i));
    printf("0.1f + 0.2f = %.20f\n", j);
    printf("(Not exactly 0.3!)\n\n");

    /* Char is just a number */
    char letter = 'A';
    printf("char 'A': ");
    print_bytes(&letter, sizeof(letter));
    printf("'A' + 1 as char: %c\n", letter + 1);
    char digit = '7';
    printf("char '7': ");
    print_bytes(&digit, sizeof(digit));
    printf("'7' - '0' = %d\n\n", digit - '0');

    printf("===== STEP 4: Pointer Fundamentals =====\n");
    int value = 100;
    int *ptr = &value;
    printf("address of value: %p\n", (void*)ptr);
    printf("value via *ptr:   %d\n", *ptr);
    printf("pointer itself (%zu bytes): ", sizeof(ptr));
    print_bytes(&ptr, sizeof(ptr));
    printf("\n");

    /* Mutation through pointer */
    *ptr = 999;
    printf("After *ptr = 999, value = %d\n", value);

    /* Aliasing */
    int *alias = ptr;
    *alias = 42;
    printf("After *alias = 42:\n");
    printf("  value  = %d\n", value);
    printf("  *ptr   = %d\n", *ptr);
    printf("  *alias = %d\n", *alias);
    printf("(All the same memory location!)\n\n");

    /* Dangling pointer warning demo */
    int *bad = dangling();
    printf("Dangling pointer returned: %p\n", (void*)bad);
    printf("(DO NOT dereference - undefined behavior!)\n\n");

    return 0;
}

You have now seen pointers at work: reading addresses, writing through them, and the danger of dangling references. Next up, you will explore pointer arithmetic and see how pointers navigate arrays byte by byte.

Explore Pointer Arithmetic and Dangers

In Step 4 you proved that pointers hold real memory addresses, and that mutating through one pointer changes the value everywhere. Now for the question that trips up most C beginners: what happens when you do math on a pointer?

In this step you will explore pointer arithmetic, the idea that adding 1 to a pointer moves it forward by the size of the type it points to. You will also see what happens when you step outside an array's bounds.

In this step, get ready to:
  • Prove that pointer arithmetic scales by type size, not byte count.
  • Walk an array byte-by-byte using a cast to unsigned char*.
  • Observe out-of-bounds reads producing garbage values.
Show that pointer arithmetic advances by type size

When you add 1 to an int*, the address does not increase by 1 byte. It jumps forward by sizeof(int) (4 bytes on your platform). This is how C navigates arrays without you manually counting bytes.

  • In memory_xray.c, find the line that says return 0; at the bottom of main().
  • Add the following code above that line:
    printf("===== STEP 5: Pointer Arithmetic =====\n");
    int arr[5] = {10, 20, 30, 40, 50};
    int *p = arr;

    printf("Array base address:  %p\n", (void*)p);
    printf("p+1 (next int):      %p\n", (void*)(p + 1));
    printf("p+2 (two ints over): %p\n", (void*)(p + 2));
    printf("Difference: %llu bytes per step (sizeof(int) = %zu)\n\n",
           (unsigned long long)((unsigned char*)(p+1) - (unsigned char*)p),
           sizeof(int));

    printf("Full array bytes: ");
    print_bytes(arr, sizeof(arr));

What does this code do?

  • int arr[5] declares a contiguous block of 5 integers (20 bytes total) on the stack.
  • int *p = arr points p at the first element. Array names decay to pointers in most expressions.
  • Printing p, p+1, and p+2 reveals that each step jumps exactly 4 bytes (the size of an int), not 1 byte.
  • print_bytes(arr, sizeof(arr)) dumps all 20 bytes of the array as hex, so you can see the raw layout.

You won't see output yet. The next chunk adds the element-access code, and you will compile everything together at the end of this step.

Access elements via pointer arithmetic and byte-level casting

Pointer arithmetic gives you the same elements as bracket notation. And by casting to unsigned char*, you can walk memory one byte at a time instead of one int at a time.

  • Add this code directly below the print_bytes(arr, sizeof(arr)); line you just wrote:
    printf("*(p+0)=%d  *(p+1)=%d  *(p+2)=%d\n", *(p+0), *(p+1), *(p+2));
    printf("arr[0]=%d  arr[1]=%d  arr[2]=%d\n\n", arr[0], arr[1], arr[2]);

    /* Byte-level access */
    printf("Byte-by-byte view of array:\n");
    unsigned char *byte_ptr = (unsigned char*)arr;
    for (size_t idx = 0; idx < sizeof(arr); idx++) {
        printf("%02x ", byte_ptr[idx]);
        if ((idx + 1) % 4 == 0) printf("| ");
    }
    printf("\n\n");

What does this code do?

  • *(p+0), *(p+1), *(p+2) dereference the pointer at each offset. These produce the same values as arr[0], arr[1], arr[2].
  • unsigned char *byte_ptr = (unsigned char*)arr casts the array pointer to a single-byte pointer. Now stepping by 1 moves one byte, not one int.
  • The loop prints a | separator every 4 bytes so you can visually see where each integer's storage ends and the next begins.

Why does casting to unsigned char* work?

The C standard guarantees that accessing any object through a character type (char, unsigned char) is always defined behavior. This is the same trick your print_bytes() function uses internally.

You won't see output yet. One more chunk to add, then you will compile and run.

Demonstrate out-of-bounds reads

C does not bounds-check array access. If you read past the end of an array, the program happily fetches whatever bytes happen to sit in adjacent memory. This is undefined behavior.

  • Add this code directly below the printf("\n\n"); line at the end of the byte-level loop:
    /* Out-of-bounds danger */
    printf("Out-of-bounds reads (undefined behavior!):\n");
    printf("*(p+5) = %d (garbage from beyond array)\n", *(p + 5));
    printf("*(p+10) = %d (even further garbage)\n", *(p + 10));
    printf("(These values are unpredictable!)\n");

What does this code do?

  • *(p+5) reads 4 bytes starting immediately after the array ends. Your array has indices 0 through 4, so index 5 is already outside.
  • *(p+10) reads even further into random stack memory. The values you see will be whatever data the stack happened to hold.
  • This is how buffer overflow vulnerabilities work in real software. C trusts you completely, and reading garbage is the best-case outcome. Writing out of bounds can corrupt other variables or hijack program flow.
  • Save memory_xray.c.
  • Compile and run the program by running these commands in your terminal:
gcc -Wall -std=c17 -o memory_xray.exe memory_xray.c
.\memory_xray.exe
  • Scroll down to the Step 5 output. You should see three addresses printed for the array, with each address exactly 4 bytes apart. The byte-by-byte view shows 20 hex values separated by pipes every 4 bytes. The out-of-bounds lines show unpredictable integer values.

Seeing a compilation error?

  • Check that you placed all the new code before return 0; and after the dangling pointer section. Code outside main() will not compile.
  • Make sure every printf line ends with a semicolon and all string literals have matching double quotes.
  • If you see a warning about byte_ptr being unused, make sure you included the full for loop that uses it.

Help me fix a compilation error in my pointer arithmetic code.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file.

ⓧ I'd like to double check the full code

#include <stdio.h>
#include <stdint.h>
#include <limits.h>

/* ========== UTILITY ========== */

void print_bytes(const void *ptr, size_t len) {
    const unsigned char *byte = (const unsigned char *)ptr;
    for (size_t i = 0; i < len; i++) {
        printf("%02x ", byte[i]);
    }
    printf("\n");
}

/* ========== DANGLING POINTER DEMO ========== */

int* dangling(void) {
    int x = 123;
    return &x;  /* WARNING: returning address of local variable */
}

/* ========== MAIN ========== */

int main(void) {
    printf("===== STEP 1: Verify print_bytes =====\n");
    int x = 42;
    printf("int x = 42: ");
    print_bytes(&x, sizeof(x));
    printf("\n");

    printf("===== STEP 2: Integer Representation =====\n");
    int a = 1;
    int b = -1;
    unsigned int c = UINT_MAX;

    printf("int 1 (%zu bytes):        ", sizeof(a));
    print_bytes(&a, sizeof(a));
    printf("int -1 (%zu bytes):       ", sizeof(b));
    print_bytes(&b, sizeof(b));
    printf("UINT_MAX (%zu bytes):     ", sizeof(c));
    print_bytes(&c, sizeof(c));
    printf("-1 and UINT_MAX are the same bits!\n\n");

    /* Unsigned overflow: defined, wraps modulo 2^8 */
    unsigned char counter = 255;
    printf("unsigned char 255: ");
    print_bytes(&counter, sizeof(counter));
    counter = counter + 1;
    printf("after +1 (wraps): ");
    print_bytes(&counter, sizeof(counter));

    /* Signed overflow: undefined behavior, but typically wraps on this platform */
    int8_t s = 127;
    printf("int8_t 127:        ");
    print_bytes(&s, sizeof(s));
    s = s + 1;  /* undefined behavior! */
    printf("after +1 (UB):     ");
    print_bytes(&s, sizeof(s));
    printf("(Platform shows wrap to -128, but this is NOT guaranteed)\n\n");

    /* Size variation */
    printf("sizeof(char)      = %zu\n", sizeof(char));
    printf("sizeof(short)     = %zu\n", sizeof(short));
    printf("sizeof(int)       = %zu\n", sizeof(int));
    printf("sizeof(long long) = %zu\n", sizeof(long long));
    short sv = -1;
    printf("short -1: ");
    print_bytes(&sv, sizeof(sv));
    printf("\n");

    printf("===== STEP 3: Float and Char Representation =====\n");
    float f = 1.0f;
    printf("float 1.0:  ");
    print_bytes(&f, sizeof(f));
    float g = -1.0f;
    printf("float -1.0: ");
    print_bytes(&g, sizeof(g));
    printf("(Sign bit flipped: 3f -> bf in highest byte)\n\n");

    /* Float imprecision */
    float h = 0.1f;
    float i = 0.2f;
    float j = h + i;
    printf("0.1f bytes: ");
    print_bytes(&h, sizeof(h));
    printf("0.2f bytes: ");
    print_bytes(&i, sizeof(i));
    printf("0.1f + 0.2f = %.20f\n", j);
    printf("(Not exactly 0.3!)\n\n");

    /* Char is just a number */
    char letter = 'A';
    printf("char 'A': ");
    print_bytes(&letter, sizeof(letter));
    printf("'A' + 1 as char: %c\n", letter + 1);
    char digit = '7';
    printf("char '7': ");
    print_bytes(&digit, sizeof(digit));
    printf("'7' - '0' = %d\n\n", digit - '0');

    printf("===== STEP 4: Pointer Fundamentals =====\n");
    int value = 100;
    int *ptr = &value;
    printf("address of value: %p\n", (void*)ptr);
    printf("value via *ptr:   %d\n", *ptr);
    printf("pointer itself (%zu bytes): ", sizeof(ptr));
    print_bytes(&ptr, sizeof(ptr));
    printf("\n");

    /* Mutation through pointer */
    *ptr = 999;
    printf("After *ptr = 999, value = %d\n", value);

    /* Aliasing */
    int *alias = ptr;
    *alias = 42;
    printf("After *alias = 42:\n");
    printf("  value  = %d\n", value);
    printf("  *ptr   = %d\n", *ptr);
    printf("  *alias = %d\n", *alias);
    printf("(All the same memory location!)\n\n");

    /* Dangling pointer warning demo */
    int *bad = dangling();
    printf("Dangling pointer returned: %p\n", (void*)bad);
    printf("(DO NOT dereference - undefined behavior!)\n\n");

    printf("===== STEP 5: Pointer Arithmetic =====\n");
    int arr[5] = {10, 20, 30, 40, 50};
    int *p = arr;

    printf("Array base address:  %p\n", (void*)p);
    printf("p+1 (next int):      %p\n", (void*)(p + 1));
    printf("p+2 (two ints over): %p\n", (void*)(p + 2));
    printf("Difference: %llu bytes per step (sizeof(int) = %zu)\n\n",
           (unsigned long long)((unsigned char*)(p+1) - (unsigned char*)p),
           sizeof(int));

    printf("Full array bytes: ");
    print_bytes(arr, sizeof(arr));

    printf("*(p+0)=%d  *(p+1)=%d  *(p+2)=%d\n", *(p+0), *(p+1), *(p+2));
    printf("arr[0]=%d  arr[1]=%d  arr[2]=%d\n\n", arr[0], arr[1], arr[2]);

    /* Byte-level access */
    printf("Byte-by-byte view of array:\n");
    unsigned char *byte_ptr = (unsigned char*)arr;
    for (size_t idx = 0; idx < sizeof(arr); idx++) {
        printf("%02x ", byte_ptr[idx]);
        if ((idx + 1) % 4 == 0) printf("| ");
    }
    printf("\n\n");

    /* Out-of-bounds danger */
    printf("Out-of-bounds reads (undefined behavior!):\n");
    printf("*(p+5) = %d (garbage from beyond array)\n", *(p + 5));
    printf("*(p+10) = %d (even further garbage)\n", *(p + 10));
    printf("(These values are unpredictable!)\n");

    return 0;
}

Secret mission

Type Punning - Reinterpreting Bits

You have proven that floats and integers look completely different in memory. But what if you could look at a float's raw bits through integer-colored glasses? In this challenge, you will reinterpret a float's bits as an integer and vice versa, proving that C's type system is just a lens over the same underlying bytes.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your files for future reference or delete them entirely.

Resources you used:

  • memory_xray.c source file.
  • memory_xray.exe compiled executable.

Keep everything running

No action needed. Choose this if you plan to revisit the program later or use it as a reference.

  • Your memory_xray.c file serves as a permanent reference for how C stores data in memory.
  • Recompile and run anytime with gcc -Wall -std=c17 -o memory_xray.exe memory_xray.c followed by .\memory_xray.exe.

Pause - I'll come back to this later

Since this project is entirely local files with no running processes, there is nothing to pause.

  • Your files will stay on disk until you decide to delete them.
  • No background services or servers are running from this project.

Delete - I don't want to use this again

Remove all project files to start fresh.

  • Delete the source file and compiled executable by running this command in the folder where you created them:

macOS

rm memory_xray.c memory_xray.exe

Windows

del memory_xray.c, memory_xray.exe
  • Confirm the files are gone by running ls (macOS) or dir (Windows). You should no longer see memory_xray.c or memory_xray.exe in the output.

That's a Wrap!

That's a Wrap!

Nice work. You just built a memory X-ray tool that reveals exactly how your computer stores data at the byte level.

You've learned how to:

  • Build a reusable print_bytes() utility that X-rays any variable's raw memory, and use it to prove that signed integers use two's complement (where -1 and UINT_MAX share the same bit pattern).
  • Decode the IEEE 754 float layout (sign bit, 8-bit biased exponent, 23-bit mantissa) and demonstrate that unsigned overflow wraps predictably while signed overflow is undefined behavior.
  • Use pointers to read and mutate memory through aliasing, prove that pointer arithmetic scales by type size, and observe how out-of-bounds access produces garbage values.
  • Secret Mission: Reinterpret float bits as integers using memcpy and unions, manually extracting the sign, exponent, and mantissa fields of any float.

Ready to quiz yourself?