16 min read

HC-SR04 Ultrasonic

The HC-SR04 is a popular ultrasonic distance sensor that uses sound waves to measure distances. It consists of a transmitter that emits ultrasonic pulses and a receiver that detects the echoes of those pulses. The time it takes for the echo to return is used to calculate the distance to an object. The sensor has a range of approximately 2 cm to 400 cm (four metres) and an accuracy of about 3 mm. It is commonly used in robotics, automation, and other applications where distance measurement is required.

Just like a bat, the sensor emits a series of ultrasonic pulses and times the echo. The speed of sound in air is approximately 343 metres per second, which is equivalent to 0.0343 centimeters per microsecond. Therefore, the distance \(d\) to an object can be calculated using the formula:

\[d = \frac{t \times v}{2}\]

Here, \(t\) is the pulse duration and \(v\) is the speed of sound in centimetres per microsecond, and the division by \(2\) accounts for the round trip of the ultrasonic pulse (from the sensor to the object and back).

Speed of Sound

Of course, the \(v\) term depends on the air: its density, temperature, and humidity can all affect the speed of sound. It requires a approximate value. The datasheet for the HC-SR04 recommends using a value of \(0.0343\) cm/µs or 343 metres per second, which is a common approximation for the speed of sound in air at room temperature. However, for more accurate measurements, especially in varying environmental conditions, an application may want to adjust this value based on the specific temperature and humidity of the air. The speed of sound can be calculated using the following formula:

\[v = 331.4 + (0.6 \times T)\]

Where \(T\) is the temperature in degrees Celsius. This formula provides a more accurate estimate of the speed of sound based on the ambient temperature.

The average of 343 m/s common approximation assumes \(T \approx 19.333^\circ C\). At higher temperatures, the speed of sound increases.

Command Line

Is it possible to operate the HC-SR04 using only the command line? It is.

Take the following compound command. It triggers the ultrasonic sensor and measures the echo at flank speed. The first part of the command uses gpioset to pulse GPIO11 for at least 10 microseconds. Second, it launches gpiomon to sample and measure the resulting echo pulse times with help from awk. The output prints the echo distance in centimetres1 by scaling the duration of the pulse in seconds by a factor of \(52\), as recommended by the datasheet.

It assumes that TRIG lives on GPIO11 and ECHO sits on GPIO8.

# The HC-SR04 ultrasonic sensor measures distance by sending a trigger pulse and
# then measuring the time it takes for the echo pulse to return. The distance
# can be calculated using the formula: distance = (time * speed of sound) / 2.
# The speed of sound is approximately 343 meters per second, which is equivalent
# to 29 microseconds per centimeter. Therefore, the distance in centimeters can
# be calculated as: distance = (time in microseconds) / 58.
gpioset -t 10us,0 GPIO11=1; gpiomon -F %S GPIO8 | awk -W interactive 'NR%2==1 {t=$1; next} {print ($1 - t) * 1e6 / 58; system("gpioset -t 10us,0 GPIO11=1")}'

Interactive mode tells awk to line-buffer the output.

This very simple implementation is not perfect. It periodically errors with “device or resource busy” when setting the GPIO for the requested line. This happens because the gpioset needs to acquire the GPIO line for the trigger, but needs account for release-acquire latency even though the previous one has released it.

Enhancements

A better implementation would use a single gpioset process that continuously pulses the trigger GPIO at the required intervals, while a separate gpiomon process continuously monitors the echo GPIO and calculates the distances without needing to restart the gpioset command each time. This would avoid the “device or resource busy” errors and provide a more reliable measurement of distances. This is quite possible, but would need to synchronise the two processes to achieve full throughput, which is a bit more complex to implement in a single command line. It would likely require some form of inter-process communication (e.g., using named pipes or shared memory) to coordinate the timing between the trigger and echo measurements.

GPIO Library v2

Is operating an HC-SR04 on Linux, entirely in userland, using only the GPIO library (libgpiod) possible? It is possible. The library interfaces with the kernel’s GPIO module. Developers can, relatively easily, sense rising and falling ECHO edges and measure their duration.

Line Abstractions

The GPIO library lets developers access GPIO lines on Linux systems. Its abstractions include line settings, line configurations and request configurations. The Figure below shows the relationships between these abstractions.

GPIO Library Abstractions

Figure 1: GPIO Library Abstractions

The diagram simplifies the names, specifically by omitting the gpiod_ prefix. It also elides the argument types for brevity. The library’s documentation provides the full details. Nevertheless, it shows that “line requests” sit at the centre of the abstraction hierarchy. A line request is a collection of line settings, which in turn are collections of line configurations. Edge-event buffers load edge events from the kernel via line requests.

Notice that edge events carry a nanosecond timestamp. This is the time at which the kernel detected the edge. The kernel’s GPIO module uses the system clock to timestamp the event. There may be some latency between the actual edge and the timestamp, but it is likely negligible for ECHO pulse measurements, which are on the order of microseconds.

Scanning for Chips

GPIO “chips” represent the GPIO controller hardware. You open a chip using its character device file, e.g. /dev/gpiochip0. But this implies that you know the chip’s device path; otherwise, scan for chips.

Fortunately, the C standard library provides a function to scan directories. The standard scandir() function reads the contents of a directory and returns a list of directory entries. You can use this function to scan the /dev directory for GPIO chip device files. It takes a filter function to select only the entries that match the desired pattern, such as gpiochip*. The following code snippet demonstrates how to scan the /dev directory, or any specified directory, for GPIO chip device files and return their paths.

ssize_t scan_dir_for_gpiochip_paths(const char *dir, char ***paths) {
  /*
   * Filter closure for scandir to find GPIO chip devices. This lambda-like
   * function will be called for each entry in the directory, and it will return
   * 1 if the entry is a GPIO chip device, and 0 otherwise. The function will
   * use gpiod_is_gpiochip_device to check if the entry is a GPIO chip device,
   * and it will also filter out symlinks to avoid false positives. Note that
   * this also means that GPIO chip devices that are symlinks will be ignored,
   * but this is a reasonable trade-off.
   *
   * This is a GNU-compiler extension that allows us to define a function inside
   * another function, and it can capture variables from the enclosing function.
   * In this case, it captures the dir variable from the enclosing
   * scan_dir_for_gpiochip_paths function, which is used to construct the full
   * path of the entry for the gpiod_is_gpiochip_device check. This allows the
   * scan operation to avoid having to pass the dir variable as an argument to
   * the filter function, and it keeps the code more concise and easier to read.
   * However, this is not standard C, and it may not be supported by all
   * compilers, so it should be used with caution if portability is a concern.
   */
  int gpiochip_device_filter(const struct dirent *entry) {
    char *path;
    if (dir_entry_path(dir, entry, &path) < 0) {
      return 0;
    }
    struct stat st;
    if (lstat(path, &st) < 0 || S_ISLNK(st.st_mode)) {
      free(path);
      return 0;
    }
    const int rc = gpiod_is_gpiochip_device(path);
    free(path);
    return rc;
  }
  struct dirent **entries;
  ssize_t num_entries = scandir(dir, &entries, gpiochip_device_filter, NULL);
  if (num_entries < 0) {
    return num_entries;
  }

  /*
   * Create an array of strings to store the paths of found GPIO chip devices.
   * The caller is responsible for freeing this array using free_gpiochip_paths.
   * The caller should use the returned number of found GPIO chip devices to
   * determine how many paths are in the array. If an error occurs while
   * creating the array of paths, the function will free any allocated memory
   * and return -1.
   */
  char **found = malloc((num_entries + 1) * sizeof(char *));
  if (!found) {
    free_dir_entries(entries, num_entries);
    return -1;
  }
  ssize_t num_found = 0;
  for (ssize_t i = 0; i < num_entries; i++) {
    char *path;
    if (dir_entry_path(dir, entries[i], &path) < 0) {
      free_gpiochip_paths(found, num_found);
      free_dir_entries(entries, num_entries);
      return -1;
    }
    found[num_found++] = path;
  }
  free_dir_entries(entries, num_entries);

  /*
   * Null-terminate the array of found paths. This is not strictly necessary,
   * but it can be useful for debugging and for certain use cases where the
   * caller may want to iterate over the array of paths without knowing the
   * number of paths in advance. The caller should still use the returned number
   * of found GPIO chip devices to determine how many paths are in the array,
   * and should not rely on the null-termination to determine the end of the
   * array. If the caller does not want the null-termination, it can simply
   * ignore the last element of the array.
   */
  found[num_found] = NULL;
  if (paths) {
    *paths = found;
  } else {
    free_gpiochip_paths(found, num_found);
  }
  return num_found;
}

You will notice some missing pieces in the code snippet above. The dir_entry_path() function constructs the full path of a directory entry by concatenating the directory path and the entry name. The free_dir_entries() function frees the memory allocated for the directory entries returned by scandir(). The free_gpiochip_paths() function frees the memory allocated for the array of paths returned by scan_dir_for_gpiochip_paths(). These functions are not shown here, but they are straightforward to implement.

Filtering for GPIO chips

Of course, it requires a filter function to select only the entries that match. For this, the GPIO library supplies gpiod_is_gpiochip_device(path), which returns a non-zero value if the path is a GPIO chip device. The filter function can use this to determine whether to include the entry in the results.

The filter function answers non-zero to include the entry in the results, and zero to exclude it. It uses dir_entry_path() to construct the full path of the entry, and then calls gpiod_is_gpiochip_device() to check if it is a GPIO chip device. It also filters out symlinks to avoid false positives: devices that appear twice because of symbolic links.

As you can read in the comments, the filter function is a GNU-compiler extension that allows it to be defined inside another function, and it can capture variables from the enclosing function. In this case, it captures the dir variable from the enclosing scan_dir_for_gpiochip_paths() function, which is used to construct the full path of the entry for the gpiod_is_gpiochip_device() check. This allows the scan operation to avoid having to pass the dir variable as a static argument to the filter function, and it keeps the code more concise and easier to read. However, this is not standard C, and it may not be supported by all compilers, so it should normally be used with caution if portability is a concern.

There is another side effect of the filter closure: it creates a binary with an executable stack. The linker issues a warning about this, but it is not a security concern in this case. Why is that? The GNU compiler implements the filter function as a closure using a trampoline, which means that it captures the dir variable from the enclosing function and stores it in a hidden data structure on the stack. This allows the filter function to access the dir variable without having to pass it as an argument. However, this also means that the filter function needs to be able to execute code on the stack, which is why the linker issues a warning about an executable stack. In this case, it is not a security concern because the filter function does not contain any malicious code, and it is only used for filtering directory entries.

Edge Event Generator Pattern

The GPIO library provides a way to read edge events from GPIO lines, but it requires some setup and configuration. Reading edges, rising and falling, requires a pattern that is not immediately obvious.

A GPIO edge buffer has a limited capacity. Given a GPIO line request, a buffer read operation will return a number of events up to the buffer’s capacity. What happens if the request accumulates more events than the buffer can hold? After processing the first batch of events, the buffer needs refilling. The event processor repeats the refill until either (1) the process has seen all the available events, or (2) some error occurs.

A GPIO edge-event generator

It is useful to encapsulate the edge-event generator pattern in a C structure. The structure holds the state of the generator, including the request, the buffer, the maximum number of events to generate, the number of events generated, and the current index in the buffer. The structure can be used to create a generator object that reads edge events from a GPIO line request in a loop, refilling the buffer as needed until the desired number of events has been generated or an error occurs. The following code snippet defines the gpio_edge_event_generator structure.

struct gpio_edge_event_generator {
  struct gpiod_line_request *request;     /*!< The GPIO line request to read edge events from. */
  struct gpiod_edge_event_buffer *buffer; /*!< The buffer used to store edge events read from the request. */
  size_t max_events;                      /*!< The maximum number of edge events to generate. */
  int num_events;                         /*!< The number of edge events generated. */
  unsigned long index;                    /*!< The current index in the edge event buffer. */
};

The max_events field is used to limit the number of edge events generated by the generator. By using this value when initialising the generator, the generator can be used to read all currently available edge events from the line request without blocking or waiting for additional events to become available. This allows for efficient processing of edge events as they occur, while also providing a mechanism to limit the number of events read in a single operation, which can help manage resource usage and ensure that the program remains responsive.

Initialising the generator involves setting the request, buffer and max_events fields to the appropriate values, and setting the num_events and index fields to zero. The generator can then be used in a loop to read edge events from the line request, refilling the buffer as needed until the desired number of events has been generated or an error occurs. The following code snippet demonstrates how to initialise the generator.

void gpio_edge_event_generator_init(struct gpio_edge_event_generator *generator /* generator to initialise */,
                                    struct gpiod_line_request *request /* request to read edge events from */,
                                    struct gpiod_edge_event_buffer *buffer /* buffer to store edge events */,
                                    size_t max_events /* maximum number of edge events to generate */) {
  generator->request = request;
  generator->buffer = buffer;
  generator->max_events = max_events;
  generator->num_events = 0;
  generator->index = 0;
}

Once set up, the generator can be used to read edge events from the line request in a loop, refilling the buffer as needed until the desired number of events has been generated or an error occurs. Its “next” function returns the next edge event from the buffer, refilling the buffer as needed. The function returns 1 if an event is returned, 0 if no more events are available, and -1 on error. The following code snippet demonstrates how to implement the “next” function.

int gpio_edge_event_generator_next(struct gpio_edge_event_generator *generator, struct gpiod_edge_event **event) {
  if (!generator || !event) {
    return -1;
  }
  if (generator->index >= generator->num_events) {
    if (generator->max_events == 0) {
      return 0;
    }
    const size_t capacity = gpiod_edge_event_buffer_get_capacity(generator->buffer);
    const size_t max_events = generator->max_events < capacity ? generator->max_events : capacity;
    if (max_events == 0) {
      return 0;
    }
    generator->num_events = gpiod_line_request_read_edge_events(generator->request, generator->buffer, max_events);
    if (generator->num_events <= 0) {
      return generator->num_events;
    }
    generator->index = 0;
    generator->max_events -= (size_t)generator->num_events;
  }
  *event = gpiod_edge_event_buffer_get_event(generator->buffer, generator->index);
  if (!*event) {
    return -1;
  }
  generator->index++;
  return 1;
}

The generator keeps refilling the buffer up to capacity until the requested number of events has been generated. The max_events field is decremented by the number of events read from the buffer, and the num_events field is updated to reflect the number of events currently in the buffer. The index field is reset to zero each time the buffer is refilled, so that the next call to gpio_edge_event_generator_next() will return the first event in the refilled buffer. The function returns 1 if an event is returned, 0 if no more events are available, and -1 on error. This allows the caller to easily iterate over the edge events output by the generator, while also providing a mechanism to limit the number of events read in a single operation: limited by the buffer capacity.

Usage Examples

You can find a working command-line HC-SR04 Ultrasonic sensor reader at GitHub. It incorporates the previously-discussed GPIO device scanning, edge-event generator and comes with optional Redis Stream output. The program accepts command-line arguments for the ECHO and TRIG GPIO pins, as well as optional Redis host, port, and stream max length parameters.

Printing Pulse Widths to Standard Output

Print pulse widths to standard output:

hc-sr04 --echo GPIO8 --trig GPIO11

Sending Readings to Redis Stream

Send readings to Redis Stream hc-sr04 on localhost:

hc-sr04 --echo GPIO8 --trig GPIO11 --host 127.0.0.1

With explicit Redis port and stream max length:

hc-sr04 --echo GPIO8 --trig GPIO11 --host 127.0.0.1 --port 6379 --maxlen 1000

Read the stream in another terminal:

redis-cli XREAD BLOCK 0 STREAMS hc-sr04 $

Each stream entry includes:

  • pulse_width_ns field whose value encodes, in decimal, the ECHO pulse width in nanoseconds.

The program emits pulse width, not distance. This is by design. Translation of pulse width to distance depends on temperature and humidity, which are not known to the program.

Conclusions

Since distance depends on echo delay and temperature, the sensor output would benefit from a temperature sensor. That does imply that the output should be raw delta time \(t\), the duration of the ECHO pulse, since the sensor by itself has no access to temperature.

The HC-SR04 is easy to prototype from the command line and robust enough to run from userland via libgpiod, provided that edge timing is handled carefully and line ownership is managed correctly. In practice, the most reliable output from the sensor is raw ECHO pulse width, not a baked-in distance estimate.

That design choice keeps measurement and interpretation separate. The sensor can report accurate timing, while the application can convert timing to distance using the current speed of sound for the local temperature (and, if needed, humidity). For real deployments, pairing the HC-SR04 with ambient sensing yields more trustworthy distance estimates than assuming a fixed acoustic model. The HC-SR04 works well on Linux from userland, but production-grade results come from disciplined engineering around timing, ownership, and environmental context. The sensor should report pulse width; the application should own distance calculation.

Treat timing as truth and distance as a derived product. That separation keeps the data reusable, the model improvable, and the system trustworthy.

“This isn’t a car. This is the Batmobile.”—Batman (1989)


  1. Personally, I prefer “freedom units” as the Americans say, i.e. inches, but the datasheet uses centimetres, so I follow suit.↩︎