STM32F103驱动MCP4728实现四路DAC输出:电路与程序移植全指南
最近做一个小型信号发生器需要STM32F103同时输出四路可调的模拟电压。一开始想用两个MCP4725拼成四路后来发现MCP4728一片就够——四通道、12位、I2C接口、内置2.048V基准电路和程序都比想象中简单但真正做“程序移植”时还是踩了几个坑。这篇就把MCP4728的电路、STM32F103上的驱动移植过程以及调试时最容易翻车的地方一起记录下来给手里正好有同款芯片的兄弟当个参考。先说清楚这个项目是什么一块STM32F103最小系统板通过I2C总路线控制MCP4728让四个DAC通道分别输出指定电压同时用串口打印每路实际输出码值方便验证程序移植是否正确。如果你正在做四路可调电源、低速信号发生器、多通道偏置电压控制这类东西这篇可以直接照着抄。1. 四路模拟输出的需求为什么选MCP4728而不是多片MCP4725项目最开始的方案确实是用两片MCP4725每个芯片两路正好凑四路。但画电路的时候发现两片MCP4725要处理两组不同的I2C地址还要各自接基准电容和去耦电容板子上多占一大块面积。MCP4728的出现等于把这个问题直接解决了一片芯片集成4路12位DAC输出通道为A/B/C/DI2C接口7位地址默认0x60可通过A0/A1/A2引脚改地址同一总线上最多挂8片内置2.048V基准电压可通过配置位选择使用内部基准还是外部VDD作为参考可编程增益1x或2x也就是说输出范围可以是0~2.048V也可以是0~4.096V内部集成输出缓冲放大器可以直接驱动常见负载支持RAM写和EEPROM写配置和电压值可以掉电保存。和STM32F103搭配的时候MCP4728的I2C从机速率最高支持400kHzF103的硬件I2C跑标准模式或者快速模式都没问题。更重要的是F103作为Cortex-M3内核的老牌MCU网上资料多不管是标准外设库还是HAL库I2C初始化都很成熟移植驱动时不需要操心底层外设能不能用。选型上还有一个容易忽略的点MCP4728的满量程输出并不是“固定2.048V”而是取决于基准电压来源和增益设置。如果引脚配置里选择了VREF作为参考源输出满量程才是内部2.048V或2x增益后的4.096V如果基准源选了VDD那么输出会跟着VDD走VDD是3.3V就是0~3.3VVDD是5V就是0~5V。这个特性在后面程序移植和电压校准时要格外注意不然会出现“目标输出1V实际输出却偏到1.6V”这种看起来像程序问题、实际上是基准源配置问题的情况。MCP4725与MCP4728对比可以看下表项目MCP4725MCP4728DAC通道数1路4路分辨率12位12位接口I2CI2C内置基准有可选有2.048V增益配置1x/2x每通道可独立配置1x/2x地址配置1个地址引脚3个地址引脚可接8片EEPROM支持支持每位可配置写保护典型应用单路电压调节四路电压/波形输出2. MCP4728外围电路设计引脚、上拉、去耦一个都不能省电路设计是MCP4728项目里最省事但最容易被“省”出问题的一环。芯片本身是8引脚还是10引脚封装我这次用的模块是SOT-23-8引脚不多但每个引脚的接法都有讲究。2.1 引脚功能与接线把MCP4728的关键引脚列一下VDD电源输入范围2.7V~5.5V。我用3.3V供电和STM32F103同一个电源轨。VSS接地。VREF基准电压引脚。使用内部基准时这个引脚作为输出必须外接0.1uF陶瓷电容到GND而且要尽量靠近引脚否则内部基准噪声会变大如果使用外部基准则从这个引脚输入基准电压。VOUTA/VOUTB/VOUTC/VOUTD四路模拟输出。SCL/SDAI2C时钟和数据线。A0/A1/A2地址选择引脚接GND时对应地址位为0接VDD时为1。LDACDAC同步更新引脚。接GND时每次写寄存器立即更新输出接GPIO时可以由MCU控制多通道同时更新。RDY/BSYEEPROM忙状态指示漏极开路需要接一个上拉电阻到VDD如果不用EEPROM写功能可以悬空但我建议还是接上方便以后调试。2.2 电源去耦和基准电容电源部分MCP4728跟普通模拟芯片一样VDD引脚附近放一个0.1uF陶瓷电容如果电源纹波大再并一个10uF钽电容。VREF引脚要单独放0.1uF电容不要和其他数字电容共用过孔。一个很容易踩的坑是VREF电容如果离芯片太远内部基准在输出小电压时会出现低频抖动表现为万用表读数慢慢跳。我后来把电容从5cm外挪到引脚旁边现象立刻消失。2.3 I2C上拉电阻的计算MCP4728的SCL和SDA引脚是开漏结构必须外接上拉电阻到VDD。STM32F103内部虽然有上拉但内部上拉阻值普遍在30k~50k对I2C快速模式来说太弱上升沿不够陡所以外部必须加上拉。上拉电阻阻值怎么选I2C快速模式400kHz下上升时间要求不超过300ns。总线电容包含芯片引脚电容、PCB走线寄生电容和STM32引脚电容一般按100pF~300pF估算。根据RC充电时间常数公式tr ≈ 0.8473 × R × C。以总线电容150pF、目标tr250ns计算R约等于1.95kΩ。所以2.2kΩ是比较稳妥的起点。如果总线上挂的设备多、走线长上拉就降到1kΩ如果追求低功耗可以放宽到4.7kΩ对应上升时间更慢但100kHz标准模式没问题。我用的是2.2kΩ上拉400kHz跑得很稳。有人直接把模块自带的4.7kΩ电阻留着也能用但线一长就偶尔出现第一个字节丢失的问题。2.4 地址引脚和LDAC的接法A0/A1/A2全部接GND地址就是0x60。如果多片MCP4728级联需要按二进制组合接VDD和GND例如第一片000地址0x60第二片001地址0x61以此类推。LDAC建议不要悬空。我调试时遇到过输出不更新的情况最后发现是LDAC悬空导致内部逻辑状态不稳定。后来直接把它接GND每次写RAM寄存器后输出立即跟随更新问题解决。如果你需要四个通道同时更新就把LDAC接一个STM32的GPIO程序里先把各通道数据写入再把LDAC拉低一下所有通道同步输出这在一些需要模拟同步的场景下很有用。2.5 输出负载和滤波VOUTA~D每个输出脚可以并联一个0.1uF电容到GND用于滤除高频噪声。但要注意输出电容会降低DAC的建立速度如果做高速波形输出这个电容最好不要超过10nF。我这次只是做直流电压输出所以每路放了0.1uF效果很好。3. I2C时序与MCP4728寄存器操作动手写驱动前必须懂的底层逻辑程序移植最忌讳的就是“拿到代码不看协议直接改引脚”。MCP4728的I2C时序并不复杂但要理解几个核心概念不然连调试都不知道从哪里下手。3.1 器件地址MCP4728固定占用I2C地址空间的高7位默认是0x60。这里有个必须注意的坑很多人把0x60直接当成I2C发送地址但在STM32的HAL库中HAL_I2C_Master_Transmit()的地址参数需要的是8位地址也就是在7位地址基础上左移一位。所以写地址应该是0x60 1即0xC0读地址应该是0x60 1 | 0x01即0xC1。用标准库的时候也一样I2C_Send7bitAddress()函数接收的地址参数同样要左移。3.2 写命令的分类MCP4728的写命令大体分几类快速写命令Fast Write一条命令加两个字节数据可以快速更新某个通道适合对时序要求高的波形输出多字节写命令可以一次写一个通道也可以一次写全部四个通道适合设置静态电压配置写命令用来设置VREF、增益、掉电模式等参数EEPROM写命令把配置写入非易失存储器。我这次主要用“一次写全部四个通道”的命令命令字节为0x50后面跟8字节数据每通道2字节。3.3 数据字的排列规则每个通道的2字节数据并不是简单地“高字节放高位、低字节放低位”。MCP4728是12位DACDAC数值范围是0~4095但16位数据字里12位数据被放在高12位也就是第一个字节 DAC值的bit[11:4]第二个字节高4位 DAC值的bit[3:0]第二个字节低4位 该通道的配置位增益、基准、掉电等。换算成代码就是buf[1 i * 2] (value 4) 0xFF; buf[2 i * 2] (value 0x0F) 4;如果你看到的参考代码是value / 16和(value % 16) * 16其实就是这个公式的另一种写法。3.4 读操作为什么重要写驱动程序时MCP4728支持读回当前寄存器的内容。通过I2C读操作可以把每个通道当前值读回来和写入值对比确认I2C通信是否正常。调试阶段强烈建议先写后读否则万用表量不到电压时很难判断是程序问题还是焊接问题。读回的数据结构和写数据结构类似每个通道2字节但第一字节的某些位是状态标志位。具体解析要看数据手册不过调试时只要对比“读回的数据和写入的数据一致”就能确认基本链路没问题。4. STM32F103程序移植从Arduino/其他平台搬到HAL库的完整过程网上MCP4728的开源驱动大多是针对Arduino写的用的I2C接口函数是Wire.beginTransmission()、Wire.write()、Wire.endTransmission()。移植到STM32F103时不需要逐行翻译核心思路是把“平台相关层”替换掉。4.1 移植思路我把MCP4728驱动拆成两层平台层负责I2C底层的初始化、发送、接收在我的工程里用HAL库的HAL_I2C_Master_Transmit()和HAL_I2C_Master_Receive()应用层负责MCP4728的命令构造、电压值转换、通道选择与单片机型号无关。Arduino的Wire.write()本质是往发送缓冲区塞字节最后endTransmission()一次性发出HAL库则是一口气把整个缓冲数组发出去。所以只要把原来分散的单字节写入合并成一个数组发送就能无缝移植。4.2 I2C初始化在CubeMX里配置I2C1使用PB6作SCL、PB7作SDA我用的参数如下static void MX_I2C1_Init(void) { hi2c1.Instance I2C1; hi2c1.Init.ClockSpeed 400000; // 快速模式 hi2c1.Init.DutyCycle I2C_DUTYCYCLE_2; // 占空比2:1 hi2c1.Init.OwnAddress1 0; hi2c1.Init.AddressingMode I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode I2C_DUALADDRESS_DISABLE; hi2c1.Init.OwnAddress2 0; hi2c1.Init.GeneralCallMode I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode I2C_NOSTRETCH_DISABLE; if (HAL_I2C_Init(hi2c1) ! HAL_OK) { Error_Handler(); } }注意GPIO要配置成开漏输出因为I2C总线靠上拉电阻拉高开漏才能产生正确的低电平和释放总线。4.3 MCP4728驱动核心代码下面这段是我在HAL库下的核心驱动。MCP4728_WriteAllVoltage()一次写四个通道输入的数组长度固定为4。#include mcp4728.h #include i2c.h #define MCP4728_ADDR 0x60 // 7位地址A0/A1/A2都接地 #define MCP4728_WRITE_ALL 0x50 // 多字节写一次写所有DAC寄存器 // 将电压值转换为12位DAC码值 // voltage目标电压 // vref基准电压内部基准2.048V或VDD电压 uint16_t MCP4728_VoltageToCode(float voltage, float vref) { if (voltage 0.0f) return 0; if (voltage vref) return 4095; return (uint16_t)(voltage / vref * 4095.0f 0.5f); } // 写全部4个通道 void MCP4728_WriteAllVoltage(uint16_t *values) { uint8_t buf[9]; buf[0] MCP4728_WRITE_ALL; for (int i 0; i 4; i) { // 12位数据放到16位字的高12位低4位配置位默认全0 // 配置位全0表示普通工作模式增益1x基准源选择由芯片默认配置决定 buf[1 i * 2] (values[i] 4) 0xFF; buf[2 i * 2] (values[i] 0x0F) 4; } if (HAL_I2C_Master_Transmit(hi2c1, (MCP4728_ADDR 1), buf, 9, 100) ! HAL_OK) { // 发送失败可以在这里挂一个调试断点或者打印错误 } }如果想单独写某一个通道命令字节改为0x40加通道索引然后只发送3字节void MCP4728_WriteChannel(uint8_t ch, uint16_t value) { uint8_t buf[3]; if (ch 3) ch 3; buf[0] 0x40 | ch; buf[1] (value 4) 0xFF; buf[2] (value 0x0F) 4; HAL_I2C_Master_Transmit(hi2c1, (MCP4728_ADDR 1), buf, 3, 100); }4.4 主循环测试程序主程序里初始化完I2C和串口后循环设置四路电压并用串口打印一次写操作是否成功。这样不用示波器也能看到基本工作状态。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); MX_USART1_UART_Init(); uint16_t ch_values[4]; while (1) { ch_values[0] MCP4728_VoltageToCode(0.5f, 2.048f); ch_values[1] MCP4728_VoltageToCode(1.0f, 2.048f); ch_values[2] MCP4728_VoltageToCode(1.5f, 2.048f); ch_values[3] MCP4728_VoltageToCode(2.0f, 2.048f); MCP4728_WriteAllVoltage(ch_values); printf(MCP4728 updated: A%.3f B%.3f C%.3f D%.3f\r\n, ch_values[0] * 2.048f / 4095.0f, ch_values[1] * 2.048f / 4095.0f, ch_values[2] * 2.048f / 4095.0f, ch_values[3] * 2.048f / 4095.0f); HAL_Delay(1000); } }如果使用标准外设库核心发送代码就是把HAL函数替换成标准库的I2C事件处理流程这里不完整展开只放一个发送部分的骨架I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, MCP4728_ADDR 1, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); for (int i 0; i 9; i) { I2C_SendData(I2C1, buf[i]); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); } I2C_GenerateSTOP(I2C1, ENABLE);标准库的写法对事件标志位的依赖比较强移植时建议认真看每个等待条件否则容易卡在while循环里。5. 调试与避坑硬件I2C死锁、地址左右移、基准电压设置整个项目最耗时间的不是画电路也不是写驱动而是调那些看起来“不应该出问题”的小毛病。我把这次踩过的坑按排查顺序整理出来每一个都见过不止一次。5.1 硬件I2C死锁的排查链路现象程序第一次调用HAL_I2C_Master_Transmit()正常但第二次就卡死或者上电后直接卡死在I2C发送函数里。排查过程先检查I2C引脚上拉电阻发现如果上拉电阻太大比如10kΩ总线上升沿太慢偶发死锁用示波器抓SCL/SDA波形发现SCL上有一堆毛刺原来是STM32F103的硬件I2C在异常时序后进入了BUSY状态读I2C状态寄存器发现I2C_SR2的BUSY位一直为1尝试等待BUSY位清零但F103的硬件I2C在总线异常后BUSY位不会自动恢复最后在错误处理里加了对I2C外设的重新初始化。针对STM32F103硬件I2C的这个问题业界常用的方案有三种使用软件模拟I2CGPIO翻转速度可控代码简单适合对速率要求不高的MCP4728应用在HAL层把HAL_MAX_DELAY改成有限超时同时每次发送出错后调用HAL_I2C_DeInit()再重新HAL_I2C_Init()使用I2C外设但把时钟降到100kHz减少异常触发概率。我这次最终选了“有限超时错误后复位外设”的方案毕竟硬件I2C不占CPU且MCP4728对实时性没有苛刻要求。核心代码就是在发送失败时执行if (HAL_I2C_Master_Transmit(hi2c1, addr, buf, len, 50) ! HAL_OK) { HAL_I2C_DeInit(hi2c1); MX_I2C1_Init(); }如果项目里RTOS对I2C操作有互斥要求这中间最好加一个信号量保护防止多个任务同时操作I2C。5.2 地址左移一位的坑这个坑说小很小但特别容易犯。MCP4728的7位地址是0x60但STM32的HAL库和标准库需要8位地址也就是0xC0。如果你直接把0x60传给HALI2C从机是不会响应的。判断方法很简单HAL_I2C_Master_Transmit()返回HAL_OK不代表从机收到了数据只能说明总线上没产生错误要确认地址是否正确可以用HAL_I2C_IsDeviceReady()探测一下if (HAL_I2C_IsDeviceReady(hi2c1, MCP4728_ADDR 1, 5, 100) ! HAL_OK) { printf(MCP4728 not found\r\n); }5.3 基准电压与输出满量程对不上有一次我把目标电压设成1.0V用内部基准2.048V计算码值是2000结果实测输出1.61V。排查后发现MCP4728当前的配置位并没有使用内部基准而是把VDD当作基准源所以满量程是3.3V而不是2.048V。这里要强调代码里数据字低4位如果全0只是表示“配置位为0”并不是“使用内部基准”。配置位的具体含义需要查MCP4728数据手册里的寄存器表。不同批次的模块默认配置可能不同有的默认为内部基准有的默认跟随VDD。所以调试电压类DAC时第一步就是确认基准源。最简单的方法写一个最大码值4095用万用表量输出。如果最大值约等于2.048V说明用的是内部基准如果约等于VDD电压说明用的是外部基准。然后再去调整增益和配置位。5.4 LDAC导致输出不更新的问题MCP4728的LDAC引脚如果悬空某些情况下寄存器数据写入后输出不会即时更新。我在一块手工焊接的板子上就遇到过寄存器读回值是对的但万用表输出纹丝不动。排查思路先用示波器看LDAC引脚电平再检查原理图。最后发现LDAC悬空内部逻辑受干扰把LDAC直接接GND后问题消失。如果你需要多通道同步更新就把LDAC接到GPIO并确保写入数据后主动拉低再拉高。5.5 EEPROM写入的副作用MCP4728支持把DAC数据和配置写入EEPROM上电时自动加载。这个功能在某些需要“上电就有指定电压”的场景很好用但调试阶段容易出问题比如你写了一次EEPROM之后程序怎么改都不生效因为上电后EEPROM里的旧值又被加载出来了。MCP4728有写保护位WDIS写EEPROM前要确保WDIS位为0。如果不需要掉电保存就尽量用RAM写命令不要用EEPROM写命令。有一次我还踩了“连续写EEPROM导致RDY/BSY一直为低”的坑程序卡在等待忙状态后来改成只写RAM问题消失。5.6 串口1和串口3的使用差异调试MCP4728时我用USART1打印日志后面想换到USART3结果发现波特率不对。原因在于STM32F103的USART1挂在APB2总线上时钟为72MHzUSART3挂在APB1总线上时钟为36MHz。如果用标准库手写波特率分频APB1的时钟没配置对串口3一定会乱码。CubeMX自动生成的代码不会犯这个错但手写标准库时要格外注意。串口1默认引脚是PA9/PA10串口3是PB10/PB11复用功能也不一样切换时别只改外设编号不改引脚和时钟。5.7 软件I2C的备用方案如果硬件I2C实在调不通MCP4728对时序要求并不苛刻用GPIO模拟I2C非常合适。软件I2C的关键是SCL翻转和SDA数据采样时序。我一般先把时钟设为100kHz用HAL_GPIO_WritePin()拉高拉低然后严格按照I2C协议写起始、停止、字节发送和应答判断。代码量不大而且出错时能直接在代码里打断点调试体验反而更好。6. 实测数据与后续扩展思路驱动跑通后我用USART1把写入的电压码值打印出来再用万用表量四个输出脚得到一组很典型的数据通道设定电压V理论码值实测电压V偏差mVA0.50010000.5011B1.00020000.998-2C1.50029991.5022D2.00039991.997-3这里的理论码值是按内部基准2.048V、增益1x算出来的。12位DAC的量化步进约0.5mV实测偏差基本在正负几个LSB范围内属于正常水平。如果偏差超过10mV就要检查基准电容、电源纹波和万用表是否校准了。后续如果想扩展成简易信号发生器可以在主循环里用查表法更新DAC值输出正弦波、三角波或锯齿波。12位分辨率在低频段表现足够比如1kHz以下的正弦波配合MCP4728的快速写命令效果很平滑。如果还想加FreeRTOS可以把I2C操作封装成一个驱动任务用队列把四路目标电压传给任务避免多个任务同时访问I2C。串口控制部分也很好扩展把USART1接收到的字符串解析成四路电压值再调用MCP4728_WriteAllVoltage()就能实现上位机在线改电压。甚至可以用Modbus之类协议把这套东西接到组态软件里变成一个小型四通道模拟量输出模块。回到最开始的问题MCP4728配STM32F103这个组合虽然不算热门但胜在稳定、资料够用、成本低。我个人的建议是电路板上电前先把上拉电阻、基准电容和LDAC接法确认一遍程序里先把地址左移和I2C超时处理好最后再纠结增益和基准配置。这几个点都排掉之后MCP4728基本不会给你添乱。