Sitemap
Arthur M

Arthur M

Embedded “Hello World”

9 min readJan 13, 2024

--

The code for this project can be found here: https://github.com/amencke/RTOSEmbeddedDrivers

Working on embedded systems gives you good perspective when you spend most of your time working on high-level languages. To illustrate what I mean, consider this Python snippet:

>>> sys.getsizeof('A')
50

Now, here’s a piece of code from the project described in this post:

#define configMINIMAL_STACK_SIZE ( ( unsigned short )  130 )

A bit contrived, but illustrative of the different worries you might have as an embedded programmer vs a web or backend developer. Anytime I think about memory when working with Python, it’s usually along the lines of “why isn’t the garbage collector freeing the memory that it should be”, or “why did the Python runtime allocate 3GB of memory for a collection of objects that serializes down to 200MB?” (here’s why). Sometimes, there’s not very much you can do about it. You just think “Thank god RAM is cheap these days”. My point is, it’s interesting to think about how they do things in embedded land from time to time.

The goal of this project is to write an interrupt-based bare metal driver for the SPI peripheral on a ST Micro 32 bit ARM Cortex M4 based microcontroller. The SPI driver uses no libraries (which you’d use on a Raspberry Pi or Arduino), but around the peripheries I’ve made a few calls into HAL code for convenience, where it wasn’t a key part of the project code anyway. To me, writing a driver for an onboard peripheral is one of the next steps into embedded systems once you’ve done the ubiquitous blinking LED. I’ll then send a message over a SPI BUS connecting a Beaglebone Black SBC (single board computer) running Linux, receive it on the STM32 and output it on a connected HD44780 LCD display. Along the way, I’ll use a logic analyzer and capture the digital signals as they cross the wires between the STM32 and BBB, and I’ll use SEGGER SystemView to capture exactly what happens on the STM32. Here’s how it looks when it’s all wired together:

Press enter or click to view image in full size

The hardware used:

On the BBB, I used a Debian-based Linux distro. The kernel has a SPI driver. The program to send data over the SPI peripheral in from the BBB was taken from here.

On Linux, configuring peripherals and sending messages is typically done using ioctl, providing the driver’s device file and the command macros defined in the SPI device header to invoke some behavior defined in the driver. Behind the scenes, the SPI driver calls in to architecture-specific code that configures the SPI peripheral of the onboard Texas Instruments am335x SOC. The message I’ll be sending reads:

“Hello World from SPI on BBB!”

On the other end, the STM32 microcontroller is running FreeRTOS. FreeRTOS isn’t really necessary — this project could be implemented using a while loop that never terminates and an interrupt handler, but it provides some nice abstractions like tasks and task notifications which I used here, and synchronization primitives, queues and various other niceties that make life a little more interesting.

Finally, once the message has been received, it’s outputted to an LCD. I copied the driver code from here.

Writing a bare metal device driver

In this context, “bare metal” means writing code that runs directly on the hardware and invoked directly by the startup script of the MCU, without an underlying OS to abstract those details away. FreeRTOS is running here, so “bare metal” is a grey area, but the code for this driver would be identical without FreeRTOS — it doesn’t use any FreeRTOS provided APIs and executes before the scheduler is started. Here, I’m writing a driver for the SPI peripheral. SPI is a 4 wire serial communication standard that uses shift registers to send data over a bus between components connected in a master-slave topology. The bus typically consists of a wire for serial clock, MOSI (master in slave out) and MISO (master in slave out) wires for data transfer, and a slave select wire which is used to control which side is master and which is slave (the slave pin is pulled low). In this case, the BBB is the master because it provides the clock and initiates the data transfer.

Writing a driver is mostly about reading the reference manual of the board and CPU. To configure the peripheral to do what you want, you need to write bits into the appropriate field in memory mapped registers. Determining the address of the registers, the bit start and end offsets, and the desired value requires reading the manual. For example, to set the speed of pin 12 of GPIO C to high, you’d write the value 0x2 shifted 24 bits to the left into the address at offset 0x08 from the base address of the GPIO C peripheral:

Press enter or click to view image in full size

The role of a SPI driver (TODO: add more details)

The driver performs the following actions when it’s loaded:

  1. Enables the peripheral clock for the GPIO pins used for SPI:
Press enter or click to view image in full size

2. Configures the pinmux settings for those pins into the desired alternate function mode. The reference manual for the MCU can be used to determine which pins can be used for SPI. The mapping below shows that PB12–15 work for SPI in mode 5.

Press enter or click to view image in full size

3. Configures the pins with the appropriate settings for SPI communication (speed, output type, and whether the pin should be pulled to high or low)

Get Arthur M’s stories in your inbox

Join Medium for free to get updates from this writer.

4. Configures interrupts for the SPI peripheral. Non-external interrupts (i.e not those driven by rising/falling triggers on pins of the expansion header) are CPU specific and configured in CPU specific registers. So, to configure those interrupts, you’d need to consult the reference manual of the ARM Cortex M4 processor, and cross-reference it against the board manual to figure out which bits to set for your desired IRQ number. Observing the code and the manual side by side, it’s easy to understand: to enable an interrupt, write to the ISER register of the CPU at the appropriate bit position, and to disable it, write to the ICER register:

Press enter or click to view image in full size
__STATIC_INLINE void NVIC_EnableIRQ(IRQn_Type IRQn)
{
NVIC->ISER[(((uint32_t)(int32_t)IRQn) >> 5UL)] = (uint32_t)(1UL << (((uint32_t)(int32_t)IRQn) & 0x1FUL));
}

Right shifting by 5 bits gets the 32-bit offset from the base address of the enable register, which is then set to the value of 1 << (IRQn % 32).

5. Finally, enable interrupts for the SPI peripheral based on your requirements. In my case, I want the peripheral to interrupt the CPU when the RX register is not empty (i.e. data has been received), so when I initialize the SPI peripheral, I need to set bit 6 of the SPI control register:

Press enter or click to view image in full size

Running the embedded hello world

Let’s see what happens when the embedded hello world runs. The stages can be summarized:

  • The SPI master (BBB running Linux) generates a clock signal and writes data into a data register which is then shifted onto the SPI bus via the MOSI pin and into a data register on the SPI slave
  • The SPI slave samples the data at the frequency of the clock speed of its own peripheral, which has to exceed the clock signal received on the SCLK line by some factor in order to read the data accurately. If the SPI slave clock frequency is too slow, it won’t sample fast enough and end up with missing data:
Press enter or click to view image in full size
  • The SPI slave (STM32) SPI peripheral issues an interrupt to the CPU to signal that the RX buffer is not empty.
  • The SPI interrupt handler runs and copies the received byte of data into a global variable. A callback into user code is then made, allowing the author of the code to decide what to do with the data. In this case, the data is copied into a preallocated buffer.
  • Steps 1–4 are repeated until all data has been received.
  • The interrupt handler notifies a FreeRTOS task that the data transmission is completed which unblocks the task to perform data processing.
  • The FreeRTOS task outputs the received data to an LCD

To capture the data as it crosses the wire, you can use a logic analyzer. You also need some software that can record and display the data. I used Saleae Logic and Sigrok Pulseview. Saleae looks a bit nicer, but newer versions cost money. Both tools understand SPI communication and can decode the signal into hex or ASCII.

Press enter or click to view image in full size
Press enter or click to view image in full size

The first 5 bytes are shown below:

Press enter or click to view image in full size

Converting the first 5 bytes to ASCII, we can observe the string “Hello”!

Lets finally look at the sequence of events that occur once the message is received on the slave. To achieve this, we can instrument the code with SEGGER system view which will track the entry and exit point of ISRs (interrupt service routines), and understands FreeRTOS tasks so will track those also

Press enter or click to view image in full size

On the left, there are a series of systick interrupts, each followed by around 994 microseconds of CPU idle (the FreeRTOS scheduler is configured to tick every 1 millisecond for this project). The FreeRTOS scheduler runs an ISR on each systick interrupt, and on finding no work (ready tasks), it runs the idle task until the next systick, and so on. At some point, there’s a flurry of activity.

Press enter or click to view image in full size

The first orange bar happens right after the SPI peripheral interrupts the CPU — it’s the ISR running and moving a byte of data from the SPI data register into a buffer. This happens 28 times (the byte length of the string being transferred). Between each ISR, the CPU idles. At this point, the systick interrupt preempts the idle task:

Were the systick interrupt to occur during the SPI ISR, it would not preempt execution of the ISR, because that interrupt has a higher priority than the systick interrupt (which has the lowest possible priority). The systick interrupt would be pended until the SPI ISR completed and would then run immediately.

After the last byte is received, the ISR notifies the LCD task to wake up and display the received message. First, it takes about 2 tickets (2ms) to clear the LCD and move the cursor to the home position. Then, it writes a character and moves the cursor position 28 times, with a delay between each operation.

Press enter or click to view image in full size

Because the clock speed of the LCD is slow (compared to the clock of the MCU), between each character written to the LCD, the RTOS task delays for 1 tick. It’s a non-blocking delay — the scheduler would schedule work if there was any. After the final character is written, the CPU goes back to an idle loop.

Press enter or click to view image in full size

--

--