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.
Part 3 of our relay bypass article will demonstrate how relay switching can be used for all the benefits discussed in parts 1 and 2, how a latching relay can be used for current savings, and how to control the switching using a microcontroller. Some experience with writing code is helpful, but we will attempt to explain all operations for those without experience. All code used in this article is licensed under the MIT license. You are free to use it in your pedal designs, commercial or otherwise.
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.
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
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 # | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|
Bit Name | - | - | DDB5 | DDB4 | DDB3 | DDB2 | DDB1 | DDB0 |
Read / Write | R | R | R/W | R/W | R/W | R/W | R/W | R/W |
Initial State | 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 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 # | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|
Bit Name | - | - | PB5 | PB4 | PB3 | PB2 | PB1 | PB0 |
Read / Write | R | R | R/W | R/W | R/W | R/W | R/W | R/W |
Initial State | 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.
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.
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 # | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|
Bit Name | - | - | PINB5 | PINB4 | PINB3 | PINB2 | PINB1 | PINB0 |
Read / Write | R | R | R/W | R/W | R/W | R/W | R/W | R/W |
Initial State | 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.
In the example function identifier in Figure 6, the first int
is the function’s "return type". int
s 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 int
s 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 blink.c
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.
Relay code
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. #define
d 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 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~.
Operation | Example 1 | Example 2 | Example 3 |
---|---|---|---|
Original value | 0b1 | 0b1011 | 0b0 |
Shifted 1 bit left | 0b10 | 0b10110 | 0b0 |
Shifted 2 bits left | 0b100 | 0b101100 | 0b0 |
Shifted 3 bits left | 0b1000 | 0b1011000 | 0b0 |
Shifted 4 bits left | 0b10000 | 0b10110000 | 0b0 |
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.
Example 1 | Example 2 | Example 3 | |
---|---|---|---|
Value 1 | 0b1010 | 0b1100 | 0b1111 |
Value 2 | 0b0101 | 0b0001 | 0b0001 |
OR Result | 0b1111 | 0b1101 | 0b1111 |
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:
Example 1 | Example 2 | |
---|---|---|
Value 1, unknown bit | 0bX | 0bX |
Value 2, "OR"ed bit | 0b1 | 0b0 |
OR Result | 0b1 | 0bX |
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:
Example 1 | Example 2 | |
---|---|---|
Value 1, unknown bit | 0bX | 0bX |
Value 2, "AND"ed bit | 0b1 | 0b0 |
AND Result | 0bX | 0b0 |
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)
.
The exclamation mark preceding this operation is the logical NOT
operator. This is different from the bitwise NOT
"~". The logical NOT
will evaluate to 1 of the value it precedes is 0, and it will evaluate to 0 if the value it precedes is anything but 0.
So the result of !(PINB & 0b1)
will be 0 if the switch is unpressed, and it will be 1 if the switch is pressed.
We now use the current_switch_state
as the condition for an if/else command.
Footswitch is Pressed
We will look at the if{}
block first, which is what runs if the switch is currently pressed:
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);
}
}
Much like the D flip-flop in part 2, we are only interested in toggling the relay on the rising edge of the switch press. We do not want to toggle the relay every time the ATtiny sees that the switch is pressed, otherwise it would continuously toggle back and forth while the switch is held down. We only want to toggle the relay once each time the switch is pressed.
Inside our if(current_switch_state){
block, we have another if statement. This block of code only runs if our switch_state
variable is set to OFF. Note that this means the code inside the second if block only runs if we just read that the switch is pressed (the value in current_switch_state
), but it was previously OFF according to our switch_state
variable. In other words, it is the rising edge of a switch press. Because a rising edge is when we want to toggle the effect (whether we are turning it on or off), we toggle both the relay and the led inside this if statement. We then update the switch_state
variable to ON since we know the switch is currently pressed.
The last command inside this if{}
block is _delay_ms(DEBOUNCE_MS);
. In part 2, we used a resistor and a capacitor as a low pass filter to debounce the switch. We are not using a hardware low pass filter in this circuit, but we can debounce the switch in software. This delay command ensures that after we register a switch press, we will wait DEBOUNCE_MS
(75) milliseconds before doing any more polling of the switch, which is plenty of time for any switch bouncing to finish.
Note that the inner if{}
statement here which uses switch_state == OFF
as its condition does not have an else section after it. That is a valid use of the if{}
statement. In this case, we are only interested in executing any commands if the switch_state
variable was OFF, indicating a rising edge. If the switch_state
variable was already ON, the switch is simply being held and we do not want to toggle the relay or the LED.
Footswitch is Not Pressed
Now we will look at the else{}
block. This is the section that runs if current_switch_state
evaluates to 0 (the switch is not pressed)
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);
}
}
Inside this else{}
section we have another if
statement. Again here, we do not need to do anything if the switch is not pressed and has remained that way. We only need to execute any commands when the switch is not pressed but was previously pressed. In other words, we care about the falling edge when the switch is released. In the case of a falling edge, all we do is set the switch_state
variable to OFF to match the current switch state, and we again delay for 75ms to debounce the release (as switch bouncing can happen on both press and release).
Although the fine details of programming a microcontroller can seem complex, particularly if you are new to it, once you get a grasp on the details of the registers and how to read and modify them, a lot can be done with relatively simple code otherwise. We spent a lot of time getting into the details of individual commands in this article, but the code otherwise is quite simple. All we are doing is initializing the inputs and outputs, and then continuously reading the switch state, toggling the LED and relay on a rising edge.
Using a relay in this way provides current savings over the method used in Part 2, and it also has a lower parts count due to the ATtiny45’s ability to do things like debounce the switch in software. The lower parts count is nice for fitting this circuit inside of a pedal enclosure. You don’t necessarily need to understand all of the code to use it in the relay circuit used here, but it should give you a good base for branching out and experimenting with the code.
If you would like to download the source file we have been discussing before compiling, the relay.c
file can be found here.
Compiling and Programming the ATtiny45
Goals:
- Turn our code into data the ATtiny45 can interpret
- Write the data into the ATtiny45’s flash memory
- Run our code on the ATtiny45 to implement software-based relay bypass
Now we’ll compile this code and use it to program our ATtiny45 so we can see it in action.
Hardware Programmers
You will need an AVR programmer or a 5V Arduino board for this. There are various programmer options from Microchip/Atmel and third parties, ranging from the very powerful and expensive STK600 from Microchip to the inexpensive and open source USBasp, which is available in pre-built form from various sources. We will be using an AVRISP MkII for the hardware programmer section and a 5V Arduino Nano for the Arduino programmer section, but the process is easily adaptable to most programmers.
We will be programming the ATtiny using ISP (In System Programming). This method of programming requires 6 connections:
- ~VCC~
- ~MOSI~
- ~GND~
- ~MISO~
- ~SCK~
- ~RST~
On the AVRISP mkII and USBasp, these are accessible via a 2x3 female header with each connection labeled.
Your programmer’s ISP connection may look different, but no matter the form, the programmer’s pins should be connected to the ATtiny45 as shown in Figure 9.
If you are using a 5V Arduino, the ~MISO~, ~MOSI~, and ~SCK~ pins are digital pins ~D11~, ~D12~, and ~D13~, respectively, and we will use digital pin ~D10~ for our reset pin.
Note that you can program the ATtiny45 while it is connected to the relay circuit by making the above connections. However, if your AVR programmer provides power, make sure the relay circuit’s power is disconnected when connecting the programmer, otherwise you could damage the programmer or some of the other hardware. PCBs that use AVR chips will often include a 2x3 ISP header so that the chip can be programmed without removing it from the board, though programming the chip on a breadboard works just as well.
Software Tools
In addition to a hardware programmer, we’ll also need some software tools to program the ATtiny.
For Windows, these tools are packaged together into a bundle called WinAVR. WinAVR is the easiest way to program an AVR chip with most hardware programmers. Download the latest version and run the installer. The default settings are fine, though feel free to uncheck "Install Programmers Notepad".
For linux, the AVR toolchain can be installed via your package manager. For Ubuntu, the command would be:
apt-get install gcc-avr binutils-avr avr-libc avrdude
Compiling the Code
With the AVR toolchain installed and the programmer wired to the ATtiny45 and plugged into your computer, open the terminal or command prompt and navigate to the directory that contains your .c
file. You can do this with the "cd" command. For example:
cd C:\Users\John\avr_relay
We will be writing the below steps for relay.c
, the name of the sample included with this article. If you are compiling and programming blink.c
instead, replace "relay" with "blink" in all the below commands.
Once you have navigated to the folder containing your .c file, the code can be compiled by typing the following command and pressing enter:
avr-gcc -Os -std=gnu99 -DF_CPU=1000000UL -mmcu=attiny45 -o relay.elf relay.c
avr-gcc
is our compiler, and we are passing a handful of flags to it which set the functionality:
-Os
: Optimize for small code size-std=gnu99
: Use the C99 standard (a standard of the C programming language)-DF_CPU=1000000UL
: Define the clock speed (necessary for_delay_ms
)-mmcu=attiny45
: Set the microcontroller type-o relay.elf
: Output to the filerelay.elf
You should now have a file relay.elf
in the same folder as relay.c
. In order to program the ATtiny, we need to turn that .elf
file into a hex file that the ATtiny understands. To do so, run the following command:
avr-objcopy -j .text -j .data -O ihex relay.elf relay.hex
The above command converts the .elf
file into a hex file which is what we will program the ATtiny with.
To actually write the hex file into the ATtiny’s flash memory to program the attiny, you can use either a dedicated hardware programmer like the AVRISP MkII or you can use a 5V Arduino board.
Method 1: Writing to Flash with a Dedicated Programmer
If you are using an AVRISP MkII, the final command is:
avrdude -c avrisp2 -p attiny45 -v -P usb -U flash:w:relay.hex
If everything is connected properly, the above command should have successfully programmed the ATtiny and you can now test out the relay switching functionality. You should see a message such as:
avrdude.exe: 218 bytes of flash verified
avrdude.exe done. Thank you.
in your command prompt.
If you are using something other than an AVRISP mkII programmer, you will need to replace avrisp2
in the above command with your programmer. There are many programmer options, but the full list of supported programmers can be found In the avrdude manual, under -c programmer-id
. Make sure you use the programmer name as it is written in that list.
For some programmers in Windows, particularly third party clones, you may need to manually install drivers for them before avrdude can recognize them. You can use software like Zadig to easily install WinUSB for this type of programmer:
For some programmers in ubuntu, you may need to install libusb
:
apt-get install libusb-dev
Unfortunately, though WinAVR is a convenient way to get the software packages installed, the version of avrdude found in the latest WinAVR package is not the newest version (as of 2020). If you are using a newer programmer, or if the above avrdude command has trouble recognizing your programmer, you can track down the latest version of avrdude and install that manually. However, if the avrdude version bundled with WinAVR causes any problems, the easiest alternate method is probably installing the Arduino IDE, which comes with up-to-date tools. If installing the Arduino IDE on Windows, we suggest using the "Windows Win 7 and newer" option rather than the Windows app version that is installed through the Windows store. Note that one of the nice things about installing avrdude from WinAVR is that it will add it to the Windows PATH, which means that you can run avrdude from the command prompt by simply typing "avrdude". If you are downloading a different version of avrdude, you will either have to add its location to your Windows PATH variable or you will have to run avrdude by typing out the full path.
If you have installed the Arduino IDE, you can run the included version of avrdude by using its full path. With the default installation options, on Windows it is most likely located at C:\Program Files (x86)\Arduino\hardware\tools\avr\bin\avrdude.exe
, in which case the following command should work for a usbasp programmer for example:
"C:\Program Files (x86)\Arduino\hardware\tools\avr\bin\avrdude.exe" -c usbasp -p attiny45 -v -P usb -U flash:w:relay.hex
If you receive an error about the configuration file, try specifying the path of the Arduino IDE’s avrdude configuration file by running the following command:
"C:\Program Files (x86)\Arduino\hardware\tools\avr\bin\avrdude.exe" -c usbasp -p attiny45 -v -P usb -U flash:w:relay.hex -C "C:\Program Files (x86)\Arduino\hardware\tools\avr\etc\avrdude.conf"
Method 2: Writing to flash with an Arduino Board
If you are using a 5V arduino board such as a 5V Nano or Uno as your programmer, first connect the arduino board to your computer via USB. Open your Arduino ISP and open the Example ArduinoISP.
Program your board with this sketch as you would any other by selecting the board you are using in Tools->Board
and selecting the correct Port in Tools->Port
. Press the Upload button in the top left. There should now be a sketch running on your arduino that allows it to function as an AVR programmer.
To use avrdude with the Arduino programmer, we have to know what port the Arduino is on in Windows or the device path in Linux. You may know it from the Tools menu of the Arduino IDE, but you can also find out which port it is on via the Device Manager on Windows or find the device path on linux by looking at entries in the /dev
path.
In Windows, open the Device Manager with the Arduino unplugged and expand the Ports section. Make note of which, if any, COM ports are there already:
Next, plug the Arduino into your computer and wait for the Device Manager to refresh. If it does not refresh on its own, exit it and re-open Device Manager. Look at Ports again and see what COM port is in use that previously was not listed:
In Figure 15, the Arduino Nano is on Port COM14
. Note that the text may not match that screenshot exactly depending on which device you are using. You just need to know the COM port number.
In Ubuntu, with the Arduino unplugged and a terminal open, type the following command:
ls /dev
Make note of anything listed in the form ttyUSBx
where x is a number. Plug the arduino into the computer and run the above command again and check for anything new. We will use the new ttyUSBx
path to program the Arduino, for example /dev/ttyUSB0
.
Now that we know the COM port or device path, connect the arduino to the ATtiny45 as shown in Figure 10. Again, you don’t have to disconnect the ATtiny from the relay circuit, but in this case you must disconnect power from the relay circuit (or do not connect +5V to VCC as shown in Figure 10), as the Arduino will provide power to the circuit.
We will use the avrdude version that is included with the Arduino IDE to program the ATtiny with the Arduino board. With a dedicated programmer, we can simply specify "-P usb" as part of our avrdude command in most cases. When using an Arduino board, we must specify the COM port or device path. On Windows, use the following command:
"C:\Program Files (x86)\Arduino\hardware\tools\avr\bin\avrdude.exe" -c arduino -p attiny45 -v -P COM14 -U flash:w:relay.hex -C "C:\Program Files (x86)\Arduino\hardware\tools\avr\etc\avrdude.conf"
Replace COM14
with the COM port of your Arduino board. Replace the paths used above with your own path if you have installed Arduino in a non-default location.
On linux, use the following command:
/path/to/Arduino/hardware/tools/avr/bin/avrdude -c arduino -p attiny45 -v -P /dev/ttyUSB0 -U flash:w:relay.hex -C "/path/to/Arduino/hardware/tools/avr/etc/avrdude.conf"
Replace /dev/ttyUSB0
with the device path of your Arduino board. Replace "/path/to/Arduino"
with the path to your Arduino program directory in both locations.
After running this avrdude command, you should see a message like this:
avrdude.exe done. Thank you.
At this point, your ATtiny45 has been programmed and if it is wired up correctly you should be able to see the relay switching in action.
Note that if you plan to use an Arduino board as a long-term AVR programming solution, the ArduinoISP sketch has some additional functionality that is worth taking advantage of. Pins ~D9~, ~D8~, and ~D7~ are output pins that will display some useful debug information if you connect LEDs to them.
- ~D9~: "Heartbeat" LED, shows that the programming sketch is running
- ~D8~: Error LED, lights up if there is a programming error
- ~D7~: Programming LED, lights up when programming an AVR chip
Figure 16 shows how the LEDs should be wired.
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.