Sunday 10 November 2019

Using the AVR USI to create a I2C peripheral device

As part of a more complex project, I recently felt the need to implement an I2C peripheral on an ATtiny2313, using its Universal Serial Interface ("USI"). As described in the link, the I2C protocol allows multiple devices to communicate using only two signalling wires.

In case you want to do something similar, here are a few suggestions that helped me a lot:
First of all, make sure that you can see what is going on. The main tools I used for creating and debugging were my DS1054 oscilloscope to look at the various signals, a Bus Pirate that can act as an I2C controller, and an USB-to-serial convertor connected to the AVR UART so that I could watch debug-messages.

And secondly, while I am generally very impressed with the quality of the AVR datasheets, the USI part was described a bit too loose for me to understand exactly what behaviour the various register settings would cause. So after some frustrating attempts, I started to dump the various USI-related registers over my serial debug output, to see what exactly happened. This helped to clarify some points, so I get my I2C peripheral to work.

Now to continue with the main story...

As said, the I2C protocol uses 2 signal-lines: SCL and SDA, being the clock and data line, respectively. Both of these are normally pulled high externally, and the devices on the bus pull them low (to ground) to communicate. Typically, the bus has one  controller device and one or more peripheral devices, but multiple controllers are possible as well.

During normal communications, SDA may only change when SCL is low. The two exceptions are the "start" and "stop" conditions. (The latter is only really important for controller devices, so I will not discuss it here.) Another important aspect of the protocol is that the controller controls the SCL line, but after the controller pulls the SCL low, peripheral devices are allowed to keep it low to slow things down in case they cannot keep up.


I2C protocol in a nutshell. Shown are the start condition ("S"), data transfer ("Bx") and stop condition ("P"). Original drawing by  Marcin Floryan and taken from wikipedia.


The "start condition" signal pulls SDA down when SCL is high. This acts as sort of a bus-reset, and makes all peripheral devices pay attention to what is coming next. Next step after pulling SDA down is that the controller also pulls the SCL down, to prepare for the first data-bit (remember that SDA normally only changes if SCL is low). Luckily, the AVR's USI helps us here. It can generate an interrupt when the SDA line goes low while SCL is high, and after the controller then pulls SCL low, the USI will keep it low until we tell it otherwise. This makes sure that even our slow code and a very fast controller will be in sync at a certain moment.

After the start condition, the controller will proceed by sending a string of 7 address bits, and 1 R/W (read-write) bit. The address bits identify for which peripheral the rest of the message is meant, and the R/W bit tells whether the controller expects to send or receive data. To read these 8 bits, we could simply setup the USI to sample a bit of SDA each time that SCL goes from low to high, do that for 8 bits, and then interrupt. But the way the USI works is that it counts in terms of SCL flanks (both low-to-high transitions, when a bit is sampled, and high-to-low transitions, that I will come to later), so we need 16 of those. Since high-to-low changes are counted as well, things get slightly more complicated, as explained below.

When serving the "start" interrupt, SCL might not have gone down yet, so we need to synchronise somewhere. All code I could find simple used a busy-loop to wait until the SCL went down. But busy-loops in interrupt routines is never a really good idea, especially when waiting for some external signal that is not under our control. So I did something more elegant: when the SCL line was still up, I set the USI to wait for one additional flank. Using the fact that the USI will keep the SCL low until released, and doing things in the right order, I could make this safe for race conditions. Unfortunately, the USI can only count a maximum of 16 flanks, and this scheme would need up to 17. So the next trick is to only sample the first 7 address bits, and count either 14 or 15 flanks.

