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封装,引脚定义如下:
| 引脚编号 | 名称 | 功能说明 |
|---|---|---|
| 1 | DIN | 串行数据输入,SPI的MOSI |
| 2 | SCLK | 串行时钟输入,SPI的SCK |
| 3 | CS | 片选信号,低电平有效 |
| 4 | DOUT | 串行数据输出,用于级联,单芯片时悬空 |
| 5 | AGND | 模拟地 |
| 6 | REFIN | 基准电压输入,决定输出范围 |
| 7 | OUT | 模拟电压输出 |
| 8 | VDD | 电源正极,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模式不对。我总结了一个排查清单:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 输出始终0V | CS一直为高,数据没锁存 | 检查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,自定义)。配置步骤如下:
- 开启GPIOA和SPI1的时钟
- 配置PA5和PA7为复用推挽输出,PA4为普通推挽输出
- 配置SPI1的参数:主模式、8位数据、MSB在前、CPOL=0、CPHA=0、波特率分频
- 使能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单片机 | Arduino | STM32 |
|---|---|---|---|
| SPI实现方式 | 手动模拟 | 硬件SPI库 | 硬件SPI外设 |
| 代码复杂度 | 中等 | 低 | 高 |
| 时钟速度 | 约500kHz | 1MHz | 1.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调光更平滑。