The answer in one paragraph
Root cause: with CanSMBorTxConfirmationPolling enabled, CanSM does not treat the controller leaving BusOff as recovery complete. The state CanSM_FullCom_S_Bus_off_Check keeps polling CanIf_GetTxConfirmationState() until it returns CANIF_TX_RX_NOTIFICATION — meaning at least one frame really went out. That frame can only come from the application's own cyclic schedule. The fastest period is 40 ms, so the extra wait averages 20 ms and peaks at 40 ms. 50 + 25 = 75 and 235 - 200 = 35 both land in that same window. Fix: while in FULLCOM_S_BUS_OFF_CHECK, call CanIf_Transmit on a 5 ms period, which drops the worst-case wait from 40 ms to 5 ms.
What makes this worth writing down is not the fix — the fix is four lines. It is that the deviation looks like a timing tolerance and is actually a measurement of something else entirely. Once you know the recovery is gated on a transmit confirmation, the 25 ms stops being noise and becomes a number you can predict, bound and test.
Two numbers that do not match
The configuration is ordinary and the test is ordinary. What does not match is the result:
| Phase | Configured | Measured | Deviation |
|---|---|---|---|
| Fast recovery | 50 ms | 75 ms | +25 ms |
| Slow recovery | 200 ms | 235 ms | +35 ms |
Two things about this table are worth stating carefully, because they are where most write-ups go wrong.
First, the deviation is not constant. 25 and 35 are not two points on a line. Both sit inside 0–40 ms, and 40 ms is the period of the fastest cyclic frame on the bus (a 40 ms HCM_L frame in this project). The wait depends on where in that 40 ms window recovery finished. If it finished just before the frame was due, the wait is near zero; if just after, it is near 40. Two samples cannot distinguish a fixed offset from a uniform distribution — and here it is the latter.
Second, and this is the part that actually matters operationally: a ±20 ms spread on a 50 ms recovery is a 40% jitter. If the network's requirement is that a node be back within 50 ms, then half the time it will not be — not because the configuration is wrong, but because the recovery is waiting on a data-path event that the configuration does not control. Anyone reading a single 75 ms measurement and concluding 'the stack is 25 ms slow' will spend a day tuning the wrong parameter.
What fast and slow recovery should be
Before blaming anything, it helps to be precise about the behaviour the standard actually specifies, because the controller half of it is fixed and not where the problem is:
- Transmit error counter
TECrises by 8 per failed transmission, falls by 1 per success. WhenTEC > 255the controller enters BusOff and removes itself from the bus entirely. - To leave BusOff it must observe
128 × 11consecutive recessive bits. If a short circuit is still present those bits never appear, so it keeps retrying and never recovers. - Fast recovery retries at a short fixed interval. Each attempt that immediately fails counts as one fast-recovery attempt; after a configured number (typically 5) the controller gives up on fast retry.
- Slow recovery then takes over with a longer interval, so a permanently broken bus is not hammered. The delay grows so that a node with a real fault stops competing for the bus with nodes that are healthy.
All of that is controller-side, deterministic, and measured in bit times. It cannot explain a 25 ms offset. Which means the offset has to come from software above the controller — and the only thing above it that owns a timer is CanSM.
The delay is not in the controller
Walk the call chain at the moment recovery is supposed to complete. This is the path in the PuHua stack, and the shape is the same in others:
CanSM_MainFunction() ← cyclic task
└─ CanSM_BsmSFullCom() ← main state machine in FULL_COM
└─ CanSM_FullCom_S_Bus_off_Check() ← ★ the recovery check lives here
if (CANIF_TX_RX_NOTIFICATION ==
CanIf_GetTxConfirmationState(controllerID)) ← the only exit condition
{
/* bus is genuinely usable; leave BUS_OFF_CHECK */
CanSM_FullCom_S_Restart_CC(...); ← restart the controller
}
/* otherwise: do nothing, wait for the next CanSM_MainFunction */
Read the exit condition literally. It is not asking whether the controller left BusOff — the controller already did that, on its own, in hardware. It is asking whether a transmit confirmation has happened. Those are two different questions, and the gap between them is the 25 ms.
This distinction is the whole article. Controller recovered is a fact about one node's internal state. Bus is usable is a claim about the wire, and the only evidence for it is a frame that made it through. CanSM, when configured this way, insists on evidence rather than inference.
What the polling flag really buys
The behaviour is switched on by CanSMBorTxConfirmationPolling. It does not change how long anything waits; it changes what counts as done.
| Configuration | Recovery condition | Risk |
|---|---|---|
FALSE |
Controller leaves BusOff → recovered | A bus still shorted or dominated by a faulty node can bounce it straight back into BusOff |
TRUE |
Controller leaves BusOff and one frame is confirmed | Recovery time now depends on the application's transmit schedule |
The design intent is sound. A controller that keeps re-entering BusOff burns bus time and, worse, can keep a genuinely broken bus from settling. Requiring evidence that the wire works before declaring recovery turns a retry storm into one clean attempt followed by a measurement.
The cost is a change of ownership that is easy to miss in review: the recovery time is no longer a property of the CanSM configuration alone. It is a function of the CanSM configuration plus the fastest cyclic transmit period in the application. Nobody writing the CanSM config file can see that second term.
That is the structural lesson worth carrying to other projects: any configuration flag that makes a state machine wait for a data-path event couples two subsystems that were designed separately. The coupling is invisible in both configuration files. It only shows up as a number that does not match — which is exactly how this one surfaced.
Two ways to satisfy the check
CanIf_GetTxConfirmationState() reads an array element, CanIf_TxConfirmationState[]. That array reaches CANIF_TX_RX_NOTIFICATION from exactly two places, and the difference between them is the whole fix:
| Where | Requires | Latency |
|---|---|---|
CanIf_TxConfirmation |
A frame actually left, controller acknowledged it | Bounded by the transmit schedule |
CanIf_TransmitHandle |
CANIF_TX_OFFLINE_ACTIVE_SUPPORT enabled |
As soon as CanIf_Transmit is called |
The second row is the one that matters, and it is easy to read past. CanIf_TransmitHandle is called from inside CanIf_Transmit. So calling CanIf_Transmit once is enough to push the state forward — you do not need the frame to reach the wire. That is what makes the retry below possible at all: it decouples the check from the application's transmit schedule by giving the check a second, cheaper trigger.
The caveat that must be checked on your own stack: CANIF_TX_OFFLINE_ACTIVE_SUPPORT is a configuration switch, and what it does in detail is implementation-specific. The claim here is narrow and verifiable: with it enabled, CanIf_Transmit reaches CanIf_TransmitHandle, which can set the confirmation state without a real bus acknowledgement. Whether your CanIf does exactly that, and whether it does it while the controller is offline, is something to confirm with a breakpoint rather than a document.
The break: nobody sends a frame
Now the failure is easy to see. During BusOff, the check sits in its wait state. The application is still producing signals and still asking to transmit — but at the CanIf level those requests do not produce a confirmation, because the controller cannot put anything on the wire. The only thing that would satisfy the check is a frame that succeeds, and no frame can succeed while the bus is down.
t0 TEC > 255 → controller enters BusOff, detaches from the bus
t1 hardware waits 128 x 11 recessive bits -> recovered, back to Error Active
t2 CanSM enters FULLCOM_S_BUS_OFF_CHECK, starts polling TxConfirmationState
t3 app still requests transmits, but CanIf produces no confirmation -> state unchanged
t4 ★ can only wait for a cyclic frame to come due
fastest period 40 ms -> average wait 20 ms, worst case 40 ms
t5 that frame goes out -> CanIf_TxConfirmation -> state set to NOTIFICATION
t6 CanSM decides recovery is complete -> Restart_CC -> communication resumes
-> measured = configured + wait
fast: 50 + 25 = 75 ms
slow: 200 + 35 = 235 ms
Notice that the wait is not a delay anyone coded. It is a sampling artefact: the state machine can only observe the transmit confirmation at the moments a transmit happens, so the resolution of the recovery clock is the frame period. A 40 ms frame means a 40 ms-granular recovery clock. That is why the deviation lands in 0–40 and not somewhere else.
There is a second-order consequence that is worse than the average. If the fastest cyclic frame happens to be in an IPduGroup that is disabled during BusOff — the fix described in the companion post — then no frame is scheduled at all, and the wait is not 40 ms but unbounded. That case gets its own section below, because it is a real deadlock and not a rounding error.
The fix: a 5 ms retry
Since the check can be satisfied by calling CanIf_Transmit, and the only problem is that nobody calls it at the right moment, the fix is to call it deliberately — but only during the window where it is needed:
/* 5 ms cyclic task */
void HCM_LR_TransmitRetry(void)
{
/* ★ acts only during BUS_OFF_CHECK; completely silent in normal operation */
if (CanSM_GetCurrentSubState(CANSM_CONTROLLER_0)
== CANSM_FULLCOM_S_BUS_OFF_CHECK)
{
/* one call is enough to advance CanIf_TxConfirmationState
— the frame need not reach the wire */
(void)CanIf_Transmit(HCM_LR_TX_PDU_ID, &HCM_LR_TxPduInfo);
}
}
Two properties make this safe rather than a hack. The state gate means it contributes zero bus load during normal operation — it is dormant except while recovering from a fault. The 5 ms period means the worst-case wait drops from 40 ms to 5 ms, so the recovery time becomes 50 + ≤5 rather than 50 + 0–40.
| Before | After | |
|---|---|---|
| Fast recovery, measured | 75 ms | 50 ms |
| Worst-case extra wait | 40 ms | 5 ms |
| Jitter on a 50 ms budget | ±20 ms | ±2.5 ms |
| Normal-operation cost | — | 0 |
The measured result came back to 50 ms — the configured value — which is the useful kind of confirmation: the number you now see is the number in the config file, and the mechanism that produced the old number is gone rather than merely hidden.
Why 5 ms and not 1 ms
The natural next question is why not go faster. Three reasons, and the third is the one that decides it:
- Diminishing returns. The residual error becomes the retry period itself. Going from 40 ms to 5 ms removes 35 ms of jitter; going from 5 ms to 1 ms removes 4 more. On a 50 ms budget that second gain is under 8%.
- Oversampling already has margin. Against a 40 ms frame period, 5 ms is 8× oversampling. Even if the fastest frame changes to 10 ms in a later variant, 5 ms still gives 2× headroom.
- Every call costs something.
CanIf_Transmitis not free: it walks the PDU path, touches the transmit buffer, and can contend with real traffic. During BUS_OFF_CHECK that is harmless, but the period is also the floor on how much you would pay if the state gate were ever wrong. A 5 ms floor is a bounded mistake; a 1 ms floor is a CPU budget problem during a fault.
There is also a correctness argument for keeping it slow-ish: the retry is a probe, and a probe that fires too often stops being a probe and becomes traffic. The point is to find out quickly whether the bus works, not to keep poking it.
The deadlock with closing IPduGroups
This section is the one I would want a reviewer to read hardest, because it is where two individually correct fixes compose into a hang. It is also the part of this article that is reasoning rather than measurement, so treat it as a hypothesis to check on your own stack.
The companion post's fix is: on BusOff, BswM calls Com_IpduGroupControl(group, FALSE) to stop the transmit I-PDUs, so that a leaked Transmiting flag cannot silently block them forever. Good fix, and it was verified working. But look at what it does to the recovery path of this article:
t0 BusOff -> BswM closes the TX group (ActiveEnable = FALSE)
t1 controller recovers in hardware (128 x 11 recessive bits) -> Error Active
t2 CanSM enters FULLCOM_S_BUS_OFF_CHECK, waiting for one transmit confirmation
t3 group is closed -> Com hands nothing to PduR -> no frame is scheduled
t4 ★ no frame -> no TxConfirmation -> the state never changes
t5 CanSM stuck in BUS_OFF_CHECK forever
t6 BswM never receives the recovered event -> never reopens the group
-> back to t3; closed loop
-> controller fine, bus fine, stack alive — yet this node never comes back
The loop is: CanSM waits for a frame, the frame waits for the group, the group waits for BswM, BswM waits for CanSM. Four waits, no progress. Nothing in that cycle is broken on its own — which is exactly why this kind of hang survives code review.
| Mitigation | How it works | Cost |
|---|---|---|
| Retry frame outside the group | Keep a minimal always-enabled group for the probe frame | One more group to maintain; the probe frame must be acceptable to everyone on the bus |
| Retry bypasses Com | Call CanIf_Transmit directly, never through Com |
Bypasses the Com layer's own bookkeeping; needs a hand-built PDU and careful review |
| Do not close the probe's group | Only close groups whose frames are not needed for recovery | Requires knowing which frames the recovery path depends on — which is the point |
The second row is what the actual implementation does, and it is why the two fixes coexist in this project. But that is a design decision someone made, not a property that falls out of either fix. If a later engineer adds a retry that goes through the Com path instead, the deadlock comes back — silently, and only under BusOff, which is exactly the condition that gets tested least.
The general form: whenever a recovery path waits on a data-path event, check what else in the system can suppress that event. Two fixes that each look local can form a cycle through the fault-handling layer. The cheapest defence is to write the cycle down explicitly — the six-line timeline above is more useful than any amount of testing, because testing only finds it if the test happens to cover the combination.
How to measure it properly
A single recovery time is not a measurement of this behaviour — it is a sample from a distribution whose width is the thing you are trying to characterise. Test accordingly:
- Inject BusOff deterministically with a VH6501 or an equivalent fault-injection channel, rather than shorting the wires by hand. Shorting gives you BusOff but not a controlled moment, and the moment is what determines the deviation.
- Inject at least 20 times and record every recovery duration. 20 samples is the minimum to say anything about a distribution; 50 is better and costs nothing extra once automated.
- Report P50 and P95, not the mean. The mean hides the tail, and the tail is what breaks timing requirements. A mean of 60 ms with a P95 of 88 ms is not a 60 ms system.
- Compare against both numbers: the configured value and the configured value plus the fastest frame period. Before the fix, P95 should sit near 50 + 40 = 90 ms. After the fix, near 50 + 5 = 55 ms. Predicting both numbers before you measure is what makes the result evidence rather than observation.
- Also measure the fast/slow transition — the 5th fast attempt should be the last. If the count is off by one, you have two problems and one of them is not this one.
One more thing worth doing: leave the retry counter visible in a debug variable. When the fault eventually shows up in a customer trace, having a count of how many probes were fired tells you instantly whether the recovery path was even entered.
What this does not fix
| Not solved | Why |
|---|---|
| BusOff happening at all | The retry makes recovery fast; it does not make the bus healthy. If the shorts are real, recovery will be fast and pointless |
| Frames lost during the outage | Physical fact. Whether they must be resent is an application decision |
| The polling trade-off | The retry shortens the wait, it does not remove the coupling between recovery and the transmit path |
| Other stacks with a different Bor design | The chain above is the PuHua implementation; the state names and even the polling behaviour vary |
| Slow recovery sizing | Whether 200 ms is the right number for this network is a system question, not a stack question |
The general lesson, stated once and plainly: when a recovery path waits for evidence from the data path, the recovery time is no longer a property of the recovery configuration. It becomes a joint property of the recovery config, the transmit schedule, and whatever else can suppress transmission. Three owners, and usually none of them knows the other two exist.
Common questions
Why is measured CAN BusOff fast recovery 25 ms longer than configured?
Because with CanSMBorTxConfirmationPolling enabled, CanSM does not treat the controller leaving BusOff as recovery complete. CanSM_FullCom_S_Bus_off_Check keeps polling CanIf_GetTxConfirmationState until it returns CANIF_TX_RX_NOTIFICATION — meaning at least one frame really went out. That frame can only come from the application's cyclic schedule. With a 40 ms fastest period the extra wait averages 20 ms and peaks at 40 ms, so 50 + 25 = 75 sits right in that window.
What does CanSMBorTxConfirmationPolling actually do?
It splits 'the controller left BusOff' from 'the bus is usable'. Hardware returns to Error Active after 128 × 11 recessive bits, which proves only that this node is willing to talk again — not that the wire is clear. With the flag set, CanSM additionally requires one successful transmit confirmation as a liveness check. The intent is sound; the cost is that recovery time is no longer decided by the CanSM configuration alone.
Where does CanIf_TxConfirmationState get set?
Two places. CanIf_TxConfirmation on the normal path: the frame really left and the controller acknowledged it. And CanIf_TransmitHandle, which requires CANIF_TX_OFFLINE_ACTIVE_SUPPORT and is called from inside CanIf_Transmit. That second path is why a retry works — calling CanIf_Transmit once pushes the state forward without needing a real bus acknowledgement.
Why is fast recovery 25 ms late but slow recovery 35 ms late?
Both sit in the same 0–40 ms window — one fastest frame period. The wait is where recovery finished relative to that frame's next transmit slot; it is uniformly distributed, not fixed. 25 and 35 are two sample points, not a rule. To characterise it you need 20+ injections and P50/P95: before the fix P95 should approach configured + 40 ms, after the fix configured + 5 ms.
How should the 5 ms retry be implemented?
A 5 ms cyclic task that calls CanIf_Transmit with a short frame, gated on CanSM being in FULLCOM_S_BUS_OFF_CHECK. The gate matters more than the period: without it you are adding bus load during normal operation for no reason. With it, the cost is zero except during recovery from a fault. Measured effect: fast recovery returns from 75 ms to the configured 50 ms.
Do closing IPduGroups on BusOff and this retry conflict?
They can deadlock. If the group you close contains the only frame that would be transmitted, CanSM never sees a confirmation, never leaves BUS_OFF_CHECK, so BswM never gets the recovery event and never reopens the group. The cycle is: CanSM waits for a frame, the frame waits for the group, the group waits for BswM, BswM waits for CanSM. Avoid it by keeping the probe outside the closed group, or by having the retry call CanIf_Transmit directly so it bypasses Com's group switch.