System calls in C — practice saving, copying and editing game saves with open, read, write and lseek (and the backup that was 96 bytes long)

The first backup I made of my saved games was 96 bytes long. The original was 80. I opened both files and the end of the copy had 16 bytes repeated from the previous line, like an echo. The bug was a single word: I had written write(out, buf, 32) instead of write(out, buf, nread). In the last round of the loop only 16 bytes had been read, but I wrote all 32 of the buffer, and the other 16 were leftovers from the previous round. The numbers add up: 3 rounds · 32 = 96. The lesson I took away, and which you’ll see repeated in all three programs: with system calls, the number they return matters as much as what they do.

This is the practice for the system calls theory. There are three medium-level programs on the same file, saves.sav, with four saved games of 20 bytes each: the first saves it slot by slot, the second makes a backup with a read and write loop, and the third edits the points of one game without touching the rest. The data looks like real data: names of different lengths, one- and two-digit levels, a file that doesn’t exist and a slot that isn’t there.

Before you start

Make sure you’re clear on:

  • That open returns a descriptor (usually 3) or −1, and what O_RDONLY, O_WRONLY, O_RDWR, O_CREAT and O_TRUNC mean.
  • That read and write return how many bytes they moved and that read returns 0 when it reaches the end.
  • That read doesn’t add the '\0'.
  • How lseek moves the pointer with SEEK_SET, SEEK_CUR and SEEK_END.

If any of these sounds shaky, review the theory before carrying on.

Each program creates its files in the folder you run it from. Compile with gcc -Wall -std=c11 save.c -o save, run with ./save and look at the result with cat saves.sav or with ls -l (it tells you the size in bytes, which is what we’ll be checking all the time).

Program 1: saving the games

We have four players with their level and points in three arrays. They must be saved in saves.sav with a fixed format of 20 bytes per game, checking that each write writes what it should and, at the end, showing the file on screen by reading it with read and writing it to descriptor 1. What makes it tricky: names and numbers have different lengths (3 and 12, 450 and 2750), and yet every slot must be the same size.

The idea

If every saved game has the same size, the position of any of them can be computed: slot n starts at byte 20 · n. That’s what programs 2 and 3 will rely on, so here we make sure the format is fixed:

Slot format The 20 bytes of slot 0, one per box with its index: name at 0-2, padding at 3-5, the text level at 6-10, the level at 12-13, the points at 15-18 and the newline at 19. Below, the formula byte = 20 times slot plus offset, with the example 20 times 2 plus 15 = 55. One slot of saves.sav: 20 fixed bytes 0 L 1 i 2 a 3 · 4 · 5 · 6 l 7 e 8 v 9 e 10 l 11 · 12 0 13 3 14 · 15 0 16 4 17 5 18 0 19 \n name 0-2 padding 3-5 fixed text 6-10 level 12-13 points 15-18 end 19 byte = 20 · slot + offset points of slot 2: 20 · 2 + 15 = 55 · whole slot 3: 20 · 3 = 60
Since every slot has the same size, the position of any field can be computed: there is no need to read the previous ones.

To get exactly those 20 bytes we use snprintf (which writes formatted text into an array, like printf but in memory) and then pass the result to write. The steps: open with O_TRUNC (saving = starting from scratch), a loop that formats and writes each slot, close, and open again to read and dump it to the screen.

Before coding, think about what snprintf returns: the number of characters it wrote, not counting the '\0'. That number is exactly what write needs.

The code

#include <stdio.h>
#include <fcntl.h>      /* open and the O_... constants */
#include <unistd.h>     /* read, write, lseek, close */

#define SLOT_SIZE 20

