Add QEMU end-to-end test verifying simulated USB devices

Boot a real Linux inside QEMU and confirm that simulated USB/IP devices
work against a real kernel (vhci-hcd + cdc_acm):

- Add examples/demo.rs: a single server exporting a HID keyboard and a
  CDC ACM serial device on port 3240.
- Add scripts/qemu/: build a minimal initramfs (host kernel + busybox +
  usbip tool + demo server), boot it in QEMU, attach the simulated devices
  over 127.0.0.1, and verify:
    * the serial port emits 'a' (0x61)  -> SERIAL_TEST
    * the keyboard generates a KEY_1 input event -> KEYBOARD_TEST
- Add .github/workflows/qemu.yml to run this in CI (KVM when available,
  TCG fallback otherwise), uploading the serial console log as an artefact.
This commit is contained in:
Jiajie Chen
2026-09-04 00:16:40 +08:00
parent 65c3d1864d
commit 411e63f8e8
7 changed files with 512 additions and 1 deletions

54
.github/workflows/qemu.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: QEMU usbip integration test
# Boot a real Linux inside QEMU and verify that the *simulated* USB devices
# exported by this crate actually work (a CDC ACM serial port and a HID keyboard).
on: [push, pull_request]
jobs:
qemu-test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install system packages
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
qemu-system-x86 busybox-static cpio usb.ids
# The usbip userspace tool ships in the distro 'usbip' package (universe),
# or in linux-tools. Try both and make sure a binary is available.
if ! sudo apt-get install -y usbip; then
sudo apt-get install -y linux-tools-common linux-tools-generic
fi
echo "--- installed binaries ---"
ls -l /usr/bin/busybox /usr/bin/qemu-system-x86_64 2>/dev/null || true
ls -l /usr/sbin/usbip /usr/bin/usbip 2>/dev/null || true
- name: Preflight checks
run: |
set -euo pipefail
echo "kernel: $(uname -r)"
ls -l /boot/vmlinuz-$(uname -r)
ls -l /lib/modules/$(uname -r)/kernel/drivers/usb/usbip/ 2>/dev/null || true
ls -l /lib/modules/$(uname -r)/kernel/drivers/usb/class/cdc-acm.ko 2>/dev/null || true
command -v usbip >/dev/null 2>&1 || test -x /usr/sbin/usbip
test -e /dev/kvm && echo "KVM: available" || echo "KVM: not available (will use TCG)"
- name: Run QEMU end-to-end test
run: |
./scripts/qemu/run-qemu-test.sh --build --dump-log
- name: Upload serial console log
if: always()
uses: actions/upload-artifact@v4
with:
name: qemu-serial-log
path: .qemu-serial.log
if-no-files-found: ignore

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target
Cargo.lock
/.qemu-serial.log

View File

@@ -31,11 +31,12 @@ cargo build --release
### Examples
The `examples/` directory contains three example programs:
The `examples/` directory contains four example programs:
1. **hid_keyboard**: Simulate a HID keyboard that types something every second
2. **cdc_acm_serial**: Simulate a CDC ACM serial device that receives a character every second
3. **host**: Act as a USB/IP server, sharing physical devices from the host machine to remote clients
4. **demo**: Simulate a HID keyboard *and* a CDC ACM serial device together (used by the QEMU test)
#### Running an example
@@ -55,6 +56,25 @@ usbip list -r $remote_ip
usbip attach -r $remote_ip -b $bus_id
```
### QEMU end-to-end test
The simulated devices can be verified against a real Linux kernel booted under
QEMU. The test assembles a minimal initramfs from the running kernel, its USB/IP
and cdc_acm modules, a static busybox and the `usbip` userspace client, then
boots it and uses `vhci-hcd` to attach the simulated keyboard and serial device.
Inside the guest it confirms the serial port emits `'a'` and that the keyboard
generates a `KEY_1` input event.
Run it locally (requires `qemu-system-x86`, a static `busybox`, `cpio`, and the
`usbip` tool):
```bash
./scripts/qemu/run-qemu-test.sh --build --dump-log
```
It runs under KVM when available and falls back to QEMU TCG otherwise. It is
also wired into CI via `.github/workflows/qemu.yml`.
## License
MIT License - see [LICENSE](LICENSE) file for details.

94
examples/demo.rs Normal file
View File

@@ -0,0 +1,94 @@
//! Simulate a HID keyboard **and** a CDC ACM serial device on a single USB/IP server.
//!
//! This example is intended for the QEMU end-to-end test: it exports two simulated
//! devices on one server port (`0.0.0.0:3240`) so a real Linux (booted inside QEMU)
//! can attach to both of them and verify that the simulated serial port and keyboard
//! actually work.
use std::net::*;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use log::*;
use tokio::time;
/// Build a simulated HID keyboard device.
fn keyboard_device() -> usbip::UsbDevice {
let handler = Arc::new(Mutex::new(
Box::new(usbip::hid::UsbHidKeyboardHandler::new_keyboard())
as Box<dyn usbip::UsbInterfaceHandler + Send>,
));
let mut device = usbip::UsbDevice::new(1).with_interface(
usbip::ClassCode::HID as u8,
0x00,
0x00,
Some("Test HID"),
vec![usbip::UsbEndpoint {
address: 0x81, // IN
attributes: 0x03, // Interrupt
max_packet_size: 0x08, // 8 bytes
interval: 10,
}],
handler.clone(),
);
device.bus_id = "1-1".to_string();
device
}
/// Build a simulated CDC ACM serial device.
fn serial_device() -> usbip::UsbDevice {
let handler =
Arc::new(Mutex::new(Box::new(usbip::cdc::UsbCdcAcmHandler::new())
as Box<dyn usbip::UsbInterfaceHandler + Send>));
let mut device = usbip::UsbDevice::new(2).with_interface(
usbip::ClassCode::CDC as u8,
usbip::cdc::CDC_ACM_SUBCLASS,
0x00,
Some("Test CDC ACM"),
usbip::cdc::UsbCdcAcmHandler::endpoints(),
handler.clone(),
);
device.bus_id = "1-2".to_string();
device
}
#[tokio::main]
async fn main() {
env_logger::init();
let keyboard = keyboard_device();
let handler = keyboard.interfaces.first().unwrap().handler.clone();
let serial = serial_device();
let serial_handler = serial.interfaces.first().unwrap().handler.clone();
let server = Arc::new(usbip::UsbIpServer::new_simulated(vec![keyboard, serial]));
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 3240);
tokio::spawn(usbip::server(addr, server));
loop {
// Drive the simulated devices every second:
// - the keyboard presses "1"
// - the serial device emits a character 'a'
time::sleep(Duration::new(1, 0)).await;
if let Some(hid) = handler
.lock()
.unwrap()
.as_any()
.downcast_mut::<usbip::hid::UsbHidKeyboardHandler>()
{
hid.pending_key_events
.push_back(usbip::hid::UsbHidKeyboardReport::from_ascii(b'1'));
info!("Simulate a key event");
}
if let Some(acm) = serial_handler
.lock()
.unwrap()
.as_any()
.downcast_mut::<usbip::cdc::UsbCdcAcmHandler>()
{
acm.tx_buffer.push(b'a');
info!("Simulate a char input");
}
}
}

91
scripts/qemu/build-initramfs.sh Executable file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Build a minimal initramfs for the QEMU usbip end-to-end test.
#
# The initramfs bundles everything the guest needs to:
# 1. boot (via the host kernel, /boot/vmlinuz-$(uname -r))
# 2. load the vhci-hcd + cdc_acm kernel modules
# 3. run the compiled usbip demo server (our library under test)
# 4. run the usbip userspace client and attach the simulated devices
#
# Usage: build-initramfs.sh <output.cpio.gz>
#
# Overridable via env:
# KERNEL kernel version whose modules/kernel to use (default: uname -r)
# BUSYBOX path to a *static* busybox binary (default: `which busybox`)
# USBIP path to the usbip userspace binary (default: auto-detect)
# DEMO_BIN path to the compiled demo example (default: target/release/examples/demo)
set -euo pipefail
OUT="${1:?usage: build-initramfs.sh <output.cpio.gz>}"
KERNEL="${KERNEL:-$(uname -r)}"
BUSYBOX="${BUSYBOX:-$(command -v busybox)}"
USBIP="${USBIP:-$(command -v usbip || echo /usr/sbin/usbip)}"
DEMO_BIN="${DEMO_BIN:-target/release/examples/demo}"
[ -e "$BUSYBOX" ] || { echo "ERROR: busybox not found at $BUSYBOX"; exit 1; }
[ -e "$USBIP" ] || { echo "ERROR: usbip not found at $USBIP"; exit 1; }
[ -e "$DEMO_BIN" ] || { echo "ERROR: demo binary not found at $DEMO_BIN"; exit 1; }
ROOT=$(mktemp -d)
trap 'rm -rf "$ROOT"' EXIT
echo "kernel=$KERNEL busybox=$BUSYBOX usbip=$USBIP demo=$DEMO_BIN"
# --- binaries ---
mkdir -p "$ROOT/bin" "$ROOT/sbin" "$ROOT/usr/sbin" "$ROOT/usr/share/misc"
cp "$BUSYBOX" "$ROOT/bin/busybox"
# busybox applet symlinks (see init.sh for the applets we use)
for a in sh insmod modprobe ls cat dmesg grep sleep dd hexdump od mount mknod \
poweroff reboot echo printf test head tail cp rm more seq timeout mkdir \
sync umount true false mdev find ifconfig stty; do
ln -sf busybox "$ROOT/bin/$a"
done
ln -sf busybox "$ROOT/sbin/modprobe"
ln -sf busybox "$ROOT/sbin/insmod"
ln -sf busybox "$ROOT/sbin/mdev"
cp "$USBIP" "$ROOT/usr/sbin/usbip"
cp "$DEMO_BIN" "$ROOT/demo_server"
# --- kernel modules (preserve layout so the guest can `find` them) ---
for mod in usbip-core vhci-hcd cdc-acm; do
ko=$(find "/lib/modules/$KERNEL" -name "$mod.ko" 2>/dev/null | head -1)
if [ -z "$ko" ]; then
echo "ERROR: kernel module $mod.ko not found for kernel $KERNEL"
exit 1
fi
mkdir -p "$ROOT$(dirname "$ko")"
cp "$ko" "$ROOT$ko"
done
# --- usb.ids database required by the usbip tool ---
UI="$(find /usr/share -name usb.ids 2>/dev/null | head -1)"
[ -n "$UI" ] && cp "$UI" "$ROOT/usr/share/misc/usb.ids" || echo "WARN: usb.ids not found"
# --- libraries needed by usbip + demo server (copy preserving absolute path) ---
copy_libs() {
local bin="$1"
ldd "$bin" 2>/dev/null \
| grep -oE '/[^ ]+\.so[^ ]*' \
| sort -u \
| while read -r lib; do
[ -e "$lib" ] || continue
mkdir -p "$ROOT$(dirname "$lib")"
cp -L "$lib" "$ROOT$lib"
done
}
copy_libs "$USBIP"
copy_libs "$DEMO_BIN"
# --- device dirs (devtmpfs populates /dev at runtime) ---
mkdir -p "$ROOT/dev" "$ROOT/proc" "$ROOT/sys" "$ROOT/tmp"
mknod -m 666 "$ROOT/dev/console" c 5 1 2>/dev/null || true
# --- init script ---
cp "$(dirname "$0")/init.sh" "$ROOT/init"
chmod +x "$ROOT/init"
# --- cpio archive ---
( cd "$ROOT" && find . -print0 | cpio --null -ov --format=newc 2>/dev/null | gzip -9 > "$OUT" )
echo "wrote $OUT"

142
scripts/qemu/init.sh Executable file
View File

@@ -0,0 +1,142 @@
#!/bin/busybox sh
# QEMU end-to-end usbip test init script.
#
# Runs *inside* the QEMU guest. It:
# 1. mounts proc/sys/devtmpfs
# 2. loads vhci-hcd + cdc_acm kernel modules
# 3. starts the compiled usbip demo server (our library under test)
# 4. uses the usbip client to attach the simulated HID keyboard + CDC ACM serial
# 5. verifies the serial device emits 'a' and the keyboard emits KEY_1
# 6. prints "TEST_RESULT: PASS" or "TEST_RESULT: FAIL" and powers off
#
# The workflow greps the serial console output for TEST_RESULT.
set -x
PATH=/sbin:/bin:/usr/sbin:/usr/bin
export PATH
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev 2>/dev/null || true
mkdir -p /dev/pts
mount -t devpts devpts /dev/pts 2>/dev/null || true
mount -t tmpfs tmpfs /tmp 2>/dev/null || true
# Bring up loopback so the usbip client can reach the local server on 127.0.0.1
ifconfig lo 127.0.0.1 netmask 255.0.0.0 up 2>/dev/null || echo "WARN: could not bring up lo"
echo "=== init: mounting done ==="
K=$(uname -r)
echo "=== kernel: $K ==="
echo "=== loading modules ==="
for mod in usbip-core vhci-hcd cdc-acm; do
ko=$(find /lib/modules -name "$mod.ko" 2>/dev/null | head -1)
echo "- loading $ko"
insmod "$ko" || echo "WARN: failed to load $ko"
done
sleep 1
echo "=== starting usbip demo server ==="
RUST_LOG=info /demo_server > /tmp/server.log 2>&1 &
SERVER_PID=$!
sleep 2
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "!!! demo server died, log:"
cat /tmp/server.log
fi
echo "=== usbip list ==="
usbip list -r 127.0.0.1
# Extract busids (e.g. 1-1, 1-2) from "usbip list -r" output
BUSIDS=$(usbip list -r 127.0.0.1 2>/dev/null \
| sed -n 's/^[[:space:]]*\([0-9][0-9-]*\):.*/\1/p')
echo "=== bus ids: [$BUSIDS] ==="
PASS=1
if [ -z "$BUSIDS" ]; then
echo "TEST_RESULT: FAIL (no exportable devices)"
dmesg | tail -40
poweroff -f
sleep 1
exit 1
fi
for BUSID in $BUSIDS; do
echo "=== attaching $BUSID ==="
# Note: the usbip tool may report a spurious "record connection" error after a
# successful attach; the kernel still enumerates the device, so we don't abort here.
usbip attach -r 127.0.0.1 -b "$BUSID"
echo "=== attach exit: $? ==="
sleep 2
done
echo "=== waiting for devices ==="
for i in $(seq 1 30); do
ACM=$(ls /dev/ttyACM* 2>/dev/null | head -1)
EV=$(awk '
/^S:.*vhci_hcd/ { f=1 }
f && /^H:/ { if (match($0, /event[0-9]+/)) { print substr($0, RSTART, RLENGTH); exit } }
' /proc/bus/input/devices)
echo "iter $i: ACM=[$ACM] event=[$EV]"
if [ -n "$ACM" ] && [ -n "$EV" ]; then
break
fi
sleep 1
done
echo "=== /proc/bus/input/devices ==="
cat /proc/bus/input/devices 2>/dev/null
echo
echo "=== /dev listing ==="
ls -la /dev/ttyACM* /dev/input/ 2>/dev/null
# ---- Serial test: read one raw byte from /dev/ttyACM0, expect 'a' (0x61) ----
if [ -n "$ACM" ]; then
echo "=== serial test: reading $ACM (raw) ==="
stty -F "$ACM" raw -echo 2>/dev/null || true
DATA=$(timeout 8 dd if="$ACM" bs=1 count=1 2>/dev/null | od -An -tx1 -v | tr -d ' \n')
echo "serial byte: [$DATA]"
case "$DATA" in
61) echo "SERIAL_TEST: PASS (got 'a' = 0x61)" ;;
*) echo "SERIAL_TEST: FAIL ([$DATA])"; PASS=0 ;;
esac
else
echo "SERIAL_TEST: FAIL (no /dev/ttyACM device)"; PASS=0
fi
# ---- Keyboard test: read the vhci HID event device, look for KEY_1 (code=2) ----
if [ -n "$EV" ]; then
echo "=== keyboard test: reading /dev/input/$EV (timeout 6s) ==="
timeout 6 cat "/dev/input/$EV" > /tmp/kbd.bin 2>/dev/null
HEX=$(od -An -tx1 -v /tmp/kbd.bin | tr -d ' \n')
echo "kbd bytes: $HEX"
# EV_KEY(type=1) KEY_1(code=2) value=1(down) -> LE bytes "0100 0200 01000000"
case "$HEX" in
*0100020001000000*) echo "KEYBOARD_TEST: PASS (KEY_1 down event seen)" ;;
*) echo "KEYBOARD_TEST: FAIL"; PASS=0 ;;
esac
else
echo "KEYBOARD_TEST: FAIL (no vhci input event device)"; PASS=0
fi
echo "=== server.log (tail) ==="
tail -40 /tmp/server.log 2>/dev/null
if [ "$PASS" = "1" ]; then
echo "TEST_RESULT: PASS"
else
echo "TEST_RESULT: FAIL"
fi
echo "=== dmesg tail ==="
dmesg | tail -20
sync
sleep 1
poweroff -f
sleep 1
exit 0

109
scripts/qemu/run-qemu-test.sh Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Run the QEMU-based usbip end-to-end test.
#
# This builds the `demo` example, assembles an initramfs from the running
# kernel + modules + busybox + usbip tool, boots it in QEMU, and verifies that
# the simulated serial port and keyboard actually work inside a real Linux.
#
# Usage: run-qemu-test.sh [--build] [--dump-log]
# --build build the demo example first (default: assume prebuilt)
# --dump-log print the full serial console log on stdout (for debugging)
#
# Exit: 0 if the test passed, non-zero otherwise.
set -uo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$REPO_ROOT"
BUILD=0
DUMP=0
for arg in "$@"; do
case "$arg" in
--build) BUILD=1 ;;
--dump-log) DUMP=1 ;;
esac
done
if [ "$BUILD" = "1" ]; then
echo "=== building demo example ==="
cargo build --example demo --release --all-features || exit 1
fi
DEMO_BIN="$REPO_ROOT/target/release/examples/demo"
[ -e "$DEMO_BIN" ] || { echo "ERROR: $DEMO_BIN not found; run with --build"; exit 1; }
KERNEL=$(uname -r)
VMLINUZ=/boot/vmlinuz-$KERNEL
[ -e "$VMLINUZ" ] || { echo "ERROR: no vmlinuz for kernel $KERNEL at $VMLINUZ"; exit 1; }
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# Persist the serial console log for CI artefact upload / debugging.
SERIAL_LOG="${REPO_ROOT}/.qemu-serial.log"
rm -f "$SERIAL_LOG"
echo "=== building initramfs ==="
DEMO_BIN="$DEMO_BIN" KERNEL="$KERNEL" "$SCRIPT_DIR/build-initramfs.sh" "$WORK/initramfs.cpio.gz" || exit 1
QEMU_CMD=(qemu-system-x86_64 -m 512 -smp 2)
# Use hardware acceleration if /dev/kvm is present, otherwise fall back to TCG.
# FORCE_TCG=1 forces software emulation (useful for debugging / CI runners w/o KVM).
if [ "${FORCE_TCG:-0}" != "1" ] && [ -e /dev/kvm ] && [ -w /dev/kvm ]; then
QEMU_CMD+=(-enable-kvm -cpu host)
echo "=== using KVM acceleration ==="
else
QEMU_CMD+=(-cpu max)
echo "=== /dev/kvm not available, falling back to TCG (slow) ==="
fi
QEMU_CMD+=(
-kernel "$VMLINUZ"
-initrd "$WORK/initramfs.cpio.gz"
-append "console=ttyS0 panic=-1"
-display none
-serial "file:$SERIAL_LOG"
-monitor none
-no-reboot
)
KVM_FAIL=0
echo "=== launching QEMU ==="
if [[ " ${QEMU_CMD[*]} " == *" -enable-kvm "* ]]; then
timeout 300 "${QEMU_CMD[@]}" 2>"$WORK/qemu.err" || { KVM_FAIL=$?; }
# If KVM failed (e.g. permission), retry with TCG once.
if [ "$KVM_FAIL" != "0" ]; then
echo "=== KVM run returned $KVM_FAIL, retrying with TCG ==="
rm -f "$SERIAL_LOG"
timeout 600 qemu-system-x86_64 -m 512 -smp 2 -cpu max \
-kernel "$VMLINUZ" \
-initrd "$WORK/initramfs.cpio.gz" \
-append "console=ttyS0 panic=-1" \
-display none -serial "file:$SERIAL_LOG" -monitor none -no-reboot \
2>"$WORK/qemu.err" || true
fi
else
timeout 600 "${QEMU_CMD[@]}" 2>"$WORK/qemu.err" || true
fi
echo "=== serial log (tail) ==="
tail -80 "$SERIAL_LOG" || true
if [ "$DUMP" = "1" ]; then
echo "=== full serial log ==="
cat "$SERIAL_LOG" 2>/dev/null || true
echo "=== qemu stderr ==="
cat "$WORK/qemu.err" 2>/dev/null || true
fi
if grep -q "TEST_RESULT: PASS" "$SERIAL_LOG"; then
echo "SUCCESS: QEMU usbip test passed"
echo "VERIFY_SERIAL=$(grep -c 'SERIAL_TEST: PASS' "$SERIAL_LOG")"
echo "VERIFY_KEYBOARD=$(grep -c 'KEYBOARD_TEST: PASS' "$SERIAL_LOG")"
exit 0
else
echo "FAILURE: TEST_RESULT: PASS not found in serial log"
exit 1
fi