ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103多通道ADC采集:DMA与扫描模式实现4路波形显示

STM32F103多通道ADC采集:DMA与扫描模式实现4路波形显示 简介面向STM32F103嵌入式开发者这份资源解决模拟数字转换器多通道采集与串口示波器联调的常见需求。工程基于HAL库搭建提供四个通道同时采集的完整源码涵盖转换序列配置、非直接存储器访问模式下的多通道设置、串口数据发送等关键内容并附有对应的文本说明和配置截图方便对照理解与快速移植。压缩包内共十四个文件以C语言源文件和头文件为主另有工程配置文件、使用说明和图片等整体体量仅八十八千字节轻量精简便于下载和查阅。目前已有二百零八人学习下载资源热度不错适合正在学习STM32模拟数字转换器或需要调试多通道模拟量采集的初中级开发者。通过本工程可获取可直接参考的初始化流程、中断与串口输出处理思路以及实际项目中的帧格式组织方法能有效减少重复配置和排查错误的时间。1. 一块 STM32F103 同时采 4 路 ADC为什么绕不开 DMA 和扫描序列标题里的“4 个通道同时采集”实际上做不到真正意义的时钟同步采样STM32F103 的 ADC 只有一个逐次逼近型转换内核所有规则组通道必须排队轮流转换。真正能落地的方案是“扫描模式 连续转换 DMA 搬运”让 ADC 自己在 PA0~PA3 四个通道之间轮流采样每次转换完由 DMA 把结果送到内存数组一轮结束后再把整组数据通过串口发出去。这样做的好处是 CPU 几乎不参与采样过程采样率稳定串口发送也不会干扰转换节奏。这篇文章面向的是那些常用裸机或 HAL 库做数据采集的工程师也适合刚把标准库程序迁移到 CubeMX 的入门者。内容覆盖了时钟树配置、DMA 请求映射、多通道转换时间计算、串口帧格式设计以及上位机显示时常见的丢帧、波形毛刺和数据对齐问题。读完以后你能直接在这套方案的基础上改通道数、改采样频率、换上位机协议而不是照抄一份工程完事。2. ADC 多通道采集的硬件原理与参数选择2.1 规则组与注入组的区别为什么采集四路用规则组STM32F103 的 ADC 支持两种转换组规则组和注入组。规则组最多可以排 16 个通道转换结果只有一个数据寄存器所以必须靠 DMA 把每次转换结果搬走否则读到的永远是最后一个通道的数据。注入组有 4 个独立的 16 位数据寄存器适合“外部触发抢断”的场景比如电机控制中需要精确捕捉霍尔信号时刻的电压。做四路连续采集显示波形规则组配合 DMA 是最自然的做法因为它的触发源可以是定时器或软件触发扫描一轮后还能自动开启新的一轮不需要反复配置通道序列。2.2 ADC 时钟树12MHz 是上限采样时间要留足F103 的 ADC 时钟来自 APB2 时钟域最大允许 14MHz。如果系统主频是 72MHz常见的分频选择是 6 分频得到 12MHz 的 ADC 时钟。分频系数太小会导致转换结果跳动太大会降低采样率。这个 12MHz 的时钟并不是直接把 72MHz“砍一刀”那么简单它还和每个通道的采样时间搭配决定单次转换周期数。单个通道的转换时间计算公式是转换时间 (采样周期 12.5 个固定周期) / ADC时钟频率比如采样周期取 1.5 周期单通道转换时间是 (1.5 12.5)/12MHz ≈ 1.17us四通道一轮约 4.7us。但实际工程里1.5 周期的采样时间对外部信号源阻抗很敏感信号源内阻过大时采样电容充不满读数会偏低。常见做法是配 55.5 周期把单通道转换时间拉到 5.67us输出阻抗几百欧的信号完全够用稳定性也明显改善。ADC时钟分频ADC时钟频率1.5周期采样55.5周期采样239.5周期采样418MHz超限0.78us3.78us14us612MHz1.17us5.67us21us89MHz1.56us7.56us28us需要注意超过 14MHz 属于超规格运行短期可能正常但温度变化后精度会明显劣化这里不建议碰。2.3 DMA 请求映射与循环模式F103 的 ADC1 在规则组转换结束后会产生一个 DMA 请求DMA1 的通道 1 专门处理 ADC1 的请求。配置时要保证外设地址是 ADC1 的数据寄存器地址 0x4001244C内存地址指向一个 uint16_t 数组方向是外设到内存。数据宽度两边都设为半字也就是 16 位。DMA 工作在循环模式时传输计数器递减到零后会自动重载初值这样 ADC 每完成一次转换DMA 就把结果写入数组的下一个元素并自动回到起始位置CPU 只需要在“半传输”或“传输完成”中断里取数据。四通道一轮采集需要设置 DMA 传输数量为 4否则数组会被覆盖。HAL 库里对应hdma_adc1.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORDMemDataAlignment同理。用标准库的话要查DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord。2.3.1 半传输中断与传输完成中断的选择循环模式下DMA 每传输完一个半组数据会触发半传输中断传满整组触发传输完成中断。四通道场景下如果每次只处理 4 个数据用传输完成中断就够了。中断里把内存数组的数据拷贝一份然后启动串口发送。但高频率采集时串口发送可能还没完成DMA 已经又填满了数组处理办法是双缓冲或在传输完成中断里用一个标志位缓存数据这个后面在实战部分展开。2.4 多通道扫描顺序与采样稳定时间ADC_InitStruct.ADC_ScanConvMode要使能否则只转第一个通道。规则序列寄存器ADC_SQR1~ADC_SQR3决定转换顺序。比如把 PA0 排第一、PA3 排第四对应的 rank1 到 rank4 依次填ADC_CHANNEL_0到ADC_CHANNEL_3。多通道连续扫描有一个容易被忽略的问题通道切换后第一次转换可能受内部参考电容残留电压影响尤其是相邻通道阻抗差异很大时。现象是四个通道里总有某一个通道的数据明显偏大或偏小而且不是每次上电都一样。解决方法是把“启动后第一次转换”当作无效值丢弃或者给高阻信号源加一个 100nF 的对地电容做缓冲。实际调试中如果发现某个通道比万用表量到的值低 50mV 以上先检查是不是信号源内阻问题不要急着怀疑代码。3. CubeMX 配置与代码实现从初始化到一轮完整发送3.1 用 CubeMX 生成基础工程的关键配置项CubeMX 里选好 STM32F103C8T6 后依次处理以下配置注意顺序别反。时钟树HCLK 设为 72MHzAPB2 分频设为 2ADC 分频设为 6得到 12MHz。RCC 时钟配置里还要注意 PCLK2 的定时器时钟是否开启后续如果需要用定时器触发 ADC 采样这个必须有。ADC1 参数面板ADC_InitStruct.ScanConvMode ENABLE; ADC_InitStruct.ContinuousConvMode ENABLE; ADC_InitStruct.DiscontinuousConvMode DISABLE; ADC_InitStruct.NbrOfConversion 4; ADC_InitStruct.ExternalTrigConv ADC_SOFTWARE_START;ContinuousConvMode设为 ENABLE 之后ADC 会在一轮转换结束后立即开始下一轮不需要软件再触发。NbrOfConversion必须和 DMA 传输数量保持一致否则 DMA 数组长度和扫描轮数的对应关系会错位。DMA 设置添加 DMA 请求选择ADC1方向PeripheralToMemory模式Circular数据宽度半字优先级选择 High。串口选择 USART1波特率 115200、8 位数据、无校验、1 停止位。3.2 HAL 库代码ADC 校准与 DMA 启动F103 的 ADC 带有自校准功能在 ADC 上电后执行一次校准能减小零漂误差。HAL 库里对应HAL_ADCEx_Calibration_Start。这段初始化放在主函数中的MX_ADC1_Init之后调用。uint16_t adc_buf[4]; volatile uint8_t adc_flag 0; void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig {0}; hadc1.Instance ADC1; hadc1.Init.ScanConvMode ADC_SCAN_ENABLE; hadc1.Init.ContinuousConvMode ENABLE; hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion 4; HAL_ADC_Init(hadc1); sConfig.Channel ADC_CHANNEL_0; sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_55CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); sConfig.Channel ADC_CHANNEL_1; sConfig.Rank 2; HAL_ADC_ConfigChannel(hadc1, sConfig); sConfig.Channel ADC_CHANNEL_2; sConfig.Rank 3; HAL_ADC_ConfigChannel(hadc1, sConfig); sConfig.Channel ADC_CHANNEL_3; sConfig.Rank 4; HAL_ADC_ConfigChannel(hadc1, sConfig); } void adc_dma_start(void) { HAL_ADCEx_Calibration_Start(hadc1); HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, 4); }SamplingTime设置成 55.5 周期是稳妥选择四通道一轮总转换时间大约 22.7us对应采样率约 44kHz。如果只做低频信号显示或电池电压监控可以改成 239.5 周期进一步抑制噪声代价是采样率降到约 17kHz。HAL_ADC_Start_DMA启动后每轮转换完成会触发一次 DMA 传输完成中断。HAL 库的回调函数是HAL_ADC_ConvCpltCallback在中断里设置标志位。3.3 串口发送的数据打包与调度主循环里检测到标志位后把adc_buf的内容打成帧发送。用串口发送的整帧数据包含帧头、四个通道的 16 位数据、校验字节和帧尾。这里推荐一种最简单可靠的帧格式uint8_t tx_buf[13]; uint8_t adc_send_flag 0; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { adc_send_flag 1; } } void send_adc_frame(void) { tx_buf[0] 0xAA; // 帧头 tx_buf[1] (adc_buf[0] 8); // 通道0高字节 tx_buf[2] adc_buf[0] 0xFF; tx_buf[3] (adc_buf[1] 8); tx_buf[4] adc_buf[1] 0xFF; tx_buf[5] (adc_buf[2] 8); tx_buf[6] adc_buf[2] 0xFF; tx_buf[7] (adc_buf[3] 8); tx_buf[8] adc_buf[3] 0xFF; tx_buf[9] 0x55; // 帧间隔标识 tx_buf[10] 0x00; tx_buf[11] 0x00; tx_buf[12] 0x0D; // 结束符 HAL_UART_Transmit_DMA(huart1, tx_buf, 13); } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); MX_USART1_UART_Init(); adc_dma_start(); while (1) { if (adc_send_flag) { adc_send_flag 0; send_adc_frame(); } } }HAL_UART_Transmit_DMA是异步发送不会阻塞主循环。帧头 0xAA 用来让对方识别一帧的起始位置帧尾 0x0D 是结束标志。上位机按字节流读取遇到 0xAA 后缓存接下来的 12 个字节遇到 0x0D 再做校验和解析。这样即使串口偶尔丢一个字节下一帧的帧头也能把数据的对齐关系重新拉回来。3.3.1 DMA 串口发送的忙标志问题F103 的 DMA 串口发送有一个常见麻烦DMA 通道发送过程中再次调用HAL_UART_Transmit_DMA会返回 HAL_BUSY数据直接丢。处理办法有两种。一种是查询串口的 DMA 忙标志空闲后再启动新一轮发送另一种是不要每轮都发攒够 N 轮再发一帧牺牲刷新率换取稳定性。代码里可以加一个判断if (__HAL_DMA_GET_FLAG(hdma_usart1_tx, DMA_FLAG_TC1)) { HAL_UART_Transmit_DMA(huart1, tx_buf, 13); }需要注意的是轮询标志位判断忙闲时串口 DMA 发送完成中断本身会置位huart1.gState状态最稳妥是在 DMA 完成中断里再允许发送下一帧否则高频采样时依然可能淹没在 HAL_BUSY 里。4. 数据帧设计与虚拟示波器显示的对齐问题4.1 串口带宽计算先算采样率再选波特率四通道每个通道 16 位数据固定帧开销按 4 字节算一帧约 12 字节。在第三章的配置下每轮转换时间 22.7us串口理论上要接收约 10.6us 就要发完一帧115200 波特率下 12 字节大约需要 1.04ms瓶颈在这里。采样率设定每轮实际耗时串口负担115200bps结论1.5周期采样四通道约4.7us丢帧严重必须降采样率或提高波特率55.5周期采样四通道约22.7us每轮刷新率44kHz串口只有约960Hz合理搭配降轮次或提高波特率239.5周期采样四通道约84us串口仍跟不上建议用921600波特率实际做四通道波形显示时通常每通道几百 Hz 到 1kHz 的刷新率就足够。所以代码里不必在 ADC 转换完成后立刻发送而是做分频发送比如每 20 轮发送一帧等效每通道刷新率约 2.2kHz。发送时DMA 回调和 ADC 转换回调互不干扰数据流就是“高速采集、低速搬运”。4.2 整帧协议里包含通道标识的意义有些简易上位机只按顺序把 8 个字节当两路或四路波形显示不解析帧头。这种情况下如果串口丢了一个字节四路波形会整个左右错位而且错位后永远不会恢复。把 0xAA 帧头加上以后上位机每次从帧头开始解析错位最多影响一帧。串口示波器软件常见的几款虚拟示波器或不带解析协议的数据记录仪工具一般都支持自定义帧格式配置要点是同步字设为 0xAA数据域长度设为 4 个通道各 2 字节字节顺序大端对齐结束符 0x0D。如果软件不支持数据域长度 8 的情况可以做简化处理把每个通道直接按 8 位发送高 8 位舍弃帧长度缩短到 6 字节带宽压力更小。低位 8 位对 3.3V 量程来说分辨率约 13mV对波形轮廓观察完全够用。4.3 电压换算关系与显示量程调整STM32F103 的 ADC 是 12 位内部参考电压基准是 VREF一般接 3.3V所以数字量换算成电压的关系是电压 数字量 / 4096 × 参考电压推导一下如果读到的值是 2048对应电压为 1.65V。但实际板上如果 LDO 输出不是精确 3.3V比如实测 3.28V按 3.3 计算会有约 0.6% 的误差四通道低电压信号看不出来测量 3V 以上电压时偏差达到 20mV这时需要做一次软件校准。常见做法是用高精度万用表量 PA0 的输入电压读 ADC 值后算出实际参考电压// 假设万用表读到 PA0 输入电压为 2.486VADC 值为 3088 float real_vref (float)3088 * 3.3f / 4096.0f; float ref_actual 2.486f * 4096.0f / 3088.0f; // 计算实际参考电压上位机显示时把通道数据除以 4096 再乘ref_actual就能让波形纵轴与实际电压对齐。5. 采样数据漂移、校准和多通道串扰排查5.1 数据整体漂移从电源和参考电压找原因ADC 数据整体缓慢漂移首先盯住 VDDA 和 VREF 的电源纹波。开发板上这两个引脚如果直接连在一起不加电感电容隔离板子上的电机、继电器或大电流负载切换时波形会跟着抖。做法是 VREF 对地用 1uF 陶瓷电容并联 0.1uFVDDA 和 VDD 之间串一个 10R 电阻给模拟部分做一个简易的滤波隔离。还有一种常见漂移来自 PA0~PA3 悬空采集。STM32F103 的 ADC 输入悬空时引脚电压不确定典型值在 1V 到 VDD 之间漂移上位机看到的波形就是一条缓慢无规律的曲线。如果通道不得不悬空建议接一个 10k 下拉电阻或直接把该通道从扫描序列里去掉。5.2 用万用表做三点校准而不是只归一化前面提到的参考电压校正是最简单的偏置校准但在高精度采集场景不够用。因为 STM32F103 的 12 位 ADC 实际线性度在中段较好在接近 0V 和接近满量程处会有明显的非线性尤其是信号源阻抗较大时。三点校准的做法是给通道输入 0V短接到 GND记录 ADC 输出值 offset输入一个稳定的中点电压比如 1.65V记录输出 value;输入接近满量程的电压比如 3.0V记录输出 full。再做分段线性插值float vref_calc[3] {0.0f, 1.65f, 3.0f}; uint16_t adc_calc[3] {offset, mid_val, full_val}; float get_calib_voltage(uint16_t adc_val) { if (adc_val adc_calc[1]) { return (float)(adc_val - adc_calc[0]) * (vref_calc[1] - vref_calc[0]) / (adc_calc[1] - adc_calc[0]); } else { return (float)(adc_val - adc_calc[1]) * (vref_calc[2] - vref_calc[1]) / (adc_calc[2] - adc_calc[1]) vref_calc[1]; } }用这种分段插值拟合四通道之间的一致性会好很多。不过要注意校准数据只对同一次上电有效因为芯片温度变化会引入额外的零点偏移所以工业级别的做法是每次开机跑一遍校准流程把三个点的数据存到 RAM 里。5.3 多通道串扰相邻通道交替采样的异常波形四通道扫描模式下如果通道 1 接高输出阻抗信号通道 2 接高电平信号通道 2 的采样电容电荷可能残留在内部总线上影响通道 1 的下一次转换结果。典型特征是通道 1 的波形上出现大小为 0.5 到 1 个 LSB 的毛刺且毛刺的位置和通道 2 的信号边沿对齐。缓解办法是提高所有通道的采样周期比如统一用 239.5 周期采样。如果必须用高速采样就在高阻信号源上并一个 100nF 电容让采样电容在极短时间内能从外部快速补充电荷。另外通道扫描顺序可以调整把高阻通道放到整个序列的最后因为下一轮的第一通道转换前采样保持电容重新连接外部信号源有更长的准备时间。5.4 用定时器触发 ADC 而不是软件连续转换需要精确定时采样时建议将ExternalTrigConv改为定时器触发典型的做法是用 TIM2 的更新事件触发 ADC1。CubeMX 里将 TIM2 的触发事件选中为TRGO更新事件同时在 ADC1 的ExternalTrigConv选择TIM2_TRGO。程序里先启动定时器再启动 ADC 连续 DMA 模式。这样做的优势是采样间隔由硬件定时器保证不受主循环和串口中断影响上位机看到的相邻采样点间隔均匀波形的横轴时间标尺才可信。具体实现时TIM2 的预分频器和自动重载值决定采样周期。要得到 1kHz 的采样率72MHz 时钟下可直接配置PSC 71, ARR 999得到定时器更新频率 1kHz。注意 ARR 值的计数模式选择向上计数触发事件选择更新事件模式。6. 波形噪点过滤与上位机显示前的数据整形6.1 串口示波器显示高频毛刺时的滑窗滤波ADC 本身转换结果比较稳定但被测信号如果包含高频干扰串口示波器上会看到波形变粗甚至出现异常尖峰。此时优先考虑硬件 RC 低通滤波在 ADC 引脚前串联 100R 电阻并联 1nF 电容截止频率约 1.6MHz对 1kHz 以下的采集任务非常足够。软件层面对每路数据做滑窗中值滤波比较适合串口示波器这类人眼观察的场景#define FILTER_WINDOW 5 uint16_t adc_filter(uint16_t new_val, uint8_t ch) { static uint16_t buf[4][FILTER_WINDOW]; static uint8_t index[4]; uint16_t temp[FILTER_WINDOW]; uint8_t i, j; buf[ch][index[ch]] new_val; index[ch] (index[ch] 1) % FILTER_WINDOW; for (i 0; i FILTER_WINDOW; i) { temp[i] buf[ch][i]; } // 简单的冒泡排序获取中位数 for (i 0; i FILTER_WINDOW - 1; i) { for (j i 1; j FILTER_WINDOW; j) { if (temp[j] temp[i]) { uint16_t tmp temp[i]; temp[i] temp[j]; temp[j] tmp; } } } return temp[FILTER_WINDOW / 2]; }窗口长度取值 5 对延迟影响约为 5 个采样周期观察缓慢变化的电压波形完全感觉不到滞后。6.2 波形时间轴标定用已知方波验证采样率串口示波器上横轴的时间单位如果不准波形判断就没有意义。手头有信号发生器的话给某个通道输入一个 100Hz 方波幅度 0~3.3V对方波上升沿到下一个上升沿之间的采样点数 n 做统计。如果当前采样率设为 1kHz那么每个方波周期应有 10 个采样点。如果实际显示的是 20 个点说明采样率实际为设置值的一半问题一般出在定时器分频配置。手头没有信号发生器也可以用 PWM 引脚输出一个已知频率的方波来做同样的验证。6.3 把数据记录成 CSV 回放检查虚拟示波器的实时波形一闪而过细节排查时可以把串口数据保存下来再用表格软件或数据处理脚本离线画图。最常见的做法是上位机软件里勾选“数据记录”把串口收到的原始字节存为 CSV 文本然后用脚本来解析帧头import serial ser serial.Serial(COM3, 115200, timeout0.1) with open(adc_log.csv, w) as f: f.write(ch0,ch1,ch2,ch3\n) while True: data ser.read(13) if len(data) 13 and data[0] 0xAA and data[12] 0x0D: ch0 (data[1] 8) | data[2] ch1 (data[3] 8) | data[4] ch2 (data[5] 8) | data[6] ch3 (data[7] 8) | data[8] f.write(f{ch0 * 3.3 / 4096:.3f},{ch1 * 3.3 / 4096:.3f}, f{ch2 * 3.3 / 4096:.3f},{ch3 * 3.3 / 4096:.3f}\n) f.flush()这段脚本用 pyserial 打开串口每次尝试读取 13 字节的整帧帧头帧尾校验通过就把 4 个通道换算成电压写入 CSV。调试时把 CSV 导入表格软件画折线图能很清楚地看到每个通道的波形细节和通道间的相位关系。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进