Project 2: Digital Oscilloscope (ADC + DMA)
Build a basic digital oscilloscope that samples analog signals with the RP2040 ADC, streams frames to a host, and renders waveforms with trigger control.
Quick Reference
| Attribute | Value |
|---|---|
| Difficulty | Level 2: Intermediate |
| Time Estimate | 1-2 weeks |
| Main Programming Language | C (Pico SDK) |
| Alternative Programming Languages | Python (host visualization), MicroPython |
| Coolness Level | Level 3: Real Instrumentation |
| Business Potential | 3. The “Embedded Tools” Niche |
| Prerequisites | GPIO basics, timers, UART/USB serial |
| Key Topics | ADC sampling, aliasing, DMA, ring buffers, trigger detection |
1. Learning Objectives
By completing this project, you will:
- Configure the RP2040 ADC for fixed-rate sampling with calibration.
- Use DMA to stream ADC FIFO data into memory buffers without CPU gaps.
- Implement trigger detection to align waveform captures.
- Design a binary frame protocol for efficient host visualization.
- Build a basic Python viewer to render time/voltage axes.
2. All Theory Needed (Per-Concept Breakdown)
2.1 ADC Sampling, Nyquist, and Aliasing
Fundamentals
An analog-to-digital converter (ADC) turns a continuous voltage into discrete numeric samples. The sample rate determines how often you measure the signal, and it directly limits the maximum frequency you can capture. The Nyquist theorem states that you must sample at least twice the highest frequency of interest. If you sample too slowly, higher-frequency components fold into lower frequencies, creating aliasing artifacts. For oscilloscopes, aliasing can make a waveform look stable but incorrect, so you need to understand this to choose sample rates and filters.
Deep Dive into the concept
On the RP2040, the ADC is a 12-bit successive approximation converter with a fixed conversion time. You configure the ADC clock divider and use either single-shot reads or continuous sampling into the FIFO. The effective sample rate is a function of ADC clock, conversion cycles, and any FIFO/DMA overhead. When sampling a signal, your discrete samples represent the waveform at instants in time. If the signal changes faster than your sample rate can capture, you will see a lower-frequency alias. This is not noise; it is mathematically correct under undersampling, and it can mislead you if you expect to see the original waveform. To prevent aliasing, you either increase sample rate or apply an anti-alias low-pass filter that removes frequencies above half the sample rate. In this project, you will likely sample between 50 kHz and 500 kHz, which is enough for audio and slow sensor signals, but not enough for MHz digital edges. Your trigger detection must also consider sampling: a single threshold crossing might be missed if the signal transitions between samples, so you should implement trigger hysteresis or oversample to improve stability.
How this fits on projects
This concept drives §3.2 Functional Requirements (sampling rate) and §5.10 Phase 1 capture configuration.
Definitions & key terms
- Sample rate -> number of samples per second
- Nyquist -> max frequency = sample_rate / 2
- Aliasing -> high-frequency content appears as low frequency in samples
- Quantization -> converting voltage to discrete digital value
Mental model diagram (ASCII)
Analog wave: /\/\/\/\
Samples: * * * * *
How it works (step-by-step)
- Configure ADC clock and channel.
- Start continuous sampling into FIFO.
- Read FIFO at a fixed pace via DMA.
- Interpret samples as discrete-time waveform.
Minimal concrete example
adc_init();
adc_gpio_init(26);
adc_select_input(0);
adc_set_clkdiv(0); // max speed
uint16_t sample = adc_read();
Common misconceptions
- “More bits fixes aliasing” -> bit depth affects resolution, not sampling frequency.
- “A stable waveform means correct” -> aliasing can create stable but wrong visuals.
Check-your-understanding questions
- What sample rate is needed to capture a 10 kHz sine without aliasing?
- Why might a 9 kHz tone sampled at 10 kHz look like 1 kHz?
- How does a low-pass filter help your oscilloscope?
Check-your-understanding answers
- At least 20 kHz (preferably higher for margin).
- Aliasing folds the frequency around Nyquist: 10k - 9k = 1k.
- It attenuates frequencies above Nyquist before sampling.
Real-world applications
- Oscilloscopes, audio interfaces, sensor data acquisition.
Where you’ll apply it
- In this project: §3.2, §3.6, §5.10 Phase 1.
- Also used in: P12-digital-theremin-touchless-music.md.
References
- RP2040 Datasheet: ADC chapter
- “Digital Design and Computer Architecture” Ch. 7
Key insights
Sample rate sets the ceiling; no software can recover aliasing you never captured.
Summary
You must choose a sample rate that matches your signal bandwidth and apply filtering to prevent aliasing.
Homework/Exercises to practice the concept
- Simulate a 9 kHz sine sampled at 10 kHz and plot the result.
- Calculate the voltage step size for a 12-bit ADC with 3.3V reference.
Solutions to the homework/exercises
- You will see a 1 kHz waveform due to aliasing.
- Step size = 3.3V / 4096 ≈ 0.806 mV.
2.2 DMA Ring Buffers for Continuous Capture
Fundamentals
DMA (Direct Memory Access) allows peripherals to move data into memory without CPU intervention. For high-rate sampling, DMA prevents jitter and keeps the ADC FIFO from overflowing. A ring buffer is a circular memory region that wraps around, enabling continuous capture while the CPU processes previous blocks. The key is to track read and write indices to avoid overruns.
Deep Dive into the concept
RP2040 DMA can be configured to transfer data from the ADC FIFO to a memory buffer at a pace driven by a DREQ (data request) signal. This means DMA moves one sample every time the ADC produces it. For oscilloscope capture, you usually use a double-buffer or ring buffer. Each buffer chunk is a fixed size, and an interrupt fires when the DMA completes a chunk. The CPU then sends the chunk over USB serial while DMA fills the next chunk. If the CPU is too slow, DMA will wrap and overwrite unread data; you must detect and count overruns. The simplest strategy is to drop old data and log an overrun counter. The best strategy is to stop capture and notify the user. For deterministic demos, you should fix buffer size (e.g., 2048 samples) and sample rate (e.g., 100 kHz) so that frame timing is predictable. DMA also requires alignment and correct transfer sizes: ADC samples are 12-bit but stored in 16-bit FIFO words, so your buffer should be uint16_t. If you misconfigure the transfer size, your samples will appear scrambled.
How this fits on projects
DMA ring buffers are required for §3.2 and §5.10 Phase 2 streaming, and for overrun detection in §6.
Definitions & key terms
- DMA -> hardware data mover
- DREQ -> DMA request signal from a peripheral
- Ring buffer -> circular buffer with wrap-around
- Overrun -> producer overwrites unread data
Mental model diagram (ASCII)
ADC FIFO -> DMA -> [Block0][Block1][Block2][Block3]
^read ^write (wraps)
How it works (step-by-step)
- Configure DMA to read from ADC FIFO into buffer.
- Set transfer count to block size.
- On DMA completion, advance write index and trigger IRQ.
- CPU streams the completed block to host.
Minimal concrete example
channel_config_set_dreq(&cfg, DREQ_ADC);
dma_channel_configure(ch, &cfg, buffer, &adc_hw->fifo, BUFSZ, true);
Common misconceptions
- “DMA means you can ignore timing” -> CPU still must keep up with streaming.
- “Ring buffer prevents data loss” -> it only prevents blocking; you still can overrun.
Check-your-understanding questions
- Why is double buffering better than a single buffer?
- What happens if the host cannot keep up with the data rate?
- Why do we use 16-bit buffer entries for a 12-bit ADC?
Check-your-understanding answers
- It allows capture to continue while a previous block is processed.
- Data is dropped or overwritten; you must detect and report it.
- The FIFO outputs 16-bit words, so alignment and transfer size must match.
Real-world applications
- Logic analyzers, audio recorders, high-rate sensor capture.
Where you’ll apply it
- In this project: §3.2, §3.7, §5.10 Phase 2.
- Also used in: P04-logic-analyzer-debug-digital-signals.md.
References
- RP2040 Datasheet: DMA chapter
- “Making Embedded Systems” Ch. 7-8
Key insights
DMA removes jitter but not bandwidth limits; you must design for throughput.
Summary
Use DMA with ring buffers to sustain continuous ADC capture and detect overruns.
Homework/Exercises to practice the concept
- Compute throughput for 100 kHz sampling at 16-bit samples.
- Determine buffer size for 50 ms of data at 200 kHz.
Solutions to the homework/exercises
- 100k * 2 bytes = 200 kB/s.
- 200k * 0.05s * 2 bytes = 20 kB.
2.3 Trigger Detection and Signal Conditioning
Fundamentals
Triggers align waveforms so the display is stable. A trigger fires when the signal crosses a threshold or matches a pattern. Without a trigger, the waveform appears to roll or jitter. Signal conditioning includes input scaling and filtering to ensure the ADC sees a safe, clean signal within 0-3.3V.
Deep Dive into the concept
Triggering can be as simple as “first rising edge above threshold” or as complex as pattern matching. For this project, you can implement a rising-edge trigger by scanning the captured buffer for a crossing where sample[n-1] < threshold and sample[n] >= threshold. To reduce false triggers from noise, you can add hysteresis or require a minimum slope. Since you sample in discrete time, you will not always capture the exact crossing, so you should align the waveform at the nearest sample and accept small phase jitter. Signal conditioning is critical because the RP2040 ADC expects 0-3.3V. Many signals (audio, sensors) might be biased around 1.65V or exceed the range. Use resistor dividers to scale amplitude and capacitor coupling to remove DC offsets. A simple RC low-pass filter can reduce high-frequency noise and aliasing. Bad conditioning leads to clipping, flattened waveforms, or noisy traces that make trigger detection unreliable.
How this fits on projects
Trigger detection is required in §3.2 and appears in the golden demo §3.7. Signal conditioning affects §3.6 edge cases.
Definitions & key terms
- Trigger -> event that aligns waveform capture
- Threshold -> voltage level that defines a trigger crossing
- Hysteresis -> two thresholds to prevent noise chatter
- Conditioning -> analog filtering and scaling
Mental model diagram (ASCII)
Signal: __/\__ Trigger at /
Threshold: -----
How it works (step-by-step)
- Capture a buffer of samples.
- Scan for threshold crossing.
- Align waveform window around trigger index.
- Stream aligned samples to host.
Minimal concrete example
for (int i=1; i<buf_len; i++) {
if (buf[i-1] < trig && buf[i] >= trig) { trig_idx = i; break; }
}
Common misconceptions
- “Triggers are only for oscilloscopes” -> any sampled system benefits from alignment.
- “No filter needed” -> noise can destroy trigger stability.
Check-your-understanding questions
- Why does hysteresis improve trigger stability?
- What happens if your signal exceeds 3.3V?
- How do you align a waveform when the exact crossing is between samples?
Check-your-understanding answers
- It prevents small noise around the threshold from retriggering.
- The ADC clips, distorting the waveform and potentially damaging input.
- Use the nearest sample and accept a small phase error.
Real-world applications
- Oscilloscopes, glitch detectors, event-driven data capture.
Where you’ll apply it
- In this project: §3.2, §3.7.2, §5.10 Phase 2.
- Also used in: P04-logic-analyzer-debug-digital-signals.md.
References
- “Making Embedded Systems” Ch. 8
Key insights
Triggering turns raw data into stable, interpretable waveforms.
Summary
Combine trigger detection with analog conditioning for clear, stable captures.
Homework/Exercises to practice the concept
- Implement hysteresis by requiring a low threshold then high threshold.
- Design a resistor divider to scale a 5V signal to 3.3V.
Solutions to the homework/exercises
- Use two thresholds: trigger only after falling below low and then crossing high.
- Example: 10k/20k divider yields 3.3V from 5V.
3. Project Specification
3.1 What You Will Build
A digital oscilloscope that captures analog signals at up to 100 kHz (or more), streams frames to a host PC, and renders waveforms with time/voltage axes and trigger alignment.
3.2 Functional Requirements
- ADC sampling: configure continuous sampling at a fixed rate.
- DMA streaming: use double buffering to avoid gaps.
- Triggering: support rising-edge trigger with adjustable threshold.
- Host viewer: display waveform and report sample rate.
- Calibration: convert raw ADC values to volts.
3.3 Non-Functional Requirements
- Performance: no dropped frames at 50 kHz for 60 seconds.
- Reliability: overrun counter and error reporting.
- Usability: clear UI with scales and trigger status.
3.4 Example Usage / Output
[ADC] rate=100000 Hz, buffer=2048 samples
[TRIG] mode=rising level=1.65V
[DMA] buffers=2, dropped=0
[CAPTURE] peak=2.10V min=0.90V
3.5 Data Formats / Schemas / Protocols
- Binary frame: header + N samples
struct FrameHeader { uint32_t magic; // 0x5049434F uint32_t rate_hz; // sample rate uint16_t count; // samples uint16_t flags; // trigger flags }
3.6 Edge Cases
- Host cannot keep up -> overrun counter increments.
- Trigger never found -> send untriggered frame with flag.
- Signal exceeds range -> clip and log warning.
3.7 Real World Outcome
You will see a stable waveform on your PC and a serial log that reports sampling health.
3.7.1 How to Run (Copy/Paste)
mkdir build && cd build
cmake ..
make -j4
picotool load -f scope.uf2
python3 host/oscilloscope.py --port /dev/ttyACM0
3.7.2 Golden Path Demo (Deterministic)
- Inject a 1 kHz, 1 Vpp sine wave centered at 1.65V.
- Set sample rate to 100 kHz and trigger at 1.65V rising.
- Host shows stable sine with 100 samples per cycle.
3.7.3 Failure Demo (Bad Input)
- Scenario: sample at 10 kHz with a 9 kHz sine.
- Expected result: aliasing appears as ~1 kHz waveform and log warns
[WARN] aliasing likely.
3.7.4 If CLI: exact terminal transcript
$ python3 host/oscilloscope.py --port /dev/ttyACM0
Connected to /dev/ttyACM0
Rate: 100000 Hz Trigger: rising @ 1.65V
Frame 42: peak=2.10V min=0.90V
4. Solution Architecture
4.1 High-Level Design
ADC -> FIFO -> DMA -> Ring Buffer -> USB Serial -> Host Viewer
^
Trigger Scan
4.2 Key Components
| Component | Responsibility | Key Decisions | |———–|—————-|—————| | ADC Config | Sample rate + input channel | Fixed rate via clock divider | | DMA Engine | Transfer FIFO to RAM | Double-buffer with IRQ | | Trigger Scan | Find alignment index | Rising edge with hysteresis | | USB Stream | Send frames | Binary framing for speed | | Host UI | Render waveform | Python + matplotlib or pygame |
4.3 Data Structures (No Full Code)
typedef struct {
uint32_t magic;
uint32_t rate_hz;
uint16_t count;
uint16_t flags;
} frame_header_t;
4.4 Algorithm Overview
Key Algorithm: Triggered Frame Extraction
- Capture buffer via DMA.
- Scan buffer for trigger crossing.
- Copy aligned samples into frame.
- Send frame to host.
Complexity Analysis:
- Time: O(N) per buffer scan
- Space: O(N) for buffer
5. Implementation Guide
5.1 Development Environment Setup
# Pico SDK + Python 3.10+
python3 -m venv .venv && source .venv/bin/activate
pip install pyserial matplotlib
5.2 Project Structure
oscilloscope/
├── firmware/
│ ├── main.c
│ ├── adc_dma.c
│ └── trigger.c
├── host/
│ └── oscilloscope.py
└── README.md
5.3 The Core Question You’re Answering
“How do you capture real-world analog signals accurately without losing data?”
5.4 Concepts You Must Understand First
- Nyquist sampling and aliasing
- DMA ring buffers and data flow
- Trigger thresholds and hysteresis
5.5 Questions to Guide Your Design
- What sample rate and buffer size give a stable UI without overruns?
- Will you stream binary frames or ASCII?
- How will you flag missing triggers or dropped frames?
5.6 Thinking Exercise
Calculate the data rate for 200 kHz sampling at 16 bits. Can USB serial handle it?
5.7 The Interview Questions They’ll Ask
- Why use DMA for ADC capture?
- What causes aliasing and how do you avoid it?
- How would you detect dropped samples?
5.8 Hints in Layers
Hint 1: Start with blocking ADC reads at low speed. Hint 2: Enable ADC FIFO and read it in a loop. Hint 3: Add DMA to fill a buffer. Hint 4: Add trigger scanning and host UI.
5.9 Books That Will Help
| Topic | Book | Chapter | |——-|——|———| | Sampling theory | “Digital Design and Computer Architecture” | Ch. 7 | | DMA and buffering | “Making Embedded Systems” | Ch. 7-8 | | Signal conditioning | “Practical Electronics for Inventors” | Ch. 12 |
5.10 Implementation Phases
Phase 1: Foundation (1-2 days)
- Configure ADC and verify raw samples.
- Log values to serial at low rate. Checkpoint: Stable readings from a potentiometer.
Phase 2: Core Functionality (3-5 days)
- Add DMA double buffering and trigger scan.
- Implement binary frame protocol. Checkpoint: Host receives stable frames without overruns.
Phase 3: Polish & Visualization (2-3 days)
- Build host UI with axes and trigger status.
- Add overrun warnings and calibration. Checkpoint: Displayed waveform matches known signal source.
5.11 Key Implementation Decisions
| Decision | Options | Recommendation | Rationale | |———-|———|—————-|———–| | Frame format | ASCII vs binary | Binary | Lower bandwidth and faster parsing | | Trigger type | Rising vs falling vs level | Rising | Simple and sufficient | | Buffering | Single vs double | Double | Prevents gaps |
6. Testing Strategy
6.1 Test Categories
| Category | Purpose | Examples | |———-|———|———-| | Unit Tests | Validate trigger scan | Synthetic buffer with known crossing | | Integration Tests | DMA + ADC pipeline | 10 kHz sine capture | | Edge Case Tests | Stress throughput | Max sample rate with host slowdown |
6.2 Critical Test Cases
- Known sine wave: 1 kHz input matches expected period.
- No trigger: buffer without crossing sets “no trigger” flag.
- Overrun: host paused triggers overrun counter.
6.3 Test Data
input: 1 kHz sine at 1 Vpp -> expected 100 samples/cycle at 100 kHz
7. Common Pitfalls & Debugging
7.1 Frequent Mistakes
| Pitfall | Symptom | Solution | |———|———|———-| | Wrong ADC clock | Sample rate off | Measure with scope and adjust divider | | Missing DMA DREQ | FIFO overflow | Set DREQ_ADC in DMA config | | ASCII streaming | USB lag | Use binary frames |
7.2 Debugging Strategies
- Toggle a GPIO on buffer completion to verify timing.
- Log overrun counter every second.
7.3 Performance Traps
- Host plotting too slowly can cause backlog; throttle UI updates.
8. Extensions & Challenges
8.1 Beginner Extensions
- Add DC offset calibration at startup.
- Add a “freeze” button to pause capture.
8.2 Intermediate Extensions
- Add FFT on host to show frequency spectrum.
- Implement windowed capture around trigger.
8.3 Advanced Extensions
- Add dual-channel capture using round-robin ADC sampling.
- Implement a simple digital filter on the MCU.
9. Real-World Connections
9.1 Industry Applications
- Instrumentation: low-cost data acquisition tools.
- IoT devices: sensor signal validation and calibration tools.
9.2 Related Open Source Projects
- sigrok: logic analyzer tooling (host-side ideas)
- pico-examples/adc_dma: reference for ADC + DMA
9.3 Interview Relevance
- Sampling theory and DMA streaming are common embedded interview topics.
10. Resources
10.1 Essential Reading
- RP2040 Datasheet: ADC + DMA sections
- “Making Embedded Systems” Ch. 7-8
10.2 Video Resources
- “Intro to ADCs” lectures (any DSP fundamentals series)
10.3 Tools & Documentation
- Pico SDK documentation
- PySerial and matplotlib documentation
10.4 Related Projects in This Series
- P04-logic-analyzer-debug-digital-signals.md for high-speed capture
- P12-digital-theremin-touchless-music.md for audio-rate sampling
11. Self-Assessment Checklist
11.1 Understanding
- I can explain Nyquist sampling and aliasing.
- I can describe DMA ring buffer behavior.
- I can implement a trigger scan reliably.
11.2 Implementation
- Sampling rate is measured and matches configuration.
- No buffer overruns in steady state.
- Host UI shows correct axes and stable waveform.
11.3 Growth
- I documented my calibration method.
- I can explain trade-offs between rate and resolution.
12. Submission / Completion Criteria
Minimum Viable Completion:
- ADC + DMA capture works and host displays waveform.
- Trigger detects rising edge.
- Calibration converts samples to volts.
Full Completion:
- Overrun detection and reporting.
- Stable display for 60 seconds at 50 kHz.
Excellence (Going Above & Beyond):
- Add FFT display and save captured frames to file.
13. Additional Content Rules
13.1 Determinism
Use fixed sample rates (e.g., 100 kHz) and fixed buffer sizes for demos. Log timestamps and frame counts.
13.2 Outcome Completeness
- Success demo: §3.7.2
- Failure demo: §3.7.3
- CLI exit codes: host viewer returns
0success,2serial open failure,4malformed frame.
13.3 Cross-Linking
Concept links appear in §2.x and related projects in §10.4.
13.4 No Placeholder Text
All sections are fully specified for this project.