Bit banging I2C

Electronics
Embedded
C
Author

Leo Qi

Published

April 16, 2025

The I2C protocol lets two chips talk to each other on the same board over a bus with only two serial 1-bit wires. It’s one of the simplest ways to connect devices in an embedded system. We’re going to use C to illustrate how it works using two GPIO (General Purpose Input Output) pins. This method of using code executed in the microcontroller itself to coordinate the transfer is called “bit banging,” and it is used when our system does not have dedicated devices to manage the transfer for us.

For example, STM32 SoCs have DMA controllers that allow memory-to-peripheral writes directly to an I2C peripheral. This saves our processor a bunch of clock cycles that it would have needed to oversee the transfer itself.

STM32 generic DMA block diagram

Using the STM32 HAL (hardware abstraction layer or common functions)’s I2C firwmare driver to configure DMA, communication speed, and polling/interrupt mode results in the most correct implementation.

What is I2C?

I2C is an embedded protocol used to talk to chips on the same board. It is made up of a two-wire shared bus with serial data and clock lines. Many controllers and many responders can use the same bus. The two lines are used for bidirectional, half-duplex communication [1]. This means that only a single device can send data at a time.

I2C bus diagram
Line Description Driven by
SCL (Serial Clock Line) Synchronously clock data in or out of target Controller asserting bus
SDA (Serial Data Line) Data in or out of target Either controller or target

The SDA and SCL lines have an open-drain connection to all devices on the bus. This means that the lines are pulled high by a resistor to a common voltage supply and can be pulled low by any device on the bus. The pull-down action looks like a NMOS switch. Capacitance on the bus line means that the lines discharge with an exponential RC time constant. Higher capacitance limits the speed of the bus, the number of devices, and the physical distance between devices on the bus. A smaller R means faster rise time but requires more power.

Pull-down open-drain

Using an open-drain has the advantage that bus contention does not put the bus into a destructive state. Many devices can be connected to the bus because if any output pulls the line low, the line is low (wired-AND): the output is the logical AND of all the outputs when tied together.

Contrast this with SPI, which is a full-duplex protocol. SPI allows the controller to both send and receive data at the same time, but requires four data lines: a serial clock, chip select, and two data lines. SPI push-pull outputs are not open-drain, so contention on the bus can cause damage to the devices.

Contention comparison

The protocol

  1. System is initialized with both SDA, SCL floating.
  2. Every controller monitors bus for start and stop bits. If the bus is idle, the controller can start a transaction.
  3. The controller sends START by pulling SDA low while SCL is high, and then pulling SCL Low. This indicates that the controller is about to send data.
  4. The controller sends a communication frame (8 bits, or 1 byte) containing a 7-bit device address and 1 \(\text{Read}/\overline{\text{Write}}\) bit.
  5. The target acknowledges receipt of communication frame by pulling SDA line low.
  6. Subsequent frames are transferred by either the controller or target, depending on the device specification (datasheet).
  7. After communication, the controller sends a STOP condition by pulling SDA low while SCL is high, and then releasing SCL.

Logic one is sent when SDA releases line (allowing resistor to pull line up), logic zero is sent when SDA is pulled low. A bit is considered valid if SDA does not change between a rising edge and falling edge of SCLK for that bit. (If SDA does change, this may be interpreted as a START/STOP condition).

I2C data frames

Implementation

An example C implementation from BitBanging.space [2].

#define SDA_ON  (OUT_REG |= (1 << PIN_SDA))
#define SDA_OFF   (OUT_REG &= ~(1 << PIN_SDA))
#define SCL_ON  (OUT_REG |= (1 << PIN_SCL))
#define SCL_OFF   (OUT_REG &= ~(1 << PIN_SCL))

inline void delay() {
  _NOP(); // set to number of NOPs
}

bool i2c_tx(uint8_t data) {
  /* Start condition*/
  SDA_ON;
  delay();
  SCL_ON;
  delay();
  SDA_OFF;
  delay();
  SCL_OFF;
  delay();
}

void stop() {
  SDA_OFF;
  delay();
  SCL_ON;
  delay();
  SDA_ON;
  dela();
}

bool i2c_tx(uint8_t dat) {
  for (uint8_t i = 8; i; --i) {
    (dat & 0x80) ? SDA_ON : SDA_OFF; // mask for eighth bit
    dat <<= 1;
    delay();
    SCL_ON;
    delay();
    SCL_OFF;
    delay();
  }
  SDA_ON;
  SCL_ON;
  delay();
  bool ack = !SDA_READ;
  SCL_OFF;
  return ack;
}

uint8_t i2c_rx(bool ack) {
  uint8_t dat = 0;
  SDA_ON;
  for (uint8_t i = 0; i < 8; ++i) {
    dat <<= 1;
    do {
      SCL_ON;
    } while (SCL_READ == 0); // clock stretching
    delay();
    if (SDA_READ)
      dat |= 1;
    delay();
    SCL_OFF;
  }
  ack ? SDA_OFF : SDA_ON;
  SCL_ON;
  delay;
  SCL_OFF;
  SDA_ON;
  return dat;
}

int main(void) {
  DDRB = (1<<DRB1)|(1<<DDB0); // PB0, PB1 as output
  
  start();
  i2c_tx((ADDR<<1)|0x00); // target address and R/W as write
  i2c_tx(0x00); // register address
  i2c_tx(0xFF); // data to write
  stop();

  while (1) {}
}

Additional complexity

  • The above does not monitor the bus for other controllers.
  • The above does not handle bus arbitration if multiple controllers wish to use the bus at (near) the same time. I2C is interesting because the first master to diverge from data transfer by keeping SDA high instead of pulling it down loses arbitration. For more information, see [1].

References

[1]
J. Wu, “A basic guide to I2C,” Texas Instruments, SBAA565, 2022. Available: https://www.ti.com/lit/pdf/SBAA565
[2]
lr, “Bit bang I2C protocol. Bitbanging.space.” Accessed: Apr. 16, 2025. [Online]. Available: https://www.bitbanging.space/posts/bitbang-i2c