Electronic – Starting with I2C on PIC18s

ccommunicationi2cmicrocontrollerpic

For a project I'd like three PICs (two slaves PIC18F4620, one master PIC18F46K22) to communicate over the I2C bus. Later on, more slaves may be added (like EEPROM, SRAM, …). I'm writing the code for these PICs in C using the C18 compiler. I've looked around on the internet a lot, but couldn't find libraries to handle the (M)SSP peripheral. I've read the datasheet of both PICs on the (M)SSP peripheral in I2C mode but couldn't find out how to interface the bus.

So I need master and slave libraries.

What do you recommend? Do you have such a library somewhere? Is it built-in in the compiler and if yes, where? Is there a good tutorial somewhere on the net?

Best Answer

Microchip wrote application notes about this:

  • AN734 on implementing an I2C slave
  • AN735 on implementing an I2C master
  • There's also a more theoretical AN736 on setting up a network protocol for environmental monitoring, but it isn't needed for this project.

The application notes are working with ASM but that can be ported to C easily.

Microchip's free C18 and XC8 compilers have I2C functions. You can read more about them in the Compiler libraries documentation, section 2.4. Here's some quick start info:

Setting up

You already have Microchip's C18 or XC8 compiler. They both have built-in I2C functions. To use them, you need to include i2c.h:

#include i2c.h

If you want to have a look at the source code, you can find it here:

  • C18 header: installation_path/vx.xx/h/i2c.h
  • C18 source: installation_path/vx.xx/src/pmc_common/i2c/
  • XC8 header: installation_path/vx.xx/include/plib/i2c.h
  • XC8 source: installation_path/vx.xx/sources/pic18/plib/i2c/

In the documentation, you can find in which file in the /i2c/ folder a function is located.

Opening the connection

If you're familiar with Microchip's MSSP modules, you'll know they first have to be initialized. You can open an I2C connection on a MSSP port using the OpenI2C function. This is how it's defined:

void OpenI2C (unsigned char sync_mode, unsigned char slew);

With sync_mode, you can select if the device is master or slave,and, if it's a slave, whether it should use a 10-bit or 7-bit address. Most of the time, 7-bits is used, especially in small applications. The options for sync_mode are:

  • SLAVE_7 - Slave mode, 7-bit address
  • SLAVE_10 - Slave mode, 10-bit address
  • MASTER - Master mode

With slew, you can select if the device should use slew rate. More about what it is here: What is slew rate for I2C?

Two MSSP modules

There's something special about devices with two MSSP modules, like the PIC18F46K22. They have two sets of functions, one for module 1 and one for module 2. For example, instead of OpenI2C(), they have OpenI2C1() and openI2C2().

Okay, so you've set it all up and opened the connection. Now let's do some examples:

Examples

Master write example

If you're familiar with the I2C protocol, you'll know a typical master write sequence looks like this:

Master : START | ADDR+W |     | DATA |     | DATA |     | ... | DATA |     | STOP
Slave  :       |        | ACK |      | ACK |      | ACK | ... |      | ACK |

At first, we send a START condition. Consider this picking up the phone. Then, the address with a Write bit - dialing the number. At this point, the slave with the sent address knows he's being called. He sends an Acknowledgement ("Hello"). Now, the master device can go send data - he starts talking. He sends any amount of bytes. After each byte, the slave should ACK the received data ("yes, I hear you"). When the master device has finished talking, he hangs up with the STOP condition.

In C, the master write sequence would look like this for the master:

IdleI2C();                         // Wait until the bus is idle
StartI2C();                        // Send START condition
IdleI2C();                         // Wait for the end of the START condition
WriteI2C( slave_address & 0xfe );  // Send address with R/W cleared for write
IdleI2C();                         // Wait for ACK
WriteI2C( data[0] );               // Write first byte of data
IdleI2C();                         // Wait for ACK
// ...
WriteI2C( data[n] );               // Write nth byte of data
IdleI2C();                         // Wait for ACK
StopI2C();                         // Hang up, send STOP condition

Master read example

The master read sequence is slightly different from the write sequence:

Master : START | ADDR+R |     |      | ACK |      | ACK | ... |      | NACK | STOP
Slave  :       |        | ACK | DATA |     | DATA |     | ... | DATA |      |

Again, the master initiates the call and dials the number. However, he now wants to get information. The slave first answers the call, then starts talking (sending data). The master acknowledges every byte until he has enough information. Then he sends a Not-ACK and hangs up with a STOP condition.

In C, this would look like this for the master part:

IdleI2C();                         // Wait until the bus is idle
StartI2C();                        // Send START condition
IdleI2C();                         // Wait for the end of the START condition
WriteI2C( slave_address | 0x01 );  // Send address with R/W set for read
IdleI2C();                         // Wait for ACK
data[0] = ReadI2C();               // Read first byte of data
AckI2C();                          // Send ACK
// ...
data[n] = ReadI2C();               // Read nth byte of data
NotAckI2C();                       // Send NACK
StopI2C();                         // Hang up, send STOP condition

Slave code

For the slave, it's best to use an Interrupt Service Routine or ISR. You can setup your microcontroller to receive an interrupt when your address is called. That way you don't have to check the bus constantly.

First, let's set up the basics for the interrupts. You'll have to enable interrupts, and add an ISR. It's important that PIC18s have two levels of interrupts: high and low. We're going to set I2C as a high priority interrupt, because it's very important to reply to an I2C call. What we're going to do is the following:

  • Write an SSP ISR, for when the interrupt is an SSP interrupt (and not another interrupt)
  • Write a general high-priority ISR, for when the interrupt is high priority. This function has to check what kind of interrupt was fired, and call the right sub-ISR (for example, the SSP ISR)
  • Add a GOTO instruction to the general ISR on the high priority interrupt vector. We can't put the general ISR directly on the vector because it's too large in many cases.

Here's a code example:

// Function prototypes for the high priority ISRs
void highPriorityISR(void);

// Function prototype for the SSP ISR
void SSPISR(void);

// This is the code for at the high priority vector
#pragma code high_vector=0x08
void interrupt_at_high_vector(void) { _asm GOTO highPriorityISR _endasm }
#pragma code

// The actual high priority ISR
#pragma interrupt highPriorityISR
void highPriorityISR() {
    if (PIR1bits.SSPIF) {        // Check for SSP interrupt
        SSPISR();            // It is an SSP interrupt, call the SSP ISR
        PIR1bits.SSPIF = 0;  // Clear the interrupt flag
    }
    return;
}

// This is the actual SSP ISR
void SSPISR(void) {
    // We'll add code later on
}

Next thing to do is to enable the high priority interrupt when the chip initializes. This can be done by some simple register manipulations:

RCONbits.IPEN = 1;          // Enable interrupt priorities
INTCON &= 0x3f;             // Globally enable interrupts
PIE1bits.SSPIE = 1;         // Enable SSP interrupt
IPR1bits.SSPIP = 1;         // Set SSP interrupt priority to high

Now, we have interrupts working. If you're implementing this, I'd check it now. Write a basic SSPISR() to start blinking an LED when an SSP interrupt occurs.

Okay, so you got your interrupts working. Now let's write some real code for the SSPISR() function. But first some theory. We distinguish five different I2C interrupt types:

  1. Master writes, last byte was address
  2. Master writes, last byte was data
  3. Master reads, last byte was address
  4. Master reads, last byte was data
  5. NACK: end of transmission

You can check at what state you are by checking the bits in the SSPSTAT register. This register is as follows in I2C mode (unused or irrelevant bits are omitted):

  • Bit 5: D/NOT A: Data/Not address: set if the last byte was data, cleared if the last byte was an address
  • Bit 4: P: Stop bit: set if a STOP condition occurred last (there's no active operation)
  • Bit 3: S: Start bit: set if a START condition occurred last (there's an active operation)
  • Bit 2: R/NOT W: Read/Not write: set if the operation is a Master Read, cleared if the operation is a Master Write
  • Bit 0: BF: Buffer Full: set if there's data in the SSPBUFF register, cleared if not

With this data, it's easy to see how to see what state the I2C module is in:

State | Operation | Last byte | Bit 5 | Bit 4 | Bit 3 | Bit 2 | Bit 0
------+-----------+-----------+-------+-------+-------+-------+-------
1     | M write   | address   |   0   |   0   |   1   |   0   |   1
2     | M write   | data      |   1   |   0   |   1   |   0   |   1
3     | M read    | address   |   0   |   0   |   1   |   1   |   0
4     | M read    | data      |   1   |   0   |   1   |   1   |   0
5     | none      | -         |   ?   |   ?   |   ?   |   ?   |   ?

In software, it's best to use state 5 as the default, which is assumed when the requirements for the other states aren't met. That way, you don't reply when you don't know what's going on, because the slave doesn't respond to a NACK.

Anyway, let's have a look at the code:

void SSPISR(void) {
    unsigned char temp, data;

    temp = SSPSTAT & 0x2d;
    if ((temp ^ 0x09) == 0x00) {            // 1: write operation, last byte was address
        data = ReadI2C();
        // Do something with data, or just return
    } else if ((temp ^ 0x29) == 0x00) {     // 2: write operation, last byte was data
        data = ReadI2C();
        // Do something with data, or just return
    } else if ((temp ^ 0x0c) == 0x00) {     // 3: read operation, last byte was address
        // Do something, then write something to I2C
        WriteI2C(0x00);
    } else if ((temp ^ 0x2c) == 0x00) {     // 4: read operation, last byte was data
        // Do something, then write something to I2C
        WriteI2C(0x00);
    } else {                                // 5: slave logic reset by NACK from master
        // Don't do anything, clear a buffer, reset, whatever
    }
}

You can see how you can check the SSPSTAT register (first ANDed with 0x2d so that we only have the useful bits) using bitmasks in order to see what interrupt type we have.

It's your job to find out what you have to send or do when you respond to an interrupt: it depends on your application.

References

Again, I'd like to mention the application notes Microchip wrote about I2C:

  • AN734 on implementing an I2C slave
  • AN735 on implementing an I2C master
  • AN736 on setting up a network protocol for environmental monitoring

There's documentation for the compiler libraries: Compiler libraries documentation

When setting up something yourself, check the datasheet of your chip on the (M)SSP section for I2C communication. I used the PIC18F46K22 for the master part and the PIC18F4620 for the slave part.