Two Boards, One CAN Bus
Six Bugs Between Total Silence and a Real ACK
— Debug Timeline —
How the two-board debug unfolded
— CAN Basics —
FDCAN vs FlexCAN — same protocol, unrelated register maps
STM32H753ZI's FDCAN1 and K64F's FlexCAN0 both speak ISO 11898-1 CAN on the wire — a frame captured off one bus looks identical regardless of which silicon sent it. But the peripherals themselves share nothing. FDCAN is Bosch's M_CAN IP, message-RAM based, with a control register (CCCR) whose INIT/CCE bits gate every protected write. FlexCAN is NXP's own design, mailbox based, with its own freeze-mode / FRZACK handshake and an entirely different bit-timing register layout.
The practical consequence for this project: every register offset, every bit position, every "how do I know the module actually left init mode" answer had to be looked up twice, once per datasheet, with zero code reuse between the two drivers beyond the shared frame_t struct shape.
Why loopback passing didn't mean the real bus would work
Both drivers were validated first in internal loopback mode — FDCAN's CCCR.TEST + TEST.LBCK, FlexCAN's CTRL1.LPB. Both passed cleanly: transmitted frame comes back through the Rx side, ID/data/CRC all match. It felt like strong evidence the drivers were correct.
It wasn't sufficient evidence. Internal loopback on both families routes the transmitted bit stream straight back to the receiver internally — per RM0433, the physical TX pin is explicitly held recessive in this mode. K64F's reference manual describes the equivalent behavior. That means loopback validates the protocol-level logic (message RAM layout, CS/status bit handling, filter configuration) but says nothing about whether the pin actually toggles, whether the bit-timing produces the intended real-world frequency, or whether the module can synchronize to an external bus at all.
Every one of the six bugs below is invisible from loopback. All of them only surfaced once real bus communication — real transceivers, real ACK slots, a real second node — entered the picture.
Physical layer — SN65HVD230, straight not crossed
Both boards use SN65HVD230 transceivers (3.3V native, no level shifting needed). TX/RX are per-board — each board's own TX goes to its own transceiver's TXD, each board's own RX comes from its own transceiver's RXD. CANH and CANL, on the other hand, are not crossed — CANH connects straight to CANH, CANL straight to CANL, because both transceivers sit on the same shared differential bus rather than a point-to-point link. 120Ω termination sits at both physical ends of that bus, and the RS pin on each transceiver is tied to GND for high-speed mode.
Getting this backwards (crossing CANH/CANL like a UART TX/RX swap) was an early false lead we ruled out with a continuity check before the real bugs — worth stating plainly here since it's the most common physical-layer mistake to reach for first.
— Root Causes —
Bug 1 — K64F: CLKSRC must be written while still in Disable mode
K64F's init cleared MCR.MDIS (enabling the module) before setting CTRL1.CLKSRC. This looked harmless — both writes happen a few lines apart in the same function, order shouldn't matter for two independent bit fields.
The K64 Sub-Family Reference Manual (§49.3.3, CTRL1 CLKSRC) says otherwise: "In order to guarantee reliable operation, this bit can be written only in Disable mode because it is blocked by hardware in other modes." Disable mode is MCR.MDIS=1 — exactly the state right after reset, and exactly the state we'd already left by the time CLKSRC was written.
The write wasn't rejected loudly. It was simply ignored by hardware. The module was left running on whatever clock source the reset default pointed to, and without a valid protocol-engine clock it could never assert FRZACK, never leave its post-reset "not ready" state, and — as an unexpected side effect during debugging — a subsequent UART TX call stalled indefinitely, because the un-clocked FlexCAN write appeared to interact with an unrelated bus transaction.
/* 1. Clock gate + pins */
SIM->SCGC6 |= SIM_SCGC6_FLEXCAN0_MASK;
SIM->SCGC5 |= SIM_SCGC5_PORTB_MASK;
/* 2. Set CLKSRC WHILE STILL IN DISABLE MODE — must happen before MDIS clear */
FLEXCAN0->CTRL1 |= CAN_CTRL1_CLKSRC_MASK;
/* 3. Enable module (clear MDIS) — now safe, CLKSRC already latched */
FLEXCAN0->MCR &= ~CAN_MCR_MDIS_MASK;
This was the single hardest bug to trace on the K64F side — the symptom (FRZACK never asserts, module stuck "Synchronizing") looked identical to a dozen other possible causes: wrong pins, wrong bit-timing, dead transceiver, floating RX. Only reading the reference manual's exact wording on Disable mode pointed at the real order-of-operations problem.
Bug 2 — STM32: RCC_D2CCIP1R defined at the wrong address offset
The FDCAN kernel clock source selector was defined as RCC_BASE + 0x094. RM0433 gives the "RCC domain 2 kernel clock configuration register (RCC_D2CCIP1R)" an address offset of 0x050. The 0x094 offset isn't a typo that happens to land on reserved space — it falls inside a different, real RCC register in the APB1L reset range.
The practical effect was subtle rather than catastrophic: FDCANSEL's reset default already selects HSE (the clock source we wanted), so every FDCAN transmit still technically worked in isolation and even passed loopback. The real cost was writing an unintended bit into whatever register actually lives at 0x094 — and losing the ability to verify or change the FDCAN clock source at all, since every attempt was silently landing somewhere else.
Confirmed against RM0433 §8.7 register offset table before changing.
Board was fully power-cycled afterward (not just reset) to clear whatever the stray 0x094 write had set.
Bug 3 — STM32: FDCAN_TXBC.NDTB written to bit 24, which is inside TFQS
RM0433's FDCAN_TXBC register places the fields as: bit 30 TFQM, bits[29:24] TFQS (Tx FIFO/queue size), bits[21:16] NDTB (number of dedicated Tx buffers), bits[15:2] TBSA (start address). The driver wrote (1U << 24U) | TBSA, intending to set NDTB=1 — but bit 24 sits at the low end of TFQS, not inside NDTB at all.
The result: TFQS silently became 1 (a 1-element Tx FIFO/queue got configured, which nothing in the driver used), while NDTB stayed at its reset value of 0 — meaning zero dedicated Tx buffers were ever recognized by hardware. fdcan_transmit() was writing frame data into the message RAM region intended as the dedicated Tx buffer, then requesting transmission via TXBAR — a request hardware had no reason to honor, because that RAM region was never registered as a valid buffer.
((TX_BUF_OFFSET / 4U) << 2U)); /* TBSA */
/* was: (1U << 24U) — that bit lives inside TFQS[29:24], not NDTB[21:16] */
Bug 4 — STM32: FDCAN_PSR defined at the offset that's actually FDCAN_TSCV
RM0433 lists two adjacent-ish registers: FDCAN_TSCV (timestamp counter value) at offset 0x0024, and FDCAN_PSR (protocol status register, containing the ACT/BO/LEC fields we actually needed) at offset 0x0044. The driver defined FDCAN1_PSR at 0x024 — the timestamp register, not protocol status.
TSCV defaults to reading 0x0000 unconditionally when the timestamp feature is disabled (TSCC.TSS=00, the reset default) — which it was, since nothing in the driver ever touched TSCC. So every debug print of "PSR" for the entire debugging session showed a flat, unchanging 0, regardless of what was actually happening on the bus. Every fix tested against that reading looked like it made no difference — because the thing being read couldn't have changed no matter what was fixed elsewhere.
uint32_t act = (psr >> 3U) & 0x3U; /* ACT lives at bits[4:3], also previously misread from bit 16 */
This bug compounded a second, smaller mistake: even after fixing the offset, an early debug print read ACT from bit 16 instead of the correct bits[4:3]. Both had to be fixed together before the "ACT=1 (Idle)" reading that finally confirmed the module had synchronized.
Bug 5 — STM32: FDCAN_NBTP field layout swapped
RM0433's actual FDCAN_NBTP layout: bits[31:25] NSJW, bits[24:16] NBRP, bits[15:8] NTSEG1, bits[6:0] NTSEG2. The driver's comment — and its shifts — assumed [24:16]=NTSEG1, [14:8]=NTSEG2, [8:0]=NBRP. The four numeric values chosen (0, 4, 1, 0 respectively) were correct for a 1 Mbit target at 8 MHz — they were just being poured into the wrong buckets.
With the fields swapped, the hardware actually computed NBRP=4 (prescaler=5), NTSEG1=1 (tBS1=2), NTSEG2=0 (tBS2=1) — a bit time of (1+2+1)=4 time quanta at 0.625 µs each, or exactly 400 kHz. A logic analyzer measurement later confirmed a bit width in that neighborhood before the fix was applied.
(0U << 16U) | /* NBRP = 0+1 = 1 ← correct field */
(4U << 8U) | /* NTSEG1 = 4+1 = 5 ← correct field */
(1U << 0U)); /* NTSEG2 = 1+1 = 2 ← correct field */
Bit time = 1/8MHz × (1+5+2) = 1µs = 1Mbit ✓
Bugs 2, 3, and 4 above were each verified independently and in combination via a loopback re-test — a clean, fully-decoded 0x123 frame on the logic analyzer, matching sent data byte-for-byte, was the confirmation that the STM32 side was finally sound end to end.
Bug 6 — K64F: ~4.2% clock deviation, outside CAN's synchronization tolerance
With all five prior bugs fixed, a four-channel logic analyzer capture across both boards' TX/RX pins showed something specific: K64F correctly received and decoded STM32's ID 0x100 frames. STM32, however, still couldn't get its own frames ACKed by K64F, and K64F's transmissions carried a decoder-flagged "E" error tag.
A direct bit-width measurement on K64F's TX line read 958 ns per bit, against STM32's measured 1000 ns — a deviation of roughly 4.2%. CAN's resynchronization jump width tolerates small clock differences between independently-clocked nodes, but not deviations anywhere near this size; two nodes drifting apart by 4% will lose bit sampling alignment within a handful of bits, producing exactly the CRC/form errors observed.
The deviation traced back to K64F's actual bus clock not matching the value assumed in the bit-timing calculation (60 MHz, derived from CLKDIV1.OUTDIV2=/2 off a nominal 120 MHz core clock) — the real bus clock frequency was close, but not exact, and the CAN prescaler/segment values needed to be recalculated against the measured frequency rather than the theoretical one.
With all six fixes in place: both boards' UART logs show continuous, successful two-way traffic — STM32 receiving K64F's 0x200 frames, K64F receiving STM32's 0x100 frames, thousands of consecutive counters with zero ACK failures on either side.
— Debug Tools —
The logic analyzer was the only source of ground truth
Every one of the six bugs above produced a driver that reported success at the software level — fdcan_transmit() returned 0, UART logs said "sent," loopback tests passed. None of that was reliable evidence once the real bus was involved. The only tool that showed what was actually happening electrically was a logic analyzer clipped directly onto the MCU TX/RX pins — before the transceiver, then after it, then on both boards simultaneously.
A recurring and useful pattern: probe the raw MCU pin first, with no transceiver in the loop at all. If the pin toggles with the expected bit pattern, the driver and clock configuration are doing their job — any remaining problem is downstream, in the transceiver, the bus wiring, or termination. If the pin never toggles, the problem is upstream, in the driver or clock tree, no matter how confidently the software claims success.
A single quirk worth flagging for anyone repeating this: at low sample rates (24 MS/s in this case, a 41.67 ns quantization step), a single bit's measured width can be misleading by tens of nanoseconds purely from quantization. Measuring across a whole frame — first bit of the ID field to the end of the CRC — and dividing by the bit count gives a far more trustworthy average bit period than trusting any one isolated measurement.
Datasheet register hunting — five of six bugs were "we defined the wrong offset"
Every FDCAN/FlexCAN register in both drivers had been hand-defined as raw memory-mapped addresses (no vendor header available with the right structs in one case, and a deliberate MISRA-style deviation-file approach in the other). That means every single offset and every single bit position was manually transcribed from a PDF at some point — and manual transcription is exactly where five of these six bugs came from.
The fix, in every case, was the same process: open the reference manual to the exact register section, read the bit-field table line by line, and compare it character-for-character against the #define. No amount of clever logic-analyzer work would have found the RCC_D2CCIP1R offset error, the TXBC field, the PSR/TSCV mixup, or the NBTP layout — those needed the datasheet, not the oscilloscope.
/* Before → After, all confirmed against RM0433 Rev 8 */ RCC_D2CCIP1R : 0x094 → 0x050 FDCAN_TXBC.NDTB : bit 24 → bits[21:16] FDCAN_PSR : 0x024 → 0x044 (0x024 is FDCAN_TSCV) FDCAN_NBTP : NBRP/NTSEG1/NTSEG2 field positions corrected
Reading ACT / PSR live — the single most useful debug signal
Once the PSR offset bug was fixed, a single field — PSR.ACT, bits[4:3] — became the fastest way to know whether a change had actually helped. ACT=0 (Synchronizing) means the module has never once achieved bus idle; it will never transmit or receive successfully no matter what else looks right. ACT=1 (Idle) is the first sign of life. Watching this one field flip from 0 to 1 was the moment that confirmed the STM32-side fixes were correct, well before any real frame exchange was attempted.
uint32_t psr = FDCAN1_PSR;
uint32_t act = (psr >> 3U) & 0x3U;
uart_printf("[DEBUG] PSR=0x%x ACT=%d (0=Sync,1=Idle,2=Rx,3=Tx)\r\n",
(unsigned int)psr, (int)act);
— Final State —
What works now
✓ STM32H753ZI FDCAN1 — real bus mode, loopback removed, PB8/PB9 confirmed via schematic + logic analyzer
✓ K64F FlexCAN0 — real bus mode, CLKSRC ordering fixed, bit-timing recalculated against measured clock
✓ Two-way traffic confirmed on both UART logs simultaneously — 0x100 (STM32) and 0x200 (K64F) exchanged continuously
✓ Bus-Off auto-recovery added on the STM32 side (fdcan_is_bus_off() + reinit) as a defensive measure
✓ Both drivers' register definitions re-audited line-by-line against their respective reference manuals
| Component | Board | Status |
|---|---|---|
| RCC_D2CCIP1R offset | STM32 | Fixed — 0x094 → 0x050 |
| FDCAN_TXBC.NDTB field | STM32 | Fixed — bit 24 → bits[21:16] |
| FDCAN_PSR offset | STM32 | Fixed — 0x024 (TSCV) → 0x044 |
| FDCAN_NBTP field layout | STM32 | Fixed — NBRP/NTSEG1/NTSEG2 positions |
| CTRL1.CLKSRC ordering | K64F | Fixed — set before MDIS clear |
| Bit-timing vs real bus clock | K64F | Fixed — recalculated against measured frequency |
| Physical layer (SN65HVD230 ×2) | Both | Confirmed — straight CANH/CANL, 120Ω × 2, RS→GND |
Lessons worth writing down
Loopback proves the logic, not the electrons
Both peripheral families explicitly document that internal loopback holds the physical TX pin recessive (or otherwise disconnects it from the real bus). A passing loopback test is real evidence the message-RAM handling, filter setup, and CS/status bit logic are correct — but it says nothing about bit-timing accuracy, GPIO alternate-function wiring, or whether the module can synchronize to an actual external signal. Real-bus testing surfaced six bugs that loopback couldn't have caught even in principle.
A function returning "success" is not evidence anything physical happened
Both fdcan_transmit() and its FlexCAN counterpart reported success by checking "was the request accepted into the queue," never "did the module confirm actual transmission via TXBTO or an equivalent register." Under three of the six bugs here, the driver was fully convinced it had sent a frame while the hardware had done nothing of the sort. Any transmit path used for real teaching material should check the actual completion flag, not just the request-acceptance flag.
Every hand-transcribed register offset is a bug waiting to happen
Five of six bugs were typos of a very specific kind: a register address or bit position copied slightly wrong from a PDF, months apart, in two unrelated codebases, by the same process. There's no shortcut around this other than treating every hand-defined register as suspect until it's been re-checked, offset by offset, against the actual manual — ideally with a second pair of eyes or a second read weeks later, since the first reading is exactly when the same mistake gets made and then confirmed as "looks right."
A register that always reads 0 is not the same as a register that's actually 0
The FDCAN_PSR/TSCV mixup cost the most debugging time of any single bug here, precisely because it didn't look like an obvious error — TSCV returning 0x0000 is completely valid, documented behavior when the timestamp feature is disabled. Nothing about that reading screamed "wrong register." The only way to catch it was cross-referencing the offset against the datasheet directly, not staring harder at the value.
Small clock deviations matter more on a shared bus than they do standalone
A ~4% bit-rate deviation is invisible in isolation — a node happily talking to itself in loopback, or even transmitting alone on an unterminated bus, shows nothing wrong. It only becomes a hard failure the moment a second, independently-clocked node needs to sample the same bits at the same relative timing. Cross-board CAN testing is exactly the class of bug that single-board bring-up will never expose.
References
STM32H7 / FDCAN
- RM0433 — STM32H753 Reference Manual, Chapter 56 (FDCAN)
- AN5348 — Introduction to FDCAN peripherals for STM32 MCUs