DEV Community

Ahmet Can Gulmez
Ahmet Can Gulmez

Posted on

Programming and Flashing MCU without any IDE

Let's dive into embedded world!

In this post, I will explain the STM32F4 microcontroller programming and flashing without any IDE like STM32Cube IDE.

Let's start with the programming side. The main interface that we use for programming STM32-based microcontroller is the STM32 Cube HAL (Hardware Abstraction Layer) library. It is implemented by vendor, STMicroelectronics. Also we need the CMSIS (Common Microcontroller Software Interface Standard) library developed by ARM. Additionally, linker script, startup code, and similar files are needed.

I'm using the PlatformIO extension in VS Code. It handles the all library dependencies, just install that extension and create a new project. The libraries and binaries will be installed and can be used directly. Don't forget that platformio.ini is the configuration file, edit it according to your microcontroller.

I've written a example program in /src/main.c in the PlatformIO project.

/**
 * Demo Project
 */

#include <stm32f4xx_hal.h>

/**
 * Oscillator and Clock Configuration
 */
void OscClockConfig(void)
{
    /* ... */
}

void main(int argc, char *argv[])
{
        HAL_Init();

    OscClockConfig();

    /* Enable some peripheral clocks over RCC registers */
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_GPIOB_CLK_ENABLE();
    __HAL_RCC_GPIOC_CLK_ENABLE();

    /* Configure the PA0 pin just as push-pull output */
    GPIO_InitTypeDef initPA0;
    initPA0.Pin = GPIO_PIN_0;
    initPA0.Mode = GPIO_MODE_OUTPUT_PP;
    initPA0.Pull = GPIO_NOPULL;
    initPA0.Speed = GPIO_SPEED_FREQ_LOW;    /* doesn't matter */
    HAL_GPIO_Init(GPIOA, &initPA0);

    /* Set the PA0 pin to logic 1 (+3.3V) */
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, SET);

    while (1);
}
Enter fullscreen mode Exit fullscreen mode

To create the proper binary (ELF for GNU/Linux and PE for Windows) that will be flashed into microcontroller, we need to compile it:

$ pio run
Enter fullscreen mode Exit fullscreen mode

Don't forget that run this command where platformio.ini is. The output of this command will be like this:

Processing genericSTM32F446RE (platform: ststm32; board: genericSTM32F446RE; framework: stm32cube)
---------------------------------------------------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/ststm32/genericSTM32F446RE.html
PLATFORM: ST STM32 (19.2.0) > STM32F446RE (128k RAM. 512k Flash)
HARDWARE: STM32F446RET6 180MHz, 128KB RAM, 512KB Flash
DEBUG: Current (blackmagic) External (blackmagic, jlink, stlink)
PACKAGES: 
 - framework-stm32cubef4 @ 1.28.1 
 - tool-ldscripts-ststm32 @ 0.2.0 
 - toolchain-gccarmnoneeabi @ 1.70201.0 (7.2.1)
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 59 compatible libraries
Scanning dependencies...
No dependencies
Building in release mode
Compiling .pio/build/genericSTM32F446RE/FrameworkHALDriver/Src/stm32f4xx_hal.o
Compiling .pio/build/genericSTM32F446RE/FrameworkHALDriver/Src/stm32f4xx_hal_adc.o

...

Compiling .pio/build/genericSTM32F446RE/FrameworkHALDriver/Src/stm32f4xx_ll_utils.o
Compiling .pio/build/genericSTM32F446RE/src/main.o
src/main.c:15:6: warning: return type of 'main' is not 'int' [-Wmain]
 void main(int argc, char *argv[])
      ^~~~
Compiling .pio/build/genericSTM32F446RE/FrameworkCMSISDevice/gcc/startup_stm32f446xx.o
Compiling .pio/build/genericSTM32F446RE/FrameworkCMSISDevice/system_stm32f4xx.o
Archiving .pio/build/genericSTM32F446RE/libFrameworkCMSISDevice.a
Indexing .pio/build/genericSTM32F446RE/libFrameworkCMSISDevice.a
Linking .pio/build/genericSTM32F446RE/firmware.elf
Checking size .pio/build/genericSTM32F446RE/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM:   [          ]   0.0% (used 40 bytes from 131072 bytes)
Flash: [          ]   0.2% (used 1148 bytes from 524288 bytes)
Building .pio/build/genericSTM32F446RE/firmware.bin
============================================== [SUCCESS] Took 20.12 seconds ==============================================
Enter fullscreen mode Exit fullscreen mode

After this command finished, there will be two files named firmware.bin and firmware.elf under ./pio/build directory.

firmware.bin includes the pure raw machine code that will be written to microcontroller. To flash this, you have to declare the base address of flash memory (0x08000000 in case). firmware.elf includes the pure the machine code plus debug info, sections or so on. This is primarily used for debugger, GDB. So if you look at the sizes, firmware.elf will be much bigger.

After producing the binaries and before flashing it, we want sometimes to inspect binary contents. For example, let's look at the section headers:

$ arm-none-eabi-objdump -h firmware.elf
Enter fullscreen mode Exit fullscreen mode

Output:

firmware.elf:     file format elf32-littlearm

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .isr_vector   000001c4  08000000  08000000  00010000  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  1 .text         00000470  080001c4  080001c4  000101c4  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  2 .rodata       00000000  08000634  08000634  0002000c  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  3 .ARM.extab    00000000  08000634  08000634  0002000c  2**0
                  CONTENTS
  4 .ARM          00000000  08000634  08000634  0002000c  2**0
                  CONTENTS
  5 .preinit_array 00000000  08000634  08000634  0002000c  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  6 .init_array   00000004  08000634  08000634  00010634  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  7 .fini_array   00000004  08000638  08000638  00010638  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  8 .data         0000000c  20000000  0800063c  00020000  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  9 .bss          0000001c  2000000c  08000648  0002000c  2**2
                  ALLOC
 10 ._user_heap_stack 00000600  20000028  08000648  00020028  2**0
                  ALLOC
 11 .ARM.attributes 0000002a  00000000  00000000  0002000c  2**0
                  CONTENTS, READONLY
 12 .comment      0000007e  00000000  00000000  00020036  2**0
                  CONTENTS, READONLY
 13 .debug_frame  0000002c  00000000  00000000  000200b4  2**2
                  CONTENTS, READONLY, DEBUGGING, OCTETS
Enter fullscreen mode Exit fullscreen mode

In here, we see the many sections. Each section include different stuffs. The machine code will be in .text section. So it is the main target for reverse-engineering works. Apart from that .data, .bss and .rodata sections will contain the various variable declarations. .isr_vector is the ARM-specific section that includes the ISRs (Interrupt Service Routines). It is related to the interrupt mechanism in ARM Cortex. .init_array and .fini_array sections include the constructor/destructor machine code. These run before/after the main() (entry-point) function. .debug_frame is primarily used by GDB debugger. There are also ARM-specific sections that I don't know actually.

To display the machine code and its corresponding assembly commands, run this:

$ arm-none-eabi-objdump -s .text -d firmware.elf
Enter fullscreen mode Exit fullscreen mode

Or we can see the HAL functions used in the program:

$ arm-none-eabi-strings firmware.elf | grep HAL_*

HAL_NVIC_SetPriority
HAL_GPIO_WritePin
HAL_MspInit
HAL_GPIO_Init
HAL_SYSTICK_Config
HAL_Init
HAL_NVIC_SetPriorityGrouping
HAL_InitTick
Enter fullscreen mode Exit fullscreen mode

Actually debugging a firmware is the huge topic and deserves a alone post so that I will go with the flashing phase.

To flash a STM32 microcontroller, we have a few options:

  • openocd

  • st-flash

  • dfu-util

Before the selecting right tool, we need to determine the programming interface that we're current using. Two interfaces shines in embedded world: SWD (Serial Wire Debug) and USB. For ARM-based microcontrollers, SWD is the standard programming/debugging interface. Most modern microcontrollers, additionally, support DFU (Device Firmware Update) interface to program microcontroller over USB. If you plan to use SWD interface, you need a hardware called ST-LINK. In USB interface, you just need USB cable. But to put the microcontroller in DFU mode, you put the BOOT0 pin to logic 1 (+3.3V). Both interfaces have advantages/disadvantages. You need to select the right tool for your case.

For this post, I have SWD interface and ST-LINK device. To list and check ST-LINK connection, lsusb can be used:

$ lsusb
Enter fullscreen mode Exit fullscreen mode

Output:

Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 001 Device 002: ID 0438:7900 Advanced Micro Devices, Inc. Root Hub
Bus 001 Device 003: ID 0bda:c024 Realtek Semiconductor Corp. Bluetooth Radio 
Bus 001 Device 004: ID 174f:116a Syntek EasyCamera
Bus 002 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 002 Device 002: ID 0483:374b STMicroelectronics ST-LINK/V2.1
Bus 003 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
Enter fullscreen mode Exit fullscreen mode

Let's start with OpenOCD (Open On-Chip Debugger), if you have SWD interface and ST-LINK, the mostly reliable way is to use OpenOCD. It's open source project that supports multiple interfaces and microcontrollers. You can think it as a bridge between ST-LINK and microcontroller. We can use OpenOCD in two different ways. If you just want flashing firmware:

$ openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program firmware.elf verify reset exit"
Enter fullscreen mode Exit fullscreen mode

Output:

Open On-Chip Debugger 0.12.0
Licensed under GNU GPL v2
For bug reports, read
    http://openocd.org/doc/doxygen/bugs.html
Info : auto-selecting first available session transport "hla_swd". To override use 'transport select <transport>'.
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
Info : clock speed 2000 kHz
Info : STLINK V2J33M25 (API v2) VID:PID 0483:374B
Info : Target voltage: 3.223622
Info : [stm32f4x.cpu] Cortex-M4 r0p1 processor detected
Info : [stm32f4x.cpu] target has 6 breakpoints, 4 watchpoints
Info : starting gdb server for stm32f4x.cpu on 3333
Info : Listening on port 3333 for gdb connections
Info : Unable to match requested speed 2000 kHz, using 1800 kHz
Info : Unable to match requested speed 2000 kHz, using 1800 kHz
[stm32f4x.cpu] halted due to debug-request, current mode: Thread 
xPSR: 0x01000000 pc: 0x0800308c msp: 0x20020000
Info : Unable to match requested speed 8000 kHz, using 4000 kHz
Info : Unable to match requested speed 8000 kHz, using 4000 kHz
** Programming Started **
Info : device id = 0x10006421
Info : flash size = 512 KiB
** Programming Finished **
** Verify Started **
** Verified OK **
** Resetting Target **
Info : Unable to match requested speed 2000 kHz, using 1800 kHz
Info : Unable to match requested speed 2000 kHz, using 1800 kHz
shutdown command invoked
Enter fullscreen mode Exit fullscreen mode

Don't forget that if you want to flash firmware.bin, explicitly, declare the flash address:

$ openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program firmware.bin 0x08000000 verify reset exit"
Enter fullscreen mode Exit fullscreen mode

We also want to debug the firmware beside the flashing it. Firstly, open a OpenOCD session in a terminal:

$ openocd -f interface/stlink.cfg -f target/stm32f4x.cfg
Enter fullscreen mode Exit fullscreen mode

Output:

Open On-Chip Debugger 0.12.0
Licensed under GNU GPL v2
For bug reports, read
    http://openocd.org/doc/doxygen/bugs.html
Info : auto-selecting first available session transport "hla_swd". To override use 'transport select <transport>'.
Info : The selected transport took over low-level target control. The results might differ compared to plain JTAG/SWD
Info : Listening on port 6666 for tcl connections
Info : Listening on port 4444 for telnet connections
Info : clock speed 2000 kHz
Info : STLINK V2J33M25 (API v2) VID:PID 0483:374B
Info : Target voltage: 3.229921
Info : [stm32f4x.cpu] Cortex-M4 r0p1 processor detected
Info : [stm32f4x.cpu] target has 6 breakpoints, 4 watchpoints
Info : starting gdb server for stm32f4x.cpu on 3333
Info : Listening on port 3333 for gdb connections
Enter fullscreen mode Exit fullscreen mode

After that in another terminal, with GDB:

$ arm-none-eabi-gdb firmware.elf
Enter fullscreen mode Exit fullscreen mode

You will be in GDB session. In here, you have to bind 3333 port and then load the firmware:

(gdb) target remote localhost:3333
(gdb) monitor reset halt
(gdb) load
Enter fullscreen mode Exit fullscreen mode

At the end, you will see the flashed sections:

Loading section .isr_vector, size 0x1c4 lma 0x8000000
Loading section .text, size 0x470 lma 0x80001c4
Loading section .init_array, size 0x4 lma 0x8000634
Loading section .fini_array, size 0x4 lma 0x8000638
Loading section .data, size 0xc lma 0x800063c
Start address 0x80005c8, load size 1608
Transfer rate: 3 KB/sec, 321 bytes/write.
Enter fullscreen mode Exit fullscreen mode

Firmware is loaded successfully 🥳.

After this phase, we can start debugging. I will show some commonly used commands but as I said before, this post focus on programming and flashing so that debugging is another post's topic.

(gdb) info registers        // show register information
(gdb) break main            // put a breakpoint at main()
(gdb) continue              
(gdb) next                  
(gdb) step
(gdb) x/1wx 0x40023830      // examine the RCC register
Enter fullscreen mode Exit fullscreen mode

Another tool is st-flash. You can think that it's STM32-version of OpenOCD. It uses SWD interface and it is used like this:

$ st-flash write firmware.bin 0x8000000
Enter fullscreen mode Exit fullscreen mode

Output:

st-flash 1.8.0
2025-10-03T12:27:22 INFO common.c: STM32F446: 128 KiB SRAM, 512 KiB flash in at least 128 KiB pages.
file firmware.bin md5 checksum: 19739c7292a119ff141424e01acdf2e8, stlink checksum: 0x0001c4f0
2025-10-03T12:27:22 INFO common_flash.c: Attempting to write 1608 (0x648) bytes to stm32 address: 134217728 (0x8000000)
EraseFlash - Sector:0x0 Size:0x4000 -> Flash page at 0x8000000 erased (size: 0x4000)
2025-10-03T12:27:22 INFO flash_loader.c: Starting Flash write for F2/F4/F7/L4
2025-10-03T12:27:22 INFO flash_loader.c: Successfully loaded flash loader in sram
2025-10-03T12:27:22 INFO flash_loader.c: Clear DFSR
2025-10-03T12:27:22 INFO flash_loader.c: enabling 32-bit flash writes
2025-10-03T12:27:22 INFO common_flash.c: Starting verification of write complete
2025-10-03T12:27:22 INFO common_flash.c: Flash written and verified! jolly good!
Enter fullscreen mode Exit fullscreen mode

dfu-util is a bit different. Despite openocd and st-flash use SWD interface, dfu-util is used with USB connection. Don't forget that you have to put the microcontroller in DFU mode by setting the BOOT0 pin to logic 1 (+3.3V). After that reset the microcontroller and then flash the firmware:

$ dfu-util -a 0 -s 0x08000000:leave -D firmware.bin
Enter fullscreen mode Exit fullscreen mode

Top comments (0)