At some point, blinking LEDs are not enough to understand what your code is actually doing.
You need to print things.
This tutorial shows how to redirect printf() to a serial port on the STM32F3Discovery with a FTDI TTL adapter.
The Github for the current tutorial with full files and code:
IDE:
Board:
Hardware for serial communication:
There are 3 ways to get serial output from the STM32F3Discovery:
The ST-Link V2 on this board does not expose a Virtual COM Port.
Upgrading the firmware to the J48 version does not help, it is a hardware limitation of this version.
The STM32F303 has a built-in USB peripheral connected to USER USB connector on the board.
Using the USB CDC device class, the board appears as /dev/ttyACM0 under WSL2 with no extra hardware.
This approach is covered in a dedicated tutorial:
It requires enabling the USB stack in firmware (USBD_USE_CDC, USBCON) which adds complexity outside the scope of this bare metal series.
The simplest approach.
No USB stack, no firmware complexity.
Just wire 3 pins and open minicom.
This is what we use here.
The FTDI TTL-232R-3V3 is a cable with a USB-A connector on one end and at least 3 wires on the other (GND, TX, RX).
It operates at 3.3V, so fully compatible with the STM32F303.
TX and RX are always crossed between two serial devices: the TX of one side goes to the RX of the other.
This is not optional.
Do not connect the red VCC wire if your cable has one, the board is already powered by USB.
The FTDI uses the FT232 chip (VID:PID 0403:6001).
To check that, simply list the USB devices on your Windows system via PowerShell:
usbipd list
Under WSL2, two steps are needed before the DevContainer can see it.
From your WSL terminal (the Ubuntu Linux instance, not the DevContainer), load the kernel driver :
$ sudo modprobe ftdi_sio
This loads the Linux driver that creates /dev/ttyUSB0 when the FTDI is plugged in.
Without this, WSL sees the USB device but does not know what to do with it.
2. Step two
Attach the BUSID to WSL via usbipd (from PowerShell as administrator):
$ usbipd bind --busid <BUSID> $ usbipd attach --wsl --busid <BUSID>
At this moment the FTDI USB adapter is not available anymore for Windows but for WSL only, so usable by the DevContainer.
Find the BUSID with the usbipd list command for the USB Serial Converter.
Then verify in the DevContainer:
$ lsusb | grep FTDI $ ls /dev/ttyUSB*
UART stands for Universal Asynchronous Receiver Transmitter.
It sends data one bit at a time over a single wire, with no shared clock between the two sides.
Both sides just agree on the speed in advance.
Four parameters must match on both ends:
This combination is called 8N1 and is the most common UART configuration in embedded systems.
On the STM32F303, the peripheral is called USART1, standing for Universal Synchronous/Asynchronous Receiver Transmitter.
The S in USART stands for Synchronous, meaning it also supports a synchronous mode with a shared clock.
We use it in asynchronous mode, so it behaves as a plain UART.
USART1 uses:
These pins must be switched to Alternate Function mode so USART1 takes control of them instead of the GPIO block.
Each GPIO pin on the STM32 can play multiple roles:
The role is selected via two registers:
MODER which sets the pin to Alternate Function mode (value 10 = 0x2):
GPIOA->MODER &= ~((0x3UL << 18) | (0x3UL << 20)); // clear PA9 and PA10 GPIOA->MODER |= ((0x2UL << 18) | (0x2UL << 20)); // set Alternate Function mode
AFR which selects the Alternate Function to route to the pin.
Each pin has a fixed mapping defined by ST in the datasheet.
For PA9 bits [7:4]
0x7 << 4 = 0111 0000
For PA10 bits [11:8]
0x7 << 8 = 0111 0000 0000
So it's the AF7 role, in our case, to map USART1 TX to PA9 and RX to PA10:
GPIOA->AFR[1] &= ~((0xFUL << 4) | (0xFUL << 8)); // clear GPIOA->AFR[1] |= ((0x7UL << 4) | (0x7UL << 8)); // AF7 = USART1
The BRR (Baud Rate Register) is straightforward:
BRR = PCLK / baudrate = 8 000 000 / 115 200 = 69
USART1->BRR = 69;
The newlib's printf() calls _write() for every character it needs to output.
In bare metal, _write() does nothing by default.
We override it in syscall.c to forward each character to USART1:
int _write(int file, char *ptr, int len)
{
(void)file;
for (int i = 0; i < len; i++) {
usart1_putchar(ptr[i]);
}
return len;
}
That single function is all it takes.
printf() now works over UART with no other changes.
The newlib's standard printf() is heavy and may silently hang when formatting integers on bare metal.
Adding --specs=nano.specs, in the CMakeLists.txt file for linker options, switches to a lighter implementation that handles %u, %d, %s correctly:
target_link_options(${PROJECT_NAME} PRIVATE
...
--specs=nosys.specs
--specs=nano.specs
...
)
Without nano.specs, try to use printf("Counter = %u\r\n", counter) may never return.
This is a common bare metal pitfall.
$ cd firmware/stm32f3discovery
$ cmake -B build -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DCMAKE_BUILD_TYPE=Debug -G Ninja
$ ninja -C build
$ openocd -f interface/stlink.cfg \
-f target/stm32f3x.cfg \
-c "program build/stm32f3discovery.bin verify reset exit 0x08000000"
$ sudo minicom -D /dev/ttyUSB0 -b 115200
Open minicom first, then press the black reset button on the board.
You should see:
STM32F3Discovery - UART printf example CPU clock: 8 MHz Baudrate: 115200 --- Hello from STM32 and Badprog! Counter = 0 Hello from STM32 and Badprog! Counter = 1 Hello from STM32 and Badprog! Counter = 2 ...
Press Ctrl+A then X to quit minicom.
The message you see on your minicom terminal is a message sent from the STM32FDiscovery board, generated by the firmware.
So it's made directly inside the MCU.
This tutorial runs on Windows 10 + WSL2 + usbipd.
This setup introduces a virtualization layer between the FTDI hardware and the DevContainer.
At 115200 bauds, some messages may be lost or displayed out of order in minicom.
This is not a bug in the firmware, the STM32 sends the data correctly.
It is a known limitation of USB forwarding under WSL2.
On a native Linux system (Raspberry Pi, bare metal Ubuntu), UART at 115200 bauds with a FTDI adapter is perfectly stable.
printf() over UART is the most practical debugging tool in embedded development.
When you cannot attach a debugger or the timing is too tight for breakpoints, serial output tells you exactly what the code is doing and when.
Good job, you did it ![]()
Add new comment