boards/micoair/h743-v2 当例子,这一篇先说 PX4 的硬件抽象层(HAL)长什么样,再顺着"加一路 PWM"这个最小需求,看改一块飞控到底要动哪些文件。

1. PX4 的代码组织(三层视角)

第一次打开 PX4 仓库容易迷路,其实跟硬件相关的就三个根目录:

目录角色改它的频率
boards/<vendor>/<board>/板级:每块飞控一份加新板必改
platforms/HAL:把外设抽象成可复用的 C++ 类加通用能力才动
src/drivers/src/modules/设备驱动与飞行控制模块加新传感器/新算法时改

硬件的真相都在板级目录里。HAL 看到的 io_timersi2c_hw_description,都是板级文件用 constexpr 表填出来的,HAL 自己不写死。

1.1 HAL 内部的分层

platforms/nuttx/src/px4/(NuttX 是 PX4 跑的 RTOS,所有 STM32 飞控都在它上面):

platforms/nuttx/src/px4/
├── common/                       # 跨架构共用:board_ctrl, hrt, cpuload, console_buffer
├── stm/
│   ├── stm32_common/             # STM32 跨系列共用
│   │   ├── dshot/                # DSHOT 协议驱动
│   │   ├── hrt/                  # 高分辨率定时器
│   │   ├── io_pins/              # PWM/输入捕获/触发(io_timer.c, pwm_servo.c)
│   │   ├── led_pwm/              # LED PWM
│   │   ├── spi/                  # SPI 总线抽象
│   │   ├── tone_alarm/           # 蜂鸣器
│   │   └── ...
│   ├── stm32f4/  stm32f7/  stm32h7/   # 系列特定(目前主要是 ADC 寄存器布局)
│   └── stm32_common/include/px4_arch/
│       ├── io_timer.h            # MAX_IO_TIMERS / MAX_TIMER_IO_CHANNELS 宏
│       ├── io_timer_hw_description.h
│       ├── dshot.h
│       ├── spi_hw_description.h
│       ├── i2c_hw_description.h
│       └── ...

几个关键点:

  • io_timer 不用 STM32 寄存器名,靠 Timer::Timer1 这种枚举和 initIOTimer / initIOTimerChannel 两个 constexpr 工厂,把硬件信息写到 io_timers[]timer_io_channels[] 两个数组里。io_timer.c 启动时读这两张表,再去配 TIMx->CCMRCCER、DMA、中断向量。

  • MAX_IO_TIMERSMAX_TIMER_IO_CHANNELS 这两个宏是 HAL 留给板级的容量口子,定义在 io_timer.h:48-55:

    #ifdef BOARD_NUM_IO_TIMERS
    #define MAX_IO_TIMERS     BOARD_NUM_IO_TIMERS
    #else
    #define MAX_IO_TIMERS     2
    #endif
    #if DIRECT_PWM_OUTPUT_CHANNELS > 8
    #define MAX_TIMER_IO_CHANNELS  DIRECT_PWM_OUTPUT_CHANNELS
    #else
    #define MAX_TIMER_IO_CHANNELS  8
    #endif
    

    HAL 不自己定这两个数,直接读 board_config.h 里的 BOARD_NUM_IO_TIMERSDIRECT_PWM_OUTPUT_CHANNELS。把板级宏改大,io_timer.c 里的 for (int i = 0; i < MAX_TIMER_IO_CHANNELS; i++) 就跟着扩,HAL 一行不用动。

  • src/drivers/pwm_out/PWMOut.cpp 也用 DIRECT_PWM_OUTPUT_CHANNELS 决定 _pwm_masksetMaxNumOutputs,上层(mixercontrol_allocator、uORB 的 actuator_outputs)在编译期就拿到 11 个槽位。

一句话:PX4 的 HAL 几乎不写死硬件,只给一组 constexpr 工厂,让板级用表格把拓扑交出来。

2. 新增一块飞控要写哪些文件

boards/micoair/h743-v2 这个目录下的文件,就是一块飞控要准备的全部家当:

boards/micoair/h743-v2/
├── default.px4board              # 普通固件要启用的 Kconfig 模块
├── bootloader.px4board            # Bootloader 固件 Kconfig(更精简)
├── firmware.prototype            # bin 文件命名/版本原型
├── extras/                        # 额外烧录资产(如出厂 bootloader bin)
│
├── init/
│   ├── rc.board_defaults          # 板级默认参数(param set-default)
│   ├── rc.board_sensors           # 启动传感器驱动(bmi088, spl06, ...)
│   └── rc.board_extras            # 板级额外动作(OSD、外部外设)
│
├── nuttx-config/
│   ├── include/
│   │   ├── board.h                # 时钟/SDIO/RCC 等 NuttX 通用板级头
│   │   └── board_dma_map.h        # DMA 通道映射(给 SPI/UART 选 DMA 流)
│   ├── nsh/defconfig              # 应用固件的 NuttX 内核配置
│   ├── bootloader/defconfig       # Bootloader 的 NuttX 配置
│   └── scripts/
│       ├── script.ld              # 应用固件链接脚本
│       └── bootloader_script.ld   # Bootloader 链接脚本
│
└── src/
    ├── CMakeLists.txt             # 把这些 .c/.cpp 编进 drivers_board 库
    ├── board_config.h             # 板级核心定义:IO 口、ADC、PWM 数、串口映射
    ├── hw_config.h                # Bootloader 用的 VID/PID/CPU 型号等
    ├── init.c                     # 早期初始化(board_app_initialize)
    ├── bootloader_main.c          # Bootloader 入口
    ├── led.c                      # 板级 LED 驱动(继而注册到 drv_led)
    ├── usb.c                      # USB 识别/OTG 初始化
    ├── sdio.c                     # SD 卡底层
    ├── i2c.cpp                    # I2C 总线注册(板级 bus 列表)
    ├── spi.cpp                    # SPI 总线注册(含 DMA、片选)
    └── timer_config.cpp           # PWM/IO Timer 表(io_timers + timer_io_channels)

后面实操只动带 ★ 的两个文件。

2.1 几类文件的分工

按职责归纳一下,加板就是这五类:

  1. 构建/裁剪(Kconfig 层)
    • default.px4board / bootloader.px4board:逐行 CONFIG_xxx=y 决定启用哪些 drivers/modules,本质是 make menuconfig 的快照。
  2. 系统集成脚本(RC 层)
    • init/rc.board_*:NuttX 启动后由 /etc/init.d/rcS 依次调用,负责用命令行拉起 sensor、设默认 param。
  3. NuttX 内核配置
    • nuttx-config/nsh/defconfig:管 CONFIG_STM32H7_TIM4=y 这类 NuttX 自己的 Kconfig。PWM 用到的定时器必须在这里打开,NuttX 才会给它分配中断向量和时钟门。
  4. 板级 C/C++ 代码
    • src/board_config.h:宏王国,GPIO 复用、ADC 通道、PWM 通道数、/dev/ttyS<n> 映射都在这里。
    • src/timer_config.cpp:整篇的戏眼,后面单独讲。
    • src/init.c:stm32_boardinitialize 在 NuttX 起来后第一个被调用,负责复位 PWM、把 GPIO 配成默认状态、初始化 SPI/USB。
  5. 链接与烧录
    • firmware.prototypeextras/*.binscripts/*.ld:管 Flash 分区和 .px4 产物。

加一块跟现有板子很像的飞控,直接 cp -r boards/<vendor>/<existing> boards/<vendor>/<new>,然后按这个清单 sed/改名,比从头写快得多。boards/micoair/ 下就有 h743 / h743-aio / h743-lite / h743-v2 四兄弟可以参考。

3. timer_config.cpp:把引脚变成通道的唯一入口

boards/micoair/h743-v2/src/timer_config.cpp 完整内容就几十行:

// io_timers[] 列出被 PX4 拿来当 IO 用的定时器。Timer4 选了 DMA1,DSHOT 之类的高速协议就靠这条 DMA。
constexpr io_timers_t io_timers[MAX_IO_TIMERS] = {
    initIOTimer(Timer::Timer1, DMA{DMA::Index1}),
    initIOTimer(Timer::Timer3, DMA{DMA::Index1}),
    initIOTimer(Timer::Timer4, DMA{DMA::Index1}),
    initIOTimer(Timer::Timer15),
};

// timer_io_channels[] 写每个通道对应的 (定时器, Channel, GPIO),数组下标 = pwm_out 的通道号,从 0 起。
// initIOTimerChannel 是 constexpr 工厂(io_timer_hw_description.h:42),做三件事:
//   1) 编译期断言:这个 pin 能不能映射到这个 timer/channel(手抖写错直接编译失败)
//   2) 根据 Channel1~4 算 CCR 偏移和中断 mask
//   3) 在 io_timers[] 里反查 timer 的 index,运行时 io_timer.c 拿 timer_index+channel 定位寄存器
// 顺序必须按"同一 timer 的通道连续成组"排列,否则被 initIOTimerChannelMapping 里的 constexpr_assert 拍死("Timers are not grouped together")。
constexpr timer_io_channels_t timer_io_channels[MAX_TIMER_IO_CHANNELS] = {
    initIOTimerChannel(io_timers, {Timer::Timer1, Timer::Channel4}, {GPIO::PortE, GPIO::Pin14}),  // PWM1  -> PE14 -> TIM1_CH4
    initIOTimerChannel(io_timers, {Timer::Timer1, Timer::Channel3}, {GPIO::PortE, GPIO::Pin13}),  // PWM2
    initIOTimerChannel(io_timers, {Timer::Timer1, Timer::Channel2}, {GPIO::PortE, GPIO::Pin11}),  // PWM3
    initIOTimerChannel(io_timers, {Timer::Timer1, Timer::Channel1}, {GPIO::PortE, GPIO::Pin9}),   // PWM4
    initIOTimerChannel(io_timers, {Timer::Timer3, Timer::Channel4}, {GPIO::PortB, GPIO::Pin1}),   // PWM5
    initIOTimerChannel(io_timers, {Timer::Timer3, Timer::Channel3}, {GPIO::PortB, GPIO::Pin0}),   // PWM6
    initIOTimerChannel(io_timers, {Timer::Timer4, Timer::Channel1}, {GPIO::PortD, GPIO::Pin12}),  // PWM7
    initIOTimerChannel(io_timers, {Timer::Timer4, Timer::Channel2}, {GPIO::PortD, GPIO::Pin13}),  // PWM8
    initIOTimerChannel(io_timers, {Timer::Timer15, Timer::Channel1}, {GPIO::PortE, GPIO::Pin5}),  // PWM9
    initIOTimerChannel(io_timers, {Timer::Timer15, Timer::Channel2}, {GPIO::PortE, GPIO::Pin6}),  // PWM10
};

// 把上面两张表压成一个查找表,io_timer.c 在 io_timer_channel_init 里查它。
constexpr io_timers_channel_mapping_t io_timers_channel_mapping =
    initIOTimerChannelMapping(io_timers, timer_io_channels);

加一路 PWM,只在数组里多放一行就行,HAL  `pwm_out` 都不用动。

# 4. Case Study:给 micoair/h743-v2 加一路 PWM11(PD14 / TIM4_CH3)

## 4.0 背景:这条 PWM 通道是怎么"丢"的

`h743-v2` 板上有一个 4-pin SH1.0 接口,丝印一般标作 **LED**。这个接口是板厂按 ArduPilot 的习惯布的:

- **PD14 / TIM4_CH3**:ArduPilot 把它当作 PWM11, LED(单色高亮或简单调光, WS2812)控制信号。
- **PD15**:ArduPilot 当作 BUZZER OUTPUT,给有源蜂鸣器或者自激蜂鸣器的驱动脚。

对照 ArduPilot 源码(它的 `MicoAir743-v2` 飞控定义)可以确认 PWM11 = PD14 = TIM4_CH3,这个对应关系是定死的, PX4 那边的 `timer_io_channels` 数组是不是登记了它无关。

问题在 PX4 这一侧。`boards/micoair/h743-v2/src/board_config.h:108-109`  PD15 拿来当 `GPIO_TONE_ALARM_IDLE` / `GPIO_TONE_ALARM_GPIO`(tunecontrol ),所以 buzzer 这边是活的;但同一根 SH1.0 排线上的 PD14, `timer_config.cpp` **从来没被注册过**——`pwm_out` 完全看不到它,这块 LED 控制脚在 PX4 上是死的。

对比同厂的 `boards/micoair/h743`( v2 旧板)就清楚了,它的 `timer_config.cpp:49-52`  Timer4 的四个通道全开了:

```cpp
initIOTimerChannel(io_timers, {Timer::Timer4, Timer::Channel1}, {GPIO::PortD, GPIO::Pin12}),  // PWM7  -> PD12
initIOTimerChannel(io_timers, {Timer::Timer4, Timer::Channel2}, {GPIO::PortD, GPIO::Pin13}),  // PWM8  -> PD13
initIOTimerChannel(io_timers, {Timer::Timer4, Timer::Channel3}, {GPIO::PortD, GPIO::Pin14}),  // PWM9  -> PD14
initIOTimerChannel(io_timers, {Timer::Timer4, Timer::Channel4}, {GPIO::PortD, GPIO::Pin15}),  // PWM10 -> PD15

h743-v2,板厂把 CH4(PD15)挪去给 buzzer 用,CH1/CH2 留下,CH3(PD14)直接没补——所以这版"少了一路 PWM"不是硬件问题,是出厂固件没把它登记上去。

本节要做的就是把这条线从 ArduPilot 风格的"LED 接口"还原回 PX4 这边的可用 PWM11,顺手把 PD15 那路 buzzer 的来龙去脉也讲清楚,免得后面有人想用 tone_alarm 的时候误把它当 PWM 通道。

需求很具体:h743-v2 现在只 10 路 PWM(PWM1~PWM10),PD14 正是 TIM4_CH3 的备用脚。Timer4 也已经登记在 io_timers[] 里,所以这次就是"一行 + 一宏"的事。

4.1 改之前先确认

手册上 PD14 的 AF2 就是 TIM4_CH3,nuttx-config/nsh/defconfig 里也已经开着 CONFIG_STM32H7_TIM4=y,所以 NuttX 配置和 script.ld 都不用动。新手容易漏的是:CONFIG_STM32H7_TIM4 一旦没开,Timer4 在编译期会触发 nuttx_config_timer_enabled = false(io_timer_hw_description.h),initIOTimer(Timer::Timer4, ...) 照样编过,但运行时定时器的时钟门不会被打开,看上去就是"配了但没输出"。

4.2 改动一:board_config.h 把通道数从 10 调到 11

boards/micoair/h743-v2/src/board_config.h 第 92 行附近:

 /* PWM
  */
-#define DIRECT_PWM_OUTPUT_CHANNELS   10
+#define DIRECT_PWM_OUTPUT_CHANNELS   11

这一行同时影响三处:

  • HAL 的 MAX_TIMER_IO_CHANNELS(io_timer.h:53-56):if DIRECT_PWM_OUTPUT_CHANNELS > 8 分支让它等于 DIRECT_PWM_OUTPUT_CHANNELS,也就是 11。io_timer.cio_timer_channel_stats[MAX_TIMER_IO_CHANNELS] 等数组跟着扩到 11。
  • src/drivers/pwm_out/PWMOut.cpp:43-44:
    _pwm_mask = ((1u << DIRECT_PWM_OUTPUT_CHANNELS) - 1);
    _mixing_output.setMaxNumOutputs(DIRECT_PWM_OUTPUT_CHANNELS);
    
    _pwm_mask 决定 pwm_out 公告给上层(mixercontrol_allocator)的可用通道位图,11 路就是 0b11111111111
  • init.cboard_on_reset 循环 for (int i = 0; i < DIRECT_PWM_OUTPUT_CHANNELS; ++i),也跟着走 11 次,保证每次复位都会把 11 个脚拉成输入态(防止 ESC 在软复位瞬间被点亮)。

不要顺手改 BOARD_NUM_IO_TIMERS(当前是 5,实际只用到 4 个 timer)。MAX_IO_TIMERS 统计的是被 PX4 接管为 IO 的定时器数量,PWM11 复用了已有的 Timer4,不用新增 timer 实体,这个宏保持原值。

4.3 改动二:timer_config.cpp 把 PD14 登记成第 11 个通道

**不是简单加一行。**新行必须插在 Timer4 那组的末尾(Timer4_CH2 之后、Timer15_CH1 之前),不能 append 到数组尾巴,否则会被 initIOTimerChannelMapping 里的 constexpr_assert 拍死,错误信息大概是:

timer_config.cpp:58:27: error: call to non-constexpr function ...
constexpr_assert(timer_io_channels_conf[channel - 1].timer_index == i, "Timers are not grouped together");

PX4 强制要求"同一 timer 的所有通道在数组中连续成组",运行时 io_timer.cio_timers_channel_mapping 查找时也按这个前提做按 timer 分块处理。Timer4 现在有 3 个通道(PD12/PD13/PD14),必须挨着放,中间不能塞 Timer15 进去。

正确的 diff(注意 Timer15 两条跟着往下挪了两行):

 constexpr timer_io_channels_t timer_io_channels[MAX_TIMER_IO_CHANNELS] = {
     initIOTimerChannel(io_timers, {Timer::Timer1, Timer::Channel4}, {GPIO::PortE, GPIO::Pin14}),  // 通道 0  PE14 -> TIM1_CH4 (PWM1 丝印)
     initIOTimerChannel(io_timers, {Timer::Timer1, Timer::Channel3}, {GPIO::PortE, GPIO::Pin13}),  // 通道 1  PE13 -> TIM1_CH3 (PWM2 丝印)
     initIOTimerChannel(io_timers, {Timer::Timer1, Timer::Channel2}, {GPIO::PortE, GPIO::Pin11}),  // 通道 2  PE11 -> TIM1_CH2 (PWM3 丝印)
     initIOTimerChannel(io_timers, {Timer::Timer1, Timer::Channel1}, {GPIO::PortE, GPIO::Pin9}),   // 通道 3  PE9  -> TIM1_CH1 (PWM4 丝印)
     initIOTimerChannel(io_timers, {Timer::Timer3, Timer::Channel4}, {GPIO::PortB, GPIO::Pin1}),   // 通道 4  PB1  -> TIM3_CH4 (PWM5 丝印)
     initIOTimerChannel(io_timers, {Timer::Timer3, Timer::Channel3}, {GPIO::PortB, GPIO::Pin0}),   // 通道 5  PB0  -> TIM3_CH3 (PWM6 丝印)
     initIOTimerChannel(io_timers, {Timer::Timer4, Timer::Channel1}, {GPIO::PortD, GPIO::Pin12}),  // 通道 6  PD12 -> TIM4_CH1 (PWM7 丝印)
     initIOTimerChannel(io_timers, {Timer::Timer4, Timer::Channel2}, {GPIO::PortD, GPIO::Pin13}),  // 通道 7  PD13 -> TIM4_CH2 (PWM8 丝印)
-    initIOTimerChannel(io_timers, {Timer::Timer15, Timer::Channel1}, {GPIO::PortE, GPIO::Pin5}),  // 通道 8  PE5  -> TIM15_CH1 (PWM9 丝印)
-    initIOTimerChannel(io_timers, {Timer::Timer15, Timer::Channel2}, {GPIO::PortE, GPIO::Pin6}),  // 通道 9  PE6  -> TIM15_CH2 (PWM10 丝印)
+    initIOTimerChannel(io_timers, {Timer::Timer4, Timer::Channel3}, {GPIO::PortD, GPIO::Pin14}),  // 通道 8  PD14 -> TIM4_CH3 (PWM11 丝印)
+    initIOTimerChannel(io_timers, {Timer::Timer15, Timer::Channel1}, {GPIO::PortE, GPIO::Pin5}),  // 通道 9  PE5  -> TIM15_CH1 (PWM9 丝印)
+    initIOTimerChannel(io_timers, {Timer::Timer15, Timer::Channel2}, {GPIO::PortE, GPIO::Pin6}),  // 通道 10 PE6  -> TIM15_CH2 (PWM10 丝印)
 };

改完之后 timer_config.cpp 完整长这样(直接 copy 走就行):

#include <px4_arch/io_timer_hw_description.h>

constexpr io_timers_t io_timers[MAX_IO_TIMERS] = {
    initIOTimer(Timer::Timer1, DMA{DMA::Index1}),
    initIOTimer(Timer::Timer3, DMA{DMA::Index1}),
    initIOTimer(Timer::Timer4, DMA{DMA::Index1}),
    initIOTimer(Timer::Timer15),
};

constexpr timer_io_channels_t timer_io_channels[MAX_TIMER_IO_CHANNELS] = {
    initIOTimerChannel(io_timers, {Timer::Timer1, Timer::Channel4}, {GPIO::PortE, GPIO::Pin14}),  // PWM1  PE14 TIM1_CH4
    initIOTimerChannel(io_timers, {Timer::Timer1, Timer::Channel3}, {GPIO::PortE, GPIO::Pin13}),  // PWM2  PE13 TIM1_CH3
    initIOTimerChannel(io_timers, {Timer::Timer1, Timer::Channel2}, {GPIO::PortE, GPIO::Pin11}),  // PWM3  PE11 TIM1_CH2
    initIOTimerChannel(io_timers, {Timer::Timer1, Timer::Channel1}, {GPIO::PortE, GPIO::Pin9}),   // PWM4  PE9  TIM1_CH1
    initIOTimerChannel(io_timers, {Timer::Timer3, Timer::Channel4}, {GPIO::PortB, GPIO::Pin1}),   // PWM5  PB1  TIM3_CH4
    initIOTimerChannel(io_timers, {Timer::Timer3, Timer::Channel3}, {GPIO::PortB, GPIO::Pin0}),   // PWM6  PB0  TIM3_CH3
    initIOTimerChannel(io_timers, {Timer::Timer4, Timer::Channel1}, {GPIO::PortD, GPIO::Pin12}),  // PWM7  PD12 TIM4_CH1
    initIOTimerChannel(io_timers, {Timer::Timer4, Timer::Channel2}, {GPIO::PortD, GPIO::Pin13}),  // PWM8  PD13 TIM4_CH2
    initIOTimerChannel(io_timers, {Timer::Timer4, Timer::Channel3}, {GPIO::PortD, GPIO::Pin14}),  // PWM11 PD14 TIM4_CH3   ← 新增这一行
    initIOTimerChannel(io_timers, {Timer::Timer15, Timer::Channel1}, {GPIO::PortE, GPIO::Pin5}),  // PWM9  PE5  TIM15_CH1
    initIOTimerChannel(io_timers, {Timer::Timer15, Timer::Channel2}, {GPIO::PortE, GPIO::Pin6}),  // PWM10 PE6  TIM15_CH2
};

