Electronics - STM32 - UART printf debugging with FTDI on STM32F3Discovery

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.

Shopping list

The Github for the current tutorial with full files and code:

https://github.com/badprog/badprog-electronics-stm32-uart-printf-debugging-with-ftdi-on-stm32f3discovery

IDE:

  • VSCode
  • DevContainer extension

Board:

Hardware for serial communication:

  • FTDI TTL-232R-3V3 adapter (USB to TTL serial, 3.3V)

Why use an FTDI cable?

There are 3 ways to get serial output from the STM32F3Discovery:

ST-Link VCP

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.

 

USB CDC via the USER USB connector

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:

https://www.badprog.com/ros2-micro-ros-using-stm32-usb-serial-port-to-communicate-with-a-linux-host-via-wsl-2

It requires enabling the USB stack in firmware (USBD_USE_CDC, USBCON) which adds complexity outside the scope of this bare metal series.

FTDI USB-to-UART cable

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.

Hardware connections

  • FTDI black wire (GND) → GND pin on the F3Discovery
  • FTDI orange wire (TX) → PA10 (RX of STM32)
  • FTDI yellow wire (RX) → PA9  (TX of STM32)

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.

Setting up the FTDI in WSL2

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.

1. Step one

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*

What is UART?

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:

  • Baudrate: bits per second.
    • We use 115200.
  • Data bits: bits per frame.
    • We use 8.
  • Parity: error detection.
    • We use none (N).
  • Stop bits: end-of-frame marker.
    • We use 1.

This combination is called 8N1 and is the most common UART configuration in embedded systems.

What is USART1?

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:

  • PA9 TX (transmit)
  • PA10 RX (receive)

These pins must be switched to Alternate Function mode so USART1 takes control of them instead of the GPIO block.

Alternate Function

Each GPIO pin on the STM32 can play multiple roles:

  • plain GPIO
  • UART TX
  • SPI clock
  • I2C data
  • etc.

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

 

Baudrate calculation

The BRR (Baud Rate Register) is straightforward:

BRR = PCLK / baudrate = 8 000 000 / 115 200 = 69
USART1->BRR = 69;

Redirecting printf() to UART

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.

nano.specs - Do not skip this

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.

Build and flash

$ 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"

Reading the output with minicom

$ 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.

Known limitations

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.

Conclusion

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 laugh

Add new comment

Plain text

  • No HTML tags allowed.
  • Lines and paragraphs break automatically.