Files
esp32-learning/04-adc/README.md
2026-06-01 13:11:45 -04:00

9.8 KiB

Analog to Digital Converters

breadboard

This project lets you control a couple of LEDs with a potentiometer and a capacitive touch sensor, while reading out the values of the Analog to Digital Converter (ADC) from the potentiometer.

The wire coming off pin 14 in the breadboard is just a bare wire hanging off the side for capacitive touch sensing.

Lessons Learned

  • How potentiometers actually work (surprise: it's more complicated than I thought, as usual)
  • How an ADC works
  • How the ESP32-S3 ADC in particular works
  • How to build a Flash ADC on a breadboard
  • How integrated circuits measure capacitance

Measuring potentiometers

I've had some prior experience working with potentiometers in electric guitars, but never really did much beyond wiring them and comparing the way they colored the tone of the guitar. I knew that they are essentially adjustable resistors, but beyond that, I never really did much with them.

GND  LOAD  SUPPLY
 1     2     3
 |     |     |

The project connects a potentiometer's pin 3 to the 3.3V power rail and the wiper (pin 2) to the ADC input pin (pin 1) of the ESP32-S3. It reads the computed value of the Analog to Digital Converter from that pin. Reading those values out and printing them on the serial UART was really easy, but I wanted to verify the numbers. The voltage measurement should be easy to check with a voltmeter. I assumed that I could check across pins 1 and 3 and, as I adjust the potentiometer, I would see the output voltage on the GND side adjust.

Wrong! The voltage across pins 3 and 1 stays constant as the brush moves along the potentiometer's range. The difference can only be measured across the load and supply pins (1 and 2). This is because the wiper physically moves between the points 1 and 2 which is how the resistance changes, so there will only be a visible difference between the wiper and ground.

That didn't make sense with my previous understanding of potentiometers as "adjustable resistors", because that understanding was incomplete. Potentiometers are adjustable resistors (just like a rheostat), but with 3 pins, they're acting as two resistors in series like a voltage divider. Inside a potentiometer, there's a resistive element that stretches all the way from pin 3 to pin 1. Think of it like a resistor that's been stretched out. The wiper, attached to pin 2, is less like a resistor adjuster, and more like a probe that contacts the resistive material at various points along its length. The further away from 3 and closer to 1 we get, the higher that resistance value gets. The wiper itself isn't actually introducing significant resistance, and is not therefore significantly changing the potential difference between 1 and 3. It's just putting in a tap at the point where the wiper contacts the resistive track.

Now if you connect the load pin to something that is ACTUALLY drawing a load, rather than something like an ADC that really isn't drawing much at all, then current will be pulled from the wiper contact point and the voltage observed at the wiper pin will sag. This isn't really a necessary detail for this project, but it's something to keep in mind for future projects where we might put something like a motor on a potentiometer.

How an ADC works

The ESP32 GPIO pins understand HIGH and LOW. So if we want to say "How wide has the user opened that potentiometer over there?", the GPIO pins can only tell us "fully open" or "fully closed" (keeping in mind that the specific voltage that the GPIO pin understands as HIGH and LOW may or may not directly correspond to the fully open and fully closed position of the potentiometer). To do that, we need to use an Analog to Digital Converter (ADC) to express the analog value as a magnitude between 0 and V, rather than ON and OFF.

An ADC measures the voltage on an input and returns a digital value as an integer having precision P in bits. So if an ADC returns a reading with a precision of 12 bits, the reading can range from 0 (being 0v) to 4095 (being 100% of the possible input voltage). An ADC is a collection of circuits that perform this job as a single package, whether integrated on the chip, or as a separate discrete component. There's a bunch of different ways this can be done, but the general theory is that the ADC is a circuit that takes an input voltage and expresses it as an integer representing a quantity between 0 and 100% of the potential maximum.

One of the simplest ADCs to implement is called a Flash ADC, and that one is simple enough we can build it on a breadboard. I can't build this one yet - I need to come back to this when some components arrive in the mail.

  • Need to come back and build a flash ADC when my components get here

How the ESP32-S3 ADC works

I really wanted to understand how the ESP32-S3 in particular accomplishes this. So I went through a deep dive of the reference manual, plus some googling, plus some GPT interpretation of the TRM, and figured some things out.

hold on to your butts

The ESP32-S3 has two ADCs on the package that both perform in the same basic fashion, but are controlled in slightly different ways. Per the technical reference manual

Two 12-bit Successive Approximation ADCs (SAR ADCs) controlled by five dedicated controllers that can input analog signals from total of 20 channels. The SAR ADCs can operate in a high-performance mode or a low-power mode.

ESP32-S3 SAR ADC diagram

There are two ADCs on the ESP32-S3, being ADC1 and ADC2. These are two distinct hardware units - they are not part of the CPU or the ULP, they are distinct peripherals. They can both be operated in a low power mode (by driving them with the real-time clock controller) or in a high power mode (using the digital controller). ADC2 is shared among many peripherals such as WiFi, and so it has an "arbiter" hardware component that controls who gets to use it at which times and which controller drives it. ADC1 appears to be dedicated to userland programs running on the CPU, so it does not have a separate hardware arbiter, it has a much simpler mux device that selects between the RTC and Digital controller based on a hardware register.

Arduino HAL be Dummy Thicc

As previously mentioned, the HAL is quite thick here. The HAL hides the grand majority of the complexity I'm about to talk about, underneath the simple concepts of oneshot and continuous sampling modes. The source in this project uses the oneshot sampling mechanism to pull ADC values from pin 1 on request:

int adcvalue = analogRead(PIN_ANALOG_IN);

... This engages the RTC controller on ADC1 to pull a single value and return it immediately. There is a second mode of operation, analogContinuous, wherein the ADC is configured to continually sample values, store the results, and call a user callback function when data is ready.

  // Optional for ESP32: Set the resolution to 9-12 bits (default is 12 bits)
  analogContinuousSetWidth(12);

  // Optional: Set different attenaution (default is ADC_11db)
  analogContinuousSetAtten(ADC_11db);

  // Setup ADC Continuous with following input:
  // array of pins, count of the pins, how many conversions per pin in one cycle will happen, sampling frequency, callback function
  analogContinuous(adc_pins, adc_pins_count, CONVERSIONS_PER_PIN, 20000, &adcComplete);

  // Start ADC Continuous conversions
  analogContinuousStart();

The demo code I worked with didn't use the continuous mode, but I can definitely see utility for it.

As usual the Arduino HAL makes this all really easy and hides a bunch of complexity from us. Part of the whole reason I'm doing these projects is as a rejection of abstraction layers between me and the computer, so, I'm not willing to stop there. Let's grab a shovel and dig into what's actually happening.

ADC hardware registers

All of the ADC hardware is controlled by writing values to the ADC hardware registers. Once these are changed, the ADC hardware sees the change and starts acting on the request.

The ADC hardware registers are mapped into memory between 0x6000_8000 and 0x6000_8FFF. These memory addresses are memory mapped in from the low power mode peripherals in the address controller, and are part of the shared instruction/data bus. Basically, when something requests data from those memory regions, the memory address controller redirects the request out to the appropriate peripheral on behalf of whoever is requesting that memory. The peripheral in question then manages the requested item, which is usually stored in a hardware register inside the peripheral. Memory mapped registers are a really common way of controlling peripherals and reduces the complexity of connections between components in the package.

The ADC registers that are of most interest to this particular explanation are:

ADC Register Address Purpose
ADC1 SENS_SAR_READER1_CTRL_REG 0x6000_8000 Controlling ADC1 data and sampling
ADC1 SENS_SAR_MEAS1_CTRL2_REG 0x6000_800C Activating ADC1 and reading the output of the operation
ADC1 SENS_SAR_MEAS1_MUX_REG 0x6000_8010 Selecting the RTC or Digital controller for ADC1 operations
ADC1 SENS_SAR_ATTEN1_REG 0x6000_8014 Setting the attenuation for ADC1
ADC2 SENS_SAR_MEAS2_MUX_REG 0x6000_8024 Selecting the RTC or Digital controller for ADC1 operations
ADC2 SENS_SAR_MEAS2_CTRL2_REG 0x6000_8030 Activating ADC1 and reading the output of the operation
ADC2 SENS_SAR_READER2_CTRL_REG 0x6000_8034 Controlling ADC1 data and sampling
ADC2 SENS_SAR_ATTEN2_REG 0x6000_8038 Setting the attenuation for ADC1
Both SENS_SAR_POWER_XPD_SAR_REG 0x6000_803C SAR ADC Power Control

Measuring Capacitance