ISR(USI_START_vect,ISR_BLOCK)
{
  /* Start condition.
   * Controller might still have to clock up at this point
   * Once it goes down, USI will keep it down, as long as we did not clear 
   * the start condition.
   */

  /* make sure that sda is in the right mode, as a start condition can happen
   * virtually anytime.
   */
  set_sda_input();
  set_sda_high();
  
  /* Next will be the 7 address bits (14 flanks on clock) */

  /* To prepare for the next step after receiving address, we need to "output"
   * 1s, so put those in the DR.
   */
  USIDR = 0xff;
  
  /* Assume clock is still high, so we prepare for 15 flanks, and fix it later. */
  set_status_register(0,1,15);

  /* Enable full mode */
  set_control_register(1,1,1);

  /* Setup for receiving address */
  i2c_peri_state = I2C_ADDRESS;

  /* Check if clock is down */
  if ((I2C_PIN & _BV(I2C_CLK))==0) {
    /* Line went down already, so only 14 flanks are needed now. 
     * Doing check like this will prevent racing.
     */
    /* Set 14 flanks.
     */
    set_status_register(0,1,14);
  }
  release_start_condition();
}
The code above shows how the "start" condition interrupt routine is done. Note that the release_start_condition() call is from where on the USI will no longer keep the SCL low after the controller pulled it down. More code and details can be found on my github.

Once the 7 address bits are received, we can check if we are the peripheral that is being addressed. If not, we can simply go off the bus, and wait for the next "start condition" signal. But if the address is correct, we need to get the R/W bit, and after that acknowledge our reception to the controller. In general, acknowledgement is done by the receiver after every 8 bits, by pulling SDA down for the 9th bit. As we already know that we should acknowledge, we can setup the USI to receive one more bit,  then pull SDA low (output a 0), for one more SCL cycle, and then interrupt again. By smartly combining the R/W bit and the acknowledge bit in 4 SCL flanks, we do this part with only three interrupts (start condition, first 7 bits, R/W bit and acknowledge). This is the same number of interrupts that other implementations use, because these need to handle the acknowledge bit separately as well.
/*
 * Overflow function: (some) data was transferred.
 */
ISR(USI_OVERFLOW_vect,ISR_BLOCK)
{
  uint8_t data;
  
  data = USIDR;
  if (i2c_peri_state & I2C_WAIT_ACK1) {
    /* We send a byte. Now try to get an acknowledge. */
    if (i2c_peri_state & I2C_WAIT_ACK2) {
      i2c_peri_state &= ~I2C_WAIT_ACK2;
      set_sda_input();
      set_status_register( 0, 1, 2 );
      goto go_leave;
    } else {
      /* check if the controller acked */
      if (data & 1) {
         /* NACK */
         goto go_idle;
      } else {
         i2c_peri_state &= ~I2C_WAIT_ACK;
         set_sda_output();
      }
    }
  } else if (i2c_peri_state & I2C_ACK_RECEIVE) {
    /* Done acknowledging our reception */
    i2c_peri_state &= ~I2C_ACK_RECEIVE;
    if (i2c_peri_state == I2C_IDLE) {
      goto go_idle;
    }
    /* release sda and set to input */
    set_sda_high();
    set_sda_input();
    /* wait for 16 flanks */
    set_status_register( 0, 1, 16 );
    goto go_leave;
  }
  
  switch( i2c_peri_state ) {
  case I2C_ADDRESS:
    /* First 7 address bits are received. */
    if (data==(0x80|(I2C_PERI_ADDR>>1))) {
      /* The address, now get our send-receive bit */
      i2c_peri_state = I2C_READ_WRITE;
      /* As this is our address, we can have the hardware do the acknowledge. */

      /* Enable output. 
       * This is okay, as the start ISR made sure that we are outputing "1", i.e. nothing.
       */
      set_sda_output();
      /* Clock is down. 
       * Next clock up will shift RW bit into DR0, and 0 to DR7
       * Next clock down will latch DR7 into SDA. -> ACK
       * Next clock up will shift our 0 into DR0, and 1 out to DR7
       * Next clock down will latch DR7 into SDA, i.e. release SDA again.
       */
      USIDR = 0xbf;
      /* Wait for 4 flanks. */
      set_status_register( 0, 1, 4 );
      goto go_leave;
    }
    /* not us, get off the bus */
    goto go_idle;
    break;
  case I2C_READ_WRITE:
    /* This is the tail end of the address phase.
     * Only the RW bit was still needed, and the ACK was
     * already dealt with by the hardware.
     */
    /* RW bit is the second bit */
    if (data&2) {
      /* controller requests data */
      i2c_peri_state = I2C_SEND_1;
      // we are ready to send now
      goto go_send;
    } else {
      /* controller will send more */
      set_sda_input();
      i2c_peri_state = I2C_CONTROL;
      /* prepare to receive 8 bits */
      set_status_register(0,1,16);
      goto go_leave;
      return;
    }
    break;
  case I2C_CONTROL:
    /* get our control value */
    i2c_peri_cmd = data;
    switch(data) {
    case 0x01: /* set pwm colors */
      i2c_peri_state = I2C_RECEIVE_6;
      goto go_ack_receive;
      break;
    case 0x10: /* read voltage */
      i2c_peri_state = I2C_IDLE;
      event_trigger( EVENT_I2C_PERI_CMD );
      goto go_ack_receive;
      break;
    case 0x20: /* read string */
      i2c_peri_send_buffer[0] = 0xde;
      i2c_peri_send_buffer[1] = 0xad;
      i2c_peri_send_buffer[2] = 0xbe;
      i2c_peri_send_buffer[3] = 0xef;
      i2c_peri_send_buffer[4] = 0xcc;
      i2c_peri_send_buffer[5] = 0xaa;
      goto go_ack_receive;
    default:
      goto go_idle;
    }
    break;
  case I2C_RECEIVE_1 ... I2C_RECEIVE_6:
    i2c_peri_receive_buffer[ i2c_peri_state - I2C_RECEIVE_1 ] = data;
    if (i2c_peri_masterstate == I2C_RECEIVE_1) {
      /* got all our bytes */
      event_trigger( EVENT_I2C_PERI_CMD );
      i2c_peri_state = I2C_IDLE;
    } else {
      --i2c_peri_state;
    }
    goto go_ack_receive;
    break;
  case I2C_SEND_1 ... I2C_SEND_MAX:
  go_send:
    // here we start sending...
    
    USIDR = i2c_peri_send_buffer[ i2c_peri_state - I2C_SEND_1 ];
    set_status_register( 0, 1, 16 );

    if (i2c_peri_state == sizeof(i2c_peri_send_buffer) ) {
      i2c_peri_state = I2C_IDLE; /* wait for ack, and go idle */
    } else {
      ++i2c_peri_state;
    }
    /* Setup so we wait for an ACK afterwards */
    i2c_peri_state |= I2C_WAIT_ACK;
    goto go_leave;
    break;
    
  case I2C_IDLE:
  default:
    goto go_idle;
  }

 go_idle:
  i2c_peri_go_idle();
 go_leave:
  return;

 go_ack_receive:
  /* we need to acknowledge our receiving... */
  i2c_peri_state |= I2C_ACK_RECEIVE;

  /* clock is down --> bring sda down to ack*/
  set_sda_output();
  set_sda_low();
  /* interrupt after two flanks */
  set_status_register( 0, 1, 2 );
  return;
}
The code above shows the "overflow" interrupt routine, that is called when a certain number of flanks is received. It handles everything from receiving and checking the address bits, receiving and sending further data, and sending, receiving and checking acknowledge bits.

For sending data, we need to switch the SDA pin to an output, send 8 bits, interrupt, switch SDA to input, receive one bit (acknowledge by the controller), interrupt again and check the acknowledge bit. If the acknowledge is ok, we can then send the next 8 bits, if needed. The USI hardware is configured such that a bit is output to SDA when SCL goes from high to low, so it is in the opposite phase as receiving a bit. This allows combined reading and writing: writing a 1 simply means that the USI does not pull the SDA low. But if another device on the bus does pull SDA low, the next flank will read a 0 (low) signal. This is the trick I used to combine reading the R/W bit with acknowledging in one go. Note that the USI only uses a single 8-bit shift register. Every SCL cycle, the MSB is shifted out to SDA on one flank, and SDA is shifted into the LSB on the next flank.

I believe the code above is a nice example of how to make an I2C peripheral using an AVR USI, and I hope this was useful or even interesting for you as well.

2020-6-20: updated to remove slavery references.

No comments: