STM32 解析 PPM 协议教程
1. PPM 是什么
PPM 全称通常叫 Pulse Position Modulation,在遥控接收机场景里,经常表示"多通道复用到一根线上"的脉冲序列。
它和普通 PWM 最大的区别是:
PWM往往一根线只表示一个通道,关注的是高电平宽度PPM是一串连续脉冲,多个通道按顺序排在一帧里- 接收端真正要测的,不一定是高电平时间,而是"相邻边沿之间的时间间隔"
在很多遥控器/接收机实现中,一帧 PPM 的结构大致是:
- 通道 1 脉冲间隔
- 通道 2 脉冲间隔
- 通道 3 脉冲间隔
- ...
- 最后出现一个明显更长的同步间隔,告诉接收端"下一次要从通道 1 重新开始"
本项目就是利用这一点来解码:只要能测出每次边沿到下次边沿的时间间隔,就能恢复各个通道值。
2. 本工程的硬件映射
当前工程中 PPM 输入使用:
- 引脚:
PA7 - 定时器通道:
TIM3_CH2
定义见 Core/Inc/main.h。
定时器初始化见 Core/Src/tim.c:
Prescaler = 71Period = 65535- 输入捕获边沿:
TIM_INPUTCHANNELPOLARITY_FALLING
这意味着在 72MHz 主频下,定时器计数频率被分到 1MHz:
text
72MHz / (71 + 1) = 1MHz
也就是:
- 定时器每计一个数 =
1us - 读出来的捕获值差值,天然就是"多少微秒"
这就是为什么这里把分频器配成 71。因为 PPM 的每个通道宽度通常就在 1000us ~ 2000us,同步间隔通常大于 4000us。如果计数单位直接就是 1us,代码最直观,也最不容易写错。
3. 为什么用输入捕获,而不是普通 GPIO 中断
很多初学者第一反应是:
- GPIO 外部中断也能检测边沿
- 那我在中断里记一个
HAL_GetTick()不就行了
这在 PPM 上通常不够好,原因有三点。
3.1 HAL_GetTick() 精度不够
HAL_GetTick() 默认是 1ms 精度,而 PPM 的通道宽度差异常常是几百微秒量级:
- 油门 1200us
- 油门 1500us
- 油门 1800us
如果你只能测到毫秒,那 1.2ms、1.5ms、1.8ms 都会变得很粗糙,解析精度明显不够。
3.2 输入捕获是硬件锁存时间戳
TIM 输入捕获的优势是:
- 边沿一到,硬件立即把当前计数器值锁存到
CCR - 中断稍后才进来没关系,因为关键时间点已经被硬件保存了
这比"中断进来后再软件读时间"稳定得多。
3.3 抗抖动和一致性更好
PPM 解析本质上是测时间。凡是测时间,越靠近硬件,结果越稳。
所以本工程选择:
TIM3输入捕获- 在捕获回调里读取锁存值
- 交给
PPM_OnCapture()做解码
这个方案是 STM32 上很标准、也很稳妥的做法。
4. 本工程 PPM 的核心解码思路
看 App/ppm.c,核心逻辑非常清晰:
- 维护上一次捕获值
last_capture - 新边沿到来时,算出这次与上次的时间差
width_us - 如果
width_us >= 4000us,说明遇到同步间隔 - 如果
< 4000us,说明这是一个普通通道宽度 - 把这些宽度依次存进通道数组
对应核心代码逻辑可以概括成:
c
width_us = 当前捕获值 - 上次捕获值;
if (width_us >= 4000) {
说明一帧结束;
发布上一帧;
通道索引清零;
} else {
这是一个正常通道;
依次写入 channels[];
}
5. 为什么同步阈值是 4000us
代码里这一句很关键:
c
if (width_us >= 4000UL)
这不是随便拍脑袋写的,而是来自 PPM 帧结构本身。
通常遥控通道宽度在:
- 最小约
1000us - 中位约
1500us - 最大约
2000us
而帧同步间隔会明显更长,常见在:
4000us5000us- 甚至更高
所以把阈值放在 4000us 的好处是:
- 不会把正常通道错判为同步
- 同步段又足够容易识别
- 算法简单,不需要更复杂的状态机
为什么不是 2500us 或 3000us?
- 因为一些抖动较大的接收机、异常波形或者边沿测量误差,可能让正常通道偶发变宽
- 阈值太低,容易误切帧
为什么不是 6000us?
- 阈值太高,某些接收机的同步段可能达不到,导致永远找不到帧边界
所以 4000us 是一个很实用的工程折中值。
6. 为什么要处理定时器回绕
本工程在 App/ppm.c 用了这段处理:
c
width_us = (capture >= last_capture) ? (capture - last_capture)
: ((0x10000UL - last_capture) + capture);
这里是在处理 TIM3 的 16 位计数器溢出回绕。
因为:
TIM3.Period = 65535- 计数器从
0数到65535后会回到0
如果上一次捕获是 65000,下一次是 500,直接做减法会得到负数。但实际时间并不是负的,而是:
text
(65536 - 65000) + 500 = 1036us
为什么必须处理这个?
- 因为
PPM是持续流,边沿有可能正好跨过定时器溢出点 - 如果不处理,偶发一次就可能把整帧解析乱掉
这类问题最危险的地方在于:不是一直错,而是偶尔错。偶尔错最难查,所以一开始就要把回绕逻辑写上。
7. 为什么用 working buffer 再发布 frame
代码里有两个存储区:
s_working_channels[]:临时收通道s_frame:已经完成、可供主循环读取的结果
这是非常重要的设计。
如果你一边在中断里写数组,一边在主循环里读同一个数组,就会出现:
- 主循环读到一半
- 中断又改了后半段
- 最终得到"半帧旧数据 + 半帧新数据"的脏数据
本工程的做法是:
- 中断期间先把新通道写入
s_working_channels[] - 一旦碰到同步间隔,说明一帧完整了
- 用
memcpy()整体复制到s_frame.channels - 置位
frame_ready
这样主循环永远只读完整帧,而不是读半成品。
8. 为什么 PPM_GetFrame() 要关中断
见 App/ppm.c。
c
__disable_irq();
ready = s_frame.frame_ready;
if (ready != 0U) {
*frame = s_frame;
s_frame.frame_ready = 0U;
}
__enable_irq();
原因很直接:
s_frame是中断上下文会更新的数据- 主循环读取时,如果刚好被输入捕获中断打断,就可能读到不一致内容
关中断不是为了"性能",而是为了"原子性":
- 先看有没有新帧
- 有的话整帧拷走
- 再清除 ready 标志
这三个动作必须尽可能视为一个整体。
为什么不用 volatile 就完了?
volatile只能保证编译器别乱优化- 它不能保证"复制结构体 + 清标志"是不可打断的
所以这里要用短临界区。
9. 中断链路是怎么串起来的
整个 PPM 数据流在本工程中的路径是:
PA7收到PPMTIM3_CH2发生输入捕获TIM3_IRQHandler()调HAL_TIM_IRQHandler(),见 Core/Src/stm32f1xx_it.c- HAL 进入
HAL_TIM_IC_CaptureCallback(),见 Core/Src/main.c - 回调里调用
PPM_OnCapture(HAL_TIM_ReadCapturedValue(...)) PPM_OnCapture()完成解码- 主循环调用
PPM_GetFrame()取走最新一帧
这种分层为什么合理:
- 中断入口只负责转发,不堆业务逻辑
- 协议解析集中在
App/ppm.c - 主循环只负责消费结果
模块边界清楚,后续维护更轻松。
10. 主循环为什么不直接在中断里用 PPM 控制电机
当前工程在 Core/Src/main.c 里这样做:
- 先
PPM_GetFrame() - 再更新时间戳
g_ppm_last_ms - 再根据优先级选择当前控制源
为什么不在中断里直接改电机目标值?
原因有四个:
10.1 中断里要尽量短
PPM 输入频繁,如果中断里做太多事:
- 会拉高中断占用
- 增加系统抖动
- 影响其他外设响应
10.2 输入与控制要解耦
PPM 只是输入源之一,本工程还有:
SBUSPX4 MAVLink
把输入先解码成统一通道,再在主循环统一仲裁,更容易扩展。
10.3 便于做超时和优先级管理
主循环里有明确逻辑:
PX4优先- 其次
SBUS - 最后
PPM - 超时就失效
这类多源仲裁放在主循环写最自然。
10.4 便于调试
你可以单独验证:
- 中断有没有收到
- 协议有没有解码成功
- 主循环有没有取到新帧
- 控制仲裁是否正确
如果全部塞进中断,很难定位问题。
11. 如何从零实现一个类似的 PPM 解析器
如果你要自己做,推荐按下面步骤来。
11.1 先把定时器计到 1us
核心目标不是"某个神奇分频值",而是:
- 最终每个计数单位最好能直接对应
1us
这样所有阈值都能直接写成:
1000150020004000
可读性极高。
11.2 选择一个固定边沿
本工程选了 FALLING。为什么可以?
- 因为这里测的是相邻同类边沿之间的间隔
- 只要始终使用同一类边沿,宽度定义就稳定
不要一会儿测上升沿,一会儿测下降沿,否则数据基准会乱。
11.3 在回调里只做一件事:算时间差
中断回调应该只做这些:
- 读捕获寄存器
- 算
delta - 判断是不是同步间隔
- 存通道
不要在这里做复杂滤波、打印日志、控制输出。
11.4 用"完成帧"而不是"实时数组"
一定要有:
- 工作缓存
- 已发布缓存
ready标志
这是嵌入式协议解析里非常实用的套路。
12. 当前实现的工程含义
本工程里 PPM 不是唯一控制源,而是最低优先级备用控制源。它的定位是:
- 在
PX4不在线时兜底 - 在
SBUS不在线时兜底 - 在系统调试早期提供一个最简单的人工输入方式
所以当前实现只保留了 6 个通道:
c
#define PPM_CHN_MAX 6U
为什么不是更多?
- 当前主控逻辑只实际消费前几个关键通道
- 少一点数组和逻辑,更简单稳定
- 如果后面需要扩展,再把
PPM_CHN_MAX调大即可
13. 常见坑
13.1 引脚模式配错
虽然使用的是 TIM3_CH2,但在 Core/Src/tim.c 里配置成了普通输入模式。对 F1 的输入捕获场景来说,这样通常也是能工作的,因为真正的捕获复用关系由定时器通道逻辑接管。
但你迁移到别的芯片时,要重新确认:
- 该引脚是否真的映射到对应
TIMx_CHy - GPIO 模式是否满足芯片要求
13.2 忘记启动输入捕获中断
本工程在 Core/Src/main.c 调用了:
c
HAL_TIM_IC_Start_IT(&htim3, TIM_CHANNEL_2);
如果你只初始化 TIM 但没启动捕获中断,是不会有数据的。
13.3 用 HAL_GetTick() 测通道宽度
这基本肯定不够精细,不推荐。
13.4 没做超时保护
主循环里通过 g_ppm_last_ms 做了超时判定。如果收不到新帧,PPM 源就会失效,这非常重要。否则断线后可能一直沿用旧油门。
14. 为什么这个实现适合当前项目
总结一下,本项目 PPM 这样写,是因为它同时满足了几个目标:
- 精度够:
TIM3输入捕获 +1us计数分辨率 - 实现简单:只按边沿间隔解码,不做复杂状态机
- 稳定性够:处理了定时器回绕,使用完整帧发布机制
- 易维护:中断、协议解析、主循环消费职责分离
- 易扩展:后续可以继续接入更多控制源,而
PPM模块本身不需要大改
如果你以后要升级这个模块,优先考虑的方向通常是:
- 增加输入滤波
- 增加通道数
- 对异常宽度做丢弃策略
- 增加统计信息,便于调试链路质量
但作为当前工程的实用实现,这套写法已经足够合理,而且结构是对的。
15. 实战联调流程(建议按这个顺序)
如果你现场调 PPM,建议严格按下面顺序,不要一上来就看主循环控制效果。
- 先确认硬件电平和地线共地,示波器看
PA7上是否有稳定脉冲列。 - 确认
TIM3真的启动了输入捕获中断,检查 Core/Src/main.c 是否执行到。 - 在
HAL_TIM_IC_CaptureCallback()里打一个轻量计数器,确认中断频率正常。 - 在
PPM_OnCapture()里临时观察width_us,验证是否大致落在1000~2000us和>=4000us两类。 - 再看
PPM_GetFrame()是否能周期性返回1,最后才检查主循环通道是否生效。
为什么要这个顺序:
- 这是从"物理层 -> 外设层 -> 协议层 -> 业务层"的分层排障路径。
- 一层没通,下一层看起来都会像"偶发 bug"。
16. 故障定位速查表(PPM)
16.1 完全收不到帧
优先检查:
TIM3_IRQHandler()是否进中断HAL_TIM_IC_Start_IT()是否调用成功- 引脚是否真的是
TIM3_CH2
16.2 通道值偶尔突变
优先检查:
- 输入线缆是否受干扰
- 同步阈值
4000us是否过低 - 是否存在异常宽度未过滤
16.3 通道顺序错乱
优先检查:
- 接收机输出的通道顺序定义
- 是否把同步间隔误识别成普通通道
16.4 明明有输入但主循环不用 PPM
优先检查:
g_ppm_last_ms是否有更新- 是否被更高优先级输入源(PX4/SBUS)覆盖
- 超时门限是否设置过短
17. 最小可复用骨架(PPM)
下面是一个独立项目里可复用的极简解码骨架,和当前工程思路一致:
c
typedef struct {
uint16_t ch[8];
uint8_t count;
uint8_t ready;
} ppm_frame_t;
static ppm_frame_t g_frame;
static uint16_t g_work[8];
static uint8_t g_idx;
static uint32_t g_last_cap;
void ppm_on_capture(uint32_t cap)
{
if (g_last_cap == 0U) {
g_last_cap = cap;
return;
}
uint32_t dt = (cap >= g_last_cap) ? (cap - g_last_cap)
: ((0x10000UL - g_last_cap) + cap);
g_last_cap = cap;
if (dt >= 4000U) {
if (g_idx > 0U) {
memcpy(g_frame.ch, g_work, sizeof(g_work));
g_frame.count = g_idx;
g_frame.ready = 1U;
}
g_idx = 0U;
return;
}
if (g_idx < 8U) {
g_work[g_idx++] = (uint16_t)dt;
}
}
这个骨架保留了三个关键点:
- 回绕处理
- 同步间隔分帧
- working buffer + ready 发布