Relay True Bypass Switching Part 3: Microcontrollers and Current Savings

This article builds on part 1 and part 2 of the Relay True Bypass Switching series. If you have not read parts 1 and 2, please do so first.

This article requires writing and compiling C code. Click here to download the sample relay.c file we will be discussing so that you can follow along.

In our fourth and final part, we will build on what we learn here to get “smart” switching functionality of our relay circuit by using a microcontroller (MCU) for the brains. Since this guide targets pedal use, we are looking for a microcontroller that is small, and since the microcontroller is doing fairly simple operations, we are also looking for a MCU that is cheap over something high-powered.

We have selected the AVR ATtiny45 (P-QATTINY45) for this application. It is available in a small but breadboard-friendly DIP-8 package, and it is one of the cheapest AVR microcontrollers you can commonly find. The ATtiny45 is also compatible with the popular Arduino ecosystem/libraries (with a little bit of work), though we will not be using the Arduino libraries here. Arduino boards can also be used as hardware programmers for AVR microcontrollers, which lowers the barrier to entry when compared to a PIC microcontroller for those of you who already have access to an arduino board. We will explain how to program the AVR chip with a dedicated hardware programmer as well as how to program one with an Arduino board. For those of you who do not have an Arduino board or an AVR programmer, both can be purchased very inexpensively. We will discuss how to program the ATtiny45 later in this article.

We will walk through all the necessary code for the ATtiny45 and explain everything we are doing, but the full datasheet is also available from Microchip if you would like to view it.

Hardware Circuit

First we will discuss the hardware circuit we will be using, which is fairly simple.

Figure 1: ATtiny45-controlled relay circuit

At the top of the Figure 1 schematic, a 5V linear voltage regulator is converting the +9V (or +12V) input voltage to +5V, just as it did in part 2. The ATtiny (P-QATTINY45) can be powered by 2.7-5.5V, so we need the regulator to provide a voltage source in that range for the ATtiny. The voltage used for The ATtiny45’s $VCC$ also determines what the output voltage of the microcontroller will be. Since the TQ2-L-5V relay is a 5V relay, the ideal high output voltage from the ATtiny is 5V, which is what a $VCC$ of 5V provides. The output voltage will decrease slightly depending on the amount of current sourced from its pins and temperature, but a $VCC$ of 5V should provide an output voltage high enough to switch the relay at any usable temperature.

Pin 5 of the ATtiny, $AREF/PB0$, is used as an input that reads the state of the footswitch. Pin 6, $PB1$, is used as an output to control the LED indicator. Pins 7 and 2, $PB2$ and $XTAL1/PB3$, are also outputs that control the state of the relay. The other 6 pins of the relay are once again wired like a standard DPDT footswitch would be to control the effect, as we saw in parts 1 and 2 of this article.

To implement the basic switching functionality with the microcontroller, the software running on the ATtiny will continually read the switch input, outputting values that toggle both the LED and the relay if the switch has been pressed. There are certain complexities we will need to tackle and initialization that needs to take place, but that’s the gist of the software at its simplest. This also does not account for any “smart” switching functionality, but we will get the basic switching functionality down before adding the smart switching.

Note that even without smart switching, the use of a microcontroller benefits us by allowing us to easily use the latching footswitch TQ2-L-5V, which can provide substantial current savings over the non-latching TQ2-5V used in part 2. The TQ2-5V must continually draw current to remain in its switched state, while the TQ2-L-5V only needs to draw current momentarily when it switches, after which point it will be “latched” in whichever state it has been switched to and it no longer needs to draw current.

ATtiny45 Input/Output Registers

Goals:

• Understand what microcontroller registers are
• Learn how to use $DDRB$ register to set a pin to input or output
• Learn how to use $PORTB$ register to set an output pin high (5V) or low (0V)
• Learn how to use $PORTB$ register to enable input pullup resistors on input pins
• Learn how to use $PINB$ register to read the current state of an input pin

Microcontrollers contain “registers” which store values relevant to their current functionality. These registers can be modified to change the functionality or they can be read from to gain knowledge of the current status of the microcontroller.

ATtiny45 Data Direction Register

Figure 2: ATtiny45 connections

In Figure 2, we can see that all of the pins besides the two power pins ($VCC$ and $GND$) are named $PBx$ where $x$ is a number. $PB$ refers to $\text{Port B}$. On larger AVR microcontrollers, there are multiple ports each with their own set of registers. Each port is responsible for up to 8 pins. On the ATtiny, there are only 6 input/output pins, so the microcontroller only has one set of I/O registers for Port B which is responsible for the I/O pins.

There are 3 I/O registers that control Port B. Each register contains 8 bits which are either $0$ or $1$, and each bit is responsible for one of the I/O pins. We will start by discussing what is called the Data Direction Register.

 Bit/Pin # Bit Name Read / Write Initial State 7 6 5 4 3 2 1 0 - - DDB5 DDB4 DDB3 DDB2 DDB1 DDB0 R R R/W R/W R/W R/W R/W R/W 0 0 0 0 0 0 0 0

When the microcontroller is powered on, all bits of the Data Direction Register B ($DDRB$) are set to $0$. This can be represented as a binary number (a number made up of zeros and ones). Binary numbers are indicated by preceding the number with “0b”. So for example, the initial state of $DDRB$ is $0b00000000$, where the rightmost zero is bit 0 responsible for $PB0$, and the leftmost zero after the binary-indicating “0b” is bit 7 responsible for $PB7$, or at least it would be, if the ATtiny45 had a $PB6$ and $PB7$. As there are only six I/O pins on the ATtiny, $PB0$ - $PB5$, bits 6 and 7 cannot be modified and have no effect on the functionality of the MCU.

The bits in the Data Direction Register are responsible for setting each pin to be either an input pin or an output pin. A $1$ bit sets the pin to be an output, while a $0$ bit sets the pin to be an input. In other words, if the Data Direction Register contains the value $0b00111111$, all I/O pins (pins $PB0$-$PB5$) are output pins. If the Data Direction Register contains the value $0b00000000$, all I/O pins are output pins. When the microcontroller is first powered on, all ports are inputs, since all bits in $DDRB$ are $0$ in their initial state.

