PIC24E I2C communication with MCP4725

I2C (Inter-Integrated Circuit) is a serial communication protocol invented by Philips. The main advantage of using I2C is that it requires only 2 wires: clock and data, where several devices can be connected. In a typical arrangement, a processor acts as the bus master and devices as slaves, though multi-master setup is also possible.

In this article, we examine an I2C module provided in the PIC24EP family. The same PIC24EP256MC202 prototype board is used. We start from a simple hardware setup. An MCP4725, 12-bit DAC, from Microchip is used as slave device. The chip is soldered to the prototype as shown in the figure above. (Well, if you are in a country where an inexpensive breakout board is available, I recommend you buy one. Soldering work is cumbersome with this tiny IC.)

Hardware Setup

SCL and SDA pins of the two ICs are wired together. Note that the PIC24EP256MC202 has ASDL1 and ASDA1 for I2C1 module, where A stands for “Alternate.” Surprisingly enough, I could not find a primary SDL1 and SDA1 from the datasheet. Standard I2C requires that the two wires must be pulled up by resistors with value range between 1K to 10K ohms. Here we use a pair of 2.2K ohms from my component stock.

The MCP4725 has only 6 pins. The SCL and SDA are taken care of. Vdd and Vss are connected to 3.3 V and GND, respectively. (The datasheet recommends putting a 0.1 mF bypass capacitor close to the supply pin.) Pin 1 is the DAC output. So what’s left is pin 6 (A0). This is your selectable DAC address. It means you can have two ICs on the same I2C bus differentiated by this address pin. Here I connect it to GND for value 0.

Figure 1 I2C connection between PIC24E and MCP4725

Note: From Microchip datasheet, if a customer wishes to have more than two MCP4725s on the same bus, he/she can have internal address A2 and A1 customized in the ordering process. The factory default is 00.

Configure the PIC24E I2C Pins

Before we go into the C code detail, one issue that is worth mentioning is on the use of alternate I2C pins ASCL1 and ASDA1. As mentioned before, even though I could not find primary pins for the I2C, I still have to tell the PIC to use alternate pins. To do so, this configuration command must be put at the top of source file.

_FPOR(ALTI2C1_ON);

Don’t forget this. It did take me hours to figure out why the I2C module didn’t function.

Also, make the ASCL1 and ASDA1 open-drain

 ODCBbits.ODCB8=0;  // ASCL1
 ODCBbits.ODCB9=0;  // ASDA1

Using I2C Library

From a theoretical viewpoint, the I2C protocol is defined as a synchronized serial communication with specific timing requirements for the two digital signals: clock and data. A good explanation is available from many sources, including Microchip datasheets and FRM. For programming sake, one only needs to know how to configure and read/write to particular registers to make the hardware work. With such knowledge, you can write your own library, but a better option is to use one that is readily available. Accompanied with [1] is a software package called pic24 library collection. Download the zip file and extract to a directory. Along with many useful stuff, you’ll find the I2C library in /lib/src. Examples are provided in /chap10. Consult the book [1] for more detail.

Because of copyright concern, I will not provide the source code of that library here. I just cut and paste the following functions from i2c.c

#ifndef I2C_ACK
# define I2C_ACK 0
#endif
#ifndef I2C_NAK
# define I2C_NAK 1
#endif

#define I2C_WADDR(x) (x & 0xFE) //clear R/W bit of I2C addr
#define I2C_RADDR(x) (x | 0x01) //set R/W bit of I2C addr

//I2C Operations

void configI2C1(uint16_t u16_FkHZ);
void startI2C1(void);
void rstartI2C1(void);
void stopI2C1(void);
void putI2C1(uint8_t u8_val);
uint8_t putNoAckCheckI2C1(uint8_t u8_val);
uint8_t getI2C1(uint8_t u8_ack2Send);

//I2C Transactions
void write1I2C1(uint8_t u8_addr,uint8_t u8_d1);
void write2I2C1(uint8_t u8_addr,uint8_t u8_d1, uint8_t u8_d2);
void writeNI2C1(uint8_t u8_addr,uint8_t* pu8_data, uint16_t u16_cnt);
void read1I2C1(uint8_t u8_addr,uint8_t* pu8_d1);
void read2I2C1(uint8_t u8_addr,uint8_t* pu8_d1, uint8_t* pu8_d2);
void readNI2C1(uint8_t u8_addr,uint8_t* pu8_data, uint16_t u16_cnt);

To use the above code without editing the variable types, define them at the top of your source file.

#define uint8_t unsigned char
#define uint16_t unsigned int
#define uint32_t unsigned long

In the library function implementation, comment out some lines that you don’t intend to use, or change it to suit your hardware. For example, an error message may be sent to hyperterminal when a device does not acknowledge the call.

Writing to the DAC

Here comes the part that I want to elaborate more. The MCP4725 has a couple of write modes depending on the speed and the destination. The chip has an internal EEPROM that can store a DAC value. This is useful for an application that requires some particular voltage at power on. Keep in mind that writing to EEPROM takes more time than writing directly to the DAC register. For our simple experiment, we do not need to memorize any value so we write only to the DAC register.

Before doing anything else, initialize the I2C by selecting the speed. The value is in kilohertz. For example, for 400 KHz speed

 configI2C1(400);

The function also enables the I2C1 module by setting I2C1CONbits.I2CEN = 1.

According to the datasheet, suppose we want to write a 12-bit value, say, 0xBD6, to MCP4725 using fast mode to DAC register, first it has to be addressed by sending the first byte 0xC0 (assuming A0 pin tied low), then the 2-byte data 0x0BD6. Note that the library has function write2I2C1(), which can be conveniently used. The only required task is to rearrange a 12-bit value to 2 bytes. A straightforward way people love is to assign the upper 4 bits to a 8-bit variable and perform some shifting. Here we prefer using union variable. Define a global union to keep DAC value

typedef union
{
    uint8_t half[2];
    uint16_t full;
} dacvalue;
dacvalue DacVal;

Then we can conveniently access the full value from DacVal.full, or either lower and upper half bytes from DacVal.half[0] and DacVal.half[1], respectively, without having to shift the data.

So, the writing process is implemented as a function writeMCP4725()

void writeMCP4725(void)  {
    write2I2C1(MCP4725ADDR, DacVal.half[1], DacVal.half[0]);
}  

where MCP4725ADDR is defined as 0xC0 somewhere at the top of the source file.

Running the Experiment

We make a simple program flow, using the ADC readouts from our previous article Simultaneous Sampling of 4 ADC Channels with PIC24E. Only the value from AN0 is sent to the MCP4725. The 12-bit DAC value is created by shifting left twice the 10-bit value from AN0.

 DacVal.full = ADCValues[0]<<2;

For example, maximum read of AN0 = 1023 converts to DAC value of 4095. This translates to voltage equal to Vdd of MCP4725, or 3.3 volts.

Even though the ADC readout from AN0 never exceeds 1023 in this experiment, It is a good practice to limit the DAC value to the 12-bit range

if (DacVal.full>4095) DacVal.full = 4095;
else if (DacVal.full<0) DacVal.full = 0;
writeMCP4725(); // send to DAC

Figure 2 - 4 show readouts from a voltmeter with 3 values of AN0. The voltage output equal 3.3, 2.14, and 0.36 volts when AN0 read 1023, 658, and 111, respectively. Simple calculations verify they are correct. For, example, for AN0 = 111, the output must equal (111 x 3.3)/1023 = 0.358 volt.

Figure 2 DAC voltage readout (3.3 Volts) when AN0 = 1023
Figure 3 DAC readout (2.14 Volts) when AN0 = 658
Figure 4 DAC readout (0.36 volt) when AN0 = 111

Reference

  1. R. Reese, J.W. Bruce and B.A. Jones, Microcontrollers: From Assembly Language to C Using the PIC24 Family, Cengage Learning, 2009.

Comments

Popular posts from this blog

An Introduction to Finite State Machine Design

A Note on Output Compare (PWM) Module of PIC24E