Session 1 · Operating Systems

C basics: pointers, strings and files

The C under everything else: argc/argv, pointers, strings that end in a 0 byte, and the two ways to a file – buffered fopen/fgets and raw open/read/write – with the loop conditions that keep them correct.

20 min readargc, argv and printf formatsPointers and the terminating 0fopen/fgets vs open/read/writeFile descriptors and permission bitslseek, binary records and stat

This course talks to the operating system from C, so the first session makes sure the C under it is solid. Three things cause most of the trouble later: pointers (an address is just a number, and the compiler trusts you with it), strings (there is no string type – only arrays of characters that end in a 0 byte), and files, which C reaches in two different ways: the buffered C library and the raw system calls.

The examples are small programs that print their arguments, play with a pointer, cut a string in half, print and copy files, and ask a file when it was last modified. The same pieces – a struct, a text file of records, careful reading – make up the first take-home assignment (beadandó).

The model in one breath: a variable lives at an address, a pointer stores such an address, a string is a char array up to the first 0, and a file is reached either through a buffered FILE * or through a small integer, the file descriptor – and every call that can fail returns something you have to check.

main, argc and argv

A C program starts in main. When it is started from the shell, it gets the words of the command line as two parameters: argc (argument count) is how many there are, and argv (argument vector) is an array of strings with the words themselves. The type char **argv reads as "a pointer to pointers to char" – in practice, an array of strings (Fig. 1).

$ ./copy a.txt b.txtthe shell splits the line at spacesargvchar **argv[0]./copy\0argv[1]a.txt\0argv[2]b.txt\0argv[3] = NULLargc = 3the program namecounts tooeach argument is a char array ending in \0argv[argc] is always NULL – the end of the list
Fig. 1 · argv is an array of pointers. The program name is argv[0], so argc is one more than the number of real arguments.

1_sec.c – printing every argument

printf("Number of command line arguments: %i\n", argc);
for (int i = 0; i < argc; i++)
{
    printf("%i. argument is %s\n", i, argv[i]);
}
$ ./args a "b c"
Number of command line arguments: 3
0. argument is ./args
1. argument is a
2. argument is b c

Two details are visible in the output. argc counts arguments, not characters, and it includes the program name: ./args a "b c" gives 3. And the shell, not your program, splits the line: the quotes kept b c together as one argument and were removed before main ran.

printf formats

printf takes a format string and then the values. The compiler cannot fix a mismatch for you – it only warns with -Wall – so the conversion must match the type:

int n = 6;
char c = 'A';
printf("%i %d\n", n, n);        // 6 6   – an int, two names for the same thing
printf("%c %i\n", c, c);        // A 65  – a char is a small number (its code)
printf("%s\n", argv[0]);        // a string (char *)
printf("%p\n", (void *)&n);     // an address
printf("%zu\n", strlen("abc")); // 3     – size_t, what strlen and sizeof give back
ConversionTypeNote
%iintSame as %d in printf. In scanf, %i also accepts hex (0x1F) and octal (017) input.
%uunsigned intNever negative.
%ccharOne character. A char is a small integer: 'A' is 65, so %i prints 65.
%schar *Prints characters up to the terminating 0.
%pvoid *An address, in hex. Cast other pointers: (void *)&n.
%zusize_tWhat strlen and sizeof return. %i is wrong here.

Pointers and addresses

Every variable sits somewhere in memory, at an address. &n gives the address of n; a pointer is a variable that stores such an address. Its type says what is at the address: an int * points to an int. Putting * in front of a pointer dereferences it – follows the address to the value (Fig. 2).

int n = 25; int *k = &n;0x7ffd…c77ck · int * · lives at 0x7ffd…c78025n · int · lives at 0x7ffd…c77ck points to n&n → 0x7ffd…c77cthe address of n (what k stores)*k → 25follow the pointer: the value at that address*k = 30;writes through the pointer – n becomes 30
Fig. 2 · k stores the address of n. &n reads an address, *k follows one.

2_pointers.c – the lines that matter

int n = 25;
int *k = &n;                      // & gives the address of n; k stores it

printf("%p\n", (void *)&n);      // the address of n
printf("%p\n", (void *)k);       // the same address
printf("%i\n", *k);              // 25 – * follows the pointer to the value
*k = 30;                          // writes n through the pointer: n is 30 now

Pointers are how a C function changes something that belongs to its caller. C passes every argument by value – the function gets a copy – so scanf("%u", &exp) needs the address of exp to be able to write into it. Every read, fread and stat call below works the same way: you hand over the address of your variable, and the call fills it.

