简介:STM32F103单片机HAL库例程,面向嵌入式物联网项目开发学习者与单片机工程师,演示DAC输出正弦波这一典型信号生成场景。例程基于KEIL HAL库编写,在STM32F103上运行,其他型号可通过修改芯片型号与FLASH容量快速移植,兼顾实用性与可扩展性。压缩包共175个文件,以h头文件与c源文件为主,涵盖HAL库底层驱动、外设配置及工程构建脚本,另有uvprojx工程文件等,整体约1.26MB,结构紧凑。已有342人学习下载,适合需要快速掌握STM32 DAC应用或借鉴外设驱动写法的开发者。代码中详细标注了单片机与模块的接线定义,并附注释说明,同时提供J-Link/ST-Link选择提示与KEIL重置脚本,便于直接编译调试;若硬件有差异,也可参照注释灵活调整,作为实际项目开发的可靠参考。
1. 在STM32F103上用HAL库DAC输出正弦波,先从“信号从哪来”说起
做一个小型信号发生器,不一定非要外挂DDS芯片,STM32F103片内就有DAC,引脚在PA4和PA5,配合查表法和定时器触发,就能输出频率、幅值都可控的正弦波。HAL库例程在这里的核心动作有两步:用CubeMX把DAC、DMA、定时器配置成一条流水线,再用HAL_DAC_Start_DMA把预先算好的正弦表循环送给DAC转换器。DAC负责把12位数字量变成模拟电压,DMA负责搬数据,定时器负责给采样点打拍子。对刚接触ADC/DAC的开发者来说,它比直接买DDS芯片更能看清“数字码与模拟电压”的对应关系;对老手来说,这套结构也是后面做任意波形发生器的底座。
2. STM32F103的DAC外设与正弦波查表原理
2.1 先看DAC输出通道:PA4、PA5和4096个码
STM32F103内部有两个独立的DAC转换器,DAC_OUT1固定在PA4,DAC_OUT2固定在PA5。每个转换器都是12位,支持12位右对齐、12位左对齐、8位右对齐三种写法,HAL库中常用DAC_ALIGN_12B_R,也就是把数字量放到寄存器低12位。数字0对应0V,数字4095对应Vref+电压。大多数最小系统开发板把Vref+直接接3.3V,所以每个LSB约0.806mV;如果做精密信号源,可以外接2.5V基准到Vref+,但输出满幅也会降到2.5V。
DAC输出缓冲是否使能,对带载能力影响很大。使能Output Buffer后,引脚可以直接驱动数十kΩ量级的负载,示波器探头不会把波形明显拉垮;关闭缓冲器后输出阻抗变高,适合外接高输入阻抗运放做后级。无论是哪种用法,DAC输出都带有零阶保持特性,也就是说每次写入数据后,输出电压一直保持到下一次写入,这会让正弦波呈现阶梯状,不是光滑曲线,后级需要加RC滤波器。
2.2 查表法:为什么正弦波要“算好放进数组”
直接在主循环里调用sin()算出每个点的电压再写DAC,不是不能跑,但存在两个问题:一是浮点库在F103上开销大,二是在不同优先级中断的影响下,相邻两个采样点的间隔会忽长忽短,波形在示波器上会看到抖动。查表法换了一种思路:先把一个周期的正弦波形离散成N个点,放进数组,然后定时器按固定频率把数组里每一个码值送进DAC,数组走完一遍就输出一个完整的正弦波周期。
查表法本质上是DDS的相位-幅值映射。假设表长N=128,DAC每秒更新Fs次,那么正弦波频率f_out = Fs / N。例如定时器让DAC每秒触发1,000,000次,表长128,输出频率就是7,812.5Hz。这个关系式是整个调频操作的基石。修改频率可以改定时器触发频率,也可以改表长;修改幅度可以改表里的最大最小码值;修改相位可以调整数组的起始下标。表里装的不一定是正弦数据,换成三角波、锯齿波甚至一段自定义曲线都能直接输出,这就是“任意波形发生器”的雏形。
2.3 为什么用HAL库而不是直接操作寄存器
老例程大多用SPL标准库,甚至直接操作DAC->DHR12R1寄存器,虽然直观,但每次换型号都要核对参考手册。HAL库把外设初始化拆成三层:句柄、初始化结构体、启动函数。以DAC为例,DAC_HandleTypeDef保存该DAC实例的全部状态,HAL_DAC_Init负责使能外设时钟和引脚,HAL_DAC_Start负责让转换器进入工作状态,HAL_DAC_Start_DMA则把内存数组交给DMA并启动整个流水线。这种分层对“定时器+DAC+DMA”的组合特别合适,因为你能清楚地看到谁拥有数据、谁控制节奏。
但HAL库也有一个需要主动记住的点:HAL_DAC_Start_DMA只启动DAC和DMA,它不会自动去启动定时器。你仍然需要显式调用HAL_TIM_Base_Start(&htim2),定时器的TRGO事件才会开始产生DAC触发信号。我看到很多人在CubeMX里配好了三个外设,代码里却只调了HAL_DAC_Start_DMA,结果PA4上永远是某个固定电压,不是没有放正弦表,而是定时器根本没跑。
3. 用HAL库和CubeMX生成DAC正弦波的最小工程
3.1 CubeMX中DAC、定时器和DMA怎么配
以常见的F103C8T6最小系统板为例。打开STM32CubeMX后,先把RCC设为HSE外部晶振,时钟树按72MHz配置:SYSCLK=72MHz,APB1=36MHz,APB1上的定时器时钟自动倍频到72MHz,DAC外设本身挂在APB1上,工作时钟36MHz。接下来按下面几步配置DAC引脚和触发链:
- 在
Pinout & Configuration中选中DAC,勾选OUT1,引脚自动锁定到PA4。 - 在DAC参数设置里把Output Buffer设为Enable,Trigger选择
Timer 2 Triggerout event。 - 在DAC的
DMA Settings页签添加一个DMA请求,选择DAC_CH1,Mode设成Circular,PeriphDataAlignment和MemDataAlignment都设成Word,Priority设High。 - 配置TIM2的Clock Source为Internal,Prescaler设为0,Counter Period设为71,Trigger Output选择Update Event。
- 生成MDK-ARM工程。
这里的关键参数是TIM2的配置。PSC=0且ARR=71时,定时器更新频率就是72MHz/(0+1)/(71+1)=1MHz,也就是DAC每秒被触发1,000,000次。配128点正弦表,输出频率就是1MHz÷128=7.8125kHz。如果目标频率是1kHz,可以让定时器分频倍数变成562.5左右,F103定时器只能整除,所以常用近似值:PSC=0、ARR=561,更新率为72MHz/562≈128,114Hz,正弦频率1000.89Hz,误差不到0.1%,实际取决于晶振精度。
为了避免每次手算,我把常用组合列在下面。注意表中“更新率”是DAC触发频率,等于72MHz除以(PSC+1)和(ARR+1)的乘积。
| 表长N | 目标正弦频率 | PSC | ARR | 实际正弦频率 |
|---|---|---|---|---|
| 128 | 1kHz | 0 | 561 | 约1000.89Hz |
| 128 | 50Hz | 49 | 224 | 50Hz(6400Hz÷128) |
| 128 | 7.8125kHz | 0 | 71 | 约7812.5Hz |
| 256 | 1kHz | 1 | 139 | 约1004.46Hz |
实际项目里如果觉得频率分辨率不够,可以先固定PSC,让ARR连续变化来细调;如果要兼顾较大频率范围,则先定ARR,再改PSC。DAC转换本身有建立时间,更新率最好不要超过1MHz,否则后一个点会覆盖前一个点未完成的变化,正弦峰峰幅度被拉低。
3.2 用Python生成128点正弦查找表
正弦表不需要手写,我一般用Python脚本一次性生成,便于之后改表长、改幅值。脚本如下:
import math N = 128 center = 2048.0 amp = 2047.0 print(f"uint32_t sine_table[{N}] = {{") for i in range(N): v = center + amp * math.sin(2 * math.pi * i / N) v = int(max(0.0, min(4095.0, round(v)))) end = "," if i < N - 1 else "" print(f" {v}{end}") print("};")说明:center设为2048,amp设为2047,这样最大码值是2048+2047=4095,最小码值是2048-2047=1,不会出现4096溢出。如果用2048作为幅值,sin等于1时计算结果就会被截断,波形顶部出现削顶。用round而不是floor是为了让量化误差在正负半个LSB内均匀分布。脚本输出的是uint32_t数组,这与CubeMX默认的DMA内存宽度Word保持一致,避免半字宽度不匹配的问题。
如果不想维护Python脚本,也可以直接在C代码初始化时算表:
#include <math.h> void sine_table_init(uint32_t *buf, uint16_t n) { for (uint16_t i = 0; i < n; i++) { float v = 2048.0f + 2047.0f * sinf(2.0f * (float)M_PI * i / n); uint32_t q = (uint32_t)roundf(v); buf[i] = (q > 4095U) ? 4095U : q; } }注意F103没有硬件FPU,sinf是软浮点实现,初始化时计算128个点耗时不明显,完全可以在main开头调用一次。
3.3 用软件触发先验证DAC通路
CubeMX生成代码后,先用最简单的软件触发确认DAC和引脚没问题,再上定时器+DMA。软件触发就是一个循环:
uint32_t sine_table[128]; sine_table_init(sine_table, 128); HAL_DAC_Start(&hdac, DAC_CHANNEL_1); while (1) { for (int i = 0; i < 128; i++) { HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, sine_table[i]); HAL_Delay(10); } }调用HAL_DAC_Start后,转换器进入工作状态,但输出仍停在DOR寄存器里的初始值;每次HAL_DAC_SetValue把新码值写入DHR寄存器,硬件自动镇压到DOR。HAL_Delay(10)让每个点保持10ms,一个正弦周期是1.28秒,用万用表能看到PA4电压在0V到3.3V之间缓慢变化。如果这时切到示波器,可以看清阶梯式正弦爬升,但频率极低。这段代码只是为了验证引脚和初始化,实际做信号发生器时不会用软件延时,因为延时受到调度和中断影响,波形时间基准不稳定。
4. 定时器触发DAC并让DMA循环搬运的正弦波实战
4.1 为什么要同时用定时器和DMA
软件SetValue写法最大的问题是采样点时间不均匀。HAL_Delay(10)的误差、循环里取数组的操作时间、中断抢占,都会让点与点之间出现几微秒到几十微秒的抖动。示波器上看起来是正弦波形,但用频谱仪或FFT模式看,噪声基底会被明显抬高。定时器触发把“什么时候转换”这个问题从CPU手里拿走了:TIM2更新事件产生TRGO,TRGO直接触发DAC从DHR寄存器锁存数据,每个采样点严格按定时器周期推进。
DMA解决的是“数据怎么送进来”的问题。DAC每完成一次触发,需要DMA把内存里下一个正弦值搬到DAC的DHR寄存器。查表数组、DMA、DAC三者构成一条链路,CPU只负责初始化,之后整个波形循环自动跑。F103的DAC通道1有独立的DMA请求,CubeMX里配置为Circular模式后,DMA指针走到数组末尾会自动回到开头,形成无限循环。
4.2 main函数中最少需要写多少代码
CubeMX生成工程后,main.c里的关键代码只有四步:定义正弦数组、初始化表、启动DAC DMA、启动定时器。整体代码如下:
uint32_t sine_table[128]; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_DAC_Init(); MX_TIM2_Init(); sine_table_init(sine_table, 128); HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, sine_table, 128, DAC_ALIGN_12B_R); HAL_TIM_Base_Start(&htim2); while (1) { } }这段代码里,HAL_DAC_Start_DMA的参数依次是DAC句柄、通道号、内存数组、数组长度、数据对齐方式。函数内部会把DMA的外设地址指向DAC的DHR12R1寄存器,所以数组必须是全局或静态生存期的变量,不能用栈上的局部数组,否则函数返回后数组地址失效。HAL_TIM_Base_Start只启动计数,不启动中断,因此不需要额外写HAL_TIM_Base_Start_IT。
启动顺序建议是先HAL_DAC_Start_DMA再启动定时器。如果反过来,定时器先跑起来而DAC的DMA还没准备好,最初几个触发事件可能没有样本可搬,波形开头会丢失一小段。虽然之后会追回来,但会在示波器上看到一个不完整的起始沿。
4.3 频率、幅度和相位如何改
频率改动只有两个位置:表长N和定时器配置。改表长会影响DMA搬运总长度,也要同步修改HAL_DAC_Start_DMA里的Length;改定时器则直接改PSC或ARR。常见做法是把PSC和ARR都做成变量,然后在运行时修改ARR,修改后重新调用HAL_TIM_Base_Stop和HAL_TIM_Base_Start让新周期生效。
幅值改动需要改生成表时的amp值。例如要输出1Vpp正弦波,Vref=3.3V,那么码值幅度约1V÷3.3V×4096≈1241,中心码保持2048,实际输出范围是2048±1241。修改后重新生成表即可。注意DAC输出带载后幅值会略低于理论值,因为内部缓冲并不是真正的轨到轨,靠近Vref上半部分会先饱和。
相位偏移在表生成时最简单:把数组按起始下标旋转。如果需要两路正交正弦,给DAC_OUT2单独建一个数组:
uint32_t sine_table_cos[128]; for (int i = 0; i < 128; i++) { sine_table_cos[i] = sine_table[(i + 32) % 128]; } HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_2, sine_table_cos, 128, DAC_ALIGN_12B_R);这里偏移32个点就是90度相位差,前提是两个通道使用同一个TIM2触发源。只要DAC_OUT2的DMA请求也配置在TIM2的TRGO上,两个通道的采样点严格同步,相移就只取决于数组索引偏移。
4.4 实测波形、示波器观察和三个高频坑
接上示波器探头到PA4,地线夹到GND,DC耦合,时基先放到1ms/div。1kHz正弦波一个完整周期大约占1格;如果看到阶梯状波形,把时基缩小到100us/div就能看到每个采样点平台。万用表测量直流均值应该约1.65V,对应中心码2048。
三个高频坑值得单独记一下。第一个坑是CubeMX里没有配置DMA的Circular模式,波形运行到数组末尾会停止,PA4停在某个电压上,这不是代码逻辑问题,而是Mode选成了Normal。第二个坑是数组用了uint16_t,但CubeMX默认DMA内存宽度是Word,搬运时高低字节错乱,示波器上波形变成一团乱码;解决方法是把数组改成uint32_t,或者在DMA设置里把MemDataAlignment改成HalfWord并保证外设数据宽度一致。第三个坑是定时器TRGO没有选择Update Event,而是留在了Disable状态,即便定时器在跑,DAC也收不到触发事件,这种现象往往被误认为是DAC坏了,实际只要回去看一眼CubeMX配置就行。
5. 进阶:正弦波质量、双通道相位与HAL库回调验证
5.1 表长、RC滤波与PCB布局对波形质量的影响
正弦波质量取决于三个层面:表长决定阶梯分辨率,RC滤波器切掉高频谐波,PCB布局决定模拟电压的干净程度。表长128足够看到波形,但输出频谱上谐波分量比较大;改成256或512后,相同正弦频率下DAC更新率要成倍提高。以F103的DAC能力,更新率1MHz封顶,所以输出频率如果要求1kHz,表长最多1000点;如果要求10kHz,表长超过100就不再合适。一般工程我用128点做高频段,512点做1kHz以下低频段,两者切换时重新初始化表即可。
DAC后加一阶RC低通是成本最低的平滑方案。截止频率取基波的5到10倍,比如1kHz正弦波取10.6kHz,R取1kΩ,C约15nF。电容要用C0G或聚丙烯电容,避免X5R电容量随电压变化。电源噪声方面,Vref+引脚要就近放1uF和10nF并联去耦电容;DAC的PA4/PA5走线不要跨过数字信号或时钟线。时钟抖动会让采样间隔波动,在频谱上表现为基频两侧的边带,所以HSE晶振的负载电容要按数据手册选,不能为了起振快而随意加大,这是很多ADC/DAC系统共性的布局问题。
5.2 两路DAC输出相位差信号
需要I/Q信号或者正交编码模拟时,让DAC1和DAC2输出sin和cos比用外部相移电路简单得多。CubeMX里同时勾选DAC的OUT1和OUT2,给两个通道各添加DMA请求,触发源都选同一个定时器,然后在main中分别启动两个通道的DMA传输。由于两个通道共用同一个TRGO触发边沿,每次触发同时更新两个DAC,数组偏移量决定相位差。相位差还可以做成运行时变量,用HAL_DAC_SetValue更新某个点的偏移值,但要注意运行时修改DMA缓冲区内容必须小心,避免数组值正在被搬运时写入,出现瞬间的毛刺。
5.3 用DMA回调验证正弦波周期是否真实跑起来
HAL库为DAC的DMA定义了回调函数,例如HAL_DAC_ConvCpltCallbackCh1会在整个缓冲区传输完成后被调用。把数组长度设成128,那么这个回调每秒会被调用更新率÷128次,也就是正弦波频率次数。在回调里翻转一个GPIO,用示波器测GPIO引脚频率,如果测到和预期频率一致,说明定时器、DMA、DAC整条链路没有丢步。要注意的是这个回调属于弱函数,需要在用户代码里重新实现;并且要确认CubeMX生成的DAC DMA中断优先级没有被其他高优先级中断长期抢占,否则回调频率会不稳定。
本文还有配套的精品资源,点击获取