Skip to content

I2C

The Inter-Integrated Circuit (I2C) protocol is a common digital communications protocol used to connect microcontrollers, sensors, displays, actuators, and other devices together using a shared two-wire bus.

I2C groups data into transactions consisting of one or more bytes. The standard defines two device roles: controllers and targets. Controllers generate clock signals and always initiate transactions. Targets respond when addressed by controllers, either reading from or writing to the bus. Any given transaction either transfers data from a controller to a target, or from a target to a controller. Targets cannot communicate with one another.

Device Terminology

Older versions of the I2C specification used the term "master" in place of "controller" and "slave" in place of "target." Though these terms have been phased out, you may still see them used in datasheets and library code.

Every target on the bus must be assigned a unique 7-bit address. This address is used by controllers to indicate which device should respond to a transaction; targets only respond to transactions that match their address. Some devices use hard-coded addresses, which others can be assigned different addresses for additional flexibility; addressing information is usually provided in a part's datasheet.

Device Addresses

Some datasheets and libraries use 8 bits to specify target addresses, adding an extra bit depending on whether the transaction is a read or a write. Daisy uses the more common 7-bit notation (not including the R/W bit), where valid addresses are in the range 0x08 - 0x77. If you are unable to communicate with a device, you may need to right-shift the address by one bit to bring it within this range.

I2C buses use two signals (in addition to a common ground):

  • SDA: Serial Data
  • SCL: Serial Clock Line

Both signals are bidirectional. Each device takes turns writing to the same signals, so many devices can share the same bus. Since I2C devices connect to the bus using pins in an open-drain configuration, the two bus lines require pull-up resistors to function. The proper resistance to use will depend on your bus configuration and connected devices: 4.7 kΩ is a good starting point, but faster rates, long bus lines, and systems with many connected devices may require a lower resistance. Be mindful that many breakout boards have built-in pull-up resistors, which you may need to change or remove.

How is I2C different from SPI?

I2C is often compared to SPI, and many devices (including Daisy) support both protocols. Like SPI, it is a synchronous serial protocol that allows messages to be transmitted to and received from multiple devices.

Unlike SPI, I2C only uses two pins per bus, no matter how many devices are connected. This makes it great for projects with limited free pins. I2C also allows multiple controllers on the same bus (as long as they don't transmit at the same time), unlike SPI which only supports one.

I2C is generally slower than SPI: since there is only one data signal, devices cannot transmit and receive at the same time.

How To Use I2C

Configuration

Daisy supports one or more independent I2C peripherals depending on device, each of which can act as either an I2C controller or target. Each peripheral is configured and accessed using an I2CHandle object. To use it, you must create an I2CHandle::Config object, then initialize the handle with it. The specifics depend on whether you want to set up the bus with Daisy as a controller or as a target.

I2CHandle::Config has the following fields:

  • I2CHandle::Config::periph: Sets which of the I2C peripherals is accessed by this handle.
  • I2CHandle::Config::pin_config: Controls which pins to use for the bus. Must match the peripheral; refer to the tables below or the pinout diagrams for more details.
    • I2CHandle::Config::pin_config::sda: The pin to use for data signals.
    • I2CHandle::Config::pin_config::scl: The pin to use for clock signals.
  • I2CHandle::Config::speed: Set the data transmission rate. Make sure that all connected devices can support this rate.
    • I2CHandle::Config::Speed::I2C_100KHZ: "Standard" speed, supported by all compliant devices.
    • I2CHandle::Config::Speed::I2C_400KHZ: "Fast" speed, supported by most devices.
    • I2CHandle::Config::Speed::I2C_1MHZ: "Fast Plus" speed, supported by some devices. (Actually 886kHz).
  • I2CHandle::Config::mode: Set whether Daisy joins the bus as a controller or target.
    • I2CHandle::Config::Mode::I2C_MASTER: Daisy is a controller, and can initiate transactions.
    • I2CHandle::Config::Mode::I2C_SLAVE: Daisy is a target, and responds to transactions from controllers.
  • I2CHandle::Config::address: Address to use when Daisy is a target. Not used when acting as a controller.

Daisy Seed / Seed 2 DFM Pins

The Daisy Seed and Seed 2 DFM support two independent user-accessible I2C peripherals, on the following pins:

Peripheral SDA SCL
I2C_1 D12 D11
I2C_4 D14 D13

Daisy Patch SM Pins

The Daisy Patch SM has one user-accessible I2C peripheral, on the following pins:

Peripheral SDA SCL
I2C_1 B8 B7

Daisy as Controller

If you are using Daisy to control external devices like sensors or displays, you should set up the I2CHandle as a controller. A typical configuration looks like this:

// Configure the handle
I2CHandle::Config i2c1_conf;
i2c1_conf.periph = I2CHandle::Config::Peripheral::I2C_1;
i2c1_conf.mode   = I2CHandle::Config::Mode::Master; // Use the peripheral as a controller
i2c1_conf.speed  = I2CHandle::Config::Speed::I2C_400KHZ;
i2c1_conf.pin_config.scl  = seed::D11; // Must match pinout for I2C1 SCL
i2c1_conf.pin_config.sda  = seed::D12; // Must match pinout for I2C1 SDA
// Address not needed for controller!

// Initialise the handle
I2CHandle i2c1_handle;
if (i2c1_handle.Init(i2c1_conf) != I2CHandle::Result::OK) {
    // Something went wrong! Handle it here.
}

// i2c1_handle is ready for use in controller mode

Daisy as Target

If you are using Daisy as a secondary device controlled by another system, you may want to use I2C to receive commands. To do this, you can set up a bus in target mode. A typical configuration looks like this:

// Configure the handle
I2CHandle::Config i2c1_conf;
i2c1_conf.periph = I2CHandle::Config::Peripheral::I2C_1;
i2c1_conf.mode   = I2CHandle::Config::Mode::Slave; // Use the peripheral as a controller
i2c1_conf.speed  = I2CHandle::Config::Speed::I2C_400KHZ;
i2c1_conf.pin_config.scl  = seed::D11; // Must match pinout for I2C1 SCL
i2c1_conf.pin_config.sda  = seed::D12; // Must match pinout for I2C1 SDA
i2c1_conf.address = 0x12; // Target must specify an address

// Initialise the handle
I2CHandle i2c1_handle;
if (i2c1_handle.Init(i2c1_conf) != I2CHandle::Result::OK) {
    // Something went wrong! Handle it here.
}

// i2c1_handle is ready for use in target mode

Blocking Transmit and Receive

Blocking methods can be used to synchronously transmit to or receive data from the bus. Execution will halt until the transaction completes, or times out.

Send data from controller to target:

uint8_t buffer[4] = {0, 1, 2, 3}; // Data to send
i2c_handle.TransmitBlocking(
    0x12, // Target address
    buffer, // Pointer to buffer
    sizeof(buffer) / sizeof(buffer[0]), // Number of bytes to send
    1000 // Try for this many milliseconds before failing
);

Receive data from target to controller:

uint8_t buffer[16]; // Buffer for data
i2c_handle.ReceiveBlocking(
    0x12, // Target address
    buffer, // Pointer to buffer
    sizeof(buffer) / sizeof(buffer[0]), // Number of bytes to receive
    1000 // Try for this many milliseconds before failing
);

Send data from target to controller:

uint8_t buffer[4] = {0, 1, 2, 3}; // Data to send
i2c_handle.TransmitBlocking(
    0x00, // Unused in target mode (controllers have no address)
    buffer, // Pointer to buffer
    sizeof(buffer) / sizeof(buffer[0]), // Number of bytes to send
    1000 // Try for this many milliseconds before failing
);

Receive data from controller to target:

uint8_t buffer[16]; // Buffer for data
i2c_handle.ReceiveBlocking(
    0x00, // Unused in target mode (controllers have no address)
    buffer, // Pointer to buffer
    sizeof(buffer) / sizeof(buffer[0]), // Number of bytes to receive
    1000 // Try for this many milliseconds before failing
);

Reading and Writing Registers

I2C devices commonly organize their configuration and data into registers. These are typically given in datasheets as lists of 8- or 16-bit values that correspond to a specific setting, actuator, or value. For example, an IMU may assign different registers to acceleration, rotation, orientation, and sampling rate.

When using registers, the controller will typically send a transaction to the target consisting of the desired register, followed immediately by either a new value to write, or a request to read the current value. Because this pattern is so common, Daisy provides the following methods:

uint8_t buffer[4] = {0, 1, 2, 3}; // Data to send
i2c_handle.WriteDataAtAddress(
    0x12, // Target address
    0xAB, // Target register
    1, // Either `1` if the register is a single byte, or `2` if two bytes
    buffer,
    sizeof(buffer) / sizeof(buffer[0]), // Number of bytes to transmit
    1000 // Try for this many milliseconds before failing
);

// Equivalent to:
/*
uint8_t reg = {0xAB}; // Target register
uint8_t buffer[4] = {0, 1, 2, 3}; // Data to send
// Send register...
i2c_handle.TransmitBlocking(0x12, reg, sizeof(reg) / sizeof(reg[0]), 1000); 
// ...followed immediately by data
i2c_handle.TransmitBlocking(0x12, buffer, sizeof(buffer) / sizeof(buffer[0]), 1000);
*/
uint8_t buffer[16]; // Buffer for data
i2c_handle.ReadDataAtAddress(
    0x12, // Target address
    0xAB, // Target register
    1, // Either `1` if the register is a single byte, or `2` if two bytes
    buffer,
    sizeof(buffer) / sizeof(buffer[0]), // Number of bytes to receive
    1000 // Try for this many milliseconds before failing
);

// Equivalent to:
/*
uint8_t reg = {0xAB}; // Target register
uint8_t buffer[16]; // Buffer for data
// Send register...
i2c_handle.TransmitBlocking(0x12, reg, sizeof(reg) / sizeof(reg[0]), 1000); 
// ...followed immediately by a read
i2c_handle.ReceiveBlocking(0x12, buffer, sizeof(buffer) / sizeof(buffer[0]), 1000);
*/

Controllers Only!

ReadDataAtAddress and WriteDataAtAddress only work in controller mode, and will return errors if used in target mode.

Breaking Change

In versions of libDaisy below v8.0.0, there was an addressing inconsistency between the generic Read and Write methods and the ReadDataAtAddress and WriteDataAtAddress methods that required users to manually left-shift addresses by one bit before passing them to ReadDataAtAddress and WriteDataAtAddress. This has been corrected in v8.0.0, and now all I2CHandle methods use the same address conventions.

To migrate existing code for external device drivers, remove any left-shifting applied to addresses passed to ReadDataAtAddress or WriteDataAtAddress.

DMA Transmit and Receive

For larger transactions, Daisy also supports DMA (Direct Memory Access). This allows the hardware to handle the transaction in the background while the code executes other tasks, i.e., it is non-blocking.

You can also pass along a callback to be called when the transfer starts, another for when the transfer is over, and a pointer to some data to send those callbacks.

Warning

Your buffer has to be in the DMA section of memory, as well as in global scope. Alternatively, cache-maintenance must be manually performed when using buffers located outside of this region.

Warning

The I2C_4 peripheral does not support DMA.

// Buffer for sending data
uint8_t DMA_BUFFER_MEM_SECTION tx_buffer[4];

...

// (Fill `tx_buffer` with data first!)
i2c_handle.DmaTransmit(
    0x12, // Target address
    tx_buffer, // Pointer to transmit buffer
    sizeof(tx_buffer) / sizeof(tx_buffer[0]), // Number of bytes to send
    nullptr, // No callback function
    nullptr, // No callback function data
);
// Buffer for receiving data
uint8_t DMA_BUFFER_MEM_SECTION rx_buffer[16];

...

i2c_handle.DmaReceive(
    0x12, // Target address
    rx_buffer, // Pointer to receive buffer
    sizeof(rx_buffer) / sizeof(rx_buffer[0]), // Number of bytes to receive
    nullptr, // No callback function
    nullptr, // No callback function data
);