ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

STM32驱动TPC116S8多通道16位DAC实战:SPI接口与驱动代码详解

STM32驱动TPC116S8多通道16位DAC实战:SPI接口与驱动代码详解 简介这是一套完整可用的 tpc116s8 设备驱动程序源码包面向嵌入式开发与工业控制场景适合需要基于 STM32F10x 系列单片机进行外设驱动的开发者直接移植或参考。包内共 126 个文件压缩后约 2.36MB主要包含 .h/.c 源码、Keil 工程配置文件.uvprojx/.uvoptx、编译生成的 .o/.axf/.hex 可执行文件以及 UART 通信相关的底层驱动与启动文件其中涉及 UART 部分的工程文件可帮助理解串口数据收发与协议处理目录结构清晰便于按模块集成与二次开发。当前已有 1254 人学习下载由开发者 z1006348650 整理发布。借助这份驱动包开发者能快速获取时钟、定时器、ADC、I2C、CAN、USART 等外设的初始化与操作代码并且因为工程已可编译可直接参考其配置用于实际项目调试有效缩短底层驱动开发周期提升项目推进效率。 做工业控制项目的时候经常遇到模拟输出通道不够用这种问题。以前我习惯一片单通道 DAC 加一个多路模拟开关去扩展后来发现输出建立时间、通道间串扰都很难看最后干脆换成多通道 DAC。这次项目里选型选到了 TPC116S8一颗 8 通道、16 位、SPI 接口的 DAC 芯片。说句实话这颗芯片在国内用的人不算多网上的资料也比较碎驱动代码更是没几个能直接用的我调通之后把整套代码和排错思路整理出来给后面踩坑的朋友做个参考。先说清楚这里的驱动程序不是 Windows 或者 Linux 那种系统驱动的概念而是嵌入式里面常说的芯片驱动也就是用单片机通过 SPI 去控制这颗 DAC让它输出我们想要的模拟电压。整篇内容适合正在用 TPC116S8 做项目的嵌入式工程师也适合那些在几颗多通道 DAC 之间纠结选型的同学。驱动代码基于 STM32 HAL 库写的但是底层做了抽象换成 GD32、ESP32、N32 这类 MCU 也只需要改一个文件。1. 一颗多通道 DAC 芯片为什么值得专门写篇驱动分享1.1 TPC116S8 到底是什么TPC116S8 是一颗 8 通道、16 位的电压输出型 DAC通信接口是 SPI工作电压范围比较宽典型应用场景包括工业 PLC 模拟量输出、仪表校准、可编程电压源、电机调速给定信号这类需要多路精确模拟输出的地方。它最大的优势就是省引脚一颗芯片占一条 SPI 总线再加一根片选线就能扩展出 8 路模拟输出。对比多片单通道 DAC的方案它的好处很直接。4 片单通道 DAC 至少需要 4 根片选PCB 上布线和单片机的 GPIO 占用都很尴尬。而 TPC116S8 这种多通道单芯片方案一片就解决了通道数量问题封装面积小不少成本上也比 4 颗单通道 DAC 叠加更有优势。另外它和市面上常见的 DAC8568、AD5668 这类 8 通道、16 位 SPI DAC 在思路上非常相似都是从寄存器写数据高位是命令和通道地址低位是 DAC 值。所以下面这套驱动逻辑只要把命令宏定义改一改完全可以平移到其他几颗芯片上。我选它还有一个原因位数是真正的 16 位不是那种内部 12 位硬拉高的所谓16 位。在需要精细调节模拟量的场合比如传感器校准时的偏置补偿12 位和 16 位的差距一测就出来了。12 位 DAC 在 0~5V 范围内最小步进约 1.22mV16 位能做到约 76.3uV差距非常明显。1.2 硬件连接五个最容易出问题的点驱动能不能跑起来一半取决于硬件接得对不对。TPC116S8 的引脚不算复杂我按实际使用习惯列了一张连接表TPC116S8 引脚方向接到 MCU 哪里说明SCLK输入SPI_SCK时钟线空闲极性看手册配置DIN输入SPI_MOSI数据线主机输出从机输入CS#输入任意 GPIO 或 SPI_NSS片选低电平有效LDAC#输入任意 GPIO输出同步锁存小心别空着VREF输入基准电压决定满量程输出AVDD电源3.3V 或 5V模拟电源AGND地GND模拟地硬件上最容易翻车的有五处。第一CS# 不能直接接地。很多人觉得只挂一颗 SPI 设备CS# 一直拉低算了结果发现通信时序完全乱掉。SPI 从机是靠 CS 边沿来复位内部位计数器的CS# 必须由 MCU 控制每一帧数据前后都要完整地拉低再拉高。第二VREF 的质量直接决定输出精度。我一开始图省事直接把 3.3V 电源分压给 VREF结果输出噪声大得离谱。后来换成一颗专用基准芯片再用 0.1uF 和 10uF 电容并联去耦效果立刻就不一样了。第三AVDD 和 DVDD 如果芯片里分开供电一定要按手册做磁珠隔离至少也要在各自引脚就近放去耦电容。第四LDAC# 不能悬空。如果芯片支持 LDAC 同步功能悬空会导致输出更新时机不确定接一个 GPIO 并且初始化时拉高是最稳妥的做法。第五SPI 速率不要一上来就拉满。我习惯先用 1MHz 以下调通功能确认波形逻辑对了再往上提速。SPI 模式下 CS# 的最小低电平时间、SCLK 和 DIN 之间的建立保持时间手册上给了参数超过上限就可能出现随机错误。2. 驱动设计前的关键功课数据帧、时序和模块划分2.1 数据帧结构先把手册那张表吃透写驱动之前最重要的一件事就是把芯片手册里的数据帧格式表读懂。TPC116S8 走的是 24 位一帧的结构高几位是命令和通道地址低 16 位是 DAC 数据。你可以把这一帧想象成一封挂号信前面是这封信寄给哪个部门、办什么事后面才是真正的正文内容。芯片收到一帧以后先解析高位的命令和通道号再把低 16 位数据锁存到对应通道的寄存器里。我针对这类芯片整理了一个常见映射关系具体到 TPC116S8 一定要以手册寄存器表为准位段含义说明bit 23 ~ 22命令类型00 写指定通道 / 01 写全部通道 / 其它命令bit 21 ~ 19通道地址000~111 对应通道 0~7bit 18 ~ 16保留或扩展一般固定为 0bit 15 ~ 0DAC 数据16 位有效数据右对齐看懂这张表之后驱动里最重要的事情就是把这 24 位拼成一个 uint32_t然后拆成 3 个字节按顺序发出去。这里有个容易踩的坑发送顺序到底是先发最高字节还是先发最低字节。绝大多数 SPI DAC 都是 MSB first也就是先发 bit 23最后发 bit 0。如果芯片手册里明确说 MSB first那就必须把第 16~23 位放在第一个字节。很多输出乱跳的问题最后查下来都是因为这个顺序反了。2.2 驱动分层把 SPI 抽象出去以后换 MCU 不用重写很多新手写芯片驱动喜欢把 SPI 收发代码直接写在芯片驱动函数里比如HAL_SPI_Transmit(hspi1, ...)直接出现在tpc116s8_write_channel里。这种写法在单个项目里很爽但一旦换平台或者换 SPI 外设全部函数都要改非常痛苦。我习惯把驱动拆成两层。底层叫移植层只干四件事初始化 SPI、片选拉低、片选拉高、发送一个 24 位帧。这一层跟具体 MCU 强相关放一个独立文件tpc116s8_port.c。上层是芯片驱动层只关心数据帧怎么组、命令怎么拼完全不碰 HAL 库放tpc116s8.c。应用层再往上调用tpc116s8_write_channel(0, 30000)这种接口就完事了。这样分层最大的好处是如果项目从 STM32 换到 GD32只需要改tpc116s8_port.c一个文件几十行代码芯片驱动层一行不用动。我现在几乎所有外设驱动都是这个套路花十分钟做的结构设计省下来的可不止两小时的移植时间。2.3 对外接口设计函数少一点调用方就舒服一点驱动接口设计有个原则宁可内部复杂对外接口一定要简洁。用过那些一上来就是十几个参数、调用之前要设置各种结构体的驱动写应用逻辑的时候真的很崩溃。所以我给 TPC116S8 设计的对外接口只有 5 个函数void tpc116s8_init(void); // 初始化复位、上电、清缓存 int tpc116s8_write_channel(uint8_t ch, uint16_t value); // 写单通道 int tpc116s8_write_all(uint16_t value); // 写全部通道 void tpc116s8_set_power(uint8_t enable); // 全局掉电/上电 void tpc116s8_reset(void); // 软件复位write_channel带返回值通道号越界时返回错误码这样调用方可以在初始化时做一次自检。层与层之间用uint16_t传 DAC 码值不在驱动里做电压换算。电压怎么映射成码值属于应用层的事情比如 0~5V 输出满量程 65535 对应 5V那我设 2.5V 就应该传 32767 或者 32768按四舍五入处理。驱动只负责把码值写进去不做任何多余解释这样接口最干净换芯片时也不用改应用代码。3. 完整驱动代码直接抄作业3.1 底层移植层 tpc116s8_port先看移植层的头文件。这个文件定义了底层需要提供的 5 个函数主要是把片选、SPI 收发、LDAC 操作全部收口/* tpc116s8_port.h */ #ifndef __TPC116S8_PORT_H #define __TPC116S8_PORT_H #include stdint.h void tpc116s8_spi_init(void); /* 初始化 SPI 外设和 GPIO */ void tpc116s8_spi_select(void); /* CS# 拉低 */ void tpc116s8_spi_release(void); /* CS# 拉高 */ void tpc116s8_spi_write_frame(uint32_t frame); /* 发送一个 24 位帧 */ void tpc116s8_ldac_pulse(void); /* LDAC 脉冲同步更新输出 */ #endif以 STM32 HAL 为例具体实现是这样的。注意我用的是软件控制片选不用硬件 NSS。原因很简单软件片选时序可控且在 SPI 速率不高或者总线还挂着其他设备时更灵活/* tpc116s8_port.c */ #include tpc116s8_port.h #include main.h extern SPI_HandleTypeDef hspi1; #define DAC_CS_PIN GPIO_PIN_4 #define DAC_CS_PORT GPIOA #define DAC_LDAC_PIN GPIO_PIN_5 #define DAC_LDAC_PORT GPIOA void tpc116s8_spi_init(void) { /* SPI1 已经由 CubeMX 初始化 * 模式配置为 SPI Mode 0CPOL0, CPHA0 * 数据宽度 8bitMSB first速率 1MHz */ HAL_GPIO_WritePin(DAC_CS_PORT, DAC_CS_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(DAC_LDAC_PORT, DAC_LDAC_PIN, GPIO_PIN_SET); } void tpc116s8_spi_select(void) { HAL_GPIO_WritePin(DAC_CS_PORT, DAC_CS_PIN, GPIO_PIN_RESET); } void tpc116s8_spi_release(void) { HAL_GPIO_WritePin(DAC_CS_PORT, DAC_CS_PIN, GPIO_PIN_SET); } void tpc116s8_spi_write_frame(uint32_t frame) { uint8_t buf[3]; buf[0] (uint8_t)(frame 16); buf[1] (uint8_t)(frame 8); buf[2] (uint8_t)(frame); HAL_SPI_Transmit(hspi1, buf, 3, 10); } void tpc116s8_ldac_pulse(void) { HAL_GPIO_WritePin(DAC_LDAC_PORT, DAC_LDAC_PIN, GPIO_PIN_RESET); /* 根据手册要求保持低电平的时间要大于最小脉冲宽度 */ for (volatile int i 0; i 20; i); HAL_GPIO_WritePin(DAC_LDAC_PORT, DAC_LDAC_PIN, GPIO_PIN_SET); }有一个细节要提醒HAL_SPI_Transmit的最后一个参数是超时时间单位是毫秒。速率越低、帧越长这个值要留足。我之前在一颗速率只有 100kHz 的从机芯片上把超时设成 1ms结果经常返回超时错误因为一帧 24 位就要 240us加上函数调用开销1ms 确实很紧张。给 10ms 超时其实没有性能损耗反而少了很多无谓的调试烦恼。3.2 芯片驱动层 tpc116s8.c / tpc116s8.h头部文件把对外接口、错误码、通道限制都定义清楚。这里的TPC116S8_FULL_SCALE常量可以在应用层做电压换算时直接引用不用到处写魔法数字 65535/* tpc116s8.h */ #ifndef __TPC116S8_H #define __TPC116S8_H #include stdint.h #define TPC116S8_CH_MAX 8 #define TPC116S8_FULL_SCALE 0xFFFFu typedef enum { TPC116S8_OK 0, TPC116S8_ERR_CHANNEL -1, TPC116S8_ERR_VALUE -2 } tpc116s8_err_t; void tpc116s8_init(void); int tpc116s8_write_channel(uint8_t ch, uint16_t value); int tpc116s8_write_all(uint16_t value); void tpc116s8_set_power(uint8_t enable); void tpc116s8_reset(void); #endif驱动源文件是整个代码的核心。命令宏定义放在文件开头万一你拿到的芯片手册命令位定义和别人的不一样只需要在这里修改其他代码不用动。这个隔离设计在换芯片型号、甚至是同系列不同子型号时特别省心/* tpc116s8.c */ #include tpc116s8.h #include tpc116s8_port.h /* 命令帧格式以你拿到的手册为准 * bit 23-22 : 命令类型 * bit 21-19 : 通道地址 0~7 * bit 18-16 : 保留位 * bit 15-0 : DAC 数据 */ #define TPC116S8_CMD_WRITE (0x00u 22) #define TPC116S8_CMD_WRITE_ALL (0x01u 22) #define TPC116S8_CMD_POWER (0x02u 22) #define TPC116S8_CMD_RESET (0x03u 22) #define TPC116S8_CH_ADDR(ch) (((uint32_t)(ch) 0x07u) 19) static uint16_t dac_cache[TPC116S8_CH_MAX]; static void tpc116s8_send_frame(uint32_t frame) { tpc116s8_spi_select(); tpc116s8_spi_write_frame(frame); tpc116s8_spi_release(); } void tpc116s8_init(void) { tpc116s8_spi_init(); tpc116s8_reset(); tpc116s8_set_power(1); for (int i 0; i TPC116S8_CH_MAX; i) { dac_cache[i] 0; } } int tpc116s8_write_channel(uint8_t ch, uint16_t value) { if (ch TPC116S8_CH_MAX) { return TPC116S8_ERR_CHANNEL; } dac_cache[ch] value; uint32_t frame TPC116S8_CMD_WRITE | TPC116S8_CH_ADDR(ch) | (uint32_t)value; tpc116s8_send_frame(frame); return TPC116S8_OK; } int tpc116s8_write_all(uint16_t value) { for (int i 0; i TPC116S8_CH_MAX; i) { dac_cache[i] value; } uint32_t frame TPC116S8_CMD_WRITE_ALL | (uint32_t)value; tpc116s8_send_frame(frame); return TPC116S8_OK; } void tpc116s8_set_power(uint8_t enable) { uint32_t frame TPC116S8_CMD_POWER | (enable ? 0x0001u : 0x0000u); tpc116s8_send_frame(frame); } void tpc116s8_reset(void) { uint32_t frame TPC116S8_CMD_RESET; tpc116s8_send_frame(frame); }这里为什么要在驱动层维护一个dac_cache数组唯一的目的是让读当前输出这个操作不需要真的发读命令去问芯片。有些 DAC 芯片支持回读用回读当然最准确但不支持回读或者回读流程很复杂的时候本地缓存的实时性完全够用。而且以后如果要实现开机恢复上一次输出的功能直接从这个缓存数组把数据重新写一遍就行。3.3 应用示例生成电压斜坡和正弦波驱动写完总要跑个例子验证一下。最简单的验证方法是让通道 0 输出一个斜坡电压用万用表看电压是否持续上升或者接一个 LED 加电阻看亮度变化。斜坡扫描代码只需要一个 for 循环/* 通道 0 输出斜坡步进 16相当于每次跳变约 1.2mV0~5V 量程 */ for (uint16_t v 0; v 0xFFFF; v 16) { tpc116s8_write_channel(0, v); HAL_Delay(2); }如果想看更接近动态波形的输出可以造一张正弦波表。工业上经常需要输出正弦激励信号或者做低频扫描思路一样把 0~65535 映射到正弦波幅度再乘以一个偏移量叠加上直流成分。下面这段代码可以生成一个 256 点的正弦表输出在 0.5VCC 上下摆动#define TBL_SIZE 256 static uint16_t sin_tbl[TBL_SIZE]; void build_sin_table(void) { for (int i 0; i TBL_SIZE; i) { double norm 0.5 0.5 * sin(6.28318530718 * i / TBL_SIZE); sin_tbl[i] (uint16_t)(norm * 65535.0); } } /* 循环输出正弦波 */ while (1) { for (int i 0; i TBL_SIZE; i) { tpc116s8_write_channel(0, sin_tbl[i]); HAL_Delay(1); /* 大约 4Hz 的正弦波 */ } }正弦波表加电压斜坡基本就能覆盖驱动是否正常工作的验证需求了。如果示波器看输出波形不是干净的正弦比如有明显的台阶或者毛刺往往就是下面调试章节里要讲的问题不是驱动逻辑的问题。4. 从零调通的记录输出异常、精度和毛刺怎么处理4.1 输出全 0 或满码先别怀疑芯片坏了我第一次上电调试这颗芯片时遇到的第一个问题就是通道输出完全不动万用表量出来始终是 0V。当时第一反应是芯片坏了后来重新翻了手册发现问题几乎都出在 SPI 时序和帧格式上。排查步骤很固定。第一步用逻辑分析仪抓 SCLK、CS#、DIN 三根线确认 CS# 是否在每个帧之间正确拉高拉低。如果 CS# 一直是低SPI 从机很可能已经数错位了。第二步确认数据是 MSB first 还是 LSB first。很多 SPI 控制器默认 MSB first但如果你在初始化时配置成了 LSB firstDIN 上的 bit 序列就是反的芯片收到的帧全是乱码。第三步确认 SPI 模式。DAC 类芯片通常要求 SCLK 空闲时是低电平数据在 SCLK 的第一个边沿被采样对应 SPI Mode 0CPOL0, CPHA0或者 Mode 1CPOL0, CPHA1具体看手册时序图。还有一点容易被忽略HAL_SPI_Transmit发送 3 个字节时如果 SPI 外设是 16 位模式一个 buf[3] 数组会被当成 2 次传输帧就完全错位了。确认 SPI 数据宽度必须配置为 8 位模式这是常见低级错误。4.2 通道串数据问题多半出在帧格式和字节序第二个典型问题想写通道 1结果通道 0 的输出变了或者所有通道一起变。这种串数据现象本质上就是帧里的通道地址没有被芯片正确解析。最常见的元凶是字节序。24 位帧拆成 3 个字节时正确顺序应该是 buf[0] bit23~16buf[1] bit15~8buf[2] bit7~0。这个顺序反了芯片就会把 DAC 数据的低位当成通道地址通道当然乱掉。另外如果命令位定义和真实芯片不一样比如手册要求写单通道命令是10而我用了00也可能导致芯片认为这是一条广播命令所有通道一起更新。遇到这种问题的排查思路是逐字节对照。先写一个函数往某个通道写入一个非常有辨识度的值比如给通道 5 写 0xA5A5然后在 DIN 引脚上用逻辑分析仪解码把解出来的 24 位帧和手册里的真值表逐位对比。对比完后再改驱动里的宏定义。这个方法笨但是最可靠通常 10 分钟就能锁定问题。4.3 16 位 DAC 精度为什么只做出 12 位效果芯片标称 16 位实际测出来有效位数只有 12 位甚至更低这种情况大多不是芯片的问题而是参考电压和电源噪声拖了后腿。16 位 DAC 在 5V 满量程下1 个 LSB 只有约 76uV。如果参考电压的纹波有 2mV那低位数据写了跟没写一样全被噪声吃掉了。解决思路有两个方向。硬件上VREF 必须用低噪声基准源不要从主电源分压分压只能得到一个电压值得不到一个低噪声电压值。基准输出端的去耦电容也要讲究0.1uF 和 10uF 并联是基本操作如果基准芯片的 datasheet 明确要求更大电容按它的要求来。软件上如果你的应用不要求输出快速变化可以在 DAC 输出端加一级 RC 低通滤波截止频率根据需要的输出带宽去算比如 100Hz 的带宽配 10k 电阻和 0.1uF 电容就差不多刚刚好。还有一个经验PCB 布局上SPI 数字信号线要尽量远离 VREF 模拟走线。数字信号边沿陡耦合到参考电压上都是高频噪声很难靠电容滤干净。我的板子上 VREF 走线很短而且两侧都有地线包裹这个细节对实测精度的提升帮助很大。4.4 多通道切换出现毛刺拿 LDAC 解决多通道 DAC 还有一个常见问题依次写通道 0、通道 1、通道 2 时如果每个通道写完后输出立刻更新那么通道 0 先变了通道 1 后变通道 2 最后变整个系统的模拟输出就像爬楼梯一样一级一级跳上去。对某些应用比如同步采样的激励源这种不同步是不能接受的。如果 TPC116S8 支持 LDAC 同步功能习惯做法是先关闭 LDAC或者把 LDAC 拉高让写入的数据只更新寄存器缓存不更新真实输出等所有通道都写完再拉低一下 LDAC让 8 个通道的输出在同一时刻全部更新。在我的代码里已经预留了tpc116s8_ldac_pulse()函数应用层调用流程是/* 先关同步保证写入只到缓存 */ HAL_GPIO_WritePin(DAC_LDAC_PORT, DAC_LDAC_PIN, GPIO_PIN_SET); /* 逐通道写入 */ tpc116s8_write_channel(0, val0); tpc116s8_write_channel(1, val1); tpc116s8_write_channel(2, val2); /* ... */ /* 所有通道写完拉低 LDAC 同步更新 */ tpc116s8_ldac_pulse();关于输出毛刺还有一个负载方面的心得DAC 输出端如果是纯电容负载特别是超过 100nF 的大电容很容易引起振荡或者建立时间变长。批量生产的板子上我习惯在输出端串联一个几十欧的小电阻既可以抑制振荡又不会对精度造成太大影响。调试阶段用示波器看输出波形这个小电阻前面和后面各测一下你就知道负载对输出的影响有多明显了。整套驱动和调试经验整理下来我自己最大的体会是写外设驱动最核心的不是会调 SPI而是沉下心把手册里的数据帧结构、时序参数和寄存器表读懂再动手写代码。读手册花 1 小时能省下 3 天的调板子时间。你先用低速 SPI 把 8 个通道都点亮再逐步加功能每一层都验证过再往上叠驱动就能做到干净、稳定、可复用。本文还有配套的精品资源点击获取
返回列表