tl;dr
Implemented the I2C protocol for controller devices on an ATtiny85 using just the AVR base library. Link to Github
Table of Contents
The Precursor
I've done some Arduino projects before but it was with using the Arduino IDE and their helper libraries.
So I wanted to try my hand at programming something at the base level -- No Arduino IDE or helper libraries.
I decided the I2C protocol would be a big enough project to get my feet wet with AVR programming but small enough to not feel overwhelmed. It would also be easy to debug with a target device (I used an Arduino Uno) to ensure everything was working appropriately.
Understanding the Protocol
Reference: Historically the terms Master/Slave were used to refer to the relationship of these devices, but modern naming conventions use Controller/Target 1
High Level
Inter-Integrated Circuit (or I2C) is a widely supported, simple synchronous method of communication between lower-speed peripherals. The setup usually involves a Controller with one or more Target devices talking across two bidirectional signal lines: Serial Data Line (SDA) and Serial Clock Line (SCL). 1
The basic concept of these two lines is SDA is for data and SCL is for keeping the timing correct between the two devices. There are several speeds that are supported but the standard speed is 100kbit/s.1
The Format
The format for communication is pretty simple and I feel the easiest way to understand it is with this diagram:
I2C Diagram - (Sparkfun, 2013)3
The first thing to notice is both SDA and SCL are set to high at the beginning. SDA is then brought to the low state and after a short wait the SCL line is brought low as well. This will wake every target device on the line to check if they will be the one the controller is talking to.
Next the controller sends the 7 bit address of the target device. We use a 7 bit address because the least significant bit is reserved as the Read/Write flag to tell the target device which mode it should be in (1 is Read, 0 is Write). Through this data exchange you'll notice the SCL line is toggled in the middle of the SDA line's state. This is important to not trigger the stop state (we'll talk about this soon).
At this point the target devices that do not match the address go back to ignoring the messages. Then the controller changes its SDA line to input and the target device that matches the address sends an N/ACK to ACKnowledge or Not ACKnowledge that data transfer.
Now the controller and target are connected and depending on the Read/Write flag the controller/target will execute accordingly until done. In the case of a Write, the controller sends data in 8 byte intervals until done or until the target returns a NACK. In the case of a Read, the controller reads data and sends back an ACK to the target until the controller is done, at that point the controller sends a NACK followed by a stop message.
Last is the stop message, which is basically the opposite of the start message. Both SDA and SCL lines are forced to the low state. Then the SCL is brought high and after a short wait, the SDA line is brought high.
That's the basics of the protocol, there are some extended rules for 8 or 10-bit addressing4 and some special cases like repeat starts3 but we aren't really concerned with those for our purposes.
The Research
The research portion of this project took the longest. I knew I'd have to comb through the data sheet of the ATtiny85 to figure out most things, but still being quite new to bare-metal programming I found myself lost a few times searching through it.
Using a combo of reference material from the TinyWire5 library and the datasheet helped guide me through most of the troubles I encountered.
Pinouts
The first thing I needed to do was look at the pinout diagram to figure out what pins supported I2C on the ATtiny85. Looking through the datasheet6 they have this handy diagram that explains all the pins.
Pinout ATtiny25/45/85 - (Atmel, 2013 - Figure 1-1 pg. 2)6
We can see that PB0 and PB2 are our SDA and SCL pins respectively. So we will start here by searching how to configure these pins for I2C serial communication.
Port Registers
Searching through the document we find chapter 10 on I/O Ports and within this chapter has this diagram for pin configuration.
Port Pin Configurations - (Atmel, 2013 - Table 10-1 pg. 55)6
Knowing that we need to use pullup resistors for our SDA and SCL lines we are interested in row two. This shows to setup pullup resistors we need to have the configuration (0, 1, 0) for our DDxn, PORTxn, PUD flag respectively.
Next we find these registers, which are defined in these tables.
Register Descriptions - (Atmel, 2013 - Section 10.4 pg. 64)6
We see that the PUD flag and all of DDRB has initial values set to zero, so we really only need to worry about setting PORTB0 and PORTB2 to 1. If you are writing a drop in library you might want to be safe and write the other bits to zero to ensure correct behavior.
Universal Serial Interface
Now that we've figured out the Port registers we need to figure out how serial communication works on the ATtiny85. Chapter 15 covers USI - Universal Serial Interface. This chapter has a lot to read through, but we can pull out the highlights.
The first thing from this chapter, in the overview, we learn that this chip has hardware level helpers to aid in serial communication which will allow less overhead than a pure software implementation. This also allows us to take advantage of the hardware controls to allow for less code on our end. Things included are: Pullups, Interrupts for flags and overflows, control flags to toggle the clock, and a data register for sending and receiving.
Further in the chapter we find the registers we will be using for communication.
-
USIDR
USI Data Register - For reading and sending data -
USISR
USI Status Register - For checking and managing state with interrupt flags and timers -
USICR
USI Control Register - For controlling and enabling different modes and timers I listed these because it was a little difficult to get as a screenshot. But we'll look at them in detail later.
Speed Support
The last bit of research I needed to find was how to implement and support the different speeds available for I2C. Judging from this document7, the AVR controllers support standard (100kbps) and Fast mode (400kbps). So what do we need to do to maintain this bit rate?
Looking back at the TinyWire5 they seem to have these hardcoded values for timing.
TWI Delay Constants - (TinyWire, 2019 - twi.h)5
But where did they get these values? It took me a while to track down but these values are from the NPX I2C Timing Specification8
Characteristics of the SDA and SCL bus - (NXP I2C Timing Specification, 2021 - Table 11, pg. 44)8
The rows we care about are the t_LOW
and t_HIGH
rows. From this we can see to meet the Standard and Fast requirements we will need delays of (4.7 and 4.0) and (1.6 and 0.6) respectively.
Now that we have most of our reference material we can start our implementation.
The Implementation
Header File
First we will define out our header file to lay out our constants and functions.
First we define the wait periods for the different bit rate speeds we can support.
// These numbers are from a calculation chart
// from I2C spec -- NXP I2C Timing Specification
#ifdef I2C_FAST_MODE // 400kHz fast
// low period of SCL
#define T2_TWI 2 // >1.3microseconds
// high period of SCL
#define T4_TWI 1 // >0.6microseconds
#else // 100kHz standard
// low period of SCL
#define T2_TWI 5 // >4.7microseconds
// high period of SCL
#define T4_TWI 4 // >4.0microseconds
#endif
Again, these numbers are defined in this document on page 448. You can see we didn't use the numbers exactly. From the references5 I used, it seemed common to round up to the next whole number. This is the reasoning for the choice 2 over 1.3, as an example.
I'm not completely sure for the reasoning behind this other than maybe it's safer to use whole numbers with the delay functions than the fractional values when making sure you are staying within the cut off point of the range.
Next we define out the constants for the registers.
#ifndef i2c_ddr
#define i2c_ddr DDRB
#endif
#ifndef i2c_port
#define i2c_port PORTB
#endif
#ifndef i2c_pin
#define i2c_pin PINB
#endif
#ifndef i2c_scl
#define i2c_scl PORTB2
#endif
#ifndef i2c_sda
#define i2c_sda PORTB0
#endif
#ifndef i2c_data
#define i2c_data USIDR
#endif
#ifndef i2c_status
#define i2c_status USISR
#endif
#ifndef i2c_control
#define i2c_control USICR
#endif
This step isn't really necessary but I felt these names were a bit better at conveying what they are for.
The last thing for the header are the functions.
/**
* Initialize I2C registers and ports.
*/
void i2c_init();
/**
* Send start command.
* @return True if the start successfully initiated, False otherwise.
*/
bool i2c_start();
/**
* Send the stop command.
*/
void i2c_stop();
/**
* Write the given byte.
* @param[in] data The byte to send.
* @return The N/ACK byte.
*/
unsigned char i2c_write_byte(unsigned char data);
/**
* Read the next byte.
* @param[in] nack True for reading more, false otherwise.
* @return The read byte.
*/
unsigned char i2c_read_byte(bool nack);
/**
* Write the target's address out.
*
* @param[in] address The target address.
* @param[in] write Flag for Write or Read bit.
* @return The N/ACK byte.
*/
unsigned char i2c_write_address(unsigned char address, bool write);
This should be the basics of the protocol with these functions. We will go into more detail in this next section.
Source File
Initialize
First we will write out our initialization code. We need to set defaults and configure our registers properly.
I'll show the function first and then break it down.
void i2c_init() {
// preload data register with default HIGH
i2c_data = 0xff;
// setup for master
i2c_control = (
// disable Start condition interrupt
(0 << USISIE) |
// disable overflow interrupt
(0 << USIOIE) |
// only set WM1 to high for normal 2wire mode
_BV(USIWM1) | (0<<USIWM0) |
// set CS1 and CLK to high to use external clock source
// with positive edge. Software Clock Strobe (with USITC register)
_BV(USICS1) | (0<<USICS0) | _BV(USICLK) |
(0<<USITC)
);
i2c_status = (
// clear all flags
_BV(USISIF) | _BV(USIOIF) | _BV(USIPF) | _BV(USIDC) |
// reset overflow counter
(0x0 << USICNT0)
);
// flip the ports to input mode so we can enable pullup resistors on the next line
i2c_ddr &= ~_BV(i2c_sda);
i2c_ddr &= ~_BV(i2c_scl);
// set both pins to HIGH to enable pullup.
i2c_port |= (_BV(i2c_sda) | _BV(i2c_scl));
// flip the ports to output mode
i2c_ddr |= (_BV(i2c_sda) | _BV(i2c_scl));
}
The first thing we do for house keeping is setting the data register to a default value of HIGH (0xFF
).
i2c_data = 0xff;
Next we configure the Control register, this will tell the controller how we want to interact with the Universal Serial Interface.
// setup for master
i2c_control = (
// disable Start condition interrupt
(0 << USISIE) |
// disable overflow interrupt
(0 << USIOIE) |
// only set WM1 to high for normal 2wire mode
_BV(USIWM1) | (0<<USIWM0) |
// set CS1 and CLK to high to use external clock source
// with positive edge. Software Clock Strobe (with USITC register)
_BV(USICS1) | (0<<USICS0) | _BV(USICLK) |
(0<<USITC)
);
The comments explain a lot of what we are doing here but I will link to the datasheet where these values are explained.
On pages 116-117 of the Atmel datasheet6 the control register is explained. The first two bits are just interrupt enable flags and since we are the controller we don't need these enabled.
Next is setting the wire mode. Since the Universal Serial Interface can support multiple modes, we need to configure which one we are using. The configuration table shows us which values to set for two-wire mode (I2C).
Wire Mode - (Atmel, 2013 - Table 15-1, pg. 117)6
The next three bits are related to how we want to interact with the clock. We've configured the clock source to be an External, positive edge, and we want to use a software clock strobe.
Clock Settings - (Atmel, 2013 - Table 15-2, pg. 118)6
The jest of the clock source is we need to tell how the clock is sourced and how the data output latch changes with respect to the sampling of the data input. The 4-bit counter clock source is just to define how the clock is being controlled. Since we are the controller we want to strobe it ourselves.
The last bit is turned off and this is the bit for toggling the clock. To toggle the clock you need to write a 1 to it.
Next we initialize our status register to default values.
i2c_status = (
// clear all flags
_BV(USISIF) | _BV(USIOIF) | _BV(USIPF) | _BV(USIDC) |
// reset overflow counter
(0x0 << USICNT0)
);
The first four bits are as follows:
- Start Condition Interrupt Flag
- Counter Overflow Interrupt Flag
- Stop Condition Flag
- Data Output Collision
Writing a logical one to these bits clears them out if they were set.
The remaining 4 bits in this register are the 4-bit counter value used for the SCL line. 4 bits allows for 16 values, which translates to 8 byte transactions, which is the size of our payload. So when this overflows we've sent one payload's worth. So we need to reset it to zero.
The last portion of the initialization function is to enable the on-board pullups of the ATtiny85.
// flip the ports to input mode so we can enable pullup resistors on the next line
i2c_ddr &= ~_BV(i2c_sda);
i2c_ddr &= ~_BV(i2c_scl);
// set both pins to HIGH to enable pullup.
i2c_port |= (_BV(i2c_sda) | _BV(i2c_scl));
// flip the ports to output mode
i2c_ddr |= (_BV(i2c_sda) | _BV(i2c_scl));
The datasheet explains that setting the DDRB in the off position while setting the associated port bits in the on position enables the pullups. We then finish by setting the DDRB bits for SDA and SCL to output mode. The screenshot in Port registers shows the table of settings in the datasheet.
Start
Next, the start condition. Most of this code should be understood from the Understanding the Protocol section. But there are a couple of bits that might need some explaining.
Here is the code for the start:
bool i2c_start() {
// ensure both lines are high
i2c_port |= (_BV(i2c_sda) | _BV(i2c_scl));
// wait till clock pin is high
while (!(i2c_pin & _BV(i2c_scl)));
_delay_us(T2_TWI);
// pull data line low
i2c_port &= ~_BV(i2c_sda);
// this is sampling time for the attiny85 to recognize the start command
_delay_us(SAMPLING_WAIT);
// pull clock line low
i2c_port &= ~_BV(i2c_scl);
// release data line to high
i2c_port |= _BV(i2c_sda);
// check for valid start
return (i2c_status & _BV(USISIF));
}
We start off like normal, bringing both lines high. Then we need to wait for the SCL line to be high so we continually check to see if the pin value is set. Once it is, we wait a defined number of microseconds stated in the chart referenced in the Speed Support section.
Next we bring the data line low and then wait a sampling amount of time. This time frame is defined in both the NXP I2C Timing Specification8 on Table 11, page 44 and the Atmel datasheet6 in section 15.3.5 on page 113. Both define it as a max of 300 nanoseconds with the Atmel datasheet defining the minimum as 50 nanoseconds.
So I've defined the sample wait constant as so:
// this is 0.2 microseconds which is 200 nanoseconds
#define SAMPLING_WAIT 0.2
Next, bring the clock line low and then release the data line to finish up. The last thing I've added is a validity check. If the USISIF
bit is set then the start condition was recognized, if not something went wrong.
Stop
The stop condition is about the same except you don't have to worry about a sampling time with this one.
void i2c_stop() {
// ensure data line is low
i2c_port &= ~_BV(i2c_sda);
// relase clock line to high
i2c_port |= _BV(i2c_scl);
// wait for clock pin to read high
while (!(i2c_pin & _BV(i2c_scl)));
_delay_us(T4_TWI);
// relase data line to high
i2c_port |= _BV(i2c_sda);
_delay_us(T2_TWI);
}
This pretty much follows the spec and uses the delays in accordance with the table specified in Speed Support. Bringing both lines low and then releasing the clock line. After it reaches the on state, release the data line.
Transfer
Next we will handle the general transferring of data.
unsigned char transfer(unsigned char mask) {
// ensure clock line is low
i2c_port &= ~_BV(i2c_scl);
i2c_status = mask;
do {
// wait a little bit
_delay_us(T2_TWI);
// toggle clock
i2c_control |= _BV(USITC);
// wait for SCL to go high
while (! (i2c_pin & _BV(i2c_scl)));
// wait short
_delay_us(T4_TWI);
// toggle clock again
i2c_control = _BV(USITC);
} while (!(i2c_status & _BV(USIOIF)));
_delay_us(T2_TWI);
// clear counter overflow status
i2c_status |= _BV(USIOIF);
unsigned char data = i2c_data;
i2c_data = 0xff;
return data;
}
The first line is probably not necessary but we just want to ensure the clock is low before beginning. The next line is setting the status to the mask parameter. (we will touch on this in the write/read functions).
Next we start our loop. The ATtiny85 will handle sending the data as we toggle the clock. So we wait the specified time between the HIGH and LOW of the clock line referenced in the table in the Speed Support section and toggle the clock on the control register by setting the USITC
bit flag.
Once we receive the counter overflow condition we exit the loop, wait a little more and then clear out that overflow status flag. We then grab the data from the data register, reset the data register, and then return our received byte value.
Write
With most of the scaffolding setup now the next few functions will be pretty easy to flesh out.
Starting with the write function:
unsigned char i2c_write_byte(unsigned char data) {
i2c_data = data;
transfer(STATUS_CLOCK_8_BITS);
// change data pin to input
i2c_ddr &= ~_BV(i2c_sda);
unsigned char nack = transfer(STATUS_CLOCK_1_BIT);
// change back to output
i2c_ddr |= _BV(i2c_sda);
return nack;
}
Stepping through this function we see we are setting the data register to the data we would like to send over. Then we call the transfer function with this new STATUS_CLOCK_8_BITS
constant. This constant is to reset the status register to be ready for a full 8 bits transfer.
It is defined as such:
#define STATUS_CLOCK_8_BITS (_BV(USISIF)|_BV(USIOIF)|_BV(USIPF)|_BV(USIDC) | \
(0x0 << USICNT0)) // reset clock to allow for full 8 bit transfer
We clear the status flags and reset the counter to 0.
After sending our data we need to flip our pin to input mode to read the ACK bit from the target. We then call the transfer method with another constant which sets up the status register to clear status flags and set the counter to a value that will force an overflow after 1 bit transfer.
It is defined as such:
#define STATUS_CLOCK_1_BIT (_BV(USISIF) | \
_BV(USIOIF) | \
_BV(USIPF) | \
_BV(USIDC) | \
(0xE << USICNT0))// we set the clock to 1110 so it overflows after 1 exchange
After the N/ACK is returned we set the SDA line back to output and return the received byte.
Read
The read function has slightly more logic, because we need to tell the target if we would like to read more or to stop.
unsigned char i2c_read_byte(bool ack) {
// HIGH value means stop sending
unsigned char response = 0xff;
if (ack) {
// LOW means read another byte
response = 0x00;
}
// change data pin to input
i2c_ddr &= ~_BV(i2c_sda);
unsigned char data = transfer(STATUS_CLOCK_8_BITS);
// change back to output
i2c_ddr |= _BV(i2c_sda);
// send n/ack
i2c_data = response;
transfer(STATUS_CLOCK_1_BIT);
return data;
}
We change our response value based on if we want to read another byte or not. This is determined by the ACK (0x00
) or NACK (0xFF
) value.
Next we operate similarly like the write function except we capture the data first and then send our N/ACK response.
Write Target Address
We basically have everything we need to perform I2C now but I wanted a helper function to handle sending the target address properly for me. So we have this last method.
unsigned char i2c_write_address(unsigned char address, bool write) {
unsigned char rw_flag = 1;
if (write) rw_flag = 0;
// shift address over 1 because Least sig bit is RW flag
return i2c_write_byte(((address << 1) | rw_flag));
}
In our case we are only dealing with the basic 7-bit addressing so we shift the address over by 1 bit and then logically or it with the rw_flag
bit. This format is explained in The Format section.
Conclusion and Demo
After all that, we can now properly handle I2C on our ATtiny85. This project has helped me explore the embedded space more than I had experience with before. It's given me a new appreciation for the craft that goes into these projects and has me excited to tackle the next embedded journey I dig into.
Here is the link9 and the demo video of the finished project.
The demo has an ATtiny85 loaded with the I2C driver I wrote and hooked up to an Arduino uno using the Wire library to act as a target device to talk to.
Actions performed:
- The controller (ATtiny85) sends "Hello, World!" to the target (Arduino Uno) device.
- The controller requests a read from the target to toggle the LED.
References
- https://en.wikipedia.org/wiki/I%C2%B2C
- https://embedjournal.com/two-wire-interface-i2c-protocol-in-a-nut-shell/
- https://learn.sparkfun.com/tutorials/i2c/all
- https://www.totalphase.com/support/articles/200349176-7-bit-8-bit-and-10-bit-i2c-slave-addressing/
- https://github.com/lucullusTheOnly/TinyWire
- https://ww1.microchip.com/downloads/en/devicedoc/atmel-2586-avr-8-bit-microcontroller-attiny25-attiny45-attiny85_datasheet.pdf
- https://ww1.microchip.com/downloads/en/Appnotes/Atmel-2561-Using-the-USI-Module-as-a-I2C-Master_AP-Note_AVR310.pdf
- https://www.nxp.com/docs/en/user-guide/UM10204.pdf
- https://github.com/jmatth11/i2c-avr-driver
Top comments (0)