Developing in C for the ATmega328P: I/O Ports

5 minute read

Where I describe how to improve the performance of your programs by accessing the input/output (I/O) ports on the Arduino Uno, natively.

Introduction

The Arduino framework and Uno hardware has performed an admirable job in abstracting much of the complexity of the ATmega328P into something which is easier to understand. This approach works well for a beginner in microcontrollers and embedded C programming. Once you begin to understand how the ATmega328P works, its not unusual to find the Arduino simplification is holding you back.

The Issue is Speed

Controlling the IO ports is an excellent example. Using pinMode() and digitalRead() works well in low speed applications. Once you want to multi-task or control higher frequency devices, the commands will hold you back and prevent your software from working as intended.

We can test this using the classic blink function. If we remove the delay() after each digitalWrite() commands, we’ll have a program which looks like this:

Arduino blink.ino

// the setup function runs once when you press reset or power the board
void setup() {
  // initialize digital pin LED_BUILTIN as an output.
  pinMode(LED_BUILTIN, OUTPUT);
}

// the loop function runs over and over again forever
void loop() {
  digitalWrite(LED_BUILTIN, HIGH);   // turn the LED on (HIGH is the voltage level)
//  delay(1000);                       // wait for a second
  digitalWrite(LED_BUILTIN, LOW);    // turn the LED off by making the voltage LOW
//  delay(1000);                       // wait for a second
}
#include "unolib.h"
#include "pinMode.h"
#include "digitalWrite.h"
#include "delay.h"
int main(void)
{
    // use built-in led and set to output
    pinMode(LED_BUILTIN, OUTPUT);
    while(1) 
    {
        // toggle led on and off
        digitalWrite(LED_BUILTIN, HIGH);
        // delay(500);
        digitalWrite(LED_BUILTIN, LOW);
        // delay(500);
    }
    return 0; 
}
#include <delay.h>
int main(void)
{
    // set pin to output
    DDRD |= (_BV(PORTD7));

    while(1) 
    {
        // toggle led on and off
        PORTD |= (_BV(PORTD7));
        // _delay_ms(1000);
        PORTD &= ~(_BV(PORTD7));
    }
    return 0; 
}

Results

We can measure the frequency, which will tell us our “absolute speed” when using these commands (Measurement in frequency (kHz)) :

Pin Arduino AVR_C AVR (native)
2 147 172 2000
7 147 112 2000
8 147 213 2000
11 119 152 2000
LED_BUILTIN (13) 147 128 2000

We learn a couple of things:

  1. When using either the Arduino or the AVR_C frameworks, we do lose speed. The native manipulation of the ports using native bit setting commmands provides a typical 500% increase in speed.
  2. We also see that our speeds aren’t consistent using the frameworks. I was a bit surprised by this and measured several times, however, each time the speed was the same as indicated in the table.

Why Speed is Important

Computers are great at performing a task very quickly. This concept is fundamental to multi-tasking which is the appearance of doing multiple things at once. All computers (even the most advanced) don’t perform multiple tasks at once, they perform one task extremely fast, then perform then next (or parts of each) task. This is why modern advanced CPU’s have multiple cores, so that each core is able to focus on a set of tasks and the combination of cores make the computer appear even faster.

The AVR 8-bit microcontroller ATmega328P will not be confused with an advanced multi-core, multi-GHz computer, however, the principal remains the same. To maximize performance, optimize the program for speed. And we can see from the table above, if you wish to maximize the capability of your ATmega328P, you will need to dive into native commands.

Native Port Manipulation

Before attempting to program using native commands, it is time to break out the datasheet for the ATmega328P. The datasheet describes how to use the digital ports in section 14.2 Ports as General Digital I/O (page 85) and has a section 14.4 Register Description (page 100) which describes how to use the ports. A detailed explanation is also here, AVR Datasheet Note: Blink.

The native set bit command looks a bit confusing when you first see it. To simplify, yet maintain the performance, I’ve created macros set_bit(port, bit) and clr_bit(port, bit) which accomplish the same task. This example, examples/blink_macro, accomplishes the same task at the speed of blink_avr, however, it uses the macros to make it easier to understand:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// avr_macro - uses bit setting by address similar to blink_avr
// The difference is using the macros set_bit(port,bit) and clr_bit(port,bit)
#include <delay.h>
#include <avr/io.h>
#include "unolib.h"

int main(void)
{
    // Identify which Uno pin to use 
    uint8_t Uno_pin = 13;

    // Setup two variables which will track the port to be manipulated (B or D)
    volatile uint8_t *PORTn;
    volatile uint8_t *DDRn;

    // pintoBit/Port provide the translation of Uno pin to specific port
    uint8_t bit = pintoBit(Uno_pin);
    PORTn = pintoPort(Uno_pin);
    DDRn = PORTn;

    // the DDRn address is right below PORTn address, set bit to make OUTPUT
    DDRn--;
    set_bit(*DDRn, bit);

    while(1) 
    {
        // setting a PORTn bit will make the pin HIGH
        set_bit(*PORTn, bit);
        _delay_ms(1000);
        // clearing a PORTn bit will make the pin LOW
        clr_bit(*PORTn, bit);
        _delay_ms(1000);
    }
    return 0; 
}

The code above performs the same task as the blink program, however, it accesses the ports using native commands instead of digitalWrite(). This allows the program to run at maximum speed (2MHz) instead of speeds ranging from 112 to 213kHz (see table above). This is an increase in performance of 5x to almost 10x.

Line 9. To make it easy for us to reference a pin, we use a variable based on the Uno pin numbering.

Line 12. It is important to setup two variables to contain the specific ports which we wish to access. These ports are based on the Uno pin number, pins 0-7 are PORTD and 8-13 are PORTB. The last letter indicates which port on the ATmega328P will be used (D or B).

Line 16. To make it easy to do the translation, two functions exist pintoBit() and pintoPort(). These functions will set the variables, PORTn and DDRn so that we can easily track them. For a better understanding of how the two functions work, compile-link-load examples/pintoPort and enter pin numbers in a serial terminal program. It will report the PORTn/DDRn addresses for that pin. It also helps to refer to the port documentation using the ATmega328P datasheet.

Line 21. These two lines are the equivalent of PinMode(pin, OUTPUT). The first line, decrements to point to the correct address and the second line sets the pin, to make it an output.

Line 27 This is the digitalWrite(pin, HIGH) command, however, it uses a macro to set the bits natively and therefore deliver maximum performance.

Line 30 This is the digitalWrite(pin, LOW) command, however, it uses a macro to set the bits natively and therefore deliver maximum performance.

Conclusion

The goal of this entry wasn’t to demonstrate how fast one can blink an LED. The objective is to provide you with knowledge of how to natively set bits in a familiar context, blink so that you can use the commands shown in your own programs.

Comments powered by Talkyard.