int main(void) {
    const char *names[] = {"Lia", "Max", "Ane", "Teo"};
    int levels[] = {3, 12, 5, 7};
    int points[] = {450, 2750, 980, 1200};

    /* O_TRUNC: every time we save, the file starts from scratch */
    int fd = open("saves.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("saves.sav");
        return 1;
    }

    char slot[64];                           /* plenty: snprintf never overflows */
    for (int i = 0; i < 4; i++) {
        /* %-6s: name left-aligned in 6 places; %02d and %04d: leading zeros */
        int n = snprintf(slot, sizeof slot, "%-6snivel %02d %04d\n",
                         names[i], levels[i], points[i]);
        ssize_t written = write(fd, slot, n);  /* n = 20: without the '\0' */
        if (written != SLOT_SIZE) {
            perror("write");
            close(fd);
            return 1;
        }
        printf("Slot %d: write returns %zd, the pointer ends at %ld\n",
               i, written, (long) lseek(fd, 0, SEEK_CUR));
    }
    close(fd);

    /* Re-read the file and dump it to standard output (fd 1) */
    fd = open("saves.sav", O_RDONLY);
    if (fd == -1) {
        perror("saves.sav");
        return 1;
    }
    char buf[64];
    ssize_t nread;
    printf("--- saves.sav ---\n");
    fflush(stdout);                          /* flushes printf before using write */
    while ((nread = read(fd, buf, sizeof buf)) > 0)
        write(1, buf, nread);                /* nread, not sizeof buf */
    close(fd);
    return 0;
}

The key line is the snprintf format. Let’s take a closer look:

for (int i = 0; i < 4; i++) {
    /* %-6s: name left-aligned in 6 places; %02d and %04d: leading zeros */
    int n = snprintf(slot, sizeof slot, "%-6snivel %02d %04d\n",
                     names[i], levels[i], points[i]);
    ssize_t written = write(fd, slot, n);  /* n = 20: without the '\0' */
    if (written != SLOT_SIZE) {
        perror("write");
        close(fd);
        return 1;
    }
    printf("Slot %d: write returns %zd, the pointer ends at %ld\n",
           i, written, (long) lseek(fd, 0, SEEK_CUR));
}
  • %-6s: the name, left-aligned and padded with spaces up to 6 characters. "Lia" becomes "Lia " (3 letters + 3 spaces). Without the -, the spaces would go in front.
  • level is fixed text (5 letters and a space).
  • %02d: the level with at least 2 digits, with leading zeros: 3 → 03, 12 → 12.
  • %04d: the points with 4 digits: 450 → 0450.
  • \n at the end.

Count: 6 + 6 + 2 + 1 + 4 + 1 = 20. And snprintf returns 20, which is what we pass to write. The slot array is 64 bytes, far more than needed: that way snprintf never has to cut anything, and there’s room for the '\0' it adds (and which we don’t write to the file).

The loop trace:

iNamesnprintf returnswrite returnsPointer after the write
0Lia202020
1Max202040
2Ane202060
3Teo202080

The second part is a home-made cat:

char buf[64];
ssize_t nread;
printf("--- saves.sav ---\n");
fflush(stdout);                          /* flushes printf before using write */
while ((nread = read(fd, buf, sizeof buf)) > 0)
    write(1, buf, nread);                /* nread, not sizeof buf */
close(fd);

It reads 64 bytes at a time and writes to descriptor 1 (the screen) the bytes it has read. With 80 bytes, the first round reads 64, the second 16 and the third 0, which ends the while.

Output

Slot 0: write returns 20, the pointer ends at 20
Slot 1: write returns 20, the pointer ends at 40
Slot 2: write returns 20, the pointer ends at 60
Slot 3: write returns 20, the pointer ends at 80
--- saves.sav ---
Lia   level 03 0450
Max   level 12 2750
Ane   level 05 0980
Teo   level 07 1200

Check: each write returned 20 and the pointer moved forward 20 at a time up to 80 (4 · 20 = 80). The four lines have their columns perfectly aligned, even though Lia and Max have one- and two-digit levels: %02d and %04d make sure 3 takes up the same space as 12. And the dump comes out after the --- saves.sav --- line thanks to fflush (why, in the next section).

Questions you might ask

**Why fflush(stdout) before the write loop?** Because printf doesn’t write directly: it keeps the text in a library buffer and sends it with write(1, …) later (when it sees a \n if the output is the terminal, or when the buffer fills up if you redirect to a file). Our write(1, …) goes straight to the kernel. If you mix both without emptying the buffer, the printf text could come out after the dump. fflush empties it at that moment.

**Why do I compare written with SLOT_SIZE and not with −1?** Because write can write fewer bytes than you ask for (for instance, if the disk fills up) without returning −1. Comparing with the expected value catches both things: the error and the half-done write.

