System calls in C — theory with open, read, write, lseek and a saved game written on top of another (and the points that weren’t mine)

Last summer I let my friend Max try a little game I had written in C to practise: it saved the game to a file, save.sav, with one line per player. Max reached level 12 with 2750 points and saved. Then I played, reached level 3 and saved on top. When I loaded the game, it greeted me like this:

Bytes read: 20
Loaded save: Lia   level 03 2750

My name, my level… and Max’s 2750 points. No compiler error, no warning. Here is the program, cut down to the essentials:

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

int main(void) {
    /* 1. Max's old save: a 20-byte slot */
    int fd = open("save.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    write(fd, "Max   level 12 2750\n", 20);
    close(fd);

    /* 2. I save MY game on top... but without O_TRUNC */
    const char *mine = "Lia   level 03";       /* only 14 bytes */
    fd = open("save.sav", O_WRONLY | O_CREAT, 0644);
    write(fd, mine, strlen(mine));             /* overwrites the first 14 */
    close(fd);

    /* 3. I load the save */
    char buf[64];
    fd = open("save.sav", O_RDONLY);
    ssize_t n = read(fd, buf, sizeof buf - 1);
    close(fd);
    buf[n] = '\0';                             /* read doesn't add the '\0' */
    printf("Bytes read: %zd\n", n);
    printf("Loaded save: %s", buf);
    return 0;
}

Can you spot the bug? The second open is missing O_TRUNC. Without that option, opening a file for writing does not empty it: the pointer starts at byte 0 and write overwrites byte by byte. My save took 14 bytes ("Lia level 03") and Max’s took 20, so the last 6 (" 2750\n") survived. The numbers add up: 20 − 14 = 6 bytes of Max’s, and read read all 20 bytes of the file.

Today you’ll understand why that happens and learn to handle files the way the operating system itself does: with system calls. In the previous article, on processes, you saw that fork() creates a process by asking the kernel. Files work the same way: open, read, write, lseek and close are direct requests to the kernel, without the fopen and fprintf layer. By the end you’ll be ready for the practice, where we’ll save, copy and edit a real saves file.

Library functions versus system calls

In C there are two ways of working with files:

Library functionsSystem calls
Examplesfopen, fprintf, fgets, fcloseopen, read, write, lseek, close
Headers<stdio.h><fcntl.h>, <unistd.h> (and <sys/stat.h> for permissions)
What opening returnsA FILE * pointerAn integer: the descriptor
BufferYes, the library collects data and writes it in blocksNo: every call goes straight to the kernel
They work withFormatted text (%d, %s)Raw bytes

Library functions are built on top of system calls: fprintf ends up calling write. Why learn the lower layer if the upper one is more comfortable? Because it’s the most direct access a program has, because it works the same with files, with the terminal and with anything that sends or receives bytes (devices, pipes, network connections), and because it’s what IC2 asks you about.

The system calls we’re going to use are not standard C but POSIX (Linux, macOS…). They compile without trouble with gcc -Wall -std=c11 on Linux; on Windows you need WSL or a virtual machine.

The headers in this article are always the same: <fcntl.h> brings open and the O_... constants; <unistd.h> brings read, write, lseek and close.

File descriptors: 0, 1, 2… and the next free one

When a process opens a file, the kernel doesn’t give it the file: it gives it an integer, the descriptor. From then on everything is done with that number: read(3, …) means “read from the file I have open as 3”.

Before main starts, every process already has three open descriptors, by convention:

DescriptorNameUsually
0standard input (stdin)the keyboard
1standard output (stdout)the terminal
2standard error (stderr)the terminal

That’s why a program’s first open almost always returns 3: the kernel hands out the lowest free descriptor, and 0, 1 and 2 are already taken. Look:

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

int main(void) {
    /* 0, 1 and 2 are already open before main starts */
    const char *msg = "Written with write(1, ...)\n";
    write(1, msg, strlen(msg));               /* 1 = standard output */
    const char *err = "Written with write(2, ...)\n";
    write(2, err, strlen(err));               /* 2 = standard error */

    /* Each open returns the lowest free descriptor */
    int a = open("slot1.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    int b = open("slot2.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    int c = open("slot3.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    printf("slot1 -> fd %d, slot2 -> fd %d, slot3 -> fd %d\n", a, b, c);

    close(b);                                 /* 4 becomes free */
    int d = open("slot4.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    printf("After closing fd %d, slot4 -> fd %d\n", b, d);

    /* A file that does not exist: open returns -1 */
    int e = open("slot9.sav", O_RDONLY);
    printf("open of slot9.sav returns %d\n", e);
    if (e == -1)
        perror("Error opening slot9.sav"); /* explains the reason */

    close(a);
    close(c);
    close(d);
    return 0;
}

The output of the single run:

Written with write(1, ...)
Written with write(2, ...)
slot1 -> fd 3, slot2 -> fd 4, slot3 -> fd 5
After closing fd 4, slot4 -> fd 4
open of slot9.sav returns -1
Error opening slot9.sav: No such file or directory

Step by step:

  • The first two lines don’t use printf: they are write(1, …) and write(2, …). Writing to descriptor 1 is writing to the terminal, without opening anything. Descriptor 2 also ends up on the terminal, but it’s a different channel (if you redirect the output with > output.txt, 2 would still show on screen).
  • slot1, slot2 and slot3 get 3, 4 and 5: the next free ones, in order.
  • When 4 is closed, that slot becomes free, and the next open reuses it: slot4.sav gets a 4, not a 6. That’s the “lowest free” rule.
  • slot9.sav doesn’t exist, so open returns −1. −1 is how all these calls say “something went wrong”, and perror turns the reason into text: No such file or directory.

Not checking for −1. If open fails and you carry on, you call read(-1, …), which fails too, and the program goes on with data that was never read. After every open, an if (fd == -1) { perror(...); return 1; }.

The text you pass to perror goes before the colon; what comes after is added by the system according to the error. In the lab terminal it may appear in Spanish if the system is set to Spanish; here you see it in English.

The kernel’s three tables

A descriptor is just an index. So where are the reading position, the opening mode, the size of the file? In three structures the kernel maintains:

  1. Descriptor table (one per process): position 3 points to an entry of the next table. It’s the only thing that “is” yours.
  2. Open file table (system-wide): one entry for each open. It stores the read/write pointer (which byte you’re at) and the mode (O_RDONLY, O_WRONLY…).
  3. I-node table: one entry per file on disk, with its size, permissions, owner and where its blocks are.
Descriptors, open files and i-nodes The parent process has descriptors 0, 1 and 2 on the terminal and 3 and 4 on save.sav. Descriptor 3 points to open-file entry A (pointer 20), shared with fd 3 of the child created with fork. Descriptor 4, from a second open, points to entry B (pointer 0). Both entries point to the same i-node of save.sav. The kernel's three tables Descriptor table one per process Open files pointer + mode I-node table one entry per file Parent process 0 stdin → terminal 1 stdout → terminal 2 stderr → terminal 3 save.sav 4 save.sav Child process (fork) 0 stdin → terminal 1 stdout → terminal 2 stderr → terminal 3 save.sav Entry A (shared) pointer: 20 mode: O_WRONLY Entry B (second open) pointer: 0 mode: O_WRONLY save.sav size: 20 bytes permissions: rw-r–r– owner, blocks… fork copies the descriptor table: the child's fd 3 points to the SAME entry A
A descriptor is just an index: the pointer lives in the open-file entry, and the file data in the i-node.

Notice two details in the diagram that we’ll check with code at the end of the article: if the same process does two opens of the same file, it has two entries (A and B) with two different pointers; if it does one open and then fork, the child copies the descriptor table, but its fd 3 points to the same entry A, so parent and child share the pointer.

When you don’t understand what a program does with files, ask yourself “how many open-file entries are there and where is each one’s pointer?”. Almost every mystery is solved there.

open: opening (or creating) a file

int open(const char *path, int flags);
int open(const char *path, int flags, mode_t mode);   /* required with O_CREAT */

flags says how you want to open it. One of the first three is mandatory, and options can be added with the | operator:

FlagMeaning
O_RDONLYRead only
O_WRONLYWrite only
O_RDWRRead and write
O_CREATCreates the file if it doesn’t exist (needs the third parameter, the permissions)
O_TRUNCIf it already existed, empties it (size 0) when opening
O_APPENDEvery write goes to the end of the file, overwriting nothing
O_EXCLTogether with O_CREAT: fails if the file already exists

Why |? Each flag is a number with a single bit set, and OR combines them into a single int: O_WRONLY | O_CREAT | O_TRUNC means “write, create it if needed and empty it”. Now see how the most important ones behave:

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

/* Reads the whole file and shows it with its size */
static void show(const char *name) {
    char buf[128];
    int fd = open(name, O_RDONLY);
    ssize_t n = read(fd, buf, sizeof buf - 1);
    close(fd);
    buf[n] = '\0';
    printf(" (%zd bytes)\n%s", n, buf);
}

int main(void) {
    /* O_TRUNC: if it already existed, it is emptied before writing */
    int fd = open("saves.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    write(fd, "Lia   level 03 0450\n", 20);
    close(fd);
    printf("1) After O_TRUNC:");
    show("saves.sav");

    /* O_APPEND: every write goes to the end, overwriting nothing */
    fd = open("saves.sav", O_WRONLY | O_APPEND);
    write(fd, "Max   level 12 2750\n", 20);
    close(fd);
    printf("2) After O_APPEND:");
    show("saves.sav");

    /* O_CREAT | O_EXCL: fails if the file already exists */
    fd = open("saves.sav", O_WRONLY | O_CREAT | O_EXCL, 0644);
    printf("3) O_CREAT | O_EXCL returns %d\n", fd);
    if (fd == -1)
        perror("   saves.sav");

    /* creat(name, mode) = open(name, O_WRONLY | O_CREAT | O_TRUNC, mode) */
    fd = creat("copy.sav", 0644);
    printf("4) creat returns fd %d\n", fd);
    close(fd);

    /* The permissions we asked for with 0644 */
    struct stat st;
    stat("saves.sav", &st);
    printf("5) Permissions of saves.sav: %o\n", st.st_mode & 0777);
    return 0;
}

Output:

1) After O_TRUNC: (20 bytes)
Lia   level 03 0450
2) After O_APPEND: (40 bytes)
Lia   level 03 0450
Max   level 12 2750
3) O_CREAT | O_EXCL returns -1
   saves.sav: File exists
4) creat returns fd 3
5) Permissions of saves.sav: 644

Between the lines:

  • 1) With O_TRUNC the file starts empty and ends up with Lia’s 20 bytes. It’s the “save game” combination: the one I was missing in the opening story.
  • 2) With O_APPEND, Max’s write goes after it: 20 + 20 = 40 bytes. Nothing had to be moved; the kernel takes the pointer to the end before every write.
  • 3) O_CREAT | O_EXCL means “create it, but only if it’s new”. Since saves.sav already existed, open returns −1 and perror says File exists. It’s useful so you don’t overwrite a saved game by accident.
  • 4) creat(path, mode) is an old call that is exactly equivalent to open(path, O_WRONLY | O_CREAT | O_TRUNC, mode). It returns 3 because the previous ones were already closed inside show.
  • 5) The permissions come out as 644, the ones we asked for with 0644.

The third parameter: permissions in octal

0644 starts with 0 because it’s in octal (base 8): each digit is 3 bits, one per permission (read = 4, write = 2, execute = 1).

DigitFor whomSumPermissions
6owner4 + 2rw-
4group4r--
4others4r--

So 0644 is rw-r--r--, the same you’d see with ls -l. Careful: without the leading 0, 644 would be a decimal number (1204 in octal) and the permissions would come out really strange.

Using O_CREAT without the third parameter. The compiler doesn’t complain (not even with -Wall), but open takes as permissions whatever happens to be in memory at that moment: sometimes you get a file not even you can read. With O_CREAT, always the mode.

The system also applies the user’s umask, which removes permissions. The usual one is 022 (removes write permission for group and others), which is why 0644 arrives intact. If you asked for 0666, with that umask you’d still get 644.

read: reading bytes (and what read does NOT do)

ssize_t read(int fd, void *buf, size_t count);

read tries to read count bytes from descriptor fd and copies them into buf, starting wherever the pointer is. It returns:

  • the number of bytes it actually read (it can be fewer than you asked for);
  • 0 if the pointer was already at the end of the file;
  • −1 if there’s an error.

And after reading, the pointer moves forward by those bytes. That’s why you read in a loop until it returns 0:

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

int main(void) {
    int fd = open("save.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    write(fd, "Lia   level 03 0450\n", 20);
    close(fd);

    fd = open("save.sav", O_RDONLY);
    if (fd == -1) {                           /* always check for -1 */
        perror("save.sav");
        return 1;
    }
    char buf[9];                              /* 8 bytes + room for the '\0' */
    ssize_t nread;
    int round = 1;
    /* read returns the bytes read; 0 = end of file */
    while ((nread = read(fd, buf, 8)) > 0) {
        buf[nread] = '\0';                    /* I add it: read doesn't */
        printf("round %d: read returns %zd -> \"", round, nread);
        for (char *p = buf; *p != '\0'; p++)  /* I walk up to the '\0' */
            if (*p == '\n')
                printf("\\n");                /* the newline is a byte too */
            else
                putchar(*p);
        printf("\"\n");
        round++;
    }
    printf("round %d: read returns %zd (end of file)\n", round, nread);
    close(fd);
    return 0;
}

The output, round by round:

round 1: read returns 8 -> "Lia   le"
round 2: read returns 8 -> "vel 03 0"
round 3: read returns 4 -> "450\n"
round 4: read returns 0 (end of file)
RoundPointer beforeread returnsBytes readPointer after
108Lia le8
288vel 03 016
3164450\n20
4200(nothing: end of file)20

Check: 8 + 8 + 4 = 20 bytes, the size of the slot. In round 3 we asked for 8 but only 4 were left: read doesn’t make up the other 4, it simply returns fewer. And the \n counts as one more byte.

You add the ‘\0’ yourself

Look at the line buf[nread] = '\0';. read copies bytes; it knows nothing about C strings, so it doesn’t add the null character. If you then use buf as a string (%s, strlen, or a loop up to the '\0' as here), you have to terminate it yourself. Two details make it work:

  • The buffer is 9 bytes long, one more than the 8 we ask for, so the '\0' always fits.
  • It goes in buf[nread], not in buf[8]: in round 3 only 4 bytes were read, and what’s in positions 4-7 are leftovers from round 2.

printf("%s", buf) right after read, without the '\0'. Sometimes it works (if there happened to be a zero in memory) and sometimes it prints garbage or leftovers from the previous read. You’ll see it live in the exercises.

write and close

ssize_t write(int fd, const void *buf, size_t count);
int close(int fd);

write writes count bytes from buf at the pointer’s position (or at the end, with O_APPEND), moves the pointer forward and returns how many it wrote, or −1. If the pointer is in the middle of the file, it overwrites: it doesn’t insert, it doesn’t shift what comes after. That’s exactly what happened to my saved game.

Two rules for the third argument:

  • To write a string, strlen(text), not sizeof(text): if text is a char *, sizeof is 8 (the size of a pointer), not the length.
  • To write what you’ve just read, the value read returned (nread), not the buffer size. In the practice we’ll copy a file like that.

close(fd) frees the descriptor (the number becomes free again, as you saw with 4) and the open-file entry if nobody else is using it. When a process ends, the kernel closes all its descriptors, but closing them by hand is the right thing to do: a process has a maximum number of open descriptors, and a long-running program that opens without closing ends up running out of them.

lseek: moving the pointer

Every open file has its pointer, which marks where the next read or write will happen. lseek moves it without reading or writing anything:

off_t lseek(int fd, off_t offset, int whence);

The third argument says where the offset (which can be negative) is counted from:

whenceCounts fromExample with an 80-byte file
SEEK_SETthe start (byte 0)lseek(fd, 40, SEEK_SET) → 40
SEEK_CURthe current positionif you’re at 43, lseek(fd, 9, SEEK_CUR) → 52
SEEK_ENDthe end (the size)lseek(fd, -2, SEEK_END) → 78

It returns the new position (or −1). Two widely used tricks come from that: lseek(fd, 0, SEEK_CUR) tells you where you are without moving, and lseek(fd, 0, SEEK_END) tells you how big the file is.

Let’s try it with a file of four saved games. Each one takes exactly 20 bytes (we’ll call it a slot), so slot 2 starts at byte 2 · 20 = 40:

#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"

int main(void) {
    int fd = open("saves.sav", O_RDWR | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("saves.sav");
        return 1;
    }
    write(fd, SAVEDATA, 4 * SLOT_SIZE);      /* 80 bytes */
    printf("After writing, the pointer is at %ld\n", (long) lseek(fd, 0, SEEK_CUR));

    /* SEEK_SET: from the start. Slot 2 = byte 2 * 20 = 40 */
    char name[4];
    off_t pos = lseek(fd, 2 * SLOT_SIZE, SEEK_SET);
    read(fd, name, 3);
    name[3] = '\0';
    printf("SEEK_SET 40 -> %ld, leo \"%s\"\n", (long) pos, name);
    printf("After reading 3 bytes, SEEK_CUR 0 -> %ld\n", (long) lseek(fd, 0, SEEK_CUR));

    /* SEEK_CUR: from where I am. 43 + 9 = 52, where the level starts */
    char level[3];
    pos = lseek(fd, 9, SEEK_CUR);
    read(fd, level, 2);
    level[2] = '\0';
    printf("SEEK_CUR +9 -> %ld, Ane's level: %s\n", (long) pos, level);

    /* SEEK_END: from the end. The last byte (79) is the '\n' */
    char c;
    pos = lseek(fd, -2, SEEK_END);           /* 80 - 2 = 78 */
    read(fd, &c, 1);
    printf("SEEK_END -2 -> %ld, last digit: %c\n", (long) pos, c);
    printf("SEEK_END 0 -> %ld = file size\n", (long) lseek(fd, 0, SEEK_END));

    /* Past the end: the gap is filled with zeros */
    pos = lseek(fd, 5, SEEK_END);            /* 80 + 5 = 85 */
    write(fd, "!", 1);
    printf("SEEK_END +5 -> %ld; after writing 1 byte, size %ld\n",
           (long) pos, (long) lseek(fd, 0, SEEK_END));
    unsigned char tail[6];
    lseek(fd, 80, SEEK_SET);
    read(fd, tail, 6);
    printf("Bytes 80 to 85:");
    for (int i = 0; i < 6; i++)
        printf(" %d", tail[i]);              /* 33 is the code of '!' */
    printf("\n");
    close(fd);
    return 0;
}

Output:

After writing, the pointer is at 80
SEEK_SET 40 -> 40, read "Ane"
After reading 3 bytes, SEEK_CUR 0 -> 43
SEEK_CUR +9 -> 52, Ane's level: 05
SEEK_END -2 -> 78, last digit: 0
SEEK_END 0 -> 80 = file size
SEEK_END +5 -> 85; after writing 1 byte, size 86
Bytes 80 to 85: 0 0 0 0 0 33
The three reference points of lseek A bar with the four 20-byte slots of saves.sav (bytes 0 to 80). Four arrows: SEEK_SET 40 moves the pointer from 0 to 40; SEEK_CUR +9 moves it from 43 to 52; SEEK_END -2 leaves it at 78; SEEK_END +5 leaves it at 85, and writing there fills bytes 80 to 84 with zeros. lseek moves the pointer of saves.sav (80 bytes) slot 0 · Lia slot 1 · Max slot 2 · Ane slot 3 · Teo 0 20 40 60 80 85 bytes 80-84: gap of zeros · 85: "!" lseek(fd, 40, SEEK_SET) → 40 lseek(fd, 9, SEEK_CUR) → 52 lseek(fd, -2, SEEK_END) → 78 lseek(fd, 5, SEEK_END) → 85 SEEK_SET counts from 0 · SEEK_CUR from the pointer · SEEK_END from the size (80)
Each arrow starts at its reference point: the start, the current pointer or the end of the file.

Follow the arrows in the diagram with the output in front of you:

  1. After writing the 80 bytes, the pointer is at the end: 80.
  2. SEEK_SET 40 jumps to slot 2 and a 3-byte read reads Ane. The pointer moves on: 40 + 3 = 43.
  3. SEEK_CUR +9 counts from 43: 43 + 9 = 52, exactly where Ane’s level (05) is.
  4. SEEK_END -2 counts from the size: 80 − 2 = 78. Why −2 and not −1? Because the last byte (79) is the \n. The last digit of Teo’s points (1200) is one before it.
  5. SEEK_END 0 returns 80: the size.
  6. SEEK_END +5 takes the pointer past the end, to 85. That’s not an error. When the ! is written there, the file grows to 86 bytes and bytes 80 to 84, which we never wrote, are 0 (33 is the ASCII code of !). That’s the “gap of zeros”.

lseek neither reads nor writes: it only changes a number in the open-file entry. That’s why you can take the pointer outside the file without an error; the problem (or the fun) comes when you write.

lseek returns off_t, an integer that can be 64 bits. To print it with printf it’s cast to long and printed with %ld, as in the code.

Descriptors inherited with fork

As promised in the three-tables diagram. Now that you know the pointer lives in the open-file entry, what do you think happens in these two cases?

  • A) A process opens the same file twice, writes Lia with the first descriptor and Max with the second.
  • B) A process opens the file once, calls fork, the child writes Lia and, when it finishes, the parent writes Max.
#include <stdio.h>
#include <fcntl.h>      /* open and the O_... constants */
#include <unistd.h>     /* read, write, lseek, close */
#include <sys/types.h>  /* pid_t */
#include <sys/wait.h>   /* waitpid */

/* Reads the whole file and shows it with its size */
static void show(const char *name) {
    char buf[128];
    int fd = open(name, O_RDONLY);
    ssize_t n = read(fd, buf, sizeof buf - 1);
    close(fd);
    buf[n] = '\0';
    printf(" (%zd bytes)\n%s", n, buf);
}

int main(void) {
    /* A) Two opens of the same file: two independent pointers */
    int a = open("two_opens.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    int b = open("two_opens.sav", O_WRONLY);
    write(a, "Lia   level 03 0450\n", 20);   /* a's pointer moves to 20 */
    write(b, "Max   level 12 2750\n", 20);   /* b's is still at 0: overwrites Lia */
    close(a);
    close(b);
    printf("A) Two opens:");
    show("two_opens.sav");

    /* B) A single open and then fork: parent and child share the pointer */
    int fd = open("fork.sav", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    pid_t pid = fork();
    if (pid == 0) {                            /* child: inherits fd 3 */
        write(fd, "Lia   level 03 0450\n", 20);
        close(fd);                             /* closes ITS copy of the fd */
        return 0;
    }
    waitpid(pid, NULL, 0);                     /* the parent waits for the child */
    printf("B) The parent sees the pointer at %ld\n", (long) lseek(fd, 0, SEEK_CUR));
    write(fd, "Max   level 12 2750\n", 20);    /* writes after it, not on top */
    close(fd);
    printf("B) open + fork:");
    show("fork.sav");
    return 0;
}

Output:

A) Two opens: (20 bytes)
Max   level 12 2750
B) The parent sees the pointer at 20
B) open + fork: (40 bytes)
Lia   level 03 0450
Max   level 12 2750
Two opens versus open + fork Panel A: two opens of the same file have independent pointers; a writes Lia and ends at 20, b is still at 0 and writes Max on top: the file ends with 20 bytes. Panel B: with one open and fork, parent and child share the pointer; the child writes Lia at 0-19 and the parent Max at 20-39: 40 bytes. Who shares the pointer? A) Two opens 1. write(a): Lia at 0-19; a moves to 20 2. write(b): b was still at 0 → overwrites Lia Max 0 20 40 a = 20 b = 0 → 20 Result: 20 bytes, Lia lost B) One open + fork 1. the child writes Lia at 0-19 → 20 2. the parent writes Max at 20-39 → 40 Lia Max 0 20 40 shared pointer = 40 Result: 40 bytes, nothing overwritten
Each open creates its own entry (its own pointer); fork copies the descriptor, not the entry.
  • In A, each open created its own entry with its own pointer. a‘s moved to 20, but b‘s was still at 0, so Max overwrote Lia: 20 bytes and Lia gone. Another way to lose a saved game.
  • In B, the child inherited descriptor 3, which points to the same entry as the parent’s. When the child wrote its 20 bytes, the shared pointer moved to 20, and the parent sees it that way (The parent sees the pointer at 20). Its write goes after: 40 bytes, nothing overwritten.

The waitpid from the processes article is what guarantees the order: without it, parent and child could write at the same time. And each one does its own close: the child closes its copy of the descriptor, and the entry stays alive until the parent closes its own.

Common mistakes

Opening for writing without O_TRUNC when you want to replace the contents. If the new data is shorter, leftovers of the old data remain (Max’s saved game).

O_CREAT without the permissions, or with 644 instead of 0644.

Using the buffer as a string without buf[nread] = '\0', or reserving exactly the bytes you ask read for and leaving no room for the '\0'.

Writing sizeof buf bytes instead of the nread ones: you copy garbage from the end of the buffer.

lseek(fd, -1, SEEK_END) to read the last character of a text file: the last byte is usually the \n.

Opening with O_WRONLY and then trying to read (or the other way round). read returns −1 with Bad file descriptor; if you’re going to do everything with the same descriptor, O_RDWR.

Watch it step by step in Python Tutor

Python Tutor runs code line by line and draws the variables and memory; choose C in the drop-down. It isn’t designed to work with real files on disk, so here we simulate one: a struct with the bytes, the size and the pointer. my_lseek and my_read do the same as the real ones, so you can watch the pointer move at every step:

#include <stdio.h>
#define SET 0
#define CUR 1
#define END 2
struct File { char data[16]; int size; int pos; };  /* pos = the pointer */

int my_lseek(struct File *f, int off, int whence) {
    int base = whence == SET ? 0 : whence == CUR ? f->pos : f->size;
    f->pos = base + off;                 /* only the pointer moves */
    return f->pos;
}
int my_read(struct File *f, char *buf, int n) {
    int i = 0;
    while (i < n && f->pos < f->size)
        buf[i++] = f->data[f->pos++];    /* copies one byte and moves on */
    return i;                            /* 0 = end of file */
}
int main(void) {
    struct File f = {"Lia 03\nMax 12\n", 14, 0};
    char buf[8] = {0};
    int n = my_read(&f, buf, 3);         /* "Lia": pos becomes 3 */
    my_lseek(&f, 4, CUR);                /* skips " 03\n": pos 7 */
    n = my_read(&f, buf, 3);             /* "Max" */
    int p = my_lseek(&f, -3, END);       /* 14 - 3 = 11 */
    n = my_read(&f, buf, 8);             /* I ask for 8, only 3 are left */
    printf("%d %d %c%c\n", p, n, buf[0], buf[1]);
    return 0;
}

When you run it, the last line it prints is:

11 3 12

What to watch:

  1. In the first my_read, see how f.pos goes from 0 to 3 while buf fills up with L, i, a: reading moves the pointer.
  2. In my_lseek(&f, 4, CUR), only f.pos changes (from 3 to 7). data and buf aren’t touched: that’s all lseek does.
  3. In my_lseek(&f, -3, END), the base is f->size (14), and 14 − 3 = 11, the byte of the 1 in 12.
  4. In the last my_read, 8 bytes are requested, but the while stops when pos reaches size: it returns 3, like the real read in the last round.

Summary

CallWhat it doesReturnsWatch out for
open(path, flags[, mode])Opens or creates and gives a descriptorThe lowest free descriptor, or −1O_TRUNC, and 0644 with O_CREAT
read(fd, buf, n)Reads up to n bytes and moves onBytes read, 0 at the end, −1It doesn’t add the '\0'
write(fd, buf, n)Writes n bytes and moves onBytes written or −1It overwrites; strlen or nread
lseek(fd, offset, whence)Moves the pointerThe new position or −1SEEK_END points past the last byte
close(fd)Frees the descriptor0 or −1Always close what you open

Three ideas to take away:

  1. A descriptor is just a number (0, 1 and 2 come built in); the pointer and the mode live in the open-file entry, and two opens are two pointers, but a fork shares the same one.
  2. read and write work with bytes and tell you how many they moved: use that number, and add the '\0' yourself if you’re going to treat what you read as text.
  3. With lseek and fixed-size records you can go straight to any field: position = record size · number + offset within the record.

In the practice we build the complete saves file: we save it slot by slot, make a backup with a read and write loop and edit the points of one saved game without touching the rest. After that, on Linux, comes another piece you’ve already glimpsed with 0644: permissions and the chmod command.

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

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 *