1. RC信号捕获的工程本质与技术选型依据

在飞控系统中,遥控接收机(RC Receiver)输出的PWM信号是姿态控制的原始输入源。其标准协议规定:每个通道输出一个周期为20ms的脉冲,脉宽在1000μs(最小油门/左极限)至2000μs(最大油门/右极限)之间线性变化,中立位置对应1500μs。这一物理层特性决定了测量精度必须达到微秒级,且需在20ms周期内完成全部6个通道的稳定采样——这对MCU的外设资源调度和中断响应提出了严苛要求。

在STM32F100系列上,早期采用 attachInterrupt() 配合 micros() 实现边沿计时的方式存在根本性缺陷:软件计时受中断延迟、上下文切换及主循环阻塞影响,实测抖动可达±15μs以上。当飞控进入高速姿态解算或PID运算密集阶段时,该误差会直接导致舵面指令跳变,引发飞行器振荡。因此,必须转向硬件级时间戳捕获方案。

STM32的输入捕获(Input Capture)模式正是为此类场景设计的专用外设功能。其核心价值在于将时间测量任务完全卸载至硬件定时器,使CPU摆脱轮询和软件计时的负担。当输入引脚电平变化时,定时器当前计数值被原子性地锁存至捕获寄存器(CCR),整个过程无需CPU干预,典型响应延迟低于1个系统时钟周期(此处为14.3ns)。这种确定性是飞控系统实时性的基石。

需要明确的是,输入捕获并非简单“测高电平宽度”。由于RC信号是单边沿调制(仅上升沿触发有效脉宽),但硬件捕获单元每次只能配置为检测单一跳变类型(上升沿或下降沿),因此必须构建双沿检测状态机:先捕获上升沿获取起始时间,再动态切换捕获极性以捕获下降沿获得结束时间。这一机制要求对定时器寄存器进行精确的时序控制,任何配置失误都将导致脉宽计算失效。

2. STM32F100定时器架构与寄存器映射原理

理解输入捕获的实现,必须深入STM32F100的定时器硬件架构。以本方案选用的TIM2为例,其本质是一个16位向上计数器,由APB1总线时钟驱动。系统时钟为72MHz,经预分频器(PSC)分频后供给定时器。为实现1μs分辨率,需将定时器时钟频率精确配置为1MHz,即设置PSC=71(因预分频值为实际分频系数减1)。

2.1 定时器寄存器地址空间布局

STM32F100的外设寄存器位于固定的内存映射地址段。TIM2的基地址为 0x4000 0000 (参考《STM32F100xx Reference Manual》Section 2.3.3)。该地址指向定时器控制寄存器1(TIM2_CR1),后续寄存器按字(4字节)递增排列:

寄存器名 偏移量 地址(Hex) 功能说明
TIM2_CR1 0x00 0x40000000 控制寄存器1:启停、计数方向、时钟选择
TIM2_CR2 0x04 0x40000004 控制寄存器2:主输出使能、同步控制
TIM2_SMCR 0x08 0x40000008 从模式控制寄存器:编码器/触发模式
TIM2_DIER 0x0C 0x4000000C DMA/中断使能寄存器:使能更新/捕获中断
TIM2_SR 0x10 0x40000010 状态寄存器:标志位(CC1IF, UIF等)
TIM2_EGR 0x14 0x40000014 事件生成寄存器:软件触发更新/捕获
TIM2_CCMR1 0x18 0x40000018 捕获/比较模式寄存器1:CH1/CH2配置
TIM2_CCER 0x20 0x40000020 捕获/比较使能寄存器:各通道极性与使能
TIM2_CNT 0x24 0x40000024 计数器寄存器:当前计数值
TIM2_PSC 0x28 0x40000028 预分频寄存器:分频系数(实际值-1)
TIM2_ARR 0x2C 0x4000002C 自动重装载寄存器:计数上限(65535)
TIM2_CCR1 0x34 0x40000034 捕获/比较寄存器1:CH1锁存值

此地址映射关系是硬件固有属性,所有寄存器操作均基于此物理地址。在裸机编程中,必须通过指针强制类型转换建立C语言变量与硬件寄存器的映射。

2.2 寄存器访问的volatile语义

外设寄存器的读写具有副作用(side effect):写入特定值会触发硬件动作(如启动计数),读取可能返回瞬态状态(如捕获值)。编译器优化若将寄存器访问缓存至CPU寄存器或重排指令顺序,将导致硬件行为不可预测。因此,所有寄存器指针声明必须使用 volatile 限定符,强制编译器每次访问均执行实际内存读写。

例如,TIM2_CR1寄存器的正确映射方式为:

#define TIM2_BASE_ADDR 0x40000000U
#define TIM2_CR1       (*((volatile uint32_t*)(TIM2_BASE_ADDR + 0x00)))

此处 volatile uint32_t* 确保编译器不会优化掉对该地址的读写操作。若省略 volatile ,在-O2优化下,连续两次读取 TIM2_CR1 可能被优化为一次读取并复用结果,导致无法检测到硬件状态变化。

2.3 STM32 for Arduino框架的寄存器抽象层

在STM32 for Arduino(Roger Clark版)框架中,寄存器访问被封装为结构体指针。其 timer.h 文件定义了如下结构:

typedef struct {
  __IO uint32_t CR1;   // 0x00
  __IO uint32_t CR2;   // 0x04
  __IO uint32_t SMCR;  // 0x08
  __IO uint32_t DIER;  // 0x0C
  __IO uint32_t SR;    // 0x10
  __IO uint32_t EGR;   // 0x14
  __IO uint32_t CCMR1; // 0x18
  __IO uint32_t CCER;  // 0x20
  __IO uint32_t CNT;   // 0x24
  __IO uint32_t PSC;   // 0x28
  __IO uint32_t ARR;   // 0x2C
  uint32_t      RESERVED0;
  __IO uint32_t CCR1;  // 0x34
} TIM_TypeDef;

#define TIM2    ((TIM_TypeDef*)0x40000000U)

使用此结构体,可直观地通过 TIM2->CR1 访问寄存器,避免了手动计算偏移量的错误。框架进一步提供了位定义宏,如 TIM_CR1_CEN (对应CR1的第0位),使代码具备自解释性:

TIM2->CR1 |= TIM_CR1_CEN; // 启动TIM2计数器

3. 输入捕获模式的硬件配置流程

输入捕获的配置需严格遵循时序逻辑,任何步骤缺失或顺序颠倒均会导致功能异常。以下配置针对TIM2_CH1(PA0引脚),完整流程如下:

3.1 时钟使能与GPIO初始化

首先,必须使能TIM2和GPIOA的时钟。STM32F100的APB1总线时钟通过RCC_APB1ENR寄存器控制:

// 使能TIM2时钟 (bit 0)
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
// 使能GPIOA时钟 (bit 2)
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;

随后配置PA0为浮空输入模式(无上拉/下拉),因其作为RC信号输入,电平由外部接收机驱动:

// PA0: Input Floating (0b01)
GPIOA->CRL &= ~(0xF << (0 * 4));
GPIOA->CRL |=  (0x4 << (0 * 4)); // CNF0[1:0]=01, MODE0[1:0]=00

3.2 定时器基础参数配置

配置16位自动重装载值(ARR)和预分频器(PSC)以实现1μs计时精度:

// 设置预分频器为71,使定时器时钟 = 72MHz / (71+1) = 1MHz
TIM2->PSC = 71;
// 设置自动重装载值为65535,实现16位满量程计数(0~65535)
TIM2->ARR = 0xFFFF;
// 清除计数器,确保从0开始计数
TIM2->CNT = 0;

此时,TIM2每计数1次即对应1μs时间流逝,计满65535后溢出归零,产生更新事件(UEV)。

3.3 输入捕获通道配置

TIM2_CH1的捕获功能需通过三个寄存器协同配置:

  1. 捕获/比较模式寄存器1(CCMR1) :配置CH1为输入捕获模式,并选择滤波器与分频器。
    c // CCMR1_CC1S = 0b01 (input mode, TI1 mapped to CCI1) // IC1F = 0b0000 (no filter), IC1PSC = 0b00 (no prescaler) TIM2->CCMR1 &= ~((uint32_t)0xFF << 0); TIM2->CCMR1 |= (uint32_t)0x01 << 0;

  2. 捕获/比较使能寄存器(CCER) :使能CH1捕获,并初始配置为上升沿触发。
    c // CC1E = 1 (enable capture), CC1P = 0 (rising edge) TIM2->CCER |= TIM_CCER_CC1E; TIM2->CCER &= ~TIM_CCER_CC1P;

  3. DMA/中断使能寄存器(DIER) :使能CH1捕获中断。
    c // CC1IE = 1 (enable capture interrupt) TIM2->DIER |= TIM_DIER_CC1IE;

3.4 中断控制器(NVIC)配置

为使TIM2_CH1捕获中断生效,必须在NVIC中使能对应中断线(TIM2_IRQn,中断号28)并设置优先级:

// 使能TIM2中断
NVIC_EnableIRQ(TIM2_IRQn);
// 设置抢占优先级为1,子优先级为0(假设使用2位抢占/2位子优先级分组)
NVIC_SetPriority(TIM2_IRQn, NVIC_EncodePriority(NVIC_GetPriorityGrouping(), 1, 0));

3.5 启动定时器与捕获功能

最后,启动定时器计数并使能捕获功能:

// 启动TIM2计数器 (CEN bit in CR1)
TIM2->CR1 |= TIM_CR1_CEN;

至此,硬件配置完成。当PA0引脚出现上升沿时,TIM2当前计数值将被锁存至 TIM2->CCR1 ,同时置位 TIM2->SR 中的 CC1IF 标志位,并触发中断。

4. 双沿捕获状态机的中断服务程序实现

输入捕获的核心挑战在于:单次捕获只能获取一个边沿的时间戳,而脉宽计算需要上升沿和下降沿两个时间点。解决方案是构建一个运行在中断服务程序(ISR)内的有限状态机,动态切换捕获极性。

4.1 状态机设计原理

状态机包含两个核心状态:
- STATE_RISING : 检测到上升沿,记录起始时间,并将CCER切换为下降沿捕获。
- STATE_FALLING : 检测到下降沿,计算脉宽(当前时间 - 起始时间),并切回上升沿捕获。

该设计的关键在于状态切换的原子性。由于CCER寄存器的写入是单条指令,且在中断上下文中执行,因此不存在竞态条件。

4.2 中断服务程序(ISR)代码

// 全局变量,用于存储捕获时间戳与状态
volatile uint32_t channel1_start = 0;
volatile uint32_t channel1_width = 0;
volatile uint8_t  capture_state = STATE_RISING; // 0=RISING, 1=FALLING

void TIM2_IRQHandler(void) {
  uint32_t sr = TIM2->SR;
  uint32_t ccr1 = TIM2->CCR1;

  // 检查是否为CH1捕获中断
  if (sr & TIM_SR_CC1IF) {
    if (capture_state == STATE_RISING) {
      // 上升沿被捕获:记录起始时间
      channel1_start = ccr1;
      // 切换为下降沿捕获
      TIM2->CCER |= TIM_CCER_CC1P; // CC1P = 1 for falling edge
      capture_state = STATE_FALLING;
    } else {
      // 下降沿被捕获:计算脉宽
      uint32_t width = ccr1 - channel1_start;

      // 处理定时器溢出:若ccr1 < channel1_start,说明发生了溢出
      if (ccr1 < channel1_start) {
        width += 0x10000; // 加上ARR+1(65536)
      }

      channel1_width = width;
      // 切回上升沿捕获
      TIM2->CCER &= ~TIM_CCER_CC1P; // CC1P = 0 for rising edge
      capture_state = STATE_RISING;
    }
    // 清除CH1捕获中断标志
    TIM2->SR &= ~TIM_SR_CC1IF;
  }
}

4.3 溢出处理的数学依据

