Skip to content

Latest commit

 

History

278 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dsPIC33AK512MPS512 on the dsPIC33 Curiosity Platform — ADC at 40 MSPS into RAM via DMA

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.

Existing examples we looked at first

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.

What comes from where

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.

Read this first

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:

  1. 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; SAMC does not change the rate either. The conversions run back-to-back and the only thing that changes the rate is the ADC clock.
  2. The CLKGEN6 divider does not change the ADC clock. Every ratio was written, read back and confirmed by DIVSWEN and CLKRDY — 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 the pacing/period commands 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 clk command with a warning; do not build on it.
    • Nothing runs by itself. The firmware boots, sets the slowest rate and waits. test lists the parts of a run, test all runs 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.
  • 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 DONE because 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; RCON is 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 — setting OSCCTRL.PLLxEN and waiting for PLLxRDY before 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/DMAHIGH were 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 in docs/TROUBLESHOOTING.md.
  • 2026-09-21 — first version.

Getting started

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.

  1. 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).
  2. Open adc_dma_40msps.X, press Build, then Program (or Debug).
  3. 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.
  4. 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".
  5. Open the MCP2221A's COM port at 115200 8N1, press Enter, type status. The reply is the counter table; help lists 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).

The board

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 under src/, split since 02.10.2026 into a core — what a customer takes into their own project, docs/CORE.md — and the lab this project adds on top:

  • core: src/drivers/ (clock, adc, dma, sccp, dac, timebase, led, uart, one .c/.h pair each, plus disi.h), src/port/ (log.h, panic.h, wait.h, regs.h — headers only: the one path by which a driver may log, wait or stop), src/lib/ (crc16, fmt, stats, frame, wavegen — hardware-free, with host tests under tests/host/ — and the not yet called building blocks iir1, goertzel_f, goertzel_i, detect), and src/core/: the stream (capture, acquisition, pingpong), the processing callback (sigproc), the routing core (routing), the signal generator (siggen), the console (cli.c, console.h, the parser pair cmd_parser.c/.h), the GUI's grab (gui_link), fault handling (diag), sim.h, and example_main.c, the core build's main();
  • glue, which a customer writes for their own board: src/app/ (main.c, config_bits.c, board.h, port_impl.c — this project's implementation of the port layer) and src/boards/ (board_cfg.h and one const board_cfg_t per board, ev74h48a.c/ev17p63a.c, exactly one linked per build);
  • lab: src/lab/ (chaintest, bench, dactest, meter, tri_eval, the lab's console commands cli_lab.c, and snap/rate/blk in b2b_link) and src/sim/ (sim_dma.c, the simulator's stand-in for dma.c).

tools\build.bat core builds the core alone (no file of src/lab/ or src/sim/, neither on the include path); every other build links the lab on top, and the MPLAB X project references the same files — nothing is duplicated. Every folder is on the include path, so the files include each other as "name.h". src/app/main.c is the place to read first: it is the start-up order and the main loop, and nothing else.

A routing core (src/core/routing.c/.h) tracks which resources — DMA channel, SCCP, DAC output, UREF, RAM — a route such as ROUTE_STREAM needs, refuses a conflict or an unreachable pin before any driver call, and is what stream on goes through; route list prints the active route and the resource table. CLAUDE.md's module table is the complete, authoritative file list and who may call what; docs/ARCHITECTURE.md draws it as a block diagram (layers, and the data path while streaming); docs/FIRMWARE-STRUCTURE.md and docs/REFACTORING-PROPOSAL.md are the analysis and the plan that led here.

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 other board: dsPIC33AK512MPS506 Curiosity Nano (EV17P63A)

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 (the CDC takes 1200 to 500 000 baud, DS70005634A 3.1.3.3); DS70005634A Table 4-4: 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\adc_gui.bat --fake --fake-board EV17P63A lets the stand-in report the Nano.

Programming the Nano. None of the three ways below has been tried on a Nano yet; they are what the user guide and the installed tools say. The image is build\adc_dma_40msps_nano.hex (tools\build.bat nano).

  • Copy the HEX file onto the board's USB drive (DS70005634A 3.1.4): the debugger enumerates as a drive named CURIOSITY next to the COM port; copying an Intel HEX file onto it programs the target, STATUS.TXT on the drive reports the result (Windows may show a cached copy). A text file whose content is CMD:RESET resets the target the same way (debugger firmware 1.25.6 or newer). No MPLAB X, no ipecmd.
  • MPLAB X: the configuration EV17P63A_Curiosity_Nano_MPS506 (tool nEdbgTool).
  • ipecmd, the command line the EV74H48A uses with -TPPKOB4: for the Nano the tool name is most likely -TPNEDBG - ipecmd.jar (MPLAB X v6.35) carries that identifier, but ipecmd -? does not list it, so it is unconfirmed: "C:\Program Files\Microchip\MPLABX\v6.35\mplab_platform\mplab_ipe\ipecmd.exe" -TPNEDBG -P33AK512MPS506 -M -Fbuild\adc_dma_40msps_nano.hex -OL. If it is refused, -TS<serial number> (from KIT-INFO.TXT on the drive) selects the tool by serial number instead.

The console needs DTR. The debugger passes data in either direction only while a terminal holds the COM port open with DTR asserted (DS70005634A 3.1.3.4; pyserial, and so tools/protocol.py and the GUI, assert it on open). The boot banner the firmware prints right after programming is therefore lost unless the port was already open - send reset (or copy CMD:RESET) after opening it to see it. While no terminal is attached, the debugger does not drive its CDC TX line and the board has no pull-up on it, so RC11 = U2RX floats (same section).

First run on hardware

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.

Step 1 — the console answers

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.

Step 2 — run the parts of a test

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.

Step 3 — read the LED

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

If it does not work

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 chain test - one command, one board run

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

(The scripted way to run this, both boards' firmware in one session, with the log archived automatically: see "Running the board test" below.)

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.

Running the board test

tools\board_run.bat is the scripted way to run the chain test above: it programs and runs the old firmware and the current one against a real board in one session, over the same console described above, and writes one archive with everything sent and received. The whole procedure is git pull, tools\gui_setup.bat (once, and again after a pull that changed a requirements file), then tools\board_run.bat COM5 (COM5 is the board's console port; --list shows the candidates, --selftest checks the tool itself without a board). Hardware set-up, what each firmware image is, and what to send back: board_run/README.md.

How it works

1. Clock tree

Clock tree

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.

2. ADC — one core, one channel, bursts of 2048 conversions back-to-back

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.

3. DMA into one buffer with two halves

Two ping-pong pairs for the triggered stream (since 01.10.2026). The 8 KB buffer holds pair A and pair B, each a ping half and a pong half of up to 1024 samples. stream on runs two DMA channels as the dsPIC33A's hardware ping-pong pair (DS70005591D 13.4.11): channel 0 fills the ping half, channel 1 the pong half, and the hardware hands over between them without a lost sample (checked on the board with the DAC triangle up to 16 MSPS). For a stream grab the channel that is waiting is pointed at the other pair, so the pair just completed stands still and goes to the GUI while acquisition and processing carry on in the other pair - nothing stops, the processing never sees a gap in its input. The GUI sees snapshots of the signal, one pair at a time. The back-to-back commands (start, test, blk, ...) still use one channel on pair A as described below.

DMA path

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.

4. What the CPU does, and what to measure

CPU and counters

Your own processing goes into src/core/sigproc.c (sigproc_block()). As an example it holds, selectable since 02.10.2026 (sigproc lp|hp|bp|off, the GUI's signal processing card), one of three 4th-order Butterworth filters at fs/8 - low-pass, high-pass, or a band-pass one octave wide around fs/8 (high- and band-pass centred on mid-scale, 2048) - and, independently, a Goertzel detector for a tone at fs/16 (sigproc gz on|off, sigproc gz thr <lsb>), which measures the input before the filter and reports the tone's amplitude, its share of the signal's power and detected or not (GRAB gz=/gzs=/gzd=, the card's fs/16 chip). Being fractions of fs, all of them follow the sample rate by themselves. Coefficients: tools/sigproc_design.py. Each filter is two biquad sections, float on the FPU, the state carried from block to block (the ping-pong stream has no gaps) and restarted where the firmware reports one (info->gap) or the filter changes. On the board (02.10.2026, the signal generator through DAC2 -> RA8 at 400 kSPS) all three match their design to three decimals at fs/32, fs/16, fs/8 and fs/4, and the Goertzel finds a tone at fs/16 (1341 LSB, 100 % of the power) and nothing at the other three. A filter costs about 63-67 CPU cycles per sample (33 % at 1 MSPS: up to 2 MSPS), the Goertzel about 7 more (it folds the block into 16 sums and needs one complex multiply per sum, not a float recursion per sample: 3.3 % at 1 MSPS, 26 % at 8 MSPS). The GUI's setup list has four Goertzel checks that drive the signal generator for it: a sine at fs/16 (detected), at fs/8 and 3 kHz beside fs/16 (not detected), and fs/16 with the high-pass on (filtered to 5 % in the plot, still detected - the Goertzel looks before the filter). Replace it with your own processing as you need. The firmware calls it from the main loop once per completed half - ping and pong alike - while a filter or the Goertzel is on (off after reset), with the half's samples, its length and which half it is.

Counting impacts (since 02.10.2026, docs/IMPLEMENTATION-PLAN.md section CNT): small balls falling onto a metal plate, each impact a ring at a high frequency (20..200 kHz) that dies away within about 0.5 ms, up to about 1000 a second. sigproc cnt ... runs a damped resonator at the ring frequency on every sample of the input, and a detector that counts each ring once: above the threshold, then not again until the magnitude has fallen below half of it. It counts continuously, also between grabs, and reports the count, the rate per second and the peak magnitude (the GUI's counter chips; GRAB cnt=/cnr=/cpk=). The GUI's setup list has four checks that make impacts with the signal generator - one damped ring per table, so the rate is exactly known.

On the board (1 MSPS, rings decaying with 100 us) it counts exactly at 500, 1000 and 2000/s, at 20, 50 and 150 kHz, and costs 10-15 % of the CPU. Two limits come from the physics, not the code:

  • Frequency selectivity is about 2.5:1. A short ring is broadband: an 80-kHz ring still moves a 50-kHz counter to 39 % of its own peak. A ring at the wrong frequency is ignored only when the threshold sits between the two (the peak chip shows where).
  • Rings closer than their decay merge. The detector re-arms once the magnitude has fallen to 70 % of its peak and counts the next rise, so a ring on the last one's tail is still counted. On the board it counts exactly up to 5000/s at tau 100 us. Regular rings at 7000/s and more merge completely, and the count then stands - the GUI says so - but at tau 25 us it counts them exactly too (10000/s: 64235 of 64240). A smaller tau separates closer rings and selects the frequency less sharply. The result goes back into the same half: stream grab then sends the processed data to the GUI (the frame says which filter: proc=1 low-pass, 2 high-pass, 3 band-pass), with no second buffer. It has one half period to return (1024 samples at 8 MSPS: 128 us, 25 CPU cycles per sample); status, the chain test's load figures and the GUI's "CPU load" chip (the GRAB frame's load=, per mille of a half period) show what it takes, and missed counts the halves it was too slow for. src/core/sigproc.h has the rules. chain all and test judge raw samples - switch the processing off for them.

The console

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; 29 commands, including route, siggen and sigproc, plus the built-in help are registered — 30 of 32 slots, 2 free, 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. Output goes the other way through an 8 KB ring buffer that the UART transmit interrupt empties (since 01.10.2026, src/drivers/uart.c): a command writes its reply and returns, so a long reply no longer holds the CPU - help while streaming used to cost the main loop 550 halves at 8 MSPS, now none. The measuring commands (chain, test, sweep, selftest, dactest, snap) still send polled, so nothing transmits while they measure; so do fail() and the trap handler. Ctrl+C cuts only output that is still being generated, not what is already in the ring.

Command Does
help the command list
version build id, git revision, board, configuration
status run state, counters, the clock, the receive diagnostics, and — since the board-run preparation (BR.6) — the stack high-water mark and its margin to SPLIM, the sample buffer's address/alignment/guard-word check, and the boot stage/trap/chain all stage of the current run
regs clock, ADC, DMA, DAC, UREF and UART registers
siggen the signal generator's status: on/off, DAC, table size, play rate (set and actual), the parameters, the table's min/max, DMA 2 and SCCP2 state, the measured transfers per second. A table computed on the target (lib/wavegen, the tab_wave_gen.py formula) is played by DMA channel 2 into a DAC, paced by SCCP2, with no CPU involvement
siggen set <f0|h2..h7|decay|amp|lo|hi> <value> one generator parameter per line (the console line is 64 characters); decimals without an exponent, lo/hi are DAC codes. Takes effect with the next siggen on
siggen on <dac 1|2> <n 2..8192> <play_hz 100..1000000> [snap] [force] [oc] compute the table and play it on DACOUT1 = RA1 or DACOUT2 = RA8; snap moves f0 to a whole number of periods in the table, force allows lo/hi outside the DAC's 205..3890, oc paces SCCP2 in 32-bit output compare (dead on silicon; the default is the dual 16-bit timer). Refused while a route uses the same DAC (stream on on DAC2)
siggen off | siggen regs stop the generator; dump DMA 2 and SCCP2 registers
sigproc [lp|hp|bp|off] the filter at fs/8 on every completed half, in place (on = lp); sigproc alone = status: filter, Goertzel on/off, its threshold and last result
sigproc gz on|off | sigproc gz thr <lsb> the Goertzel detector for a tone at fs/16 on the input, and its threshold (1..4095, default 100 LSB)
sigproc cnt on|off|reset | sigproc cnt f <hz> | cnt tau <us> | cnt thr <lsb> the impact counter: a resonator at the ring frequency (1000 Hz .. fs/2.5), its time constant (default 100 us) and the threshold; sigproc alone reports count, rate per second, time, missed halves and the peak
route list the active route (source, core, pinsel, DAC, sink) and the resource table — which DMA channel, SCCP, DAC output and UREF are in use, RAM used vs. budget (docs/DESIGN-MULTICHANNEL.md's routing core)
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 - the 8 KB hold two ping-pong pairs since 01.10.2026 (2048, one pair, that morning; 1024 before)
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 (one ping-pong pair) 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 send one ping-pong pair to the GUI while the stream carries on in the other pair — see "The chain test" below
clear zero the error counters
led on|off|auto LED0
reset software reset

The parts of a test, and what each one proves

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

What a run looks like

[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.

The DAC test measures inside the chip

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.

Which sample rates you can get

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 buffer (4096 samples at the default length), 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.

The point of the whole thing

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.

What to measure

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:

  1. One channel, 40 MSPS. Does dma_overrun stay at 0 over a longer run? That proves the basic configuration.
  2. 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.
  3. Three channels. If overruns appear here, the limit is found — with a number the datasheet cannot give you.
  4. 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.

If the bandwidth is not enough

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.

What this code does not do

  • 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 (ACALEN and CALRATE in AD3CON) — 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.

Errata

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.

One trap worth knowing about

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.

In the MPLAB X simulator

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.c excluded, sim_dma.c included, __MPLAB_DEBUGGER_SIMULATOR=1 defined).
  • Command line: tools\build.bat sim or make -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.

Files

Since the N+1 restructuring (27.09.2026) the sources sit under src/, one folder per role — see "The board" above for the folder list and CLAUDE.md's module table for which file may call which. The table below is the reading order, not the full list:

Path Contents
src/app/main.c start-up sequence and the main loop — the order of the inits, and why
src/app/board.h, src/boards/board_cfg.h, src/boards/ev74h48a.c/ev17p63a.c board-specific choices: board.h for the compile-time ones (ADC_INSTANCE, ADC_PINSEL, ADC_SAMC, the LED and console pins), board_cfg.h/the two .c files for the one that turned out safe as run-time data — the boot sample rate as PLL1's output dividers
src/app/config_bits.c every configuration word of the device, with the reason for each value — and why two of them are written as numbers
src/drivers/clock.c/.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)
src/drivers/adc.c/.h the ADC core: channel 0 in Integration mode, burst trigger, input/sample-time register
src/drivers/dma.c/.h DMA channel 0: address window, Repeated One-Shot mode, HALF/DONE interrupt, status flags — knows no ADC and no buffer
src/sim/sim_dma.c, src/core/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"
src/core/pingpong.c/.h the two-half buffer's bookkeeping on its own since P9.1: which half just completed, the guard-word check, the main-loop service counters — no driver include, the buffer is passed in
src/core/capture.c/.h the measurement: wires ADC and DMA together, the DMA event handler and the burst restart, start/stop/input, the buffer itself (with guard words), and capture_chain_halt/_resume — pausing and restarting the chain stream's trigger in place, for the GUI's grab cycle — what the console may read and control
src/lab/meter.c/.h the counters, processing cost and rate measurement, out of capture.c on P9.3
src/core/acquisition.c/.h the rate setters, the variant matrix, and the standing chain stream chain_stream_*() (out of chaintest.c on P9.4)
src/core/routing.c/.h the routing core: which resources (DMA, SCCP, DAC output, UREF, RAM) a route needs, checked and refused before any driver call; route list's data source
src/lib/crc16.c/.h CRC-16 over a sample block, for the blk/stream grab binary transfer
src/lib/fmt.c/.h, src/lib/stats.c/.h, src/lab/tri_eval.c/.h the printf-free formatting helpers, min/max/mean over a completed half, and the chain test's triangle evaluator — hardware-free, each with a host test under tests/host/
src/lib/frame.c/.h the binary frame writer blk/stream grab share: header, chunked payload with the CRC folded in, CRC tail
src/lib/iir1.c/.h, goertzel_f.c/.h, goertzel_i.c/.h, detect.c/.h, wavegen.c/.h a first-order IIR filter, damped Goertzel in float and in Q16 fixed point, a hysteresis pulse detector, and a signal-generator table — included in every build, cross-checked against a Python reference on the host, and not yet called from anywhere: the building blocks for the multi-channel signal chain planned for N+4
src/drivers/sccp.c/.h SCCP1 as the chain test's trigger source (clock, mode, event), its timer and compare interrupts as event counters
src/lab/chaintest.c/.h the chain test itself — chain all, the @ log line format, and chain_stream_grab_begin/_end (in src/core/acquisition.c since P9.4) — one freeze/send/release cycle of a ping-pong pair for stream grab; see "The chain test" below
src/lab/bench.c/.h the back-to-back test suite (test ..., sweep, matrix), out of cli.c
src/drivers/led.c/.h LED0
src/core/diag.c/.h stop codes (fail()), trap and unhandled-interrupt handler, boot-stage record, reset cause, the register-dump visitor
src/port/log.h, panic.h, wait.h, regs.h the port layer: the one path (port_log*(), port_panic(), PORT_WAIT_WHILE, a register visitor) by which a driver under src/drivers/ may log, wait, stop or dump its registers, without including the console — implemented by src/app/port_impl.c
src/drivers/timebase.c/.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
src/drivers/dac.c/.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
src/lab/dactest.c/.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
src/core/gui_link.c/.h the GUI's binary transport: stream grab's body, built on frame.c, out of cli.c
src/lab/b2b_link.c/.h snap/rate/blk, the back-to-back mode's binary transfer, out of gui_link.c on 02.10.2026
src/core/cli.c, console.h the console: on top of src/drivers/uart.c, the receive callback, the core's commands and their reply framing
src/lab/cli_lab.c the lab's commands (start, stop, samc, input, core, clk, pll, selftest, dactest, chain, and through their own files sweep/test and snap/rate/blk), registered after the core's through cli_register_lab()
src/core/example_main.c the core build's main(): the same start-up without the lab, then capture_service() in the loop — a customer's starting point
docs/CORE.md taking the core into your own project: what to copy, the glue to write, where the processing goes, how to build
src/drivers/uart.c/.h UART2 on the MCP2221A channel: pins/PPS, the baud generator, the non-blocking transmit and bounded flush, the receive interrupt — out of cli.c on P5.1
src/core/cmd_parser.c/.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
tools/export_core.py the customer's handover from one commit: build\core-<rev>.zip with the core, the glue templates, core_example.X, docs/CORE.md and the GUI, compiled from the unpacked ZIP as a check
core_example.X/ the core alone as an MPLAB X project (EV74H48A and EV17P63A, example_main.c, -O1) — a customer's starting point, docs/CORE.md; generated from adc_dma_40msps.X by tools/gen_core_project.py
tests/host/ host-side (gcc) unit tests for every module in src/lib/ plus pingpong/routing, run by tools\hosttest.bat
tests/trace/ the register-trace harness: golden logs of every register write a driver makes, reproduced bit-for-bit after each change (tools\trace.bat)
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
docs/FIRMWARE-STRUCTURE.md, docs/REFACTORING-PROPOSAL.md the analysis of the pre-N+1 structure and the plan that led to the layout above; kept as the record of why it looks like this
docs/IMPLEMENTATION-PLAN.md the N+1 restructuring plan and its task-by-task status; where "done" is checked against, for every task
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: --smoke for the short boot/console check ([SMOKE], under a minute), the default for the ~7-minute ping-pong acceptance run ([SIM]); see "In the MPLAB X simulator"
tools/board_run.py, board_run/ the scripted board run — one script drives the console against the pre- and post-restructuring firmware and writes one archive; see "The chain test" above and board_run/README.md
tools/ command-line build without the IDE, the host test/trace runners, the GUI; ignore this unless you want it

The GUI: capture, plot, FFT (tools/adc_gui.py)

Settings. Every value the page can set is in a JSON file. At every start the GUI reads tools/adc_gui_defaults.json - the standard, tracked in git, every key with its default - and then tools/adc_gui_settings.json on top of it, the user's own state (git-ignored; "save" writes it, a key it lacks keeps the standard's value). standard in the settings tile puts every value back to the standard file; "save as" writes another file, and the setup list's last entry, "from a file ...", reads one; --settings <file> starts with one. The list's other entries are ready-made setups (SETUPS in adc_gui.py), applied at once and to the board when connected: the firmware's test triangle; four DAC triangles (DAC2 slow / 35 kHz / 390 kHz small swing, DAC1 on RA1), each read on its own pin through a custom input on core 5; and eight signal-generator shapes on DAC2 (sine, 1 kHz + h3 as in the loop preset, square-, sawtooth- and triangle-like from their harmonic series, pulse train, a damped tone, a 50 kHz tone with h2..h4 that decays to zero within 1 ms) plus a sine on DAC1. A setup only sets what it names - input, rate, DACs, generator, trigger - the rest of the page stays.

Key What it sets
board board profile: EV74H48A or EV17P63A (switched on connect to what the firmware reports)
connection.port COM port preselected at start (empty: none)
view.dac_source signal source shown on the pinout tiles for a custom input: 0 none, 1 DAC1, 2 DAC2
view.tooltips tooltips on (true) or off - the header checkbox
view.vref reference voltage in V: 4096 ADC counts = this voltage, the time chart's right axis and tooltip
view.collapsed titles of the tiles that are folded - written at once on every fold, without "save"
acquisition.mode test (DAC2 loopback: core 5, AN3 = RA8, the firmware's triangle) or custom
acquisition.ksps sample rate in kSPS; the board uses the nearest 160 MHz / N
acquisition.core, .pinsel, .samc the custom input: ADC core 1..5, PINSEL 0..15, sample time 0..31
acquisition.interval_ms pause between two grabs in LIVE
trigger.on, .level, .slope, .hyst the time plot's trigger: on/off, level in ADC counts (2048), rising/falling, hysteresis in LSB (16)
buffer.size one ping-pong pair (buf = half of it), even, 32..the board's maximum (2048 since 01.10.2026)
dac.1, dac.2 on, low, high, slpdat of each DAC's triangle (DAC tiles). A change goes to the board by itself 0.8 s after the last one. DAC2's on can also be "auto" (standard): with the test input, auto is the firmware's own triangle (slope chosen per rate), true the card's triangle on RA8, false DAC2 off - a quiet channel; the card's state is resent after every stream on
siggen the signal generator card: on, dac, n, play_hz, f0, h2..h7, decay, amp, lo, hi, snap, force (also in tools/adc_gui_defaults.json)
fake.source what the stand-in plays in --fake, on either input: dac2 (the test input: what RA8 carries; standard), sine or dac1. Switching the input picks dac2 (test) or sine (custom); it can be changed after
fake.* the sine's parameters: signal_khz, amplitude, noise, harmonic2, harmonic3
version settings format (3)

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 - since 01.10.2026 without stopping anything: the stream moves on to the other of the buffer's two ping-pong pairs, and the pair just completed (ping and pong, 2048 samples at the default) goes out as one binary frame while acquisition and processing carry on - 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.

trigger (in the same card) makes the time plot stand still, like an oscilloscope: with it ticked, each grab is shown from the first point where the signal crosses the level (ADC counts, a dashed line in the plot) on the chosen edge; the hysteresis (LSB) must first be exceeded on the other side, so noise on a slow slope does not fire early. The plot then shows a fixed window of half the grabbed samples, x[k..k+N/2), with the crossing searched only in the first half - always a contiguous piece of the signal, never the end joined to the start. The x axis starts at the interpolated crossing. A chip reads trig @ k, or no trigger when the level is not crossed; the plot then shows the untriggered start (auto mode). Display only: the firmware, the stream, the FFT and the triangle verdict are untouched and keep the whole half; a change takes effect with the next grab (tools/trigger.py).

The signal generator card drives siggen: on/off, DAC 1 or 2, table size n, play rate, f0, the harmonic factors h2..h7, decay, amp, the output range lo/hi, snap and force, with the table's preview. The defaults are tab_wave_gen.py's, with the range 800..3500 rather than the DAC's nominal 205..3890, because on the board the DAC did not follow below about code 780. A change goes out 0.8 s after the last one as one siggen set line per parameter, then siggen on. loop preset sets the loop DAC2 -> RA8 -> ADC core 5 (custom input, core 5 / PINSEL 3): 1 kHz plus h3 = 0.3. In the loop the time plot overlays the fitted table (also inside the trigger's window) and a loop chip reports rms, gain and start entry of the match, and the harmonic factors fitted in the time domain against the set ones (an FFT of one period has no bins for them). The model is tools/wavegen_model.py; --fake plays the table on the generator's pin. The test input (stream on <ksps> with the firmware's triangle) is refused while the generator plays on DAC2; switching the generator on while the test input streams sends stream off first.

decay is the rate of the envelope exp(-decay x t), in 1/s, with t counted from the start of the table: the time constant is 1/decay, the amplitude is down to a fraction r after ln(1/r)/decay - so decay = ln(100)/T makes the pulse fall to 1 % in T (921 for 5 ms). The table repeats every n / play rate, so a pulse needs a table clearly longer than its decay; decay 0 is a steady tone.

A DAC card and the generator share the DAC: while the generator plays on a DAC, the GUI does not send that DAC's card by itself (a dac command would stop the generator - siggen_release_dac()); only switching the card to on replaces the generator with the triangle, and the generator card then says so. loop preset sets DAC2's card to auto. At start the card is filled from the settings file but nothing is sent to the board: apply (or any change in the card) starts the generator.

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 (--check: also the tools' self-tests)
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.

The triangle verdict

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, a target that never answers at all timing out rather than hanging, and the trigger (a sine of known phase, two stand-in grabs at different phases lining up, the hysteresis on a noisy slope, a level outside the signal, the triangle verdict unchanged). 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).

About tools/

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:

  1. -mdfp must point at the xc16 subdirectory 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.info lives one level down.
  2. The linker script must be given explicitly with -T (support/dsPIC33A/gld/p33AK512MPS512.gld inside the pack). Without it the compiler links against a 30F architecture and stops with "incompatible with 30Fxxxx output".

License

Microchip's example license, see LICENSE.txt: you may use this software and its derivatives exclusively with Microchip products. Every source file carries the same notice in its header.

One exception: the command parser src/core/cmd_parser.c/.h, taken from zabooh/cmd_parser, is under the Apache License 2.0; its own header states the terms, and they apply to those two files.

About

Bare-metal example: dsPIC33AK512MPS512 ADC at 40 MSPS into RAM via DMA, with counters to measure what the shared DMA bus actually sustains. MPLAB X project included.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages