FB pixel

Create a Real-Time Clock with OLED Display Using I2C Module | Embedded C Programming - Part 34New

Published


Hello again!

After the last tutorial where we didn’t study any new modules, we are opening the next subseries devoted to the digital interfaces of the PIC18F14K50 MCU. It has several: I2C, SPI, UART, and USB.

Moreover, starting from this tutorial until the last one of the series, we will use the Universal board we became acquainted with in Tutorial 26. If you have no such board, you can just keep using the breadboard and separate parts, it’s totally fine. Or, if we get enough interest, we may produce them in bulk, and you can purchase one from us, or, if you’d like, you can assemble one yourself. But once you start using it, believe me, you will not want to return to separate parts again.

This tutorial will be somewhat meaty. We will create a clock and calendar using the RTC chip DS1307. To indicate the time and date, we will use the 64x32 OLED display with the SSD1306 driver. All these parts are already populated on the Universal board, so we don’t need to look for them (if you do, you might want to purchase the Arduino DS1307 RTC module (Figure 1) and the OLED module (Figure 2) on any marketplace (Amazon, ebay, Aliexpress))

Figure 1 - RTC and EEPROM module
Figure 1 - RTC and EEPROM module
Figure 2 - 64x32 pixels OLED display module
Figure 2 - 64x32 pixels OLED display module

The price of each module is about $1.5-2 USD so this purchase most likely will not be painful for your wallet.

Both the RTC and the display use the same communication interface, I2C, which fortunately is supported by the PIC18F14K50 with its dedicated I2C module.

We already used the I2C interface in the PIC10F200 tutorial when we created the FM radio. There you can also find a brief description of this interface. For a more detailed description, please refer to our tutorial on I2C. In this tutorial, I will not describe the interface itself, considering you can familiarize yourself with it in the linked tutorial.

So the task for this tutorial will be quite simple - create a digital clock and calendar which will be able to display and adjust the current time and date. But the program which implements this short task will be quite long and complex.

Before starting considering the code, let’s first get familiar with the I2C module used in the current project. Even though the operation of this module is not very hard, it has a lot of functions, so its description will take some time.

Master Synchronous Serial Port (MSSP) Module

As you can see, the module is called “MSSP” instead of I2C, which means that things are more complicated than we anticipated initially. With the PIC18F14K50, there is a joint I2C + SPI module, which is called the MSSP, and there is only one such module in this MCU. Which means (as you might already guess) that you can’t use both I2C and SPI interfaces in the same project. This is another flaw of this MCU (too many of them for a single chip, isn’t it?) but as I used to say in regards to it - we have what we have.

In this tutorial, we will consider operation of the MSSP module only in I2C mode, and leave the SPI mode for the next one.

Actually, this module is quite advanced. Let’s see what it supports.

It has full hardware support for the I2C master and slave modes. For the master mode, it provides detection of start and stop bits, which gives us the means of organizing the multi-master bus. In slave mode, it supports 7-bit and 10-bit addressing along with the general calls. With such a vast functionality, it’s not surprising that this module uses 7 registers for I2C operation.

In this tutorial, we will only consider the single-master mode, so the bits that are not related to this mode will be described very briefly.

  1. Serial Receive/Transmit Buffer Register - SSPBUF is the data register of the MSSP module. When sending some data via the MSSP, we need to write it into this register. And when some data is received via the MSSP, it’s also read from this register.
  2. MSSP shift register - SSPSR is the internal register of the MSSP module and is not available directly for users.
  3. MSSP Control 1 register (I2C mode) - SSPCON1, which has the following bits:
    • bit #7 - WCOL (Write COLlision detect). In Master transmit mode, if this bit is 1, then we have written data into the SSPBUF register while I2C conditions were not valid for transmission, and if it is 0 then there was no collision. In Slave transmit mode, this bit becomes 1 if we write into the SSPBUF register while the previous byte is still being transmitted. Bit WCOL must be cleared by software.
    • bit #6 - SSPOV (Receive OVerflow indicator). In receive mode, this bit is set if a new byte has come while the SSPBUF register is still holding the previous data. It also must be cleared by software.
    • bit #5 - SSPEN (Synchronous Serial Port ENable). If we set this bit as ‘1’, then the MSSP module becomes enabled, and pins SCL and SDA are used as serial port pins. If we clear this bit, the MSSP module becomes disabled, and pins SCL and SDA operate as regular GPIOs.
    • bit #4 - CKP (SCK release control). This bit is used only in Slave mode. If we set this bit as ‘1’ then the clock is released, and if it is ‘0’ then the clock is held low (clock stretching) while MCU processes the data before receiving a new one.
    • bits #3-#0 - SSPM3-SSPM0 (Synchronous Serial Port Mode select). The modes are listed in the table below. The missing combinations are either reserved or used in SPI mode.

    SSPM3-SSPM0

    Mode

    0110

    I2C Slave mode, 7-bit address

    0111

    I2C Slave mode, 10-bit address

    1000

    I2C Master mode, clock = Fosc / (4 * (SPADD + 1))

    1011

    I2C Firmware Controlled Master mode (Slave idle)

    1110

    I2C Slave mode, 7-bit address with start and stop bit interrupts enabled

    1111

    I2C Slave mode, 10-bit address with start and stop bit interrupts enabled

    Frankly, I didn’t get any useful information from the data sheet about the mysterious Firmware Controlled Master mode, but in an empirical way, I found out that the I2C Master mode (SSPM = 1000) works just fine. The SPADD is another register which we will consider a bit later.

  4. MSSP Control 2 register (I2C mode) - SSPCON2, which has the following bits:
    • bit #7 - GCEN (General Call ENable). This bit enables (if it’s 1) and disables (if it’s 0) the interrupt when the general call address (0x00) is received. This bit is applicable only in Slave mode.
    • bit #6 - ACKSTAT (ACKnowledge STATus). Don’t mix this up! This bit is ‘0’ if acknowledgement has been received from the Slave, and is ‘1’ if acknowledgement was not received. This bit is applicable only in Master transmit mode.
    • bit #5 - ACKDT (ACKnowledge DaTa). This bit is applicable only in Master receive mode. If it’s 0 then the ACK bit will be transmitted, otherwise, NACK bit will be sent. This bit just configures what will be sent but doesn’t start sending this bit.
    • bit #4 - ACKEN (ACKnowledge sequence ENable). This bit is also applicable in Master receive mode. When we set it as ‘1’ the Acknowledge sequence is started on SCL and SDA pins according to the state of the ACKDT bit. This bit is cleared by hardware.
    • bit #3 - RCEN (ReCeiver ENable). Setting this bit as ‘1’ enables the receive mode. It is applicable only in Master mode and should be set if we’re going to receive some data now.
    • bit #2 - PEN (stoP condition ENable). Setting this bit as ‘1’ initiates the Stop condition on the SDA and SCL pins. This bit is cleared by hardware. Applicable only in Master mode.
    • bit #1 - RSEN (Repeated Start condition ENable). Setting this bit as ‘1’ initiates the Repeated Start condition on SDA and SCL pins. This bit is cleared by hardware. Applicable only in Master mode.
    • bit #0 - SEN (Stop condition ENable / Stretch ENable). In Master mode setting this bit as ‘1’ initiates the Start condition on SDA and SCL pins. Then this bit is cleared by hardware. In Slave mode setting this bit as ‘1’ enables the clock stretching, and clearing this bit disables this feature.
  5. MSSP Status register (I2C mode) SSPSTAT, which has the following bits:
    • bit #7 - SMP (Slew rate control). If we set this bit as ‘1’ then the slew rate control is disabled for Standard speed mode (100 kHz and 1 MHz), and if we clear it then the slew rate control is enabled for High speed mode (400 kHz).
    • bit #6 - CKE (SMBus select). Setting this bit as ‘1’ enables the SMBus specific inputs.
    • bit #5 - D/A (Data/Address). This bit is applicable only in Slave mode. If this bit is ‘1’, this indicates that the last received byte was data, and if it is ‘0’, this indicates that the last received byte was an address.
    • bit #4 - P (stoP). If this bit is ‘1’ this indicates that the Stop condition has been detected last.
    • bit #3 - S (Start). If this bit is ‘1’ this indicates that the Start condition has been detected last.
    • bit #2 - R/W (Read/Write information). In Slave mode this bit indicates the mode - if it’s ‘1’ then it’s Read, and if it’s ‘0’ then it’s Write. In Master mode this bit is ‘1’ if the transmission is in progress, and is ‘0’ otherwise.
    • bit #1 - UA (Update Address). This bit is applicable only in 10-bit Slave mode. If it’s ‘1’ this indicates that the user needs to update the address in the SSPADD register. And if it is ‘0’ then the address doesn't need to be updated.
    • #bit #0 - BF (Buffer Full). If this bit is ‘1’ this indicates that the SSPBUF is full, and if it’s ‘0’ then the SSPBUF is empty.
  6. MSSP address and baud rate register (I2C mode) SSPADD. This register has multiple functions.
    • In Master mode this register sets the baud rate of the SCL signal, as follows: SCL clock frequency = Fosc / (4 * (SPADD + 1))
    • In 7-bit address Slave mode bits #7-#1 represent the slave address, and bit #0 is not used.
    • In 10-bit address Slave mode (most significant address byte) bits #2-#1 represent the bits #9-#8 of the address, and other bits are not used.
    • In 10-bit address Slave mode (least significant address byte) bits #7-#0 represent the bits #7-#0 of the address, respectively.
  7. MSSP mask register SSPMSK is used only in Slave mode and sets the bits of the SPADDR register which will be used in the address matching operation. If the bit of the SSPMSK register is set then the corresponding bit of the SSPADD register will participate in the comparison, and it will be ignored otherwise.

Also, MSSP module uses some bits of the interrupt related registers we’re already familiar with:

  1. In PIR1 register bit #3 - SSPIF (Synchronous Serial Port Interrupt Flag) is the interrupt flag when the transmission/reception is complete (this works both in I2C and SPI modes).
  2. In PIR2 register bit #3 - BCLIF (Bus CoLlision Interrupt Flag) is the interrupt flag when a bus collision has occurred.
  3. In PIE1 register bit #3 - SSPIE (Synchronous Serial Port Interrupt Enable) enables (if it is ‘1’) or disables (if it is ‘0’) the MSSP interrupt.
  4. In PIE2 register bit #3 - BCLIE (Bus CoLlision Interrupt Enable) enables (if it is ‘1’) or disables (if it is ‘0’) the bus collision interrupt.
  5. In IPR1 register bit #3 - SSPIP (Synchronous Serial Port Interrupt Priority) sets the high (if it is ‘1’) or low (if it is ‘0’) priority of the MSSP interrupt.
  6. In IPR2 register bit #3 - BCLIP (Bus CoLlision Interrupt Priority) sets the high (if it is ‘1’) or low (if it is ‘0’) priority of the bus collision interrupt.

OK, that’s more than enough information about the MSSP module to fulfill the given task. As usual, please refer to Chapter 15.3 of the PIC18F14K50 data sheet for more information. Probably even with all this knowledge the right sequence of the I2C communication is not clear yet. Well, that’s fine. I also spent some time trying to make it work correctly, and now I’m ready to share how to do it with you. But before this, let’s briefly consider the new chips we will use in this tutorial.

DS1307 RTC Chip

DS1307 is a special chip which provides a real-time clock and calendar. Why do we need an external chip if we can use a regular timer to count the seconds, and then minutes, hours, days, months, and years? Well, it's always good if some functionality is performed at the hardware level, not including the processor. Many 32-bit MCUs have the built-in RTC module which eliminates the need of an external chip but the PIC18F14K50 doesn’t have one, so we use this DS1307 chip.

Let’s use the words from the datasheet of the DS1307 IC to describe its functionality. “The DS1307 serial real-time clock (RTC) is a low-power, full binary-coded decimal (BCD) clock/calendar plus 56 bytes of NV SRAM. Address and data are transferred serially through an I2C, bidirectional bus. The clock/calendar provides seconds, minutes, hours, day, date, month, and year information. The end of the month date is automatically adjusted for months with fewer than 31 days, including corrections for leap year. The clock operates in either the 24-hour or 12-hour format with AM/PM indicator. The DS1307 has a built-in power-sense circuit that detects power failures and automatically switches to the backup supply. Timekeeping operation continues while the part operates from the backup supply.

What is important for us as users, from this description? DS1307 provides all the day and the time values with the correct month lengths and the leap year correction. It has the backup power circuit which allows the IC to hold the time even if the main power is off. By the way, you can see this backup power battery in figure 1. This single CR2032 battery can keep the time for several years.

The data in DS1307 is represented in BCD format which, on one hand, simplifies splitting the number into digits and, on the other hand, makes the process of setting the time more complex, which we will see in our program.

Let’s see the connection diagram of the DS1307 chip from its datasheet (Figure 3).

Figure 3 - Connection diagram of the DS1307 chip
Figure 3 - Connection diagram of the DS1307 chip

As you can see, the chip itself requires some external parts if you use it standalone. If you use the module like in Figure 1, all these parts are already mounted on the module. Among the mandatory parts there are:

  • CRYSTAL - 32768 Hz crystal which is usually used in the quartz clocks, connected to pins X1 and X2 of DS1307. The accuracy of this crystal sets the accuracy of the whole clock, so if you are not intending to adjust the time every day or week, select the crystal with the low frequency error.
  • Pull-up resistors on the SDA and SCL pins, which are required by the I2C standard.

The optional parts are:

  • Pull-up resistor on the SQW/OUT pin. This pin is used to output the square pulses with the defined frequency. We will use it in our device to define the time when we need to update the display with the new value.
  • Backup battery connected to the Vbat pin. If you want your clock to keep counting when the power is down you need to install this battery. As I already mentioned, a regular 3V CR2032 battery will work out ideally for this purpose.

The power supply voltage for the DS1307 chip should be from 4.5 to 5.5 V though.

All the values of the DS1307 IC are stored in the internal registers accessible via the I2C bus through their addresses. The registers set (taken from the data sheet) is presented in Table 1.

Table 1 - Register set of the DS1307 chip
Table 1 - Register set of the DS1307 chip

As you can see, the clock/calendar values are located at addresses 0x00-0x06 and are given in the BCD format, as I already mentioned. The register with the address 0x07 is the control register, it configures the functionality of the SQW/OUT pin.

Let’s consider it in more detail (yeah, yeah, registers everywhere, not only in MCUs).

  • bit #7 - OUT. This bit controls the output level of the SQW/OUT pin when the square-wave output is disabled. If SQWE = 0, the logic level on the SQW/OUT pin is 1 if OUT = 1 and is 0 if OUT = 0. On initial application of power to the device, this bit is typically set to 0.
  • bit #4 - SQWE. This bit, when set to 1, enables the oscillator output. The frequency of the square-wave output depends upon the value of the RS0 and RS1 bits. With the square-wave output set to 1Hz, the clock registers update on the falling edge of the square wave. On initial application of power to the device, this bit is typically set to a 0.
  • bits #1 & #0 - RS1, RS0. These bits control the frequency of the square-wave output when the square-wave output has been enabled. The following table lists the square-wave frequencies that can be selected with the RS bits. Upon initial application of power to the device, these bits are typically set to a 1
Table 2 - Behavior of the SQW/OUT pin depending on the Control register bits
Table 2 - Behavior of the SQW/OUT pin depending on the Control register bits

Also, please pay attention to the bit CH of the register 0x00 (Seconds). This bit enables the oscillation and generally the time counting. After power up, this bit is ‘1’ and the RTC is stopped. To start its operation, we need to write ‘0’ into this bit.

Registers 0x08-0xFF are 56 RAM registers which the user can use as he wants. The content of this memory will be retained while either main or backup power are applied to the RTC chip.

That’s actually everything we need to know about the DS1307 chip to work with it. Now let’s consider the OLED display.

SSD1306-based OLED Display

The display we will use in this tutorial is a graphic one, unlike the 1602 LCD we used in previous tutorials which was a character display. This means that this OLED display doesn’t have any character generator, so now it’s our problem to generate the fonts (luckily for us there are already plenty of ready-made fonts which we can use). If we use the same 7x5 font used in the 1602 LCD, then each character will use 8 x 6 pixels (one extra pixel to avoid gluing of the characters). With the resolution of the display of 64x32 pixels, we can fit 4 rows of 10 characters each, so 40 characters in total. This is even greater than what the 1602 LCD can fit, despite the bigger size of the latter.

SSD1306 is the name of the driver which is very widely used in monochrome OLED displays of different resolutions from 16 x 32 to 64 x 128 pixels. It provides control of the LEDs of the display, generates the voltage required for the matrix operation, and allows communication with the MCU using either the I2C or SPI interface.

The full description of this driver is quite long, but if you are curious enough, here’s the link to the datasheet. This driver has many more parameters than the HD44780 driver that controls the 1602 LCD which we considered in Tutorial 18.

