Professional GUI with Debugging for Bare-Metal on the Raspberry Pi 5

Aus C und Assembler mit Raspberry

Many developers desire a professional way to perform bare-metal development directly within a graphical user interface (GUI). In this guide, I will show you how to set up such a development environment. As our IDE (Integrated Development Environment), we will use Visual Studio Code from Microsoft, which is released under the open-source MIT license.

To communicate directly with the Raspberry Pi 5 and debug programs, you will additionally need a Raspberry Pi Debug Probe. This is connected to a Windows PC via USB and to the Raspberry Pi 5 via the debug pins.

Software Requirements

As mentioned earlier, we will use Visual Studio Code as our GUI. The software can be downloaded for free from the official website:

👉 https://code.visualstudio.com/

Additionally, we need the official toolchain from ARM to be able to compile code for the AArch64 architecture of the Raspberry Pi 5. It is available for download here:

👉 ARM GNU Toolchain Downloads

Note: At the time of writing this tutorial, version arm-gnu-toolchain-15.2.rel1-mingw-w64-x86_64-aarch64-none-elf.zip was used. Since the toolchain is under continuous development, the version number of your download may vary slightly.

For the connection between the PC and the Debug Probe, we use OpenOCD (Open On-Chip Debugger). A pre-compiled Windows version is provided by the xPack project:

👉 xPack OpenOCD Website

Direct download link for the version used: xPack OpenOCD v0.12.0-7

Finally, we also need the build tool GNU Make, which we will install directly via the Windows console in the next step.

Installation and Setup

Setting Up the Toolchain

Since Windows can occasionally have issues with very long path names, we first rename the downloaded toolchain archive to toolchain.zip.

Extract the ZIP archive completely.

Create a new directory directly on your system drive: C:\tools.

Copy the entire contents of the extracted toolchain folder into this directory, so that the folder structure starts directly with C:\tools\bin.

Setting Up and Configuring OpenOCD

Extract the OpenOCD ZIP archive.

In the extracted folder, you will find the subdirectory xpack-openocd-0.12.0-7 (or your more recent version). Simply rename this folder to openocd.

Move the entire openocd folder to C:\tools, so that the path becomes C:\tools\openocd.

Next, check if the file C:\tools\openocd\openocd\scripts\interface\cmsis-dap.cfg exists. This is usually included by default. If it is missing, create this file with the following content:

adapter driver cmsis-dap

Since older or standard OpenOCD releases lack a suitable target profile for the new Broadcom BCM2712 chip of the Raspberry Pi 5, we need to create it manually. To do this, create a new file at the path C:\tools\openocd\openocd\scripts\target\bcm2712.cfg and insert the following content:

if { [info exists CHIPNAME] } {
        set  _CHIPNAME $CHIPNAME
} else {
        set  _CHIPNAME bcm2712
}

if { [info exists CHIPCORES] } {
        set _cores $CHIPCORES
} else {
        set _cores 4
}

if { [info exists USE_SMP] } {
        set _USE_SMP $USE_SMP
} else {
        set _USE_SMP 0
}

if { [info exists DAP_TAPID] } {
        set _DAP_TAPID $DAP_TAPID
} else {
        set _DAP_TAPID 0x4ba00477
}

transport select swd

swd newdap $_CHIPNAME cpu -expected-id $_DAP_TAPID -irlen 4
adapter speed 4000

dap create $_CHIPNAME.dap -chain-position $_CHIPNAME.cpu

# MEM-AP for direct access
target create $_CHIPNAME.ap mem_ap -dap $_CHIPNAME.dap -ap-num 0

# These addresses were read from the ROM table via the 'dap info 0' command
set _DBGBASE {0x80010000 0x80110000 0x80210000 0x80310000}
set _CTIBASE {0x80020000 0x80120000 0x80220000 0x80320000}

set _smp_command "target smp"

for { set _core 0 } { $_core < $_cores } { incr _core } {
        set _CTINAME $_CHIPNAME.cti$_core
        set _TARGETNAME $_CHIPNAME.cpu$_core

        cti create $_CTINAME -dap $_CHIPNAME.dap -ap-num 0 -baseaddr [lindex $_CTIBASE $_core]
        target create $_TARGETNAME aarch64 -dap $_CHIPNAME.dap -ap-num 0 -dbgbase [lindex $_DBGBASE $_core] -cti $_CTINAME

        set _smp_command "$_smp_command $_TARGETNAME"
}

if {$_USE_SMP} {
        eval $_smp_command
}

# Default target is cpu0
targets $_CHIPNAME.cpu0

Installing GNU Make

On Windows, a package for GNU Make is available, which can be conveniently installed via the integrated package manager. Open a terminal (Command Prompt or PowerShell) and enter the following command:

winget install GnuWin32.Make

The program is installed by default in the directory C:\Program Files (x86)\GnuWin32\bin. To ensure that Make works smoothly with our other tools, copy the entire contents of this bin folder into our previously created directory C:\tools\bin.

Adjusting the "Path" Environment Variable

In order for Windows and Visual Studio Code to find the installed tools (compiler, Make, and OpenOCD) system-wide via the console, we need to add the executables to the system path.

Press the Windows key and type "environment variables" in the search field.

Select the option "Edit the system environment variables" and click the "Environment Variables..." button at the bottom of the next window.

In the "User variables" or "System variables" section, look for the entry Path (or PATH) and select Edit.

Add the following two paths to the list as separate, new lines:

C:\tools\bin
C:\tools\openocd\bin

Confirm all open windows with OK.

The basic toolchain is now successfully installed and set up. In the next part, we will focus on configuring Visual Studio Code for the actual bare-metal project.

Setting Up Visual Studio Code

Now that the basic toolchain is installed, we will set up the development environment in Visual Studio Code.

First, complete the installation of Visual Studio Code using the installer you downloaded earlier.

Installing VS Code Extensions

Open Visual Studio Code. To make bare-metal development as comfortable as possible, we will first install some essential extensions. Click on the Extensions icon in the left menu bar (or press CTRL + SHIFT + X) and search for the following extensions:

  • C/C++ (by Microsoft): Provides syntax highlighting and code completion (IntelliSense) for C/C++.
  • C/C++ Extension Pack (optional): Comes with additional useful tools for C development.
  • Cortex-Debug (by marus25): The key extension that allows us to debug directly on the Raspberry Pi 5 via OpenOCD.
  • Arm Assembly (by dan-c-underwood): Provides excellent support and highlighting for ARM assembly code.

Optional: Changing the User Interface Language to German

If your VS Code is in English and you prefer the German interface, for example:

Press CTRL + SHIFT + P to open the Command Palette. Type Configure Display Language and press Enter.

Select "Deutsch" (German). (If it is not listed, you can install it directly from there).

Restart VS Code.

Creating and Opening the Project Directory

Now create a directory on your hard drive where your bare-metal project will reside. In this example, we will use the path D:\projekt1. In VS Code, select File -> Open Folder... and choose the directory D:\projekt1. Confirm the security prompt asking if you trust the authors of the folder by clicking "Yes, I trust the authors".

Creating the .vscode Configuration Files

To let VS Code know which compiler to use and how to start the debugger, we need to create a configuration directory. In the main directory of your project, create a new folder with the exact name .vscode (don't forget the dot at the beginning!). Right-click on the newly created .vscode folder and create the following three files one after the other.

Copy the corresponding JSON code into each of them:

  • c_cpp_properties.json: This file configures code completion (IntelliSense) so that VS Code understands the ARM-specific commands and headers.
{
    "configurations": [
        {
            "name": "Bare-Metal (Pi 5)",
            "includePath": [
                "${workspaceFolder}/include",
                "${workspaceFolder}/src"
            ],
            "compilerPath": "C:/tools/bin/aarch64-none-elf-gcc.exe",
            "cStandard": "c11",
            "cppStandard": "c++14",
            "intelliSenseMode": "windows-gcc-arm64"
        }
    ],
    "version": 4
}
  • launch.json: This file controls the debugger (Cortex-Debug). It ensures that your code is loaded onto the Raspberry Pi 5 via OpenOCD and that the processor is stopped exactly at the start address.
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Bare-Metal Debug (Pi 5)",
            "cwd": "${workspaceFolder}",
            "executable": "${workspaceFolder}/kernel_2712.elf",
            "request": "launch",
            "type": "cortex-debug",
            "runToEntryPoint": "0x80000",
            "servertype": "openocd",
            "searchDir": [
                "C:/tools/openocd/openocd/scripts"
            ],
            "configFiles": [
                "interface/cmsis-dap.cfg",
                "target/bcm2712.cfg"
            ],
            "gdbPath": "C:/tools/bin/aarch64-none-elf-gdb.exe", 
            "serverpath": "C:/tools/openocd/bin/openocd.exe", 
            
            "openOCDLaunchCommands": [
                "transport select swd",
                "adapter speed 1000"
            ],

            // Overrides the default "reset halt" command from VS Code
            "overrideResetCommands": [
                "monitor halt"
            ],
            
            // Commands that are executed directly after connecting
            "overrideLaunchCommands": [
                "monitor halt",
                "load",
                "monitor reg pc 0x80000" // Forces the processor directly to the start address
            ],
            
            "preLaunchTask": "Compile"
        }
    ]
}
  • tasks.json: This defines the automated build process. Before the debugger starts, this task automatically calls make.
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "Compile",
            "type": "shell",
            "command": "make",
            "args": ["all"],
            "options": {
                "env": {
                    "PATH": "${env:PATH};C:\\tools\\bin"
                }
            },
            "group": {
                "kind": "build",
                "isDefault": true
            },
            "problemMatcher": ["$gcc"]
        }
    ]
}

Save all configurations by clicking File -> Save All in the menu.

Important: Note on Apparent Error Messages

After saving the c_cpp_properties.json, you will probably notice a small red "2" in the file explorer or in the "Problems" tab (at the bottom of VS Code). This indicates active warnings. If you click on the message, you will see the reason: VS Code is complaining that the two directory paths ${workspaceFolder}/include and ${workspaceFolder}/src do not exist.

💡Don't worry: This is completely correct! Since we started with an empty project, these folders simply don't exist yet. As soon as you create the src and include folders later in your project, this warning will disappear automatically.

Tip: If VS Code does not immediately register newly created folders, you can quickly refresh the development environment. To do this, press F1, type Reload Window, and confirm with Enter. This reloads the interface without interrupting your work.

Source Code, Linker Script, and Makefile

The Source Code Directory (src/)

To practically test our setup, we will use a minimalist LED blinking example in C and Assembly (Lass die LED leuchten in C (PI5)). This project uses a split into multiple files so that you can directly experience cross-references in the code and the convenience of a professional GUI.

In the main directory of your project (e.g., D:\projekt1), create a new folder named src.

💡 Important note for assembly files: Make sure that assembly files that use the C preprocessor (such as including header files via #include) strictly have the file extension with a capital "S" (.S). A lowercase "s" will cause the compiler to ignore the includes.

Create the following six files in the src/ folder:

src/boot.S:

// boot.S
//

#include "config.h"

.section .init  // Ensures that the linker places this at the beginning of the kernel image
.globl _start   // Execution starts here

_start:
    ldr x0, =MEM_KERNEL_STACK
    mov sp, x0          // Initialize stack pointer
    b sysinit

src/kernel.c:

// kernel.c
//

#include "led.h"
#include "time.h"

int main (void)
{
    while(1)
    {
        LED_off();
        wait(0x3F0000);
        LED_on();
        wait(0x3F0000);
    }
}

src/led.c:

// led.c
//

#include "base.h"
#include "util.h"
#include "types.h"

void LED_off(void)
{
    u32 reg = read32(ARM_GPIO2_DATA0);
    reg &= ~0x200; // Set bit 9 to 0
    write32(ARM_GPIO2_DATA0, reg);
}

void LED_on(void)
{
    u32 reg = read32(ARM_GPIO2_DATA0);
    reg |= 0x200; // Set bit 9 to 1
    write32(ARM_GPIO2_DATA0, reg);
}

src/sysinit.S:

// sysinit.S
//

.section .text
.globl sysinit

sysinit:
    b main

src/time.c:

// time.c
//

#include "types.h"

void wait(u32 cycles) 
{
    volatile u32 i;
    for (i = 0; i < cycles; i++) 
    {
        // Empty loop for delay
    }
}

src/util.S:

// util.S
//

.globl write32
write32:
    stp x29, x30, [sp, -16]!
    mov x29, sp
    str w1, [x0]
    ldp x29, x30, [sp], 16
    ret

.globl read32
read32:
    stp x29, x30, [sp, -16]!
    mov x29, sp
    ldr w0, [x0]
    ldp x29, x30, [sp], 16
    ret

Note: As soon as you save these files, VS Code will display various errors in the "Problems" tab. The code will be underlined with red, wavy lines. This is completely normal because the compiler currently lacks the header files (.h) and cannot resolve the references.

The Include Directory (include/)

To fix the errors, we will now create the header files. To do this, create a new folder named include in the main directory of your project. Place the following six files inside it:

include/base.h:

// base.h
//

#ifndef _base_h
#define _base_h

#define RPI_BASE  0x107C000000UL

// GPIO definitions for the Pi 5
#define ARM_GPIO2_BASE   (RPI_BASE + 0x1517C00)
#define ARM_GPIO2_DATA0  (ARM_GPIO2_BASE + 0x04)

#endif

include/config.h:

// config.h
//

#ifndef _config_h
#define _config_h

#define MEGABYTE 0x100000

#define MEM_KERNEL_START 0x80000          // Start address of the main program
#define KERNEL_MAX_SIZE  (2 * MEGABYTE)
#define MEM_KERNEL_END   (MEM_KERNEL_START + KERNEL_MAX_SIZE)
#define KERNEL_STACK_SIZE 0x20000
#define MEM_KERNEL_STACK (MEM_KERNEL_END + KERNEL_STACK_SIZE)

#endif

include/led.h:

// led.h
//

#ifndef _ms_led_h
#define _ms_led_h

void LED_off(void);
void LED_on(void);

#endif

include/time.h:

// time.h
//

#ifndef _ms_time_h
#define _ms_time_h

#include "types.h"

void wait(u32 cycles);

#endif

include/types.h:

// types.h
//

#ifndef _ms_types_h
#define _ms_types_h

typedef unsigned char    u8;
typedef unsigned short   u16;
typedef unsigned int     u32;

typedef signed char      s8;
typedef signed short     s16;
typedef signed int       s32;

typedef unsigned long    u64;
typedef signed long      s64;

typedef long             intptr;
typedef unsigned long    uintptr;

typedef unsigned long    size_t;
typedef long             ssize_t;

typedef char             boolean;

#define ALIGN(n)    __attribute__((aligned (n)))

#define FALSE       0
#define TRUE        1

#endif

include/util.h:

// util.h
//

#ifndef _ms_util_h
#define _ms_util_h

#include "types.h"

void write32(u64 a, u32 b);
u32 read32(u64 a);

#endif

As soon as all header files are saved in the include folder, the red lines in VS Code will disappear automatically. The GUI has successfully recognized the references.

Creating the Linker Script and Makefile

In order to build an executable bare-metal image for the Raspberry Pi 5 from the source code, we need a linker script and the control file for GNU Make. Both files are created directly in the main directory (root) of your project.

linker.ld

This script defines the exact layout of the code segments in the Raspberry Pi 5's memory.

ENTRY(_start)

SECTIONS
{
    .init : {
        *(.init)
    }
    .text : {
        *(.text)
        *(.text.*)
        _etext = .;
    }

    .rodata : {
        *(.rodata)
        *(.rodata.*)
    }
    .init_array : {
        __init_start = .;
        KEEP(*(.init_array*))
        __init_end = .;
    }
    .ARM.exidx : {
        __exidx_start = .;
        *(.ARM.exidx*)
        __exidx_end = .;
    }
    .eh_frame : {
        *(.eh_frame*)
    }
    .data : {
        *(.data)
    }
    .bss : {
        __bss_start = .;
        *(.bss)
        *(COMMON)
        __bss_end = .;
    }
}
__bss_size = (__bss_end - __bss_start) >> 3;

Makefile

The Makefile automates the invocation of the compiler and linker. Note that it explicitly targets the architecture of the Raspberry Pi 5 (-mcpu=cortex-a76).

CSRCS := $(wildcard src/*.c)
CPPSRCS := $(wildcard src/*.cpp)
ASRCS := $(wildcard src/*.S)
COBJS := $(CSRCS:.c=.o)
CPPOBJS := $(CPPSRCS:.cpp=.o)
AOBJS := $(ASRCS:.S=.o)
AllOBJS := $(COBJS) $(CPPOBJS) $(AOBJS)
LOADADDR = 0x80000

GCCFLAGS = -mcpu=cortex-a76 -mlittle-endian -Wall -O0 -ffreestanding \
           -nostartfiles -nostdlib -nostdinc -g -I ./include

AFLAGS = -mcpu=cortex-a76 -mlittle-endian  -I ./include -O0 -g

CFLAGS = -mcpu=cortex-a76 -mlittle-endian -Wall -fsigned-char -ffreestanding -g \
         -I ./include -O0 -fno-exceptions 

CPPFLAGS = -fno-exceptions -fno-rtti -nostdinc++ -mcpu=cortex-a76 -mlittle-endian -Wall -fsigned-char \
           -ffreestanding -g -I ./include -O0 -mstrict-align -std=c++14 -Wno-aligned-new

all: clean new kernel_2712.img

%.o: %.S
	@echo "as $@"
	@aarch64-none-elf-gcc $(AFLAGS) -c $< -o $@

%.o: %.c
	@echo "gcc $@"
	@aarch64-none-elf-gcc $(CFLAGS) -c $< -o $@

%.o: %.cpp
	@echo "g++ $@"
	@aarch64-none-elf-g++ $(CPPFLAGS) -c $< -o $@

kernel_2712.img: $(AllOBJS)
	@echo "============================================================================="
	@echo "Linking..."
	@aarch64-none-elf-ld -o kernel_2712.elf -Map kernel_2712.map -nostdlib \
		--section-start=.init=$(LOADADDR) --no-warn-rwx-segments \
		-g -T linker.ld $(AllOBJS)
	aarch64-none-elf-objcopy -O binary kernel_2712.elf kernel_2712.img

clean:
	@if exist kernel_2712.elf del /q /f kernel_2712.elf
	@if exist kernel_2712.img del /q /f kernel_2712.img
	@if exist kernel_2712.map del /q /f kernel_2712.map
	@if exist src\*.o del /q /f src\*.o

new:
	@cls

Important note regarding Makefiles: Do not use spaces for indentation. make will not accept this. Always use a TAB character instead.

Finally, do not forget to save all open files via File -> Save All in the VS Code menu. The project is now fully configured and ready for its first build and debugging run!

Preparing the Hardware and Debugging in the GUI

Preparing the Hardware and Setting Up the SD Card

Since we are testing the code directly on the real hardware, we need to prepare the Raspberry Pi 5's SD card. The operating system (EEPROM/firmware) of the Pi 5 needs to know that we want to debug a bare-metal program via JTAG/SWD.

Format a MicroSD card as FAT32 and copy the following three files into the root directory of the card:

kernel_2712.img (This file will be generated during the first compilation).

bcm2712-rpi-5-b.dtb (The original device tree blob from the official Raspberry Pi firmware)

config.txt (The configuration file for the firmware).

Create the config.txt with the exact following content:

arm_64bit=1
kernel_address=0x80000
enable_jtag_gpio=1
kernel=kernel_2712.img
framebuffer_depth=32

💡 What does this configuration do?

kernel_address=0x80000 sets the start address in RAM where our kernel will be loaded.

enable_jtag_gpio=1 switches the Raspberry Pi 5's GPIO pins into JTAG/SWD mode. This is what makes communication with the Raspberry Pi Debug Probe possible in the first place.

Connecting the hardware:

  • Insert the prepared SD card into the Raspberry Pi 5.
  • Connect the Raspberry Pi Debug Probe to the dedicated debug port of the Raspberry Pi 5 (located between the Micro-HDMI ports) using the supplied 3-pin UART/debug cable.
  • Connect the Debug Probe to your Windows PC via a USB cable.
  • Power on the Raspberry Pi 5 (connect the power supply).

With the JTAG interface enabled, the processor now waits at the start address for the debugger to connect and issue commands.

Compiling the Program and Starting the Debugger

Thanks to our preparations in Visual Studio Code, we can control the entire build and flash process using keyboard shortcuts.

Step 1:

Compile: Press the keyboard shortcut CTRL + SHIFT + B. VS Code will now run the Makefile (make all) in the background. This will generate the files kernel_2712.elf (for the debugger, including symbols) and kernel_2712.img (the raw binary format) in the project directory.

Step 2:

Start Debugger: Press the F5 key.

The following now happens fully automatically:

  • OpenOCD establishes the connection to the Raspberry Pi 5 via the Debug Probe.
  • The GDB debugger is started.
  • The newly compiled program is loaded directly into the Raspberry Pi 5's RAM (load).
  • The processor's program counter is forced to the start address 0x80000.

Since we specified in the launch.json that the debugger should halt on startup, execution stops exactly at the first instruction. In our source code, the cursor jumps directly to the boot.S file at the _start: label.

Operating the Debugger in VS Code

As soon as the debugger is active, the VS Code user interface changes. A floating debug control bar appears at the top of the screen.

The GUI controls:

Controls
Icon / Symbol Action Keyboard Shortcut Description
Pause F6 Immediately halts the running program at the current location.
Continue F5 Resumes normal program execution (until the next breakpoint).
Step Over F10 Executes the current line. Does not step into functions.
Step Into F11 Steps directly into a function to examine it line by line.
Step Out SHIFT + F11 Executes the rest of the current function and stops immediately after returning.
Restart CTRL + SHIFT + F5 Reloads the program onto the Pi and starts the debugging process from the beginning.
Stop SHIFT + F5 Ends the debug session and closes the connection to the Pi 5.


Using the Debugger Views

The biggest advantage of a professional GUI over the GDB command line is the visual presentation of all processor information on the left side of the screen:

  • Variables Window: Local and global variables are automatically displayed here. You can immediately see their current values. You can even manipulate these values with a double-click while halted to simulate test cases!
  • Watch Window: If you want to keep a permanent eye on specific variables or register addresses, you can add them here.
  • Call Stack: Shows you exactly which functions the program has gone through to reach the current point.
  • Registers Window (Cortex-Debug): A highlight for bare-metal developers. Here you can see the CPU registers of the ARM Cortex-A76 core (X0 to X30, SP, PC, etc.) with real-time access. If a register value changes after a single step, it is highlighted in color.

Setting Breakpoints with a Mouse Click

Typing memory addresses into GDB is a thing of the past. In VS Code, simply move your mouse to the left of the line numbers in the source code (e.g., in kernel.c on the line LED_on();). A faint red dot will appear. With a simple left-click, you activate the breakpoint (it turns solid red). If you now press F5 (Continue), the program runs until it reaches exactly this line and freezes the CPU. Another click on the dot removes the breakpoint again.

Summary

You have now set up a fully-fledged, professional development environment with hardware debugging for the Raspberry Pi 5!