Getting Started with C and Systems Programming

Our goal is to learn the basics of C and later C++ as general-purpose programming languages but also as systems languages. C and C++ are mature and powerful languages that come with rich and, especially for C++, vast standard libraries. Mastering them takes decades of experience, as well as a keen attention to details. It is a long and fascinating journey.

This is an introduction to C and C++ and to systems programming. These are the first steps in that wonderful journey. We do not intend to be complete in our treatment of C or C++. Absolute correctness is not a primary goal either. Insted, we want to develop basic knowledge and, above all, good intuitions. We want to learn enough of C and C++, so as to be able to develop simple programs and also to understand how such programs interact their own execution environment and with other programs. This is what systems programming is about: you as a programmer design programs that interact with other programs or components that are part of a “system”.

Preliminaries

We do not start from scratch with programming. We presume that you know at least some basic notions of programming. We even assume that you are somewhat familiar with the primary syntactic structures of C and C++.

We also intend to show the “nuts and bolts” of programming with C. We directly use basic tools such as editors, compilers, linkers, and debuggers to expose and better understand the build process and ultimately the programs themselves. To be sure, the tools we use are very sophisticated. They are not “basic” in that sense—and of course they can also be integrated within modern development environments. However, we use them at least initially with very little automation and integration so as to emphasize their role.

For our purposes, a C program consists of one or more simple files of text. You must be at least minimally familiar with files and the file system on your computer, and you must also be able to create and edit a text file. To do that, you should use a good text editor. The absolute best one by a wide margin—trust me!—is Emacs. But of course feel free to use whatever works well for you.

Once we have a program, we must compile it to obtain an “executable” version of the program. This is another file that we can actually run. In running the program, we might want to pass some input data to the program, and then read the output that the program produces. Or perhaps we might want to use the content of a file or the output of another program as input. And there are many other things we might want to do with programs and their input and output. We do all that through a command shell. So, that is also something you must be familiar with, at least to some extent.

This is a read-do document. The reading part is important. Don’t rush through it. Don’t just skim the text. There isn’t so much text anyway, so read it carefully. Read one paragraph at a time, sometime one sentence at a time. Don’t worry if you don’t understand everything at first. You can always go back to the text, and if you do, you will find that there are deeper and deeper layers.

Still, you should strive to get at least a basic and intuitive idea of the concepts described in the text. With that basic understanding, you should then immediately try to put things into practice with the many examples and exercises listed within the text. You should also practice with other examples that you create and experiment with out of your own curiosity. This is the do part. And it is equally important. Don’t skip it! Go through every exercise, possibly experimenting with variants of the code presented here. Trust me, this is the best way to learn programming in C, Systems Programming, programming in general—or anything else, really.

I also encourage you to follow your curiosity and explore the languages and libraries both in range and in depth. A good way to do that is to consult the excellent documentation available at https://en.cppreference.com/. Even if you don’t feel like exploring, that documentation is still an essential reference and should be your primary source to accompany you in this journey.

Let’s Go!

It is good to start with examples. Let’s try one of the simplest C programs there is. This is the program:

#include <stdio.h>

int main() {
    printf("Hello darkness, my old friend!\n");
}

Since this is our very first example, let’s go through the basic steps of editing, compiling, and running the program. Indeed this is the purpose of the first example. First of all, I suggest you create a directory in which to experiment with this first program. Using the command shell, from some place in your file system, run the following commands:

$ mkdir example1 
$ cd example1 

Notice that the prefix $ is the shell prompt, and therefore is not part of the commands themselves. We show the shell prompt in all our shell examples to distinguish a command from its output.

