A small, complete MPLAB X project showing how to configure the device so the ADC runs at its maximum rate and the DMA moves the samples into RAM — and how to measure whether it really keeps up. A command console on the board's USB-UART channel controls it from a terminal or a script.
It is cut to the EV74H48A (dsPIC33 Curiosity Platform Development Board) with the dsPIC33AK512MPS512 General Purpose DIM plugged in — the same hardware Microchip's own 40 MSPS example runs on. Order the two, open the project, press Program, and the LED tells you whether the chain works, before you connect any signal. The source builds unchanged for the other dsPIC33AK512MPS5xx parts; only the project's device selection and the pin table differ.
Bare metal, no MCC. Every register write in the source cites the datasheet table or page it comes from, so nothing has to be taken on trust.
Before writing anything we searched Microchip's own example organisation
microchip-pic-avr-examples
for something that already did this. These are the ones we evaluated:
| Repository | What it does | What we took from it |
|---|---|---|
| dspic33ak-curiosity-adc-40msps | 40 MSPS ADC on dsPIC33AK128MC106 and dsPIC33AK512MPS512 — MCC-generated, runs on a Curiosity board. Reads conversions in software; no DMA. | Studied in detail. Our clock setup and our ADC trigger scheme follow it, because that code has been on silicon and ours has not. |
| dspic33a-dac-dma-sinewave | DAC fed by DMA to emit a 100 Hz sine without CPU intervention. | Checked for DMA setup patterns: it sets the DMA address window and clears the status flags by writing 0 — both of which we had wrong before the 2026-09-22 review. |
| dspic33a-curiosity-dma-spi-eeprom-demo | SPI transfers driven by DMA. | DMA, but not from an ADC and not at rate. |
| dspic33a-code-examples | Collection of smaller dsPIC33A examples. | Scanned for an ADC-plus-DMA combination; there is none. |
| dspic33ak512mps506-dppim-demo | PWM and ADC on the dsPIC33AK512MPS506. | PWM-triggered conversion, not continuous sampling into memory. |
The gap this project fills: ADC and DMA together, at full rate, with counters. The official 40 MSPS example proves the ADC reaches 40 MSPS. It does not answer how much of that actually arrives in RAM, because it does not use the DMA — and on this device all eight DMA channels share a single data bus (DS70005591D §13.4.4, p825), with no throughput figure given anywhere.
If you only need the ADC and can read conversions in software, use the official example instead of this one. It has run on hardware.
To be precise about how much of this code rests on something that has run on silicon:
| Part of this code | Origin | Has run on hardware? |
|---|---|---|
| Clock setup: PLL1/PLL2 divider values, CLKGEN1/CLKGEN6 settings, switching sequence | bit-identical to the MCC-generated clock.c of dspic33ak-curiosity-adc-40msps |
yes, in that example |
ADC trigger scheme: Integration mode, software trigger starts a burst, the conversions inside it run back-to-back (MODE = 2, TRG1SRC = 1, TRG2SRC = 2, AD3SWTRG) |
the same as Microchip's 40 MSPS example, plus Example 16-6 (p1331) for the burst | yes — and by elimination. Every documented way to pace the conversions inside a burst was tried on the board and none worked (docs/HARDWARE-LOG.md runs 4 to 7): the repeat timer and SAMC are ignored, the SCCP1 trigger produces no conversion at all. Back-to-back is what is left, and the rate comes from the ADC clock |
DMA basics: DMALOW/DMAHIGH window, status flags cleared by writing 0, control register layout |
MCC dma.c of dspic33a-dac-dma-sinewave and datasheet Examples 13-1 to 13-4 (p832 ff.) |
yes — but memory-to-DAC in Repeated One-Shot mode, the opposite direction and a far lower rate |
ADC burst → DMA in Repeated One-Shot mode (Repeated Continuous until 25.09.2026, see dma.c) → one buffer with HALF/DONE interrupts → burst restarted from the DONE ISR |
our own construction, assembled from datasheet §13.4.8 (Example 13-4, p835), §13.6.1.2 (HALF interrupt, p848) and §16.4.5 (p1322) | no. There is no Microchip example for this combination. This is the part docs/TROUBLESHOOTING.md §1.1 flags as the remaining risk |
| Self-test on the internal 15/16·VDD reference (ADxAN6) | the input and its sample time come from datasheet Example 16-3 (p1328), which uses it for gain calibration; the pass/fail logic is ours | the input yes, the check no |
| Board pins, UART2 on the MCP2221A channel, PPS codes, baud generator setting | the MCC-generated pins.c and uart2.c of the same 40 MSPS example (RPINR13bits.U2RXR = 0x32, RPOR28bits.RP114R = 0x15, U2BRG = 0x364) and the DIM info sheet |
yes, in that example |
Command parser (cmd_parser.c/.h) |
zabooh/cmd_parser, copied unchanged; it has run on a SAM E54 and a PIC32CM there | yes, on other targets — not yet on this one |
Console commands, transport, receive interrupt (cli.c) |
our own | no |
Measurement counters, ISR, main.c, LED reporting, bounded waits, file structure |
our own | no |
Nothing was copied verbatim. The examples served as the reference for register values and patterns; every line here was written for this project and cites the datasheet page it rests on.
The result, in one document: docs/RESULTS.md (25.09.2026). The
chain SCCP1 → ADC → DMA → ping-pong → CPU streams without loss up to 8 MSPS with the CPU
processing every half, proven on the board; the report explains how it was found, what
the limits are and what is still open. The history below is kept as it was written.
What has run on hardware. Since 23.09.2026 this code runs on the EV74H48A, and
thirteen runs are recorded with their logs in docs/HARDWARE-LOG.md. As of 24.09.2026
the following is proven on silicon, not argued from the datasheet:
- Clock tree, console, ADC core, DMA channel and the self-test on the internal reference.
- The measurement chain itself. With a known triangle from the on-chip DAC routed to the ADC, one captured buffer contains the triangle: a clean rise, one turning point, a clean fall, the largest step between two neighbouring samples 113 counts out of a swing of 1440, no jump and no gap (run 13). The ADC converts a real changing signal and the DMA places every result in the ping-pong buffer, complete and in the order it was converted.
- The sample rate follows the setting. One clean burst, timed with Timer1, delivered 3990 kSPS against 4081 nominal — 2.2 % off (run 13).
What is not settled. How far up that stays true. At the undivided clock the DMA
loses samples to overruns - dma_overrun reaches about 4 % of the sample count, and
that is a lower bound, because the counter moves once per handler entry that finds the
flag set and not once per lost sample - and because every overrun raises the DMA
interrupt — 1.6 million per second, one every 625 ns — the CPU stops coming back to the
main loop at all. The rate at which the chain stays lossless is exactly what the sweep
is for, and the table from a board is still outstanding.
One number to distrust. Every rate figure measured under load in runs 4 to 11 —
the sweep's old measured column — is wrong by about a factor of ten. It was taken by a
CPU drowning in the overrun interrupt. The sweep now measures each rate on a single
clean burst as well and prints both, because the difference between the two columns is
the artefact itself.
Two things about this device that cost us four days, both in docs/HARDWARE-LOG.md
with register evidence, and both worth knowing before you trust a datasheet page here:
- Nothing paces the conversions inside a burst. The ADC's repeat timer (
TRG2SRC = 3,RPTCNT) and the SCCP1 trigger were configured correctly, read back correctly and ignored;SAMCdoes not change the rate either. The conversions run back-to-back and the only thing that changes the rate is the ADC clock. - The CLKGEN6 divider does not change the ADC clock. Every ratio was written, read back
and confirmed by
DIVSWENandCLKRDY— with the generator switched off around the write and with it left running as Example 12-2 prescribes — and the rate did not move. The ADC also kept converting with CLKGEN6 switched off entirely. The rate is set with PLL1's output dividers instead, which works.
Parts of this example were AI-assisted. All register names, bitfields and value ranges were taken from datasheet DS70005591D, the errata DS80001162E and the ATDF files of the dsPIC33AK-MP_DFP device pack, and each one is cited at the point of use — so every setting can be checked against the primary source.
Revision history
- 2026-09-24, nine more runs on the board, and the chain proved (runs 5 to 13, all
in
docs/HARDWARE-LOG.md). The firmware was rebuilt around what the board actually does:- Back-to-back only. The repeat timer, the SCCP1 trigger as second and as first
trigger,
sccp.c/.h, the pacing selection and thepacing/periodcommands are gone: four mechanisms, four times ignored by the hardware. - The rate comes from PLL1's output dividers, 1600 MHz / (POSTDIV1 · POSTDIV2),
40 down to 4.08 MSPS with 8 MSPS exactly on the ladder. The CLKGEN6 divider is kept
as the
clkcommand with a warning; do not build on it. - Nothing runs by itself. The firmware boots, sets the slowest rate and waits.
testlists the parts of a run,test allruns them. The reason: through seven runs the console never received a byte, and only an idle board could show that this was the receive interrupt starving behind the DMA interrupt rather than a wiring fault. - An emergency brake in the DMA handler. Past 500 000 overruns in one measurement it masks its own interrupt and takes the channel down, so a rate that floods the CPU ends in a log line instead of a silent board.
- A DAC test that proves the data. The on-chip DAC2 is routed to the ADC inside the chip over the UREF line, so no pin and no wire are involved; one buffer is captured with the stream stopped from the interrupt, and the judgement is made afterwards on the stored samples.
- Back-to-back only. The repeat timer, the SCCP1 trigger as second and as first
trigger,
- 2026-09-23, first runs on the board (four of them). Found and fixed: the console's
tail garbled at the clock switch (flush first); a lost DMA
DONEbecause the interrupt flag was cleared at the end of the handler and the status flags by read-modify-write; the sample buffer is a dedicated volatile object with guard words and the DMA window is exactly that buffer;RCONis reported at boot; the rate is measured with Timer1. The source was also split into modules (board.h,clock,adc,dma,capture,led,diag,cli) with a simulator build for the buffer logic. - 2026-09-22, after the first report from a board — the project's tool is the PKOB4
(
pkob4hybrid). A first attempt had run against a PC-side tool instead of the board and looked like a dead board; that was the whole cause. A second change made in the same breath — settingOSCCTRL.PLLxENand waiting forPLLxRDYbefore the first divider switch — was reverted after review: it rests on Example 16-3, a snippet from the ADC chapter whose own arithmetic is wrong, and it waits for a lock on the POR dividers. The clock code follows the MCC sequence again, which has run on silicon. - 2026-09-22, third revision — moved to the EV74H48A with the dsPIC33AK512MPS512
DIM (the board of Microchip's own 40 MSPS example): ADC3 on the mikroBUS A analog pin,
LED0 on RC8, pin table below. A command console (
cli.c, on the parser from zabooh/cmd_parser) on the MCP2221A USB-UART channel. The console runs in the UART receive interrupt below the DMA interrupt. - 2026-09-22, second revision — tailored so that the first run needs nothing but the board: a self-test on the ADC's internal 15/16·VDD reference runs before the external input is used; LED0 reports heartbeat, error and a stop code; every hardware wait loop is bounded and reports where it gave up instead of hanging.
- 2026-09-22 — full review against the datasheet, the errata and Microchip's MCC
examples. Four mistakes found and fixed, all of which would have stopped the first
run dead: (1) the ADC was set to single-conversion mode with a re-trigger source,
which the datasheet says is ignored in that mode — it would never have converted;
(2)
DMALOW/DMAHIGHwere left at their reset value 0, so the first DMA write would have faulted and disabled the channel; (3) the DMA status flags were "cleared" by writing 1 — they clear on 0 — so every counter would have stuck; (4) the two sample buffers were swapped by rewriting the DMA destination inside the ISR while the transfer was already running, which splits every block. Details in the sections below and indocs/TROUBLESHOOTING.md. - 2026-09-21 — first version.
You need: an EV74H48A (dsPIC33 Curiosity Platform Development Board) with the
dsPIC33AK512MPS512 GP DIM — or a dsPIC33AK512MPS506 Curiosity Nano (EV17P63A), then
pick the MPLAB X configuration EV17P63A_Curiosity_Nano_MPS506 — a USB cable, MPLAB X with the XC-DSC compiler and the
dsPIC33AK-MP device pack (MPLAB X offers to download the pack when you open the
project). A signal source is optional — the self-test does not need one. A terminal
program (Tera Term, PuTTY, MPLAB Data Visualizer's terminal) is optional too — the LED
and the debugger tell you the same things.
- Plug the board in. The PKOB4 debugger enumerates for programming, and the MCP2221A's COM port appears for the console (user guide DS70005562D 2.1.1).
- Open
adc_dma_40msps.X, press Build, then Program (or Debug). - The project's tool is the board's PKOB4 (
pkob4hybrid). Check it once in the Dashboard or under Project Properties → Conn. — it has to be a real debugger, or the code never reaches the board and the silent COM port looks exactly like a broken one. - Watch LED0 (green, the row of eight): slow blink = everything works. The self-test on the internal reference has passed, the ADC, the DMA and the interrupt are running at 40 MSPS. What the other patterns mean is under "First run on hardware".
- Open the MCP2221A's COM port at 115200 8N1, press Enter, type
status. The reply is the counter table;helplists the rest. See "The console" below.
Verified on 2026-09-22 with:
| Tool | Version |
|---|---|
| MPLAB X IDE | v6.35 (project format version="65", which v6.25 also reads) |
| XC-DSC compiler | v3.31.00; the source also builds with v3.21 when the pack supplies the device |
| Device pack | dsPIC33AK-MP_DFP 1.4.260 and 1.3.185 — the source builds against both, -Wall -Wextra clean |
| Target | dsPIC33AK512MPS512 (project); the source also builds for the MPS506 |
| Board | EV74H48A + dsPIC33AK512MPS512 GP DIM, user guide DS70005562D, DIM info sheet DS70005563A |
If MPLAB X complains about the toolchain version when you open the project: the
.X has a version recorded in it, and yours will differ. Go to Project Properties →
XC-DSC and pick the version you have. Nothing in the source depends on it — we have
built this with v3.21 and v3.31, and the configuration bits are written so that the
pack version does not matter either (see "One trap worth knowing about" below).
Everything this example touches on the EV74H48A with the dsPIC33AK512MPS512 DIM, from the DIM info sheet DS70005563A (Table 1, DIM pin → device pin → board function) and the board user guide DS70005562D:
| What | Device pin | DIM pin | Where on the board | Note |
|---|---|---|---|---|
| Analog input AD3AN5 (default) | RA0 | P77 | mikroBUS A, pin AN | 0 … 3.3 V against GND. Same input as Microchip's 40 MSPS example. ADC_INSTANCE 3, ADC_PINSEL 5 |
| Potentiometer AD5AN0 | RA7 | P66 | the 10 kΩ pot | for a knob-driven demo: ADC_INSTANCE 5, ADC_PINSEL 0, and samc ≥ 9 — the pot is a high-impedance source |
| Internal reference ADxAN6 | — | — | inside the ADC | 15/16·VDD, used by the self-test on every core |
| LED0 | RC8 | P28 | leftmost of the eight green LEDs | driven high to light. LED1…7 are RC9…RC15 |
| S1, S2, S3 | RF3, RF0, RB2 | P45, P43, P41 | push buttons | active low, pull-up on the board; not used by this example |
| Console UART2 | TX RH1 (RP114), RX RD1 (RP50) | P98, P96 | MCP2221A USB-UART channel — its own COM port | 115200 8N1, the channel Microchip's example streams to Data Visualizer on |
| Second UART | TX RH0 (RP113), RX RD10 (RP59) | P102, P100 | PKOB4 USB-UART channel, another COM port | not used by this console |
| Debugger | — | — | PKOB4 via the USB connector J24 | programming and debugging; the console's COM port is on the same USB cable, via the MCP2221A |
| GND | — | — | mikroBUS GND pins, test points | signal ground for the generator |
AD1AN0 of this device sits on RA2, which the board routes to a capacitive touch pad (P38) — that is why the example uses ADC3 here and not ADC1.
The sources sit in the repository root — main.c, one .c/.h pair per module (clock,
adc, dma, capture, led, diag, cli/console), board.h and the parser pair
cmd_parser.c/.h — and the MPLAB X project references them there; nothing is duplicated.
main.c is the place to read first: it is the start-up order and the main loop, and
nothing else.
This needs real hardware. The clock generators, the PLLs, the ADC and the DMA are
the four things this example is about, and all four only exist on silicon. The number
that matters — dma_overrun staying at 0 at full rate — cannot be produced anywhere
else. The project is therefore set up for the board: the tool is the PKOB4
(pkob4hybrid). A second configuration, sim, runs the same code in the MPLAB X
simulator with a stand-in for the DMA — useful for the software above the DMA, useless
for the four things above; see "In the MPLAB X simulator" below.
One part is worth exercising on its own: process_buffer(). Write test values into
buf, call it directly, and you can check your arithmetic and its cycle count on a
host compiler without a board.
The tools/ folder builds the same file from the command line without the IDE. You
can ignore it — we use it to check that the code compiles against different
compiler and pack versions.
The same code runs on the Curiosity Nano, which carries the 64-pin
dsPIC33AK512MPS506 (user guide DS70005634). Everything that differs is in
board.h under BOARD_EV17P63A, and the MPLAB X configuration EV17P63A_Curiosity_Nano_MPS506 selects it
(device MPS506, the on-board debugger nEdbgTool, BOARD=2); on the command line it is
tools\build.bat nano or make -C tools nano. The two devices share the ADC, the DMA,
the clock tree and the RAM map; the only configuration word that differs is
FDEVOPT_ALTI2C3, which the 64-pin part does not have.
| Function | Pin | Where | Notes |
|---|---|---|---|
| Analog input AD1AN0 | RA2 (RP3, QFN64 pin 12) | edge connector, labelled "RA2 / AD1AN0" | ADC_INSTANCE 1, ADC_PINSEL 0; shares the pin with OA1OUT/CMP1A, both off after reset |
| Internal reference ADxAN6 | — | inside the ADC | 15/16·VDD, the self-test input on every core |
| LED0 | RD0 (RP49) | the yellow LED | active low — LED_ACTIVE_LOW 1 |
| SW0 | RC3 (RP36) | push button | no external pull-up; not used by this example |
| Console UART2 | TX RC10 (RP43), RX RC11 (RP44) | the debugger's CDC channel, one COM port | 115200 8N1; DS70005634 6.2: RC10 is the target's TX line (debugger CDC RX), RC11 the target's RX line (debugger CDC TX) |
| Debugger | — | the on-board nEDBG via the USB connector | programming, debugging and the console share the one cable |
| GND | — | edge connector | signal ground for the generator |
The generator goes to RA2 and GND on the edge connector. Nothing on the Nano has run
yet at the time of writing (docs/HARDWARE-LOG.md); the EV74H48A is where the
measurements come from, and the results carry over because the silicon is the same.
The chain on the Nano. Everything the chain uses exists on the 64-pin part as well
(ADC core 5, SCCP1, the clock monitor, DAC2, _AD5CH0Interrupt), so chain all,
stream on and the GUI work unchanged; checked against the MPS506 device header and
by building, not yet on a Nano. The DAC test triangle comes out on RA8 = DACOUT2 =
AD5AN3, edge connector right row, position 10 - the plain stream on <ksps> samples
it there with no wire. A real signal goes to RA2 = AD1AN0 (right row, position 8),
which is stream on <ksps> 1 0 and the GUI's default custom input on this board.
The GUI follows the board by itself: after connecting it reads the board name from
the firmware's version reply ([build] board: EV17P63A, ...), switches the board
profile - pinout, edge-connector diagram - and sets the input to the DAC loopback:
DAC2 -> DACOUT2 = RA8 = AD5AN3 -> ADC core 5, AN3, the same on both boards and with no
wire, so the first LIVE shows the test triangle with its PASS/FAIL. The board's own
measurement input (core 1, PINSEL 0 = RA2 on the Nano; core 3, PINSEL 5 = mikroBUS A AN
on the EV74H48A) is what "custom input" then starts from. While the test input is
chosen, every tile shows the loopback (core 5, AN3 · RA8, source DAC2) and its core,
channel and source selectors are locked. The
console port is the Nano debugger's CDC channel. Without a board:
tools�dc_gui.bat --fake --fake-board EV17P63A lets the stand-in report the Nano.
The firmware runs no test by itself. It boots, brings the console up, sets the slowest sample rate and waits. Everything else is typed. That is deliberate: through seven board runs the console never received a byte, and it could not be told whether the bytes never arrived or whether the receive interrupt was starving behind the DMA interrupt. With nothing converting after the boot, that question answers itself — and it turned out to be the starvation.
Open the board's USB-UART channel at 115200 8N1 and reset. About a dozen lines appear,
ending in [boot] READY. Then type a character: it echoes. Then type help.
If nothing echoes, stop here — it is the terminal, the COM port or the wiring, not the firmware. Nothing is converting at this point, so nothing can starve the receiver.
test lists the parts and what each one proves
test all self, clock, clkoff, sweep, dac, in that order, with a verdict
test self ADC -> DMA -> RAM on the internal 15/16*VDD reference
test clock switch every CLKGEN6 ratio and read it back (no measurement)
test clkoff switch CLKGEN6 off: does the ADC still convert?
test rate delivered rate at the rate set now, from one clean burst
test sweep the rate ladder, slowest first, with the counters
test dac the DAC triangle through the chain: is everything there, in order?
test all stops only if test self fails — without a working chain every number after
it is meaningless. Everything else runs to the end and reports.
Two commands set the rate by hand: pll <p1> <p2> (the one that works) and clk <ratio>
(the CLKGEN6 divider, which does not change the rate on this silicon and is kept for the
record). regs prints the register dump.
| LED0 | Meaning |
|---|---|
| slow blink, 1 Hz | running, no error counter has moved. This is the goal. |
| fast blink, 5 Hz | running, but an error counter is non-zero — dma_overrun, late_service, proc_missed, dma_addr_err or dma_bus_err. |
| n short blinks, pause, repeat | stopped at a checkpoint; n is the code below. fail_code holds the same number. |
| dark, or steadily on | nothing runs at all: not programmed, no power, or stopped in a debugger |
The stop codes:
| Code | Stopped because | Look at |
|---|---|---|
| 1 | PLL1 (ADC clock) did not configure or lock | PLL1DIV, OSCCTRL |
| 2 | PLL2 (system clock) did not configure or lock | PLL2DIV, OSCCTRL |
| 3 | CLKGEN1 did not switch to PLL2 | CLK1CON |
| 4 | CLKGEN6 did not switch to PLL1 | CLK6CON |
| 5 | the ADC core never reported ready | AD3CON, CLK6CON.CLKRDY |
| 6 | no DMA blocks arrived, or the stream stopped later | AD3CH0CNT.CNTSTAT, DMA0CNT, DMA0SEL, IEC2 |
| 7 | self-test value out of range | selftest_mean — expected ≈ 3840, window 3648 … 4032 |
| 8 | the DMA channel switched itself off | dma_addr_err, DMALOW, DMAHIGH |
| 9 | a CPU trap or an interrupt with no handler | the [TRAP] block on the console — it names the vector, the boot stage and the INTCON* cause bits. docs/TROUBLESHOOTING.md §2.0b |
| 10 | the fail-safe clock monitor moved the CPU to the backup FRC | the [CLKF] lines: OSCCTRL, PLL2CON, CLK1CON |
| 11 | something wrote past the end of the sample buffer | the [guard] lines: which of the 16 guard words behind buf changed and what it holds. A 12-bit value there means the DMA ran past the buffer |
What the self-test proves, and what it cannot. It runs the identical clock, ADC, DMA and interrupt chain on the ADC's internal 15/16·VDD reference (AD3AN6, Table 16-2) and checks that the mean of a buffer half is 3840 ± 5 %. That is a real end-to-end check of the wiring — but it samples a constant, so a result register that never changed would give exactly the same mean. Only the DAC test can tell those apart, which is why it exists.
The counters, readable with status or a debugger:
| Variable | Should be |
|---|---|
blocks_done |
increasing while a test runs a stream |
selftest_mean |
≈ 3840 |
last_sample |
changing once a signal is connected; noise around some level on an open pin |
dma_overrun |
0 at a usable rate. Above that it is a lower bound on the samples lost: OVERRUN is one bit and the counter moves once per handler entry that finds it set, so several losses between two entries count as one. blocks_done against burst_starts is the exact relation - one burst is a whole buffer, so blocks must be twice bursts |
dma_addr_err |
0 — non-zero means the DMA address window is wrong |
late_service, proc_missed |
0 |
fail_code |
0 |
docs/TROUBLESHOOTING.md is the guide, and it is written symptom first: it starts with
the places where we doubt our own code, then walks the symptoms in the order you will
meet them — it does not compile, it lands in a break session, the LED blinks a code,
dma_overrun counts up, the values look wrong, the console is silent. docs/HARDWARE-LOG.md
is the other half: every run on this board, dated, with what the log said and what was
changed because of it. If something here surprises you, it has probably surprised us
first and is written down there.
The firmware carries a test of the chain the example is about - SCCP1 as the
sample clock, the ADC converting once per trigger, the DMA moving every result
into the ping-pong buffer, the CPU processing each half - with the on-chip DAC2
as the signal, on RA8 (DACOUT2 = AD5AN3, ADC core 5). It checks every link on
its own at low rates first, then every rate from 100 kSPS to 40 MSPS, and ends
with an attempt at the real thing. What each stage checks and why:
docs/CHAIN-TEST-PLAN.md.
For the person at the board:
git pull
MPLAB X: configuration EV74H48A_Curiosity_Platform_MPS512, build, program
terminal on the MCP2221A COM port, 115200 8N1, logging to a file
wait for "[boot] READY", then type: chain all
wait for "@END" (under a minute), send the log file back
If it stops without @END, reset the board and send the log including the new
boot banner: the next boot prints the stage the run was in
([boot] WARNING the last 'chain' run ended without @END, in stage S...), and
chain from <stage> continues from there.
The chain as the example itself: stream on <ksps> starts SCCP1 -> ADC ->
DMA -> ping-pong at about that rate (the nearest 160 MHz / N, 1..40000 kSPS),
with the DAC triangle on RA8 as the signal, and returns; from then on the main
loop processes every half, exactly as an application would, and the console
stays free. stream shows its state - rate, seconds, halves, overrun, late,
missed, processing time, free CPU cycles per sample, min/max/mean of the last
half - and stream off stops it and restores the boot configuration. Printing
the report takes the main loop's CPU for a few milliseconds, so at high rates a
report can itself cost a few halves; they show as missed in the next one.
stream grab is the GUI's own command: with a stream already on, it halts
the trigger (trigger first, as stream off does), sends the half that stood
still as one binary frame - GRAB n=... from=... ksps=... ov=... late=... missed=... halves=... xfer=... slp=... dachz=..., then the 2·n sample bytes
and a CRC line, framed exactly like blk (docs/PLAN-BINARY-TRANSFER.md) -
and restarts the same trigger. The counters in the header are per cycle
(since the previous grab), not the running total. See "The GUI's chain
tile" below for what uses it.
Other forms: chain <n> runs one stage (0..9), chain run <ksps> [seconds]
runs the chain at a chosen rate (the nearest 160 MHz / N) for as long as asked,
with one status line per second, printed after the stream so that printing does
not disturb it.
The log is one line per result, @S<stage>.<n> key=value ... -> PASS|FAIL|SKIP|INFO,
about 170 lines when everything passes; a window that fails the grid check is
added as a @DUMP of its samples. python tools/eval_chain.py <log> re-judges
every line from its fields, re-evaluates every dumped window and lists what the
run says about the open questions (--png DIR plots the dumped windows).
python tools/eval_chain.py --selftest checks the evaluator itself.
Stages: S0 preconditions (Timer1, every clock measured by the chip's clock monitor, core 5, RA8) - S1 SCCP1 alone - S2 SCCP1 -> ADC at 1..100 kHz with both ends counted and the DAC stepped by the CPU - S3 ADC -> DMA -> buffer at 100 kHz - S4 triggers against transfers at every rate - S5 the DAC triangle in the data, turning points to a fraction of a sample - S6 one second of stream with the CPU processing, per rate - S7 start, stop, restart, rate change - S8 the old open questions (CLKGEN6 divider and CLKGEN6 off measured at the clock itself, back-to-back repeats) - S9 the attempt, 15 s at the best rate and at 8 MSPS, and the registers the chain ran with.
The part that differs from many other devices: the fast peripherals do not hang off the system clock. There are two dedicated PLLs and fourteen clock generators, so 320 MHz at the ADC alongside a 200 MHz CPU is no contradiction. This example uses both PLLs — PLL1 at 320 MHz for the ADC, PLL2 at 200 MHz for the system — so neither clock needs a fractional divider.
The switching order matters and is easy to get wrong. Page 778 requires PLLSWEN
(apply input and feedback dividers), then FOUTSWEN (apply output dividers), then
NOSC, then OSWEN. Setting only the last two does not produce an error — it silently
leaves the old dividers in place and the part runs at the wrong speed.
Which generator feeds what is not stated in one place:
- CLKGEN1 is the system clock — §12.4.9, page 795: "Clock Generator 1 is the clock source for the system clock (sys_clk) and peripheral clock."
- CLKGEN6 is the ADC clock — Table 16-1, page 1223, column "Clock Source", together with "Max Input Clock 32 MHz to 320 MHz".
TAD derives from the ADC input clock: TAD = 4 / F_IN (parameter AD50, Table 40-39, page 2034). At 320 MHz that is 12.5 ns, which is also the minimum — more than 320 MHz is not specified (Table 40-24, page 2016). Hence the 40 MSPS (AD51, throughput including 1.5 TAD conversion time).
A useful cross-check: the datasheet measures its own current consumption at exactly this operating point — "Input frequency 320 MHz, ADC clock 80 MHz, TAD 12.5 ns" (DC120/DC121, page 2008). So this is the intended setting, not brinkmanship.
CLKxDIV also has a 9-bit fractional divider FRACDIV next to the integer INTDIV,
so non-integer ratios are possible — but this example does not need one: both clock
generators take their PLL output straight through, CLK1DIV = CLK6DIV = 0.
MODE = 10 is Integration mode: a software trigger (TRG1SRC = 1, AD3SWTRG) starts a
burst, and every following conversion inside it is started by TRG2SRC. CNT is the
number of conversions in the burst and is tied to the DMA buffer, so one burst is one
full buffer and the DMA's DONE interrupt is also the moment to start the next one.
IRQSEL = 0 raises the channel event after every conversion, and that event is what
triggers the DMA.
TRG2SRC = 2, back-to-back, and nothing else. The datasheet offers a repeat timer
(TRG2SRC = 3 with the period in RPTCNT) and external triggers such as SCCP1
(TRG2SRC = 34). On this silicon none of them work:
| Tried | Result on the board |
|---|---|
Repeat timer, RPTCNT 2 … 63 |
register holds the value, rate does not change (runs 5, 6, 7) |
SCCP1 as second trigger, code 34, AUXOUT = 01 |
no conversion at all (run 7) |
One conversion per SCCP1 trigger, Single Conversion mode, TRG1SRC = 34 |
no conversion at all (run 7) |
Sample time SAMC 0 … 31 |
rate does not change (run 4) |
All four were removed from this example after run 7, together with sccp.c/.h. What
remains is the one mechanism the hardware honours, and the rate is set by the ADC clock —
see Which sample rates you can get.
The per-conversion result is ADxCH0RES[11:0]. ADxCH0DATA is the burst accumulator
(page 1270) and is not what the DMA reads.
| Field | Value | Why |
|---|---|---|
DMALOW / DMAHIGH |
0x4000 / 0x13FFF | the data RAM window — mandatory. Both reset to 0; every transaction is checked against them (page 829, step 5) and an access above DMAHIGH sets ADRERR and clears CHEN (pages 810, 826). Taken from the device header (__DATA_BASE, __DATA_LENGTH). |
DMA0SEL |
0x3B | trigger source "ADC3 Done CH0" (ATDF value group DMA_SEL__CHSEL; 0x2F … 0x48 for ADC1 … 5, follows ADC_INSTANCE) |
DMA0SRC |
&AD3CH0RES |
the per-conversion result register |
SIZE |
1 | 16-bit transfers (page 812) |
SAMODE |
0 | source address stays put |
DAMODE |
1 | destination increments |
TRMODE |
1 | repeated one-shot: one transfer per trigger (page 832). Was 3 (repeated continuous, a whole block per trigger) until 25.09.2026 |
RELOADD, RELOADC |
1 | back to the buffer start after each block, in hardware (page 812) |
HALFEN, DONEEN |
1 | one interrupt when the first half is full, one when the second is (page 848) |
On 2 bytes per sample: the DMA handles 8, 16 and 32-bit transactions, selected
through SIZE[1:0]. A 12-bit result therefore costs 2 bytes, not 4. AD3CH0RES is
32 bits wide with RES[11:0] in the low half and RESF[11:0] in bits 31:20 (register
summary, page 1229), so the 16-bit read of the low half is the sample.
On the two halves: the first version of this code used two separate buffers and
rewrote DMA0DST from the ISR to swap them. That cannot work at this rate — with
RELOADD the DMA restarts at the old address the moment a block completes, samples
keep arriving every 25 ns, and by the time the ISR rewrites the pointer a dozen of
them have landed in the buffer the CPU is reading, while the rest of the block goes
to the new address minus those samples. The hardware has the right tool for this:
the HALF flag. One buffer of 2048, an interrupt at the halfway point and one at the
end, and no address is ever touched by software while the channel runs.
(The device also has a hardware ping-pong mode across a channel pair, PPEN/PCHEN,
§13.4.11 page 841 — more than this example needs.)
On the status flags: DMAxSTAT bits are R/C/HS — set by hardware, cleared by
writing 0 (legend page 815; Example 13-4 page 835 does DMA0STATbits.DONE=0).
Writing 1, as the first version did, leaves them set.
At 40 MSPS one half is 25.6 µs of signal and the interrupt arrives at about 39 kHz.
The ISR only clears flags, notes which half is complete, counts errors and — at DONE
— restarts the ADC burst. Deliberately short, because at this rate a long ISR becomes
the cause of the next overrun.
UART2 on the board's MCP2221A USB-UART channel, 115200 8N1, no flow control. The parser
is zabooh/cmd_parser, copied unchanged except for
one line (the command table is 32 entries instead of 16 — CMD_PARSER_MAX_COMMANDS,
cmd_parser.h; 26 commands plus the built-in help are registered, nano-board). It
runs in the UART receive interrupt, below the DMA interrupt — which is why a rate that
overruns makes the console unresponsive, and why the firmware boots idle.
| Command | Does |
|---|---|
help |
the command list |
version |
build id, git revision, board, configuration |
status |
run state, counters, the clock, the receive diagnostics |
regs |
clock, ADC, DMA, DAC, UREF and UART registers |
test [part] [halves] |
run a part of the measurement, or all — see below |
pll <p1> <p2> |
the sample rate: PLL1 output dividers, 1600 MHz / (p1·p2), p1 ≥ p2, both 1…7 |
clk <100…1000> |
the CLKGEN6 divide ratio ×100. Arrives in the register and does not change the rate on this silicon; kept for the record |
start / stop |
the burst stream |
input <0…15> / samc <0…31> |
analog input and sample time |
core <1…5> [pinsel] |
switch the ADC core |
buf [n] |
samples per buffer half, 16…1024 |
dac <1|2> <on|off> [low] [high] [slpdat] |
triangle on DACOUT1 = RA1 or DACOUT2 = RA8, both sharing CLKGEN7 (the last unit to stop switches it off). slpdat is the step per DAC clock, so larger is faster (default 8; the DAC test itself starts DAC2 at 64, since 8 leaves the triangle almost standing still inside one captured buffer) |
dactest [halves] |
the DAC test on its own, against whichever DAC is active (dac_active() picks DAC2 first if both run) |
stats / dump [count] [offset] |
the completed half: min/max/mean, or the raw values |
blk [n] |
a contiguous block of up to 2048 samples as binary, with a CRC — docs/PLAN-BINARY-TRANSFER.md. The back-to-back capture command; kept for a terminal, no longer used by tools/adc_gui.py (25.09.2026 on, the GUI only drives the triggered chain, stream grab) |
chain all|<n>|from <n>|run <ksps> [s] |
the chain test (chaintest.c) — see "The chain test" below |
stream on <ksps>|off|grab |
the chain as a standing stream: start it, stop it, or halt/transfer/restart one window for the GUI — see "The chain test" below |
clear |
zero the error counters |
led on|off|auto |
LED0 |
reset |
software reset |
test … |
Proves |
|---|---|
self |
the chain is wired up: clock, ADC, DMA, interrupt, buffer. It samples a constant, so it cannot tell a working converter from a result register that never changes |
clock |
that a CLKGEN6 divider write arrives and is confirmed. It passes on this silicon and the rate still does not follow — passing here proves the register holds the value, nothing more |
clkoff |
whether CLKGEN6 feeds the ADC at all: the generator is switched off and the ADC is asked to convert anyway. On this board it keeps converting |
rate |
the delivered rate at the current setting, from one clean burst timed with Timer1 |
sweep |
the rate ladder from the slowest setting up, with overrun, late and missed per point, and the rate measured both clean and under load |
dac |
the one that proves the data: a known triangle through the chain, captured as one contiguous buffer, judged for a changing signal with even steps and no jump |
[boot] adc_dma_40msps Sep 24 2026 13:22:59 git b57e310 (master)
[boot] RCON: 0x00000080
[boot] reset cause: EXTR
[boot] pll1 postdiv1: 7
[boot] pll1 postdiv2: 7
[boot] adc clock Hz: 32653061
[boot] sample rate ksps (back-to-back): 4081
[boot] READY - nothing is converting, the console has the CPU
> dac on 64
dac slpdat: 64
dac period ns: 54880
> test dac
[dactest] DAC2 routed to the internal UREF line, INSEL: 7
[dactest] measured on this core's AN7, ADC core: 3
[dactest] samples in the window: 2048
[dactest] window ns: 513280
[dactest] sample rate ksps in this burst: 3990
[dactest] min: 2411 max: 3851 peak-to-peak: 1440
[dactest] largest step between two samples: 113
[dactest] slope reversals: 1
[dactest] every 64th sample across the window:
2418 2541 2603 2714 2816 2960 3044 3128 3260 3386 3496 3581 3706 3789 3851
3688 3581 3512 3397 3281 3195 3063 2998 2861 2755 2666 2539 2416 ...
That last block is the point of the whole project: a triangle, sampled by the ADC, carried by the DMA into RAM, complete and in order.
UREFCON.INSEL puts one of DAC1…DAC8 on the device's internal UREF line, and ADnAN7
is the UREF input of every ADC core (Table 16-2). So the test routes DAC2 to UREF and
samples AN7 on whatever core is already in use: no pin, no wire, no core switch.
The pin route exists too and is documented in board.h: DACOUT1 is AD5AN1 = RA1 (shared
with PGC2), DACOUT2 is AD5AN3 = RA8 — one physical pin carrying both the DAC output and
the ADC input, so the loop closes without a wire. On the EV74H48A that pin also goes to
capacitive touch pad 2, which loads it; the internal route has neither problem.
Two things about the DAC are modelled wrongly in dac.c and are not used for any
verdict: the computed triangle period is about eight times off what the capture shows,
and DACLOW is not reproduced — the triangle's upper end matches DACDAT exactly, its
lower end does not match DACLOW at all. The test measures the period from the data
instead and prints the computed one beside it for the record.
All figures are per ADC core; the dsPIC33AK512MPS512 has five (Table 16-1, page 1223).
The basis. The ADC clock may run from 32 to 320 MHz (Table 16-1). The conversions run back-to-back and eight ADC clocks make one conversion, so 320 MHz is 40 MSPS and 32 MHz is 4 MSPS. Nothing inside the burst paces them — see the ADC section — so the ADC clock is the only knob.
The knob that works: PLL1's output dividers. PLL1 feeds nothing but the ADC path (the
CPU runs off PLL2), its VCO is 1600 MHz, and the output is 1600 MHz / (POSTDIV1 ·
POSTDIV2) with both fields 1…7 and POSTDIV1 ≥ POSTDIV2 (page 778). pll <p1> <p2> sets
it; the sweep walks this ladder from the slowest rate up:
| POSTDIV1/2 | ADC clock | Rate |
|---|---|---|
| 7/7 | 32.65 MHz | 4.08 MSPS — the slowest that clears the ADC minimum |
| 7/6 | 38.10 MHz | 4.76 MSPS |
| 6/6 | 44.44 MHz | 5.56 MSPS |
| 7/5 | 45.71 MHz | 5.71 MSPS |
| 6/5 | 53.33 MHz | 6.67 MSPS |
| 7/4 | 57.14 MHz | 7.14 MSPS |
| 5/5 | 64.00 MHz | 8.00 MSPS |
| 6/4 | 66.67 MHz | 8.33 MSPS |
| 5/4 | 80.00 MHz | 10.00 MSPS |
| 6/3 | 88.89 MHz | 11.11 MSPS |
| 5/3 | 106.67 MHz | 13.33 MSPS |
| 6/2 | 133.33 MHz | 16.67 MSPS |
| 5/2 | 160.00 MHz | 20.00 MSPS |
| 5/1 | 320.00 MHz | 40.00 MSPS — the boot setting of clock_init() |
Measured on the board (run 13): one clean burst at 7/7 delivered 3990 kSPS against
4081 nominal, 2.2 % off. The switch takes the DMA channel down, the ADC core off, the
PLL's output dividers are changed (page 778: they must not move while the PLL is
operating), the core comes back with ADRDY and the DMA is set up from scratch — the
boot order, run backwards and forwards again.
The knob that does not work: the CLKGEN6 divider (clk, clock_adc_set_div()). The
divided clock should be F_IN / (2 · (INTDIV + FRACDIV/512)) per Example 12-2, and
every ratio is written, read back and confirmed by DIVSWEN and CLKRDY — with the
generator switched off around the write and with it left running as the example
prescribes. The conversion rate does not move at any of them, and the ADC keeps
converting with CLKGEN6 switched off entirely (test clkoff). It is kept in the firmware
so the behaviour can be reproduced, not because it is useful. Two datasheet details
apply if you try it anyway: "FRACDIV will not work if INTDIV is configured to 0" (12.4.2
4b), so no ratio between 1 and 2 exists at all; and the divider is changed with the
generator running.
Below 4 MSPS the burst mechanism is the wrong tool. For rates like the 40 kHz of a
piezo grain sensor, use Single Conversion mode with a PWM trigger as TRG1SRC: the DMA
transfer per conversion works exactly the same way and the burst restart disappears.
That goes down to a few Hz. Note that the SCCP1 trigger did not work on this board — a
PWM trigger has not been tried.
Three caveats:
- The figures are per core. Five cores together are nominally 200 MSPS — whether the single DMA bus carries that is precisely the open question this example is meant to measure.
- The burst restart costs one interrupt latency per 2048 samples, so the measured rate sits a few tenths of a percent below the nominal value.
- AD51 carries the footnote "design guidance only, not characterised or tested in manufacturing". 40 MSPS is the design target, not a tested limit.
One sentence in the datasheet matters more for this project than any ADC register, §13.4.4 on page 825:
"While DMA channels can function independently to service different peripherals at the same time, they are still limited by the presence of a single DMA data bus and a single data channel to data space."
So the eight DMA channels are not eight parallel data paths. They share one bus,
and when they contend an arbitration decides (fixed or round robin via
DMACON.PRIORITY). For scale: three channels at 40 MSPS and 2 bytes are 240 MB/s
across that one bus, five channels 400 MB/s. How much it actually carries is not in
the datasheet — there is no figure in transfers per second, and the DMA does not
appear in the peripheral clock table either.
That is why this code has counters instead of claims.
Read these in the debugger after a run:
| Variable | Meaning | Expectation |
|---|---|---|
blocks_done |
completed buffer halves | × 1024 / elapsed time = actual sample rate (includes the burst restart gap) |
dma_overrun |
DMA0STAT.OVERRUN seen: triggered again before the previous transfer finished (page 816) |
must stay 0, otherwise samples were lost on the DMA bus |
late_service |
ISR found HALF and DONE pending together, i.e. it was more than one half late |
must stay 0 |
proc_missed |
main() did not reach a completed half before the next one finished |
must stay 0 |
dma_bus_err |
bus write error (BWERR; BRERR needs RETEN, see errata) |
0 |
dma_addr_err |
access outside DMALOW…DMAHIGH |
0 |
last_sample |
last value of the completed half | changing = data really moving |
ready_half |
which half completed last | alternating 0 / 1 |
selftest_mean |
mean of a half sampled on the internal 15/16·VDD reference | ≈ 3840 |
fail_code |
stop code, same number the LED blinks | 0 |
A sequence we would suggest:
- One channel, 40 MSPS. Does
dma_overrunstay at 0 over a longer run? That proves the basic configuration. - Two channels. Set up ADC2 the same way, second DMA channel
(
DMA1SEL = 0x35, "ADC2 Done CH0"). This is where the shared bus first shows its limit. - Three channels. If overruns appear here, the limit is found — with a number the datasheet cannot give you.
- Only then add the processing.
process_buffer()is deliberately written as a placeholder loop over every sample, so the cost of touching each value is visible.
The ADC can average internally, before a DMA transfer even happens — Oversampling mode
(MODE = 3) with ACCNUM[1:0] in AD3CH0CON1 (page 1266):
ACCNUM |
Samples | Result width |
|---|---|---|
| 0b00 | 4 | 13 bit |
| 0b01 | 16 | 14 bit |
| 0b10 | 64 | 15 bit |
| 0b11 | 256 | 16 bit |
At 16× averaging, 240 MB/s becomes 15 MB/s and the result still fits in 2 bytes with
14 bits. Sampling stays at 40 MSPS; only the output rate drops. In that mode the
averaged result lives in AD3CH0DATA and the channel event with IRQSEL = 1 fires
once per average — so DMA0SRC, IRQSEL and the burst restart change accordingly.
Whether that is an option depends on your measurement method — for a pure amplitude measurement it helps, for a phase-based method averaging can destroy the information. That is worth a phone call.
MODE[1:0] also offers Window mode (gated by an external signal), and the last three
setting channels have a second accumulator for second-order filters.
- No PWM and no clock output. For an external DAC above 120 MHz a controller pin is not the way: the output pins are specified with 2.3 ns rise and 1.7 ns fall time (Table 40-26, page 2017, at 25 pF). At 120 MHz one period is 8.3 ns — four of those nanoseconds would be edges. The datasheet states no maximum PWM output frequency, and none for the Reference Clock Output either.
- No multiple channels. On purpose: one should be provably working first.
- No interrupt prioritisation, no error recovery, no calibration. The ADC can
recalibrate itself periodically (
ACALENandCALRATEinAD3CON) — worth a look for a longer measurement. - No statement on analog input bandwidth. The datasheet does not give one, and the input parameters it does give (hold capacitance, pin capacitance, interconnect resistance) all carry the note "design guidance only, not tested". The ENOB of 10.5 bits was characterised with a 1 kHz sine and says nothing about high input frequencies.
- No gapless stream. The burst restart costs one interrupt latency per 2048 samples. If that matters, the datasheet's hardware ping-pong across two DMA channels and a second ADC channel alternating bursts would close the gap — a different example.
Silicon errata DS80001162E (rev. E, July 2026) was checked. Nothing in it concerns
the ADC, the PLLs or the clock generators. Two items touch this code: BRERR is only
reported when RETEN is set (item 2 — this code leaves it clear, so dma_bus_err
counts write errors only), and on rev A1 silicon the compiler option
-merrata=base_offset is recommended (item 22). docs/TROUBLESHOOTING.md has the
details.
The symbolic values of two configuration bits were renamed between pack versions (all other names are identical in both packs — ATDF value-groups compared):
| Bit | dsPIC33AK-MP_DFP 1.3.185 | dsPIC33AK-MP_DFP 1.4.260 |
|---|---|---|
FICD_NOBTSWP |
ON / OFF |
BTSWP_ENABLED / BTSWP_DISABLED |
FWDT_RCLKSEL |
BFRC256 |
BFRC244 |
Both spellings name the same bits (FICD mask 0x8000, value 0x0 = BOOTSWP enabled; FWDT
mask 0xC0, value 0x3 = WDT clock BFRC divided down to 32.78 kHz). If MCC
generates config_bits.c against one pack and the build uses another, the compiler
rejects a value that is perfectly valid elsewhere:
error: unknown value for configuration setting 'FICD_NOBTSWP': 'BTSWP_ENABLED'
This is very likely why an MCC-generated config_bits.c suddenly stops compiling:
MCC generated it against a different pack than the build is using. The value is not
wrong — the spelling belongs to another pack version.
This project sidesteps the problem by writing those two bits numerically:
#pragma config FICD_NOBTSWP = 0x0 /* BOOTSWP enabled */
#pragma config FWDT_RCLKSEL = 0x3 /* BFRC/244 (1.4.260) = BFRC/256 (1.3.185) */Every pack version accepts that. Verified: config_bits.c built against packs 1.3.185
and 1.4.260 produces a bit-identical configuration area in the HEX file (the only
bytes that differ between the two builds are the __TIME__ string). So the numeric
form is not a workaround with side effects — it is the same setting, spelled in a way
that does not depend on the pack.
config_bits.c sets every configuration word of the device explicitly, so the
programmed state does not depend on what the programmer does with words a project
leaves out. Apart from the two above and FWDT_WDTEN = SW (watchdog off unless the
software turns it on), every value is the erased default; the file says for each
register why that is right for this example. The one to never change by accident is
FPED_ICSPPED: ON would stop the PKOB4 from programming the part.
The same trick works for any configuration bit whose symbolic names have moved: look
the value up in the ATDF (<value-group name="FICD_NOBTSWP">) and write the number.
The simulator has no PLL, no ADC conversion and no DMA transfer, and in this project it
does not dispatch interrupts either (any pending interrupt aborts the run with
E0110-SIM: Failed to execute instruction; MPLAB X v6.35, checked 22.09.2026). So the
DMA interrupt — the only producer of data on silicon — never runs there. The simulator
build therefore swaps one module: sim_dma.c replaces dma.c. It implements the same
dma.h interface without a DMA and delivers a buffer half whenever the firmware would
otherwise wait for one (SIM_DMA_TICK() in sim.h, empty on silicon): a 1 MHz sine
on the measurement input, a flat 3840 on the self-test input, through the same
dma0_event() path the interrupt uses. Everything above that — counters, the burst
restart, capture_service(), the self-test, the console — runs unchanged. The clock and
ADC waits are no-ops (WAIT_WHILE in diag.h), and three spots in cli.c skip what
the simulator's UART model cannot do (no receiver, TXMTIF never set).
Two ways to build it:
- MPLAB X: configuration
sim(tool Simulator,dma.cexcluded,sim_dma.cincluded,__MPLAB_DEBUGGER_SIMULATOR=1defined). - Command line:
tools\build.bat simormake -C tools sim→build\adc_dma_40msps_sim.elf.
tools\sim_trap.py drives that ELF in MDB, the command-line debugger, routes UART2 to a
file and prints the result. The expected log ends with
[selftest] mean on internal 15/16 VDD (expect ~3840): 3840
[simtest] halves compared against the sine vector: 100
[simtest] full ping-pong buffers: 50
[simtest] halves with a mismatch: 0
[simtest] PASS: ping-pong order and data intact
The ping-pong check is what the simulator build is for: sim_dma.c writes a known
vector, so every half that process_buffer() receives through capture_completed_half()
is compared with it, and the phase must continue from the previous half (1024 mod 40 =
24 samples). A half served twice, two halves swapped, a wrong pointer or a corrupted
region all show up as a mismatch with the index. The negative test,
sim_trap.py --fault 65536, drops one sine sample mid-run and must end in
[simtest] FAIL with a mismatch at index 0. DMA status masks written the same way
(--fault 8 = OVERRUN) exercise the error counters.
What it does not cover: timing and throughput, the interrupt path, the DMA address window and real DMA errors, the clock tree — everything the hardware run is for. A simulator run takes about 2.5 minutes for the 100 halves.
| Path | Contents |
|---|---|
main.c |
start-up sequence and the main loop — the order of the inits, and why |
board.h |
everything board-specific: the compile-time choices (ADC_INSTANCE, ADC_PINSEL, ADC_SAMC, the boot sample rate ADC_PLL_POSTDIV1/2), where the DAC reaches the ADC, the LED pin, the console pins |
config_bits.c |
every configuration word of the device, with the reason for each value — and why two of them are written as numbers |
clock.c, clock.h |
FRC → PLL1 320 MHz (ADC) and PLL2 200 MHz (CPU), the switching order, the clock-fail interrupt, the ADC clock's rate control (clock_adc_set_pll(), PLL1's output dividers — the knob that works) and clock_adc_set_div() (the CLKGEN6 divider, which does not change the rate on this silicon and is kept only so the behaviour can be reproduced) |
adc.c, adc.h |
the ADC core: channel 0 in Integration mode, burst trigger, input/sample-time register |
dma.c, dma.h |
DMA channel 0: address window, Repeated One-Shot mode, HALF/DONE interrupt, status flags — knows no ADC and no buffer |
sim_dma.c, sim.h |
simulator build only: stand-in for dma.c that produces buffer halves (1 MHz sine) and the ping-pong check; see "In the MPLAB X simulator" |
capture.c, capture.h |
the measurement: wires ADC and DMA together, handles the DMA events with every error counter and the burst restart, start/stop/input, self-test, per-half processing, the triggered stream the chain test uses, and capture_chain_halt/_resume — pausing and restarting that stream's trigger in place, for the GUI's grab cycle — what the console may read and control |
crc16.c, crc16.h |
CRC-16 over a sample block, for the blk binary transfer command |
sccp.c, sccp.h |
SCCP1 as the chain test's trigger source (clock, mode, event), its timer and compare interrupts as event counters |
chaintest.c, chaintest.h |
the chain test itself — chain all, its triangle evaluator, the @ log line format, and chain_stream_grab_begin/_end — one halt/grab/restart cycle for stream grab; see "The chain test" below |
led.c, led.h |
LED0 |
diag.c, diag.h |
stop codes (fail()), trap and unhandled-interrupt handler, boot-stage record, reset cause, register dump |
timebase.c, timebase.h |
Timer1 as a 12.5 MHz stopwatch — the independent clock the delivered sample rate is measured against (test rate, test sweep, and the window length of the DAC test). It does not pace the ADC |
dac.c, dac.h |
DAC1 and DAC2 in Triangle Wave mode on their pins DACOUT1 = RA1 and DACOUT2 = RA8 (CLKGEN7 as their shared clock), one table for both units. The DAC test itself instead routes DAC2 through UREFCON onto the chip's internal UREF line, where every ADC core can sample it as ANn7 — no pin, no wire, no core switch |
dactest.c, dactest.h |
captures one contiguous buffer with the stream stopped from the DMA interrupt, then judges whichever DAC is running (dac_active() picks DAC2 first if both run): a changing signal, even steps, no jump, and the triangle period measured from the data. [dactest] lines, PASS/FAIL |
cli.c, console.h |
the console: UART2 on the MCP2221A channel, the receive interrupt, the commands |
cmd_parser.c, cmd_parser.h |
the command parser, unchanged from zabooh/cmd_parser (Apache 2.0) |
adc_dma_40msps.X/ |
MPLAB X project — build, program and debug from here |
docs/TROUBLESHOOTING.md |
what to do when it does not work — including where we doubt our own code |
docs/HARDWARE-LOG.md |
every run on the board, dated: what the log said, what was changed because of it |
CLAUDE.md |
working notes for continuing with Claude Code: module rules, build and verification steps, open questions |
docs/*.png, docs/*.mmd |
the block diagrams above, with their Mermaid sources |
tools/sim_trap.py |
drives the simulator build in MDB and reports the verdict ("In the MPLAB X simulator" above) |
tools/ |
command-line build without the IDE; ignore this unless you want it |
A browser front end for the console, for looking at what the triggered chain
delivers. The back-to-back burst mode (pll/snap/dump/blk, the old sweep tile)
is retired from this tool as of 25.09.2026 - the owner's decision: the triggered chain
(stream on/off/grab, chaintest.c) is the only data path the GUI shows now. The
firmware keeps the back-to-back commands for a terminal; the GUI simply no longer sends
them.
The acquisition card sets the rate in kSPS and the input: either the built-in test
signal (core 5, PINSEL 3 = RA8, the firmware's own DAC2 triangle, stream on <ksps>) or
a custom core/PINSEL/SAMC (stream on <ksps> <core> <pinsel> <samc>, the DAC left
alone - switch a DAC on in its own card if it should drive that pin). live starts the
chain if it is not already running at that rate/input (a change while live is picked up
before the next grab) and then repeats stream grab at the interval shown - halt the
trigger just long enough to send the half that stood still as one binary frame, restart
it, plot the time signal and its spectrum (Hann window, dBFS, frequency axis from the
frame's own actual rate), evaluate, repeat - until stop, which sends stream off
and restores the boot configuration. single does the same for one grab: if the chain
is not already streaming it starts it, grabs once, and stops it again; it is disabled
while live is running. With the test signal, an additional card shows the triangle
verdict (see below); with any other input that card is hidden, since there is nothing to
judge against a model.
Board limits from the last hardware run are shown as guidance under the rate field, not enforced: clean to about 8 MSPS with the CPU processing, occasional DMA overruns from about 10, lost triggers from about 16, the triggered chain measured up to about 18-20 MSPS.
Set-up once (a private Python environment in tools\.venv, nothing touches the system
Python), then start:
tools\gui_setup.bat creates .venv, installs nicegui/pyserial/numpy, runs the self-test
tools\adc_gui.bat --fake no board: a built-in stand-in with a synthetic signal, for trying the GUI
tools\adc_gui.bat --port COM7 the board's console port
Linux/macOS: tools/gui_setup.sh, then tools/.venv/bin/python tools/adc_gui.py ....
The page opens at http://127.0.0.1:8080. Every command goes through the console and
waits for the parser's ACK/NAK byte, so the tool never talks over the board.
Each grab's window is plotted and, when it carries the test signal (slp > 0 in the
GRAB frame), evaluated with tri_eval - the very function chaintest.c's own chain
test uses, ported once in tools/eval_chain.py and reused here rather than
re-implemented, so a PASS/FAIL verdict here means the same thing it means in a
chain all log: a lost or repeated sample shifts the triangle's turning points off the
grid by a whole sample (slip), and that is what fails it. Chips show the turning-point
count, the up/down slope lengths, the slope against the model computed from the frame's
own slp/dachz fields (chaintest.c's triangle_for()), and the actual rate plus the
per-cycle overrun/late/missed counters, highlighted red when non-zero. Every
control has a tooltip naming the console command it sends.
Try it without a board first (tools\adc_gui.bat --fake): the built-in stand-in answers
stream on/off and builds a GRAB frame - eval_chain.synth()'s triangle for the test
signal, a configured sine with harmonics and noise for any other input (so SNR/THD/
harmonics show something meaningful) - so the whole cycle, including a PASS and a
deliberately induced FAIL, can be seen without hardware. python tools\adc_gui.py --selftest exercises the same path headlessly: a grab refused before stream on, a
clean triangle grab that passes the grid check with the actual rate used as the FFT's
fs, the second grab landing in the other buffer half (from > 0), a lost-sample grab
that correctly fails, a custom-input grab with slp=0 and a real FFT peak, a corrupted
frame caught by its CRC, a truncated frame caught by the frame parser, and a target that
never answers at all timing out rather than hanging. tools/gui_ui_test.py drives the
page itself with a headless browser against --fake (see the header comment there for
how to run it).
Only needed to build without MPLAB X:
cd tools
python setup.py # find compiler and pack, configure build.bat and Makefile
build.bat # build
setup.py scans for installed XC-DSC compilers and dsPIC33AK-MP packs, in both
places they can live (%USERPROFILE%\.mchp_packs and MPLAB X's own packs folder),
lets you choose, and writes the paths into build.bat and Makefile. With
--verify it also runs a real test build. --list just shows what it found.
Two things that cost us time there, in case you build without the IDE:
-mdfpmust point at thexc16subdirectory of the pack, not the pack root — otherwise the compiler reports "does not seem to support the selected device" although the pack does contain it.c30_device.infolives one level down.- The linker script must be given explicitly with
-T(support/dsPIC33A/gld/p33AK512MPS512.gldinside the pack). Without it the compiler links against a 30F architecture and stops with "incompatible with 30Fxxxx output".


