STM32 & CLion

Scott

This guide uses CLion 2019.1 with integrated embedded support, and will cover the entire process of creating a new STM32F4 project, setting up the UART and USB CDC, and integrating Electric UI.

We also have a Github repo with a DMA based UART example for the STM32F4 which is a bit more advanced than this guide.

A STM32F407 Discovery development board is used, but most STM32 targets should also work fine.

Project Creation and Setup

We'll basically follow the normal project creation process with CLion's embedded support. This JetBrains article shows the process and covers dependancy resolution and configuration. We'll also cover project creation basics, but will configure the peripherals and change firmware needed to actually get things running.

CLion Project Start Screen

Create a new project with the STM32CubeMX template. The template will populate a .ioc file which you need to edit in CubeMX.

Create STM32CUBEMX Project at Location

Fresh project CLion main window view

The template doesn't start with our target microcontroller selected, clicking the name of the microcontroller in the top breadcrumb bar next to "Home" will take you to the device selection page.

Board Selection Screen in STMCubeMX

Find your microcontroller or development board. This article will use the trusty STM32F407 Discovery Board. In this example, we'll be setting up ElectricUI to operate over USART2 which corresponds to pins PA2 and PA3.

Pin Diagram of STM32F4 Discovery USART2

If you are selecting a board rather than a bare microcontroller, CubeMX will prompt if the peripherals should be configured in their default mode. This is useful to skip manually setting up GPIO for the user button and LEDs.

With the "Pinout & Configuration" tab open, we'll expand the "Connectivity" pane, and then select USART2.

Set the mode to Asynchronous, and check that the baud rate is set to 115200 Bits/s, with 8-bit word length, no parity, and 1 stop bit.

Configure USART2 mode

In the "NVIC Settings" tab, enable the USART2 global interrupt.

Configure USART2 global interrupt enable

The hardware serial peripheral is now configured, and we'll make sure the native USB interface is also configured correctly.

  1. From the "Connectivity" pane, just under the USART options, select USB_OTG_FS.

  2. Change the USB_OTG_FS mode from Host_Only to Device_Only.

  3. In the "Middleware" pane, check the USB_HOST "Class for FS IP" is set to Communication Host Class (Virtual Port Com).

    USB FS IP Configured as VCP

In the "Project Manager" tab, we need to re-configure the Project Settings as the microcontroller change effectively created a new file. Set the Project Name, Project Location to match the existing file created by CLion, and set the Toolchain / IDE dropdown to SW4STM32.

STCube Project Manager Export Settings

I also recommend clicking the "Code Generator" settings tab, and selecting the Generate peripheral initalization as a pair of '.c/.h' files per peripheral box, as it makes the generated project structure easier to manage in the long-run.

Remember to save your CubeMX project.

Generate the code with the "Generate Code" button on the right side of the top toolbar.

STCube generation success modal

CLion will detect the new files and update the project to match. When prompted, choose the relevant OpenOCD board config file to use for flashing rules. For bare microcontrollers, the config files for development boards of the same family often work fine.

CLion STM32 project import refresh view

Issues with development toolchains can be painful to sort out when things don't just work.

  • Make sure you have the gcc-arm-none-eabi compiler set installed on your computer.
  • Make sure you can reach gcc-arm-none-eabi-gcc and gcc-arm-none-eabi-g++ from your shell.
  • Configure these in CLion. File > Settings > Build, Execution, Deployment > Toolchains > ...
  • Restart CLion (it needs to re-source), though I ultimately needed to restart entirely before the toolchains were found properly.
  • Failing the above, you could attempt a ritual sacrifice.

Once you are able to build the templated project successfully, configure the debugger from Run > Edit Configurations.

CLion OpenOCD Configuration settings default

In main.c, add a LED blinker inside the superloop, then flash to your hardware to make sure everything works fine.

LD3 is the orange LED on the F4 Discovery board, and the port and pin naming is provided by the CubeMX template.

  1. while (1)
  2. {
  3. HAL_GPIO_TogglePin( LD3_GPIO_Port, LD3_Pin );
  4. HAL_Delay( 100 );
  5. /* USER CODE END WHILE */
  6. /* USER CODE BEGIN 3 */
  7. }

If the selected OpenOCD .cfg doesn't find your STLink, try other OpenOCD configurations which match your platform. In my case, stm32f4discovery.cfg didn't work while st_nucleo_f4.cfg did.

The difference was the STLink version OpenOCD was trying to find. If desired, you can modify the source [find interface/stlink-v2-1.cfg] line to match your board's programmer version.

In some cases (very old unused Discovery boards) you might need to run newer STLink firmware version. You can update using the STLink update utility (java tool).

Now the development and debugging environment is setup, we'll configure our template to read and write serial data over the USART2 peripheral, and also configure the USB Virtual Com Port (VCP).

Setting up USART

We'll start by implementing a circular buffer which holds data as the usart interrupts fire. This means we can poll the buffer whenever we want, while new data is inserted as soon as new data arrives.

In usart.c, we'll be adding some functions to make life a little easier. To start with, lets declare the variables we'll need.

  1. #include "usart.h"
  2. /* USER CODE BEGIN 0 */
  3. #define CIRCULAR_BUFFER_SIZE 32
  4. uint8_t rx_buffer[CIRCULAR_BUFFER_SIZE] = { 0 };
  5. uint8_t rx_head = 0;
  6. uint8_t rx_tail = 0;
  7. uint8_t rx_byte[2] = { 0 }; // buffer we give to the ST HAL
  8. /* USER CODE END 0 */

At the end of the MX_USART2_UART_Init function call, we'll tell the HAL that we want inbound data to be put in our rx_byte variable, and we want it to fire the interrupt for every byte.

HAL_UART_Receive_IT(&huart2, rx_byte, 1);

Now lets implement our circular buffer and grab data when the receive interrupt fires. We'll add this code to the bottom of usart.c in the templated user code area.

  1. /* USER CODE BEGIN 1 */
  2. void uart_write(uint8_t *buffer, size_t len)
  3. {
  4. HAL_UART_Transmit(&huart2, buffer, len, 100); // 100 tick timeout
  5. }
  6. uint8_t uart_check_buffer(void)
  7. {
  8. uint8_t bytes_in_buffer = 0;
  9. // When the head and tail of the circular buffer are at different point we have data
  10. if((rx_head != rx_tail))
  11. {
  12. // Handle data wraps across the buffer end boundary
  13. if( rx_head < rx_tail)
  14. {
  15. bytes_in_buffer = rx_head + (CIRCULAR_BUFFER_SIZE - rx_tail);
  16. }
  17. else
  18. {
  19. bytes_in_buffer = rx_head - rx_tail;
  20. }
  21. }
  22. return bytes_in_buffer;
  23. }
  24. uint8_t uart_get_byte(void)
  25. {
  26. if(rx_tail == CIRCULAR_BUFFER_SIZE)
  27. {
  28. rx_tail = 0;
  29. }
  30. return rx_buffer[rx_tail++];
  31. }
  32. // Callback on inbound byte, copy the data to the circular buffer
  33. void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
  34. {
  35. if (huart->Instance==USART2)
  36. {
  37. if(rx_head == CIRCULAR_BUFFER_SIZE)
  38. {
  39. rx_head = 0;
  40. }
  41. rx_buffer[rx_head++] = rx_byte[0];
  42. HAL_UART_Receive_IT(&huart2, rx_byte, 1);
  43. }
  44. }
  45. /* USER CODE END 1 */

You'll need to put the function prototypes into the usart.h header so they are accessible from our main loop.

  1. /* USER CODE BEGIN Prototypes */
  2. void uart_write(uint8_t *buffer, size_t len);
  3. uint8_t uart_check_buffer(void);
  4. uint8_t uart_get_byte(void);
  5. /* USER CODE END Prototypes */

From our infinite loop inside main() we'll check if there is data, then write any data back over the serial connection for a loop-back test. Each inbound byte will be written to a new line, and due to the delay in our loop, we can watch them come back one by one.

  1. /* Infinite loop */
  2. /* USER CODE BEGIN WHILE */
  3. while (1)
  4. {
  5. HAL_GPIO_TogglePin(LD3_GPIO_Port, LD3_Pin);
  6. HAL_Delay(50);
  7. uint8_t data_rx = uart_check_buffer();
  8. if( data_rx )
  9. {
  10. // Read the byte into a small printing buffer and append a newline
  11. uint8_t loopback[2];
  12. loopback[0] = uart_get_byte();
  13. loopback[1] = '\n';
  14. uart_write( loopback, 2);
  15. HAL_GPIO_WritePin(LD4_GPIO_Port, LD4_Pin, GPIO_PIN_SET);
  16. }
  17. else
  18. {
  19. HAL_GPIO_WritePin(LD4_GPIO_Port, LD4_Pin, GPIO_PIN_RESET);
  20. }
  21. /* USER CODE END WHILE */
  22. /* USER CODE BEGIN 3 */
  23. }

Opening up a serial terminal and we can see that inbound data is written back to us line by line (timestamps were added by the terminal).

Serial window showing uart loopback

We've now got the ability to send data over serial, store it as it comes in, then process it in our own time outside the interrupt.

Setting up USB CDC

Lets implement the same circular buffer approach for USB VCP, this is useful if you want to play with the Electric UI demo but don't have a hardware serial to USB converter.

In usb_device.c we're going to implement a circular buffer. The CubeMX template is a little inconsistent with the usart.c template blocks, so the functions are going above the existing MX_USB_DEVICE_Init this time.

  1. /* USER CODE BEGIN 0 */
  2. #define CIRCULAR_BUFFER_SIZE 32
  3. uint8_t cdc_rx_buffer[CIRCULAR_BUFFER_SIZE] = { 0 };
  4. uint8_t cdc_rx_head = 0;
  5. uint8_t cdc_rx_tail = 0;
  6. /* USER CODE END 0 */
  7. /* USER CODE BEGIN 1 */
  8. void usb_write(uint8_t *buffer, size_t len)
  9. {
  10. CDC_Transmit_FS(buffer, len);
  11. }
  12. uint8_t usb_check_buffer(void)
  13. {
  14. uint8_t bytes_in_buffer = 0;
  15. // When the head and tail of the circular buffer are at different point we have data
  16. if((cdc_rx_head != cdc_rx_tail))
  17. {
  18. // Handle data wraps across the buffer end boundary
  19. if( cdc_rx_head < cdc_rx_tail)
  20. {
  21. bytes_in_buffer = cdc_rx_head + (CIRCULAR_BUFFER_SIZE - cdc_rx_tail);
  22. }
  23. else
  24. {
  25. bytes_in_buffer = cdc_rx_head - cdc_rx_tail;
  26. }
  27. }
  28. return bytes_in_buffer;
  29. }
  30. uint8_t usb_get_byte(void)
  31. {
  32. if(cdc_rx_tail == CIRCULAR_BUFFER_SIZE)
  33. {
  34. cdc_rx_tail = 0;
  35. }
  36. return cdc_rx_buffer[cdc_rx_tail++];
  37. }
  38. void usb_receive_data( uint8_t *data_in, size_t len)
  39. {
  40. for( size_t i = 0; i < len; i++)
  41. {
  42. if(cdc_rx_head == CIRCULAR_BUFFER_SIZE)
  43. {
  44. cdc_rx_head = 0;
  45. }
  46. // copy from the CDC buffer to the circular buffer
  47. cdc_rx_buffer[cdc_rx_head++] = data_in[i];
  48. }
  49. }
  50. /* USER CODE END 1 */

In usb_device.h the function prototypes need to be declared.

  1. /* USER CODE BEGIN PFP */
  2. void usb_write(uint8_t *buffer, size_t len);
  3. uint8_t usb_check_buffer(void);
  4. uint8_t usb_get_byte(void);
  5. void usb_receive_data( uint8_t *data_in, size_t len);
  6. /* USER CODE END PFP */

You'll notice that we didn't declare a HAL_UART_RxCpltCallback equivalent this time, and instead we have a usb_receive_data function. This is because the HAL is a little different for USB CDC, and the recieve callback is already implemented elsewhere. Because of this, we need to pass the inbound data from that callback to our buffer.

The useful part of the CDC implementation lives in usbd_cdc_if, so at the top of usbd_cdc_if.c we need to include our usb_device.h.

  1. /* USER CODE BEGIN INCLUDE */
  2. #include "usb_device.h"
  3. /* USER CODE END INCLUDE */

Now lets scroll down and look at the CDC_Receive_FS function, and how it all works.

  1. When CDC data comes in, the CDC_Receive_FS function is called by the USB HAL along with a pointer to a buffer of new data.
  2. The applications developer (us) now has the ability to access that buffer.
  3. Once done, we tell the USB stack that we are ready for more, and which buffer should contain it.
    • The usbd_cdc_if scope has it's own buffers for the inbound data, so we pass that buffer pointer back to the USB stack for the next inbound frame.

So, when the CDC_Receive_FS callback fires, we'll call our own usb_receive_data function to copy the data into our circular buffer, as we want to allow the USB stack to receiving more data as quickly as possible.

Our CDC_Receive_FS now looks like this

  1. static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len)
  2. {
  3. /* USER CODE BEGIN 6 */
  4. usb_receive_data( Buf, *Len); // insert the data into our circular buffer
  5. USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]);
  6. USBD_CDC_ReceivePacket(&hUsbDeviceFS);
  7. return (USBD_OK);
  8. /* USER CODE END 6 */
  9. }

Just like the UART example earlier, we'll poll the circular buffer for data inside our infinite loop inside main() and write it over the USB CDC port. Each inbound byte will be written to a new line. While the buffer has data, the blue LED will glow.

  1. /* Infinite loop */
  2. /* USER CODE BEGIN WHILE */
  3. while (1)
  4. {
  5. HAL_GPIO_TogglePin(LD3_GPIO_Port, LD3_Pin);
  6. HAL_Delay(50);
  7. uint8_t data_rx = usb_check_buffer();
  8. if( data_rx )
  9. {
  10. uint8_t loopback[2];
  11. loopback[0] = usb_get_byte();
  12. loopback[1] = '\n';
  13. usb_write( loopback, 2);
  14. HAL_GPIO_WritePin(LD6_GPIO_Port, LD6_Pin, GPIO_PIN_SET);
  15. }
  16. else
  17. {
  18. HAL_GPIO_WritePin(LD6_GPIO_Port, LD6_Pin, GPIO_PIN_RESET);
  19. }
  20. /* USER CODE END WHILE */
  21. /* USER CODE BEGIN 3 */
  22. }

We can test this with a serial terminal, the bytes sent are broken into new lines and sent back.

Serial window showing USB CDC loopback

Congratulations, you've got a working USB CDC VCP implementation.

Integrating Electric UI

First, use git to clone a copy of the electricui-embedded library, or download it from Github and unzip it.

Configure CMake to include the library by adding include_directories(electricui-embedded/src) to the CMakeLists.txt file.

Because the CLion embedded template uses the CMake GLOB_RECURSE during the file search on the default CubeMX directories, its a minor annoyance to add cleanly.

Either put the directory into /src or /Middlewares depending on taste and delete examples and test folders,

... or ...

Place it elsewhere and explicitly add /electricui-embedded/src/*.* to the file(GLOB_RECURSE SOURCES "startup/*.*" ... "electricui-embedded/src/*.*")

In main.c, we'll include the embedded library, define a macro to help grab the STM's internal chip UUID, and declare some variables for a adjustable LED blinker demo.

  1. #include "electricui.h"
  2. #define STM32_UUID ((uint32_t *)0x1FFF7A10)
  3. uint8_t blink_enable = 1; //if the blinker should be running
  4. uint8_t led_state = 0; //track if the LED is illuminated
  5. uint16_t glow_time = 200; //in milliseconds
  6. // Timestamp used as part of LED timing behaviour
  7. uint32_t led_timer = 0;
  8. char nickname[15] = "STM32 UART/USB"; // human friendly device name

Create a pair of functions which accept a pointer to a buffer of uint8_t, and the corresponding length of data. These will be provided to Electric UI as the output callbacks, rather than directly using the HAL function calls.

  1. void eui_serial_output( uint8_t *data, uint16_t len)
  2. {
  3. uart_write(data, len);
  4. }
  5. void eui_usb_output( uint8_t *data, uint16_t len)
  6. {
  7. usb_write(data, len);
  8. }

Now, lets setup the eui_interface_t which represent the hardware interfaces we'll be using to communicate with the user interface.

For this example we'll use both the hardware UART and USB VCP, so we put the interfaces in an array (but its OK to just use one interface if you want).

  1. // Instantiate a communication interface management object
  2. // eui_interface_t eui_uart_interface = {0}
  3. // Instantiate both interface management objects in an array
  4. eui_interface_t eui_comm[] = {
  5. EUI_INTERFACE( &eui_serial_output ),
  6. EUI_INTERFACE( &eui_usb_output )
  7. };

We want the user interface to access the variables we declared as part of our LED blinker. Simply create an array containing eui_message_t for the variables you want to track. Electric UI provides a series of macros which reduce the boilerplate involved.

For each tracked variable, we use the macro which corresponds to the type of the data being tracked, include a message identifier string, and then the reference to the variable.

  1. // Electric UI manages variables referenced in this array
  2. eui_message_t tracked_vars[] =
  3. {
  4. EUI_UINT8( "led_blink", blink_enable ),
  5. EUI_UINT8( "led_state", led_state ),
  6. EUI_UINT16( "lit_time", glow_time ),
  7. EUI_CHAR_RO_ARRAY( "name", nickname ),
  8. };

At startup, we need to tell Electric UI about the interfaces available, the array of variables to manage, and the identifier for this specific board. We'll put these calls inside main() just after the HAL setup is called (MX_USART2_UART_Init() etc).

  1. /* USER CODE BEGIN 2 */
  2. EUI_LINK( eui_comm );
  3. EUI_TRACK( tracked_vars );
  4. eui_setup_identifier( (char *)STM32_UUID, 12 ); // 96-bit UUID is 12 bytes
  5. led_timer = HAL_GetTick(); //bootstrap the LED timer
  6. /* USER CODE END 2 */

Now our setup is done, we just need to pass data into eui_parse as it comes in. The example below does this for both the UART and USB interfaces.

Then, we have some simple code to control the orange LED blinker.

  1. /* Infinite loop */
  2. /* USER CODE BEGIN WHILE */
  3. while (1)
  4. {
  5. while( uart_check_buffer() )
  6. {
  7. eui_parse(uart_get_byte(), &eui_comm[0]);
  8. }
  9. while( usb_check_buffer() )
  10. {
  11. eui_parse(usb_get_byte(), &eui_comm[1]);
  12. }
  13. if( blink_enable )
  14. {
  15. // Check if the LED has been on for the configured duration
  16. if( HAL_GetTick() - led_timer >= glow_time )
  17. {
  18. led_state = !led_state; //invert led state
  19. led_timer = HAL_GetTick();
  20. }
  21. }
  22. HAL_GPIO_WritePin(LD3_GPIO_Port, LD3_Pin, led_state); // set the orange LED
  23. /* USER CODE END WHILE */
  24. /* USER CODE BEGIN 3 */
  25. }

That's it! Compile and flash to the board, and fire up a fresh Electric UI template project to test it in action.