AVR basics: Using cheap ultrasonic rangefinders

As part of my cunning plan to develop ‘smart’ sensors, I’ve been playing around with infrared rangefinders (and may do a post on those soon). But the first kind of rangefinder I ever tried was the ultrasonic type, so it was time to reacqauint myself. And, in particular, I wanted to see if I could do this on the cheap.

The first ultrasonic rangefinder I bought was a Devantech model. These are very easy to use because they have I2C interfaces. You just write to a register to get them to ping, wait a short while, then read the result from another register.

But they’re expensive. A quick search finds the two models I used are currently for sale at €14 and €28. Ouch!

And it’s not as though the sensors themselves are expensive or difficult to use. I picked up a batch of five HC-SR04 models from AliExpress for $8 including shipping. Yup, that’s just $1.60 a pop.

You need to feed them with +5V power and a ground connection. And then there are just two signal lines to connect to your microcontroller – Trigger and Echo.

At startup, you set the Trigger line as an output and the Echo line as an input. And you take the Trigger line low. Then, whenever you want to take a reading, the process is this:

  • Take the Trigger line high, hold it there for 10µs, and then take it low again. This prompts the sensor to send out a short burst of ultrasonic pulses. The sensor will respond by taking the Echo line high for a while, the length of time it’s high being proportional to the distance measured. So…
  • Loop until the Echo line goes high.
  • When it does, start a timer.
  • Loop until the Echo line goes low.
  • When it does, read the timer to get the desired value.

And in essence, that’s it. The key to this is the timer.

Small and cheap

I chose to use an Atmel ATTiny84A as my microcontroller of choice because it’s small and cheap but has enough pins to make it useful as my go-to smart sensor chip. And I’m running it using the internal clock at 1MHz. That becomes relevant, as we’ll see.

Here’s some code:

/*
* HC-SR04.cpp
*/
#ifndef F_CPU
#define F_CPU 1000000UL
#endif

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

#define TRIG_PIN PA1
#define ECHO_PIN PA2
#define MAX_ECHO_TIME 23200 // 23200us is 400cm max range.
#define DIST_FACTOR 58

uint16_t ping(void)
{
    unsigned long elapsed = 0;
    PORTA |= (1 << TRIG_PIN);       // set trigger pin high
    _delay_us(10);                  // wait for minimum of 10us
    PORTA &= ~(1 << TRIG_PIN);      // take trigger pin low again
    while ((PINA & (1 << ECHO_PIN)) == 0); // loop until echo pin goes high
    TCNT1 = 0;                      // reset timer
    while (PINA & (1 << ECHO_PIN)); // loop until echo pin goes low again
    elapsed = TCNT1; // read timer 
    elapsed = elapsed / ( F_CPU / 1000000.0); // to scale for clock speed 
    if(elapsed > MAX_ECHO_TIME) elapsed = 0;
    return (uint16_t)elapsed;
}

int main(void)
{
    // -----------------
    // ----- SETUP -----
    // -----------------
    DDRA = 0; // defaults to this, but let's be specific. Also sets Echo pin as input.
    DDRA = (1 << TRIG_PIN);       // set as output
    PORTA &= ~(1 << TRIG_PIN);    // set trigger pin low
    TCCR1B |= (1 << CS10);  // set prescaler to clk/1 (no prescaling)

    uint16_t echoTime = 0;

    // ---------------------
    // ----- MAIN LOOP -----
    // ---------------------
    while (1)
    {
        echoTime = ping();
        _delay_ms(100);
    }
}

Right at the start, I’m ensuring we have F_CPU, the clock frequency, defined.

In the setup section, we define pins to handle the Trigger (output) and Echo (input) lines.

Then we set the prescaler for the timer. I’m using Timer1, which is 16-bit. And I’m using its most basic functionality – setting it to 0 and then, a short while later, reading how many clock cycles it has counted.

You can prescale the timer. This is done by setting its clock input to a fraction of the microcontroller’s main clock. If you’re running the microcontroller fast, for example, you can use a prescaler to help ensure that the numbers counted by the timer remain within its 16-bit capabilities by dividing by a factor of 8, 64, 256 or 1,024.

With Timer1, you set the prescaler using bits CS10, CS11 and CS12 (actually bits 0, 1 and 2) of the TCCR1B register. Here’s an important point though – you have to set at least one of these bits. If all three bits are left at their default value of 0 then the timer won’t run.

CS12 CS11 CS10 Description
0 0 0 No clock source – timer/counter stopped
0 0 1 clk/1 (no prescaling)
0 1 0 clk/8
0 1 1 clk/64
1 0 0 clk/256
1 0 1 clk/1024
1 1 0 External clock source on T1 pin. Clock on falling edge.
1 1 1 External clock source on T1 pin. Clock on rising edge.

Here are some examples of what this might look like in code (pick one, not all three!):

TCCR1B |= (1 << CS11 | 1 << CS10);      // set prescaler to clk/64
TCCR1B |= (1 << CS12);                  // set prescaler to clk/256
TCCR1B |= (1 << CS12 | 1 << CS10);      // set prescaler to clk/1024

As it happens, at the clock speed I’m using I don’t really need any prescaling, so I set only the CS10 bit to set prescaling to 1 (which is the same as saying ‘divide by 1’ or no prescaling).

In the main loop, I just repeatedly call the ping() function. What you do with the result is up to you (in my final code I sent information to an LCD screen).

There’s a bit of scaling going on in the ping() function too. So let’s walk through it.

uint16_t ping(void)
{
    unsigned long elapsed = 0;
    PORTA |= (1 << TRIG_PIN);       // set trigger pin high
    _delay_us(10);                  // wait for minimum of 10us
    PORTA &= ~(1 << TRIG_PIN);      // take trigger pin low again
    while ((PINA & (1 << ECHO_PIN)) == 0); // loop until echo pin goes high
    TCNT1 = 0;                      // reset timer
    while (PINA & (1 << ECHO_PIN)); // loop until echo pin goes low again
    elapsed = TCNT1; // read timer
    elapsed = elapsed / ( F_CPU / 1000000.0); // to scale for clock speed
    if(elapsed > MAX_ECHO_TIME) elapsed = 0;
    return (uint16_t)elapsed;
}

We set up the elapsed variable. I chose an unsigned long because that’s a four-byte type that gives scope for doing calculations. In this particular example we really don’t need that headroom and a couple of bytes could be saved using a uint16_t, but what the hell – let’s think about future uses.

We take the Trigger line high, hold it there for 10µs and drop it again. (On my oscilloscope, by the way, this pulse actually lasts 11.64µs. But as this is a minimum, that’s good enough.)

We then loop until the Echo line goes high. When it does, we reset Timer1 to 0 by setting the value of the TCNT1 register.

We then loop until the Echo line goes low again. When it does, we read the value of the TCNT1 register – which by this time has counted a number of clock cycles – into the elapsed variable.

Because I’m running the ATTiny84A at 1MHz, it just so happens that each clock tick is 1µs. So the number in elapsed at this moment will be the length of the Echo pulse in µs.

But that won’t be true if we’re running at a higher clock speed and haven’t prescaled. Let’s say we’re running at 16MHz – the clock will have ticked 16 times more often. So if we want result in µs, we need to divide by 16. The simplest way of ensuring we always get the right scaling factor is to divide elapsed by F_CPU – the clock speed in Hertz – divided by a million. This is an unnecessary step in my case (F_CPU divided by a million is just 1), and I’ll probably comment out that line for this application. But I’ll leave it there to remind me that I may need it some other time.

The eagle-eyed among you will have spotted that I’ve defined DIST_FACTOR but not used it. In fact, I do use it in my own version of this code: dividing the echo time (scaled so that it’s in µs) by 58 gives the actual distance in cm.

And it seems pretty accurate. This sensor is good only for distances up to 4m, which explains the if statement in the echo() function. Above that, things get weird. It’s also good down to about 3cm. Again, below that readings rise again in odd ways. (An infrared sensor would be better at those distances anyway.) And ultrasonic sensors are easily confused: they like flat, matt surfaces that are orthogonal to the sensor. Clutter, angles and shiny surfaces make them angry.

But hey, the sensor’s working. Now all I have to do is make it smart.

Never miss a post

Enter your email address to subscribe to this blog and receive notifications of new posts by email.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.