ESP32 – Dual ADC + Serial Oscilloscope (Telemetry Viewer, ESP-IDF + VS Code)

Sample two analog signals simultaneously, stream them as CSV over USB, and plot both channels in real time.

Board: ESP32 DevKitC (ESP32-WROOM-32) • Language: C (ESP-IDF) • IDE: Visual Studio Code • Plotter: Telemetry Viewer

Contents

Video

Watch the full walkthrough here. This page is the searchable companion checklist.

What you need

Important: ESP32 ADC pins are not 5 V tolerant. Keep inputs within 0–3.3 V.

Wiring (GPIO34 / GPIO35)

On ESP32 DevKitC V2, we use two ADC1 channels:

VS Code + ESP-IDF Setup

  1. Install VS Code (Windows build).
  2. Install the extension: Espressif IDF.
  3. Run: ESP-IDF: Configure ESP-IDF extension and choose Express.
  4. When asked, let it install the tools.

Create the Project

Create a new ESP‑IDF project using the “hello_world” template and then replace the main file:

  1. In VS Code: ESP-IDF: New Project.
  2. Select target: esp32.
  3. Choose template: hello_world.
  4. Open the generated folder in VS Code.
  5. Replace main/hello_world_main.c with the code in the next section.

Firmware: Capture → Process → Stream

The firmware does the following in an infinite loop: capture 3 periods of 50 Hz, remove DC component per channel, apply a light smoothing filter, compute phase shift (degrees), then stream CSV lines over USB.

This tutorial uses the ADC one shot driver. Samples are paired into rows (A,B) emitted on the serial port.

#include 
#include 
#include 
#include 
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

#include "esp_err.h"
#include "esp_log.h"
#include "esp_timer.h"

#include "esp_adc/adc_oneshot.h"
#include "esp_rom_sys.h"

// ======================== Logging / diagnostics ========================
static const char *gTag = "ADC_MEAS";   // Log tag for ADC measurement output

// ======================== Hardware mapping (ADC channels / GPIO) ========================
// CH_A on ADC1
#define iChA_AdcChannel ADC_CHANNEL_6 // GPIO34 = ADC1_CH6
// CH_B on ADC1
#define iChB_AdcChannel ADC_CHANNEL_7 // GPIO35 = ADC1_CH7


// ======================== Signal & acquisition configuration ========================
// Expected input signal characteristics (used to size capture window)
#define SIGNAL_HZ                   50
#define PERIODS_TO_CAPTURE          3
#define CAPTURE_MS                  (1000 * PERIODS_TO_CAPTURE / SIGNAL_HZ)   // 60 ms

// Sampling configuration
#define PER_CH_SAMPLE_RATE_HZ       10000
#define SAMPLES_PER_CH              ((PER_CH_SAMPLE_RATE_HZ * CAPTURE_MS) / 1000) // 600

// ======================== ADC scaling / auto-ranging thresholds ========================
#define ADC_FULL_SCALE_COUNTS       4095    // Max ADC code for configured resolution

// ======================== Data structures (raw statistics bookkeeping) ========================
// Raw ADC statistics for one channel at one attenuation
typedef struct {
    int  iMin;
    int  iMax;
    int  iZeroCount;
    int  iFullScaleCount;
    bool bValid;
} sRawStats_t;

// Raw ADC statistics for both channels at one attenuation
typedef struct {
    sRawStats_t sChA;
    sRawStats_t sChB;
} sRawStatsPerAtten_t;

// ======================== Runtime state (collected stats, driver handles) ========================
// Stats per attenuation level (index corresponds to attenuation levels you test)
static sRawStatsPerAtten_t gsRawStatsByAtten[4] = { 0 };

// ADC oneshot driver handles (hardware resources)
static adc_oneshot_unit_handle_t gAdcHandleUnit1 = NULL;
//static adc_oneshot_unit_handle_t gAdcHandleUnit2 = NULL;

// ======================== Filtering configuration ========================
#define FILTER_TAP_COUNT            5


static void Clear_Raw_Stats_By_Atten(void)
{
	// Clears the stored raw statistics collected during the auto-ranging process
	// Ensures no stale stats from previous frames appear in the next stats printout
	// Keeps only attenuation levels actually tried in the current auto-range cycle

	// Reset all stored entries
	for (int iIndex = 0; iIndex < 4; iIndex++) {
		gsRawStatsByAtten[iIndex].sChA.bValid = false;
		gsRawStatsByAtten[iIndex].sChB.bValid = false;
		gsRawStatsByAtten[iIndex].sChA.iMin = 0;
		gsRawStatsByAtten[iIndex].sChA.iMax = 0;
		gsRawStatsByAtten[iIndex].sChA.iZeroCount = 0;
		gsRawStatsByAtten[iIndex].sChA.iFullScaleCount = 0;
		gsRawStatsByAtten[iIndex].sChB.iMin = 0;
		gsRawStatsByAtten[iIndex].sChB.iMax = 0;
		gsRawStatsByAtten[iIndex].sChB.iZeroCount = 0;
		gsRawStatsByAtten[iIndex].sChB.iFullScaleCount = 0;
	}
}


static void Record_Raw_Stats_For_Atten(adc_atten_t eAttenChA, adc_atten_t eAttenChB,
                                   int iMinRawA, int iMaxRawA, int iZeroCountA, int iFullScaleHitsA,
                                   int iMinRawB, int iMaxRawB, int iZeroCountB, int iFullScaleHitsB)
{
	// Records the latest raw min/max and saturation counters for attenuation levels tried
	// Updates only the attenuation entries that were actually used on this capture attempt
	// Allows later printing of all tried levels to assess whether the chosen attenuation is optimal

	// Store channel A stats under its attenuation index
	int iAttenIndexA = (int)eAttenChA;
	if (iAttenIndexA >= 0 && iAttenIndexA < 4) {
		gsRawStatsByAtten[iAttenIndexA].sChA.iMin = iMinRawA;
		gsRawStatsByAtten[iAttenIndexA].sChA.iMax = iMaxRawA;
		gsRawStatsByAtten[iAttenIndexA].sChA.iZeroCount = iZeroCountA;
		gsRawStatsByAtten[iAttenIndexA].sChA.iFullScaleCount = iFullScaleHitsA;
		gsRawStatsByAtten[iAttenIndexA].sChA.bValid = true;
	}

	// Store channel B stats under its attenuation index
	int iAttenIndexB = (int)eAttenChB;
	if (iAttenIndexB >= 0 && iAttenIndexB < 4) {
		gsRawStatsByAtten[iAttenIndexB].sChB.iMin = iMinRawB;
		gsRawStatsByAtten[iAttenIndexB].sChB.iMax = iMaxRawB;
		gsRawStatsByAtten[iAttenIndexB].sChB.iZeroCount = iZeroCountB;
		gsRawStatsByAtten[iAttenIndexB].sChB.iFullScaleCount = iFullScaleHitsB;
		gsRawStatsByAtten[iAttenIndexB].sChB.bValid = true;
	}
}


static void Adc_Deinitialize(void)
{
    // Stops all ADC activity and releases ADC unit handle
    // Leaves the system in a clean state for the next cycle

    // Deinit ADC1 oneshot unit
    if (gAdcHandleUnit1 != NULL) {
        (void)adc_oneshot_del_unit(gAdcHandleUnit1);
        gAdcHandleUnit1 = NULL;
    }
}


