1. 先搞清楚要做什么STM32F103 上的 PWM 与 DAC 波形输出STM32F103 这颗芯片在嵌入式圈子里算是“国民级”的存在价格便宜、资料多、外设也够用。这次要干的事情很具体用它的定时器输出一路 PWM 方波再用 DAC 通道输出一段可调的正弦波或音频波形最后用示波器或者串口采样把频率和幅值验证一遍。适合谁看刚上手 STM32、手里有最小系统板或者正点原子/野火开发板、想在 Keil CubeMX 环境下快速搭出一个能跑的波形输出工程的人。PWM 的本质其实不复杂你可以把它理解成一个开关定时器负责数数数到某个值就把引脚拉高数到另一个值就拉低于是引脚上出现一串高低电平。占空比就是高电平时间占整个周期的比例频率由定时器的时钟和重装载值决定。STM32F103 的通用定时器 TIM2/TIM3/TIM4 都能干这事高级定时器 TIM1 还带互补输出不过本篇先用 TIM3 通道 2 映射到 PB5 来演示因为这条路径在最小系统板上最容易接线。DAC 则是另一条路。它是 12 位数字转模拟输入一个 0 到 4095 的数字输出对应 0 到 VREF 的电压。STM32F103 有 DAC1 和 DAC2 两个通道分别对应 PA4 和 PA5。要让 DAC 输出正弦波思路是提前算好一张正弦表然后用定时器触发 DMA 把表里的数据一个个搬进 DAC 数据寄存器DAC 就按节奏吐出模拟电压。频率由触发定时器的更新频率除以采样点数决定幅值由表里的最大值和 VREF 共同决定。下面这张表先把两个外设的关键参数对照一下后面配置的时候心里有数项目PWMTIM3_CH2 / PB5DACDAC1 / PA4时钟来源APB172MHz 经预分频APB1触发定时器决定更新率分辨率由 ARR 决定16 位计数12 位0–4095输出引脚PB5部分重映射PA4频率公式72MHz / ((ARR1)*(PSC1))触发频率 / 采样点数幅值范围0–3.3V 方波0–VREF通常 3.3V验证方式示波器看方波串口打印占空比示波器看正弦串口采样还原我试过在只有串口没有示波器的情况下用 ADC 回采 DAC 输出再通过串口发到上位机画波形虽然精度一般但验证频率和大致幅值够用了。下面就从工程配置开始一步步把骨架搭起来。2. TaoToken 前置把模型对话和 API Key 准备好写嵌入式代码的时候最烦的往往不是配置本身而是某个寄存器位忘了清、某个时钟没使能编译过了但波形出不来。这时候有个能随时问的模型对话入口会省很多时间。TaoToken 这边我一般这么用先在模型对话里把 CubeMX 的配置思路捋一遍确认定时器时钟树和引脚复用没问题再去 console 里生成 API Key把一些重复性的代码生成或者报错分析交给 API 来做。具体入口如下按需取用模型对话https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel_chatCoding Plan长期编码/Agent 场景https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding_planConsole生成和管理 Keyhttps://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentconsoleAPI Keys 管理https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentapi_keys接入文档https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdocClaudeCodeAnthropic 相关https://taotoken.net/api?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentclaudecode官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 基础地址是 https://taotoken.net/api 注意 API 地址后面不加 UTM 参数直接拼路径就行。提示API Key 只在 console 里生成一次复制后自己存好不要写进公开的代码仓库。嵌入式工程里如果要用建议放在单独的 config.h 并加入 .gitignore。拿到 Key 之后你可以让模型帮你做这几件事根据 CubeMX 的 .ioc 文件反推初始化代码、检查 TIM3 重映射配置是否和引脚冲突、把正弦表生成脚本从 MATLAB 改成 Python。这些都比自己翻手册快。下面进入正题开始配工程。3. 可复制配置CubeMX 时钟、引脚与 Keil 代码骨架3.1 CubeMX 里的时钟与引脚分配打开 CubeMX 新建工程选 STM32F103C8T6 或者你板子上的具体型号。第一步先把时钟树配好HSE 选外部晶振 8MHzPLL 倍频到 72MHzAPB1 分频系数设为 2这样 APB1 定时器时钟是 72MHz。这一步很关键因为后面 PWM 频率公式里的 72MHz 就是从这来的。引脚分配清单如下功能引脚模式备注PWM 输出PB5TIM3_CH2 部分重映射复用推挽输出DAC 输出PA4DAC_OUT1模拟输出无需上拉串口 TXPA9USART1_TX用于打印验证数据串口 RXPA10USART1_RX接收上位机指令调试PA13/PA14SWD下载和调试TIM3 的通道 2 默认在 PA7要用 PB5 必须开启部分重映射。在 CubeMX 的 TIM3 配置里找到 “Remap” 选项选 “Partial Remap”然后 PB5 会自动变成 TIM3_CH2 的复用功能。DAC 这边把 OUT1 勾上触发源先选 None后面用定时器触发的时候再改。3.2 TIM3 PWM 初始化代码CubeMX 生成代码后PWM 部分的核心在tim.c里。下面这段是手动整理过的骨架参数含义都标清楚了你可以直接对照自己的工程改// tim.c 中 TIM3 初始化部分 void MX_TIM3_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig {0}; TIM_MasterConfigTypeDef sMasterConfig {0}; TIM_OC_InitTypeDef sConfigOC {0}; htim3.Instance TIM3; htim3.Init.Prescaler 71; // 预分频 72-1定时器时钟 1MHz htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 999; // ARR999PWM 频率 1MHz/1000 1kHz htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; if (HAL_TIM_Base_Init(htim3) ! HAL_OK) { Error_Handler(); } sClockSourceConfig.ClockSource TIM_CLOCKSOURCE_INTERNAL; if (HAL_TIM_ConfigClockSource(htim3, sClockSourceConfig) ! HAL_OK) { Error_Handler(); } if (HAL_TIM_PWM_Init(htim3) ! HAL_OK) { Error_Handler(); } sMasterConfig.MasterOutputTrigger TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode TIM_MASTERSLAVEMODE_DISABLE; if (HAL_TIMEx_MasterConfigSynchronization(htim3, sMasterConfig) ! HAL_OK) { Error_Handler(); } sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 500; // 初始占空比 50% sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode TIM_OCFAST_DISABLE; if (HAL_TIM_PWM_ConfigChannel(htim3, sConfigOC, TIM_CHANNEL_2) ! HAL_OK) { Error_Handler(); } HAL_TIM_MspPostInit(htim3); }这里 Prescaler 设 71是因为定时器时钟 72MHz 除以 72 得到 1MHz。Period 设 999那么一个周期就是 1000 个计数PWM 频率就是 1MHz 除以 1000 等于 1kHz。Pulse 设 500占空比就是 500/1000 等于 50%。这三个数你可以按需改公式就是PWM 频率 72MHz / ((PSC1) * (ARR1))。启动 PWM 只需要在主函数里加一行HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_2);3.3 DAC 与 DMA 正弦波输出配置DAC 输出正弦波的思路是用 TIM2 做触发源更新频率设为 72kHz正弦表放 360 个点那么输出正弦波频率就是 72kHz / 360 200Hz。如果你想输出 2kHz可以把触发频率提到 720kHz或者把采样点减到 36 个。下面先给正弦表生成脚本用 Python 就行不用装 MATLAB# gen_sin_table.py import math POINTS 360 AMPLITUDE 2047 # 12位DAC中点约2048留一点余量 OFFSET 2048 print(const uint16_t Sine12bit[%d] { % POINTS) for i in range(POINTS): val int(OFFSET AMPLITUDE * math.sin(2 * math.pi * i / POINTS)) if val 4095: val 4095 if val 0: val 0 if i % 12 0: print( , end) print(%4d, % val, end) if i % 12 11: print() print(};)运行后把输出粘贴到 Keil 工程里。接着配置 TIM2 作为 DAC 触发源// TIM2 配置为 DAC 触发更新频率 72kHz void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig {0}; TIM_MasterConfigTypeDef sMasterConfig {0}; htim2.Instance TIM2; htim2.Init.Prescaler 0; // 72MHz 不分频 htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 999; // 72MHz/1000 72kHz 更新 htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(htim2); sClockSourceConfig.ClockSource TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(htim2, sClockSourceConfig); sMasterConfig.MasterOutputTrigger TIM_TRGO_UPDATE; // 更新事件触发 DAC sMasterConfig.MasterSlaveMode TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(htim2, sMasterConfig); }DAC 这边要开 DMA让 DMA1 通道 1 把正弦表搬到 DAC 的 DHR12R1 寄存器// dac.c 中 DAC 初始化 void MX_DAC_Init(void) { DAC_ChannelConfTypeDef sConfig {0}; hdac.Instance DAC; HAL_DAC_Init(hdac); sConfig.DAC_Trigger DAC_TRIGGER_T2_TRGO; // TIM2 更新事件触发 sConfig.DAC_OutputBuffer DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(hdac, sConfig, DAC_CHANNEL_1); }启动的时候用 HAL 库的 DMA 接口HAL_DAC_Start_DMA(hdac, DAC_CHANNEL_1, (uint32_t *)Sine12bit, 360, DAC_ALIGN_12B_R); HAL_TIM_Base_Start(htim2);这样 DAC 就会在 TIM2 每次更新时自动取下一个采样点PA4 上就出现连续的正弦波。如果你要输出音频把正弦表换成 WAV 文件解析出来的采样数组就行采样率对齐 TIM2 的更新频率即可。4. 验证请求与成功结果示波器与串口采样两条路配置写完烧进去先别急着接示波器用串口打印几个关键状态确认外设真的跑起来了。在 main 循环里加一段printf(TIM3 PWM freq %lu Hz\r\n, 72000000 / ((htim3.Init.Prescaler 1) * (htim3.Init.Period 1))); printf(DAC sine freq %lu Hz\r\n, 72000000 / ((htim2.Init.Prescaler 1) * (htim2.Init.Period 1)) / 360);串口波特率设 115200打开上位机应该能看到类似TIM3 PWM freq 1000 Hz DAC sine freq 200 Hz如果这两个数和你预期一致说明时钟树和定时器参数没问题。接下来接示波器探头地夹 GND探针接 PB5应该看到 1kHz 方波高电平约 3.3V占空比 50%。把探针挪到 PA4应该看到 200Hz 正弦波峰峰值大约 3.3V中心在 1.65V 附近。没有示波器的话用 ADC 回采也能验证。把 PA4 用杜邦线接到 PA0ADC1_IN0在定时器中断里每隔固定时间采一次通过串口发到上位机用 Python 画出来import serial import matplotlib.pyplot as plt ser serial.Serial(COM3, 115200) data [] for _ in range(500): line ser.readline().decode().strip() if line.isdigit(): data.append(int(line)) plt.plot(data) plt.title(DAC output sampled by ADC) plt.show()采样率要至少是信号频率的 10 倍以上200Hz 正弦用 2kHz 采样就能看出形状。实测下来ADC 回采的波形会有一些台阶和噪声但频率和幅值趋势是对的用来验证足够。5. 本篇常见错排查PWM 没输出、DAC 波形不对、DMA 不搬数据5.1 PWM 引脚没波形先查时钟使能。TIM3 挂在 APB1 上GPIOB 挂在 APB2 上两个都要开。CubeMX 一般会自动加但如果你手动改过 RCC 配置可能漏掉。其次查重映射PB5 做 TIM3_CH2 必须开 AFIO 时钟并调用__HAL_AFIO_REMAP_TIM3_PARTIAL()CubeMX 里对应 “Partial Remap” 选项。最后查启动函数HAL_TIM_PWM_Start和HAL_TIM_Base_Start不一样PWM 输出必须用前者。5.2 DAC 输出一直是直流最常见的原因是 DMA 没启动或者触发源没配对。检查HAL_DAC_Start_DMA的第四个参数是不是正弦表长度对齐方式是不是DAC_ALIGN_12B_R。另外 TIM2 的MasterOutputTrigger必须设成TIM_TRGO_UPDATE否则 DAC 收不到触发信号只会输出表里第一个值。还有一点DAC 的输出缓冲建议打开否则带负载能力很弱接示波器可能看到幅值被拉低。5.3 正弦波频率不对先确认 TIM2 的实际更新频率。如果 APB1 分频不是 2定时器时钟可能不是 72MHz。STM32F103 的 APB1 最大 36MHz但定时器时钟在 APB1 分频不为 1 时会倍频所以 72MHz 是这么来的。如果你把 APB1 分频改成 4定时器时钟就变成 36MHz输出频率会减半。公式里的 72MHz 要换成实际定时器时钟。5.4 串口打印乱码波特率不匹配是最常见的。检查 USART1 的时钟源是不是 72MHzhuart1.Init.BaudRate是不是 115200。另外 printf 重定向要加#include stdio.h并实现fputc否则不会输出。如果用的是 Keil还要在 Target 选项里勾上 “Use MicroLIB”。5.5 DMA 传输一半就停DAC 的 DMA 请求在每次触发时搬一个数据如果 DMA 配置成 Normal 模式搬完 360 个点就停了。要循环输出必须设成 Circular 模式。CubeMX 里在 DMA Settings 里把 Mode 改成 Circular或者手动改hdma_dac1.Init.Mode DMA_CIRCULAR。6. 继续往下走把骨架变成你自己的波形工程到这里 PWM 和 DAC 的基本骨架已经能跑了。接下来可以做的扩展不少把正弦表换成方波表、三角波表用按键切换波形类型把 TIM3 的占空比用串口指令动态改做成可调亮度或者可调频率的输出把 WAV 文件解析成数组通过 DAC DMA 播放音频。这些都是在现有骨架上加逻辑不用重新配外设。如果你在配置过程中遇到 CubeMX 生成的代码和手动代码冲突或者 DMA 搬运的数据对不上可以把报错信息丢到模型对话里让它帮你分析。需要长期做嵌入式编码和 Agent 辅助的话Coding Plan 那边有更完整的工具链支持。API Key 在 console 里生成后接入文档里有详细的调用示例照着改就行。最后留一个我踩过的坑DAC 输出接喇叭的时候一定要加隔直电容否则直流分量会让喇叭偏置声音失真甚至损坏。PA4 出来的是 0 到 3.3V 的单极性信号串一个 10uF 电容再接负载波形就干净了。