address
The position of a byte in the memory of the process, printed with %p.
pointer
A variable that holds an address. Its type (int *, char *) says what lives there.
& (address-of)
Gives the address of a variable: &n.
* (dereference)
Follows a pointer to the value it points to: *k.
NULL
A pointer that points nowhere. Functions return it to say "failed" or "nothing".

Strings: char arrays ending in 0

C has no string type. A string is an array of char, and it ends at the first byte with value 0 ( written 0 or '\0'). Everything that works with strings – printf("%s"), strlen, strcpy – walks the array until it finds that 0. The array can be longer than the string; whatever comes after the 0 is ignored (Fig. 3).

01234567891011str1as declaredHello world\0afterstr1[5] = 0;Hello\0world\0the string: "Hello", strlen = 5still in memory, ignored
Fig. 3 · One 0 byte shortens the string: "Hello world" becomes "Hello", and "world" stays in memory as leftover bytes.

3_strings.c – cutting a string

char str1[] = "Hello world";    // 12 bytes: 11 characters + the terminating 0
str1[5] = 0;                     // overwrite the space with 0
printf("'%s'\n", str1);         // 'Hello' – printing stops at the first 0
printf("%c\n", str1[6]);        // w – the rest is still in memory

That is also how strlen works inside. This hand-written version walks the pointer until it reaches the 0:

int length(char *str)
{
    int l = 0;
    while (*str++ != 0)   // read a character, step the pointer to the next one
        l++;
    return l;             // the terminating 0 is not counted
}

A pointer is not a copy

A char * variable holds an address and nothing else. str2 = str1 makes it point to the same array – change a character through one name and the other sees it. To have text of its own, the pointer needs memory: malloc reserves a block on the heap, which stays until you free it, while str1 is an ordinary local array on the stack, which disappears when the function returns (Fig. 4).

stack – freed when the function returnsstr1: "Hello world\0"char[12] – the array itselfstr2char * – 8 bytesheap – malloc / free"New content\0" + unused80 bytes from mallocstr2 = str1;str2 = malloc(80);free(str2) when done
Fig. 4 · str2 = str1 shares the array; str2 = malloc(80) gives str2 a block of its own on the heap.
char *str2 = str1;               // copies the address, not the text
str2 = malloc(80 * sizeof(char));  // a new, uninitialised block on the heap
if (str2 == NULL)
{
    perror("malloc");
    exit(1);
}
strcpy(str2, "New content");     // copies the characters AND the terminating 0
free(str2);                      // give it back – exactly once
str2 = NULL;

The functions of string.h you will use every week:

strlen(s)          // number of characters before the 0 (size_t)
strcpy(dst, src)   // copy src into dst – dst must be big enough!
strcat(dst, src)   // append src to the end of dst – same warning
strcmp(a, b)       // 0 if equal, <0 / >0 for alphabetical order
snprintf(dst, sizeof(dst), "%s-%i", name, id)   // formatted, never overflows

Two ways to a file

C programs on Linux can reach a file at two levels (Fig. 5). The C library (stdio.h) gives you fopen, fgets, fprintf and fread, which work on a FILE * and keep a buffer inside your process. The system calls (fcntl.h, unistd.h) – open, read, write, close – ask the kernel directly and work on an int, the file descriptor. fopen calls open itself; the library is a convenience layer on top.

your process (user space)fopen · fgets · fprintf · freadC library (stdio), FILE *buffer inside the FILEe.g. 4 KB, filled / emptied at onceopen · read · write · closesystem calls, int file descriptorread / write, when the buffer is full or emptyevery callgoes to the kernelkernelfile descriptor table → open file (offset, mode) → file system → disk
Fig. 5 · stdio collects data in a buffer and calls the kernel rarely; the system calls go to the kernel every time.
stdio (C library)system calls
HandleFILE *int file descriptor
Open / closefopen(name, "r") / fcloseopen(name, O_RDONLY) / close
Textfgets, fprintf, fscanf, fgetcnone – only bytes
Bytesfread, fwriteread, write
Positionfseek, ftell, rewindlseek
Bufferingyes – fewer kernel calls, output appears laterno – every call is a kernel call
ErrorsNULL / EOF, then perror-1, then perror

Buffering is why stdio is fast for many small reads and writes, and why it is sometimes the wrong tool: with pipes between processes (later in the course) you want data to leave immediately, not when a buffer fills up. The buffer will also bite you in the next session, when fork copies it.

File descriptors

A file descriptor is a small integer: an index into the process's table of open files (Fig. 6). Three entries are open before main starts – 0 standard input, 1 standard output, 2 standard error – and open always returns the lowest free number, so the first file you open is usually 3. When a call fails it returns -1 and sets the global errno; perror("open") prints your text plus the reason, such as open: No such file or directory.

file descriptor table of the processwhat it refers to (kept by the kernel)0stdinterminal (keyboard)1stdoutterminal (screen)2stderrterminal (screen)3fa.txt · O_RDONLY · offset 04gb.txt · O_WRONLY · offset 0f = open("a.txt", O_RDONLY)g = open("b.txt", O_WRONLY …)open when the program starts
Fig. 6 · 0, 1 and 2 are inherited from the shell; the files you open get the next free numbers.

Line by line with fopen and fgets

fopen opens a file with a mode string and returns a FILE *, or NULL if it could not. fgets(line, size, f) reads one line – up to and including the '\n', but never more than size - 1 characters – and adds the 0. At the end of the file it returns NULL. That return value is the loop condition:

4_open_fopen.c – reading lines

FILE *f = fopen(fname, "r");
if (f == NULL)
{
    perror("fopen");
    exit(1);
}
char line[160];
while (fgets(line, sizeof(line), f) != NULL)   // NULL: end of file or error
{
    printf("%s", line);                          // line still ends with '\n'
}
fclose(f);
ModeMeaning
"r"Read. The file must exist.
"w"Write. Creates the file, or empties it if it exists.
"a"Append. Creates the file if needed; every write goes to the end.
"r+"Read and write an existing file.
"rb", "wb"Binary. On Linux the b changes nothing – text and binary are the same.
fgets #1"First line\n"feof: falsefgets #2"Second line\n"feof: falsefgets #3"Last line\n"feof: falsefgets #4NULL – nothing readfeof: truewhile (!feof(f)) { fgets(…); printf(…); }after #3 feof is still false → the body runs once more → #4 fails → "Last line" is printed againwhile (fgets(…) != NULL) { printf(…); }the loop asks the read itself: #4 returns NULL → the loop ends, nothing is printed twice
Fig. 7 · Test the result of the read, not feof: feof turns true only after a read has already failed.

Copying a file character by character with stdio uses fgetc and fputc. Note the int c: fgetc returns either a byte (0–255) or EOF (−1), and a char cannot hold all 257 values.

5_fopen_readwrite.c – copy with fgetc/fputc

FILE *in = fopen("fourth_text.txt", "r");
FILE *out = fopen("write_here.txt", "w");   // "w": create, or empty an existing file
if (in == NULL || out == NULL) { /* report and stop */ }

int c;                                      // int, not char: EOF is not a character
while ((c = fgetc(in)) != EOF)
    fputc(c, out);

fclose(in);
fclose(out);                                // writes out what is still in the buffer

fclose does more than tidy up: it writes out what is still in the buffer. A program that ends with return from main or exit flushes its streams too, but one that crashes loses the buffered data – close what you opened.

open, read, write, close

The system-call version of the copy: open the source for reading, open the target for writing, then read and write until the source runs out. open takes flags combined with | instead of a mode string:

FlagMeaning
O_RDONLYOpen for reading only.
O_WRONLYWriting only (O_RDWR: both).
O_CREATCreate the file if it does not exist – then the third argument, the permissions, is required.
O_TRUNCIf it exists, empty it first.
O_APPENDEvery write goes to the end of the file.

6_open_readwrite.c – opening both files

int f = open(argv[1], O_RDONLY);
if (f < 0) { perror("open"); exit(1); }

int g = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR);
if (g < 0) { perror("open"); exit(1); }

The third argument of open is the permission of a newly created file: nine bits, read/write/execute for the owner, the group and everyone else – the same rwx triples that ls -l shows. The S_I… constants name single bits; the octal number writes all nine at once (Fig. 8).

user (owner)S_IRUSRrS_IWUSRwS_IXUSR-6r 4 + w 2 = 6groupS_IRGRPrS_IWGRP-S_IXGRP-4r 4 = 4othersS_IROTHrS_IWOTH-S_IXOTH-4r 4 = 4open("b.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644)same as S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH · the course example uses S_IRUSR | S_IWUSR = 0600the third argument is used only when O_CREAT creates the file; the umask can still remove bits
Fig. 8 · 0644: the owner reads and writes, everyone else only reads. Each octal digit is one rwx triple.

The process's umask removes bits from the requested mode: with the common umask 022, asking for 0666 gives 0644. If O_CREAT is there and the third argument is missing, the file gets whatever garbage happens to be in that argument slot – always pass it.

The copy loop

read(fd, &buf, n) returns how many bytes it actually read: a positive number, 0 at the end of the file, or -1 on an error. write returns how many bytes it wrote. Both facts go into the loop:

6_open_readwrite.c – byte-by-byte copy

char c;
while (read(f, &c, sizeof(c)) > 0)          // > 0: stop at end of file (0) AND on error (-1)
{
    if (write(g, &c, sizeof(c)) != sizeof(c))
    {
        perror("write");
        exit(1);
    }
}
close(f);
close(g);

Moving in a file: lseek and fseek

The kernel keeps an offset for every open file: the position where the next read or write starts. Reading moves it forward. lseek(fd, offset, whence) moves it anywhere, counted from the start, the current position or the end, and returns the new position (Fig. 9).

First line\nSec012345678910111213…offset = 3: the next read returns 's'the filelseek(f, 0, SEEK_SET)→ 0, the first bytelseek(f, 2, SEEK_CUR)→ 3 + 2 = 5lseek(f, -1, SEEK_END)→ the last byteread(f, &c, 1)→ offset + 1
Fig. 9 · The offset is where the next read starts. SEEK_SET, SEEK_CUR and SEEK_END say what the lseek offset is counted from.
lseek(f, 0, SEEK_SET);           // back to the first byte
lseek(f, -1, SEEK_END);          // to the last byte
off_t size = lseek(f, 0, SEEK_END);   // the new offset = the size of the file

fseek(fp, 0, SEEK_SET);          // the same for a FILE * stream (also: rewind(fp))

The example program reads a whole file, then jumps back to the start and reads the first character again – once with lseek on a descriptor, once with fseek on a stream. Use each with its own family: lseek for descriptors from open, fseek for streams from fopen. Moving the descriptor under a FILE * leaves the stdio buffer out of step with the file.

Byte by byte and binary records

fread(ptr, size, count, f) reads count elements of size bytes and returns how many elements it read. Reading one char at a time, the right loop condition is == 1; the same feof trap as with fgets would print the last byte twice.

4_open_fopen.c – binary read

char c;
while (fread(&c, sizeof(c), 1, f) == 1)   // how many ELEMENTS were read: 1 or 0
{
    printf("%c", c);
}

Nothing forces the element to be a char. read, write, fread and fwrite move raw bytes, so you can write a whole struct and read it back – fixed size, no parsing:

struct point { int x; int y; } p = { 3, 4 };
write(g, &p, sizeof(p));         // the raw bytes of the struct, not text

struct point q;
if (read(f, &q, sizeof(q)) == sizeof(q))
    printf("%i %i\n", q.x, q.y);

The price: the file is not readable in an editor, and it is only portable to machines with the same type sizes and byte order. A text file of records – one line per record, fields separated by a tab – is readable, easy to debug with cat, and can be edited by hand:

drivers.txt – one record per line, tab-separated

1	Kiss Anna	0612345	3
2	Nagy Bence	0698765	7
fprintf(out, "%u\t%s\t%s\t%u\n", d.id, d.name, d.phone, d.exp);   // one record = one line

while (fscanf(in, "%u\t%49[^\t]\t%49s\t%u", &d.id, d.name, d.phone, &d.exp) == 4)
{
    // a complete record – 4 fields matched; anything less means end of file or a broken line
}

Reading the keyboard

The keyboard is just another file: stdin, descriptor 0. fgets(s, sizeof(s), stdin) reads one line typed by the user, with the same rules as for a file – it keeps the '\n', and it returns NULL when the input ends. The example writes every line it gets into a file until the user types over:

7_filewriter_loop.c – the loop

char s[100];
do
{
    printf("Enter: ");
    if (fgets(s, sizeof(s), stdin) == NULL)   // Ctrl+D: end of input
        break;
    int length = strlen(s);                    // includes the '\n' fgets kept
    if (write(g, s, length) != length) { perror("write"); exit(1); }
} while (strcmp(s, "over\n") != 0);

Because fgets keeps the newline, the comparison is with "over\n". If you want the line without it, cut it off:

s[strcspn(s, "\n")] = '\0';   // cut the line at the first '\n' (if there is one)

File metadata with stat

stat(name, &st) fills a struct stat with what the file system knows about a file, without opening it: size, owner, permission bits, and three timestamps. st_mtime is the time of the last modification as a time_t (seconds since 1970); ctime turns it into readable text.

8_mtime.c – the idea

struct stat st;
if (stat(fname, &st) != 0)
{
    perror("stat");
    exit(1);
}
printf("size: %lld bytes\n", (long long)st.st_size);
time_t t = st.st_mtime;
printf("last modified: %s", ctime(&t));   // ctime's text already ends with '\n'
FieldContains
st_sizeSize in bytes.
st_modeFile type and the permission bits (st.st_mode & 0777).
st_uidThe owner, as a user ID.
st_mtimeLast modification of the content.
st_atimeLast access (to save disk writes, Linux usually does not update it on every read).
st_ctimeLast change of the metadata (permissions, owner) – not the creation time.

Practice

Write these on the course server or any Linux machine, compile with -Wall -Wextra, and get rid of every warning. Each one is small – the point is to get the details right: the loop condition, the 0 at the end, the checked return value.

Task 1

Arguments backwards

Print the command-line arguments in reverse order, one per line, without the program name. With no arguments, print a usage line to standard error and exit with code 1.

Approach

The last argument is argv[argc - 1]; stop before index 0.

for (int i = argc - 1; i >= 1; i--)
    printf("%s\n", argv[i]);

Task 2

Your own strcpy

Write void my_strcpy(char *dst, const char *src) without string.h. Test it by copying "Operating Systems" into a char[80] and printing it. What happens if you leave out the last line of the hint?

Approach

Copy until the 0 – then copy the 0 as well.

int i = 0;
while (src[i] != '\0')
{
    dst[i] = src[i];
    i++;
}
dst[i] = '\0';          // the easiest line to forget

Task 3

Count the lines of a file

Take a file name as an argument and print how many lines it has. Compare your answer with wc -l on a few files, including an empty one and one whose last line has no '\n'.

Approach

fgets in a loop on its return value. A line longer than your buffer arrives in several pieces, so count only the pieces that end in '\n' – that is exactly what wc -l counts.

int lines = 0;
while (fgets(line, sizeof(line), f) != NULL)
    if (strchr(line, '\n') != NULL)    // a full line – a long one comes in several pieces
        lines++;

Task 4

A faster copy

Change the open/read/write copy to move 512 bytes per call. Copy a large file (for example one made with head -c 5000000 /dev/urandom > big.bin) and check with cmp that the copy is identical. Time both versions with time.

Approach

The last read returns fewer than 512 bytes. Write n bytes, not sizeof(buf) – otherwise the copy ends in garbage.

char buf[512];
ssize_t n;
while ((n = read(f, buf, sizeof(buf))) > 0)
{
    if (write(g, buf, n) != n)   // write n bytes – not sizeof(buf)
    { perror("write"); exit(1); }
}
if (n < 0) perror("read");

Task 5

An append-only log

Read lines from the keyboard and append each to log.txt, keeping what was already there. Stop on over or on Ctrl+D. Create the file as rw-r--r--, and check with ls -l.

Approach

O_APPEND instead of O_TRUNC (or mode "a" with stdio), permission 0644, and the fgets loop with its NULL check.

int g = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);
/* or with stdio: */
FILE *fp = fopen("log.txt", "a");