static void Adc_Initialize(adc_atten_t eAttenChA, adc_atten_t eAttenChB)
{
    // Creates an ADC1 one-shot unit and configures two channels
    // Applies independent attenuation per channel on ADC1
    // Leaves units ready for timed sampling in the capture loop

    // Create ADC1 unit
    adc_oneshot_unit_init_cfg_t sUnit1Cfg = {
        .unit_id = ADC_UNIT_1,
        .ulp_mode = ADC_ULP_MODE_DISABLE,
    };
    ESP_ERROR_CHECK(adc_oneshot_new_unit(&sUnit1Cfg, &gAdcHandleUnit1));

    // Configure CH_A on ADC1
    adc_oneshot_chan_cfg_t sChanCfgA = {
        .atten = eAttenChA,
        .bitwidth = ADC_BITWIDTH_12,
    };
    ESP_ERROR_CHECK(adc_oneshot_config_channel(gAdcHandleUnit1, iChA_AdcChannel, &sChanCfgA));

    // Configure CH_B on ADC1
    adc_oneshot_chan_cfg_t sChanCfgB = {
        .atten = eAttenChB,
        .bitwidth = ADC_BITWIDTH_12,
    };
    ESP_ERROR_CHECK(adc_oneshot_config_channel(gAdcHandleUnit1, iChB_AdcChannel, &sChanCfgB));
}


static void Adc_Reconfigure(adc_atten_t eAttenAdc1, adc_atten_t eAttenAdc2)
{
	// Reinitializes ADC continuous mode with new attenuation settings
	// Fully deinitializes first to avoid invalid state during config changes
	// Leaves ADC running with the new configuration

	// Restart ADC engine with new settings
	Adc_Deinitialize();
	Adc_Initialize(eAttenAdc1, eAttenAdc2);
}


static void Dc_Remove(const uint16_t *puInput, int32_t *piOutput, int iCount)
{
	// Removes DC component from a channel by subtracting the mean
	// Produces signed, zero-centered samples for filtering
	// Keeps scaling in ADC counts for deterministic thresholds

	// Compute mean value
	int64_t liSum = 0;
	for (int iIndex = 0; iIndex < iCount; iIndex++) {
		liSum += puInput[iIndex];
	}
	float fMean = (float)liSum / (float)iCount;

	// Subtract mean from every sample
	for (int iIndex = 0; iIndex < iCount; iIndex++) {
		piOutput[iIndex] = (int32_t)((float)puInput[iIndex] - fMean);
	}
}


static void Moving_Average_Filter(const uint16_t *puInput, uint16_t *puOutput, int iCount)
{
	// Applies a small moving-average filter for basic noise reduction
	// Preserves sample count by clamping indices at the edges
	// Keeps output in ADC counts for thresholding and printing

	// Set half window for symmetric averaging
    int iTapHalf = FILTER_TAP_COUNT / 2;

	// Filter each sample with a clamped moving window
    for (int iIndex = 0; iIndex < iCount; iIndex++) {
        uint32_t uiAccumulator = 0;
        for (int iTap = -iTapHalf; iTap <= iTapHalf; iTap++) {
            int iSource = iIndex + iTap;
            if (iSource < 0) iSource = 0;
            if (iSource >= iCount) iSource = iCount - 1;
            uiAccumulator += puInput[iSource];
        }
        puOutput[iIndex] = (uint16_t)(uiAccumulator / FILTER_TAP_COUNT);
    }
}


static float Adc_Counts_To_Volts(adc_atten_t eAttenChannel, int32_t iCounts)
{
    // Converts signed ADC counts to volts using the channel attenuation selection
    // Uses a simple full-scale approximation per ESP32 attenuation option
    // Provides AC-relative volts when used on DC-removed buffers

    // Select full-scale voltage based on attenuation setting
    float fFullScaleVolts = 1.1f;
    switch (eAttenChannel) {
        case ADC_ATTEN_DB_0:
            fFullScaleVolts = 1.1f;
            break;
        case ADC_ATTEN_DB_2_5:
            fFullScaleVolts = 1.5f;
            break;
        case ADC_ATTEN_DB_6:
            fFullScaleVolts = 2.2f;
            break;
        case ADC_ATTEN_DB_12:
        default:
            fFullScaleVolts = 3.9f;
            break;
    }

    // Convert ADC counts to volts using the selected full-scale range
    float fVolts = ((float)iCounts * fFullScaleVolts) / (float)ADC_FULL_SCALE_COUNTS;
    return fVolts;
}


static bool Capture_Paired_Samples(uint16_t *puChA, uint16_t *puChB)
{
    // Compute sample interval in microseconds
    const int64_t liSamplePeriodUs = (1000000LL / (int64_t)PER_CH_SAMPLE_RATE_HZ);

    int iSampleIdx = 0;
    int64_t liNextSampleTimeUs = esp_timer_get_time();

    while (iSampleIdx < SAMPLES_PER_CH) {

        // Wait until the next scheduled sample time
        int64_t liNowUs = esp_timer_get_time();
        if (liNowUs < liNextSampleTimeUs) {
            esp_rom_delay_us((uint32_t)(liNextSampleTimeUs - liNowUs));
        }

        // Read CH_A from ADC1
        int iRawA = 0;
        esp_err_t eErrA = adc_oneshot_read(gAdcHandleUnit1, iChA_AdcChannel, &iRawA);
        if (eErrA != ESP_OK) {
            ESP_LOGE(gTag, "adc_oneshot_read CH_A, ADC1, failed: %s", esp_err_to_name(eErrA));
            return false;
        }

        // Read CH_B from ADC1
        int iRawB = 0;
        esp_err_t eErrB = adc_oneshot_read(gAdcHandleUnit1, iChB_AdcChannel, &iRawB);
        if (eErrB != ESP_OK) {
            ESP_LOGE(gTag, "adc_oneshot_read CH_B, ADC1, failed: %s", esp_err_to_name(eErrB));
            return false;
        }

        // Store paired samples
        puChA[iSampleIdx] = (uint16_t)iRawA;
        puChB[iSampleIdx] = (uint16_t)iRawB;

        // Advance to the next index and time slot
        iSampleIdx++;
        liNextSampleTimeUs += liSamplePeriodUs;
    }

    return true;
}


static adc_atten_t Step_Attenuation_More_Sensitive(adc_atten_t eCurrent)
{
	// Steps attenuation one level toward more sensitivity
	// Uses the ESP32 attenuation ordering from lowest range to highest range
	// Returns current value if already at the most sensitive setting

	// Define ordered attenuation levels
	const adc_atten_t aeLevels[] = { ADC_ATTEN_DB_0, ADC_ATTEN_DB_2_5, ADC_ATTEN_DB_6, ADC_ATTEN_DB_12 };
	const int iLevelCount = (int)(sizeof(aeLevels) / sizeof(aeLevels[0]));

	// Find current index and step down if possible
	for (int iIndex = 0; iIndex < iLevelCount; iIndex++) {
		if (aeLevels[iIndex] == eCurrent) {
			if (iIndex > 0) {
				return aeLevels[iIndex - 1];
			}
			return eCurrent;
		}
	}

	return eCurrent;
}


static void Auto_Range_Attenuations(adc_atten_t *peAttenAdc1, adc_atten_t *peAttenAdc2)
{
    // Auto-range each channel independently to the most sensitive attenuation that
    // does *not* saturate.
    //
    // Behavior:
    //  - Start both channels at least sensitive (12 dB).
    //  - Keep increasing sensitivity (12->6->2.5->0) per channel until that
    //    channel saturates, then step back one level for that channel.
    //  - Do not stop optimizing one channel just because the other channel is
    //    finished; finished channels stay fixed while the other continues.
    //  - Record stats for every attenuation actually tried, per channel, and dump
    //    them all together at the end (one line per channel per attenuation).

    // Start fresh each time so the dump reflects *this* optimization run.
    Clear_Raw_Stats_By_Atten();

    adc_atten_t eAttenA = ADC_ATTEN_DB_12;
    adc_atten_t eAttenB = ADC_ATTEN_DB_12;

    adc_atten_t ePrevA = eAttenA;
    adc_atten_t ePrevB = eAttenB;

    bool bDoneA = false;
    bool bDoneB = false;
										  

    // Guard against infinite loops (4 attenuation levels + a little slack).
    for (int iAttempt = 0; iAttempt < 12 && !(bDoneA && bDoneB); iAttempt++) {

        // Apply current attenuation settings
        Adc_Reconfigure(eAttenA, eAttenB);

        // Capture one analysis frame
        static uint16_t auRawChA[SAMPLES_PER_CH];
        static uint16_t auRawChB[SAMPLES_PER_CH];
        if (!Capture_Paired_Samples(auRawChA, auRawChB)) {
            break;
        }
        static uint16_t auFiltChA[SAMPLES_PER_CH];
        static uint16_t auFiltChB[SAMPLES_PER_CH];

        Moving_Average_Filter(auRawChA, auFiltChA, SAMPLES_PER_CH);
        Moving_Average_Filter(auRawChB, auFiltChB, SAMPLES_PER_CH);

        // Compute raw stats per channel for saturation detection
        int iMinRawA = INT_MAX;
        int iMaxRawA = INT_MIN;
        int iMinRawB = INT_MAX;
        int iMaxRawB = INT_MIN;
        int iFullScaleHitsA = 0;
        int iFullScaleHitsB = 0;
        int iZeroCountA = 0;
        int iZeroCountB = 0;

        for (int iIndex = 0; iIndex < SAMPLES_PER_CH; iIndex++) {
            int iValueA = (int)auFiltChA[iIndex];
            int iValueB = (int)auFiltChB[iIndex];

            if (iValueA < iMinRawA) iMinRawA = iValueA;
            if (iValueA > iMaxRawA) iMaxRawA = iValueA;
            if (iValueB < iMinRawB) iMinRawB = iValueB;
            if (iValueB > iMaxRawB) iMaxRawB = iValueB;

            if (iValueA >= ADC_FULL_SCALE_COUNTS) iFullScaleHitsA++;
            if (iValueB >= ADC_FULL_SCALE_COUNTS) iFullScaleHitsB++;
            if (iValueA == 0) iZeroCountA++;
            if (iValueB == 0) iZeroCountB++;
        }

        // Record stats for the attenuation levels actually used on this attempt
        Record_Raw_Stats_For_Atten(eAttenA, eAttenB,
                               iMinRawA, iMaxRawA, iZeroCountA, iFullScaleHitsA,
                               iMinRawB, iMaxRawB, iZeroCountB, iFullScaleHitsB);

        bool bSaturatedA = (iFullScaleHitsA > 0);
        bool bSaturatedB = (iFullScaleHitsB > 0);
											
        // Channel A: keep stepping more sensitive until saturation, then back off
        if (!bDoneA) {
            if (bSaturatedA) {
                eAttenA = ePrevA;
                bDoneA = true;
            } else if (eAttenA == ADC_ATTEN_DB_0) {
                bDoneA = true;
            } else {
                ePrevA = eAttenA;
                eAttenA = Step_Attenuation_More_Sensitive(eAttenA);
            }
        }

        // Channel B: keep stepping more sensitive until saturation, then back off
        if (!bDoneB) {
            if (bSaturatedB) {
                eAttenB = ePrevB;
                bDoneB = true;
            } else if (eAttenB == ADC_ATTEN_DB_0) {
                bDoneB = true;
            } else {
                ePrevB = eAttenB;
                eAttenB = Step_Attenuation_More_Sensitive(eAttenB);
            }
        }
    }

    *peAttenAdc1 = eAttenA;
    *peAttenAdc2 = eAttenB;
}
				 

void app_main(void)
{
    // Continuously samples two ADC channels and prints their filtered values
    // Applies auto-ranging, DC removal and a moving average filter per frame
    // Prints CSV rows containing only channel A and channel B values

    while (1) {
													  
        // Start ADC units for the measurement window
        Adc_Initialize(ADC_ATTEN_DB_12, ADC_ATTEN_DB_12);

        // Continuously capture and print frames while ADC is enabled
        while (1) {

            // Auto-range per channel before printing any samples
            adc_atten_t eChosenAttenChA = ADC_ATTEN_DB_12;
            adc_atten_t eChosenAttenChB = ADC_ATTEN_DB_12;
            Auto_Range_Attenuations(&eChosenAttenChA, &eChosenAttenChB);

			//printf("ATTEN,%d,%d\n", (int)eChosenAttenChA, (int)eChosenAttenChB);

            // Re-init ADC units with chosen attenuations
            Adc_Reconfigure(eChosenAttenChA, eChosenAttenChB);

            // Capture one output frame
            static uint16_t auRawChA[SAMPLES_PER_CH];
            static uint16_t auRawChB[SAMPLES_PER_CH];
            if (!Capture_Paired_Samples(auRawChA, auRawChB)) {
                break;
            }

            static uint16_t auFiltChA[SAMPLES_PER_CH];
            static uint16_t auFiltChB[SAMPLES_PER_CH];
            Moving_Average_Filter(auRawChA, auFiltChA, SAMPLES_PER_CH);
            Moving_Average_Filter(auRawChB, auFiltChB, SAMPLES_PER_CH);

            // Remove DC component per channel
            static int32_t aiChA_Ac[SAMPLES_PER_CH];
            static int32_t aiChB_Ac[SAMPLES_PER_CH];
            Dc_Remove(auFiltChA, aiChA_Ac, SAMPLES_PER_CH);
            Dc_Remove(auFiltChB, aiChB_Ac, SAMPLES_PER_CH);

            // Print CSV rows: chA, chB
            for (int iIdx = 0; iIdx < SAMPLES_PER_CH; iIdx++) {
				float fVoltChA = Adc_Counts_To_Volts(eChosenAttenChA, aiChA_Ac[iIdx]);
				float fVoltChB = Adc_Counts_To_Volts(eChosenAttenChB, aiChB_Ac[iIdx]);
				printf("%.6f,%.6f\n", fVoltChA, fVoltChB);
            }

            // Short pause for PC plotter responsiveness
            vTaskDelay(pdMS_TO_TICKS(50));
        }

        // Stop ADC units if the capture loop ever exits unexpectedly
        Adc_Deinitialize();
    }
}
				

The CSV output format is: chA, chB. Plotters can ignore comment lines starting with #.

Build, Flash, Monitor, Debug

Set COM port

  1. Plug in the ESP32 board.
  2. In VS Code: (CTRL + SHIFT + P) ESP-IDF: Select Port and pick the correct COM port.
  3. Run (CTRL + SHIFT + P): ESP-IDF: Set Targetesp32 (only needed once).
  4. If you have issues connecting to the board, see this tutorial

Build + Flash

  1. Run (CTRL + SHIFT + P): ESP-IDF: Full Clean Project.
  2. Run (CTRL + SHIFT + P): ESP-IDF: Build, Flash and Start a Monitor on Your Device).

Most DevKitC boards auto-enter bootloader. If flashing hangs at “Connecting…”, hold BOOT, click flash, then release BOOT when you see “Writing at …”.

Serial Monitor

Use (CTRL + SHIFT + P) ESP-IDF: Monitor to confirm the CSV stream is running.

Serial Oscilloscope (Telemetry Viewer)

For a free, non‑GPL, feature‑rich plotter that supports multiple channels and live streaming, we recommend Telemetry Viewer.

Quick setup

  1. Install Telemetry Viewer.
  2. Connect the board via USB.
  3. Select CSV Mode, set the baud rate to 115200, and select the appropriate UART COM port for your ESP32, then click Connect.
  4. In the CSV Data Structure panel, enter a name for the first channel, choose a color, and specify the unit and any scaling if needed, then click Add. Repeat this process for each column in the CSV data being sent over the USB port, and when finished, click Done.
  5. In the next window, drag to select all of the rectangles to display the oscilloscope view.
  6. In the Data section, enable the channels you want to display, and they will appear in the plot window. To zoom in on the time domain, reduce the number of samples shown, and to capture a single triggered waveform, click the Single button in the Trigger section.

Only one program can open the COM port at a time. Close the ESP‑IDF monitor (CTRL + T then CTRL + X) before opening Telemetry Viewer.

Resources