ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TLC5615驱动实战:51、Arduino、STM32三平台SPI时序与代码对比

TLC5615驱动实战:51、Arduino、STM32三平台SPI时序与代码对比

1. 从三个平台看TLC5615的驱动差异

TLC5615这颗10位DAC芯片,在电子DIY圈子里算是老面孔了。它便宜、好买、SPI接口简单,输出0到Vref×2的模拟电压,特别适合做可调电源、波形发生器、传感器校准源这类小项目。但有意思的是,同一颗芯片,用51单片机、Arduino、STM32三种平台去驱动,代码结构、时序控制、甚至硬件接线都有不小的差别。我前后用这三种平台都跑过TLC5615,踩过的坑各有各的花样,今天就把这些经验完整地摊开来讲。

先说清楚TLC5615到底能干什么。它是一颗10位分辨率的电压输出型DAC,供电5V,基准电压从REFIN引脚输入,输出电压范围是0到2×Vref。什么意思呢?如果你给REFIN接2.048V,那输出范围就是0到4.096V,分辨率是4.096V除以1024,约等于4mV。这个精度对于大多数DIY项目够用了。它用的是三线SPI接口——CS、SCLK、DIN,没有MISO,因为DAC只写不读。内部结构上,它有一个16位的移位寄存器,但实际有效数据是12位:10位数据加2位子LSB位(通常写0),再加2位填充位。这个细节很多人第一次用会搞错,后面我会详细说。

为什么选这三个平台来对比?因为它们的开发范式完全不同。51单片机是寄存器级操作,你得自己翻转IO口来模拟SPI时序;Arduino有现成的SPI库,但库的抽象层有时候反而让你看不清底层发生了什么;STM32的硬件SPI最强大,但配置步骤也最多,时钟极性、相位、波特率分频这些参数一个都不能错。三种平台代表了从“纯手动”到“半自动”再到“全自动”的完整光谱,理解了它们的差异,你换任何平台都能快速上手。

这篇文章适合谁看?如果你手头正好有TLC5615,想用某个平台驱动它但不确定怎么接线、怎么写时序,那这篇就是为你准备的。如果你已经能跑通一个平台,想迁移到另一个平台,这里也有现成的对照。哪怕你只是好奇“DAC到底怎么输出指定电压”,我也会从原理层面把关键点讲透。下面按平台逐个拆解,每个平台都会给出完整的接线方案、代码实现和实测结果。

1.1 TLC5615的引脚定义与硬件连接要点

先把芯片的引脚搞清楚,这是所有平台共通的基础。TLC5615有8个引脚,DIP8封装,引脚定义如下:

引脚编号名称功能说明
1DIN串行数据输入,SPI的MOSI
2SCLK串行时钟输入,SPI的SCK
3CS片选信号,低电平有效
4DOUT串行数据输出,用于级联,单芯片时悬空
5AGND模拟地
6REFIN基准电压输入,决定输出范围
7OUT模拟电压输出
8VDD电源正极,5V

接线的时候有几个容易翻车的点。第一,REFIN的电压决定了输出上限,但REFIN不能超过VDD减2V,也就是说5V供电时REFIN最高只能到3V左右,否则输出会失真。我一般用2.048V的基准芯片或者用TL431分压得到2.048V,这样输出范围0到4.096V,分辨率刚好4mV,算起来很舒服。第二,AGND和数字地要单点共地,否则输出会有噪声。第三,OUT引脚后面最好跟一个电压跟随器,因为TLC5615的输出驱动能力很弱,直接带负载会拉低电压。我试过直接接万用表测量没问题,但接上负载电阻后电压就掉下来了,加一个运放做缓冲就稳了。

还有一个细节:TLC5615的SPI模式是CPOL=0,CPHA=0,也就是时钟空闲时为低电平,数据在时钟上升沿被采样。这个和大多数SPI器件一样,但如果你用STM32的硬件SPI,配置的时候要确认这两个参数。Arduino的SPI库默认就是Mode 0,所以不用改。51单片机手动模拟的时候,你要保证在SCLK上升沿之前数据已经稳定在DIN上。

2. 51单片机驱动TLC5615的完整实现

51单片机驱动TLC5615,核心就是手动模拟SPI时序。没有硬件SPI可用,所有时序都得靠IO口翻转来实现。这听起来麻烦,但其实反而最直观,因为你能完全掌控每一个时钟沿。我用的是STC89C52,晶振11.0592MHz,IO口用的是P1.0到P1.2。

2.1 51平台接线方案与IO口分配

接线很简单,三个信号线加电源:

  • P1.0接CS(片选)
  • P1.1接SCLK(时钟)
  • P1.2接DIN(数据)
  • VDD接5V,AGND接地
  • REFIN接2.048V基准
  • OUT接万用表或后续电路

这里有个小技巧:如果你用的是开发板,P1口通常已经引出来了,直接插杜邦线就行。但要注意,有些51开发板的P1口上拉了电阻或者接了LED,可能会影响时序。我遇到过一块板子P1.0接了LED,结果CS信号被拉低的时候LED微亮,虽然不影响功能,但说明IO口有额外负载。如果你发现时序不对,先用万用表量一下IO口的实际电平。

2.2 51平台SPI时序模拟的核心代码

TLC5615的数据格式是12位有效数据,但传输的时候要凑成16位。具体来说,16位数据中,前10位是DAC数据的高10位,第11和12位是子LSB位(通常写0),最后4位是填充位(也写0)。等等,这里我要纠正一个常见误解:TLC5615实际是10位DAC,但数据寄存器是12位,其中低2位是“子LSB”,用于配合外部电阻网络实现更精细的调节,但大多数应用直接写0就行。所以你要发送的16位数据格式是:

位15到6:10位DAC数据(MSB在前) 位5到4:2位子LSB(写00) 位3到0:4位填充(写0000)

换句话说,如果你要输出满量程的一半,10位数据是512(0x200),那么16位数据就是0x200左移4位,即0x2000。实际发送的时候,先发高位再发低位。

下面是51单片机的完整代码,我用的是Keil C51编译环境:

#include <reg52.h> sbit CS = P1^0; sbit SCLK = P1^1; sbit DIN = P1^2; void TLC5615_Write(unsigned int dat) { unsigned char i; unsigned int temp; temp = (dat & 0x3FF) << 4; // 取低10位,左移4位凑成16位格式 CS = 0; // 片选拉低,开始传输 SCLK = 0; for(i = 0; i < 16; i++) { if(temp & 0x8000) DIN = 1; else DIN = 0; SCLK = 1; // 上升沿,数据被采样 SCLK = 0; // 下降沿,准备下一位 temp <<= 1; } CS = 1; // 片选拉高,锁存数据 } void main() { unsigned int val; while(1) { // 输出1.024V,假设Vref=2.048V,满量程4.096V // 1.024V对应码值 = 1.024 / 4.096 * 1024 = 256 TLC5615_Write(256); // 延时一段时间 for(val = 0; val < 60000; val++); } }

这段代码的关键点在于temp = (dat & 0x3FF) << 4;这一行。它把10位数据左移4位,低4位补0,正好符合TLC5615的16位格式。然后循环16次,每次先设置DIN,再拉高SCLK产生上升沿,再拉低。注意顺序:先设数据,再给时钟沿,这个顺序不能反,否则数据会在时钟沿变化时不稳定。

2.3 51平台实测与精度验证

烧录进去之后,我用万用表测OUT引脚,输入码值256时,理论输出是1.024V,实测是1.021V,误差3mV,在10位DAC的精度范围内。输入512时理论2.048V,实测2.045V。输入1023时理论4.092V,实测4.088V。整体线性度不错,误差主要来自基准电压的精度和万用表本身的误差。

这里有个经验:51单片机的IO口翻转速度有限,如果你把SCLK的延时去掉,时钟频率大概在几百kHz,TLC5615最高支持1.21MHz的SCLK,所以51的模拟速度完全够用。但如果你在循环里加了其他耗时操作,可能会拉长时钟周期,不过TLC5615对时钟频率没有下限要求,慢一点也没关系。

注意:51单片机的P0口是开漏输出,如果你用P0口驱动TLC5615,必须外接上拉电阻,否则输出高电平的时候没有驱动能力。我建议直接用P1或P2口,它们是准双向口,内部有弱上拉,驱动TLC5615足够了。

3. Arduino驱动TLC5615的快速上手

Arduino平台驱动TLC5615,最大的优势是有现成的SPI库。但这里有个坑:Arduino的SPI库默认使用固定的引脚,比如Uno上SCK是D13,MOSI是D11,CS可以自定义。如果你非要用其他引脚,就得用软件SPI,反而更麻烦。所以我建议直接用硬件SPI引脚,接线简单,代码也简洁。