**Why O_TRUNC?** Because of the story in the theory: if the file already existed and was longer (say, with 5 saved games), without O_TRUNC we’d overwrite the first 4 and the fifth would still be there. Saving must leave exactly what you save.

Why does the dump read 64 at a time and not 20? Because here we don’t care about slots, only about copying bytes to the screen. The buffer size is up to you: with 64 there are 2 reads with data; with 20 there would be 4. The result is the same because we write nread.

What if a name had more than 6 letters? %-6s sets a minimum, not a maximum: “Teodoro” would take 7 and the slot would be 21 bytes. That’s why the program checks that write returned 20. If you wanted to cut it, the format would be %-6.6s.

Writing with write(fd, slot, sizeof slot). It would write all 64 bytes of the array (the 20 good ones, the '\0' and 43 bytes of garbage) and the file would be 256 bytes instead of 80.

Try it yourself

  • Add a fifth player, {"Sam", 1, 50}, and check that the final size is 100 and that her line is Sam level 01 0050.
  • Use a 7-letter name and see what the written check does. Then fix it with %-6.6s.

Program 2: backup

Before touching a saves file, the sensible thing is to copy it. The program creates saves.sav (the same four games, 80 bytes) and copies it to saves.bak with a copy function that reads and writes in 32-byte chunks. It must count the bytes copied, say how big the copy is and behave well if the source file doesn’t exist. What makes it tricky: 80 is not a multiple of 32.

The idea

The copy pattern is always the same and it’s worth learning by heart:

  1. Open the source for reading and the destination for writing (with O_CREAT | O_TRUNC).
  2. While read returns more than 0: write those bytes to the destination.
  3. Close both.
The rounds of the copy loop Three rows, one per round: 32, 32 and 16 bytes are read from saves.sav, go through buf and are written to saves.bak, which grows to 32, 64 and 80 bytes. In the fourth round read returns 0 and the loop ends. Copying 32 at a time: read → buf → write saves.sav (80 bytes) buf[32] saves.bak round 1 · read returns 32 · write(out, buf, 32) 32 round 2 · read returns 32 · write(out, buf, 32) 64 round 3 · read returns 16 · write(out, buf, 16) 80 round 4 · read returns 0 → the while ends In round 3 there are only 16 new bytes: the other 16 in the buffer are leftovers from round 2.
That is why write receives nread and not 32: in the last round, half the buffer is garbage from the previous round.

With a 32-byte buffer and an 80-byte file, the loop does 3 rounds with data (32, 32 and 16) and a fourth in which read returns 0. The diagram shows the danger: in round 3, half the buffer is leftovers from round 2.

Before coding, think about what must happen if the second open (the destination’s) fails. The first one is already open: it has to be closed before leaving.

The code

#include <stdio.h>
#include <fcntl.h>      /* open and the O_... constants */
#include <unistd.h>     /* read, write, lseek, close */

#define SLOT_SIZE 20
#define SAVEDATA "Lia   level 03 0450\n" "Max   level 12 2750\n" \
                 "Ane   level 05 0980\n" "Teo   level 07 1200\n"
#define BUF_SIZE 32

/* Creates the saves file with the four sample slots */
static int prepare(const char *name) {
    int fd = open(name, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror(name);
        return -1;
    }
    write(fd, SAVEDATA, 4 * SLOT_SIZE);
    close(fd);
    return 0;
}

