What Is an ADC (Analog-to-Digital Converter) in the Context of Embedded Systems?

What is an ADC (Analog-to-Digital Converter) in the context of embedded systems?

Almost every embedded project I’ve built eventually needs to sense something from the physical world — a temperature, a light level, a battery voltage, a joystick position — and the physical world doesn’t speak in 1s and 0s. That’s where the ADC comes in. It’s the bridge between continuous, analog reality and the discrete, digital world microcontrollers operate in. In this article, I’ll walk through what an ADC actually does, how it works internally, how to configure and use one, and the practical gotchas that trip people up.

What Is an ADC?

An Analog-to-Digital Converter (ADC) is a peripheral (or standalone chip) that samples a continuously varying analog voltage and converts it into a discrete digital value that the CPU can read, store, and process. Nearly every microcontroller intended for sensor work — STM32, AVR, ESP32, PIC — has one or more ADC peripherals built in.

graph LR
    A[Analog Signal - e.g. 0-3.3V] --> B[Sample & Hold]
    B --> C[Quantizer]
    C --> D[Digital Value - e.g. 0-4095]
    D --> E[CPU / Memory]

Key ADC Concepts

Resolution

Resolution defines how many discrete steps the ADC can represent, expressed in bits. A 12-bit ADC divides its input voltage range into 2^12 = 4096 discrete steps (0 to 4095). A 10-bit ADC (common on classic AVR chips like the ATmega328) offers 1024 steps. Higher resolution means finer distinction between voltage levels, at the cost of conversion time and complexity.

Step size (LSB) = V_ref / 2^resolution
Example (12-bit, 3.3V reference): 3.3V / 4096 ≈ 0.8mV per step

Reference Voltage (V_ref)

The ADC measures the input voltage relative to a reference voltage. If V_ref is 3.3V and the ADC reads the maximum digital value, that corresponds to (approximately) 3.3V at the input pin. Using an accurate, stable, low-noise reference is critical — a noisy or drifting V_ref translates directly into noisy or drifting readings, even if the sensor itself is perfectly stable.

Sampling Rate

This is how many conversions per second the ADC can perform, governed by the Nyquist-Shannon sampling theorem: to accurately capture a signal, you must sample at least twice the highest frequency component present in that signal. For slow-changing signals like temperature, a few samples per second is plenty; for audio or vibration analysis, you might need tens of kHz of sampling rate.

Quantization Error

Because the ADC can only represent a finite number of discrete levels, there’s always some rounding — the actual analog value gets “snapped” to the nearest representable digital step. This introduces a small, unavoidable quantization error, typically ±0.5 LSB (least significant bit) for a well-designed ADC.

How a Successive Approximation ADC (SAR ADC) Works

Most microcontroller ADCs use the Successive Approximation Register (SAR) architecture, since it offers a good balance of speed, resolution, and simplicity.

sequenceDiagram
    participant Input as Analog Input
    participant SAR as SAR Logic
    participant DAC as Internal DAC
    participant Comp as Comparator
    Input->>Comp: Sampled voltage held
    SAR->>DAC: Set MSB = 1, rest 0
    DAC->>Comp: Compare DAC output vs input
    Comp-->>SAR: Input > DAC? Keep bit : Clear bit
    SAR->>DAC: Move to next bit, repeat
    Note over SAR: Repeats for each bit (12 cycles for 12-bit ADC)
    SAR->>SAR: Final digital value ready

The SAR ADC works like a game of “guess the number” using a binary search: it starts by guessing the most significant bit is 1, compares that guess (via an internal DAC and comparator) against the actual input, keeps the bit if the guess was too low, clears it if too high, and moves to the next bit — repeating until every bit of the result has been resolved. This is why a SAR ADC conversion takes roughly N clock cycles for an N-bit result.

Configuring ADC on an STM32 (HAL Example)

#include "stm32f4xx_hal.h"

ADC_HandleTypeDef hadc1;

void ADC1_Init(void) {
    ADC_ChannelConfTypeDef sConfig = {0};

    hadc1.Instance = ADC1;
    hadc1.Init.Resolution = ADC_RESOLUTION_12B;
    hadc1.Init.ScanConvMode = DISABLE;
    hadc1.Init.ContinuousConvMode = DISABLE;
    hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT;
    hadc1.Init.NbrOfConversion = 1;
    HAL_ADC_Init(&hadc1);

    sConfig.Channel = ADC_CHANNEL_0;      // PA0
    sConfig.Rank = 1;
    sConfig.SamplingTime = ADC_SAMPLETIME_84CYCLES;
    HAL_ADC_ConfigChannel(&hadc1, &sConfig);
}

uint16_t ADC1_Read(void) {
    HAL_ADC_Start(&hadc1);
    HAL_ADC_PollForConversion(&hadc1, 100);
    uint16_t value = HAL_ADC_GetValue(&hadc1);
    HAL_ADC_Stop(&hadc1);
    return value;
}

float ADC_ToVoltage(uint16_t raw) {
    return (raw * 3.3f) / 4095.0f;   // 12-bit, 3.3V reference
}

Configuring ADC on Arduino

void setup() {
    Serial.begin(9600);
    // analogReference(DEFAULT);  // Uses 5V (or 3.3V on some boards)
}

void loop() {
    int raw = analogRead(A0);          // 10-bit result: 0-1023
    float voltage = raw * (5.0 / 1023.0);
    Serial.print("Raw: ");
    Serial.print(raw);
    Serial.print("  Voltage: ");
    Serial.println(voltage);
    delay(500);
}

Configuring ADC on ESP32 (Arduino Framework)

void setup() {
    Serial.begin(115200);
    analogReadResolution(12);          // ESP32 supports up to 12-bit
    analogSetAttenuation(ADC_11db);    // Extends input range to ~3.3V
}

void loop() {
    int raw = analogRead(34);          // ADC1 channel on GPIO34
    float voltage = (raw / 4095.0) * 3.3;
    Serial.println(voltage);
    delay(500);
}

Interrupt and DMA-Driven ADC Sampling

For continuous sensor monitoring (say, sampling an audio signal or a fast-changing sensor at a fixed rate), polling HAL_ADC_PollForConversion() repeatedly wastes CPU cycles. A far more efficient approach uses a timer to trigger conversions automatically and DMA to move each result into a buffer without CPU intervention:

#define BUFFER_SIZE 256
uint16_t adc_buffer[BUFFER_SIZE];

void ADC_DMA_Init(void) {
    HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, BUFFER_SIZE);
    // Timer (e.g. TIM3) configured to trigger ADC conversions at a fixed rate
}

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) {
    // Buffer is full - process adc_buffer[] here
    process_samples(adc_buffer, BUFFER_SIZE);
}
graph LR
    Timer[Hardware Timer] -->|Trigger| ADC[ADC Peripheral]
    ADC -->|DMA Channel| Buffer[Memory Buffer]
    Buffer -->|Interrupt on complete| CPU[CPU: Process batch]

Multi-Channel Scanning

Most microcontroller ADCs support scanning multiple input channels in sequence, useful when reading several sensors sharing one ADC peripheral:

ADC_ChannelConfTypeDef sConfig = {0};
hadc1.Init.ScanConvMode = ENABLE;
hadc1.Init.NbrOfConversion = 3;

sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = 1; HAL_ADC_ConfigChannel(&hadc1, &sConfig);
sConfig.Channel = ADC_CHANNEL_1; sConfig.Rank = 2; HAL_ADC_ConfigChannel(&hadc1, &sConfig);
sConfig.Channel = ADC_CHANNEL_4; sConfig.Rank = 3; HAL_ADC_ConfigChannel(&hadc1, &sConfig);

Noise Reduction Techniques

Analog signals are inherently susceptible to noise, and I’ve spent more debugging hours than I’d like chasing “jittery” ADC readings that turned out to be entirely normal electrical noise rather than a bug. A few techniques that consistently help:

#define ALPHA 0.1f
float filtered_value = 0;

void update_filter(float new_sample) {
    filtered_value = ALPHA * new_sample + (1 - ALPHA) * filtered_value;
}

Real-World Applications

IoT Integration Example: Soil Moisture Monitor

#define MOISTURE_PIN 34

void setup() {
    Serial.begin(115200);
    analogReadResolution(12);
}

void loop() {
    int raw = analogRead(MOISTURE_PIN);
    // Calibrate: raw ~ 3000 (dry) to ~1200 (wet), adjust per sensor
    int moisture_percent = map(raw, 3000, 1200, 0, 100);
    moisture_percent = constrain(moisture_percent, 0, 100);

    Serial.printf("Soil Moisture: %d%%\n", moisture_percent);
    // publish_to_mqtt(moisture_percent);  // send to cloud/broker

    delay(60000);  // check once per minute
}

Accuracy, Calibration, and Non-Idealities

Real ADCs aren’t perfect. Datasheets typically specify:

For applications needing higher accuracy than a raw ADC read provides, a two-point calibration against known reference voltages can correct for offset and gain error in software:

float calibrated_voltage(uint16_t raw) {
    // Measured raw=100 at 0.1V, raw=4000 at 3.2V (example calibration)
    float slope = (3.2f - 0.1f) / (4000 - 100);
    return 0.1f + (raw - 100) * slope;
}

Performance and Power Considerations

Faster sampling and higher resolution both come at a power cost — internal reference circuits and comparators draw current whenever active. Many microcontrollers let you power down the ADC between readings, and some (like the ESP32 in deep sleep) can wake, take a single reading, and go back to sleep, which is essential for battery-powered sensor nodes that need to last months or years on a coin cell or small battery pack.

Frequently Asked Questions

Why do my ADC readings jump around even when the input is steady? This is typically noise — from the power supply, nearby switching circuits (like PWM signals or Wi-Fi radios), or an unstable reference voltage. Averaging multiple samples and adding a small hardware RC filter usually resolves it.

Can an ADC measure negative voltages? Standard microcontroller ADCs typically only measure voltages between 0V and V_ref (unipolar). Measuring negative or bipolar signals requires level-shifting circuitry to bring the signal into the ADC’s valid input range, or a differential/bipolar ADC designed for that purpose.

What’s the difference between ADC resolution and accuracy? Resolution is how many discrete steps the ADC can represent; accuracy is how close those readings are to the true analog value. A 16-bit ADC with poor reference stability or high noise can be less accurate in practice than a well-designed 12-bit ADC.

How do I choose the right sampling rate? Per the Nyquist theorem, sample at least twice as fast as the highest frequency component you care about in your signal — but in practice, sampling well beyond that (4x–10x) and filtering gives cleaner results for most sensor applications.

Summary

An ADC is the essential bridge between the analog physical world and digital microcontroller logic, converting a continuous voltage into a discrete numeric value through processes like successive approximation. Understanding resolution, reference voltage, sampling rate, and noise sources is what separates “it kind of works” ADC code from genuinely reliable sensor readings — and for anything beyond occasional single readings, offloading sampling to a timer-triggered, DMA-backed pipeline frees the CPU while giving you clean, consistent data.

References and Further Reading

Exit mobile version