Preface

This is not a theoretical C language specifications document. It is a practical primer for the vast majority of real life cases of C usage that are relevant to EFL on todays common architectures. It covers application executables and shared library concepts and is written from a Linux/UNIX perspective where you would have your code running with an OS doing memory mappings and probably protection for you. It really is fundamentally not much different on Android, iOS, OSX or even Windows.

It won't cover esoteric details of “strange architectures”. It pretty much covers C as a high level assembly language that is portable across a range of modern architectures.

Your first program

Let's start with the traditional “Hello world” C application. This is about as simple as it gets for an application that does something you can see.

hello.c
#include <stdio.h>
 
// This is my very first program
int
main(int argc, char **argv)
{
   printf("Hello world!\n"); /* Print some stuff */
   return 0;
}

You would compile this on a command-line as follows:

cc hello.c -o hello

Run the application with:

./hello

You should then see this output:

Hello world!

So what has happened here? Let's first look at the code itself. The first line:

#include <stdio.h>

This tells the compiler to literally include the stdio.h file into your application. The compiler will find this file in the standard locations to look for include files and literally “paste” it there where the include line is. This file provides some “standard I/O” features, such as printf().

The next thing is something every application will have - a main() function. This function must exist only once in the application because this is the function that is run AS the application. When this function exits, the application does.

int
main(int argc, char **argv)
{
}

The main() function always returns an integer value, and is given 2 parameters on start. Those are an integer argc and then an array of strings. In C an array is generally just a pointer to the first element. A String is generally a pointer to a series of bytes (chars) which ends in a byte value of 0 to indicate the end of the string. We'll come back to this later, but as we don't use these, just ignore this for now.

The next thing that happens is for the code to call a function printf() with a string “Hello world!\n”. Why the “\n” at the end? This is an “escape”. A way of indicating a special character. In this case this is the newline character. Strings in C can contain some special characters like this, and “\” is used to begin the escaped string.

printf("Hello world!\n");

Now finally we return from the main function with the value 0. The main() function of an application is special. It always returns an integer that indicates the “success” of the application. This is used by the shell executing the process to determine success. A return of 0 indicates the process ran successfully. Any other return value is an indicator of failure.

return 0;

You will notice a few things. First lines starting with # are commands, but don't have a ;. This is normal because these lines are processed by the pre-processor. All code in C goes through the C pre-processor and this basically generates more code for the compiler to actually deal with. Other lines that are not starting a function, ending it or defining control end every statement in C with a ; character. If you don't do this, the statement continues until a ; is found, even if it goes across multiple lines.

Note that we can do comments for human-eyes-only that the compiler ignores by having the first 2 letters if any line be // excluding whitespace (spaces, tabs etc.). Everything until the end of the line on such lines will be ignored by the compiler. For comments stretching over multiple lines or only for a small section of a line, you can start such a comment with /* and that comment will continue for as long as needed (across multiple lines) until a matching */ is found.

If we look at how the application is compiled, We execute the C compiler, give it 1 or more source files to compile and the with -o tell it what output file to produce (the executable)

cc hello.c -o hello

Often cc will be replaced with things like gcc or maybe clang or whatever compiler you prefer. A compiler will run the source through the pre-processor and then convert your source code into a binary form that your CPU and Os can actually understand and run.

Now let's take a detour back to the machine that is running your very first C application.

The machine

Reality is that you are dealing with a machine. A real modern piece of hardware. Not abstract. It's real. Most machines are fairly similar these days, of course with their variations on personality, size etc.

All machines have at least a single processor to execute a series of instructions. If you write an application (a common case) this is the model you will see right in front of you. An application begins by executing a list of instructions at the CPU level.

The C compiler takes the C code source files you write and converts them into “machine code” (which is really just a series of numbers that end up stored in memory, and these numbers have meanings like “0” is “do nothing”, “1” is “add the next 2 numbers together and store the result” etc.). Somewhere these numbers are placed in memory by the operating system when the executable is loaded, and the CPU is instructed to begin executing them. What these numbers mean is dependent on your CPU type.

An example:

Memory location Value in hexadecimal Instruction meaning
0 e1510000 cmp r1, r0
4 e280001a add r0, r0, #26

CPUs will do arithmetic, logic operations, change what it is they execute, and read from or write to memory to deal with data. In the end, everything to a CPU is effectively a number, somewhere to store it to or load it from and some operation you do to it.

To computers, numbers are a string of “bits”. A bit can be on or off. Just like you may be used to numbers, with each digit having 10 values (0 through to 9), A computer sees numbers more simply. It is 0, or it is 1. Just like you can have a bigger number by adding a digit (1 digit can encode 10 values, 2 digits can encode 100 values, 3 can encode 1000 values etc.), So too with the binary (0 or 1) numbering system computers use. Every binary digit you add doubles the number of values you can deal with. For convenience we often use hexadecimal as a way of writing numbers because it aligns nicely with the bits used in binary. Hexadecimal uses 16 values per digit, with 0 through to 9, then a through to f being digits.

Binary Hexadecimal Decimal
101 5 5
00101101 2d 45
1111001101010001 f351 62289

Numbers to a computer normally come in sizes that indicate how many bits they use. The sizes that really matter are:

Common term C type Number of bits Max unsigned
Byte char 8 255
Word short 16 65535
Integer int 32 ~4 billion
Long Integer long 32 / 64 ~4 billion / ~18 qunitillion
Long Long Integer long long 64 ~18 qunitillion
Float float 32 3.402823466 e+38
Double Float double 64 1.7976931348623158 e+308
Pointer * X 32 / 64 ~4 billion / ~18 qunitillion

The sizes here are the COMMON SIZES found across real life architectures today. (This does gloss over some corner cases such as on x86 systems, doubles can be 80 bits whilst they are inside a register, etc.)

Pointers are also just integers. Either 32 or 64 bits. They refer to a location in memory as a multiple of bytes. Floats and doubles can encode numbers with “a decimal place”. Like 3.14159. Thus both floats and doubles consist of a mantissa and exponent. The mantissa determines the digits of the number and the exponent determines where the decimal place should go.

When we want signed numbers, we center our ranges AROUND 0. So bytes (chars) can go from -128 to 127, shorts from -32768 to 32767, and so on. By default all of the types are signed (except pointers) UNLESS you put an “unsigned” in front of them. You can also place “signed” in front to explicitly say you want the type to be signed. A catch - on ARM systems chars often are unsigned by default. Also be aware that it is common on 64 bit systems to have long integers be 64 bit, and on 32 bit they switch to being 32 bits. Windows is the exception here and long integers will remain 32 bit (we are skipping windows 16 bit coding here).

Pointers follow the instruction set mode. For 32 bit architectures pointers are 32 bits in size, and are 64 bits in size on 64 bit architectures. Standard ARM systems are 32 bit, except for very new 64 bit ARM systems. On x86, 64 bit has been around for a while, and so you will commonly see both. This is the same for PowerPC and MIPS as well.

Memory to a machine is just a big “spreadsheet” of numbers. Imagine it as a spreadsheet with only 1 column and a lot of rows. Every cell can store 8 bits (a byte). If you “merge” rows (2, 4, 8) you can store more values as above. But when you merge rows, the next row number doesn't change. You also could still address the “parts” of the merged cell as bytes or smaller units. In the end pointers are nothing more than a number saying “go to memory row 2943298 and get me the integer (4 bytes) located there” (if it was a pointer to an integer). The pointer itself just stores the PLACE in memory where you find the data. The data itself is what you get when you de-reference a pointer.

This level of indirection can nest. You can have a pointer to pointers, so de-reference a pointer to pointers to get the place in memory where the actual data is then de-reference that again to get the data itself. Follow the chain of pointers if you want values. Since pointers are numbers, you can do math on them like any other. You can advance through memory just by adding 1, 2, 4 or 8 to your pointer to point to the “next thing along” for example, which is how arrays work.

In general machines like to store these numbers in memory at a place that is aligned to their size. That means that bytes (chars) can be stored anywhere as the smallest unit when addressing memory is a byte (in general). Shorts want to be aligned to 16 bits - that means 2 bytes (chars), so you should (ideally) never find a short at an ODD byte in memory. Integers want to be stored on 4 byte boundaries, Long integers may align to either 4 or 8 bytes depending, and long long integers on 8 byte boundaries. Floats would align to 4 bytes, doubles to 8 bytes, and pointers to either 4 or 8 bytes depending on size. Some architectures such as x86, don't care if you align things, and will “fix things up for you” transparently. But others (most actually) care and will refuse to access data if it is nor aligned correctly.

So keep this in mind as a general rule - your data must be aligned. The C compiler will do most of this for you, until you start doing “fun” things with pointers.

Note that in addition to memory, CPUs will have “temporary local registers” that are directly inside the CPU. They do not have addresses. The compiler will use them as temporary scratch space to store data from memory so the CPU can work on it. Different CPU types have different numbers and naming of such registers. ARM CPUs tend to have more registers than x86 for example.

Variables

C provides you with a handy “abstraction” to store data in memory. These are normally variables. When you first see some C code, you likely see some variables and these are stored on the stack. This is a special place in memory for such temporary variables and data. It grows and shrinks as needed and has a specific order to store data.

Other data is likely on the “heap” and you will explicitly ask it to be there. The heap is basically a “big mess of data” where you likely have more permanent data or the larger bits of data. C provides some basic methods to ask for this storage, where the stack allocation space is generally implicit when you call a function.

Variables will live within this memory, and normally the C compiler will deal with alignment (especially the stack). You simply say “I want a variable called “bob”, that is of the type “integer”. The type of a variable tells the compiler how much memory it should use. The name is how to refer to it in your code.

int bob;

You can even tell the compiler to make sure it has an initial value. If you don't, its value may be random garbage that was there before in memory.

int bob = 42;

Once you have declared a variable, you can now use it. You can group values together in repeating sequences using arrays or in mixed groups called structs that contain a sequence of variables structured as indicated. Order is important and is maintained in memory. You can at times take advantage of this ordering for doing things like “inheritance”. Arrays also have strict ordering in memory, so you can later on use pointers and simple arithmetic to walk up and down an array to access data, which is very handy on many occasions. Keep in mind that arrays (like memory) all begin at 0, so the first item is item 0, thus the last item is always size - 1.

int bobs[100];
double things[200];
struct mydata
{
   int count;
   double items[100];
};
 
struct mydata bob;

Structs (structured data) are very important and allow C to become rather complex and powerful when it comes to data storage, and don't forget you can embed structs inside structs, have arrays of structs, structs with arrays and use pointers to indirect from one struct to another, arrays of pointers to structs and so on.

Functions

A function is a basic unit of execution. Conceptually a function hides an implementation of how to do something and exposes this as a higher level command. “Go make me a sandwich, using butter, cheese and ham” could be seen as calling the “make me a sandwich” function, with the input parameters (or arguments) being butter, cheese and ham, and the function returns a sandwich (for example). In C we might express this as follows:

struct sandwich
{
  struct bread_slice top;
  struct bread_slice bottom;
  enum filling *fillings;
  int num_fillings;
};
 
enum filling
{
  FILLING_HAM,
  FILLING_CHEESE,
  FILLING_BUTTER
};
 
struct sandwich *
make_sandwich(enum filling *fillings, int num_fillings)
{
  struct sandwich *sandwich;
  int i;
 
  sandwich = malloc(sizeof(struct sandwich));
  if (!sandwich) return NULL;
  get_bread_top(&(sandwich->top));
  get_bread_bottom(&(sandwich->bottom));
  sandwich->fillings = malloc(sizeof(enum filling) * num_fillings);
  if (!sandwich->fillings)
    {
      free(sandwich);
      return NULL;
    }
  for (i = 0; i < num_fillings; i++)
    sandwich->fillings[i] = fillings[i];
  sandwich->num_fillings = num_fillings;
  return sandwich;
}

I may call the function as follows:

struct sandwich *sandwich;
struct filling my_fillings[3] = {FILLING_HAM, FILLING_CHEESE, FILLING_BUTTER};
 
sandwich = make_sandwich(my_fillings, 3);

You will notice we used little markers such at * to indicate the variable is a POINTER to that type of variable. The sandwich structure is located over at “byte 1837616 in memory” for example (and the next N bytes will be the data for the sandwich). This also is used for return types of functions. This level of indirection (to return something that points to the the real thing) is very common, because it is very efficient (returning only 4 or 8 bytes of data) and keeping the original data exactly where it is without needing any copies. It means we can modify the same object from many locations by passing around a pointer TO that object.

We also use the & operator to get the pointer TO that element in memory. This is a great way of passing in “please do something to THIS THING HERE that i have”. In the above case, we call an imaginary function that we haven't specified above to “get a bread slice” and place it into the top and bottom elements of the sandwich.

So back to functions, They exist mostly to hide complexity. Many simple things you will see are actually rather complex, and are many detailed steps to get it right. Functions encapsulate this and hide it at a higher level. They are basically ways of summarizing “this bit of code here” with a defined set of input parameters (0 or more) and a return value (or no return) when the function is done. When a function is called, execution waits for it to finish before resuming in the current scope.

In C (and on a machine) everything lives in memory somewhere, and so functions can even have pointers. This is a very useful mechanism to say “call this thing here, when this other things happens”. What “this thing here” is can be modified at runtime to point to any function you like. These are often referred to as Callbacks. See below for more details on these.

Types

C provides a host of basic types. The common ones are:

Type Description
char A single byte almost always signed except on ARM where it likely is unsigned by default (use signed char explicitly to get signed on ARM)
unsigned char A single byte that has no sign
short 2 bytes together as a short word, signed
unsigned short 2 bytes together as a short word, unsigned
int 4 bytes together as a word, signed
unsigned int 4 bytes together as a word, unsigned
long 4 or 8 bytes (32 or 64 bit) together as a word, signed
unsigned long 4 or 8 bytes (32 or 64 bit) together as a word, unsigned
long long 8 bytes together as a word, signed
unsigned long long 8 bytes together as a word, unsigned
float 4 bytes together as a floating point value, signed
double 8 bytes together as a floating point value, signed
struct x A custom defined set of N bytes, broken up into sub-types as per the struct definition of “x”
enum y A type that can have one of N values (an enumerated type), where those word-like values also have numerical values (size is almost always 4 bytes like an int)
void An undefined type, used often to mean no type (no return or no parameters) or if used with pointers, a pointer to “anything”
* z A pointer to type “z”. Have ** z to mean “a pointer to a pointer to something of type z”, or *** z etc.

You can create new types yourself with a typedef. For example:

typedef double Coordinate;

This would create a new type of Coordinate which really is a double. You can typedef to typedefs too.

typedef double Coordinate;
typedef Coordinate Graphics_Coordinate;

You can use this with structs and enums as well to make new types that represent this custom defined type you have created, like:

struct sandwich
{
  struct bread_slice top;
  struct bread_slice bottom;
  enum filling *fillings;
  int num_fillings;
};
 
enum filling
{
  FILLING_HAM,
  FILLING_CHEESE,
  FILLING_BUTTER
};
 
typedef struct sandwich Sandwich;
typedef enum filling Filling;

You can now replace all struct sandwich instances with Sandwich. Take note that since C only parses a file once from beginning to end, you need to typedef something before you use it, otherwise the compiler will not know about that type yet when it sees it in code. Keep this in mind - ordering does matter.

Arithmetic

C supports all the usual mathematics you might want, as well as binary logic operations and boolean logic. You can add (+), subtract (-), multiply (*), divide (/), modulo (%), do bit-wise logic operations with XOR (^), AND (&), OR (|), NOT (~), or do boolean logic with operators such as AND (&&), OR (||), NOT (!). It is highly advised to group operations in braces like () to be clear on the order of operation you wanted. So x = a * b - c / d is unclear as to what you intended. It is wise to explicitly group like x = a * ((b - c) / d). The same goes for the bit-wise operations as well.

Note that this arithmetic starts falling apart if you try to add two structs (it doesn't work), as these are not normal integers or floating point values. You can add two pointers of the same type, but do not use void * for pointer arithmetic as it is unclear what the unit is. Normally a pointer has a unit size of its type size. Also bit-wise arithmetic isn't very useful on floating point values etc.

You also have shortcuts like i = i + 1 can be i++. Or i = i - 1 can be i-- or to be specific i = i + 10 can be i += 10 and same with i -= 10 being i = i - 10.

Logic

In addition to bit-wise logic, there is boolean logic. In C if a variable is 0, it is false, and otherwise it is true. You can thus use simple ints, chars etc. as booleans. You would indicate boolean logic vs bit logic with && for logical AND, and || for logical OR. For example:

// if (a is less than 10 AND b is < 100) OR z is true then...
if (((a < 10) && (b < 100)) || (z)) printf("Success\n");

In addition to && and || there is ! which means NOT or INVERSE. You can combine these with comparisons like == meaning “is equal to numerically” (the 2 values either side have the same number when boiled down to a series of bits/bytes), != meaning “is not equal to” (the inverse of ==), < meaning “is less than”, > meaning “is greater than”, meaning “is less than or equal to” and >= meaning “is greater than or equal to”.

Like with arithmetic, it is highly advisable to group your logic into clear order of evaluation with braces. Relying on order of operation can lead to bugs when you happen to not quite remember an order properly, and then someone trying to fix your code later being confused as to exactly what you meant to do. So be clear and use braces liberally. They cost you nothing in performance at runtime, just add clarity in code when it is written.

Loops

Like most languages you will have seen, C can loop with for loops as well as while loops and do while loops. You can even use flow control such as goto to create loops, though this is generally not recommended except in very rare circumstances. The most common use of goto for such flow control would be error handling in a single section at the end of a function or code segment to avoid duplicate error case handling in multiple places. You can control the flow within a loop using continue and break. The continue statement will skip the rest of the current outer most for or while loop and go to the end of it to re-evaluate looping conditions. the break statement breaks out of the current while or for loop and returns to the parent scope.

int i;
 
for (i = 0; i < 100; i++)
  {
    printf("Loop number %i\n", i);
    if (rand() < 100) break;
  }
 
while (i > 0)
  {
    printf("While loop changing i to %i\n", i);
    if (rand() < 100) break;
  }
 
do
  {
    i--;
    if (rand() < 100) continue;
    printf("do .. while only prints some of the iterations as this may be skipped. i = %i\n". i);
  }
while (i > 0);

The for loop always has 3 statements separated by a ; char. The first is the starting statement. This is executed when the loop first starts. In this case we set i to be 0. The second statement is run at the start of every loop to determine if it should continue or not. If this statement is true, then the loop continues. The last statement is something to do at the end of every loop iteration before returning to the start of the loop. Here it is to increment the value of i by one.

The while loop simple checks the statement in the braces following the while is true at the start of each loop, and if it is, executes the statements in the following scope, until the end, then repeats this statement in the first braces, until it is false.

The do while loop is just like while But the test statement is executed at the end of each loop, not at the start, so you are guaranteed that the inner scope code of the loop will execute at least once before a test.

Pre-processor and Macros

You already had your first encounter with the C pre-processor with the #include line in the first “Hello world” example above. The pre-processor is “executed” during compilation before the code is actually parsed by the C compiler proper. The job of the pre-processor in general is to “generate” more code. This mechanism is used to save a lot of repetitive typing, copy & pasting, conditional compilation (compile section of code A instead of code B based on compile-time information), as well as wholesale importing of other pieces of code that can define types, structs, function prototypes and more. This will not cover all possible features of the pre-processor, but the most common and useful. Here are some of the basic use cases.

System include headers
#include <Eina.h>

These headers define functions, types and even other pre-processor macros for use in your code. These headers are generally provided by system libraries (such as libc or EFL etc.) and you generally would place such include lines at the top of your C source files to indicate you are “including” the content of that file. Remember that this file is also passed through the pre-processor and thus can recurse, including more files than can include more files and so on. All the content of these files is effectively “pasted” into your code at the point of the include. These lines all start with a # character on the first character of the line.

Local headers
#include "myheader.h"

Once your projects get a bit larger, you will divide your application or library up into many .c and .h files. The headers (.h files) will define things from other parts of your source code base. you likely will use a Makefile or some similar mechanism to compile your project file by file and then link it together. You will compile the .c files, and include .h files along the way. The quotes as opposed to the <> is that this here searches in the same directory as the source file that includes it, but the latter will search in a search path of directories (normally /usr/include and this can be extended with extra command line options to the compiler such as -I/usr/local/include to add this directory to the search path for includes).

Defined values
#define NUM_ITEMS 100
 
int x = NUM_ITEMS;

It is useful to avoid “magic numbers” in your code to define them in a central place (at the top of your .c files or in a shared common .h file) and give them a name. If you need to change them later, you change them in one place only. This also helps document the purpose of this magic value as it gives it a descriptive name, improving maintainability. Note that you can also “undefine” a value with an #undef NUM_ITEMS for example. This will remove the definition from that point on in your source file, unless re-defined again. This can be useful if you want a special define for just a section of code, but then want to release that definition to allow it to be re-used later on.

#define MY_TITLE "Hello world"
 
printf("This is: %n", MY_TITLE);

You can define anything. It literally is a “string replacement” system, so it will replace the defined toke with what you define it as. This works with numbers, strings, functions and whole sections of text. You can even define a definition with other definitions inside of it:

#define NUM_ITEMS 100
#define MY_STRING "Hello", NUM_ITEMS
 
printf("This string: %s has %i items\n", MY_STRING);
More complex macros
#define SIMPLE_FUNC(x, y) complex_func(100, 200, x, "hello", 4.8, y)
 
int x = rand();
SIMPLE_FUNC("Boo", 10 * x);

You can define macros that take parameters. They will produce the code that you specify exactly as given, replacing instances of the tokens given as parameters as they are passed into the macro. This is extremely useful in being able to simplify and make code more concise. The tokens passed in don't have to even have to be simple single values. They can be entire expressions going on and on, even entire snippets of code. Such macros are very powerful, and a common convention in C is to make defined macros or values “all upper-case” to indicate that it is actually a macro, as opposed to a function.

Conditional compilation
int
myfunc(void)
{
#ifdef _WIN32
  // windows specific code here
#else
  // generic code here
#endif
}

Another very common use of the pre-processor is to compile only some pieces of code in specific circumstances. A common use-case is for portability (but you can also use this along with #includes, macros etc. to use the pre-processor as a code-generation tool to save a lot of re-typing of almost the same bits of code). On one platform you may have to have some pieces of code work in a specific way that differs from other platforms. This commonly happens with Windows vs Linux vs BSD etc. so you may end up with segments of code such as above, with platform-specific segments of code (individual lines, or maybe even whole large sections of files of 100s or 1000s of lines of code, or perhaps in header files with sometimes specific include files being included only on certain platforms).

You can use this also to compile your code with features enabled or not. You can define pre-processor values on the command line with -D with most compilers, such as -DMY_FEATURE=1 for example which is the same as putting in the code #define MY_FEATURE 1. You can then have your code be something like:

int
myfunc(void)
{
#ifdef MY_FEATURE
  int i = 0;
 
  while (i < 0) i = rand();
  return i;
#else
  // feature not implemented, so return -1
  return -1;
#endif
}

So only compile the active code in when enabled in the compilation process.


Memory

Reality is that languages like C are really a slightly more convenient and portable interface to the actual machine you have. That means the CPU, its instructions and processing as well as memory that the CPU will access one way or another. Let's visualize just some of this. Imagine memory as simply a series of boxes than can contain a number that has a value from 0 to 255 (if unsigned). To do signed values we just interpret values differently. We can have from -128 to 127 as values. When you hit the maximum value, things wrap around to the minimum. For signed and unsigned the first 128 values are the same in memory. This also is true for shorts, ints, longs and long longs, except it is the lower positive half of the range. To make life easier for computers, we will use hexadecimal to represent the numbers as raw data (no sign is implied here).

Byte Value
0 01
1 2e
2 fe
3 00
4 1a
5 43
6 aa

And so on. All memory is a massive set of these bytes, one after the other. On modern systems you don't have hundreds or even thousands of these boxes, but you have millions or even billions of them. You can group them together to store more than just a small value. This is what shorts, ints, longs and long longs do (as well as floats and doubles). They simply tell the CPU to take a set of 2, 4 or 8 bytes together to be a larger value.

Let us see how a struct might map onto memory in this way. This struct:

struct mydata
{
   int    number1;
   char   string1[15];
   int    number2;
   double floating_point1;
   char   char1;
   float  floating_point2;
   short  short1;
   int    number3;
   double floating_array[3];
};

If we were to fill this structure with the following code:

struct mydata d;
 
d.number1 = 1234567;
strcpy(d.string1, "Hello world!");
d.number2 = 1;
d.floating_point1 = 2.0;
d.char1 = 1;
d.floating_point2 = 999.999;
d.short1 = 30000;
d.number3 = -1;
d.floating_array[0] = 1.0;
d.floating_array[1] = 2.0;
d.floating_array[2] = 3.0;

In memory from start to end it looks like:

Memory layout

Note that members will add padding to align members to their natural alignment. This is necessary for correctness and speed reasons across all architectures. Everything is really just a series of bytes in memory. Memory is filled with 1000s or even millions of these bits of data, either one after the other, or spread out. You can jump from one to the other by just using a pointer. Pointers are simply byte numbers from the start of memory (which is 0). The data lives somewhere in memory, and a pointer just says what address it lives at. All data will consume some number of bytes at that address, and may inside contain more pointers pointing to other bits of memory too.

Generally you allocate memory with functions such as malloc(), calloc(), or realloc(). Some libraries you use may do allocations for you. Be sure to read the manuals on them as well as these above. You would free memory you no longer need with free(). All these functions just take a pointer value that .. points at the memory to free, reallocate, or they return a pointer to this place in memory where your new memory block has been arranged. There are some special functions like alloca() that you don't need to free, and instead allocate on the stack instead of the heap.

Stack and heap

The memory of your process, other than memory used to store the code/instructions loaded from disk, is primarily made up of 2 elements. The stack and the heap. Your memory space will generally look something as follows, often with the stack high up in memory (at high addresses) and pieces of code from the process and libraries mapped in from disk, as well as heap space being allocated there too. Mappings not explicitly set up are inaccessible, and any access to them causes a crash (often via a SEGFAULT).

Complete memory space diagram

The stack is managed for you mostly by the compiler and runtime. As the application runs, every time a function is called, a new blob of memory is “pushed” at the “top” of the stack (conceptually stacks grow.. thus the top is where the newest item(s) are on the stack, but often the stack grows down in memory, so to push you subtract values from the top of the stack and to pop, you add again). This memory contains the parameters passed to each function, and will contain return values from the function as well as a return address to go back to (to read instructions from by the CPU) when this function returns. It is a very simple structure, and yet incredibly useful. Note that stack often has limited sizes (sometimes in the order of dozens of KB, maybe only a few MB, but commonly stacks are like 8MB in size). So don't abuse the stack too much. A function like alloca() can allocate extra memory on the stack (it pushes a segment of N bytes onto the stack that is cleaned up when the function in which it is allocated returns). This is handy if you need some temporary scratch space that is not too big and a fixed size is not known at compile time.

The heap is where more permanent memory is stored. Data here remains until explicitly freed. Most objects and larger data will live here. Getting memory in the heap is more costly in terms of time taken to allocate it or free it (but once allocated, access cost is the same). The limit on memory in the heap is generally “all available memory on the system”, given caveats of fragmentation of memory, and possible process allocation limits imposed by the OS.

Libraries

A shared library is simple a large bit of code you can “load” when your application (or library) is loaded, where the code in it is shared with other users on the system. It mostly is provided by another group of developers, and thus may change its internals without your application or library needing to be re-compiled. If there is a bug in the library it may be fixed later on by an update to the library. Everyone who installs the update gets the fix (next time the process is executed). The same applies for new features. Libraries have no special powers. They have the exact same abilities that the code in your application would have. The literally are just externally applied code extensions of your process or library. They can't gain more powers, nor can they hide security-related data “in the library” as all this data is visible to your app.

If you want to do something privileged, or hide data, it needs to cross a process boundary. Normally you'll speak some form of IPC to a privileged “root” process for example. Of course all of this “we share everything” with libraries also means that code in your application could corrupt/mess/destroy data the library is maintaining, as well as vice-versa. There is no protection between a library, another library and your process. This lack of protection means performance is very good and you really pay no major price putting code in a shared library, but you get no protection.

The benefit of a shared library is to avoid needing a re-compile to get improvements, save writing all the code the library shares with you, and to share the memory the code in the shared library would consume. As it is a SHARED library, the code from that library is loaded only once on the system. It may add to the virtual size of the process, but this space is shared across every process using that library, so the cost is paid just once.

Generally a library exposes an API (a set of functions to call). It will provide header files you #include in your application (or library). You would link to the library and thus, at runtime, the “runtime linker” (ld.so often), will glue in the function symbols in your code to the library you link to. This also is done for global variables exposed by the library as well. This is all done at the start of process startup before the main() function is called. There is a cost to this, but it is generally worth paying for the benefits. Your code will then be able to call the functions from the library (or access its data) as if the library were part of your own code.

You generally would compile your code to link to a library as follows, assuming the source for your application is hello.c, the binary you wish to output is hello and the library you want to link to is eina (often the file on disk will be libeina.so for the development environment).

 cc hello.c -o hello -leina

This assumes the library is in a standard directory the compiler always looks in. This would be /lib and /usr/lib. If the library is not there, you need to tell the compiler of a new location to look at with the -L option such as the following if the eina library you wish to link to is located in /usr/local/lib:

cc hello.c -o hello -leina -L/usr/local/lib

Of course that is just the linking. The include files may also require an option to tell the compiler to look somewhere other than /usr/include. That option is the -I option as follows if the include files needed are in /usr/local/include/eina-1:

cc hello.c -o hello -leina -L/usr/local/lib -I/usr/local/include/eina-1

This of course is rather painful, and that is why a modern system called Pkg-config was created, and it is far easier to use. The above would become a simpler:

cc hello.c -o hello `pkg-config --cflags --libs eina`

In this case pkg-config will provide the compiler flags needed to link to eina. If you want to use multiple libraries, just list them as follows:

cc hello.c -o hello `pkg-config --cflags --libs eina ecore embryo`

Pkg-config relies on setting your PKG_CONFIG_PATH environment variable to indicate where to look for the .pc files that contain the include and linking information. You may want to do a one-time setup of this environment variable in your shell setup like:

export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/lib/pkgconfig

Add as many directories as you like with a : character between them. They will be searched in order from the first to last, until a matching .pc file is found.

API calls

A library (or in fact any part of a program can too) exports API calls. These are mostly actual function calls, with the prototypes (telling the compiler the function name, what it returns and what it accepts as parameters) in the header files. The headers may also provide macros to use and perhaps also global variables. When an application runs, it's the functions and variables that matter. These are exposed as “symbols”.

Symbols are listed as strings in a symbol table. This could be thought of as an array of strings, with locations where that function (or variable) is stored in the library. At runtime, libraries are linked and these symbols are looked up, with pointers to these functions being “fixed up” by the runtime linker. If a symbol is missing in a library that something else needs, you will get a runtime linking error (and generally execution stops here before even main() is called).

If the types that a function returns or accepts changes, then there is an ABI (Application Binary Interface) break. Libraries should never do this without also jumping up a major version e.g. from 1.x to 2.0, or 2.x to 3.0 etc. This major version change indicates that there is a break in ABI (or API). On Linux you will see the version of the library in its filename:

libeina.so.1.4.99

For example. When compiling, the compiler looks for a file called libeina.so in the link search path (usually /lib and /usr/lib, unless you also add directories with -L such as -L/usr/local/lib). If this file (libeina.so) is a symbolic link (symlink), this is dereferenced. Generally this is the symlink setup used for shared libraries:

File Link
libeina.so libeina.so.1
libeina.so.1 libeina.so.1.4
libeina.so.1.4 lineina.so.1.4.99
libiena.so.1.4.99

After this dereferencing, the compiler will embed into the binary being compiled the need to link to libiena.so.1. If this doesn't exist anywhere in the runtime search path (which is different to the compile time one - this is normally /lib and /usr/lib and /etc/ld.so.conf can extend this list, as well as the LD_LIBRARY_PATH environment variable can set the search path at runtime something like /usr/local/lib:/usr/lib:/lib which would mean to look in /usr/local/lib first, then /usr/lib then /lib for the shared library.

It is also possible to manually load a shared object file (thus the .so extension) manually using calls such as dlopen() and dlsym(). This will led you load a shared object file and manually look for specific symbols in the file and get a pointer per symbol. This is often a way API/ABI calls are found from modules.

The difference between ABI and API is that API covers the interface in general both at compile AND at runtime, but ABI is specifically post-compilation at runtime. If ABI stays the same, but API changes, then existing binaries will keep working fine. If you wish to adapt to the new API you will need to recompile and possibly change some of your code to adapt to these changes.

System calls

System calls are special API calls. They are almost all buried inside libc. Function calls here inside libc issue a special interrupt to switch into kernel mode and passing in parameters. Once switched into kernel mode, no application code is executing anymore. All code is from the kernel, and its job is to implement the functionality of the system call. This code runs in “kernel space” and basically has full privileges to do anything it likes. Kernels though will enforce access and security policies, so unless there is a bug in the kernel, your process should be limited to what it can do based on these rules. Once the kernel has finished doing what is necessary for the system call, it will return back to userspace and continue execution where the system call was.

System calls are rather expensive. They can cost hundreds if not thousands of CPU cycles. Also once they return, your code will not run at full speed for a while, because Kernels will flush and empty caches, so the L1, L2 (and maybe L3) caches all need to warm up again. Avoid system calls if you can because they are far more expensive than a simple function call within an app or between an app and a library. Also be aware that context switches between 2 processes or a page fault (swapping out memory to disk when low on physical RAM, as well as paging in data from disk such as code or other memory-mapped files) has the same switching overhead in addition to the actual work that needs to be done.

A good list of system calls can be found here, but a common list of important ones is:

  • open()
  • close()
  • read()
  • write()
  • lseek()
  • stat()
  • mmap()
  • munmap()
  • madvise()
  • mprotect()
  • fcntl()
  • ioctl()
  • connect()
  • accept()
  • bind()
  • access()
  • select()
  • getpid()
  • getuid()
  • gettimeofday()
  • clock_gettime/()
  • time()
  • mkdir()

Endianess

Memory is really a sequence of bytes from the view of a CPU, but when you access data types like shorts or ints etc. that consume multiple bytes, the order that they are read from in memory and assigned to slots from the LSB (Least Significant Byte) to the MSB (Most Significant byte). There are 2 commonly referred to ways of accessing these bytes in multi-byte types, called Big Endian and Little Endian. Endianess these days is mostly the little variety. X86 and x86_64 are both little endian. ARM generally is shipped as little endian, but is capable of being big endian too. PowerPC, SPARC, Alpha and MIPS are generally big endian, but may feature the ability to switch depending on version. Generally if it is possible to switch, this is decided at boot and remains fixed. So generally endianess these days is:

Architecture Endianess
x86 Little
x86_64 Little
ARM Little
PowerPC Big
MIPS Big
SPARC Big
Alpha Big

That means that the majority of people will find themselves programming for a little endian environment. This does not mean you can assume it everywhere. Many file formats store data in a big endian fashion, as do many network protocols, so when you deal with memory and have to save and load it or transmit it across a network, you need to be very careful about endianess and have to convert as necessary. Also be aware that alignment also matters.

In memory, in order from lowest memory address to highest, endianess looks as follows for an 4 byte int type:

Decimal Hex Big endian Little endian
1393589900 53107e8c 53107e8c 8c7e1053

Function pointers

Pointers are the stuff of life when it comes to C and machines. They tell you where everything lives. Where your data is and where the next bit of data after this one is and so on. Following this setup, functions also live somewhere in memory. They actually have pointers. These are function pointers. This is an amazingly powerful feature of C that most don't being to discover until much later. It allows you to do things like say “When this operation has finished, call THIS function here”. And that function is just a piece of data. A pointer. A function pointer. You can simply pass it around just like any other data. You can store it in structures, in local variables and pass it in as a parameter to another function.

The downside of function pointers is the added mental load to handle the indirection, as well as the horrible syntax. So for a function with a prototype of:

int *myfunc (struct t *tim, int num, char *str);

You would declare the function pointer as:

int *(*myfunc) (struct t *tim, int num, char *str)

So you pass this function pointer in as parameter funcptr in the following function:

void dothis(int num, int *(*funcptr) (struct t *tim, int num, char *str));

Or in a structure:

struct t
{
  int num;
  int *(*funcptr) (struct t *tim, int num, char *str);
};

You may find it easier to typedef these function pointer types so they are simpler to write later such as:

typedef int *(*MyCallbacktype) (struct t *tim, int num, char *str);
 
void dothis(int num, MyCallbacktype funcptr);

You can use any compatible (returns the same types and accepts the same parameters) function names as actual function pointers such as:

typedef int *(*MyCallbacktype) (struct t *tim, int num, char *str);
 
void dothis(int num, MyCallbacktype funcptr);
 
int *task_a(struct t *tim, int num, char *str)
{
  // ... content task a here
}
 
int *task_b(struct t *tim, int num, char *str)
{
  // ... content of task b here
}
 
if (rand() < 100) dothis(99, task_b);
else dothis(100, task_a);

Function pointers are extremely important and useful and form the backbone of EFL in the form of the following Callbacks.

Callbacks

Callbacks are simply a formal way of naming a function pointer to be called back at another point. This is used commonly among software like GUI toolkits for useful behavior handling, such as when someone “clicks” a button, or when a window resizes, or a slider changes value etc. It literally is saying “When X happens, call function Y”. For example:

static void
win_del(void *data, Evas_Object *obj, void *event_info)
{
   elm_exit();
}
 
// ...
 
Evas_Object *win;
 
win = elm_win_add(NULL, "tst", ELM_WIN_BASIC);
evas_object_smart_callback_add(win, "delete,request", win_del, NULL);

In this example, the code creates a new window and then adds a callback to the object to be called on the “delete,request” event. The function to call whenever this happens is the win_del function. This function simple calls another function that triggers an exit. Callbacks will keep being be called whenever such an event happens until they are deleted and/or unregistered.

In most cases such callbacks for a GUI toolkit will be called from the main loop function. This main loop will process events and eventually when an event does end up being one that asks to delete a window, then the logic code in the toolkit that figures this out will end up calling all callbacks registered for this event.

Callbacks are a very simple, yet very powerful concept. It is important to understand them and be comfortable with them once you write less trivial C code.

Threads

A more advanced concept in programming is threading. This is where multiple pieces of code (functions and children of functions) can execute in the same process at the same time. All threads can read and write to all memory within that process. This is extremely dangerous, so avoid threading until you have fairly well mastered C without threads.

If you must use threads, even if you are experienced, sticking to a model where threads share as little data as possible and very carefully hand off data from one thread to another and then never touch it again is far safer. The lowest levels of C that deal with threads generally is the pthreads API. EFL has a wrapper for systems with and without pthreads to ensure portability and compatibility, but this here will cover raw pthreads.

You would begin a thread with pthread_create() as follows:

#include <stdio.h>
#include <pthread.h>
 
static void
thread1(void *data)
{
  for (;;)
    {
      sleep(8);
      printf("THREAD 1, data: %s\n", data);
    }
}
 
static void
thread2(void *data)
{
  for (;;)
    {
      sleep(7);
      printf("THREAD 2, data: %s\n", data);
    }
}
 
int
main(int argc, char **argv)
{
  pthread_t thread1_handle, thread2_handle;
 
  pthread_create(&thread1_handle, NULL, thread1, "first data");
  pthread_create(&thread2_handle, NULL, thread2, "second data");
  for (;;)
    {
      sleep(3);
      printf("MAIN PROCESS\n");
    }
  return 0;
}

The pthread API provides various utilities used to synchronize things between threads. There are mutexes (locks used to indicate the lock owner currently owns the data and everyone else has to wait to gain a lock), conditions, spinlocks and more. You would use mutexes (locks) like this:

struct obj
{
  pthread_mutex_t lock;
  int data1, data2;
  char *str;
};
 
struct obj *
create_obj(void)
{
  struct obj *ob = malloc(sizeof(struct obj));
  if (!ob) return NULL;
  ob->data1 = 1;
  ob->data2 = 7;
  obj->str = strdup("a string");
  pthread_mutex_init(&(ob->lock));
  return ob;
}
 
void
set_string(struct obj *ob, char *str)
{
  pthread_mutex_lock(&(ob->lock));
  free(ob->str);
  ob->str = strdup(str);
  pthread_mutex_unlock(&(ob->lock));
}

Note that any function modifying or accessing the object like set_string() first locks the mutex, then does its work, then unlocks. This is a major source of threading bugs. Often some code locks an object, then forgets to unlock, or with more complex lock setups, you end up with deadlocks as 2 pieces of code wait for the other to blink. It is easy to get this wrong and often very difficult to debug it as often the issues only happen rarely in special timing circumstances. Use threads very wisely.


Also See

Now you have read our less-than-perfect primer on C for those wanting to get into developing on EFL or using it from the C APIs we have, you may also want to look at the following list of pages and pick up some more information or fill in the gaps we do not cover here (feel free to link to more articles that are good for teaching C from new developers through to advanced).


You could leave a comment if you were logged in.