Why I Started Using This Tool

Every embedded project I've started has the same boring first hour: get code from my PC onto the board, and get a debugger attached. On earlier boards I solved it with a pile of editor config files: a settings file for the board's address, a tasks file for build and deploy, a launch file for the debugger, and a problem-matcher hack to tell the editor when the debug server was ready. It worked, but only inside that one editor, and every time I came back to a project I had to remember how all the pieces fit together.

For this Raspberry Pi proximity alarm, I wanted something simpler: a few files in Git, any terminal, one command. CMake was already building my code, so I asked myself whether it could do the rest too. It turns out it can.

Missed the console tour? Start with Part 3: dmesg, the ACT LED, and GPIO From the Console (video).

What It Does

The setup has four small pieces:

  • A toolchain file (cmake/arm-linux-gnueabihf.cmake) that tells CMake to use the Linaro arm-linux-gnueabihf cross-compiler in C:/gcc-linaro-5.1-arm-linux-gnueabihf, so Windows produces ARM Linux programs.
  • CMakeLists.txt that builds the program with pthreads, plus three custom targets: deploy (scp the program to the Pi and chmod +x it), gdbserver (start the debug server on the Pi on port 2345) and debug (launch the ARM gdb on the PC, connect, and stop at main). Each one depends on the one before it.
  • A tiny gdb script generated by CMake with the board address filled in: load the symbols, target remote au.local:2345, break main, continue.
  • CMakePresets.json that holds the board settings (user au, host au.local, folder /home/au, port 2345) once, and gives every action a short name: build, clean, deploy, gdbserver, debug, plus a win-build preset that builds a plain Windows version with MSYS2.

After that, cmake --build --preset debug compiles, deploys, starts gdbserver and drops me at the first line of main, ready to step through code that's running on the Pi.

Step by Step

1. Why we build on the PC, not on the Pi

Our PC has an x86 processor (Intel or AMD). The Raspberry Pi 3 B+ has an ARM processor. They speak completely different machine languages, so a program compiled for the PC simply won't run on the Pi, and vice versa.

We could install a compiler on the Pi and build there, but the Pi is slow, and we'd be editing code over SSH. Instead we cross-compile: a compiler that runs on Windows but produces ARM Linux programs. That compiler is called a cross-compiler toolchain, and we'll use two toolchains in this project:

Toolchain Folder Makes programs for
Linaro GCC 5.1 (arm-linux-gnueabihf)C:/gcc-linaro-5.1-arm-linux-gnueabihfThe Pi: ARM, Linux, hard-float
MSYS2 MinGW-w64C:/msys64/mingw64/binWindows itself (quick PC-only builds, later unit tests)

The name arm-linux-gnueabihf reads like a sentence: ARM CPU, Linux OS, GNU C library, EABI (the ARM rules for how functions call each other), hf for hardware floating point. That last part matters: it must match the OS, which is why we installed Raspberry Pi OS Lite 32-bit back in Part 1. This toolchain targets ARMv7, which the Pi 3 runs happily.

From the MSYS2 folder we also borrow mingw32-make.exe. Despite the "32" in its name it's a normal 64-bit Windows build of GNU Make, and it's what actually runs the build steps CMake writes for us.

Inside the Linaro folder, the tools we care about are:

C:/gcc-linaro-5.1-arm-linux-gnueabihf/bin/arm-linux-gnueabihf-gcc.exe   the C compiler
C:/gcc-linaro-5.1-arm-linux-gnueabihf/bin/arm-linux-gnueabihf-gdb.exe   the debugger (runs on the PC)

2. The plan: CMake does everything

CMake doesn't compile anything itself. It's a build generator: you describe your project once, and CMake writes the Makefiles that mingw32-make runs. CMake presets (CMakePresets.json) are saved settings. Instead of typing ten options every time, you type a short name like pi-debug.

Here's the chain we're building. Each step depends on the one before it:

build โ”€โ”€โ–บ deploy โ”€โ”€โ–บ gdbserver โ”€โ”€โ–บ debug
 compile    copy to Pi   start the       connect gdb from the PC,
 for ARM    + chmod +x   debug server    stop at main
                         on the Pi

A quick honest note: presets can only configure and build. They can't run "any command I like." So how do we deploy and debug? We add custom targets in CMakeLists.txt. A custom target is a fake "thing to build" whose recipe is any commands we want, like scp and ssh. A build preset can then point at that target. Same cmake --build command, totally different job.

3. Project layout

12_yt_pi/
โ””โ”€โ”€ proxalarm/                          the CMake project (run every command from here)
    โ”œโ”€โ”€ .gitignore                      build/
    โ”œโ”€โ”€ CMakeLists.txt                  what to build + deploy/debug targets
    โ”œโ”€โ”€ CMakePresets.json               the named settings (pi-debug, deploy, debug...)
    โ”œโ”€โ”€ cmake/
    โ”‚   โ”œโ”€โ”€ arm-linux-gnueabihf.cmake   toolchain file: "use the Linaro compiler"
    โ”‚   โ””โ”€โ”€ debug.gdb.in                gdb start-up commands (template)
    โ””โ”€โ”€ src/
        โ”œโ”€โ”€ main.c
        โ””โ”€โ”€ ctypes.h

Add proxalarm/.gitignore with one line, build/, so compiled files never end up in Git. Each preset builds into its own folder, proxalarm/build/<preset>.

4. The toolchain file

The toolchain file tells CMake: "we're building for a different computer, and here's its compiler." Create proxalarm/cmake/arm-linux-gnueabihf.cmake:

# Target: Raspberry Pi running 32-bit Linux
set(CMAKE_SYSTEM_NAME      Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)

# Where the Linaro toolchain is installed
set(TOOLCHAIN "C:/gcc-linaro-5.1-arm-linux-gnueabihf" CACHE PATH "Linaro toolchain folder")
set(CROSS     ${TOOLCHAIN}/bin/arm-linux-gnueabihf-)

set(CMAKE_C_COMPILER ${CROSS}gcc.exe)
set(GDB              ${CROSS}gdb.exe CACHE FILEPATH "ARM gdb that runs on the PC")

# Look for ARM headers/libraries only inside the toolchain, never in Windows folders
set(CMAKE_FIND_ROOT_PATH ${TOOLCHAIN}/arm-linux-gnueabihf/libc)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

Line by line:

  • CMAKE_SYSTEM_NAME Linux: the moment you set this, CMake knows it's cross-compiling. It sets a variable called CMAKE_CROSSCOMPILING, which we'll use later.
  • TOOLCHAIN: the toolchain folder, in one place. If yours is installed elsewhere, change this one line.
  • CMAKE_FIND_ROOT_PATH...: the Linaro folder includes a mini copy of the ARM Linux C library (a sysroot). These lines stop CMake from accidentally grabbing a Windows library when it searches for something like pthreads.

5. CMakeLists.txt: the program and the deploy/debug targets

Create proxalarm/CMakeLists.txt:

cmake_minimum_required(VERSION 3.25)
project(proxalarm C)

find_package(Threads REQUIRED)

add_executable(proxalarm src/main.c)
target_compile_options(proxalarm PRIVATE -Wall -Wextra)
target_link_libraries(proxalarm PRIVATE Threads::Threads)

if(CMAKE_CROSSCOMPILING)
    # Board settings (the preset fills these in)
    set(PI_USER  "au"       CACHE STRING "Login user on the Pi")
    set(PI_HOST  "au.local" CACHE STRING "Pi hostname or IP address")
    set(PI_DIR   "/home/au" CACHE STRING "Folder on the Pi to copy the program to")
    set(GDB_PORT "2345"     CACHE STRING "TCP port for gdbserver")

    set(REMOTE     ${PI_USER}@${PI_HOST})
    set(REMOTE_BIN ${PI_DIR}/proxalarm)
    set(SSH_OPTS   -o StrictHostKeyChecking=accept-new)

    # deploy: stop any old copy, copy the new one, make it executable
    add_custom_target(deploy
        COMMAND ssh ${SSH_OPTS} ${REMOTE} "pkill -x gdbserver; pkill -x proxalarm; true"
        COMMAND scp ${SSH_OPTS} $<TARGET_FILE_NAME:proxalarm> ${REMOTE}:${REMOTE_BIN}
        COMMAND ssh ${SSH_OPTS} ${REMOTE} chmod +x ${REMOTE_BIN}
        WORKING_DIRECTORY $<TARGET_FILE_DIR:proxalarm>
        COMMENT "Deploying proxalarm to ${REMOTE}:${PI_DIR}"
        VERBATIM)
    add_dependencies(deploy proxalarm)

    # gdbserver: start the debug server on the Pi in the background
    add_custom_target(gdbserver
        COMMAND ssh ${SSH_OPTS} ${REMOTE}
                "nohup gdbserver --once :${GDB_PORT} ${REMOTE_BIN} > /tmp/proxalarm.log 2>&1 < /dev/null &"
        COMMENT "Starting gdbserver on ${PI_HOST}:${GDB_PORT}"
        VERBATIM)
    add_dependencies(gdbserver deploy)

    # debug: connect the PC's ARM gdb to gdbserver and stop at main
    configure_file(cmake/debug.gdb.in debug.gdb @ONLY)
    add_custom_target(debug
        COMMAND ${CMAKE_COMMAND} -E sleep 1
        COMMAND ${GDB} -q -x ${CMAKE_BINARY_DIR}/debug.gdb
        USES_TERMINAL
        VERBATIM)
    add_dependencies(debug gdbserver)
endif()

The top half is a normal C project: one program called proxalarm, extra warnings on, and linked with pthreads (Threads::Threads), because every task in our alarm will be its own thread.

The bottom half only exists when cross-compiling, so the Windows-only build never tries to SSH anywhere. Let's walk through it:

  • The PI_* and GDB_PORT variables are cache variables: settings with a default that a preset can override.
  • deploy runs three commands on our behalf:
    1. Stops any copy of the program (or gdbserver) still running on the Pi. Linux won't let you overwrite a program while it's running ("Text file busy"). ; true makes sure this step never fails just because nothing was running.
    2. scp (secure copy, which comes with SSH) copies the program to /home/au/proxalarm. StrictHostKeyChecking=accept-new means "the first time you see this Pi, trust it automatically instead of stopping to ask." It still refuses if the Pi's identity changes later, so it's safe.
    3. chmod +x marks the file as executable on the Pi.
    add_dependencies(deploy proxalarm) means deploy always builds first. You can never copy a stale program.
  • gdbserver starts gdbserver on the Pi. It's a tiny program that loads proxalarm, freezes it before its first instruction, and waits for a debugger to connect over the network on port 2345. --once means it quits after one debug session. nohup ... & runs it in the background, so the ssh command returns right away instead of blocking. Because it's running in the background, our program's printf output goes to /tmp/proxalarm.log on the Pi.
  • debug waits one second so gdbserver has time to start listening, then launches the ARM gdb on the PC with a script of start-up commands. USES_TERMINAL tells CMake this step needs your keyboard.

Notice the chain: debug โ†’ gdbserver โ†’ deploy โ†’ proxalarm. Ask for debug, and CMake does all four steps in order.

6. The gdb start-up script

Create proxalarm/cmake/debug.gdb.in. The @...@ words get replaced with real values when CMake configures (that's what configure_file(... @ONLY) does):

set pagination off
set confirm off
file @PROJECT_BINARY_DIR@/proxalarm
set architecture arm
target remote @PI_HOST@:@GDB_PORT@
break main
continue
  • file .../proxalarm: load the PC's copy of the program. It's the same file we sent to the Pi, and it includes the debug symbols (the map from machine code back to your source lines and variable names). The Pi runs the code; the PC holds the map.
  • set architecture arm: be explicit that this is ARM code.
  • target remote au.local:2345: connect to gdbserver over the network.
  • break main then continue: gdbserver stopped the program before main even started, so we set a breakpoint on main and let it run until it gets there. You always land on the first line of main.
  • set pagination off and set confirm off stop gdb from interrupting with "press Enter" and "are you sure?" prompts.

7. CMakePresets.json: giving everything a short name

Create proxalarm/CMakePresets.json, next to CMakeLists.txt:

{
  "version": 6,
  "cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 },
  "configurePresets": [
    {
      "name": "pi-base",
      "hidden": true,
      "generator": "MinGW Makefiles",
      "binaryDir": "${sourceDir}/build/${presetName}",
      "toolchainFile": "${sourceDir}/cmake/arm-linux-gnueabihf.cmake",
      "cacheVariables": {
        "CMAKE_MAKE_PROGRAM": "C:/msys64/mingw64/bin/mingw32-make.exe",
        "PI_USER": "au",
        "PI_HOST": "au.local",
        "PI_DIR": "/home/au",
        "GDB_PORT": "2345"
      }
    },
    {
      "name": "pi-debug",
      "displayName": "Raspberry Pi - Debug",
      "inherits": "pi-base",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }
    },
    {
      "name": "pi-release",
      "displayName": "Raspberry Pi - Release",
      "inherits": "pi-base",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" }
    },
    {
      "name": "win-debug",
      "displayName": "Windows only - Debug",
      "generator": "MinGW Makefiles",
      "binaryDir": "${sourceDir}/build/${presetName}",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug",
        "CMAKE_MAKE_PROGRAM": "C:/msys64/mingw64/bin/mingw32-make.exe",
        "CMAKE_C_COMPILER": "C:/msys64/mingw64/bin/gcc.exe"
      }
    }
  ],
  "buildPresets": [
    { "name": "build",     "configurePreset": "pi-debug" },
    { "name": "clean",     "configurePreset": "pi-debug", "targets": ["clean"] },
    { "name": "deploy",    "configurePreset": "pi-debug", "targets": ["deploy"] },
    { "name": "gdbserver", "configurePreset": "pi-debug", "targets": ["gdbserver"] },
    { "name": "debug",     "configurePreset": "pi-debug", "targets": ["debug"] },
    { "name": "win-build", "configurePreset": "win-debug" }
  ],
  "workflowPresets": [
    {
      "name": "pi-deploy",
      "steps": [
        { "type": "configure", "name": "pi-debug" },
        { "type": "build",     "name": "deploy" }
      ]
    }
  ]
}

What's going on here:

  • pi-base is hidden: you never pick it directly. It holds everything the Pi presets share: the generator, the toolchain file, mingw32-make, and the board settings. Each preset gets its own build folder, like build/pi-debug.
  • pi-debug and pi-release inherit from it and only change the build type. Debug adds -g (debug symbols) and turns optimisation off so the debugger can follow your code line by line. Release optimises (-O3) for the final program.
  • win-debug has no toolchain file, so it builds a normal Windows .exe with the MSYS2 GCC. There's no deploy or debug for it, thanks to if(CMAKE_CROSSCOMPILING).
  • Build presets are where the magic happens. clean, deploy, gdbserver and debug are all just "build" with a different target.
  • The workflow preset pi-deploy chains configure and deploy into one command. Handy on a fresh checkout.

If your Pi has a different hostname or user, change it once, in pi-base.

8. One-time setup on the Pi and the PC

On the Pi, install gdbserver:

sudo apt update
sudo apt install gdbserver

On the PC, create an SSH key so scp and ssh don't ask for your password three times on every deploy. In PowerShell:

ssh-keygen -t ed25519                         # press Enter to accept the defaults (skip if you already have a key)
type $env:USERPROFILE\.ssh\id_ed25519.pub | ssh au@au.local "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
ssh au@au.local                               # should log in with no password now

(Linux and Mac have a ssh-copy-id command for this. Windows doesn't, so we pipe the key in ourselves.)

Hostname check: I'm using au.local here. Use whatever hostname you typed into Raspberry Pi Imager in Part 1. If .local names don't work on your network, use the Pi's IP address in PI_HOST.

9. A program to test with

Our real main.c already calls SWAlive_start(), the software alive task we'll write in the next post, so it won't compile yet. For today, a tiny pthread "hello" is enough to prove the whole pipeline works:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>

static void *hello_task(void *arg)
{
    int i;

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

int main(void)
{
    pthread_t thread;

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

    if (pthread_create(&thread, NULL, hello_task, NULL) != 0)
    {
        fprintf(stderr, "could not start hello task\n");
        return EXIT_FAILURE;
    }

    pthread_join(thread, NULL);
    return EXIT_SUCCESS;
}

Notice #include <unistd.h>. That's where sleep() lives. Leave it out and GCC warns about an "implicit declaration," which is a bug waiting to happen.

10. Using it: the five commands

Run these from the proxalarm folder (cd C:\git\12_yt_pi\proxalarm), in any terminal:

Command What it does
cmake --preset pi-debugConfigure: pick the toolchain, write the Makefiles into build/pi-debug (once, or after editing CMake files)
cmake --build --preset buildBuild: compile proxalarm for ARM
cmake --build --preset cleanClean: delete the compiled files
cmake --build --preset deployBuild, then copy to the Pi and chmod +x
cmake --build --preset debugBuild, deploy, start gdbserver, and debug from main
cmake --workflow --preset pi-deployConfigure + build + deploy in one go

Let's prove the build really is for ARM. After deploy, ask the Pi:

ssh au@au.local file /home/au/proxalarm
# /home/au/proxalarm: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked ...

ssh au@au.local /home/au/proxalarm
# hello from the Pi, count 0
# hello from the Pi, count 1 ...

ELF 32-bit, ARM: built on Windows, running on the Pi.

11. Your first debug session

cmake --build --preset debug

You'll see the build, the deploy, then gdb connecting and stopping:

Breakpoint 1, main () at C:/git/12_yt_pi/proxalarm/src/main.c:23
23          setvbuf(stdout, NULL, _IOLBF, 0);
(gdb)

You're now controlling a program running on the Pi, from your PC. A survival kit of gdb commands:

Command Short What it does
nextnRun the current line, stepping over function calls
stepsRun the current line, stepping into function calls
break hello_taskb hello_taskStop when this function runs (also works with b main.c:13)
continuecRun until the next breakpoint
print ip iShow a variable's value
info threadsList the threads (you'll see our hello task!)
backtracebtShow the chain of function calls that got us here
quitqEnd the session (the program on the Pi stops too)

Want to see the source code while you step? Press Ctrl+X then A to toggle gdb's built-in text UI.

To see the program's printf output while you debug, open a second terminal:

ssh au@au.local tail -f /tmp/proxalarm.log

You may see warnings at connect time about shared library symbols or library versions not matching. That's because our toolchain's copy of the C library is older than the one on the Pi. It only affects stepping inside the C library itself. Breakpoints and stepping in our code work fine.

12. When things go wrong

Problem What it means Fix
Could not resolve hostname au.localWrong hostname, or .local names don't resolve on your networkUse your Imager hostname, or the Pi's IP address in PI_HOST
Asked for a password on every deployNo SSH key installed on the PiRepeat step 8
bash: gdbserver: command not foundgdbserver isn't installed on the Pisudo apt install gdbserver
cannot execute binary file: Exec format error on the PiThe program was built for Windows (wrong preset)Configure with pi-debug, not win-debug
CMAKE_MAKE_PROGRAM is not set / make not foundMSYS2 isn't installed where the preset expectsFix the mingw32-make.exe path in CMakePresets.json
Connection refused / Connection timed out on port 2345gdb connected before gdbserver was readyRun debug again, or raise -E sleep 1 to 2
Changed CMakePresets.json or CMakeLists.txt, nothing changedOld configure settings still in the cachecmake --preset pi-debug --fresh
Ctrl+C in gdb also killed the buildWindows sends Ctrl+C to every program in the terminalUse breakpoints to stop the program, or run gdb directly from proxalarm/: arm-linux-gnueabihf-gdb -x build/pi-debug/debug.gdb after cmake --build --preset gdbserver

13. What's next

We now have the full loop: edit, build, deploy, debug, all with short commands and a few small files in Git. That's exactly the boring foundation we need before the fun part. Next, we write the first real piece of the proximity alarm: the software alive task, a pthread that prints a heartbeat to the console. It's the SWAlive_start() that's waiting in our main.c. (And if you prefer clicking breakpoints in VS Code, it can attach to this exact same gdbserver. We'll cover that later.)

Key Takeaways

  1. Cross-compiling means building ARM Linux programs on the PC. The toolchain file points CMake at the Linaro arm-linux-gnueabihf compiler, and mingw32-make from MSYS2 runs the build.
  2. Presets only configure and build, but custom targets can do anything. deploy, gdbserver and debug are custom targets, and build presets give each one a short name.
  3. Dependencies make the chain automatic: debug โ†’ gdbserver โ†’ deploy โ†’ build. You can never deploy or debug an old program.
  4. Remote debugging splits the work. gdbserver runs the program on the Pi. The ARM gdb on the PC holds the debug symbols and talks to it over the network on port 2345.
  5. One source of truth: board settings (au, au.local, /home/au, 2345) live once in CMakePresets.json, and everything works from any terminal or editor.

Final Verdict

If you're cross-compiling for a Linux board, put your deploy and debug steps in CMake, not in your editor. It's free, it's checked into Git with your code, and it works the same in PowerShell, in VS Code's terminal, or on a teammate's laptop.

Honest gotchas I hit:

  • Presets alone can't deploy or debug. They only configure and build. Custom targets are the trick that makes it work.
  • Set up an SSH key first, or you'll type your Pi password three times on every deploy.
  • Install gdbserver on the Pi (sudo apt install gdbserver). It isn't there by default on Raspberry Pi OS Lite.
  • Ctrl+C in gdb on Windows can kill the whole build command. Use breakpoints, or run gdb directly when you need to interrupt.
  • Terminal gdb takes a little getting used to. Learn n, s, b, c, p and bt, and press Ctrl+X A for a source view. Ten minutes later it feels natural.

Watching a program I compiled on Windows stop at main on a Raspberry Pi, from a single command, never gets old. Next up: the first real thread of the proximity alarm.

Related Videos