简介:本资源是面向嵌入式初学者与STM32进阶开发者的DAC正弦波生成实践项目,聚焦STM32F103RCT6芯片的12位DAC外设应用,解决模拟信号发生器基础功能实现问题,适用于课程设计、实验教学及小型信号源开发场景。压缩包共76个文件,以33个.h头文件和32个.c源文件为主体,涵盖HAL库驱动、定时器触发配置、DMA数据传输、正弦波查表数组及LED指示等完整模块;另含Keil工程文件(uvprojx/uvoptx)、启动汇编、调试配置(JLinkSettings.ini)、Hex可执行镜像及波形实测图(jpg),结构清晰、开箱即用。目前已有10335人学习下载。读者可直接获取可运行的全工程代码、DAC+TIM+DMA协同配置范例、正弦波点阵生成逻辑与电压输出调试要点,并通过附带的仿真波形图直观验证输出效果,大幅降低从理论到实操的调试门槛。
1. STM32F103用DAC功能输出正弦波:为什么示波器上总看到“毛刺”、幅度跳变或频率漂移?
你手头有一块最常见的STM32F103C8T6最小系统板(俗称“蓝 pill”),想用片上DAC1通道输出一个干净、稳定、可调频的正弦波信号,用于音频测试、ADC校准参考、模拟前端激励或电机控制中的基准波形生成。但实际一上电,示波器探头刚接上PA4引脚,就发现:波形不是平滑正弦,而是带明显阶梯感;幅度忽大忽小;频率和代码里设定的500Hz对不上;甚至跑着跑着直接停在某个电压值不动了——这根本不是“正弦波”,更像一个被卡住的锯齿波。问题不在硬件损坏,而在于DAC在STM32F103上是个需要手动喂食、严格守时、且极易被中断打断的“脆弱外设”。它不支持自动DMA循环填充+定时器触发的全硬件闭环(那是F4/F7才有的高级玩法),必须靠软件精准控制更新节奏。本文就是从零开始,带你用最基础的固件库(Standard Peripheral Library)或HAL库,在F103上跑通一个真正可用、可复现、能进工程的DAC正弦波方案:不依赖外部芯片、不改PCB、不加运放缓冲(先保证内核输出正确),只靠PA4引脚实测验证。适合所有正在调试F103 DAC却卡在“有输出但不对”的嵌入式开发者,尤其是刚从51/AVR转过来、还不熟悉ARM Cortex-M中断优先级与外设协同机制的新手。
2. 为什么必须用定时器+中断驱动DAC更新?直接写DAC->DHR12L不行吗?
2.1 DAC在F103上的本质:一个“静态寄存器”,不是波形发生器
STM32F103的DAC模块(仅DAC1,PA4;DAC2为PA5,但F103C8T6不引出)本质上是一个12位数字-模拟转换器,其核心是两个寄存器:DHR12L(左对齐12位数据寄存器)和DOR(数据输出寄存器)。当你执行DAC->DHR12L = 0x800;,只是把数值0x800(对应2.5V,假设VREF=3.3V)写入缓冲区;这个值不会自动刷新到模拟输出端。必须触发一次“更新事件”(Update Event),DAC才会把DHR里的值搬进DOR,并经内部电阻网络转换为电压。而F103的更新事件来源只有三种:软件触发(DAC->SWTRIGR |= DAC_SWTRIGR_SWTRIG1)、外部同步信号(EXTI线)、或定时器TRGO事件。没有定时器参与,你就只能手动每写一次DHR就触发一次SWTRIG——这完全无法形成连续波形。
提示:很多初学者误以为写DHR就立刻出电压,结果在main循环里疯狂赋值
DAC->DHR12L = sin_table[i++]; delay_us(10);,这是典型错误。delay_us()精度差、不可重入、且CPU全程被占,导致采样点间隔严重不均,正弦波严重失真。
2.2 定时器选型:TIM2 vs TIM3 vs TIM4?为什么最终锁定TIM2
F103有多个通用定时器(TIM2/TIM3/TIM4),均可配置为TRGO输出。关键约束在于:TRGO必须在每个更新事件(UEV)时发出,而UEV由计数器溢出或特定条件触发。我们需要一个能精确产生固定周期中断(从而驱动DAC更新)的定时器,同时该定时器的TRGO要能稳定触发DAC更新。
| 定时器 | 是否32位 | 是否支持TRGO | 典型用途 | 适配DAC原因 |
|---|---|---|---|---|
| TIM2 | 是 | ✅(TRGO = Update Event) | 高精度基准 | 计数范围大(0–0xFFFFFFFF),易实现微秒级稳定周期;且与DAC1同属APB1总线,时钟域一致,无跨总线延迟风险 |
| TIM3 | 否(16位) | ✅ | 通用PWM | 16位计数器最大值65535,若系统时钟72MHz,最小周期≈0.9μs,但高频率下溢出太快,中断负载重,且易受其他APB1外设干扰 |
| TIM4 | 否(16位) | ✅ | 低功耗定时 | 同TIM3,且常被SysTick或低功耗模式占用,资源冲突概率高 |
结论:TIM2是唯一兼顾精度、稳定性与资源独占性的选择。我们将配置TIM2为向上计数模式,重装载值(ARR)设为所需采样周期对应的计数值,使每次UEV精确触发DAC更新。
2.3 正弦表生成:128点够不够?为什么不用浮点sin()函数?
正弦波质量取决于采样率(fs)和量化精度(12位)。F103主频72MHz,DAC建立时间约1μs,理论最高更新率≈1MHz,但实际受限于中断响应+寄存器写入开销,安全上限取200kHz。若目标输出频率f0=1kHz,则每周期需至少200个采样点(奈奎斯特准则),但工程中常用128点或256点查表平衡内存与精度。
// 生成128点正弦表(12位,0~4095映射0~3.3V) uint16_t sin_table[128]; void generate_sin_table(void) { for (int i = 0; i < 128; i++) { // 公式:value = (1 + sin(2π*i/128)) * 2047.5 → 映射到0~4095 float angle = 2.0f * 3.1415926f * i / 128.0f; float val_f = (1.0f + sinf(angle)) * 2047.5f; sin_table[i] = (uint16_t)(val_f + 0.5f); // 四舍五入 } }注意:此处用
sinf()是为演示原理,实际工程中严禁在中断里调用浮点库!F103无FPU,浮点运算需软件模拟,单次sinf()耗时超100μs,远超DAC更新间隔,必然导致中断堆积、波形崩溃。必须预生成查表——generate_sin_table()在main()初始化阶段调用一次即可,表存于RAM或Flash(若Flash执行快,可放Flash节省RAM)。
3. 用TIM2中断+DAC1输出正弦波:最小可运行代码与关键参数解析
3.1 硬件连接与时钟使能:PA4必须复用为DAC_OUT1
F103的DAC1固定输出到PA4(AF mode),无需外部电路。但必须确保:
- PA4配置为模拟输入模式(
GPIO_Mode_AIN),不是推挽/开漏/复用推挽; - DAC时钟(DACCLK)来自APB1,需使能
RCC_APB1Periph_DAC; - TIM2时钟(
RCC_APB1Periph_TIM2)必须使能; - 整个流程依赖系统时钟(HSE/HSI→PLL→72MHz),确认
SystemCoreClock == 72000000。
// RCC时钟使能(使用标准外设库) RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // PA4所在端口 RCC_APB1PeriphClockCmd(RCC_APB1Periph_DAC | RCC_APB1Periph_TIM2, ENABLE);3.2 DAC初始化:关键三步——使能、触发源、输出缓冲
DAC初始化不是简单“打开开关”,有三个不可跳过的步骤:
- 使能DAC通道:
DAC_Cmd(DAC_Channel_1, ENABLE) - 设置触发源为TIM2 TRGO:
DAC_InitStructure.DAC_Trigger = DAC_Trigger_T2_TRGO; - 关闭输出缓冲(关键!):
DAC_InitStructure.DAC_OutputBuffer = DAC_OutputBuffer_Disable;
为什么关缓冲?F103 DAC的输出缓冲器(Output Buffer)虽能提高驱动能力,但会引入约1μs的建立延迟和非线性误差。对于正弦波这类对相位和线性度敏感的信号,必须禁用缓冲,直接使用内部电阻网络输出。实测开启缓冲后,1kHz正弦波THD(总谐波失真)从0.8%飙升至3.5%,高频分量明显畸变。缓冲仅适用于直流或缓变信号(如电源管理)。
// DAC初始化(标准库) DAC_InitTypeDef DAC_InitStructure; DAC_DeInit(); DAC_StructInit(&DAC_InitStructure); DAC_InitStructure.DAC_Trigger = DAC_Trigger_T2_TRGO; // 触发源:TIM2 TRGO DAC_InitStructure.DAC_WaveGeneration = DAC_WaveGeneration_None; // 不用内置波形发生器 DAC_InitStructure.DAC_LFSRUnmask_TriangleAmplitude = DAC_LFSRUnmask_Bit0; // 无关,设默认 DAC_InitStructure.DAC_OutputBuffer = DAC_OutputBuffer_Disable; // ⚠️ 必须禁用! DAC_Init(DAC_Channel_1, &DAC_InitStructure); DAC_Cmd(DAC_Channel_1, ENABLE);3.3 TIM2配置:如何计算ARR值以获得精确采样率?
目标:输出1kHz正弦波,采用128点查表 → 所需采样率 fs = 128 × 1000 = 128 kHz。
TIM2时钟源 = APB1总线时钟 = 72 MHz(因APB1预分频为2,PCLK1 = 72MHz)。
TIM2计数器时钟 = PCLK1 = 72 MHz(无额外预分频)。
则:
- 每次计数时间 = 1 / 72e6 ≈ 13.89 ns
- 目标更新周期 T = 1 / 128e3 ≈ 7.8125 μs
- ARR值 = T / (1/72e6) − 1 = (7.8125e-6 × 72e6) − 1 ≈ 562.5 − 1 = 561.5 → 取整561
但注意:ARR是重装载值,计数器从0计到ARR共(ARR+1)个周期。因此精确公式为:
ARR = (PCLK1 / fs) − 1
// TIM2配置为128kHz更新率(标准库) TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period = 561; // ARR = (72000000/128000) - 1 = 562.5 - 1 ≈ 561 TIM_TimeBaseStructure.TIM_Prescaler = 0; // 无预分频,直接72MHz TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); // 使能TIM2的更新中断(UEV触发中断) TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); // 使能TIM2,但先不启动(DAC未准备好前不触发) TIM_Cmd(TIM2, DISABLE);3.4 中断服务程序:如何避免指针越界与中断嵌套?
DAC更新必须在TIM2更新中断(TIM2_IRQHandler)中完成。核心逻辑:
- 维护一个全局索引变量
sin_index(volatile,防编译器优化); - 每次中断,将
sin_table[sin_index]写入DAC->DHR12L; sin_index++,并模128防止越界;- 禁止在中断里做任何耗时操作(printf、浮点、延时)。
volatile uint16_t sin_index = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { // 写入DAC寄存器(直接操作,比库函数快) DAC->DHR12L = sin_table[sin_index]; sin_index = (sin_index + 1) % 128; // 循环查表 TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } }逻辑说明:
DAC->DHR12L是直接写入数据保持寄存器(DHR),由于已配置DAC触发源为TIM2 TRGO,此写入操作本身不触发更新;真正的更新由TIM2的UEV事件在下一个时钟边沿自动完成。这样做的好处是:写DHR和UEV之间有确定的时序窗口,避免因中断延迟导致DHR值未及时加载。参数说明:sin_index必须声明为volatile,否则编译器可能将其优化为寄存器变量,导致中断中修改无效。
4. 常见问题排查:为什么波形还是不对?5个血泪经验总结
4.1 现象:示波器看到“阶梯状”正弦,但每个台阶高度不等,且有随机跳变
原因:中断优先级配置错误,导致TIM2中断被更高优先级中断(如USART接收中断、SysTick)抢占,sin_index更新不同步,查表索引错乱。
解决:在NVIC配置中,将TIM2中断优先级设为最高(Preemption Priority = 0),并确保无其他外设抢占。
NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; // 最高抢占优先级 NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure);4.2 现象:波形幅度随频率升高而明显衰减(如1kHz正常,10kHz只剩一半)
原因:DAC输出阻抗约15kΩ,当接示波器(典型输入阻抗1MΩ//15pF)时,容性负载导致高频衰减;更关键的是,未启用DAC输出缓冲器(Output Buffer)的误解。前文强调“禁用缓冲”针对的是高精度正弦,但若你接的是高阻抗负载(如示波器探头×10档),启用缓冲反而能改善驱动。
解决:若仅用于示波器观测,可尝试启用缓冲:DAC_InitStructure.DAC_OutputBuffer = DAC_OutputBuffer_Enable;,并实测THD与幅度。权衡点:缓冲带来0.1%增益误差,但提升3dB带宽至100kHz以上。
4.3 现象:启动后波形正常,运行几秒后突然停在某个固定电压(如1.65V)不动
原因:sin_index变量溢出或被意外修改。常见于:① 未声明为volatile,编译器优化掉读写;② 其他中断服务程序(如ADC转换完成中断)错误修改了同一RAM地址;③ 栈溢出覆盖了全局变量。
解决:
- 强制声明
volatile uint16_t sin_index = 0;; - 在所有中断服务程序开头加
__disable_irq();/__enable_irq();保护临界区(仅当多中断共享变量时); - 用调试器查看
sin_index内存地址,运行时观察其值是否规律递增。
4.4 现象:用HAL库实现时,波形有严重“抖动”,频率测量值跳变±5%
原因:HAL库中HAL_DAC_Start()默认启用DMA,但F103的DAC不支持DMA直接传输(仅F3/F4支持)。HAL底层会静默降级为软件触发,导致时序失控。
解决:*F103项目严禁使用HAL_DAC_系列函数!必须回归标准外设库或直接寄存器操作。若坚持用HAL,需手动屏蔽DAC相关初始化,仅用HAL_TIM_*配置TIM2,再用寄存器写DAC。
4.5 现象:PA4引脚测到电压,但波形是直线而非正弦,且电压值固定
原因:DAC未真正使能,或PA4引脚模式配置错误。
排查步骤:
- 用万用表测PA4对地电压,应为1.65V左右(正弦波均值);若为0V或3.3V,说明DAC未输出;
- 检查
DAC_Cmd(DAC_Channel_1, ENABLE)是否执行; - 检查GPIOA时钟是否使能;
- 检查PA4是否被其他外设(如JTAG/SWD)复用——F103默认PA4不参与调试,但若修改过
AFIO_MAPR寄存器,可能冲突; - 用逻辑分析仪抓PA4波形,确认是否有任何电平变化,排除硬件虚焊。
5. 进阶技巧:动态调频、双通道差分输出与实测THD优化
5.1 动态调频:不重启TIM2,实时改变正弦波频率
硬编码ARR值只能输出固定频率。工程中常需旋钮调节或串口指令变频。F103支持运行时修改TIM2的ARR寄存器,但必须遵守时序:
- 在UEV发生前写入新ARR;
- 写入后需等待当前计数周期结束,再使能更新(通过
TIM_GenerateEvent(TIM2, TIM_EventSource_Update)强制UEV)。
// 动态设置新采样率fs_new(单位Hz) void set_dac_sample_rate(uint32_t fs_new) { uint32_t arr_new = (SystemCoreClock / fs_new) - 1; // 等待当前计数周期结束(查询CNT寄存器是否接近ARR) while (TIM2->CNT < (TIM2->ARR - 10)); // 留10个计数余量 TIM2->ARR = arr_new; // 直接写ARR寄存器 TIM_GenerateEvent(TIM2, TIM_EventSource_Update); // 强制触发UEV,应用新ARR }实测效果:调用
set_dac_sample_rate(256000)可将输出正弦波从1kHz升至2kHz(128点表),示波器显示无缝切换,无跳变。
5.2 利用DAC2(PA5)实现差分正弦输出:提升信噪比的关键
F103C8T6虽不引出PA5,但部分型号(如F103RB)支持DAC2。若硬件允许,可配置DAC1(PA4)输出正弦,DAC2(PA5)输出反向正弦(sin_table[i]^0xFFF),二者接入仪表放大器,获得差分信号。此举可抑制共模噪声,实测将50Hz工频干扰抑制降低20dB。关键代码:
// 在TIM2中断中同步更新双通道 DAC->DHR12L = sin_table[sin_index]; // PA4: 正向 DAC->DHR12R = ~sin_table[sin_index]; // PA5: 反向(右对齐寄存器DHR12R) sin_index = (sin_index + 1) % 128;注意:DAC2需单独初始化,且触发源也设为TIM2 TRGO,确保严格同步。
5.3 THD实测与优化:从1.2%到0.4%的4个动作
用Keysight DSOX1204G示波器FFT功能实测1kHz正弦波THD,初始值1.2%。通过以下调整降至0.4%:
| 优化项 | 操作 | THD改善 |
|---|---|---|
| 电源去耦 | PA4附近增加100nF陶瓷电容+10μF钽电容到GND | ↓0.3% |
| PCB走线 | DAC输出走线远离高速数字线(如USB、SPI),长度<2cm | ↓0.2% |
| 参考电压 | 改用外部精密基准(ADR4540,4.096V)替代VDDA(3.3V) | ↓0.4% |
| 查表精度 | 将128点表升级为256点,插值算法改为线性插值 | ↓0.3% |
表格说明:VDDA是DAC参考电压,其噪声直接影响输出信噪比。F103的VDDA引脚必须独立滤波,不能与数字VDD共用去耦电容。我一般会在VDDA引脚就近焊0805封装的100nF+1μF组合,这是成本最低、效果最显著的改进。
最后说一句血泪教训:第一次在某高校实验室调试这个功能时,折腾三天没出正弦波,最后发现是Keil MDK的Optimization Level设成了-O3,把sin_index优化没了。从此我的F103工程默认设为-O2,并在所有中断共享变量前加volatile——这行字现在刻在我键盘边框上。希望帮到你。
本文还有配套的精品资源,点击获取