Embedded SPI Peripheral Simulation And Testing

Full case study about simulation and testing of an SPI GPIO expander using a simulated STM32 board using Swedish Embedded Platform SDK

Embedded SPI Peripheral Simulation And Testing

What if you could develop and test your embedded firmware entirely on your PC even before your hardware has been manufactured?

demo

This case study is for you if:

  • You have products in the field that use embedded software: simulation your existing hardware gives you a very useful toolbox where you can setup use cases and verify customer requirements.
  • You are developing a new product and you don’t have the hardware yet. In this case you will be able to setup a development environment where you can interact with your product before hardware is ready.
  • You want to implement a solid CI workflow for your team: in which case simulation is absolutely necessary because traditional hardware in the loop testing requires tremendous amount of physical maintenance that you just don’t need with a simulated environment.

In this case study I’m going to show you how to simulate a board peripheral from scratch.

We are going to implement simulation for a GPIO expander chip connected to our CPU over SPI bus. The chip we are going to simulate is a very common GPIO expander MCP23S17 (link to datasheet).

The result is that we are going to be able to connect virtual buttons to the pins of the GPIO expander and use it in our firmware:

block diagram.drawio

Once our simulation is setup, we are going to be able to interact with our firmware in two ways: (1) over uart serial console which we’ll enable on the firmware side and (2) the simulated buttons which we are going to be controlled through emulation monitor console.

The final result of this case study looks like this:

What you need in order to simulate a chip from scratch

We will be using the Swedish Embedded Platform SDK to make the process easier. The tools we will be working with the most is Zephyr RTOS and Renode - which are integrated into the SDK. The SDK provides also additional resources that make development easier.

You can install the SDK and even run the example as follows:

  • Install the SDK
  • Build the sample
  • Run the sample

Installing and running the SDK

For convenience, the SDK including all toolchains and configuration can be downloaded as a docker image:

$ docker pull swedishembedded/develop:latest
$ docker run -ti swedishembedded/develop:latest
SDK /build> cd platform/sdk
SDK /build/platform/sdk> git pull origin main

Building and running the example

You can now build and execute the example as follows:

SDK /build/platform/sdk> west build -b custom_board -s \
	samples/drivers/gpio/mcp23s17
SDK /build/platform/sdk> west build -t run_sim
...
(machine-0)

This is our simulation console and here we can now interact with peripherals (ie our buttons) directly. Firmware will respond to the interrupts and update its internal state.

Let’s press a button:

(machine-0) sysbus.spi1.gpioExpander.A1 Press

Now let’s connect to the firmware uart and query the gpio state:

(machine-0) uart_connect sysbus.usart2
Redirecting the input to sysbus.usart2, press <ESC> to quit...
uart:~$ gpio get GPIO_E0 1
Reading GPIO_E0 pin 1
Value 1
uart:~$ gpio get GPIO_E0 2
Reading GPIO_E0 pin 2
Value 0

Press ESC to exit the console and type "quit" in the monitor to return to the docker shell.

Renode provides multiple ways to interact with the console. If you are running renode locally (outside of docker) you can have a graphical window with the console. Another method is to instruct renode to create a pseudoterminal (pty) device and connect it to the sysbus.usart2 peripheral. This allows you to connect to the serial port using picocom.

Renode is also highly extendable and the platform SDK provides a seamless integration of renode with kernel, CI and the whole development process. This means we can develop our own plugins for visualizing the state of our peripherals and have them integrated with the build process so that they are only loaded when we build for a particular target board.

If you would like to have a more detailed overview of the SDK and how you can work with it within your company then booking a demo is the fastest way to get exactly what you need.

Let’s jump right into it and implement simulation and driver for MCP23S17.

Simulation of a peripheral: how it’s done

In order to simulate our firmware, we are going to have to implement the simulation side which shall behave exactly like the real chip. We will then run our firmware on a virtual STM32F4 and communicate with our simulation from both sides (firmware uses SPI and GPIO interrupts while we can also control the simulation through the monitor console).

The simulation itself is running in the Renode simulator which is integrated into the SDK. Renode is an open source instruction set simulator and is similar to QEmu (even uses the same emulation engine), but is much easier to extend than QEmu due to being scripted in C# and Python. With renode we can easily write simulation models in a variety of languages and even simulate Verilog models.

For this case study, we are only going to focus on C# peripheral simulation. For this we are going to implement an SPI device model that behaves like MCP23S17.

simulation files.drawio

Implementing the C# model

The C# model is available in the SDK as follows:

SDK /build/platform/sdk> emacs -nw renode/drivers/gpio/Mcp23S17.cs

The first thing we do is create a new class for the new peripheral:

public class MCP23S17 : BaseGPIOPort, ISPIPeripheral, IGPIOReceiver, IBytePeripheral {
  20 | public MCP23S17(Machine machine)
  21 | | : base(machine, NumberOfPins)
  22 | {
  23 | | CS = new GPIO();
  24 | | A0 = new GPIO();
  25 | | A1 = new GPIO();
  26 | | A2 = new GPIO();
  27 | | RESET = new GPIO();
  28 | | INTB = new GPIO();
  29 | | INTA = new GPIO();
  30
  31 | | registers = CreateRegisters();
  32 | | Reset();
  33 | }

This will be our simulated device. It needs to extend a few interfaces:

  • BaseGPIOPort: this gives it a list of "connections" which we can then bind to other peripherals (in our case the buttons).
  • ISPIPeripheral: this is the interface that all spi peripherals need to implement. It includes prototype for "Transmit" and "FinishTransmission" which allow us to receive SPI data and CS pin function from the firmware.
  • IGPIOReceiver: this provides interface for "OnGPIO" function which allows us to receive interrupts (we need to receive interrupts from buttons and pass them out to the STM32 as INTA and INTB signals).
  • IBytePeripheral: this means that our peripheral is byte addressable. This basically communicates that we are going to address our internal registers as bytes. We could have gone with "IWordPeripheral" as well and used 16 bit integers, but bytes make things more clear and easier to map to the device datasheet.

Next we have a few public GPIO pins that we define in the simulation:

  35 | public GPIO CS { get; }
  36 | public GPIO A0 { get; }
  37 | public GPIO A1 { get; }
  38 | public GPIO A2 { get; }
  39 | public GPIO RESET { get; }
  40 | public GPIO INTB { get; }
  41 | public GPIO INTA { get; }

We are going to use the INTA and INTB labels here to bind these to GPIO pins on our STM32 simulation later.

Next we implement the SPI interface for our simulated driver:

  49 | public byte Transmit(byte data)
  50 | {
  51 | | if(CS.IsSet)
  52 | | | return 0;
  53 | | byte value = 0;
  54 | | switch(byteId){
  55 | | | case 0:
  56 | | | | isRead = ((data & 0x01) == 1)?1:0;
  57 | | | | break;
  58 | | | case 1:
  59 | | | | memOffset = data;
  60 | | | | break;
  61 | | | default:
  62 | | | | if(isRead == 1){
  63 | | | | | value = registers.Read(memOffset);
  64 | | | | | if(memOffset == (uint)Registers.INTFA) {
  65 | | | | | | registers.Write(memOffset, 0);
  66 | | | | | | INTA.Set();
  67 | | | | | } else if(memOffset == (uint)Registers.INTFB){
  68 | | | | | | registers.Write(memOffset, 0);
  69 | | | | | | INTB.Set();
  70 | | | | | }
  71 | | | | } else {
  72 | | | | | registers.Write(memOffset, data);
  73 | | | | }
  74 | | | | memOffset++;
  75 | | | | break;
  76 | | }
  77 | | this.DebugLog("SPI byte {0:X}: rx: {1:X} (tx: {2:X})", byteId, data, value);
  78 | | byteId++;
  79 | | return value;
  80 | }

This method parses the SPI messages which are sent to our simulated device.

The "FinishTransmission" method is used to reset the byte counter:

  43 | public void FinishTransmission()
  44 | {
  45 | | byteId = 0;
  46 | | memOffset = 0;
  47 | }

The "OnGPIO" method is called when state changes on any of the GPIO pins of the simulated MCP23S17:

 102 | public override void OnGPIO(int number, bool value)
 103 | {
 104 | | if(number >= 0 && number < NumberOfPins){
 105 | | | base.OnGPIO(number, value);
 106 | | | Connections[number].Set(value);
 107 | | | if(number < 8){
 108 | | | | byte INTEN = (byte)registers.Read((long)Registers.GPINTENA);
 109 | | | | if((INTEN & (byte)(1 << number)) != 0){
 110 | | | | | registers.Write((long)Registers.INTFA,
     (byte)(registers.Read((long)Registers.INTFA) | (byte)(1 << number)));
 111 | | | | | INTA.Unset();
 112 | | | | } else {
 113 | | | | | this.DebugLog("Interrupt on pin {0} is not enabled in GPINTENA", number);
 114 | | | | }
 115 | | | } else {
 116 | | | | byte INTEN = (byte)registers.Read((long)Registers.GPINTENB);
 117 | | | | if((INTEN & (byte)(1 << (number - 8))) != 0){
 118 | | | | | registers.Write((long)Registers.INTFB,
     (byte)(registers.Read((long)Registers.INTFB) | (byte)(1 << (number - 8))));
 119 | | | | | INTB.Unset();
 120 | | | | } else {
 121 | | | | | this.DebugLog("Interrupt on pin {0} is not enabled in GPINTENB", number);
 122 | | | | }
 123 | | | }
 124 | | } else if(number == (int)GPIOPin.CS){
 125 | | | this.DebugLog("CS: {0}", value);
 126 | | | CS.Set(value);
 127 | | | FinishTransmission();
 128 | | }
 129 | }

In the "CreateRegisters" function we setup our register map. We could of course simply directly write to an array, but the register dictionary allows us to define callbacks for reads and writes which makes our implementation more flexible:

 131 | private ByteRegisterCollection CreateRegisters()
 132 | {
 133 | | var registersMap = new Dictionary<long, ByteRegister>
 134 | | {
 135 | | | {(long)Registers.IODIRA, new ByteRegister(this, 0xff)
 136 | | |  .WithValueField(0, 8, out IODIRA, name: "IODIRA")
 137 | | | },
 138 | | | {(long)Registers.IODIRB, new ByteRegister(this, 0xff)
 139 | | |  .WithValueField(0, 8, out IODIRB, name: "IODIRB")
 140 | | | },
 141 | | | {(long)Registers.IPOLA, new ByteRegister(this, 0)
 142 | | |  .WithValueField(0, 8, out IPOLA, name: "IPOLA")
 143 | | | },
 144 | | | {(long)Registers.IPOLB, new ByteRegister(this, 0)
 145 | | |  .WithValueField(0, 8, out IPOLB, name: "IPOLB")
 ....
 189 | | | {(long)Registers.GPIOA, new ByteRegister(this)
 190 | | |  .WithValueField(0, 8, name: "GPIOA", writeCallback: (_, val) => WriteOutputs(0, val),
 191 | | | | | | | | | | |  valueProviderCallback: _ => ReadInputs(0))
 192 | | | },
 193 | | | {(long)Registers.GPIOB, new ByteRegister(this)
 194 | | |  .WithValueField(0, 8, name: "GPIOB", writeCallback: (_, val) => WriteOutputs(1, val),
 195 | | | | | | | | | | |  valueProviderCallback: _ => ReadInputs(1))
 196 | | | },

Finally we implement the callbacks that are referenced in the register definition:

 207 | private uint ReadInputs(byte port){
 208 | | uint values = BitHelper.GetValueFromBitsArray(State);
 209 | | this.DebugLog("ReadInputs port {0}: {1}", port, values);
 210 | | return (port == 0)?(values & 0xff):(values >> 8);
 211 | }
 212
 213 | private void WriteOutputs(byte port, uint value){
 214 | | if(port == 0){
 215 | | | OLATA.Value = (byte)value;
 216 | | | for(var i = 0; i < 8; i++){
 217 | | | | if(((value >> i) & 1) != 0){
 218 | | | | | State[i] = true;
 219 | | | | | Connections[i].Set();
 220 | | | | } else {
 221 | | | | | State[i] = false;
 222 | | | | | Connections[i].Unset();
 223 | | | | }
 224 | | | }
 225 | | } else {
 226 | | | OLATB.Value = (byte)value;
 227 | | | for(var i = 0; i < 8; i++){
 228 | | | | if(((value >> i) & 1) != 0){
 229 | | | | | State[8 + i] = true;
 230 | | | | | Connections[8 + i].Set();
 231 | | | | } else {
 232 | | | | | State[8 + i] = false;
 233 | | | | | Connections[8 + i].Unset();
 234 | | | | }
 235 | | | }
 236 | | }
 237 | }

We can test the model without firmware driver through the renode monitor. But first let’s setup a simulation…​

Setting up the simulation

Renode uses two kinds of files for the simulation:

  • resc: this is a script of monitor commands to run inside the monitor console in order to load the ELF file and start the machine.
  • repl: this is the platform description of peripherals and their wiring.

There is also a third type of file which is the RobotFramework emulation. But this basically works with the existing resc files and runs additional monitor commands to verify behavior of the firmware running inside the simulation.

We start with our repl file:

SDK /build/platform/sdk> emacs -nw \
	samples/drivers/gpio/mcp23s17/boards/custom_board.repl

We need to define a platform where buttons are connected to our simulation. For this we are going to define 16 buttons and two interrupts:

using "platforms/cpus/stm32f4.repl"

// chip select
gpioPortB:
    6 -> gpioExpander@16

// buttons
A0: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@0
A1: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@1
A2: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@2
A3: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@3
A4: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@4
A5: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@5
A6: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@6
A7: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@7
B0: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@8
B1: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@9
B2: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@10
B3: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@11
B4: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@12
B5: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@13
B6: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@14
B7: Miscellaneous.Button @ gpioExpander
    -> gpioExpander@15

// interrupts back to stm32
gpioExpander: GPIOPort.MCP23S17 @ spi1
    INTA -> gpioPortB@8
    INTB -> gpioPortB@9

If you remember the "OnGPIO" callback, then you can see above which GPIO indices are going to be sent to our simulation by renode.

We wire pins from STM32 GPIO and from simulated buttons to our GPIO expander and then we define our GPIO expander as "GPIOPort.MCP23S17" which points to our C# file. We place this under spi1 in the sysbus tree.

Then we wire the INTA and INTB pins to gpioPortB (defined in the STM32 repl file) so that we can register GPIO interrupt callbacks in our firmware and respond to the interrupts reported by the gpio expander.

This gives our system the ability to respond to individual GPIO interrupts occurring on the expander GPIO pins by reading the expander interrupt flag registers to get the interrupt status.

Next we implement the Renode script that loads our firmware (when we run "west build -t run_sim" command):

SDK /build/platform/sdk> emacs -nw \
	samples/drivers/gpio/mcp23s17/boards/custom_board.resc
:name: MCP23S17 Sample
:description: Custom MCP23S17 Board

using sysbus

i $PROJECT_BASE/renode/drivers/gpio/Mcp23S17.cs

mach create
machine LoadPlatformDescription $ORIGIN/custom_board.repl

# Setup pty terminal
#emulation CreateUartPtyTerminal "usart2" $APPLICATION_BINARY_DIR/usart2
#connector Connect sysbus.usart2 usart2

cpu PerformanceInMips 168

logLevel 3
sysbus LogPeripheralAccess sysbus.spi1.gpioExpander

showAnalyzer sysbus.usart2

macro reset
"""
    sysbus LoadELF $bin
"""

runMacro $reset

This script uses several predefined variables that are automatically passed to it by the SDK. These are named the same way as they are defined in the CMake context. So we can just use them directly in our script. The "bin" variable will point to our firmware elf bin file.

The RESC file can be written such that it uses absolute path to the ELF file or even uses an HTTP location. Thus the RESC file can be made completely standalone and you can test production firmware with the SDK without actually invoking the build step.

However, in this case we are integrating the simulation with the build process for the ability to directly run our firmware more easily. Thus we rely on the build process to pass the firmware bin file path to us and we run the most recently built elf file in this case.

Implementation of the firmware driver

The next thing we need to do is implement our firmware driver.

SDK /build/platform/sdk> emacs -nw \
	drivers/gpio/mcp23s17.c

This is the main SPI driver that we are going to expose to our application. We are going to expose it as a generic GPIO device so that our application does not need to worry about the fact that this is an SPI gpio device at all. Instead, our application will treat it exactly the same way as built in STM32 gpio device and we will be able to register interrupt callbacks for each of the expansion gpio pins in exactly the same way as we do it for the on chip GPIO pins.

The first thing directly at the top of the source file is the device tree compatible id:

   1 #define DT_DRV_COMPAT microchip_mcp23s17v2

This will be used to map our C driver to a device tree definition which contains all of the settings for the SPI device.

Next we have the main data structure that will be instantiated for every instance of MCP23S17 on our PCB:

  39 /** Driver private data */
  40 struct mcp23s17 {
  41 | /** Driver data needs to be first */
  42 | struct gpio_driver_data data;
  43 | /** Device pointer (used for gpio callbacks) */
  44 | const struct device *dev;
  45 | /** Device lock */
  46 | struct k_mutex lock;
  47 | /** Callback list */
  48 | sys_slist_t cb;
  49 | /** Interrupt A callback */
  50 | struct gpio_callback inta_cb;
  51 | /** Interrupt B callback */
  52 | struct gpio_callback intb_cb;
  53 | /** Work for handling IRQs */
  54 | struct k_work irq_work;
  55 | /** Register cache */
  56 | struct {
          ....
 101 | } __packed regs;
 102 };

The driver needs to use a mutex because we are very likely to share the same GPIO chip across multiple threads. Using a mutex guarantees that we can queue threads waiting for the device to become available and that we can do it with priority inheritance (semaphores don’t support priority inheritance).

We also keep a cache of the device registers locally because we want to avoid having to read registers before writing them. Instead, we assume that our "true" state of the chip is always managed by the driver and that we simply upload individual registers to the chip when their values change.

Next we define the configuration structure for the driver:

 104 /** Driver configuration */
 105 struct mcp23s17_config {
 106 | /** Device address */
 107 | uint8_t addr;
 108 | /** SPI bus */
 109 | struct spi_dt_spec spi;
 110 | /** Interrupts */
 111 | struct gpio_dt_spec inta_gpio, intb_gpio;
 112 };

This structure is populated directly from the device tree definition in the same C file and contains configuration for the GPIO pins that we would like to use as interrupt inputs.

Note that these are generic gpio pins, so theoretically these pins themselves could potentially reside on another instance of the MCP23S17 chip itself. We don’t care how they are implemented. This is extremely flexible approach of defining portable embedded drivers.

Next we implement two functions for reading and writing values to the chip:

 125 static int _mcp23s17_read_regs(const struct device *dev, uint8_t reg, uint8_t *buf, size_t size)
 126 {
 127 | if (!dev || !buf) {
 128 | | return -EINVAL;
 129 | }
 130
 131 | const struct mcp23s17_config *config = dev->config;
 132 | int ret;
 133 | uint8_t addr = config->addr | 1;
 134 | uint8_t buffer_tx[] = { 0x40 | addr, reg };
 135
 136 | const struct spi_buf tx_buf[] = {
 137 | | {
 138 | | | .buf = buffer_tx,
 139 | | | .len = sizeof(buffer_tx),
 140 | | },
 141 | };
 142 | const struct spi_buf_set tx = {
 143 | | .buffers = tx_buf,
 144 | | .count = ARRAY_SIZE(tx_buf),
 145 | };
 146 | const struct spi_buf rx_buf[2] = {
 147 | | {
 148 | | | .buf = NULL,
 149 | | | .len = sizeof(buffer_tx),
 150 | | },
 151 | | {
 152 | | | .buf = (uint8_t *)buf,
 153 | | | .len = size,
 154 | | },
 155 | };
 156 | const struct spi_buf_set rx = {
 157 | | .buffers = rx_buf,
 158 | | .count = ARRAY_SIZE(rx_buf),
 159 | };
 160
 161 | ret = spi_transceive_dt(&config->spi, &tx, &rx);
 162 | if (ret) {
 163 | | LOG_DBG("spi_transceive FAIL %d\n", ret);
 164 | | return -EIO;
 165 | }
 166
 167 | return 0;
 168 }
 182 static int _mcp23s17_write_regs(const struct device *dev, uint8_t reg, uint8_t *buf, size_t size)
 183 {
 184 | const struct mcp23s17_config *config = dev->config;
 185
 186 | uint8_t buffer_tx[2] = { 0x40 | config->addr, reg };
 187
 188 | const struct spi_buf tx_buf[2] = {
 189 | | {
 190 | | | .buf = buffer_tx,
 191 | | | .len = 2,
 192 | | },
 193 | | {
 194 | | | .buf = buf,
 195 | | | .len = (uint8_t)size,
 196 | | },
 197 | };
 198
 199 | const struct spi_buf_set tx = {
 200 | | .buffers = tx_buf,
 201 | | .count = ARRAY_SIZE(tx_buf),
 202 | };
 203
 204 | int ret = spi_write_dt(&config->spi, &tx);
 205
 206 | if (ret != 0) {
 207 | | LOG_ERR("spi_write FAIL: %d\n", ret);
 208 | | return -EIO;
 209 | }
 210
 211 | return 0;
 212 }

The rest of the C driver is concerned with implementing the generic GPIO device interface which will allow us to use this driver in a portable fashion (both within other drivers and within our application):

 535 static const struct gpio_driver_api _mcp23s17_api = {
 536 | .pin_configure = _mcp23s17_pin_configure,
 537 | .port_get_raw = _mcp23s17_port_get_raw,
 538 | .port_set_masked_raw = _mcp23s17_port_set_masked_raw,
 539 | .port_set_bits_raw = _mcp23s17_port_set_bits_raw,
 540 | .port_clear_bits_raw = _mcp23s17_port_clear_bits_raw,
 541 | .port_toggle_bits = _mcp23s17_port_toggle_bits,
 542 | .pin_interrupt_configure = _mcp23s17_pin_interrupt_configure,
 543 | .manage_callback = _mcp23s17_manage_callback,
 544 | .get_pending_int = _mcp23s17_get_pending_int,
 545 };

We implement our interrupts by registering two callbacks which are called when a state change occurs on the correponding interrupt GPIO pins:

 570 static void _mcp23s17_irqa_callback(const struct device *dev, struct gpio_callback *gpio_cb,
 571 | | | |     uint32_t pins)
 572 {
 573 | struct mcp23s17 *self = CONTAINER_OF(gpio_cb, struct mcp23s17, inta_cb);
 574
 575 | ARG_UNUSED(pins);
 576 | k_work_submit(&self->irq_work);
 577 }
 578
 579 static void _mcp23s17_irqb_callback(const struct device *dev, struct gpio_callback *gpio_cb,
 580 | | | |     uint32_t pins)
 581 {
 582 | struct mcp23s17 *self = CONTAINER_OF(gpio_cb, struct mcp23s17, intb_cb);
 583
 584 | ARG_UNUSED(pins);
 585 | k_work_submit(&self->irq_work);
 586 }

Both of these GPIO callbacks defer the action of reading the MCP23S17 interrupt registers to the system work queue. This is important, because for the on chip GPIO pins, the interrupt callback will be called in interrupt context where we can not execute SPI operations. One optimization that we could do in this case is call "k_is_in_isr()" kernel function and either do the work directly or defer it if we are in the ISR. But in this case we defer it regardless.

The work item in turn calls the actual work handler in the context of the system work queue thread that has priority -1 (higher than all user threads):

 547 static void _mcp23s17_irq_work(struct k_work *work)
 548 {
 549 | struct mcp23s17 *self = CONTAINER_OF(work, struct mcp23s17, irq_work);
 550 | uint8_t buf[2];
 551
 552 | k_mutex_lock(&self->lock, K_FOREVER);
 553
 554 | if (_mcp23s17_read_regs(self->dev, REG_INTFA, buf, sizeof(buf)) != 0) {
 555 | | LOG_ERR("Failed to read interrupt flag registers");
 556 | | goto done;
 557 | }
 558
 559 | uint32_t intf = ((uint32_t)buf[1] << 8) | buf[0];
 560
 561 | self->regs.INTFA = buf[0];
 562 | self->regs.INTFB = buf[1];
 563
 564 | gpio_fire_callbacks(&self->cb, self->dev, intf);
 565
 566 done:
 567 | k_mutex_unlock(&self->lock);
 568 }

Inside this function we read the interrupt status register of the MCP23S17 and then fire the GPIO callbacks that user has registered with our driver. In this way, we can call external gpio callbacks that have been registered for the pins provided by our GPIO expander.

If you are very very attentive to the above code, you will see that it has a a little problem. We are trying to acquire a mutex inside a function that is executed in the system work queue context. While this will work just fine in most cases, it also results in other work queue items potentially being delayed. The work queue is a scarce resource where we must do things as fast as possible. Since acquiring a lock can take time if it is busy, it would actually be better to acquire it with zero timeout and if the acquisition fails, to reschedule the work item again. In this way, the work queue can always be responsive to new work being pushed to it.

Finally, we instantiate our driver for all instances compatible with the MCP23S17 in our device tree:

 655 #define MCP23S17_INIT_PRIORITY 75
 656 #define MCP23S17_INIT(n)                                                                           \
 657 | static const struct mcp23s17_config _mcp23s17_##n##_config = {                             \
 658 | | .spi = SPI_DT_SPEC_INST_GET(                                                       \
 659 | | | n, SPI_OP_MODE_MASTER | SPI_MODE_CPOL | SPI_MODE_CPHA | SPI_WORD_SET(8),   \
 660 | | | 0),                                                                        \
 661 | | .inta_gpio = GPIO_DT_SPEC_INST_GET_OR(n, inta_gpios, { 0 }),                       \
 662 | | .intb_gpio = GPIO_DT_SPEC_INST_GET_OR(n, intb_gpios, { 0 }),                       \
 663 | };                                                                                         \
 664 | static struct mcp23s17 _mcp23s17_##n##_data = { 0 };                                       \
 665 | DEVICE_DT_INST_DEFINE(n, _mcp23s17_init, NULL, &_mcp23s17_##n##_data,                      \
 666 | | |       &_mcp23s17_##n##_config, POST_KERNEL, MCP23S17_INIT_PRIORITY,        \
 667 | | |       &_mcp23s17_api)
 668
 669 DT_INST_FOREACH_STATUS_OKAY(MCP23S17_INIT);

This concludes our overview of the driver implementation.

Next, let’s look at how we define this driver inside the device tree.

Device tree definition

In order to use our driver in the devicetree and be able to query variables from the device tree definition as we did above, we need to define device tree bindings for our driver:

SDK /build/platform/sdk > emacs -nw dts/bindings/gpio/microchip,mcp23s17.yaml

Inside this file we define the 'schema' for the device tree definition:

   4 description: |
   5     This is a representation of the Microchip MCP23S17 SPI Gpio Expander.
   6
   7 compatible: "microchip,mcp23s17v2"
   8
   9 include: [gpio-controller.yaml, spi-device.yaml]
  10
  11 properties:
  12     label:
  13       required: true
  14     "#gpio-cells":
  15       const: 2
  16
  17     ngpios:
  18       type: int
  19       required: true
  20       const: 16
  21       description: Number of gpios supported
  22     inta-gpios:
  23       type: phandle-array
  24       required: false
  25       description: Interrupt for PORTA
  26     intb-gpios:
  27       type: phandle-array
  28       required: false
  29       description: Interrupt for PORTB
  30
  31 gpio-cells:
  32   - pin
  33   - flags

Once this has been done, we can now instantiate our device in any board definition. This provides an incredibly flexible way to support any number of boards. You can define device tree definitions both at application level (using overlays) and at board level by placing them directly into your board dts file.

Let’s place our MCP23S17 on the SPI bus as an overlay for our sample:

SDK /build/platform/sdk > emacs -nw \
	samples/drivers/gpio/mcp23s17/boards/custom_board.overlay

This overlay will only be used when we build our application for "custom_board" target.

&spi1 {
| pinctrl-0 = <&spi1_sck_pa5 &spi1_miso_pa6 &spi1_mosi_pa7>;
| pinctrl-names = "default";
| cs-gpios = <
| | &gpiob 6 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)
| >;
| status = "okay";

| mcp23s17: mcp23s17@0 {
| | compatible = "microchip,mcp23s17v2";
| | label = "GPIO_E0";
| | spi-max-frequency = <1000000>;
| | reg = <0>;
| | gpio-controller;
| | #gpio-cells = <2>;
| | ngpios = <16>;
| | inta-gpios = <
| | | &gpiob 8 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)
| | >;
| | intb-gpios = <
| | | &gpiob 9 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)
| | >;
| | status = "okay";
| };
};

This is where we define for the firmware the fact that our MCP23S17 is connected to SPI1 bus of STM32 and also has two interrupts wired to PB8 and PB9 on the STM32.

Our driver can then query this data and use it to register GPIO interrupt callbacks.

Automated testing

Once we have both simulation and firmware ready, we can now script all of the emulator commands using robot framework. So let’s do just that:

SDK /build/platform/sdk > emacs -nw samples/drivers/gpio/mcp23s17/sample.robot

This script contains robot framework simulation which adds an extra automation layer on top of our platform description and renode script. This allows us to test actual usage scenarios and automate the whole process so that it runs automatically in CI:

*** Settings ***
Resource                      ${RENODEKEYWORDS}
Suite Setup                   Setup
Suite Teardown                Teardown
Test Setup                    Reset Emulation
Test Teardown                 Test Teardown

*** Variables ***
${PROJECT_BASE}               %{PROJECT_BASE}
${APPLICATION_BINARY_DIR}     %{APPLICATION_BINARY_DIR}
${APPLICATION_SOURCE_DIR}     %{APPLICATION_SOURCE_DIR}
${BOARD}                      %{BOARD}
${UART}                       sysbus.usart2

*** Test Cases ***

GPA pins are connected to gpio register
| Set Test Variable  ${GPIO}  GPIO_E0
| Boot
| FOR  ${PIN}  IN RANGE  0  7
| | Write Line To Uart  gpio get ${GPIO} ${PIN}
| | Wait For Line On Uart  Value 0  timeout=2

