First of all: welcome to my first blog post!
Recently, I've been working on an STM32H745ZI for a very cool upcoming project - no spoilers for now. This powerful microcontroller is also a multicore beast, featuring both an ARM Cortex-M7 and an ARM Cortex-M4. The dual core nature of this microcontroller allows one to run different code at the same time: it's like having two different microcontrollers!
In my project, for instance, the M7 deals with all the "heavy tasks" for the user interface and connectivity: displaying information in a nice LVGL user interface on an LCD display via SPI, managing multiplexed keyboard and user inputs, handling USB and UART... Meanwhile, the Cortex-M4 takes care of time-critical tasks that demand fast, uninterrupted processing.
But what happens when the two cores need to speak to each other? How does one core send data to the other one?
When I faced these questions, I found the information online to be scattered, incomplete, and sometimes hard to follow. So today, let's try to tackle this systematically and find a clear answer.
First, we have to find where to store our data and variables. Reading the STM32H745ZI Reference Manual, we can find that the D3 domain AHB SRAM4, with starting address 0x3800000, can be used for our purposes. Quoting:
AHB SRAM4 is mapped at address 0x38000000 and accessible by most of system masters through D3 domain AHB matrix. AHB SRAM4 can be used as BDMA buffers to store peripheral input/output data in D3 domain. It can also be used to retain some application code/data when D1 and D2 domain enter DStandby mode, or as shared memory between the two cores.
That's our starting point. Remember that if you use a different microcontroller, your starting address might be different!
This memory area provides a shared space accessible by both cores.
However, there is a limitation: the cores can only detect changes to
a variable’s value by continuously polling its memory address. While
this approach may work in some contexts, I prefer a more efficient
solution: Hardware semaphores!
Hardware semaphores regulate access to shared data or peripherals, functioning like traffic lights. They're implemented as a shared 32-bit variable where each bit represents an individual semaphore. When a semaphore is set, it triggers an interrupt on both CPUs. We'll leverage this functionality to arbitrate data access between cores.
We can set up to 32 different semaphores, but if you are using STM32CubeIDE or STM32CubeMX I suggest avoiding using bit 0, since is used for syncronizing the two cores.
Note that this is a simplified explanation. I recommend consulting the reference manual for a deeper understanding, but this overview should be sufficient for our tutorial purposes.
From now on, I will use STM32CubeIde, and its built-in STM32CubeMX. Your setup might be different, but the core idea still applies.
We need to enable the Hardware Semaphores (HSEM from now on) on both cores Nested Vectored Interrupt Controller (NVIC) in our project, as showed in the following pictures.
Cortex-M7:
Cortex-M4:
I also enable on both cores the Memory Protection Unit (MPU), allowing sharability to our address. For this example I'm using a region size of 64kB.
Cortex-M7:
Cortex-M4:
Now that we did setup our requirements, we can shift our attention to the code. First, we have to create a .h file that will be common to both compile paths. Following the STM32CubeIDE folder structure convention, we will create the sharedData.h in /ProjectDir/Common/Inc.
ProjectDir
|-Common
|---Inc
|---|---sharedData.h
|---Src
|-Drivers
|-PROJECTNAME_CM4
|-PROJECTNAME_CM7
|-PROJECTNAME_CM7
|-Middlewares
In this header file, we define the memory addresses and sizes of the data we want to share between cores. For this example, we'll define a simple 8-bit variable and an array of generic structs:
Let's examine our code structure. Since we needed an array of a generic struct, we defined testStruct along with its dimension using ARRAY_SIZE. We also created MEM_ALIGN(x), a macro that aligns data to multiples of 4 bytes to prevent data corruption. Next, we established the memory addresses for our variables. STRUCTVAR_ADDR begins precisely at the start of our shared memory space. STRUCTVAR_LEN indicates how much space our array occupies, calculated simply as sizeof(testStruct)*ARRAY_SIZE. Now for our simple variable: its address equals the struct array's starting address plus its size (STRUCTVAR_ADDR + STRUCTVAR_LEN), while its dimension is simply sizeof(uint8_t). We also defined two hardware semaphores: in this case bit 1 for the struct array, and bit 2 for the simple variable.
Since we've defined our shared data, we can proceed to work on the main.c file for both cores. First, include the header file we just created and define our variables:
Now we can focus on implementing the data sharing mechanism. Let's say the struct array is written by the Cortex-M7 and read by the Cortex-M4, while the simple variable is written by the Cortex-M4 and read by the Cortex-M7.
Let's review this code. First, we define a support variable that will store the content of the shared simple variable locally. I do this because I prefer to avoid using a shared resource directly.
We then enter main(). Right after all initialization routines, we enable notifications for the simple variable semaphore with HAL_HSEM_ActivateNotification(1U<HSEM_UPDATE_SIMPLE_VAR). As mentioned earlier, when a semaphore is set, an interrupt is sent to both CPUs. However, if we don't enable reception for a particular semaphore, our code won't trigger the callback.
Next, we enter the main while loop. After a delay, we invoke HAL_HSEM_FastTake(HSEM_UPDATE_STRUCT_ARRAY). This allows the Cortex-M7 to take possession of the semaphore (in this case the first semaphore). This is particularly useful when sharing resources between cores because the first core to acquire the semaphore prevents the other from accessing it.
We then populate our struct array with the value of localSimpleVar using a for loop. Afterward, we release the semaphore with HAL_HSEM_Release(HSEM_UPDATE_STRUCT_ARRAY, 0), which triggers an interrupt.
Outside main(), we define the function void HAL_HSEM_FreeCallback(uint32_t SemMask), which serves as the callback for semaphore release interrupts. If the triggered semaphore is HSEM_UPDATE_SIMPLE_VAR (semaphore number 2), we update the local simple variable with the new value stored in simpleVar. Finally, we reactivate interrupt reception with HAL_HSEM_ActivateNotification(1U<HSEM_UPDATE_SIMPLE_VAR).
Here instead is the implemetation for the Cortex-M4:
As before, we define a support variable to store the content of our struct array locally. Entering main(), we initialize simpleVar between HAL_HSEM_FastTake(HSEM_UPDATE_SIMPLE_VAR) and HAL_HSEM_Release(HSEM_UPDATE_SIMPLE_VAR, 0). This triggers an interrupt to the Cortex-M7.
Similar to the Cortex-M7 implementation, we increment the value of the shared variable by enclosing the operation between HAL_HSEM_FastTake and HAL_HSEM_Release. We also define void HAL_HSEM_FreeCallback(uint32_t SemMask) here. In this case, we check if the triggered semaphore is HSEM_UPDATE_STRUCT_ARRAY (semaphore number 1). If so, we update the local struct array and then reactivate interrupt reception with HAL_HSEM_ActivateNotification(1U<<HSEM_UPDATE_STRUCT_ARRAY).
That's it! We now have two CPUs sharing variables and accessing them after being notified by a semaphore. Clearly, this is a very simple example, but I hope it has demystified the process of sharing variables between two STM32 cores. I'm planning to create a video of the implementation, which should make it easier to follow the whole process and see the actual results of this code.
Until next time!
🐊