A CAN ↔ USB transceiver built from a NUCLEO-G474RE board, a Waveshare SN65HVD230 CAN transceiver and any FT232 USB↔UART adapter. The firmware bridges the CAN bus onto a serial link using a small framed binary protocol with a CRC-16 per frame.
The bus runs at a hardcoded 500 kbit/s; the serial link runs at 921600
baud, 8N1. Changing the bitrate means editing MX_FDCAN1_Init() (see
Changing the CAN bitrate).
Desktop application: https://github.com/kurta999/CustomKeyboard (this is the project formerly known as WindowsAddon — the repository was renamed, so any older links pointing at
WindowsAddonshould be updated).
Just want a CAN adapter and not a project? Buy a Lawicel CANUSB instead. This repository exists because building one is more fun.
- Features
- Hardware
- Wiring
- Wire protocol
- How it works
- Building the firmware
- Flashing
- Configuration
- Project layout
- Known limitations
- Troubleshooting
- Screenshots
- Contributing
- License
- Bidirectional CAN ↔ UART bridge, 11-bit and 29-bit identifiers.
- CRC-16 protected framing in both directions, so a corrupted serial byte is dropped rather than injected onto the bus.
- Idle-gap framing on the serial link — no escaping, no start/stop bytes.
- FreeRTOS (CMSIS-RTOS v1) with a single worker task; the CAN RX path runs straight from the FDCAN interrupt for low latency.
- Independent watchdog (2 s) recovers the bridge from any hang.
- Status LED (Nucleo LD2) toggles on every forwarded CAN frame; a rapid blink
burst signals
Error_Handler(). - Builds both in STM32CubeIDE and from a plain CMake + arm-none-eabi-gcc command line, with CI building both configurations on every push.
| Part | Notes |
|---|---|
| NUCLEO-G474RE | STM32G474RET6, Cortex-M4F @ 80 MHz, on-board ST-LINK |
| Waveshare SN65HVD230 CAN board | 3.3 V CAN transceiver, has a 120 Ω termination jumper |
| Any FT232 USB↔UART adapter | Must be switched to 3.3 V logic levels |
| 120 Ω termination | One at each end of the bus |
The MCU runs from the internal HSI16 oscillator through the PLL
(M=1, N=10, R=2) at 80 MHz — no external crystal is required.
| STM32 pin | Function | Connect to |
|---|---|---|
PA11 |
FDCAN1_RX (AF9) |
SN65HVD230 RXD |
PA12 |
FDCAN1_TX (AF9) |
SN65HVD230 TXD |
PC10 |
UART4_TX (AF5) |
FT232 RXD |
PC11 |
UART4_RX (AF5) |
FT232 TXD |
PA5 |
LED_OK output |
Nucleo LD2 (on board) |
PC13 |
USER_Button input |
Nucleo B1 (on board) |
3V3 / GND |
Power | SN65HVD230 and FT232 |
On the Nucleo board
PA12is shared with the ST morpho connector. Make sure nothing else drivesPA11/PA12, and keep the FT232 at 3.3 V logic — a 5 V adapter will damage the MCU pins.
The CAN side is a normal two-wire differential bus: CANH ↔ CANH, CANL ↔ CANL, with 120 Ω across the pair at both physical ends.
Every message in either direction is the same fixed 19-byte packed structure, transmitted little endian:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | magic_number |
0xAABBCCDD host → device, 0xAABBCCDE device → host |
| 4 | 4 | frame_id |
CAN identifier. > 0x7FF is sent as a 29-bit extended ID |
| 8 | 1 | data_len |
Payload length, 0–8 |
| 9 | 8 | data |
Payload. Bytes beyond data_len are transmitted but ignored |
| 17 | 2 | crc |
CRC-16 over bytes 0–16 |
CRC-16 is the reflected CRC-16/IBM (MODBUS) variant: polynomial 0xA001,
initial value 0xFFFF, no final XOR, computed over the first 17 bytes of the
structure. See Core/Src/crc.c — the desktop application
uses the identical routine.
Framing. There are no start or stop delimiters. Host → device frames are delimited by an idle gap on the line: TIM16 is reset on every received byte and fires after roughly 102 µs of silence (~9 byte times at 921600 baud), which tells the worker task that a complete frame has arrived. Send the 19 bytes of a frame back to back, then pause before the next one.
A frame is dropped silently when the magic number does not match, fewer than 19
bytes arrived, the CRC does not verify, or data_len exceeds 8.
CAN bus ── SN65HVD230 ──> FDCAN1 RX FIFO0 (11-bit) / FIFO1 (29-bit)
│ interrupt
▼
FDCAN_ForwardRxFrameToUart()
build 19-byte frame + CRC-16
│
▼
Host <── FT232 <── UART4 TX (921600 8N1)
Host ──> FT232 ──> UART4 RX interrupt ──> 512-byte circular buffer
│ TIM16 idle-gap timeout (~102 µs)
▼
StartDefaultTask()
verify magic + length + CRC-16
│
▼
FDCAN1 TX FIFO queue ──> SN65HVD230 ──> CAN bus
CAN bit timing. The FDCAN kernel clock is PCLK1 = 80 MHz. The nominal phase
uses prescaler 1, tseg1 119, tseg2 40 → 160 time quanta → 500 kbit/s
with a 75 % sample point. The controller is initialised in FDCAN_FRAME_FD_BRS
mode so it can receive CAN FD frames, but everything it transmits is classic
CAN with BRS off. Auto-retransmission is disabled: a frame that loses
arbitration or is not acknowledged is not retried.
Filters. Standard IDs are routed to RX FIFO 0 and extended IDs to RX FIFO 1; the global filter is configured to accept non-matching frames as well, so the bridge is effectively promiscuous.
Watchdog. The IWDG is configured with prescaler 16 and reload 3999 against the ~32 kHz LSI, giving a 2 second timeout. It is refreshed once per millisecond from the worker task, and frozen while the debugger has the core halted.
- File → Import → Existing Projects into Workspace, select this repository.
- Pick the
DebugorReleaseconfiguration and build.
The CubeMX configuration lives in CAN.ioc. Regenerating code is
safe as long as edits stay inside the /* USER CODE BEGIN ... */ markers.
Requires CMake ≥ 3.20, Ninja and the
Arm GNU Toolchain
(arm-none-eabi-gcc, 10 or newer; CI uses 13.2).
cmake -B build -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi-gcc.cmake \
-DCMAKE_BUILD_TYPE=Release
cmake --build buildThis produces build/CanUsbTransceiver.elf, .hex, .bin and a .map, and
prints the flash/RAM usage. The CMake project uses the same MCU flags, defines
and linker script as the CubeIDE build — both build systems share the same
sources, so you can use whichever you prefer.
If the toolchain is not on PATH:
cmake -B build -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi-gcc.cmake \
-DTOOLCHAIN_PREFIX=/opt/gcc-arm-none-eabi-13.2| CMake option | Default | Meaning |
|---|---|---|
CMAKE_BUILD_TYPE |
Release |
Release = -Os, Debug = -Og -g3 + DEBUG define |
ENABLE_DEBUG_PRINTF |
OFF |
Compile the DBG_PRINTF() traces in (see Configuration) |
-
.github/workflows/ci.ymlbuilds Debug and Release on every push and pull request, uploads the binaries as artifacts and runscppcheckover the application sources. -
.github/workflows/release.ymlbuilds a Release image, generatesSHA256SUMSand publishes a GitHub Release whenever av*tag is pushed:git tag v1.0.0 && git push origin v1.0.0
With the on-board ST-LINK connected over USB:
# stlink-tools
st-flash --reset write build/CanUsbTransceiver.bin 0x08000000
# or the CMake convenience target
cmake --build build --target flash
# or STM32CubeProgrammer
STM32_Programmer_CLI -c port=SWD -w build/CanUsbTransceiver.hex -rstAlternatively, drag CanUsbTransceiver.bin onto the NODE_G474RE mass-storage
drive that the ST-LINK exposes.
Everything tunable lives at the top of Core/Src/main.c:
| Define | Default | Meaning |
|---|---|---|
FDCAN_RX_DATA_SIZE |
64 |
RX payload buffer; must stay ≥ the largest FD frame the controller may deliver |
UART_RX_FRAME_MAX |
64 |
Longest serial burst kept by the parser |
UART_TX_TIMEOUT_MS |
100 |
Blocking UART transmit timeout for one frame |
FDCAN_TX_PENDING_TIMEOUT_MS |
10 |
Upper bound on waiting for a CAN frame to leave the controller |
MAGIC_HOST_TO_CAN / MAGIC_CAN_TO_HOST |
0xAABBCCDD / 0xAABBCCDE |
Protocol frame markers |
ENABLE_DEBUG_PRINTF |
unset | Enables DBG_PRINTF() traces |
DBG_PRINTF() is compiled out by default on purpose: __io_putchar() is not
implemented on this board, and printf() would allocate newlib's stdout buffer
from the heap and format on a 1.5 kB task stack — from interrupt context in
some call sites. Provide an __io_putchar() first, then build with
-DENABLE_DEBUG_PRINTF=ON.
Edit MX_FDCAN1_Init() in Core/Src/main.c. With a 80 MHz kernel clock:
bitrate = 80 MHz / (NominalPrescaler * (1 + NominalTimeSeg1 + NominalTimeSeg2))
The shipped values — prescaler 1, tseg1 119, tseg2 40 — give 500 kbit/s.
For 250 kbit/s, double the prescaler to 2. Keep NominalSyncJumpWidth
≤ NominalTimeSeg2. The desktop application must use the same bitrate.
Edit huart4.Init.BaudRate in MX_UART4_Init(). If you go much slower, also
raise the TIM16 period in MX_TIM16_Init() so the idle-gap timeout stays longer
than one byte time.
├── CMakeLists.txt CMake build (mirrors the CubeIDE settings)
├── cmake/arm-none-eabi-gcc.cmake Toolchain file
├── CAN.ioc STM32CubeMX configuration
├── STM32G474RETX_FLASH.ld Linker script (flash image)
├── STM32G474RETX_RAM.ld Linker script (RAM image, unused by default)
├── Core/
│ ├── Inc/ Application headers, FreeRTOSConfig.h, HAL conf
│ ├── Src/
│ │ ├── main.c Peripheral init, CAN/UART bridging, worker task
│ │ ├── circbuff.c Lock-free single-producer/consumer byte FIFO
│ │ ├── crc.c CRC-16 (0xA001, init 0xFFFF)
│ │ ├── app_freertos.c FreeRTOS hooks
│ │ └── stm32g4xx_*.c CubeMX generated MSP / interrupt handlers
│ └── Startup/ Vector table and reset handler
├── Drivers/ STM32G4 HAL + CMSIS (vendor code)
├── Middlewares/Third_Party/FreeRTOS/ FreeRTOS kernel (vendor code)
└── github_screens/ Photos of the assembled hardware
- The CAN bitrate (500 kbit/s) and the UART baudrate (921600) are compile-time constants; there is no host command to change them at runtime.
- Only classic CAN frames with up to 8 payload bytes are bridged. CAN FD frames
longer than 8 bytes are received by the controller but reported to the host
with
data_len = 0. - Remote frames, error frames and bus-off state are not reported to the host.
- Auto-retransmission is disabled, so a frame that loses arbitration is dropped rather than retried.
- The serial link has no flow control; a host that stops reading will lose frames rather than back-pressure the bridge.
| Symptom | Likely cause |
|---|---|
| No CAN frames reach the host | CANH/CANL swapped, missing 120 Ω termination, or a bitrate mismatch with the other node |
| LED blinks a fast burst, then the board resets | Error_Handler() was reached; the watchdog then resets the MCU after 2 s |
| Host receives garbage | FT232 not at 921600 8N1, or 5 V logic levels on the UART pins |
| Frames are rejected as bad CRC | The host is not sending the 19 bytes back to back, so the idle-gap timeout split the frame |
| Nothing on the bus at all | The SN65HVD230 RS pin must be low (high-speed mode), not left floating high |
Bug reports and pull requests are welcome — see
CONTRIBUTING.md. Keep hand-written code inside the
/* USER CODE BEGIN ... END */ markers so STM32CubeMX regeneration does not
wipe it.
Everything
Nucleo
Waveshare CAN Transceiver
FT232
RS232
MIT — see LICENSE.md.
The Drivers/ and Middlewares/ directories contain STMicroelectronics HAL /
CMSIS code and the FreeRTOS kernel, which carry their own licenses (see the
LICENSE.txt files in those directories).