| | Execute Command  sysbus.spi1.gpioExpander.A${PIN} Press
| | Write Line To Uart  gpio get ${GPIO} ${PIN}
| | Wait For Line On Uart  Value 1  timeout=2

| | Execute Command  sysbus.spi1.gpioExpander.A${PIN} Release
| | Write Line To Uart  gpio get ${GPIO} ${PIN}
| | Wait For Line On Uart  Value 0  timeout=2
| END
| Check gpio io works 0x12 0x14 A0 0x01
| Check gpio io works 0x12 0x14 A1 0x02
| Check gpio io works 0x12 0x14 A2 0x04
| Check gpio io works 0x12 0x14 A3 0x08
| Check gpio io works 0x12 0x14 A4 0x10
| Check gpio io works 0x12 0x14 A5 0x20
| Check gpio io works 0x12 0x14 A6 0x40
| Check gpio io works 0x12 0x14 A7 0x80

GPB pins are connected to gpio register
| Set Test Variable  ${GPIO}  GPIO_E0
| Boot
| FOR  ${PIN}  IN RANGE  8  15
| | ${idx}=  Evaluate  ${PIN} - 8
| | Write Line To Uart  gpio get ${GPIO} ${PIN}
| | Wait For Line On Uart  Value 0  timeout=2

| | Execute Command  sysbus.spi1.gpioExpander.B${idx} Press
| | Write Line To Uart  gpio get ${GPIO} ${PIN}
| | Wait For Line On Uart  Value 1  timeout=2

| | Execute Command  sysbus.spi1.gpioExpander.B${idx} Release
| | Write Line To Uart  gpio get ${GPIO} ${PIN}
| | Wait For Line On Uart  Value 0  timeout=2
| END
| Check gpio io works 0x13 0x15 B0 0x01
| Check gpio io works 0x13 0x15 B1 0x02
| Check gpio io works 0x13 0x15 B2 0x04
| Check gpio io works 0x13 0x15 B3 0x08
| Check gpio io works 0x13 0x15 B4 0x10
| Check gpio io works 0x13 0x15 B5 0x20
| Check gpio io works 0x13 0x15 B6 0x40
| Check gpio io works 0x13 0x15 B7 0x80

*** Keywords ***

Boot
| Execute Command           set bin @${APPLICATION_BINARY_DIR}/zephyr/zephyr.elf
| Execute Command           set APPLICATION_BINARY_DIR
@${APPLICATION_BINARY_DIR}
| Execute Command           include
@${APPLICATION_SOURCE_DIR}/boards/${BOARD}.resc
| Create Terminal Tester    ${UART}
| Start Emulation

