FB pixel

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.

Figure 17 - Project structure
Figure 17 - Project structure

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).

Figure 18 - System Module
Figure 18 - System Module

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.

Figure 19 - Adding the timer modules
Figure 19 - Adding the timer modules

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.

Figure 20 - Adding the ECCP1 and EXT_INT modules
Figure 20 - Adding the ECCP1 and EXT_INT modules

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.

Figure 21 - Configuration of Timer0 module
Figure 21 - Configuration of Timer0 module

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).

Figure 22 - Configuration of Timer2 module
Figure 22 - Configuration of Timer2 module

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).

Figure 23 - Configuration of ECCP module in PWM mode
Figure 23 - Configuration of ECCP module in PWM mode

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).

Figure 24 - Default External Interrupt configuration
Figure 24 - Default External Interrupt configuration

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).

Figure 25 - Deselection of the external interrupt INT0
Figure 25 - Deselection of the external interrupt INT0

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).

Figure 26 - Configuration of the EXT_INT module
Figure 26 - Configuration of the EXT_INT module

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

Figure 27 - Pins configuration
Figure 27 - Pins configuration

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.

Figure 28 - Enabling interrupt priorities
Figure 28 - Enabling interrupt priorities

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.

Figure 29 - Interrupt module after final configuration
Figure 29 - Interrupt module after final configuration

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).

Figure 30 - LCD view
Figure 30 - LCD view

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.

Make Bread with our CircuitBread Toaster!

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

What are you looking for?