In this blog entry, I’d like to focus on the I2C ‘stuck bus’ problem. I first encountered this bug when trying to read data from three different I2C sensors all integrated on the SENSEVAL-SCB4XV1 DIL24 board for my smart environmental monitoring system project. I will not cover the basics of the I2C bus here. For that, take a look at some of the links listed at the end of the article.
If you have worked with I2C for long enough, you have probably run into a peripheral that simply stops responding. The bus looks fine electrically at first glance, yet your driver calls return timeouts, and in some cases, even a power cycle can’t cause the I2C bus to recover. This is the classic “stuck bus” problem, and it is one of the more annoying failure modes in embedded systems because it is intermittent, hard to reproduce on demand, and often blamed on the wrong thing. The “stuck bus”problem is further exacerbated when you have multiple I2C peripheral devices on a single I2C bus (especially when you can’t isolate them electrically as in the case of the SENSEVAL-SCB4XV1 board ).
This blog entry walks through why I2C buses get stuck in the first place, the everyday scenarios that trigger it, and then looks at a concrete recovery routine written for the STM32 (STM32G474RE specifically) that fixes the problem in firmware without requiring a power cycle.
A quick refresher on why I2C is fragile this way
I2C uses two open-drain lines, SDA and SCL, each pulled high through a resistor. Any device on the bus can pull a line low, but no device can drive it high. This is what makes multi-master and multi-slave communication possible on just two wires, but it also means the bus has no built-in way to force itself back to a known state. If a peripheral device pulls SDA low and then never lets go, the line stays low until either that device changes its mind or something else intervenes.
A normal I2C transaction is a tightly choreographed sequence: START condition, address byte, ACK/NACK, data bytes, ACK/NACK, repeated as needed, then STOP condition. Every device on the bus is watching this sequence and keeping track of where it thinks the transaction is. The trouble starts when one device’s view of “where we are in the transaction” gets out of sync with everyone else’s.
Common scenarios that cause a stuck bus
The master resets mid-transaction. This is the most common cause in practice. Imagine the master has just pulled SCL low, about to clock out the next bit, and at that exact moment the MCU resets due to a watchdog trip, a brown-out, or a debugger halt. The slave device is left waiting for a clock edge that never comes. It is holding SDA low because it is in the middle of sending an ACK bit, and it has no way of knowing the master has disappeared. When the master’s firmware restarts and reinitializes the I2C peripheral, it assumes the bus is idle, but the slave is still sitting there with SDA held low.
Noise or ESD glitches on the bus. A voltage spike or a bit of electrical noise can look like a spurious START or STOP condition to one device but not another. This desynchronizes their internal state machines, and one side ends up waiting for something the other side will never send.
Clock stretching gone wrong. Some slaves hold SCL low to signal that they need more time (clock stretching). If that slave hangs internally, for example due to firmware bug or a brownout on the slave itself, SCL can be stuck low indefinitely, and the master will never get to complete its clock pulses.
Hot-plugging a device. If a sensor or peripheral is connected or disconnected while the bus is active, it can be inserted mid-byte from the perspective of the other devices, leaving its internal transaction state out of sync from power-up.
Firmware bugs and improper reinitialization. Calling HAL_I2C_Init or its equivalent without first confirming the bus lines are actually idle is a common self-inflicted cause. If your own peripheral was reset while SDA or SCL were momentarily low due to normal bus activity, reinitializing without checking line state can lock things up on your own side.
In all of these cases, the fundamental symptom is the same: SDA and/or SCL are held low by some device; usually due to a false clock edge, and nobody sends the clock pulses that would normally let that device finish and release the line.
Why a plain reinit does not fix it
It is tempting to think that calling the STM32 HAL’s I2C init function again will reset the peripheral and clear the problem. Sometimes it does, but often it does not, for two reasons.
First, reinitializing the STM32 side does nothing to fix a slave device that is still holding SDA low waiting for clocks. The STM32 peripheral coming back up does not generate the clock pulses the slave is waiting for unless a transaction is started.
Second, if the STM32’s own I2C peripheral was mid-transaction when something went wrong, the peripheral’s internal state machine can get confused if you try to re-enable it while the lines are not electrically idle. This is sometimes referred to as an init-timing race: the peripheral is enabled while SDA or SCL have not yet settled to their idle-high state, and the peripheral effectively starts in the wrong state before you have even sent a byte.
The generic bus recovery procedure
The I2C specification actually documents a recovery procedure for exactly this situation, and it boils down to a few steps:
- Take manual control of the SDA and SCL pins as GPIO in open-drain mode, rather than letting the I2C peripheral drive them.
- Release both lines and let them float high through their pull-ups.
- If SDA is still low, pulse SCL up to nine times. Nine is the worst case: eight data bits plus one ACK bit, which covers any point a slave could be stuck at.
- After the slave releases SDA, or after the nine pulses regardless, issue a manual STOP condition: pull SDA low, bring SCL high, then release SDA while SCL is still high. This is the electrical definition of STOP, and it tells every device on the bus, not just your own MCU, that the bus is now idle.
- Verify both lines are actually high before handing the pins back to the I2C peripheral’s alternate function.
The reason this works is that toggling SCL by hand while SDA is being watched effectively lets the stuck slave finish clocking out whatever byte or ACK it was in the middle of. Once it has shifted out the bits it thinks it owes the bus, it releases SDA on its own, exactly as it would during a normal transaction.
Walking through the STM32 recovery function
The function below, written for the STM32 HAL, implements exactly this procedure.
/**
* @brief Recovers a wedged I2C bus and re-arms the peripheral clock.
* Handles both the init-timing-race case (SDA/SCL not fully idle
* when the I2C peripheral was previously enabled) and a genuinely
* wedged slave (SDA held low mid-transaction) via SCL clock pulses.
* @note Caller is still responsible for calling HAL_I2C_Init(hi2c)
* afterwards — this function does not do that.
* @retval HAL_OK if the bus is confirmed idle-high afterwards, HAL_ERROR
* if SDA/SCL are still stuck low after full recovery (a real fault,
* not just a timing race) or if an unrecognized port/instance
* was passed in.
*/
HAL_StatusTypeDef I2C_Clear_and_RESET_bus(I2C_HandleTypeDef *hi2c,
GPIO_TypeDef *SDA_PORT, uint16_t SDA_PIN, uint8_t SDA_AF,
GPIO_TypeDef *SCL_PORT, uint16_t SCL_PIN, uint8_t SCL_AF)
{
switch ((uint32_t)SDA_PORT) {
case (uint32_t)GPIOA: __HAL_RCC_GPIOA_CLK_ENABLE(); break;
case (uint32_t)GPIOB: __HAL_RCC_GPIOB_CLK_ENABLE(); break;
case (uint32_t)GPIOC: __HAL_RCC_GPIOC_CLK_ENABLE(); break;
case (uint32_t)GPIOD: __HAL_RCC_GPIOD_CLK_ENABLE(); break;
case (uint32_t)GPIOE: __HAL_RCC_GPIOE_CLK_ENABLE(); break;
case (uint32_t)GPIOF: __HAL_RCC_GPIOF_CLK_ENABLE(); break;
case (uint32_t)GPIOG: __HAL_RCC_GPIOG_CLK_ENABLE(); break;
default: Error_Handler(); break; // unrecognized port — fail loudly
}
switch ((uint32_t)SCL_PORT) {
case (uint32_t)GPIOA: __HAL_RCC_GPIOA_CLK_ENABLE(); break;
case (uint32_t)GPIOB: __HAL_RCC_GPIOB_CLK_ENABLE(); break;
case (uint32_t)GPIOC: __HAL_RCC_GPIOC_CLK_ENABLE(); break;
case (uint32_t)GPIOD: __HAL_RCC_GPIOD_CLK_ENABLE(); break;
case (uint32_t)GPIOE: __HAL_RCC_GPIOE_CLK_ENABLE(); break;
case (uint32_t)GPIOF: __HAL_RCC_GPIOF_CLK_ENABLE(); break;
case (uint32_t)GPIOG: __HAL_RCC_GPIOG_CLK_ENABLE(); break;
default: Error_Handler(); break;
}
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
// 1. Take manual control of both lines as plain open-drain GPIO
GPIO_InitStruct.Pin = SDA_PIN;
HAL_GPIO_Init(SDA_PORT, &GPIO_InitStruct);
GPIO_InitStruct.Pin = SCL_PIN;
HAL_GPIO_Init(SCL_PORT, &GPIO_InitStruct);
// 2. Release both lines high and let them settle
HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_SET);
HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_SET);
HAL_Delay(2);
// 3. If SDA is still stuck low, a slave is mid-transaction, waiting on
// clocks. Pulse SCL up to 9 times (worst case: one full byte + ack)
// while watching for SDA to release.
if (HAL_GPIO_ReadPin(SDA_PORT, SDA_PIN) == GPIO_PIN_RESET) {
for (uint8_t i = 0; i < 9; i++) {
HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_RESET); // SCL low
HAL_Delay(1);
HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_SET); // SCL high
HAL_Delay(1);
if (HAL_GPIO_ReadPin(SDA_PORT, SDA_PIN) == GPIO_PIN_SET) {
break; // slave released SDA, no need to keep clocking
}
}
}
// 4. Issue a manual STOP condition regardless, so the bus ends in a
// clean, known idle state: SDA rises while SCL is high, so every
// device on the bus agrees it's idle now — not just the STM32.
HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_RESET);
HAL_Delay(1);
HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_SET);
HAL_Delay(1);
HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_SET);
HAL_Delay(1);
// 5. Confirm both lines are actually idle-high before handing over.
HAL_StatusTypeDef result = HAL_OK;
if (HAL_GPIO_ReadPin(SDA_PORT, SDA_PIN) == GPIO_PIN_RESET ||
HAL_GPIO_ReadPin(SCL_PORT, SCL_PIN) == GPIO_PIN_RESET) {
result = HAL_ERROR; // still stuck — real fault, not a timing race
}
// 6. Hand both pins over to the I2C peripheral's alternate function
GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
GPIO_InitStruct.Pin = SDA_PIN;
GPIO_InitStruct.Alternate = SDA_AF;
HAL_GPIO_Init(SDA_PORT, &GPIO_InitStruct);
GPIO_InitStruct.Pin = SCL_PIN;
GPIO_InitStruct.Alternate = SCL_AF;
HAL_GPIO_Init(SCL_PORT, &GPIO_InitStruct);
switch ((uint32_t)hi2c->Instance) {
case (uint32_t)I2C1: __HAL_RCC_I2C1_CLK_ENABLE(); break;
case (uint32_t)I2C2: __HAL_RCC_I2C2_CLK_ENABLE(); break;
case (uint32_t)I2C3: __HAL_RCC_I2C3_CLK_ENABLE(); break;
case (uint32_t)I2C4: __HAL_RCC_I2C4_CLK_ENABLE(); break;
default: Error_Handler(); break;
}
return result;
}
Here is how it maps to the steps above.
Enabling the GPIO clocks
switch ((uint32_t)SDA_PORT) {
case (uint32_t)GPIOA: __HAL_RCC_GPIOA_CLK_ENABLE(); break;
...
}
Before touching any pin, the function makes sure the clock for whichever GPIO port the SDA and SCL pins live on is actually enabled. This is defensive: if this recovery routine runs very early in startup, or after a partial reset, the relevant port clock might not yet be on. Passing an unrecognized port falls through to Error_Handler, which is a reasonable choice here, since continuing with GPIO writes to a port whose clock is not enabled at all would silently do nothing.
Taking manual control of the lines
c
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; GPIO_InitStruct.Pin = SDA_PIN; HAL_GPIO_Init(SDA_PORT, &GPIO_InitStruct); GPIO_InitStruct.Pin = SCL_PIN; HAL_GPIO_Init(SCL_PORT, &GPIO_InitStruct);
Both pins are reconfigured from their I2C alternate function into plain open-drain GPIO outputs. This is the step that detaches the pins from the I2C peripheral and gives the recovery routine direct bit-level control over the bus. Open-drain mode is kept here deliberately, matching how I2C behaves electrically: the code can only pull lines low or let them float, never drive them high directly.
Releasing the lines and checking SDA
c
HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_SET); HAL_Delay(2);
Both lines are released and allowed to settle high through the external pull-ups. The short delay gives the pull-ups time to charge the line capacitance, which matters more on buses with longer traces or multiple devices. After this, the code reads SDA. If it reads high already, there is nothing to recover; the earlier concern was just an init timing race, and the bus was idle all along.
Clocking out a stuck slave
c
if (HAL_GPIO_ReadPin(SDA_PORT, SDA_PIN) == GPIO_PIN_RESET) {
for (uint8_t i = 0; i < 9; i++) {
HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_RESET);
HAL_Delay(1);
HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_SET);
HAL_Delay(1);
if (HAL_GPIO_ReadPin(SDA_PORT, SDA_PIN) == GPIO_PIN_SET) {
break;
}
}
}
If SDA is still low, this is the genuine bus recovery loop. It pulses SCL low then high, up to nine times, checking after every pulse whether SDA has been released. This directly matches the worst-case scenario of a slave stuck mid-byte with an ACK still pending. As soon as SDA goes high, the loop exits early, since there is no need to keep clocking a slave that has already let go.
Issuing a manual STOP
c
HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_SET); HAL_Delay(1); HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_SET); HAL_Delay(1);
This sequence runs unconditionally, even if SDA never got stuck in the first place. It bit-bangs a STOP condition: SDA goes low, SCL goes high, then SDA rises while SCL is still high. Doing this regardless of whether recovery was needed is a good habit, because it guarantees every device on the bus, not just the STM32’s own peripheral, agrees that the bus is now idle. Skipping this step in the “SDA was already high” case would leave the state of other devices assumed rather than confirmed.
Confirming the result
c
HAL_StatusTypeDef result = HAL_OK;
if (HAL_GPIO_ReadPin(SDA_PORT, SDA_PIN) == GPIO_PIN_RESET ||
HAL_GPIO_ReadPin(SCL_PORT, SCL_PIN) == GPIO_PIN_RESET) {
result = HAL_ERROR;
}
After the manual STOP, both lines are checked one more time. If either is still low at this point, that is no longer a timing race, it is a real fault: a shorted line, a slave that is truly wedged and unresponsive to clocking, or a wiring problem. Reporting HAL_ERROR here lets calling code distinguish “I recovered the bus” from “something is actually broken and needs attention,” which matters if you want to log a fault or fall back to a different behavior rather than silently retrying forever.
Handing the pins back to the I2C peripheral
c
GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
GPIO_InitStruct.Alternate = SDA_AF;
...
switch ((uint32_t)hi2c->Instance) {
case (uint32_t)I2C1: __HAL_RCC_I2C1_CLK_ENABLE(); break;
...
}
Finally, both pins are switched back to their I2C alternate function, and the peripheral clock for the specific I2C instance in use is enabled. Notice the function does not call HAL_I2C_Init itself. That is left to the caller, and it is the right call: by the time HAL_I2C_Init runs, the lines are confirmed idle-high (or the caller knows they are not, thanks to the return value), so the peripheral starts from a clean, unambiguous state instead of possibly racing against a line that has not settled yet.
Practical notes for using this on a real STM32 board
A few things worth keeping in mind if you are adding something like this to your own project:
Run this recovery routine before HAL_I2C_Init during normal startup, not just after detecting an error. This avoids the init timing race entirely rather than only fixing it after the fact.
Call it again whenever your I2C driver reports a timeout or a NACK that should not have happened. A repeated pattern of unexplained I2C errors is a good sign that a slave is getting wedged, and wrapping your error handling to call this recovery function before retrying can turn a hard fault into a self-healing one.
Keep the HAL_Delay based timing in mind if you plan to call this from an interrupt context or a time-critical section. The delays here are short, but they do block, so this routine is best called from a normal task context or during initialization rather than from inside an ISR.
Double check the pull-up resistor values on your board. Bus recovery only works if the lines actually have somewhere to go when released. On a bus with a missing or too-weak pull-up, this procedure will not be able to bring SDA or SCL high no matter how many times SCL is pulsed, and the final check will correctly report HAL_ERROR.
Wrapping up
A stuck I2C bus is almost always a symptom of two devices disagreeing about where they are in a transaction, most often because the master reset or glitched mid-byte. Since I2C’s open-drain design means no device can force a line high, recovering from this state requires manually bit-banging the lines back into agreement, then formally closing out the bus with a STOP condition before handing control back to the hardware peripheral. The STM32 routine above is a solid, spec-compliant implementation of that idea, and building it into your I2C initialization and error handling paths is a cheap way to make a design meaningfully more robust against a failure mode that would otherwise require a full power cycle to clear.
