简介:STM32F429驱动两片AD7689的完整工程源码,面向嵌入式开发与电力信号采集场景,解决16通道高精度同步采样需求。工程基于Cortex-M4内核与16位并行ADC架构,支持双片并联,采样速率约50kHz,足以无失真采集50/60Hz工频信号。资源包共264个文件、约9.11MB,以C源文件、H头文件、编译中间文件(o、crf、d)及Keil工程配置为主,目录结构清晰,适合直接编译学习或二次开发。已有1590人学习/下载。代码覆盖DMA传输、中断采样、并行时序优化等关键设计,并体现驱动层与应用层分离的思路,可帮助读者掌握双AD7689同步采集的软件架构,为降低CPU占用率提供可落地的优化参考。 手里在做一台16路模拟量采集板,信号从工业传感器出来,经过二级运放后送给ADC,精度要求16位,带宽从直流到几十kHz,还要求16路能够同时触发采样。第一版方案选的是ADI的AD7689,8通道、16位、250kSPS、SPI接口,一片不够就上两片,用两片AD7689配STM32F429来做。真正动手后发现,两片AD7689的连接方式、时序配置、数据拼帧,网上资料很散,很多例程连菊花链需要多少个SCK都写错。这篇把整个调通过程、关键选择和踩过的坑完整记录一下,给做多通道高速采集的同行省点时间。
1. 为什么是两片AD7689:选型逻辑与整体方案
1.1 16路采集需求怎么落到两片ADC上
需求其实很明确:16路模拟量,每路都要16位精度,通道之间要能同时启动转换,避免相位差。如果只用一颗多通道ADC,32通道的SAR ADC选择非常少,价格也高;用两颗8通道ADC是最常规的思路。
AD7689单颗250kSPS,两片理论上有500kSPS的总吞吐,每片内部有8通道MUX,分时轮询时每通道平均约31kSPS,对几十kHz带宽以内的信号完全够用。更重要的是,它的SPI接口支持菊花链(daisy-chain)模式,两片可以级联成16通道,用一组SPI就够,还能保证两片同时CNV触发。
如果项目里每通道采样率要求特别高,比如单通道150kSPS以上,那两片AD7689这种8通道分时结构就吃力了,应该考虑每通道独立ADC。但对于绝大多数传感器采集、多路巡检场景,两片AD7689的性价比和电路复杂度是最平衡的。
1.2 AD7689关键参数与选型理由
AD7689的关键参数我列在下面:
| 参数 | 数值 | 备注 |
|---|---|---|
| 分辨率 | 16位 | 无失码 |
| 通道数 | 8通道 | 可软件配置为单端/差分 |
| 吞吐率 | 250kSPS | SAR架构 |
| 接口 | SPI | 支持3线/4线、链式、菊花链 |
| 参考电压 | 内置2.5V | 可切换外部参考 |
| 供电范围 | 2.3V~5.5V | AVDD/DVDD/VIO |
| 输入类型 | 8路单端或4路差分 | 软件寄存器配置 |
板上已有5V模拟电源,AVDD用5V,VIO接3.3V,逻辑电平直接和STM32匹配,不需要额外电平转换。SAR ADC采样率250kSPS不算高,但16位精度加上内置MUX,在同类芯片里接口简单、外围省事,这是选它的核心原因。
1.3 为什么是菊花链而不是两路独立SPI
两片独立SPI看起来也不复杂,F429的SPI不止一个,SCK、MOSI、MISO各接一组,再配两套CS就行。但实际操作下来有两个问题。
第一,两片ADC需要两套片选和使能逻辑,占用的GPIO多,如果是多层板还好,两层板布线会挤。第二,也是更要命的,两片独立SPI很难保证同时CNV触发。除非给两个片分别接定时器PWM输出,还要校准两个PWM通道的相位差,不然两片的采样时刻对不齐,对要求同步采集的场景是硬伤。
菊花链模式下,两片的SCK、MOSI并联,CNV和CS也并联,U1的SDO接U2的SDI,U2的SDO再接回STM32的MISO。一个CNV脉冲同时启动两片采样,天然同步,一组SPI接口搞定16通道。这个方案我从一开始就定了,后面调试也证明是正确选择。
2. 两片AD7689的硬件连接:从引脚分配到参考电压
2.1 菊花链引脚连接与数据流向
连接方式不复杂,我直接说清楚:
- SPI1_SCK(PA5)同时接到U1和U2的SCK;
- SPI1_MOSI(PA7)接到U1的SDI;
- U1的SDO接到U2的SDI;
- U2的SDO接到SPI1_MISO(PA6);
- U1的CNV和U2的CNV并联,接到一个GPIO;
- U1的CS和U2的CS并联,接到另一个GPIO。
这里有个容易懵的地方:数据流方向。MCU发时钟时,U1的转换结果先从U1.SDO移出,进入U2.SDI,再跟着U2自己的结果一起从U2.SDO回到MCU。所以MCU先收到的是U2的数据,其次才是U1的数据。命名的时候我把靠近MISO的那片叫U2,靠近MOSI的那片叫U1,后面所有解析代码都按这个顺序写。
真正动手时,CS和CNV不要合并成一个引脚。虽然AD7689在CS模式下可以CS和CNV共用一个信号,但菊花链模式要求CS在整个转换和移位期间保持低电平,分开控制时序才干净。我用PA4做CS,PB0做CNV,两个GPIO都是普通推挽输出。
2.2 参考电压与基准源选型
AD7689内部自带2.5V基准,省事,但内部基准的温漂和初始精度一般,做16位数据采集我建议外部基准。我用了ADR4525,低噪声2.5V输出,温漂很小。
REF引脚接外部基准后,每个芯片的REF引脚放一个10uF+100nF去耦电容。REFCAP引脚的电容也别忘了,我用的是4.7uF,位置尽量靠近芯片。基准输出到ADC的走线不要拉长线,最好有完整地平面包围,否则基准噪声会直接叠加到转换结果上。
顺便说一句,如果对精度要求不高,内部基准也能用,先把功能跑通完全没问题。但计量级采集,外部基准的钱不能省。
2.3 前端调理与电源去耦的工程细节
AD7689的输入是开关电容结构,等效输入阻抗不高,如果直接接高阻信号源,采样建立时间不够,会看到码值随信号源内阻变化。我在每路输入前加了一级运放缓冲,用ADA4612,轨到轨输出,后面串100Ω电阻再接1nF对地电容,低通截止频率约1.6MHz,对几十kHz信号没有影响,还能抑制高频噪声。
电源上,AVDD、DVDD、VIO各放一个10uF陶瓷电容加100nF高频电容。AGND和DGND在PCB底层单点相连,ADC芯片正下方不要走数字信号线。第一版板子我图省事,数字地模拟地整片铺铜直接连通,结果噪声大了一圈,改成单点连接后明显好转。这类细节对16位系统影响很大。
3. F429端SPI初始化:两个位决定成败
3.1 AD7689时序要求与SCK极性分析
STM32F429系统时钟180MHz,APB2为90MHz,SPI1挂在APB2上,最高45MHz。AD7689的SCK最高支持50MHz,所以直接配置SPI1的波特率预分频为2,SCK跑45MHz。
最关键的是CPOL和CPHA。AD7689的SDO在SCK下降沿更新数据,在上升沿数据稳定,所以主机应该在上升沿采样,对应配置就是CPOL=0、CPHA=0,也就是SCK空闲为低、第一个边沿采样。
很多例程从别的ADC抄配置,用了CPHA=1,结果主机在SCK下降沿采样,正好采到数据在翻转,读回来的码值乱七八糟。这是最典型的翻车点,调试时如果发现数据完全不对,先查这两个位。
时序节奏上,CNV上升沿启动转换,CNV高电平时间至少10ns,实际我给100ns;CNV拉低后必须等转换完成,AD7689典型转换时间是720ns,保险起见延时1us再发SCK。这个芯片没有BUSY引脚,所以只能固定延时,时序上多一点没坏处。
3.2 SPI初始化代码
初始化代码我用HAL库,这样看起来直接:
void AD7689_SPI_Init(void) { GPIO_InitTypeDef gpio; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_SPI1_CLK_ENABLE(); /* PA5=SCK, PA6=MISO, PA7=MOSI, 复用AF5 */ gpio.Pin = GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_VERY_HIGH; gpio.Alternate = GPIO_AF5_SPI1; HAL_GPIO_Init(GPIOA, &gpio); /* PA4 = CS */ gpio.Pin = GPIO_PIN_4; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_VERY_HIGH; HAL_GPIO_Init(GPIOA, &gpio); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); /* PB0 = CNV */ gpio.Pin = GPIO_PIN_0; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; HAL_GPIO_Init(GPIOB, &gpio); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; HAL_SPI_Init(&hspi1); }如果板子上CS和CNV接的引脚不同,改GPIO编号就行,SPI部分不用动。注意MISO引脚我是按照原理图编的,有冲突就换SPI2之类的其它外设。
3.3 为什么用8位帧加DMA而不是16位帧
读两片AD7689菊花链的结果,需要33个SCK,因为两片是16位×2=32位有效数据,外加1个无效位。33不是8的倍数,也不是16的倍数。
F429的SPI如果配16位帧,DMA一传就是16的整数倍SCK,很难凑出33个。配8位帧就好办,DMA传5字节就是40个SCK,覆盖33个,多出的7个SCK不影响数据。
有人会担心多发7个SCK会把数据冲掉,这里解释一下:有效数据在33个SCK期间已经全部从U2.SDO移出,后面再多发的时钟只是把主机发出的0x00推入链路,SDO输出变成0,前面已经收到的33位不会改变。所以用5字节DMA既满足时序要求,代码又简单。
DMA配置要点:
- SPI1_RX用DMA2_Stream0,Channel3,方向外设到内存,字节对齐;
- SPI1_TX用DMA2_Stream3,Channel3,方向内存到外设,字节对齐;
- 用HAL_SPI_TransmitReceive_DMA同时启动收发,txBuf内容全0;
- DMA传输完成中断里拉高CS,置一个标志位。
4. 从CNV脉冲到16通道数据:一次转换的完整数据流
4.1 一次转换的状态序列
完整的采集流程我整理成一个固定序列:
- CS拉低;
- CNV拉高,延时100ns,CNV拉低,此时两片同时启动当前通道的采样保持和转换;
- 延时1us等待转换完成;
- 启动SPI DMA,发5字节0x00,同时接收5字节;
- DMA完成中断里CS拉高,置dataReady标志;
- 主循环解析位流:跳过第1位无效位,连续读两个16位。
这个流程里最容易被忽略的是CS拉低的时机。菊花链模式下,CS要从CNV脉冲开始到SCK结束全程保持低。如果你把CS和CNV接在一起拉高拉低,转换结果锁存时序会乱,数据根本读不对。
顺序上先CS低再CNV脉冲,还是先CNV脉冲再CS低,我实测都能工作,但保持CS低到SCK结束是硬要求。我在结构体里额外记录了一个错误计数,一旦CS时序异常,计数会增加,方便排查。
4.2 数据拼帧代码
解析位流我用逐位方式,逻辑清楚,不依赖字节对齐:
#define AD7689_CHAIN_NUM 2 #define AD7689_TOTAL_BITS (AD7689_CHAIN_NUM * 16 + 1) // 33 #define AD7689_DMA_BYTES ((AD7689_TOTAL_BITS + 7) / 8) // 5 uint8_t ad7689_tx[AD7689_DMA_BYTES] = {0}; uint8_t ad7689_rx[AD7689_DMA_BYTES] = {0}; volatile uint8_t ad7689_data_ready = 0; uint16_t AD7689_ReadFrame(const uint8_t *buf, int *bitPos) { uint16_t val = 0; for (int i = 0; i < 16; i++) { int byteIndex = (*bitPos) >> 3; int bitIndex = 7 - ((*bitPos) & 7); val = (uint16_t)((val << 1) | ((buf[byteIndex] >> bitIndex) & 0x01)); (*bitPos)++; } return val; } void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // CS拉高 ad7689_data_ready = 1; } } void AD7689_UpdateChannels(void) { if (!ad7689_data_ready) return; ad7689_data_ready = 0; int bitPos = 1; // 跳过第一个无效位 uint16_t u2 = AD7689_ReadFrame(ad7689_rx, &bitPos); // 靠MISO那片 uint16_t u1 = AD7689_ReadFrame(ad7689_rx, &bitPos); // 靠MOSI那片 /* u1对应8个通道,u2对应另外8个通道,按需填充 */ }启动一次DMA的调用也很直接:
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // CS低 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET); // CNV高 delay_ns(100); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); // CNV低 delay_us(1); HAL_SPI_TransmitReceive_DMA(&hspi1, ad7689_tx, ad7689_rx, AD7689_DMA_BYTES);如果你不想用DMA,阻塞式发送5字节也能工作,只要把CS管理好。DMA唯一的好处是CPU不用死等,数据到了中断里置位就行。
4.3 采样周期与DMA中断的配合
DMA传5字节需要40个SCK,45MHz下大约0.89us,加1us转换等待、100ns CNV脉冲,单次周期约2us,对应500kSPS,已经超过单颗AD7689的250kSPS上限。所以实际使用时要在流程末尾加延时,把周期压到4us以上,也就是把采样率控制在235kSPS左右,留一点余量。
我把这个延时常量做成可调,调试时先用4.5us,稳定后再往下压。DMA中断处理里只拉CS和置标志,不要做数据解析,否则中断时间会把采样周期拉长,还会影响其它中断实时性。解析放主循环,一次转换解析一次,时间完全够。
5. 实测数据与异常排查:从全FF到错位
5.1 判定链路是否正常的第一步
板子第一次跑通时,我把U1的IN0接1.000V,U2的IN0接2.000V,其余通道接地,内部参考2.5V。1.000V理论上应读到0x6666左右,2.000V应读到0xCCCC左右。
如果U1和U2的读数交叉,说明解析顺序反了;如果有一个通道恒为0或全FF,先查SDI/SDO链路,再查CS时序。实际调通后两个通道读数误差在±3LSB以内,说明链路正常。如果读数在几百LSB级别差很多,多半是参考电压或前端运放的问题,不一定是数字链路。
5.2 常见异常现象对照
调试过程中我整理了一张问题对照表,遇到现象可以直接对号入座:
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 所有通道全FF | CNV脉冲没真正触发转换;CS没拉低;MISO引脚复用配置错 | 用逻辑分析仪看CNV和CS波形;检查GPIO的AF配置 |
| 所有通道全00 | U2.SDO到MISO连线断;SDI链路断开导致后级读回0 | 万用表量连接,重点查虚焊 |
| 两片数据一模一样 | 其中一片SDI悬空,实际读到的是同一份数据复制;或两片SDO都接MISO造成总线竞争 | 确认U1.SDO→U2.SDI,且只有U2.SDO接MISO |
| 数值整体错1位 | 没跳过第1个无效位;SPI相位配置错误导致采样点偏了半拍 | bitPos从1开始;检查CPOL/CPHA |
| 高字节对、低字节错 | 解析顺序反了,或MSB/LSB方向搞反 | 交换U1/U2解析顺序;确认MSB先行 |
第二片数据等于第一片这个坑,我一开始也遇到了,原因就是U1的SDI悬空,U1的SDO正常输出给U2,但U2的数据没有原位串进来,导致回读时两个16位帧内容一样。解决方法是把MOSI和U1.SDI接好,链路就连贯了。
5.3 噪声与校准的个人经验
16位系统里,直流输入的码值跳动在±2~3LSB内属正常。如果跳动超过±10LSB,优先查参考电压噪声和输入RC参数,其次查数字地模拟地。
AD7689内置参考能满足一般测试,但做计量级采集我强烈建议外部基准,实测噪声能低一半。另外,每片ADC的8个通道之间会有小的offset差异,可以在生产测试时对每个通道做两点校准,把offset和增益系数存到外部Flash里。这个校准业务放到MCU端做就行,用软件减法、乘法,不必依赖ADC本身。
6. W25Q256下载算法:调试环节绕不开的外部Flash
6.1 为什么要把程序放到外部SPI Flash
这个项目Bootloader放在内部Flash,APP打算放外部W25Q256,所以SPI下载算法绕不开。STM32F429虽然有2MB内部Flash,但Bootloader、固件升级缓存、参数存储都挤在一起会很紧张,外部SPI Flash空间大、擦写灵活,是常见做法。
我在Keil里折腾下载算法时发现,默认的STM32F429下载算法只识别内部Flash,网上能找到的M25P64算法容量也不匹配W25Q256。最后自己改了一版FLM算法,把程序直接烧到W25Q256里,上电后由Bootloader搬运执行。
6.2 FLM算法适配的几个关键点
- FlashDev.c里把Device Size改成0x2000000(32MB),Page Size设256字节,Erase Sector Size设4KB;
- Init函数末尾发一条0xB7命令,让W25Q256进入4字节地址模式,否则超过16MB的地址会被截断;
- ProgramPage和EraseSector命令分别用0x02和0x20,擦除整片用0xC7;
- 等待WIP(写忙位)用0x05读状态寄存器,bit0为1表示忙;
- 编译生成FLM文件后,在MDK的Flash Download列表里添加,算法下载地址要和外部Flash起始地址一致。
比较坑的是,下载完程序复位后,W25Q256如果回到3字节地址模式,Bootloader自己取代码时也要在SPI Flash初始化里再发一次0xB7,否则从高地址启动直接就跑飞了。这个现象很隐蔽,代码看起来没问题,一跑就进HardFault,后来在Flash驱动初始化里补了一条命令就好了。
6.3 与AD7689共用SPI总线的注意事项
W25Q256和AD7689挂同一条SPI1总线,靠片选区分。最需要注意的是两个外设的CS默认都要拉高,尤其W25Q256的CS不能悬空,我加了10k上拉电阻。
原因是下载算法跑完后,MCU复位,GPIO状态在复位瞬间是不确定的。如果Flash的CS被拉到低电平,Flash会把SPI总线上给AD7689的时钟当成自己的命令,轻则AD7689读数全乱,重则Flash被误写。这个电阻不能省。
另外,两个外设共用的SCK如果走线距离远,建议在SCK上串33Ω电阻,减少过冲,实测对AD7689的转换精度有正面帮助。如果AD7689和Flash的频率要求差得多,也可以考虑分频后降低SPI速度,但AD7689的45MHz已经跑得很稳,Flash读写速度也够,共用一路SPI问题不大。
我自己的习惯是先把W25Q256的驱动和下载算法全部调通,再上AD7689,两边单独都稳定后再合到一起,这样排查问题时有明确边界。这次级联两片AD7689,真正花时间的不是SPI初始化,而是那一个无效位、一个CPHA、一个CS时序。把这三点记牢,其它都是水到渠成的事。
本文还有配套的精品资源,点击获取