So how does the I2C bus actually work?
As mentioned before, there are just two signals: SCL, the clock, and SDA, the data line. At any point in time, there is at most one chip which controls SCL. This is the bus master.
See the following oscilloscope traces – SCL in blue at the top, and SDA in yellow below:
In electrical terms, these signals are really horrible – look at how asymmetric they are! The explanation for this is actually the key to how I2C works: each signal pin is tied to 3.3V via a resistor (usually in the range 1..10 kΩ), while each chip only has circuitry to pull the signal low, i.e. shorting it to ground via a transistor.
The result is that each signal line acts like a distributed logical AND gate: the value is high only when all the chips on the bus leave it high. As soon as any chip pulls the line to ground via its transistor, the whole signal line goes low – this can then be detected by all chips.
The slow rise time of the signal is caused by the capacitance inherent to any wire of some length. The pull-up resistor has to charge up this capacitance to pull the line high, which causes that characteristic exponentially-flattening gradual upward slope. Lower resistance values will charge faster and make the rising slope steeper, but also require more current.
Setting the signal to zero is instant in comparison, hence very well-defined. Which is why the I2C bus defines the falling edge of SCL as main sampling point for the SDA pin value.
To summarise: there is normally one bus master (and it’s usually a µC), which uses SCL as clock to define the meaning of the data going over the SDA line. Using a well-defined set of conventions, either the master or a designated slave may then drive the SDA line.
Note that a µC can also be used as I2C slave. In that case, it must be told which address(es) to listen to – and only then will it take control of the SDA line, if the master tells it to.
Here is an example of an LPC810 acting as fake RTC device, listening to the same 0x68 address (there can always be at most one device for each address in use on the bus):
#include "LPC8xx.h"
#include "lpc_types.h"
#include "romapi_8xx.h"
uint32_t i2cBuffer [24];
I2C_HANDLE_T* ih;
void i2cSetupXfer(); // forward
void i2cSetup () {
LPC_SWM->PINASSIGN7 = 0x02FFFFFF; // SDA on P2
LPC_SWM->PINASSIGN8 = 0xFFFFFF03; // SCL on P3
LPC_SYSCON->SYSAHBCLKCTRL |= (1<<5); // enable I2C clock
ih = LPC_I2CD_API->i2c_setup(LPC_I2C_BASE, i2cBuffer);
LPC_I2CD_API->i2c_set_slave_addr(ih, 0x68<<1, 0);
NVIC_EnableIRQ(I2C_IRQn);
}
extern "C" void I2C0_IRQHandler () {
LPC_I2CD_API->i2c_isr_handler(ih);
}
void i2cDone (uint32_t, uint32_t) {
i2cSetupXfer(); // restart the next transfer
}
void i2cSetupXfer() {
static uint8_t buf [] = { 0, 1, 2, 3, 4, 5, 6 };
static uint8_t seq;
static I2C_PARAM_T param;
static I2C_RESULT_T result;
buf[0] = ++seq;
buf[1] = 1; // gets overwritten by received register index
/* Setup parameters for transfer */
param.func_pt = i2cDone;
param.num_bytes_send = 8;
param.num_bytes_rec = 2;
param.buffer_ptr_send = param.buffer_ptr_rec = buf;
LPC_I2CD_API->i2c_slave_receive_intr(ih, ¶m, &result);
LPC_I2CD_API->i2c_slave_transmit_intr(ih, ¶m, &result);
}
int main () {
i2cSetup();
i2cSetupXfer();
while (true)
__WFI();
}
The actual code is available on GitHub, as usual. The compiled code takes 516 bytes of flash and 144 bytes of RAM (this includes a 96 byte work area for the I2C ROM driver).
This example is even smaller than the master demo, and also illustrates using the ROM-based built-in I2C driver in interrupt mode. Send / receive requests are set up in advance, and when they happen, the i2cdone
code gets called, which prepares for another round.
The fake “time” results sent by this little demo are simply a series of fixed values, except for the first byte, which is incremented each time around.
[Back to article index]