Basic elements of the C language
C is the language of electronics. It is used to program the microcontrollers of Years 6 and 7, industrial embedded systems and the kernel of almost every operating system. It is demanding, but it gives total control over the machine.
01Why C in an electronics technician program
There are more comfortable languages, but none combines these four things at once:
- Direct access to the hardware. It lets you write a value into a microcontroller register or read an input port exactly as described in the datasheet.
- Efficiency. The generated code is almost as compact and fast as code written in assembly, and that matters when you have 2 KB of memory.
- Portability. The same program, with minimal adjustments, compiles for a PIC, an AVR, an ARM or a PC.
- Universality. Every microcontroller manufacturer supplies its own C compiler. It is the common denominator of the industry.
The source program (.c) goes through the preprocessor, then through the
compiler, which translates it to object code (.o), and finally through the
linker (link editor), which joins the objects and the libraries and produces the executable. On
a microcontroller that result is a .hex file that is written to flash memory.
Understanding this chain is what makes it possible to interpret error messages.
02Structure of a program
#include <stdio.h> // preprocessor directives
#define PI 3.14159 // symbolic constant
float area(float radio); // function prototype
int main(void) // EVERY program has a main
{
float r = 2.5; // declaration with initialization
printf("Area = %.2f\n", area(r));
return 0; // 0 = finished without error
}
float area(float radio) // function definition
{
return PI * radio * radio;
}
| Element | What it is for |
|---|---|
#include | Brings in a header file with ready-made declarations. With <> it searches the system libraries; with "", the project directory. |
#define | Defines a constant or macro that the preprocessor replaces textually before compiling. |
| Prototype | Tells the compiler a function’s name, what it receives and what it returns, before its code appears. |
main() | Entry point. On a microcontroller it never ends: its body is usually a while(1). |
{ } | Delimit blocks. Everything declared inside a block exists only there. |
; | Ends each statement. Forgetting it is the number one compilation error. |
contador, Contador and CONTADOR are three different
variables. By convention, lowercase is used for variables and functions, and UPPERCASE for
constants defined with #define.
03Variables, types and constants
Every variable must be declared before it is used, stating its type. That type determines how much memory it takes up and what values it can hold.
| Type | Typical size (8-bit microcontroller) | Range | Use |
|---|---|---|---|
char | 1 byte | −128 to 127 | A character, or a small signed integer. |
unsigned char | 1 byte | 0 to 255 | The most widely used type in microcontrollers: one port, one byte. |
int | 2 bytes | −32,768 to 32,767 | General-purpose integers. Careful: on a PC they are 4 bytes. |
unsigned int | 2 bytes | 0 to 65,535 | Counters, values from a 10-bit ADC. |
long | 4 bytes | ± 2,147,483,648 | Elapsed milliseconds, frequencies. |
float | 4 bytes | ± 3.4 × 1038, 7 digits | Calculations with decimals. Expensive on a micro with no floating-point unit. |
double | 8 bytes | 15 digits | Double precision. Rarely justifiable in embedded systems. |
void | — | — | “Nothing.” A function that returns no value or takes no parameters. |
int depends on the machineThe C standard only guarantees minimums, not exact sizes. That is why in embedded programming the
types from <stdint.h> are used, which are unambiguous: uint8_t,
int16_t, uint32_t. The number is the count of bits and the u
means unsigned. It is the recommended professional practice and avoids errors when porting code.
Overflow
unsigned char c = 250;
c = c + 10; // NOT 260: it gives 4, because 260 does not fit in 8 bits
unsigned char i;
for (i = 0; i < 300; i++) { ... } // INFINITE LOOP: i never reaches 300
Overflow does not generate any error: the value simply “wraps around.” It is one of the hardest causes to find in an embedded program, which is why the type must be chosen with the maximum possible value in mind, not the usual one.
Constants
#define LARGO 10 // preprocessor: textual replacement, no type
const float PI = 3.14159; // read-only variable, WITH a type
int a = 255; // decimal
int b = 0xFF; // hexadecimal — the most widely used with registers
int c = 0b11111111; // binary (GCC extension, very handy for ports)
int d = 0377; // OCTAL: the leading 0 changes the base. Classic trap
In electronics hexadecimal is the natural format: each hex digit represents exactly
4 bits, so 0xF0 reads at a glance as “the four high bits set to 1.”
04Operators
+ − * / % · ++ -- ·
= += -= *= /=
The % is the remainder of integer
division. / between integers truncates: 7/2 gives 3, not 3.5.
== != < > <= >= ·
&& || !
In C, 0 is false and any other value is true.
Bitwise operators: the essential ones
These are what make C suitable for electronics. They let you manipulate individual bits of a register without touching the others.
| Operator | Function | Typical use |
|---|---|---|
& | Bitwise AND | Setting bits to 0 (masking) and testing a bit. |
| | Bitwise OR | Setting bits to 1. |
^ | Bitwise XOR | Inverting bits (toggle). |
~ | Complement | Inverting all the bits. |
<< | Shift left | Building masks. Equivalent to multiplying by 2. |
>> | Shift right | Extracting fields. Equivalent to dividing by 2. |
// The four operations used all the time with registers:
PORTB |= (1 << 3); // set bit 3 to 1, without touching the others
PORTB &= ~(1 << 3); // set bit 3 to 0
PORTB ^= (1 << 3); // toggle bit 3
if (PINB & (1 << 3)) { // is bit 3 set to 1?
// yes: the pushbutton is pressed
}
PORTB = 8;Because that would set bit 3 to 1 and all the others to 0, switching off the rest of the port’s
outputs. The forms with |= and &= ~ modify only the bit you
care about. It is the difference between a program that works and one that shuts off the motor every time
it turns on the LED.
= versus ==if (x = 5) // ASSIGNS 5 to x, and since 5 is not 0, it is ALWAYS true
if (x == 5) // COMPARES x with 5. This is what was intended
It is not a compilation error: the program compiles and misbehaves. Modern compilers warn
with a warning, which is why you should always compile with -Wall and read the
warnings.
05Flow control
for loop executed step by step, animated: you can see the line being
executed and the value of each variable at that moment.if (temp > 30) {
encender_ventilador();
} else if (temp < 18) {
encender_estufa();
} else {
apagar_todo();
}
switch (tecla) {
case 1: subir(); break;
case 2: bajar(); break;
default: parar();
}
for (i = 0; i < 8; i++) {
encender_led(i);
}
while (!dato_listo()) {
// wait
}
do {
v = leer_adc();
} while (v < umbral);
while (1) { ... } // main loop
break in a switchWithout break, execution keeps falling through into the next case. Sometimes this is done
on purpose (to group cases), but it is almost always an oversight and produces
baffling behavior.
int main(void)
{
configurar_puertos(); // runs ONCE
configurar_adc();
configurar_timers();
while (1) { // repeats FOREVER
leer_sensores();
procesar();
actualizar_salidas();
}
return 0; // never reached
}
This structure — configuration + infinite loop — is that of every microcontroller program,
and it is literally what the setup() and loop() functions of Arduino do,
which is C underneath.
06Arrays and strings
An array is a set of elements of the same type, contiguous in memory, accessed by an index that starts at 0.
int muestras[10]; // valid indices: 0 to 9
int tabla[5] = {2, 4, 6, 8, 10};
muestras[0] = 512; // first element
muestras[9] = 128; // LAST element
muestras[10] = 0; // OUT OF RANGE! Overwrites memory that is not yours
// Average 10 samples from the ADC
long suma = 0;
for (int i = 0; i < 10; i++) {
muestras[i] = leer_adc();
suma += muestras[i];
}
int promedio = suma / 10;
Writing muestras[10] in an array of 10 elements gives no error: it writes to the
next memory location, which may belong to another variable, to the stack or to a register. The
program keeps running and fails later in an inexplicable way. On a PC this is a classic security
hole (buffer overflow); on a microcontroller, a random hang. Checking
the indices is the programmer’s responsibility.
Character strings
In C a string is simply an array of char terminated by the null character
'\0'. It is not a special type.
char nombre[20] = "Electronics"; // takes 11 letters + 1 for the '\0' = 12
#include <string.h>
strlen(nombre); // 11 — does not count the '\0'
strcpy(destino, origen); // copy
strcmp(a, b); // compare: returns 0 if they are equal
strcat(destino, origen); // concatenate
'\0'An array of 20 char holds at most a string of 19 characters. And
strcmp(a, b) returns 0 when they are equal, which is counterintuitive: you write
if (strcmp(a,b) == 0). Never compare strings with ==, because that compares
memory addresses, not contents.
07Functions
Splitting the program into functions is not cosmetic: it is what lets you test each part separately, reuse code and understand a thousand-line program.
// return_type name(type param1, type param2)
float celsius_a_fahrenheit(float c)
{
return c * 9.0 / 5.0 + 32.0;
}
void parpadear(unsigned char veces) // void: returns nothing
{
for (unsigned char i = 0; i < veces; i++) {
PORTB |= (1 << 0);
retardo_ms(200);
PORTB &= ~(1 << 0);
retardo_ms(200);
}
}
The function receives a copy of the argument. If it modifies it, the original does not change. For a function to be able to modify a variable of its caller you have to pass its address, with pointers — which is the central subject of the next topic.
Scope of variables
| Kind | Where it lives | When to use it |
|---|---|---|
| Local | Declared inside a function. Born on entry and dies on exit. | Whenever possible. It is the safest and saves memory. |
| Global | Outside every function. Exists during the whole program. | Only for data shared by several parts, typically between the main loop and an interrupt. |
| Static | static inside a function: keeps its value between calls. | Internal counters that must not be visible from outside. |
| Volatile | volatile: tells the compiler that it can change outside the program flow. | Mandatory for variables modified by an interrupt or mapped to a hardware register. |
volatile, the optimizer breaks the programIf a global variable is modified inside an interrupt and the main loop reads it, the
compiler may “realize” that nobody changes it inside the loop and keep it in a register
forever. The program ends up waiting endlessly for a change it cannot see. The keyword
volatile forbids that optimization. It is an error that only shows up when compiling with
optimization turned on, and it is a classic of Year 6.
08The preprocessor and the standard library
The preprocessor acts before the compiler and performs text replacements. It knows nothing about types or C syntax.
#include <stdio.h> // paste the contents of that file here
#define LED_ON PORTB |= 1
#define MAX(a,b) ((a) > (b) ? (a) : (b)) // macro with parameters
#ifdef DEBUG // conditional compilation
printf("value = %d\n", x);
#endif
#ifndef CONFIG_H // include guard: avoids including
#define CONFIG_H // the same file twice
...
#endif
With #define DOBLE(x) x*2, the expression DOBLE(3+1) expands to
3+1*2 = 5, not 8. That is why every macro is written wrapping each parameter and the
whole expression in parentheses: #define DOBLE(x) ((x)*2). When possible, an
inline function is preferable, since it does respect types.
The libraries in use
| Header | Contains | Note for embedded systems |
|---|---|---|
<stdio.h> | printf, scanf, files | printf takes up several KB: on a small micro it is replaced by a custom function that sends over serial. |
<stdlib.h> | atoi, rand, malloc | Avoid malloc in embedded systems: it fragments memory and can fail without warning. |
<string.h> | String and memory handling | memset and memcpy are very useful with buffers. |
<math.h> | sin, sqrt, pow | Very heavy. If it is enough, use integer arithmetic or precomputed tables. |
<stdint.h> | uint8_t, int16_t, … | Always use in microcontroller programming. |
<stdbool.h> | bool, true, false | Improves readability at no cost. |
09In the lab
With GCC on the command line (not with a graphical environment, so you can see the steps):
gcc -Wall -c hola.c -o hola.o # compile to object
gcc hola.o -o hola # link
./hola # run
gcc -E hola.c > hola.i # see the PREPROCESSOR output
Open hola.i and check that the #include turned into hundreds of
lines and that the #defines disappeared, replaced by their value. It is the clearest way
to understand what each stage does.
Write a program that declares an unsigned char at 250 and adds 1 to it in a loop,
printing the value. Observe how it jumps from 255 to 0. Then print
sizeof(int), sizeof(long), sizeof(float) and compare with what
the course notes say: on the workshop PC int will take up 4 bytes, not 2.
Simulate a port with a variable unsigned char puerto = 0; and write functions
set_bit(n), clear_bit(n), toggle_bit(n) and
get_bit(n). Print the value in binary after each operation. It is exactly what
will be done with real registers in Digital Electronics III.
Write a function that takes the raw value of a 10-bit ADC (0 to 1023) and converts it
to voltage with a 5 V reference (it helps to have the prefix
converter at hand so you do not get lost between millivolts and volts). Do it in two ways: with float and with
integer arithmetic in millivolts. Compare the results and think about which is better on an
8-bit microcontroller. It is the calculation that will appear in every sensor project.
10Common mistakes
| Mistake | Consequence |
|---|---|
A ; is missing | Compilation error, sometimes reported on the next line. Always check the line above the one the compiler points to. |
= instead of == in an if | It compiles, and the condition is always true. Compile with -Wall. |
| Array index out of range | No error: memory that is not yours is overwritten and the failure shows up much later, somewhere else. |
| Variable used uninitialized | It contains garbage. On a PC it often happens to be zero; on a micro, anything. |
| Integer division when decimals were expected | 7/2 gives 3. You have to write 7.0/2 or use a cast. |
volatile missing on an interrupt variable | The optimizer “freezes” it and the program hangs waiting for a change it cannot see. |
break missing in a switch | The following cases are executed too. |
Comparing strings with == | It compares memory addresses, not contents. You have to use strcmp. |
| Macro without parentheses | Unexpected arithmetic results because of operator precedence. |
11Self-assessment
What are the three stages a C program goes through until it is executable?
Preprocessor (resolves #include and #define),
compiler (translates to object code) and linker (joins the objects with the libraries and
generates the executable or the .hex).
What does unsigned char c = 200; c = c + 100; give?
44. 300 does not fit in 8 bits: 300 − 256 = 44. It is a silent overflow.
Write the statement that sets bit 5 of PORTC to 1 without altering the others.
PORTC |= (1 << 5);
To clear it to 0: PORTC &= ~(1 << 5);
To toggle it: PORTC ^= (1 << 5);
How many usable characters fit in char texto[16];?
15. The sixteenth slot is taken by the terminator '\0'.
Why does if (strcmp(a, b)) do the opposite of what it seems?
Because strcmp returns 0 when the strings are equal, and in C
0 is false. That is, that condition is true when they are different. You have to write
if (strcmp(a, b) == 0).
What is the difference between a local variable and a static one inside a function?
The local one is created on entering the function and destroyed on leaving, losing its
value. The static one (static) keeps its value between successive calls, although
it is still invisible from outside the function.
When is it mandatory to declare a variable as volatile?
When it can change outside the normal flow of the program: variables modified
by an interrupt, or mapped to a hardware register. Without volatile,
the optimizer may assume nobody changes it and use a copy in a register.
Why is it recommended to use uint8_t instead of unsigned char?
Because the size of C’s basic types depends on the platform. The
types in <stdint.h> explicitly declare the number of bits, so the
program behaves the same when ported to another microcontroller or to a PC.
What does printf("%d", 7/2); print? And how do you get 3.5?
It prints 3: division between two integers is integer division and truncates. To get
3.5 you have to force floating point: 7.0/2 or (float)7/2, and print it with
%f.