So in regards to this display we will do things differently. We will take an existing SSD1306 library and adjust it for our MCU. There is a good Adafruit library developed for the Arduino platform which can be found here: https://github.com/adafruit/Adafruit_SSD1306. I already explained how to port this library to RA MCUs in the corresponding series. But for the PIC MCUs, there are several problems with this library. Firstly, it’s written in C++, which is not supported by the XC8 compiler, so we need to translate it from C++ to plain C. And the second related problem is that the class SSD1306 inherits from other classes, which we also need to add and translate somehow. Considering all these difficulties, and the fact that after adding all the related functions, we can exceed the Flash memory volume, I decided to use the older version of their library. I downloaded it a long time ago when it was just a standalone class, and I don’t even know the number of its release. But I already translated it into the C language and successfully ported it into many MCU families: MSP430 by TI, STM32 by ST, nRF51 by Nordic Semiconductor, EFR32 by Silicon labs, and probably others which I already don’t remember.

So in this tutorial, I will provide you the source files of this library, explain its main functions, and show the parts of the code which directly work with the hardware of the MCU. As you already probably assume, we will not deeply consider the library, we will just use it. But still, the code is open source, and it’s not very hard to understand, so if you are curious enough, feel free to discover (and probably upgrade) this library by yourself.

Now, let’s briefly consider the OLED module (Figure 2). As you can see, it has just four pins:

  • GND - negative power pin
  • VCC - positive power pin. Even though the display driver requires 3.3 - 3.6 V to operate, you can apply up to 5V to this pin, as there is the LDO voltage regulator on board.
  • SCL and SDA - clock and data pins of the I2C interface.

If you want, you can buy a bare OLED and solder it by yourself, but be aware that it requires several external capacitors and resistors.

I will provide more information about the display work when we consider the code of the library, and for now let’s consider the schematic diagram of the device.

Schematic Diagram

Starting from this tutorial, and till the end of this course we will consider two schematic diagrams - one with the regular parts, and another with the Universal board.

Schematic diagram with the regular parts is presented in Figure 4.

Figure 4 - Schematic diagram with the PIC18F14K50 with DS1307 and OLED modules
Figure 4 - Schematic diagram with the PIC18F14K50 with DS1307 and OLED modules

I made this schematic diagram based on an assumption that you will use the pre-assembled modules (like shown in fig 1, 2) and not the separate parts, but if you decide to use the bare DS1307 chip, please replace the DS1307 module according to Figure 3.

So, this schematic diagram consists of the PIC18F14K50 MCU (DD1) and two modules with DS1307 (X2) and OLED (X3). I specifically didn’t put the pin numbers of the modules as there are plenty of them and they might differ with the pinout, so please stick to the pin names, not numbers. Also, there are two push buttons - S1 for selecting the value to change and S2 for changing this value.

As you can see, SCL and SDA pins of the modules are connected together and go to pins RB6 and RB4 of the MCU, respectively which are merged with the SCL and SDA pins of the MSSP module. The SQ pin of the DS1307 module is connected to pin RB7 and is used to trigger the MCU every second to let it know that it’s time to read the new value from the RTC, and show it in the display. This approach allows us to eliminate any kind of timers to perform the delay between the display updates.

You may notice that there are no pull-up resistors on the SDA, SCL, and SQ pins. They are already populated on the corresponding module. One may ask, “Won’t there be any problems if there are pull-up resistors in both modules? Their resistance will be half that required by the I2C standard”. Well, I don’t think so. In some sources, the resistances of 4.7-10 kOhm are mentioned, so it will be just fine.

Attentive readers also may notice the unconnected pins DS and BAT on the DS1307 module. The BAT pin is used if you want to use some external backup battery instead of the mounted one. The DS pin stands for DS18B20, which as you remember is a temperature sensor. The thing is that this module is multifunctional. It has the DS1307 RTS, the I2C EEPROM, and the spot for soldering the DS18B20 sensor (see three free pins in Figure 1).

Now, let’s consider the schematics diagram based on the Universal board (Figure 5). If you don’t have one and are not going to get it, you may skip this chapter.

Figure 5 - Schematic diagram with the PIC18F14K50 with Universal board
Figure 5 - Schematic diagram with the PIC18F14K50 with Universal board

Well, this diagram looks quite big but in fact it has only the MCU DD1, the debugger X1 and the Universal board J6 and J7 (positions are taken from the board itself). Each part on the board is connected to these two 80-pin connectors, and marked correspondingly. The OLED display and the DS1307 RTC have a common I2C bus which is connected to pins “I2C SDA” and “I2C SCL”. Also we need the “RTC SQW” pin, as we already agreed. You may see that there is also the LCD RST pin (which I think would better be called OLED RST). This is the hardware reset of the OLED driver. Sometimes when some glitch happens on the I2C bus, the software reset command doesn’t normally reset the display, and it starts to output the data from a random position. In this case you can apply a short negative pulse to this Reset pin to reset the OLED to the initial state. And don’t forget to reinitialize it afterwards. Here we will not use this pin, just keep in mind that it exists and it may be very helpful sometimes. Buttons S1 and S2 are connected with the pins “S1 1” and “S2 1” to the MCU, and their opposite pins “S1 2” and “S2 2” are connected to the GND using the wires. We need to do this, as neither buttons nor LEDs have a connection to GND, you need to deal with this by yourself. Also don’t forget to connect the MCU and the debugger to the “+5V” and “GND” pins of the board so the board will be powered by the PICKit programmer.

And that’s actually everything about the device schematic diagram. It’s up to you which one to use depending on the resources you have.

Program Code Description

Let’s create a new project now. I’ve called it “OLED” but as usual you can give it any name you want. Then create the new “main.c” file in it, as we are used to doing, and copy the “config.h” file which we already used in the several last tutorials. First time we created it was in Tutorial 21, and it hasn't changed since then.

Also, we need to copy the SSD1306 files which consist the following:

  • “Adafruit_SSD1306.h” - the header file which consists of the headers of the display-related functions.
  • “Adafruit_SSD1306.c” - the source file with the implementation of the display-related functions. Here we will make some changes to work with the PIC’s MSSP module.
  • “glcdfont.c” contains the array in which there is a 5x7 pixels font generator for all the ASCII characters from 0 to 255.

These three files will be attached at the end of the tutorial. We will not consider them in detail as they are quite long, I will only highlight the functions in which we will make the changes.

Let’s create another two files “i2c_routines.h” and “i2c_routines.c”, in which we will write all the I2C-related functions. This approach has been chosen because we will need these functions in both the “main.c” and “Adafruit_SSD1306.c” files, and to prevent the doubling, it’s better to gather them in separate files.

After all, your project should look like in Figure 6.

Figure 6 - Project structure with all required files
Figure 6 - Project structure with all required files

Let’s start considering this huge amount of files with the “i2c_routines.h” and “i2c_routines.c”.

Content of the “i2c_routines.h” file:

void i2c_init (void); //Initialize the MSSP module in I2C mode

void i2c_start (void); //Issue the START condition

void i2c_stop (void); //Issue the STOP condition

void i2c_rep_start (void); //Issue the REPEATED START condition

void i2c_send_byte (uint8_t byte); //Send a byte via I2C

uint8_t i2c_receive_byte (uint8_t ack); //Receive a byte via I2C

Content of the “i2c_routines.c” file:

#include <xc.h>

#include "i2c_routines.h"

void i2c_init (void)

{

TRISBbits.RB4 = 1; //Configure pin RB4/SDA as input

TRISBbits.RB6 = 1; //Configure pin RB6/SCL as input

SSPCON1bits.SSPM = 0x08; //Master mode

SSPCON1bits.SSPEN = 1; //Pins RB4 and RB6 are SDA and SCL, respectively

SSPCON2 = 0x00; //Clear all I2C condition bits

SSPSTAT = 0x00; //Clear all status bits

SSPADD = 0x4F; //Set the I2C clock as 100 kHz

}

void i2c_start (void)

{

PIR1bits.SSPIF = 0; //Clear the interrupt flag

SSPCON2bits.SEN = 1; //Issue the start condition

while (!PIR1bits.SSPIF); //Wait for completion of the start condition

}

void i2c_stop (void)

{

PIR1bits.SSPIF = 0; //Clear the interrupt flag

SSPCON2bits.PEN = 1; //Issue the stop condition

while (!PIR1bits.SSPIF); //Wait for completion of the stop condition

}

void i2c_rep_start (void)

{

PIR1bits.SSPIF = 0; //Clear the interrupt flag

SSPCON2bits.RSEN = 1; //Send the repeated start condition

while (!PIR1bits.SSPIF); //Wait for completion of the start condition

}

void i2c_send_byte (uint8_t byte)

{

PIR1bits.SSPIF = 0; //Clear the interrupt flag

SSPBUF = byte; //Send the byte

while (!PIR1bits.SSPIF); //Wait for completion of the transmission

}

uint8_t i2c_receive_byte (uint8_t ack)

{

PIR1bits.SSPIF = 0; //Clear the interrupt flag

SSPCON2bits.RCEN = 1; //Enable the reception

while (!PIR1bits.SSPIF);//Wait for completion of the reception

if (ack) //If we need to transmit ACK

SSPCON2bits.ACKDT = 1;//Then set the ACKDT bit

else //Otherwise

SSPCON2bits.ACKDT = 0;//Clear this bit

PIR1bits.SSPIF = 0; //Clear the interrupt flag

SSPCON2bits.ACKEN = 1; //Enable sending the acknowledgement bit

while (!PIR1bits.SSPIF);//Wait for completion of the reception

return SSPBUF; //Return the content of the data register

}

As you can see, these files contain only several functions. In the “i2c_routines.h” file there are just the declarations of the functions, and in the “i2c_routines.c” file there are their bodies.

We will consider only the file “i2c_routines.c” as there is actually nothing to consider in the “i2c_routines.h” file, there are just six declarations of the functions.

So, in line 1, we include the “xc.h” file to be able to use the PIC MCU-related variables, functions, and macros. In line 2, we include its own header file “i2c_routines.h”.

In lines 4-13, there is the i2c_init function which is used to initialize the MSSP module in the I2C mode. First, we configure pins RB4 and RB6, which are merged with the SDA and SCL functions, as inputs (lines 6, 7). Then we need to configure the MSSP module itself using the registers we considered earlier. In the SSPCON1 register we need to set the bits SSPM3-SSPM0 as 0x08 (line 8) to configure the module as an I2C master, and bit SSPEN as ‘1’ (line 9) to enable the module. We will not use the multi-master functionality, so no need to worry about collision detection, thus we can leave other bits of this register as 0. Then we clear all the bits of the SSPCON2 (line 10) and SSPSTAT (line 11) register in case something is accidentally set.

Finally, we set the SSPAD register as 0x4F. As you remember, this register sets the SCL frequency in the Master mode:

SCL clock frequency = Fosc / (4 * (SPADD + 1))

In our case Fosc = 32 MHz (we’ll see it soon when we consider the “main.c” file), and the 0x4F is 79 is the decimal format, so:

SCL clock frequency = 32,000,000 / (4 * (79 + 1)) = 100,000 Hz, or 100 kHz.

This is the frequency of the I2C bus in Standard mode. We could use the Fast mode with the 400 KHz frequency but we’re not in a rush actually, as we’re not displaying some animation or something like that, so 100 kHz is more than enough.

In lines 15-20, there is the i2c_start function which is used for issuing the START condition on the I2C bus. It’s quite simple. First, we clear the MSSP interrupt flag SSPIF in the register PIR1 (line 17). Then we set the bit SEN in the SSPCON2 register to issue the START condition (line 18), and finally, wait for the interrupt flag SSPIF to be set which will happen after successfully sending off the START condition to the bus.

Functions i2c_stop (lines 22-27) and i2c_rep_start (lines 29-34) have the same structure as i2c_start. The first one issues the STOP condition by setting the PEN bit in the SSPCON2 register (line 25), and the second one issues the REPEATED START condition by setting the RSEN bit in the same register (line 32).

Function i2c_send_byte (lines 36-41) also has a similar structure. Again, we first clear the SSPIF flag (line 38), but then we load a byte of data into the SSPBUF register (line 39). Once some data is loaded into this register, it immediately is copied into the shift register and sent via the bus. When the byte is fully transmitted, the SSPBUF is set again, for which we’re waiting in line 40.

And only the i2c_receive_byte function (lines 43-56) is different. As you can see, it accepts the argument ack which is the flag that indicates whether we need to send the ACK after receiving the byte or not. The function starts in a familiar way, though, by clearing the SSPIF flag (line 45). Then we set the RCEN bit of the SSPCON2 register which enables the reception mode (line 46). After that, the module will wait while the Slave device sends some data to it. When this happens, the omnipresent bit SSPIF is set, for which we’re waiting in line 47. After that we need to decide whether we’re going to transmit the ACK or NACK. The NACK condition is sent if this received byte is the last one and we’re not expecting any new data. In other cases we should send ACK. But this is not the right function to decide this. It just accepts the argument ack and checks it (line 48). If it’s 1 then we need to send ACK, and thus we set the bit ACKDT in the SSPCON2 register (line 49). Otherwise we clear this bit (line 51) to send NACK. After that we clear the SSPIF bit again (line 52) and set the ACKEN bit of the SSPCON2 register to start transmitting the ACK/NACK bit (line 53). Then we wait while this action is completed by checking while the same SSPIF bit is set again (line 54). After that we can read the MSSP data register SSPBUF and use it as the return data of the function (line 55).

That’s actually it. Now let’s consider the SSD1306 files. As I mentioned, we will consider just the part of them which we will need to change, and which we will use in our program. But feel free to discover these files by yourself if you are curious how they work. The content of the “Adafruit_SSD1306.h” file is listed below:

#define BLACK 0

#define WHITE 1

#define SSD1306_I2C_ADDRESS 0x78 // 011110+SA0+RW - 0x78 or 0x7A

// Address for 128x32 is 0x78

// Address for 128x32 is 0x7A (default) or 0x3C (if SA0 is grounded)

// #define SSD1306_128_64

// #define SSD1306_128_32

#define SSD1306_64_32

extern int8_t ssd1306_getCursorY(void);

extern int8_t ssd1306_getCursorX(void);

extern void ssd1306_init(void);

extern void ssd1306_begin(uint8_t switchvcc/* = SSD1306_SWITCHCAPVCC*/, uint8_t i2caddr/*= SSD1306_I2C_ADDRESS*/);

extern void ssd1306_command(uint8_t c);

extern void ssd1306_data(uint8_t c);

extern void ssd1306_clearDisplay(void);

extern void ssd1306_sleep(uint8_t c);

extern void ssd1306_invertDisplay(uint8_t i);

extern void ssd1306_display(void);

extern uint8_t ssd1306_getRotation(void);

extern void

ssd1306_setCursor(int16_t x, int16_t y),

ssd1306_setTextColor(uint16_t c),

ssd1306_setTextBgColor(uint16_t c, uint16_t bg),

ssd1306_setTextSize(uint8_t s),

ssd1306_setTextWrap(uint8_t w),

ssd1306_setRotation(uint8_t r),

ssd1306_drawPixel(int16_t x, int16_t y, uint16_t color),

ssd1306_drawLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t color),

ssd1306_fillRect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color),

ssd1306_drawFastVLine(int16_t x, int16_t y, int16_t h, uint16_t color),

ssd1306_drawFastHLine(int16_t x, int16_t y, int16_t w, uint16_t color),

ssd1306_drawRect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color),

ssd1306_write(const char *c),

ssd1306_drawChar(int16_t x, int16_t y, unsigned char c, uint16_t color,uint16_t bg, uint8_t size),

ssd1306_drawBitmap(int16_t x, int16_t y, uint8_t *bitmap, int16_t w, int16_t h, uint16_t color),

ssd1306_fillScreen(uint16_t color),

ssd1306_drawCircle(int16_t x0, int16_t y0, int16_t r, uint16_t color),

ssd1306_drawCircleHelper(int16_t x0, int16_t y0, int16_t r, uint8_t cornername, uint16_t color),

ssd1306_fillCircle(int16_t x0, int16_t y0, int16_t r, uint16_t color),

ssd1306_fillCircleHelper(int16_t x0, int16_t y0, int16_t r, uint8_t cornername, int16_t delta, uint16_t color),

ssd1306_drawTriangle(int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t x2, int16_t y2, uint16_t color),

ssd1306_fillTriangle(int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t x2, int16_t y2, uint16_t color),

ssd1306_drawRoundRect(int16_t x0, int16_t y0, int16_t w, int16_t h, int16_t radius, uint16_t color),

ssd1306_fillRoundRect(int16_t x0, int16_t y0, int16_t w, int16_t h, int16_t radius, uint16_t color);

In lines 20 and 21, there are macros BLACK and WHITE which can be used as a color argument in the display functions.

In line 23, we define the I2C address of the display. It can be either 0x78 or 0x7A depending on the connection of the SA0 pin of the driver. In some OLED display modules, you can select this connection using a 0 Ohm resistor. The default address is 0x78, but if it doesn’t work, you can try to change it to 0x7A.

In lines 42-44 we must select the resolution of the display, and only one. If you select none of them, or more than one, the compiler will throw an error. In our case we have the 64 x 32 resolution so we select the SSD1306_64_32 parameter. If you have another LCD, please select your resolution, as the initialization of the display differs for different resolutions.

