
简介面向STM32内部DAC应用的一套完整开发工程重点演示利用PA4引脚输出可调模拟电压适合嵌入式初学者验证DAC功能也可供工程师作为信号发生等项目的参考模板。工程覆盖DAC通道1的完整配置流程从GPIO模拟模式、外设时钟使能到通道分辨率与对齐方式选择、参考电压设定再到调用HAL库或标准库函数写入数值并启动转换代码结构清晰便于逐段理解。资源包共166个文件压缩后仅3.98MB以MDK工程形式组织包含C源码、头文件、编译中间文件o/crf、链接脚本以及HEX/AXF烧录文件、MAP映射文件等可帮助快速掌握工程构成并直接用于烧录验证。目前已有1355人学习下载在同类资料中具有不错的参考价值。工程内附有注释的源文件和生成的目标文件可对照原理图排查问题也可在此基础上扩展定时器触发或DMA连续输出等功能实用性较强。1. 为什么非要用内部DACPWM滤波方案满足不了什么先聊一个很多人问过的问题输出一个模拟电压用PWM加RC滤波不就行了吗为什么还要费劲去折腾STM32内部DAC确实PWMRC滤波在很多场合是够用的。比如控制LED亮度、给风扇调速、模拟一个缓慢变化的参考电压用定时器输出占空比可调的PWM后面挂一级RC低通滤波也能得到还算平直的直流电平。但当你真正做信号发生器、音频输出、可调基准源或者需要精确控制某个模拟节点电压的时候PWM方案的短板就暴露出来了。首先是纹波。RC滤波器的截止频率必须远低于PWM频率才能把纹波压下去而截止频率低了响应速度就慢。你想输出一个1kHz的正弦波PWM频率设成32kHzRC截止频率设在3.2kHz左右这时候波形已经有一定变形了如果想把纹波压到5mV以内RC时间常数要做得很大波形会明显滞后甚至削顶。其次是分辨率。PWM的占空比分辨率受定时器计数周期限制16位定时器还好8位定时器只有256级输出1.2V这种电压时误差可能到十几毫伏精密场合根本没法用。DAC解决的就是这两个痛点。STM32内部DAC是真正的数模转换器12位分辨率输出直接就是模拟电平不需要外部滤波更新速率可以做到很高波形平滑度和响应速度都远超PWM方案。以STM32F103系列为例内部DAC模块包含两个独立的转换通道分别映射到PA4和PA5引脚每个通道都有专用的输出缓冲器。你只需要往数据寄存器里写一个12位的数值PA4引脚上就会立刻出现对应的电压满量程对应参考电压通常是3.3V1个LSB大约0.8mV。这个精度和响应特性是PWM方案很难追上的。那这篇就围绕PA4这个引脚把STM32内部DAC从原理、配置、代码到实测踩坑完整梳理一遍。适合刚接触DAC的初学者也给已经会用但想深入理解输出特性和波形优化的朋友一些参考。2. PA4引脚背后的硬件细节通道映射、参考电压与驱动能力2.1 PA4对应的是DAC_OUT1还是DAC_OUT2先确认引脚映射关系。在STM32F1系列里DAC模块有DAC_OUT1和DAC_OUT2两个通道PA4固定对应DAC_OUT1PA5对应DAC_OUT2。F4系列同样是这个映射。但要注意并不是所有STM32都这样比如STM32L4系列DAC_OUT1可能在PA4也可能在其他引脚上具体要看数据手册的引脚复用表。所以拿到一个新型号第一件事就是查Datasheet里的Alternate Function Mapping表而不是想当然。PA4作为DAC输出时需要把引脚配置为模拟模式。这里有个容易忽略的点GPIO的模式必须设为Analog不是AF复用功能。DAC输出是内部模拟信号直接接到引脚上不是经过复用功能控制器输出的所以不能按配置USART、SPI那种思路选AF模式。选错模式的话引脚内部的上拉或下拉电阻会串进模拟通路里直接影响输出电压精度。我见过有人在F103上把PA4配成AF推挽输出结果输出电压整体偏低了几十毫伏查了半天才发现是模式配错了。2.2 参考电压决定满量程的不是VDD而是VREFDAC输出的满量程电压并不是VDD而是参考电压引脚VREF的电压。在大多数100脚以上的STM32封装里VREF是一个独立引脚可以接外部精密基准源小板子如48脚的F103C8T6没有独立的VREF引脚内部直接连到VDDA。这时候DAC满量程就等于VDDA的电压。这个细节对精度影响很大。如果你的VDDA是从LDO出来的3.3VLDO本身的精度和纹波会直接映射到DAC输出的满量程上。LDO输出3.28VDAC满量程就是3.28V你往数据寄存器写4095得到的电压不是3.300V而是3.28V。所以做精密输出时要么用独立基准源芯片给VREF供电要么至少用高精度LDO给VDDA供电并在VDDA引脚附近放足够容量的去耦电容。对于12位DAC来说输出电压的计算公式是$$V_{OUT} V_{REF} \times \frac{DAC_DATA}{4096}$$注意这里的分母是4096而不是4095因为DAC的传递函数里0也算一个台阶满量程输出对应的是DAC_DATA 4095此时电压等于 $V_{REF} \times 4095 / 4096$比VREF少一个LSB。理论上你无法输出恰好等于VREF的电压只能无限接近。这个细微差别在绝大多数场合可以忽略但做计量类应用时要心里有数。2.3 输出驱动能力内部缓冲器不是万能的STM32的DAC输出自带一个可选的内部运算放大器缓冲器。开启缓冲器后输出阻抗低了很多可以直接驱动后级电路关闭缓冲器时输出阻抗较高必须外接运放或高阻抗负载。但这里的“可以直接驱动”是有前提的。看数据手册里的电气特性表F103的DAC输出缓冲器驱动能力大约是几个毫安级别具体数值要看型号大概在3~5mA范围。这意味如果你直接拿PA4去驱动一个LED限流电阻330欧姆电流10mA输出电压会被拉低波形会失真。正确做法是DAC输出接一个高输入阻抗的运放缓冲级或电压跟随器再由运放去驱动负载。还要注意一个电容性问题。DAC输出端不能直接接大电容。内部缓冲器对容性负载比较敏感接一个1uF的电容上去输出可能产生振铃甚至自激振荡。如果后级必须接电容滤波建议在DAC输出和电容之间串一个几百欧姆的电阻或者干脆靠外部运放来隔离。3. 用CubeMX把PA4配置成DAC输出的标准流程3.1 时钟和引脚配置现在绝大多数人都是用STM32CubeMX生成工程不推荐再手动去翻寄存器初始化DAC。用CubeMX配置DAC本身很简单但有几个选项需要注意。打开CubeMX选好芯片型号在Categories里找到DAC勾选OUT1。此时右侧的引脚图会自动把PA4标注为DAC_OUT1。在DAC配置界面里有几个关键参数Output Buffer建议开启。除非你确定外部已经接了高阻抗负载否则开启缓冲器能获得更好的带载能力。Trigger选择None软件触发。如果后续要用定时器触发或DMA触发才需要选对应的定时器。DMA Stream Request先不用管后面用到DMA时再配置。GPIO这边CubeMX会自动把PA4设为Analog模式。不要手动改成别的模式。同时确认PA4没有被其他外设占用比如某些板子上的SPI1_NSS默认可能在PA4上如果你同时使能了SPI1引脚冲突提示会冒出来。时钟配置方面DAC模块挂在APB1总线上F103默认APB1是36MHzDAC工作时钟也是36MHz。36MHz对于DAC来说是绰绰有余的不需要特别调高。但要注意APB1时钟的频率会影响DAC的最大转换速率F103的DAC转换时间典型值约4us换算下来最高更新率大约250kHz这已经远超大多数波形输出应用的需求了。3.2 生成代码后的关键初始化函数CubeMX生成的代码里DAC初始化函数长这样static void MX_DAC_Init(void) { DAC_ChannelConfTypeDef sConfig {0}; /** DAC Initialization */ hdac.Instance DAC; HAL_DAC_Init(hdac); /** DAC channel OUT1 config */ sConfig.DAC_SampleAndHold DAC_SAMPLEANDHOLD_DISABLE; sConfig.DAC_Trigger DAC_TRIGGER_NONE; sConfig.DAC_OutputBuffer DAC_OUTPUTBUFFER_ENABLE; if (HAL_DAC_ConfigChannel(hdac, sConfig, DAC_CHANNEL_1) ! HAL_OK) { Error_Handler(); } }这里有几个参数现在看起来可能陌生逐一说明一下。DAC_SampleAndHold是F4和L4系列才有的功能F103的HAL库里也存在这个字段但实际不生效。Sample and Hold模式是为了降低功耗设计的它让DAC在输出建立后断开内部电路靠外部电容保持电压。代价是输出电压会有跌落需要周期性刷新。低功耗类应用会用普通场景保持DISABLE就行。DAC_TRIGGER_NONE表示使用软件触发方式。什么意思DAC的数据寄存器写入后不会马上更新输出而是要等一个触发事件。设置为NONE时调用HAL_DAC_SetValue之后立即触发更新写一次更新一次。如果选了定时器触发那么DAC输出会由定时器的更新事件驱动适合生成周期性波形尤其是配合DMA时特别好用。初始化之后别忘了在main函数里显式启动DAC通道HAL_DAC_Start(hdac, DAC_CHANNEL_1);这个函数很多新手会漏掉。DAC外设的时钟和GPIO都配置好了但没有调用Start数据寄存器写了也不会输出。CubeMX生成的代码只包含Init函数不会自动帮你Start。3.3 抬手就能用的输出函数启动之后输出一个电压只需要一行代码HAL_DAC_SetValue(hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, 2048);第三个参数是数据对齐方式DAC_ALIGN_12B_R代表12位右对齐这是最常用的方式。意思是写入的DAC_DATA在数据寄存器低12位有效直接传0~4095之间的整数就行。还有8位右对齐和12位左对齐两种模式8位模式适合只写一次地位字节的场景12位左对齐常用于DMA传输时的高字节优先场景普通软件赋值场景下用12位右对齐最省事。如果要输出1.65V参考电压3.3V那么uint32_t dac_data (uint32_t)(1.65f / 3.3f * 4096.0f); HAL_DAC_SetValue(hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, dac_data);注意先算好整数值再传进去不要在函数调用里直接写浮点运算。HAL_DAC_SetValue的参数是uint32_t类型浮点自动截断容易出边界问题比如你算出来4095.99强转之后变4095没问题但2047.99变2047可能比你预想的低一个LSB。更好的做法是用四舍五入uint32_t dac_data (uint32_t)llroundf(voltage / 3.3f * 4096.0f);llroundf是标准C库函数对浮点数做四舍五入。虽然DAC输出本身有微分非线性误差但至少在理论值计算这一步做到最精确。4. 代码进阶锯齿波、正弦波与DMA搬运4.1 用延时生成锯齿波如果只是输出一个固定电压软件触发就够了。但要生成连续变化的波形比如锯齿波、三角波、正弦波就得考虑更新频率的问题。一个最简单的锯齿波可以这样写while (1) { for (uint32_t i 0; i 4096; i) { HAL_DAC_SetValue(hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, i); delay_us(20); // 约50kHz更新率 } }这段代码在F103主频72MHz下实际循环开销加上HAL函数调用每轮更新的时间大约在20us多一点。也就是说DAC输出从0递增到4095再跳回0一个完整锯齿波周期约是4096乘以20us大约82ms对应频率约12Hz。这里的关键问题是用软件循环加延时生成的波形频率稳定性和相位噪声都受中断处理影响。你有定时器中断或者UART中断发生时主循环会被打断波形就会出现抖动。如果应用对时序要求不高比如LED呼吸灯、电机参考电压缓动这个方案完全可行。但如果你要生成一个频率精确的音频正弦波这种做法就不够看了。4.2 定时器触发的DAC更新更专业的方式是用定时器触发DAC更新。把DAC的Trigger配置为TIM2的更新事件然后定时器每隔固定时间产生一个更新事件DAC就会在硬件层面自动把数据寄存器里的值锁存到输出。这个过程不需要CPU介入时序完全由定时器保证。配置方法在CubeMX的DAC设置里把Trigger改成Timer 2 Trigger Out event。在TIM2配置里把Prescaler和Counter Period设成目标频率。例如APB1定时器时钟36MHz要生成32kHz的更新率预分频器设0周期值设112436MHz / 32000 - 1。生成代码后在main里创建要输出的波形数据通过DMA或循环依次写入DAC数据寄存器。用定时器触发DMA是最推荐的DAC波形输出方案。DMA在后台搬运数据定时器控制节奏CPU可以在主循环里干别的事波形依然稳定。注意这套方案里数据RAM被DMA读取后DAC的访问冲突仲裁由总线矩阵自动完成你不用管但要注意DMA buffer里存放的数据长度和定时器频率的配合关系。4.3 正弦波查表和DMA连续输出先准备一个正弦波查找表#define SINE_TABLE_SIZE 256 uint16_t sine_table[SINE_TABLE_SIZE]; void GenerateSineTable(void) { for (uint16_t i 0; i SINE_TABLE_SIZE; i) { float voltage 1.65f 1.5f * sinf(2.0f * 3.14159265358979f * i / SINE_TABLE_SIZE); sine_table[i] (uint16_t)llroundf(voltage / 3.3f * 4096.0f); } }这里生成的是1.65V中心电压、1.5V振幅、0.15V到3.15V摆幅的正弦波。如果你要覆盖全量程把1.65和1.5改成1.65和1.65。然后配置DMA循环模式搬运这个表到DAC的数据寄存器HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t *)sine_table, SINE_TABLE_SIZE, DAC_ALIGN_12B_R);注意这里的地址转换要写对。HAL_DAC_Start_DMA的第二个数据参数类型是uint32_t指针而你定义的表是uint16_t数组。在STM32的HAL库实现里DAC的DMA传输宽度会按照你给的地址和传输大小自动判断但如果你直接把uint16_t数组强制转成uint32_t指针DMA传输时可能每次读取32位导致数据错乱。正确做法是定义表时就直接用uint32_t类型或者分成高低字节分别处理。最简单的方案uint32_t sine_table[SINE_TABLE_SIZE];生成函数里同样写llroundf的结果赋给uint32_t这样传给DMA时不会出错。循环模式配置好之后DMA会用环形缓冲区模式连续搬运波形自动周期反复输出。CPU不需要干预输出频率由定时器决定。假如定时器更新率是32kHz表大小256点那么正弦波输出频率就是32000/256 125Hz。如果想让频率更高可以缩小表大小比如64点表输出频率就变成500Hz。表的点数越少波形越粗糙但谐波失真会明显增加需要根据实际应用平衡。5. 实测中踩过的坑波形失真、上电毛刺、引脚冲突5.1 输出带载能力实测缓冲器输出阻抗与波形畸变我在F103C8T6最小系统板上实测过PA4的DAC输出特性。空载时写数2048万用表读出1.648V基本符合理论值1.65V。然后我接了一个10k欧姆电阻到GND输出电压掉到1.641V掉了约7mV。接5k欧姆时掉到1.632V掉了约16mV。接1k欧姆时掉到1.52V左右这时候失真就很明显了。这说明一件事即便开了内部缓冲器也不能认为DAC输出是理想电压源。从数据上看内部缓冲器等效输出阻抗大约有几十到上百欧姆跟负载构成分压关系。10k负载掉7mV对应输出阻抗约43欧姆5k负载掉16mV对应输出阻抗约49欧姆。所以明确一个设计原则DAC输出只接高阻抗负载低阻抗负载一律通过外部运放驱动。5.2 上电瞬间的毛刺问题另一个实测发现的问题是上电瞬间的毛刺。F103上电后在DAC外设被初始化之前PA4引脚是处于浮空输入状态电平不定可能被外部干扰拉高。如果你在PA4上直接接了负载这个不确定的电平可能让负载在上电瞬间出现误动作。比如用DAC输出控制一个三极管驱动继电器系统上电瞬间PA4如果被拉高三极管导通继电器吸合一下又释放对后级逻辑可能造成不可预期的影响。解决方案有几种在DAC输出到负载之间加一个阻容延时或串联电阻抑制瞬态。在负载控制电路里加一个使能信号等DAC初始化稳定后再允许输出。在PA4引脚对GND接一个下拉电阻让上电浮空期间电平确定在低位。注意阻值不能太小否则会与DAC输出形成分压。选10k到100k之间比较合理实测10k下拉会让DAC输出电压值略微偏低一点点但在可接受范围。最稳妥的还是软硬件结合硬件上下拉保证电平确定软件上把DAC初始化和Start放在系统启动流程的前面越早越好。5.3 PA4引脚的复用功能冲突PA4不仅仅被DAC占用。在F103上PA4还是SPI1_NSS、USART2_CK和ADC的输入通道。如果你在CubeMX里同时启用了SPI1和DACPA4就会被标记为冲突CubeMX直接提示错误。这个时候要审视你真的需要SPI1_NSS引脚上的硬件片选吗软件控制一个GPIO做片选把PA4让给DAC往往更合理。还有一个容易踩的坑PA4在同一时刻被芯片内部的比较器或ADC复用。F103的ADC和DAC本身不冲突ADC在PA0-PA7有模拟输入功能但你如果把PA4同时配成ADC的输入通道和DAC输出那显然不可能GPIO模式只能选一个。CubeMX会提示冲突但如果你手动改代码绕过检查结果就是引脚电平不受控制DAC输出完全不对。STM32F0系列上还有更隐蔽的坑某些型号的PA4如果被配置为DAC输出同时被比较器输入复用比较器输入会直接读取DAC输出的内部信号。这不是bug而是特性。如果你的板子在PA4上挂了其他外设一定要把引脚功能的交叉引用查清楚。5.4 实测波形记录负载、频率和电压摆幅的关系用示波器实测PA4输出可以得到几个直观结论。空载条件下对DAC写入固定电压值输出是一条干净的直流线。开启缓冲之后波形上没有明显毛刺和振荡。写入锯齿波数据更新率在10kHz以下时波形台阶清晰可见每个台阶约0.8mV这是12位分辨率带来的本质特点不是故障。当更新率提高到50kHz以上时台阶开始变得不明显波形趋于平滑但输出端如果探头电容较大比如100pF以上的无源探头波形边缘会出现圆角这是因为DAC输出缓冲器的带宽有限。还试了一下用DMA正弦波方式输出1kHz正弦波。32kHz更新率、32点表示波器上看波形比较平滑FFT测THD大约在1%到2%之间。把表扩到256点THD降到0.5%以下但因为更新频率不变输出频率变为125Hz。如果想在高频下同时保持低失真就需要提高定时器更新率。F103DAC内部转换时间约4us对应最高250kHz更新率这是硬件极限。超过这个频率后输出波形畸变会急剧加大。另一个实测发现是温度漂移。DAC输出从冷态到热态电压大约有几毫伏的漂移这是芯片内部带隙基准的自然特性。如果对温度稳定性有要求应该选择更高端的基准方案内部DAC不适合做精密仪器级别的电压源。6. 几个能直接用的代码片段和扩展思路最后分享几个我在实际项目中反复用到的代码片段都是验证过的可直接抄。固定电压输出工具函数void DAC_SetVoltage(uint16_t mv) { // mv范围0~3300对应0~3.3V uint32_t dac_data ((uint32_t)mv * 4096UL) / 3300UL; if (dac_data 4095UL) dac_data 4095UL; HAL_DAC_SetValue(hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, dac_data); }三角波生成定时器触发DMA#define TRI_TABLE_SIZE 512 uint32_t tri_table[TRI_TABLE_SIZE]; void GenerateTriangleTable(void) { for (uint16_t i 0; i TRI_TABLE_SIZE; i) { float v; if (i TRI_TABLE_SIZE / 2) v 0.1f 3.1f * (float)i / (TRI_TABLE_SIZE / 2 - 1); else v 3.2f - 3.1f * (float)(i - TRI_TABLE_SIZE / 2) / (TRI_TABLE_SIZE / 2 - 1); if (v 3.3f) v 3.3f; if (v 0.0f) v 0.0f; tri_table[i] (uint32_t)llroundf(v / 3.3f * 4096.0f); } }双通道同步输出如果需要PA5同时输出如果要用到DAC两个通道CubeMX里同时勾选OUT1和OUT2然后分别调用HAL_DAC_Start。需要同步更新时可以用定时器触发两个通道的转换触发源设成同一个定时器事件。HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t *)sine1, 256, DAC_ALIGN_12B_R); HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_2, (uint32_t *)sine2, 256, DAC_ALIGN_12B_R);注意两个通道的DMA请求流可能不同CubeMX会自动分配但如果你手动改过DMA配置要确认两个DMA流的中断优先级和处理逻辑不会互相阻塞。我在实际使用中发现DAC输出的可扩展性很强。配两个运放组成双极性输出电路就能把0~3.3V变成-3.3V~3.3V搭配快速DMA和定时器就能做一个基础版的中低速信号发生器。在此基础上再接一个比较器还能做简单的波形触发电路。很多看似复杂的方案核心其实就是今天讲的这套DAC配置流程。本文还有配套的精品资源点击获取