You are now in the example1 directory. Now, use your favorite text editor to create a file called ciao.c that contains the program listed above (“#include <stdio.h> …”). Notice that the code listed in this document is shown as it might be displayed by a good text editor, with colors and special font properties that highlight some syntactic elements of the code. This code highlighting might be different in your editor and in any case is not really part of the program, which must in fact be a plain-text file.

When you’re done editing, make sure that you have the program file (ciao.c) and that the file indeed contains those lines of code in plain text. You can check that with a couple of shell commands:

$ ls
ciao.c
$ cat ciao.c
#include <stdio.h>

int main() {
    printf("Hello darkness, my old friend!\n");
}

If this is not what you get, then there might be something wrong with your text editor. Unfortunately, some editors will mess things up because they don’t write files in plain text format. As I said, you should use a good text editor, preferably one used specifically by programmers. Anyway, if you’re stuck, this is a good time to ask your hacker friend or your friendly teacher to help you out. (Yes, everyone should have a hacker friend. And yes, your teacher is friendly and happy to help you, and he likes to think he’s a bit of a hacker, too. So, there, you might already have a hacker friend!)

Now I’m assuming you have your first C program saved in a file called ciao.c in your current directory. It is now time to compile the program. To do that, we will invoke a compiler from the command shell. You should do the same.

$ cc ciao.c -o ciao
$ ls
ciao  ciao.c

As in this shell session, the compiler should terminate without any output on the terminal, and at the same time it should create a file called ciao. This is our executable program, which we can now run like so:

$ ./ciao 
Hello darkness, my old friend!

Is this what you get, too? If you didn’t even try, then you should do it right now. Seriously, do it now! Remember, this is a read-do document, and practicing is the best way to learn. Anyway, if that is what you got, then great! You edited, compiled, and executed your first C program. Congratulations! Or perhaps it wasn’t your first C program. Still, good.

But what if that is not what you got? Well, that’s an excellent question. (Thank you for asking!) Let’s see what can go wrong even with this simple example.

Troubleshooting

I’m assuming you have the ciao.c program in the current directory of your command shell. We checked that earlier, and you should have alreay fixed any issue with that, possibly with the help of your hacker friend.

Next step: you also need to have a C compiler installed on your computer that you can invoke with the cc command. This means that the executable must be called cc, and that your command shell must be configured to find it. If that is not the case, then you would probably get an error message from the command shell:

$ cc -o ciao ciao.c
Command 'cc' not found.

Perhaps you do have a C compiler, but it has a different name. Other common ones are gcc and clang. So, you could instead try:

$ clang ciao.c -o ciao
$ ls
ciao  ciao.c
$ ./ciao 
Hello darkness, my old friend!

If that’s what you get, then you’re good. You just need to remember to invoke clang instead of cc. If on the other hand that does not work either, then perhaps the compiler is there but your command shell can’t find it. Or perhaps more likey it’s not there. Or it’s there but it fails to compile, perhaps because it can’t find the stdio.h header file, perhaps because that doesn’t exist or… Whatever. This is where you should reach out to your hacker friend to help you set things up. What you need to do is probably pretty simple, but these things tend to be different from one system to another, and they also tend to change over time as system configurations evolve. So it makes no sense for us to try to deal with such details here. As I said, ask your hacker friend to fix things up for you here.

I’m now assuming you can run a correctly installed and configured compiler from your command shell. What else can go wrong? Many things. The most common problem is that there are some errors in the program code. Our first program is pretty simple, and you probably copied and pasted the code above straight into your editor. So it is unlikely you made programming mistakes. Still, you might have typed something wrong or you might have forgotten some punctuation here and there. Let’s see some examples.

$ cc -o ciao ciao.c
ciao.c: In function ‘main’:
ciao.c:4:5: warning: implicit declaration of function ‘print’; did you mean ‘printf’? [-Wimplicit-function-declaration]
    4 |     print("Ciao!\n");
      |     ^~~~~
      |     printf
/bin/ld: /tmp/cc7SyOrr.o: in function `main':
ciao.c:(.text+0x18): undefined reference to `print'
collect2: error: ld returned 1 exit status

Here the compiler tells you that your program uses a function called print that the compiler doesn’t know anything about. This is a typo. That is, you made a mistake in typing the program text. In fact, here the compiler also suggests the proper fix: “did you mean 'printf'?” Yes, the program we intended to write uses printf, not print. You can fix this error by going back to line four of the program (“ciao.c:4”) and replace print with printf.

Here’s another example:

$ cc ciao.c -o ciao
ciao.c: In function ‘main’:
ciao.c:4:47: error: expected ‘;’ before ‘}’ token
    4 |     printf("Hello darkness, my old friend!\n")
      |                                               ^
      |                                               ;
    5 | }
      | ~                                              

Again, the compiler tells you what is wrong with your code. You forgot a semicolon (;) at the end of the printf statement. It is easy to make mistakes like this one, but that’s okay, since these are also the easiest mistakes to find and fix.

Basic Input/Output

You develop programs to do something. This means that you want your program to interact with the outside world. Typically, that means displaying something onto a screen and reading something from the keyboard. Or perhaps your program reads and writes data through network connections. Whatever. C programs can do that and a million other things, of course. But for now we consider a few very basic input and output mechanisms. We start with formatted output and then cover simple input and output one byte at a time. However, even before that, let’s see a bit more in detail what we mean by input and output.

Input/Output Streams

The input and output we consider here are streams of bytes. A program is automatically connected to a “standard input” stream and a “standard output” stream. You as the programmer don’t have to explicitly open or close those streams. The execution environment does that automatically for you. When you run the program from a terminal application, the standard input and standard output of the program are connected directly to the terminal application. So, what you type into the terminal application goes into the standard input stream of the program, and what the program outputs onto its standard output stream goes to the terminal application that then displays some of that output onto the terminal window.

Anyway, for now, at the most basic level, what the program reads and writes is a stream of bytes, meaning a sequence of integer values between 0 and 255. A stream is a sequential communication channel in the sense that if the program writes onto its output a byte of value 1 followed by a byte of value 2, then the application that reads from that stream will first read value 1 and then value 2.

The byte values per-se have no semantic value, and more generally the streams per-se have no structure. It is up to your program, and the applications your program writes to, or reads from, to interpret individual bytes or sequences of bytes.

Consider in fact our first example program. Here is the code (slightly modified) once again saved in a file called ciao.c:

#include <stdio.h>

int main () {
    printf ("Ciao!\n");
}

If we run the program, we see the following text displayed on the terminal window:

$ ./ciao
Ciao!

This is no surprise. You write "Ciao!\n" in the program, and that is what gets printed out onto the terminal window. As you most likely already figured out, that \n is a special code for the end of a line, so everything is totally consistent. But things are not so straightforward if you think about it. We just said that the output consists of a sequence of bytes, meaning numbers between 0 and 255. How does the program turn "Ciao!\n", as written in the program, into a sequence of bytes. And how does the terminal application turn that sequence of bytes into a message printed on the screen?

Strings, Characters, and Bytes

The parameter we pass to the printf function is the string "Ciao!\n" . A string is essentially a sequence of bytes. The printf function takes those bytes and simply writes them onto the standard output stream. We will see later that printf processes that string of bytes as a formatting template. But in this case there is no special formatting and therefore printf simply outputs the bytes onto the standard output. The standard output of the program is connected to the input of the terminal application, so those bytes end up going to the terminal application that then prints the message.

In fact, we’ll get a bit ahead of ourselves, but let’s try the following program. Let’s call it ciao2.c:

#include <stdio.h>

int main () {
    putchar(67);
    putchar(105);
    putchar(97);
    putchar(111);
    putchar(33);
    putchar(10);
}

Compile and run:

$ cc ciao2.c -o ciao2
$ ./ciao2
Ciao!

Magic. (Did you try it yourself?!) This second program ciao2 outputs exactly the same message as the first program ciao. That’s because the output is just a sequence of bytes. Makes sense, right? Wait, what!? Where do those numbers come from? And how does the terminal application know that they correspond to the message “Ciao!”?

Here’s a good-enough initial explanation. The message is written in the program as a string of characters, literally the characters C i a o ! \ n. The most astute reader would object that the characters are written by your text editor into the program file as bytes. True. But the astute reader must also know that it’s yet another application of the same encoding. So let’s simplify a bit and start from the characters that you type and see in your program.

So, back to the characters C i a o ! \ n. The compiler “encodes” that sequence of characters as the sequence of bytes 67, 105, 97, 111, 33, and 10. That sequence is stored somewhere in the memory of the program, and it is passed to printf, which writes it onto the standard output. That same sequence of bytes is then decoded by the terminal application to get the original message.

Okay, but again, where do those numbers come from? Simplifying a bit, the compiler first maps some special sequences of characters into some special byte values, such that \n is encoded as the byte value 10. The compiler then also maps the other characters into a sequence of bytes using a particular algorithm that might depend on your system configuration but that most often corresponds to the Unicode map and the UTF-8 encoding. At the other end of the line, the terminal application uses the same UTF-8 and Unicode mappings (in reverse) to interpret and therefore print the sequence of bytes 67, 105, 97, 111, 33 as the sequence of characters “Ciao!” (without quotation marks). The terminal application then interprets the last byte value, 10, as the end of line.

All good? Do you want to know more about Unicode and UTF-8? That’s simple, at least conceptually. Unicode is a map from characters to numbers also called code-points, while UTF-8 is a map from those numbers into sequences of bytes, which are themselves numbers between 0 and 255. By characters here we mean the elementary components of human languages when expressed in written form. For example, the letter “C” (without quotation marks) is a character from the Latin script used in languages like English and Italian, and “π” is also a character from the Greek alphabet used in modern and ancient Greek as well as in mathematical writing. A smiley “🙂” is also a character. You get the idea. In summary, Unicode maps a character into a numeric code (and vice-versa), and UTF-8 maps that numeric code into a sequence of bytes (and vice-versa). Below is a table of these mappings for three example characters:

Table 1: Examples of mapping of character to sequences of bytes through Unicode and UTF-8.
character code point “U+” notation bytes
C 67 U+0043 67
π 960 U+03C0 207 128
🙂 128578 U+1F642 240 159 153 130

Don’t worry if you don’t undestand how code point 128578 becomes bytes 240, 159, 153, and 130. That’s UTF-8, and we won’t go into the detail of that. It’s actually interesting. And you should definitely look it up if you are mildly curious. But it’s not essential for our purposes.

However, there is one aspect of the UTF-8 encoding that is very important for us. Notice that Unicode maps the character “C” to code-point 67, and UTF-8 maps code-point 67 to a single byte value 67. This case, where the Unicode code-point is encoded with a single byte and that byte has the same numeric value, is a special case that for us will be the norm. It is the case for the alphabet of the English language (uppercase and lowercase) plus a bunch of common characters such as numbers, punctuation, spaces, etc., including all the characters of the most basic set of characters you can find in a C/C++ program, which is displayed below (plus some space characters):

abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ
0123456789
 _{}[]()<>%:;.?*+-/^&|~!=,\"’

So, most if not all our examples and exercises deal with characters that map to a single byte. When we only use those characters, dealing with strings becomes very simple: the length of a string (of characters) is the same as the length of its encoding (in bytes), and you can go to the n-th character by going to the n-th byte.

The special one-byte character encoding of Unicode with UTF-8 correspond exactly to another encoding you might have heard of, called ASCII. And of course this is no coincidence. UTF-8 and Unicode were in fact designed to be backward compatible with ASCII.

Okay. Enough about character encoding. Now you know at least the basics of how your compiler and your terminal application encode characters into sequences of bytes and vice-versa. And as the astute reader would have pointed out, the same goes for your text editor and many other components of your computer system.

Basic Formatting

The printf function we used in our very first program can do much more than just print a given literal string. The first argument is in fact interpreted as a format string that determines how to print the following arguments. Here’s an example:

#include <stdio.h>

int main () {
    for (int n = 0; n < 10; ++n)
        printf("%dPI = %f\n", n, 3.14159265*n);
}

And here’s the output:

0PI = 0.000000
1PI = 3.141593
2PI = 6.283185
3PI = 9.424778
4PI = 12.566371
5PI = 15.707963
6PI = 18.849556
7PI = 21.991149
8PI = 25.132741
9PI = 28.274334

Very intuitively here, the format string (first argument) is interpreted literally, meaning that its content is copied onto the output stream, except that a percent character (%) followed by one or more formatting characters are interpreted as a place-holder for one of the other arguments. In the example above, the format string starts with the characters %d, so at that point printf outputs the next argument (n) as an integer in decimal format. At a later point the format string contains the characters %f, so at that point printf outputs the following argument (3.14159265*n) as a floating-point number.

The formatting can be controlled in great detail, for example by changing the width of each field, the alignment, the number of decimal digits, and more. For example, here is a variant of the example above:

#include <stdio.h>

int main () {
    for (int n = 0; n < 15; ++n)
        printf("%6dPI = %6.3f\n", n, 3.14159265*n);
}

And this is the output.

 0PI =  0.000
 1PI =  3.142
 2PI =  6.283
 3PI =  9.425
 4PI = 12.566
 5PI = 15.708
 6PI = 18.850
 7PI = 21.991
 8PI = 25.133
 9PI = 28.274
10PI = 31.416
11PI = 34.558
12PI = 37.699
13PI = 40.841
14PI = 43.982

Try these examples yourself to experiment with formatted output. As usual, the reference documentation is by far your best source to figure out all the formatting options.

Input/Output One Byte at a Time

Sometimes you want to read or write “raw” bytes. The most basic way to do that is to read or write one byte at a time, which you can do with getchar and putchar, respectively. For example, try this program in a file called bytecount.c

#include <stdio.h>

int main () {
    unsigned int count = 0;
    while (getchar() != EOF)
        ++count;
    printf("I just read %u bytes.\n", count);
}

If you run the program passing its own source file as the standard input, you get the following:

$ ./bytecount < bytecount.c
I just read 149 bytes.

Quiz 1: What’s the output of the shell command echo '🙂🙂🙂' | ./bytecount? Feel free to try it yourself.

Quiz 2: Why?

Okay, apart from the complication of character encoding and invisible characters, this is all quite straightforward. However, there is one aspect of this program and input/output more generally that often leads to confusion. Let’s clarify this aspect right away. Input operations may fail. Same for output. Yeah, I know… Well, that’s just a fact of life. Your program might be just fine, but the devices or other programs that produce or consume the streams that your program reads from or writes into might fail. Or your input/output streams might have come to an end. Whatever it might be, the input operation must yield a valid input when the operation succeeds, or otherwise signal that the operation did not succeed. And your code should deal with those cases appropriately.

Going back to the example, getchar does everything through the return value. So, getchar() returns a byte—a number between 0 and 255—when the read operation succeeds, or the special value EOF when the operation fails, either because the input stream ended or because of some error condition.

Just by logic, it follows that EOF is not a valid byte value. But this is where things might get confusing, probably because of the name of the EOF symbol. You might think that EOF is the value of the end-of-file character or some other special byte value that terminates the input. But there is no such terminator character or byte. The EOF return value might indicate that the stream has ended (or that an error occurred), but it is not in itself a valid byte value. I repeat, there is no end-of-file or end-of-input character or byte.

Here’s an example that illustrates the difference between the bytes in a stream and the end-of-file or error conditions of the stream. We are getting ahead of ourselves with a number of features of the language and the library. But that’s okay. In fact, it’s a learning experience. Use your intuition, Luke!

#include <stdio.h>

int main () {
    int c;
    unsigned int count = 0;
    printf(" count  getchar  feof  ferror\n");
    do {
        c = getchar();
        printf("%6u  %7d  %4s  %6s\n",
               count, c,
               (feof(stdin) ? "yes" : "no"), (ferror(stdin) ? "yes" : "no"));
        ++count;
    } while (c != EOF);
}

Save this program as fstatus.c and then compile it as usual. You can now see clearly what happens when the input stream terminates:

$ echo ciao | ./fstatus
 count  getchar  feof  ferror
     0       99    no      no
     1      105    no      no
     2       97    no      no
     3      111    no      no
     4       10    no      no
     5       -1   yes      no
$ ./fstatus < /dev/null
 count  getchar  feof  ferror
     0       -1   yes      no

What does this program do? The program reads one byte at a time from the standard input using getchar in a loop. For each invocation of getchar(), the program outputs a counter, the return value of getchar(), and then two Boolean values. The first Boolean value is the result of feof(stdin) and represents the end-of-file status of the standard input stream (stdin). The second Boolean value is the error status of the standard input stream returned by ferror(stdin). You can see that, at some point, getchar() returns -1, which in fact corresponds to the value of EOF. The result of feof() then confirms that the EOF return value from getchar() indeed signals the end of the stream.

We talked about reading one byte at a time with getchar. Let’s now talk about writing one byte at a time with putchar. In fact, we have already seen an example in which we use putchar to output a specific sequence of bytes, remember? Go check it out. Do it!

See, putchar takes an integer argument and outputs that argument as a byte onto the standard output. Simple. For those who are really curious about the specifics of programming languages and C in particular, it would a good exercise to figure out what happens if the integer argument is outside of the range of valid values for a byte (0–255). Other than that, there isn’t much more to say about putchar.

To put everything together in a nice exercise, here’s a more elaborate example.

#include <stdio.h>
#include <ctype.h>

int main () {
    int c;
    unsigned int x = 0;
    int reading_number = 0;
    do {
        c = getchar();
        if (isdigit(c)) {
            reading_number = 1;
            x = 10*x + (c - '0');
        } else {
            if (reading_number == 1) {
                for (; x > 0; --x) {
                    putchar('#');
                }
                putchar('\n');
                reading_number = 0;
            }
        }
    } while (c != EOF);
}

What does this program do? I’m not going to tell you. You should figure it out by yourself. It’s a good exercise. Do it! Of course, one way to figure it out is to run the program and simply play with it, so go ahead and do that. I’ll wait for you here.

One last comment: putchar takes an integer argument but also returns an integer value. As it turns out, the semantics is the same as getchar. The return value is the byte successfully written onto the standard output, or EOF if the output operation failed for whatever reason. The code in the examples above does not use the return value of putchar, so it simply ignores potential error conditions. It is not that difficult to deal with such errors, as shown in the example below. However, that is something we rarely do in our examples.

#include <stdio.h>
#include <ctype.h>

int main () {
    int c;
    int x = 0;
    int reading_number = 0;
    do {
        c = getchar();
        if (isdigit(c)) {
            reading_number = 1;
            x = 10*x + (c - '0');
        } else {
            if (reading_number == 1) {
                for (; x > 0; --x) {
                    if (putchar('#') == EOF) {
                        perror("putchar() failed");
                        return 1;
                    }
                }
                if (putchar('\n') == EOF)  {
                    perror("putchar() failed");
                    return 1;
                }
                reading_number = 0;
            }
        }
    } while (c != EOF);
    return 0;
}

You might be a bit confused or even upset here. Am I telling you to check for errors or not? The answer is, yes, as a systems programmer, you should always be aware of possible error conditions, and you should deal with those conditions in your code. However, for our purposes, proper error-handling code is often a complication that gets in the way of what we want to show with our examples. We want our examples to be concise and to the point. So, we simplify them as much as possible by omitting, among other things, some or even all error handling.

Exercises

For the purpose of these exercises, the input will consist of characters that are all encoded as individual bytes. Reading a byte at a time gives the codes for the input characters, one at a time.

Word count

Write a C program called wordcount that counts the words in the standard input. A word is a sequence of one or more characters delimited by “white space” or the end of the stream. (Hint: there is a standard function to check whether a character is “white space”.) The output should be the same as the command: wc -w.

Diamond

Write a C program called diamond that takes a non-negative number \(n\) as a command-line argument, and prints on the standard output an \(n\) by \(n\) diamond made of # characters. For example, this should be the output for a 6-by-6 diamond:

     #
    ###
   #####
  #######
 #########
###########
 #########
  #######
   #####
    ###
     #