Using the PIC16F88 as an I2C device
Final project for PHYS 335A: Digital Electronics, taught Spring of 2024 by Dr. David Pengra at the University of Washington.
The PIC16F88 is a 16-bit, lightweight microcontroller from Microchip devices which is useful for a variety of low-cost and low-power, high speed embedded systems operations.
Here I describe some details of how I try to set up an IC implementation on the PIC16F88, and design a program using MPASM Assembly language to operate it as an I2C target device (slave), to be controlled by higher-level controller (master) devices such as Arduinos or Raspberry Pis.
Note: MPASMx is no longer supported as of MPLAB X v5.40 - use >v5.35 if you're intending to use it here. See the footnote1 for more details on this & using OSx.
Introduction to I2C
IC (or inter-integrated circuit) is, like so many things in electrical engineering, a complicated-looking protocol that's actually pretty simple in a clever sort of way.
When compared to its closest serial communications cousin SPI (or serial peripheral interface), IC only requires 2 datalines (SDA and SCL) compared to SPI's 4 (MISO, MOSI, SCLK and CS), and allows for far more devices to be connected due to the use of peripheral addressing rather than SPI's chip select.
The master device / controller, in my case an Arduino Mega 2560, specifies some baudrate to operate the clock frequency on. When no data transmission is happening, both SDA and SCL are pulled to
Vdd
through the pullup resistors ( above - typical choices range from
4.7k) to 10k).
Since this implementation of IC is open-drain, the controller and target oscillate lines by just directly grounding them - the pullup resistors avoid shorting the circuit out this way. Note this means that the longer SCL=0
or SDA=0
, the more power the circuit will use by extension.
Pullup resistor choice is important if you're trying to optimize - small PU resistors have high power consumption but also high speeds, while large PU resistors have low power consumption but also create capacitance delays. Texas Instruments has a good description of this.
By my understanding, IC (in 7-bit addressing space2) generally operates like this:
- Controller device sends a
START
bit by holding SDA low with SCL high. - Controller sends a 7-bit address, one bit per clock cycle.
- At the 8th bit (8th time SDA goes HIGH), target devices compare the sent address with their own. If the two match, continue. Otherwise, ignore - this stream isn't meant for that device.
- Controller and specified target communicate.
END
bit set by either to indicate the conversation ifs over.
Each 'communication sequence' happens in 8-bit intervals - 7 bits for data, the 8th allowing time for each device to tell the other what to expect next. The PIC16F88 SSP section of the datasheet has a handy visualization of the target transmitting data back to the host - what we're planning on doing here.
So - let's talk about the project.
I2C Rangefinder Project
We'll use the PIC16F88 in conjunction with the HC-SR04 ultrasonic rangefinder module to create a rangefinding device, retrieving the rangefinder values from the PIC by communicating over IC.
For our controller, we'll use an Arduino Mega 2560 - chosen because it allows serial console monitoring & has 5V IC logic by default (the PIC16F88 uses 5V, and thus has 5V IC outputs), and has a number of high-level logic libraries for interfacing with IC (see the Wire library for more information).
It is possible to use an IC controller with 3.3V logic (such as a Raspberry Pi) with 5V target devices or visa-versa, however you'll need to convert logic levels between the two, either with a dedicated logic-level conversion device or by using N-channel MOSFETs and pull-up resistors. Refer to NXP note AN10441 for more information.
Hardware
Making a circuit diagram for this,
For my pullup resistors I decided to use 4.7k, as I'm not particularly worried about power consumption on a proof-of-concept. Note that
RA<x>
refers to pins PORTA<x>
, and similarly RB<x>
refer to pins PORTB<x>
.
Note: it's quite important that the controller and target devices share a common ground - otherwise, the "reference ground voltage" for IC pulses may not be the same and a circuit may not function as hoped!
Here's the physical implementation of this circuit.
Software
First, I made sure code for the rangefinder was working before fiddling with IC. I made use of some of the code provided in Lab 8 - Sonic Ranger with Interrupts & Hardware Control, ensuring the rangefinder worked by tinkering with the debugger and observing variable changes in response to changed echo distances from the rangefinder. This could have also been done by reusing our Lab 7 code, but I thought the Lab 8 code looked much neater.
Now - onto the software side of IC. The SSP section on IC in the PIC16F88 datasheet is unfortunately quite lacking with regards to exact implementation methods, so I was forced to scrounge around a bit through sources official and otherwise for information.
In brief, the PIC16F88 doesn't easily support a controller-mode implementation of IC out of the box, lacking the MSSP register found in other PICs that would allow easier implementation.
Instead, the 'F88 supports a variety of both SPI and IC target device implementations that may be used instead - refer to Register 10.2 in the datasheet SSP section. If you're looking to implement master-mode on a PIC, look to other devices such as the PIC16F1508 which have MSSP registers.
Using the PIC16F88 datasheet section on SSP (paying particular attention to section 10.3.1) as well as the section on interrupts and the section on configuring PORTB, here's the process I followed to implement "IC Slave mode, 7-bit address with Start and Stop bit interrupts enabled".
- Clear
ANSEL
to enable digital (instead of analog) inputs. - Set
TRISB<1>
(SDA) andTRISB<4>
(SCL) pins, turning them into inputs.- "But wait! IC is bidirectional!" you might argue - this is true. The SSP (synchronous serial port) module in the PIC16F88 will automatically set and clear
TRISB<1>/<4>
in response toSDA
andSCL
events.
- "But wait! IC is bidirectional!" you might argue - this is true. The SSP (synchronous serial port) module in the PIC16F88 will automatically set and clear
- Set bits in
SSPCON
(SSP control register):SSPCON<5>
-SSPEN
, enables SSPSSPCON<4>
-CKP
, clock polarity, allowing controller to oscillateSCL
SSPCON<3:0>=1110
-SSPM<3:0>
, "SSP mode select". See Register 10.2 in the SSP section of the datasheet for other options - by using1110
, we're setting our SSP mode to IC target mode with 7 bit addressing, start/stop interrupts enabled.
- Choose an address for your device. It must not be
0x00
or0x01
, other values allowed up to0xFF
. I chose0x77
arbitarily. Write it intoSSPADD
.- Since only bits
SSPSR<7:1>
are compared toSSPADD
, run anRLF
instruction (rotate-left-through-carry) onSSPADD
to make sure your address matches the checked one.
- Since only bits
- Enable interrupts. Set bits:
INTCON<7>
-GIE
, enable global interruptsINTCON<6>
-PEIE
, enable peripheral interruptsPIE1<3>
-SSPIE
, enable SSP interrupts
- Create interrupt functions in your ISR space
- Check if
PIR<3>
(SSPIF
) is set to make sure the interrupt was caused by IC, if there are multiple possible interrupts in your code. - Function to
Save
STATUS
andW
as you normally would. WriteData
- to send data, write (less than 8 bits total) toSSPBUF
, then setSSPCON<4>
to indicate to the controller that you (the target) are ready to transmit.SSPBUF
will now start automatically sending.- Function to
Load
as you normally would - Clear
PIR1<3>
(SSPIF
) to reset the SSP ...
- Check if
- ... and
RETFIE
.
My ISR implementation following 6-7 above looks like this:
; Interrupt Service Routine ----------------------------------------------------
ORG 0x0004 ; ISR beginning
SaveState
MOVWF SAVE_W ; Save W register
SWAPF STATUS, W ; Save STATUS reg
MOVWF SAVE_STAT ; ... into temporary reg
LoadAndSend
MOVFW TimerCounts ; Load last pulse period into W
MOVWF SSPBUF ; Load SSPBUF with W (will be sent)
BSF SSPCON,CKP ; Set CKP bit to indicate our buffer is ready
LoadState
SWAPF SAVE_STAT,W ; Load STATUS
MOVWF STATUS
SWAPF SAVE_W, F ; Load W
SWAPF SAVE_W, W ; Load W into W
BCF PIR1,SSPIF ; Clear serial interrupt flag
RETFIE ; ... and return to program execution
; End ISR ----------------------------------------------------------------------
and relevant IC code blocks like this:
; I2C initialization subroutine ------------------------------------------------
SetI2C
BANKSEL ANSEL ; Bank 1
CLRF ANSEL ; Set to all-digital inputs
BSF TRISB, TRISB1 ; Set PORTB<1> (SDA) as an input
BSF TRISB, TRISB4 ; Set PORTB<4> (SCL) as an input
CLRF SSPSTAT ; Reset SSPSTAT
BANKSEL SSPCON ; Bank 0
BSF SSPCON, SSPEN ; Turn on SSP
BSF SSPCON, CKP ; Enable clock (if 0, holds clock low)
BSF SSPCON, SSPM3 ; I2C 7-bit slave-mode w/ int is SSPM<3:0>=1110
BSF SSPCON, SSPM2
BSF SSPCON, SSPM1
BCF SSPCON, SSPM0
BANKSEL SSPADD ; Bank 1
MOVLW I2C_ADDR ; Load I2C address into W
MOVWF SSPADD ; Load SSPADD with I2C_ADDR (0xIC random)
RLF SSPADD,F ; Left-shift since SSPSR<7:1> compared
; Interrupt configuration
BANKSEL INTCON ; Bank 0
BSF INTCON, GIE ; Enable global interrupts - SSP interrupt on START
BSF INTCON, PEIE ; Enable peripheral interrupts
BANKSEL PIE1 ; Select PIE register
BSF PIE1, SSPIE ; Enable SSP interrupts in peripheral interrupt reg
; End subroutine ---------------------------------------------------------------
Results
Using the Arduino IDE with the Wire library, I set up a basic script that, after initializing IC and the serial monitor both at a baudrate of 9600, would send an IC "read" query down SDA
to address 0x77
(our PIC) to try and retrieve our TimerCounts
value from our interrupt function LoadAndSend
shown above.
#include <Wire.h>
// Notes: D21 is SCL, D20 is SDA for MEGA 2560
void setup() {
// Initialize I2C
Wire.begin();
// Start serial output for console monitoring
Serial.begin(9600);
Serial.println("Setup complete. Starting loop ...");
}
// Poll 0x77 address - then sleep for two seconds - then poll.
void loop() {
// Request 2 bits from 0x77
Wire.requestFrom(0x77, 1);
// Send results (if any) to serial monitor
while (Wire.available()) {
char c = Wire.read();
Serial.print("Distance: ");
Serial.print(c, DEC);
Serial.println(" cm");
}
delay(2000);
}
And, after some tinkering, checking the serial monitor revealed ...
Voilà!!
Notes
I definitely didn't get this first try - I wrote the Arduino script pretty early on to monitor the PIC16F88 while I figured out the maze of interrupt flags necessary to work with IC.
Something I should note - I've wasted at least a few hours during this project because I wasn't operating on the correct bank while trying to operate on some register. Such problems won't often be immediately apparent, only becoming clear when you step through your code with a debugger and realize a value isn't changing when it should.
BANKSEL
is a friend!
Code
The full program I used for this is written below.
;*******************************************************************************
;
; Filename: Final Project I2C Ranger
; Date: 5/31/2024
; File Version: 1.0
; Author: Parker Lamb
; Description: Sets PIC16F88 up as I2C-ready ultrasonic rangefinding device.
;
;*******************************************************************************
;*******************************************************************************
;
; Procesor initial setup
;
;*******************************************************************************
list F=inhx8m, P=16F88, R=hex, N=0 ; File format, chip, and default radix
#include p16f88.inc ; PIC 16f88 specific register definitions
__config _CONFIG1, _MCLR_ON & _FOSC_INTOSCCLK & _WDT_OFF & _LVP_OFF & _PWRTE_OFF & _BODEN_ON & _LVP_OFF & _CPD_OFF & _WRT_PROTECT_OFF & _CCP1_RB0 & _CP_OFF
__config _CONFIG2 , _IESO_OFF & _FCMEN_OFF
Errorlevel -302 ; switches off msg [302]: Register in operand not in bank 0.
;*******************************************************************************
;
; Constants and variables
;
;*******************************************************************************
; Program vars
TimerCounts EQU h'20' ; Saving timer counts
; vars
SAVE_W EQU h'21' ; Interrupt temporary W storage
SAVE_STAT EQU h'22' ; Interrupt temporary STATUS FSR storage
; I2C status
I2C_STAT EQU h'23' ; Check if I2C is connected
I2C_ADDR EQU 0x77 ; I2C address (const)
; Delay count registers
DInd1 EQU h'24'
DInd2 EQU h'25'
; Delay times
DTime1 EQU .199 ; 60 ms delay - outer loop
DTime2 EQU .60 ; Nested loop runs for 59,941 cycles
; SCL and SDA locations
#define _SDA PORTB,RB1 ; Make _SDA easier to check
#define _SCL PORTB,RB4 ; Make _SCL easier to check
;*******************************************************************************
;
; Memory init & interrupts
;
;*******************************************************************************
ORG 0x00
GOTO Init
; Interrupt Service Routine ----------------------------------------------------
ORG 0x0004 ; ISR beginning
SaveState
MOVWF SAVE_W ; Save W register
SWAPF STATUS, W ; Save STATUS reg
MOVWF SAVE_STAT ; ... into temporary reg
LoadAndSend
MOVFW TimerCounts ; Load last pulse period into W
MOVWF SSPBUF ; Load SSPBUF with W (will be sent)
BSF SSPCON,CKP ; Set CKP bit to indicate our buffer is ready
LoadState
SWAPF SAVE_STAT,W ; Load STATUS
MOVWF STATUS
SWAPF SAVE_W, F ; Load W
SWAPF SAVE_W, W ; Load W into W
BCF PIR1,SSPIF
RETFIE ; ... and return to program execution
;*******************************************************************************
;
; Program start
;
;*******************************************************************************
Init ORG 0x0020
; Set up oscillator to 4 MHz
SetOsc_4MHz
BANKSEL OSCCON
CLRF OSCCON
BSF OSCCON, IRCF1 ; Bit 5
BSF OSCCON, IRCF2 ; Bit 6
; Tuned value from Lab 7 - adjust as needed
MOVLW 0x16
MOVWF OSCTUNE
; Reset IO to all digital, all outputs, clear latches
ResetIO
BANKSEL PORTA ; Clear data latch registers
CLRF PORTA
CLRF PORTB
BANKSEL TRISA ; Data direction on PORTA / PORTB
CLRF TRISA ; Set PORTA to all output
CLRF TRISB ; Set PORTB to all output
BANKSEL ANSEL ; Select digital / analogue register
CLRF ANSEL ; And set to all-digital inputs
; ... and set what we need to
SetIO
BANKSEL TRISA
MOVLW 0xFF
MOVWF TRISA ; Set PORTA direction to all inputs
BCF TRISA, RA0 ; ... except PORTA<0>, an output
SetTMR0
; TMR0 duration between pulses indicates our radar distance
BANKSEL OPTION_REG ; TMR0 operation controlled via OPTION_REG
CLRF OPTION_REG ; Clear it - sets PSA to TMR0, low-hi edge
BSF OPTION_REG, 0x0
BSF OPTION_REG, 0x2 ; Set prescaler rate to 1:64 w.r.t oscillator (101)
BANKSEL INTCON ; Select interrupt control register
BCF INTCON,TMR0IE ; Disable TMR0 OF interrupt (not using here)
BCF INTCON,TMR0IF ; Clear flag (maybe unnecessary - do anyway)
;*******************************************************************************
;
; I2C configuration
; We're using this device in 7-bit I2C slave-mode with stop + start
; interrupts enabled.
;
;*******************************************************************************
SetI2C
BANKSEL TRISB ; Bank 1
BSF TRISB, TRISB1 ; Set PORTB<1> (SDA) as an input
BSF TRISB, TRISB4 ; Set PORTB<4> (SCL) as an input
; BANKSEL SSPSTAT ; Bank 1
CLRF SSPSTAT ; Reset SSPSTAT
BANKSEL SSPCON ; Bank 0
BSF SSPCON, SSPEN ; Turn on SSP
BSF SSPCON, CKP ; Enable clock (if 0, holds clock low)
BSF SSPCON, SSPM3 ; I2C 7-bit slave-mode w/ int is SSPM<3:0>=1110
BSF SSPCON, SSPM2
BSF SSPCON, SSPM1
BCF SSPCON, SSPM0
BANKSEL SSPADD ; Bank 1
MOVLW I2C_ADDR ; Load I2C address into W
MOVWF SSPADD ; Load SSPADD with I2C_ADDR (0xIC random)
RLF SSPADD,F ; Left-shift since SSPSR<7:1> compared
; Interrupt configuration
BANKSEL INTCON ; Bank 0
BSF INTCON, GIE ; Enable global interrupts - SSP interrupt on START
BSF INTCON, PEIE ; Enable peripheral interrupts
BANKSEL PIE1 ; Select PIE register
BSF PIE1, SSPIE ; Enable SSP interrupts in peripheral interrupt reg
;*******************************************************************************
;
; Main program loop
; Cause the sonic module to pulse every 10 microseconds. Wait for a response,
; then save time it took into a register. Repeatedly do this.
;
; Once I2C interrupt is detected, send information via I2C.
;
; TODO only start this loop if START condition detected, stop with I2C STOP.
;
;*******************************************************************************
; Reset to bank 0
BCF STATUS, RP0
BCF STATUS, RP1 ; All of the action is in Bank 0 now
MainLoop
; Send sonic device a 10-microsecond pulse
Pulse
BSF PORTA, RA0 ; Set PORTA<0> output to high
NOP
NOP
NOP
NOP
NOP
NOP
NOP
NOP
NOP
BCF PORTA, RA0 ; Set PORTA<0> output low
; Loop until PORTA<1> goes HI, indicating a response
WaitForResp
BTFSS PORTA, RA1 ; Check PORTA<1>
GOTO WaitForResp ; ... and loop if it's still zero
; Start of our response
CLRF TMR0 ; Start timer from now
WaitUntilLow
BTFSC PORTA, RA1 ; Check PORTA<1> to see if response finished
GOTO WaitUntilLow ; ... loop if still not LOW
; We have a response! Store timer value in a variable
MOVFW TMR0
MOVWF TimerCounts
; Delay for a bit so we aren't constantly polling
CALL Delay
; Return to MainLoop
GOTO MainLoop
;*******************************************************************************
;
; Subroutines
;
;*******************************************************************************
; Loop from Template_for_Ranger_with_interrupts.asm, course lab template
Delay
MOVLW DTime2
MOVWF DInd2
Loop1 MOVLW DTime1
MOVWF DInd1
Subloop1 NOP
NOP
DECFSZ DInd1,F
GOTO Subloop1
DECFSZ DInd2,F
GOTO Loop1
RETURN
Finish
END
The code for the Mega 2560 is written in Results.
-
With version v5.40 of MPLAB X, Microchip's dedicated compiler/IDE, Microchip upgraded all their binaries to 64-bit. However, since
mpasmx
was not updated from 32 bits, and Mac OSx does not support 32-bit applications since Mojave, Mac users will need to either use Windows or a VM. ↩ -
See Wikipedia's page for addressing structure for more details. ↩