0x6A Logbook

0x6A Logbook
Shi6a的筆記本
  1. 首頁
  2. 未分類
  3. 正文

ADC 類比數位轉換器完整教學:從原理到 STM32/ESP32 實作

2026 年 5 月 29 日 740點熱度 0人點贊 0條評論

ADC 類比數位轉換器完整教學封面

前言:沒有 ADC,MCU 就是個瞎子

你手上的 ESP32 或 STM32 本質上是數位生物 —— 它只認 0 與 1。但真實世界全都是連續變化的電壓:電池還剩多少、光線多亮、馬達電流多大、感測器電壓怎麼飄。

ADC(Analog-to-Digital Converter)就是那道翻譯閘門:把某個瞬間的電壓,變成一個數字。

它看起來很簡單,但實務上有三個地方一定會讓人卡住:取樣時間算不出來、讀值一直跳、還有速度上不去。這篇就照這個順序講。

基本原理:三個名詞就講完

參數 說明 公式
解析度 (Resolution) 數位輸出的位元數 N bits → 2^N 個電平
參考電壓 (Vref) ADC 的最大輸入電壓 通常 = VDD 或內部參考
LSB (最小有效位) 1 個數位步進對應的電壓 Vref / 2^N
取樣率 (Sampling Rate) 每秒可完成的轉換次數 SPS (Samples Per Second)

真正要記住的只有一條式子:LSB = Vref ÷ 2^N。以 ESP32 常見的 12-bit、3.3 V 全量程來說,一個步進大約 0.8 mV —— 這就是你理論上能分辨的最小變化。

但要提醒一件事:理論解析度不等於實際精度。雜訊、參考電壓穩定度、佈局都會吃掉低幾位,這也是為什麼後面「讀值不穩」那一節會佔掉不少篇幅。

SAR ADC 的轉換只有四步

ESP32 與 STM32 內建的都是 SAR(逐次逼近型)ADC,原理像用天平秤重量:

SAR ADC 的轉換流程

接著是最容易被忽略的一段:轉換需要多久。

ADC 轉換時間的計算

T_conv = (取樣週期 + 轉換位元數) × ADC_CLK 週期
T_conv = (n_cycles + 12) × 1/ADC_CLK

// STM32F4 @ 21 MHz ADC Clock, 3 cycle sampling:
T_conv = (3 + 12) / 21 MHz = 0.714 μs
Sampling Rate ≈ 1.4 MSPS

那個「+ 12」是重點:解析度直接算進轉換時間。想要 12-bit 的精度,就得接受每一次轉換比 8-bit 多四個週期。

STM32 實作:從單通道到三重同步

先用 HAL 把單通道讀值跑起來。這一段是所有 STM32 ADC 專案的起點:

#include "stm32f4xx_hal.h"

ADC_HandleTypeDef hadc1;

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

    hadc1.Instance = ADC1;
    hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4;  // 84/4=21 MHz
    hadc1.Init.Resolution = ADC_RESOLUTION_12B;
    hadc1.Init.ScanConvMode = DISABLE;        // 單通道
    hadc1.Init.ContinuousConvMode = DISABLE;   // 單次
    hadc1.Init.DiscontinuousConvMode = DISABLE;
    hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START;  // 軟體觸發
    hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT;
    hadc1.Init.NbrOfConversion = 1;
    hadc1.Init.EOCSelection = ADC_EOC_SEQ_CONV;
    HAL_ADC_Init(&hadc1);

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

uint32_t read_adc(void)
{
    HAL_ADC_Start(&hadc1);                       // 啟動轉換
    HAL_ADC_PollForConversion(&hadc1, 100);      // 等待完成
    return HAL_ADC_GetValue(&hadc1);             // 讀取結果
}

// 轉換為電壓
float adc_to_voltage(uint32_t raw)
{
    return (float)raw * 3.3f / 4095.0f;  // 12-bit, Vref=3.3V
}

接著是最常用的組合:多通道 + DMA。ADC 自己掃描三路輸入並把結果寫進陣列,CPU 完全不用等:

#define ADC_CHANNELS  3
uint32_t adc_values[ADC_CHANNELS];

void MX_ADC1_DMA_Init(void)
{
    // ... 同上設定,修改以下參數
    hadc1.Init.ScanConvMode = ENABLE;          // 掃描模式
    hadc1.Init.ContinuousConvMode = ENABLE;    // 連續轉換
    hadc1.Init.NbrOfConversion = ADC_CHANNELS; // 3 通道
    HAL_ADC_Init(&hadc1);

    // 通道 0: PA0 (電位器)
    sConfig.Channel = ADC_CHANNEL_0;
    sConfig.Rank = 1;
    sConfig.SamplingTime = ADC_SAMPLETIME_15CYCLES;
    HAL_ADC_ConfigChannel(&hadc1, &sConfig);

    // 通道 1: PA1 (LM35 溫度)
    sConfig.Channel = ADC_CHANNEL_1;
    sConfig.Rank = 2;
    HAL_ADC_ConfigChannel(&hadc1, &sConfig);

    // 通道 2: PA2 (電池電壓)
    sConfig.Channel = ADC_CHANNEL_2;
    sConfig.Rank = 3;
    HAL_ADC_ConfigChannel(&hadc1, &sConfig);
}

void start_dma_adc(void)
{
    HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_values, ADC_CHANNELS);
}

// DMA half-complete / complete callback
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        // adc_values[0] = PA0, adc_values[1] = PA1, adc_values[2] = PA2
        float v0 = adc_values[0] * 3.3f / 4095.0f;
        float temp = adc_values[1] * 3.3f / 4095.0f * 100.0f;  // LM35: 10mV/°C
        float batt = adc_values[2] * 3.3f / 4095.0f * 2.0f;     // 分壓 x2

        printf("Pot: %.2fV  Temp: %.1f°C  Batt: %.2fV\n", v0, temp, batt);
    }
}

如果要更高的取樣率,原文還示範了「雙 ADC 同步模式」:讓兩顆 ADC 分別當 Master 與 Slave,同一時間啟動,取樣率直接翻倍。

// ADC1 = Master, ADC2 = Slave (同步模式)
// 需要 HAL_ADCEx_MultiModeConfigChannel() 設定
// ADC1 轉換偶數次,ADC2 轉換奇數次
// 有效取樣率從 ~1.4 MSPS → ~2.8 MSPS

ESP32 實作:先搞清楚它的三個限制

ESP32 ADC 的三個限制

其中最常讓人白忙一場的就是 ADC2 與 Wi-Fi 衝突:ADC2 的硬體被 Wi-Fi 佔用,只要無線連上,讀值就開始亂跳。解法很單純 —— 整個專案只用 ADC1 的腳位。

#define POT_PIN   34   // ADC1_CH6
#define LIGHT_PIN 35   // ADC1_CH7
#define BAT_PIN   36   // ADC1_CH0 (SENSOR_VP)

void setup()
{
    Serial.begin(115200);
    analogReadResolution(12);   // 12-bit (0~4095)
    analogSetAttenuation(ADC_11db);  // 0~3.3V 全量程
}

void loop()
{
    int pot   = analogRead(POT_PIN);
    int light = analogRead(LIGHT_PIN);
    int bat   = analogRead(BAT_PIN);

    float v_pot   = pot   * 3.3f / 4095.0f;
    float v_light = light * 3.3f / 4095.0f;
    float v_bat   = bat   * 3.3f / 4095.0f * 2.0f;  // Voltage divider

    Serial.printf("Pot: %.2fV  Light: %.2fV  Batt: %.2fV\n",
                  v_pot, v_light, v_bat);
    delay(100);
}

少了 Arduino 的包裝、要直接碰 IDF 的話,寫法如下:

#include "driver/adc.h"
#include "esp_adc_cal.h"

#define DEFAULT_VREF 1100  // mV (typ. calibration)
static esp_adc_cal_characteristics_t adc_chars;

void adc_init(void)
{
    adc1_config_width(ADC_WIDTH_BIT_12);
    adc1_config_channel_atten(ADC1_CHANNEL_6, ADC_ATTEN_DB_11);  // GPIO34
    esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_11,
                             ADC_WIDTH_BIT_12, DEFAULT_VREF, &adc_chars);
}

uint32_t read_adc_mv(void)
{
    uint32_t raw = adc1_get_raw(ADC1_CHANNEL_6);
    uint32_t mv  = esp_adc_cal_raw_to_voltage(raw, &adc_chars);
    return mv;  // mV 單位
}

至於「不想每隔一段時間才問一次結果」的場合,就用 ADC 連續模式 + DMA:

#include "esp_adc/adc_continuous.h"

adc_continuous_handle_t adc_handle;

void adc_continuous_init(void)
{
    adc_continuous_handle_cfg_t handle_cfg = {
        .max_store_buf_size = 1024,
        .conv_frame_size    = 256,
    };
    adc_continuous_new_handle(&handle_cfg, &adc_handle);

    adc_continuous_config_t dig_cfg = {
        .sample_freq_hz = 20000,         // 20 kHz 取樣率
        .conv_mode      = ADC_CONV_SINGLE_UNIT_1,
        .format         = ADC_DIGI_OUTPUT_FORMAT_TYPE2,
    };

    adc_digi_pattern_config_t patterns[2] = {
        {.atten = ADC_ATTEN_DB_11, .channel = 6,  .unit = ADC_UNIT_1, .bit_width = ADC_BIT_WIDTH_12},
        {.atten = ADC_ATTEN_DB_11, .channel = 7,  .unit = ADC_UNIT_1, .bit_width = ADC_BIT_WIDTH_12},
    };
    dig_cfg.adc_pattern = patterns;
    dig_cfg.pattern_num = 2;

    adc_continuous_config(adc_handle, &dig_cfg);
    adc_continuous_start(adc_handle);
}

void adc_read_task(void *pv)
{
    uint8_t buf[256];
    uint32_t ret_num = 0;

    while (1)
    {
        adc_continuous_read(adc_handle, buf, 256, &ret_num, pdMS_TO_TICKS(100));
        if (ret_num > 0)
        {
            for (int i = 0; i < ret_num; i += 4) // TYPE2: 4 bytes per sample { uint32_t raw = *(uint32_t *)&buf[i]; uint8_t ch = (raw >> 16) & 0xF;
                uint16_t val = raw & 0xFFF;
                printf("CH%d: %d (%.2fV)\n", ch, val, val * 3.3f / 4095.0f);
            }
        }
    }
}

讀值一直跳?先從這四個地方下手

讀值不穩的四個原因與解法

原文的移動平均範例很適合當第一層軟體濾波:開一個 16 格的環形緩衝,每次讀值塞進去、回傳平均值。

// 移動平均濾波
#define FILTER_TAP 16
uint32_t filter_buf[FILTER_TAP];
uint8_t filter_idx = 0;

uint32_t adc_filtered(void)
{
    filter_buf[filter_idx++] = read_adc();
    if (filter_idx >= FILTER_TAP) filter_idx = 0;

    uint32_t sum = 0;
    for (int i = 0; i < FILTER_TAP; i++) sum += filter_buf[i];
    return sum / FILTER_TAP;
}

要注意的是順序:先處理硬體(輸入電容、參考電壓),軟體濾波才是最後一道。如果輸入端本身就是雜訊,再怎麼平均也只是把雜訊畫得比較平滑。

嫌轉換太慢?四個可以動的旋鈕

提升 ADC 速度的四個手段

原文對時脈的建議值得照抄:STM32F4 的 ADC Clock 最高可到 36 MHz,但建議跑 21 MHz。速度與穩定度在這裡是要交換的,不是越快越好。

實戰:資料記錄儀(ADC + RTC + Flash)

把前面學到的接起來,做一台能長時間記錄的裝置。架構比想像中簡單:

資料記錄儀的資料流

// 架構
// Sensor → ADC1_CH0 (PA0) ─┐
// Sensor → ADC1_CH1 (PA1) ─┤ DMA → adc_values[2] → Log to SD/Flash
// Sensor → ADC1_CH2 (PA2) ─┘

typedef struct {
    uint32_t timestamp;   // RTC timestamp
    uint16_t ch0;         // 12-bit raw
    uint16_t ch1;
    uint16_t ch2;
    uint16_t reserved;    // 4-byte alignment
} LogEntry;

#define LOG_SIZE 512
LogEntry log_buf[LOG_SIZE];
uint16_t log_index = 0;

void log_adc_data(void)
{
    log_buf[log_index].timestamp = get_rtc_sec();
    log_buf[log_index].ch0 = adc_values[0];
    log_buf[log_index].ch1 = adc_values[1];
    log_buf[log_index].ch2 = adc_values[2];
    log_index++;

    if (log_index >= LOG_SIZE)
    {
        flash_write_sector((uint8_t *)log_buf, LOG_SIZE * sizeof(LogEntry));
        log_index = 0;
    }
}

這個設計有兩個值得學的地方:用 struct 把時間戳與三個通道綁成一筆記錄(換成別的資料也一樣好用),以及先累積到 512 筆再整批寫入 Flash —— 減少寫入次數就是延長 Flash 壽命。

ESP32 的校正與完整資料流

ESP32 的 ADC 每顆晶片都有差異,所以原廠提供了校正函式,把原始值換算成毫伏:

// ESP32 ADC 校正(更加精確)
uint32_t adc_calibrated_read(adc1_channel_t channel)
{
    uint32_t raw = adc1_get_raw(channel);
    uint32_t voltage = esp_adc_cal_raw_to_voltage(raw, &adc_chars);
    return voltage;  // 已校準到 mV
}

把濾波與校正串起來,就是一條完整的量測管線:

濾波與校正的順序

順序很重要:先濾波、再校正、最後才乘分壓比。反過來做的話,校正曲線會被單點突波帶偏,而且你再也分不出誤差是來自 ADC 還是來自演算法。

總結

ADC 這條路上,真正會卡住人的永遠是這四件事:

  • 理論解析度 ≠ 實際精度:12-bit 標示的是階數,不是可信位元數
  • 取樣時間要配阻抗:源阻抗高,就要給更長的取樣週期
  • 雜訊要分層處理:硬體電容先擋,軟體濾波後補
  • 平台限制要先知道:ESP32 的 ADC2 與 Wi-Fi 衝突,躲開就省一半時間

把這四點想通,之後不管是做電壓監測、音訊取樣還是電流回饋,你都會知道要先檢查哪一個環節。

標籤: RS485 工業通訊
最後更新:2026 年 9 月 30 日

shi6a

這個人很懶,什麼都沒留下

點贊
< 上一篇

文章評論

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回覆

COPYRIGHT © 2026 0x6A Logbook. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang