diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5c62b1e..d6bbde6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,3 +41,12 @@ jobs:
-c Release
--no-build
--logger "console;verbosity=normal"
+
+ # ── Native AOT smoke ──────────────────────────────────────────────────────
+ # Publishes the core with Native AOT and runs a blink program: fails if the
+ # core stops being AOT/trim compatible (IL2xxx/IL3xxx warnings are surfaced too).
+ - name: AOT smoke
+ run: |
+ sudo apt-get update && sudo apt-get install -y clang zlib1g-dev
+ dotnet publish tests/Avr8Sharp.AotSmoke -r linux-x64 -c Release -o aot-smoke
+ ./aot-smoke/Avr8Sharp.AotSmoke
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5549ce..51f96f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -66,6 +66,38 @@ Several change cycle counts or register values that a test may have pinned; each
- `AvrRunner.Run(budget)`, `RunUntil(cycle)` and `RunCycles(budget)` carry the target across
calls, so stepping in small slices no longer drifts. `Execute()` uses the same target.
+### New: the SiliconTwin.Abstractions contract
+
+The core implements [`SiliconTwin.Abstractions`](https://github.com/silicon-twin/abstractions),
+the shared embedder contract of the Silicon Twin emulators (MIT, no dependencies), so a host
+drives an AVR the same way it drives an RP2040.
+
+- `AvrBuilder.BuildMcu(name)` returns an `AvrMcu` (`IMcu`, `IGpio`, `IPwmSource`, `IAdcInput`,
+ `IPeripheralMap`) over everything the builder mounted. `runner.AsMcu(...)` covers machines
+ built by hand.
+- `IMcu`: `Run(budget)`/`RunUntil` without drift, `Reset(Soft | Power)`, `Load` of Intel HEX
+ (strict) or a raw binary.
+- One `IPinBank` per port: `SetInputs(mask, levels)`, `ReleaseInputs(mask)`,
+ `OutputEnableMask`, `OutputLevels`, `PullUpMask`, and `PinChanged` with the cycle of the
+ write and a `WatchMask`. It costs one null check when nobody subscribes.
+- `IPwmSource.TryGetPwm`: frequency and duty from the timer's clock select, waveform mode, TOP,
+ OCR and COM bits (fast, phase-correct and phase-and-frequency-correct, channels A to C).
+- `IAdcInput`: `ReadChannelVolts`, a callback the ADC calls only when a conversion samples its
+ input, instead of pushing `ChannelValues` every step.
+- UART (`TxByte`, `TryInjectRx`), SPI (`Transfer`, idle bus reads 0xFF) and I2C targets attached
+ by address.
+
+Underneath, also usable directly:
+
+- `AvrIoPort.SetInputs`, `ReleaseInputs`, `ApplyInputs` (full state in one PIN recompute),
+ `OutputEnableMask`, `OutputLevels`, `PullUpMask`, `PinChanged` and `WatchMask`.
+ `SetPinValue` and `ReleasePin` no longer recompute PIN when nothing changes.
+- `AvrAdc.ReadChannelVolts`, `AvrTimer.TryGetPwm`, `AvrClock.Changed`, `AvrBuilder.AddClock`.
+- C ABI `a8s_gpio_set_inputs`, `a8s_gpio_release_inputs`, `a8s_gpio_apply_inputs`; Python
+ `Port.set_inputs`, `release_inputs`, `apply_inputs`.
+- The core declares `IsTrimmable` and `IsAotCompatible`, and CI publishes and runs a Native AOT
+ smoke test.
+
---
## [v1.1.0] - 2026-10-07
diff --git a/bindings/python/src/avr8sharp/_native.py b/bindings/python/src/avr8sharp/_native.py
index b5db097..eb9f6b4 100644
--- a/bindings/python/src/avr8sharp/_native.py
+++ b/bindings/python/src/avr8sharp/_native.py
@@ -66,6 +66,9 @@
"a8s_gpio_pin": ([c_void_p, c_int, c_int], c_int),
"a8s_gpio_set_pin": ([c_void_p, c_int, c_int, c_int], c_int),
"a8s_gpio_release_pin": ([c_void_p, c_int, c_int], c_int),
+ "a8s_gpio_set_inputs": ([c_void_p, c_int, c_int, c_int], c_int),
+ "a8s_gpio_release_inputs": ([c_void_p, c_int, c_int], c_int),
+ "a8s_gpio_apply_inputs": ([c_void_p, c_int, c_int, c_int], c_int),
# analog / bus peripherals (Uno and Mega sessions)
"a8s_adc_set_channel": ([c_void_p, c_int, c_double], c_int),
"a8s_spi_queue_response": ([c_void_p, c_byte], c_int),
diff --git a/bindings/python/src/avr8sharp/simulation.py b/bindings/python/src/avr8sharp/simulation.py
index 3986038..69e3f87 100644
--- a/bindings/python/src/avr8sharp/simulation.py
+++ b/bindings/python/src/avr8sharp/simulation.py
@@ -114,6 +114,23 @@ def release(self, pin: int) -> None:
lib = self._sim._lib
_check(lib, self._sim._h, lib.a8s_gpio_release_pin(self._sim._h, self._index, pin), "gpio_release_pin")
+ def set_inputs(self, mask: int, levels: int) -> None:
+ """Drives only the pins in `mask` to the matching bits of `levels` in one call; the other
+ pins keep whatever they had (driven or released)."""
+ lib = self._sim._lib
+ _check(lib, self._sim._h, lib.a8s_gpio_set_inputs(self._sim._h, self._index, mask & 0xFF, levels & 0xFF), "gpio_set_inputs")
+
+ def release_inputs(self, mask: int) -> None:
+ """Stops driving the pins in `mask` in one call, undoing set_inputs()."""
+ lib = self._sim._lib
+ _check(lib, self._sim._h, lib.a8s_gpio_release_inputs(self._sim._h, self._index, mask & 0xFF), "gpio_release_inputs")
+
+ def apply_inputs(self, driven_mask: int, levels: int) -> None:
+ """Sets the whole external state in one call: pins in `driven_mask` are driven to `levels`,
+ every other pin is released."""
+ lib = self._sim._lib
+ _check(lib, self._sim._h, lib.a8s_gpio_apply_inputs(self._sim._h, self._index, driven_mask & 0xFF, levels & 0xFF), "gpio_apply_inputs")
+
def _read_buffer(fn, sim: "Simulation", *args) -> bytes:
"""Two-pass read of a native length-prefixed byte buffer (call with cap, then size exactly)."""
diff --git a/bindings/python/tests/test_pin_injection.py b/bindings/python/tests/test_pin_injection.py
index 8791c7b..b5804e3 100644
--- a/bindings/python/tests/test_pin_injection.py
+++ b/bindings/python/tests/test_pin_injection.py
@@ -162,3 +162,31 @@ def test_respond_to_rise_reports_the_wait_before_the_trigger():
assert 9 <= waited <= 12
uno.close()
+
+
+def test_set_inputs_drives_only_the_mask_and_release_inputs_undoes_it():
+ sim = a.Simulation.create()
+ port_d = sim.add_gpio(2)
+ sim.cpu.write(PORTD, 0x03) # pull-ups on PD0, PD1
+
+ port_d.set_inputs(0xFF, 0x84)
+ assert sim.cpu.read(PIND) == 0x84
+
+ port_d.set_inputs(0x01, 0x01) # PD1..PD7 untouched
+ assert sim.cpu.read(PIND) == 0x85
+
+ port_d.release_inputs(0xFC) # PD0, PD1 stay driven
+ assert sim.cpu.read(PIND) == 0x01
+ port_d.release_inputs(0x03)
+ assert sim.cpu.read(PIND) == 0x03 # pull-ups
+
+
+def test_apply_inputs_sets_the_full_state():
+ sim = a.Simulation.create()
+ port_d = sim.add_gpio(2)
+ sim.cpu.write(PORTD, 0x03)
+ port_d.apply_inputs(0xFF, 0x00)
+ assert sim.cpu.read(PIND) == 0x00
+ port_d.apply_inputs(0xFC, 0xFC)
+ assert sim.cpu.read(PIND) == 0xFF # PD0, PD1 released back to their pull-ups
+ sim.close()
diff --git a/src/Avr8Sharp.Native/Exports.cs b/src/Avr8Sharp.Native/Exports.cs
index 3e25b9b..4b09e59 100644
--- a/src/Avr8Sharp.Native/Exports.cs
+++ b/src/Avr8Sharp.Native/Exports.cs
@@ -393,6 +393,39 @@ public static int GpioSetPin(IntPtr h, int portIdx, int pin, int value)
s.Ports[portIdx].SetPinValue((byte)pin, value != 0);
});
+ /// Drives only the pins in of port to the
+ /// matching bits of ; other pins are left as they were.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_gpio_set_inputs")]
+ public static int GpioSetInputs(IntPtr h, int portIdx, int mask, int levels)
+ => Run(h, s =>
+ {
+ if (portIdx < 0 || portIdx >= s.Ports.Count)
+ throw new ArgumentOutOfRangeException(nameof(portIdx));
+ s.Ports[portIdx].SetInputs((byte)mask, (byte)levels);
+ });
+
+ /// Stops driving the pins in of port
+ /// (see a8s_gpio_release_pin), in one call.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_gpio_release_inputs")]
+ public static int GpioReleaseInputs(IntPtr h, int portIdx, int mask)
+ => Run(h, s =>
+ {
+ if (portIdx < 0 || portIdx >= s.Ports.Count)
+ throw new ArgumentOutOfRangeException(nameof(portIdx));
+ s.Ports[portIdx].ReleaseInputs((byte)mask);
+ });
+
+ /// Sets the whole external state of port : pins in
+ /// are driven to , every other pin is released.
+ [UnmanagedCallersOnly(EntryPoint = "a8s_gpio_apply_inputs")]
+ public static int GpioApplyInputs(IntPtr h, int portIdx, int drivenMask, int levels)
+ => Run(h, s =>
+ {
+ if (portIdx < 0 || portIdx >= s.Ports.Count)
+ throw new ArgumentOutOfRangeException(nameof(portIdx));
+ s.Ports[portIdx].ApplyInputs((byte)drivenMask, (byte)levels);
+ });
+
/// Stops driving on port , undoing
/// a8s_gpio_set_pin: the line floats again, so an input pin with its PORT bit set reads
/// 1 through the internal pull-up and any other floating input reads 0.
diff --git a/src/Avr8Sharp/Avr8Sharp.csproj b/src/Avr8Sharp/Avr8Sharp.csproj
index 5516402..1e82956 100644
--- a/src/Avr8Sharp/Avr8Sharp.csproj
+++ b/src/Avr8Sharp/Avr8Sharp.csproj
@@ -5,6 +5,8 @@
enable
enable
true
+ true
+ true
Avr8Sharp
@@ -25,6 +27,10 @@
default
+
+
+
+
diff --git a/src/Avr8Sharp/Core/Abstractions/AvrMcu.cs b/src/Avr8Sharp/Core/Abstractions/AvrMcu.cs
new file mode 100644
index 0000000..b145a76
--- /dev/null
+++ b/src/Avr8Sharp/Core/Abstractions/AvrMcu.cs
@@ -0,0 +1,442 @@
+using System.Text;
+using AVR8Sharp.Core.Peripherals;
+using AVR8Sharp.Core.Utils;
+using SiliconTwin.Abstractions;
+
+namespace AVR8Sharp.Core.Abstractions;
+
+///
+/// Thin adapter that exposes an and its peripherals through the
+/// SiliconTwin contracts (, , ,
+/// , ). It holds no emulation state of its own
+/// and does not change how the core behaves.
+///
+///
+/// Peripherals are passed in because hands them back through out
+/// parameters and the core keeps no registry of them (GPIO ports are the exception: they register
+/// themselves in and are discovered automatically).
+///
+/// var runner = AvrBuilder.Create()
+/// .AddGpioPort(AvrIoPort.PortBConfig, out _)
+/// .AddUsart(AvrUsart.Usart0Config, out var usart)
+/// .Build();
+/// var mcu = runner.AsMcu("atmega328p", usarts: [usart]);
+///
+///
+public sealed class AvrMcu : IMcu, IGpio, IPwmSource, IAdcInput, IPeripheralMap
+{
+ readonly AvrRunner _runner;
+ readonly AvrClock? _clock;
+ readonly AvrWatchdog? _watchdog;
+ readonly AvrAdc? _adc;
+ readonly AvrTimer[] _timers;
+ readonly AvrPinBank[] _banks;
+ readonly AvrUartPort[] _uarts;
+ readonly AvrSpiPort[] _spis;
+ readonly AvrI2cPort[] _i2cs;
+ Func? _adcCallback;
+ Action? _clockChanged;
+
+ /// Value of at the last power reset; counts from there.
+ internal ulong CycleBase { get; private set; }
+
+ /// The machine, normally from .
+ /// Device name reported by .
+ /// Clock prescaler peripheral, if mounted: makes follow CLKPR.
+ /// Watchdog, if mounted: a power reset restores its power-on MCUSR.
+ /// ADC, if mounted.
+ /// Timers, used by .
+ /// USARTs, in order: index 0 is UART0.
+ /// SPI controllers, in order.
+ /// TWI controllers, in order. Each one gets its replaced.
+ public AvrMcu (AvrRunner runner, string name = "avr", AvrClock? clock = null, AvrWatchdog? watchdog = null,
+ AvrAdc? adc = null, IEnumerable? timers = null, IEnumerable? usarts = null,
+ IEnumerable? spis = null, IEnumerable? twis = null)
+ {
+ ArgumentNullException.ThrowIfNull (runner);
+ _runner = runner;
+ Name = name;
+ _clock = clock;
+ _watchdog = watchdog;
+ _adc = adc;
+ _timers = timers?.ToArray () ?? [];
+
+ // Chip order (PORTA, PORTB, ...) regardless of the order the ports were mounted in
+ _banks = runner.Cpu.GpioPorts.OrderBy (p => p.Config.PORT).Select (p => new AvrPinBank (this, p)).ToArray ();
+ _uarts = (usarts ?? []).Select ((u, i) => new AvrUartPort (i, u)).ToArray ();
+ _spis = (spis ?? []).Select ((s, i) => new AvrSpiPort (i, s)).ToArray ();
+ _i2cs = (twis ?? []).Select ((t, i) => new AvrI2cPort (i, t)).ToArray ();
+
+ if (_clock != null) _clock.Changed += () => _clockChanged?.Invoke ();
+ }
+
+ /// The wrapped machine.
+ public AvrRunner Runner => _runner;
+
+ /// The wrapped CPU.
+ public Cpu Cpu => _runner.Cpu;
+
+ public string Name { get; }
+
+ public long Cycles => (long)(_runner.Cpu.Cycles - CycleBase);
+
+ public uint ClockHz => _clock?.Frequency ?? _runner.Speed;
+
+ /// Raised when CLKPR changes the clock. Never raised without an .
+ public event Action? ClockChanged {
+ add => _clockChanged += value;
+ remove => _clockChanged -= value;
+ }
+
+ public long Run (long cycleBudget) => _runner.Run (cycleBudget);
+
+ public long RunUntil (long absoluteCycle)
+ {
+ if (absoluteCycle <= Cycles) return 0;
+ return _runner.RunUntil ((ulong)absoluteCycle + CycleBase);
+ }
+
+ ///
+ /// Soft: (registers, peripherals and pending events; SRAM and
+ /// flash are kept, keeps counting). Power: the same, after clearing the
+ /// general registers and SRAM, then MCUSR gets its power-on value (if a watchdog is mounted)
+ /// and restarts from zero. Flash is kept.
+ ///
+ public void Reset (ResetKind kind = ResetKind.Soft)
+ {
+ var cpu = _runner.Cpu;
+ if (kind == ResetKind.Power) {
+ Array.Clear (cpu.Mmio.Data, 0, 32);
+ Array.Clear (cpu.Mmio.Data, cpu.RamStart, cpu.Mmio.Data.Length - cpu.RamStart);
+ }
+ cpu.Reset ();
+ if (kind == ResetKind.Power) {
+ _watchdog?.PowerOnReset ();
+ CycleBase = cpu.Cycles;
+ }
+ }
+
+ ///
+ /// Loads Intel HEX (strict: malformed records, bad checksums or an image past the end of flash
+ /// throw and leave flash untouched) or a raw binary at
+ /// (bytes outside the image are kept). Other formats throw
+ /// .
+ ///
+ public void Load (in FirmwareImage image)
+ {
+ switch (image.Format) {
+ case FirmwareFormat.IntelHex:
+ if (!_runner.TryLoadHex (Encoding.ASCII.GetString (image.Data.Span), out var info))
+ throw new FormatException ("Invalid Intel HEX: " + string.Join ("; ", info.Errors));
+ break;
+ case FirmwareFormat.Bin:
+ var flash = _runner.Cpu.ProgBytes;
+ if ((long)image.Offset + image.Data.Length > flash.Length)
+ throw new ArgumentOutOfRangeException (nameof (image), "The image does not fit in flash.");
+ var merged = (byte[])flash.Clone ();
+ image.Data.Span.CopyTo (merged.AsSpan ((int)image.Offset));
+ _runner.Cpu.LoadProgram (merged);
+ break;
+ default:
+ throw new NotSupportedException ($"Firmware format {image.Format} is not supported by AVR.");
+ }
+ }
+
+ public IReadOnlyList Banks => _banks;
+
+ public IReadOnlyList Uarts => _uarts;
+ public IReadOnlyList Spis => _spis;
+ public IReadOnlyList I2cs => _i2cs;
+
+ /// Reference voltage currently selected by ADMUX; 0 when no ADC is mounted.
+ public double ReferenceVolts => _adc?.ReferenceVoltage ?? 0;
+
+ /// Maps to . Ignored when no ADC is mounted.
+ public Func? ReadChannelVolts {
+ get => _adcCallback;
+ set {
+ _adcCallback = value;
+ if (_adc != null) _adc.ReadChannelVolts = value;
+ }
+ }
+
+ ///
+ /// PWM of timer compare outputs (fast PWM and phase correct, non-inverting or inverting), from the
+ /// current timer registers. Returns false for pins that no mounted timer's OCnx output uses.
+ ///
+ public bool TryGetPwm (int bank, int pin, out PwmInfo info)
+ {
+ info = default;
+ if ((uint)bank >= (uint)_banks.Length || (uint)pin >= 8) return false;
+ var portAddress = _banks[bank].Port.Config.PORT;
+ foreach (var timer in _timers) {
+ var c = timer.Config;
+ for (var ch = 0; ch < 3; ch++) {
+ var (port, cpin) = ch switch {
+ 0 => (c.ComparatorPortA, c.ComparatorPinA),
+ 1 => (c.ComparatorPortB, c.ComparatorPinB),
+ _ => (c.ComparatorPortC, c.ComparatorPinC),
+ };
+ if (port != portAddress || cpin != pin) continue;
+ if (!timer.TryGetPwm (ch, ClockHz, out var enabled, out var freq, out var duty)) continue;
+ info = new PwmInfo (enabled, freq, duty);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public void Dispose ()
+ {
+ }
+}
+
+/// Factory extensions to wrap a built machine as an .
+public static class AvrRunnerExtensions
+{
+ /// Wraps the runner as an ; see its constructor for the parameters.
+ public static AvrMcu AsMcu (this AvrRunner runner, string name = "avr", AvrClock? clock = null,
+ AvrWatchdog? watchdog = null, AvrAdc? adc = null, IEnumerable? timers = null,
+ IEnumerable? usarts = null, IEnumerable? spis = null, IEnumerable? twis = null)
+ => new (runner, name, clock, watchdog, adc, timers, usarts, spis, twis);
+}
+
+/// One as an of 8 pins.
+public sealed class AvrPinBank : IPinBank
+{
+ readonly AvrMcu _mcu;
+ readonly AvrPinChangeHandler _forward;
+ PinChangeHandler? _changed;
+
+ internal AvrPinBank (AvrMcu mcu, AvrIoPort port)
+ {
+ _mcu = mcu;
+ Port = port;
+ Name = "PORT" + PortLetter (port.Config.PORT);
+ _forward = (changed, enable, levels, cycle) => {
+ var e = new PinChange (changed, enable, levels, (long)(cycle - _mcu.CycleBase));
+ _changed?.Invoke (in e);
+ };
+ }
+
+ /// The wrapped port.
+ public AvrIoPort Port { get; }
+
+ public string Name { get; }
+ public int PinCount => 8;
+ public ulong OutputEnableMask => Port.OutputEnableMask;
+ public ulong OutputLevels => Port.OutputLevels;
+ public ulong PullUpMask => Port.PullUpMask;
+ public ulong PullDownMask => 0;
+
+ public void SetInputs (ulong mask, ulong levels) => Port.SetInputs ((byte)mask, (byte)levels);
+ public void ReleaseInputs (ulong mask) => Port.ReleaseInputs ((byte)mask);
+
+ public event PinChangeHandler PinChanged {
+ add {
+ if (_changed == null) Port.PinChanged += _forward;
+ _changed += value;
+ }
+ remove {
+ _changed -= value;
+ if (_changed == null) Port.PinChanged -= _forward;
+ }
+ }
+
+ public ulong WatchMask {
+ get => Port.WatchMask;
+ set => Port.WatchMask = (byte)value;
+ }
+
+ static string PortLetter (ushort portAddress) => portAddress switch {
+ 0x22 => "A", 0x25 => "B", 0x28 => "C", 0x2b => "D", 0x2e => "E", 0x31 => "F", 0x34 => "G",
+ 0x102 => "H", 0x105 => "J", 0x108 => "K", 0x10b => "L",
+ _ => "@" + portAddress.ToString ("x"),
+ };
+}
+
+/// One as an .
+public sealed class AvrUartPort : IUartPort
+{
+ readonly AvrUsart _usart;
+ readonly Action _forward;
+ Action? _previous;
+ Action? _tx;
+
+ internal AvrUartPort (int index, AvrUsart usart)
+ {
+ Index = index;
+ _usart = usart;
+ _forward = value => {
+ _previous?.Invoke (value);
+ _tx?.Invoke (value);
+ };
+ }
+
+ public int Index { get; }
+
+ public uint BaudRate => _usart.TxEnable || _usart.RxEnable ? (uint)_usart.BaudRate : 0;
+
+ ///
+ /// Raised for every transmitted byte. Wraps : a handler
+ /// already set there keeps being called.
+ ///
+ public event Action? TxByte {
+ add {
+ if (_tx == null) {
+ _previous = _usart.OnByteTransmit;
+ _usart.OnByteTransmit = _forward;
+ }
+ _tx += value;
+ }
+ remove {
+ _tx -= value;
+ if (_tx == null && ReferenceEquals (_usart.OnByteTransmit, _forward)) {
+ _usart.OnByteTransmit = _previous;
+ _previous = null;
+ }
+ }
+ }
+
+ ///
+ /// Delivers a byte through (it arrives after one character
+ /// time). False when the receiver is disabled or still busy with the previous byte.
+ ///
+ public bool TryInjectRx (byte value) => _usart.WriteByte (value);
+}
+
+/// One (master mode) as an .
+public sealed class AvrSpiPort : ISpiPort
+{
+ readonly AvrSpi _spi;
+ Func? _transfer;
+
+ internal AvrSpiPort (int index, AvrSpi spi)
+ {
+ Index = index;
+ _spi = spi;
+ _spi.OnTransfer = _ => 0xff;
+ }
+
+ public int Index { get; }
+ public int FrameBits => 8;
+
+ /// Maps to ; null makes the bus read 0xFF.
+ public Func? Transfer {
+ get => _transfer;
+ set {
+ _transfer = value;
+ _spi.OnTransfer = value == null ? _ => 0xff : mosi => (int)(value (mosi) & 0xff);
+ }
+ }
+}
+
+///
+/// One as an . Installs its own
+/// that dispatches the firmware's master transactions to the
+/// attached targets by address (no target at the address: NACK).
+///
+///
+/// Slave side: , and
+/// map to the TWI slave simulation. throws
+/// because does not model the slave-transmit byte sequence.
+///
+public sealed class AvrI2cPort : II2cPort
+{
+ const int StatusSlaveDataRxAck = 0x80;
+
+ readonly AvrTwi _twi;
+ readonly Dictionary _targets = new ();
+ II2cTarget? _active;
+ Action? _slaveAddressChanged;
+
+ internal AvrI2cPort (int index, AvrTwi twi)
+ {
+ Index = index;
+ _twi = twi;
+ _twi.EventHandler = new Handler (this);
+ }
+
+ public int Index { get; }
+
+ public void AttachTarget (byte addr7, II2cTarget target)
+ {
+ ArgumentNullException.ThrowIfNull (target);
+ _targets[addr7] = target;
+ }
+
+ public void DetachTarget (byte addr7) => _targets.Remove (addr7);
+
+ public byte SlaveAddress => _twi.SlaveAddress;
+
+ public event Action? SlaveAddressChanged {
+ add {
+ if (_slaveAddressChanged == null) _twi.SlaveAddressChanged += Forward;
+ _slaveAddressChanged += value;
+ }
+ remove {
+ _slaveAddressChanged -= value;
+ if (_slaveAddressChanged == null) _twi.SlaveAddressChanged -= Forward;
+ }
+ }
+
+ void Forward (byte address) => _slaveAddressChanged?.Invoke (address);
+
+ public bool BeginMasterTransfer (byte addr7, bool write) => _twi.SimulateIncomingAddress (addr7, write);
+
+ /// False when the firmware answered with NACK (TWEA clear) instead of status 0x80.
+ public bool MasterWrite (byte data)
+ {
+ _twi.SimulateIncomingData (data);
+ return _twi.Status == StatusSlaveDataRxAck;
+ }
+
+ public bool TryMasterRead (out byte data)
+ => throw new NotSupportedException ("AvrTwi does not model slave-transmit sequencing.");
+
+ public void MasterStop () => _twi.SimulateIncomingStop ();
+
+ sealed class Handler (AvrI2cPort port) : ITwiEventHandler
+ {
+ public void Start (bool repeated)
+ {
+ if (repeated) EndTransaction ();
+ port._twi.CompleteStart ();
+ }
+
+ public void Stop ()
+ {
+ EndTransaction ();
+ port._twi.CompleteStop ();
+ }
+
+ public void ConnectToSlave (byte address, bool write)
+ {
+ EndTransaction ();
+ if (port._targets.TryGetValue (address, out var target) && target.Probe (address, write)) {
+ port._active = target;
+ port._twi.CompleteConnect (true);
+ } else {
+ port._twi.CompleteConnect (false);
+ }
+ }
+
+ public void WriteByte (byte data)
+ {
+ if (port._active == null) {
+ port._twi.CompleteWrite (false);
+ return;
+ }
+ port._active.Write (data);
+ port._twi.CompleteWrite (true);
+ }
+
+ public void ReadByte (bool ack) => port._twi.CompleteRead (port._active?.Read () ?? 0xff);
+
+ void EndTransaction ()
+ {
+ port._active?.Stop ();
+ port._active = null;
+ }
+ }
+}
diff --git a/src/Avr8Sharp/Core/Peripherals/Adc.cs b/src/Avr8Sharp/Core/Peripherals/Adc.cs
index 5c20c60..30ba529 100644
--- a/src/Avr8Sharp/Core/Peripherals/Adc.cs
+++ b/src/Avr8Sharp/Core/Peripherals/Adc.cs
@@ -203,6 +203,15 @@ public double ReferenceVoltage
public double[] ChannelValues { get; }
+ ///
+ /// Optional pull hook: when set, a conversion asks it for the input voltage at the instant it
+ /// samples (the same instant is read), instead of reading
+ /// . The argument is the single-ended input number (ADC0..ADC15 as
+ /// in ); differential modes call it once per input. When null the
+ /// array is used.
+ ///
+ public Func? ReadChannelVolts;
+
///
/// Voltage returned by the internal temperature sensor channel (mux input 8 on ATmega328P).
/// Defaults to ~0.378 V which corresponds to approximately 25 °C.
@@ -320,13 +329,19 @@ public void Trigger(AdcTriggerSource source)
StartConversion();
}
+ private double SampleChannel(int channel)
+ {
+ var read = ReadChannelVolts;
+ return read != null ? read(channel) : ChannelValues[channel];
+ }
+
public void OnADCRead(AdcMuxInput input)
{
var voltage = input.Type switch
{
AdcMuxInputType.Constant => input.Voltage,
- AdcMuxInputType.SingleEnded => ChannelValues[input.Channel],
- AdcMuxInputType.Differential => input.Gain * (ChannelValues[input.PositiveChannel] - ChannelValues[input.NegativeChannel]),
+ AdcMuxInputType.SingleEnded => SampleChannel(input.Channel),
+ AdcMuxInputType.Differential => input.Gain * (SampleChannel(input.PositiveChannel) - SampleChannel(input.NegativeChannel)),
AdcMuxInputType.Temperature => TemperatureVoltage,
_ => 0.0
};
diff --git a/src/Avr8Sharp/Core/Peripherals/Clock.cs b/src/Avr8Sharp/Core/Peripherals/Clock.cs
index 6561736..7c15064 100644
--- a/src/Avr8Sharp/Core/Peripherals/Clock.cs
+++ b/src/Avr8Sharp/Core/Peripherals/Clock.cs
@@ -32,6 +32,9 @@ public uint Frequency
get { return (uint)(_baseFreqHz / (_prescalerValue != 0 ? _prescalerValue : 1)); }
}
+ /// Raised after a CLKPR write or a reset changes .
+ public event Action? Changed;
+
public int Prescaler
{
get { return _prescalerValue; }
@@ -73,6 +76,7 @@ public AvrClock(Cpu cpu, uint baseFreqHz, AvrClockConfig clockConfig)
{
_cyclesDelta = (int)(((double)cpu.Cycles + _cyclesDelta) * (oldPrescaler / (double)_prescalerValue) -
(double)cpu.Cycles);
+ Changed?.Invoke();
}
}
@@ -88,6 +92,7 @@ public AvrClock(Cpu cpu, uint baseFreqHz, AvrClockConfig clockConfig)
_cyclesDelta = (int)(((double)cpu.Cycles + _cyclesDelta) * (_prescalerValue / 1.0) -
(double)cpu.Cycles);
_prescalerValue = 1;
+ Changed?.Invoke();
}
};
}
diff --git a/src/Avr8Sharp/Core/Peripherals/Gpio.cs b/src/Avr8Sharp/Core/Peripherals/Gpio.cs
index 8f57010..5b7d8c3 100644
--- a/src/Avr8Sharp/Core/Peripherals/Gpio.cs
+++ b/src/Avr8Sharp/Core/Peripherals/Gpio.cs
@@ -459,11 +459,11 @@ public PinState GetPinState (byte index)
public void SetPinValue (byte index, bool value)
{
var bitMask = 1 << index;
- _pinValue &= ~bitMask;
- if (value) {
- _pinValue |= bitMask;
- }
- _driven |= bitMask;
+ var newPinValue = value ? _pinValue | bitMask : _pinValue & ~bitMask;
+ var newDriven = _driven | bitMask;
+ if (newPinValue == _pinValue && newDriven == _driven) return;
+ _pinValue = newPinValue;
+ _driven = newDriven;
UpdatePinRegister (_cpu.Mmio.Data[_portConfig.DDR]);
}
@@ -476,10 +476,105 @@ public void SetPinValue (byte index, bool value)
public void ReleasePin (byte index)
{
var bitMask = 1 << index;
+ if ((_driven & bitMask) == 0) return;
_driven &= ~bitMask;
UpdatePinRegister (_cpu.Mmio.Data[_portConfig.DDR]);
}
-
+
+ ///
+ /// Drives only the pins in from outside to the matching bits of
+ /// ; the other pins keep whatever they had (driven or released).
+ /// Equivalent to for each bit of the mask, but recomputes PIN once
+ /// and returns early when nothing changed.
+ ///
+ /// Pins to drive from outside
+ /// Levels of the pins in the mask (other bits are ignored)
+ public void SetInputs (byte mask, byte levels)
+ {
+ var newDriven = _driven | mask;
+ var newPinValue = (_pinValue & ~mask) | (levels & mask);
+ if (newDriven == _driven && newPinValue == _pinValue) return;
+ _driven = newDriven;
+ _pinValue = newPinValue;
+ UpdatePinRegister (_cpu.Mmio.Data[_portConfig.DDR]);
+ }
+
+ ///
+ /// Stops driving the pins in from outside, as
+ /// does for one pin, with a single PIN recompute and an early return when none was driven.
+ ///
+ public void ReleaseInputs (byte mask)
+ {
+ if ((_driven & mask) == 0) return;
+ _driven &= ~mask;
+ UpdatePinRegister (_cpu.Mmio.Data[_portConfig.DDR]);
+ }
+
+ ///
+ /// Sets the full external state in one call: pins in are driven
+ /// to and every other pin is released. Same result as
+ /// SetInputs(drivenMask, levels) followed by ReleaseInputs(~drivenMask), with one
+ /// PIN recompute.
+ ///
+ public void ApplyInputs (byte drivenMask, byte levels)
+ {
+ var newPinValue = levels & drivenMask;
+ if (drivenMask == _driven && newPinValue == (_pinValue & drivenMask)) return;
+ _driven = drivenMask;
+ _pinValue = (_pinValue & ~drivenMask) | newPinValue;
+ UpdatePinRegister (_cpu.Mmio.Data[_portConfig.DDR]);
+ }
+
+ ///
+ /// Pins with the internal pull-up in effect, exactly the pins reports
+ /// as : inputs with the PORT bit set, plus open-collector
+ /// outputs that are released (high) with the PORT bit set.
+ ///
+ public byte PullUpMask {
+ get {
+ var port = _cpu.Mmio.Data[_portConfig.PORT];
+ var released = _cpu.Mmio.Data[_portConfig.DDR] & OpenCollector & _lastValue;
+ return (byte)(port & (~_cpu.Mmio.Data[_portConfig.DDR] | released));
+ }
+ }
+
+ /// The register layout this port was built with.
+ public AvrPortConfig Config => _portConfig;
+
+ private AvrPinChangeHandler? _pinChanged;
+
+ ///
+ /// Raised when the effective pad level or output enable of a pin in
+ /// changes on the chip side (after timer overrides), with at that
+ /// instant. Costs nothing while nobody subscribes. A change of
+ /// alone is not reported until the next write that moves a pin.
+ ///
+ public event AvrPinChangeHandler? PinChanged {
+ add { _pinChanged += value; }
+ remove { _pinChanged -= value; }
+ }
+
+ /// Pins that may raise . All pins by default.
+ public byte WatchMask { get; set; } = 0xff;
+
+ ///
+ /// Pins the chip drives (as or in
+ /// ): DDR bits, minus open-collector pins that are in their high
+ /// (released) state. Timer overrides only change the level, never the direction.
+ ///
+ public byte OutputEnableMask {
+ get {
+ var ddr = _cpu.Mmio.Data[_portConfig.DDR];
+ return (byte)(ddr & ~(OpenCollector & _lastValue));
+ }
+ }
+
+ ///
+ /// Driven level of the bits in (timer overrides included).
+ /// Bits outside the mask read 0.
+ ///
+ public byte OutputLevels => (byte)(_lastValue & OutputEnableMask);
+
private void UpdatePinRegister (byte ddr)
{
var pulled = _cpu.Mmio.Data[_portConfig.PORT] & ~_driven;
@@ -629,9 +724,20 @@ private void WriteGpio (byte value, byte ddr)
var newValue = (byte)((((value & _overrideMask) | _overrideValue) & ddr) | (value & ~ddr));
var prevValue = _lastValue;
if (newValue == prevValue && ddr == _lastDdr) return;
+ var pinChanged = _pinChanged;
+ if (pinChanged == null) {
+ _lastValue = newValue;
+ _lastDdr = ddr;
+ OnGpioChange?.Invoke(newValue, prevValue);
+ return;
+ }
+ var oldEnable = (byte)(_lastDdr & ~(OpenCollector & prevValue));
_lastValue = newValue;
_lastDdr = ddr;
+ var newEnable = (byte)(ddr & ~(OpenCollector & newValue));
+ var changed = (byte)(((oldEnable ^ newEnable) | ((prevValue ^ newValue) & oldEnable & newEnable)) & WatchMask);
OnGpioChange?.Invoke(newValue, prevValue);
+ if (changed != 0) pinChanged (changed, newEnable, (byte)(newValue & newEnable), _cpu.Cycles);
}
}
@@ -668,6 +774,9 @@ public class AvrPortConfig (ushort pin, ushort ddr, ushort port, AvrPinChangeInt
public readonly AvrExternalInterrupt?[]? ExternalInterrupts = externalInterrupts;
}
+/// Chip-side pin change: pins that changed, the pins the chip drives and their levels afterwards.
+public delegate void AvrPinChangeHandler (byte changed, byte outputEnable, byte levels, ulong cycle);
+
public enum PinState
{
Low = 0,
diff --git a/src/Avr8Sharp/Core/Peripherals/Timer.cs b/src/Avr8Sharp/Core/Peripherals/Timer.cs
index c7a0f6d..2bce225 100644
--- a/src/Avr8Sharp/Core/Peripherals/Timer.cs
+++ b/src/Avr8Sharp/Core/Peripherals/Timer.cs
@@ -491,6 +491,54 @@ public int OcrMask
}
}
+ /// The register layout this timer was built with.
+ public AvrTimerConfig Config => _config;
+
+ ///
+ /// Describes the PWM waveform on compare channel (0 = A, 1 = B, 2 = C)
+ /// from the current registers: fast PWM and phase correct / phase and frequency correct, with
+ /// COMnx = 2 (non-inverting) or 3 (inverting). Returns false when the channel does not exist.
+ /// is false when the channel is not producing PWM (other mode,
+ /// output disconnected, toggle mode, or clock stopped). Fast PWM high time is OCR + 1 counts,
+ /// phase correct is OCR counts out of TOP, as the waveform generator does.
+ ///
+ public bool TryGetPwm(int channel, uint clockHz, out bool enabled, out double frequencyHz, out double duty)
+ {
+ enabled = false;
+ frequencyHz = 0;
+ duty = 0;
+ int ocr;
+ byte comp;
+ switch (channel) {
+ case 0: ocr = _ocrA; comp = _compA; break;
+ case 1: ocr = _ocrB; comp = _compB; break;
+ case 2 when _hasOcrC: ocr = _ocrC; comp = _compC; break;
+ default: return false;
+ }
+ var divider = _config.Dividers != null && CS < _config.Dividers.Length ? _config.Dividers[CS] : 0;
+ var top = TOP;
+ // With TOP = OCRnA, channel A only toggles; it has no duty of its own
+ if (comp < 2 || divider <= 0 || top <= 0 || (channel == 0 && _topValue == TOP_OCRA)) return true;
+ double period, high;
+ switch (_timerMode) {
+ case TimerMode.FastPWM:
+ period = top + 1;
+ high = Math.Min(ocr, top) + 1;
+ break;
+ case TimerMode.PWMPhaseCorrect:
+ case TimerMode.PWMPhaseFrequencyCorrect:
+ period = 2.0 * top;
+ high = 2.0 * Math.Min(ocr, top);
+ break;
+ default:
+ return true;
+ }
+ enabled = true;
+ frequencyHz = clockHz / (divider * period);
+ duty = comp == 3 ? 1.0 - high / period : high / period;
+ return true;
+ }
+
public int DebugTCNT
{
get { return _tcnt; }
diff --git a/src/Avr8Sharp/Core/Utils/Builder.cs b/src/Avr8Sharp/Core/Utils/Builder.cs
index 16f3b23..f5e88f9 100644
--- a/src/Avr8Sharp/Core/Utils/Builder.cs
+++ b/src/Avr8Sharp/Core/Utils/Builder.cs
@@ -1,3 +1,4 @@
+using AVR8Sharp.Core.Abstractions;
using AVR8Sharp.Core.Peripherals;
using AVR8Sharp.Core.Utils;
@@ -6,6 +7,16 @@ namespace AVR8Sharp.Core;
public class AvrBuilder (AvrRunner @base, int flashSize = 0x8000)
{
readonly AvrRunner @base = @base;
+
+ // What the Add* methods mounted, so BuildMcu can wrap the machine without being told again
+ readonly List _timers = [];
+ readonly List _usarts = [];
+ readonly List _spis = [];
+ readonly List _twis = [];
+ AvrAdc? _adc;
+ AvrClock? _clock;
+ AvrWatchdog? _watchdog;
+
public static AvrBuilder Create (int flashSize = 0x8000, int ramSize = 8192)
{
return new AvrBuilder (new AvrRunner (new byte[flashSize], ramSize), flashSize);
@@ -45,12 +56,14 @@ public AvrBuilder AddGpioPort(AvrPortConfig config, out AvrIoPort port)
public AvrBuilder AddTimer(AvrTimerConfig config, out AvrTimer timer)
{
timer = new AvrTimer (@base.Cpu, config);
+ _timers.Add (timer);
return this;
}
public AvrBuilder AddUsart(AvrUsartConfig config, out AvrUsart usart)
{
usart = new AvrUsart (@base.Cpu, config, @base.Speed);
+ _usarts.Add (usart);
return this;
}
@@ -63,12 +76,14 @@ public AvrBuilder AddUsi(AvrIoPort portObj, int portPin, int dataPin, int clockP
public AvrBuilder AddSpi(AvrSpiConfig config, out AvrSpi spi)
{
spi = new AvrSpi (@base.Cpu, config, @base.Speed);
+ _spis.Add (spi);
return this;
}
public AvrBuilder AddTwi(AvrTwiConfig config, out AvrTwi twim)
{
twim = new AvrTwi (@base.Cpu, config, @base.Speed);
+ _twis.Add (twim);
return this;
}
@@ -81,12 +96,22 @@ public AvrBuilder AddEeprom(AvrEepromConfig config, IEepromBackend backend, out
public AvrBuilder AddAdc(AvrAdcConfig config, out AvrAdc adc)
{
adc = new AvrAdc (@base.Cpu, config);
+ _adc = adc;
return this;
}
+ public AvrBuilder AddClock(AvrClockConfig config, out AvrClock clock)
+ {
+ clock = new AvrClock (@base.Cpu, @base.Speed, config);
+ _clock = clock;
+ return this;
+ }
+
public AvrBuilder AddWatchdog(AvrWatchdogConfig config, AvrClock clock, out AvrWatchdog watchdog)
{
watchdog = new AvrWatchdog (@base.Cpu, config, clock);
+ _watchdog = watchdog;
+ _clock ??= clock;
return this;
}
@@ -108,6 +133,16 @@ public AvrBuilder UseSwitchDecoder()
return this;
}
+ ///
+ /// Builds the machine and wraps it as an (SiliconTwin contracts) with
+ /// every peripheral mounted through this builder. USARTs, SPIs and TWIs are indexed in the
+ /// order they were added.
+ ///
+ public AvrMcu BuildMcu(string name = "avr")
+ {
+ return new AvrMcu (@base, name, _clock, _watchdog, _adc, _timers, _usarts, _spis, _twis);
+ }
+
public AvrRunner Build()
{
return @base;
diff --git a/tests/Avr8Sharp.AotSmoke/Avr8Sharp.AotSmoke.csproj b/tests/Avr8Sharp.AotSmoke/Avr8Sharp.AotSmoke.csproj
new file mode 100644
index 0000000..5f6296a
--- /dev/null
+++ b/tests/Avr8Sharp.AotSmoke/Avr8Sharp.AotSmoke.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ false
+ true
+ true
+
+
+
+
+
+
+
diff --git a/tests/Avr8Sharp.AotSmoke/Program.cs b/tests/Avr8Sharp.AotSmoke/Program.cs
new file mode 100644
index 0000000..676e51d
--- /dev/null
+++ b/tests/Avr8Sharp.AotSmoke/Program.cs
@@ -0,0 +1,57 @@
+using AVR8Sharp.Core;
+using AVR8Sharp.Core.Abstractions;
+using AVR8Sharp.Core.Peripherals;
+using AVR8Sharp.Core.Utils;
+using SiliconTwin.Abstractions;
+
+// Native AOT smoke check: assemble a blink program, run a few thousand cycles through the
+// real decoder, and require PORTB to have toggled. Exit code 0 on success.
+
+const string source = """
+ sbi 0x04, 0 ; DDRB.0 output
+loop:
+ sbi 0x05, 0 ; PORTB.0 high
+ cbi 0x05, 0 ; PORTB.0 low
+ rjmp loop
+""";
+
+var assembler = new AvrAssembler();
+var program = assembler.Assemble(source);
+if (assembler.Errors.Count > 0) {
+ Console.Error.WriteLine("assembly failed: " + string.Join(", ", assembler.Errors));
+ return 2;
+}
+
+// Everything below goes through the SiliconTwin interfaces
+IMcu mcu = AvrBuilder.Create(0x8000, 2048)
+ .AddGpioPort(AvrIoPort.PortBConfig, out _)
+ .AddTimer(AvrTimer.Timer0Config, out _)
+ .AddUsart(AvrUsart.Usart0Config, out _)
+ .AddAdc(AvrAdc.AdcConfig, out _)
+ .BuildMcu("atmega328p");
+mcu.Load(FirmwareImage.FromBin(program));
+mcu.Reset(ResetKind.Power);
+
+var portB = ((IGpio)mcu).Banks[0];
+var toggles = 0;
+var cycleOk = true;
+portB.PinChanged += (in PinChange e) => {
+ if (e.Affects(1) && e.IsDriven(0)) toggles++;
+ if (e.Cycle != mcu.Cycles) cycleOk = false;
+};
+
+var consumed = 0L;
+for (var i = 0; i < 50; i++) consumed += mcu.Run(100);
+
+((IAdcInput)mcu).ReadChannelVolts = _ => 2.5;
+var pwmOk = !((IPwmSource)mcu).TryGetPwm(0, 3, out _);
+var uartTx = 0;
+((IPeripheralMap)mcu).Uarts[0].TxByte += _ => uartTx++;
+
+Console.WriteLine($"cycles={mcu.Cycles} consumed={consumed} toggles={toggles} outputEnableMask=0x{portB.OutputEnableMask:x2}");
+if (toggles < 100 || portB.OutputEnableMask != 0x01 || !cycleOk || !pwmOk || mcu.Cycles != consumed) {
+ Console.Error.WriteLine("FAIL: PORTB did not toggle as expected");
+ return 1;
+}
+Console.WriteLine("OK");
+return 0;
diff --git a/tests/Avr8Sharp.Tests/AdcPullTests.cs b/tests/Avr8Sharp.Tests/AdcPullTests.cs
new file mode 100644
index 0000000..5b62654
--- /dev/null
+++ b/tests/Avr8Sharp.Tests/AdcPullTests.cs
@@ -0,0 +1,118 @@
+using AVR8Sharp.Core.Peripherals;
+using Avr8Sharp.Tests.Utils;
+
+namespace Avr8Sharp.Tests;
+
+[TestFixture]
+public class AdcPull : AvrTestBase
+{
+ const int ADMUX = 0x7c;
+ const int ADCSRA = 0x7a;
+ const int ADCSRB = 0x7b;
+ const int ADCH = 0x79;
+ const int ADCL = 0x78;
+ const int REFS0 = 1 << 6;
+ const int ADEN = 1 << 7;
+ const int ADSC = 1 << 6;
+ const int PRESCALER_128 = 7;
+
+ AvrAdc _adc;
+
+ protected override void SetupPeripherals () => _adc = new AvrAdc (Cpu, AvrAdc.AdcConfig);
+
+ void Convert (int admux, int adcsrb = 0)
+ {
+ Cpu.WriteData (ADMUX, (byte)admux);
+ Cpu.WriteData (ADCSRB, (byte)adcsrb);
+ Cpu.WriteData (ADCSRA, ADEN | ADSC | PRESCALER_128);
+ Cpu.Cycles += 128 * 26;
+ Cpu.Tick ();
+ }
+
+ int Result => Cpu.Mmio.Data[ADCL] | (Cpu.Mmio.Data[ADCH] << 8);
+
+ [Test]
+ public void CalledOncePerConversionWithTheChannel ()
+ {
+ var calls = new List ();
+ _adc.ReadChannelVolts = ch => { calls.Add (ch); return 2.56; };
+ _adc.ChannelValues[3] = 1.0; // ignored while the hook is set
+
+ Convert (REFS0 | 3);
+ Assert.That (calls, Is.EqualTo (new[] { 3 }));
+ Assert.That (Result, Is.EqualTo (524));
+
+ Convert (REFS0 | 5);
+ Assert.That (calls, Is.EqualTo (new[] { 3, 5 }));
+ }
+
+ [Test]
+ public void NotCalledWithoutConversions ()
+ {
+ var calls = 0;
+ _adc.ReadChannelVolts = _ => { calls++; return 1; };
+ Cpu.WriteData (ADMUX, REFS0 | 2);
+ Cpu.WriteData (ADCSRA, ADEN | PRESCALER_128); // enabled, no start
+ Cpu.Cycles += 100000;
+ Cpu.Tick ();
+ Assert.That (calls, Is.EqualTo (0));
+ }
+
+ [Test]
+ public void ConstantAndTemperatureInputsDoNotPull ()
+ {
+ var calls = 0;
+ _adc.ReadChannelVolts = _ => { calls++; return 1; };
+ Convert (REFS0 | 8); // temperature
+ Convert (REFS0 | 14); // 1.1 V bandgap
+ Assert.That (calls, Is.EqualTo (0));
+ }
+
+ [Test]
+ public void FallsBackToChannelValuesWhenNull ()
+ {
+ _adc.ChannelValues[1] = 2.56;
+ Convert (REFS0 | 1);
+ Assert.That (Result, Is.EqualTo (524));
+
+ _adc.ReadChannelVolts = _ => 5.0;
+ Convert (REFS0 | 1);
+ Assert.That (Result, Is.EqualTo (1023));
+
+ _adc.ReadChannelVolts = null;
+ Convert (REFS0 | 1);
+ Assert.That (Result, Is.EqualTo (524));
+ }
+}
+
+[TestFixture]
+public class AdcPullMega : AvrTestBase
+{
+ const int ADMUX = 0x7c;
+ const int ADCSRA = 0x7a;
+ const int ADCSRB = 0x7b;
+ const int ADCH = 0x79;
+ const int ADCL = 0x78;
+ const int MUX5 = 0x8;
+
+ AvrAdc _adc;
+
+ protected override void SetupPeripherals () => _adc = new AvrAdc (Cpu, AvrAdc.Atmega2560AdcConfig);
+
+ [TestCase (0, 0, 0)]
+ [TestCase (7, 0, 7)]
+ [TestCase (0, MUX5, 8)]
+ [TestCase (7, MUX5, 15)]
+ public void MuxChannelsReachTheHook (int mux, int adcsrb, int expected)
+ {
+ var calls = new List ();
+ _adc.ReadChannelVolts = ch => { calls.Add (ch); return 2.56; };
+ Cpu.WriteData (ADMUX, (byte)(0x40 | mux));
+ Cpu.WriteData (ADCSRB, (byte)adcsrb);
+ Cpu.WriteData (ADCSRA, 0x80 | 0x40 | 7);
+ Cpu.Cycles += 128 * 26;
+ Cpu.Tick ();
+ Assert.That (calls, Is.EqualTo (new[] { expected }));
+ Assert.That (Cpu.Mmio.Data[ADCL] | (Cpu.Mmio.Data[ADCH] << 8), Is.EqualTo (524));
+ }
+}
diff --git a/tests/Avr8Sharp.Tests/AvrMcuContractTests.cs b/tests/Avr8Sharp.Tests/AvrMcuContractTests.cs
new file mode 100644
index 0000000..18e7a35
--- /dev/null
+++ b/tests/Avr8Sharp.Tests/AvrMcuContractTests.cs
@@ -0,0 +1,439 @@
+using System.Text;
+using AVR8Sharp.Core;
+using AVR8Sharp.Core.Abstractions;
+using AVR8Sharp.Core.Peripherals;
+using AVR8Sharp.Core.Utils;
+using SiliconTwin.Abstractions;
+
+namespace Avr8Sharp.Tests;
+
+/// Drives an Uno-like machine only through the SiliconTwin interfaces.
+[TestFixture]
+public class AvrMcuContract
+{
+ const int MCUSR = 0x54;
+ const int CLKPR = 0x61;
+ const int UCSR0B = 0xc1;
+ const int UDR0 = 0xc6;
+ const int UCSR0A = 0xc0;
+ const int SPCR = 0x4c;
+ const int SPDR = 0x4e;
+ const int TWBR = 0xb8;
+ const int TWSR = 0xb9;
+ const int TWDR = 0xbb;
+ const int TWCR = 0xbc;
+ const int ADMUX = 0x7c;
+ const int ADCSRA = 0x7a;
+ const int ADCL = 0x78;
+ const int ADCH = 0x79;
+ const int TCCR0A = 0x44;
+ const int TCCR0B = 0x45;
+ const int OCR0A = 0x47;
+
+ // sbi DDRB,0 ; loop: sbi PORTB,0 ; cbi PORTB,0 ; rjmp loop
+ const string Blink = "sbi 0x04, 0\nloop:\nsbi 0x05, 0\ncbi 0x05, 0\nrjmp loop\n";
+
+ AvrMcu _mcu = null!;
+ IMcu Mcu => _mcu;
+ AVR8Sharp.Core.Cpu Cpu => _mcu.Cpu;
+ IPinBank PortB => ((IGpio)_mcu).Banks[0];
+ IPinBank PortD => ((IGpio)_mcu).Banks[2];
+
+ [SetUp]
+ public void SetUp () => _mcu = BuildUno ();
+
+ [TearDown]
+ public void TearDown () => _mcu.Dispose ();
+
+ static AvrMcu BuildUno ()
+ {
+ var builder = AvrBuilder.Create (0x8000, 2048)
+ .AddGpioPort (AvrIoPort.PortBConfig, out _)
+ .AddGpioPort (AvrIoPort.PortCConfig, out _)
+ .AddGpioPort (AvrIoPort.PortDConfig, out _)
+ .AddTimer (AvrTimer.Timer0Config, out _)
+ .AddUsart (AvrUsart.Usart0Config, out _)
+ .AddSpi (AvrSpi.SpiConfig, out _)
+ .AddTwi (AvrTwi.TwiConfig, out _)
+ .AddAdc (AvrAdc.AdcConfig, out _)
+ .AddClock (AvrClock.ClockConfig, out var clock)
+ .AddWatchdog (AvrWatchdog.WatchdogConfig, clock, out _);
+ return builder.BuildMcu ("atmega328p");
+ }
+
+ static byte[] Assemble (string source)
+ {
+ var assembler = new AvrAssembler ();
+ var bytes = assembler.Assemble (source);
+ Assert.That (assembler.Errors, Is.Empty);
+ return bytes;
+ }
+
+ static string ToHex (byte[] data)
+ {
+ var sb = new StringBuilder ();
+ for (var i = 0; i < data.Length; i += 16) {
+ var n = Math.Min (16, data.Length - i);
+ var sum = n + (i >> 8) + (i & 0xff);
+ sb.Append ($":{n:X2}{i:X4}00");
+ for (var j = 0; j < n; j++) { sb.Append (data[i + j].ToString ("X2")); sum += data[i + j]; }
+ sb.Append ($"{(byte)-sum:X2}\n");
+ }
+ sb.Append (":00000001FF\n");
+ return sb.ToString ();
+ }
+
+ void LoadBlink () => Mcu.Load (FirmwareImage.FromBin (Assemble (Blink)));
+
+ [Test]
+ public void Identity ()
+ {
+ Assert.Multiple (() => {
+ Assert.That (Mcu.Name, Is.EqualTo ("atmega328p"));
+ Assert.That (Mcu.ClockHz, Is.EqualTo (16_000_000u));
+ Assert.That (Mcu.TryGet (out _), Is.True);
+ Assert.That (Mcu.TryGet (out _), Is.True);
+ Assert.That (Mcu.TryGet (out _), Is.True);
+ Assert.That (Mcu.TryGet (out _), Is.True);
+ Assert.That (((IGpio)_mcu).Banks.Select (b => b.Name), Is.EqualTo (new[] { "PORTB", "PORTC", "PORTD" }));
+ Assert.That (PortB.PinCount, Is.EqualTo (8));
+ });
+ }
+
+ [Test (Description = "Repeated small budgets do not drift")]
+ public void RunBudgetDoesNotDrift ()
+ {
+ LoadBlink ();
+ long consumed = 0;
+ for (var i = 0; i < 1000; i++) consumed += Mcu.Run (15); // odd budget vs 2-cycle instructions
+ Assert.Multiple (() => {
+ Assert.That (Mcu.Cycles, Is.EqualTo (15000).Within (1));
+ Assert.That (consumed, Is.EqualTo (Mcu.Cycles));
+ Assert.That (Mcu.Run (0), Is.EqualTo (0));
+ Assert.That (Mcu.Run (-5), Is.EqualTo (0));
+ });
+ }
+
+ [Test]
+ public void RunUntilReachesTheAbsoluteCycle ()
+ {
+ LoadBlink ();
+ var used = Mcu.RunUntil (1000);
+ Assert.That (Mcu.Cycles, Is.EqualTo (1000).Within (1));
+ Assert.That (used, Is.EqualTo (Mcu.Cycles));
+ Assert.That (Mcu.RunUntil (500), Is.EqualTo (0));
+ }
+
+ [Test]
+ public void SoftAndPowerReset ()
+ {
+ LoadBlink ();
+ Mcu.Run (100);
+ Cpu.WriteData (0x200, 0x5a);
+ Mcu.Reset (ResetKind.Soft);
+ Assert.Multiple (() => {
+ Assert.That (Cpu.Pc, Is.EqualTo (0));
+ Assert.That (Mcu.Cycles, Is.GreaterThanOrEqualTo (100), "soft reset keeps counting");
+ Assert.That (Cpu.Mmio.Data[0x200], Is.EqualTo (0x5a), "SRAM is kept");
+ });
+
+ Mcu.Run (100);
+ Mcu.Reset (ResetKind.Power);
+ Assert.Multiple (() => {
+ Assert.That (Mcu.Cycles, Is.EqualTo (0));
+ Assert.That (Cpu.Pc, Is.EqualTo (0));
+ Assert.That (Cpu.Mmio.Data[MCUSR] & 1, Is.EqualTo (1), "PORF");
+ Assert.That (Cpu.Mmio.Data[0x200], Is.EqualTo (0));
+ Assert.That (Cpu.ProgBytes[0], Is.Not.EqualTo (0), "flash is kept");
+ });
+ Mcu.Run (50);
+ Assert.That (Mcu.Cycles, Is.EqualTo (50).Within (1));
+ }
+
+ [Test]
+ public void LoadIntelHexAndBin ()
+ {
+ var program = Assemble (Blink);
+ Mcu.Load (FirmwareImage.FromIntelHex (ToHex (program)));
+ Assert.That (Cpu.ProgBytes.AsSpan (0, program.Length).ToArray (), Is.EqualTo (program));
+
+ Mcu.Load (FirmwareImage.FromBin (new byte[] { 0xaa, 0xbb }, 0x100));
+ Assert.Multiple (() => {
+ Assert.That (Cpu.ProgBytes[0x100], Is.EqualTo (0xaa));
+ Assert.That (Cpu.ProgBytes[0x101], Is.EqualTo (0xbb));
+ Assert.That (Cpu.ProgBytes[0], Is.EqualTo (program[0]), "bytes outside the image are kept");
+ });
+
+ Assert.Throws (() => Mcu.Load (FirmwareImage.FromIntelHex (":10000000ZZ\n")));
+ Assert.Throws (() => Mcu.Load (FirmwareImage.FromUf2 (new byte[4])));
+ Assert.Throws (() => Mcu.Load (FirmwareImage.FromElf (new byte[4])));
+ Assert.Throws (() => Mcu.Load (FirmwareImage.FromBin (new byte[4], 0x7ffe)));
+ }
+
+ [Test (Description = "PinChange.Cycle equals the cycle counter at the write")]
+ public void PinChangedCarriesTheCycle ()
+ {
+ LoadBlink ();
+ Mcu.Run (40); // set DDRB, settle
+ Mcu.Reset (ResetKind.Power); // cycles restart: the base offset must be applied
+ var seen = new List<(PinChange change, long now)> ();
+ PortB.PinChanged += (in PinChange e) => seen.Add ((e, Mcu.Cycles));
+
+ Mcu.Run (60);
+ Assert.That (seen.Count, Is.GreaterThanOrEqualTo (8));
+ Assert.Multiple (() => {
+ foreach (var (change, now) in seen) {
+ Assert.That (change.Cycle, Is.EqualTo (now));
+ Assert.That (change.Changed, Is.EqualTo (1UL));
+ }
+ Assert.That (seen.Select (s => s.change.IsHigh (0)), Is.EqualTo (Enumerable.Range (0, seen.Count).Select (i => i % 2 == 0)).Or.EqualTo (Enumerable.Range (0, seen.Count).Select (i => i % 2 == 1)));
+ Assert.That (seen.Zip (seen.Skip (1), (a, b) => b.change.Cycle - a.change.Cycle), Is.All.AnyOf (2L, 4L), "sbi to cbi is 2 cycles, cbi to sbi is 4 (cbi + rjmp)");
+ Assert.That (seen[0].change.IsDriven (0), Is.True);
+ });
+ }
+
+ [Test]
+ public void WatchMaskFiltersPins ()
+ {
+ var count = 0;
+ PinChangeHandler handler = (in PinChange e) => count++;
+ PortB.PinChanged += handler;
+ Assert.That (PortB.WatchMask, Is.EqualTo (0xffUL));
+
+ Cpu.WriteData (0x24, 0xff);
+ count = 0;
+ PortB.WatchMask = 1UL << 3;
+ Cpu.WriteData (0x25, 0x01);
+ Cpu.WriteData (0x25, 0x02);
+ Assert.That (count, Is.EqualTo (0), "pins outside the mask are silent");
+ Cpu.WriteData (0x25, 0x08);
+ Assert.That (count, Is.EqualTo (1));
+ PortB.PinChanged -= handler;
+ Cpu.WriteData (0x25, 0x00);
+ Assert.That (count, Is.EqualTo (1));
+ }
+
+ [Test]
+ public void SetInputsReleaseInputsAndPullUps ()
+ {
+ Cpu.WriteData (0x25, 0x06); // PORTB: pull-ups on PB1, PB2
+ Assert.That (PortB.PullUpMask, Is.EqualTo (0x06UL));
+ Assert.That (PortB.PullDownMask, Is.EqualTo (0UL));
+ Assert.That (Cpu.Mmio.Data[0x23], Is.EqualTo (0x06));
+
+ PortB.SetInputs (0x03, 0x01);
+ Assert.That (Cpu.Mmio.Data[0x23], Is.EqualTo (0x05), "PB0 high, PB1 driven low, PB2 pulled up");
+ PortB.SetInputs (0x80, 0x80); // PB0, PB1 untouched
+ Assert.That (Cpu.Mmio.Data[0x23], Is.EqualTo (0x85));
+ PortB.ReleaseInputs (0x83);
+ Assert.That (Cpu.Mmio.Data[0x23], Is.EqualTo (0x06));
+
+ Cpu.WriteData (0x24, 0x06); // PB1, PB2 become outputs: no pull-up, driven
+ Assert.That (PortB.PullUpMask, Is.EqualTo (0UL));
+ Assert.That (PortB.OutputEnableMask, Is.EqualTo (0x06UL));
+ Assert.That (PortB.OutputLevels & PortB.OutputEnableMask, Is.EqualTo (0x06UL));
+ }
+
+ [Test]
+ public void AdcPullsThroughTheInterface ()
+ {
+ var adc = (IAdcInput)_mcu;
+ var asked = new List ();
+ adc.ReadChannelVolts = ch => { asked.Add (ch); return 2.5; };
+ Cpu.WriteData (ADMUX, 0x40 | 2); // AVCC, ADC2
+ Assert.That (adc.ReferenceVolts, Is.EqualTo (5.0));
+ Cpu.WriteData (ADCSRA, 0x80 | 0x40 | 7);
+ Mcu.Run (128 * 26);
+ Assert.Multiple (() => {
+ Assert.That (asked, Is.EqualTo (new[] { 2 }));
+ Assert.That (Cpu.Mmio.Data[ADCL] | (Cpu.Mmio.Data[ADCH] << 8), Is.EqualTo (512));
+ });
+ Cpu.WriteData (ADMUX, 0xc0 | 2); // internal 1.1 V
+ Assert.That (adc.ReferenceVolts, Is.EqualTo (1.1));
+ }
+
+ [Test]
+ public void UartTransmitAndReceive ()
+ {
+ var uart = ((IPeripheralMap)_mcu).Uarts.Single ();
+ var tx = new List ();
+ uart.TxByte += tx.Add;
+
+ Cpu.WriteData (0xc4, 103); // UBRR0 = 103: 9600 baud at 16 MHz
+ Cpu.WriteData (UCSR0B, 0x18); // RXEN | TXEN
+ Assert.That (uart.BaudRate, Is.EqualTo (9615u));
+ Cpu.WriteData (UDR0, (byte)'A');
+ Mcu.Run (20000);
+ Assert.That (tx, Is.EqualTo (new[] { (byte)'A' }));
+
+ Assert.That (uart.TryInjectRx (0x42), Is.True);
+ Assert.That (uart.TryInjectRx (0x43), Is.False, "receiver busy with the previous byte");
+ Mcu.Run (20000);
+ Assert.Multiple (() => {
+ Assert.That (Cpu.Mmio.Data[UCSR0A] & 0x80, Is.EqualTo (0x80), "RXC");
+ Assert.That (Cpu.ReadData (UDR0), Is.EqualTo (0x42));
+ });
+
+ Cpu.WriteData (UCSR0B, 0x08); // RX off
+ Assert.That (uart.TryInjectRx (1), Is.False);
+ }
+
+ [Test]
+ public void SpiTransferMapsToTheCallback ()
+ {
+ var spi = ((IPeripheralMap)_mcu).Spis.Single ();
+ var mosi = new List ();
+ Cpu.WriteData (SPCR, 0x50); // SPE | MSTR
+
+ Cpu.WriteData (SPDR, 0x11);
+ Mcu.Run (200);
+ Assert.That (Cpu.ReadData (SPDR), Is.EqualTo (0xff), "no callback reads all ones");
+
+ spi.Transfer = b => { mosi.Add (b); return b + 1u; };
+ Cpu.WriteData (SPDR, 0x22);
+ Mcu.Run (200);
+ Assert.Multiple (() => {
+ Assert.That (mosi, Is.EqualTo (new[] { 0x22u }));
+ Assert.That (Cpu.ReadData (SPDR), Is.EqualTo (0x23));
+ Assert.That (spi.FrameBits, Is.EqualTo (8));
+ });
+
+ spi.Transfer = null;
+ Cpu.WriteData (SPDR, 0x33);
+ Mcu.Run (200);
+ Assert.That (Cpu.ReadData (SPDR), Is.EqualTo (0xff));
+ }
+
+ sealed class Eeprom : II2cTarget
+ {
+ public readonly List Written = [];
+ public int Stops;
+ public bool Probe (byte addr7, bool write) => true;
+ public void Write (byte data) => Written.Add (data);
+ public byte Read () => 0x5a;
+ public void Stop () => Stops++;
+ }
+
+ int TwiStep (byte twcr)
+ {
+ Cpu.WriteData (TWCR, twcr);
+ Mcu.Run (3000);
+ return Cpu.Mmio.Data[TWSR] & 0xf8;
+ }
+
+ [Test]
+ public void I2cTargetAcksNacksAndMovesData ()
+ {
+ var i2c = ((IPeripheralMap)_mcu).I2cs.Single ();
+ var target = new Eeprom ();
+ i2c.AttachTarget (0x50, target);
+ Cpu.WriteData (TWBR, 8);
+
+ Assert.That (TwiStep (0xa4), Is.EqualTo (0x08), "START");
+ Cpu.WriteData (TWDR, 0x50 << 1);
+ Assert.That (TwiStep (0x84), Is.EqualTo (0x18), "SLA+W acked");
+ Cpu.WriteData (TWDR, 0x77);
+ Assert.That (TwiStep (0x84), Is.EqualTo (0x28), "data acked");
+ Assert.That (TwiStep (0x94), Is.EqualTo (0xf8).Or.EqualTo (0x00), "STOP");
+ Assert.Multiple (() => {
+ Assert.That (target.Written, Is.EqualTo (new byte[] { 0x77 }));
+ Assert.That (target.Stops, Is.EqualTo (1));
+ });
+
+ Assert.That (TwiStep (0xa4), Is.EqualTo (0x08));
+ Cpu.WriteData (TWDR, 0x51 << 1);
+ Assert.That (TwiStep (0x84), Is.EqualTo (0x20), "no device at 0x51: SLA+W NACK");
+ TwiStep (0x94);
+
+ Assert.That (TwiStep (0xa4), Is.EqualTo (0x08));
+ Cpu.WriteData (TWDR, (0x50 << 1) | 1);
+ Assert.That (TwiStep (0x84), Is.EqualTo (0x40), "SLA+R acked");
+ Assert.That (TwiStep (0xc4), Is.EqualTo (0x50), "byte read with ACK");
+ Assert.That (Cpu.Mmio.Data[TWDR], Is.EqualTo (0x5a));
+ TwiStep (0x94);
+
+ i2c.DetachTarget (0x50);
+ Assert.That (TwiStep (0xa4), Is.EqualTo (0x08));
+ Cpu.WriteData (TWDR, 0x50 << 1);
+ Assert.That (TwiStep (0x84), Is.EqualTo (0x20), "detached");
+ }
+
+ [Test]
+ public void I2cSlaveSide ()
+ {
+ var i2c = ((IPeripheralMap)_mcu).I2cs.Single ();
+ var addresses = new List ();
+ i2c.SlaveAddressChanged += addresses.Add;
+ Cpu.WriteData (0xba, 0x42 << 1); // TWAR
+ Assert.That (i2c.SlaveAddress, Is.EqualTo (0x42));
+ Assert.That (i2c.BeginMasterTransfer (0x42, write: true), Is.True);
+ Assert.That (i2c.BeginMasterTransfer (0x43, write: true), Is.False);
+ Cpu.WriteData (TWCR, 0x44); // TWEA | TWEN
+ Assert.That (i2c.MasterWrite (0x99), Is.True);
+ Assert.That (Cpu.Mmio.Data[TWDR], Is.EqualTo (0x99));
+ i2c.MasterStop ();
+ Assert.That (Cpu.Mmio.Data[TWSR] & 0xf8, Is.EqualTo (0xa0));
+ Assert.Throws (() => i2c.TryMasterRead (out _));
+ }
+
+ [Test]
+ public void PwmFromTimer0 ()
+ {
+ var pwm = (IPwmSource)_mcu;
+ Assert.That (pwm.TryGetPwm (2, 6, out var info), Is.True, "PD6 is OC0A");
+ Assert.That (info.Enabled, Is.False);
+ Assert.That (pwm.TryGetPwm (2, 3, out _), Is.False, "PD3 has no mounted timer");
+ Assert.That (pwm.TryGetPwm (0, 6, out _), Is.False);
+
+ Cpu.WriteData (0x2a, 0x60); // DDRD: PD5, PD6
+ Cpu.WriteData (OCR0A, 128);
+ Cpu.WriteData (TCCR0A, 0x83); // COM0A1, fast PWM
+ Cpu.WriteData (TCCR0B, 0x01);
+ Assert.That (pwm.TryGetPwm (2, 6, out info), Is.True);
+ Assert.Multiple (() => {
+ Assert.That (info.Enabled, Is.True);
+ Assert.That (info.FrequencyHz, Is.EqualTo (16_000_000.0 / 256).Within (1e-6));
+ Assert.That (info.Duty, Is.EqualTo(129.0 / 256).Within (1e-9));
+ });
+
+ Cpu.WriteData (TCCR0A, 0xc3); // inverting
+ pwm.TryGetPwm (2, 6, out info);
+ Assert.That (info.Duty, Is.EqualTo (1 - 129.0 / 256).Within (1e-9));
+
+ Cpu.WriteData (TCCR0A, 0x81); // phase correct
+ pwm.TryGetPwm (2, 6, out info);
+ Assert.Multiple (() => {
+ Assert.That (info.FrequencyHz, Is.EqualTo (16_000_000.0 / 510).Within (1e-6));
+ Assert.That (info.Duty, Is.EqualTo (128.0 / 255).Within (1e-9));
+ });
+
+ Cpu.WriteData (TCCR0B, 0x00); // clock stopped
+ pwm.TryGetPwm (2, 6, out info);
+ Assert.That (info.Enabled, Is.False);
+ }
+
+ [Test]
+ public void ClockChangedFollowsClkpr ()
+ {
+ var raised = 0;
+ Mcu.ClockChanged += () => raised++;
+ Cpu.WriteData (CLKPR, 0x80);
+ Cpu.WriteData (CLKPR, 0x01); // divide by 2
+ Assert.Multiple (() => {
+ Assert.That (Mcu.ClockHz, Is.EqualTo (8_000_000u));
+ Assert.That (raised, Is.EqualTo (1));
+ });
+ }
+
+ [Test]
+ public void CycleBudgetHelperWorksWithRun ()
+ {
+ LoadBlink ();
+ double carry = 0;
+ for (var i = 0; i < 100; i++) {
+ var budget = CycleBudget.Next (ref carry, Mcu.ClockHz, 0.000001);
+ CycleBudget.Settle (ref carry, budget, Mcu.Run (budget));
+ }
+ Assert.That (Mcu.Cycles, Is.EqualTo (1600).Within (2));
+ }
+}
diff --git a/tests/Avr8Sharp.Tests/GpioBatchInputTests.cs b/tests/Avr8Sharp.Tests/GpioBatchInputTests.cs
new file mode 100644
index 0000000..d3460bd
--- /dev/null
+++ b/tests/Avr8Sharp.Tests/GpioBatchInputTests.cs
@@ -0,0 +1,263 @@
+using AVR8Sharp.Core.Peripherals;
+using Avr8Sharp.Tests.Utils;
+
+namespace Avr8Sharp.Tests;
+
+/// SetInputs, the no-change early returns, and OutputEnableMask / OutputLevels.
+[TestFixture]
+public class GpioBatchInput : AvrTestBase
+{
+ const int PINB = 0x23;
+ const int DDRB = 0x24;
+ const int PORTB = 0x25;
+ const int DDRD = 0x2a;
+ const int PCICR = 0x68;
+ const int PCIFR = 0x3b;
+ const int PCMSK0 = 0x6b;
+ const int TCCR0A = 0x44;
+ const int TCCR0B = 0x45;
+ const int OCR0A = 0x47;
+
+ AvrIoPort _portB;
+ AvrIoPort _portD;
+ AvrTimer _timer0;
+
+ protected override void SetupPeripherals ()
+ {
+ _timer0 = new AvrTimer (Cpu, AvrTimer.Timer0Config);
+ _portB = new AvrIoPort (Cpu, AvrIoPort.PortBConfig);
+ _portD = new AvrIoPort (Cpu, AvrIoPort.PortDConfig);
+ }
+
+ static void Drive (AvrIoPort port, byte mask, byte levels)
+ {
+ for (byte bit = 0; bit < 8; bit++)
+ if ((mask & (1 << bit)) != 0)
+ port.SetPinValue (bit, (levels & (1 << bit)) != 0);
+ }
+
+ static void Release (AvrIoPort port, byte mask)
+ {
+ for (byte bit = 0; bit < 8; bit++)
+ if ((mask & (1 << bit)) != 0)
+ port.ReleasePin (bit);
+ }
+
+ static void Apply (AvrIoPort port, byte drivenMask, byte levels)
+ {
+ for (byte bit = 0; bit < 8; bit++) {
+ if ((drivenMask & (1 << bit)) != 0)
+ port.SetPinValue (bit, (levels & (1 << bit)) != 0);
+ else
+ port.ReleasePin (bit);
+ }
+ }
+
+ [Test (Description = "SetInputs / ReleaseInputs / ApplyInputs leave PIN, PCINT flag and listeners identical to per-bit calls")]
+ public void EquivalentToPerBitCalls ()
+ {
+ // kind: 0 = SetInputs, 1 = ReleaseInputs, 2 = ApplyInputs
+ var steps = new (int kind, byte mask, byte levels)[] {
+ (0, 0xff, 0xa5), (0, 0xff, 0xa5), (0, 0x0f, 0xff), (1, 0xf0, 0), (0, 0x3c, 0x18), (1, 0x00, 0),
+ (2, 0x7e, 0x24), (2, 0x7e, 0x24), (1, 0xff, 0), (0, 0x81, 0x80), (2, 0xff, 0x00),
+ };
+ foreach (var ddr in new byte[] { 0x00, 0x0f, 0xaa }) {
+ foreach (var port in new byte[] { 0x00, 0x55, 0xff }) {
+ var a = new AVR8Sharp.Core.Cpu (new ushort[1024], 8192);
+ var b = new AVR8Sharp.Core.Cpu (new ushort[1024], 8192);
+ var pa = new AvrIoPort (a, AvrIoPort.PortBConfig);
+ var pb = new AvrIoPort (b, AvrIoPort.PortBConfig);
+ var logA = new List<(byte, byte)> ();
+ var logB = new List<(byte, byte)> ();
+ pa.AddListener ((v, o) => logA.Add ((v, o)));
+ pb.AddListener ((v, o) => logB.Add ((v, o)));
+ var clocksA = new List<(int, bool)> ();
+ var clocksB = new List<(int, bool)> ();
+ for (var i = 0; i < 8; i++) {
+ var bit = i;
+ pa.ExternalClockListeners[i] = v => clocksA.Add ((bit, v));
+ pb.ExternalClockListeners[i] = v => clocksB.Add ((bit, v));
+ }
+ foreach (var cpu in new[] { a, b }) {
+ cpu.WriteData (DDRB, ddr);
+ cpu.WriteData (PORTB, port);
+ cpu.WriteData (PCMSK0, 0xff);
+ cpu.WriteData (PCICR, 1);
+ }
+ foreach (var step in steps) {
+ switch (step.kind) {
+ case 0:
+ Drive (pa, step.mask, step.levels);
+ pb.SetInputs (step.mask, step.levels);
+ break;
+ case 1:
+ Release (pa, step.mask);
+ pb.ReleaseInputs (step.mask);
+ break;
+ default:
+ Apply (pa, step.mask, step.levels);
+ pb.ApplyInputs (step.mask, step.levels);
+ break;
+ }
+ Assert.Multiple (() => {
+ Assert.That (b.Mmio.Data[PINB], Is.EqualTo (a.Mmio.Data[PINB]), $"PIN ddr={ddr:x2} port={port:x2} step={step}");
+ Assert.That (b.Mmio.Data[PCIFR], Is.EqualTo (a.Mmio.Data[PCIFR]));
+ Assert.That (logB, Is.EqualTo (logA));
+ Assert.That (clocksB, Is.EqualTo (clocksA));
+ });
+ b.Mmio.Data[PCIFR] = 0;
+ a.Mmio.Data[PCIFR] = 0;
+ }
+ }
+ }
+ }
+
+ [Test (Description = "SetInputs leaves pins outside the mask alone; ReleaseInputs and ApplyInputs release")]
+ public void MaskSemantics ()
+ {
+ Cpu.WriteData (PORTB, 0x03); // pull-ups on PB0, PB1
+ _portB.SetInputs (0xff, 0x00);
+ Assert.That (Cpu.Mmio.Data[PINB], Is.EqualTo (0x00));
+ _portB.SetInputs (0x80, 0x80);
+ Assert.That (Cpu.Mmio.Data[PINB], Is.EqualTo (0x80), "other pins stay driven low");
+ _portB.ReleaseInputs (0x03);
+ Assert.That (Cpu.Mmio.Data[PINB], Is.EqualTo (0x83), "released pins read their pull-up");
+ _portB.ApplyInputs (0x80, 0x00);
+ Assert.That (Cpu.Mmio.Data[PINB], Is.EqualTo (0x03));
+ }
+
+ [Test (Description = "PullUpMask matches GetPinState")]
+ public void PullUpMaskMatchesGetPinState ()
+ {
+ foreach (var oc in new byte[] { 0x00, 0x0f }) {
+ _portB.OpenCollector = oc;
+ foreach (var ddr in new byte[] { 0x00, 0x3c, 0xff }) {
+ foreach (var port in new byte[] { 0x00, 0x55, 0xff }) {
+ Cpu.WriteData (DDRB, ddr);
+ Cpu.WriteData (PORTB, port);
+ byte expected = 0;
+ for (byte bit = 0; bit < 8; bit++)
+ if (_portB.GetPinState (bit) == PinState.InputPullup) expected |= (byte)(1 << bit);
+ Assert.That (_portB.PullUpMask, Is.EqualTo (expected), $"oc={oc:x2} ddr={ddr:x2} port={port:x2}");
+ }
+ }
+ }
+ }
+
+ [Test (Description = "PinChanged reports chip-side changes with the cycle, honours WatchMask, costs nothing unsubscribed")]
+ public void PinChangedEvent ()
+ {
+ var events = new List<(byte changed, byte en, byte lev, ulong cycle)> ();
+ AvrPinChangeHandler handler = (c, e, l, cy) => events.Add ((c, e, l, cy));
+ Cpu.WriteData (DDRB, 0x03);
+ Cpu.WriteData (PORTB, 0x01); // nobody subscribed yet
+ _portB.PinChanged += handler;
+
+ Cpu.Cycles = 100;
+ Cpu.WriteData (PORTB, 0x02);
+ Assert.That (events, Is.EqualTo (new[] { ((byte)0x03, (byte)0x03, (byte)0x02, 100UL) }));
+
+ events.Clear ();
+ _portB.WatchMask = 0x02;
+ Cpu.Cycles = 200;
+ Cpu.WriteData (PORTB, 0x01);
+ Assert.That (events, Is.EqualTo (new[] { ((byte)0x02, (byte)0x03, (byte)0x01, 200UL) }));
+
+ events.Clear ();
+ Cpu.WriteData (PORTB, 0x01); // unchanged
+ Cpu.WriteData (DDRB, 0x01); // PB1 released: output enable changes
+ Assert.That (events, Is.EqualTo (new[] { ((byte)0x02, (byte)0x01, (byte)0x01, 200UL) }));
+
+ events.Clear ();
+ _portB.PinChanged -= handler;
+ Cpu.WriteData (PORTB, 0x00);
+ Assert.That (events, Is.Empty);
+ }
+
+ [Test (Description = "Nothing changed: no PCINT flag, no external clock listener")]
+ public void NoChangeFiresNothing ()
+ {
+ Cpu.WriteData (PCMSK0, 0xff);
+ Cpu.WriteData (PCICR, 1);
+ var clocks = 0;
+ for (var i = 0; i < 8; i++) _portB.ExternalClockListeners[i] = _ => clocks++;
+
+ _portB.SetInputs (0xff, 0x0f);
+ Assert.That (clocks, Is.EqualTo (4));
+ Assert.That (Cpu.Mmio.Data[PCIFR] & 1, Is.EqualTo (1));
+ Cpu.Mmio.Data[PCIFR] = 0;
+ clocks = 0;
+
+ _portB.SetInputs (0xff, 0x0f);
+ _portB.SetPinValue (0, true);
+ _portB.SetPinValue (5, false);
+ _portB.ReleasePin (7); // changes: pin 7 was driven low, now floats low
+ Assert.That (clocks, Is.EqualTo (0), "no PIN bit changed, so no listener");
+ Assert.That (Cpu.Mmio.Data[PCIFR], Is.EqualTo (0));
+
+ _portB.ReleasePin (7);
+ _portB.SetInputs (0x7f, 0x0f);
+ Assert.Multiple (() => {
+ Assert.That (clocks, Is.EqualTo (0));
+ Assert.That (Cpu.Mmio.Data[PCIFR], Is.EqualTo (0));
+ });
+ }
+
+ [Test (Description = "Redundant SetPinValue / ReleasePin / SetInputs do not recompute PIN")]
+ public void RedundantCallsAreSilent ()
+ {
+ _portB.SetPinValue (2, true);
+ Cpu.Mmio.Data[PINB] = 0x77; // a recompute would overwrite this
+ _portB.SetPinValue (2, true);
+ _portB.SetInputs (0x04, 0x04);
+ _portB.ReleasePin (3);
+ Assert.That (Cpu.Mmio.Data[PINB], Is.EqualTo (0x77));
+
+ _portB.SetPinValue (2, false);
+ Assert.That (Cpu.Mmio.Data[PINB], Is.EqualTo (0x00));
+ }
+
+ [Test (Description = "OutputEnableMask is DDR, minus open-collector pins that are high")]
+ public void OutputEnableMaskFollowsDdr ()
+ {
+ Cpu.WriteData (DDRB, 0x35);
+ Cpu.WriteData (PORTB, 0x15);
+ Assert.Multiple (() => {
+ Assert.That (_portB.OutputEnableMask, Is.EqualTo (0x35));
+ Assert.That (_portB.OutputLevels, Is.EqualTo (0x15));
+ });
+
+ _portB.OpenCollector = 0x01;
+ Assert.Multiple (() => {
+ Assert.That (_portB.OutputEnableMask, Is.EqualTo (0x34));
+ Assert.That (_portB.OutputLevels, Is.EqualTo (0x14));
+ Assert.That (_portB.GetPinState (0), Is.EqualTo (PinState.InputPullup));
+ });
+ }
+
+ [Test (Description = "With timer 0 driving OC0A, the masks agree with GetPinState on every count")]
+ public void OutputMasksWithTimerOverride ()
+ {
+ Cpu.WriteData (DDRD, 0x40); // PD6 = OC0A
+ Cpu.WriteData (OCR0A, 128);
+ Cpu.WriteData (TCCR0A, 0x83);
+ Cpu.WriteData (TCCR0B, 1);
+
+ var sawHigh = false;
+ var sawLow = false;
+ var cycle = Cpu.Cycles;
+ for (var i = 0; i < 600; i++) {
+ Cpu.Cycles = ++cycle;
+ Cpu.Tick ();
+ var high = _portD.GetPinState (6) == PinState.High;
+ sawHigh |= high;
+ sawLow |= !high;
+ Assert.Multiple (() => {
+ Assert.That (_portD.OutputEnableMask, Is.EqualTo (0x40));
+ Assert.That ((_portD.OutputLevels & 0x40) != 0, Is.EqualTo (high));
+ Assert.That (_portD.OutputLevels & ~0x40, Is.EqualTo (0));
+ });
+ }
+ Assert.That (sawHigh && sawLow, Is.True);
+ }
+}