3.1 Arduino Uno的SPI引脚与接线

Arduino Uno的SPI引脚是固定的:

  • D13接SCLK
  • D11接DIN(MOSI)
  • D10接CS(可以自定义,但D10是默认的SS引脚,用起来最方便)
  • VDD接5V,AGND接地
  • REFIN接2.048V

注意Arduino Uno的IO口是5V逻辑,和TLC5615的5V供电完全兼容,不需要电平转换。如果你用的是3.3V的板子比如ESP32,那就要注意了,TLC5615的VDD是5V,但输入高电平最低要求是0.7×VDD=3.5V,3.3V可能不够,需要加电平转换或者把TLC5615也降到3.3V供电(但这样REFIN也要相应降低)。

3.2 Arduino SPI库的配置与代码实现

Arduino的SPI库用起来很简单,但要注意数据格式。SPI库的transfer()函数一次发送8位,而TLC5615需要16位,所以要发两次。而且SPI库默认是MSB在前,正好符合TLC5615的要求。

#include <SPI.h> const int CS_PIN = 10; void setup() { pinMode(CS_PIN, OUTPUT); digitalWrite(CS_PIN, HIGH); SPI.begin(); SPI.setBitOrder(MSBFIRST); SPI.setDataMode(SPI_MODE0); // CPOL=0, CPHA=0 SPI.setClockDivider(SPI_CLOCK_DIV16); // 16MHz/16 = 1MHz } void TLC5615_Write(uint16_t dat) { uint16_t temp = (dat & 0x3FF) << 4; digitalWrite(CS_PIN, LOW); SPI.transfer((temp >> 8) & 0xFF); // 高8位 SPI.transfer(temp & 0xFF); // 低8位 digitalWrite(CS_PIN, HIGH); } void loop() { // 输出2.048V,码值512 TLC5615_Write(512); delay(1000); // 输出1.024V,码值256 TLC5615_Write(256); delay(1000); }

这段代码里,SPI.setClockDivider(SPI_CLOCK_DIV16)把时钟设为1MHz,低于TLC5615的1.21MHz上限。如果你设成DIV2就是8MHz,超频了,TLC5615可能工作不稳定。我实测DIV8(2MHz)也能跑,但为了保险还是用DIV16。

3.3 Arduino平台常见问题与调试技巧

用Arduino驱动TLC5615,最常见的问题就是“输出一直是0V或者一直是满量程”。这通常是因为CS引脚没有正确控制,或者SPI模式不对。我总结了一个排查清单:

现象可能原因解决方法
输出始终0VCS一直为高,数据没锁存检查CS接线,确保传输时拉低
输出始终5V数据格式错误,发送了全1检查(dat & 0x3FF) << 4是否正确
输出跳变无规律SPI时钟太快或接线松动降低时钟分频,检查杜邦线
输出有噪声电源滤波不足VDD加0.1uF陶瓷电容,REFIN加10uF钽电容

还有一个坑:Arduino Uno的D10引脚同时也是SPI的SS引脚,如果你在代码里把D10用作其他用途,SPI库可能会自动把D10拉低,导致CS误触发。所以要么用D10做CS,要么把D10设为输出高电平,避免干扰。

提示:如果你用的是Arduino Mega,SPI引脚是D50(MISO)、D51(MOSI)、D52(SCK),CS可以用D53或其他引脚。Mega的SPI引脚和Uno不同,接线的时候别搞混了。

4. STM32驱动TLC5615的硬件SPI方案

STM32驱动TLC5615,可以用硬件SPI,也可以用软件模拟。硬件SPI速度快、占用CPU少,但配置步骤多;软件模拟灵活,但速度慢。我两种都试过,最后推荐硬件SPI,因为STM32的SPI外设确实好用,配置一次之后就很省心。

4.1 STM32硬件SPI的初始化配置

我用的是STM32F103C8T6,SPI1,引脚是PA5(SCK)、PA7(MOSI)、PA4(CS,自定义)。配置步骤如下:

  1. 开启GPIOA和SPI1的时钟
  2. 配置PA5和PA7为复用推挽输出,PA4为普通推挽输出
  3. 配置SPI1的参数:主模式、8位数据、MSB在前、CPOL=0、CPHA=0、波特率分频
  4. 使能SPI1

波特率分频要算一下:STM32F103的APB2时钟是72MHz,SPI1挂载在APB2上。TLC5615最高SCLK是1.21MHz,所以分频系数至少是72/1.21≈60,取64分频,得到1.125MHz,安全。SPI的波特率分频只有2、4、8、16、32、64、128、256这几种,64分频对应SPI_BaudRatePrescaler_64。

#include "stm32f10x.h" void SPI1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); // PA5 SCK, PA7 MOSI GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA4 CS GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_4); // CS高电平 SPI_InitStructure.SPI_Direction = SPI_Direction_1Line_Tx; SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_64; SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_InitStructure.SPI_CRCPolynomial = 7; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); } void TLC5615_Write(uint16_t dat) { uint16_t temp = (dat & 0x3FF) << 4; GPIO_ResetBits(GPIOA, GPIO_Pin_4); // CS低 while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, (temp >> 8) & 0xFF); while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, temp & 0xFF); while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); GPIO_SetBits(GPIOA, GPIO_Pin_4); // CS高 }

这里有个细节:SPI_Direction_1Line_Tx表示只发送不接收,因为TLC5615没有MISO。如果你设成SPI_Direction_2Lines_FullDuplex也能用,但会多占用一个MISO引脚,没必要。

4.2 STM32平台输出指定电压的码值计算

输出指定电压的码值计算公式是:

码值 = (目标电压 / (2 × Vref)) × 1024

比如Vref=2.048V,目标电压1.5V,码值 = (1.5 / 4.096) × 1024 = 375。实际写入的时候,把375左移4位得到0x1770,然后分两个字节发送。

我写了一个函数,直接输入目标电压的毫伏值,自动计算码值:

void TLC5615_SetVoltage(uint16_t mV) { // Vref = 2048mV, 满量程 = 4096mV uint16_t code = (uint32_t)mV * 1024 / 4096; if(code > 1023) code = 1023; TLC5615_Write(code); }

这样用起来就很方便,比如TLC5615_SetVoltage(1500)就是输出1.5V。注意计算的时候要用32位中间变量,否则mV×1024可能溢出16位。

4.3 三种平台驱动TLC5615的对比总结

把三种平台的实现放在一起对比,差异一目了然:

对比项51单片机ArduinoSTM32
SPI实现方式手动模拟硬件SPI库硬件SPI外设
代码复杂度中等低高
时钟速度约500kHz1MHz1.125MHz
引脚灵活性任意IO固定SPI引脚固定SPI引脚或重映射
适合场景学习时序原理快速原型高性能项目
最小系统成本最低中等较高

从学习角度,我建议先用51单片机手动模拟一遍,把时序彻底搞懂;然后用Arduino快速验证功能;最后用STM32做正式项目。这样三个阶段走下来,你对SPI和DAC的理解会非常扎实。

5. 实操中遇到的典型问题与排查方法

不管用哪个平台,TLC5615的调试都会遇到一些共性问题。我把这些年踩过的坑整理成速查表,方便你对照排查。

5.1 输出不准或跳变的排查思路

输出不准是最常见的问题,原因可能有很多。先量基准电压,REFIN引脚的电压必须稳定,如果REFIN本身就在跳,输出肯定不稳。我遇到过用电阻分压做基准,结果电源波动导致REFIN跟着变,输出自然不准。后来换成TL431基准,问题就解决了。

然后检查数据格式。很多人以为TLC5615是12位DAC,直接把12位数据发过去,结果输出只有预期的一半或者四分之一。记住:它是10位DAC,数据要左移4位。如果你发12位数据不左移,相当于把数据当成了16位的高12位,低4位补0,实际有效数据变成了高10位,输出就会偏小。

还有一个隐蔽的问题:CS信号的时序。TLC5615要求在SCLK的上升沿采样数据,但CS必须在整个16个时钟周期内保持低电平,传输完成后拉高才能锁存。如果你在传输中间拉高了CS,数据就会丢失。我见过有人在每个字节传输后拉高CS,结果只写入了高8位,低8位丢了,输出自然不对。

5.2 电源与基准电压的噪声处理

DAC的输出噪声主要来自两个方面:电源噪声和基准噪声。TLC5615的电源抑制比不算高,VDD上的纹波会直接耦合到输出。我在VDD和AGND之间并了一个0.1uF的陶瓷电容,又在REFIN和AGND之间并了一个10uF的钽电容,输出噪声明显降低。如果你对噪声要求高,还可以在OUT后面加一个RC低通滤波,截止频率根据你的信号带宽来定。

注意:TLC5615的AGND和数字地要分开走线,最后在电源入口处单点共地。如果数字地和模拟地混在一起,数字信号的开关噪声会通过地线串到输出上。我试过把AGND直接接到数字地,输出有几十毫伏的尖峰,分开之后尖峰就消失了。

5.3 多平台代码移植的注意事项

从51移植到Arduino,最大的变化是SPI库帮你处理了时序,你只需要关注数据格式。但要注意Arduino的SPI.transfer()是阻塞式的,传输完成才返回,所以不需要额外的延时。从Arduino移植到STM32,要注意STM32的SPI需要手动管理TXE和BSY标志位,否则数据可能还没发完你就拉高了CS。

还有一个跨平台的坑:数据类型。51的int是16位,Arduino的int是16位,但STM32的int是32位。如果你用int存码值,在STM32上没问题,但移植到51上可能溢出。我建议统一用uint16_t,明确指定16位无符号整数,这样跨平台都不会出问题。

6. 进阶应用:用TLC5615输出任意波形

TLC5615不仅能输出固定电压,还能输出波形。只要按一定的时间间隔更新码值,就能在OUT引脚上得到阶梯波、三角波、正弦波等。我用STM32的定时器触发DAC更新,做成了一个简易的波形发生器。

6.1 定时器触发DAC更新的实现

思路很简单:用定时器产生固定频率的中断,在中断里更新TLC5615的输出码值。比如要输出1kHz的正弦波,每个周期采样32个点,那定时器中断频率就是32kHz。在中断里查正弦表,把码值写入TLC5615。

const uint16_t sine_table[32] = { 512, 612, 709, 799, 878, 943, 990, 1018, 1023, 1018, 990, 943, 878, 799, 709, 612, 512, 412, 315, 225, 146, 81, 34, 6, 1, 6, 34, 81, 146, 225, 315, 412 }; volatile uint8_t index = 0; void TIM2_IRQHandler(void) { if(TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); TLC5615_Write(sine_table[index]); index = (index + 1) % 32; } }

定时器的配置这里不展开,关键是计算好中断频率。32个点输出1kHz正弦波,中断频率32kHz,STM32的72MHz主频完全能胜任。输出波形用示波器看,阶梯感比较明显,因为只有32个点,加一个低通滤波就平滑了。

6.2 输出波形的精度与频率限制

TLC5615的建立时间是12.5微秒,意味着输出变化后需要12.5微秒才能稳定到最终值的0.5LSB以内。所以理论最大更新率是80kHz。但实际上,如果你更新太快,输出波形会失真。我实测更新率超过50kHz后,波形顶部开始变圆,因为建立时间不够。

另外,TLC5615没有内部缓冲,输出阻抗比较高,直接接示波器探头(1MΩ)没问题,但接低阻抗负载就会掉电压。所以做波形输出的时候,一定要加运放缓冲。我用的是LM358做电压跟随器,效果不错,成本也低。

提示:如果你要输出更高频率的波形,TLC5615就不太合适了,建议换建立时间更短的DAC芯片。但如果是音频范围(20Hz到20kHz),TLC5615完全够用,配合好的低通滤波,音质还不错。

6.3 从固定电压到波形输出的扩展思路

固定电压输出是基础,波形输出是进阶。再往上,你还可以做闭环控制:用ADC采集TLC5615的输出,和设定值比较,通过PID算法调整码值,实现高精度的电压源。这个思路在数控电源里很常见。TLC5615负责粗调,PID负责细调,最终精度可以做到1mV以内。

我在实际项目中用STM32的ADC采集TLC5615的输出,12位ADC的分辨率是1mV左右,和TLC5615的4mV分辨率配合,刚好能覆盖。PID参数整定花了点时间,主要是积分时间要够长,否则会振荡。整定好之后,设定1.5V,实测稳定在1.500V±1mV,效果很好。

这个内容后续还可以这样扩展:把TLC5615的输出接到运放的同相端,运放的反相端接采样电阻,构成恒流源,就能驱动LED或者激光二极管。恒流源的电流由TLC5615的电压和采样电阻决定,调节TLC5615的输出就能调节电流。这个方案我用来驱动大功率LED,电流稳定性很好,比PWM调光更平滑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进