16位定时器的最大计数值为65535。当脉宽接近20ms(20000μs)时,若起始时间接近65535,而结束时间较小(如100),则 ccr1 - channel1_start 将为负数。这并非计算错误,而是定时器已发生溢出(从65535归零至0)。真实脉宽应为:
width = (0x10000 - channel1_start) + ccr1 = ccr1 - channel1_start + 0x10000

因此,在检测到 ccr1 < channel1_start 时,必须加上 0x10000 (即65536)以校正溢出。

4.4 主循环中的数据消费

在主循环中,可安全地读取 channel1_width 变量(因其为 volatile ,确保每次读取均为最新值),并将其发送至串口调试:

void loop() {
  // 读取并打印脉宽(单位:微秒)
  Serial.print("CH1: ");
  Serial.print(channel1_width);
  Serial.println(" us");
  delay(50); // 20Hz刷新率
}

5. 多通道扩展与资源规划策略

一个完整的飞控系统通常需要采集6个RC通道(如AIL, ELE, THR, RUD, AUX1, AUX2)。STM32F100C8T6的TIM2和TIM3均支持4路输入捕获,但需注意引脚复用约束。

5.1 通道引脚映射分析

根据《STM32F100xx Datasheet》Pin Definitions表格:
- TIM2_CH1 : PA0, PA15, PB3
- TIM2_CH2 : PA1, PB10
- TIM2_CH3 : PA2, PB11
- TIM2_CH4 : PA3
- TIM3_CH1 : PA6, PB4, PC6
- TIM3_CH2 : PA7, PB5, PC7
- TIM3_CH3 : PB0, PC8
- TIM3_CH4 : PB1, PC9

为最大化引脚灵活性并避免与系统调试接口(SWD)冲突,推荐分配方案:
| 通道 | 定时器 | 引脚 | 备注 |
|------|--------|------|------|
| CH1 | TIM2 | PA0 | 默认映射,无需重映射 |
| CH2 | TIM2 | PA1 | 同属TIM2,共享PSC/ARR |
| CH3 | TIM2 | PA2 | 同属TIM2,共享PSC/ARR |
| CH4 | TIM2 | PA3 | 同属TIM2,共享PSC/ARR |
| CH5 | TIM3 | PA6 | 需启用TIM3时钟及GPIOA时钟 |
| CH6 | TIM3 | PA7 | 同属TIM3,共享PSC/ARR |

5.2 多定时器协同配置要点

扩展至TIM3时,需复制TIM2的配置逻辑,但注意以下关键差异:
- 时钟使能 RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;
- NVIC中断号 TIM3_IRQn (中断号29),需独立使能与配置优先级。
- 寄存器基地址 TIM3_BASE_ADDR = 0x40000400U ,所有寄存器偏移量相同。
- GPIO复用功能 :PA6/PA7需配置为复用推挽输出(虽作为输入,但需使能AFIO),并通过 AFIO->MAPR 寄存器启用TIM3部分重映射(若使用默认引脚则无需)。

5.3 内存与性能优化实践

在6通道全速采样(20ms周期)下,每个通道每秒触发100次中断。若在ISR中执行复杂运算(如浮点转换、数组拷贝),将显著增加中断负载。实践中应遵循以下原则:
- ISR最小化 :ISR内仅执行时间戳捕获、状态切换与标志清除,所有数据处理移至主循环。
- 变量缓存 :为避免频繁访问 volatile 变量,主循环中可先将其值拷贝至本地非volatile变量再处理。
- 防抖处理 :RC信号可能存在电气噪声,可在主循环中对 channelX_width 进行滑动平均滤波(如取最近5次采样的中位数),而非在ISR中实现。

6. RC接收机电平兼容性验证与硬件连接

RC接收机输出电平与MCU引脚耐压能力的匹配是系统可靠性的前提。STM32F100C8T6的I/O引脚为3.3V逻辑,不具备5V容限(FT标记引脚才支持5V输入)。而多数2.4GHz接收机(如FlySky FS-i6配套接收机)采用3.3V供电设计,其PWM输出高电平实测约为3.0V–3.3V,完全兼容。

6.1 电平实测方法

使用Arduino Uno作为简易电压表验证接收机电平:
1. 将接收机VCC接Uno 5V,GND接GND。
2. 接收机CH1信号线接Uno A0模拟引脚。
3. 运行以下代码:

void setup() {
  Serial.begin(9600);
  analogReference(DEFAULT); // 使用5V基准
}

void loop() {
  int val = analogRead(A0);
  float voltage = val * 5.0 / 1024.0;
  Serial.print("Voltage: ");
  Serial.print(voltage, 2);
  Serial.println(" V");
  delay(500);
}

实测FlySky FS-i6接收机输出电压稳定在2.98V–3.05V,远低于3.3V,可直接接入PA0等非FT引脚。

6.2 硬件连接规范

  • 电源 :接收机VCC接开发板3.3V(非5V!),GND共地。STM32F100的3.3V LDO可提供150mA电流,足以驱动小型接收机。
  • 信号线 :接收机CH1接PA0,CH2接PA1,依此类推。避免使用长导线,减少电磁干扰。
  • 去耦电容 :在接收机VCC与GND间并联100nF陶瓷电容,抑制高频噪声。

7. 实际工程调试经验与常见问题排查

在将上述方案部署至真实飞控硬件时,我曾遭遇数个典型问题,其解决过程凝结了宝贵的工程经验:

7.1 中断未触发的根因分析

现象:串口无输出,示波器显示PA0有正常RC脉冲,但 TIM2_IRQHandler 从未执行。

排查路径:
1. 确认NVIC使能 :检查 NVIC_EnableIRQ(TIM2_IRQn) 是否执行,以及 NVIC->ISER[0] 对应位是否置1。
2. 检查CCER配置 TIM2->CCER CC1E 位必须为1,且 CC1P 极性与信号边沿匹配。
3. 验证时钟树 :使用ST-Link Utility读取 RCC->CFGR ,确认APB1总线时钟已正确分频至36MHz(TIM2时钟为APB1时钟,因APB1预分频为2)。
4. 寄存器写保护 :某些STM32型号的TIMx_CR2寄存器存在写保护位,但F100系列无此限制。

7.2 脉宽跳变的噪声处理

现象: channel1_width 在1495–1505μs间随机跳变,超出RC协议允许的±5μs精度。

解决方案:
- 硬件滤波 :在PA0与GND间并联100pF电容,吸收高频毛刺。
- 软件滤波 :主循环中不直接使用 channel1_width ,而是维护一个环形缓冲区,每次取其中位数:
```c
#define FILTER_SIZE 5
uint32_t width_buffer[FILTER_SIZE];
static uint8_t buf_idx = 0;

void update_filtered_width(uint32_t raw_width) {
width_buffer[buf_idx] = raw_width;
buf_idx = (buf_idx + 1) % FILTER_SIZE;
}

uint32_t get_median_width() {
uint32_t temp[FILTER_SIZE];
memcpy(temp, width_buffer, sizeof(temp));
// 简单冒泡排序(FILTER_SIZE小,效率可接受)
for (int i = 0; i < FILTER_SIZE; i++) {
for (int j = 0; j < FILTER_SIZE - 1; j++) {
if (temp[j] > temp[j + 1]) {
uint32_t t = temp[j];
temp[j] = temp[j + 1];
temp[j + 1] = t;
}
}
}
return temp[FILTER_SIZE / 2];
}
```

7.3 多通道同步性问题

现象:6个通道的采样时间点不同步,导致飞控算法计算的姿态角存在相位差。

根本原因:每个定时器(TIM2/TIM3)独立运行,其计数器初始相位不同。解决方案是强制同步:
- 在所有定时器配置完成后,向 TIM2->EGR TIM3->EGR 写入 TIM_EGR_UG (更新事件生成),使所有计数器同时清零并重新开始计数。
- 此操作应在所有 TIMx->CR1 |= TIM_CR1_CEN 之前执行,确保它们从同一时刻启动。

我在某次四轴调试中发现,未同步的TIM2/TIM3导致油门通道比其他通道滞后约120μs,引发PID控制器输出震荡。加入同步机制后,六通道采样偏差稳定在±2μs内,飞行稳定性显著提升。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