STM32通过GPIO模拟SPI驱动DAC7614数模转换器

发布时间:2026/10/29 17:43:31
STM32通过GPIO模拟SPI驱动DAC7614数模转换器

简介:这套STM32 DAC7614驱动工程面向嵌入式开发者和STM32入门用户,解决DAC7614四通道12位数模转换芯片的模拟SPI通信与控制问题。包内为完整可编译的Keil工程,共76个文件,以34个头文件和33个C源文件为主,配合工程配置、启动汇编及辅助脚本,压缩包仅285KB,结构清晰,便于直接移植。代码实现GPIO模拟SPI的时序生成、片选与寄存器读写配置,覆盖单通道与多通道输出电压控制,并给出初始化、数据更新、参考电压设置及延时抗干扰等关键处理,可对照DAC7614数据手册逐行理解。配套LED、定时器等基础外设驱动,有助于快速搭建数模转换调试环境。已有254人学习下载,适合正在做工业控制、信号发生或数据采集项目的开发者参考。

1. 先把“模数转换”这个误会说破:DAC7614 与 STM32 模拟 SPI

拿到这个标题的人多半是奔着“STM32 驱动一颗串行 DAC”来的,但标题里“模数转换芯片”这个叫法其实是个老误会——DAC7614 是 TI 的 12 位四通道数模转换器,不是 ADC。它做的事情是把你代码里的数字量变成模拟电压,而不是把电压读回来。这个误会不影响你使用,但会影响你查手册的方向:你要是按 ADC 的思维找它的输出数据寄存器,翻半天也翻不到。这颗芯片在工控里很常见,四路独立输出、12 位分辨率、串行接口,一片就能顶四路电位器,用来做偏置电压、阈值设定、模拟输出校准都合适。我这次讲的是不用硬件 SPI、纯用 GPIO 模拟 SPI 时序的驱动写法,适合那种引脚紧张、SPI 外设已经被别的器件占满,或者就是想少绕一根线的场景。

2. 读懂 DAC7614:引脚、串行时序和 15 位命令字的三个关键

2.1 DAC7614 的接口:三条串行线加一条同步锁存线

DAC7614 的串行口长得很像 SPI,但严格说它只需要三根线:SCLK(时钟)、SDI(数据输入)、CS(片选),外加一根单独控制的 LDAC(同步锁存)。它没有数据输出脚,所以和 STM32 通信是典型单工写,硬件 SPI 的全双工特性在这里完全用不上,这也是我倾向于模拟 SPI 的原因之一。

引脚名方向作用
SCLK输入串行时钟,数据在上升沿被锁存
SDI输入串行数据输入,先发高位
CS输入片选,低电平有效,拉低后开始接收
LDAC输入同步锁存,低电平把输入寄存器内容送到 DAC 寄存器
AOUTA/B/C/D输出四路模拟电压输出
VREFH/VREFL输入参考电压高/低端,决定输出范围
RST输入异步复位,复位后输出清零

这颗芯片的参考电压是外部给的,输出范围和你的基准直接相关。比如 VREFH 接 5V、VREFL 接地,那输出就在 0~5V 附近;如果你想输出负压,VREFL 得接负电源。这个特性后面标定章节还要细讲,先记住一个结论:DAC7614 的线性度不错,但绝对精度取决于你的参考电压,别指望它能自己变准。

2.2 15 位命令字的拆法:4 位控制加 12 位数据

DAC7614 一次完整的传输是 15 位,不是常见的 8 位或 16 位。15 位里高 4 位是控制位,低 12 位才是真正的 DAC 数据。我按主流手册的取法拆给你看:

位序名称作用
BIT14A1通道选择高位
BIT13A0通道选择低位
BIT12M1寄存器操作模式高位
BIT11M0寄存器操作模式低位
BIT10~BIT0D11~D012 位 DAC 数据,高 9 位有效,后 3 位可忽略或作为符号位

通道选择很简单:A1A0 组合 00/01/10/11 分别对应 A/B/C/D 四路。M1M0 是控制这次数据写到哪个寄存器里,不同组合决定是只进输入寄存器、还是同时刷新 DAC 寄存器。具体编码每版手册可能标注不同,你驱动代码里只要固定用同一种模式就行,我不会在这里把编码表抄死。

2.3 输入寄存器和 DAC 寄存器:为什么需要 LDAC 这根额外的线

DAC7614 内部是双缓冲结构:你先把数据通过串行口写进输入寄存器,然后 LDAC 引脚拉低一下,输入寄存器的值才统一搬到DAC 寄存器,输出引脚上的电压跟着变化。双缓冲的意义在于多通道同步更新——四路数据可以分四次写进去,最后拉一次 LDAC,四路输出同时变,不会出现一路一路依次跳变的情况。

这个结构对模拟 SPI 驱动的写法影响很大。很多人第一次调这颗芯片,写完数据发现输出没变,就开始怀疑 SPI 时序,其实只是忘了拉 LDAC。我自己的驱动习惯是把 LDAC 单独做一个引脚,不进 SPI 帧里,复位后默认拉高,每次写完一串需要同步的数据后再低脉冲一次。这也是标题这种“模拟 SPI 驱动”最常见的可靠接法。

3. 纯 GPIO 搭出模拟 SPI:从原语到可运行驱动

3.1 模拟 SPI 和硬件 SPI 的选型差异:全双工在这里用不上

为什么不用 STM32 的硬件 SPI?两个原因。第一,DAC7614 只有 SDI 没有 SDO,硬件 SPI 的全双工收发在这里是浪费,你还要刻意屏蔽接收中断。第二,硬件 SPI 的时钟极性和相位要和芯片匹配,DAC7614 这类老芯片的时序容限比较宽,但引脚复用、片选控制反而容易出问题,尤其当你把 CS 挂在 SPI 外设的硬件 NSS 上时,可能被外设自动拉高拉低干扰。模拟 SPI 的好处是想用哪几个脚就用哪几个脚,时序节奏完全自己把控,这在引脚紧张的板子上是实打实的优势。代价是 CPU 占用高,传输 15 位数据要翻转几十次 GPIO,但对 DAC 这种低频写入场景完全够用。

3.2 最底层的三个原语函数:电平、延时和片选

模拟 SPI 的骨架是原语函数。我一般把它们做成 static,只对驱动内部暴露。先看电平控制和延时:

/* dac_spi_gpio.h 中定义的引脚宏,按你自己的板子改 */ #define DAC_SCLK_PORT GPIOA #define DAC_SCLK_PIN GPIO_PIN_5 #define DAC_SDI_PORT GPIOA #define DAC_SDI_PIN GPIO_PIN_7 #define DAC_CS_PORT GPIOA #define DAC_CS_PIN GPIO_PIN_4 #define DAC_LDAC_PORT GPIOA #define DAC_LDAC_PIN GPIO_PIN_3 static void DAC_SPI_SCLK(uint8_t level) { HAL_GPIO_WritePin(DAC_SCLK_PORT, DAC_SCLK_PIN, level ? GPIO_PIN_SET : GPIO_PIN_RESET); } static void DAC_SPI_SDI(uint8_t bit) { HAL_GPIO_WritePin(DAC_SDI_PORT, DAC_SDI_PIN, bit ? GPIO_PIN_SET : GPIO_PIN_RESET); } static void DAC_SPI_CS(uint8_t level) { HAL_GPIO_WritePin(DAC_CS_PORT, DAC_CS_PIN, level ? GPIO_PIN_SET : GPIO_PIN_RESET); } static void DAC_SPI_Delay(void) { for (volatile uint32_t i = 0; i < 20; i++) { __NOP(); } }

这段代码的逻辑没什么玄学,就是给硬件抽象层套了一层薄壳。延时函数是模拟 SPI 的关键——它决定 SCLK 半周期的时间。20 次空循环在 72MHz 主频下大概能撑出微秒级的半周期,对 DAC7614 这种几十纳秒建立时间的老芯片绰绰有余。但要注意,这个延时函数会随编译器优化级别变化,Release 模式下可能被优化得很短。我见过有人调好的驱动一开 O2 优化就乱码,多半就是这个循环被改了。真要稳,就在延时函数里加一个volatile修饰,或者干脆用 DWT 计数器做精确延时。

3.3 发送一帧 15 位数据:循环体里的时序边界

有了原语,发送一帧就顺理成章了。DAC7614 是上升沿锁存数据,所以正确顺序是:先置 SDI 电平,再把 SCLK 拉高,等延时,再拉低 SCLK,等延时,然后发下一位。

void DAC7614_SendFrame(uint16_t frame) { /* 拉低片选,开始接收 */ DAC_SPI_CS(0); DAC_SPI_Delay(); /* 15 位数据,从 BIT14 开始逐位发送 */ for (int8_t bit = 14; bit >= 0; bit--) { uint8_t bit_val = (frame >> bit) & 0x01; DAC_SPI_SDI(bit_val); DAC_SPI_Delay(); /* 上升沿,DAC7614 在此时锁存当前位 */ DAC_SPI_SCLK(1); DAC_SPI_Delay(); DAC_SPI_SCLK(0); DAC_SPI_Delay(); } /* 拉高片选,结束本次传输 */ DAC_SPI_CS(1); DAC_SPI_Delay(); }

这段代码的边界在循环起始条件:bit = 14,不是 15,也不是 11。15 位帧的最高位是 BIT14,低 12 位才是数据,所以循环少一位都不行。我在调试时踩过这个坑——先把数据写成 16 位左移 4 位的格式,结果多发了一位,芯片把控制位当数据吃进去,输出完全是乱的。这个函数封装的是“发一帧”,它不关心帧内容,所以后面构造命令字的逻辑要单独再写一层,别混在一起。发送结束后 CS 拉高是必须的,DAC7614 在 CS 上升沿结束本次通信,不拉高的话下一次写会把上一帧残余数据混进去。

3.4 把驱动对接进 STM32CubeMX 工程

模拟 SPI 的初始化比你想象中简单,CubeMX 里只需要把用到的引脚配成推挽输出、默认电平拉高,不需要开启任何 SPI 外设。我一般这样做:

  1. 在 CubeMX 的 Pinout 视图里,把 SCLK、SDI、CS、LDAC 对应的引脚全部设为 GPIO_Output,初始电平都选 High。
  2. 生成代码后,在main.c里调用DAC7614_Init(),这个函数主要做两件事:把 CS 和 LDAC 置高,把 SCLK 和 SDI 置低,确保总线空闲。
  3. 编译时注意优化等级,我建议前期 Debug 模式用 O0,调通后再切更高优化,并回测输出是否变化。

有一个细节很多人不注意:CubeMX 生成的 GPIO 初始化默认是推挽输出、无上下拉,这对模拟 SPI 是对的。如果你把 SDI 挂在和某个外设共用的引脚上,务必确认那个外设没有被同时初始化,否则引脚模式冲突,写进去的电平会被另一个外设拉走。引脚分配一旦确定,后续所有代码都围绕这五个引脚(SCLK/SDI/CS/LDAC)展开,不需要再动 CubeMX。

4. 驱动之上的应用层:四通道输出、联动刷新和标定

4.1 让 A 通道输出一个固定电压:最小用户代码

现在把命令字拼起来。DAC7614 的高 4 位是控制位,低 12 位是数据,所以构造命令字的代码要保证控制位和数据位各就各位:

#define DAC_CH_A 0 #define DAC_CH_B 1 #define DAC_CH_C 2 #define DAC_CH_D 3 /* 默认使用模式:写输入寄存器,不立即更新 DAC 输出 */ #define DAC_MODE_WRITE_INPUT (0x00) uint16_t DAC7614_BuildFrame(uint8_t channel, uint16_t value) { uint16_t frame = 0; /* 通道选择:A1A0 位,即 BIT14/BIT13 */ frame |= ((channel >> 1) & 0x01) << 14; frame |= ((channel >> 0) & 0x01) << 13; /* 寄存器模式:M1M0 位,即 BIT12/BIT11 */ frame |= (DAC_MODE_WRITE_INPUT & 0x03) << 11; /* 12 位数据:只取低 12 位,放在 BIT10~BIT0 */ frame |= (value & 0x0FFF); return frame; } void DAC7614_WriteChannel(uint8_t channel, uint16_t value) { uint16_t frame = DAC7614_BuildFrame(channel, value); DAC7614_SendFrame(frame); }

这段代码里最值得讲的是value & 0x0FFF。DAC7614 的 12 位数据范围是 0~4095,如果你传入的 value 超过 4095,或者你习惯用 16 位 ADC 的满量程 65535 来算,那控制位会被数据污染。我见过有人把 65535 直接传进来,结果 BIT14 被置 1,通道选成了 B,输出逻辑全乱。上限裁剪应该在传入前做好,或者在这个函数里加上限判断,我更推荐后者,因为驱动层不该信任调用方。

4.2 四通道联动刷新:LDAC 集中触发

双缓冲结构带来的好处是可以四路一次同步更新。写法上就是先连续写四次输入寄存器,最后统一拉一次 LDAC:

void DAC7614_WriteAllChannels(uint16_t val_a, uint16_t val_b, uint16_t val_c, uint16_t val_d) { /* 四路都只写输入寄存器,不触发输出更新 */ DAC7614_WriteChannel(DAC_CH_A, val_a); DAC7614_WriteChannel(DAC_CH_B, val_b); DAC7614_WriteChannel(DAC_CH_C, val_c); DAC7614_WriteChannel(DAC_CH_D, val_d); /* 拉低 LDAC,四路同时更新 */ HAL_GPIO_WritePin(DAC_LDAC_PORT, DAC_LDAC_PIN, GPIO_PIN_RESET); DAC_SPI_Delay(); HAL_GPIO_WritePin(DAC_LDAC_PORT, DAC_LDAC_PIN, GPIO_PIN_SET); }

这个函数的时序价值在于:四路数据即使分四次写完,输出变化仍是同时发生的。如果你在做一个需要四路严格同步的模拟输出系统,比如电机电流基准、多路比较器阈值,这个特性很重要。LDAC 低脉冲的宽度我一般给到微秒级,再短也没问题,但要留足建立时间。注意,LDAC 拉低之后要保证 CS 已经是高电平,否则芯片会认为还在通信中,锁存行为变得不可预期。

4.3 输出标定:为什么理论值和万用表读数对不上

DAC7614 的理想输出公式是:

Vout = VREFL + (VREFH - VREFL) × value / 4096

这里的 4096 是完整步进数,value 最大是 4095,所以满量程输出其实是 VREFH 的 4095/4096,差了将近 1mV。这个不算误差,但你要知道。真正的误差来源有三个:参考电压本身的精度、DAC 的增益误差、运放或输出级的失调电压。我标定的做法是:

  1. 选两个码值,比如 0x000 和 0x800,用万用表实测输出电压。
  2. 以实测两点做线性拟合,算出增益误差和失调误差。
  3. 把这两个值修正进驱动,在BuildFrame之前对代码值做一次线性变换。

修正公式就是典型的value_corrected = (value - offset) / gain,其中 gain 接近 1,offset 可能在几毫伏量级。这个标定值每个板子都不一样,我一般把它放在一个结构体里,用 ADC 回读来自动校准。

4.4 配一个 ADC 回读:与 STM32 ADC 切换通道的配合

驱动 DAC 只做了一半,真正项目里几乎都要配一个 ADC 回读来验证输出。STM32 的 ADC 多通道切换是个经典场景:四路模拟输出要分别采样回来比对,每次切换通道都要等稳定时间,否则采到的值偏大或偏小。做法是先用HAL_ADC_ConfigChannel切到目标通道,启动转换,等转换完成,再切下一个。这里容易翻车的地方是采样时间没给够,DAC 输出经过 RC 滤波后上升沿变缓,你立即采样就采到了还没建立好的值。我一般把采集通道脚本写成函数,每次切换后至少延时 10 个 ADC 时钟周期再启动采样,回读误差能明显下降。

5. 模拟 SPI 驱动 DAC7614 的避坑记录:六个翻车现场

5.1 现象:输出电压每隔一段时间跳变一次

排查了很久,发现是看门狗中断回调里不小心调用了 DAC 写函数。模拟 SPI 是裸 GPIO 翻转,时序全靠代码保证,中断打断发送过程会让 SCLK 停在半空,帧直接损坏。解决方法是把 DAC 写入放到临界区,或者在中断里只置标志位、在主循环里处理。另外注意一点:模拟 SPI 的函数不能重入,一旦两个地方同时发帧,总线上的电平就乱了。

5.2 现象:只有 B 通道有输出

这个现象的反直觉之处在于:通道选择错了,输出却没全乱。原因是调用方传了 16 位数据,高 4 位把 A1A0 覆盖成了非预期值,恰好选中了 B 通道。解决方法是严格裁剪 12 位数据,并在BuildFrame里先把 frame 清零,不要用frame |=裸加。踩过这个坑之后我习惯在函数开头写frame = 0;,这是个很小的习惯,但能救你半天排查时间。

5.3 现象:和硬件 SPI 屏共用总线后 DAC 输出乱跳

板子上有一块 ILI9341 屏幕占用了硬件 SPI1,我图省事把 DAC 的 SDI 也挂在这根线上,用软件 CS 做区分。结果屏幕刷新时 DAC 的输出跟着乱跳。原因很简单:模拟 SPI 和硬件 SPI 共用一根 MOSI 线,硬件 SPI 在传输时会主动拉高拉低这根线,DAC 又靠 CS 低电平选中,时序交错时就收到了屏幕的数据。解决思路分两种:要么 DAC 的 CS 永远只在发送帧前拉低,发送期间关掉屏幕的硬件 SPI 总线;要么干脆换独立 GPIO 走模拟 SPI,不再共享。这个教训让我后来一律不允许模拟 SPI 和硬件 SPI 共用数据线,代价只是多一根飞线,收益是省三天查错时间。

5.4 现象:上电瞬间输出不是零

DAC7614 的 RST 引脚悬空时,上电后输出状态是不确定的。有人以为芯片会自动清零,实际上手册里写的复位行为要 RST 引脚给一个低脉冲才生效。我驱动里初始化时先拉低 RST、延时、再拉高,确保输出从零开始。如果你不想多占一个引脚,也可以把 RST 通过电阻下拉,但那样每次上电要先写一次数据才能确定输出,对某些上电要求严格的场合不可接受。

5.5 现象:发送时序看着对,但输出全是满量程

SCLK 极性和相位搞反了。DAC7614 是上升沿锁存数据,如果你的代码在 SCLK 拉低时置数据、拉高时锁存,这是对的;但要是你把 SCLK 默认拉高、下降沿置数、上升沿锁存,那数据会提前一位或者整体错位,表现为输出值比预期大 8 倍或 16 倍——这就是数据左移了 3 位或 4 位的典型症状。逐位检查 SCLK 翻转顺序,确认是“先改变 SDI,再拉高 SCLK,再拉低”,不要反过来。

5.6 现象:万用表读数稳定但不准

稳定说明通信没问题,问题在参考电压或基准源。DAC7614 输出以 VREFH/VREFL 为基准,参考电压本身抖,输出自然跟着抖。我遇到过用开关电源的 5V 直接做基准,输出波动十几毫伏,换成独立基准 IC 之后立刻稳定。另外,输出引脚如果直接接大电容负载,建立时间会很长,万用表采到的是电容充电过程中的电压,造成读数偏低。建议输出串联一个 100Ω 电阻再进电容,或者在采样前加长静置时间。

6. 进阶验证:错误注入、输出校准与最后的习惯

6.1 错误注入:把延时缩短看芯片的容忍边界

模拟 SPI 最大的优势是你可以随意改变时序。我把延时从 20 个空循环逐步往下调,观察 DAC7614 什么时候开始丢位。这个实验很有价值——它告诉你这颗芯片在当前主频下的真实时序余量,也为将来换用更快的 MCU 或者开启更高编译优化提供信心。做法很简单:把延时函数改成可变量,循环发送同一个码值,用 ADC 回读或万用表监测输出是否变化。我测下来的经验是,老款 TI 串行 DAC 对时序的容忍度普遍高于手册标称值,但别依赖这个余量,量产时还是要留足余量。

6.2 输出精度验证:三点法与万用表记录

标定完成后要做验证,我习惯用三点法:选接近零、中间、满量程三个码值,实测输出电压,计算每个点的误差。注意,验证用的万用表精度要高于 DAC 的分辨率,否则你测出来的“误差”其实混入了测量仪器的误差。还有一点容易被忽略:DAC 的输出建立时间,从写入到电压稳定可能需要几十微秒,尤其是驱动大电容负载时,验证时每测一个点都要等够时间,不要连着刷。

最后说一个我自己的教训:模拟 SPI 驱动这种代码,调通之后一定要加注释,尤其是延时函数旁边的注释要写明“这个循环次数对应多长半周期、对应手册哪个参数”。因为半年之后你回头看这段代码,如果不写明白,你会怀疑自己当时是不是拍脑袋定的值。也希望我这次的整理能帮到你——至少能让你少查半天手册、少翻几个论坛帖子。

本文还有配套的精品资源,点击获取