23 KiB
Why 38400 wedges
Folded into
testReport.mdas section 10, which carries the headline numbers; this file is the full record. It was written against the build that still had the fault, so its addresses and its present tense are that build's;portCompatReport.mdandtestReport.mdsection 11 measure the build with the re-entrancy guard, in which the failure below does not occur.
highRateReport.md characterised the 38400-baud wedge and said plainly that it had not been
explained: four boots, four wedges, one ending in *** Main CPU: JAM at $0007, and no path traced
from any measured byte to any of it. This document explains it, from measurements taken for that
purpose.
The cause is NMI re-entrancy, and it is established, not inferred. A machine was stopped by a
checkpoint at the instant it entered commNmiHandler with SP = $12, and thirty-four complete
nested handler frames were decoded off page 1. The descent from a healthy stack to an exhausted one
was recorded three separate times, with the emulator's own cycle counter, and takes 8 to 13
milliseconds. The arithmetic behind it was measured too: one pass of the handler costs 175 to 266
cycles, and at 38400 baud a character time is 266.3 cycles with two interrupts inside it. The
handler cannot finish before the next one arrives, so it never finishes at all.
Everything below is VICE 3.7.1 (x64sc), NTSC, -acia1mode 1 -acia1base 0xDE00 -acia1irq 1, on
2026-08-22, against a disk built from the current source with ./build.sh
(checkAbi.py prints ABI CHECK PASSED). No driver source was changed. The only write either
script makes to a machine is baudIndex $E055 before the link is opened - 38400's hot key was
removed from baudEntryTable, so the rate is selected by setting the index and letting the driver's
own loadBaudParameters $E353 program the control register. $DE03 read $1F on every machine in
every run.
1. The answer
- The 6502 does not mask NMI. Entering an NMI sets
I, which stops IRQs, and nothing at all stops another NMI. commNmiHandler $E685reachesjsr aciaGetStatusat$E68Feight instructions in, and that routine'slda $DE01is the ninth. On a 6551 that read is the acknowledge: it clears the IRQ flag and releases/IRQ.- From that read to the end of the pass - up to about 220 more cycles - the chip is free to raise
a fresh event.
/IRQfalls again, the 6502 latches the edge, and the handler is re-entered at the very next instruction boundary. Each re-entry costs 6 bytes of stack (the CPU'sPCH/PCL/Pplus the handler'sA,Xand saved$01), or 8 when the edge lands while the interrupted pass was inside one of its ownJSRs. - At 38400 baud with traffic in both directions there are two interrupt-raising events per
character time - one
RDRF, oneTDRE- so an edge arrives roughly every 133 cycles, while one pass of the handler costs 175 to 266. Service time exceeds inter-arrival time. The queue is not a queue: it is a stack, and it grows without bound. - About thirty frames is the whole of the 170-odd bytes of page 1 the game leaves free. Measured: 8.4 ms, 12.5 ms and 11.5 ms in three independent bursts.
- Once
SPwraps past$00the frames overwrite one another. EveryRTIfrom then on pops aPand aPCthat belong to a different frame, and thePLA/STA $01at$E6E3/$E6E4writes a foreign byte into the CPU port register. The machine leaves the handler with a corrupted program counter, a corrupted flag byte, or a corrupted memory map - and that is the wedge.
The driver's own comment at commNmiHandler names this race and prices it at "a checksum failure and
one retransmit, which the ARQ layer above already deals with". That price is right for one re-entry.
What the comment does not say is that above about 19200 baud it stops being one re-entry.
2. What one pass of the handler costs - measured
testCost.py traces six addresses inside the handler - the entry $E685, sta aciaRxByte $E699,
dec uartPendingCount $E6C9, jsr aciaPutData $E6D6, and both exits (rti $E6E9, jmp (nmiChainVector) $E67F) - and pairs every entry with its exit, so each pass can be priced and
classified by what it actually did. It was run at 19200, where the link is healthy and busy, and
where nothing nests: the handler's cost is the same code at any rate.
Four windows, two machines, 34.8 emulated seconds, 5694 complete passes:
| what the pass did | passes | trace-to-trace, median | whole interrupt |
|---|---|---|---|
| received a character | 2983 | 165 cycles | 178 cycles |
| transmitted a character | 1835 | 165 | 178 |
| transmit interrupt found the ring dry, disarmed it | 677 | 162 | 175 |
| received and transmitted in the same pass | 188 | 243-253 | 256-266 |
| neither half had work (a DCD/DSR change) | 11 | 151 | 164 |
"Whole interrupt" adds the 7 cycles the CPU spends taking the NMI and the 6 the RTI costs, neither
of which lies between the two tracepoints. A hand count of the same paths off the source agrees to
within a cycle, which is worth saying because it means the figure is a property of the code and not
of the emulator.
Now put that against the line. An NTSC C64 runs at 1,022,727 Hz and 8N1 is ten bits per character:
| rate | characters/s | one character time | one pass | two passes | share of the CPU |
|---|---|---|---|---|---|
| 300 | 30 | 34090.9 cycles | 178 | 356 | 1% |
| 1200 | 120 | 8522.7 | 178 | 356 | 4% |
| 2400 | 240 | 4261.4 | 178 | 356 | 8% |
| 4800 | 480 | 2130.7 | 178 | 356 | 17% |
| 9600 | 960 | 1065.3 | 178 | 356 | 33% |
| 19200 | 1920 | 532.7 | 178 | 356 | 67% |
| 38400 | 3840 | 266.3 | 178 | 356 | 134% - cannot keep up |
The "one pass" column is a single-half pass; a pass that services both halves costs 256-266.
Two numbers in that table decide everything.
- A full-duplex pass costs 266 cycles and a character time at 38400 is 266.3 cycles. One interrupt that services both halves consumes an entire character time on its own, leaving the game nothing.
- When the receive and transmit events land in separate passes - which is the normal case, because they are separate events at separate instants - the cost is 356 cycles of work offered every 266 cycles. That is the whole failure in one line.
This is also the ladder every earlier report measured without knowing why: healthy at 4800 (17%), degrading at 9600 and 19200 (33% and 67%), gone at 38400.
3. The re-entrancy itself - measured three times
testStack.py arms one tracepoint before the link is ever opened:
trace exec e685 if sp < $b4
A tracepoint prints and lets the machine run on, so this does not disturb anything, and it prints
nothing at all while nesting is shallow: the main line runs with SP between $BD and $EB, so
an entry below $B4 is already one frame deeper than the deepest healthy entry seen on any
machine in any run. Every VICE trace
line carries SP and the free-running cycle counter, so the output is a timestamped staircase.
Three runaways were caught, on freely running machines, with no breakpoint involved:
| run | machine | entries | duration | SP fell |
frames | rate of descent |
|---|---|---|---|---|---|---|
st384d |
B | 44 | 8611 cycles = 8.42 ms | $B0 -> $02 |
174 bytes = 29 | 20666 stack bytes/s |
st384d |
B | 91 | 12807 cycles = 12.52 ms | $B3 -> $08 |
171 bytes = 28.5 | 13656 bytes/s |
st384c |
B | 84 | 11780 cycles = 11.52 ms | $B3 -> $00 |
179 bytes = 30 | 15541 bytes/s |
The first twenty-four entries st384d's machine B recorded below $B4, as (SP, cycle):
($B3,153671517) ($B0,+264) ($B2,+152) ($AA,+115) ($AF,+147) ($A7,+117) ($A9,+152) ($A1,+115)
($A9,+153) ($A1,+115) ($A1,+148) ($99,+115) ($A1,+153) ($99,+115) ($99,+147) ($91,+117)
($99,+151) ($91,+115) ($93,+153) ($8B,+115) ($90,+147) ($88,+163) ($8A,+107) ($84,+113)
Read the gaps: they alternate 115 and about 150 cycles, and 115 + 150 = 265 - one character time
at 38400, with two interrupts inside it. That is the "two events per character" claim measured
directly rather than argued. Read the stack pointer: it saws up and down as inner passes complete,
and every saw tooth is lower than the last. In st384d the in-burst gaps were min 55, 25th percentile 117,
median 158, 75th percentile 265 cycles; in st384c, min 55, 25th percentile 60, median 171, max 269
- against a handler that needs 175 to 266.
Control. The same script, the same instrumentation, 200 seconds at 19200 with two machines
carrying 16729 and 16856 bytes - six times the traffic the 38400 runs managed - produced no entry
below SP $B4 at all, on either machine, and neither stack was ever pushed below $01C9. At
19200 the handler fits inside the character time, so it never nests.
4. The stack, decoded
st384b carried a second checkpoint, break exec e685 if sp < $20, which stops the emulator once
the stack is nearly gone. It fired. Machine A was stopped at $E685, the first instruction of
commNmiHandler, with SP = $12, X = $14 and I set - about to push three more bytes onto a
stack with room for two more nested passes. This is the one machine whose stack can still be
walked: it was stopped part-way down, before the pointer wrapped and the frames began overwriting
one another.
Its page 1, walked upward from $0113 (the newest pushed byte) to $01FF, decodes without a gap or
an ambiguity into 34 complete nested handler frames plus 16 JSR return addresses:
| what | value | count |
|---|---|---|
| interrupted PC | $E6C4 |
10 |
$E6C7 |
9 | |
$E551 |
8 | |
$E6C2 |
7 | |
saved X |
$14 |
33 of 33 |
saved $01 |
$35 |
33 of 33 |
saved P |
$25 / $27 |
25 / 9 - I set in every one |
saved A |
$10 / $09 / $00 |
17 / 8 / 8 |
JSR return addresses under the frames |
$E40A, $E6E2 |
8 each |
Every one of those addresses is inside the handler's transmit half, in the window between the
status read at $E68F and the two writes that would remove the interrupt source:
$E6C2-beq L_E6EA, theTDREtest$E6C4-lda txCharActive$E6C7-beq L_E6D1$E551-sta aciaCommandShadowinsideaciaSetCommand, reached fromaciaSetCommandIdle $E408at$E6E0; the twoJSRreturn addresses$E6E2and$E40Asitting under those frames are that call chain
The saved $01 is $35 in all 33 frames, which by itself proves nesting: $35 is the value the
handler forces at $E68D, so a frame that saved $35 was interrupted by a handler that had
already run. The saved X is $14 = 20 in all 33: pushUartRxRing leaves X at uartRxCount,
and the receive ring was pinned at its 20-byte limit because the game had stopped draining it.
Three things fall out of that table that earlier reports could not explain.
$E6C4and$E6C7straddledec uartPendingCount $E6C9. A pass interrupted at$E6C7has already readtxCharActiveand decided to count a byte off; the inner pass then does its owndec, clearstxCharActive, pops the ring and writes the data register; when the outer pass resumes it decrements again for the same byte and writes the data register again. That is wherehighRateReport.mdsection 4's lost transmit characters come from - VICE's "data register written although data has not been sent yet", 0.8% at 4800 climbing to 2.2% at 19200, the same curve as the duty cycle in section 2 - and it is howuartPendingCountgets below zero to the$FFthat section 6.1 found. The double decrement is shown here as a frame stopped one instruction short of theDEC; aDEC/INCimbalance was not separately counted during a storm.$E551explains boot 4's impossible command register.aciaSetCommandwrites the shadow at$E551and the chip at$E554. A machine thrown off the rails between those two instructions ends up exactly ashighRateReport.mdsection 6.2 found it:$DE02=$05, transmit interrupt still armed, whileaciaCommandShadowsays$09.- The two stacks that finished the job cannot be walked, and that is the point.
st384c's andst384d's machine B both finished with page 1 covered end to end in the same material -$35/$14pairs,Pbytes of$65/$67withIset, and the return addresses$E6B5(jsr reportLinkError),$E6E2(jsr aciaSetCommandIdle),$E40A(jsr aciaSetCommand),$E551/$E554,$E6B9/$E6BBand$E6E7/$E6E8, the last two beingtaxandplain the handler's own exit sequence. Those two stacks cannot be walked frame by frame the wayst384b's can, because the pointer had wrapped and the frames had overwritten each other - which is itself the point.
5. The aftermath, and why the four boots looked different
Once SP has wrapped, an RTI pops three bytes that were written by three different interrupts.
What the machine does next depends on which bytes. Five 38400 boots were run in this session and
four machines died; with the one highRateReport.md caught, they are the same failure photographed
at different moments:
| machine | where it ended up |
|---|---|
st384b A |
caught mid-runaway: PC $E685, SP $12, 34 frames still on the stack |
wg384a A |
running game code at $44EB-$4502 with I set, SP frozen at $BD |
st384d B |
PC $C631, SP $67, I set, page 1 covered in handler frames |
st384c B |
PC $A34E, SP $B2, I and B set, and $01 = $31 - CHAREN clear, so $D000-$DFFF is the character ROM and the ACIA is not in the address space at all |
highRateReport.md boot 4 |
JAM at $0007, $00/$01 = $5E/$15 - the same corrupt PC and the same smashed CPU port, landing on an illegal opcode instead of on live code |
st384c's $01 = $31 is the mechanism caught in the open: $E6E3 is pla and $E6E4 is sta $01, so a wrecked stack hands the memory-map register whatever byte happens to be there. Boot 4's
$01 = $15 is the same instruction with different rubbish.
Why it never recovers
wg384a's machine A was measured for 154 seconds after it died, and three readings say why nothing
comes back:
pollCarrierState $E3CB- the$E00Centry the raster IRQ calls once a frame - executed ZERO times in a 12.15-second traced window and zero again in an 8.19-second one. Its healthy peer executed it 479 times in 8.51 seconds, 56.3/s. Across the whole run the monitor read machine A's registers 124 times, and the interrupt flag was set in all 124, withSPat$BDin all 124; the same 124 reads on its healthy peer foundIset in only 15 - the ones that caught it inside its raster IRQ - andSPranging over$DF-$E5. A 6502 withIset takes no IRQ, so there is no$E000/X=0 and no$E00C, and nothing in the driver runs except the NMI.sta uartPendingCount $E611,startNextTxChar's once-a-frame repair of exactly the bytehighRateReport.mdsection 6.1 found stuck at$FF, also executed zero times. Its peer ran it 478 times in the same 8.51 seconds. The self-heal cannot heal anything, because the self-heal is on the frame tick and the frame tick is gone.serviceLock $E0A7read$00. It rests at 1;serviceCommTickdecrements it on entry and restores it at$E149. The runaway threw the CPU out of the middle of a service tick, so the lock is held for ever - a second latch that would refuse to run the driver even if the raster IRQ came back.
Its remaining state, for the record: connectionPhase $E040 = 2, isLinkActive $E03B = 1,
uartRxCount $E42D = $14 with both indices frozen at $02, uartTxCount $E414 = 0,
uartPendingCount $E0A5 = $00, txCharActive $E5BB = $00, aciaCommandShadow $E5BE = $09,
$DE01/$DE02/$DE03 = $10/$09/$1F. The NMI itself was still working perfectly - 18 to 21
interrupts a second, 0.3% duty cycle, every one of them 164 to 207 cycles - and it had nobody left to
deliver to. That is why the 2400-baud repair probe could never have worked: by the time a wedged
machine can be examined, the driver is fine and the machine above it is dead.
6. What was ruled out
| hypothesis | verdict | on what evidence |
|---|---|---|
| NMI re-entrancy | ESTABLISHED | 34 nested frames decoded off a live stack; three timed descents; the cost/character-time budget |
| Steady-state CPU starvation by the NMI | ruled out as the wedge | after the wedge the NMI duty cycle is 0.3% and the game gets essentially the whole CPU - it just has I set. Overload is the trigger, not the resting state |
| The 20-byte receive ring being too small | not the cause | it overflows at 9600 and 19200 too, and those recover. In the wedges the ring is full because the game stopped draining it - a consequence, and X = $14 in every stack frame is that consequence in the act |
uartPendingCount reaching $FF (spec_uart.md hazard 1) |
a consequence, not the cause | wg384a's machine A wedged with uartPendingCount = $00. The $FF shape needs both the double decrement (section 4) and a dead frame tick (section 5) |
The control byte $1F itself, or the rate table |
ruled out | $DE03 read $1F on every machine in every run and characters crossed the wire correctly; highRateReport.md section 7 ran a machine at $1F against a 300-baud peer for 245 s with no wedge, no overflow and no bad character. The chip setting is innocent; the traffic is what kills |
| A spurious interrupt storm from the chip (a VICE artefact) | ruled out | in normal operation the median gap between handler entries is 267-268 cycles, exactly one character time, and the interrupt rate tracks the character rate. The chip raises one interrupt per event, as it should |
| A protocol deadlock above the driver | ruled out | the wedged machines' CPUs were thrown out of their own control flow with a corrupt P, PC or $01; a healthy peer's page 1 in the same run is untouched below $01C9 |
| The 2400-baud transmitter deadlock | not this | txCharActive/aciaCommandShadow were the self-consistent pair $00/$09 on every wedged machine, as highRateReport.md section 6 already found |
| Word length / parity / stop bits | irrelevant here | every run was 8N1 throughout. Nothing in this failure depends on the framing |
38400 does not always kill inside a minute. Run st384a reached phase 3 on both machines and
neither wedged in 200 seconds, on the same build, with the same instrumentation armed. The failure
needs both directions bursting at once - sendPacket $E909 queues a whole 20-odd byte frame into the
22-byte ring in one loop and the NMI shifts it out back to back - so it is a race that a quiet link
can lose for a while. Five 38400 boots in this session, four machines dead.
7. What this does not show
- VICE, not hardware. No physical SwiftLink and no C64 Ultimate. Two things carry to hardware
regardless, because they are properties of the driver and of the 6502: the handler's cost in cycles
(measured here, and confirmed by a hand count off the source) and the fact that reading the status
register releases
/IRQwhile the handler still has 100 to 240 cycles of work left. What VICE cannot settle is the precise instant a real 6551 re-asserts/IRQ, so the speed of the collapse on a real cartridge may differ from 8-13 ms. The direction cannot: 356 cycles of work per 266 cycles of line time is arithmetic. - VICE's ACIA is byte level, so no framing or parity error can be produced on this rig. On a
real line those errors add
reportLinkError $E3BC->countLinkError $E73Fto the receive path, which makes the pass longer, not shorter. - The double decrement of
uartPendingCountis shown structurally, not counted. Section 4 has a stack frame stopped one instruction before theDEC; it does not have aDEC/INCimbalance counted during a storm. Instrumenting$E6C9and$E71Eunconditionally at 38400 prints thousands of lines a second, and the storm lasts eight milliseconds. - Nothing was fixed and nothing was changed. No driver source file was touched;
checkAbi.pypasses on the image every run above was made against.
8. What any fix has to satisfy (not implemented)
Stated here only because the measurements pin the constraints, and because two of them rule out repairs that look obvious:
- The handler must be safe to re-enter, or must not be re-enterable. Re-entry is not rare at
high rates and it is not free: it costs 6 to 8 bytes of stack and it corrupts
uartPendingCount,txCharActiveand the byte in the transmitter. A re-entrancy counter alone is not enough - the source's own comment is right that naively refusing a nested pass can lose the interrupt that would have restarted the transmitter, and a lostTDREstalls the transmitter for ever. - Total interrupt work per character time must stay below one character time at the rate the user has configured. At 38400 that means the two halves must be serviced by one interrupt of well under 266 cycles - i.e. the handler has to get materially cheaper, or the transmitter has to stop asking for an interrupt per character.
- Whatever is done must hold when the user's hardware is set to a rate the driver did not choose, which is the point of the current work: the rate is not the driver's to pick.
- Eight data bits are mandatory - the frame layer sends
$55length codes, sequence bytes and rotate-and-add checksums, so it is not 7-bit clean. Parity and stop bits are free. Nothing in this diagnosis depends on framing either way.
9. Files
| File | What it is |
|---|---|
testWedge.py |
pairs the handler's entry with its exits, counts re-entries, measures the duty cycle, counts the $E00C frame service, and profiles PC/SP with monitor pauses |
testStack.py |
the deep-nesting tracepoint and the stack-page checkpoints; traceonly mode records the staircase without ever stopping the machine |
testCost.py |
classifies and prices every pass of the handler, and prints the per-character-time budget |
testLogs/wedge.wg384a.txt |
the 38400 boot whose machine A died with I set and its raster IRQ gone |
testLogs/wedge.wg384a.A.nmi.trace.txt, .A.after.trace.txt, .B.after.trace.txt |
the traces behind the "$E3CB executed zero times" measurement |
testLogs/stack.st384a.txt |
a 38400 boot in which neither machine wedged in 200 s |
testLogs/stack.st384b.txt |
the boot stopped at SP = $12; the stack page decoded in section 4 |
testLogs/stack.st384c.txt, stack.st384d.txt |
the trace-only boots; the three timed descents |
testLogs/stack.st384c.B.deep.txt, stack.st384d.B.deep.txt |
the raw staircases, with SP and cycle counter per line |
testLogs/stack.st192a.txt |
the 19200 control: heavier traffic, no nesting, no wedge |
testLogs/cost.c192a.txt, testLogs/cost.c192a.[AB].[01].trace.txt |
the pass-cost measurement of section 2 |
shots/wgwg384a*.png, shots/stst384*.png, shots/stst192a*.png |
both machines at the end of each run |