We will skip the definitions of the display macros and variables and consider the functions that you can use to draw something on the display (lines 127-166).

  • ssd1306_getCursorY and ssd1306_getCursorX return the Y and X coordinates of the current “cursor” position on the display.
  • ssd1306_init should be used prior to all other display functions, as it initializes all the internal variables of the library.
  • ssd1306_begin should follow the previous function. Here the proper initialization of the display driver, depending on the resolution, happens.
  • ssd1306_command and ssd1306_data are used to send the command and the data to the display, respectively. We will make changes exactly in these functions as they provide the low-level communication between the MCU and the display driver.
  • ssd1306_clearDisplay is used to clear the display buffer.
  • ssd1306_sleep is used to switch the display mode between sleep (c = 1) and normal (c = 0) modes.
  • ssd1306_invertDisplay inverts (i = 1) or returns to normal (i = 0) the view on the display.
  • ssd1306_display is another function in which we will make the changes. Actually, all the drawings implemented with the functions listed below will happen only in the display buffer, which is located in the RAM of the MCU, and you will not see any changes in the display until you invoke this function.
  • ssd1306_getRotation returns the rotation of the display view: 0 - 0°, 1 - 90°, 2 - 180°, 3 - 270°.
  • ssd1306_setCursor - sets the coordinates x and y of the “cursor”.These values should be within the resolution of the display starting from 0,0 which is the top left corner.
  • ssd1306_setTextColor - sets the pen color c as WHITE or BLACK. The last option is used if you want to draw something on the filled area. Please note that this function sets the color of the pen for all drawings, not only a text.
  • ssd1306_setTextBgColor - sets the pen (c) and the background (bg) color. This will affect only the elements drawn after implementation of this function, the same as for the previous function.
  • ssd1306_setTextSize - sets the size of the text. The s argument is the multiplier of the text size. If it’s 1 then the text size is 5 x 7 pixels, if it’s 2 then the size is 10 x 14 pixels, and so on.
  • ssd1306_setTextWrap - allows to wrap the text and continue writing from the next line (w = 1) or disables this feature (w = 0). Please note, that it’s the text wrap, not word wrap, so it doesn’t distinguish the characters.
  • ssd1306_setRotation - sets the rotation of the display (r = 0 - 0°, r = 1 - 90°, r = 2 - 180°, r =3 - 270°).
  • ssd1306_drawPixel - draws a single pixel at coordinates x,y with the specified color (WHITE or BLACK).
  • ssd1306_drawLine - draws a line which starts with the coordinates x0,y0 and ends with the coordinates x1,y1 with the specified color.
  • ssd1306_fillRect - draws the rectangle starting with the coordinates x,y with the height h and width w with the specified color and fills it with the same color.
  • ssd1306_drawFastVLine - draws a vertical line starting with the coordinates x,y with the length h with the specified color.
  • ssd1306_drawFastHLine - draws a horizontal line starting with the coordinates x,y with the length w with the specified color.
  • ssd1306_drawRect - draws a rectangle starting with the coordinates x,y with the height h and width w with the specified color.
  • ssd1306_write - writes some string c starting with the current coordinates.
  • ssd1306_drawChar - writes the single character c at the coordinates x,y with the specified pen color color, background color bg, and the text multiplier size.
  • ssd1306_drawBitmap - draws a bitmap buffer bitmap starting with the coordinates x,y with the width w, height h, and the specified color.
  • ssd1306_fillScreen - fills the whole display with the specified color.
  • ssd1306_drawCircle - draws the circle with the center at coordinates x0,y0, radius r, and the specified color.
  • ssd1306_drawCircleHelper - draws the arch of 90 - 360 degrees with the center at coordinates x0,y0, radius r, and the specified color. This is the helper function used in the ssd1306_drawRoundRect described below.
  • ssd1306_fillCircle - draws the circle with the center at coordinates x0,y0, radius r, and the specified color and fills it with the same color.
  • ssd1306_fillCircleHelper - draws an arch of 90 - 360 degrees with the center at coordinates x0,y0, radius r, and the specified color and fills it with the same color. This is the helper function used in the ssd1306_fillRoundRect described below.
  • ssd1306_drawTriangle - draws a triangle with the summits at coordinates x0,y0; x1,y1, and x2,y2 with the specified color.
  • ssd1306_fillTriangle - draws a triangle with the summits at coordinates x0,y0; x1,y1, and x2,y2 with the specified color and fills it with the same color.
  • ssd1306_drawRoundRect - draws a rectangle starting with the coordinates x0,y0 with the height h and width w, and rounded corners with the radius radius with the specified color.
  • ssd1306_fillRoundRect - draws a rectangle starting with the coordinates x0,y0 with the height h and width w, and rounded corners with the radius radius with the specified color and fills it with the same color.

This looks like more than enough to draw anything you want on the monochrome display. Again, all these functions draw not in the display directly, but in the buffer that is located in the MCU’s RAM, and to flush this buffer to the display, you need to use the ssd1306_display function.

Let’s now consider the “Adafruit_SSD1306.c” file:

#include <xc.h>

#include "config.h"

#include "Adafruit_SSD1306.h"

#include "i2c_routines.h"

void ssd1306_command (uint8_t c)

{

uint8_t control = 0x00; // Co = 0, D/C = 0

i2c_start(); //Issue the start condition

i2c_send_byte(ssd1306_i2caddr);//Send the I2C address

i2c_send_byte(control); //Send the control byte

i2c_send_byte(c); //Send the data byte

i2c_stop(); //Issue the stop condition

}

void ssd1306_data (uint8_t c)

{

uint8_t control = 0x40; // Co = 0, D/C = 1

i2c_start(); //Issue the start condition

i2c_send_byte(ssd1306_i2caddr);//Send the I2C address

i2c_send_byte(control); //Send the control byte

i2c_send_byte(c); //Send the data byte

i2c_stop(); //Issue the stop condition

}

void ssd1306_begin(uint8_t vccstate, uint8_t i2caddr)

