Real-life Implementation of Temperature Control with PID Using MCC | Embedded C Programming - Part 23
Published
Hello again! In this part of the tutorial, we are finishing up with the PID control system. As you can see, this part has another title starting with “MCC Based.” So today, we will learn how to make the program code of the PID control system using MCC. For those who don’t follow the non-MCC tutorials, I need to explain a bit about what’s going on here. In tutorial 20, I discussed creating a temperature control system using the PID controller. This tutorial has been divided into three parts: the first one is about the hardware and theoretical background, the second one is about the program code without using MCC, and the third one is about the practical work with the control system. The first and the third parts are common for both MCC and non-MCC tutorials, and the current part replaces the second part. So, if you are following only the MCC-based tutorials, first familiarize yourself with the first part if you haven’t done that yet, and then keep reading this tutorial. After finishing it, continue with the third part.
Hopefully, we are clear now, so let’s start programming the control system using MCC.
Adding the temperature sensor and LCD source files
This project will use separate files for 1-wire and LCD functions. Before configuring the project using the MCC, let’s additionally create two “c” files and two “h” files (see tutorial 15 if you don’t know how to do this), and call them “ds18b20_mcc.c”, “lcd_1602_mcc.c”, Now the project should look like in Figure 17.

In Tutorial 13, we already used the DS18B20 temperature sensor, we wrote the whole code in the “main.c” file. This time, we will separate the 1-wire and temperature sensor functions into dedicated files.
Let’s start with the header file “ds18b20_mcc.h”:
#include <xc.h>
void ow_configure (void); //Configure the 1-wire pin
uint8_t ow_reset(void); //Reset the 1-wire bus and wait for the presence pulse
void ow_write_bit (uint8_t bit); //Write one bit to the 1-wire bus
uint8_t ow_read_bit (void); //Read one bit from the 1-wire bus
void ow_write_byte (uint8_t byte); //Write one byte to the 1-wire bus
uint8_t ow_read_byte (void); //Read one byte from the 1-wire bus
void convert_t (void); //Start temperature conversion
int16_t read_temp (void); //Read the temperature value in 0.1 C
In line 1, we include the “xc.h” file to use the standard integer types and the MCU registers and bits names. In lines 3-10, there are function definitions. All functions in this file were already described in detail in tutorial 13.
Let’s now consider the corresponding source file “ds18b20_mcc.c” which is also mainly just copy-paste from Tutorial 13:
#include "mcc_generated_files/mcc.h"
#include "ds18b20_mcc.h"
//=============1-wire functions======================
//---------1-wire bus reset-------------------------------
uint8_t ow_reset(void)
{
uint8_t presence; //Presence flag
OW_BUS_SetDigitalOutput();//Configure OW_BUS as output (set bus low)
__delay_us(480); //Set bus low for 480 us
OW_BUS_SetDigitalInput();//Configure OW_BUS as input (set bus high)
__delay_us(70); //Waiting for the presence signal for 70us
if (OW_BUS_GetValue() == 0) //If bus is low then the device is present
presence = 1; //Set presence flag high
else //Otherwise
presence = 0; //Set presence flag low
__delay_us(410); //And wait for 410 us
return (presence); //Return the presence flag
}
//---------Writing one bit to the device------------------------
void ow_write_bit (uint8_t bit)
{
OW_BUS_SetDigitalOutput();//Configure OW_BUS as output (set bus low)
if (bit) //If we send 1
{
__delay_us(6); //Perform 6 us delay
OW_BUS_SetDigitalInput();//Configure OW_BUS as input (set bus high)
__delay_us(64); //Perform 64 us delay
}
else //If we send 0
{
__delay_us(60); //Perform 60 us delay
OW_BUS_SetDigitalInput();//Configure OW_BUS as input (set bus high)
__delay_us(10); //Perform 10 us delay
}
}
//--------Reading one bit from the device-------------------------
uint8_t ow_read_bit (void)
{
uint8_t bit; //Value to be returned
OW_BUS_SetDigitalOutput();//Configure OW_BUS as output (set bus low)
__delay_us(6); //Perform 6 us delay
OW_BUS_SetDigitalInput();//Configure OW_BUS as input (set bus high)
__delay_us(9); //Perform 9 us delay
bit = OW_BUS_GetValue();//Save the state of the bus in the bit variable
__delay_us(55); //Perform 55 us delay
return (bit);
}
//---------Writing one byte to the device------------------------
void ow_write_byte (uint8_t byte)
{
for (uint8_t i = 0; i < 8; i++) //Loop to transmit all bits LSB first
{
ow_write_bit (byte & 0x01);//Send the bit
byte >>= 1; //Shift the byte at one bit to the right
}
}
//---------Reading one byte from the device-----------------------
uint8_t ow_read_byte (void)
{
uint8_t byte = 0; //Value to be returned
for (uint8_t i = 0; i< 8; i++)//Loop to read the whole byte
{
byte >>= 1; //Shift the byte at one bit to the right
if (ow_read_bit()) //If 1 has been read
byte |= 0x80; //Then add it to the byte
}
return (byte);
}
//========DS18B20 sensor functions=============================
//-----------Command to start the temperature conversion-----------
void convert_t (void)
{
if (ow_reset()) //If sensor is detected
{
ow_write_byte(0xCC);//Issue "Skip ROM" command
ow_write_byte(0x44);//Issue "Convert T" command
}
}
//---Reading and calculating the temperature from the ds18s20 sensor-------
int16_t read_temp (void)
{
int16_t tmp; //Calculated temperature
uint8_t data[2]; //Array to read two bytes from the scratchpad
if (ow_reset()) //If sensor is detected
{
ow_write_byte (0xCC);//Issue "Skip ROM" command
ow_write_byte (0xBE);//Issue "Read Scratchpad" command
for (uint8_t i = 0; i < 2 ; i++)//Loop for reading 2 bytes from scratchpad
data[i] = ow_read_byte();//Writing the received byte into the data array
tmp = data[0] + (data[1] << 8);//Calculating the temperature
tmp = (tmp * 10) / 16;//Convert positive temperature into 0.1 C
}
else //If sensor was not detected
tmp = -990; //Then return the value that sensor can't have
return (tmp); //And return the temperature value
}
In lines 1-3, we include the necessary header files:
- “mcc_generated_files/mcc.h” (line 1) to use the integer data types, functions, and macroses generated by MCC;
- “ds18b20_mcc.h” (line 2) to use the macro definitions of the 1-wire pin we have just considered;
In lines 6-103, there are 1-wire and DS18B20-specific functions. As I have mentioned, all of them were already considered in detail in tutorial 13, so please refer to it for a detailed explanation.
Now let’s switch to the “lcd_1602_mcc.h” file.
#include <xc.h> // include processor files - each processor file is guarded.
void lcd_send (uint8_t value, uint8_t rs); //Send any data to the LCD
void lcd_command (uint8_t command); //Send the command to the LCD
void lcd_data (uint8_t data); //Send the character to the LCD
void lcd_init (uint8_t cursor, uint8_t blink); //Initialize the LCD
void lcd_clear (void); //Clear the LCD
void lcd_write_string (char *s); //Write the string at the LCD
void lcd_write_number (int32_t number, uint8_t dp_pos);//Write the number at the LCD
void lcd_set_cursor (uint8_t x, uint8_t y); //Set cursor at the specified position
void lcd_create_char (uint8_t addr, uint8_t *data);//Create a custom character
As you can see, it’s quite similar to the eponymous file from tutorial 19, but there are some new functions in the current file, which I added for more convenient use of the LCD:
- lcd_clear() (line 7) - clears the LCD and sets the cursor at position (1;1).
- lcd_write_string() (line 8) - the same as lcd_write() from tutorial 19, it accepts the parameter s of char* type and displays it on the LCD. I renamed it because of the next function to keep the difference obvious.
- lcd_write_number() (line 9) - displays the number given as the parameter number on the LCD and the decimal point at the position given as parameter dp_pos from the end. If dp_pos is 0, then the decimal point is not shown.
Now let’s consider the source file “lcd_1602_mcc.c”.
#include "mcc_generated_files/mcc.h"
#include "lcd_1602_mcc.h"
void lcd_send (uint8_t value, uint8_t rs)//Send any data to LCD
{
if (rs) //If rs is 1
RS_SetHigh(); //Then set RS pin high
else //If rs is 0
RS_SetLow(); //Then set RS pin high
E_SetHigh(); //Set E pin high to start the pulse
if (value & 0x80) //If bit #7 of the "value" is 1
D7_SetHigh(); //Then set the D7 pin high
else //Else
D7_SetLow(); //Set the D7 pin low
if (value & 0x40) //If bit #6 of the "value" is 1
D6_SetHigh(); //Then set the D6 pin high
else //Else
D6_SetLow(); //Set the D6 pin low
if (value & 0x20) //If bit #5 of the "value" is 1
D5_SetHigh(); //Then set the D5 pin high
else //Else
D5_SetLow(); //Set the D5 pin low
if (value & 0x10) //If bit #4 of the "value" is 1
D4_SetHigh(); //Then set the D4 pin high
else //Else
D4_SetLow(); //Set the D4 pin low
__delay_us(1); //Delay needed by the driver
E_SetLow(); //Set E pin low to finish the pulse
__delay_us(1); //Delay needed by the driver
E_SetHigh(); //Set E pin high to start the pulse
if (value & 0x08) //If bit #3 of the "value" is 1
D7_SetHigh(); //Then set the D7 pin high
else //Else
D7_SetLow(); //Set the D7 pin low
if (value & 0x04) //If bit #2 of the "value" is 1
D6_SetHigh(); //Then set the D6 pin high
else //Else
D6_SetLow(); //Set the D6 pin low
if (value & 0x02) //If bit #1 of the "value" is 1
D5_SetHigh(); //Then set the D5 pin high
else //Else
D5_SetLow(); //Set the D5 pin low
if (value & 0x01) //If bit #0 of the "value" is 1
D4_SetHigh(); //Then set the D4 pin high
else //Else
D4_SetLow(); //Set the D4 pin low
__delay_us(1); //Delay needed by the driver
E_SetLow(); //Set E pin low to finish the pulse
__delay_us(40); //Delay more than 37 us to implement the command
}
void lcd_command (uint8_t command)//Send command to LCD
{
lcd_send(command, 0); //Issue lcd_send function with RS = 0
}
void lcd_data (uint8_t data) //Send command to LCD
{
lcd_send(data, 1); //Issue lcd_send function with RS = 1
}
void lcd_init (uint8_t cursor, uint8_t blink) //Initialize LCD
{
lcd_command(0x30); //Try to use 8-bit interface
__delay_us(4200); //Delay for command implementation
lcd_command(0x30); //Try 8-bit interface one more time
lcd_command(0x28); //Set 4-bit interface, two lines
lcd_command(0x08); //Turn off the display
lcd_command(0x01); //Clear the display and reset the address to 0
__delay_us(4200); //Delay for command implementation
lcd_command(0x06); //Cursor move direction from left to right
lcd_command(0x0C | (cursor << 1) | blink); //Turn on display, set the cursor and blinking parameters
}
void lcd_clear (void) //Clear the LCD
{
lcd_command(0x01); //Clear the display and reset the address to 0
__delay_us(4200); //Delay for command implementation
}
void lcd_write_string (char *s) //Write the string at the LCD
{
uint8_t i = 0; //Characters counter
while (s[i]) //While s[i] is not 0 (end of the string is C)
{
lcd_data(s[i]); //Send the character to LCD
i ++; //Increment the characters counter
}
}
void lcd_write_number (int32_t number, uint8_t dp_pos) //Write the number at the LCD
{
uint8_t length = 0; //Number length
int32_t temp; //Temporary value to calculate the number length
uint32_t mult = 1; //Decimal multiplier
if (number == 0) //If number is 0
{
lcd_data('0'); //Then write 0
return; //And return from the function
}
else if (number < 0) //If number is negative
{
lcd_data('-'); //Then write minus sign
number = -number; //And negate the number value
}
temp = number; //Assign number to the temp
while (temp) //While temp value is not 0
{
temp /= 10; //Divide temp by 10
length ++; //Increment the number length
mult *= 10; //Multiply the decimal multiplier by 10
}
for (uint8_t i = 0; i < length; i ++) //Loop to display the number
{
if (dp_pos == (length - i))//If position of the decimal point is (length -i)
lcd_data('.'); //Then write dot
mult /= 10; //Divide decimal multiplier by 10
lcd_data(number / mult + 0x30);//Write the upper number at the LCD
number = number % mult;//Calculate the modulo of division of number by mult
}
}
void lcd_set_cursor (uint8_t x, uint8_t y) //Set cursor at the specified position
{
if (y > 2) //If we try to set the line number more than 2
y = 2; //Set the line #2
if (x > 16) //If we try to set the position number more than 16
x = 16; //Set the position #16
lcd_command(0x80 | ((y - 1) << 6) | (x - 1)); //Set the cursor position at the LCD
}
void lcd_create_char (uint8_t addr, uint8_t *data) //Create a custom character
{
if (addr > 7) //If the address is higher than 7
addr = 7; //Then set address as 7
lcd_command (0x40 | addr << 3); //Set the address of the CGRAM
for (uint8_t i = 0; i < 8; i ++) //Loop to send all 8 bytes
{
lcd_data (data[i]); //Send data to LCD
}
}
You may notice that some functions remain the same, namely lcd_send (lines 4-50), lcd_command (lines 52-55), lcd_data (lines 57-60), lcd_init (lines 62-73), lcd_write_string (lines 81-89) (previously called lcd_write), lcd_set_cursor (lines 124-131), and lcd_create_char (lines 133-142), so we will not consider them here, please refer to tutorial 19 for their description.
The lcd_clear function (lines 75-79) is quite simple. We send the “Clear display” command 0x01 (line 77) and wait for more than 4.1 ms (line 78) to let the LCD implement the command.
The lcd_write_number function (lines 91-122) is complex and tangled. Maybe it can be implemented in a simpler way, but this is what I came up with. Let’s now try to understand what this function does.
First, we declare three variables:
- length (line 93), which is the number of digits of the number parameter. The initial value of it should be 0.
- temp (line 94) is the temporary auxiliary variable that we will use to calculate the length of the number.
- mult (line 95) is the multiplier, which has the look of 10n and will be used to divide the number by 10n at every iteration. It may not be clear now, but I’ll explain it later. The initial value of the mult variable should be 1.
First, we check if the number is 0 (line 96). In this case, we don’t need to do any further calculations; we just display “0” on the LCD (line 98) and return from the function (line 99).
Then, we check if the number is negative (line 101). In this case, we display the minus sign at the LCD (line 103) and negate the number value to simplify operations(line 104).
Now, we need to calculate the number length. First, we assign the number value to the temp variable (line 107). Then, we implement the loop while the temp value is not 0 (line 108). In this loop, we divide the temp by 10 at each iteration (line 110), increment the length value (line 111), and multiply the mult value by 10 (line 112).
Let’s consider an implementation of this loop when the number is 23509, for instance:
Iteration number | number | temp | length | mult |
0 | 23509 | 23509 | 0 | 1 |
1 | 23509 | 2350 | 1 | 10 |
2 | 23509 | 235 | 2 | 100 |
3 | 23509 | 23 | 3 | 1000 |
4 | 23509 | 2 | 4 | 10000 |
5 | 23509 | 0 | 5 | 100000 |
Iteration #0 stands for the initial value of the variables.
As you see, the number value does not change as it is not used in the loop. The temp value is divided by 10 until it becomes 0 at iteration #5. Now, the length value is 5, which is correct because the 23509 number has exactly 5 digits. With the mult value, it’s more complicated. Its last value is 100000, but to split the number by digits, we need to divide it by 10000 first to get the first digit 2 of the 23509 number. So we must divide mult by 10 before using it later.
Now that we know the length of the number, we can implement another loop (lines 114-121) to display all the digits of the number one by one.
First, we check the position of the decimal point dp_pos from the least significant digit (line 116). If the dp_pos matches the (length - i) value, we display the dot character (line 117). Please note that if dp_pos is 0, the decimal point will not be displayed because the expression (length - i) never reaches 0, as the maximum i value is length - 1, and when it becomes length, the loop ends.
Then we divide the mult value by 10 (line 118): as I said before, we need to do it prior to using it.
Next, we send to the LCD a strange value (number / mult + 0x30) (line 119). Let’s try to understand it. As I mentioned before, the mult value has the look of 10n, so dividing the number by mult just leaves the most significant digit of the number. But we need to send to the LCD not just a digit but the ASCII character that corresponds to the digit. If you look at the ASCII table (for example, here https://www.asciitable.com/), you can see that the digits 0-9 have ASCII codes 0x30-0x39. So, to convert the digit into the ASCII character, we just need to add 0x30 to it.
Finally, we recalculate the number as the modulo of the division of the number by mult (line 120).
Let’s now see what’s happening inside this loop when we want to display the number 235.09 (number = 23509, dp_pos = 2)
i | Line number | number | mult | number / mult | length - i | LCD view |
0 | 117 | 23509 | 100000 | - | 5 | |
118 | 23509 | 10000 | - | 5 | ||
119 | 23509 | 10000 | 2 | 5 | 2 | |
120 | 3509 | 10000 | - | 5 | 2 | |
1 | 117 | 3509 | 10000 | - | 4 | 2 |
118 | 3509 | 1000 | - | 4 | 2 | |
119 | 3509 | 1000 | 3 | 4 | 23 | |
120 | 509 | 1000 | - | 4 | 23 | |
2 | 117 | 509 | 1000 | - | 3 | 23 |
118 | 509 | 100 | - | 3 | 23 | |
119 | 509 | 100 | 5 | 3 | 235 | |
120 | 9 | 100 | - | 3 | 235 | |
3 | 117 | 9 | 100 | - | 2 | 235. |
118 | 9 | 10 | - | 2 | 235. | |
119 | 9 | 10 | 0 | 2 | 235.0 | |
120 | 9 | 10 | - | 3 | 235.0 | |
4 | 117 | 9 | 10 | - | 1 | 235.0 |
118 | 9 | 1 | - | 1 | 235.0 | |
119 | 9 | 1 | 9 | 1 | 235.09 | |
120 | 0 | 1 | - | 1 | 235.09 |
Well, that’s all about how to display any integer number on the LCD. I also want to point out that the number has the type int32_t, so you can display the values from -2147483648 to 2147483647, which seems to be quite enough, taking into account that each LCD line can consist of just up to 16 characters.
This is all about the auxiliary files. Let’s now open MCC and configure our project there.
Configuration of the Project using MCC
In this tutorial, we will learn a lot of new modules: Timer2, External Interrupts, and PWM.
First, open the System Module page, set the Internal Clock as 8MHz_HF, and enable the PLL (Software or not - it doesn’t matter); thus, you will have the CPU frequency as 32 MHz (the same as we did in tutorial 11. Also, don’t forget to remove the check from the “Low-voltage programming enable” field (Figure 18).

Then we need to go to the “Device Resources” tab, expand the Timer list and add two timers: TMR0 and TMR2 (Figure 19). The first one will be used to count the 10 ms intervals, and the second one will be used for generating the PWM for controlling the heater voltage.

Then we need to expand the ECCP list, add the ECCP1 module, expand the Ext_Interrupt list, and select the EXT_INT module (Figure 20). The first one is already familiar to us from tutorials 15 and 17, but this time, it will be used to generate the PWM pulses. The second one will be used to read the encoder pulses. I talked about this in detail in the first part of tutorial 20.

Let’s now configure these modules one by one. The configuration of Timer0 is similar to the one from Tutorial 9, but here, we will count 10 ms intervals instead of 1 ms because we don’t need a bigger resolution. So, configure Timer0 according to Figure 21.

As we need the timer period to be 10 ms, we need to make some changes in the default timer configuration, except for changing the Clock Source from T0CKI to FOSC/4. With the default settings, we can’t achieve the desired timer period, so we need to do the following steps:
- Set the checkbox “Enable Prescaler.” Now, we can divide the input frequency as we need.
- Set the prescaler value as 1:2. This will provide the necessary accuracy of the counting intervals.
- Change the Timer mode from 8-bit to 16-bit. This will increase the timer range from 0-255 to 0-65535.
After these actions, you can see in the field “Timer Period” that we can reach the timer periods from 250 ns to 16.38375 ms, which totally suits us. So, in the “Requested Period” field, we write the required value of 10 ms and see that the “Actual period” also becomes 10 ms.
Now, we need to set the “Enable Timer Interrupt” checkbox because we will use it to increment the interval counter. Also, we need to set the “Callback Function Rate” as “1”. This is everything in regard to the Timer0 configuration.
In Tutorial 15, I already mentioned that the ECCP module could work in PWM mode only together with Timer2; that’s why we need to use this particular timer. In the first part of tutorial 20, I already mentioned that Timer2 differs from others that we already know and talked about its functionality and parameters. Let’s now see how it’s configured in MCC (Figure 22).

In the configuration of Timer2, we need to set the prescaler as 1:16 to increase the timer period. As I already said in the first part of tutorial 20, if Timer2 is used for generating PWM, the postscaler is not applicable, so even if we change it, this will have no effect. Also, we need to set the “Timer Period” as the maximum possible value to increase the PWM resolution, so we change this field to “512 us”. These are all the settings we need to change here. We will not use the Timer2 interrupts, so we leave the corresponding checkbox clear.
Now, let’s configure the ECCP module in PWM mode (Figure 23).

We already used the ECCP module in Capture mode (tutorial 15) and Compare mode (tutorial 17). Now, we will learn the last mode it can work - PWM mode. So first, you need to change the “ECCP Mode” from “off/reset” to “Enhanced PWM”, after which you will see the additional setting (Figure 23). Even though there are a lot of them, we need to configure just a couple of them.
- “Timer Select” field allows the user to select the timer used with the ECCP module in the current mode. In the PWM mode, the only option is “Timer2,” so we don’t need (and actually can not anyway) change it.
- “PWM Duty Cycle” sets the initial duty cycle of the PWM. As we don’t want the plant to heat after powering up, we must set this value as 0.
- “CCPR Value” reflects the CCPR register value corresponding to the selected duty cycle.
- “Enhanced PWM mode” allows the user to select the PWM mode between Single, Half-bridge, Full-bridge forward, and Full-bridge reverse. The last three modes are mainly used to control the motors, so we will not use them now. We need to output the PWM pulses at a single output, so we leave this field as “Single”.
- “PWM pins polarity” sets the polarity of the PWM pulses. In our project, we use the P1A output, which is merged with the RC5 pin (Figure 4 of the first part). Also, in the same Figure 4, you can see that the high level on the RC5 pin will turn on the heater; that’s why we need to select the option where the P1A is active high. The default value “P1A, P1C: active high; P1B, P1D: active high” suits us well.
- “Enable Steering” allows to steer the PWM pulses to one of the outputs P1A, P1B, P1C, or P1D. This option can only be enabled in the “Single” PWM mode. We don’t need to redirect the PWM pulses to other outputs, we leave this checkbox unchecked.
- “PWM Steering occurs on the” sets the moment when the steering is applied. Either immediately when the corresponding register is changed (“start_at_begin”) or at the next PWM period (“start_at_next”).
- “PWM steering enabled on pins” allows the user to select which pins will be involved in the steering.
- “Auto Shutdown” block contains the options for the auto-shutdown feature. We will not consider them here in detail as we are not using this feature in this project.
- “PWM Parameters” block shows the parameters of the PWM pulses that will be generated. Also, here, you can set the “PWM Delay,” representing the dead time in the half- and full-bridge modes.
That’s all about the ECCP module in the PWM mode. Now, we can proceed with the last module left - EXT_INT. By default, all three external interrupts (INT0, INT1, and INT2) are enabled (Figure 24).

According to Figure 4 of the first part, the encoder is connected to pins RC1 and RC2, merged with external interrupts INT1 and INT2, respectively. So, we need to disable the INT0 interrupt to avoid random triggering of the interrupts on the unconnected pin RC0. To do this, we need to right-click on the “RC0|INT0” pin in the “Pin Manager: Package View” window and then click on the “Extint | INT0 | input” point of the drop-down menu (Figure 25).

After that, you will see that the “RC0” pin became blue, which means it’s now unconfigured. Also, in the “EXT_INT” module configuration, only INT1 and INT2 are left. We need to change their “Edge Detect” field from “rising edge” to “falling edge” because when the encoder starts moving, the first edge will be high-to-low (Figure 26).

Now let’s switch to the “Pin Module” tab and configure the MCU pins according to Figure 27.

The pins are configured according to Figure 4 of the first part. The RA5 pin is the encoder’s switch, so we call it “SWITCH,” configure it as an input and enable the pull-up resistor because, for some reason, the external pull-up resistor is not soldered in the encoder module board.
RB4-RB7, RC4, and RC6 pins are connected to the LCD’s D4-D7, RS, and E pins and have the corresponding names. These pins should be configured as outputs. Also, we should give them exactly these custom names because they are used in the “lcd_1602_mcc.c” file.
RC1 and RC2 pins are now configured as external interrupt pins by the eponymous module. For some reason, we are unable to give a custom name to these pins, so we don’t change anything in their configuration.
RC5 pin is configured as the P1A output by the ECCP module, and we also don’t configure it here.
RC7 is the pin to the DS18B20 sensor and is connected via the 1-wire bus. So we call it “OW_BUS” (don’t alter this name as it is used in the “ds18b20_mcc.c” file). This pin should be configured as input to leave the bus free by default.
That’s all about the pin configurations. Finally, we need to open the “Interrupt Module” and configure the interrupts. Unlike the previous projects, here we will use the interrupt priorities. The Timer0 interrupt will have a high priority not to break the PID controller output value calculation. And external interrupts INT1 and INT2 will have low priority. In our program, the Timer0 overflow interrupt is more important than the external interrupts because a lot of program parts are dependent on the timer ticks, and the external interrupts are only used by the encoder. The difference between the high-priority and the low-priority interrupts is that the first can interrupt the interrupt subroutine of the second one, but not vice versa.
So first, we need to check the “Enable High/Low Interrupt Vector priority.” You will see the two lists now - “High Priority Interrupt Vector” and “Low Interrupt Vector” (I believe they missed the “priority” word here), as shown in Figure 28.

To move the interrupt between the lists, you need to select it and then press the arrow right to move it to the low-priority list or the arrow left to move it to the high-priority list. So, let’s select interrupts INT1I and INT2I and move them to the right list, as shown in Figure 28. Finally, you will have the result shown in Figure 29.

Here are all the settings needed to be done with the MCC. Now, we can press the “Generate” button to generate the required content, and then we can write the code for the whole program.
Program Code Description
Now, let’s switch to the “main.c” file where the most interesting things happen.
#include "mcc_generated_files/mcc.h"
#include "ds18b20_mcc.h"
#include "lcd_1602_mcc.h"
#define DEBOUNCE 3 //Button debounce, x10ms
#define MIN_TEMP 250 //Minimal temperature is 25 degrees
#define MAX_TEMP 650 //Maximal temperature is 65 degrees
int16_t temp_cur; //Temperature received from the DS18B20 sensor
int16_t temp_set; //Set temperature
int32_t x[3]; //Error signal
int32_t y; //Control impact (0-100%)
uint8_t mode; //Control mode: 0 - manual; 1 - PID
uint32_t tick; //10 ms counter
uint8_t previous; //Previous state of the button
uint32_t lastPress; //Time of the buttons last press
uint8_t bit0, bit1; //Bits of Grey code read from sensor
uint8_t prev_enc_val, new_enc_val;//Previous and new encoder relative values (0..3)
uint32_t seconds; //Seconds counter
const uint16_t h = 1; //Time step is 1s
const int32_t Kp = 200; //Proportional factor
const int32_t Ki = 10; //Integration factor
const int32_t Kd_inv = 200; //Inverted derivative factor (1 / Kd)
void timer0_overflow_isr (void) //Timer0 overflow interrupt subroutine
{
tick ++; //increment the tick value
if ((tick % 100) == 0) //Every 100 ticks
seconds ++; //increment the seconds
}
void ext_int_isr (void) //External interrupts INT1 and INT2 subroutine
{
int8_t step; //Encoder step (+1 or -1)
bit1 = PORTCbits.RC1; //Read the upper bit of the Grey code
bit0 = PORTCbits.RC2; //Read the lower bit of the Grey code
new_enc_val = (bit1 << 1) + (bit0 ^ bit1);//Conversion from Grey to binary code
step = new_enc_val - prev_enc_val; //calculate the encoder step
if (step == -3) step = 1;//If got difference 0-3 (overflow, we made four steps) then step is 1
if (step == 3) step = -1;//If dot difference 3-0 (overflow, we made four steps) then step is -1
prev_enc_val = new_enc_val;//Save new encoder value as previous
if (new_enc_val == 3) //If encoder is in the stall position
{
if (step == 1) //If the direction is clockwise
{
if (mode == 0) //If mode is manual
{
if (y < 1000)//If control impact is less than 100.0
y += 10; //Then increase it by 1.0%
}
else //If mode is automated
{
if (temp_set < MAX_TEMP)//If temperature is less than max value
temp_set += 10;//then increase it by 1.0 degree
}
}
if (step == -1) //If the direction is counterclockwise
{
if (mode == 0) //If mode is manual
{
if (y > 0) //If control impact is bigger than 0%
y -= 10;//Then decrease it by 1.0%
}
else //If mode is automated
{
if (temp_set > MIN_TEMP)//If temperature is bigger than min value
temp_set -= 10;//then decrease it by 1.0 degree
}
}
}
}
void lcd_refresh (void) //Update the LCD information
{
lcd_set_cursor(1, 1); //Set the cursor at position 1;1
lcd_write_string("Tc="); //Write text "Tc="
lcd_write_number(temp_cur, 1);//Write the current temperature
lcd_write_string("C Ts="); //Write text "C Ts="
lcd_write_number(temp_set / 10, 0);//Write the set temperature
lcd_write_string("C "); //Write text "C "
lcd_set_cursor(1, 2); //Set cursor at position 1;2
lcd_write_string("Y="); //Write text "Y="
lcd_write_number(y, 1); //Write the control impact value
lcd_write_string("% "); //Write text "% "
lcd_set_cursor(9, 2); //Set the cursor at position 9;2
lcd_write_string("t="); //Write text "t="
lcd_write_number(seconds, 0);//Write the seconds
lcd_set_cursor(16, 2); //Set the cursor at position 16;2
if (mode == 0) //If mode is manual
lcd_write_string("M"); //Then write "M"
else //If mode is automated
lcd_write_string("A"); //Then write "A"
}
void PID_control (void) //PID control algorithm
{
x[2] = x[1]; //Save x1 into x2
x[1] = x[0]; //Save x0 into x1
x[0] = temp_set - temp_cur; //Calculate new value of x0
y = y + (Kp * (x[0] - x[1]) + (x[1] * h) * Ki + ((x[0] - 2 * x[1] + x[2])) / (h * Kd_inv));//Calculate new value of control impact
if (y < 0) //If control impact is less than 0
y = 0; //Then set it as 0
if (y > 1000) //If control impact is more than 100.0
y = 1000; //Then set it as 100.0
}
/*
Main application
*/
void main(void)
{
// Initialize the device
SYSTEM_Initialize();
INT1_SetInterruptHandler(ext_int_isr);
INT2_SetInterruptHandler(ext_int_isr);
TMR0_SetInterruptHandler(timer0_overflow_isr);
// Enable high priority global interrupts
INTERRUPT_GlobalInterruptHighEnable();
// Enable low priority global interrupts.
INTERRUPT_GlobalInterruptLowEnable();
lcd_init(0, 0); //Initialize the LCD without cursor and blinking
bit1 = PORTCbits.RC1; //Read the upper bit of the Grey code
bit0 = PORTCbits.RC2; //Read the lower bit of the Grey code
prev_enc_val = (bit1 << 1) + (bit0 ^ bit1);//Conversion from Grey to binary code
temp_set = MIN_TEMP; //Set temperature to minimal value
y = 0; //PID controller output is 0
mode = 0; //Mode is manual
x[1] = 0; //Set x1 as 0
x[2] = 0; //Set x2 as 0
while (1)
{
if ((tick % 100) == 0)//When modulo of tick/100 is 0
convert_t(); //Start temperature conversion
if ((tick % 100) == 90)//When modulo of tick/100 is 90
{
temp_cur = read_temp();//Read the temperature value
if (mode == 1) //If mode is automated
PID_control();//Then calculate control impact by PID algorithm
EPWM1_LoadDutyValue(y);
lcd_refresh(); //Update the LCD
}
if (((tick - lastPress) > DEBOUNCE)) //If the time between the last press and the current time is bigger than the DEBOUNCE
{
lastPress = tick; //Set the last press time as the current time
if ((SWITCH_GetValue() == 0) && (previous != 0)) //If the current button state is low and previous state is high
{
previous = 0; //We reset the previous state to 0 to prevent implementation of this branch the next time
mode ^= 1; //Toddle the mode value
}
else if ((SWITCH_GetValue() != 0) && (previous == 0)) //If the current button state is high and previous state is low
{
previous = 1; //Then we set the previous state as 1 to prevent implementation of this branch the next time
}
}
}
}
Well, the “main.c” file is quite long and doesn’t have any repeating parts like in previous long programs. Let’s start from the very beginning.
In lines 1-4, we include the “mcc_generated_files/mcc.h” header (line 1) and all header files that we have created (lines 2-3).
In line 5, we define the macro DEBOUNCE, which represents the debounce delay for the encoder button. We set its value as 3 because we will count in 10ms intervals to reach 30ms.
In lines 6 and 7, we define the MIN_TEMP and MAX_TEMP macros in 0.1 degrees centigrade. So, the values 250 and 650 correspond to temperatures 25.0 and 65.0 degrees. The MIN_TEMP value is the environment temperature, and as we are in the middle of spring here now, it’s 25 degrees for me, and you probably will have another value. The MAX_TEMP is the maximum temperature of the plant. This value is experimental, so you most likely will have a different one. I have explained how to get it in the third part of the tutorial.
In lines 9-19, we define a lot of global variables.
The temp_cur (line 9) is the current actual temperature value measured by the DS18B20 sensor. The temp_set (line 10) is the temperature setpoint, set with the encoder in automated mode.
x (line 11) is the array of three values of the error signal (see Figure 1 and equation (11) in the first portion of this tutorial). As follows from equation (11), we need to know the value of the error signal on three consequent steps; that’s why x has three elements.
y (line 12) is the control impact in 0.1% increments, so its value can vary from 0 to 1000. This value will be either set manually using the encoder in the manual mode or calculated according to tequation (11) in the automated mode.
The mode (line 13) sets the operation mode of the controller. If it’s 0, then the mode is manual; if it’s 1, then the mode is automated.
The tick (line 14) is the 10ms intervals counter. There is nothing new here; we used these ticks in many previous projects.
The previous (line 15) and lastPress (line 16) variables are used for the non-blocking reading of the encoder button. We have already met such variables in tutorial 9.
The bit0 and bit1 variables (line 17) represent the lower and the upper bits of the two-bit Gray code produced by the encoder.
The previous_enc_val and new_enc_val (line 18) are the values of the encoder outputs after conversion from the Gray code into the binary one in the previous and current steps.
The seconds (line 19) is the seconds counter. The only purpose of this variable is to be displayed on the LCD.
In lines 21-24, we define the constants that represent the PID controller parameters:
- Step h (line 21)
- Proportional factor Kp (line 22)
- Integration factor Ki (line 23)
- Derivative factor Kd_inv (line 24)
The values of these factors depend on the plant parameters, and I’ll tell you how to find them in the next part. One may ask why the derivative factor is inverted while the others are not. The answer is simple: the value of Kd is just 0.005, and to eliminate the floating point calculations, we invert it, and instead of multiplying by 0.005 (according to equation (11)), we will divide by 200.
Now, let’s skip the auxiliary functions and consider the main function of the program (lines 110-161).
In the initialization part (lines 113-132), we, as usual, configure the MCU modules and set the initial values of the variables.
In line 113, we call the SYSTEM_Initialize function, configuring all the system and peripheral modules according to the settings we made in MCC.
In lines 114-116, we set the interrupt handlers for external interrupts INT1, INT2, and Timer0 interrupt, respectively. Please pay attention that INT1 and INT2 have the same interrupt handler ext_int_isr (lines 33-72) because their processing is the same. The Timer0 interrupt handler is called timer0_overflow_isr and is located on lines 26-31. We will return to them later.
In lines 119 and 122, we enable the high-priority and low-priority interrupts, respectively. Please pay attention that this time, we uncomment other functions than we used in the previous tutorials. Previously, we used INTERRUPT_GlobalInterruptEnable and INTERRUPT_PeripheralInterruptEnable because we didn’t use the interrupt priorities, and in the current program, we invoke the INTERRUPT_GlobalInterruptHighEnable (line 119) and INTERRUPT_GlobalInterruptLowEnable (line 122) functions.
In line 124, we initialize the LCD without cursor and blinking.
In lines 125-126. we read the states of the RC1 and RC2 pins to which the encoder outputs are connected and save them into the bit1 and bit0 variables. These variables represent the upper and the lower bits of the 2-bit Gray code. Please pay attention that in these lines, we don’t use the MCC-generated macro definitions <pin name>_GetValue simply because MCC didn’t create them for us, as these pins are configured as external interrupts, not regular GPIO. So, we have to read the register values directly. As you see, MCC does not always provide you with the API functions for everything; sometimes, you have to do the low-level operations (and this is not the last one in this tutorial).
In line 127, we convert the Gray code into the regular binary code. I didn’t invent this formula; I took the algorithm presented in Wikipedia and simplified it to use with 2 bits. We save this binary value into the prev_enc_val variable. We will consider later how it is used, and now let’s see how the conversion from Gray to binary code works.
Value | bit1 | bit0 | bit1 << 1 | bit0 ^ bit1 | Binary code |
---|---|---|---|---|---|
0 | 0 | 0 | 00 | 0 | 00 |
1 | 0 | 1 | 00 | 1 | 01 |
2 | 1 | 1 | 10 | 0 | 10 |
3 | 1 | 0 | 10 | 1 | 11 |
As you see, our formula works correctly.
In lines 128-132, we set the initial values of the variables related to the PID controller.
In line 128, we set the setpoint of the temperature temp_set as the minimum possible temperature value MIN_TEMP.
In line 129, we set the control impact y as 0.
In line 130, we assign 0 to the mode variable to start in the manual mode.
In lines 131-132, we set the initial values of the error signals on the previous steps x[1] and x[2] as 0.
In lines 134-160, there is the program's main loop.
In line 136, we check if the modulo of the division of the tick by 100 is 0. As the tick increments every 10ms, this check becomes true every second, and in this case, we send the “convert_t” command to the DS18B20 sensor (line 137).
In line 138, we check if the modulo of the division of the tick by 100 is 90. This check also becomes true every 1 second, but it’s shifted to the previous one by 900ms, which is required to calculate the temperature value. So if this condition is true, we implement several actions:
- Read the temperature from the sensor and save it into the temp_cur variable (line 140).
- If the mode is automated (line 141), we calculate the new control impact value with the PID algorithm (line 142) by calling the PID_control() function, which is located in lines 99-109.
- In line 143, we copy the y value in the PWM duty cycle registers using the API function EPWM1_LoadDutyValue. So, I already mentioned that y represents the control impact in 0.1% increments and changes in the range of 0-1000. The maximum possible value of the 10-bit PWM register is 1024. So if we ignore the values of 1001-1023, we can do the assignments made in line 143. Thus, you need to remember that we can increase the maximum PWM value by another 2.3% but this will need additional calculations.
- Finally, in line 144, we update the LCD view by calling the lcd_refresh function located in lines 74-94.
In lines 147-159, there is the non-blocking button processing algorithm, which I described in detail in tutorial 9. So here, I will just consider what is done when the button is pressed. Its only payload is line 153, where we toggle the mode value to switch between the manual and automated mode.
The rest of the functionality is implemented inside the functions and interrupt subroutines. Let’s now consider the lcd_refresh function (lines 74-94). As I said before, in it the LCD view is updated every second. Let’s first see the LCD view and consider what is shown in which position (Figure 30).

First, we set the cursor to the coordinates [1;1] (line 76), then we write the text “Tc =” (pos. 1, line 77), which means the “current temperature.”
Next, we write the value of the current temperature temp_cur with the decimal point after the first digit from the end (pos. 2, line 78), as temp_cur represents the temperature in 0.1C.
Then we write one more static text, “C Ts=” (pos. 3, line 79), where “C” stands for centigrades, and “Ts” stands for “temperature setpoint”.
In pos. 4 (line 80), we display the setpoint of the temperature temp_set divided by 10 because we don’t need the fractional part of it. Finally, we write the text “C ” (pos. 5, line 81). The space after the “C” is needed to erase the next character if the temperature value has only one digit.
Now, we set the cursor to coordinates [1;2] (line 82) and display the text “Y=” (pos. 6, line 83), which stands for the “control impact y”. Then, we write the y value itself with one fractional digit (pos. 7, line 84). In pos 8 (line 85) we write “% “. The spaces after the percent are needed to erase the unnecessary characters when the length of the y value changes.
Then, we set the cursor to coordinates [9;2] (line 86) to always display the next text in the same position regardless of the length of the y value. There, we write text “t=” (pos. 9, line 87), which stands for the “time”. Next, we write the seconds value (pos. 10, line 88).
Finally, we set the cursor to coordinates [16;2] (line 89) and display the mode letter (pos. 11): if the mode is 0 (line 90), then we write “M” (line 91), which stands for “manual”, and if the mode is 1 (line 92), we write “A” (line 93) which stands for “automated.”
Now, let’s consider the PID_control function (lines 96-106), which is short but very important. First, we reassign the error signal values from the previous steps to the next one: x[2] = x[1] (line 98), x[1] = x[0] (line 99), then calculate the error signal at the current step (line 100). Now, as we have all the required data, we can calculate the control impact y according to equation (11) from the first part (line 101):
There is only one difference because we want to get rid of the floating point numbers, so instead of Kd, we use the Kd_inv:
Using floating point calculations in the 8-bit MCUs requires a lot of memory and CPU resources as these numbers are emulated and not supported natively. I first wrote this program using floating point calculations, and the code size spent about 53% of the flash memory. And when I got rid of it, the size was reduced to 27%!
After calculatingthe y value, we need to limit its minimum and maximum values to keep it in limits [0…1000] (lines 102-105).
Now, let’s consider the last part of the program - interrupts subroutines. Let’s start with the “timer0_overflow_isr” which is located in lines 26-34.
Actually, it’s very similar to one that we did in tutorial 9 but a bit more expanded. First, we increment the tick value (line 28), and if the modulo of the division of the tick by 100 is 0 (which happens one time per second) (line 29), we increment the seconds value (line 30).
Now, let’s consider the ext_int_isr function, which is located at lines 33-72; it’s quite interesting.
In line 35, we declare the variable step, which represents the encoder’s step, or changing the state. It can be either 1 if the encoder handle moves clockwise or -1 otherwise. Lines 36-37 are the same as lines 125-126; here, we read the encoder’s current state and save it into variables bit1 and bit0. Line 38 is also very similar to line 127; we also convert the encoder’s Gray code into the regular binary code, but unlike line 127, in line 38, we save the result into the new_enc_val variable, which is the encoder state on the current step.
In line 39, we calculate the step value as a difference between the current encoder state new_enc_val and the previous encoder state prev_enc_val.
In line 40, we check if the step is -3; this may happen when the new_enc_val is 0 and the prev_enc_val is 3. In this case, the step should be positive because the transition from 3 (0b11) to 0 (0b00) in one step can happen only in the clockwise direction. So, in this case, we set the step as 1.
In line 41, we check the opposite case: if the step is 3. This happens when the new_enc_val is 3, and the prev_enc_val is 0, which may occur only in the counterclockwise direction, so the step should be -1.
In line 42, we assign the new_enc_val to the prev_enc_val because the current state will become previous in the next step.
In lines 43-71, we process the new encoder state. In line 43, we check if the current state is 3. As I mentioned in the first part, the normal state of the current encoder is when both its pins are not connected to the ground and are high, and each encoder “click” during rotation means changing for consequent states. So if the new_enc_val is 3, this means that the encoder is in the stall position, and we can do what we need from it.
First, we check if the step is 1 (line 45). If yes, then we check the mode variable (line 47). If it is 0 (manual mode), then we increase the y value by 10 (line 50) if it’s smaller than 1000 (line 49). Otherwise (in automated mode) (line 52), we increment the temp_set value by 10 (line 55) if it’s smaller than MAX_TEMP (line 54).
Next, we check if the step is -1 (line 58). If yes, we recheck the mode variable (line 60). If it is 0 (manual mode), then we decrease the y value by 10 (line 63) if it’s greater than 0 (line 62). Otherwise (in automated mode) (line 65), we decrement the temp_set value by 10 (line 68) if it’s greater than MIN_TEMP (line 67).
And that’s all about the “main.c” file. As you see, it’s quite long, but even so, it spends just 4836 bytes of the program memory even without the optimization, and with the “s” optimization, it reduces to just 3860 bytes or 23.6% of the overall memory.
Before proceeding to the practical work, we need to make one more change in the file “MCC generated files/interrupt_manager.c”. Let’s open and correct it. In the listing below, I presented the part of the “interrupt_manager.c” file in which I’ve made the changes, which are highlighted with the green color.
void __interrupt(low_priority) INTERRUPT_InterruptManagerLow (void)
{
// interrupt handler
if(INTCON3bits.INT2IE == 1 && INTCON3bits.INT2IF == 1)
{
INT2_ISR();
INTCON2bits.INTEDG2 ^= 1;//Change the interrupt edge
}
else if(INTCON3bits.INT1IE == 1 && INTCON3bits.INT1IF == 1)
{
INT1_ISR();
INTCON2bits.INTEDG1 ^= 1;//Change the interrupt edge
}
else
{
//Unhandled Interrupt
}
}
Here, the function INTERRUPT_InterruptManagerLow is presented and invoked when any low-priority interrupts are triggered. In our case, there are external interrupts INT1 and INT2. This function is quite simple. In line 87, we check if the INT2 interrupt is enabled and if the INT interrupt flag is set. This means that the interrupt has been triggered. In this case, we call the interrupt subroutine function INT2_ISR (line 89). The same happens with the interrupt INT1 (lines 92-96).
We need to change to toggle the interrupts’ triggering edge after each triggering. This is necessary because while the encoder is revolving, we must consequently get high-to-low and low-to-high transitions. And as the external interrupts INTx can sense only one edge at a time, we have to toggle it. This is done by toggling the bits INTEDG1 (for INT1) and INTEDG (for INT2) of the register INTCON2, which is done in lines 95 and 90, respectively. I talked about this register in detail in the first part.
Now, we can proceed to the last part of the tutorial, in which we will make the experimental work with our PID control system.
Get the latest tools and tutorials, fresh from the toaster.