
1. 这个项目到底在解决什么问题为什么值得折腾做单片机项目时间一长就会发现数字量输出好搞模拟量输出反而是刚需。51、Arduino、STM32 这类单片机很多型号内部根本没有 DAC少数带 DAC 的型号引脚又不够灵活。你需要一个能被普通 IO 口控制的电压输出模块TLC5615 就是这种出场率极高的入门级 DAC 芯片。这个项目的本质就是让三种不同平台、不同开发环境都能用同样的时序逻辑通过 TLC5615 输出一个程序里指定的电压值。它看起来像是一个“外挂小芯片”但解决的实际问题不小可调电源给定信号、电机调速电压、传感器偏置、音频级波形输出、仪表校准参考量都能靠它完成。适合正在做课设、毕业设计、DIY 仪器或者想彻底搞懂 SPI 写 DAC 的人。不需要太高深的模拟电路功底只要能看懂一个 12 位数据帧的发送顺序就能把电压稳稳地放出来。1.1 先说人话DAC 到底是干什么的DAC 的工作就是“把数字翻译成模拟电压”。单片机里存的是 0 和 1算出来的是一个个数字码DAC 芯片内部是一组电阻网络和运放你给一个码值它就输出对应的电压。你可以把 DAC 想象成一个被数字旋钮控制的电压水龙头拧的角度由程序里的数值决定水流大小就是输出电压。TLC5615 是 10 位分辨率意思是从 0 到 1023 一共 1024 个档位可以调。只要参考电压稳定它的输出就能做到很细的步进。对于绝大多数控制类场景10 位已经足够用而且这款芯片便宜、体积小、三线接口几乎任何单片机都能直接驱动。1.2 为什么选 TLC5615而不是其他 DAC市面上 DAC 不少我列几个常见选择对比一下你就明白 TLC5615 的位置了。型号分辨率接口输出范围特点TLC561510 位3 线 SPI0 ~ 2×Vref便宜、引脚少、资料多MCP492112 位4 线 SPI0 ~ Vref分辨率更高但需要额外一发一个片选DAC08328 位并行接口0 ~ Vref老芯片、接线多、占用 IO 多单片机内置 DAC常见 12 位内部外设0 ~ Vref型号少引脚和功能冲突多TLC5615 最大的优势不是性能参数而是“容易上手”。三根线就能驱动SPI 时序也标准51 用 IO 口模拟都没问题Arduino 用硬件 SPI 更简单STM32 用 HAL 库配置也快。另一个容易被忽略的点是它内部带了输出放大结构满量程可以到参考电压的两倍这让它输出范围比很多同价位芯片更宽灵活性更高。1.3 三个平台本质是同一套时序逻辑51、Arduino、STM32 的编程框架完全不同但驱动 TLC5615 的底层逻辑完全一致把片选 CS 拉低在时钟线 SCLK 的上升沿把数据一位一位送到输入线 DIN发完一帧后把 CS 拉高输出电压更新。三者的差别只在于发数据的工具不同。51 系列没有硬件 SPI 的情况很常见所以直接用任意三个 IO 口模拟时序Arduino 自带 SPI 库几行代码就能搞定STM32 的 SPI 外设功能更强配置成对应模式后同样可以一发搞定。把这个共同逻辑拆出来你会发现所谓“跨平台驱动”其实只是换了件外套核心的内核一点没变。2. 吃透 TLC5615 的关键参数和时序成功一半前面说了很多“很容易”但真正调不通的人几乎都栽在引脚接错、数据帧位置不对、参考电压理解错误这三个地方。这一节把 TLC5615 的脾气彻底讲清楚后面写代码时你就能少走弯路。2.1 引脚接线和第一脚确认方法TLC5615 的引脚不算多核心信号就几个引脚作用典型接法CS片选低有效接单片机任意 IO软件拉高/拉低SCLK串行时钟接 SPI 时钟线或普通 IODIN数据输入接 SPI MOSI 或普通 IOVREF参考电压输入接稳定基准电压常见 2.5V 或 2.048VVOUT模拟电压输出接示波器、运放缓冲或后级电路VDD供电3.3V~5V根据实际接GND接地与单片机共地这里特别提醒第一次拿芯片的同学先确认第一脚位置。TLC5615 常见封装上会有圆点或缺口第一脚一般在这个标记的左下方。别直接照网上某张不清晰的引脚图接很容易把 DIN 和 VOUT 接反。操作前拿万用表量一下 VDD 和 GND 有没有短路这是接坏芯片最常见的原因。接线还有一个关键点VREF 不要随便从 VDD 分压就用了。输出精度完全取决于参考电压稳定性。要求不高时可以用一个大电容压住 VDD 分压点要求高时用 TL431 或者专门的基准源给 VREF效果会稳定非常多。VDD 和 VREF 旁边各自放一个 0.1μF 陶瓷电容去耦能减少输出毛刺。2.2 12 位数据帧到底怎么发TLC5615 虽然只有 10 位 DAC但它一次要接收 12 位数据帧。帧结构非常简单bit11 bit10 bit9 bit8 ... bit0 0 0 D9 D8 ... D0也就是说先发两个 0再发 10 位有效数据高位在前。很多人卡在这里因为网上有些代码用 12 个时钟直接把 10 位数据左移两位发出去结果电压对不上。TLC5615 内部的取数位置是按照“前两位填充、后十位数据”设计的我代码里也都按这个标准帧来写。时序上CS 为低电平期间SCLK 每个上升沿锁存一位 DIN。发送结束后把 CS 拉高TLC5615 就会把新码值更新到输出端。对应到 SPI 模式就是常见的 Mode 0SCLK 空闲为低上升沿采样高位先出。如果你用硬件 SPI 的 16 位帧也不是不行只要把前 12 位拼成标准帧即可。比如 10 位码值是 code_value那么 16 位数据可以这样拼(code_value 0x03FF) 4这样高两位自动是 010 位数据落在第 13 到第 4 位最后 4 位补 0。TLC5615 只在乎前 12 位后面几位可以忽略。2.3 输出电压换算别被“两倍”坑了TLC5615 的输出电压计算公式是Vout 2 × Vref × code_value / 1024很多人会把满量程算成 Vref其实不是。TLC5615 内部输出级把 D/A 转换结果放大了两倍所以当 Vref 2.5V 时满量程是 5VVref 2.048V 时满量程约 4.096V。反过来想输出指定的 Vout码值计算是code_value Vout × 512 / Vref拿 Vref 2.5V 举例几个常见目标电压的码值如下目标电压码值计算写入码值0.5V0.5 × 512 / 2.51021.2V1.2 × 512 / 2.52462.5V2.5 × 512 / 2.55124.8V4.8 × 512 / 2.5983最终输出会有一点量化误差这是 10 位 DAC 本身的特性别追求它输出 1.000000V那是不现实的。比如 1.2V 对应 246实际输出是 246 × 2 × 2.5 / 1024 ≈ 1.201V这个精度对绝大多数控制场景完全够用。3. 三平台真实驱动代码照着抄就能跑这一节给出 51、Arduino、STM32 三种写法。每一份代码我都按“先两个 0再 10 位数据”的标准帧来写而且在注释里标清楚了关键点。没有硬件的时候51 可以用 Proteus 仿真Arduino 可以用 Wokwi 在线仿真STM32 可以用 Keil 配合仿真器或先跑软件模拟。3.1 51 系列用三个 IO 手写 SPI51 系列最通用的做法是用普通 IO 模拟时序不用纠结有没有硬件 SPI。下面这份代码基于标准 51 内核任意三个 IO 都能跑我用 P1 口的 P1.0、P1.1、P1.2 举例。#include reg52.h #include intrins.h sbit DAC_CS P1^0; sbit DAC_SCLK P1^1; sbit DAC_DIN P1^2; // 把一个 10 位码值写入 TLC5615 void TLC5615_Write(unsigned int code_value) { unsigned char i; unsigned int frame; frame code_value 0x03FF; // 只保留 10 位有效数据 DAC_CS 0; // 片选拉低开始传输 for (i 0; i 12; i) { DAC_SCLK 0; if (frame 0x0800) // 从 bit11 开始判断前两位自动为 0 DAC_DIN 1; else DAC_DIN 0; _nop_(); _nop_(); DAC_SCLK 1; // 上升沿TLC5615 锁存数据 _nop_(); frame 1; } DAC_CS 1; // 片选拉高输出更新 } // 由目标毫伏值和参考毫伏值计算码值带四舍五入 unsigned int Voltage_To_Code(unsigned int mv, unsigned int ref_mv) { if (mv ref_mv * 2UL) mv ref_mv * 2UL; return (unsigned int)(((unsigned long)mv * 512UL ref_mv / 2) / ref_mv); } // 主函数例子输出 1.2V void main(void) { unsigned int code_val; code_val Voltage_To_Code(1200, 2500); // 参考电压 2.5V TLC5615_Write(code_val); while (1); }这个代码里的_nop_()是让时序更稳的小延迟。51 的 IO 翻转速度很快适当加一点延迟可以避免数据还没稳定就被采样。主频 12MHz 下这个延迟量级完全够用。3.2 Arduino硬件 SPI三行搞定数据发送Arduino Uno 的 SPI 引脚固定SCLK 对应 13MOSI 对应 11片选可以任意选我用 10 号引脚。如果你用的是 ESP32 或其他 Arduino 兼容板改引脚定义即可逻辑完全不变。#include SPI.h #define DAC_CS 10 void setup() { pinMode(DAC_CS, OUTPUT); digitalWrite(DAC_CS, HIGH); SPI.begin(); } void loop() { // 目标 1.2VVref 2.5V码值 246 tlc5615Write(246); delay(1000); } void tlc5615Write(uint16_t code_value) { SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0)); digitalWrite(DAC_CS, LOW); // 标准帧前两位 0 10 位数据16 位发送时左移 4 位 uint16_t frame (code_value 0x03FF) 4; SPI.transfer16(frame); digitalWrite(DAC_CS, HIGH); SPI.endTransaction(); }如果你不想固定死在 SPI 引脚上也可以用软件模拟这样引脚随便选。下面这个函数和 51 的写法是同构的#define DAC_CS 10 #define DAC_SCLK 7 #define DAC_DIN 8 void tlc5615WriteSoft(uint16_t code_value) { code_value 0x03FF; digitalWrite(DAC_CS, LOW); for (int i 11; i 0; i--) { digitalWrite(DAC_SCLK, LOW); digitalWrite(DAC_DIN, (code_value i) 0x01); digitalWrite(DAC_SCLK, HIGH); } digitalWrite(DAC_CS, HIGH); }注意(code_value i)在 i 等于 11 和 10 时因为 code_value 只有 10 位结果自然是 0正好补了前面两个填充位。这种写法的好处是不依赖 SPI 引脚坏处是速度比硬件 SPI 慢一些但驱动 TLC5615 完全够用。3.3 STM32HAL 库配合 SPI 外设稳且快STM32 我推荐用硬件 SPI。CubeMX 里配置 SPI1模式选 Transmit Only Master数据宽度改成 16 位时钟极性 CPOL Low相位 CPHA 1 Edge也就是 Mode 0高位先出。片选用一个普通 GPIO比如 PB0。#include main.h #define TLC5615_CS_PORT GPIOB #define TLC5615_CS_PIN GPIO_PIN_0 extern SPI_HandleTypeDef hspi1; // 计算码值电压单位 V参考单位 V uint16_t TLC5615_CalcCode(float voltage, float vref) { if (voltage 0.0f) voltage 0.0f; if (voltage vref * 2.0f) voltage vref * 2.0f; // Vout 2 * Vref * code / 1024 code Vout * 512 / Vref return (uint16_t)(voltage * 512.0f / vref); } // 写码值 void TLC5615_Write(uint16_t code_value) { uint16_t frame (code_value 0x03FF) 4; HAL_GPIO_WritePin(TLC5615_CS_PORT, TLC5615_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, (uint8_t *)frame, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(TLC5615_CS_PORT, TLC5615_CS_PIN, GPIO_PIN_SET); } // 使用例输出 1.2V void TLC5615_SetVoltage(float voltage) { TLC5615_Write(TLC5615_CalcCode(voltage, 2.5f)); }这里有个最容易踩的坑SPI 配置成 16 位数据宽度后HAL_SPI_Transmit的第三个参数是“数据个数”不是字节数。很多人填 2结果一次发了两帧CS 没拉高后面的数据又把前面的覆盖了。你要是想用 8 位数据宽度就自己拆成两个字节发uint8_t frame_buf[2]; frame_buf[0] (uint8_t)(frame 8); frame_buf[1] (uint8_t)(frame 0xFF); HAL_SPI_Transmit(hspi1, frame_buf, 2, HAL_MAX_DELAY);两种方式都行但务必保证一帧数据发送期间 CS 始终保持低电平发完一帧立刻拉高。3.4 一个统一思路输出指定电压就是三步无论哪个平台核心操作浓缩起来就三步算码值、拉低 CS、发数据。第一步根据目标电压和参考电压算码值公式是code Vout × 512 / Vref。第二步把 CS 拉低。第三步把标准 12 位帧按高位先发出去发完把 CS 拉高。51 和软件 SPI 是自己打 12 个时钟Arduino 和 STM32 用硬件 SPI 发 16 位也没问题只要前 12 位符合帧格式即可。电压更新时也是一样。你想让输出电压从 1.2V 跳到 3.3V就在程序里重新算一个码值再走一遍这三步输出端只要几十微秒就能稳定到新值。这也是 DAC 比 PWM 加 RC 滤波更舒服的地方电压调整不需要时间常数去拉响应快而且纹波更小。4. 常见问题与排查技巧实录这个模块虽然简单但实际连起来还是会遇到不少问题。我把常见的现象、原因、解决办法整理成下面几组都是我实际调过的场景。4.1 输出一直是 0V或者一直是满量程先说 0V。最常见是 CS 没有真正拉低或者拉低后发送完没有拉高芯片不认为这是一次有效传输。其次是 SCLK 和 DIN 接反或者数据位序发反。51 模拟 SPI 时如果忘记加延时数据可能还没稳定就被上升沿采样也会导致收到一堆 0。满量程的情况常见于 DIN 引脚一直为高比如代码里判断帧高位用了错误的位掩码导致有效位全部变成 1。另一个隐蔽原因是参考电压 VREF 引脚悬空或者接了一个不稳定电压此时输出可能直接飘到某个异常值。先查供电和参考电压再查时序和数据位。4.2 输出值有但和计算值对不上这类问题绝大多数出在“帧位序不对”或者“换算公式用错”。网上很多代码是把 10 位数据左移 2 位再发 12 位如果你的芯片严格按照“前两位填充、后十位数据”工作这种写法发出去的数据位就整体错位表现出来就是某些电压偏得离谱某些小码值看起来又好像正常。用公式计算时也要小心满量程倍数。TLC5615 的输出是 2×Vref不是 Vref。如果你把满量程当成 2.5V码值就会整体大一倍输出电压自然偏高。建议先在代码里固定输出几个已知电压比如 1.2V、2.5V用万用表实测校准再验证换算函数。4.3 输出波形毛刺大电压跳动毛刺一般来自电源和接线。VDD 与 GND 之间没有去耦电容、VREF 滤波电容太小、杜邦线太长都会让模拟输出看起来脏兮兮。处理办法很简单VDD 和 VREF 对地各加 0.1μF 电容模拟电路和数字电路尽量单点接地SCLK 和 DIN 不要和电机驱动线绑在一起走线。如果输出要送进采样精度要求高的后级输出端加一个运放缓冲或者 RC 低通效果会改善很多。TLC5615 输出端不要直接驱动功率负载它只是一个信号级 D/A后面要放大或驱动请交给运放。4.4 三大平台各自的专属坑平台常见坑解决思路51 系列IO 模式不是强推挽时序过快导致采样不稳适当加_nop_()拉低 CS 后先稳定电平Arduino硬件 SPI 用完了没endTransaction影响其他 SPI 设备每次传输前后成对使用 begin/endTransactionSTM32SPI 数据宽度配置和 HAL 发送个数不对齐16 位宽度下 Size 填 18 位宽度下拆两字节填 2全部平台万用表直接测高阻抗节点读数偏低输出端接一个大电阻负载或运放缓冲后再测量4.5 我的一个独家排查习惯我调这种芯片时不会一上来就接目标电压。先把码值固定写成 0、512、1023 三个值分别在输出端量电压。如果是 0V、2.5V、5V说明时序和参考电压部分正常如果中间值不对就是帧格式或换算问题。这样做的效率远远高于直接追一个“看起来很奇怪”的电压值。另一个有意思的小技巧用示波器看 VOUT 的上升沿响应。如果每次更新码值后 VOUT 都能立刻跳到新台阶说明通信链路正常。如果输出更新慢或者有阶梯感多半是 CS 没拉高导致芯片把后续时钟也当成了有效数据正在连续更新寄存器。5. 这块 DAC 还能玩出什么花怎么继续扩展驱动 TLC5615 只是一个起点掌握了 SP I 写码值这套逻辑后它能做的事情其实非常多。5.1 从固定电压到输出模拟波形想要输出正弦波、三角波、锯齿波只需要在定时器中断里不断更新码值。比如你做一个 100 点的正弦波表每隔一定时间送一个码值给 TLC5615输出端就会显现出一个模拟电压包络再接一个低通滤波器就能得到平滑的波形。具体实现思路是先用计算器或上位机生成一张正弦表把浮点电压换算成码值存成一个数组定时器每触发一次取数组的下一个值调用驱动函数循环到末尾就回到开头。TLC5615 的转换速度是微秒级做 1kHz 以下波形绰绰有余做 10kHz 级别也能跑只是波形台阶会更明显需要后端滤波配合。5.2 和 ADC 组合成闭环控制很多 STM32 项目里同时有 ADC 和 DACTLC5615 完全可以和 ADC 组成一个简单的闭环系统。比如做一个恒压源ADC 采样输出电压单片机把采样值和目标值比较然后通过 PID 计算更新 TLC5615 的码值。这样硬件上只多了几根线却能实现真正意义上的“数字闭环”。同样的思路还能做恒流源、温度控制、电池模拟器之类的东西。大部分同学只会用 DAC 输出一个固定电压一旦形成“ADC 采样 - 计算 - DAC 输出”的闭环思维项目深度立刻就不一样了。5.3 多个 DAC 通道与级联玩法一个 TLC5615 只有一个输出通道但实际项目往往需要多路可调电压。最简单的方案是给每片 TLC5615 分配独立的 CS 引脚同一根 SCLK 和 DIN 并联到所有芯片写哪一路就拉低哪一片的 CS。这种接法代码最简单也最容易排查。如果你的 TLC5615 带 DOUT 引脚还可以像移位寄存器一样级联上一片的 DOUT 接下一片的 DIN两片共用一个 CS 和 SCLK。发送时把两路的帧拼成一个更长的数据串CS 拉低后一口气发完再拉高 CS两片同时更新。这种接法省片选 IO适合引脚紧张但需要多路输出的情况。最后再说一个我自己摸索出来的经验TLC5615 这类小 DAC 真正决定输出质量的不是主控性能而是参考电压和布线。移位寄存器时序再快只要 VREF 上有个几十毫伏的纹波输出端就会跟着抖。如果你已经排除了所有代码问题电压还是不稳请回过去检查 VREF 和电源问题十有八九在那里。