4、PX4 PWM输出驱动框架:uORB消息与PWM输出的映射关系、输出驱动层代码结构

好,咱们今天聊点实在的。PWM输出驱动,说白了就是让飞控板上的电机或者舵机动起来。你想想看,飞控大脑(比如位置控制器)算出来一个油门值,比如0.5,这个值怎么变成电机上实实在在的1500us脉宽?中间这一层,就是咱们今天要讲的输出驱动框架。

我个人习惯把PX4的PWM输出分成三层看:上层是uORB消息层,中间是输出驱动抽象层,底层是硬件寄存器操作层。今天咱们重点聊前两层——消息怎么映射,代码怎么组织。

4.1 uORB消息与PWM输出的映射关系

先问一个问题:飞控里谁在发PWM指令?答案是混控器(Mixer)。混控器从uORB总线订阅actuator_controls消息,然后根据混控矩阵算出每个通道的输出值,再发布成actuator_outputs消息。驱动层就盯着这个actuator_outputs消息,把它转成硬件PWM信号。

我截个关键的消息结构给你看:

// 来自 uORB/topics/actuator_outputs.h
struct actuator_outputs_s {
    uint64_t timestamp;        // 时间戳
    uint32_t noutputs;         // 有效输出通道数
    float output[ACTUATOR_OUTPUTS_MAX]; // 输出值,范围0~1
};

这里有个坑,我当年踩过。output数组的值是归一化的,范围0~1。但电机需要的PWM脉宽通常是1000us~2000us。这个映射关系在哪做?

嗯,答案是在驱动层。驱动里有一个缩放因子,把0~1映射到实际脉宽。比如:

pwm_value = (int)(1000 + output * 1000);  // 0→1000us, 1→2000us

但注意,不同执行机构映射不一样。舵机可能是500~2500us,电调可能是1100~1900us。所以驱动里通常会有一个参数表,比如PWM_MINPWM_MAX,允许你在启动时配置。

核心映射流程:

  1. 混控器订阅 actuator_controls(来自控制器的期望力矩)
  2. 混控器计算后发布 actuator_outputs(归一化0~1)
  3. 输出驱动订阅 actuator_outputs,按参数映射为PWM脉宽
  4. 驱动调用硬件接口输出PWM波形

你可能会问:为什么中间要插一层actuator_outputs,而不是直接让混控器输出PWM?

我个人觉得,这是为了解耦。混控器只关心数学计算,不关心硬件是PWM还是DShot还是CAN。驱动层只关心怎么把0~1变成物理信号。各司其职,好维护。

4.2 输出驱动层代码结构

好,咱们看看代码怎么组织的。PX4的输出驱动在src/drivers/目录下,按硬件平台分。比如:

  • src/drivers/dshot/ — DShot协议驱动
  • src/drivers/pwm_out/ — 普通PWM输出
  • src/drivers/px4io/ — 协处理器IO扩展

每个驱动里,核心文件就那么几个。我以pwm_out为例,给你拆开看看:

src/drivers/pwm_out/
├── CMakeLists.txt
├── PwmOut.cpp          // 主驱动类
├── PwmOut.hpp          // 头文件
└── pwm_out_params.c    // 参数定义

PwmOut.cpp 里干了三件事:

  1. 初始化:打开定时器,配置PWM模式,设置频率和极性
  2. 订阅消息:在Run()函数里等待actuator_outputs消息
  3. 输出更新:收到消息后,遍历每个通道,计算脉宽,写入定时器比较寄存器

我摘一段核心代码,你感受一下:

void PwmOut::Run()
{
    // 阻塞等待新消息
    if (_actuator_outputs_sub.update(&_actuator_outputs)) {
        // 遍历所有有效输出通道
        for (unsigned i = 0; i < _actuator_outputs.noutputs; i++) {
            // 归一化值转PWM脉宽
            float value = _actuator_outputs.output[i];
            uint16_t pwm = _pwm_min + (uint16_t)(value * (_pwm_max - _pwm_min));
            // 写入定时器比较寄存器
            up_pwm_servo_set(i, pwm);
        }
    }
}

注意那个up_pwm_servo_set()函数,它是硬件抽象层(HAL)的接口。不同芯片(STM32、Kinetis、Raspberry Pi)的实现不一样,但接口统一。这就是PX4驱动设计的精髓——平台无关性

我的小技巧: 调试PWM输出时,别一上来就接电机。先用示波器看波形,确认频率和脉宽对不对。我曾经有一次把频率设成50Hz(舵机标准),结果电调不认,折腾了半天才发现电调要400Hz。

4.3 定时器与PWM通道的绑定

说到硬件,就绕不开定时器。每个PWM通道背后都是一个定时器通道。比如STM32F4,TIM1的CH1~CH4可以输出4路PWM。驱动里怎么知道哪个定时器对应哪个通道?

答案在板级配置文件里。以boards/px4/fmu-v5为例:

// board_config.h
#define PWM_TIMER1          TIM1
#define PWM_TIMER1_CHANNELS (1 << 0 | 1 << 1 | 1 << 2 | 1 << 3)
#define PWM_TIMER2          TIM8
#define PWM_TIMER2_CHANNELS (1 << 0 | 1 << 1)

这样驱动初始化时,就知道:TIM1的CH0~CH3是前4个输出通道,TIM8的CH0~CH1是后2个。这种设计的好处是,换一块板子,只需要改board_config.h,驱动代码不用动。

注意: 不同定时器的时钟源可能不同,导致PWM频率不一致。比如TIM1挂在APB2上,TIM8挂在APB1上,频率可能差一倍。如果你要求所有通道频率一致,必须选同一个定时器,或者用同一个时钟源。

4.4 输出驱动层的整体架构图

说了这么多,我画张图帮你理清关系。这张图展示了从uORB消息到硬件PWM的完整链路:

uORB消息层 actuator_controls actuator_outputs 混控器 (Mixer) 订阅 controls → 计算 发布 outputs 输出驱动层 订阅 outputs 映射 0~1 → 脉宽 写入定时器 硬件层 定时器 → PWM通道 GPIO → 电机/舵机 软件 硬件 抽象边界

这张图从左到右展示了数据流。你注意看,混控器是承上启下的关键节点。它把控制器的期望(力矩、推力)转成了执行机构能理解的输出值。而驱动层只负责最后一步——把0~1变成实实在在的PWM波。

4.5 实际项目中的避坑指南

最后,分享几个我踩过的坑,希望能帮你省点时间:

  • 通道顺序搞反:混控器输出的通道0对应物理PWM通道几?不同板子定义不一样。我建议你开机后先打印actuator_outputs的值,再用示波器逐个通道确认。
  • 频率不匹配:电调通常要400Hz,舵机要50Hz。如果你混用,必须分两组定时器。我曾经图省事,所有通道用一个频率,结果舵机抖得跟筛子似的。
  • 启动时序:电调上电后需要先发一个低油门信号(比如1000us)持续几秒,才能进入正常工作模式。驱动初始化时别忘了这个“校准”过程。

调试小技巧:PwmOut::Run()里加一个计数器,每输出一次就打印。这样你能看到输出频率是否稳定。如果发现丢帧,八成是uORB消息队列满了,调大ORB_QUEUE_LENGTH试试。

好了,关于PWM输出驱动框架,核心就是这些。记住三个关键词:uORB消息、混控器、定时器映射。搞懂这三者的关系,你就能自己写一个输出驱动了。


公众号:蓝海资料掘金营,微信deep3321