We illustrate how to build and debug the native image of a Java application, produced by MicroDoc GraalVM Embedded, and running on a remote embedded device. The debugging experience is illustrated inside the VSCode IDE. The embedded device runs Linux on an ARMv8-A processor (running in AArch64 mode), like a RaspberryPi 4 or 5.

Building and debugging Java native images of MicroDoc GraalVM Embedded

MicroDoc specializes in the individual development of embedded software solutions using Java and GraalVM technology. From Munich, Berlin and Stuttgart, our teams of experts serve our customers from the following business areas: automotive industry, mechanical engineering, energy supply and telecommunications.

The GraalVM is the new technology in terms of platform-independent programming. Not only in terms of performance, but also through the support of other languages such as Python and JavaScript, you have all the possibilities of modern software development in the embedded area at your disposal.

An important point in the development of software is always the possibility of debugging your own application. This becomes even more relevant as applications and systems become more complex. Simple and clear debugging directly on the embedded system also reduces the risk of failures and associated costs in production.

This article illustrates how to build and debug the native image of a “HelloWorld” Java application, produced by MicroDoc GraalVM Embedded, and running on a remote embedded device.

The local computer, used for building and debugging the native image, is a Debian Linux/AMD64 system. The debugging experience is illustrated inside the VSCode Integrated Development Environments (IDE). The embedded device runs Linux on an ARMv8-A processor (running in AArch64 mode), like a RaspberryPi 4 or 5.

Overview of the data flow

⏩ TL;DR (too long; didn’t read)

  1. Build a native image with debug information for a device’s platform.
  2. Copy it to the device.
  3. Run it in the device under a debugger agent, e.g., gdbserver, lldb-server.
  4. Configure your IDE to connect to a remote debugger.
  5. Create break points and debug your native image.

🧭 Before you start

Throughout this article, we assume that the reader has access to:

  • MicroDoc GraalVM Embedded.
  • A Java application in compiled form, e.g., in a JAR.
  • A native debugger that supports the device’s platform.
  • A native debugger agent that can run on the device.
  • An IDE, e.g., VSCode.

We also assume that the reader knows how to:

  • Copy files to the devices.
  • Run programs on the device.
  • Use and configure and IDE that supports remote native debugging.

🏗️ Build a native image on the build computer

Starting with the Java source file <somewhere>/src/com/example/HelloWorld.java, whose contents are:

package com.example;

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!"); 
    }
}

First, we need to compile .java source files into .class files, and maybe store them into a .jar file. For this, we usually invoke the Java compiler javac, either manually, or through the IDE. If javac is invoked manually, then make sure to specify -g, in order to store debugging information into the .class files, thus enabling better debugging experience later on.

$ javac -g -d '<somewhere>/bin' '<somewhere>/src/com/example/HelloWorld.java'

This should generate the file <somewhere>/bin/com/example/HelloWorld.class.

Once the Java sources are compiled, we will build a native image executable using native-image:

$ <somewhere>/graalvm/bin/native-image \
    -O0 -g -cp <somewhere>/bin -o <somewhere>/hello-world --target=linux-aarch64 \
    --native-compiler-path=<somewhere>/aarch64-none-linux-gnu-gcc \
    --native-linker-options=-L<somewhere>/lib \
    -H:+UnlockExperimentalVMOptions \
    -H:+UseCAPCache -H:CAPCacheDir=<somewhere>/cap-cache/aarch64-linux-gnu \
    -H:-StripDebugInfo -H:DebugInfoSourceSearchPath=<somewhere>/src \
    -H:-UnlockExperimentalVMOptions \
    com.example.HelloWorld

This builds the executable <somewhere>/hello-world that runs on the target device, with debugging information embedded into the executable. It also populates the source cache directory <somewhere>/sources containing copies of many sources files that make up the Java application, e.g., <somewhere>/sources/com/example/HelloWorld.java.

💡 In order to get some information about the executable, you can run:

$ file <somewhere>/hello-world
<somewhere>/hello: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, with debug_info, not stripped

In order to indicate to native-image where the Java source files reside, you can specify the -H:DebugInfoSourceSearchPath=<some-directory> option, as many times as needed.

⚠️ When building release native images of your application, consider enabling optimizations (-O2 option) and stripping debugging information (-H:+StripDebugInfo option).

📂 Copy files to the target device

You should have the following files on the build computer:

  • hello-world: the native image file generated for your Java application. This file runs on the device’s platform.
  • sources: a source cache directory containing copies of many sources files (e.g., .java files) that make up the Java application.

You should also have a native debugger agent installed on the target device, e.g., lldb-server or gdbserver. We recommend using lldb-server.

💡 To check for lldb-server, run lldb-server version on the device.

💡 To check for gdbserver, run gdbserver --version on the device:

Every time you build your native image, you have to copy the new binary to the target device. This ensures the program running on the device and the program shown in the IDE debugging session is the same.

Copying files can be done either automatically by your debugger or manually through other means, e.g., scp, ftp, rsync. Some IDEs also allow specifying tasks that copy files to remote devices. For example:

$ scp <somewhere>/hello-world username@somedevice:/tmp/hello-world

💡 Using the lldb debugger and the lldb-server debugger agent does this automatically.

📡 Run the debugger agent on the target device

In order to debug the native image on the target device, we need to run it, on the device, under the control of a debugger agent.

We will connect through TCP/IP to the debugger agent. For that, we need to choose a TCP port number for the debugger agent to listen to. Any number between 1024 and 65535 should work fine. We chose 11000.

Using the lldb-server debugger agent

We recommend using the lldb-server debugger agent.

If your target device runs Debian Linux or a derivative distribution, then you can install lldb-server with the following command:

$ apt install lldb

In order to start the lldb-server debugger agent, run the following on the device:

$ cd <some-writable-directory>
$ lldb-server platform --server --listen '*:11000'

lldb-server automatically copies the native image to its working directory before starting the debugging session.

💡 lldb-server needs to be started only once. It can be used to start multiple successive debugging sessions.

Using the gdbserver debugger agent

If your target device runs Debian Linux or a derivative distribution, then you can install gdbserver with the following command:

$ apt install gdbserver

In order to start the gdbserver debugger agent, run the following on the device:

$ gdbserver --multi --disable-randomization '0.0.0.0:11000'

💡 With the --multi flag, gdbserver needs to be started only once. It can be used to start multiple successive debugging sessions.

🪲 Connect the IDE to the remote debugger agent

In order to start debugging in the IDE, the IDE needs to configure a local debugger, in order for it to connect to the remote debugger agent.

We’ll need to figure out the host name or address of the target device on the IP network. If the target device host name cannot be resolved by DNS, then you’ll need to use its IP address instead of its name in the following sections.

Using the VSCode with lldb-server

Install the CodeLLDB extension. Then, from the menu Run, choose Add configuration. Configure your new debug configuration like the following:

{
    "name": "hello-world-remote",
    "type": "lldb",
    "request": "launch",
    "program": "<somewhere>/hello-world",
    "sourceLanguages": [
        "C",
    ],
    "initCommands": [
        "platform select remote-linux",
        "platform connect connect://<target-device-host-name>:11000",
        "settings set target.inherit-env false",
        "settings set target.language c",
    ],
},

Once done, open a source file in the sources cache directory and put a break point in some method. Then switch to the Run and Debug pane, select the configuration hello-world-remote, then click on the play button ▶️.

The CodeLLDB manual is a very helpful resource to tweak the debug configuration.

Debugging remote native image using `lldb-server` and Visual Studio Code

Using the VSCode with gdbserver

Install the C/C++ extension. Then, from the menu Run, choose Add configuration. Configure your new debug configuration like the following:

{
    "name": "hello-world-remote",
    "type": "cppdbg",
    "request": "launch",
    "targetArchitecture": "arm64",
    "MIMode": "gdb",
    "miDebuggerPath": "<somewhere>/aarch64-none-linux-gnu-gdb",
    "miDebuggerServerAddress": "<target-device-host-name>:11000",
    "useExtendedRemote": true,
    "program": "<somewhere>/hello-world",
    "cwd": "/tmp",
    "externalConsole": false,
    "setupCommands": [
        {
            "description": "Set remote executable path",
            "text": "set remote exec-file /tmp/hello-world",
        }
    ],
    "deploySteps": [
        {
            "type": "scp",
            "host": "<target-device-host-name>",
            "files": "<somewhere>/hello-world",
            "targetDir": "/tmp",
        }
    ],
}

Debugging remote native image using `gdbserver` and Visual Studio Code

Using the IntelliJ IDEA with gdbserver

Install the GraalVM Native Debugger Plugin. This plugin is only supported by the Ultimate Version.

Then, from the menu Run, choose Edit configurations. Add a new GraalVM Native Attach configuration like the following:

Configuring remote GraalVM Native Attach using `gdbserver` and IntelliJ IDEA

Debugging remote native image using `gdbserver`and IntelliJ IDEA

🏁 Still a work in progress

Building native images of Java applications and support for debugging them in an IDE is still an ongoing effort. New features are frequently added, in order to improve the developer experience.

Approaches to reduce the builds times of native images are being investigated, in order to reduce the time of the cycle of writing/building/testing.

Because native images are native programs that don’t need interpretation or just-in-time compilation, they are debugged using native debuggers (e.g., LLDB, GDB), instead of the usual Java debuggers (front-ends of JDWP). This implies that, for the moment, the debugging experience of native images lacks some features that a typical Java debugger provides, such as modifying contents of variables and fields, running Java methods, etc. These limitations are known issues that would hopefully be fixed in the near future, in order to make it easier for Java developers to debug native images in a more familiar setting.

Authors

  • Bruno Caballero
  • Dr. Christian Kuka
  • Dr. Koutheir Attouchi