constexpr io_timers_channel_mapping_t io_timers_channel_mapping =
    initIOTimerChannelMapping(io_timers, timer_io_channels);

几个需要记住的点:

  • 数组下标 ≠ 丝印编号。因为新行插在了 Timer4 组末尾(数组 index 8),Timer15 的两条跟着往下挪到 index 9、index 10。所以丝印 PWM11 (PD14) 在 pwm_out 里其实是 channel 8,不是 channel 10
  • 丝印 PWM9 / PWM10 没动,但它们的 pwm_out 通道号从原来的 8/9 变成了 9/10。后面 mixer 配置文件、QGroundControl 的 servo 映射、actuator test 用的都是丝印号,这条不影响,但调试时如果按 “channel N” 报错,要看清楚是丝印号还是数组下标。
  • 同一 Timer4CH1/CH2/CH3 现在共享 CR1/PSC/DMA,三个通道的 PWM 周期严格同源,相位差最多一两个 tick(跟之前 CH1/CH2 共用的性质一样,只是多了一个兄弟)。
  • constexpr 工厂会校验 {Timer4, Channel3, PD14} 在 STM32H7 复用表里合不合法。比如不小心写成 PA7(它不映射到 TIM4_CH3),编译期就会在 Timer::Channel3 分支报 Timer not found(io_timer_hw_description.h:88-94)。错配在编译期就死,不会留到飞控上才炸。
  • 反过来想:如果以后要按某种业务顺序排(比如按丝印号 1~11 排),就得自己保证"同一 timer 的通道也连着",做不到的话只能牺牲丝印号和数组下标的一致性,选一个。h743-v2 这次选了"按 timer 分组优先"。

4.4 不需要改的地方(以及为什么)

路径要不要改原因
default.px4boardCONFIG_DRIVERS_PWM_OUT=y / CONFIG_DRIVERS_DSHOT=y 已经够,通道数运行时从 board_config.h
nuttx-config/nsh/defconfigCONFIG_STM32H7_TIM4=y 已开
init/rc.board_*没引入新传感器,默认参数也不用加
src/spi.cpp / src/i2c.cpp不影响总线拓扑
CMakeLists.txttimer_config.cpp 已经在 drivers_board 库列表里

要把这 11 路都切成 DSHOT 时,得再确认 DShotConfig(MIXER/几何)、DMA 流、反向,以及 pwm_servodshot 怎么复用同一个 CCR。

4.5 编译与上机验证

第一步是 clean 重建,不是洁癖。 参数元数据是 Tools/module_config/generate_actuators_metadata.pytimer_config.cpp 重新生成的,放在 build/micoair_h743-v2/src/drivers/pwm_out/ 下面;增量构建里它不会失效,PWM_MAIN_FUNC10 / DIS10 / MIN10 / MAX10 / CENT10 / FAIL10 整组 param 就不会进固件,后面 §4.6 那个"Channel 10 全 0"现象就是这条引起的。

rm -rf build/micoair_h743-v2
make micoair_h743-v2          # 走 px4 的 cmake 工具链
make micoair_h743-v2 upload   # 通过 USB DFU 烧录

烧录完开机,串口进 NSH,三步验证:

  1. 通道列表

    nsh> pwm_out status
    

    pwm_out statussrc/drivers/pwm_out/PWMOut.cppprint_status() 暴露的命令(注意是 pwm_out,不是 pwm——PX4 里没有 pwm 这个模块)。会看到 11 行 Channel 0 ~ 10,同时底部三行 Timer N: rate: 400 channels: ... 报告 Timer 2 拿到了三个通道(6/7/8),Timer 3 拿到两个(9/10)。注意 channel 编号是数组下标 0 起,不是丝印号 1 起:PWM11 丝印的 PD14 实际在 Channel 8,PWM10 丝印的 PE6 在 Channel 10。调试时按"丝印号"还是"channel N"对,心里要有数。

  2. PWM 手动测试

    nsh> actuator_test set -s 11 -v 0.5    # Servo 11 = 丝印 PWM11 (PD14),输出 0.5 = 中位
    nsh> actuator_test set -s 11 -v 0      # 释放
    

    actuator_test 走的是 src/systemcmds/actuator_test/actuator_test.cpp,不是 pwm test——后者在当前 PX4 里不存在。拿示波器或舵机测试器看 PD14 上有没有 1500 µs 的脉冲(默认值 1000~2000 µs,中位对应 v=0.5),周期 ~50 Hz。要用电机就换 -m <n>,要按功能找就 -f <n>(PWM_MAIN_FUNC11 配的是哪个功能就用哪个)。

  3. DSHOT(可选)

    nsh> dshot start
    nsh> actuator_test set -s 11 -v 0.5    # DSHOT 模式下 actuator_test 一样能驱动
    

    PD14 上接支持 DSHOT 的 ESC,actuator_test 会把 v 转成 11-bit DSHOT 帧通过 DMA 喂给 CCR3,这一步能通信就说明 Timer4_CH3 的 DMA 触发、帧时序都正常。要看 ESC 状态(转速/温度/电压)走 dshot telemetry -d /dev/ttyS<n>(哪个 UART 接 ESC telemetry 线由硬件定,看 boards/micoair/h743-v2/src/board_config.hRC_SERIAL_PORT 的注释)。

排错小贴士:pwm_out status 看到 11 行但 PD14 没输出,先 dmesg | grep -i tim4 看有没有 stm32_tim_init 失败,再看 CONFIG_STM32H7_TIM4 有没有被 menuconfig 误关。如果编译直接挂在 constexpr_assert,那八成是把 Timer4_CH3 写到 Timer15 后面去了,回到 §4.3 那一段,把它挪到 Timer4_CH2 后面。如果通道数对、Timer 也认得到,但 Channel 10 这种老通道反而全 0,八成是没 rm -rf build/micoair_h743-v2 增量构建留下的陈旧 param,跳到 §4.6 跑一遍。io_timer.c 自己不报错,出问题基本是 NuttX 那一层把 timer 整个禁掉了,或者上层 param 元数据没刷新。

4.6 Channel 10 全 0 真相(踩过的坑)

按上面 4.2 / 4.3 / 4.5 改完,没 clean 重建直接烧,串口会看到这种输出:

nsh> pwm_out status
...
Channel 0: func:   0, value: 1000, failsafe: 1000, disarmed: 1000, min: 1000, max: 2000, center: 65535
...
Channel 8:  func:   0, value: 1000, failsafe: 1000, disarmed: 1000, min: 1000, max: 2000, center: 65535
Channel 9:  func:   0, value: 1000, failsafe: 1000, disarmed: 1000, min: 1000, max: 2000, center: 65535
Channel 10: func:   0, value:    0, failsafe:    0, disarmed:    0, min:    0, max:    0, center:    0
Timer 1: rate: 400 channels: 4 5
Timer 2: rate: 400 channels: 6 7 8
Timer 3: rate: 400 channels: 9 10

Channel 0~9 都是 1000/1000/1000/1000/2000/65535(PWM 默认范围),只有 Channel 10 全 0。第一眼看上去是 “Timer15 没配 DMA → PE6 死掉了”,但其实不是。

先确认 ch10 到底是谁。 §4.3 那个 diff 把新行插在 Timer4_CH2 之后,Timer15_CH1 / CH2 跟着挪到数组下标 9 / 10。所以:

数组下标硬件丝印改前 ch改后 ch
6TIM4_CH1 / PD12PWM766
7TIM4_CH2 / PD13PWM877
8TIM4_CH3 / PD14PWM118(新增)
9TIM15_CH1 / PE5PWM989
10TIM15_CH2 / PE6PWM10910

Channel 10 = PE6 = Timer15_CH2,不是新加的 PD14pwm_out statusTimer 3: rate: 400 channels: 9 10 那一行也说明 io_timer 这一层已经把 PE5/PE6 都识别成 Timer 3(也就是 Timer15)的成员,CCR、AF、CCER 全部初始化成功——硬件是活的。

真正的死因是参数元数据陈旧。 PWMOut::print_status() 走的 MixingOutput::printStatus()(src/lib/mixer_module/mixer_module.cpp:135)从 _function_assignment[i]_disarmed_value[i]_min_value[i]_max_value[i]_center_value[i] 这几个数组里读,这些数组由 updateParams()initParamHandles() 阶段(mixer_module.cpp:115)通过 param_find("PWM_MAIN_FUNC<n>") / DIS / MIN / MAX / CENT / FAIL 一组一组的 param 查出来。

