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.
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).
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 cTwo 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| Conversion | Type | Note |
|---|---|---|
%i | int | Same as %d in printf. In scanf, %i also accepts hex (0x1F) and octal (017) input. |
%u | unsigned int | Never negative. |
%c | char | One character. A char is a small integer: 'A' is 65, so %i prints 65. |
%s | char * | Prints characters up to the terminating 0. |
%p | void * | An address, in hex. Cast other pointers: (void *)&n. |
%zu | size_t | What 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).
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 nowPointers 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).
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 memoryThat 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).
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 overflowsTwo 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.
| stdio (C library) | system calls | |
|---|---|---|
| Handle | FILE * | int file descriptor |
| Open / close | fopen(name, "r") / fclose | open(name, O_RDONLY) / close |
| Text | fgets, fprintf, fscanf, fgetc | none – only bytes |
| Bytes | fread, fwrite | read, write |
| Position | fseek, ftell, rewind | lseek |
| Buffering | yes – fewer kernel calls, output appears later | no – every call is a kernel call |
| Errors | NULL / 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.
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);| Mode | Meaning |
|---|---|
"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. |
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 bufferfclose 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:
| Flag | Meaning |
|---|---|
O_RDONLY | Open for reading only. |
O_WRONLY | Writing only (O_RDWR: both). |
O_CREAT | Create the file if it does not exist – then the third argument, the permissions, is required. |
O_TRUNC | If it exists, empty it first. |
O_APPEND | Every 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).
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).
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 7fprintf(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'| Field | Contains |
|---|---|
st_size | Size in bytes. |
st_mode | File type and the permission bits (st.st_mode & 0777). |
st_uid | The owner, as a user ID. |
st_mtime | Last modification of the content. |
st_atime | Last access (to save disk writes, Linux usually does not update it on every read). |
st_ctime | Last 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 forgetTask 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
argccounts the arguments including the program name;argv[argc]isNULL. Checkargcbefore readingargv[i].&takes an address,*follows one. Achar *holds no text by itself – it points at an array or atmalloced memory.- A string ends at the first 0 byte. Every buffer needs room for it, and
strlenreturnssize_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 onfeof. O_CREATneeds a permission argument (e.g.0644);lseekmoves the offset;statreads size andst_mtime.- Every call that can fail gets its return value checked;
perrorexplains why a system call failed.