Atari ST MC68901 MFP: Timer Modes, Interrupt Priority, and In-Service State
Trace Atari ST MFP timers and interrupts from prescaler events through pending, mask, priority, and in-service registers, with repeatable diagnostic fixtures.
The Atari ST’s MC68901 Multi-Function Peripheral is easy to underestimate because it sits behind a compact set of memory-mapped registers. It is not just four countdown variables and an interrupt callback. The MFP combines programmable timers, general-purpose I/O, serial support, interrupt pending and enable state, masks, priority, and in-service tracking. A timer reaching zero is only one event in a longer chain that may or may not become visible to the 68000.
For emulator authors, the most common failure is to collapse all of that state into a single “timer fired” boolean. That shortcut can produce a scanline effect that looks plausible while breaking nested interrupt behavior, event counting, timer reloads, or software that masks a source while it is pending. A useful model follows the signal from its source to the CPU exception and preserves every register that software can read or acknowledge along the way.
Four timers, more than four countdowns
The MFP has timers A, B, C, and D. Timer control selects whether a timer is stopped or running and, for supported modes, how its input is divided before the counter decrements. Timer data supplies the count or reload value. Timer A and B have their own control and data registers; C and D have separate data registers but share a control register whose fields select their divisors. That register layout matters: writing one timer’s field must not accidentally overwrite or restart the other.
The timer’s visible data value is not a host wall-clock duration. It is an 8-bit device counter driven by a clock-derived event stream. A prescaler converts source ticks into timer ticks, and the timer reaches its terminal condition after the programmed count. The exact off-by-one convention and data-register readback behavior are device semantics, not a reason to replace the counter with sleep() or a millisecond timer. Keep source cycles, prescaler remainder, current counter, and reload/control state explicit.
Delay/countdown operation is only one kind of timer activity. Event-count behavior uses external transitions rather than a free-running divided clock. A timer configured for event counting should advance only when its selected input event occurs; elapsed CPU time alone must not decrement it. This distinction is essential for raster-driven software and peripheral pulse counting. It also makes input routing part of the timer model: the edge detector and timer counter should not be hidden inside a generic scheduler callback.
On an ST, software often uses the MFP for timing patterns tied to display or serial behavior. Timer B is commonly associated with horizontal-blank counting, while Timer C participates in the system timing environment and Timer D is often selected for serial baud generation. Those are operating-system and software conventions layered on the chip, not a license to hard-code every timer to one purpose. Games, demos, and custom software may program the MFP differently.
A timer expiry is an interrupt request, not a CPU exception
When a timer event occurs, it can set that source’s pending bit. The interrupt-enable state determines whether that source is allowed to request service; the interrupt-mask state can further prevent it from reaching the CPU. If it is enabled and unmasked, the MFP presents a request subject to its priority rules and the 68000’s own interrupt mask. The CPU then acknowledges the level and obtains the MFP vector according to the configured vector base and source number.
Pending, enabled, masked, and in-service are distinct pieces of state. A masked source can remain pending while the CPU sees no request. Unmasking later can make the already-pending event eligible. When software begins servicing a source, in-service state constrains which lower-priority requests may be accepted. In automatic end-of-interrupt mode, the MFP updates service state as the interrupt-acknowledge sequence proceeds; in software EOI mode, software performs the documented clear operation. Treating an interrupt as an edge that disappears after one callback loses these semantics.
The MFP groups sources into two banks of eight, conventionally named A and B. Each bank has interrupt-enable, pending, in-service, and mask registers. The chip’s priority order is fixed: GPIP I7 is highest and I0 is lowest, with the timer and serial sources between them in the documented order. The vector register supplies the upper vector bits and selects automatic versus software end-of-interrupt mode; it does not reverse the priority ordering. Selective masking can change which pending source is eligible and therefore effectively alter which one is serviced first, without changing the hardware’s underlying order. A realistic test should create simultaneous requests, mask and unmask selected channels, and verify the documented winner, vector, and EOI behavior.
The MFP’s interrupt request is also level-sensitive at the integration boundary. If one eligible pending source remains asserted after another source is acknowledged, the CPU may see another request without a fresh timer edge. Conversely, clearing pending state in the wrong order can drop a real request. Recompute the output line from current register state after every write, timer expiry, acknowledge, and EOI operation rather than toggling it opportunistically.
Timer and interrupt registers need directional semantics
Register reads and writes do not all mean “copy a byte.” Control writes can stop or start a timer, select a prescaler, change an event-count mode, or change interrupt configuration. Pending and in-service registers may have write-one-to-clear or software-EOI semantics that differ from ordinary storage. A read should return the device’s current state, not the last byte written to the address. Implement dedicated accessors for writable controls and latched status so one generic register array cannot silently erase hardware side effects.
For countdown state, schedule the next terminal event in emulated cycles, but preserve the partial prescaler phase when a control is changed if the chip behavior requires it. If the timer is stopped, discard or retain the remainder only according to the hardware reference; do not accidentally allow a previously scheduled callback to fire after a stop. If data is rewritten while running, test whether the active count changes immediately or the new value takes effect at reload. The answer is mode- and device-specific and should be derived from the MFP reference or validated implementation, not assumed from a generic timer class.
Save states need the timer’s current data, selected mode, prescaler remainder, source edge history when applicable, pending and in-service bits, and the next scheduled event in machine time. Serializing only the countdown value can shift an interrupt by one prescaler period after restore. Test restore just before terminal count, immediately after expiry, while masked, and during service. Compare the complete register and CPU-line trace before and after restoration.
A deterministic model for tests
This Python fixture is a software test representation for mask eligibility only. It does not implement the MFP register map, resolve timer timing, compare source priority, or decide whether a request can preempt an active service level. A hardware core must apply the actual MFP priority encoding and 68000 interrupt level around this state; request_asserted() here means only that at least one source passes the enable and mask gates.
from dataclasses import dataclass
@dataclass
class MfpInterruptBank:
enabled: int = 0
pending: int = 0
masked: int = 0
in_service: int = 0
def eligible(self):
return self.pending & self.enabled & ~self.masked & 0xFF
def request_asserted(self):
return self.eligible() != 0
def acknowledge(self, source_bit):
if not 0 <= source_bit < 8:
raise ValueError("MFP source bit must be 0..7")
bit = 1 << source_bit
if not (self.eligible() & bit):
raise ValueError("cannot acknowledge a disabled or masked source")
self.in_service |= bit
bank = MfpInterruptBank(enabled=0b00000101, pending=0b00000101)
assert bank.request_asserted()
bank.masked = 0b00000001
assert bank.eligible() == 0b00000100
The fixture’s bit-order convention is intentionally abstract. A register-facing implementation must map source numbers to the MFP’s documented vector and bank positions; a one-bit shift in that mapping changes the delivered vector while leaving most timer tests green. Build one test per source, then add pairwise pending requests to test priority and service blocking.
Practical diagnostics on Atari ST software
Start with a known ROM or diagnostic program that programs one timer and toggles a visible border or palette at the interrupt handler. Record the timer control/data writes, source clock cycles, pending bit, mask and enable state, CPU interrupt level, acknowledge vector, and handler entry cycle. Repeat with the interrupt masked, with it disabled, with a pending request unmasked later, and with software EOI enabled. A screenshot alone cannot distinguish an accurate timer from a frame-tick approximation.
For horizontal timing, test the timer against the shifter’s scan position, not only a host display refresh. Pair MFP timer traces with the video counter and CPU bus cycle. Then test serial activity separately: changing Timer D for baud generation should affect the serial subsystem’s bit timing without corrupting Timer C’s state. These cross-device checks catch bugs caused by shared control-register writes and accidental use of host time.
When reporting a compatibility fix, identify whether it corrects timer frequency, reload behavior, event input, interrupt mask logic, priority, vector formation, or EOI. “Fixed MFP timing” is too broad to reproduce. Include the register sequence and expected trace, and be clear if a result applies to a particular MFP revision or software convention rather than every ST-compatible machine.
Acceptance criteria
A dependable MFP model keeps each timer’s control and phase independent, distinguishes event counts from clocked countdowns, and derives the CPU request from pending, enable, mask, priority, and in-service state. It produces deterministic vectors, supports both EOI behaviors, and survives save-state restoration at an arbitrary timer phase. With those invariants, a raster or serial defect can be localized to an input, a timer, an interrupt latch, or CPU acknowledgement instead of being patched with a game-specific delay.
Related:
- Atari ST Shifter Screen Fetching: Base Registers, Address Counter, and MMU Timing
- Amiga CIA Timers, TOD Clock, and Interrupt Control
Sources: