前阵子调一块电源监控板七路模拟信号要同时送进 STM32F4三路电池电压、两路电流采样、一路温度、一路备用通道。我一开始想得很简单一个 ADC 扫描到底——ADC1 一口气配七个通道DMA 把数据搬出来完事。真跑起来才发现单 ADC 扫描七通道每一轮转换周期长不说各路数据的时间戳还是错开的后面想再算同一时刻的电压×电流就特别别扭。后来改成双重 ADC 多通道配合 DMA 的架构数据干净了时序也稳了。这篇文章把从方案选型到寄存器/HAL 配置再到实测中踩过的坑完整写一遍适合正在做多路模拟采集、又不想被数据错位问题反复折磨的开发者参考。1. 七路信号为什么值得动用两个ADC1.1 单ADC扫描七路的真实瓶颈单 ADC 连续扫描七路逻辑上没有任何问题把七个通道按 Rank 17 排进规则组打开扫描模式转换完一轮DMA 把七个数按顺序搬进缓冲区。但它有两条硬伤。第一条是时序。规则组的转换是严格串行的Rank1 转换完才开始 Rank2Rank2 完再 Rank3。这意味着第 1 路的采样时刻和最后一路的采样时刻之间差了整整六个通道的转换时间。假设每个通道 1.3 µs那两端的采样时刻就差了将近 8 µs。对纯电压监控来说 8 µs 无所谓但如果是电压和电流同步采样、算瞬时功率这种应用这 8 µs 里的电流纹波足以让你的功率值抖成一片。第二条是吞吐量。单 ADC 全扫描七路数据率上限被锁死在单个 ADC 的转换时间上。你想提高采样率要么降低采样周期要么牺牲分辨率反正没有别的空间。STM32F4 上白白放着一个 ADC2 不用单靠 ADC1 硬扛怎么想都亏。1.2 双重ADC的三种打开方式别一上来就选错F4 系列的双 ADC 支持并不是简单地把两个 ADC 都使能就行它有三种典型用法效果完全不同。第一种是独立运行。ADC1 和 ADC2 各自配置各自的规则组、各自跑扫描、各自走 DMA互不干涉。想采七路不同的信号就把七路拆成 4 3 或者 3 4分别塞给两个 ADC。这是最常见的双重 ADC 多通道玩法简单、稳定、可维护。第二种是规则同时采样regular simultaneous。ADC1 做主ADC2 做从两者转换的是同一个序列、同一批通道同时打两个样。每个采样点得到两份数据一份来自 ADC1一份来自 ADC2硬件自动打包在同一个 32 位字里。它的价值是冗余和平均——两个 ADC 采同一个物理信号取平均可以压掉一部分噪声或者做故障诊断时发现两路结果差异过大就知道链路出问题了。注意它并不能让你同时采十四路不同的信号因为两个 ADC 的输入通道编号对应的是同一个引脚。第三种是交叉采样interleaved。两个 ADC 轮流采同一个通道等效采样率翻倍适合单通道高速采集。对七路监控场景用处不大我这次没有用。所以标题里双重 ADC具体走哪条路取决于你要的是七路不同信号还是同一信号的冗余双份。前者用独立运行后者用规则同时采样。我自己的方案是两阶段先用独立运行把七路跑通后来为了功率计算又加了规则同时采样后文两种配置都给出来。2. 引脚、时钟和转换周期动手前先算这笔账2.1 七路信号怎么挂到ADC输入通道STM32F4 的 ADC1 和 ADC2 输入通道对应关系基本一致PA0PA7 分别是 IN0IN7PB0/PB1 是 IN8/IN9PC0PC5 是 IN10IN15。要接七路外部信号最省事的是用 PA0PA6七个引脚刚好够。我在板子上这样分配的ADC1 负责 PA0PA3IN0IN3ADC2 负责 PA4PA6IN4IN6。这样做的原因很简单两个 ADC 的管理范围清楚出问题的时候排查起来不用来回翻映射表。信号用途引脚ADC 实例输入通道电池电压 1PA0ADC1IN0电池电压 2PA1ADC1IN1电池电压 3PA2ADC1IN2电流采样 1PA3ADC1IN3电流采样 2PA4ADC2IN4温度信号PA5ADC2IN5备用通道PA6ADC2IN6有一点容易被忽略这些引脚必须配置成模拟模式GPIO_MODE_ANALOG否则 ADC 读到的是引脚驱动器的残余电平干扰。用过 CubeMX 图形化配置工具的人可能感受不到这一步但手写寄存器或者照搬旧工程时漏掉这条线会让数据飘得没有规律。2.2 ADC时钟分频和转换周期的核算F4 的 ADC 时钟上限是 36 MHz它挂在 APB2 上。假设系统主频 168 MHz、APB2 为 84 MHz那么 ADC 预分频只能选 /4、/6、/8 这一档往下/2 会得到 42 MHz已经超规格长时间跑可靠性没有保证。我选的 /4ADC 时钟 21 MHz留出充足裕量。转换周期的公式很简单转换周期数 采样周期 分辨率位数12 位就是 12实际转换时间 转换周期数 ÷ ADC 时钟频率我默认用 15 个采样周期换算下来每个通道是(15 12) / 21 MHz ≈ 1.286 µs七个通道一轮就是 9 µs 左右。这个数字听起来挺快但要注意它意味着 DMA 每 9 µs 就写完一轮数据。如果代码里还想着在主循环轮询读缓冲区大概率读到的已经是下一轮的数据了后面第五节我会专门说缓冲区怎么处理。不同采样周期的代价差异很大给个对照采样周期cycles单通道总周期单通道耗时21MHz七通道一轮3150.714 µs5 µs15271.286 µs9 µs56683.238 µs22.7 µs84964.571 µs32 µs48049223.43 µs164 µs我的经验是如果信号源输出阻抗偏高或者走线长、对地电容大15 周期经常不够采样电容还没充满就切走了典型表现是数据跟着上一通道串扰。这时把采样周期调到 56 甚至 84问题往往立刻消失代价只是速度慢一点。监控类应用完全不在乎这几十微秒。3. 双重ADC多通道DMA的配置拆解3.1 方式AADC1四路加ADC2三路独立运行先看最常用的独立运行配置。ADC1 四通道、ADC2 三通道各自扫描、各自连续转换、各自用 DMA 循环搬运。以 HAL 库为例ADC1 的初始化核心是这样ADC_HandleTypeDef hadc1; DMA_HandleTypeDef hdma_adc1; uint16_t adc1_data[4] {0}; uint16_t adc2_data[3] {0}; void MX_ADC1_Init(void) { __HAL_RCC_ADC1_CLK_ENABLE(); __HAL_RCC_DMA2_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); hadc1.Instance ADC1; hadc1.Init.ClockPrescaler ADC_CLOCK_SYNC_PCLK_DIV4; /* 21MHz */ hadc1.Init.Resolution ADC_RESOLUTION_12B; hadc1.Init.ScanConvMode ADC_SCAN_ENABLE; /* 扫描模式 */ hadc1.Init.ContinuousConvMode ENABLE; /* 连续转换 */ hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.NbrOfConversion 4; /* 规则组长度 */ hadc1.Init.ExternalTrigConvEdge ADC_EXTERNALTRIGCONVEDGE_NONE; hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.DMAContinuousRequests ENABLE; HAL_ADC_Init(hadc1); ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel ADC_CHANNEL_0; sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_15CYCLES; HAL_ADC_ConfigChannel(hadc1, sConfig); /* Rank 2~4 依次配 ADC_CHANNEL_1/2/3代码形式相同 */ }ADC2 的配置只是把 Instance 换成 ADC2通道换成 ADC_CHANNEL_4/5/6NbrOfConversion 改成 3其他完全一致。这里最容易出问题的是ScanConvMode和ContinuousConvMode这两个开关忘开扫描会导致七个 DMA 结果全是第一个通道的值这个坑第四部分细说。DMA 部分要特别注意F4 上每个外设的 DMA 请求映射是固定的ADC1 和 ADC2 走的 DMA 流不一样HAL 初始化 DMA 时除了Instance哪个 Stream还要设定Init.Channel请求映射通道号两个都要对。网上很多工程模板只写了 Stream 没写 ChannelDMA 根本不响应 ADC 请求程序看起来在跑数据却一动不动。我这边 ADC1 配 DMA2 Stream0ADC2 配 DMA2 Stream2具体到你的芯片型号务必翻开参考手册的 DMA 请求映射表核对一遍不同系列的 F4 存在差异。void MX_DMA_ADC1_Init(void) { hdma_adc1.Instance DMA2_Stream0; hdma_adc1.Init.Channel DMA_CHANNEL_0; hdma_adc1.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc DMA_PINC_DISABLE; hdma_adc1.Init.MemInc DMA_MINC_ENABLE; hdma_adc1.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode DMA_CIRCULAR; hdma_adc1.Init.Priority DMA_PRIORITY_HIGH; hdma_adc1.Init.FIFOMode DMA_FIFOMODE_DISABLE; HAL_DMA_Init(hdma_adc1); __HAL_LINKDMA(hadc1, DMA_Handle, hdma_adc1); }启动的写法HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc1_data, 4); HAL_ADC_Start_DMA(hadc2, (uint32_t *)adc2_data, 3);之后adc1_data[0] ~ adc1_data[3]永远保持最新一轮的四个通道值adc2_data[0] ~ adc2_data[2]是另外三路。3.2 方式B规则同时采样一份序列换两份数据如果你需要的是冗余或平均就用规则同时采样。此时 ADC1 是主ADC2 是从序列只配置在 ADC1 上ADC2 的规则组配置会被忽略它跟着 ADC1 同步转换同一个通道。但两边采样时间最好手动对齐否则两个 ADC 采同一个信号采样窗口不一样出来的两笔数据本身就不该相等平均也就失去了意义。uint32_t dual_data[7] {0}; /* 关键必须是32位 */ hadc1.Init.Multimode ADC_DUALMODE_REGSIMULT; HAL_ADC_Init(hadc1); /* ADC2 的 Init.Multimode 也要设为同一模式不同 HAL 版本写法略有差异 */ hadc2.Init.Multimode ADC_DUALMODE_REGSIMULT; HAL_ADC_Init(hadc2);启动时只挂一路 DMAHAL_ADC_Start_DMA(hadc1, (uint32_t *)dual_data, 7);取数据的时候每个dual_data[i]的低 16 位是 ADC1 的结果高 16 位是 ADC2 的结果uint16_t adc1_v (uint16_t)(dual_data[i] 0xFFFF); uint16_t adc2_v (uint16_t)(dual_data[i] 16);我已经在两种模式下实测过独立模式适合七路不同信号同时采样模式适合同一信号求平均。如果你的七路信号不需要成对冗余别为了双重两个字硬上同时采样否则最后会发现只是把七个数变成了七对一样的数白白浪费 ADC2 的通道能力。3.3 DMA缓冲区与数据排布的对应关系独立模式下缓冲区里每个元素的顺序就是规则组的 Rank 顺序。adc1_data[0]对应 Rank1也就是 IN0这个映射关系在配置时就要刻进脑子里不然调试的时候必然对着数组下标猜半天。同时采样模式下DMA 的搬运单位是 32 位字缓冲区长度和序列通道数一致。这里最容易翻车的是有人图省事复制独立模式的代码缓冲区还声明成uint16_t结果 DMA 每搬一次写 32 位内存里一半数据被覆盖高 16 位和低 16 位互相踩踏读出来的数据像打乱的拼图。记住一句话独立模式用 16 位数组同时采样模式用 32 位数组。4. 数据错位和读数异常的排查思路这部分是重头戏。七路 ADC 的配置本身不复杂真正花时间的全是各种看起来正常、实际数据错位的诡异现象。我把实测中遇到的和周围人问过最多的六类问题整理成一张排查表后面再展开讲判断思路。异常现象直接原因处理办法所有通道读到的都是第一个通道的值扫描模式没打开配置 ScanConvMode 使能数据只有第一次正确之后不再更新连续转换没打开或 DMA 不是循环模式开 ContinuousConvMode DMA_CIRCULAR通道顺序错乱NbrOfConversion 与实际配置的 Rank 数不一致核对规则组长度和序列配置数据整体偏大或偏小几格ADC 未校准启动前调用校准单次转换前完成某几路数据跟着上一路串扰采样周期太短采样电容未充满调大 SMP或降低信号源阻抗DMA 一直不更新Init.Channel 与参考手册映射不符核对 DMA 流和通道号4.1 扫描没开七个值全是第一通道这是最经典的低级错误。ADC 在非扫描模式下规则组只转换 Rank1转换完就停了。DMA 一搬搬的全是同一个通道的重复值。如果你看到七路数据完全一样、而且跟第一路输入信号吻合先别怀疑硬件直接去看ScanConvMode。HAL 库不同版本里这个宏的写法还不一样老版本是ADC_SCAN_ENABLE新版本有的变成ADC_SCAN_DIRECTION_FORWARD编译不过的时候不要硬改查一下手头头文件里定义的是什么。4.2 半字和全字错配导致的高位异常用规则同时采样模式却仍然用 16 位 DMA 配置这是我实际踩过的。症状是 ADC2 的数据完全消失或者 ADC1 的数据偶尔跳一个巨大的值。因为 DMA 以半字搬运时每次从 32 位数据寄存器里只取低 16 位硬件打包的高 16 位 ADC2 结果被丢掉了而缓冲区长度如果还按 7 算DMA 搬运的节奏和 ADC 产生数据的节奏错开低 16 位里夹杂的其实是上一次转换的残留位。解决方式很干脆同时采样一律 32 位数组 字对齐的 DMA 配置。4.3 规则组长度与序列配置不一致NbrOfConversion等于几硬件就认为规则组有几段。如果你写了四个 Rank却把长度配成 7硬件会按 SQR 寄存器里残存的无效值继续转换读出来的通道排布完全对不上。反过来长度配成 3、却写了四个 Rank第四个 Rank 被忽略。我的习惯是每配一个通道就数一遍长度两个数字必须完全吻合这是整个配置里最不需要技术、但最需要耐心的检查项。4.4 GPIO 没配成模拟模式这个坑我栽过一次症状很隐蔽单独测每一路都正常七路一起跑就有几路轻微跳动。原因是引脚复用没切到模拟模式GPIO 的施密特触发器还接着输入电路ADC 采样时额外耦合了引脚电平变化。进入模拟模式后输入缓冲器被断开ADC 量到的才是纯模拟信号。检查GPIO_InitStruct.Mode GPIO_MODE_ANALOG这一行很多人会配成GPIO_MODE_INPUT或者干脆漏配。4.5 采样时间不足带来的通道串扰多路 ADC 最隐蔽的问题就是串扰。如果某一路的信号源输出阻抗很高而采样周期又设得很短采样电容在开关闭合的一瞬间来不及充满测到的其实是上一通道残留的电荷。症状是第 N 路数据总像第 N-1 路的拖尾尤其信号源内阻大的时候特别明显。解决办法按优先级排序把信号源阻抗降下来加一级运放跟随、对地加一个小电容稳定采样电容取电、或者直接把采样周期拉长到 56 cycles 以上。我这次在温度信号上遇到了最初用的是 15 cycles数据总是跟着前一通道的电流采样漂改成 56 cycles 后立刻稳定。4.6 初始化顺序与校准时机F4 系列 ADC 上电后需要做一次校准否则零点偏移通常是几个 LSB。校准必须在 ADC 未开始转换时调用而且要等校准结束再开启 DMAHAL_ADCEx_Calibration_Start(hadc1); HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc1_data, 4);顺序反了的话校准会一直挂起或者返回异常状态。这个函数很不起眼但少了它后面做电压换算时零点老是差十几个毫伏排查半天还以为电阻分压标定错了。5. DMA缓冲区策略与采样时序的取舍5.1 连续转换的最高速率与实时性陷阱按前面算的21 MHz 的 ADC 时钟、15 周期采样七通道独立模式一轮只要 9 µs也就是每秒大约 11 万轮。听起来很爽但这里有个陷阱——DMA 环形缓冲区转得飞快主循环里随便一个memcpy或者printf等它执行完缓冲区已经至少被覆盖了一轮。你读到的数组里adc1_data[0]可能是第 1000 轮的数据adc1_data[1]却已经是第 1001 轮的数据了各路之间压根不在同一时刻。5.2 半传输中断做双缓冲消费处理环形缓冲区最稳的办法是双缓冲套路把 DMA 传输长度看作一整个窗口窗口前半段写完后触发半传输中断窗口后半段写完后触发传输完成中断。主循环只在上一个半段写完、下一个半段还没写完的间隙里消费数据这样读写不会相互踩踏。void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { process_flag_half 1; /* 前半段数据可用 */ } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { process_flag_full 1; /* 后半段数据可用 */ } }注意一个细节DMA 半传输中断在传输长度是奇数时触发点会落在中间附近不会精确到通道边界。所以七通道数据我建议把传输长度设成 8多留一个空洞不参与计算半传输触发点就是第 4 个 16 位字正好在两个通道之间逻辑干净。5.3 物理量换算不要在主循环里反复除法ADC 原始值是 04095 的整数要变成电压得乘基准再除满量程。监控类项目里我习惯在 DMA 回调里只做一次换算保存成浮点数组主循环直接查数组避免每次读原始值又除一遍。换算公式看起来很基础但基准电压的精度直接影响结果如果你用的 3.3 V 基准实际是 3.28 V所有通道都会系统性偏低约 0.6%。对精度有要求时可以启用片内 VREFINT 通道做在线基准校正这个功能只有 ADC1 有规划引脚时要提前留出来。6. 实测效果与后续扩展方向6.1 实测时序与数据表现我最终定型的配置是ADC 时钟 21 MHz采样周期 15 cyclesADC1 四通道 ADC2 三通道独立运行DMA 循环双缓冲缓冲区长度每侧取 8。实测七通道一轮约 9 µs连续采样稳定跑了一整晚数据没有出现过跳变、错位或通道互串。温度通道因为信号源内阻偏高单独改用 56 cycles 采样其他六路保持 15 cycles 不变——每个通道的采样时间本来就是独立配置的这种按需分配是扫描模式的天然优势。值得一提的坑是ADC1 和 ADC2 的 DMA 都挂在 DMA2 上如果工程里还有 SDIO 之类也占用 DMA2 的外设需要仔细核对 Stream 冲突。我这次 SDIO 用了一个 Stream恰好和 ADC1 的映射相邻配置时差点写串后来查参考手册的映射表才发现。DMA 资源分配这种事提前画一张表比事后对着寄存器猜快得多。6.2 超过七路怎么办如果后续要加到八路以上独立模式可以直接把 ADC3 也用起来ADC3 最多也能扫 16 个外部通道。不过 ADC3 不参与双重 ADC 的双重模式只能独立跑。分配上尽量保持一个原则把相关信号放进同一个 ADC例如需要同批采样的电压电流通道都归 ADC1独立监控的温度压力归 ADC2/ADC3这样逻辑清晰后期加触发同步时也好处理。如果 ADC1、ADC2、ADC3 三个都用满还不够F4 还有两个内置通道可以用温度传感器和内部基准电压参考但它们只挂在 ADC1 上同一段规则组里混入内部通道会挤占外部通道名额使用时要有心理准备。6.3 定时器触发与真正的同步采样独立模式虽然让两个 ADC 各扫各的但 ADC1 和 ADC2 的软件触发毕竟有先后两边的数据严格来说仍然差了几个微秒。如果将来要做高精度的电压电流同步功率计算建议改成定时器触发用一个定时器的输出触发信号同时触发 ADC1 和 ADC2 开始规则组转换两边在同一时刻打第一枪之后各自扫描各自的序列后续每个通道的时间差变得完全可控。我在这个项目里最终没有上定时器触发因为监控场景 8 µs 的偏差完全可以接受。但如果你要做的应用对相位差敏感这一步是绕不开的。双重 ADC 的真正价值不只是多几个通道而是让多路信号的时间关系变得可控——这一点在选型时想清楚后面就能少走很多弯路。最后分享一个我反复吃过的教训不要为了追求配置的高级感去开连续转换加环形 DMA。如果项目只需要每 10 ms 刷新一次监控数据用软件触发一轮扫描、DMA 搬完一组数据就停主循环拿到的是绝对干净的一轮快照整个系统也省电得多。连续采样的高吞吐量是给波形采集这类真正需要它的人准备的监控类应用用上它只会换来一堆需要在回调里小心维护的缓冲区边界问题。