← How-Tos
electronics 1 hr ago ◯ 6 min read

Bit-Banging SPI and I2C in Firmware: When You Don't Have a Hardware Peripheral and How to Do It Right

bit bangingsoftware spisoftware i2cgpiomicrocontrollerprotocol timingembedded firmware

Every I2C and SPI tutorial on this site assumes you have a hardware peripheral doing the heavy lifting — the ESP32's I2C and SPI controllers, the dedicated TWI hardware in an AVR. That hardware handles clock generation, timing, and shift-register logic in silicon, which is why those buses feel almost effortless to use. But there are real situations where you don't have a free hardware peripheral available, or you're working with a chip that doesn't have one at all — a bare ATtiny85 with only one hardware SPI-capable pin set already in use, an ESP32 project that's already consumed every hardware I2C and SPI bus and needs one more sensor, or a microcontroller with no SPI/I2C silicon whatsoever. Bit-banging — toggling GPIO pins in software to fake the protocol's timing and signaling — is the fallback, and it works reliably if you understand what you're actually reproducing in software.

What Bit-Banging Actually Means

Both I2C and SPI are, underneath the protocol rules, just specific patterns of pins changing voltage in a specific order with specific timing relationships. A hardware peripheral generates those patterns automatically once you load a byte into a register. Bit-banging does the identical job with your own code: you set and clear GPIO pins directly, with delays or careful instruction timing between transitions, to reproduce the same waveform a hardware peripheral would generate. The receiving device on the bus — a sensor, an EEPROM, a display driver — has no way to tell whether the signal came from real I2C/SPI hardware or from a microcontroller doing it in software; the protocol only cares about the electrical waveform, not how it was generated.

Bit-Banging SPI

SPI is the easier of the two to bit-bang because it's a simple synchronous shift register protocol with no arbitration or open-drain signaling to worry about. You need four GPIO pins: SCLK (clock), MOSI (data out), MISO (data in), and CS (chip select, one per device). A basic bit-bang SPI transfer of one byte looks like this in pseudocode logic:

The detail that trips people up is SPI mode (CPOL/CPHA) — whether data is sampled on the rising or falling clock edge, and whether the clock idles high or low. Get this wrong and the device will appear to not respond at all, or respond with garbage, even though your wiring is correct. Check the target device's datasheet for its required SPI mode and match your bit-bang sequence to it exactly; most simple SPI devices (many displays, flash chips, ADCs) use Mode 0, but plenty don't.

Bit-Banging I2C

I2C is trickier because it's an open-drain, multi-master bus with specific start/stop condition signaling and a required acknowledge bit after every byte. You need two pins, SDA and SCL, both configured as open-drain outputs (driven low or released to float high via the bus's pull-up resistors — never driven high directly, since that's what allows multiple devices to share the bus safely). A bit-banged I2C write sequence includes:

Because I2C is open-drain, both SDA and SCL need external pull-up resistors (typically 4.7kΩ) to a logic-level voltage rail if your bus doesn't already have them — a bit-banged I2C bus without pull-ups will not work, since neither device can actually drive the line high, only release it. This is the single most common bit-bang I2C failure and it's easy to miss if you're used to boards with built-in pull-ups on their hardware I2C pins.

Timing: The Part That Actually Matters

Both protocols have minimum and maximum timing requirements, but I2C in particular specifies minimum hold and setup times around the clock edges (measured in hundreds of nanoseconds at standard 100kHz speed). On a fast modern microcontroller, a tight loop toggling pins with no added delay can genuinely violate these minimums, producing a bus that appears to work with some devices and fails intermittently with others depending on how picky their input timing is. Add explicit short delays (even a few CPU cycles' worth, via a tiny busy-wait or a nop loop) between transitions rather than relying on however fast your GPIO toggle instruction happens to execute — this is the difference between a bit-bang implementation that works reliably across devices and one that "mostly works" and fails under specific conditions.

Bit-banged buses are also blocking by nature unless you write them with interrupts or a state machine — the CPU is busy toggling pins and can't do anything else during a transfer. On a single-threaded AVR this just means the transfer takes CPU time; on something running FreeRTOS or handling other time-sensitive work, a long bit-bang transfer can cause timing problems elsewhere if it's not kept short or yielded properly.

When to Bit-Bang vs When to Add a Peripheral

Bit-banging is the right tool when you're out of hardware peripherals but have spare GPIO pins, when you need I2C or SPI on a chip that has no hardware support for it at all (some very low pin-count microcontrollers), or when you need multiple independent SPI buses on a chip with only one hardware SPI controller. It's the wrong tool when you need high throughput (bit-banged buses top out well below hardware peripheral speeds — expect tens to a couple hundred kHz reliably rather than the multi-MHz hardware SPI can hit) or when the target device is timing-sensitive enough that software jitter causes intermittent failures no amount of delay tuning fully resolves. In those cases, adding an I2C/SPI multiplexer or expander chip (like a TCA9548A I2C mux, or a shift register for simple output-only SPI-like devices) to extend your existing hardware peripheral is usually more reliable than pushing bit-banging further than it comfortably goes.

Bit-banging isn't a hack or a last resort to be embarrassed about — it's how these protocols worked before most microcontrollers had dedicated silicon for them, and it's still the right call in plenty of real projects. The two things that separate a bit-bang implementation that works from one that doesn't are matching the exact protocol timing rules (SPI mode, I2C start/stop/ACK signaling) and adding real pull-up resistors on I2C — get those right and a software-driven bus is just as functional as hardware, just slower and more CPU-hungry.