Creation of hardware-independent libraries for microcontrollers

Table of contents

  1. Introduction

  2. Description of the problem

  3. Basic Driver Implementation

  4. Implementation of a library for an LED matrix module

  5. Examples of use

  6. Demonstration of results

  7. Conclusion

1. Introduction

In this article I would like to tell you how you can create your own hardware-independent libraries for microcontrollers to work with digital chips.

The essence of creating a hardware-independent library is to decouple from the level of abstraction (libraries and frameworks) provided by the microcontroller manufacturer, inside the implemented library. For example, for STM32 – HAL, ESP32 – ESP-IDF or Arduino, for AVR they often use Arduino. This will allow you to use the same library on different microcontrollers (and not only) without changing the library code for each stone.

Most microcircuits operate via digital interfaces (UART, SPI, I2C, etc.). Using these interfaces, we interact with the registers of the microcircuit and obtain a certain result. To do this, it will be enough to describe several functions for working with the interface and pass pointers to these functions to our library. This means that in the implementation of the library itself it is possible to describe only the logic of operation and at the output provide an interface for working with almost any microcontroller.

I will explain how this can be implemented using the example of the MAX7219 microcircuit, which is quite simple in functionality, and the LED matrix module based on it. I think many are familiar with this chip and have seen LED matrix and seven-segment indicator modules based on it. During the implementation, I will not go into detail about how the microcircuit works; you either already know all this or can find it in the documentation.

Rice. 1. - LED matrix module based on the MAX7219 chip

Rice. 1. – LED matrix module based on the MAX7219 chip

2. Description of the problem

Note: All code in this section is for STM32 and was found in the GitHub repositories.

When I look for ready-made libraries on the Internet, basically everything I find looks like this:

In the .h files of the library, HAL handlers, pins and ports are defined and externalized.

#define NUMBER_OF_DIGITS      8
#define SPI_PORT              hspi1
extern SPI_HandleTypeDef      SPI_PORT;
#define MAX7219_CS_Pin        GPIO_PIN_6
#define MAX7219_CS_GPIO_Port  GPIOA
…
void max7219_Init();
void max7219_SetIntensivity(uint8_t intensivity);
…
void max7219_SendData(uint8_t addr, uint8_t data);
…

The .c files include HAL headers for a specific stone and, accordingly, the entire library runs on HAL.

#include "stm32f1xx_hal.h"
#include <max7219.h>
…
void max7219_Init() {
    max7219_TurnOff();
    max7219_SendData(REG_SCAN_LIMIT, NUMBER_OF_DIGITS - 1);
    max7219_SendData(REG_DECODE_MODE, 0x00);
    max7219_Clean();
}

void max7219_SetIntensivity(uint8_t intensivity) {
    if (intensivity > 0x0F)
        return;

    max7219_SendData(REG_INTENSITY, intensivity);
}
…
void max7219_SendData(uint8_t addr, uint8_t data) {
    HAL_GPIO_WritePin(MAX7219_CS_GPIO_Port, MAX7219_CS_Pin, GPIO_PIN_RESET);
    HAL_SPI_Transmit(&hspi1, &addr, 1, HAL_MAX_DELAY);
    HAL_SPI_Transmit(&hspi1, &data, 1, HAL_MAX_DELAY);
    HAL_GPIO_WritePin(MAX7219_CS_GPIO_Port, MAX7219_CS_Pin, GPIO_PIN_SET);
}

Disadvantages of this approach:

  1. The library can only work with one connected module.

  2. Dependency on HAL.

  3. When connecting a library to your project, you need to go into the library files and configure it for yourself.

  4. Transferring the library to microcontrollers from other manufacturers will be problematic.

What I suggest you do:

First, describe the structure with which we can create multiple instances of plug-ins. This approach will solve the first problem.

Secondly, in order not to use HAL inside the library, we can create pointers to functions to work with the interface and ports we need. You will need to describe the implementation of the function yourself in the main program and pass a pointer to it to the library. This will solve the remaining three problems.

All this applies not only to STM32 and HAL, but also to all other implementations of libraries for a specific microcontroller, created according to this principle.

3. Implementation of the basic driver

First, let's create a small driver for working with chip registers. The developer will not have to use this driver in the main program. This driver will describe the lowest level of abstraction, and then a library will be written based on this driver, which the developer can use in the project.

The first thing to start with is to create a pair of files max7219.h and max7219.c.

In the file max7219.h we define the registers of the microcircuit:

#define REG_NOOP         0x00
#define REG_DIGIT_0      0x01
#define REG_DIGIT_1      0x02
#define REG_DIGIT_2      0x03
#define REG_DIGIT_3      0x04
#define REG_DIGIT_4      0x05
#define REG_DIGIT_5      0x06
#define REG_DIGIT_6      0x07
#define REG_DIGIT_7      0x08
#define REG_DECODE_MODE  0x09
#define REG_INTENSITY    0x0A
#define REG_SCAN_LIMIT   0x0B
#define REG_SHUTDOWN     0x0C
#define REG_DISPLAY_TEST 0x0F

Next, let's create pointers to functions for working with SPI:

typedef void (*SPI_Transmit)(uint8_t* data, size_t size);
typedef void (*SPI_ChipSelect)(uint8_t level);

In the main program you will need to describe these functions yourself. The SPI_Transmit function will have to transmit an array of data bytes of size size over the SPI we have chosen. SPI_ChipSelect will have to switch the state of the CS pin in accordance with the passed level parameter.

Next, we will define the structure that will describe our microcircuit.

typedef struct{
	SPI_Transmit spiTransmit;
	SPI_ChipSelect spiChipSelect;
}MAX7219_st;

In this case, there will be enough fields that accept pointers to functions for working with SPI. The data buffer on the matrix will be described at the next level of abstraction.

Finally, let's define the main functions:

void MAX7219_Init(MAX7219_st* max7219, SPI_Transmit spiTransmit,
                  SPI_ChipSelect spiChipSelect);
void MAX7219_WriteReg(MAX7219_st* max7219, MAX7219_Register_t reg, uint8_t data);

To the initialization function MAX7219_Init we will pass a pointer to the MAX7219_st structure and pointers to functions for working with SPI, which we will describe in the main program.

Let's go to the file max7219.c.

#include "max7219.h"

void MAX7219_Init(MAX7219_st* max7219, SPI_Transmit spiTransmit, 
                  SPI_ChipSelect spiChipSelect){
	max7219->spiTransmit = spiTransmit;
	max7219->spiChipSelect = spiChipSelect;
}

void MAX7219_WriteReg(MAX7219_st* max7219, MAX7219_Register_t reg, uint8_t data){
	if(max7219->spiChipSelect != NULL){
		max7219->spiChipSelect(0);
	}
	max7219->spiTransmit(&reg, 1);
	max7219->spiTransmit(&data, 1);
	if(max7219->spiChipSelect != NULL){
		max7219->spiChipSelect(1);
	}
}

In MAX7219_Init we simply assign function pointers to the fields of the passed structure. In MAX7219_WriteReg we call functions for sending data via SPI. There is one caveat with SPI_ChipSelect. The fact is that in some microcontrollers you can configure automatic switching of the CS pin; in this case, there is no need to programmatically switch this pin. If this is how you configure your SPI, you can simply pass NULL to the spiChipSelect parameter during initialization.

This completes the implementation of the basic driver. In the next section, we will implement higher-level logic for working with the LED matrix using this driver.

4. Implementation of the library for the LED matrix module

At this stage, we will create a library that will provide a convenient interface for the developer to work with the LED matrix.

Let's create a pair of files MatrixLed.h and MatrixLed.c.

In MatrixLed.h we will connect the previously created driver max7219 and describe the structure of the matrix module.

#include "max7219.h"
#define MATRIX_SIZE 8

typedef struct{
	MAX7219_st max7219;
	uint8_t displayBuffer[MATRIX_SIZE];
}MatrixLed_st;

The MatrixLed_st structure contains an instance of the MAX7219_st driver and an image buffer on the matrix.

Next we will declare the following functions:

void MatrixLed_Init(MatrixLed_st* matrixLed, SPI_Transmit spiTransmit, 
                    SPI_ChipSelect spiChipSelect);
void MatrixLed_SetPixel(MatrixLed_st* matrixLed, uint8_t x, uint8_t y, 
                        uint8_t state);
void MatrixLed_DrawDisplay(MatrixLed_st* matrixLed);

In MatrixLed_Init we pass a pointer to the MatrixLed_st structure and pointers to functions for working with SPI.

Using MatrixLed_SetPixel we will set the state of the pixel by coordinates. This function does not switch the state of the LEDs immediately, there will be a separate function for this.

MatrixLed_DrawDisplay is needed to update the state of the LEDs.

Let's move on to MatrixLed.c.

void MatrixLed_Init(MatrixLed_st* matrixLed, SPI_Transmit spiTransmit, 
                    SPI_ChipSelect spiChipSelect){
	matrixLed->max7219.spiTransmit = spiTransmit;
	matrixLed->max7219.spiChipSelect = spiChipSelect;

	MAX7219_WriteReg(&matrixLed->max7219, REG_NOOP, 0x00);
	MAX7219_WriteReg(&matrixLed->max7219, REG_SHUTDOWN, 0x01);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DISPLAY_TEST, 0x00);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DECODE_MODE, 0x00);
	MAX7219_WriteReg(&matrixLed->max7219, REG_INTENSITY, 0x00);
	MAX7219_WriteReg(&matrixLed->max7219, REG_SCAN_LIMIT, 0x07);

	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_0, 0x00);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_1, 0x00);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_2, 0x00);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_3, 0x00);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_4, 0x00);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_5, 0x00);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_6, 0x00);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_7, 0x00);
}

In the initialization function, we accept the function indicators for working with SPI, configure the module and turn off all the LEDs on the matrix.

void MatrixLed_SetPixel(MatrixLed_st* matrixLed, uint8_t x, uint8_t y, 
                        uint8_t state){
	if(state){
		matrixLed->displayBuffer[y] |= (0x80 >> x);
	}
	else{
		matrixLed->displayBuffer[y] &= ~(0x80 >> x);
	}
}

MatrixLed_SetPixel sets the required bits in the matrix image buffer according to the transmitted coordinates.

void MatrixLed_DrawDisplay(MatrixLed_st* matrixLed){
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_0, 
                        matrixLed->displayBuffer[0]);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_1, 
                        matrixLed->displayBuffer[1]);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_2, 
                        matrixLed->displayBuffer[2]);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_3,
                        matrixLed->displayBuffer[3]);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_4,
                        matrixLed->displayBuffer[4]);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_5,
                        matrixLed->displayBuffer[5]);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_6,
                        matrixLed->displayBuffer[6]);
	MAX7219_WriteReg(&matrixLed->max7219, REG_DIGIT_7,
                        matrixLed->displayBuffer[7]);
}

MatrixLed_DrawDisplay writes data from the buffer to the chip registers.

5. Examples of use

For example, we will implement the same algorithm on different microcontrollers.

Task: cyclically turn on the LEDs diagonally, starting from the bottom left corner to the top right corner with a period of 1 second.

In all examples the code will be almost the same. The main differences will only be in the implementation of functions for working with SPI for a specific microcontroller. A demonstration of the results is shown in paragraph 6. Demonstration of results.

5.1. Example of use on an STM32 microcontroller

For example, we will use a development board based on STM32F401. Let's create a new project in CubeIDE and configure SPI.

Rice. 2 - SPI configuration in CubeIDE

Rice. 2 – SPI configuration in CubeIDE

Pinout:

MAX7219

STM32

VCC

3V3

GND

GND

DIN

PA7

C.S.

PA4

CLK

PA5

Next in main.c we will describe the following code fragment:

#include "main.h"
#include "MatrixLed.h"

SPI_HandleTypeDef hspi1;

MatrixLed_st matrixLed;

void MatrixLed_SPI_ChipSelect (uint8_t level){
	HAL_GPIO_WritePin(SPI1_CS1_GPIO_Port, SPI1_CS1_Pin, level);
}
void MatrixLed_SPI_Transmit (uint8_t* data, size_t size){
	HAL_SPI_Transmit(&hspi1, data, size, 10);
}

int main(void)
{
    MatrixLed_Init(&matrixLed, MatrixLed_SPI_Transmit, MatrixLed_SPI_ChipSelect);
    
        while (1)
        {
            uint8_t x = 0;
            uint8_t y = 0;
            while(x < MATRIX_SIZE && y < MATRIX_SIZE){
            MatrixLed_SetPixel(&matrixLed, x, y, 1);
            MatrixLed_DrawDisplay(&matrixLed);
            HAL_Delay(1000);
            MatrixLed_SetPixel(&matrixLed, x, y, 0);
            MatrixLed_DrawDisplay(&matrixLed);
            x++;
            y++;
        }
    }
}

MatrixLed_SPI_ChipSelect sets the desired level on the CS pin according to the passed parameter. MatrixLed_SPI_Transmit sends the transferred buffer via SPI. Pointers to these functions are passed to MatrixLed_Init. In the cycle, the LEDs are lit according to the task posed in the example.

5.2 Example of use on an ESP32 microcontroller

For example, we will use a development board based on ESP32C3. Let's create a new project in ESP-IDE and configure SPI.

Pinout:

MAX7219

ESP32

VCC

3V3

GND

GND

DIN

GPIO4

C.S.

GPIO3

CLK

GPIO2

main.c code:

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/spi_master.h"
#include "driver/gpio.h"
#include "MatrixLed.h"

#define MOSI_PIN    GPIO_NUM_4
#define CS_PIN      GPIO_NUM_3
#define CLK_PIN     GPIO_NUM_2

spi_device_handle_t spi2;

MatrixLed_st matrixLed;

void MatrixLed_SPI_Transmit(uint8_t* data, size_t size){
    spi_transaction_t transaction = {
        .tx_buffer = data,
        .length = size * 8
    };

    spi_device_polling_transmit(spi2, &transaction);	
}

static void SPI_Init() {
    spi_bus_config_t buscfg={
        .miso_io_num = -1,
        .mosi_io_num = MOSI_PIN,
        .sclk_io_num = CLK_PIN,
        .quadwp_io_num = -1,
        .quadhd_io_num = -1,
        .max_transfer_sz = 8,
    };

    spi_device_interface_config_t devcfg={
        .clock_speed_hz = 1000000,
        .mode = 0,
        .spics_io_num = CS_PIN,
        .queue_size = 1,
        .flags = SPI_DEVICE_HALFDUPLEX,
        .pre_cb = NULL,
        .post_cb = NULL,
    };

    spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO);
    spi_bus_add_device(SPI2_HOST, &devcfg, &spi2);
};

void app_main(void)
{
    SPI_Init();
    MatrixLed_Init(&matrixLed, MatrixLed_SPI_Transmit, NULL);
    
    while (1) {
        uint8_t x = 0;
        uint8_t y = 0;
        while(x < MATRIX_SIZE && y < MATRIX_SIZE){
            MatrixLed_SetPixel(&matrixLed, x, y, 1);
            MatrixLed_DrawDisplay(&matrixLed);
            vTaskDelay(1000/portTICK_PERIOD_MS);
            MatrixLed_SetPixel(&matrixLed, x, y, 0);
            MatrixLed_DrawDisplay(&matrixLed);
            x++;
            y++;
        }    
    }
}

The implementation of the MatrixLed_SPI_Transmit function is similar to the example with STM32, but in this case you do not need to implement the MatrixLed_SPI_ChipSelect function because The SPI is configured to automatically drive the CS pin. The task implementation code has not changed, with the exception of the delay function.

5.3 Example of use on an AVR microcontroller

For example, we will use a development board based on Atmega328. Let's create a new project in PlatformIO and configure SPI. The project is based on Arduino, but the implementation will not use Arduino functions except delay().

Pinout:

MAX7219

Atmega328

VCC

3V3

GND

GND

DIN

PB3

C.S.

PB2

CLK

PB5

main.c code:

#include "MatrixLed.h"

MatrixLed_st matrixLed;

void MatrixLed_SPI_ChipSelect(uint8_t level){
  if(!level){
    PORTB &= ~(0x04);
  }
  else{
    PORTB |= 0x04;
  }
}
void MatrixLed_SPI_Transmit(uint8_t* data, size_t size){
  for(size_t i = 0; i < size; i++){
    SPDR = data[i];
    while(!(SPSR & (1 << SPIF)));
  }
}

void SPI_Init(){
  DDRB = (1 << DDB2)|(1 << DDB3)|(1 << DDB5);
  SPCR = (1 << SPE)|(1 << MSTR)|(1 << SPR0);
}

void setup() {
  SPI_Init();
  MatrixLed_Init(&matrixLed, MatrixLed_SPI_Transmit, MatrixLed_SPI_ChipSelect);
}


void loop() {
  uint8_t x = 0;
  uint8_t y = 0;
  while(x < MATRIX_SIZE && y < MATRIX_SIZE){
    MatrixLed_SetPixel(&matrixLed, x, y, 1);
    MatrixLed_DrawDisplay(&matrixLed);
    delay(1000);
    MatrixLed_SetPixel(&matrixLed, x, y, 0);
    MatrixLed_DrawDisplay(&matrixLed);
    x++;
    y++;
  }
}

The implementation of the MatrixLed_SPI_ChipSelect and MatrixLed_SPI_Transmit functions is similar to the example with STM32. The task implementation code has not changed, with the exception of the delay function.

6. Demonstration of results

Since the results on all three boards are the same, I will attach only one gif with the implementation on STM32. On other boards the result is identical.

Rice. 3 - Demonstration of the result on the STM32 microcontroller

Rice. 3 – Demonstration of the result on the STM32 microcontroller

7. Conclusion

This approach makes it easy to use the same library on different hardware platforms without directly interfering with the library. All that needs to be done in this case is to describe several functions for working with the interface in the project itself, taking into account the features of the platform used. Driver repository link https://github.com/krllplotnikov/MAX7219.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *