
前言:沒有 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,原理像用天平秤重量:

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

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 實作:先搞清楚它的三個限制

其中最常讓人白忙一場的就是 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;
}
要注意的是順序:先處理硬體(輸入電容、參考電壓),軟體濾波才是最後一道。如果輸入端本身就是雜訊,再怎麼平均也只是把雜訊畫得比較平滑。
嫌轉換太慢?四個可以動的旋鈕

原文對時脈的建議值得照抄: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 衝突,躲開就省一半時間
把這四點想通,之後不管是做電壓監測、音訊取樣還是電流回饋,你都會知道要先檢查哪一個環節。
文章評論