FastArduino  v1.7
C++ library to build fast but small Arduino/AVR projects
Adding support for an I2C device

There are plenty of devices of all kinds, based on I2C interface, that you may want to connect to your Arduino or a board you created with an AVR ATmega or ATtiny MCU.

If you want to learn more about I2C concepts and vocabulary, you can find further information on Wikipedia.

Unfortunately, FastArduino obviously cannot provide specific support for all existing I2C devices.

However, based on a given device datasheet, it can be quite easy to add a FastArduino driver for any I2C device.

FastArduino provides all the necessary classes and methods for you to implement such a specific driver.

The following sections describe the FastArduino API for I2C device driver implementation, and list the steps to successfully implement such a driver.

FastArduino I2C driver API

The generic support for I2C device driver in FastArduino looks quite simple, it is entirely embedded in one class, i2c::I2CDevice; this is a template class which all actual I2C device drivers shall derive from.

This template class has only one MANAGER parameter, which must be kept as is in all subclasses; this represents the type of I2C Manager used to handle the I2C bus and operations.

The i2c::I2CDevice class mainly contains protected types aliases and methods to create and launch read and write commands to a device on the I2C bus.

Any FastArduino I2C device must be able to work in both asynchronous and synchronous modes The i2c::I2CDevice API is made for asynchronous operations; synchronous flavours of a specific device API are based on asynchronous implementations of that API (just awaiting for the operation to finish).

As you can see in the following diagrams, the drivers for I2C devices currently supported by FastArduino directly derive from i2c::I2CDevice:

  1. DS1307 Real Time Clock chip
  2. MCP23008 8-Bit I/O Expander chip
  3. MCP23017 16-Bit I/O Expander chip
  4. MPU6050 3D Accelerometer-Gyroscope chip
  5. HMC5883L 3D Compass chip

Creating a new driver for an I2C device must follow these steps:

  1. Create a i2c::I2CDevice template subclass; let's call it MyI2CDevice in the rest of this page.
  2. Redefine (as private) the following type aliases inherited from i2c::I2CDevice: PARENT, PROXY, FUTURE
  3. Add a public constructor with one argument: MyI2CDevice::MyI2CDevice(MANAGER& manager) where MANAGER is a class template argument of both MyI2CDevice and i2c::I2CDevice; this constructor must call the inherited constructor and pass it 3 arguments: manager, the default I2C address for your device, and finally, one of i2c::I2C_STANDARD or i2c::I2C_FAST constants, to indicate the best mode (highest I2C frequency) that you device can support.
  4. List the API you need to provide to the end user of your device (based on the device datasheet)
  5. For each public API you need to provide, define a specific Future to hold values written to the device, as well as values later read from the device. Each defined Future shall derive from FUTURE (type alias defined above). This future will allow asynchronous execution of the API. FastArduino guidelines for I2C devices suggest to name the future class according to the API name itself e.g. SetDatetimeFuture for the set_datetime() API.
  6. For each public API, define a method that takes a PROXY to the future defined above and return an int. The implementation of this method is based on mainly 3 inherited protected methods:, i2c::I2CDevice.write() and i2c::I2CDevice.launch_commands()
  7. For each public API, also define a similar method (same name) with a synchronous flavour. That method will directly take "natural" arguments (no futures) as input or output (reference), and return a bool to indicate if API was performed without any error.

I2CDevice API

Before describing FastArduino I2C Device API, it is important to mention that this API is heavily based on FastArduino future API, which concepts shall be first understood before starting to build your own support for an I2C device.

Subclassing i2c::I2CDevice gives MyI2CDevice access to all low-level protected aliases:

  • PARENT: this is simply defined as i2c::I2CDevice<MANAGER> and is useful for accessing next aliases
  • i2c::I2CDevice::PROXY: this is the type of lifecycle proxy used by MANAGER; it must be used for all asynchronous API of MyI2CDevice to embed actual Future types
  • i2c::I2CDevice::FUTURE: this is the type of Future used by MANAGER; it must be used for defining your own Future types for all asynchronous API of MyI2CDevice

Note that to be accessible from MyI2CDevice class, these types must be redefined as follows:

using PARENT = i2c::I2CDevice<MANAGER>;
template<typename T> using PROXY = typename PARENT::template PROXY<T>;
template<typename OUT, typename IN> using FUTURE = typename PARENT::template FUTURE<OUT, IN>;

i2c::I2CDevice constructor takes 4 arguments:

  1. MANAGER& manager: this should be passed as is from MyI2CDevice constructor
  2. uint8_t device: this is the default I2C address of this device (this 7-bits address must be already left-shifted one bit to leave the LSB available for I2C direction read or write)
  3. Mode<MODE> mode (MODE is a template argument of the constructor, i2c::I2CMode MODE): this should be passed one of 2 constants i2c::I2C_FAST or i2c::I2C_STANDARD) to indicate the best I2C mode (highest frequency) supported by MyI2CDevice: this will impact what MANAGER type can be used when instantiating MyI2CDevice template
  4. bool auto_stop: this defines whether all chains of commands of MyI2CDevice shall automatically be ended with a STOP condition on the I2C bus or not. In most cases, the default (false) should work, but some devices (e.g. DS1307) do not work properly in 2 chains of commands are not separated by a STOP condition.

Note that device address must be provided at construction time but can optionally be changed later. Regarding its I2C address, typically an I2C device falls in one of the following categories:

  1. it has a fixed I2C address that cannot be changed (e.g. DS1307 RTC chip)
  2. it has an I2C address that can be changed by hardware (e.g. jumpers) among a limited range of possible addresses (e.g. MCP23017 I/O expander chip, MPU 6050 acceleromete/gyroscope chip)
  3. it has a fixed I2C address that can be changed by software (e.g. VL53L0X "micro lidar" chip); this is generally harder to support.

For devices in category 1, you would typically define the address as a constant in MyI2CDevice and pass it directly to i2c::I2CDevice constructor.

Here is a snippet from DS1307 device:

explicit DS1307(MANAGER& manager) : PARENT{manager, DEVICE_ADDRESS, i2c::I2C_STANDARD} {}
static constexpr const uint8_t DEVICE_ADDRESS = 0x68 << 1;

For devices in category 2, you would rather define an enum class limiting the possible addresses configurable by hardware, or pass the address (as uint8_t) to the driver class constructor.

For devices in category 3, you would first define the fixed address as a constant, then define an API to change it (as a data member of MyI2CDevice).

Subclassing i2c::I2CDevice gives MyI2CDevice access to all low-level protected methods:

Note that read() and write() methods do not actually perform any I2C operation! They only prepare an I2C read or write command (i2c::I2CLightCommand type embedding necessary information) based on their arguments:

  • read_count/write_count specify the number of bytes to read or write. When 0 (the default), this means that all bytes (as defined in the specific Future) will be read or written.
  • finish_future: specify that, after this command execution, the Future assigned to the current transaction will be marked as finished
  • stop: specify that, after this command execution, an I2C STOP condition will be generated on the I2C bus; this will automatically trigger a "START" condition on the next command (whether it is part of the current chain of commands or not)

The launch_commands() method does the actual work:

  • with a synchronous I2C Manager, it blocks until all commands get executed or an error occurs; the assigned Future is directly READY (or in ERROR) when the method returns
  • with an asynchronous I2C Manager, it enqueues all commands for asynchronous execution and returns immediately; the assigned Future will be later updated (it status will become either READY or ERROR) once all commands are complete.

I2C Bus handling

Handling of the I2C bus by the I2C Manager and the I2CDevice follows standard I2C protocol, with some level of "intelligence".

In usual conditions: launch_commands() can execute long chains of commands on one device:

  1. The first command in the chain will generate a "START" condition on the I2C bus as required by the I2C protocol
  2. By default, all following commands will be preceded by a "REPEAT START" condition on the I2C bus
  3. By default, the last command in the chain will not end with a "STOP" condition on the bus; FastArduino I2C Manager will keep the I2C bus for itself, unless required otherwise by I2C devices implementation.

This default behaviour allows your I2C device API implementation to perform a sequence of calls to the I2C device, where the first call will acquire the bus, and all following calls in between will not need to acquire the bus.

You can change the default for each command or for a whole device:

  • at command level, by setting stop argument to true, which will produce a "STOP" condition at the end of that command and a "START" condition on the next command in the chain.
  • at device level, by setting auto_stop constructor argument to true; then all chains of commands for the device will always end with a STOP condition.

IMPORTANT: Actually, asynchronous flavours of I2C Managers will release the I2C bus at the end of an I2C transaction, in case there is no more pending command (from another I2C transaction) in the commands queue.

API Typical Example

For many I2C devices, communication is based on writing and reading "registers", each device having its own list of specific registers. Hence most I2C device drivers API will consist in reading or writing one register.

In FastArduino, drivers like devices::mcp230xx::MCP23008 first define private generic Future classes, later used by specific API to read registers:

class ReadRegisterFuture : public FUTURE<uint8_t, uint8_t>
using PARENT = FUTURE<uint8_t, uint8_t>;
ReadRegisterFuture(uint8_t address) : PARENT{address} {}
ReadRegisterFuture(ReadRegisterFuture&&) = default;
ReadRegisterFuture& operator=(ReadRegisterFuture&&) = default;

In this snippet, a base Future, ReadRegisterFuture, is defined. It will serve as a base class for all specific Futures needed by all API reading registers, like in the following excerpt from MCP23008 device:

// Address of GPIO register (to read digital input pins from MCP23008)
static constexpr const uint8_t GPIO = 0x09;
class GetValuesFuture : public ReadRegisterFuture
GetValuesFuture() : ReadRegisterFuture{GPIO} {}
GetValuesFuture(GetValuesFuture&&) = default;
GetValuesFuture& operator=(GetValuesFuture&&) = default;
int values(PROXY<GetValuesFuture> future)
return this->launch_commands(future, {this->write(), this->read()});

In the above code, the only added value of GetValuesFuture class is to embed the GPIO register address; this allows callers of the values() API to directly instantiate this Future without further input.

The implementation of values() is a one-liner that requires a few explanations:

  • launch_commands() has only 2 arguments: future that is directly passed from the API argument, and a list of commands (embedded within braces)
  • in this example, there are only 2 commands; the first, returned by write(), writes all input content of future, i.e. the GPIO register address (single) byte); the second command, created by read(), reads enough bytes (only one here) from the decice to fill the output of future.
  • both commands are created with default calls to read() and write() i.e. 0 for bytes count (special meaning: use full content size of future), false for finish_future and stop, leading to generation of "START" condition at the beginning, and no STOP forced at the end.

A similar approach is used for writing a value to a device register and will not be detailed here.

Debugging support for a new device (low-level)

In general, before developing a full-fledged driver for an I2C device, you need to learn how to use that device.

Based on the device datasheet, you first learn how to manipulate the device through the I2C bus.

For better understanding, you generally use a debugging example that helps demonstrate how the device works.

One easy way to develop such a debugging sample is to create a program with just one source code file containing:

  • proper #include directives
  • a PublicDevice class that derives from i2c::I2CDevice but declares main() as a friend, which allows direct calls, from main(), to protected API of i2c::I2CDevice, for easy testing
  • directly call SPI API on a PublicDevice instance, from main() and trace results to a console, through UART
  • use, as I2C Manager, I2CSyncDebugManager or I2CSyncDebugStatusManager, which allow tracing (live or later) all steps of I2C transactions

FastArduino includes such a debugging sample in examples/i2c/I2CDeviceProto example, copied hereafter:

1 // Copyright 2016-2021 Jean-Francois Poilpret
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 //
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
15 /*
16  * This is a skeleton program to help connect, debug and understand how a given
17  * I2C device (not already supported by FastArduino) works.
18  * That helps creating a new specific support API for that device for reuse in
19  * other programs and potential integration to FastArduino project.
20  * To ease wiring and debugging, I suggest using a real Arduino board (I typically
21  * use UNO) and a small breadboard for connecting the I2C device.
22  *
23  * Wiring:
24  * NB: you should add pullup resistors (10K-22K typically) on both SDA and SCL lines.
25  * - on Arduino UNO:
26  * - A4 (PC4, SDA): connected to I2C SDA pin
27  * - A5 (PC5, SCL): connected to I2C SCL pin
28  * - direct USB access (traces output)
29  */
31 #include <fastarduino/time.h>
32 #include <fastarduino/i2c_device.h>
33 #include <fastarduino/utilities.h>
34 #include <fastarduino/uart.h>
35 #include <fastarduino/i2c_debug.h>
37 // I2C Device specific constants go here
38 //======================================
39 static constexpr const i2c::I2CMode MODE = i2c::I2CMode::FAST;
40 static constexpr const uint8_t DEVICE_ADDRESS = 0x68 << 1;
42 static constexpr const uint8_t DEBUG_SIZE = 32;
45 #define DEBUG(OUT) debugger.trace(OUT)
47 // The following type aliases will be useful for declaring proper Futures and calling I2CDevice API
48 using PARENT = i2c::I2CDevice<MANAGER>;
49 template<typename T> using PROXY = typename PARENT::template PROXY<T>;
50 template<typename OUT, typename IN> using FUTURE = typename PARENT::template FUTURE<OUT, IN>;
52 // Define vectors we need in the example
55 // UART for traces
56 static constexpr const uint8_t OUTPUT_BUFFER_SIZE = 64;
57 static char output_buffer[OUTPUT_BUFFER_SIZE];
58 static serial::hard::UATX<board::USART::USART0> uart{output_buffer};
59 static streams::ostream out = uart.out();
61 // Subclass I2CDevice to make protected methods available
62 class PublicDevice: public PARENT
63 {
64 public:
65  PublicDevice(MANAGER& manager): PARENT{manager, DEVICE_ADDRESS, i2c::Mode<MODE>{}, true} {}
66  friend int main();
67 };
69 using streams::endl;
70 using streams::dec;
71 using streams::hex;
73 int main()
74 {
75  board::init();
76  sei();
78  uart.begin(115200);
79  out.width(2);
81  // Start TWI interface
82  //====================
83  DEBUGGER debugger;
84  MANAGER manager{debugger, debugger};
85  manager.begin();
86  out << F("I2C interface started") << endl;
88  PublicDevice device{manager};
90  // Init I2C device if needed
92  // Output all debug traces
93  DEBUG(out);
95  // Loop to show measures
96  while (true)
97  {
98  // Read measures and display them to UART
100  // Output all debug traces
101  DEBUG(out);
103  time::delay_ms(1000);
104  }
106  // Stop TWI interface
107  //===================
108  manager.end();
109  out << F("End") << endl;
110 }

This example is just an empty skeleton for your own tests. It is made of several parts:

Those lines include a few headers necessary (or just useful) to debug an I2C device.

static constexpr const i2c::I2CMode MODE = i2c::I2CMode::FAST;
static constexpr const uint8_t DEVICE_ADDRESS = 0x68 << 1;

Any specificity of the tested I2C device is defined as a constant in the next code section. Note the definition of DEVICE_ADDRESS constant: this 7-bit I2C device address is shifted one bit left as an 8th bit will be added (I2C communication protocol) to define data direction for each transmission.

static constexpr const uint8_t DEBUG_SIZE = 32;
#define DEBUG(OUT) debugger.trace(OUT)
// The following type aliases will be useful for declaring proper Futures and calling I2CDevice API
using PARENT = i2c::I2CDevice<MANAGER>;
template<typename T> using PROXY = typename PARENT::template PROXY<T>;
template<typename OUT, typename IN> using FUTURE = typename PARENT::template FUTURE<OUT, IN>;

This section defines various types aliases, for I2C Manager, I2C debugger, and types used as part of device API definition. In addition, a DEBUG macro to debug all I2C steps after API execution is defined.

// UART for traces
static constexpr const uint8_t OUTPUT_BUFFER_SIZE = 64;
static char output_buffer[OUTPUT_BUFFER_SIZE];
static serial::hard::UATX<board::USART::USART0> uart{output_buffer};
static streams::ostream out = uart.out();

Then an output stream is created for tracing through UART, and the necessary UART ISR is registered.

class PublicDevice: public PARENT
PublicDevice(MANAGER& manager): PARENT{manager, DEVICE_ADDRESS, i2c::Mode<MODE>{}, true} {}
friend int main();

This is where we define a utility class to debug our I2C interface to the tested device. PublicDevice class does nothing but making all protected methods callable from main(), so that we can directly perform our code tests in main(), without thinking much about proper API design now.

int main()

This is the main() function where it all happens. First we initialize the MCU and the UART for tracing.

MANAGER manager{debugger, debugger};

Here we simply initialize I2C function on the UNO.

PublicDevice device{manager};

We then declare the device variable that we will use for testing our I2C device.

Finally the rest of the code is placeholder for any initialization API, followed by an infinite loop where you can call lauinch_commands/read/write methods on device in order to test the way to handle the target device.

Defining the driver API based on device features

At this level, you have already been able to debug how the device works and you have a good overview of what features you want to provide to developers (and to yourself as the first of all) who will want to use this device.

An easy way is to provide an API that maps every feature found in the datasheet to its dedicated method. This is what we would call a low-level API; that is the minimum your driver should provide.

Additionally MyI2CDevice might implement a higher level API, based on the low-level one, but this is not mandatory; actually, this is not even advised generally, as this high-level API might be implemented in a distinct class. Using a separate class for high-level API allows other developers to develop their own high-level API without having to use yours if it does not fit their needs.

It is often advised to add begin() and end() methods to MyI2CDevice when it makes sense. begin() would initialize the device before usage (most devices will require special setup before use).

Implementing the driver API

This step consists in implementing the API defined in the step before.

Typically every API will be made of:

  • a specific Future class taht encapsulates input arguments (in its constructor) and holds place for output; this Future shall embed any necessary conversion of input arguments if needed, as well as conversion of output, through override of get() method
  • one asynchronous method taking as only argument PROXY to the Future defined above, calling launch_commands() withe write() and read() calls to prepare I2C commands, as described above in the description of i2c::I2CDevice API
  • one synchronous method taking same arguments as Future constructor defined above, plus a reference argument for any output; this method instantiates the above Future, calls the asynchronous method defined before, and awaits the Future to be ready and get its output

Here is a concrete example from devices::rtc::DS1307, another I2C device driver in FastArduino.

The first snippet below defines a specific Future for getting current datetime from the device:

static constexpr const uint8_t TIME_ADDRESS = 0x00;
class GetDatetimeFuture : public FUTURE<tm, uint8_t>
GetDatetimeFuture() : FUTURE<tm, uint8_t>{TIME_ADDRESS} {}
GetDatetimeFuture(GetDatetimeFuture&&) = default;
GetDatetimeFuture& operator=(GetDatetimeFuture&&) = default;
bool get(tm& datetime)
if (!FUTURE<tm, uint8_t>::get(datetime)) return false;
// convert DS1307 output (BCD) to integer type
datetime.tm_sec = utils::bcd_to_binary(datetime.tm_sec);
datetime.tm_min = utils::bcd_to_binary(datetime.tm_min);
datetime.tm_hour = utils::bcd_to_binary(datetime.tm_hour);
datetime.tm_mday = utils::bcd_to_binary(datetime.tm_mday);
datetime.tm_mon = utils::bcd_to_binary(datetime.tm_mon);
datetime.tm_year = utils::bcd_to_binary(datetime.tm_year);
return true;

In this code, tm is a strcuture to hold all parts of a datetime.

The constructor takes no argument: it just passes TIME_ADDRESS constant to the superclass.

Note the overridden get() method, necessary to convert raw datetime data read from the device, to properly formatted data, usable by the caller program.

The second snippet shows the asynchronous API method:

int get_datetime(PROXY<GetDatetimeFuture> future)
return this->launch_commands(future, {this->write(), this->read()});

In this code, one read and one write commands are generated and sent to the I2C Manager for execution (immediate or deferred, depending on the I2C Manager associated to the device); the write command writes all bytes from GetDatetimeFuture, i.e. one byte; the read command reads as many bytes as expected by GetDatetimeFuture, i.e. one byte. Although not directly visible in this snippet, at the end of the I2C transaction (end of read command), a "STOP" condition is generated, releasing the I2C bus. This seems required by DS1307 device (from experiment) to release the bus between two consecutive transactions. This is why DS1307 device constructor sets auto_stop to true.

The last snippet demonstrates implementation of the synchronous API method:

bool get_datetime(tm& datetime)
GetDatetimeFuture future;
if (get_datetime(PARENT::make_proxy(future)) != 0) return false;
return future.get(datetime);

The implementation is totally based on the asynchronous method: it instantiates a GetDatetimeFuture future, passes it as a PROXY, through PARENT::make_proxy(future) to the asynchronous method. If the asynchronous method fails (return != 0), then we return false immediately; otherwise, we await on future, through the call of future.get(datetime) which is blocked until future is READY or in ERROR.

The last mile: add driver to FastArduino project!

Bravo! You successfully added FastArduino support, in your own project, for a specific I2C device!

The last mile would now consist in adding your valuable work to FastArduino library! You do not have to, of course, but this would be a good way to:

  • thank other people who provided FastArduino open source library to you
  • feel part of the community
  • get feedback on your work, potentially allowing it to be further improved
  • share your work with the rest of the world

However, like for a marathon, the last mile can be difficult! In order to run this last mile, you will have to:

  • first accept FastArduino Apache License 2.0 for your contribution, or discuss with FastArduino owner for another one, if compatible
  • follow FastArduino coding guidelines: this might impose some code rewrite or reformatting
  • add API documentation with doxygen: this is mandatory for all public methods, and advised for protected ones.
  • add one (or more) usage example and integrate it in the examples/i2c directory; examples must be kept simple but still demonstrate the API usage; example circuits (connection pins) shall be described. These examples can be further used as "tests" before new releases of FastArduino.
  • optionally develop a tutorial for this device
  • prepare and propose a PR to FastArduino project

Important condition: in order to accept merging a PR to FastArduino, I must be able to check it by myself, hence I need to first have the new supported device available on my workbench; I will gladly buy one (or a few) if it is affordable and easy to find.

Contains the API around Future implementation.
Definition: future.cpp:18
i2c::I2CDevice< MANAGER >
Defines all API for UART features.
Definition: soft_uart.h:84
Output stream wrapper to provide formatted output API, a la C++.
Definition: streams.h:61
Defines all USART modules of target MCU.
Definition: empty.h:105
I2C available transmission modes.
Definition: i2c.h:108
Indicate what in I2C protocol shall be debugged.
Definition: i2c_debug.h:55
void endl(FSTREAM &stream)
Manipulator for an output stream, which will insert a new-line character and flush the stream buffer.
Definition: streams.h:722
static void init()
Performs special initialization for ATmega644, actually nothing at all.
Definition: atmega_xx4.h:49
void hex(FSTREAM &stream)
Manipulator for an output or input stream, which will set the base, used to represent (output) or int...
Definition: ios.h:774
Hardware serial API.
Defines all types and constants specific to support Arduino MEGA board (ATmega644 MCU target).
Definition: atmega_xx4.h:39
I2C Device API.
Simple time utilities.
Class recording I2C debug and status notifications for later output.
Definition: i2c_debug.h:293
Register the necessary ISR (Interrupt Service Routine) for an serial::hard::UATX to work correctly.
Definition: uart.h:37
General utilities API that have broad application in programs.
void dec(FSTREAM &stream)
Manipulator for an output or input stream, which will set the base, used to represent (output) or int...
Definition: ios.h:765
Synchronous I2C Manager for ATmega architecture with status notification and debug facility.
Definition: i2c_handler_atmega.h:1246
uint8_t bcd_to_binary(uint8_t bcd)
Convert Binary-coded decimal byte (each nibble is a digit from 0 to 9) into a natural byte.
Definition: utilities.h:343
Proxy< T > make_proxy(const T &dest)
Utility template function to create a Proxy<T> from dest without the need to speicify T.
Definition: lifecycle.h:593
I2C debugging utilities (useful when implementing support for new devices).
void delay_ms(uint16_t ms) INLINE
Delay program execution for the given amount of milliseconds.
Definition: time.h:346
static constexpr Mode I2C_STANDARD
Constant determining that best supported I2C mode for an I2CDevice is STANDARD (100kHz).
Definition: i2c_device.h:39
#define F(ptr)
Force string constant to be stored as flash storage.
Definition: flash.h:98
void width(uint8_t width)
Set minimum width used for displaying values.
Definition: ios.h:390
I2C Standard mode, less than 100KHz.