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 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/.hpair each, plusdisi.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 undertests/host/— and the not yet called building blocksiir1,goertzel_f,goertzel_i,detect), andsrc/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 paircmd_parser.c/.h), the GUI's grab (gui_link), fault handling (diag),sim.h, andexample_main.c, the core build'smain(); - 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) andsrc/boards/(board_cfg.hand oneconst board_cfg_tper board,ev74h48a.c/ev17p63a.c, exactly one linked per build); - lab:
src/lab/(chaintest,bench,dactest,meter,tri_eval, the lab's console commandscli_lab.c, andsnap/rate/blkinb2b_link) andsrc/sim/(sim_dma.c, the simulator's stand-in fordma.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 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
CURIOSITYnext to the COM port; copying an Intel HEX file onto it programs the target,STATUS.TXTon the drive reports the result (Windows may show a cached copy). A text file whose content isCMD:RESETresets 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(toolnEdbgTool). - 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, butipecmd -?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>(fromKIT-INFO.TXTon 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).
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
(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.
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.
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.
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.
| 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.
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
peakchip 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 grabthen sends the processed data to the GUI (the frame says which filter:proc=1low-pass,2high-pass,3band-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'sload=, per mille of a half period) show what it takes, andmissedcounts the halves it was too slow for.src/core/sigproc.hhas the rules.chain allandtestjudge raw samples - switch the processing off for them.
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 |
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 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.
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.
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 |
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.
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).
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".
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.


