Catto / Topic Map · Computer Science I Level 1
Computer Science I · 120 h · Topic 6 of 6

Using the language for low-level applications

The same C used to write a desktop program drives the pins of a microcontroller: all it takes is the bit operators, a pointer to a fixed address and knowing why volatile is needed.

Bit operators Masks volatile stdint.h Interrupts

01What “low level” means

A low-level program is one that talks directly to the hardware: it lights an LED by writing a bit into a register, reads a sensor by waiting for a status bit to change, builds a frame bit by bit to send it over a wire. In the stack of layers from the first topic, it means working just above the instruction set architecture, with no operating system in between.

C is suited to this because it has three things that high-level languages hide: bit operators, pointers to fixed addresses and types of known size. With them, the same language used to write a spreadsheet takes control of the pins of a chip.

02Bit operators and masks

OperatorWhat it doesWhat it is used for
a & bBitwise ANDSetting bits to 0 and testing a bit
a | bBitwise ORSetting bits to 1
a ^ bBitwise exclusive ORToggling bits
~aComplement of all the bitsBuilding the mask for clearing
a << n, a >> nShiftBuilding masks and extracting fields

A mask is a number that has 1s in the bits you want to act on. (1 << 5) is 0010 0000: only bit 5. With it, you get the four operations that are used all the time:

reg |=  (1 << n);     // set bit n to 1
reg &= ~(1 << n);     // clear bit n to 0
reg ^=  (1 << n);     // toggle bit n
if (reg & (1 << n))   // is bit n set to 1?

All four touch only the chosen bit and leave the others as they were. That is what matters when each bit of a register controls a different pin: turning on one LED must not turn off the one next to it.

Lab · an 8-LED port

The PORTB register drives eight LEDs. Choose the operation and the bit, and look at the C line, the binary calculation and the result.


        
Fields of more than one bit

To read a field, shift and mask: bits 4 to 6 of reg are (reg >> 4) & 0x07. To write it without touching the rest, first clear it and then set it: reg = (reg & ~(0x07 << 4)) | ((v & 0x07) << 4). This is how you configure, for example, the clock divider of a timer or the channel of an A/D converter.

03Registers at fixed addresses

In a microcontroller, peripherals are controlled through registers located at fixed memory addresses, published in the datasheet. In the ATmega328P (the one in the Arduino Uno), the output register of port B is at data address 0x25. In C it is reached with a pointer to that address:

#include <stdint.h>
#define PORTB (*(volatile uint8_t *)0x25)
#define DDRB  (*(volatile uint8_t *)0x24)

DDRB  |= (1 << 5);    // PB5 as an output (the on-board LED)
PORTB |= (1 << 5);    // and turned on

Read from the inside out: 0x25 is a number; (volatile uint8_t *) turns it into the address of a byte; the outer * accesses the byte that is there. From then on, PORTB is used like any other variable. The manufacturer’s header files (<avr/io.h>, the CMSIS ones on ARM) are built exactly this way.

Why volatile

The compiler optimizes on the assumption that a variable only changes when the program writes it. A hardware register changes on its own: the “data received” bit of a UART is set to 1 when a byte arrives. Without volatile, a loop while (!(UCSR0A & (1 << RXC0))); may be compiled to read the register just once and hang forever. volatile forces the real memory to be read and written on every access. It is needed for hardware registers and for variables modified by an interrupt service routine.

04Exact-width types

The C standard only guarantees minimums: an int has at least 16 bits. On an AVR it has 16; on a PC, 32. A program that assumes 32 bits breaks when ported to a microcontroller. For work with hardware, the types from <stdint.h> are used, which state their size in the name:

TypeBitsRange
uint8_t / int8_t80 … 255 / −128 … 127
uint16_t / int16_t160 … 65,535 / −32,768 … 32,767
uint32_t / int32_t320 … 4,294,967,295 / ±2,147,483,647
Example: scaling an A/D converter reading

A 10-bit converter with a 5 V reference delivers 0 to 1023. To convert it to millivolts: uint32_t mv = (uint32_t)reading * 5000 / 1023;. Without the conversion to 32 bits, on an AVR the multiplication would be done in 16 bits: 1023 · 5000 = 5,115,000 does not fit in 65,535 and the result comes out wrong with no warning. With the conversion, 1023 gives exactly 5000 mV. Converters are covered in A/D and D/A converters.

05Byte order and bit fields

A uint32_t takes up four bytes, but in what order? In a little-endian processor (x86, almost all ARM, AVR) the least significant byte goes at the lowest address; in a big-endian one, the other way around. The value 0x12345678 stored at 0x100 ends up like this:

Address0x1000x1010x1020x103
Little-endian78563412
Big-endian12345678

Inside a program it goes unnoticed. It shows up when sending data to another machine: network protocols use big-endian, which is why htons and ntohl exist. The portable way is to build the bytes with shifts, b[0] = x >> 24; b[1] = x >> 16; …, which gives the same result on any processor. This applies to any frame of serial communication.

C also allows declaring bit fields inside a structure, unsigned mode : 3;. They are convenient, but the standard leaves the order of the bits within the byte to the compiler. To describe hardware registers that must match the datasheet bit for bit, masks are safer.

06The program facing the hardware

A program for a microcontroller never ends: it initializes the peripherals and enters an infinite loop. Inside the loop it polls inputs and updates outputs; whatever is urgent is handled by an interrupt service routine, which communicates with the main loop through volatile variables:

volatile uint8_t data_ready = 0;
volatile uint8_t data;

ISR(USART_RX_vect)           // called by the hardware when a byte arrives
{
    data = UDR0;
    data_ready = 1;
}

int main(void)
{
    init();
    for (;;) {
        if (data_ready) {
            data_ready = 0;
            process(data);
        }
        /* rest of the work, without long delays */
    }
}
Accesses that get cut in half

On an 8-bit processor, reading a uint16_t takes two instructions. If the interrupt modifies it between the two, the program reads the old half and the new half. Such reads are done with interrupts disabled for an instant (a critical section). It is an error that appears once in a thousand runs and is not found by testing: it is avoided by knowing that it exists.

Interrupts and peripherals are covered in interrupts and in microcontroller peripherals.

07What the compiler does with this

The best way to convince yourself that C is close to the machine is to look at what it generates. For the AVR, with optimization:

CAVR assemblyComment
PORTB |= (1 << 5);sbi 0x05, 5One instruction that sets a bit of an I/O register to 1
PORTB &= ~(1 << 5);cbi 0x05, 5And another that clears it to 0
if (PINB & (1 << 0))sbis 0x03, 0Skips the next instruction if the bit is 1

The 0x05 is the address of PORTB in the I/O space, which in the data space is the 0x25 from the previous example: there is a difference of 0x20 because the first 32 bytes of the data space are the working registers. One line of C, one machine instruction: that is why there is no need to program in assembly to have fine control. Microcontrollers are covered in microprocessor architecture and the buses driven with these techniques, in serial buses.

08In the lab

Exercise 1 · Blinking without libraries

On an Arduino Uno, blink the PB5 LED by writing directly to DDRB and PORTB, without pinMode or digitalWrite. Use the oscilloscope to measure the maximum frequency achieved with PORTB ^= (1 << 5) in an empty loop and compare it with that of digitalWrite. You can practice with the oscilloscope beforehand in the instrument simulator.

Exercise 2 · Bit functions on the PC

Write and test on the PC set_bit, clr_bit, toggle_bit, get_field and set_field on a uint8_t, printing each result in binary. Test the extreme cases: bit 0, bit 7, a field that takes up the whole byte.

Exercise 3 · Discovering the byte order

Store 0x12345678 in a uint32_t, point a uint8_t * at it and print the four bytes. Deduce whether the machine is little- or big-endian.

09Common mistakes

  • reg = (1 << n) instead of reg |= (1 << n): it turns one bit on and turns all the others off.
  • reg &= (1 << n) to turn off: without the ~ it clears all the bits except n.
  • Forgetting volatile on a register or on a variable shared with an interrupt.
  • Shifting by more bits than the type has: 1 << 31 with a 16-bit int is undefined; write 1UL << 31.
  • Confusing && with &: 4 && 2 is 1; 4 & 2 is 0.
  • Writing magic numbers bare: PORTB |= 0x20 works, but (1 << PB5) is understandable.

10Self-assessment

Which line clears bits 2 and 3 of reg to 0 without touching the others?

reg &= ~((1 << 2) | (1 << 3));, that is, reg &= ~0x0C;

If reg = 0xB4, what is the value of bits 4 to 6?

0xB4 = 1011 0100. (0xB4 >> 4) & 0x07 = 1011 & 111 = 011 = 3.

Why does a loop that waits for a status bit need volatile?

Because without it the compiler may read the register just once, assume it does not change and turn the wait into an infinite loop.

How is 0xA1B2 laid out in memory on a little-endian machine?

First B2 and then A1.

Why can reading * 5000 come out wrong on an AVR if reading is uint16_t?

Because the calculation is done in int, which on an AVR has 16 bits, and the product exceeds 65,535. You have to convert to 32 bits before multiplying.

What is a critical section?

A stretch of code that cannot be interrupted halfway without leaving the data inconsistent. On a microcontroller it is protected by disabling interrupts during that stretch.

11Further reading

  • Michael Barr and Anthony Massa. Programming Embedded Systems: With C and GNU Development Tools. 2nd ed., O'Reilly, 2006. Registers, interrupts and peripherals in C, with examples on real hardware.
  • Elecia White. Making Embedded Systems. 2nd ed., O'Reilly, 2024. How to design the software of an embedded system from end to end, with an engineering mindset.
  • Randal E. Bryant and David R. O'Hallaron. Computer Systems: A Programmer's Perspective. 3rd ed., Pearson, 2016. Chapter 3 shows, line by line, what the compiler generates from C.
  • Microchip Technology. ATmega328P Datasheet. Official datasheet, on the manufacturer’s website. The I/O register map and the instruction set that were used in this topic.
Development of the topic “Using the language for low-level applications” of Computer Science I (Level 1), based on the curriculum of the UTN Electronic Engineering program, 2023 curriculum — Ordinance No. 1849 of the UTN Higher Council. Back to the Topic Map · catto.ar