Why I Started Using This Tool

After the last step my Pi started the proximity alarm automatically at boot, and that created a new problem: I couldn't tell whether it was working. No screen, no LED yet, no sound. A running program and a crashed one looked identical.

I also knew what was coming. The alarm will have a sensor, a buzzer, an LCD, an LED and a UART all running at once, and they all need to share data: the distance, the buzzer settings, the text on the screen. If I didn't get the sharing right before adding hardware, I'd be chasing random glitches for weeks. So I wanted one small, boring task that would force me to build that foundation properly: a heartbeat. The blinking light on your router, the slow pulse on a laptop's power button: engineers call it an alive task.

Missed the autostart setup? Start with Part 5: Starting a Raspberry Pi App on Boot with systemd (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 project now has two small modules:

  • frame.c/h: one struct, the frame, holding every control and status value the alarm will ever need (alive counter, distance, buzzer volume and period, LED state, two LCD lines). The frame and its mutex are private to frame.c. Other code gets in only through Frame_get() (lock, copy everything, unlock), small setters like Frame_incAlive(), and Frame_toString(), which formats a snapshot as one line of text.
  • swalive.c/h: a pthread that every second bumps the alive counter, takes a snapshot of the frame, and prints it. It sleeps with clock_nanosleep until an absolute time on CLOCK_MONOTONIC, so the beat never drifts.

main() is now a conductor: it sets up the frame, starts the task, waits for Ctrl+C or systemctl stop, and then stops the task cleanly with pthread_join. The result is a line like this every second in journalctl -u proxalarm -f:

[alive 42] dist=---cm buz=0%/0ms led=0 lcd="Proximity alarm|starting..."

and, finally, a green active (running) in systemctl status.

Step by Step

1. Where we are, and the plan for today

Here's the main.c we've been carrying since Part 4. It starts one thread that prints five lines and quits:

static void *hello_task(void *arg)
{
    ...
    for (i = 0; i < 5; i++)
    {
        printf("hello from the Pi, count %d\n", i);
        sleep(1);
    }
    return NULL;
}

You might have spotted some commented-out lines in there too: SWAlive_start() and SWAlive_stop(). Today we make them real. Four new files:

File Job
frame.hDefines the frame: the one struct holding all control and status data
frame.cOwns the frame and its mutex; the only code that touches it directly
swalive.hThe alive task's public functions: start and stop
swalive.cThe thread itself: every second, read the frame and print it

Splitting code into a .h (the "menu": what you can call) and a .c (the "kitchen": how it's done) is how every C project stays organized as it grows. Other files only ever #include the header.

2. Threads in 60 seconds

A process is a running program: our proxalarm. A thread is a line of work inside that process. Every program starts with one thread, the one running main(). With pthreads (POSIX threads, the standard thread library on Linux) we can start more, and they all run at the same time and share the same memory.

The proximity alarm will end up with one thread per job:

proxalarm (one process)
β”œβ”€β”€ main          starts everything, waits for "stop"
β”œβ”€β”€ swalive       prints a heartbeat every second      ◄── today
β”œβ”€β”€ hwalive       prints the frame over UART           (next)
β”œβ”€β”€ led           blinks the ACT LED
β”œβ”€β”€ ultrasonic    measures distance
β”œβ”€β”€ buzzer        beeps faster when things get close
└── lcd           shows the numbers

Why threads instead of one big loop? Because each job has its own rhythm. The ultrasonic sensor wants to measure many times a second, the buzzer needs precise beeps, the LCD is slow, and the heartbeat ticks once a second. With threads, each task just sleeps until its next turn, and Linux handles the juggling.

3. The problem with sharing: race conditions

"They all share the same memory" is the powerful part, and also the dangerous part. Imagine the ultrasonic thread is updating the distance while the LCD thread is reading it:

ultrasonic thread                    lcd thread
-----------------                    ----------
write distance low byte  = 0x2C
                                     read distance  β†’ half old, half new!
write distance high byte = 0x01

The LCD might show a number that never existed. This is called a race condition, and it's the nastiest kind of bug: it happens once every few hours, never when you're watching, and never in the debugger.

The fix is a mutex (short for mutual exclusion). Think of it as the single key to a room. Before touching the shared data, a thread takes the key (pthread_mutex_lock). If someone else has it, it waits at the door. When it's done, it hands the key back (pthread_mutex_unlock). Only one thread is ever inside.

Our project rule, straight from the goals:

One shared data structure for all control and status data, protected by a mutex. Lock it, do your work, unlock it immediately.

4. frame.h: designing the frame

I call the shared structure the frame. Even though today only the alive counter changes, I want to design the full frame now, for every task we plan to build. That way all the later posts just fill in values, and the design is visible from day one.

#ifndef FRAME_H
#define FRAME_H

#include <stddef.h>
#include "ctypes.h"

#define FRAME_LCD_COLS      (16u)   /* LCD1602: 16 characters per line  */
#define FRAME_TOSTRING_LEN  (128u)  /* buffer size for Frame_toString() */

typedef struct
{
    /* status: written by the input / alive tasks */
    UINT32  aliveCount;                     /* +1 every SW alive tick     */
    UINT16  distanceCm;                     /* last HC-SR04 reading       */
    BOOLEAN distanceValid;                  /* FALSE until a good reading */

    /* controls: read by the output tasks */
    UINT8   buzzerVolume;                   /* 0..100 %                   */
    UINT16  buzzerPeriodMs;                 /* beep period, 0 = silent    */
    BOOLEAN ledOn;                          /* ACT LED state              */
    CHAR    lcdLine1[FRAME_LCD_COLS + 1u];  /* +1 for the '\0'            */
    CHAR    lcdLine2[FRAME_LCD_COLS + 1u];
} FRAME;

void Frame_init(void);
void Frame_get(FRAME *copy);
void Frame_incAlive(void);
void Frame_toString(const FRAME *f, CHAR *buf, size_t len);

#endif

Let's read it slowly:

  • #ifndef FRAME_H / #define / #endif is an include guard. If two files both include frame.h, the compiler only sees it once.
  • ctypes.h is our project's types file: UINT32, UINT8, BOOLEAN and so on. They say exactly how many bits each value has, which matters on embedded hardware.
  • Status vs. controls. Status is what the world tells us: is the app alive, how far is the object. Controls are what we tell the hardware: how loud the buzzer is, is the LED on, what the LCD says. Sorting the fields this way makes it obvious which task writes what.
  • distanceValid is there because "0 cm" and "no reading yet" are very different things. Never let a missing value pretend to be a real one.
  • lcdLine1[16 + 1]. The LCD1602 has 2 lines of 16 characters. In C a string needs one extra byte for the '\0' that marks its end, so 17.
  • Notice what's not in the header: the actual frame variable and the mutex. Other files can't reach in and grab the data without the lock. The only way in is through the four functions.

5. frame.c: the frame and its key

#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include "frame.h"

static FRAME frame;
static pthread_mutex_t frameLock = PTHREAD_MUTEX_INITIALIZER;

static at file level means "private to this file." There is exactly one frame and one key in the whole program, and only frame.c can see them. PTHREAD_MUTEX_INITIALIZER sets up the mutex before main even runs.

void Frame_init(void)
{
    pthread_mutex_lock(&frameLock);
    memset(&frame, 0, sizeof(frame));       /* safe defaults: silent, LED off */
    frame.distanceValid = FALSE;
    strncpy(frame.lcdLine1, "Proximity alarm", FRAME_LCD_COLS);
    strncpy(frame.lcdLine2, "starting...",     FRAME_LCD_COLS);
    pthread_mutex_unlock(&frameLock);
}

Start from safe defaults. Zeroed means buzzer silent, LED off, no distance. For a device that makes noise, "off" is always the safe starting point.

Now the most important function in the project:

void Frame_get(FRAME *copy)
{
    pthread_mutex_lock(&frameLock);
    *copy = frame;                          /* copy, then unlock right away */
    pthread_mutex_unlock(&frameLock);
}

This is the snapshot pattern. Lock, copy the whole struct in one go, unlock. The caller now has its own private copy that no other thread can change, and it can take as long as it likes with it. The lock is held for about a microsecond.

The rookie mistake is the opposite: lock, then format text, print to the console, talk to the LCD over I2C... and only then unlock. Meanwhile every other task is standing at the door waiting for the key. Hold the lock for the copy, not for the work.

Writing works the same way, short and sweet:

void Frame_incAlive(void)
{
    pthread_mutex_lock(&frameLock);
    frame.aliveCount++;
    pthread_mutex_unlock(&frameLock);
}

Even ++ needs the lock. It looks like one step, but the CPU does it in three: read, add, write. Two threads doing it at once can lose a count.

And finally our "toString". C doesn't have one built in, so we write it with snprintf:

/* Works on a snapshot from Frame_get(), so no lock is needed here */
void Frame_toString(const FRAME *f, CHAR *buf, size_t len)
{
    CHAR dist[8];

    if (f->distanceValid)
    {
        snprintf(dist, sizeof(dist), "%u", (unsigned)f->distanceCm);
    }
    else
    {
        snprintf(dist, sizeof(dist), "---");
    }

    snprintf(buf, len, "[alive %lu] dist=%scm buz=%u%%/%ums led=%u lcd=\"%s|%s\"",
             (unsigned long)f->aliveCount, dist,
             (unsigned)f->buzzerVolume, (unsigned)f->buzzerPeriodMs,
             (unsigned)f->ledOn, f->lcdLine1, f->lcdLine2);
}
  • It takes a const FRAME *, a snapshot, so it never touches the lock. It just formats.
  • snprintf, never sprintf. The n means "never write more than len bytes." If the text is too long it gets cut off instead of overwriting whatever memory comes after the buffer.
  • %% prints a literal % sign.
  • The casts to unsigned and unsigned long make the types match the format codes exactly, so it prints correctly on the 32-bit Pi and on a 64-bit PC.

6. swalive.h and swalive.c: the heartbeat thread

The header is tiny on purpose:

#ifndef SWALIVE_H
#define SWALIVE_H

#include "ctypes.h"

#define SWALIVE_PERIOD_MS   (1000u)

INT32 SWAlive_start(void);
void  SWAlive_stop(void);

#endif

main doesn't need to know anything about threads, clocks or the frame. It only needs "start" and "stop."

Now the thread. First, the simple version you'd write first:

while (1)
{
    print_the_frame();     /* takes a few milliseconds */
    sleep(1);              /* then 1 full second       */
}

That's not "every second." That's "every one second plus however long the printing took." A few milliseconds per tick doesn't sound like much, but it adds up: the heartbeat slowly drifts, and over a day it's seconds behind. For a heartbeat that's harmless. For a buzzer rhythm or a sensor sample rate, it isn't. So let's learn the right way now, on the easy task.

The fix is to sleep until an absolute time, not for a relative time:

relative: sleep(1)                     absolute: sleep until next tick
work│──1s──│work│──1s──│work           work│──────│work│──────│work
    0     1.003    2.006                    0      1.000   2.000
          drift grows β–Ί                     always on the grid

Here's swalive.c:

#include <pthread.h>
#include <stdio.h>
#include <time.h>
#include "frame.h"
#include "swalive.h"

static pthread_t        aliveThread;
static volatile BOOLEAN aliveRunning = FALSE;

/* Move an absolute time forward by ms, keeping tv_nsec in 0..999999999 */
static void addMs(struct timespec *t, UINT32 ms)
{
    t->tv_sec  += ms / 1000u;
    t->tv_nsec += (long)(ms % 1000u) * 1000000L;
    if (t->tv_nsec >= 1000000000L)
    {
        t->tv_sec++;
        t->tv_nsec -= 1000000000L;
    }
}

A struct timespec stores time as whole seconds (tv_sec) plus nanoseconds (tv_nsec). addMs adds our period and carries over when the nanoseconds pass one full second, just like carrying a 1 in long addition.

/* Sleep until absolute time 'until' on the monotonic clock.
   Linux supports this directly; if the C library can't (e.g. MinGW on
   Windows), fall back to a relative sleep for the time that is left. */
static void sleepUntil(const struct timespec *until)
{
    struct timespec now;
    struct timespec left;

    if (clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, until, NULL) == 0)
    {
        return;
    }

    clock_gettime(CLOCK_MONOTONIC, &now);
    left.tv_sec  = until->tv_sec  - now.tv_sec;
    left.tv_nsec = until->tv_nsec - now.tv_nsec;
    if (left.tv_nsec < 0)
    {
        left.tv_sec--;
        left.tv_nsec += 1000000000L;
    }
    if (left.tv_sec >= 0)
    {
        nanosleep(&left, NULL);
    }
}
  • CLOCK_MONOTONIC is a clock that only ever counts up, steadily, from boot. The normal wall clock (CLOCK_REALTIME) can jump: the Pi has no battery-backed clock, so when it reaches the internet it may suddenly jump its date forward by months. A timer on the wall clock would go crazy. Timers belong on the monotonic clock.
  • TIMER_ABSTIME means "the time I'm giving you is a moment on that clock, not a duration."
  • The fallback is an honest lesson I learned while making this video. I also build the program on Windows with the win-build preset to test quickly, and there clock_nanosleep with CLOCK_MONOTONIC + TIMER_ABSTIME simply returns an error straight away without sleeping. My first version ignored the return value, and on Windows the "once a second" heartbeat printed five thousand lines in two minutes. The lesson: always check the return value of system calls, even the boring ones. On the Pi the first call succeeds and the fallback never runs.

And the thread itself:

static void *swalive_task(void *arg)
{
    struct timespec next;
    FRAME snapshot;
    CHAR  text[FRAME_TOSTRING_LEN];

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

    while (aliveRunning)
    {
        Frame_incAlive();
        Frame_get(&snapshot);                       /* lock, copy, unlock */
        Frame_toString(&snapshot, text, sizeof(text));
        printf("%s\n", text);                       /* print outside the lock */

        /* sleep until an absolute time, so the period never drifts */
        addMs(&next, SWALIVE_PERIOD_MS);
        sleepUntil(&next);
    }
    return NULL;
}
  • The signature void *task(void *arg) is what pthread_create requires: it takes one pointer in and returns one pointer out. We don't need either, so (void)arg; tells the compiler "unused on purpose" and keeps -Wextra quiet.
  • next is the grid. We read the clock once, then every loop add exactly 1000 ms. No matter how long printf takes, the next wake-up is always exactly one period after the last one.
  • The order inside the loop is the whole frame story in four lines: update the shared data (with lock), take a snapshot (with lock), format the copy (no lock), print (no lock).

Start and stop:

INT32 SWAlive_start(void)
{
    aliveRunning = TRUE;
    if (pthread_create(&aliveThread, NULL, swalive_task, NULL) != 0)
    {
        aliveRunning = FALSE;
        return -1;
    }
    return 0;
}

void SWAlive_stop(void)
{
    if (aliveRunning)
    {
        aliveRunning = FALSE;
        pthread_join(aliveThread, NULL);            /* wait for the last tick */
    }
}
  • pthread_create starts swalive_task running in parallel and returns right away. A non-zero result means it failed (for example, out of memory), and we report that to main.
  • SWAlive_stop lowers the flag, then pthread_join waits for the thread to finish its current tick and leave the loop. Never just walk away from a running thread: join it, so you know it's done before the program exits. Worst case this waits one period, one second.

7. main.c: start, wait, stop cleanly

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
#include "frame.h"
#include "swalive.h"

static volatile sig_atomic_t running = 1;

/* Ctrl+C (SIGINT) and systemctl stop (SIGTERM) end the main loop */
static void on_signal(int sig)
{
    (void)sig;
    running = 0;
}

int main(void)
{
    setvbuf(stdout, NULL, _IOLBF, 0);   /* line buffering, so no need for fflush(stdout) */

    signal(SIGINT,  on_signal);
    signal(SIGTERM, on_signal);

    Frame_init();

    if (SWAlive_start() != 0)
    {
        fprintf(stderr, "could not start SW alive task\n");
        return EXIT_FAILURE;
    }

    printf("Main running. Press Ctrl+C to stop.\n");

    while (running)
    {
        sleep(1);
    }

    SWAlive_stop();
    printf("Main stopped.\n");
    return EXIT_SUCCESS;
}

main is now a conductor, not a worker: set up the frame, start the tasks, wait, shut them down in order. Every task we add later is just one more _start() and _stop() line here.

How does it know when to stop? Signals. A signal is a tiny message Linux sends to a process:

You do Linux sends Default without a handler
Press Ctrl+C in the SSH terminalSIGINTProgram dies instantly
sudo systemctl stop proxalarmSIGTERMProgram dies instantly

"Dies instantly" means the buzzer could be left screaming and the LED left on. So we install on_signal for both. All it does is set running = 0. That's on purpose: a signal handler can interrupt your code anywhere, so it should do the bare minimum. volatile sig_atomic_t is the one variable type C promises is safe to set from a handler. The main loop notices within a second, and we shut down properly.

Also: Frame_init() comes before any thread starts, so nobody ever sees an uninitialized frame.

8. Tell CMake about the new files

In proxalarm/CMakeLists.txt, the executable now has three source files:

add_executable(proxalarm
    src/main.c
    src/frame.c
    src/swalive.c)

Headers don't need listing, the compiler finds them through #include. If you forget a .c here, you'll get a linker error like undefined reference to 'Frame_init': the menu was there, but the kitchen wasn't.

9. Build, deploy, watch it beat

From Part 4, one command builds, stops the old copy (including the systemd service from Part 5) and copies the new one over:

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

Run it by hand over SSH first:

ssh au@au.local
./proxalarm
Main running. Press Ctrl+C to stop.
[alive 1] dist=---cm buz=0%/0ms led=0 lcd="Proximity alarm|starting..."
[alive 2] dist=---cm buz=0%/0ms led=0 lcd="Proximity alarm|starting..."
[alive 3] dist=---cm buz=0%/0ms led=0 lcd="Proximity alarm|starting..."
^CMain stopped.

One line per second, the whole frame in each line, and Ctrl+C exits cleanly with "Main stopped." instead of just vanishing. dist=--- is our distanceValid = FALSE doing its job: no sensor yet, so no fake number.

Now the moment promised at the end of Part 5. Start it as a service:

sudo systemctl start proxalarm
systemctl status proxalarm
● proxalarm.service - Proximity alarm (embedded C demo)
     Loaded: loaded (/etc/systemd/system/proxalarm.service; enabled; preset: enabled)
     Active: active (running) since ...; 5s ago
...
[alive 4] dist=---cm buz=0%/0ms led=0 lcd="Proximity alarm|starting..."
[alive 5] dist=---cm buz=0%/0ms led=0 lcd="Proximity alarm|starting..."

Green dot, active (running). Because main never returns now, systemd sees a living service. Follow the heartbeat live:

journalctl -u proxalarm -f

And check the clean shutdown:

sudo systemctl stop proxalarm
journalctl -u proxalarm -n 3
# ... Main stopped.
# ... proxalarm.service: Deactivated successfully.

"Deactivated successfully" right away, not a 90-second wait followed by a SIGKILL. That's our SIGTERM handler working.

Want to see that the timing doesn't drift? Ask the journal for exact timestamps:

journalctl -u proxalarm -o short-precise -n 5

The microseconds stay on the same grid line after line.

10. Testing on the PC first

Our presets also have a Windows-only build, handy for checking logic without touching the Pi:

cmake --preset win-debug
cmake --build --preset win-build
build/win-debug/proxalarm.exe

Same heartbeat, same Ctrl+C behavior. (This is exactly where I caught the clock_nanosleep problem from step 6.) One Windows quirk: its C library treats _IOLBF as full buffering, so when output goes to a file or pipe, lines can arrive in big chunks instead of one per second. That's Windows only; on the Pi, _IOLBF really is line by line. It's a quick check, not a replacement for running on the real board: the Pi is ARM, 32-bit and Linux, and that's what we ship.

11. When things go wrong

Problem What it means Fix
undefined reference to 'SWAlive_start'The .c file isn't being compiledAdd it to add_executable in CMakeLists.txt
implicit declaration of function 'Frame_get'You called it without including its header#include "frame.h"
Heartbeat prints thousands of lines a secondThe sleep call is failing and returning immediatelyCheck the return value; that's why sleepUntil has a fallback
Prints once and then nothingThe thread is stuck, often holding the lockMake sure every lock has its unlock on every path
Program freezes completelyDeadlock: a thread waits for a lock it, or someone waiting on it, already holdsNever call another frame function while holding the lock
Lines show up in the journal in burstsOutput bufferingKeep setvbuf(stdout, NULL, _IOLBF, 0); at the top of main
systemctl stop takes 90 s, then SIGKILLSIGTERM isn't handled, or the main loop never checks runningInstall the handler and loop on while (running)
status still shows inactive (dead)The service is running the old binarycmake --build --preset deploy, then sudo systemctl start proxalarm

12. What's next

We now have the skeleton every later task plugs into: a frame with a single key, a conductor main, and one task that runs on a steady clock. Next up is the hardware alive task: the same frame summary, but sent out of the Pi's UART pins, so you can see the heartbeat on a serial cable even when there's no network and no SSH at all.

Key Takeaways

  1. A silent device looks the same alive or dead. An alive task gives you a heartbeat you can always check: on SSH, in journalctl, and soon on UART and the LED.
  2. One frame, one mutex. All control and status data lives in one struct that only frame.c can touch, through small functions that lock, do the minimum, and unlock.
  3. Snapshot, then work. Frame_get copies the whole frame under the lock; formatting and printing happen on the copy, with the lock already released.
  4. Sleep until an absolute time on CLOCK_MONOTONIC to get a period that never drifts, and always check system-call return values.
  5. main is the conductor. It starts each task, waits for SIGINT/SIGTERM, and stops each task with pthread_join, so the app shuts down cleanly and systemd shows active (running).

Final Verdict

If you're writing a multithreaded embedded app, build the heartbeat and the shared data structure first, before any hardware. It costs one afternoon, and every later task becomes "read the frame, do my job, write the frame."

Honest gotchas I hit:

  • Hold the lock for the copy, not for the work. It's tempting to lock, print, update the LCD, then unlock. Every other thread waits the whole time. Copy, unlock, then work.
  • sleep(1) is not "once a second." It's one second plus your work time, and it drifts. Sleep until an absolute time instead.
  • Check return values, even on sleep. On my Windows test build, clock_nanosleep with CLOCK_MONOTONIC failed instantly, and my "once a second" heartbeat printed 5,000 lines in two minutes. A small fallback fixed it; ignoring the return value hid it.
  • Handle SIGTERM. Without it, systemctl stop kills the program mid-step. With a two-line handler, it shuts down cleanly in under a second.
  • Design the whole frame now. Most of the fields are zero today, but seeing the full layout early made the plan for the sensor, buzzer and LCD much clearer.

It's not flashy. It's just a line of text every second. But it's the first time the Pi feels like it's doing something on its own, and the first time I can prove it.

Related Videos