One I²C Bus, Many Sensors: Arbitration Under an RTOS
Two sensors share one I²C bus on our hub. One is an MLX90640 thermal imager sampling an 8×8 temperature grid twice a second. The other is a combination proximity-and-barometer that checks the room distance and ambient pressure at roughly the same cadence. Each sensor looks harmless in isolation. On the bench, both work fine. Then you put them in a product running FreeRTOS with separate tasks, and the reads start misbehaving - garbage temperature frames, a proximity register that comes back with three bytes instead of two, and a HAL_BUSY return code that shows up exactly when the thermal task fires mid-baro read.
The Failure Mode: HAL_BUSY, Torn Reads, and Silent Garbage
I²C is a shared medium. SDA and SCL are bus signals - every device on the wire sees every transaction. The protocol is built for single-master use or very careful multi-master arbitration at the hardware level, and it has no concept of a software task boundary. So when FreeRTOS schedules the thermal task in the middle of a barometer read, the bus driver doesn't pause politely. It either sees the bus busy and returns HAL_BUSY immediately, or - worse - it fights the ongoing transaction byte by byte.
The failure modes come in three flavors. The first is HAL_BUSY: the HAL detects the bus is claimed and returns an error code that most application code quietly ignores. The sensor read returns an all-zero frame, or the previous frame, or nothing - and the caller never knows. The second is the torn read: the two tasks interleave SCL pulses on the same transaction. You get the first byte of the proximity response, then the first byte of the thermal response, in whatever order the scheduler decided. The resulting value is numerically plausible - which is the worst kind of wrong, because it passes downstream sanity checks. The third flavor is the bus hang: the sensor was mid-word when a STOP condition landed from a different task. The device's internal state machine is now waiting for a clock edge that never comes, and it will hold SDA low indefinitely. The only recovery is a bus reset or a power cycle.
None of this is obvious in a logic-analyzer capture at first glance. The bus looks busy, transactions look complete, the timing looks fine. It's only when you zoom in on the exact boundary between a thermal and a proximity transaction that you see the two START conditions arrive within a few microseconds of each other - one winning, one turning into garbage.
The Fix: A FreeRTOS Mutex Around Every Bus Transaction
The solution is straightforward once you've named the problem correctly: the I²C bus is a shared resource, and shared resources need mutual exclusion. A FreeRTOS mutex - a binary semaphore with priority-inheritance semantics - does exactly that. One mutex guards the entire bus. Every task that touches SDA or SCL must take the mutex, do its work, and give it back. Nothing else runs on the bus while the mutex is held.
The wrapper around each sensor driver looks like this:
/* i2c_bus.h - one mutex governs the entire bus */
static SemaphoreHandle_t s_i2c_mutex;
void i2c_bus_init(void)
{
s_i2c_mutex = xSemaphoreCreateMutex();
configASSERT(s_i2c_mutex);
}
/* Every sensor driver calls these two helpers, never the HAL directly. */
static inline bool i2c_take(TickType_t ticks)
{
return xSemaphoreTake(s_i2c_mutex, ticks) == pdTRUE;
}
static inline void i2c_give(void)
{
xSemaphoreGive(s_i2c_mutex);
}The thermal task calls i2c_take() before it fires off any of the MLX90640 reads and calls i2c_give() after the last byte of the frame has been received. The proximity-baro task does exactly the same. The two tasks can still run concurrently - they'll just park at xSemaphoreTake() while the other holds the bus, blocking for up to a configurable timeout, and proceed once the bus is free. The bus gets exactly one transaction at a time.
But the mutex is only half the protocol.
Stop-Ranging and Settle Delays: The Other Half of the Protocol
The MLX90640 is a continuous-ranging thermal imager. While it's measuring, it issues I²C transactions on its own initiative - or rather, it asserts its interrupt line to signal that a new subpage is ready, and the driver then reads it. If another task snatches the bus at exactly that moment, the sensor's state machine doesn't freeze gracefully. It keeps its internal measurement cycle running, the ready flag stays asserted, and the next read starts from a mid-cycle snapshot. The resulting 32×24 array has a perfectly plausible temperature spread - just not the one from this moment in time.
The solution is to put the sensor into a quiescent state before relinquishing the mutex. For the MLX90640, that means writing the CTRL_REG1 register with the step-mode bit set: the sensor takes one subpage measurement and then stops, waiting for the host to explicitly trigger the next one. No autonomous bus activity, no mid-cycle snapshots, no race against the measurement cycle. The proximity-baro sensor has a similar concept - a single-shot measurement mode where the device reads once and then goes idle.
After stopping the ranging cycle there is a settle delay: the sensor's analog front-end has capacitances that are still discharging, and reading immediately after stopping can catch stale ADC values. The settle time is stated in the datasheet and must be respected. A missed settle delay is the kind of bug that only surfaces at cold temperatures - because at room temperature the ADC settles fast enough to look correct, and the problem only appears when ambient conditions slow the analog path.
The protocol therefore is: take the mutex, trigger a single-shot measurement, wait for the data-ready flag (or a fixed settling window), read the result, put the sensor back to idle, then give the mutex. Both tasks follow this pattern. The bus is only ever busy for one complete atomic transaction at a time, and every sensor is in a known idle state when the bus is returned:
{
if (!i2c_take(pdMS_TO_TICKS(50))) {
ESP_LOGW(TAG, "thermal: bus timeout - skip frame");
return false;
}
/* 1. Trigger one subpage measurement. */
mlx90640_set_ctrl_mode(MLX90640_STEP_MODE);
vTaskDelay(pdMS_TO_TICKS(MLX90640_SETTLE_MS));
/* 2. Wait for data-ready (typ <10 ms). */
if (!mlx90640_wait_ready(pdMS_TO_TICKS(30))) {
i2c_give();
return false;
}
/* 3. Read the full subpage. */
bool ok = mlx90640_read_raw(out_grid);
/* 4. Return sensor to idle before releasing the bus. */
mlx90640_set_ctrl_mode(MLX90640_IDLE);
i2c_give();
return ok;
}Notice the structure: the mutex is taken first, the device is read, and i2c_give() is called in every exit path - including the timeout early return. That last point is critical. A mutex that is taken but never released because of an early return or an error path is one of the most common causes of I²C deadlocks in multitasking firmware. The function-static structure - one mutex wrapper around the entire transaction - makes it easy to audit.
Quirks That Bite You: Auto-Increment and Single-Byte Fallbacks
Two sensor-specific behaviors complicate the otherwise clean pattern above.
Auto-increment is broken on the MLX90640. Most I²C sensors accept a multi-byte read starting at a register address and then auto-increment the pointer with each clock cycle, letting you burst-read a full frame in one transaction. The MLX90640 does not do this reliably for all address ranges. The driver must read certain register groups one word at a time, issuing separate transactions for each 16-bit value. This multiplies the number of bus transactions per frame - which in turn means the mutex is held for longer during a thermal read, and the proximity task has to wait proportionally longer. The practical consequence: the thermal task's mutex hold time can be tens of milliseconds, not microseconds. Any other sensor that tries to read during that window will see HAL_BUSY and must retry.
The proximity sensor has a single-byte read quirk. Some registers on the proximity-baro sensor are only one byte wide. HAL's generic multi-byte read function always tries to read at least two bytes for efficiency - but if the sensor wraps the register pointer after the first byte, the second byte comes from the next register address, silently overwriting that register's cached value. The fix is a narrow single-byte path for registers that are known to be exactly one byte wide. This is not always documented clearly; it's the kind of thing you discover by diffing captures of working and broken reads at 3 a.m. once you have a logic analyzer attached.
When to Migrate to i2c_master_write_read_device() Atomic Transactions
The mutex-plus-stop/settle protocol solves the arbitration problem. But there's a second class of problem it doesn't fully address: the gap between a write (setting the register address pointer) and the subsequent read. In ESP-IDF and STM32 HAL, the classic two-call pattern issues a STOP condition after the write and a new START condition before the read. Some sensors handle this fine; others use that inter-transaction gap as a trigger to do something - advancing an internal state machine, starting a conversion, or latching the current register address permanently. If another task wins the mutex in that gap, the register pointer can be redirected before the read arrives.
The solution is i2c_master_write_read_device() - a single HAL call that issues a write, a repeated START (no STOP condition in between), and then the read, all as one atomic bus operation. There is no inter-transaction gap in which another master or task can intervene, because the bus is never released between the write and the read. The register pointer stays locked to where you set it. This is the correct tool for sensors that specify "repeated START" in their datasheet, and it is the correct tool any time you observe phantom reads that don't match what you wrote.
Migrating to the atomic function is also the right call when you add a third or fourth sensor to the bus. A mutex with a 50 ms timeout can mask latency problems when there are two sensors; with four sensors each holding the mutex for tens of milliseconds, a 50 ms timeout becomes a 200 ms latency spike for the lowest-priority task - which on a sampling-rate-sensitive sensor is a dropped measurement, not a delay. Atomic transactions don't help with hold time, but they remove one class of timing edge case and make the driver code easier to reason about.
The Results
After adding the mutex and the stop/settle protocol, the logic-analyzer capture changes immediately. Where before you saw two START conditions arriving within a few microseconds of each other - one winning, one corrupting a transaction - you now see strict serialization: the thermal task holds the bus for a clean burst of transactions, releases it, and only then does the proximity-baro task proceed with its own clean single-byte read. There are no overlapping SCL cycles, no mid-transaction START from a different device address, no torn frames.
The application-level result is equally clear: the thermal frames are consistent, the proximity distance matches what the sensor hardware actually measures, and the barometer pressure reading stops drifting by random amounts between samples. HAL_BUSY disappears from the logs entirely. The settle-delay cost - a few milliseconds per transaction - is invisible at the sampling rates these sensors run at. The mutex contention cost - one task occasionally waiting for the other - is bounded and predictable, and it shows up as exactly the right amount of latency in the timing diagrams: one sensor finishes, the other starts. No surprises.
Why It Matters at Hoomanely
Hoomanely is reinventing healthcare for pets - replacing reactive, imprecise care with continuous, clinical-grade monitoring that catches problems early. Our devices form a Physical Intelligence ecosystem: sensors fused at the edge, feeding the Biosense AI Engine that turns raw signals into personalized, preventive insights.
Pet devices pack many sensors on few pins. Our tracker and hub both run multiple I²C peripherals - thermal, proximity, barometric, inertial - on a single bus that must never collide mid-reading. A torn thermal frame doesn't just look wrong in a dashboard; it feeds a bad temperature reading to the Biosense AI Engine, which then reasons incorrectly about a pet's thermal comfort, sleep quality, or fever. A silently corrupted proximity distance pushes the thermal overlay registration off-target and makes the fused measurement untrustworthy. Garbage in, at the I²C bus level, is garbage all the way up to the veterinary insight.
Our guiding principle is that every signal matters and every detail counts. Arbitrating a shared I²C bus correctly - with a mutex, a stop/settle protocol, and sensor-specific single-byte fallbacks - is the kind of unglamorous firmware discipline that ensures the signals reaching the Biosense engine are the ones the sensors actually measured.
Key Takeaways
Name the shared resource and protect it. An I²C bus shared between RTOS tasks is a critical section. A single FreeRTOS mutex that every sensor driver must take before touching SDA or SCL is the minimum viable arbitration.
Stop ranging before releasing the bus. A sensor that autonomously generates I²C traffic - like a continuous-ranging thermal imager - must be put into single-shot or idle mode before the mutex is released. Giving up the mutex while the sensor is still autonomous is not protection; it's a slow-motion collision.
Settle delays are real and device-specific. Read the datasheet timing tables. A settle delay that looks optional at room temperature will bite you at the low end of the operating range, exactly when pet health data is most interesting.
Know when auto-increment is broken. MLX90640 and similar sensors may require word-by-word reads for certain register banks. This extends mutex hold time; design timeouts accordingly.
Use i2c_master_write_read_device() for sensors that need repeated START. Two-call read patterns leave a gap between write and read that another task can enter. Atomic transactions close that gap permanently.
Audit every early return. Every path that calls i2c_take() must also call i2c_give(). A missed give on an error path is an invisible deadlock waiting for the exact wrong moment.
Author's Note
This I²C arbitration pattern runs across all of Hoomanely's multi-sensor hubs and trackers - wherever a thermal imager, proximity sensor, barometer, or inertial unit shares a bus with anything else. The mutex and the stop/settle protocol are the two-line contract between any driver and the bus: take first, put to idle before you give back. It's the boring firmware discipline that makes the interesting sensor fusion possible - and that keeps the Biosense AI Engine reasoning over measurements, not over noise.