Professionelle GUI mit Debugging für Bare-Metal (64-Bit) auf dem Raspberry Pi 4

Aus C und Assembler mit Raspberry

Viele Entwickler wünschen sich eine professionelle Möglichkeit, die Bare-Metal-Entwicklung direkt in einer grafischen Benutzeroberfläche (GUI) zu realisieren. In dieser Anleitung zeige ich Ihnen, wie Sie eine solche Entwicklungsumgebung aufsetzen können. Als IDE (Integrated Development Environment) nutzen wir Visual Studio Code von Microsoft, das unter der Open-Source-Lizenz MIT veröffentlicht ist.

Der Raspberry Pi unterstützt für solche Kommunikation das JTAG-Protokoll. Leider können Hostsysteme dieses Protokoll selten direkt bereitstellen, weshalb wir auf zusätzliche Hardware angewiesen sind. Eine günstige Option ist das "CJMCU FT232H Modul" (https://amzn.eu/d/hb8tKuA), welches ich für diese Anleitung verwendet habe.

Software-Voraussetzungen

Wie eingangs erwähnt, nutzen wir als GUI Visual Studio Code. Die Software kann kostenlos von der offiziellen Website heruntergeladen werden:

👉 https://code.visualstudio.com/

Zusätzlich benötigen wir die offizielle Toolchain von ARM, um Code für die AArch64-Architektur des Raspberry Pi 5 kompilieren zu können. Diese steht hier zum Download bereit:

👉 ARM GNU Toolchain Downloads

Hinweis: Zum Zeitpunkt der Erstellung dieses Tutorials wurde die Version arm-gnu-toolchain-15.2.rel1-mingw-w64-x86_64-aarch64-none-elf.zip verwendet. Da die Toolchain kontinuierlich weiterentwickelt wird, kann sich die Versionsnummer bei Ihrem Download leicht unterscheiden.

Für die Verbindung zwischen dem PC und dem FT232H Modul verwenden wir OpenOCD (Open On-Chip Debugger). Eine vorkompilierte Windows-Version wird über das xPack-Projekt bereitgestellt:

👉 xPack OpenOCD Website

Direkter Download-Link der verwendeten Version: xPack OpenOCD v0.12.0-7

Zuletzt benötigen wir noch das Build-Werkzeug GNU Make, welches wir im nächsten Schritt direkt über die Windows-Konsole installieren.

Installation und Einrichtung

Toolchain einrichten

Da Windows gelegentlich Probleme mit sehr langen Pfadnamen haben kann, benennen wir das heruntergeladene Archiv der Toolchain zunächst in toolchain.zip um.

Entpacken Sie das ZIP-Archiv vollständig.

Erstellen Sie ein neues Verzeichnis direkt auf Ihrem Systemlaufwerk: C:\tools.

Kopieren Sie den gesamten Inhalt des entpackten Toolchain-Ordners in dieses Verzeichnis, sodass die Ordnerstruktur direkt mit C:\tools\bin beginnt.

OpenOCD einrichten und konfigurieren

Entpacken Sie das OpenOCD-ZIP-Archiv.

Im entpackten Ordner finden Sie das Unterverzeichnis xpack-openocd-0.12.0-7 (bzw. Ihre aktuellere Version). Benennen Sie diesen Ordner einfach in openocd um.

Verschieben Sie den gesamten Ordner openocd nach C:\tools, sodass der Pfad C:\tools\openocd entsteht.

Leider fehlt uns eine Konfigurationsdatei, die JTAG und FT232H beschreibt. Daher erstellen wir eine Datei namens ft232h-jtag.cfg mit folgendem Inhalt und speichern diese ins Verzeichnis C:\tools\openocd\openocd\scripts\interface:

adapter speed 3000

adapter driver ftdi

ftdi vid_pid 0x0403 0x6014
ftdi layout_init 0x0078 0x017b

ftdi_tdo_sample_edge falling
ftdi layout_signal nTRST -ndata 0x0010 -noe 0x0040
ftdi layout_signal nSRST -ndata 0x0020 -noe 0x0040

transport select jtag

Zusätzlich benötigen wir ein Target-Profil für den Broadcom-Chip BCM2711 des Raspberry Pi 4. Erstellen Sie dazu eine neue Datei unter dem Pfad C:\tools\openocd\openocd\scripts\target\bcm2711.cfg und fügen Sie folgenden Inhalt ein:

# SPDX-License-Identifier: GPL-2.0-or-later

# The Broadcom BCM2711 used in Raspberry Pi 4
# No documentation was found on Broadcom website

# Partial information is available on the Raspberry Pi website:
#  https://www.raspberrypi.com/documentation/computers/processors.html#bcm2711

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

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
}

jtag newtap $_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 are obtained from the ROM table via 'dap info 0' command
set _DBGBASE {0x80410000 0x80510000 0x80610000 0x80710000}
set _CTIBASE {0x80420000 0x80520000 0x80620000 0x80720000}

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

GNU Make installieren

Unter Windows stellt Microsoft ein Paket für GNU Make bereit, das sich komfortabel über den integrierten Paketmanager installieren lässt. Öffnen Sie ein Terminal (Eingabeaufforderung oder PowerShell) und geben Sie folgenden Befehl ein:

winget install GnuWin32.Make

Das Programm wird standardmäßig in das Verzeichnis C:\Program Files (x86)\GnuWin32\bin installiert. Damit Make reibungslos mit unseren anderen Tools zusammenarbeitet, kopieren Sie den gesamten Inhalt dieses bin-Ordners in unser zuvor angelegtes Verzeichnis C:\tools\bin.

Umgebungsvariable „Path“ anpassen

Damit Windows und Visual Studio Code die installierten Tools (Compiler, Make und OpenOCD) systemweit über die Konsole finden können, müssen wir die ausführbaren Dateien dem Systempfad hinzufügen.

Drücken Sie die Windows-Taste und tippen Sie im Suchfeld „Umgebungsvariable“ ein.

Wählen Sie den Punkt „Systemumgebungsvariablen bearbeiten“ und klicken Sie im nächsten Fenster unten auf den Button „Umgebungsvariablen...“.

Suchen Sie im Bereich „Benutzervariablen“ oder „Systemvariablen“ nach dem Eintrag Path (oder PATH) und wählen Sie Bearbeiten.

Fügen Sie der Liste die folgenden zwei Pfade als separate, neue Zeilen hinzu:

C:\tools\bin
C:\tools\openocd\bin
C:\Program Files (x86)\GnuWin32\bin

Bestätigen Sie alle geöffneten Fenster mit OK.

Die grundlegende Toolchain ist damit erfolgreich installiert und eingerichtet. Im nächsten Teil widmen wir uns der Konfiguration von Visual Studio Code für das eigentliche Bare-Metal-Projekt.

Visual Studio Code einrichten

Nachdem die grundlegende Toolchain installiert ist, richten wir nun die Entwicklungsumgebung in Visual Studio Code ein.

Zunächst installiere Visual Studio Code komplett aus dem vorhergehenden Download.

VS Code Erweiterungen (Extensions) installieren

Öffnen Sie Visual Studio Code. Um die Bare-Metal-Entwicklung so komfortabel wie möglich zu gestalten, installieren wir zunächst einige essenzielle Erweiterungen. Klicken Sie dazu auf das Erweiterungs-Icon auf der linken Menüleiste (oder drücken Sie STRG + UMSCHALT + X) und suchen Sie nach folgenden Extensions:

  • C/C++ (von Microsoft): Bietet Syntax-Highlighting und Code-Vervollständigung (IntelliSense) für C/C++.
  • C/C++ Extension Pack (optional): Bringt zusätzliche nützliche Werkzeuge für die C-Entwicklung mit.
  • Cortex-Debug (von marus25): Die Schlüssel-Erweiterung, die es uns ermöglicht, via OpenOCD direkt auf dem Raspberry Pi 5 zu debuggen.
  • Arm Assembly (von dan-c-underwood): Bietet exzellente Unterstützung und Highlighting für ARM-Assembler-Code.

Optional: Sprache der Benutzeroberfläche auf Deutsch umstellen

Falls Ihr VS Code englischsprachig ist und Sie zum Beispiel die deutsche Oberfläche bevorzugen:

Drücken Sie STRG + UMSCHALT + P, um die Befehlspalette zu öffnen. Tippen Sie Configure Display Language ein und drücken Sie Enter.

Wählen Sie „Deutsch“ aus (falls nicht aufgelistet, können Sie es darüber direkt nachinstallieren).

Starten Sie VS Code neu.

Projektverzeichnis erstellen und öffnen

Erstellen Sie nun ein Verzeichnis auf Ihrer Festplatte, in dem Ihr Bare-Metal-Projekt leben soll. In diesem Beispiel verwenden wir den Pfad D:\projekt1. Wählen Sie in VS Code Datei -> Ordner öffnen... und wählen Sie das Verzeichnis D:\projekt1 aus. Bestätigen Sie die Sicherheitsabfrage, ob Sie den Autoren des Ordners vertrauen, mit „Ja, ich vertraue den Autoren“.

Die .vscode Konfigurationsdateien anlegen

Damit VS Code weiß, welchen Compiler es nutzen soll und wie der Debugger gestartet wird, legen wir ein Konfigurationsverzeichnis an. Erstellen Sie im Hauptverzeichnis Ihres Projekts einen neuen Ordner mit dem exakten Namen .vscode (den Punkt am Anfang nicht vergessen!). Klicken Sie mit der rechten Maustaste auf den neu erstellten Ordner .vscode und legen Sie nacheinander die folgenden drei Dateien an.

Kopieren Sie jeweils den entsprechenden JSON-Code hinein:

  • c_cpp_properties.json: Diese Datei konfiguriert die Code-Vervollständigung (IntelliSense), damit VS Code die ARM-spezifischen Befehle und Header versteht.
{
    "configurations": [
        {
            "name": "Bare-Metal (Pi 4)",
            "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: Diese Datei steuert den Debugger (Cortex-Debug). Sie sorgt dafür, dass Ihr Code via OpenOCD auf den Raspberry Pi 4 geladen und der Prozessor exakt an der Startadresse gestoppt wird.
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Bare-Metal Debug (Pi 4) - FT232H",
            "cwd": "${workspaceFolder}",
            "executable": "${workspaceFolder}/kernel8.elf",
            "request": "launch",
            "type": "cortex-debug",
            "servertype": "openocd",
            "searchDir": [
                "C:/tools/openocd/openocd/scripts",
                "${workspaceFolder}"
            ],
            "configFiles": [
                "interface/ft232h-jtag.cfg",
                "target/bcm2711.cfg"
            ],
            "gdbPath": "C:/tools/bin/aarch64-none-elf-gdb.exe", 
            "serverpath": "C:/tools/openocd/bin/openocd.exe", 
            
            "openOCDLaunchCommands": [
                "adapter speed 3000",
                "init"
            ],

            "overrideLaunchCommands": [
                "monitor halt",
                "monitor wait_halt 2000",
                "load",
                "monitor reg pc 0x80000"
            ],
            
            "preLaunchTask": "Kompilieren",
            "showDevDebugOutput": "raw"
        }
    ]
}
  • tasks.json: Hier wird der automatisierte Build-Prozess definiert. Bevor der Debugger startet, wird über diese Task automatisch make aufgerufen.
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "Kompilieren",
            "type": "shell",
            "command": "make",
            "args": ["all"],
            "options": {
                "env": {
                    "PATH": "${env:PATH};C:\\tools\\bin"
                }
            },
            "group": {
                "kind": "build",
                "isDefault": true
            },
            "problemMatcher": ["$gcc"]
        }
    ]
}

Speichern Sie alle Konfigurationen ab, indem Sie im Menü auf Datei -> Alles speichern klicken.

Important: Hinweis zu vermeintlichen Fehlermeldungen

Nach dem Speichern der c_cpp_properties.json fällt Ihnen im Datei-Explorer oder im Reiter „Probleme“ (unten in VS Code) vermutlich eine kleine rote „2“ auf. Dies deutet auf aktive Warnungen hin. Wenn Sie auf die Meldung klicken, sehen Sie den Grund: VS Code beschwert sich, dass die beiden Verzeichnis-Pfade ${workspaceFolder}/include und ${workspaceFolder}/src nicht existieren.

💡Keine Sorge: Das ist völlig korrekt! Da wir ein leeres Projekt gestartet haben, gibt es diese Ordner einfach noch nicht. Sobald Sie im weiteren Verlauf Ihres Projekts die Ordner src und include anlegen, verschwindet diese Warnung von selbst.

Tipp: Sollte VS Code neu erstellte Ordner einmal nicht sofort registrieren, können Sie die Entwicklungsumgebung schnell aktualisieren. Drücken Sie dazu F1, tippen Sie Reload Window ein und bestätigen Sie mit Enter. Das lädt die Oberfläche neu, ohne Ihre Arbeit zu unterbrechen.

Sourcecode, Linkerscript und Makefile

Das Quellcode-Verzeichnis (src/)

Um unser Setup praktisch zu testen, verwenden wir ein minimalistisches LED-Blinkbeispiel in C und Assembler (Lass die LED leuchten in C (PI4)). Dieses Projekt nutzt die Aufteilung in verschiedene Dateien, damit Sie die Querverweise im Code und den Komfort einer professionellen GUI direkt erleben können.

Erstellen Sie im Hauptverzeichnis Ihres Projekts (z. B. D:\projekt1) einen neuen Ordner namens src.

💡 Wichtiger Hinweis für Assembler-Dateien: Achten Sie darauf, dass Assembler-Dateien, die den C-Präprozessor nutzen (wie das Einbinden von Header-Dateien via #include), zwingend die Dateiendung mit einem großen „S“ (.S) haben müssen. Ein kleines „s“ führt dazu, dass der Compiler die Includes ignoriert.

Erstellen Sie im Ordner src/ die folgenden sechs Dateien: src/boot.S:

// boot.S
//

#include "config.h"

.section .init  // Stellt sicher, dass der Linker dies an den Anfang des Kernel-Images setzt
.globl _start   // Hier beginnt die Ausführung

_start:
    ldr x0, =MEM_KERNEL_STACK
    mov sp, x0          // Stack-Pointer initialisieren
    b sysinit

src/kernel.c:

// kernel.c
//

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

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

src/led.c:

//
// The LED program for RPI4
// 20.02.2025 www.satyria.de
//
// led.c
//

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

void LED_on (void)
{
  u32 LED_Pin = 42;
  SetGPIOFunction(LED_Pin,GPIO_output);

  u32 GPSET = GPIO_GPSET0; 
  if (LED_Pin>31)
  {
    GPSET = GPSET + 4;
    LED_Pin = LED_Pin - 32;
  }
  write32(GPSET,1<<LED_Pin);
}

void LED_off (void)
{
  u32 LED_Pin = 42;
  SetGPIOFunction(LED_Pin,GPIO_output);

  u32 GPCLR = GPIO_GPCLR0; 
  if (LED_Pin>31)
  {
    GPCLR = GPCLR + 4;
    LED_Pin = LED_Pin - 32;
  }
  write32(GPCLR,1<<LED_Pin);
}

void SetGPIOFunction(u32 Pin, u32 Function)
{
  u32 GPSEL = GPIO_GPFSEL0;
  while (Pin >= 10)
  {
    Pin = Pin -10;
    GPSEL = GPSEL + 4;
  }
  Pin = Pin *3;
  Function = Function << Pin;
  u32 mask = 0b111 << Pin;
  mask =~mask; //Bitweise negieren

  u32 sel=read32(GPSEL);
  sel=sel&mask;
  sel=sel|Function;
  write32(GPSEL,sel);
}

src/sysinit.S:

// sysinit.S
//

.section .text
.globl sysinit

sysinit:
    b main

src/time.c:

// time.c
//

#include "types.h"

void wait(u32 zyklen) 
{
    volatile u32 i;
    for (i = 0; i < zyklen; i++) 
    {
        // Leere Schleife zur Verzögerung
    }
}

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

Hinweis: Sobald Sie diese Dateien gespeichert haben, wird Ihnen VS Code im „Probleme“-Tab diverse Fehler anzeigen. Der Code ist mit roten, gewellten Linien unterlegt. Das ist völlig normal, da dem Compiler aktuell die Header-Dateien (.h) fehlen und er die Verweise nicht auflösen kann.

Das Include-Verzeichnis (include/)

Um die Fehler zu beheben, legen wir nun die Header-Dateien an. Erstellen Sie dazu im Hauptverzeichnis Ihres Projekts einen neuen Ordner namens include. Legen Sie darin die folgenden sechs Dateien ab:

include/base.h:

// base.h
//

#ifndef _base_h
#define _base_h

#define RPI_BASE 0xFE000000

// General Purpose I/O (GPIO)                 
#define GPIO_BASE RPI_BASE + 0x200000               
#define GPIO_GPFSEL0                    GPIO_BASE + 0x00 // GPIO Function Select 0        
#define GPIO_GPFSEL1                    GPIO_BASE + 0x04 // GPIO Function Select 1        
#define GPIO_GPFSEL2                    GPIO_BASE + 0x08 // GPIO Function Select 2        
#define GPIO_GPFSEL3                    GPIO_BASE + 0x0c // GPIO Function Select 3        
#define GPIO_GPFSEL4                    GPIO_BASE + 0x10 // GPIO Function Select 4        
#define GPIO_GPFSEL5                    GPIO_BASE + 0x14 // GPIO Function Select 5        
#define GPIO_GPSET0                     GPIO_BASE + 0x1c // GPIO Pin Output Set 0       
#define GPIO_GPSET1                     GPIO_BASE + 0x20 // GPIO Pin Output Set 1       
#define GPIO_GPCLR0                     GPIO_BASE + 0x28 // GPIO Pin Output Clear 0       
#define GPIO_GPCLR1                     GPIO_BASE + 0x2c // GPIO Pin Output Clear 1       
#define GPIO_GPLEV0                     GPIO_BASE + 0x34 // GPIO Pin Level 0        
#define GPIO_GPLEV1                     GPIO_BASE + 0x38 // GPIO Pin Level 1        
#define GPIO_GPEDS0                     GPIO_BASE + 0x40 // GPIO Pin Event Detect Status 0      
#define GPIO_GPEDS1                     GPIO_BASE + 0x44 // GPIO Pin Event Detect Status 1      
#define GPIO_GPREN0                     GPIO_BASE + 0x4c // GPIO Pin Rising Edge Detect Enable 0     
#define GPIO_GPREN1                     GPIO_BASE + 0x50 // GPIO Pin Rising Edge Detect Enable 1     
#define GPIO_GPFEN0                     GPIO_BASE + 0x58 // GPIO Pin Falling Edge Detect Enable 0     
#define GPIO_GPFEN1                     GPIO_BASE + 0x5c // GPIO Pin Falling Edge Detect Enable 1     
#define GPIO_GPHEN0                     GPIO_BASE + 0x64 // GPIO Pin High Detect Enable 0      
#define GPIO_GPHEN1                     GPIO_BASE + 0x68 // GPIO Pin High Detect Enable 1      
#define GPIO_GPLEN0                     GPIO_BASE + 0x70 // GPIO Pin Low Detect Enable 0      
#define GPIO_GPLEN1                     GPIO_BASE + 0x74 // GPIO Pin Low Detect Enable 1      
#define GPIO_GPAREN0                    GPIO_BASE + 0x7c // GPIO Pin Async. Rising Edge Detect 0     
#define GPIO_GPAREN1                    GPIO_BASE + 0x80 // GPIO Pin Async. Rising Edge Detect 1     
#define GPIO_GPAFEN0                    GPIO_BASE + 0x88 // GPIO Pin Async. Falling Edge Detect 0     
#define GPIO_GPAFEN1                    GPIO_BASE + 0x8c // GPIO Pin Async. Falling Edge Detect 1     
#define GPIO_GPIO_PUP_PDN_CNTRL_REG0    GPIO_BASE + 0xe4 // GPIO Pull-up / Pull-down Register 0      
#define GPIO_GPIO_PUP_PDN_CNTRL_REG1    GPIO_BASE + 0xe8 // GPIO Pull-up / Pull-down Register 1      
#define GPIO_GPIO_PUP_PDN_CNTRL_REG2    GPIO_BASE + 0xec // GPIO Pull-up / Pull-down Register 2      
#define GPIO_GPIO_PUP_PDN_CNTRL_REG3    GPIO_BASE + 0xf0 // GPIO Pull-up / Pull-down Register 3      
// Information from BCM2835 ARM Peropherals                 
#define GPIO_GPPUD                      GPIO_BASE + 0x94 // GPIO Pin Pull-up/down Enable        
#define GPIO_GPPUDCLK0                  GPIO_BASE + 0x98 // GPIO Pin Pull-up/down Enable Clock 0      
#define GPIO_GPPUDCLK1                  GPIO_BASE + 0x9c // GPIO Pin Pull-up/down Enable Clock 1      

#endif

include/config.h:

// config.h
//

#ifndef _config_h
#define _config_h

#define MEGABYTE          0x100000

#define MEM_KERNEL_START  0x80000          // Startadresse des Hauptprogramms
#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

#include "types.h"

#define GPIO_input  0b000      //Input
#define GPIO_output 0b001      //Output
#define GPIO_alt0   0b100      //alternate function 0
#define GPIO_alt1   0b101      //alternate function 1
#define GPIO_alt2   0b110      //alternate function 2
#define GPIO_alt3   0b111      //alternate function 3
#define GPIO_alt4   0b011      //alternate function 4
#define GPIO_alt5   0b010      //alternate function 5

#define GPPUD_OFF    0b00

void LED_off(void);
void LED_on(void);
void SetGPIOFunction(u32 Pin, u32 Function);

#endif

include/time.h:

// time.h
//

#ifndef _ms_time_h
#define _ms_time_h

#include "types.h"

void wait(u32 zyklen);

#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(u32 a, u32 b);
u32 read32(u32 a);

#endif

Sobald alle Header-Dateien im include-Ordner abgespeichert sind, verschwinden die roten Linien in VS Code automatisch. Die GUI hat die Bezüge erfolgreich erkannt.

Linkerscript und Makefile erstellen

Damit aus dem Quellcode ein ausführbares Bare-Metal-Image für den Raspberry Pi 4 gebaut werden kann, benötigen wir ein Linkerscript und die Steuerungsdatei für GNU Make. Beide Dateien werden direkt im Hauptverzeichnis (Root) Ihres Projekts angelegt.

linker.ld

Dieses Skript definiert die genaue Anordnung der Code-Segmente im Arbeitsspeicher des Raspberry Pi 4.

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

Das Makefile automatisiert den Aufruf des Compilers und Linkers. Beachten Sie, dass hier explizit die Architektur des Raspberry Pi 4 (-mcpu=cortex-a72) angesprochen wird.

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 = -DAARCH=64 -mcpu=cortex-a72 -mlittle-endian -Wall -O0 -ffreestanding \
           -nostartfiles -nostdlib -nostdinc -g -I ./include

AFLAGS = -DAARCH=64 -mcpu=cortex-a72 -mlittle-endian  -I ./include -O0 -g

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

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

all: clean new kernel8.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 $@

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

clean:
ifeq ($(MSYSTEM),)
# --- Native Windows Umgebung (CMD / PowerShell) ---
# @cls
	@if exist kernel8.elf del /q /f kernel8.elf
	@if exist kernel8.img del /q /f kernel8.img
	@if exist kernel8.map del /q /f kernel8.map
	@if exist src\*.o del /q /f src\*.o
else
# --- MSYS2 / Unix-ähnliche Umgebung ---
	/bin/rm -f kernel8.elf kernel8.map src/*.o *.img > /dev/null 2> /dev/null || true
endif

new:
ifeq ($(MSYSTEM),)
	@cls
else
	/bin/clear
endif

Wichtiger Hinweis zu Makefiles: Verwende keine Leerzeichen um Einrückungen zu erstellen. Dies mag make nicht. Verwende stattdessen immer ein TAB.

Vergessen Sie am Ende nicht, alle geöffneten Dateien im VS-Code-Menü über Datei -> Alles speichern zu sichern. Das Projekt ist nun vollständig konfiguriert und bereit für den ersten Build- und Debugging-Lauf!

Hardware vorbereiten und Debugging in der GUI

Hardware vorbereiten und SD-Karte einrichten

Da wir den Code direkt auf der echten Hardware testen, müssen wir die SD-Karte des Raspberry Pi 4 vorbereiten. Das Betriebssystem (EEPROM/Firmware) des Pi 4 muss wissen, dass wir ein Bare-Metal-Programm via JTAG debuggen möchten.

Formatieren Sie eine MicroSD-Karte auf FAT32 und kopieren Sie folgende drei Dateien in das Hauptverzeichnis (Root) der Karte:

kernel8.img (Diese Datei wird gleich beim ersten Kompilieren erzeugt).

bcm2711-rpi-4-b.dtb (Der originale Device-Tree-Blob aus der offiziellen Raspberry Pi Firmware)

config.txt (Die Konfigurationsdatei für die Firmware).

bootcode.bin

fixup4.dat

start4.elf

Erstellen Sie die config.txt mit exakt folgendem Inhalt:

gpio=22-27=np
enable_jtag_gpio=1

💡 Was bewirkt diese Konfiguration?

enable_jtag_gpio=1 schaltet die GPIO-Pins des Raspberry Pi 4 in den JTAG-Modus um. Dadurch wird die Kommunikation mit dem FT232H Modul überhaupt erst möglich.

Hier kannst du den kompletten Inhalt runterladen: https://www.satyria.de/arm/sources/RPI4/C/RPI4_ImageVSCode.zip

Hardware verbinden

Verdrahtung

Die Verdrahtung zwischen dem FT232H Modul und dem Raspberry Pi 4 erfolgt wie folgt:

Verdrahtung
FT232H Raspi 4
Name GPIO PIN
AD0 GPIO25 22
AD1 GPIO26 37
AD2 GPIO24 18
AD3 GPIO27 13
AD4 GPIO22 15
AD7 GPIO23 16
GND GND 6 (9,14,20,25,30,34,39)


  • Stecken Sie die vorbereitete SD-Karte in den Raspberry Pi 4.
  • Schließen Sie das FT232H Modul per USB-Kabel an Ihren Windows-Rechner an.
  • Schalten Sie den Raspberry Pi 4 ein (Stromversorgung anschließen).

Durch das aktivierte JTAG-Interface wartet der Prozessor nun an der Startadresse auf die Verbindung und Befehle des Debuggers.

Programm kompilieren und Debugger starten

Dank unserer Vorbereitungen in Visual Studio Code können wir den gesamten Build- und Flash-Prozess mit Tastenkombinationen steuern.

Schritt 1:

Kompilieren: Drücken Sie die Tastenkombination STRG + UMSCHALT + B. VS Code führt nun im Hintergrund das Makefile aus (make all). Im Projektverzeichnis entstehen dadurch die Dateien kernel8.elf (für den Debugger inklusive Symbolen) und kernel8.img (das reine Binärformat).

Schritt 2:

Debugger starten: Drücken Sie die Taste F5.

Nun passiert Folgendes vollautomatisch:

  • OpenOCD baut die Verbindung zum Raspberry Pi 4 über das FT232H Modul auf.
  • Der GDB-Debugger wird gestartet.
  • Das neu kompilierte Programm wird direkt in den RAM des Raspberry Pi 4 geladen (load).
  • Der Programmzähler des Prozessors wird auf die Startadresse 0x80000 gezwungen.

Da wir in der launch.json festgelegt haben, dass der Debugger beim Start anhalten soll, stoppt die Ausführung exakt am ersten Befehl. In unserem Sourcecode springt der Cursor direkt in die Datei boot.S zum Label _start:

Bedienung des Debuggers in VS Code

Sobald der Debugger aktiv ist, verändert sich die Benutzeroberfläche von VS Code. Am oberen Bildschirmrand erscheint eine schwebende Debug-Steuerungsleiste.

Die Steuerelemente der GUI:

Steuerelemente
Icon / Symbol Aktion Tastenkürzel Beschreibung
Pause F6 Hält das laufende Programm sofort an der aktuellen Stelle an.
Fortsetzen F5 Lässt das Programm normal weiterlaufen (bis zum nächsten Breakpoint).
Einzelschritt (Step Over) F10 Führt die aktuelle Zeile aus. Springt bei Funktionen nicht in die Funktion hinein.
Einzelschritt (Step Into) F11 Springt direkt in eine Funktion hinein, um sie Zeile für Zeile zu prüfen.
Herausspringen (Step Out) UMSCHALT + F11 Führt den Rest der aktuellen Funktion aus und stoppt direkt nach der Rückkehr.
Neustart STRG + UMSCHALT + F5 Lädt das Programm neu auf den Pi und startet den Debug-Vorgang von vorne.
Stoppen UMSCHALT +F5 Beendet die Debug-Sitzung und schließt die Verbindung zum Pi.5


Die Debugger-Ansichten nutzen

Der größte Vorteil einer professionellen GUI gegenüber der GDB-Kommandozeile ist die visuelle Aufbereitung aller Prozessor-Informationen auf der linken Seite des Bildschirms:

  • Variablen-Fenster (Variables): Hier werden Ihnen lokale und globale Variablen automatisch angezeigt. Sie sehen sofort, welchen Wert sie aktuell besitzen. Sie können Werte hier während des Haltepunkts sogar per Doppelklick manipulieren, um Testfälle zu simulieren!
  • Überwachungs-Fenster (Watch): Wenn Sie bestimmte Variablen oder Registeradressen permanent im Auge behalten wollen, können Sie diese hier hinzufügen.
  • Aufrufliste (Call Stack): Zeigt Ihnen genau, durch welche Funktionen das Programm gelaufen ist, um an den aktuellen Punkt zu gelangen.
  • Register-Fenster (Cortex-Debug): Ein Highlight für Bare-Metal-Entwickler. Hier sehen Sie die CPU-Register des ARM Cortex-A72 Cores (X0 bis X30, SP, PC, etc.) im Echtzeit-Zugriff. Ändert sich ein Registerwert nach einem Einzelschritt, wird dieser farblich hervorgehoben.

Breakpoints (Haltepunkte) per Mausklick setzen

Das Eintippen von Speicheradressen in GDB gehört der Vergangenheit an. In VS Code bewegen Sie einfach die Maus links neben die Zeilennummern im Sourcecode (z. B. in der kernel.c in der Zeile LED_on();). Ein schwach roter Punkt erscheint. Mit einem einfachen Linksklick aktivieren Sie den Breakpoint (er wird kräftig rot). Wenn Sie nun F5 (Fortsetzen) drücken, läuft das Programm so lange, bis es genau diese Zeile erreicht, und friert die CPU ein. Ein weiterer Klick auf den Punkt entfernt den Haltepunkt wieder.

Wichtiger Hinweis zum Reboot

Da es nicht möglich ist, ein Reset an den Raspberry Pi 4 zu schicken, muss vor jedem Neukompilieren der Raspberry Pi 4 neu gestartet werden.

Zusammenfassung

Damit haben Sie eine vollwertige, professionelle Entwicklungsumgebung mit Hardware-Debugging für den Raspberry Pi 4 eingerichtet!