STM32解析PPM协议

STM32 解析 PPM 协议教程

1. PPM 是什么

PPM 全称通常叫 Pulse Position Modulation,在遥控接收机场景里,经常表示"多通道复用到一根线上"的脉冲序列。

它和普通 PWM 最大的区别是:

  • PWM 往往一根线只表示一个通道,关注的是高电平宽度
  • PPM 是一串连续脉冲,多个通道按顺序排在一帧里
  • 接收端真正要测的,不一定是高电平时间,而是"相邻边沿之间的时间间隔"

在很多遥控器/接收机实现中,一帧 PPM 的结构大致是:

  1. 通道 1 脉冲间隔
  2. 通道 2 脉冲间隔
  3. 通道 3 脉冲间隔
  4. ...
  5. 最后出现一个明显更长的同步间隔,告诉接收端"下一次要从通道 1 重新开始"

本项目就是利用这一点来解码:只要能测出每次边沿到下次边沿的时间间隔,就能恢复各个通道值。

2. 本工程的硬件映射

当前工程中 PPM 输入使用:

  • 引脚:PA7
  • 定时器通道:TIM3_CH2

定义见 Core/Inc/main.h

定时器初始化见 Core/Src/tim.c

  • Prescaler = 71
  • Period = 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.2ms1.5ms1.8ms 都会变得很粗糙,解析精度明显不够。

3.2 输入捕获是硬件锁存时间戳

TIM 输入捕获的优势是:

  • 边沿一到,硬件立即把当前计数器值锁存到 CCR
  • 中断稍后才进来没关系,因为关键时间点已经被硬件保存了

这比"中断进来后再软件读时间"稳定得多。

3.3 抗抖动和一致性更好

PPM 解析本质上是测时间。凡是测时间,越靠近硬件,结果越稳。

所以本工程选择:

  • TIM3 输入捕获
  • 在捕获回调里读取锁存值
  • 交给 PPM_OnCapture() 做解码

这个方案是 STM32 上很标准、也很稳妥的做法。

4. 本工程 PPM 的核心解码思路

App/ppm.c,核心逻辑非常清晰:

  1. 维护上一次捕获值 last_capture
  2. 新边沿到来时,算出这次与上次的时间差 width_us
  3. 如果 width_us >= 4000us,说明遇到同步间隔
  4. 如果 < 4000us,说明这是一个普通通道宽度
  5. 把这些宽度依次存进通道数组

对应核心代码逻辑可以概括成:

c 复制代码
width_us = 当前捕获值 - 上次捕获值;

if (width_us >= 4000) {
    说明一帧结束;
    发布上一帧;
    通道索引清零;
} else {
    这是一个正常通道;
    依次写入 channels[];
}

5. 为什么同步阈值是 4000us

代码里这一句很关键:

c 复制代码
if (width_us >= 4000UL)

这不是随便拍脑袋写的,而是来自 PPM 帧结构本身。

通常遥控通道宽度在:

  • 最小约 1000us
  • 中位约 1500us
  • 最大约 2000us

而帧同步间隔会明显更长,常见在:

  • 4000us
  • 5000us
  • 甚至更高

所以把阈值放在 4000us 的好处是:

  • 不会把正常通道错判为同步
  • 同步段又足够容易识别
  • 算法简单,不需要更复杂的状态机

为什么不是 2500us3000us

  • 因为一些抖动较大的接收机、异常波形或者边沿测量误差,可能让正常通道偶发变宽
  • 阈值太低,容易误切帧

为什么不是 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:已经完成、可供主循环读取的结果

这是非常重要的设计。

如果你一边在中断里写数组,一边在主循环里读同一个数组,就会出现:

  • 主循环读到一半
  • 中断又改了后半段
  • 最终得到"半帧旧数据 + 半帧新数据"的脏数据

本工程的做法是:

  1. 中断期间先把新通道写入 s_working_channels[]
  2. 一旦碰到同步间隔,说明一帧完整了
  3. memcpy() 整体复制到 s_frame.channels
  4. 置位 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 数据流在本工程中的路径是:

  1. PA7 收到 PPM
  2. TIM3_CH2 发生输入捕获
  3. TIM3_IRQHandler()HAL_TIM_IRQHandler(),见 Core/Src/stm32f1xx_it.c
  4. HAL 进入 HAL_TIM_IC_CaptureCallback(),见 Core/Src/main.c
  5. 回调里调用 PPM_OnCapture(HAL_TIM_ReadCapturedValue(...))
  6. PPM_OnCapture() 完成解码
  7. 主循环调用 PPM_GetFrame() 取走最新一帧

这种分层为什么合理:

  • 中断入口只负责转发,不堆业务逻辑
  • 协议解析集中在 App/ppm.c
  • 主循环只负责消费结果

模块边界清楚,后续维护更轻松。

10. 主循环为什么不直接在中断里用 PPM 控制电机

当前工程在 Core/Src/main.c 里这样做:

  • PPM_GetFrame()
  • 再更新时间戳 g_ppm_last_ms
  • 再根据优先级选择当前控制源

为什么不在中断里直接改电机目标值?

原因有四个:

10.1 中断里要尽量短

PPM 输入频繁,如果中断里做太多事:

  • 会拉高中断占用
  • 增加系统抖动
  • 影响其他外设响应

10.2 输入与控制要解耦

PPM 只是输入源之一,本工程还有:

  • SBUS
  • PX4 MAVLink

把输入先解码成统一通道,再在主循环统一仲裁,更容易扩展。

10.3 便于做超时和优先级管理

主循环里有明确逻辑:

  • PX4 优先
  • 其次 SBUS
  • 最后 PPM
  • 超时就失效

这类多源仲裁放在主循环写最自然。

10.4 便于调试

你可以单独验证:

  • 中断有没有收到
  • 协议有没有解码成功
  • 主循环有没有取到新帧
  • 控制仲裁是否正确

如果全部塞进中断,很难定位问题。

11. 如何从零实现一个类似的 PPM 解析器

如果你要自己做,推荐按下面步骤来。

11.1 先把定时器计到 1us

核心目标不是"某个神奇分频值",而是:

  • 最终每个计数单位最好能直接对应 1us

这样所有阈值都能直接写成:

  • 1000
  • 1500
  • 2000
  • 4000

可读性极高。

11.2 选择一个固定边沿

本工程选了 FALLING。为什么可以?

  • 因为这里测的是相邻同类边沿之间的间隔
  • 只要始终使用同一类边沿,宽度定义就稳定

不要一会儿测上升沿,一会儿测下降沿,否则数据基准会乱。

11.3 在回调里只做一件事:算时间差

中断回调应该只做这些:

  1. 读捕获寄存器
  2. delta
  3. 判断是不是同步间隔
  4. 存通道

不要在这里做复杂滤波、打印日志、控制输出。

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 这样写,是因为它同时满足了几个目标:

  1. 精度够:TIM3 输入捕获 + 1us 计数分辨率
  2. 实现简单:只按边沿间隔解码,不做复杂状态机
  3. 稳定性够:处理了定时器回绕,使用完整帧发布机制
  4. 易维护:中断、协议解析、主循环消费职责分离
  5. 易扩展:后续可以继续接入更多控制源,而 PPM 模块本身不需要大改

如果你以后要升级这个模块,优先考虑的方向通常是:

  • 增加输入滤波
  • 增加通道数
  • 对异常宽度做丢弃策略
  • 增加统计信息,便于调试链路质量

但作为当前工程的实用实现,这套写法已经足够合理,而且结构是对的。

15. 实战联调流程(建议按这个顺序)

如果你现场调 PPM,建议严格按下面顺序,不要一上来就看主循环控制效果。

  1. 先确认硬件电平和地线共地,示波器看 PA7 上是否有稳定脉冲列。
  2. 确认 TIM3 真的启动了输入捕获中断,检查 Core/Src/main.c 是否执行到。
  3. HAL_TIM_IC_CaptureCallback() 里打一个轻量计数器,确认中断频率正常。
  4. PPM_OnCapture() 里临时观察 width_us,验证是否大致落在 1000~2000us>=4000us 两类。
  5. 再看 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;
  }
}

这个骨架保留了三个关键点:

  1. 回绕处理
  2. 同步间隔分帧
  3. working buffer + ready 发布
相关推荐
czhaii3 小时前
基于Arm Cortex-M7内核GD32H7
单片机·嵌入式硬件
番茄灭世神3 小时前
MCU开发常见软件BUG总结(持续更新)
c语言·stm32·单片机·嵌入式·gd32
wanghanjiett3 小时前
双轮平衡车建模及控制 2 PID控制原理与调参
嵌入式硬件·控制算法
EVERSPIN3 小时前
SQPI PSRAM为单片机提供RAM扩展方案
单片机·嵌入式硬件·psram·sqpi psram
Ar-Sr-Na3 小时前
STM32现代化AI开发指南-VSCode环境配置(macOS)
c语言·人工智能·vscode·stm32·嵌入式硬件·硬件工程
进击的小头4 小时前
第6篇:嵌入式芯片算力核心来源:多级流水线架构与指令并行机制详解
单片机·嵌入式硬件·架构
jacklood4 小时前
煤矿用甲烷报警仪的性能试验具体方法
单片机·嵌入式硬件·煤矿电子
不做无法实现的梦~4 小时前
px4仿真和示例运行
单片机·嵌入式硬件
世微 如初4 小时前
AP5125 宽压大功率 LED 恒流驱动器:技术参数与应用设计指南
stm32·单片机·嵌入式硬件