Task 6

The last character

Print the last character of a file given as an argument using open, lseek and read – without reading the rest. What does your program do with an empty file?

Approach

SEEK_END with offset -1. On an empty file that position would be before the start, so lseek fails – check it.

if (lseek(f, -1, SEEK_END) >= 0 && read(f, &c, 1) == 1)
    printf("last character: %c\n", c);

Task 7

A tiny ls

For every file name given as an argument, print the name, the size in bytes and the last modification time. A missing file should produce an error message for that file, and the program should go on with the next one.

Approach

stat per argument, perror(argv[i]) and continue on failure. ctime already ends the line.

for (int i = 1; i < argc; i++)
{
    struct stat st;
    if (stat(argv[i], &st) != 0) { perror(argv[i]); continue; }
    printf("%s\t%lld\t%s", argv[i], (long long)st.st_size, ctime(&st.st_mtime));
}

Summary

  • argc counts the arguments including the program name; argv[argc] is NULL. Check argc before reading argv[i].
  • & takes an address, * follows one. A char * holds no text by itself – it points at an array or at malloced memory.
  • A string ends at the first 0 byte. Every buffer needs room for it, and strlen returns size_t (%zu).
  • stdio (FILE *, buffered) and system calls (file descriptor, unbuffered) are two layers over the same files. 0, 1, 2 are stdin, stdout, stderr.
  • Loop on the result of the read: fgets(…) != NULL, fread(…) == 1, read(…) > 0 – never on feof.
  • O_CREAT needs a permission argument (e.g. 0644); lseek moves the offset; stat reads size and st_mtime.
  • Every call that can fail gets its return value checked; perror explains why a system call failed.