简介:面向嵌入式开发者的 STM32F103 四通道90度相位差方波生成工程,适用于电机四象限驱动、正交编码器激励及无线通信正交调制等场景。资源包含完整可编译的 Keil 工程,共73个文件,以C与H源码为主,穿插启动汇编文件、Hex固件、工程配置和清理脚本,总体积仅277KB,便于直接导入工程查看;已有943人学习下载。工程重点展示了基于定时器PWM的多通道相位差实现方法,包括预分频器配置、比较寄存器设置、输出极性控制、多通道同步更新及异常保护策略;代码基于标准外设库编写,覆盖定时器初始化、PWM通道配置、相位偏移调节等关键环节,各通道比较值按定时器周期的四分之一递增,从而得到准确的90度相位差,并通过更新事件保证四路输出同步。涉及的初始化思路与代码结构清晰,适合需要快速掌握STM32F103定时器正交信号生成的嵌入式开发者参考;同时也可作为后续二次开发和功能移植的基础模板。
1. 4通道90°相位差方波:一个经常被做成“抖动脉冲”的STM32F103需求
如果你的 STM32F103 最小系统板要做的是正交编码器信号模拟、四相步进驱动前级,或者给伺服驱动器灌一组可调速的 A/B/Z 脉冲,那标题里的“4通道90°相位差方波”不是让你开 4 个定时器同时输出 PWM。真正的难点在“相位差”三个字:四路方波同频、占空比 50%,相邻通道边沿错开严格 90°,且运行时能改频率、换方向,不能靠 CPU 一条一条挤。搜这个标题的人大多在用标准外设库 V3.5.0 的老工程,所以下面这套方案基于标准库实现,把 STM32F103 的定时器更新事件当成“节拍器”,用 DMA 在固定相位点刷新 GPIO,不需要进中断,相位误差只来自定时器计数和 DMA 响应速度。适合想一次性把信号做干净、不想反复调延时的工程师。
2. 为什么中断翻转和 PWM 都做不出四路 90° 方波
2.1 软件翻转的抖动上限:中断延迟和相位累积误差
最常见的做法是开一个定时器中断,每次进入中断翻转 4 个 GPIO。可一旦频率到几十 kHz,问题立刻暴露:F103 的中断入口本身有固定延迟,以及 NVIC 抢占、串口或其他外设中断的竞争,都会让每次翻转时基偏移几十到几百纳秒。四路信号之间的 90° 关系一旦靠“翻转次数”数出来,任何一次抖动都会累积成相位漂移。示波器上看上升沿间距不是恒定的 T/4,而是忽大忽小。
把中断优先级推到最高也救不回来,因为 Flash 取指、写 ODR 的读改写、以及编译器优化都会引入不确定周期。低频 1kHz 以下可能看不出来,但编码器模拟常用于 10kHz~500kHz,这种方案基本不可用。
2.2 PWM 只控制占空比,控制不了 90° 绝对相位差
有人会想:用 TIM2 的 CH1~CH4,设四个不同的 CCR,让每个通道电平翻转时刻不同。这个思路在边沿对齐 PWM 下有一个前提性错误:PWM1 模式里,通道输出高电平的起点在计数器 0 处,四个通道的上升沿天然对齐,CCR 只能错开下降沿。要让两路 50% 占空比方波上升沿相差 90°,单一 PWM 通道做不到。
中心对齐模式可以改变上升/下降两个边沿的位置,但 CCR 从根本上决定的是高电平窗口宽度,也就是占空比。把 CCR 平移实现相位移动的同时,占空比会跟着变,不再是 50%。你要么接受占空比偏差,要么用两套定时器主从触发做相位粗调,代码复杂度和调试成本会迅速超过一个 DMA 方案。
2.3 TIM6 + DMA + GPIO ODR 的方案原理:更新事件代替中断
正确思路是改变“更新对象”:不用定时器中断去翻转引脚,而是让定时器更新事件直接触发 DMA,由 DMA 把一个相位状态字写到 GPIO 的 ODR。每来一次更新事件,DMA 从内存取一个 16 位数据写到 GPIOB,四路引脚的电平组合随之切换。一次 DMA 传输只需要几个 AHB 周期,不占用 CPU,也不会被中断延迟打扰。
拿 STM32F103 的典型配置举例:定时器使用基本定时器 TIM6,它的更新事件映射到 DMA1 的通道 3,这是官方库和大量 DAC 波形例程用过的路径。DMA 设成循环模式,内存数组放 4 个相位状态,定时器每计满一次就推进一个状态。方波频率和 90° 相位被同一个时基锁定,不存在累积误差。
2.4 三种方案对比表
| 方案 | 相位精度 | CPU 占用 | 频率上限 | 代码复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 定时器中断翻转 GPIO | 中低,受中断抖动影响 | 高,ISR 频繁 | 几十 kHz 以下 | 低 | 低频演示、非关键脉冲 |
| PWM + 中心对齐 | 无法同时保证 50% 占空比和移相 | 低 | 较高 | 中 | 占空比可变的调速信号 |
| TIM6 + DMA 循环写 ODR | 高,边沿由硬件时基决定 | 极低,初始化后不用管 | 可达数百 kHz 至 MHz 级 | 中 | 正交编码器模拟、四相方波源 |
选型结论很直接:只要你的负载对“四路之间的相位关系”有硬性要求,就放弃中断方案,把定时器和 DMA 绑定在一起。
3. STM32F103 标准外设库下的最小实现:TIM6+DMA1 生成四路方波
3.1 引脚与 DMA 资源分配
这里用一个最小系统板最常见的外部引脚组合:PB0、PB1、PB2、PB3 分别作为 CH1(0°)、CH2(90°)、CH3(180°)、CH4(270°) 输出。选择 PB0~PB3 的原因是它们在 GPIOB ODR 的低四位,DMA 写一个半字即可一次更新四路电平,后续验证也直观。
资源占用如下:
- TIM6:仅作为时基,不占用任何输出引脚
- DMA1_Channel3:TIM6 更新事件触发源,内存到外设,循环模式
- GPIOB ODR:DMA 外设目标地址
TIM6 是基本定时器,不带输出比较通道,正好适合“只用来定时”的场景。如果你手头只有通用定时器,比如 TIM2/TIM3,也可以做同样的事,但需要确认对应 DMA 通道映射,因为不同定时器的更新事件挂在不同的 DMA 通道上。
3.2 相位状态表推导:90° 一个状态,4 个半字
四路方波占空比 50%,周期划分成 4 个 90° 区间。每过 90°,四路电平组合状态如下表:
| 相位区间 | CH1 (0°) | CH2 (90°) | CH3 (180°) | CH4 (270°) | GPIOB ODR值 |
|---|---|---|---|---|---|
| 0° ~ 90° | 1 | 0 | 0 | 0 | 0x0001 |
| 90° ~ 180° | 1 | 1 | 0 | 0 | 0x0003 |
| 180° ~ 270° | 0 | 1 | 1 | 0 | 0x0006 |
| 270° ~ 360° | 0 | 0 | 1 | 1 | 0x000C |
这个状态表的关键在于:任意相邻两个状态之间,至少有一条边沿发生;状态循环一圈正好是 4 次更新,也就是一个完整方波周期。DMA 循环读取这 4 个半字,输出就是持续不断的四路 90° 方波。
3.3 可直接抄的标准库初始化代码
下面的代码按标准外设库 V3.5.0 风格写,硬件为 72MHz 主频的 STM32F103C8T6。
#include "stm32f10x.h" #define PHASE_TABLE_SIZE 4 /* 相位表:低四位对应 PB0/PB1/PB2/PB3 */ volatile uint16_t phase_tbl[PHASE_TABLE_SIZE] = { 0x0001, /* 0°: CH1=1 CH2=0 CH3=0 CH4=0 */ 0x0003, /* 90°: CH1=1 CH2=1 CH3=0 CH4=0 */ 0x0006, /* 180°: CH1=0 CH2=1 CH3=1 CH4=0 */ 0x000C /* 270°: CH1=0 CH2=0 CH3=1 CH4=1 */ }; static void QuadWave_Init(uint32_t square_freq_hz) { GPIO_InitTypeDef gpio; TIM_TimeBaseInitTypeDef tim; DMA_InitTypeDef dma; uint32_t timer_clk = 72000000UL; uint32_t update_freq = square_freq_hz * 4UL; /* 每 90° 更新一次 */ uint32_t div, psc, arr; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM6, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); /* PB0~PB3 推挽输出,速度必须配置为最高档 */ gpio.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; gpio.GPIO_Mode = GPIO_Mode_Out_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &gpio); GPIO_Write(GPIOB, phase_tbl[0]); /* 计算分频,先保证 ARR 不超过 16 位 */ div = timer_clk / update_freq; psc = 0; while (div / (psc + 1) > 0xFFFFUL) { psc++; } arr = div / (psc + 1) - 1; /* TIM6 作为时基,更新频率 = 72MHz / ((PSC+1)*(ARR+1)) */ TIM_TimeBaseStructInit(&tim); tim.TIM_Prescaler = psc; tim.TIM_Period = arr; TIM_TimeBaseInit(TIM6, &tim); TIM_ARRPreloadConfig(TIM6, ENABLE); /* DMA1_Channel3 对应 TIM6 的更新请求 */ DMA_DeInit(DMA1_Channel3); dma.DMA_PeripheralBaseAddr = (uint32_t)&GPIOB->ODR; dma.DMA_MemoryBaseAddr = (uint32_t)phase_tbl; dma.DMA_DIR = DMA_DIR_PeripheralDST; /* 内存 -> 外设 */ dma.DMA_BufferSize = PHASE_TABLE_SIZE; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Circular; /* 循环发送相位表 */ dma.DMA_Priority = DMA_Priority_High; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel3, &dma); /* 先使能 DMA,再打开定时器,避免丢第一个状态 */ DMA_Cmd(DMA1_Channel3, ENABLE); TIM_DMACmd(TIM6, TIM_DMA_Update, ENABLE); TIM_Cmd(TIM6, ENABLE); }这段代码做了三件事:初始化 PB0~PB3 为推挽输出;按照目标方波频率计算 TIM6 的分频参数;把 phase_tbl 数组配置成 DMA 的循环源。请注意DMA_DIR被设置为DMA_DIR_PeripheralDST,标准库这里语义是“外设作为目的地”,实际方向是内存到外设,命名容易看反。
3.4 频率公式与常见取值
输出更新频率和方波频率的关系是:
更新频率 = 72MHz / ((PSC+1) * (ARR+1)) 方波频率 = 更新频率 / 4也就是说 TIM6 每触发一次 DMA,四路方波整体推进 90°。四个更新才走完一个 360° 周期。下面给出几个目标频率下的参数参考,代码中已经按 div 自动计算,这里用来验证你的手算结果:
| 方波频率 | PSC | ARR | 实际方波频率 |
|---|---|---|---|
| 100 Hz | 179 | 999 | 100 Hz |
| 1 kHz | 17 | 999 | 1 kHz |
| 10 kHz | 1 | 899 | 10 kHz |
| 100 kHz | 0 | 179 | 100 kHz |
| 500 kHz | 0 | 35 | 500 kHz |
4. 参数调整:目标频率计算、串口调速和相位反向
4.1 运行时修改频率:PSC 和 ARR 一起改
上面代码里的分频参数是一次性算好的。实际设备通常要能在运行中调速,比如用串口发一个频率值。常见做法是把计算逻辑封装成独立函数,调用时直接修改 TIM6 的 PSC 和 ARR。
void QuadWave_SetFreq(uint32_t square_freq_hz) { uint32_t timer_clk = 72000000UL; uint32_t update_freq = square_freq_hz * 4UL; uint32_t div, psc, arr; if (square_freq_hz == 0) { TIM_Cmd(TIM6, DISABLE); return; } div = timer_clk / update_freq; psc = 0; while (div / (psc + 1) > 0xFFFFUL) { psc++; } arr = div / (psc + 1) - 1; TIM_PrescalerConfig(TIM6, psc, TIM_PSCReloadMode_Update); TIM_SetAutoreload(TIM6, arr); }注意TIM_PrescalerConfig的更新方式是TIM_PSCReloadMode_Update,也就是等 TIM6 当前周期结束后新分频值才生效。这样改频率时不会切断正在输出的半周期,四路相位关系也不会因为改参数而错位。
4.2 用串口把频率和控制指令接进来
只做开环输出意义不大,我一般会把串口 1 或串口 3 的中断接进来,收一个简单文本指令。串口 1 在 APB2 总线上有独立的 USART1_IRQn,串口 3 挂在 APB1 上,初始化时钟和中断向量不同,但解析逻辑完全一样。
/* 假设串口中断里收到一行文本:F=1000\n */ void Parse_Command(const char *cmd) { uint32_t freq; if (cmd[0] == 'F') { freq = 0; sscanf(cmd + 1, "%lu", &freq); QuadWave_SetFreq(freq); } }sscanf在 F103 上通常能从标准 C 库直接调用,注意小内存工程要检查链接器是否打入了scanf系列函数。如果不想引入格式化库,可以自己写一个strtoul手工解析,这也顺带避开了热词里常被问到的strcmp使用差异。
4.3 反转方向:phase 数组倒序
四路方波的方向对编码器模拟非常重要。正转时相位状态按0°→90°→180°→270°推进,反转时应该反过来走0°→270°→180°→90°。对应的 phase_tbl 重排为:
phase_tbl[0] = 0x0001; /* 0° */ phase_tbl[1] = 0x000C; /* 反向的下一状态:270° */ phase_tbl[2] = 0x0006; /* 180° */ phase_tbl[3] = 0x0003; /* 90° */直接改数组在 DMA 循环模式下最终会生效,但有可能在切换瞬间产生一个错误状态。更稳的做法在最后一章讲,先记住方向反转的本质不是翻转某个 GPIO,而是改变状态表的循环顺序。
4.4 ODR 直接写的注意事项
DMA 把数据写到GPIOB->ODR,这是整个方案的唯一敏感点。ODR 是读-改写寄存器,DMA 写入的是一个 16 位半字,如果其他代码也通过GPIO_WriteBit操作 GPIOB 的其它引脚,理论上可能覆盖掉 DMA 刚写入的低位状态。避免方法有两种:一是把四路方波引脚占用的低四位从所有业务逻辑中隔离,CPU 只操作 BSRR/BRR;二是将方波输出放到独立 GPIO 端口上,比如 PORTA 的低四位,DMA 写GPIOA->ODR,其他功能绕开这个端口。
实际项目中我更推荐第二个办法,端口隔离能减少很多无法复现的偶发问题。
5. 逻辑分析仪验证与排错:边沿间隔 25% 周期才算对
5.1 状态表对照法验证四路时序
代码下进去以后,不要直接看频率,先抓四路波形。正常情况下应该看到四条频率相同的方波,CH1 相对 CH2、CH2 相对 CH3、CH3 相对 CH4 的上升沿之间正好差一个方波周期的四分之一。以 1kHz 方波为例,相邻上升沿间隔应为 250μs。
如果图上看到四路频率正确但间隔明显不是 T/4,大概率是相位表顺序错了。用逻辑分析仪把四路解析成总线值,对照 3.2 节的状态表,正转应该循环出现1, 3, 6, C这 4 个值。顺序一旦是1, 6, 3, C,说明数组元素放混了。
5.2 用 CH1 XOR CH2 做 2 倍频验证
90° 相位差有一个非常实用的物理特征:把 CH1 和 CH2 接进一个异或门,输出端的频率刚好是原方波的 2 倍。如果你的板子上没有额外逻辑门,可以用示波器的数学通道做 CH1 XOR CH2。这个 2 倍频信号的高电平宽度应该严格等于原方波的半周期。
这个验证方法比肉眼量边沿快很多,因为它把相位误差转化成了脉宽误差。任何一个通道的相位不对,异或结果的脉宽就会变成 1/4 周期或 3/4 周期,一眼就能看出来。
5.3 常见坑对照表
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 四路无输出 | TIM6 和 DMA 使能顺序反了 | 先DMA_Cmd再TIM_Cmd |
| 输出频率减半或翻倍 | 更新频率与方波频率关系搞错 | 确认公式:方波 = 更新频率 / 4 |
| 高频时波形边沿不陡 | GPIO_Speed 配成了 2MHz | 改成GPIO_Speed_50MHz |
| DMA 只发一次就停 | DMA_Mode 没有配为 Circular | 检查DMA_Mode是否为循环模式 |
| 频率改动后出现毛刺 | PSC 立即生效导致计数周期不完整 | 使用TIM_PSCReloadMode_Update |
| 个别引脚被其他外设干扰 | ODR 被 CPU 读改写覆盖 | 将输出引脚挪到独立端口 |
还有一个容易被忽略的点:TIM6 的时钟来自 APB1。如果 APB1 分频系数不是 1,定时器时钟会自动倍频到 72MHz。用标准库RCC_GetClocksFreq读一次PCLK1_Timer_Frequency,确认你代码里的 timer_clk 没算错。
6. 动态换向不产生毛刺的 DMA 完成中断技巧
直接在图 4.3 那种“改数组”的做法里换向,DMA 可能正好处于 phase_tbl 的中间位置,切换后会先读到新数组的后续元素,导致状态跳变不连续。要让换向在相位表的边界处发生,可以利用 DMA 循环模式自带的传输完成中断:DMA 每传完一轮 4 个状态,会触发一次 TC 中断,此时 DMA 内部地址已经回到数组首地址,但下一次更新还没有开始。
在这个中断里改写 phase_tbl[1] 到 phase_tbl[3],就能保证反向从 0° 状态之后才开始,不会出现半个错误状态。
volatile uint8_t dir_change_pending = 0; void DMA1_Channel3_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC3)) { DMA_ClearITPendingBit(DMA1_IT_TC3); if (dir_change_pending) { dir_change_pending = 0; /* 反向状态表:从 0° 之后进入 270° */ phase_tbl[1] = 0x000C; phase_tbl[2] = 0x0006; phase_tbl[3] = 0x0003; } } }调用换向时不需要停定时器,只需要置dir_change_pending = 1,TC 中断会在下一次周期边界执行替换。phase_tbl[0] 在正转和反转状态下都是 0x0001,所以第一个状态不需要改。这个技巧同时适用于编码器方向切换和步进电机正反转控制,省掉了外部方向引脚和额外逻辑。
TC 中断本身只执行几个数组赋值,时间很短,不会影响正常 DMA 时序。如果后续还想扩展单步脉冲输出,可以把dir_change_pending换成模式变量,比如在 DMA 完成中断里再判断是否执行一次状态推进后暂停,这样就能顺手得到一个可单步的正交脉冲发生器,输出精度和连续模式完全一致。
本文还有配套的精品资源,点击获取