BlackPill STM32F411CE — Mbed Studio Setup Guide
Board: WeAct STM32F411CE BlackPill v3.1
Framework: Mbed OS 5 (bare-metal, no RTOS)
Toolchain: ARMC6 (Arm Compiler 6, included in Mbed Studio)
Status:
Confirmed working — USB HID keyboard
USB Serial CDC
Clock 96 MHz 
Overview
The WeAct BlackPill is not a supported Mbed target. It is made to work by telling Mbed Studio it is a NUCLEO-F411RE (same STM32F411CE chip) and providing two project-level overrides:
-
A custom_targets.json that names the board and sets the right macros
-
A TARGET_BLACKPILL_F411CE/system_clock.c that configures the 25 MHz HSE crystal and derives 96 MHz SYSCLK + 48 MHz USB clock correctly
Two additional library patches are required for USB to enumerate:
-
USBKeyboard.h — adds a connect_blocking parameter
-
USBHAL_STM32F4.cpp — disables the VBUS comparator (NOVBUSSENS)
Nothing inside mbed-os/ itself needs changing (the USB patches are in the standalone USBDevice library, not in mbed-os).
Project Structure
Every BlackPill project must have this layout:
MyProject/
├── main.cpp
├── custom_targets.json ← tells Mbed Studio about the board
├── mbed_app.json ← sets clock macros for this project
├── TARGET_BLACKPILL_F411CE/
│ └── system_clock.c ← 25 MHz HSE + 96/48 MHz PLL config
├── USBDevice/ ← standalone USB library (if using USB)
│ └── USBHID/
│ └── USBKeyboard.h ← patched (connect_blocking)
│ └── targets/TARGET_STM/
│ └── USBHAL_STM32F4.cpp ← patched (NOVBUSSENS)
└── mbed-os/ ← unmodified
File 1 — custom_targets.json
Place in the project root (same level as main.cpp).
{
"BLACKPILL_F411CE": {
"inherits": ["NUCLEO_F411RE"],
"macros_add": [
"HSE_VALUE=25000000",
"CLOCK_SOURCE_USB=1",
"BLACKPILL"
],
"overrides": {
"clock_source": "USE_PLL_HSE_XTAL|USE_PLL_HSI"
},
"device_has_add": ["USBDEVICE"],
"release_versions": ["5"]
}
}
What each field does:
| Field |
Purpose |
inherits: NUCLEO_F411RE |
Reuses the F411RE pin map, linker script, startup files — the chips are identical |
HSE_VALUE=25000000 |
Tells the clock driver the crystal is 25 MHz (BlackPill), not 8 MHz (Nucleo) |
CLOCK_SOURCE_USB=1 |
Selects the PLL configuration that produces exactly 48 MHz for USB |
BLACKPILL |
Guards platform-specific code in common.h, QS_DRO_KBD.h, etc. |
clock_source override |
USE_PLL_HSE_XTAL = use crystal. USE_PLL_HSI = fallback to internal RC if crystal fails |
device_has_add: USBDEVICE |
Enables USB peripheral support in the build |
File 2 — mbed_app.json
Place in the project root.
{
"config": {},
"target_overrides": {
"*": {
"platform.stdio-baud-rate": 115200
},
"NUCLEO_F411RE": {
"target.clock_source": "USE_PLL_HSE_XTAL|USE_PLL_HSI",
"target.macros_add": [
"HSE_VALUE=25000000",
"CLOCK_SOURCE_USB=1",
"BLACKPILL"
]
}
}
}
Why this is needed in addition to custom_targets.json:
custom_targets.json is read by Mbed Studio’s target selector. mbed_app.json is read by the compiler. Both must agree on HSE_VALUE or clock setup silently uses the wrong value.
The NUCLEO_F411RE key applies because custom_targets.json declares the board as inheriting from NUCLEO_F411RE — the build system resolves overrides through the inheritance chain.
File 3 — TARGET_BLACKPILL_F411CE/system_clock.c
Why a separate folder?
Mbed OS 5 has a built-in system_clock.c for the NUCLEO-F411RE target located deep inside mbed-os/. That file assumes an 8 MHz HSE crystal — wrong for the BlackPill’s 25 MHz crystal. Editing the file inside mbed-os/ would work but is fragile: it gets overwritten if mbed-os is updated, and it breaks every other project that uses NUCLEO-F411RE correctly.
Mbed OS provides a clean override mechanism: if a folder named TARGET_<boardname>/ exists in the project root, any file inside it silently replaces the matching file from mbed-os for that build only. The mbed-os original is untouched. Other projects are unaffected.
The folder name must exactly match the active target
The folder must be named TARGET_BLACKPILL_F411CE — exactly as defined in custom_targets.json. Case sensitive. No spaces.
MyProject/
├── custom_targets.json ← defines "BLACKPILL_F411CE"
├── TARGET_BLACKPILL_F411CE/ ← folder name = "TARGET_" + board name
│ └── system_clock.c ← overrides mbed-os/targets/.../system_clock.c
└── mbed-os/ ← unmodified
If the folder is named anything else (TARGET_NUCLEO_F411RE/, blackpill_clocks/, etc.) Mbed OS ignores it and uses the wrong 8 MHz file. The symptom is the firmware hanging silently before main() — HSE never locks, HAL_RCC_OscConfig() blocks forever.
What the file does
Input: HSE = 25 MHz crystal (BlackPill)
PLLM = 25 → VCO input = 1 MHz
PLLN = 192 → VCO output = 192 MHz
PLLP = /2 → SYSCLK = 96 MHz
PLLQ = /4 → USB clock = 48 MHz ← exactly required by USB spec
| Mode |
SYSCLK |
AHBCLK |
USB clock |
HSE + USB (CLOCK_SOURCE_USB=1) |
96 MHz |
96 MHz |
48 MHz  |
| HSE, no USB |
100 MHz |
100 MHz |
— |
| HSI fallback + USB |
96 MHz |
96 MHz |
48 MHz  |
| HSI fallback, no USB |
100 MHz |
100 MHz |
— |
The HSI fallback path (USE_PLL_HSI in custom_targets.json) means if the 25 MHz crystal fails to start, the firmware falls back to the internal 16 MHz RC oscillator, still reaches main(), and the LED blinks fast — you know the board is alive even though USB won’t work on HSI.
The file is based on hudakz BLACKPILL_Custom_Target (rev 6, Oct 2021) with one correction: HAL_RCC_ClockConfig() was missing in earlier revisions, causing AHB/APB dividers to remain at reset values.
See system_clock.c in the outputs folder for the full file.
COM Port Setup — Two Ports, Two Purposes
This project uses two separate COM ports simultaneously. They are completely independent and both must be open at the same time during development.
| Port |
Source |
Purpose |
Always active? |
| COM6 (ST-Link) |
USART2 on PA_2/PA_3, via ST-Link USB |
Debug printf output — boot messages, state changes, error logging |
Yes — active from first line of main(), regardless of USB state |
| COM3 (USB-C) |
STM32 OTG_FS USB peripheral |
USBSerial CDC virtual COM port — application data |
Only when USB_MODE_SERIAL is flashed and a terminal opens it |
The port numbers (COM3, COM6) are assigned by Windows and will differ on each PC. Find your actual numbers in Device Manager → Ports (COM & LPT):
Setting up the ST-Link debug port (COM6 equivalent)
This port carries all dbg.printf() output and is essential for diagnosing USB enumeration problems. It is active from the very first line of main() regardless of USB state — if USB is broken, this port tells you why.
-
Plug the ST-Link into the PC via USB
-
Open Device Manager → Ports (COM & LPT) — note the STLink Virtual COM Port number
-
Open TeraTerm → Setup → Serial Port:
-
Port: your ST-Link COM number
-
Speed: 115200
-
Data: 8 bit, Parity: none, Stop: 1 bit
-
Flow control: None
-
Click OK — power on or reset the BlackPill, boot messages appear immediately
Setting up the USB-C serial port (COM3 equivalent)
Only needed when USB_MODE_SERIAL is flashed.
-
Plug the USB-C cable from the BlackPill directly into the PC
-
Windows installs usbser.sys automatically — USB Serial Device (COMx) appears in Device Manager within a few seconds
-
Open a second TeraTerm window → Setup → Serial Port:
-
Port: the USB Serial Device COM number
-
Speed: 9600 (any value — CDC ignores baud rate entirely)
-
Flow control: DTR/DSR ← critical — without this connected() stays false and the firmware never sends output
-
Text appears as soon as DTR is asserted by TeraTerm on open
Why two TeraTerm windows at the same time?
The ST-Link port shows what the firmware is doing before USB enumerates — exactly when enumeration problems occur. You cannot diagnose USB issues with only the USB-C port open because if USB is broken, that port shows nothing. Always have both open during development.
How to Select the Board in Mbed Studio
After placing custom_targets.json in the project root:
-
In the bottom toolbar, click the chip icon (next to the target name).
This opens “Manage custom targets”.
-
In the “USB device” dropdown, select your ST-Link programmer (e.g. STM32 STLink).
-
In the “Build target” dropdown, scroll down and select
BLACKPILL_F411CE — it appears because custom_targets.json is present.
-
Click Save All.
-
Build (hammer icon). Flash (play icon or drag .bin to NUCLEO virtual disk).
Note: The ST-Link connects to the BlackPill via SWD (4 wires: SWDIO, SWDCLK, GND, 3.3V). The BlackPill’s own USB-C port is separate — it is the USB HID device port, not the programming port.
USB Library Patches
These patches apply to the standalone USBDevice library (c:\mbed_DesktopStudio\USBKeyboard\USBDevice\), not to mbed-os itself.
Patch 1 — USBDevice/USBHID/USBKeyboard.h
Problem: The original constructor calls connect() unconditionally, blocking forever if the USB host is not present at boot time.
Fix: Add connect_blocking as a 4th parameter defaulting to true (backwards compatible). Pass false for non-blocking hot-plug operation.
// BEFORE (always blocks):
USBKeyboard(uint16_t vendor_id = 0x1235,
uint16_t product_id = 0x0050,
uint16_t product_release = 0x0001):
USBHID(0, 0, vendor_id, product_id, product_release, false) {
lock_status = 0;
connect(); // ← always blocks until host enumerates
};
// AFTER (caller chooses):
USBKeyboard(uint16_t vendor_id = 0x1235,
uint16_t product_id = 0x0050,
uint16_t product_release = 0x0001,
bool connect_blocking = true): // ← new parameter
USBHID(0, 0, vendor_id, product_id, product_release, false) {
lock_status = 0;
if (connect_blocking) { connect(); } // ← guarded
};
Patch 2 — USBDevice/targets/TARGET_STM/USBHAL_STM32F4.cpp
Problem: GCCFG bit 19 (VBUSBSEN) enables the OTG_FS internal VBUS comparator. The comparator requires >4.75V on PA_9 to assert VBUS valid. The WeAct BlackPill v3.1 has a 5.1kΩ/5.1kΩ voltage divider on PA_9, presenting only ~2.5V. The comparator never fires → D+ never asserted → host never sees the device → configured() stays false forever.
Fix: Replace bit 19 with bit 21 (NOVBUSSENS) — bypasses the comparator entirely. Also change PA_9’s pin_function from OTG_FS_VBUS to plain GPIO so the OTG peripheral does not own the pin.
// BEFORE — blocks enumeration on BlackPill:
pin_function(PA_9, STM_PIN_DATA(STM_MODE_INPUT, GPIO_PULLDOWN, GPIO_AF10_OTG_FS));
// ...
OTG_FS->GREGS.GCCFG |= (1 << 19) | // Enable VBUS sensing ← WRONG for BlackPill
(1 << 16); // Power Up
// AFTER — enumerates correctly:
pin_function(PA_9, STM_PIN_DATA(STM_MODE_INPUT, GPIO_PULLDOWN, 0)); // GPIO only
// ...
OTG_FS->GREGS.GCCFG |= (1 << 21) | // NOVBUSSENS — bypass comparator ← FIXED
(1 << 16); // PWRDWN — power up PHY
Why PA_9 still works as VBUS indicator:
The firmware reads PA_9 as a plain DigitalIn for software VBUS detection (DigitalIn vbus(PA_9, PullDown)). The divider presents a valid logic HIGH when USB-C is plugged in. Only the OTG hardware comparator (which needs full 5V) is bypassed. Software VBUS sensing continues to work normally.
Confirmed Working — Serial Output
With all four changes in place:
=== BOOT ===
SystemCoreClock = 96000000 Hz
Creating USBKeyboard(VID, PID, REL)...
Object created. Calling connect(false)...
connect(false) returned. Entering loop...
[USB] Configured — sending test keystroke
Hello!Hello!Hello!Hello!Hello!Hello!...
Clock confirmed: 96 MHz (HSE 25 MHz, PLL ×192 ÷2).
USB confirmed: HID keyboard device enumerated, keystrokes received on host PC.
Master File List — What to Change and What to Leave Alone
This is the complete and definitive list of every file involved in the BlackPill port. Nothing outside this list needs touching.
Files you CREATE (do not exist yet — add to project root)
| File |
Location |
Change |
custom_targets.json |
MyProject/ |
Create — defines BLACKPILL_F411CE target inheriting from NUCLEO_F411RE |
mbed_app.json |
MyProject/ |
Create — sets HSE_VALUE=25000000, CLOCK_SOURCE_USB=1, BLACKPILL for compiler |
TARGET_BLACKPILL_F411CE/system_clock.c |
MyProject/TARGET_BLACKPILL_F411CE/ |
Create folder + file — 25 MHz HSE → 96/48 MHz PLL; overrides mbed-os built-in |
Files you PATCH (exist in USBDevice library — modify in place)
| File |
Location |
Change |
Why |
USBKeyboard.h |
USBDevice/USBHID/ |
Add bool connect_blocking = true as 4th constructor parameter; guard connect() call |
Original always blocks in constructor — device hangs if USB-C not plugged in at boot |
USBHAL_STM32F4.cpp |
USBDevice/targets/TARGET_STM/ |
Line ~59: PA_9 AF from GPIO_AF10_OTG_FS → 0. Line ~105: GCCFG bit 19 → bit 21 (NOVBUSSENS) |
BlackPill 5.1k/5.1k VBUS divider gives 2.5V; OTG comparator needs >4.75V; D+ never asserts without this fix |
Files in mbed-os you DO NOT touch
| File |
Location |
Why untouched |
system_clock.c (original) |
mbed-os/targets/TARGET_STM/TARGET_STM32F4/TARGET_STM32F411xE/ |
Overridden by project-local TARGET_BLACKPILL_F411CE/system_clock.c — original never compiled |
USBPhy_STM32.cpp |
mbed-os/targets/TARGET_STM/TARGET_STM32/ |
This is the mbed-os 5.14 USB stack — not used; this project uses the standalone USBDevice library instead |
PinNames.h |
mbed-os/targets/.../NUCLEO_F411RE/ |
BlackPill pin names are compatible with NUCLEO_F411RE — inherited as-is |
| All other mbed-os files |
mbed-os/ |
Unmodified — mbed-os is used as a library only |
Files in USBDevice you DO NOT touch
| File |
Why untouched |
USBDevice.cpp / USBDevice.h |
connect(bool blocking) already exists and works correctly |
USBHAL.h |
Interface header — no changes needed |
USBHAL_STM_144_64pins.h |
Never compiled — only included when USB_STM_HAL is defined, which this project does not define |
USBCDC.cpp / USBCDC.h |
Works correctly as-is — connect_blocking already present |
USBSerial.cpp / USBSerial.h |
Works correctly as-is — connect_blocking already present |
USBAudio/, USBMIDI/, USBMSD/ |
Not used by this project |
Summary count
| Category |
Count |
Files |
| Files to create |
3 |
custom_targets.json, mbed_app.json, system_clock.c |
| Files to patch |
2 |
USBKeyboard.h, USBHAL_STM32F4.cpp |
| mbed-os files touched |
0 |
— |
| Total changes |
5 |
|
Troubleshooting
| Symptom |
Cause |
Fix |
| No blink, no serial output |
system_clock.c not found — hanging in HSE startup |
Verify TARGET_BLACKPILL_F411CE/system_clock.c exists in project root |
| Serial output but wrong clock speed |
mbed_app.json missing or wrong HSE_VALUE |
Check HSE_VALUE=25000000 in mbed_app.json |
| Constructor hangs (“Calling USBKeyboard…” never appears) |
Old USBKeyboard.h without connect_blocking |
Apply Patch 1 above |
| “Not configured yet” forever |
VBUS comparator blocking (VBUSBSEN bit 19) |
Apply Patch 2 above |
BLACKPILL_F411CE not in target dropdown |
custom_targets.json not in project root |
Move it to root, restart Mbed Studio, use chip icon |
| Build errors about duplicate symbols |
USBHAL_STM_144_64pins.h included twice |
Ensure USB_STM_HAL is NOT defined — this project must use the USBHAL_STM32F4.cpp path |
USBSerial — CDC Virtual COM Port
USBSerial is in the same standalone USBDevice library and works with the same NOVBUSSENS patch already applied. No additional library patches are needed.
What you get
Plugging the USB-C connector into a PC creates a virtual COM port (COMx on Windows, /dev/ttyACMx on Linux). Any terminal application can open it. The ST-Link UART on PA_2/PA_3 continues to work simultaneously as a separate debug channel.
Windows driver
Windows 10/11 installs the CDC driver automatically (Microsoft usbser.sys). No INF file required. The device appears as “USB Serial Device (COMx)” in Device Manager under “Ports (COM & LPT)”.
Critical rule — always gate writes on connected()
USBSerial::writeBlock(), printf(), and _putc() are blocking calls. They wait forever for the host to ACK if nobody is draining the endpoint. If you call any write function when no terminal is open, the firmware freezes.
configured() = true → COM port exists in Device Manager, safe for reads
connected() = true → terminal has asserted DTR, safe to write
Always gate every write on connected(), never on configured() alone.
if (usbser->connected()) // ← DTR asserted — safe to write
{
usbser->printf("data\r\n");
}
connected() requires DTR
connected() goes true only when the terminal asserts the DTR control line. Different terminals handle this differently:
| Terminal |
DTR behaviour |
What to do |
| TeraTerm |
DTR off by default |
Setup → Serial Port → Flow Control: DTR/DSR |
| PuTTY |
DTR on automatically |
Open port — works immediately |
| Arduino Serial Monitor |
DTR on automatically |
Open port — works immediately |
Constructor — do NOT call connect() again
USBCDC (base of USBSerial) already calls USBDevice::connect() inside its constructor. Calling connect() a second time resets the USB state machine mid-enumeration and prevents configured() from ever becoming true.
// CORRECT:
USBSerial *usbser = new USBSerial(0x1f00, 0x2012, 0x0001, false);
// ← do NOT call usbser->connect() here
// WRONG — resets USB mid-enumeration, configured() stays 0:
USBSerial *usbser = new USBSerial(0x1f00, 0x2012, 0x0001, false);
usbser->connect(false); // ← kills enumeration
This is the opposite of USBKeyboard (which has our patch removing connect from the constructor — so USBKeyboard requires an explicit connect() call).
| Class |
Constructor calls connect()? |
Explicit connect() needed? |
USBSerial / USBCDC |
Yes (always) |
No — never call it |
USBKeyboard (patched) |
No (patched out) |
Yes — call it once |
Minimal working pattern
#include "mbed.h"
#include "USBSerial.h"
USBSerial *usbser = new USBSerial(0x1f00, 0x2012, 0x0001, false);
// No connect() call here
while (true)
{
if (usbser->connected()) // terminal has DTR — safe to write
{
usbser->printf("hello\r\n");
}
wait_us(1000000);
}
Confirmed working output
On TeraTerm (COM3, Flow Control: DTR/DSR):
=== BlackPill USBSerial ===
Clock = 96000000 Hz
Type anything to echo.
[106 s] alive
[107 s] alive
On ST-Link UART (COM6):
[USB] configured -> 1
[USB] connected(DTR) -> 1
Choosing Between USBSerial and USBKeyboard
The USB peripheral can only be one device at a time. You cannot run both simultaneously — switch by reflashing.
| Use case |
Choose |
| DRO sending keystrokes to CAM software |
USBKeyboard |
| Debug / position data logging to PC terminal |
USBSerial |
| Bench development |
USBSerial (easier to read) |
| Production deployment |
USBKeyboard |
HID Keyboard — Keystroke Timing
USBKeyboard::printf() sends characters in a tight loop with no gap. The HID polling interval is 8 ms (bInterval=8 in the endpoint descriptor). Sending faster than the poll interval causes the host to see overlapping key-down events before key-up is processed, producing garbled output like GrHelelo instead of Hello.
Always insert an 8 ms delay between keystrokes:
// WRONG — garbles on fast hosts:
kbd->printf("Hello!\n");
// CORRECT — clean output on all hosts:
const char *msg = "Hello!\n";
for (const char *p = msg; *p; p++)
{
kbd->putc(*p);
wait_us(8000); // 8 ms = one HID report interval
}
This applies to every string send in the real DRO — position digits, units, newlines. 8 ms per character is imperceptible to a human but essential for reliable HID delivery.
Multiple Source Files — Linker Error
Mbed OS 5 compiles every .cpp file found anywhere in the project tree. If more than one file contains main(), the linker dies with:
L6200E: Symbol $Super$$main multiply defined
Only one .cpp file in the entire project tree may contain main(). Keep old test files outside the project folder or delete them. Never leave main_test.cpp, main_old.cpp, etc. inside the project directory.
Switching Between USB Modes
The main_usb_dual.cpp file selects the USB mode at compile time:
#define USB_MODE_SERIAL // USB-C = virtual COM port (CDC)
// #define USB_MODE_KEYBOARD // USB-C = HID keyboard
Change the define, rebuild, reflash. Both modes confirmed working:
| Mode |
Host sees |
Open with |
USB_MODE_SERIAL |
COMx / ttyACMx |
TeraTerm (Flow: DTR/DSR), PuTTY, Arduino Monitor |
USB_MODE_KEYBOARD |
HID keyboard |
Any text editor — keystrokes appear at cursor |
Document created: 2026-03-08. Confirmed working on WeAct BlackPill v3.1, Mbed Studio 1.4, ARMC6, Mbed OS 5.14, standalone USBDevice library.