{

ssd1306_i2caddr = i2caddr;

__delay_ms (100); //Wait some time to stabilize LCD voltage regulator

void ssd1306_display(void) {

uint16_t MAX;

#ifndef SSD1306_64_32

ssd1306_command(SSD1306_SETLOWCOLUMN | 0x0); // low col = 0

ssd1306_command(SSD1306_SETHIGHCOLUMN | 0x0); // hi col = 0

ssd1306_command(SSD1306_SETSTARTLINE | 0x0); // line #0

MAX = SSD1306_LCDWIDTH*SSD1306_LCDHEIGHT/8;

for (uint16_t i = 0; i < MAX; i += 16) {

i2c_start(); //Issue the start condition

i2c_send_byte(ssd1306_i2caddr); //Send the I2C address

i2c_send_byte(0x40); //Send the control byte

for (uint8_t x = 0; x < 16; x ++) {

i2c_send_byte(ssd1306_buffer[i]); //Send the data byte

i++;

}

i--;

i2c_stop(); //Issue the stop condition

}

# else //SD1306_64_32

MAX = SSD1306_LCDWIDTH*SSD1306_LCDHEIGHT/8;

for (uint16_t i = 0; i < MAX; i ++) {

i2c_start(); //Issue the start condition

i2c_send_byte(ssd1306_i2caddr); //Send the I2C address

i2c_send_byte(0x40); //Send the control byte

for (uint8_t x = 0; x < 64; x ++) {

i2c_send_byte(ssd1306_buffer[i]); //Send the data byte

i++;

}

i--;

i2c_stop(); //Issue the stop condition

}

#endif

}

Functions ssd1306_command (lines 41-49) and ssd1306_data (line 51-59) are actually the same except for the control byte which is sent prior to the command/data byte c. For commands, this byte is 0x00 (line 43) and for data, it is 0x40 (line 53). Moreover, the ssd1306_data function is never used in this library, and I believe it’s left as a legacy or in case you want to send some data to the display without the display buffer. So we will consider only the ssd1306_command function. It’s actually quite simple.

First, we issue the start condition (line 44) then send the device address (line 45). The PIC18F14K50 MCU doesn’t have separate commands to send the address and the data, so we use the same i2c_send_byte function for this as well. Then we send the control byte (line 46) followed by the command byte c we want to send (line 47). After that, we issue the stop condition (line 48). As you can see, we don’t check if the display is actually present on the bus or not, we just send the data, assuming it’s all right.

In the function ssd1306_begin we add the 100 ms delay (line 64) to wait while the display’s voltage regulator stabilizes. If we start initialization right after power-up, the driver may not be ready yet, and the display will not be initialized properly.

In lines 424-459, there is the ssd1306_display function, which is responsible for sending the display buffer from the RAM memory of the MCU into the display itself. The buffer is organized as a set of bytes, each bit of which represents the state of some pixel on the display. If this bit is 1, then the pixel is turned on, otherwise it’s turned off. These bytes are sent to the display row-wise. So you first fill the first eight rows (each byte holds the information about the eight bits), then you fill the next eight rows and so on and so forth. As the resolution of our display is 64 x 32 pixels we will need to send 64 x (32 / 8) = 256 bytes in total. Actually, this value is assigned to the MAX variable (defined in line 425 and assigned in lines 431 and 466).

I will tell you honestly, I don’t know why exactly this function is implemented in this way, I just took it from the source like this. According to the data sheet of the SSD1306 driver it should’ve been implemented in a simpler way, but probably there were some reasons to write it exactly like this. Or maybe the first developer just wrote it and thought “OK, this works, so let’s leave it”

So, as you can see, this function is split into two parts. First one (lines 426 - 444) is dedicated to the displays with the 128 x 64 and 128 x 32 resolutions, and the second one (lines 445-458) works with the resolution of 64 x 32. The parts are distinguished by the #ifndef, #else, #endif directives of the compiler which we already used in the PIC10F200 tutorials. They allow us to perform a so-called conditional compilation. #ifndef means “if not defined”, so this part of the code will be implemented if the following macro is not defined (in our case it’s SSD1306_64_32). But we have defined exactly this macro in line 44 of the “Adafruit_SSD1306.h” file, so lines 426-444 will be ignored, and the part between the #else and the #endif directives will be compiled. So let’s consider it as the upper part is quite similar, there are just some extra commands before sending the data (lines 427-429) and the data is sent with the smaller chunks.

So, in line 446 we assign the value SSD1306_LCDWIDTH*SSD1306_LCDHEIGHT/8 to the MAX variable for the reasons I’ve already mentioned. In the next line we start the loop to send all MAX bytes to the display. Inside this loop (lines 448-456) we do the following: issue the START condition on the I2C bus (line 448), send the address of the SSD1306 driver (line 449), send the byte 0x40 (line 450) to indicate that the following bytes should be treated as display data, not commands. Then we send the chunk of the 64 bytes (the whole row) to the display (lines 451-454). In line 455 we need to decrement the i variable to compensate for its increment by the for loop in line 447. Actually we could just write this loop as:

for (uint16_t i = 0; i < MAX; )

because we increment the i variable in line 453 anyway, and in this case line 455 could be eliminated. Finally, in line 456, we issue the STOP condition after which we will send another chunk of 64 bytes until all data is sent. Again, I don’t know why it’s done like this, there should not be any obstacles to send the whole display buffer at once. You can try this by yourself as homework, by the way.

The “glcdfont.c” only consists of the constant array font, which is used to generate the ASCII characters to write on display. Actually there is nothing to consider there in detail, so let’s just skip it.

And now let’s finally consider the “main.c” file in which the main logic of our program is implemented.

#include <xc.h>

#include "config.h"

#include "Adafruit_SSD1306.h"

#include "i2c_routines.h"

#include <stdio.h>

#define DS1307_ADDR 0xD0 //I2C address of the DS1307 chip

uint8_t clock_data[7]; //Array to read the data from the RTC

uint8_t update_clock_flag; //Indicates that we need to update the time

uint8_t mode; //Clock mode: 0 - normal, 1 - set hour, 2 - set minute, 3 - set day, 4 - set month, 5 - set year

void clock_write (uint8_t reg, uint8_t data)

{

i2c_start(); //Issue the start condition

i2c_send_byte(DS1307_ADDR);//Send the I2C address

i2c_send_byte(reg); //Send the register address

i2c_send_byte(data); //Send the data byte

i2c_stop(); //Issue the stop condition

}

void clock_read (uint8_t *data)

{

i2c_start(); //Issue the start condition

i2c_send_byte(DS1307_ADDR);//Send the I2C address

i2c_send_byte(0x00); //Send the register address

i2c_rep_start(); //Send the repeated start condition

i2c_send_byte(DS1307_ADDR + 1);//Send the I2C address for reading

for (uint8_t i = 0; i < 7; i ++)

{

data[i] = i2c_receive_byte(i < 6 ? 0 : 1); //read the RTC register

}

i2c_stop(); //Issue the stop condition

}

void write_date_time (void)

{

char s[11];

ssd1306_clearDisplay(); //Clear the display buffer

ssd1306_setCursor(8,0); //Set cursor to the first line

sprintf(s,"%02x:%02x:%02x",clock_data[2], clock_data[1], clock_data[0] & 0x7F); //Form the time string

ssd1306_write(s); //Write the string to the OLED

ssd1306_setCursor(2,10); //Set cursor to the second line

sprintf(s,"%02x.%02x.20%02x",clock_data[4], clock_data[5], clock_data[6]); //Form the date string

ssd1306_write(s); //Write the string to the OLED

switch (mode) //Check the clock mode

{

case 1: ssd1306_drawFastHLine(8, 8, 12, WHITE); break; //If "set hour" then draw the line under the hour

case 2: ssd1306_drawFastHLine(26, 8, 12, WHITE); break;//If "set minute" then draw the line under the minute

case 3: ssd1306_drawFastHLine(2, 18, 12, WHITE); break;//If "set day" then draw the line under the day

case 4: ssd1306_drawFastHLine(20, 18, 12, WHITE); break;//If "set month" then draw the line under the month

case 5: ssd1306_drawFastHLine(38, 18, 24, WHITE); break;//If "set year" then draw the line under the year

}

ssd1306_display(); //Send the display buffer to the OLED

}

void __interrupt() INTERRUPT_InterruptManager (void) //Interrupt subroutine

{

if((INTCONbits.RABIE == 1) && (INTCONbits.RABIF == 1)) //If pin change interrupt is enabled and pin change interrupt flag is set

{

if(IOCBbits.IOCB7 == 1) //If pin RB7 change interrupt is enabled

{

update_clock_flag = 1; //Set the update_clock_flag variable

}

INTCONbits.RABIF = 0; // Clear global Interrupt-On-Change flag

}

}

void main(void) //Main function of the program

{

//GPIO configure

TRISAbits.RA4 = 1; //Configure RA4 pin as input

TRISAbits.RA5 = 1; //Configure RA5 pin as input

TRISBbits.RB7 = 1; //Configure RB7 pin as input

ANSELbits.ANS3 = 0; //Disable analog buffer at the pin RA4

WPUAbits.WPUA4 = 1; //Enable pull-up resistor at the pin RA4

WPUAbits.WPUA5 = 1; //Enable pull-up resistor at the pin RA5

INTCON2bits.nRABPU = 0; //Enable pull-up resistors at ports A and B

IOCBbits.IOCB7 = 1; //Enable interrupt on RB7 pin

//Oscillator module configuration

OSCCONbits.IRCF = 6; //Set CPU frequency as 8 MHz

OSCTUNEbits.SPLLEN = 1; //Enable PLL

//Interrupts configuration

RCONbits.IPEN = 0; //Disable priority level on interrupts

INTCONbits.RABIF = 0; //Clear pin change interrupt flag

INTCONbits.RABIE = 1; //Enable pin change interrupt

INTCONbits.GIE = 1; //Enable all unmasked interrupts

i2c_init(); //Initialize the MSSP module in I2C Master mode

ssd1306_init(); //Initialize the display variables

ssd1306_begin(SSD1306_SWITCHCAPVCC, SSD1306_I2C_ADDRESS); //Initialize the display itself

ssd1306_setTextColor(WHITE); //Set text color visible

ssd1306_sleep(0); //Wake up the display

ssd1306_setTextSize(1); //Set text size

clock_write(0x00, 0x00); //Start the clock oscillator

clock_write(0x07, 0x10); //Enable the SWQ output with 1 second period

mode = 0; //Normal mode

while (1) //Main loop of the program

{

if (update_clock_flag) //If we received the signal from the SQW output

{

update_clock_flag = 0; //Reset the flag

clock_read(clock_data); //Read the clock data

write_date_time(); //And update the LCD

}

if (PORTAbits.RA5 == 0) //If the Mode button is pressed

{

__delay_ms(20); //Then perform the debounce delay

if (PORTAbits.RA5 == 0) //If after the delay button is still pressed

{

while (PORTAbits.RA5 == 0); //Then wait while button is pressed

__delay_ms(20); //After button has been released, perform another delay

if (PORTAbits.RA5 == 1) //If the button is released after the delay

{

mode ++; //Increment the mode variable

if (mode > 5) //If it's greater than 5

{

mode = 0; //Then reset it,

for (uint8_t i = 1; i < 7; i ++)//And write the new date and time into RTC

clock_write(i, clock_data[i]);

clock_write(0x00, 0x00); //start the clock oscillator

}

if (mode > 0) //If mode is not normal

{

clock_write(0x00, 0x80); //Stop the clock oscillator

}

write_date_time(); //Update the display with the new values

}

}

}

if (PORTAbits.RA4 == 0) //If the Set button is pressed

{

__delay_ms(20); //Then perform the debounce delay

if (PORTAbits.RA4 == 0) //If after the delay button is still pressed

{

while (PORTAbits.RA4 == 0); //Then wait while button is pressed

__delay_ms(20); //After button has been released, perform another delay

if (PORTAbits.RA4 == 1) //If the button is released after the delay

{

switch (mode) //Check the mode

{

case 1: //"Set hour" mode

clock_data[2]++; //Increment the hours value

if ((clock_data[2] & 0x0F) == 0x0A) //If the last character becomes 0x0A

clock_data[2] += 6; //Then we add 6 to the hours value as it is in the BCD format

if (clock_data[2] >= 0x24) //If the hours value is greater or equal than 24

clock_data[2] = 0; //Then set it as 0

break;

case 2: //"Set minute" mode

clock_data[1]++; //Increment the minutes value

if ((clock_data[1] & 0x0F) == 0x0A) //If the last character becomes 0x0A

clock_data[1] += 6; //Then we add 6 to the minutes value as it is in the BCD format

if (clock_data[1] >= 0x60) //If the minutes value is greater or equal than 60

clock_data[1] = 0; //Then set it as 0

break;

case 3: //"Set day" mode

clock_data[4]++; //Increment the days value

if ((clock_data[4] & 0x0F) == 0x0A) //If the last character becomes 0x0A

clock_data[4] += 6; //Then we add 6 to the days value as it is in the BCD format

if (clock_data[4] >= 0x32) //If the days value is greater or equal than 32

clock_data[4] = 1; //Then set it as 1

break;

case 4: //"Set month" mode

clock_data[5]++; //Increment the months value

if ((clock_data[5] & 0x0F) == 0x0A) //If the last character becomes 0x0A

clock_data[5] += 6; //Then we add 6 to the months value as it is in the BCD format

if (clock_data[5] >= 0x13) //If the months value is greater or equal than 13

clock_data[5] = 1; //Then set it as 1

break;

case 5: //"Set year" mode

clock_data[6]++; //Increment the years value

if ((clock_data[6] & 0x0F) == 0x0A) //If the last character becomes 0x0A

clock_data[6] += 6; //Then we add 6 to the years value as it is in the BCD format

if (clock_data[6] >= 0x99) //If the years value is greater or equal than 99

clock_data[6] = 0; //Then set it as 0

break;

}

write_date_time(); //Update the display with the new values

}

}

}

}

}

The programs become larger and larger but what can we do? We grow up and don’t fit into 100 lines anymore.

In line 1, we, as usual, include the “xc.h” file to use the PIC MCU-related variables, functions, and macros. In line 2, we include the “config.h” file, in which the configuration bits are defined. In line 3, we include the “Adafruit_SSD1306.h” file, which consists of the display-related functions we have considered above. In line 4, we include the “i2c_routines.h” file which we also already considered. And in line 5, we include the “stdio.h” file which consists of the standard input-output functions among which we will use sprintf to form the string to send to the display.

in line 7, we define the macro DS1307_ADDR and assign the value 0xD0 to it. This is the I2C address of the DS1307 RTC chip.

In lines 9-11 we declare some global variables:

  • clock_data is the array of seven elements to read the content of the registers 0x00 - 0x06 of the DS1307 chip which represent the time and date from second to year (see Table 1).

  • update_clock_flag is the flag that indicates that the time and date needs to be updated. This flag is set by the SQW signal from the DS1307 RTC.

  • mode is the variable that sets the clock operation mode. If it is 0, it’s a normal mode when the time is read from RTC and shown on the OLED display. Modes 1 to 5 represent the setting of some time or date parameter. We will talk about them later.

As we already got used to, let’s first skip the user’s functions and consider the main function of the program which is located in lines 69-191.

The initialization part occupies lines 71-102 and consists of the familiar register configurations, so we’ll look it through quite briefly.

In lines 72-74, we configure pins RA4 (S2), RA5 (S1), and RB7 (SQW) as inputs (see Figure 4, 5). In line 75, we disable the analog buffer on pin RA4. In lines 76, 77, we enable the pull-up resistors on the RA4 and RA5 pins. We don’t need one on pin RB7 as there is already an external pull-up resistor connected to the SQW pin of the DS1307 RTC. In line 78, we allow the pull-up resistors on the RA and RB ports. In line 79, we enable the port change interrupt on pin RB7, so when the pulse from the RTC comes the interrupt will be generated. We considered these interrupts in detail in the previous tutorial.

In lines 82, 83, we configure the oscillator to work with the 32 MHz frequency.

In lines 86-89, we configure the RA and RB port change interrupt: disable the interrupts priority level (line 86 may be omitted, as there is only one interrupt in our program), clear the interrupt flag (line 87), enable the port change interrupt (line 88) and enable all unmasked interrupts (line 89).

In line 91, we invoke the i2c_init function which we have created in the “i2c_routines.c” file (lines 4-13).

In lines 93-97, we initialize the OLED display. I recommend using this exact sequence in your further project related to this display.

First, we need to invoke the ssd1306_init function (line 93) to initialize the internal variables of the library. Second, we invoke the ssd1306_begin function (line 94) which has two arguments: SSD1306_SWITCHCAPVCC which sets the voltage source of the OLED matrix as the internally generated one by the SSD1306 driver, and SSD1306_I2C_ADDRESS which is the I2C address of the display. The function ssd1306_setTextColor with the argument WHITE (line 95) sets the text color as white (or whatever color the display has). Without it all the text and other drawing primitives will remain invisible. Invocation of the ssd1306_sleep function with the parameter ‘0’ (line 96) wakes up the display. If you want to turn it off to save the power, just call this function with the parameter ‘1’. Finally, the ssd1306_setTextSize function (line 97) sets the minimal text size of 6 x 8 pixels. The larger size will make the text more visible but unfortunately, we will not fit all the information in the display in this case.

In lines 99, 100, we initialize the RTC with the function clock_write which we will discover later. This function has two arguments - the register address according to Table 1, and the value to write into it. So we first write 0x00 into the 0x00 (Seconds) register to clear the CH bit and start the RTC. And then we write 0x10 into the 0x07 (Control) register to set the bit OUT and clear all other bits. This will lead to forming the 1 Hz pulses on the SQW pin of the RTC (see Tables 1 and 2).

In line 102, we clear the mode variable to start operation in the normal mode. And with this, the initialization part is over.

In lines 104-190, there is the main loop of the program, which is long but consists of some similar blocks.

First, we check if the update_clock_flag variable is set (line 106). It becomes 1 when the SQW signal changes its state and the RA and RB port change interrupt is triggered. This interrupt is processed in lines 57-67, and we will consider it later. As the frequency of the SQW signal is 1 Hz, this will happen twice a second.

So, when the update_clock_flag variable is set, we first clear it (line 108), then read the registers 0x00-0x06 from the RTC using the function clock_read (line 109) and send the new date and time to the display with the function write_date_time (line 110). Both these functions will be considered later.

In lines 113-137 there is a standard blocking processing of the S1 button press. We considered it several times in previous tutorials, so we will consider only the payload of it (lines 122-134). This function changes the mode of operation of the clock in a circle allowing us to update, consequently, hours, minutes, days, months and years, and return to the normal mode. So, first it increments the mode variable (line 122) which can take the values from 0 to 5. If, after incrementing, it becomes greater than 5 (line 123) we assign 0 to it (line 125) and return to the normal operation mode. To do this, we write the new values into the Minutes-Year register (lines 126, 127) and start the RTC counting again by writing 0 into the Seconds register (line 128).

If the operation mode differs from the normal (mode > 0, line130) then we stop the oscillation of the RTC by setting the bit CH in the Seconds register (line 132) to prevent the unintended change of the date and time by the clock itself.

In line 134 we invoke the write_date_time function to update the display, as each mode changing highlights the value that is currently being changed.

In lines 139-190 we process the S2 button. The payload of this part is located at lines 148-186. This button is used to increment the time or date value (hour, minute, day, month, year) which is selected by the S1 button. Even though this part is quite long, it consists of very similar code chunks. Let’s consider incrementing the hours (lines 150-156). Changing the hours value corresponds to mode = 1 which we check in line 150.

The clock_data array consists of the time and date values, and the number of the element of the array corresponds to the number of the register of the DS1307 RTC (see Table 1). As we want to change the hours value, we need to operate with the second element of the clock_data array. So, in line 151 we increment it. As the data stored in the array is in the BCD (binary-coded decimal) format, we need to do some tricks to increment it correctly. If you don’t know anything about the BCD format, I recommend you to get acquainted with it here.

I will briefly remind you what it is if you don’t want to read another big tutorial. So in BCD format, unlike the regular binary format, each nibble (four bits) of the byte represents the decimal digit from 0 to 9. Even though each nibble can have the maximum value of 15, the upper six bit combinations are not used which makes this format less compact than binary. But what’s its advantage? It simplifies splitting the number into digits. If you remember, if we want to split a regular 2-digit number, we need to divide it by 10 to get the number of tens, and take a modulo of division by 10 to get the number of ones. Even though in C this operation is quite simple, in fact the PIC MCU doesn’t have an instruction to implement the division, so it will take a while for it to calculate the value. In the case of BCD format we just need to implement the AND operation between the value and the 0xF0 mask to get the number of tens, and the same AND operation between the value and the 0x0F mask to get the number of ones which, from the MCU’s perspective, is much simpler.

The BCD format is stored as a hexadecimal number that looks like a decimal number which we want to display. For example, if we want to have the number 25 in BCD format, it will be stored as 0x25. So this lays the problem when we increment the number, which ends with 9. As it is stored in the hexadecimal format, after incrementing, it will become 0xA which corresponds to the decimal 10. And we need to have the value of 0x10. So if the number ends with 9 after its incremented, we need to add another 0x10 - 0x0A = 6. This is what we do in lines 152-153. In line 152, we check if, after the increment, we get the number that ends with 0x0A. If it is so, then we add the number 6 to the clock_data value (line 153) to get the correct BCD number.

In line 153, we check if the hour's value is greater or equal than 24 (see, it’s still in BCD format, like 0x24). If it is so, we assign the 0 to the hours (line 154).

All other parameters are treated in the same way: minutes (lines 157-163), days (lines 164-170), months (lines 171-177), and years (lines 178-184). The only differences are the maximum values and the values to which these parameters are reset (for example, days and months start with 1, not with 0).

After any change of any parameter, we update the display with the function write_date_time (line 186). As the clock is stopped when we set it up, the SQW signal is not generated either, so update of the display is implemented only here.

And that’s everything about the main function. Let’s now return to line 13 and consider the rest of functions.

The clock_write function (lines 13-20), as it was mentioned before, is used to send the byte data into the register reg of the DS1307 RTC chip. It is very similar to the functions ssd1306_data and ssd1306_command described above. We first issue the START condition (line 15), then send the DS1307 I2C address (line 16) followed by the register address (line 17) and the data to be written into this register (line 18). When everything is being sent, we issue the STOP condition (line 19) to release the line.

The clock_read function (lines 22-34), as follows from its name, is used to read the data from the DS1307 RTC. This function, unlike the previous one, reads all the registers from 0x00 to 0x06 and stores them into the data array. We didn’t implement the reading from the I2C device yet, so let’s consider how it’s done. First, we, as usual, issue the START condition (line 24) followed by the device address (line 25). Then we send the number of the register from which we want to start the reading. In our case it’s the 0x00 (line 26). Now we need to switch to reading mode. To do this, we issue the REPEATED START condition (line 27) instead of the STOP condition not to lose the control on the bus. Then we send the device address with the logical ‘1’ in the last bit (line 28). As you remember, this bit indicates the direction of the data on the bus. If it’s 0 then the master is going to send the data, (like we did in all previous functions) but if it’s 1, then the master is going to receive some data from the specified device.

In lines 29-32 there is a loop to read seven bytes from the RTC. The address of the register from which the data will be read is incremented automatically, so we don’t need to specify it every time. The function i2c_receive_byte used to receive the data from the I2C bus has the parameter ack which makes the master send either ACK (when it’s 0) or NACK (when it’s 1) after receiving the byte from the device. The NACK should be sent after the last received byte to let the slave device know that we’re not going to receive anything else. In all other cases the ACK should be sent. To implement this logic, we use the conditional operator

i < 6 ? 0 : 1

which means that if i < 6, the parameter is 0, and otherwise, it’s 1.

When all the data is received, we issue the stop condition (line 33).

The write_date_time function (lines 36-55) is responsible for sending the date and time values to the OLED display.

In line 38, we declare the s array to form the time and date strings to send to the display.

In line 39, we clear the display then set the cursor on the position [8, 0] (line 40). In this line of the display we will show the time in format hh:mm:ss. As you can see, this string consists of eight characters. As each character spends 6 pixels and the display width is 64 pixels, to put the text in the middle of it, we need to make an offset for (64 - 6 x 8) / 2 = 8 pixels, which we do in line 40.

Now, some notes about forming the string (line 41). We use the standard C function sprintf for this. Its first argument is the pointer to the char array into which the string will be stored. The second argument is the string format. In our case it’s

"%02x:%02x:%02x"

The % character means that at this place will be inserted some value (number, character or string). The type of the parameter is set by the letter after it. In our case letter ‘x’ means hexadecimal number (I hope you still remember that we deal with the numbers in BCD format). The digits between ‘%’ and ‘x’ are the format modifiers. Number ‘2’ means that we want the number to always spend 2 positions, and the preceding number ‘0’ means that if the number is smaller than 10, we need to fill the upper positions with zeros. Let me show you some examples of how these modifiers work.

sprintf format

Formed string

“%02x:%02x:%02x”

10:05:03

“%2x:%2x:%2x”

10: 5: 3

“%x:%x:%x”

10:5:3

As you can see, the first option suits the best for displaying the time.

The rest of the parameters are the values that should be put instead of the ‘%x’ in the string format, and they should be written in the same order, and their amount should match the amount of ‘%’. We write clock_data[2] for hours, clock_data[1] for minutes and clock_data[0] for seconds. Please note that for the seconds value, we implement the AND operation between the clock_data[0] and the 0x7F. This is because bit #7 of the Seconds register is CH and can be set as 1 (see Table 1).

In line 42, we put the string s into the display buffer with the ssd1306_write function.

In line 43, we set the cursor on the position [2, 10]. In this line, we will output the date in the format DD.MM.YYYY, which occupies 10 characters, so the offset should be (64 - 10 x 6) / 2 = 2. The vertical offset is 10, even though the character width is 8 pixels. But we are going to use the underscore to indicate that some parameter is being changed now (when the mode is greater than 0), so we need some room for it.

In line 44, we form another string with the format

"%02x.%02x.20%02x"

Here, the ‘20’ number between the last ‘%02x’ is just ‘20’, and represents the first two digits of the year.

In line 45, we put the updated s string into the display buffer.

In lines 46-53 we draw the horizontal lines under the value which we are changing at the moment using the ssd1306_drawFastHLine function. For example, if the mode is 1, this means that we change the Hours value, so we draw the line underneath it (line 48), and so on.

When all the drawings are done, we invoke the ssd1306_display function to flush the display buffer into the display itself.

In lines 57-67, there is the interrupt subroutine which is very simple this time.

We first check if the RA and RB port change interrupt is enabled and if the corresponding flag is set (line 59). Then we check if the interrupt for the RB7 pin is unmasked (line 61), and if it’s so, we set the update_clock_flag variable as ‘1’. This variable is processed in lines 106-111 of the main loop of the program. Finally, we clear the interrupt flag, and that is it.

And with this we are finally done with the program code. This time it was really long, considering the number of files we had to describe.

Testing of the Real Time Clock (RTC)

Let’s now assemble the device according to Figure 4 or Figure 5, compile and build the project and flash it into the PIC18F14K50 MCU. If everything is assembled and programmed correctly, you should see the following text on your display (Figure 7).

Figure 7 - Time and date on the OLED display
Figure 7 - Time and date on the OLED display

My display is a bit fried, as you can see. This happened when I soldered one wrong capacitor in the charge-pump circuit of the display driver. The lower part of the matrix is now burned but the upper one still works well. And this is a warning to those who decide to assemble the Universal board themselves - be careful with the parts you solder.

When you press the S1 button, the underscore appears under the hour. The next pressing moves the underscore to the minutes. Pressing the S2 button will change the highlighted parameter (Figure 8).

Figure 8 - Changing the hours
Figure 8 - Changing the hours

Holding the S1 and S2 buttons, you can set the current date and time. After you press the S1 button after setting the year, the clock will return to the normal state, and the seconds will start to change automatically.

And that’s finally all! In this tutorial, we have learnt the MSSP module of the PIC18F14K50 MCU in I2C mode. We have connected the OLED display and the RTC to the MCU using the I2C bus and implemented a simple clock and calendar.

As homework, I suggest you improve the setting of the clock so that while the S2 button is pressed, the corresponding value changes 3 times per second.

Here’s the project file for this tutorial:

Make Bread with our CircuitBread Toaster!

Get the latest tools and tutorials, fresh from the toaster.

What are you looking for?