做输出指定电压这个需求时,很多人第一反应是找一片带I2C接口的DAC,但真到项目里会发现PCF8591分辨率不够、I2C地址又容易和别的传感器打架。TLC5615是我在一台51小仪表上用顺手的10位串行DAC,后来又在Arduino Uno和STM32F103上各验证了一遍,三根线(SCLK、DIN、CS)就能稳定输出指定电压。这篇文章就把51系列、Arduino、STM32三个平台的驱动方法、帧结构、基准电压计算和常见的翻车点一起讲清楚,适合刚接触DAC,或者想从51平台迁到其他平台的人直接照抄。
1. 为什么选TLC5615:这颗10位DAC的定位与选型
1.1 关于TLC5615必须知道的几件事
TLC5615是一颗10位电压输出型DAC,SPI-like三线接口,内部自带输出运放缓冲,SOP-8封装,外围只需要一个基准电压源和几颗电容。它的核心参数决定了这个项目的基本玩法:
- 分辨率10位,共1024个台阶,按VREF=2.5V算,每个台阶约4.88mV。
- 输出运放增益固定为2,也就是实际输出电压范围是0到2×VREF。
- 三线接口是SCLK、DIN、CS,不占用太多IO。
- 没有I2C地址冲突问题,也没有并行DAC那种占一堆引脚的问题。
选DAC的时候,大家最容易忽视的就是“输出增益”这件事。TLC5615内部运放增益是2,所以VREF=2.5V时满刻度输出接近5V;VREF直接接VCC=5V时,理论满刻度是10V,但输出运放会被供电轨限幅,实测到不了。所以大多数模块都把REFIN单独引出来,方便外接精密基准。
1.2 和MCP4921、PCF8591简单对比(选型对照)
| 芯片 | 位数 | 接口 | 输出范围 | 适合场景 |
|---|---|---|---|---|
| TLC5615 | 10位 | SPI-like三线 | 0 ~ 2×VREF | 对精度要求中等、想要简单三线驱动的项目 |
| MCP4921 | 12位 | SPI | 0 ~ VREF(可配增益) | 需要更细分辨率,或想要增益可配置 |
| PCF8591 | 8位 | I2C | 0 ~ VREF | 临时测试、低速、不想多占IO,但精度差 |
如果只做“输出一个指定电压”这种基础需求,10位的TLC5615足够,因为2.5V基准下每一步4.88mV,手动调压和模拟测温都够用。想再精细一点,MCP4921的12位更合适,但它通信帧和增益配置逻辑又不一样,需要另外写驱动。TLC5615的优势在于驱动逻辑简单,51这种没有SPI外设的老平台也能轻松搞定。
2. 先搞懂通信时序:12位帧、CS和基准电压的计算
2.1 三线时序到底在传什么
TLC5615的传输时序一句话就能讲明白:CS拉低之后,在SCLK的上升沿把DIN上的数据一位一位移进去,一共12个时钟,前2位是无效位,惯例上填0,后10位才是真正的DAC数据,MSB先行,全部移完再把CS拉高,输出就更新了。
这段逻辑对应的信号顺序是:
- CS拉低,开始一次传输。
- 第1、2个SCLK上升沿,依次移入两个0。
- 第3~12个SCLK上升沿,依次移入D9、D8……D0。
- CS拉高,DAC锁存并更新输出。
所以写代码时要特别小心一点:10位数据天然只占bit9~bit0,我们把整个数据放进一个16位变量里,循环判断bit11,因为bit11和bit10本来就是0,这就自动完成了“前导0”的发送。不要画蛇添足再去左移两位,否则会把数据位置搞错。
2.2 输出电压公式与code反算
TLC5615的输出公式是:
Vout = 2 × VREF × code / 1024
假设VREF=2.5V,code=0时输出0V,code=1023时输出接近5V。反过来,要输出指定电压,就直接解code:
code = Vout × 1024 / (2 × VREF)
比如要输出3.3V,VREF=2.5V,那么code = 3.3 × 1024 / 5 ≈ 675.84,四舍五入取676。
注意:TLC5615是10位DAC,公式里除数是1024,不是4096。很多人第一次按12位甚至16位去算,算出来的code差了四倍,输出自然不对。
我实际用的时候会把计算函数做成浮点,因为代码里经常要换算电压。下面这张表是VREF=2.5V时的常用对照,调试时可以拿来直接验证驱动是否正常:
| 目标电压 | code(取整) |
|---|---|
| 0.00V | 0 |
| 1.25V | 256 |
| 2.50V | 512 |
| 3.30V | 676 |
| 4.00V | 819 |
| 4.995V | 1023 |
2.3 用模拟SPI还是硬件SPI
这里要分清平台特点:
- 51系列基本没有SPI外设,用GPIO模拟,方法最直观。
- Arduino的硬件SPI很快,但TLC5615不是标准SPI从机,帧长又不是整数个字节,直接怼SPI库要注意帧对齐。
- STM32可以配置16位硬件SPI,也能模拟,但我更推荐硬件SPI加软件CS,更新率更稳。
TLC5615本身对时序要求不苛刻,GPIO翻转速度足够,所以就算STM32上用模拟SPI,只要别把延时拉太长,输出一样稳定。硬件SPI的优势主要在连续刷新波形时,释放CPU负载。
3. 51单片机驱动:纯GPIO模拟,最基础也最好移植
3.1 接线与基准电压方案
在51上用TLC5615,接线非常简单,我一般这样接:
| TLC5615引脚 | 单片机引脚 | 说明 |
|---|---|---|
| SCLK | P1.0 | 时钟输入 |
| DIN | P1.1 | 串行数据输入 |
| CS | P1.2 | 片选,低有效 |
| VREF | 2.5V基准 | 建议外接TL431或REF3025 |
| VOUT | 输出 | 接万用表/负载 |
| VCC | 5V或3.3V | 注意和模块电平匹配 |
基准电压这里一定要单独讲。我见过很多人图省事把VREF直接接VCC,结果输出比例没问题但绝对值差很多。因为单片机VCC本身就是靠LDO或电源芯片出来的,精度也就2%~5%,DAC的满量程完全依赖VREF,VREF不准,输出就跟着不准。想要真正能“指定电压”,最好用TL431搭一个2.5V基准,或者直接买REF3025基准芯片。
3.2 C51驱动代码逐段拆解
51上我用的是普中板子,12MHz晶振,引脚直接接P1口。完整驱动如下:
#include <reg52.h> sbit TLC_SCLK = P1^0; sbit TLC_DIN = P1^1; sbit TLC_CS = P1^2; void TLC5615_Write(unsigned int code) { unsigned char i; unsigned int frame = code & 0x03FF; // 只取低10位,防止越界 TLC_CS = 0; for (i = 0; i < 12; i++) { TLC_SCLK = 0; if (frame & 0x0800) // 判断bit11,先输出的两位自然是0 TLC_DIN = 1; else TLC_DIN = 0; TLC_SCLK = 1; // 上升沿,数据被TLC5615移入 frame <<= 1; // 准备下一位 } TLC_CS = 1; // CS高电平,锁存并更新输出 TLC_SCLK = 0; }这段代码的关键就一行:frame & 0x0800。frame是10位数据,bit11和bit10是0,第一次循环输出0,第二次再输出0,第三次开始才是真正的D9。这样天然满足12位帧的要求。
调用方式也简单:
void main(void) { while (1) { TLC5615_Write(0); // 输出0V左右 delay_ms(1000); TLC5615_Write(512); // VREF=2.5V时,约2.5V delay_ms(1000); TLC5615_Write(1023); // VREF=2.5V时,接近5V delay_ms(1000); } }我在51上踩过一个小坑:CS拉高之后忘了把SCLK拉低,下一片板子复用引脚时出现轻微毛刺。所以循环外面我习惯补一个TLC_SCLK = 0,保证每次开始传输时SCLK是低电平。
3.3 给一个可以直接用的电压换算函数
51的C语言不支持float运算太频繁时效率会差,但偶尔换算一次完全没压力。我会把电压换算写成一个独立函数,避免每次写code都要心算:
unsigned int Voltage2Code(float vout, float vref) { float code = 1024.0f * vout / (2.0f * vref); if (code < 0.0f) code = 0.0f; if (code > 1023.0f) code = 1023.0f; return (unsigned int)(code + 0.5f); // 四舍五入 }比如TLC5615_Write(Voltage2Code(3.3, 2.5)),就能直接输出3.3V。注意要在函数里做上下限钳位,防止VOUT超过量程后code溢出到高两位,导致帧错乱。
4. Arduino驱动:硬件SPI一行搞定,但别栽在帧对齐上
4.1 接线和Arduino SPI配置
Arduino Uno的引脚分配很固定,接线如下:
| TLC5615引脚 | Arduino Uno引脚 |
|---|---|
| SCLK | D13(SCK) |
| DIN | D11(MOSI) |
| CS | D10(任意GPIO) |
| VREF | 2.5V基准 |
| VOUT | 输出 |
代码里我用的是硬件SPI,配置成SPI Mode 0。为什么是Mode 0?TLC5615在SCLK上升沿采样数据,而SPI Mode 0正好是CPOL=0、CPHA=0,也就是空闲时SCLK为低,第一个时钟边沿采样,和芯片时序一致。
#include <SPI.h> const int CS_PIN = 10; void setup() { pinMode(CS_PIN, OUTPUT); digitalWrite(CS_PIN, HIGH); SPI.begin(); } void TLC5615_Write(unsigned int code) { uint16_t tx = (code & 0x03FF) << 4; // 重点:左移4位,不是6位 SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0)); digitalWrite(CS_PIN, LOW); SPI.transfer16(tx); digitalWrite(CS_PIN, HIGH); SPI.endTransaction(); } void loop() { float vref = 2.5; TLC5615_Write((unsigned int)(1024.0f * 3.3f / (2.0f * vref) + 0.5f)); delay(1000); }这里最容易被坑的是tx = (code & 0x03FF) << 4这个左移量。TLC5615实际有效的是12位帧,前2位是0、后10位是数据。但Arduino的SPI.transfer16一次要发16位,没法只发12位,所以我们把10位数据放到16位帧的第3~12位,前2位是0,后面4位自动补0。如果手滑写成<< 6,数据就会从前两位开始发,帧对齐直接错位,输出要么乱跳要么不对。
4.2 手写模拟SPI与硬件SPI的取舍
Arduino上如果不想用硬件SPI,也可以用和51一模一样的模拟方式:
#define TLC_SCLK 13 #define TLC_DIN 11 #define TLC_CS 10 void TLC5615_SoftWrite(unsigned int code) { uint16_t frame = code & 0x03FF; digitalWrite(TLC_CS, LOW); for (int i = 0; i < 12; i++) { digitalWrite(TLC_SCLK, LOW); digitalWrite(TLC_DIN, (frame & 0x0800) ? HIGH : LOW); digitalWrite(TLC_SCLK, HIGH); frame <<= 1; } digitalWrite(TLC_CS, HIGH); }模拟SPI的好处是引脚随便选,尤其在使用其他SPI外设时不会互相干扰。缺点是在Arduino上digitalWrite本身就慢,连续刷新波形时更新率上不去。硬件SPI配合SPI.transfer16一次发完16位,效率高很多,代价是要把CS和帧结构处理好。
4.3 实测结果与更新率说明
我实测下来,VREF=2.5V时,写code=676,万用表读数是3.29V左右,和理论值3.3V的偏差主要来自基准精度和万用表内阻。用硬件SPI时1MHz时钟完全稳定,没出现丢码现象。如果用到舵机类项目或者音频波形输出,硬件SPI的稳定性和速度优势更明显。
5. STM32驱动:HAL库SPI收发和一点DMA进阶
5.1 CubeMX配置要点
STM32F103上我用SPI1,接线选择PA5、PA7、PA4:
| TLC5615引脚 | STM32引脚 | 说明 |
|---|---|---|
| SCLK | PA5 | SPI1_SCK |
| DIN | PA7 | SPI1_MOSI |
| CS | PA4 | 普通GPIO输出 |
| VREF | 2.5V基准 | 独立基准 |
| VOUT | 输出 | 接万用表 |
CubeMX里需要注意这几个配置项:
- SPI Mode选择Full-Duplex Master。
- Data Size选16 bits,不要选8 bits,否则要自己拼字节。
- Clock Polarity选Low,Clock Phase选1 Edge,也就是SPI Mode 0。
- First Bit选MSB First。
- Prescaler按APB2时钟算,我习惯把SCLK压到1MHz左右。速度不必追求极限,抗干扰更重要。
CS脚直接配置成GPIO输出,推挽模式,初始电平为高。
5.2 HAL驱动代码
配置好CubeMX后,驱动只需要封装一个函数:
void TLC5615_Write(uint16_t code) { uint16_t tx = (code & 0x03FF) << 4; // 和Arduino一样,16位帧左移4位 HAL_GPIO_WritePin(TLC_CS_GPIO_Port, TLC_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, (uint8_t *)&tx, 1, 100); HAL_GPIO_WritePin(TLC_CS_GPIO_Port, TLC_CS_Pin, GPIO_PIN_SET); }注意HAL_SPI_Transmit的第三个参数Size,在16位数据宽度下,1表示一个半字,也就是16位,不是两个字节。有的文章会写Size为2,那其实发送的是两个16位半字,数据多发了一倍,帧就乱了。
如果非得用8位数据宽度,就要手动把tx拆开,并且注意STM32是小端机,直接取出高低字节的顺序和直觉相反。这个坑我踩过,所以建议直接用16位数据宽度,最简单。
5.3 多片级联与定时更新波形的思路
TLC5615想要多路输出,不需要每个芯片都接一组SCLK和DIN。SCLK和DIN可以并联,每片单独接CS,写哪一片就把哪一片的CS拉低,其他CS保持高电平即可。
我做过一个12通道电压源,就是用一片STM32F103挂多片TLC5615,SCLK统一走PA5,DIN统一走PA7,CS分别接PA4、PB0、PB1……每一路电压互不影响。
如果要做连续波形输出,比如正弦波或者斜坡电压,可以预先算好一张code表放在数组里,然后开一个定时器中断,每个周期调用一次TLC5615_Write。这个方案在10kHz以下的更新率下很稳,不需要上DMA。想再高速,就把波形表预生成后配合SPI DMA发送,但CS的帧间拉高还需要额外用定时器或PWM引脚配合,复杂度会明显增加,我一般建议先跑通定时器中断版本再说。
6. 调试实录:输出电压不对的排查链路
6.1 症状一:电压始终接近0V或满幅
这种问题最常见的原因是CS时序不对。如果CS一直为低,或者CS高脉冲太短,DAC寄存器根本没有正确锁存。先写死两个值测试:TLC5615_Write(0x000)和TLC5615_Write(0x3FF),看输出是不是分别接近0V和满幅。
如果写0x3FF时输出满幅,但写0x000时还是满幅,多半是CS拉高失败,或者DIN引脚一直为高。如果是写任意值输出都是满幅,还要检查DIN和SCLK有没有接反,TLC5615只认SCLK上升沿,DIN在上升沿瞬间必须已经稳定。
6.2 症状二:电压值有误差、比例不对
输出有电压但比例不对,先别怀疑时序,先怀疑VREF。拿万用表直接量REFIN引脚,看到底是多少。我之前用的某模块,板载基准标称2.5V,实测2.46V,输出自然偏低。把代码公式里的VREF改成实测值,误差立刻缩小。
还有一个非常经典的错误:把公式写成code = VOUT / VREF × 1024,忘记TLC5615内部增益是2。这样算出来的code只有正确值的一半,输出电压也就只有一半。确认公式用对后,再检查是不是用了1023而不是1024,虽然只差1个LSB,但精调时也会被发现。
6.3 症状三:用硬件SPI时偶尔跳变
硬件SPI偶尔跳变,大概率是SPI时钟太快或者接线太长。TLC5615是低速DAC,SCLK没必要拉到十几MHz。我实际测试下来,SPI时钟压到1MHz左右,杜邦线长度控制在20cm以内,基本不会跳。另外,VCC和VREF旁边一定要放100nF和10uF电容,DAC输出是模拟电路,供电纹波会直接串到输出上。
如果用STM32 HAL库做16位传输出现数据错位,还要重点确认CubeMX里Data Size确实选的是16 bits。有的工程模板默认是8 bits,代码看起来没改,但实际每次只发了一个字节,输出自然不对。
6.4 软件校准与实测记录
作为一个“输出指定电压”的项目,最终还是要落实到校准。我一般做两步:
- 先写code=0,记录实测Vzero。
- 再写code=1023,记录实测Vfull。
然后按线性关系反算:
V_real = Vzero + (Vfull - Vzero) × code / 1023
将目标V代入,解出修正后的code。对我手上这块板,实测数据大概是:
| code | 理论Vout | 实测Vout |
|---|---|---|
| 0 | 0V | 0.012V |
| 512 | 2.5V | 2.49V |
| 1023 | 4.995V | 4.93V |
低压端有几十毫伏偏置,满量程偏低一点。如果你只要求“大概能输出指定电压”,不校准也能用;如果要求误差在1%以内,就必须把基准实测值和两点校准加进去。
7. 用了一个月后我的一些体会
TLC5615这套驱动我前后在三种平台上各写了一遍,最深的感受是:DAC项目能不能跑起来,通信时序只占一半,另一半在基准电压和帧结构。只要记死了“12位帧、前2位填0、Vout=2×VREF×code/1024”这个套路,51、Arduino、STM32之间移植基本就是改引脚定义和SPI封装的事。
个人建议,初次上手先用模拟SPI把基本流程跑通,再换硬件SPI追求性能。软件里校准参数一定要留成变量,不要写死,因为你换一块模块或者换一个基准芯片,整个线性关系都会变。最后再分享一个小技巧:调试时先写0x200也就是512,这个code对应VREF=2.5V时的2.5V,正好是量程一半,最容易从示波器或万用表上判断驱动方向对不对。