Firmware testing with the TestKit#
Avr8Sharp.TestKit turns the emulator into a fluent test harness: build a simulation,
attach probes, run it under a bound, and assert on the result.
Building a simulation#
Use a board preset for the fastest path:
using Avr8Sharp.TestKit.Boards;
var uno = new ArduinoUnoSimulation()
.WithHex(File.ReadAllText("sketch.hex"));
Or build a custom simulation for chips not covered by a preset:
using Avr8Sharp.TestKit;
var sim = AvrTestSimulation.Create(flashSize: 0x8000, sramBytes: 2048)
.WithFrequency(16_000_000)
.WithHex(File.ReadAllText("firmware.hex"))
.AddGpio(AvrIoPort.PortBConfig, out var portB)
.AddUsart(AvrUsart.Usart0Config, out var serial)
.AddTimer(AvrTimer.Timer0Config);
Running the simulation#
Method |
When to use |
|---|---|
|
Fixed simulated time — simple cases where firmware is healthy |
|
Exact cycle count |
|
Exact instruction count |
|
Stop as soon as a condition is true |
|
Stop on condition, timeout in simulated time |
|
Stop when serial output contains a string |
|
Stop at the next |
|
Stop when PC reaches a byte address (from objdump) |
Serial probes#
Every AddUsart call returns a SerialProbe that captures all transmitted bytes:
var mega = new ArduinoMegaSimulation()
.WithHex(hex);
mega.RunUntilSerial(mega.Serial0, "Ready", maxMs: 2000);
mega.Serial0.Text.Should().Contain("Ready");
mega.Serial0.Lines.Should().HaveCount(3);
mega.Serial0.ByteCount.Should().BeGreaterThan(0);
Assertions#
CPU health#
using Avr8Sharp.TestKit.Assertions;
sim.Cpu.Should().HaveSreg(SREG_Z); // specific SREG flags set
sim.Cpu.Should().HaveRegister(16, 0xFF); // r16 == 0xFF
sim.Cpu.Should().HavePc(0x0042); // PC at word address
GPIO ports#
uno.PortB.Should().HavePinHigh(5); // PB5 driven high by firmware
uno.PortB.Should().HavePinLow(0);
Memory#
sim.Memory.Should().HaveBytesAt(0x0200, new byte[] { 0xDE, 0xAD, 0xBE, 0xEF });
Serial#
uno.Serial.Should().Contain("Hello");
uno.Serial.Should().HaveLineCount(5);
Inline assembly#
Tests that target individual instructions can assemble code inline without an external toolchain:
var sim = AvrTestSimulation.Create()
.WithAsm("ldi r16, 0x55\r\nneg r16\r\nbreak");
sim.RunToBreak();
sim.Cpu.Should().HaveRegister(16, 0xAB);
Simulating external input#
UART RX (host → firmware)#
Inject bytes into the firmware’s receive buffer:
// Send the string "hello\n" to the firmware over USART0
foreach (var b in System.Text.Encoding.ASCII.GetBytes("hello\n"))
uno.Serial.Usart.WriteByte(b);
uno.RunUntilSerial(uno.Serial, "got: hello", maxMs: 1000);
ADC (analog sensor)#
Set the voltage on an ADC channel before or during the run. The firmware reads it on the next ADC conversion:
var uno = new ArduinoUnoSimulation()
.WithHex(hex)
.AddAdc(AvrAdc.AdcConfig, out var adc);
// 2500 mV on A0 → mid-scale (512 counts at 10-bit / 5 V reference)
adc.ChannelValues[0] = 2500;
uno.RunUntilSerial(uno.Serial, "adc=512", maxMs: 1000);
You can change the value mid-run to simulate a changing sensor:
uno.RunMilliseconds(100);
adc.ChannelValues[0] = 4000; // voltage rises
uno.RunUntilSerial(uno.Serial, "adc=819", maxMs: 1000);
TWI / I²C (slave device simulation)#
The built-in TWI peripheral emulates master mode. To simulate a slave device responding to the firmware, hook the TWI events:
var uno = new ArduinoUnoSimulation()
.WithHex(hex)
.AddTwi(AvrTwi.TwiConfig, out var twi);
// Respond to address 0x48 (a temperature sensor) with two bytes
twi.EventHandler = (eventType, data) =>
{
if (eventType == TwiEventType.AddressMatch)
return [0x01, 0x90]; // 25.0 °C in 12-bit format
return null;
};
Debugging a failing test#
Reading the TimeoutException message#
When a bounded run times out, the exception message includes the CPU state at the moment the limit was hit:
TimeoutException: RunUntilSerial: "PASS" not found within 2000 ms of simulated time
Cycles=32000000, PC=0x01A4 (byte 0x0348), SP=0x08F8, SREG=0x02
PC (word address): convert to byte address with
× 2and cross-reference withavr-objdump -d firmware.elfto find which function the firmware was stuck in.SREG=0x02: only the Zero flag is set — the firmware may be in a tight busy-wait loop.
SP: compare with RAMEND (
0x08FFon Uno) — if SP is near RAMEND the stack has not grown, suggesting the firmware never reachedmain()or itssetup().
Step-by-step bisection#
Run a small number of instructions at a time and print state at each checkpoint:
var uno = new ArduinoUnoSimulation().WithHex(hex);
for (int i = 0; i < 100; i++)
{
uno.RunInstructions(1000);
Console.WriteLine(
$"PC=0x{uno.Cpu.Pc:X4} SP=0x{uno.Cpu.Sp:X4} " +
$"SREG=0x{uno.Cpu.Sreg:X2} Serial=\"{uno.Serial.Text}\"");
}
Checking CPU health mid-run#
Assert after each segment to pinpoint when the firmware goes wrong:
uno.RunMilliseconds(10);
uno.Cpu.Should().HaveInterruptsEnabled("setup() should have run SEI");
uno.Cpu.Should().HaveSP(0x08F8, "stack should be shallow after setup");
uno.RunUntilSerial(uno.Serial, "ready", maxMs: 500);
uno.PortB.Should().HavePinHigh(5, "LED should be on after init");
Verify the firmware actually loaded#
If the PC never moves from 0, the HEX file may not have loaded correctly:
uno.RunInstructions(10);
Assert.That(uno.Cpu.Pc, Is.GreaterThan(0u), "PC did not advance — HEX load failed?");
Deterministic instruction count#
Because the clock is driven by executed cycles (not wall-clock), the instruction count is reproducible across machines. Use it as a compiler-size regression guard:
var uno = new ArduinoUnoSimulation()
.WithHex(hex);
uno.RunUntilSerial(uno.Serial, "PASS");
// Fail the test if the firmware grew by more than 10 %
Assert.That(uno.Cpu.Cycles, Is.LessThanOrEqualTo(expectedCycles * 1.10));