Learn C With Me #10 — File I/O, Part Two | Live

In Lesson 10 of Learn C With Me, we continue our exploration of File Input/Output in C, this time moving from text files to binary files.

Using Beej’s Guide to C, we learn how binary streams work, how to read and write raw data with fread() and fwrite(), and why writing binary data directly from memory introduces some important portability problems.

We cover:

  • Binary files and binary streams
  • The difference between text and binary I/O
  • Opening files with "rb" and "wb"
  • Reading binary data with fread()
  • Writing binary data with fwrite()
  • Understanding the arguments to fread() and fwrite()
  • Writing arrays of bytes to a file
  • Reading binary data back into memory
  • Inspecting binary files with hexdump
  • Working with unsigned char and raw bytes
  • Writing values larger than one byte
  • Byte order and endianness
  • Why multibyte values can be stored differently on different architectures
  • Why directly writing a struct to disk isn’t portable
  • Struct padding
  • Why compiler and architecture differences matter
  • The idea of serialization
  • Why portable binary formats need a defined representation
  • A brief look at Protocol Buffers
  • Why serialization allows data to move between architectures and programming languages

One of the biggest takeaways from this lesson is that binary data is just bytes. Writing a simple array of bytes is straightforward, but things become more complicated once we start writing multibyte integers or complete structs directly from memory.

We experiment with this ourselves and run into endianness, where the same multibyte value can have a different byte order depending on the architecture. We also revisit structs and see why compiler-added padding makes dumping an entire struct directly to a file non-portable.

This leads us to serialization: instead of blindly copying memory to disk, we define exactly how our data should be represented. We briefly look at Google’s Protocol Buffers as a real-world example of a serialization system.

As usual, we experiment, make predictions, get things wrong, inspect the actual bytes, get help from the live chat, and try to understand what C is actually doing under the hood.

With this stream, we finish Chapter 9 — File Input/Output. Next time, we move on to a new topic.

This is an interactive, follow-along live course. I am learning C alongside you, so expect questions, mistakes, experiments and plenty of discussion. No passive watching here — participate in the chat and work through the examples with us.

Homework #10 — Binary File I/O

For this week’s homework, we’re going to create a tiny binary save file.

The goal is to practice what we’ve learned about:

  • structs
  • binary files
  • fread()
  • fwrite()
  • raw bytes
  • multibyte values
  • portability
  • serialization

Use only concepts we’ve covered so far in Learn C With Me, up to and including Chapter 9 — File Input/Output.

The task

We’re going to save a small game character to a binary file and then read it back.

Start with:

struct character {
    unsigned char health;
    unsigned char attack;
    unsigned char level;
};

Create a character:

struct character hero = {
    .health = 100,
    .attack = 25,
    .level = 7
};

1. Create a binary file

Open:

save.bin

for binary writing.

Remember the appropriate mode for a binary file.

Check whether fopen() succeeded before trying to use the file.


2. Write the character

Write the three values:

health
attack
level

to save.bin using:

fwrite()

Each field is an unsigned char, so each value can be treated as a single byte.

Close the file when you’re finished.

Your file should contain exactly three pieces of character data.


3. Inspect the file

Use a tool such as:

hexdump

or:

xxd

to inspect save.bin.

Before looking at the result, predict what bytes you expect to see.

Then compare your prediction with the actual file.

Try changing the values in the character, writing the file again, and observing how the bytes change.


4. Load the character

Create another:

struct character loaded;

Open save.bin for binary reading and use:

fread()

to load the three values into loaded.

Then print the result:

Loaded character:

Health: 100
Attack: 25
Level: 7

If everything worked correctly, the loaded values should match the original character.


5. Make the save format explicit

Now change your program so that it does not depend on the memory layout of the struct.

Instead of writing:

fwrite(&hero, sizeof hero, 1, fp);

write each field individually.

For example, think in terms of:

BYTE 1: health
BYTE 2: attack
BYTE 3: level

Do the same thing when reading the file back.

The program now defines its own tiny binary file format.


6. Add a magic number

Let’s make the file recognizable as our save file.

The first four bytes of the file should contain:

KARO

After those bytes, store the character data.

Our file format is now:

K A R O health attack level

When loading the file, first read those four bytes.

Check that they contain:

KARO

If they don’t, print:

Invalid save file!

and don’t load the character.


7. Break the save file

Open save.bin in a hex editor, or otherwise modify some of its bytes.

Try:

  • changing the health byte
  • changing the level byte
  • changing one of the KARO bytes
  • removing bytes from the end of the file

Then run your loader again.

Observe what happens.

Can your program detect an invalid header?

What happens if there isn’t enough data in the file?


Bonus challenge — A multibyte value

Change:

unsigned char health;

to:

unsigned short health;

Now health can occupy more than one byte.

Set it to:

1000

Before writing anything, think about today’s lesson.

If you simply write the unsigned short directly with fwrite():

Which byte comes first?

Inspect the resulting file with hexdump or xxd.

Can you determine whether your machine is storing the value little-endian or big-endian?


Bonus challenge — Portable health value

Instead of directly writing the unsigned short, manually split the value into individual bytes and decide which byte should be written first.

When loading the file, reconstruct the original value from those bytes.

The goal is to make the file format define the byte order, rather than allowing the CPU’s native byte order to define it for us.

Don’t worry if you don’t immediately know how to do this.

Experiment.

Think about what each individual byte needs to contain.


Questions to think about

Why might this be a bad idea?

fwrite(&hero, sizeof hero, 1, fp);

What happens if the compiler inserts padding into the struct?

What happens if the file is written on one architecture and read on another?

What changes when a value occupies more than one byte?

Why might a real application use a defined serialization format instead of simply dumping its memory directly into a file?


Rules

Use only concepts we’ve covered so far in Learn C With Me, up to and including Chapter 9 — File Input/Output.

You may use what we’ve already learned, including:

struct
FILE *
fopen()
fclose()
fread()
fwrite()
printf()
fprintf()
sizeof

along with arrays, strings, pointers, loops, conditions and functions from previous lessons.

Do not use concepts from later chapters.

In particular, you don’t need:

  • typedef
  • dynamic memory allocation
  • command-line arguments
  • external serialization libraries

The point isn’t to build a production-quality save system.

The point is to understand the difference between:

“write whatever happens to be in memory”

and:

“define exactly which bytes our file contains.”

As always: predict first, experiment second.

Inspect the actual bytes. Change them. Break the file. See whether your prediction matches reality.

And if you get stuck, post your code, compiler output, and what you expected to happen in the thread.

i dont like this lesson i feal like am just rewriting the same thing and if its removed then theres no prof that i did it… curently working on it and just keeping old stuff in comments or comment block

i must admit this lesson did not take to account that we would do it in 1 file and bonus steps over write some of the parts that was made so theres alot of comment out code from these steps in this one to get it in one file…

secret code of horrors
#include<stdio.h>

int main(void) {
    struct character {
        unsigned short health;
        unsigned char attack;
        unsigned char level;
    };

    struct character hero = {
        .health = 1000,
        .attack = 25,
        .level = 7
    };

    struct character loaded;

    FILE *fp;
    fp = fopen("save.bin", "wb+");
    if (!fp) {
        fprintf(stderr, "Error: file could not open\n");
        return 1;
    }


/*
    Part 4 had this but 5 dont...
    if (fwrite(&hero, sizeof(hero), 1, fp) != 1) {
        fprintf(stderr, "Write failed!\n");
        return 1;
    }
*/

    fwrite("KARO", 4, 1, fp);
    // fwrite("K", 1, 1, fp) part 7 broken save file would only show "K" and validation chack later fails
    // fputc(hero.health, fp); removed becuse of bonus point
    fwrite(&hero.health, 2, 1, fp); // hexdump litle-endian and xxd big-endian i think but i think cpu is litle endian and most other stuff bigendian
    /*
    extra bonus etempt
    unsigned char low  = health & 0xFF;   litle-endian
    unsigned char high = (health >> 8) & 0xFF; bigendian

    fputc(high, fp);   this would start with 03 = 10 of 1000
    fputc(low, fp);    this would start with E8 = 00 of 1000 but in reverse like 0010
    
    */  
    fputc(hero.attack, fp);
    fputc(hero.level, fp);
    rewind(fp);

/*
    same here for some reason seams lesson lacks consistancy
    if (fread(&loaded, sizeof(loaded), 1, fp) != 1) {
        fprintf(stderr, "Read failed!\n");
        return 1;
    }

*/

    char magic[4]; // the "KARO" bytes
    fread(magic, 1, 4, fp);

    if (magic[0] != 'K' || magic[1] != 'A' || magic[2] != 'R' || magic[3] != 'O') {
        fprintf(stderr, "Invalid save file!\n");
        return 1;
    } // validates and stays at ladt byte befor loaded gets read/get

    fread(&loaded.health, 2, 1, fp); 
    // loaded.health = fgetc(fp);
    loaded.attack = fgetc(fp);
    loaded.level = fgetc(fp);
    printf("Loaded character:\n\n");
    printf("Health: %d\n", loaded.health);
    printf("Attack: %d\n", loaded.attack);
    printf("Level: %d\n\n", loaded.level);

    fclose(fp);
    return 0;

}
Picture of terminal out put