We can read the values in $DDRB$ to see the status of the pins, or we can write to it to set or change the status of the pins, and thus change which pins are inputs and which are outputs. Since a $1$ bit sets the port to be an output, writing the binary value $0b00000001$ to $DDRB$ would result in all pins being inputs except for $PB0$, which would be an output.

Figure 3: Inputs, outputs, and their respective $DDRB$ bits

Figure 3 illustrates what the $DDRB$ bits should actually be set to for our relay code ($0b00001110$). Note that since we are not using $PB4$ or $PB5$ and the ATtiny45 does not have a $PB6$ or a $PB7$, we do not care what values are in the four leftmost bits.

ATtiny45 Port Data Register

 Bit/Pin # Bit Name Read / Write Initial State 7 6 5 4 3 2 1 0 - - PB5 PB4 PB3 PB2 PB1 PB0 R R R/W R/W R/W R/W R/W R/W 0 0 0 0 0 0 0 0

The second register of interest is the Port B Data Register ($PORTB$). Note that again, bits 6 and 7 cannot be modified and have no effect on the functionality since there is no $PB6$ or $PB7$ pin. For the rest of the bits, what their value means depends on whether or not their respective pins are inputs or outputs.

$PB1$-$PB3$ will be configured as outputs in our program. Setting their respective bits (bits 1-3) in $PORTB$ to $1$ will set the pin output high (5V when $VCC$ is 5V). Setting their bits to $0$ will set the pin output low (0V). For example, if we wrote $0b00000010$ into the $PORTB$ register, pin $PB1$ would be set high to 5V, and the LED would turn on. If we wrote $0b00000000$ back into the $PORTB$ register, the LED would turn off again. We will use this information to toggle the LED and to toggle the relay by setting its respective pins either high or low, depending on which state it should be in.

On the other hand, $PB0$ is configured as an input. Writing a $1$ to its $PORTB$ bit does not set it high. Instead, writing a $1$ to an input pin’s bit in the $PORTB$ register enables an internal pull-up resistor for that pin.

Pull-up resistors in detail

We used a pull-down resistor in Part 2 which is a similar concept, but to explain in detail, let’s look at what an input sees without a pull-up resistor.

Figure 4: Input without a pull-up resistor

When one pin of the footswitch is connected to ground and the other pin is connected to the input pin, the input is predictable when the switch is closed. There is a connection from the input, through the switch, directly to $GND$ (0V), so our input sees 0V and will read low. When the switch is open, we would like it to read high (5V) to differentiate from a closed reading. However, when the switch is open and there is no pull-up or pull-down resistor, the input pin is not connected to any known voltage. The input is neither high nor low, it is “floating”. The input cannot be predicted to be low or high. It may be either one at any time, depending on various environmental factors, including small static charges on surrounding pins.

Using an input pull-up resistor avoids this unpredictable behavior.

Figure 5: Input with a pull-up resistor

In Figure 5, we can see that with a pull-up resistor, when the switch is closed the input is still connected directly to $GND$ through the switch, so the input will be low. The input also connects to $VCC$ through the pull-up resistor. With $VCC$ set to 5V, there will be a 5V drop across this resistor so the input still sees 0V.

On the other hand, when the switch is open, the input is pulled high to $VCC$ through the pull-up resistor. There is very little voltage drop across the resistor in this case, so the input sees $VCC$ (5V). We can build this type of switching circuit with the pull-up resistor externally if we want, but the ATtiny45’s I/O pins have built-in internal pull-up resistors that we can enable, so we can save on size and cost by eliminating the need for an external resistor.

Since $PB0$ is the ATtiny45 pin connected to the footswitch, we will enable the pull-up resistor for that pin, by writing $0b00000001$ to the $PORTB$ register initially. This enables the $PB0$ pull-up and leaves the outputs $PB1$-$PB3$ all low. We will be changing the $PB1$-$PB3$ outputs throughout our program to toggle the LED and the relay, but they should initially be off.

ATtiny45 Port Input Pins Register

The last I/O related register is the Port B Input Pins register ($PINB$).

 Bit/Pin # Bit Name Read / Write Initial State 7 6 5 4 3 2 1 0 - - PINB5 PINB4 PINB3 PINB2 PINB1 PINB0 R R R/W R/W R/W R/W R/W R/W 0 0 N/A N/A N/A N/A N/A N/A

Writing a 1 bit to any of the bits in $PINB$ will toggle the corresponding bit in $PORTB$. We will not be using this functionality. If we need to toggle bits in $PORTB$ we will write to $PORTB$ directly. Instead, we will be reading from $PINB$. The $PINB$ register bits will tell us the corresponding state of any input pins. For example, with $PB0$ set as input, bit 0 in $PINB$ will be 1 if the pin is at logic high (5V), or 0 if the pin is at logic low (0V). $PINB$ will also tell us whether an output pin is low or high. However, since the output pins will not change on their own, we will only use the $PINB$ register to read the input pin’s state to see whether or not the switch is pressed.

Controlling the LED with Software

Goals: Use the C Programming Language to:

• Modify the ATtiny45 registers with software to control an LED output
• Create an infinite software loop that runs forever when the ATtiny is powered
• Use available built-in AVR functions and identifiers
• Create a simple, working ATtiny45 program

To demonstrate how the above information will be used, we’ll start with a very simple demo program that blinks the indicator LED when using the circuit in Figure 1.

To program the ATtiny45 to do what we want, we will be writing code in the C Programming Language. We will then use a compiler to turn the code we have written into data that the microcontroller more easily understands. We will then use a hardware programmer to write the compiled data into the microcontroller’s flash memory, which is what the microcontroller looks at to determine how it should run.

Let’s create a file called blink.c. The file extension “.c” indicates that the file contains code using the C Programming Language. Now let’s open up the blink.c file and add some code to it. You can use a simple text editor like the built-in notepad on Windows or more advanced code-focused text editors like Visual Studio Code (available for Windows, macOS, and Linux), but do not use a word processor like Microsoft Word or LibreOffice Writer. Word processors tend to auto-correct code that is written properly, and any features they provide over a basic text editor usually get in the way of writing code. Using a text editor, we add the following contents to blink.c:

#include <avr/io.h>
#include <util/delay.h>

int main(void){   // <-- Main function opening curly bracket
// Set PB1 to output. The other pins remain inputs (unused).
DDRB = 0b00000010;
while(1){
// Set the PB1 output pin to logic high:
PORTB = 0b00000010;
// wait 100 milliseconds
_delay_ms(100);
// Set the PB1 output pin to logic low:
PORTB = 0b00000000;
// wait 100 milliseconds
_delay_ms(100);
}
// Program never reaches the below command:
return 1;
}   // <-- Main function closing curly bracket

C Programming Language Basics

Anything following two forward slashes // on a line is a comment. These are not evaluated as part of the program, and in the above code they are used to make note of what the other contents mean.

Functions

In C, the programming language we are using, the main function is the entry point of our program. It is where the program begins and ends. With the exception of some initialization, the first command that will be executed is the first command in main, and the last command executed will be the last command in main. The contents of the main function are enclosed in curly brackets.

  int main(void){
// [Contents of main here]
}

The curly brackets tell our compiler that everything inside of them is part of the main function. int main(void) identifies that this is the main function. To understand what int main(void) means, we’ll look at a more general function identifier.

Figure 6: Function identifier example for an addition function

In the example function identifier in Figure 6, the first int is the function’s “return type”. ints are integer values. That means that when we “call” (run) the function in figure 6, it will “return” an integer to us. So we could get a value like -5, 6, or 0 from it but not a non-integer value like 7.23. add is the name of the function. Inside the parentheses are the parameters. These are values that we “pass” or give to the function. In an addition function, we would pass the two numbers that we would like to add together. Both ints inside the parentheses indicate that the function is expecting two integer numbers. num1 and num2 are the names used inside of the function to reference the integer parameters that were passed to it. The full add function could look something like this:

  int add(int num1, int num2){
return num1 + num2;
}

Again, the curly brackets indicate that everything inside of them is part of the add function. The return command will return the value that follows it, in this case num1 + num2. Every command in C is followed by a semicolon, so we end the return line with one. The + symbol is of course used to add the two parameters together. If we wanted to add 5+20, we would “call” the add function like this, again adding a semicolon at the end of the command:

  add(5, 20);

The add function will return 25 when using the above command. When called like this, parameter num1 inside the add function now contains the integer 5, and parameter num2 inside the add function now contains the integer 20.

Although we would never see it this way, behind the scenes the return function inside add becomes:

  return 5 + 20;

So the command add(5, 20) returns 25, but it does not do anything with that number.

Variables

A more common and productive way to use a function like add is to assign its result (return value) to a variable. A variable can be declared and defined like this:

  int variable_name = 10;

Where variable_name can be whatever you like (with a few rules), though it is recommended that variables are named in a way which helps the reader understand their role in the program. In the above line of code, the variable variable_name is an integer variable that is being set to 10. It does not have to be set directly to a value like that. One of the ways in which the result of our add function can actually be used is to assign its return value to a variable. For example:

  int sum1 = add(5, 20);

In the above example, the variable sum1 will hold the value 25 after that command. That variable can then be used in place of any integer value. For example:

  int sum1 = add(5, 20);
int sum2 = add(sum1, 50);

In the above example, the value of 25 is assigned to sum1, and then sum1 is used as a parameter to the add function. After the second line, sum1 will hold the value 25, and sum2 will hold the value 75.

Note that when the sum1 variable was used in the second line, it was not preceded by int as it was in the first line. The first line defines the variable by giving it a value, but it also declares the value, which is what brings it into existence. The data type (int in this case) only needs to be specified when the variable is declared, and the variable only needs to be declared once, after which point it can be used without the preceding data type. A variable can also be declared without defining it:

  int sum1;
sum1 = add(100, -50);

In the above example, the first line declares the variable sum1, at which point its value is undefined. It should not be used at this point except to assign a value to it. The second line defines the variable, giving it a value of 50.

More Function Forms

Note that although our add function takes 2 parameters and returns an integer, functions do not need to take parameters nor do they need to return a value. Functions that do not return a value must specify void as their return type. Functions that take no parameters can either have nothing in the parameters list, or simply void. For example, the following two functions would be valid:

  void function_name1(){
// Function contents here
}

void function_name2(void){
// Function contents here
}

The above functions would be called like this:

  function_name1();
function_name2();

Now let’s look at our main function again. The main function does not need to be called, it will simply run as if it was called at the beginning of the program.

  int main(void){   // <-- Main function opening curly bracket
// Set PB1 to output. The other pins remain inputs (unused).
DDRB = 0b00000010;
while(1){
// Set the PB1 output pin to logic high:
PORTB = 0b00000010;
// wait 100 milliseconds
_delay_ms(100);
// Set the PB1 output pin to logic low:
PORTB = 0b00000000;
// wait 100 milliseconds
_delay_ms(100);
}
// Program never reaches the below command:
return 1;
}

Main Function Contents

The first non-comment line of main is setting the $DDRB$ register to $0b00000010$. We can assign values to the registers we have discussed similar to the way we can assign values to variables. However, writing to $DDRB$ writes the value directly into the hardware register, while writing to a standard variable writes to a location stored in memory.

      DDRB = 0b00000010;

As we discussed, the initial value of $DDRB$ is $0b00000000$. The above line of code is changing bit 1 ($PB1$’s associated bit) of the register to a $1$, which will change $PB1$ (our LED pin) from an input to an output.

The next line in main begins an infinite while loop.

Infinite While Loop

How While Loops Works

A generic while loop is of the form:

  while(condition){
// do something
}

A while loop will execute the code between the curly brackets in a loop for as long as condition evaluates to a non-zero value. For example, the following loops will run forever:

  while(1){
// do something
}
while(10.5){
// do something
}
while(7+12*3){
// do something
}

The following loops will never run:

  while(0){
// Code inside is never executed
}
int var = 0;
while(var){
// Code inside is never executed
}
while(7-7){
// Code inside is never executed
}

A common way to use a while loop is to have a condition that contains a variable that is changing inside the while loop. For example:

  int num_loops = 10;
