Why I Started Using This Tool

After wiring the HC-SR04, I'd measured a distance from the console with pinctrl and gpiomon, then did the math by hand. Fun once, useless for an alarm. I needed a thread that measures on its own, ten times a second, and drops the result into the shared frame the rest of the program already reads.

My first plan was libgpiod, the modern GPIO library. Then I checked my cross-compiler: gcc-linaro 5.1 from 2015, with Linux 4.0 headers. The GPIO character device that libgpiod is built on arrived in 4.8. No header, no library.

That pushed me to /dev/mem, the oldest trick in the embedded Linux book: a file that is the chip's physical memory. Map one page at the GPIO address from the datasheet, and the registers are just an array in your program. For timing a pulse to the microsecond, it's about as fast and as direct as it gets. It sounds scary. It's about twenty lines of C, and once you've seen it, "a register" stops being a mystery word forever.

Missed the sensor wiring? Start with Part 9: Teaching My Raspberry Pi to See: The HC-SR04 and Two Resistors That Save a GPIO Pin (video).

Here's the video walkthrough of this post, if you'd rather watch it step by step:

Watch on YouTube

What It Does

The distance task is one new file, ultrasonic.c, plus one setter in the frame:

  • Setup: open("/dev/mem") as root, mmap one 4 KB page at physical address 0x3F200000 (the Pi 3's GPIO block; the datasheet's 0x7E200000 is the bus address), and now gpio[...] is the hardware. TRIG (GPIO23) becomes an output, ECHO (GPIO24) an input, using the 3-bits-per-pin GPFSEL registers.
  • Each ping: TRIG high for 10 µs through GPSET0, low through GPCLR0, then spin on GPLEV0 until ECHO goes high, and again until it goes low. CLOCK_MONOTONIC gives the two times, and (t1 − t0) / 58 gives centimetres.
  • Safety: every wait has a timeout, so a missing echo means "no reading" instead of a frozen program. Readings outside 2–400 cm are marked invalid.
  • Sharing: the result goes into the frame with Frame_setDistance(cm, valid), both fields under one lock, so no thread ever sees half an update.
  • Timing: busy-wait during the echo (microsecond precision), then sleep until the next 100 ms tick with the same drift-free Tick code as the alive tasks.

In the console, dist= now follows my hand: 52, 24, 9 cm, and --- when I point it at the room. With a book and a ruler it's within a centimetre or two.

Step by Step

1. Recap: what the task has to do

From Part 9, one measurement is:

  1. Pull TRIG (GPIO23) high for 10 µs, then low.
  2. Wait for ECHO (GPIO24) to go high. Note the time.
  3. Wait for ECHO to go low again. Note the time.
  4. distance_cm = (t_low − t_high) / 58.

Plus the rules from the datasheet:

  • Range about 2 cm to 400 cm. 400 cm is 400 × 58 = 23 200 µs of echo.
  • If nothing reflects, ECHO stays high for a long time (often ~38 ms), and we treat that as "nothing in range".
  • At least 60 ms between pings. We'll use 100 ms: ten readings a second, plenty for an alarm.

And the result goes where we planned it back in the shared frame:

    UINT16  distanceCm;                     /* last HC-SR04 reading       */
    BOOLEAN distanceValid;                  /* FALSE until a good reading */

2. How does a C program talk to a GPIO pin?

So far we've used several ways to touch hardware from Linux. Here's how each one fits this job:

WayWhat it isFit for timing an echo?
/sys/class/gpioThe old "pins as files" interfaceDeprecated. On new kernels the pin numbers are even shifted (GPIO24 is no longer 24). Skip it.
GPIO character device + libgpiodThe modern, official way (/dev/gpiochip0), what gpioset/gpiomon useThe right tool for most projects. But our 2015 cross-compiler only has Linux 4.0 headers, and that interface arrived in 4.8. No linux/gpio.h, no libgpiod to link against.
/dev/gpiomemA Raspberry Pi-only driver that maps just the GPIO pageSafer shortcut, works without root. But it hides where the registers are, and it only exists on Pi kernels.
/dev/memThe classic Linux device for all of physical memory✔ No library needed, works on any Linux board. We map one page at the address from the datasheet, and reading a pin is a single memory read, millions of times a second.

You can check that toolchain claim yourself:

grep LINUX_VERSION_CODE /c/gcc-linaro-5.1-arm-linux-gnueabihf/arm-linux-gnueabihf/libc/usr/include/linux/version.h
# #define LINUX_VERSION_CODE 262144     → 262144 = 4 × 65536 → Linux 4.0

So /dev/mem it is. Why this one? Because it's the honest, bare-metal view: the file is the chip's physical address space. Byte 0x3F200000 of /dev/mem is the first GPIO register, exactly where the datasheet says it is. Nothing in between. Learn it once and the same trick works on almost any ARM board running Linux.

But respect it, because it comes with two strings attached:

  • Root only. Opening /dev/mem needs root (to be precise, the CAP_SYS_RAWIO capability). Being in the gpio group does not help here. Our service from Part 5 already runs as root, and for testing by hand we use sudo.
  • A wrong address can hit anything. /dev/mem will happily map the kernel's own memory, or another device's registers. So we map exactly one 4 KB page, at one fixed, checked address, and we never write outside it.

The trade-off, so you know: when we touch the registers directly, the kernel no longer knows what we're doing with those two pins. That's fine for pins that only our program uses. It's exactly what pinctrl itself does under the hood.

3. Registers: the chip's control panel

A register is a 32-bit memory location that isn't really memory: it's wired to hardware. Write to it, and a pin changes. Read it, and you get what's on the pins right now.

Where do the GPIO registers live? Open the BCM2835 ARM Peripherals datasheet (the Pi 3's BCM2837 uses the same GPIO block) and you'll find GPIO at 0x7E200000. Careful: that's a bus address, the address seen by the chip's internal bus. The ARM cores see the peripherals through a different window:

bus address      0x7E200000   (what the datasheet prints)
peripheral base  0x7E000000 → 0x3F000000 on the Pi 3 (as the ARM sees it)
GPIO offset                    + 0x200000
ARM physical     0x3F200000   (what we pass to mmap on /dev/mem)

That base address changes with the Pi model, so this is the one number you must get right:

BoardChipGPIO physical address
Pi 1, Zero, Zero WBCM28350x20200000
Pi 2, Pi 3, Zero 2 WBCM2836 / BCM28370x3F200000
Pi 4, Pi 400BCM27110xFE200000
Pi 5RP1Completely different GPIO chip, not covered here

Don't take my word for it, ask your Pi:

sudo cat /proc/iomem | grep gpio
# 3f200000-3f2000b3 : gpio@7e200000

There it is: physical 3f200000, named after its bus address 7e200000. And the block is only 0xB4 bytes long, so one 4 KB page holds all of it.

Inside that block, we need only four kinds of register:

RegisterOffsetWhat it does
GPFSEL0..50x00, 0x04, …Function select: input, output, or a special function. 3 bits per pin, 10 pins per register
GPSET00x1CWrite a 1 to bit n: pin n goes high. Zeros do nothing.
GPCLR00x28Write a 1 to bit n: pin n goes low. Zeros do nothing.
GPLEV00x34Read: bit n is the current level of pin n

The offsets are in bytes, from 0x3F200000. We'll look at the registers as an array of 32-bit words, so we divide by 4: GPSET0 is word 0x1C / 4 = 7.

Where are GPIO23 and GPIO24 in GPFSEL? Each register holds 10 pins, 3 bits each:

register = pin / 10        GPIO23 → 23 / 10 = 2 → GPFSEL2
shift    = (pin % 10) × 3  GPIO23 → 3 × 3 = 9  → bits 9..11
                           GPIO24 → 4 × 3 = 12 → bits 12..14

the 3 bits:  000 = input   001 = output   (others = special functions)

To change only our 3 bits without touching the other 9 pins in that register, we read, clear, set, write:

gpio[reg] = (gpio[reg] & ~(7u << shift)) | (mode << shift);
  • 7u << shift is three 1-bits sitting on our pin's position.
  • & ~(…) clears those three bits, keeps everything else.
  • | (mode << shift) puts our new mode in.

Why do GPSET and GPCLR exist, instead of one "output level" register? Because writing 1u << 23 to GPSET0 changes only GPIO23, in one write. No read-modify-write, so no risk of fighting with another program that's changing a different pin at the same moment.

volatile matters here. Normally the compiler is allowed to think: "you just read that variable, nothing changed it, so I'll reuse the old value." For GPLEV0 that would be a disaster: the hardware changes it, and the compiler can't see that. Marking the pointer volatile UINT32 * tells the compiler: every read and write must really happen, every time. Without it, our wait-for-ECHO loop could become an infinite loop.

4. The frame gets a new writer: Frame_setDistance

Same three-line pattern as Frame_setLed from Part 7. In frame.h:

void Frame_setDistance(UINT16 cm, BOOLEAN valid);

In frame.c:

void Frame_setDistance(UINT16 cm, BOOLEAN valid)
{
    pthread_mutex_lock(&frameLock);
    frame.distanceCm    = cm;
    frame.distanceValid = valid;
    pthread_mutex_unlock(&frameLock);
}

Why set both fields in one call, under one lock? Because they belong together. If we set them with two separate calls, another thread could grab a snapshot in between and see "valid, 0 cm": a reading that never happened. Fields that only make sense together are written together.

And Frame_toString from Part 6 already handles both: it prints the number when distanceValid is TRUE, and --- when it isn't. That code has been waiting four parts for today.

One writer per field: the ultrasonic task owns distanceCm and distanceValid. Nobody else writes them.

5. ultrasonic.h: the public menu

#ifndef ULTRASONIC_H
#define ULTRASONIC_H

/************************************************************************/
/*  Ultrasonic task: measures distance with the HC-SR04 sensor.        */
/*  A 10 us pulse on TRIG starts a ping, ECHO stays high for the       */
/*  round-trip time of the sound, and distance_cm = time_us / 58.      */
/*  GPIO is accessed through /dev/mem: the BCM2837 GPIO registers at   */
/*  physical address 0x3F200000. Needs root.                           */
/************************************************************************/

#include "ctypes.h"

#define ULTRASONIC_PERIOD_MS    (100u)  /* one ping every 100 ms (datasheet: >= 60 ms) */
#define ULTRASONIC_TRIG_GPIO    (23u)   /* BCM GPIO23, physical pin 16 */
#define ULTRASONIC_ECHO_GPIO    (24u)   /* BCM GPIO24, physical pin 18, via divider */
#define ULTRASONIC_MIN_CM       (2u)    /* closer than this the sensor can't hear the echo */
#define ULTRASONIC_MAX_CM       (400u)  /* farther than this the reading is not reliable */

INT32 Ultrasonic_start(void);
void  Ultrasonic_stop(void);

#endif

Same shape as swalive.h and hwalive.h: start, stop, a period. The pin numbers are BCM numbers, the same ones from our pin table, with the physical pin in the comment so you can find the wire. Rewire to other pins? Change two lines.

6. ultrasonic.c: the distance thread

Here's the whole file. Then we'll walk through it piece by piece.

#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <sys/mman.h>
#include <time.h>
#include <unistd.h>
#include "frame.h"
#include "tick.h"
#include "ultrasonic.h"

/* BCM2837 (Pi 3): peripherals start at 0x3F000000, GPIO block at +0x200000.
   The datasheet lists bus address 0x7E200000; the ARM sees it at 0x3F200000. */
#define GPIO_PHYS_BASE  (0x3F200000u)

/* BCM2837 GPIO registers, as 32-bit word offsets from GPIO_PHYS_BASE */
#define GPFSEL0     (0x00u / 4u)    /* function select: 3 bits per pin, 10 pins per register */
#define GPSET0      (0x1Cu / 4u)    /* write 1 to bit n: pin n goes high */
#define GPCLR0      (0x28u / 4u)    /* write 1 to bit n: pin n goes low  */
#define GPLEV0      (0x34u / 4u)    /* read bit n: current level of pin n */

#define GPIO_INPUT      (0u)
#define GPIO_OUTPUT     (1u)
#define GPIO_MAP_SIZE   (4096u)     /* one page, enough for all GPIO registers */

#define TRIG_PULSE_US       (10u)       /* datasheet: at least 10 us */
#define ECHO_START_US       (30000u)    /* ECHO must go high within this time   */
#define ECHO_END_US         (25000u)    /* 25 ms is about 430 cm: beyond MAX_CM */
#define US_PER_CM           (58u)       /* 343 m/s, there and back */

static pthread_t         usThread;
static volatile BOOLEAN  usRunning = FALSE;
static volatile UINT32  *gpio      = NULL;  /* volatile: every access really hits the hardware */

static void gpioMode(UINT32 pin, UINT32 mode)
{
    UINT32 reg   = GPFSEL0 + (pin / 10u);
    UINT32 shift = (pin % 10u) * 3u;

    gpio[reg] = (gpio[reg] & ~(7u << shift)) | (mode << shift);
}

static void gpioWrite(UINT32 pin, BOOLEAN high)
{
    if (high)
    {
        gpio[GPSET0] = 1u << pin;
    }
    else
    {
        gpio[GPCLR0] = 1u << pin;
    }
}

static BOOLEAN gpioRead(UINT32 pin)
{
    return ((gpio[GPLEV0] >> pin) & 1u) ? TRUE : FALSE;
}

static UINT64 nowUs(void)
{
    struct timespec t;

    clock_gettime(CLOCK_MONOTONIC, &t);
    return (UINT64)t.tv_sec * 1000000u + (UINT64)(t.tv_nsec / 1000);
}

/* Busy-wait until 'pin' reads 'level'. Returns 0 and the time in *at,
   or -1 if it didn't happen within timeoutUs. */
static INT32 waitLevel(UINT32 pin, BOOLEAN level, UINT32 timeoutUs, UINT64 *at)
{
    UINT64 start = nowUs();

    while (gpioRead(pin) != level)
    {
        if ((nowUs() - start) > timeoutUs)
        {
            return -1;
        }
    }
    *at = nowUs();
    return 0;
}

/* One ping. Returns TRUE and the distance in *cm if the echo was in range. */
static BOOLEAN measureCm(UINT16 *cm)
{
    UINT64 t0;
    UINT64 t1;
    UINT64 us;

    if (gpioRead(ULTRASONIC_ECHO_GPIO))             /* last echo still busy */
    {
        return FALSE;
    }

    gpioWrite(ULTRASONIC_TRIG_GPIO, TRUE);          /* 10 us trigger pulse */
    t0 = nowUs();
    while ((nowUs() - t0) < TRIG_PULSE_US)
    {
    }
    gpioWrite(ULTRASONIC_TRIG_GPIO, FALSE);

    if (waitLevel(ULTRASONIC_ECHO_GPIO, TRUE, ECHO_START_US, &t0) != 0)
    {
        return FALSE;                               /* sensor never answered */
    }
    if (waitLevel(ULTRASONIC_ECHO_GPIO, FALSE, ECHO_END_US, &t1) != 0)
    {
        return FALSE;                               /* nothing in range */
    }

    us = t1 - t0;
    if ((us < (UINT64)ULTRASONIC_MIN_CM * US_PER_CM) ||
        (us > (UINT64)ULTRASONIC_MAX_CM * US_PER_CM))
    {
        return FALSE;
    }
    *cm = (UINT16)(us / US_PER_CM);
    return TRUE;
}

static void *ultrasonic_task(void *arg)
{
    struct timespec next;
    UINT16  cm = 0u;
    BOOLEAN valid;

    (void)arg;
    clock_gettime(CLOCK_MONOTONIC, &next);

    while (usRunning)
    {
        valid = measureCm(&cm);
        Frame_setDistance(valid ? cm : 0u, valid);  /* measure first, then lock */

        Tick_addMs(&next, ULTRASONIC_PERIOD_MS);
        Tick_sleepUntil(&next);
    }
    return NULL;
}

INT32 Ultrasonic_start(void)
{
    INT32 fd;
    void *map;

    fd = open("/dev/mem", O_RDWR | O_SYNC);         /* root only: this is all of physical memory */
    if (fd < 0)
    {
        perror("/dev/mem");
        return -1;
    }
    map = mmap(NULL, GPIO_MAP_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, GPIO_PHYS_BASE);
    close(fd);                                      /* the mapping stays valid */
    if (map == MAP_FAILED)
    {
        perror("mmap /dev/mem");
        return -1;
    }
    gpio = (volatile UINT32 *)map;

    gpioWrite(ULTRASONIC_TRIG_GPIO, FALSE);         /* low before it becomes an output */
    gpioMode(ULTRASONIC_TRIG_GPIO, GPIO_OUTPUT);
    gpioMode(ULTRASONIC_ECHO_GPIO, GPIO_INPUT);

    usRunning = TRUE;
    if (pthread_create(&usThread, NULL, ultrasonic_task, NULL) != 0)
    {
        usRunning = FALSE;
        gpioMode(ULTRASONIC_TRIG_GPIO, GPIO_INPUT);
        munmap(map, GPIO_MAP_SIZE);
        gpio = NULL;
        return -1;
    }
    return 0;
}

void Ultrasonic_stop(void)
{
    if (usRunning)
    {
        usRunning = FALSE;
        pthread_join(usThread, NULL);               /* wait for the last ping */
        gpioMode(ULTRASONIC_TRIG_GPIO, GPIO_INPUT); /* leave the pin safe */
        munmap((void *)gpio, GPIO_MAP_SIZE);
        gpio = NULL;
        Frame_setDistance(0u, FALSE);
    }
}

Let's walk through it.

The one address: GPIO_PHYS_BASE. 0x3F200000 is the number from step 3, with the datasheet's bus address in the comment so the next reader can check it. On another Pi model, this is the only line you change.

Opening the door: open + mmap. open("/dev/mem") asks the kernel for access to physical memory, and that's where root comes in. mmap then takes one 4 KB page starting at GPIO_PHYS_BASE (the last argument, the offset into /dev/mem, is the physical address) and makes it show up inside our program's memory, at an address the kernel picks. That offset must be a multiple of the page size, 4096; 0x3F200000 is. From now on, gpio[7] is the GPSET0 register: no system call, no driver in the middle, just a memory access. We can close the file right away; the mapping stays valid until munmap.

O_SYNC is not optional here. On /dev/mem it asks for an uncached mapping. Without it, the CPU could keep an old copy of GPLEV0 in its cache and our loop would never see ECHO change.

Setting up the pins. TRIG becomes an output, ECHO an input. Notice the order: we write TRIG low first, then switch it to output. That way the pin can't flash high for a moment and trigger a stray ping. ECHO needs no pull-up or pull-down: the 2 kΩ resistor from our divider already holds it low.

The four little helpers are the register math from step 3, each in its own function with a readable name. Inside the measurement code we just write gpioWrite(TRIG, TRUE), and nobody has to remember that GPSET0 lives at 0x1C.

nowUs(): a microsecond clock. clock_gettime(CLOCK_MONOTONIC) gives seconds plus nanoseconds. We fold them into one 64-bit microsecond count, so subtracting two times is a single -. Monotonic, as always in this series: it never jumps when the Pi syncs its clock over the network.

The 10 µs pulse is a tiny spin loop. Why not usleep(10)? Because a sleep means "wake me up at least 10 µs from now", and Linux may take a lot longer to bring us back. For 10 µs, simply watching the clock is more precise. And the datasheet only asks for at least 10 µs, so a pulse that's slightly long is fine.

waitLevel and the timeouts. Every wait has a deadline. That's the most important habit in hardware code: never wait forever for a pin. A loose wire, a missing sensor, a soft cushion that swallows the sound: any of them would hang a loop without a timeout, and our alarm would freeze. With timeouts, the worst case is "no reading this time".

  • ECHO_START_US (30 ms): the sensor should start its echo pulse within a few hundred µs of the trigger. If it never does, something is wrong with the sensor or the wiring.
  • ECHO_END_US (25 ms ≈ 430 cm): any echo pulse longer than that is beyond what we trust. We stop waiting and report "nothing in range".

Range check. Readings under 2 cm or over 400 cm are reported as not valid. The buzzer task will treat "not valid" as "nothing close": silent.

Why busy-wait here, when every other task sleeps? Good question, because it's a real trade-off:

Sleeping (alive tasks)Busy-waiting (echo timing)
CPU while waiting~0 %100 % of one core
Timing precisionMilliseconds, whenever Linux wakes usAbout a microsecond: we read the pin as fast as the loop spins

For the ECHO pulse, 1 µs of error is 0.02 cm, while waking up late by 1 ms would be 17 cm of error. So we spin, but only for the length of one echo (≤ 25 ms), and then we go back to sleeping on the drift-free Tick timing from Part 7.

Honest cost: with nothing in range, the task spins ~25 ms out of every 100 ms: about 25 % of one core. With something close it's much less (50 cm ≈ 3 ms). The Pi 3 has four cores, so the alive tasks, and later the buzzer and LCD, have plenty of room.

Lock only to write the result. The thread measures first, without the lock, and then calls Frame_setDistance, which locks for a few nanoseconds. Never hold the frame mutex while waiting for hardware. If we held it during a 25 ms echo, every other task would be stuck for 25 ms waiting to read the frame.

Ultrasonic_stop leaves things the way we found them. Join the thread, set TRIG back to an input (just like pinctrl set 23 ip pd at the end of Part 9), unmap the page, and mark the distance invalid. Same clean-up habit as handing the ACT LED back to mmc0 in Part 7.

7. main.c: this one is fatal

#include "ultrasonic.h"
    if (Ultrasonic_start() != 0)
    {
        /* an alarm that can't see is useless: this one is fatal */
        fprintf(stderr, "could not start ultrasonic task\n");
        HWAlive_stop();
        SWAlive_stop();
        return EXIT_FAILURE;
    }
    Ultrasonic_stop();                  /* stop in reverse start order */
    HWAlive_stop();
    SWAlive_stop();

Compare this with the ACT LED in Part 7. No LED? We print a warning and keep going, because the alarm still works. No sensor? Then the alarm is blind, and pretending to run would be worse than stopping. So we stop the tasks we already started and exit with a failure code. systemd sees that failure and logs it, so journalctl -u proxalarm tells you exactly why. Forgot sudo? That lands here too, with /dev/mem: Permission denied on the line above.

Deciding which failures are fatal is a design decision, not a coding detail. Write it down in a comment, like we did.

8. CMake

add_executable(proxalarm
    src/main.c
    src/frame.c
    src/tick.c
    src/swalive.c
    src/hwalive.c
    src/ultrasonic.c)

One new .c file. No new library to link: open, mmap and clock_gettime are all part of the C library we already use.

9. Build, deploy, measure

cmake --preset pi-debug
cmake --build --preset deploy

Run it by hand first. With /dev/mem, sudo is required:

ssh au@au.local
sudo ./proxalarm
Main running. Press Ctrl+C to stop.
[alive 1] dist=---cm buz=0%/0ms led=0 lcd="Proximity alarm|starting..."
[alive 2] dist=52cm buz=0%/0ms led=1 lcd="Proximity alarm|starting..."
[alive 3] dist=51cm buz=0%/0ms led=1 lcd="Proximity alarm|starting..."
[alive 4] dist=24cm buz=0%/0ms led=0 lcd="Proximity alarm|starting..."
[alive 5] dist=9cm buz=0%/0ms led=0 lcd="Proximity alarm|starting..."
[alive 6] dist=---cm buz=0%/0ms led=1 lcd="Proximity alarm|starting..."

The very first line can show ---: the software alive task printed before the first ping finished. After that, dist= follows your hand. Move it closer and the number drops. Point the sensor at the ceiling, or at the open room, and you'll see ---: nothing in range.

The ruler test. Put a book flat in front of the sensor at 10, 50 and 100 cm, measured from the front of the silver eyes. You should be within a centimetre or two. Compare that with the gpiomon test from Part 9: same sensor, same wiring, but now the timing happens inside our own loop instead of passing through the kernel's interrupt handling first.

Three threads, one frame. The ultrasonic task writes the distance, the hardware alive task writes the LED state, the software alive task reads both and prints them. None of them call each other.

Press Ctrl+C, then check that TRIG was left safe:

pinctrl get 23
# 23: ip    -- | lo // GPIO23 = input

An input again. (Your exact output depends on the pinctrl version; the important bit is ip.) Now the real way, as the service from Part 5, which runs as root, so /dev/mem just works:

sudo systemctl restart proxalarm
journalctl -u proxalarm -f          # watch the frame lines, Ctrl+C to stop watching

10. When things go wrong

ProblemWhat it meansFix
/dev/mem: Permission deniedYou're not root. The gpio group is not enough for /dev/memsudo ./proxalarm, or run it as the service
mmap /dev/mem: Operation not permittedThe kernel restricts /dev/mem (CONFIG_STRICT_DEVMEM, or an iomem= setting)Check /boot/firmware/cmdline.txt for iomem=strict. Stock Raspberry Pi OS allows GPIO access as root
Runs, but always --- and pinctrl get 23 never shows opWrong GPIO_PHYS_BASE for your Pi model: we're poking at the wrong pagesudo cat /proc/iomem | grep gpio and put that address in GPIO_PHYS_BASE
could not start ultrasonic task and the program exitsThat's our fatal path doing its jobRead the line just above it for the real reason
Always dist=---cmNo echo: sensor not powered, Trig/Echo swapped, or nothing in front of itRe-run the pinctrl + gpiomon test from Part 9. If that fails too, it's wiring, not code
Always ---, and gpiomon showed nothing in Part 9 eitherECHO never reaches the PiCheck the divider: the middle point must go to pin 18
A reading now and then is much too bigLinux paused our thread for a moment in the middle of an echoNormal for a busy desktop-style OS. Later improvements: real-time priority (SCHED_FIFO) or a median of 3 readings
Readings jump around a lotSoft or angled target, or two sensors pinging at onceUse a flat, hard target facing the sensor
Distance correct but the console shows it a bit lateThe alive task prints every 500 ms, the sensor measures every 100 msExpected: the print shows the latest reading at that moment
pinctrl get 23 shows op after a crashThe program was killed before Ultrasonic_stop could clean uppinctrl set 23 ip pd, or reboot

11. What's next

The alarm can see: ten times a second, distanceCm and distanceValid land in the frame. And the buzzer from Part 8 has been sitting on the breadboard, waiting. Next we write the buzzer task: it reads the distance from the frame and turns it into buzzerVolume and buzzerPeriodMs, driving GPIO18's hardware PWM. Far away: nearly silent. Close: loud and urgent. That's the moment this project becomes a real proximity alarm.

Key Takeaways

  1. /dev/mem + mmap at 0x3F200000 puts the Pi 3's GPIO registers in your program's memory. No library, no system call per access, and it works on any Linux board. We use it because our 2015 toolchain has no linux/gpio.h.
  2. /dev/mem is all of physical memory: root only, and map exactly one page. The base address depends on the Pi model (check /proc/iomem). /dev/gpiomem is the Pi's safer, non-root shortcut to the same page.
  3. Four registers do it all: GPFSEL (3 bits per pin: input/output), GPSET (write 1 → high), GPCLR (write 1 → low), GPLEV (read the level). Change only your own bits, use O_SYNC, and make the pointer volatile.
  4. Busy-wait for microseconds, sleep for milliseconds, and never wait without a timeout. Spin to time the ECHO pulse (~1 µs precision), give up after 25 ms, then sleep with Tick_sleepUntil until the next ping.
  5. Measure without the lock, then write both fields together with Frame_setDistance(cm, valid). And decide on purpose which failures are fatal: no LED is fine, no sensor is not.

Final Verdict

Going down to physical memory was less scary than I expected: one address, four registers, a few lines of bit math, and one volatile. It's also a great way to really understand what a "GPIO library" does underneath. For a project on a modern toolchain, I'd still start with libgpiod. And if you don't want to run as root, the Pi's own /dev/gpiomem maps the same page more safely. But for seeing exactly where the hardware lives, /dev/mem is the clearest teacher there is.

Gotchas I hit, so you don't have to:

  • /dev/mem needs root. The gpio group isn't enough. Use sudo by hand; the systemd service already runs as root.
  • The address depends on the Pi model. 0x3F200000 on a Pi 3, 0xFE200000 on a Pi 4, 0x20200000 on a Pi 1/Zero. sudo cat /proc/iomem | grep gpio tells you.
  • Map one page, nothing more. /dev/mem will let you write anywhere, including over the kernel.
  • Forget O_SYNC or volatile and your wait loop may never end. The compiler and the cache don't know the hardware changes GPLEV0.
  • Write TRIG low before making it an output, or you can fire a stray ping on startup.
  • Never wait on a pin without a timeout. A loose wire shouldn't freeze your alarm.
  • Don't hold the mutex while you measure. Measure first, then lock just long enough to write.
  • Busy-waiting costs CPU. Up to ~25 % of one core with nothing in range. Fine on a 4-core Pi 3, but know it's there.
  • The occasional wild reading is Linux pausing your thread mid-echo. A median filter or real-time priority fixes it later.
  • Decide what's fatal. No LED: keep running. No sensor: stop and say why.

Next up: the buzzer task, which finally turns distanceCm into sound.

Related Videos