PWM_MAIN_*10 这一组是 Tools/module_config/generate_actuators_metadata.pytimer_config.cpp 数出 11 行后,才在构建时生成的。增量构建里这个 JSON 缓存没失效,param 表里就根本没有 _10 这一套;param_find 全部返回 PARAM_INVALID,MixingOutput 拿不到值,i==10 这一行的所有数组保持构造函数里的零值。结果就是 Channel 10 整行 0/0/0/0/0/0——这不是 PWM 没输出,是这条通道的元数据从来就没存在过

actualFailsafeValue(10) 那个 0 容易误读成"failsafe=0"。实际上 _failsafe_value[10] == UINT16_MAX(mixer_module.cpp:94 初始化时赋的),没人写过它,actualFailsafeValuedefault_failsafe 路径返回 0,跟"failsafe 真设成 0"不是一回事。

怎么救: rm -rf build/micoair_h743-v2 && make micoair_h743-v2,让 generate_actuators_metadata.py 重新数到 11,把 0..10 这 11 套 param 全部打出来,Channel 10 就会回到 func: 0, value: 1000, failsafe: 1000, ... 的默认状态,PWM_MAIN_FUNC11 也会出现在 param show 里。改任何跟 timer_io_channels[] 数量有关的文件,这条都要默认走一遍

顺带澄清几个常被一起怀疑的点,免得下次再走弯路:

  • “Timer15 没传 DMA{DMA::Index1} 是不是导致死?” 不是。initIOTimer 里那段 if (dma.index != DMA::Invalid) { ret.dshot.dma_* = ... } (io_timer_hw_description.h:283)只给 DShot 配 DMA 时才生效;PWM 是驱动里直接 REG(timer, ccr_offset) = value 写 CCR,不走 DMA。PWM 400Hz 跑得动就行,补不补 Timer15 的 DMA 是改 DShot 时才要碰的事。
  • “PE6 被 PD15 的 buzzer 抢了?” 没抢。board_config.h:108-109GPIO_TONE_ALARM_* 占的是 PD15,跟 PE6 没关系,AF 表里 Timer15 → AF4 → PE5 / PE6 这条路完全没被别的外设覆盖。
  • “是新增 Timer4_CH3 把谁挤掉了?” 没有。Timer4io_timers[] 里早就登记了,新加的是 timer_io_channels[] 里多一行,io_timers[] 数组大小由 BOARD_NUM_IO_TIMERS=5 决定,跟 timer_io_channels[] 无关。

5. 小结

  • PX4 的 HAL 不写死硬件,只给 constexpr 工厂;加一路 PWM/IO,等价于在板级数组里多放一行。
  • 新增一块飞控,文件清单是死的:构建层(*.px4board)、集成层(init/rc.board_*)、NuttX 内核(defconfig)、板级代码(board_config.h / timer_config.cpp / init.c)、链接脚本(*.ld)。
  • 这次给 h743-v2 加 PWM11 只动两个文件、两行:
    • board_config.h:DIRECT_PWM_OUTPUT_CHANNELS 10 → 11
    • timer_config.cpp:timer_io_channels[]Timer4_CH2 之后、Timer15_CH1 之前插入 {Timer4, Channel3, PD14}(同 timer 的通道必须连成组,否则编译期 constexpr_assert 拍死)
  • 但有三件事比这两行容易踩:
    • 必须 rm -rf build/<board>make——generate_actuators_metadata.py 是按当前 timer_io_channels[] 长度生成 PWM_MAIN_FUNC<n> 这组 param 的,增量构建里缓存不失效,新增通道的 param 就缺,pwm_out status 看上去是"新通道死",其实是元数据没生成(§4.6)。
    • 数组下标 ≠ 丝印号 ≠ pwm_out 的 channel 号initIOTimerChannelMapping 要求同 timer 通道连成组,新行一旦插进 Timer15 那块,会把 8/9 改成 9/10,mixer 文件 / QGC servo 映射 / actuator test 用的丝印号跟 pwm_out status 里的 Channel N 不再 1:1,出错时先分清你在按哪套编号。
    • Timer15 不写 DMA 不是 bug。DMA 字段只服务 DShot,纯 PWM 由 io_timer.c 直接写 CCR,Timer15 不配 DMA{DMA::Index1} 也能正常吐 400Hz。要用 DShot 时再回去补。