
简介面向高精度数据采集开发的AD7616串行驱动代码包适合正在使用STM32平台调试16位ADC的嵌入式工程师与高校学习者参考。内容围绕2.5V参考输入、线仿突发模式、硬件CRC校验及24通道序列器等关键点展开并以STM32F767为控制核心给出串行通信与外设初始化的C语言实现可用于工业采集、测试测量等场景的驱动移植与联调。压缩包共170个文件以87个h头文件和77个c源文件为主体另含Keil工程uvprojx/uvoptx、启动汇编.s、批处理bat及说明txt整体约1.01MB目录便于按外设与工程配置检索。已有1791人学习下载说明其在ADC驱动调试中具有一定参考面。读者可据此梳理AD7616与STM32F767的串行时序、通道序列配置、CRC校验流程和HAL库外设组织方式并借助现成工程框架缩短搭建与排错时间。1. 16 路模拟量只给三根线AD7616 串行驱动要解决的场景一块 16 路模拟采集板上AD7616 的并行数据线一根都没引出来只留了 SCLK、CS、DOUT 三根线接到 STM32F767 上却要在 100 kSPS 的节奏里把 16 个通道全部读回来——这是我第一次给这颗 16 位、双极性输入的 ADC 写串行驱动时的处境。AD7616 串行驱动.zip 里放的就是把这件事做通的 C 代码配置寄存器、启动转换、等 BUSY、拉低 CS 走一次突发burst读、把原始码换算成电压一整套流程都落在 STM32F767 的 SPI DMA 上不依赖任何中间件。它适合三类人正在做多路电压电流采集、被并口走线逼到串行口的硬件工程师手里有现成板子、想快速验证 AD7616 数据对不对的固件开发者以及要把这套驱动从 F767 挪到别的 MCU 上、需要一份干净可移植参考代码的人。读完你会知道哪个引脚不能接错、SPI 参数为什么必须那么设、以及读回来全是 0x8000 时该先怀疑谁。2. 驱动包的结构串行模式下的物理前提与代码分层拿到一个驱动包我第一件事不是编译而是先确认它假设的硬件接法和我板子上的是不是同一套。AD7616 的串行模式和并行模式共用同一批引脚功能是靠 SER/PAR 那个选择脚切过去的接错一根后面所有代码都是白调。2.1 串行模式的物理前提三根线怎么接才不返工进入串行模式后原来的并行数据总线里有两根被复用成数据输出脚加上时钟和片选构成了完整的数据通道。除了这些还有四根控制线必须单独引到 MCUCONVST 负责发起转换BUSY 告诉你可以读了RESET 用于上电复位另外参考电压和量程选择脚决定了你后续换算公式里的系数。我见过最常见的翻车是把 CONVST 和 CS 接在同一根 GPIO 上想着省一个脚结果转换启动和读时序耦合在一起读出来的数永远差一个通道。数据输出有两种接法这是整个驱动里最关键的一个分叉。单 DOUT 线模式把 A 组和 B 组的结果都从一根线上吐出来16 个通道就是 16 个 16 位字一共 256 个 SCLK双 DOUT 线模式让两根线同时出数A 组走一根、B 组走一根128 个 SCLK 就能读完。听起来双线快一倍但 STM32 的一个 SPI 外设只有一个接收引脚你要么用两路 SPI 做主从配合要么在 SCLK 下降沿用 GPIO 手动采两路——前者调试成本高后者占 CPU。包里默认走的是单 DOUT 线加 CS 全程拉低的突发读稳定、好调代价是时钟数翻倍。如果你板子上两根 DOUT 都接了、又确实需要更高吞吐第五章会讲怎么改。还有一个容易被忽略的点突发模式的本质不是读得快而是CS 中途不能松。AD7616 在 CS 有效期内把整条序列连续移出一旦你在第 100 个时钟把 CS 抬起来剩下的数据就丢了而且不会报错只会安静地给你一堆旧值。所以驱动里 CS 必须是软件控制的 GPIO绝对不能交给 SPI 外设的硬件 NSS 去管。2.2 目录拆解端口层、寄存器层、应用层怎么切驱动包按 BSP 和应用两层切端口层只跟 HAL 打交道寄存器层只跟时序和位域打交道两者之间靠一个设备结构体传递句柄。这种切法的好处是换 MCU 的时候你只动一个文件寄存器配置和换算式一行都不用改。路径职责换平台时改不改bsp/ad7616_port.cGPIO 初始化、SPI 初始化、CS/CONVST 电平操作、微秒延时必改bsp/ad7616.c转换启动、BUSY 等待、突发读、寄存器读写不改bsp/ad7616.h设备结构体、通道数、帧长、对外 API基本不改bsp/ad7616_reg.h寄存器地址、位域宏、命令字拼接宏不改app/main.c初始化顺序、主循环、串口打印按需改app/app_sample.c采集调度、电压换算示例按需改mdk/Keil 工程文件按需改端口层的边界划得很清楚它只暴露ad7616_port_init、ad7616_cs_low/high、ad7616_convst_pulse、ad7616_delay_us这几个函数。寄存器层调它们但从不直接碰GPIOx或者hspi1。我第一次移植到另一颗 Cortex-M7 上时只重写了 port 文件里的六个函数半天就跑通了这个分层是真的省事。ad7616_reg.h单独拆出来是有原因的。AD7616 的寄存器写命令是一个 16 位帧里面塞了写使能位、寄存器地址和数据三段位宽一旦记错写进去的就是另一个寄存器的另一个值而且现象极其隐蔽——你可能只是量程没配对却以为是前端电路的问题。把这些位域全部做成宏出问题时只改一处比在函数里手写移位靠谱得多。2.3 技术栈与依赖HAL、SPIDMA、外部中断代码基于 STM32 HAL 库核心外设就三个SPI1 负责出时钟和收数据一个 GPIO 外部中断挂在 BUSY 的下降沿上一个定时器 PWM 通道用来产生 CONVST 脉冲第一版是 GPIO 软件翻转后面改成了定时器第六章细说。SPI 走 DMA 接收一次搬完一整帧全程不占 CPU。时钟这块要注意F767 的 SPI1 挂在 APB2 上主频能到 108 MHz分频系数选 4 就是 27 MHz 的 SCLK。AD7616 的串行时钟有上限手册里会给出具体数值27 MHz 已经贴着边了我一般先用分频 813.5 MHz把功能调通确认数据对了再往上提看看波形和信噪比能不能接受。上来就拉满频率然后对着跳动的数据怀疑人生这种亏吃过一次就够了。移植成本方面只要目标 MCU 有 SPI、有 DMA、有一个能产生微秒级脉冲的定时器这套代码就能搬。F4 系列注意 SPI 的 16 位帧在 HAL 里的计数单位H7 系列注意缓存一致性——DMA 往uint16_t数组里写读之前该做的无效化操作不能省否则你读到的是 cache 里的旧数据现象是数据偶尔整帧不变查起来非常费劲。3. 时序与状态机一次 CONVST 到 16 路数据落到 RAM 的完整链路AD7616 的串行读不是一个调 SPI 收发就完事的接口它有一条严格的信号顺序先给 CONVST 一个脉冲芯片开始转换并拉高 BUSY转换结束后 BUSY 落下这时候才允许给 SCLK并且在整帧 SCLK 期间 CS 必须一直保持低。顺序错一步你拿到的就是垃圾数据而且芯片不会告诉你它不高兴。3.1 CONVST、BUSY、CS 的先后顺序错一个都读不到数启动转换的脉冲有最小宽度要求只有几十纳秒在 216 MHz 的 F767 上靠软件翻转 GPIO 很容易翻得太快导致脉冲太窄芯片根本没认出来。我用了一个笨但可靠的办法拉低之后插入一段固定的空转循环保证低电平持续接近 1 微秒远远超过手册要求的下限。/* bsp/ad7616.c —— 启动一次转换 */ void ad7616_start_convert(ad7616_t *dev) { dev-frame_ready 0; dev-busy_done 0; /* CONVST 空闲为低一个高脉冲触发转换 */ HAL_GPIO_WritePin(dev-convst_port, dev-convst_pin, GPIO_PIN_RESET); /* 216MHz 下约 6 个周期就是 25ns这里给到 1us 量级稳字优先 */ ad7616_delay_us(1); HAL_GPIO_WritePin(dev-convst_port, dev-convst_pin, GPIO_PIN_SET); ad7616_delay_us(1); HAL_GPIO_WritePin(dev-convst_port, dev-convst_pin, GPIO_PIN_RESET); }这里有个设计选择值得说一下CONVST 的脉冲放在函数里生成等待 BUSY 的逻辑放在读函数里两者分开。原因是实际项目里经常需要先把转换发出去等采集完别的事情再回来取数比如你还要在这几微秒里处理串口或者喂看门狗。如果把等待塞进start_convert主循环就被卡住了100 kSPS 的节奏下这是致命的。BUSY 的等待有轮询和中断两种写法。包里两个都给了轮询版简单直观适合先验证硬件中断版把 BUSY 接到 EXTI 下降沿在回调里置标志位或者直接启动 DMA主循环不阻塞。我一般先用轮询版把数据调对再换成中断版做压力测试因为轮询版出问题时你能清楚看到它是卡在BUSY 一直不落还是落了但数据不对这两种情况的排查方向完全不同。序列器模式下还有一层选择一次 CONVST 只推进一对通道还是把整条序列一次性走完。后者在配置寄存器里有个突发使能位打开之后一次脉冲就能把 16 个通道全部转换完BUSY 会一直保持高电平直到最后一对结束宽度大约是单次转换的 8 倍。吞吐上两者差不多但整串突发的时序更规整读取窗口是连续的不用担心两次转换之间的建立时间。包里默认开的就是整串突发start_convert之后等到 BUSY 落下直接读 256 个时钟即可。3.2 SPI 参数怎么定16 位帧、模式 0、关掉硬件 NSSSPI 的初始化参数是整个驱动里最容易被抄错的部分因为大部分例程都是 8 位帧、硬件 NSS、模式 0而 AD7616 需要的是 16 位帧、软件 NSS、模式 0。/* bsp/ad7616_port.c —— SPI1 初始化关键项 */ hspi1.Instance SPI1; hspi1.Init.Mode SPI_MODE_MASTER; hspi1.Init.Direction SPI_DIRECTION_2LINES; /* 只用 RX但保留全双工便于逻辑分析仪比对 */ hspi1.Init.DataSize SPI_DATASIZE_16BIT; /* 一个半字一次搬 16 bit */ hspi1.Init.CLKPolarity SPI_POLARITY_LOW; /* 空闲低 */ hspi1.Init.CLKPhase SPI_PHASE_1EDGE; /* 第一个边沿采样即上升沿 */ hspi1.Init.NSS SPI_NSS_SOFT; /* CS 必须由软件全程控制 */ hspi1.Init.BaudRatePrescaler SPI_BAUDRATEPRESCALER_8;/* 108MHz/8 13.5MHz先慢后快 */ hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; /* 高位先出 */ hspi1.Init.TIMode SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation SPI_CRCCALCULATION_DISABLE; if (HAL_SPI_Init(hspi1) ! HAL_OK) { Error_Handler(); }CPOL 和 CPHA 这两个参数不能拍脑袋。AD7616 是在 SCLK 的下降沿更新 DOUT 上的数据主机应该在上升沿采样对应到 STM32 的 SPI 就是空闲低电平加第一个边沿采样也就是常说的模式 0。如果你选成第二个边沿采样读回来的整个字会整体偏移一位数值看起来大概是那个数但总是差一倍很容易被误判成量程配置错误。帧长设成 16 位之后有一个 HAL 的细节必须记住所有 SPI 收发函数的Size参数在这个配置下指的是半字个数不是字节数。我曾经按字节数传了个 32 进去结果 DMA 多搬了一倍后半段缓冲里全是上一帧的残留现象是第 9 到 16 通道数据永远不变查了整整一个下午。提示HAL_SPI_Receive_DMA的Size在 16 位数据帧下是半字数量接收缓冲必须声明成uint16_t数组用uint8_t数组接会踩到字节序问题。3.3 突发读的核心实现DMA 一次搬完 128 个时钟真正的读取函数只有十几行但每一行都不能省。核心思路是等 BUSY 落下拉低 CS启动一次 DMA 接收然后在 DMA 完成回调里把 CS 抬起来。/* bsp/ad7616.h —— 帧长由通道数推出来 */ #define AD7616_CH_TOTAL 16u /* A/B 各 8 路 */ #define AD7616_FRAME_BITS (AD7616_CH_TOTAL * 16u) /* 单 DOUT 线256 bit */ #define AD7616_FRAME_WORDS (AD7616_FRAME_BITS / 16u) /* 16 个半字 */ /* bsp/ad7616.c —— 等待转换完成并启动突发读 */ HAL_StatusTypeDef ad7616_read_burst(ad7616_t *dev, uint32_t timeout_ms) { uint32_t t0 HAL_GetTick(); /* 1. 等 BUSY 落下超时保护必须有否则总线一断程序就死在这 */ while (HAL_GPIO_ReadPin(dev-busy_port, dev-busy_pin) GPIO_PIN_SET) { if ((HAL_GetTick() - t0) timeout_ms) { return HAL_TIMEOUT; } } /* 2. 拉低 CS此后到 DMA 完成前绝对不能松开 */ HAL_GPIO_WritePin(dev-cs_port, dev-cs_pin, GPIO_PIN_RESET); /* 3. 一次 DMA 搬完 16 个半字 256 个 SCLK中途 CPU 不参与 */ if (HAL_SPI_Receive_DMA(dev-hspi, (uint8_t *)dev-raw, AD7616_FRAME_WORDS) ! HAL_OK) { HAL_GPIO_WritePin(dev-cs_port, dev-cs_pin, GPIO_PIN_SET); return HAL_ERROR; } return HAL_OK; } /* 4. 只有 DMA 全部搬完才允许释放片选 */ void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance AD7616_SPI_INSTANCE) { HAL_GPIO_WritePin(g_ad7616.cs_port, g_ad7616.cs_pin, GPIO_PIN_SET); g_ad7616.frame_ready 1; } }这段代码里最值钱的是第 2 步和第 4 步的配对关系。CS 的拉低和抬高跨越了一次 DMA 传输中间不允许有任何其他代码去碰它。这也是为什么不能用HAL_SPI_TransmitReceive分多次调用——每次调用结束 HAL 都会按自己的逻辑处理 NSS即使你设了软件 NSS函数返回时也可能把片选状态改掉。一次 DMA 搬完是唯一能保证 CS 全程稳定的写法。超时保护那一行也别省。调试阶段供电不稳或者 CONVST 没接好BUSY 可能一直不落没有超时的话程序就卡在 while 里你会以为是 SPI 的问题实际上是前面那一步根本没发起转换。3.4 中断与回调BUSY 下降沿之后才允许发时钟轮询版跑通之后换成中断版改动很小把 BUSY 引脚配成下降沿触发的外部中断在HAL_GPIO_EXTI_Callback里判断引脚号然后直接调用ad7616_read_burst。这里有个前提回调里不能做耗时操作所以等待 BUSY 的那段 while 循环实际上被去掉了进回调就意味着 BUSY 已经落下。/* app/app_sample.c —— BUSY 下降沿触发直接进突发读 */ void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin AD7616_BUSY_PIN) { /* 已在中断上下文BUSY 必然为低直接发起 DMA 接收 */ HAL_GPIO_WritePin(g_ad7616.cs_port, g_ad7616.cs_pin, GPIO_PIN_RESET); HAL_SPI_Receive_DMA(g_ad7616.hspi, (uint8_t *)g_ad7616.raw, AD7616_FRAME_WORDS); } }要注意中断优先级。SPI 的 DMA 完成中断优先级必须低于或者等于 BUSY 的外部中断否则可能出现 BUSY 中断里启动 DMA、DMA 完成中断又被打断的嵌套问题。我一般把 EXTI 设成抢占优先级 1DMA 通道设成 2主循环里只负责搬运已经就绪的帧职责分明。4. 寄存器配置与数据换算量程、序列器和原始码到电压数据能读出来只是第一步读出来的值对不对取决于两件事芯片内部的配置寄存器有没有写对以及你把 16 位二补码换算成电压的公式有没有搞错符号。这两块出问题的现象非常像都是数在动但是不对所以最好分开验证。4.1 16 位写帧的位域写错一位配的是另一个寄存器AD7616 的寄存器写操作走的是同一组 SCLK 和 DIN 引脚一个 16 位帧里塞了三段信息最高位是写使能中间几位是寄存器地址剩下的低位是数据。这种把地址和数据拼在一个字里的协议在老式 ADC 里很常见好处是省引脚坏处是位宽记错一次就很难受。/* bsp/ad7616_reg.h —— 命令帧的位域定义改这里就能适配不同手册版本 */ #define AD7616_CMD_WRITE (1u 15) /* bit151 写0 读 */ #define AD7616_ADDR_POS 9 /* 地址段起始位 */ #define AD7616_ADDR_MSK 0x3Fu /* 6 位地址 */ #define AD7616_DATA_MSK 0x1FFu /* 9 位数据 */ /* bsp/ad7616.c —— 写一个寄存器 */ void ad7616_write_reg(ad7616_t *dev, uint8_t addr, uint16_t data) { uint16_t cmd AD7616_CMD_WRITE | ((uint16_t)(addr AD7616_ADDR_MSK) AD7616_ADDR_POS) | (data AD7616_DATA_MSK); HAL_GPIO_WritePin(dev-cs_port, dev-cs_pin, GPIO_PIN_RESET); /* DataSize 16bitSize 1 表示发一个半字 */ HAL_SPI_Transmit(dev-hspi, (uint8_t *)cmd, 1, 10); HAL_GPIO_WritePin(dev-cs_port, dev-cs_pin, GPIO_PIN_SET); /* 两次写命令之间手册要求 CS 有一个最小高电平时间用微秒延时兜住 */ ad7616_delay_us(2); }这段代码的写法刻意把位域全部抽成宏而不是在函数里硬写cmd 0x8000 | (addr 9) | data。原因是不同批次的手册对地址段宽度的描述可能略有出入你只需要核对一次宏定义全工程的写操作就都对了。反过来如果散落在五六个函数里各写一遍改起来必然漏。HAL_SPI_Transmit的最后一个参数是超时毫秒数写 10 是给自己留的容错空间——正常一个半字几十微秒就发完了但如果 SPI 时钟没起来或者 DMA 被占着这个调用会一直等。给个 10 毫秒卡住的时候至少能返回错误码让你知道出事了而不是整个系统静默。读寄存器是反过来的发一个写使能为 0 的帧下一个帧才能拿到数据所以读操作本质上是发两次、取第二次的返回值。包里对外的 API 只暴露了写因为实际项目里几乎不需要读回配置——你把配置表写死在代码里上电写一遍就行读回来对不上反而说明写时序有问题。4.2 序列器 stack把 16 个通道排进一次转换AD7616 内部有一个序列器你可以把要采的通道按顺序排进一个栈里之后每次转换就按这个顺序走不用 MCU 干预。这是它比普通多路复用 ADC 好用的地方——16 个通道的切换完全由芯片自己完成MCU 只管在 BUSY 落下之后把数据搬走。/* app/app_sample.c —— 配置序列器并开启整串突发 */ static const uint8_t seq_tbl[] { /* 编码约定bit4 区分 A/B 组bit3:0 是组内通道号 */ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, /* A1 ~ A8 */ 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, /* B1 ~ B8 */ }; void ad7616_config_sequence(ad7616_t *dev) { /* 配置寄存器开启序列器 整串突发 ±10V 量程 */ ad7616_write_reg(dev, AD7616_REG_CONFIG, AD7616_CFG_SEQ_EN | AD7616_CFG_BURST | AD7616_CFG_RANGE_10V); /* 按顺序把 16 个通道推进栈先写的先转换 */ for (uint32_t i 0; i sizeof(seq_tbl); i) { ad7616_write_reg(dev, AD7616_REG_SEQ_STACK, seq_tbl[i]); } }这里的关键是先写的先转换这个顺序关系。读回来的raw[]数组里元素下标和seq_tbl的下标是一一对应的所以你在换算的时候可以直接用同一张表做通道映射不用再写一套 if-else。如果哪天硬件改了只需要采 A1、A3、B2 三个通道那也只需要改这张表读函数和换算函数一行都不用动。序列器的长度也是可配的。全都采是 16 个只采 4 个就是 4 个帧长跟着变短单次读的时钟数也变少吞吐立刻上去。包里把帧长定义成了由通道数推导的宏改通道数只需要改一个AD7616_CH_TOTALAD7616_FRAME_WORDS会自动跟着变。这个设计在后期做高频通道 低频通道分组采集的时候特别有用。4.3 过采样与吞吐的取舍不是越高越好AD7616 支持数字过采样把多次转换的结果在片内做平均再输出能明显压低噪声。过采样倍数越高单次转换的时间越长你的最大采样率就越低。这是一个纯粹的工程取舍没有标准答案。我的经验是分场景定如果是工频电压监测这类信号本身很慢、但要求高精度的场景过采样开到 8 倍甚至 16 倍噪声改善肉眼可见如果是采集快速变化的电流波形、要求看到谐波细节过采样一律关掉宁可要原始噪声也要保住带宽。中间地带比如温度、压力这类慢变物理量4 倍是个比较舒服的折中花的时间不多噪声也能压下去一截。包里把过采样倍数做成了一个宏改完重新编译就生效不需要动时序代码——因为过采样只影响转换时间不影响读取帧的格式。这一点很重要很多人以为开过采样之后读的位数会变实际上输出永远是 16 位只是这个 16 位背后的有效精度变了。你按照原始公式换算电压完全没问题不需要改任何换算逻辑。4.4 原始码转电压二补码、LSB 和零点校准换算公式本身很简单但符号和量程这两个地方特别容易错。AD7616 输出的是 16 位二补码负电压对应的是补码形式直接按无符号数去算会得到一个巨大的正数然后你就会怀疑是不是前端运放坏了。/* app/app_sample.c —— 原始码转电压 */ #define AD7616_VRANGE 10.0f /* ±10V 量程对应配置寄存器里的量程位 */ /* code 是 int16_t直接承托二补码vrange 是单边满幅 */ static inline float ad7616_code_to_volt(int16_t code, float vrange) { /* 满幅 2*vrange 对应 65536 个码1 LSB 2*vrange/65536 */ return (float)code * (2.0f * vrange) / 65536.0f; } void app_dump_voltage(const ad7616_t *dev) { for (uint32_t i 0; i AD7616_CH_TOTAL; i) { /* 缓冲区是 uint16_t转成 int16_t 才是有符号数 */ int16_t code (int16_t)dev-raw[i]; float volt ad7616_code_to_volt(code, AD7616_VRANGE); printf(CH%02lu: code%6d volt%.4f V\r\n, (unsigned long)i, code, volt); } }参数说明一下code必须用int16_t承接这是整个换算式成立的前提vrange是单边满幅±10V 量程就写 10.0±5V 就写 5.0它必须和配置寄存器里写的量程位保持一致否则算出来的电压会整整差一倍。除数是 65536 而不是 65535因为二补码的负数端能表示到 -32768正数端到 32767用 65536 才能让两端对称。量程满幅范围1 LSB 对应电压±10 V20 V约 305 µV±5 V10 V约 153 µV±2.5 V5 V约 76 µV换算对了之后还要做一步零点校准。实际的板子上前端运放有失调、参考电压有偏差短接输入端读到的可能不是 0 而是十几个码。我的做法是上电时短接一次输入采 256 个点求平均把那个值存成zero_offset之后每次换算先减掉它。这一步不做你的 ±10V 采集在零点附近永远有几十毫伏的固定偏差客户一眼就能看出来。增益校准同理给一个已知的精确电压反算出实际斜率把理论斜率替换掉即可。5. 避坑本文还有配套的精品资源点击获取