Mixing C and C++ with the STM32 microcontroller | article review image

Mixing C and C++ with the STM32 microcontroller

The STM32 world defaults to C, but sometimes you yearn for the sophistication of C++. The good news is that you can have both — in the same…

MICROCONTROLLERS

The STM32 world defaults to C, but sometimes you yearn for the sophistication of C++. The good news is that you can have both — in the same project.

There's something about classes that is appealing. While functional programming advocates will disagree, encapsulating functionality and data in an object is elegant. It also makes for easily reusable code.

But when you generate code for an STM32 microcontroller in STM32CubeMX what do you get? C. Which is fine.

In the context of microcontroller firmware, C is fast, compact and efficient. Nothing wrong with that. I am not a computer scientist, I'm not even a real programmer (I just cosplay one) but if you are then you might like to chime in with a comment about whether using C++, and particularly classes, bloats code and makes performance suffer.

Not that it matters. I'm going to use it anyway because I like it. Much of my AVR coding makes heavy use of classes that I employ across multiple projects. The same goes for the Arduino world, and if it's good enough for them then who am I to argue?

Creating code

I believe that STM32CubeIDE allows for creating C++ projects. I tried firing it up to check but it crashed. In any case, that's neither the way I roll when it comes to coding nor is it ST's recommended approach. The company is now focusing on VS Code, as am I.

My workflow is to start a project using STM32CubeMX, configuring the pins, clock sources and speed and so on and then click 'Generate Code'.

As mentioned, this creates C code and you might be tempted to rename main.c as main.cpp and just go for it. I wouldn't if I were you. For one thing, everything's going to get more than a little screwed up if you have reason to pop back into STM32CubeMX, make some pin changes or whatever and hit 'Generate Code' again.

The approach we're going to explore here mostly leaves main.c unmolested, other than one small trick which we'll get to.

Instead, what we do is create a 'bridge' between C and C++ code so that you can use both in the same project.

Guarding the code

As a quick aside, one thing I wanted to do in this project is make use of a header file that is common across both AVR and STM32 projects and which needs to be compatible with both C and C++. Here's a cut-down version of it:

#ifndef __SB_LIB_DEFINES_H__
#define __SB_LIB_DEFINES_H__

// These defines work in BOTH C and C++
#define SBMSG_USONIC_DATA_US 10
#define SBMSG_SET_PARAM 20
#define SBMSG_GET_PARAM 21

#ifndef OFF
#define OFF 0
#endif
#ifndef ON
#define ON 1
#endif

#ifdef __cplusplus    // This stuff is for C++ only.
namespace SensorBus {
    inline const uint8_t MSG_BUF_LEN = 16;
    inline const uint8_t MAX_SEND_RETRIES_DFL = 3;
    typedef enum errcodes {
        UNDEFINED = -1,
        ERR_NONE = 0,
        ERR_UNKNOWN_DEVICE = 255
    } err_code;
}
#endif // __cplusplus

#endif // __SB_LIB_DEFINES_H__

The lines within the #ifdef __cplusplusblock are compiled only when a C++ compiler is used.

Note how we've used inline, because this file is going to be included in a whole bunch of files.

Also, how do you define __cplusplus? The answer is that you don't, not in the conventional sense of including something like #define __cplusplus in your code.

When you run a command like avr-g++ or arm-none-eabi-g++, the compiler internally sets this macro as a flag that says 'I am in C++ mode'. As soon as that mode is active, the compiler injects the __cplusplusdefinition into the preprocessor's memory before it even looks at the first line of your code. And it's not just a boolean flag. It's a long integer that represents the specific version of the C++ standard you are using.

The point is that I can now include this file in both C and C++ files without worrying about the compiler getting huffy.

External main

So much for that. Now back to our particular application.

In this approach, our main application code doesn't live in main.c but in separate files, app.hpp (which is in Core/Inc/) and app.cpp (in Core/Src). And as you might guess from the extensions, these are C++.

Here's app.hpp:

#pragma once
#include "app_bridge.h"
#include <SB_lib_defines.h>
#include <SB_devicelib.hpp>

using namespace SensorBus;

extern SB_Device sbDevice;

#ifdef __cplusplus
  extern "C" {
#endif

extern "C" void app_init(void);
extern "C" void app_loop(void);

#ifdef __cplusplus
  }
#endif

We'll come to app_bridge.h very soon. The key thing to note is the #ifdef __cplusplusblock. When a C++ compiler sees this, everything gets wrapped in an extern "C" {...} block.

C++ compilers have a habit of 'mangling' symbol names into complex strings. The extern "C" tells it not to do that but to maintain symbols in a manner that C can understand.

Where you put things is important. You should never put a C++ class instance or a using namespace directive inside an extern "C" block because the compiler is likely to get confused about how to name the symbols, which often leads to 'undefined reference' errors.

This header file declares an instance (sbDevice) of a class (SB_Device), but simply as extern and without instantiating it. This is a class I've written in C++. The instantiation is done in the the implementation (.cpp) file. If you were to define it in the header, every file that included app.hpp would try to create its own instance of the variable, causing 'multiple definition' errors.

Here's the accompanying app.cpp:

#include "app.hpp"

SB_Device sbDevice = SB_Device(); // Create instance
#ifdef __cplusplus
  extern "C" {
#endif

extern "C" void app_init(void) {
    // Setup code
}

extern "C" void app_loop(void) {
    while(1) {
        // Main loop logic
    }
}

#ifdef __cplusplus
  }
#endif

The two functions are where the main application logic go.

Building a bridge

So, I have a class (SB_Device) written in C++, the app code written in C++ but in a way that makes sense to both C and C++ compilers. Now it's time to build a bridge between the two worlds.

We'll put this bridge code in a header-only file that we'll call app_bridge.h (choose whatever name makes sense to you). This goes in the Core/Inc/ directory of the project. Here it is in all its glory:

#ifndef APP_BRIDGE_H
#define APP_BRIDGE_H

#ifdef __cplusplus
  extern "C" {
#endif

// These functions can be called from main.c
void app_init(void);
void app_loop(void);

#ifdef __cplusplus
  }
#endif

#endif

Essentially, it predefines the two key functions we'll use for our code in a way that's accessible by C even though the code itself will be written in C++.

This 'bridge pattern' (or C-to-C++ wrapper) acts as a neutral zone where two different languages can meet without tripping over each other's syntax rules. And it addresses two key issues:

  • The C side (main.c) doesn't understand C++ stuff such as class, namespace or public/private. If it sees them, it fails to compile.
  • The C++ side (app.cpp): Knows everything about C++, but its names are 'mangled' (transformed) into complex strings that the C linker cannot read.

This bridge again uses the __cplusplus macro to change how it appears depending on who is looking at it.

  • To C it looks like a standard list of simple C functions.
  • To C++ it looks like an extern "C"block, which tells the compiler, 'Don't mangle these function names; keep them simple so C can find them.'

The magic happens during the linker stage.

  • main.c generates an object file that says: 'I need a function exactly named app_init'.
  • app.cpp generates an object file that contains the code for the main logic. Because of extern "C", it labels that code exactly as app_init instead of a mangled C++ name.
  • The linker sees the request from main.o and the label in app.o, matches them up, and stays happy.

The main event

Inside main.c, we include our bridge file and any other C-compatible user files:

/* USER CODE BEGIN Includes */

#include "app_bridge.h"
#include <SB_lib_defines.h>

/* USER CODE END Includes */

And inside the main()function, we call the two functions we created:

/* USER CODE BEGIN WHILE */
/***** INIT *****/

app_init();

/***** MAIN LOOP *****/
app_loop();

/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
/* USER CODE END 3 */

The calls to app_setup() and app_loop()occur just before we would normally have our while(1) infinite loop (which I've removed).

When the code runs, main.c handles the low-level STM32 HAL setup (clocks, GPIO, etc). It calls app_init() which moves execution into app.cpp. Once there, we're in a pure C++ environment. We can now use all the wonderful things that C++ offers. Then the flow returns to main.c which calls app_loop(), taking us back into C++, but permanently this time because that function includes the everlasting while(1)loop.

CMake modifications

The final step is to make some additions and modifications to the CMakeLists.txt file.

The following is the whole file that I'm using for this current project (which is at a very early stage, so this will develop). Afterwards, I'll go through the significant bits because a lot of this is auto-generated.

cmake_minimum_required(VERSION 3.22)

# Setup compiler settings
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS ON)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Set the project name
set(CMAKE_PROJECT_NAME STM32_SB_Node)

# Define the build type
if(NOT CMAKE_BUILD_TYPE)
    set(CMAKE_BUILD_TYPE "Debug")
endif()

project(${CMAKE_PROJECT_NAME} LANGUAGES C CXX ASM)

# Enable compile command to ease indexing with e.g. clangd
set(CMAKE_EXPORT_COMPILE_COMMANDS TRUE)

# Core project settings
message("Build type: " ${CMAKE_BUILD_TYPE})

# Enable CMake support for ASM and C languages
enable_language(C CXX ASM)

# Create an executable object type
add_executable(${CMAKE_PROJECT_NAME})

# Add STM32CubeMX generated sources
add_subdirectory(cmake/stm32cubemx)

# Link directories setup
target_link_directories(${CMAKE_PROJECT_NAME} PRIVATE
    # Add user defined library search paths
)

# Add sources to executable
target_sources(${CMAKE_PROJECT_NAME} PRIVATE
    # Add user sources here
    ${CMAKE_CURRENT_SOURCE_DIR}/Core/Src/app.cpp
    "$ENV{HOME}/ms_stm32lib/SB_devicelib.cpp"
)

# Add include paths
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
    # Add user defined include paths
    ${CMAKE_CURRENT_SOURCE_DIR}/Core/Inc
    "$ENV{HOME}/mculib"
    "$ENV{HOME}/ms_stm32lib"
)

# Add project symbols (macros)
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE
    # Add user defined symbols
)

# Remove wrong libob.a library dependency when using cpp files
list(REMOVE_ITEM CMAKE_C_IMPLICIT_LINK_LIBRARIES ob)

# Add linked libraries
target_link_libraries(${CMAKE_PROJECT_NAME}
    stm32cubemx
    # Add user defined libraries
)

Near the top, we've added:

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

That tells CMake what version of C++ we want, and this one seems as good as any.

The following two lines existed already but have been amended with the insertion of CXX:

project(${CMAKE_PROJECT_NAME} LANGUAGES C CXX ASM)

enable_language(C CXX ASM)

The contents of the next two parts are mostly specific to my project (with one exception) but might be of interest as a way of showing how things are done:

# Add sources to executable
target_sources(${CMAKE_PROJECT_NAME} PRIVATE
    # Add user sources here
    ${CMAKE_CURRENT_SOURCE_DIR}/Core/Src/app.cpp
    "$ENV{HOME}/ms_stm32lib/SB_devicelib.cpp"
)

# Add include paths
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
    # Add user defined include paths
    ${CMAKE_CURRENT_SOURCE_DIR}/Core/Inc
    "$ENV{HOME}/mculib"
    "$ENV{HOME}/ms_stm32lib"
)

The target_sources section includes the app.cpp code we created. This bit is essential if you follow the approach I've outlined here.

There's also the C++ library item I'm using. On my dev machines I have a directory (~/ms_stm32lib/) where I keep my own library files and I've explicitly included SB_devicelib.cpp here. If I use more library files as the project progresses, I'll need to add them to this list.

The target_include_directories section tells CMake where it can find header files. The first item points to the Core/Inc/directory in the project, which is standard. The next two are my custom locations, with ~/mculib/ being where I store header files valid for both AVR and STM32 projects and ~/ms_stm32lib/ the library folder we met just now. It's unlikely I'll need to add further to this list, and if you keep everything within the project directory structure you won't have to touch it at all.

And that's it.

I can now create and use C++ classes even though the entry point to the whole project, main.c, remains steadfastly in C. I'm sure there will be other wrinkles to sort out, but I'll report on that as I find them.

Steve Mansfield-Devine is a freelance writer, tech journalist and photographer. You can find photography portfolio at Zolachrome, buy his books and e-books, or follow him on Bluesky or Mastodon.

Or you can buy Steve a coffee — it helps keep these projects going.