blob: f98a7d1227f89bb5e214c7406c33acbefc559d3e [file]
# Copyright 2025 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Utility to invoke qemu and pipe the output through the detokenizer.
"""
import argparse
import logging
import os
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
from pw_tokenizer import detokenize
_LOG = logging.getLogger(__name__)
_LOG.setLevel(logging.INFO)
try:
import qemu.qemu_system_arm # type: ignore
import qemu.qemu_system_riscv32 # type: ignore
from python.runfiles import runfiles # type: ignore
r = runfiles.Create()
assert r is not None
_QEMU_ARM = r.Rlocation(
*qemu.qemu_system_arm.RLOCATION,
)
_QEMU_RISCV32 = r.Rlocation(*qemu.qemu_system_riscv32.RLOCATION)
except ImportError:
_LOG.fatal("runfiles could not find qemu")
# Keep mypy happy
assert _QEMU_ARM is not None
assert _QEMU_RISCV32 is not None
# Dict mapping the cpu type to the correct qemu binary
_QEMU_BINARY_CPU_TYPE = dict[str, str](
{
'cortex-m0': _QEMU_ARM,
'cortex-m3': _QEMU_ARM,
'cortex-m33': _QEMU_ARM,
'cortex-m4': _QEMU_ARM,
'cortex-m55': _QEMU_ARM,
'rv32': _QEMU_RISCV32,
'rv32i': _QEMU_RISCV32,
'rv32e': _QEMU_RISCV32,
}
)
def _parse_args():
"""Parse and return command line arguments."""
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
'--machine',
type=str,
help='qemu machine type',
)
parser.add_argument(
'--cpu',
type=str,
help='qemu cpu type',
)
parser.add_argument(
'--image',
type=str,
help='image file to run',
)
parser.add_argument(
'--semihosting',
action='store_true',
help='image file to run',
)
parser.add_argument(
'--qemu-args',
nargs='*',
help='Extra arguments to pass to qemu',
)
parser.add_argument(
'--uart-tcp-port',
type=int,
help='Expose serial port over local TCP socket on this port',
)
parser.add_argument(
'--gdb-tcp-port',
type=int,
help='Expose a gdb server over local TCP socket on this port.',
)
parser.add_argument(
'--gdb-pause',
action=argparse.BooleanOptionalAction,
help=(
'On startup, the guest will wait until the gdb client '
'sends a `continue` command. '
'Only applicable if the gdb tcp port is set.'
),
)
return parser.parse_known_args()
def _detokenizer(
image: Path, tokenized_file: Path, qemu_finished: threading.Event
):
try:
detokenizer = detokenize.Detokenizer(image)
line_buffer = ""
with open(tokenized_file, 'r', buffering=1) as f:
while not qemu_finished.is_set():
try:
chunk = f.readline()
if chunk:
# qemu may not write a complete line, so buffer
# the chunks until there is a complete line to
# pass to the detokenizer.
line_buffer += chunk
# Use a while loop, as there could also potentially
# be multiple lines printed in-between iterations.
while '\n' in line_buffer:
newline_pos = line_buffer.find('\n') + 1
complete_line = line_buffer[:newline_pos]
detokenizer.detokenize_text_to_file(
complete_line, sys.stdout.buffer
)
sys.stdout.flush()
line_buffer = line_buffer[newline_pos:]
except BlockingIOError:
# If writing to stdout too fast, it's sometimes possible
# to get BlockingIOError due to the stdout buffer being
# full, so sleep and try again.
time.sleep(0.1)
# detokenize any remaining data in the buffer.
if line_buffer:
detokenizer.detokenize_text_to_file(
complete_line, sys.stdout.buffer
)
sys.stdout.flush()
except OSError as e:
print(f"Exception opening file {e}", file=sys.stderr)
def _main(args) -> int:
try:
qemu_exe: str = _QEMU_BINARY_CPU_TYPE[args.cpu.lower()]
except KeyError:
_LOG.fatal("unknown cpu type: %s", args.cpu)
qemu_args = [
qemu_exe,
"-machine",
args.machine,
"-cpu",
args.cpu,
"-bios",
"none",
"-nographic",
"-serial",
(
f"tcp:127.0.0.1:{args.uart_tcp_port},server,wait"
if args.uart_tcp_port
else "mon:stdio"
),
"-kernel",
args.image,
]
if args.semihosting:
qemu_args.extend(
[
"-semihosting-config",
"enable=on,target=native",
]
)
if args.gdb_tcp_port:
qemu_args.extend(
[
"-gdb",
f"tcp:127.0.0.1:{args.gdb_tcp_port},server,wait",
]
)
if args.gdb_pause:
qemu_args.append("-S") # make the guest pause on startup
if args.qemu_args:
qemu_args.extend(args.qemu_args)
_LOG.info("Invoking QEMU: %s", qemu_args)
with tempfile.NamedTemporaryFile() as f:
with subprocess.Popen(
args=qemu_args,
stdout=f,
) as proc:
# Capturing the sub process stdout or stderr and then writing to
# stdout can cause deadlocks (see
# https://docs.python.org/3/library/subprocess.html#subprocess.Popen.stderr)
# due to a write buffer (child process) filling up the pipe
# buffer before the parent process can consume it.
# To work around this, write to a temp file, and have the
# detokenizer poll and detokenize the temp file.
qemu_finished_event = threading.Event()
stdout_thread = threading.Thread(
target=_detokenizer,
args=(Path(args.image), Path(f.name), qemu_finished_event),
daemon=True,
)
stdout_thread.start()
out, err = proc.communicate()
qemu_finished_event.set()
if out:
print(out)
if err:
print(err)
return_code = proc.returncode
stdout_thread.join()
sys.exit(return_code)
if __name__ == '__main__':
known_args, remaining_args = _parse_args()
if os.environ.get("PW_RUNNER_PASSTHROUGH") == "1":
_LOG.info("Bypassing QEMU: %s", known_args.image)
res = subprocess.run([known_args.image] + remaining_args)
sys.exit(res.returncode)
_main(known_args)