while(num_loops){
some_function();
num_loops = num_loops - 1;
}

The above while loop will run 10 times. The condition will initially evaluate to 10. In each iteration of the while loop, 1 is subtracted from num_loops. After 10 loops, num_loops contains 0, so our condition evaluates to 0 and the loop is exited. Besides the subtraction line, the while loop can contain whatever code you want to run 10 times. Here, we are simply calling a function called some_function 10 times.

The while loop in our main function is simpler. We have simply put the number 1 inside the condition parentheses so that this loop will run forever. The condition will never evaluate to 0.

Infinite while loops like the one in our main function are ubiquitous in microcontrollers because the while loop contains code that should execute repeatedly until the microcontroller loses power. The microcontroller should never reach the end of the program, and the program should never end on its own. If a microcontroller does reach the end of the program, it will do nothing until its power is cycled again, which is rarely the kind of behavior we would want.

Everything inside the while loop runs forever, so let’s now take a look at its contents:

    // Set the PB1 output pin to logic high:
PORTB = 0b00000010;
// wait 100 milliseconds
_delay_ms(100);
// Set the PB1 output pin to logic low:
PORTB = 0b00000000;
// wait 100 milliseconds
_delay_ms(100);

In the first non-comment line, we use PORTB much like we used DDRB previously. Remember that when we write to a register like $PORTB$, we are writing directly to the $PORTB$ register, not a variable in memory. $PORTB$ is the register that determines whether an output is high or low for pins that are set to be outputs. We previously set $PB1$ to be an output when we wrote to $DDRB$, so writing $0b00000010$ to PORTB will set bit 1 to $1$ and thus pin $PB1$ will become high (5V). If an LED is connected to the microcontroller as shown in Figure 1, this is the point where the LED will turn on.

The next line calls the _delay_ms function. This is a built-in function provided in AVR libraries we are using (specifically, in the util/delay.h file we included at the top of our code). We do not have to write or define this function, it has already been done for us. The _delay_ms function takes a value of milliseconds as its parameter, and it will cause the microcontroller to delay (do nothing) for that amount of time. We are delaying for 100 milliseconds when we call _delay_ms(100).

The _delay_ms function is in this form:

void _delay_ms(double __ms){
// function contents
}

It does not return a value (specified by the void return identifier), and it takes one parameter of the type double.

What is a double?

Double is short for “double-precision floating point”, which is a highly precise decimal number. While int variables and parameters must be whole numbers, variables of the type double and the less precise type float can be decimal numbers. For example, we could delay by half a millisecond by calling _delay_ms(0.5). In this case, we do not need that level of precision.

100 milliseconds is an arbitrary value that we picked to determine how rapidly the LED should blink. We could easily use a value like 1000 to make the LED blink slower, or 50 to make the LED blink faster.

PORTB = 0b00000000;

The next line writes 0b00000000 back into PORTB. PORTB was previously set to 0b00000010, which turned the LED on. Writing 0b00000000 to it will turn the LED back off. We then delay for 100ms again, and then the loop repeats.

Looking at the while loop as a whole, it is fairly simple. We turn the LED on, do nothing for 100ms, turn the LED off, do nothing for 100ms, repeat. This will cause our LED to continuously blink 5 times per second.

Returning From Main

return 1;

The last line in our main function, after the while loop, is returning the value 1. As mentioned previously, the while loop will run forever, and this return command should never be executed. This is simply here to suppress any warnings or errors the compiler might otherwise give us when we attempt to compile our code into data the ATtiny45 understands. It is normal in C programs written for desktop computers to return a value from the main function, so the C compiler (which is used to compile AVR code and also compile desktop programs) expects the main function to return a value of type int, even if it is not typically used in AVR programming.

AVR Library Files

Let’s look at the first two lines in our code:

#include <avr/io.h>
#include <util/delay.h>

We are including two files that come with the AVR development tools we will be using (for example, WinAVR for Windows). The first file, avr/io.h, allows us to easily access the registers by simply using names like PORTB or DDRB. Behind the scenes, when we write to PORTB, we are writing the value to the hardware location of the $PORTB$ register. Without the clearly defined register names provided in avr/io.h, writing to PORTB requires the use of much less readable and memorable code:

// With avr/io.h
PORTB = 0b00000010;
// Without avr/io.h
(*(volatile uint8_t *)(0x38) = 0b00000010;

The second file, util/delay.h, gives us access to the _delay_ms function we are using. Although the functionality of _delay_ms is quite simple, the actual implementation is complex and dependent on microcontroller clock speed, so we use the function provided to us in util/delay.h.

Compiling the blink.c code and programming the ATtiny45 with it will cause our LED connected to $PB1$ to rapidly blink.

The blink.c example was intended to demonstrate some core principles of AVR and C programming. We will now build on this knowledge to create a fully-functioning relay program, but if you would like to compile and run the blink.c code on the ATtiny45 first, see the "Compiling and Programming the ATtiny45" section at the end of this article.

Goals:

• Read and respond to the input switch
• Use multiple functions to split up a larger program
• Use bit manipulation to toggle register bits or set them high and low
• Debounce the switch in software to eliminate need for external low pass filter
• Control the relay and take advantage of the latching relay’s current savings
• Create a fully working ATtiny45-based relay switching program

Now we’ll build on the concepts used in blink.c to implement simple relay switching using the ATtiny45. Since a relay switching program is more complex, we will not be doing everything in the main function and will instead call other functions from main to keep things more organized.

The full code in a file relay.c is seen below. We will go through this part-by-part.

// Defines register and bit names
#include <avr/io.h>
// Defines _delay_ms
#include <util/delay.h>

#define SETTLE_MS 5
#define DEBOUNCE_MS 75
#define ON 1
#define OFF 0

uint8_t switch_state = OFF;
uint8_t relay_state = OFF;

void led_on(){
PORTB |= (1 << PB1);
}
void led_off(){
PORTB &= $(1 << PB1); } void led_toggle(){ PORTB ^= (1 << PB1); } void relay_on(){ // Set PB3 high and PB2 low PORTB |= (1 << PB3); PORTB &=$(1 << PB2);
// Relay needs time to set
_delay_ms(SETTLE_MS);
// Set PB3 low again after the relay is
// set for current savings
PORTB &= $(1 << PB3); relay_state = ON; } void relay_off(){ // Set PB2 high and PB3 low PORTB |= (1 << PB2); PORTB &=$(1 << PB3);
// Relay needs time to reset
_delay_ms(SETTLE_MS);
// Set PB2 low again after the relay is
// reset for current savings
PORTB &= $(1 << PB2); relay_state = OFF; } void relay_toggle(){ if(relay_state == OFF){ relay_on(); } else{ relay_off(); } } void io_init(){ // Set data direction register so that PB0 is an input, // PB1-3 are outputs DDRB = 0b00001110; // Enable the input pullup for PB0 PORTB = 0b00000001; // Set the relay to off state initially relay_off(); } void poll_switch(){ uint8_t current_switch_state = !(PINB & 0b1); if(current_switch_state){ // If switch is pressed if(switch_state == OFF){ // If the switch was previously not pressed // (the switch changed from not pressed to pressed) relay_toggle(); led_toggle(); switch_state = ON; // To debounce, we wait DEBOUNCE_MS milliseconds // before polling the switch again _delay_ms(DEBOUNCE_MS); } } else{ // else if switch is not pressed if(switch_state == ON){ // If the switch was previously pressed // (the switch changed from pressed to not pressed) switch_state = OFF; // Debounce on switch release to avoid // undesired switches happening on release. _delay_ms(DEBOUNCE_MS); } } } int main(void){ io_init(); // Initialize the inputs and outputs while(1){ // Loop forever, reading the switch input // and responding accordingly poll_switch(); } return 1; } #include, #define, and Global Variables At the very top of our code, the following is not inside any function: // Defines register names #include <avr/io.h> // Defines _delay_ms #include <util/delay.h> #define SETTLE_MS 5 #define DEBOUNCE_MS 75 #define ON 1 #define OFF 0 uint8_t switch_state = OFF; uint8_t relay_state = OFF; We include avr/io.h and util/delay.h just as we did in our blink program so that the register names (PORTB, DDRB, PINB) are defined and _delay_ms is implemented. Below that, we are defining four constants, the time it takes for our relay to latch, the debounce time, and ON/OFF values which we will use for the state of the switch and the relay. What does #define do? The lines that begin with #define create constants, which are aliases for the values that follow them. The first #define line tells the compiler that anywhere we type SETTLE_MS in our code, we want to use the value 5. For example the variable result would be 10 after the following line:  int result = SETTLE_MS + SETTLE_MS; We could simply use the values themselves, but sometimes using a #define makes the code more clear. For example, to debounce the switch we use:  _delay_ms(DEBOUNCE_MS); The above makes it much more clear what the delay is for than simply using:  _delay_ms(75); It also allows us to easily change the debounce time if we find 75ms too short or too long. We debounce in two places (on press and release), so we only have to change the #define value to change the debounce time rather than changing it in both _delay_ms commands. Note that when you #define a term you are permanently setting it to that value. #defined terms cannot be changed inside the code. Below the defined terms are two global variables, switch_state and relay_state. These track whether the footswitch is unpressed (OFF) or pressed (ON) and whether the relay is in its default state (OFF) or its switched state (ON). What is a uint8_t data type? Previously we have used variables of type int (integer). Here, we are using type uint8_t. A uint8_t variable is an unsigned integer that explicitly takes up 8 bits (1 byte) in our ATtiny’s memory. A 1 byte variable can only represent a small amount of numbers when compared to a 2 (or more) byte variable. A uint8_t variable can be anything in the range of 0 to 255. Type uint8_t would be a poor choice if we were working with large numbers, however both switch_state and relay_state will only ever be either OFF (0) or ON (1), so the range of a uint8_t is perfectly acceptable. A 1 byte variable is the smallest possible variable, and the RAM (memory) is quite limited on the ATtiny45, so it is wise to explicitly use a variable which only uses 1 byte of memory if we can get away with it. The ATtiny45 has plenty of RAM for this program, but keeping the memory usage low gives you more room to build on it in the future should you ever need to. What are global variables? Notice that the switch_state and relay_state variables are not defined or declared inside any function. These are global variables that all of our functions can access and write to. Let’s look at how a global variable can be used in the below code snippet (which is not part of our relay code):  int value = 1; void increment(){ value = value + 1; } int main(void){ increment(); value = value + 1; } In the above code, value starts as 1. The main function runs and calls increment() which adds 1 to value, and then 1 is added to value in the main function itself. The variable value equals 3 at the end of main. Both the increment function and the main function are able to access the variable value and modify it. On the other hand, variables defined within functions are local variables, and by default cannot be accessed outside of the function they are declared in.  void increment(){ int value = 1; value = value + 1; } int main(void){ increment(); value = value + 1; } If we try to compile the above code, the compiler will give an error: Error: ‘value’ undeclared Local variables cease to exist once the function they are declared in is exited. When increment is called inside the main function, the value variable is created on the first line of the increment function, successfully incremented on the next line, and then the function increment() is exited and the value variable no longer exists. When the main function tries to increment value, it is unaware of any variable named value. switch_state and relay_state are global variables so that we can track the state of the switch and the relay anywhere in our program. Main Function Our main function in relay.c is very simple: int main(void){ io_init(); // Initialize the inputs and outputs while(1){ // Loop forever, reading the switch input // and responding accordingly poll_switch(); } return 1; } It calls a function io_init() which initializes all of our inputs and outputs by writing the proper values to their respective registers. Once the inputs and outputs are initialized, it enters an infinite loop (a loop that will repeat forever), which calls the function poll_switch in each loop iteration. poll_switch checks whether the switch has been pressed, and updates our outputs to toggle the LED and the relay if it should do so. Input/Output Initialization Let’s take a closer look at io_init: void io_init(){ // Set data direction register so that PB0 is an input, // PB1-3 are outputs DDRB = 0b00001110; // Enable the input pullup for PB0 PORTB = 0b00000001; // Set the relay to off state initially relay_off(); } io_init is a function that takes no parameters and returns no value. Its purpose is to modify the ATtiny’s registers so that pins$PB0$-$PB3$are set up correctly for input (in the case of$PB0$) and output (in the case of$PB1$-$PB3$). Remember that a 1 bit in a pin’s associated$DDRB$bit sets that pin to output, while a 0 bit sets that pin to input. As shown in Figure 3, we write 0b00001110 to DDRB to set$PB1$-$PB3$(our LED and two relay pins) to outputs, and set$PB0$to input. On the next non-comment line, we set PORTB to 0b00000001. Remember that writing a 1 to an output pin’s associated$PORTB$bit sets its output to high, and writing a 1 to an input pin’s associated bit enables its internal pull-up resistor (as seen in Figure 5). We are writing a 1 to bit 0 only, which will enable the internal pull-up resistor for$PB0$, our switch’s input pin. After those two registers are set up, we call a function to turn the relay off (to its unswitched state), since we would like the effect to be off when power is first applied. We also want the LED to be off, but the LED will either be on or off depending on the bits in$PORTB$, and we just wrote a 0 to bit 1 of$PORTB$which will cause the LED to be off. The relay, on the other hand, is latching, so it may still be in the on state unless we explicitly turn it off in our initialization. LED On, Off, and Toggle functions Though we are calling relay_off in our io_init function, we will look at our LED functions first as they are simpler. void led_on(){ PORTB |= (1 << PB1); } The one command inside led_on is something we have not seen before. It uses an assignment operator |=. The operator |= is shorthand for a combination of “|” (logic OR) and “=”. A simpler example is the assignment operator +=. An assignment operator like += adds the value on the right side of the operator to the value on the left side of the operator, and then assigns that value to the variable on the left side of the operator. The below lines of code are equivalent, and both increment value by 1: value += 1; value = value + 1; The same is true of |=. The following two lines are equivalent: PORTB |= (1 << PB1); PORTB = PORTB | (1 << PB1); Both lines of code set the PB1 bit in PORTB to 1. This is the bit that will set the$PB1$pin output (the LED pin) to 5V if the bit is 1 or 0V if the bit is 0. Like PORTB, PB1 is defined when we include avr/io.h. While PORTB is actually a very complicated value, PB1 is simply defined as its bit number, 1. The actual line of code in one of our included files is: #define PB1 1 We could easily use 1 in its place, although again, using PB1 makes it a little more explicit what we are doing with this command - setting the bit PB1 in PORTB to 1. The following three lines of code are all equivalent: PORTB |= (1 << PB1); PORTB |= (1 << 1); PORTB = PORTB | (1 << 1); Binary and Decimal Numbers in Detail If you are not very familiar with binary numbers, we should take a closer look at them before going further. When we write a line such as: PORTB = 0b00000001; We are indicating that 00000001 is a binary value by preceding it with “0b”. Writing the register’s value in binary makes it easy to see what each bit should be, but we could just as easily assign a standard decimal (non-binary) value to it. The binary value 00000001 is equivalent to the non-binary value 1. The following two lines of code are equivalent:  PORTB = 0b00000001; PORTB = 1; Let’s say that we wanted to assign a value to PORTB where$PB5$,$PB4$, and$PB1$are 1. With binary, it is easy: PORTB = 0b00110010; To set that same value with a decimal number, we either need to know or determine the decimal equivalent of$0b00110010$or we need to look it up. There are plenty of binary-to-decimal and decimal-to-binary converters online, and the conversion can be done without them, but in some cases it is much easier and more readable to simply use the binary number, just as it is sometimes easier to use the decimal number. Hexadecimal numbers, which are preceded by$0x$, are also used fairly often, though we will not get into them in detail in this article. The equivalent decimal and hexadecimal values are: $$0b00110010 == 0x32 == 50$$ Standard decimal (non-binary and non-hexadecimal) values are implied when the value is not preceded by$0b$or$0x$. One more aspect of binary numbers that we did not cover is that they do not need leading zeros.$0b0001$is the same as$0b1$.$0b00001001$is the same as$0b1001$We have been using 8 digits because the registers we are writing to have 8 bits. Adding the leading zeros makes it very clear what value is in each bit, but they are not strictly necessary. Let’s look closer at the command to set bit PB1 to 1 using one of its equivalent forms: PORTB = PORTB | (1 << 1); Binary Left Shift Operator (<<) Let’s look at the (1 << 1) part of the above command. The << operator is a “binary left shift” operator. In the generic form (x << y), this can be read as “shift x y bits to the left”. In the scenario (1 << 1), we want to shift 1 one bit to the left. This is an instance where it’s easier to look at the binary version of the number that is being shifted. Let’s consider (0b1 << 1), which is equivalent to (1 << 1). We are shifting$0b1$one bit to the left. To do so, let’s consider it with a leading zero as$0b01$. To shift left one bit, we move all bits to the left one digit, inserting a 0 in the right-most bits that were shifted.$0b01$becomes$0b010$, where the rightmost zero has been inserted. Figure 7: A binary number shifted left 1 bit and a binary number shifted left 3 bits Figure 7 demonstrates the operation better. Note that we are only removing the red digits because they are zeros and not significant. If these were 1 bits, they could not be removed. For example,$0b11111111$shifted left 8 bits is$0b1111111100000000$. Table 4: Binary values shifted left, no leading zeros OperationExample 1Example 2Example 3 Original value0b10b10110b0 Shifted 1 bit left0b100b101100b0 Shifted 2 bits left0b1000b1011000b0 Shifted 3 bits left0b10000b10110000b0 Shifted 4 bits left0b100000b101100000b0 Now back to the full command: PORTB = PORTB | (1 << 1); (1 << 1) is equivalent to (0b1 << 1), and$0b1$shifted left one digit is$0b10$. So the full command becomes: PORTB = PORTB | 0b10; Bitwise OR Operator (|) The | command is a logic OR command. An OR command compares two numbers and if a bit is a 1 in either number or both, that bit is a 1 in the result. Table 5: Result of OR operation Example 1Example 2Example 3 Value 10b10100b11000b1111 Value 20b01010b00010b0001 OR Result0b11110b11010b1111 Even without knowing the contents of$PORTB$, we can know how$0b10$will affect it. Let’s look at two 1-bit numbers “OR”ed together. For one of the 1-bit numbers, we do not know whether a 0 or a 1 is in its single bit. We’ll use “X” for this unknown bit. For the other 1-bit number, we do know. This will be the outcome: Table 6: OR operation of known and unknown 1-bit values Example 1Example 2 Value 1, unknown bit0bX0bX Value 2, "OR"ed bit0b10b0 OR Result0b10bX If the known bit is a 1, the resulting bit is a 1. If the known bit is a 0, the result is X. In other words, a bit “OR”ed with 1 is 1, while a bit “OR”ed with 0 is unchanged. Looking at PORTB | 0b10 again, since PORTB is an 8-bit value, we could consider the operation to be:  0bXXXXXXXX OR 0b00000010 0bXXXXXX1X  Where$PORTB$is$0bXXXXXXXX$. From table 2, we know that the 1 bit in the above result is the$PB1$bit of$PORTB$, which is responsible for the$PB1$pin’s output state. The command: PORTB |= (1 << PB1); In our led_on function modifies PORTB so that the PB1 bit is 1 (high), regardless of whether or not it was high before the command. All other bits are unchanged. The same is true if we use PB0, PB2, PB3, PB4, or PB5. The command: PORTB |= (1 << PB5); would result in a PORTB value of$0bXX1XXXXX$, where the 1 bit is the bit responsible for$PB5$’s output state. All other bits are unchanged. This command, in general, is frequently used to set a certain bit high in a register without affecting any of the other bits in the register: REGISTER_NAME |= (1 << BIT_NAME); Conversely, there is a shorthand for setting a pin low in the same way which uses logic AND instead of logic OR: REGISTER_NAME &=$(1 << BIT_NAME);

We use this in our led_off function:

void led_off(){
PORTB &= $(1 << PB1); } Bitwise AND Operator (&) Much like the |= operator and the += operator, the &= operator is an AND assignment operator. The following two lines are equivalent:  PORTB &=$(1 << PB1);
PORTB = PORTB & $(1 << PB1); If we look at what PORTB is being “AND”ed with when we are setting a pin low, it is in a slightly different form from what it was “OR”ed with when we were setting the pin high, due to the preceding “$”:

$(1 << PB1) The $ operator is the bitwise NOT. It changes all 1 bits in a value to 0, and all 0 bits to 1. We know (1 << PB1) is equivalent to (0b1 << 1) which evaluates to $0b10$. Since PORTB is an 8-bit number, we should consider 8 bits of 0b10: $0b00000010$. If we apply the bitwise NOT to this, it becomes $0b11111101$. Now the command is:

PORTB = PORTB & 0b11111101;

An AND (&) command compares two numbers and if a bit is a 1 in both numbers, that bit is a 1 in the result. If the bit is a 0 in either number or both, the bit is a 0 in the result. Much like we did with OR operations in table 6, let’s see how an unknown bit is affected when “AND”ed with either a 1 bit or a 0 bit:

Table 7: AND operation of known and unknown 1-bit values
Example 1Example 2
Value 1, unknown bit0bX0bX
Value 2, "AND"ed bit0b10b0
AND Result0bX0b0

With an unknown PORTB, the operation looks like this

     0bXXXXXXXX AND 0b11111101     0bXXXXXX0X 

So the operation PORTB &= $(1 << PB1); sets the PB1 bit in PORTB to 0 without modifying any of the other bits. Just like the previous |= operation that we can use to set any bit in a register to 1, we can use the &= operator combined with the bitwise NOT operator ($) to set any bit in a register to 0:

REGISTER_NAME |= (1 << BIT_NAME);   // Set BIT_NAME to 1
REGISTER_NAME &= $(1 << BIT_NAME); // Set BIT_NAME to 0 There is one other common bit operator that we will be using in our led_toggle function: void led_toggle(){ PORTB ^= (1 << PB1); } This assignment operator uses the XOR (exclusive or) operator “^”. Bitwise XOR Operator (^) When two bits are XORed together, the resulting bit is a 1 if only one of the two bits is a 1. If both bits are 0 or if both bits are 1, the result is a 0. With an unknown PORTB, the above command looks like this:  0bXXXXXXXX XOR 0b00000010 0bXXXXXXCX  Where$X$is an unknown bit, and$C$is the “complement” (opposite) of that bit (if$X$is 0,$C$is 1; if$X$is 1,$C$is 0). Thus, the ^= assignment operator is used to toggle a bit: REGISTER_NAME |= (1 << BIT_NAME); // Set BIT_NAME to 1 REGISTER_NAME &=$(1 << BIT_NAME);   // Set BIT_NAME to 0
REGISTER_NAME ^= (1 << BIT_NAME);   // Toggle BIT_NAME

Now we can look closer at our relay program again. The functions to turn the LED on, turn the LED off, and toggle the LED use the assignment operators we discussed above to modify the $PB1$ bit in $PORTB$ to set the state of the LED’s output pin.

void led_on(){
PORTB |= (1 << PB1);
}
void led_off(){
PORTB &= $(1 << PB1); } void led_toggle(){ PORTB ^= (1 << PB1); } Relay On, Off, and Toggle Functions The relay functions are a little more complex than the LED functions, but still very simple now that we know how the registers’ bits are modified. Relay On Function We’ll look at relay_on first: void relay_on(){ // Set PB3 high and PB2 low PORTB |= (1 << PB3); PORTB &=$(1 << PB2);
// Relay needs time to set
_delay_ms(SETTLE_MS);
// Set PB3 low again after the relay is
// set for current savings
PORTB &= $(1 << PB3); relay_state = ON; } The first two non-comment lines set pin$PB3$’s output high (5V) and pin$PB2$’s output low (0V). This puts 5V across the relay’s coil, which is needed to set the relay to the “switched” state (effect on). The next command, _delay_ms(SETTLE_MS); uses the built-in _delay_ms to wait 5ms before doing anything else. Remember that we used #define SETTLE_MS 5 at the top of our relay.c file. The reason why we are delaying 5ms is due to the following specifications on page 4 of the TQ2-L-5V relay’s spec sheet:  Operate time [Set time] (at 20°C, 68°F) Max. 3 ms (Nominal coil voltage applied to the coil, excluding contact bounce time) Release time [Reset time] (at 20°C 68°F) Max. 3 ms (Nominal coil voltage applied to the coil, excluding contact bounce time) The relay needs 5V across its coil for up to 3ms at 20°C for it to latch. We use 5ms here to account for any changes due to temperature fluctuations. The extra 2ms delay is negligible in this application. Once the relay has latched, the next line PORTB &=$(1 << PB3); sets our $PB3$ pin’s output to low (0V) again so that there is no longer 5V across the relay’s coil. This takes advantage of one of the major benefits of using a latching relay properly. Once the relay has latched, it no longer needs to draw any current. If PB3 were to remain high after the relay has latched, it would continue to draw around 15-20mA of current, which is why we set it low as soon as we are sure the relay has latched.

A non-latching relay must draw current in order to stay in its switched state, so there can be substantial power savings when using a latching relay. The last command, relay_state = ON;, sets our global relay_state variable to ON. We need to know the relay’s state in our relay_toggle function, so any time its state changes we must update the relay_state variable.

Relay Off Function

void relay_off(){
// Set PB2 high and PB3 low
PORTB |= (1 << PB2);
PORTB &= $(1 << PB3); // Relay needs time to reset _delay_ms(SETTLE_MS); // Set PB2 low again after the relay is // reset for current savings PORTB &=$(1 << PB2);
relay_state = OFF;
}

Our relay_off code is very similar to our relay_on function. Again, this function sets one of the output pins connected to the relay high and sets the other low. We wait 5ms for it to latch, then set the previously high pin to low and update the relay_state variable. The only difference is which pin is being set to 5V and which pin is being set to 0V, and what we are setting relay_state to at the end.

Relay Toggle Function

void relay_toggle(){
if(relay_state == OFF){
relay_on();
}
else{
relay_off();
}
}

The relay_toggle function is pretty self-explanatory. If our relay_state is off, we turn the relay on. If relay_state is on, we turn the relay off.

If/Else Statements

Earlier we looked at the generic form of a while loop, which is:

  while(condition){
// do something
}

For as long as condition evaluates to a non-zero value, the contents of the while loop repeat. An if/else statement checks a condition, and if it evaluates to a non-zero value, the commands inside the if{} section are run. If the condition evaluates to zero, the commands inside the else{} section are run:

  if(condition){
// If condition does not evaluate to 0, do this
}
else{
// If condition evaluates to 0, do this instead
}

Our condition is relay_state == OFF. Note that we are using “==” (two equal signs) rather than “=” here. While relay_state = OFF would set relay_state to OFF using the assignment operator “=”, “==” is a relational operator which checks to see if the two operands are equal. If they are equal, the operation evaluates to 1. If they are not equal, the operation evaluates to 0. Using this knowledge, we can see that if relay_state does in fact equal OFF, the condition will evaluate to 1, and the contents of the if{} section will be run, and the relay will be turned on. If the relay_state equals ON, the condition evaluates to 0 and the contents of the else{} section will be run, and the relay will be turned off.

Polling the Footswitch

Let’s look at our main function again:

int main(void){
io_init();   // Initialize the inputs and outputs
while(1){
// Loop forever, reading the switch input
// and responding accordingly
poll_switch();
}
return 1;
}

First we initialized our inputs and outputs. Next, we enter an infinite while loop that calls the poll_switch function for as long as the ATtiny45 is powered. Remember that we should never reach the return 1; command because we will never exit the infinite while loop.

The last function we need to look at is the poll_switch function, which we are continuously calling in the infinite while loop.

void poll_switch(){
uint8_t current_switch_state = !(PINB & 0x1);
if(current_switch_state){
// If switch is pressed
if(switch_state == OFF){
// If the switch was previously not pressed
// (the switch changed from not pressed to pressed)
switch_state = ON;
relay_toggle();
led_toggle();
// To debounce, we wait DEBOUNCE_MS milliseconds
// before polling the switch again
_delay_ms(DEBOUNCE_MS);
}
}
else{
// If switch is not pressed
if(switch_state == ON){
switch_state = OFF;
// Debounce on switch release to avoid
// undesired switches happening on release.
_delay_ms(DEBOUNCE_MS);
}
}
}

The first command in poll_switch, uint8_t current_switch_state = !(PINB & 0b1);, reads the state of our footswitch’s pin $PB0$ and stores it in a newly declared variable current_switch_state. Remember that the $PINB$ register stores the current state (high or low) of pins $PB0$-$PB5$ (see Table 3). The command (PINB & 0b1) is a bitwise AND. We are using this to isolate PB0’s state. The command looks like this, where PINB’s bits are unknown:

     0bXXXXXXXX AND 0b00000001     0b0000000X 

If $PB0$ is high, the result will be $0b1$ (1 in non-binary). If $PB0$ is low, the result will be $0b0$ (0 in non-binary).

If we look at Figure 5 again, we can see that when the switch is closed (pressed), $PB0$ is low. When the switch is open (unpressed), $PB0$ is high. The result of (PINB & 0b1) will be 1 if the switch is unpressed, and it will be 0 if the switch is pressed.

We can use those values, but it may be a little more intuitive if current_switch_state is a 1 when the switch is pressed and 0 when the switch is not pressed. To achieve this result, we use the logical NOT operator on the result of (PINB & 0b1).

Figure 16: 5V Arduino’s ISP connection to ATtiny45 pins with LEDs

Figure 16 shows how the LEDs should be wired.

Figure 17: Arduino Nano as ISP with feeback LEDs

Figure 17 shows the Arduino programmer we are using, with 3 feedback LEDs, switchable external 5V with an indicator LED, and a 6-pin ISP header wired offboard.

Note that there are many other ways to program an AVR chip, and AVR programming can be a complex topic. For the sake of brevity, we are trying to stick to the simplest and most minimal programming methods in this article, but it is worth diving into that topic if you plan to do a lot of AVR programming, or if you would like to know more about what is happening behind the scenes.

In Part 4 of our Relay True Bypass Switching articles we will expand on the relay code we are using here to implement “smart” switching functionality found in many popular guitar pedals that use relay switching.