Check gpio io works ${gpio} ${olat} ${name} ${exp}
| Check gpio input works ${gpio} ${olat} ${name} ${exp}
| Check gpio output works ${gpio} ${olat} ${name} ${exp}

Check gpio input works ${gpio} ${olat} ${name} ${exp}
| ${value}=  Execute Command  sysbus.spi1.gpioExpander ReadByte ${gpio}
| Should Contain  ${value}  0x00

| Execute Command  sysbus.spi1.gpioExpander.${name} Press
| Sleep  50ms
| ${value}=  Execute Command  sysbus.spi1.gpioExpander ReadByte ${gpio}
| Should Contain  ${value}  ${exp}
| # OLAT should be zero here
| ${value}=  Execute Command  sysbus.spi1.gpioExpander ReadByte ${olat}
| Should Contain  ${value}  0x00

| Execute Command  sysbus.spi1.gpioExpander.${name} Release
| Sleep  50ms
| ${value}=  Execute Command  sysbus.spi1.gpioExpander ReadByte ${gpio}
| Should Contain  ${value}  0x00

Check gpio output works ${gpio} ${olat} ${name} ${exp}
| ${value}=  Execute Command  sysbus.spi1.gpioExpander ReadByte ${gpio}
| Should Contain  ${value}  0x00

| Execute Command  sysbus.spi1.gpioExpander WriteByte ${gpio} ${exp}
| ${value}=  Execute Command  sysbus.spi1.gpioExpander ReadByte ${gpio}
| Should Contain  ${value}  ${exp}
| # OLAT should contain output value as well
| ${value}=  Execute Command  sysbus.spi1.gpioExpander ReadByte ${olat}
| Should Contain  ${value}  ${exp}

| Execute Command  sysbus.spi1.gpioExpander WriteByte ${gpio} 0x00
| Execute Command  sysbus.spi1.gpioExpander WriteByte ${olat} 0x00

Having done this, we can now run the automation using support provided by the Swedish Embedded Platform SDK:

SDK /build/platform/sdk > west build -t run_robot
Preparing suites
Started Renode instance on port 9999; pid 1892
Starting suites
Running /build/platform/sdk/samples/drivers/gpio/mcp23s17/sample.robot
13:24:28.5074 [INFO] Loaded monitor commands from: /opt/renode/scripts/monitor.py
13:24:28.5258 [INFO] Robot Framework remote server is listening on port 9999
+++++ Starting test 'sample.GPA pins are connected to gpio register'
13:24:29.2085 [INFO] Including script: /build/platform/sdk/samples/drivers/gpio/mcp23s17/boards/custom_board.resc
13:24:30.5936 [INFO] System bus created.
13:24:32.6584 [INFO] sysbus: Loaded SVD: /tmp/renode-1892/85cf5df8-43c9-41a0-a674-585432a9a1d0.tmp. Name: STM32F40x. Description: STM32F40x.
13:24:35.3736 [INFO] machine-0: Machine paused.
13:24:35.4073 [INFO] machine-0: Disposed.
+++++ Finished test 'sample.GPA pins are connected to gpio register' in 6.27 seconds with status OK
+++++ Starting test 'sample.GPB pins are connected to gpio register'
13:24:35.4547 [INFO] Including script: /build/platform/sdk/samples/drivers/gpio/mcp23s17/boards/custom_board.resc
13:24:35.4585 [INFO] System bus created.
13:24:35.7963 [INFO] sysbus: Loaded SVD: /tmp/renode-1892/7f453e48-8011-4c69-a31c-2a7a78aa4124.tmp. Name: STM32F40x. Description: STM32F40x.
13:24:37.8962 [INFO] machine-0: Machine paused.
13:24:37.9013 [INFO] machine-0: Disposed.
+++++ Finished test 'sample.GPB pins are connected to gpio register' in 2.47 seconds with status OK
Cleaning up suites
Closing Renode pid 1892
Aggregating all robot results
Output:  /build/platform/sdk/build/robot_output.xml
Log:     /build/platform/sdk/build/log.html
Report:  /build/platform/sdk/build/report.html
Tests finished successfully :)

The resulting test report is available under build/log.html.

This concludes our case study and I hope you learned something.

Why this case study is important

It is in this way that a complete peripheral can be simulated and exposed to our firmware application in a highly portable and flexible way.

We do not need to stop at simulating only digital peripherals. Analog peripherals can be easily simulated as well. Since firmware always acts on digital samples of the real signal, we can easily convert any analog signal into corresponding ADC samples and feed them into our firmware as well.

The SDK provides you with a fully integrated build environment for developing and simulating your embedded applications.

Book demo to find out how we can help

If you like what you have seen so far, then the best possible next step is for us to explore how we can apply this to your specific project and use case.

The fastest way for you to get there is to book a demo call where we do just that. We have several consulting offers where you can get the help you need at a price level that is reasonable as well.

Click here to schedule your demo call and find out more!