/* Copies src into dest in chunks; returns the bytes copied or -1 */
static long copy(const char *src, const char *dest) {
    int in = open(src, O_RDONLY);
    if (in == -1) {
        perror(src);
        return -1;
    }
    int out = open(dest, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (out == -1) {
        perror(dest);
        close(in);                            /* don't leave the other one open */
        return -1;
    }
    char buf[BUF_SIZE];
    ssize_t nread;
    long total = 0;
    int round = 1;
    while ((nread = read(in, buf, BUF_SIZE)) > 0) {
        ssize_t written = write(out, buf, nread);   /* only what was read */
        if (written != nread) {
            perror("write");
            break;
        }
        total += written;
        printf("  round %d: read %zd, written %zd, total %ld\n",
               round, nread, written, total);
        round++;
    }
    printf("  round %d: read returns %zd -> end\n", round, nread);
    close(in);
    close(out);
    return total;
}

int main(void) {
    if (prepare("saves.sav") == -1)
        return 1;

    printf("Copying saves.sav to saves.bak:\n");
    long n = copy("saves.sav", "saves.bak");
    printf("Copied: %ld bytes\n", n);

    int fd = open("saves.bak", O_RDONLY);
    printf("saves.bak is %ld bytes\n", (long) lseek(fd, 0, SEEK_END));
    close(fd);

    printf("Copying a file that does not exist:\n");
    n = copy("saves_2019.sav", "old.bak");
    printf("copy returns %ld\n", n);
    return 0;
}

A closer look at the copy function, which is the one that matters:

static long copy(const char *src, const char *dest) {
    int in = open(src, O_RDONLY);
    if (in == -1) {
        perror(src);
        return -1;
    }
    int out = open(dest, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (out == -1) {
        perror(dest);
        close(in);                            /* don't leave the other one open */
        return -1;
    }
    char buf[BUF_SIZE];
    ssize_t nread;
    long total = 0;
    int round = 1;
    while ((nread = read(in, buf, BUF_SIZE)) > 0) {
        ssize_t written = write(out, buf, nread);   /* only what was read */
        if (written != nread) {
            perror("write");
            break;
        }
        total += written;
        printf("  round %d: read %zd, written %zd, total %ld\n",
               round, nread, written, total);
        round++;
    }
    printf("  round %d: read returns %zd -> end\n", round, nread);
    close(in);
    close(out);
    return total;
}
  • **The two opens.** The source with O_RDONLY: we’re only going to read it, and that way we can’t damage it by accident. The destination with O_WRONLY | O_CREAT | O_TRUNC, 0644: if it doesn’t exist it’s created, and if it does (an old backup) it’s emptied.
  • **The close(in) in the second if.** If the destination fails, the source was already open. Without that close, every failed call would leave a descriptor taken.
  • **The while.** The assignment goes in brackets, (nread = read(...)) > 0: first what read returns is stored, and then it’s compared. Without the brackets, nread would get the result of the comparison (1 or 0).
  • **write(out, buf, nread).** The heart of the practice: the bytes read are written, not BUF_SIZE.
  • **written != nread.** If the disk fills up, write writes fewer bytes; the copy would be incomplete and we report it with perror.
  • **The function returns long** with the total, or −1 if it couldn’t open something. That way main can tell “I copied 0 bytes” (an empty file) from “I couldn’t copy”.
Roundread returnswrite writestotalSource pointer
132323232
232326464
316168080
40—8080

Output

Copying saves.sav to saves.bak:
  round 1: read 32, written 32, total 32
  round 2: read 32, written 32, total 64
  round 3: read 16, written 16, total 80
  round 4: read returns 0 -> end
Copied: 80 bytes
saves.bak is 80 bytes
Copying a file that does not exist:
saves_2019.sav: No such file or directory
copy returns -1

Check: 32 + 32 + 16 = 80, and lseek(fd, 0, SEEK_END) on the copy confirms it’s 80 bytes, the same as the original. In the second call, saves_2019.sav doesn’t exist: perror(src) prints the file name and the reason, and copy returns −1 without creating old.bak (the destination is never even opened).

Questions you might ask

Why a 32-byte buffer and not the size of the file? Because you don’t know how big the file you’ll be given is: it could be 80 bytes or 2 GB. The loop works with any size. Real programs use buffers of 4096 or 8192 bytes (fewer system calls), but with 32 you can see the rounds clearly.

**Why don’t I add the '\0' to the buffer?** Because here we never treat it as a string: we pass it as it is to write. The '\0' is only needed if you’re going to use %s, strlen and company. In fact, if the file were an image, it could have zeros in the middle, and a “string” copy would stop there.

**What happens if read returns −1 halfway?** The while ends (−1 isn’t greater than 0) and the line after it prints read returns -1. A more complete version would check it and return −1 too.

**Why printf with two spaces in front?** Only so the rounds look “inside” the call to copy in the output. It has nothing to do with files.

write(out, buf, BUF_SIZE). That’s my 96-byte backup: the last round writes 16 good bytes and 16 of leftovers, and the copy ends up bigger than the original. If you compare the sizes with ls -l, you spot it immediately.

Try it yourself

  • Change BUF_SIZE to 7 and predict how many rounds there will be and how much the last one will read (80 = 7 · 11 + 3). Check it.
  • Make copy refuse to overwrite an existing backup using O_EXCL, and try running it twice.

Program 3: editing a saved game

Ane has got 3100 points and her slot, number 2, must be updated without rewriting the whole file. The program reads slot 2, changes its points, reads it again to check, reads the last slot without knowing how many there are and tries to read a slot that doesn’t exist (7). What makes it tricky: you have to read and write the same file, and land exactly on the 4 bytes of the points.

The idea

Thanks to the fixed format, it’s all arithmetic:

  • Slot n starts at n * SLOT_SIZE.
  • Its points start 15 bytes further on: n * SLOT_SIZE + POINTS_POS. For slot 2, 2 · 20 + 15 = 55.
  • The last slot starts 20 bytes before the end: lseek(fd, -SLOT_SIZE, SEEK_END).
Overwriting the points of slot 2 Slot 2 (bytes 40 to 59) before and after. lseek moves the pointer to byte 55 and write writes 3100 over 0980 at bytes 55 to 58; the rest does not change. change_points(fd, 2, 3100): only 4 bytes change Before A 40 n 41 e 42 · 43 · 44 · 45 l 46 e 47 v 48 e 49 l 50 · 51 0 52 5 53 · 54 0 55 9 56 8 57 0 58 \n 59 After A n e · · · l e v e l · 0 5 · 3 1 0 0 \n lseek(fd, 2 · 20 + 15, SEEK_SET) → 55 write(fd, "3100", 4) The rest of the slot (and of the file) is untouched: the size is still 80 bytes.
With O_RDWR and lseek a file is edited in place: you write over it, you do not insert.

The file is opened with O_RDWR (read and write with the same descriptor) and without O_TRUNC, which would delete the saved games.

Before coding, think about how many bytes you have to write. If you write 5 (the 4 digits and the '\0'), you eat the slot’s \n and the next game ends up glued to this one.

The code

#include <stdio.h>
#include <fcntl.h>      /* open and the O_... constants */
#include <unistd.h>     /* read, write, lseek, close */
#include <string.h>     /* strlen */

#define SLOT_SIZE 20
#define SAVEDATA "Lia   level 03 0450\n" "Max   level 12 2750\n" \
                 "Ane   level 05 0980\n" "Teo   level 07 1200\n"
#define POINTS_POS 15                     /* the points start at byte 15 of the slot */

/* Creates the saves file with the four sample slots */
static int prepare(const char *name) {
    int fd = open(name, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror(name);
        return -1;
    }
    write(fd, SAVEDATA, 4 * SLOT_SIZE);
    close(fd);
    return 0;
}

/* Reads slot n into buf (21 bytes); returns what read returns */
static ssize_t read_slot(int fd, int n, char *buf) {
    lseek(fd, (off_t) n * SLOT_SIZE, SEEK_SET);   /* direct jump, without reading the earlier ones */
    ssize_t nread = read(fd, buf, SLOT_SIZE);
    buf[nread > 0 ? nread : 0] = '\0';
    return nread;
}

/* Overwrites the 4 points digits of slot n */
static void change_points(int fd, int n, int new_points) {
    char digits[8];
    snprintf(digits, sizeof digits, "%04d", new_points);  /* always 4 digits */
    off_t pos = lseek(fd, (off_t) n * SLOT_SIZE + POINTS_POS, SEEK_SET);
    write(fd, digits, strlen(digits));                    /* 4 bytes, not one more */
    printf("Writing \"%s\" at byte %ld\n", digits, (long) pos);
}

int main(void) {
    if (prepare("saves.sav") == -1)
        return 1;
    int fd = open("saves.sav", O_RDWR);    /* read AND write */
    if (fd == -1) {
        perror("saves.sav");
        return 1;
    }
    char slot[SLOT_SIZE + 1];

    read_slot(fd, 2, slot);
    printf("Before: %s", slot);
    change_points(fd, 2, 3100);
    read_slot(fd, 2, slot);
    printf("After:  %s", slot);

    /* The last slot, without knowing how many there are: 20 bytes before the end */
    off_t pos = lseek(fd, -SLOT_SIZE, SEEK_END);
    read(fd, slot, SLOT_SIZE);
    slot[SLOT_SIZE] = '\0';
    printf("Last (byte %ld): %s", (long) pos, slot);

    /* A slot that does not exist: read returns 0 */
    ssize_t n = read_slot(fd, 7, slot);
    printf("Slot 7: read returns %zd (empty)\n", n);
    printf("Size: %ld bytes (it has not grown)\n", (long) lseek(fd, 0, SEEK_END));
    close(fd);
    return 0;
}

The two functions, up close:

/* Reads slot n into buf (21 bytes); returns what read returns */
static ssize_t read_slot(int fd, int n, char *buf) {
    lseek(fd, (off_t) n * SLOT_SIZE, SEEK_SET);   /* direct jump, without reading the earlier ones */
    ssize_t nread = read(fd, buf, SLOT_SIZE);
    buf[nread > 0 ? nread : 0] = '\0';
    return nread;
}

/* Overwrites the 4 points digits of slot n */
static void change_points(int fd, int n, int new_points) {
    char digits[8];
    snprintf(digits, sizeof digits, "%04d", new_points);  /* always 4 digits */
    off_t pos = lseek(fd, (off_t) n * SLOT_SIZE + POINTS_POS, SEEK_SET);
    write(fd, digits, strlen(digits));                    /* 4 bytes, not one more */
    printf("Writing \"%s\" at byte %ld\n", digits, (long) pos);
}
  • **read_slot** jumps with SEEK_SET to the start of the slot (there’s no need to read the earlier ones) and reads 20 bytes. The (off_t) converts before multiplying, so that with huge files the calculation doesn’t overflow an int. The '\0' goes at nread if something was read and at 0 if not: if read returns 0 (a slot that doesn’t exist) or −1, buf becomes an empty string instead of holding garbage. That’s why buf has to be 21 bytes.
  • **change_points** formats the points with %04d (always 4 digits: 3100 → 3100, 50 → 0050), jumps to n · 20 + 15 and writes strlen(digits) bytes: 4, without the '\0'. It prints the position lseek returns so you can see where it wrote.

The positions the pointer goes through:

StepCallPointer
Read slot 2lseek(fd, 40, SEEK_SET) + read of 2040 → 60
Change pointslseek(fd, 55, SEEK_SET) + write of 455 → 59
Re-read slot 2lseek(fd, 40, SEEK_SET) + read of 2040 → 60
Last slotlseek(fd, -20, SEEK_END) + read of 2060 → 80
Slot 7lseek(fd, 140, SEEK_SET) + read140 (returns 0)

Output

Before: Ane   level 05 0980
Writing "3100" at byte 55
After:  Ane   level 05 3100
Last (byte 60): Teo   level 07 1200
Slot 7: read returns 0 (empty)
Size: 80 bytes (it has not grown)

Check: 0980 becomes 3100 at byte 55 (2 · 20 + 15 = 55) and the rest of Ane’s line doesn’t change. The last slot starts at byte 60 (80 − 20 = 60) and it’s Teo’s. Slot 7 would start at 7 · 20 = 140, beyond the 80 bytes: lseek doesn’t complain (moving outside is legal) and read returns 0. And the size is still 80: we’ve overwritten, not added.

Questions you might ask

**Why O_RDWR and not open twice, once for reading and once for writing?** You could, but you’d have two open-file entries with two pointers (we saw it in the theory) and you’d have to lseek each one. With O_RDWR it’s a single descriptor and a single pointer.

Why don’t we have to read slots 0 and 1 to get to slot 2? Because the format is fixed: we know slot 2 starts at 40. With variable-length lines (a normal CSV) you couldn’t: you’d have to read from the start counting \ns. That’s the great advantage of fixed-size records.

Why doesn’t reading slot 7 give an error? Because an lseek past the end is valid, and a read from there simply finds no bytes: it returns 0, just like at the end of a file. If you wrote there, the file would grow and there’d be a gap of zeros between 80 and 140.

**Why do I check nread > 0 before adding the '\0'?** Because if read returned −1, buf[-1] = '\0' would write outside the array. With the ternary operator, in that case the '\0' goes in buf[0].

Opening with O_RDWR | O_TRUNC “out of habit”. The file is emptied on opening, slot 2 doesn’t exist and the write at 55 creates a 59-byte file with 55 zeros in front.

To know how many slots the file has without reading it, lseek(fd, 0, SEEK_END) / SLOT_SIZE. With 80 bytes, 80 / 20 = 4.

Try it yourself

  • Write change_level(fd, n, new_level) that changes the 2 digits of the level (they’re at byte 12 of the slot) and moves Teo up to level 8.
  • Print the number of slots with the SEEK_END trick and go through all of them from the last to the first using lseek backwards.

Common mistakes in this practice

  • **Writing the buffer size instead of nread:** the copy grows with garbage (my 96-byte one).
  • **Using sizeof with arrays bigger than what you write:** 64 bytes per slot instead of 20.
  • **Writing the '\0' to the file:** with strlen(digits) + 1 or sizeof digits you eat the next byte (the \n).
  • **Forgetting close on the error paths:** if the second open fails, the first one is still open.
  • **Opening with O_TRUNC when you want to edit:** it deletes what you were going to modify.
  • **Leaving out the brackets in (nread = read(...)) > 0:** nread ends up being 1 or 0.

Watch it step by step in Python Tutor

Python Tutor draws the variables and arrays at every step; choose C. Since it doesn’t work with real files on disk, here the “file” is a 14-byte array and the copy is done with a 4-byte buffer, with the same steps as copy:

#include <stdio.h>

int main(void) {
    char src[] = "Lia 03\nMax 12\n";     /* 14 bytes, like a file */
    char dest[16] = {0};
    char buf[4];                         /* the copy buffer */
    int size = 14, pos = 0, total = 0, nread;
    do {
        nread = 0;                       /* "read" of up to 4 bytes */
        while (nread < 4 && pos < size)
            buf[nread++] = src[pos++];
        for (int i = 0; i < nread; i++)  /* "write" of nread bytes */
            dest[total + i] = buf[i];
        total += nread;
    } while (nread > 0);                 /* 4, 4, 4, 2 and 0 */
    printf("Copied %d bytes\n%s", total, dest);
    return 0;
}

Its output:

Copied 14 bytes
Lia 03
Max 12

What to watch:

  1. In the first round of the do, see how buf fills up with L, i, a and the space, and pos goes from 0 to 4.
  2. In the fourth round only 2 bytes are left: nread is 2, and buf[2] and buf[3] still hold the leftovers from the previous round. Notice that the for only copies 2.
  3. Watch dest grow 4 at a time (4, 8, 12) and then only 2 (14).
  4. In the fifth round nread is 0, the for does nothing and the do-while ends: the same ending as the read that returns 0.

Summary

  • With a fixed format (%-6s, %02d, %04d) every slot has the same size and the position of any field can be computed.
  • write returns how many bytes it wrote: compare it with the number you expected, not only with −1.
  • The copy loop is while ((nread = read(...)) > 0) write(out, buf, nread);, and the third argument of write is always nread.
  • To edit a file in place: O_RDWR, no O_TRUNC, lseek to the exact position and a write of just the right bytes.
  • lseek past the end isn’t an error: read returns 0 and write creates a gap of zeros.
  • Close everything you open, also on the error paths.

Checklist: have you practised it?

  • Have you run the three programs and checked the outputs?
  • Could you work out which byte the points of slot 3 are at?
  • Can you explain why the copy with write(out, buf, 32) would be 96 bytes long?
  • Do you know what read returns when you read a slot that doesn’t exist?
  • Have you tried the “Try it yourself” tasks without looking at the theory?

If you answered yes to almost everything, you’re ready for the system calls exercises, where I no longer give you the code. See you there!

Next step: exercisesSystem calls in C — exercises with byte counters, O_APPEND, a ranking of saved games and three lseek and read bugs (and the saved game that refused to go first)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *