ARM workflow with qemu and arm-none-eabi

John Andersen
John Andersen

This is just the markdown portion of set of files which can be found here: https://gist.github.com/johnandersen777/3de9a9bdd38cacf3ea394207762f1002

This should get you up and running writing ARM assembly without hardware.

Clone this the repo for this tutorial.

git clone https://gist.github.com/johnandersen777/3de9a9bdd38cacf3ea394207762f1002 arm-qemu

Dependencies

The first step is to install the necessary packages. These are the arm-none-eabi tool chain and qemu with arm support.

Arch Linux

sudo pacman -S arm-none-eabi-gcc arm-none-eabi-binutils arm-none-eabi-gdb \
  arm-none-eabi-newlib qemu qemu-arch-extra

Ubuntu

sudo apt -y install make qemu-system-arm \
    gcc-arm-none-eabi binutils-arm-none-eabi gdb-arm-none-eabi \
    libstdc++-arm-none-eabi-newlib libnewlib-arm-none-eabi

GDB

In .gdbinit we have placed commands which gdb will run on startup. But to make this work the .gdbinit file in our home directory needs to say its ok for gdb to load this .gdbinit file. To do that we just add the directory to the auto-load safe-path.

echo "set auto-load safe-path $PWD" >> ~/.gdbinit

Building

The Makefile should have plenty of comments to help you understand what is being done in it. It takes all the .s assembly files in the current directory and compiles them into object files. Then it runs the linker to create the ELF binary. All of this is done with arm-none-eabi-gcc rather than your regular gcc for host programs.

make

Will rebuild all the modified .s files into their object file forms and relink to the binary. Run make clean all if you are having really weird errors. That usually fixes things.

Running

To run you can do qemu-arm ./main. But hey why not put it in the Makefile right.

make all qemu

Will rebuild any changed files and run the created binary in qemu.

Debugging

Oh you ran the program and everything exploded? Time to debug.

make all gdb

Will rebuild all your source files and start the program in qemu with it as a gdb target on port 1234, so make sure nothing else is using that port or change it in the .gdbinit file and Makefile.

Help nothing works

Comment on the gist with the problem so we can figure it out and everyone else can see the solution.

Arduino

curl -sfL https://github.com/arduino/arduino-cli/releases/download/0.19.2/arduino-cli_0.19.2_Linux_64bit.tar.gz | tar xvz
# Hangs because proxies aren't set yet
timeout 1s ./arduino-cli config init --overwrite
./arduino-cli config set network.proxy $https_proxy
./arduino-cli core update-index
./arduino-cli core install arduino:avr
./arduino-cli compile --fqbn arduino:avr:leonardo .
"""


Compile QEMU Version 5.1.0 or newer. 5.1.0 is when AVR support was introduced.

.. code-block:: console

    $ wget https://download.qemu.org/qemu-6.1.0.tar.xz
    $ tar xvJf qemu-6.1.0.tar.xz
    $ cd qemu-6.1.0
    $ ./configure --target-list="avr-softmmu"
    $ make -j $(($(nproc)*4))

Change directory to this file's parent directory and run using unittest

.. code-block:: console

    $ cd python/cmd_msg_test/
    $ python -u -m unittest discover -v
    test_connect (test_cmd_msg.TestSerial) ... qemu-system-avr: -chardev socket,id=serial_port,path=/tmp/tmpuuq3oqvj/socket,server=on: info: QEMU waiting for connection on: disconnected:unix:/tmp/tmpuuq3oqvj/socket,server=on
    reading message from arduino
    b''
    b''
    qemu-system-avr: terminating on signal 2 from pid 90395 (python)
    ok

    ----------------------------------------------------------------------
    Ran 1 test in 4.601s

    OK
"""
import os
import sys
import signal
import pathlib
import tempfile
import unittest
import subprocess
import contextlib
import dataclasses

import main

# top level directory in this git repo is three levels up
REPO_ROOT = pathlib.Path(__file__).parents[2].resolve()


@contextlib.contextmanager
def start_qemu(bios):
    with tempfile.TemporaryDirectory() as tempdir:
        socket_path = pathlib.Path(tempdir, "socket")
        qemu_cmd = [
            "qemu-system-avr",
            "-mon",
            "chardev=none",
            "-chardev",
            f"null,id=none",
            "-serial",
            "chardev:serial_port",
            "-chardev",
            f"socket,id=serial_port,path={socket_path},server=on",
            "-nographic",
            "-machine",
            "arduino-uno",
            "-cpu",
            "avr6-avr-cpu",
            "-bios",
            str(bios),
        ]
        qemu_proc = subprocess.Popen(qemu_cmd, start_new_session=True)
        serial_port_path = pathlib.Path(tempdir, "ttyACM0")
        socat_cmd = [
            "socat",
            f"PTY,link={serial_port_path},rawer,wait-slave",
            f"UNIX:{socket_path}",
        ]
        socat_proc = subprocess.Popen(socat_cmd, start_new_session=True)
        try:
            while not serial_port_path.exists():
                pass
            yield str(serial_port_path)
        finally:
            # Kill the whole process group (for problematic processes like qemu)
            os.killpg(qemu_proc.pid, signal.SIGINT)
            os.killpg(socat_proc.pid, signal.SIGINT)
        qemu_proc.wait()
        socat_proc.wait()


class RunQEMU(unittest.TestCase):
    """
    Base class which will start QEMU to emulate an Arduino Uno machine using the
    BIOS (the .elf output of arduino-cli compile) provided.

    qemu-system-avr from QEMU Version 5.1.0 or newer is required.

    Starts a new virtual machine for each test_ function.
    """

    BIOS = REPO_ROOT.joinpath("build", "serial_cmd_test.ino.elf")

    def setUp(self):
        self.qemu = start_qemu(self.BIOS)
        # __enter__ is called at the beginning of a `with` block. __exit__ is
        # called at the end of a `with` block. By calling these functions
        # explicitly within setUp() and tearDown() we ensure a new VM is created
        # and destroyed each time.
        self.serial_port = self.qemu.__enter__()

    def tearDown(self):
        self.qemu.__exit__(None, None, None)
        del self.qemu


class TestSerial(RunQEMU, unittest.TestCase):
    def test_connect(self):
        os.environ["SERIAL_PORT"] = self.serial_port
        subprocess.check_call([sys.executable, "main.py"])