STM32遥控器三段链路:按键采集、遥控码组帧与红外/2.4G发射
简介:这是一份面向嵌入式初学者与STM32开发者的遥控器交互实践资源包,围绕富斯i6遥控器与STM32微控制器的通信实现,提供可直接编译调试的完整工程,帮助读者解决无线信号接收、协议解析与中断处理等典型问题。包内共995个文件,以559个c源文件与243个h头文件为主体,辅以51个s汇编启动文件、28个icf链接脚本、25个txt说明及map、hex、uvprojx等工程与产物文件,涵盖DSP数学库、外设驱动与IAR、Keil双环境配置,压缩包约23.07MB,目录结构便于按模块查阅。目前已有330人学习下载。读者可从中获得遥控器帧结构解析思路、UART与SPI接口参数配置参考、中断服务与定时器同步的完整代码框架,以及外部设备状态指示的调试方法,适合对照硬件实测、快速搭建遥控接收端原型并在其基础上扩展航模或车模控制项目。
1. 拿到 YaoKongQi.zip 先别急着编译:STM32 遥控器的三段链路
解压、找工程、点编译,然后盯着满屏报错发呆——这是多数人打开 YaoKongQi.zip 的前五分钟。它是一套 STM32 遥控器方向的源码工程,核心链路只有三段:按键采集、遥控码组帧、发射。不管是红外打家电、2.4G 控小车云台,还是自己定一套私协议,骨架都逃不出这三段。适合正在做课设、要给某个执行端配一个手持端、或者想把现成遥控方案换个主控重写的人。
判断这份工程值不值得花时间,我一般先看三件事:发射介质是红外还是 2.4G、按键是独立键还是矩阵、协议时序有没有从业务代码里抽出来。前两件决定硬件要不要动,第三件决定你是改两个宏就能用,还是得把底层推倒重来。这三件事看完,再决定是拿来主义还是当参考读。
2. 拆工程看骨架:主控、按键、发射三层的选型与接口
遥控器这类产品的设计约束和常规开发板项目是反的:性能要让位给功耗,功能要让位给手感。所以看一份遥控器工程,不该先看它用了多少外设,而该看它闲下来的时候在干什么。下面按三层拆,选型理由和接口边界都说清楚。
2.1 最小闭环:一块电池要撑多久决定了一切
遥控器的最小闭环是 MCU + 按键 + 发射器件 + 一颗指示灯,屏幕和蜂鸣器都是可选项。这套闭环里最贵的不是物料,是电。红外发射管瞬时电流动辄上百毫安,2.4G 模块发射峰值也在十几毫安量级,而设备 99% 的时间在等按键。所以主控选型上,F0、L0、L1 这类低功耗系列比 F1/F4 更合适,除非你要跑显示或复杂协议栈。
由此带来的第一个代码习惯是:所有延时都不能是空转for循环。空转延时在低功耗场景下是纯浪费,而且一旦开了中断就会被拉长,时序直接崩。常见做法是用一个 1ms 或 10ms 的定时器中断做系统时基,按键扫描、指示灯闪烁、超时重发全部挂在这个时基上,主循环只做状态推进。这样后面进 STOP 模式时,唤醒源和时基能对上,不用改一整套逻辑。
/* 时基初始化:1ms 中断,只置标志位,不做任何业务 */ void bsp_tick_init(void) { /* 以 72MHz 主频为例,预分频到 1kHz */ htim3.Instance = TIM3; htim3.Init.Prescaler = 7200 - 1; /* 72MHz / 7200 = 10kHz 计数 */ htim3.Init.Period = 10 - 1; /* 10kHz / 10 = 1kHz,即 1ms */ HAL_TIM_Base_Init(&htim3); HAL_TIM_Base_Start_IT(&htim3); } /* 中断里只做一件事:累加计数并置位 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { g_tick_ms++; if ((g_tick_ms % 10) == 0) g_flag_scan = 1; /* 10ms 扫描一次按键 */ } }这段代码的关键不在配置,在于中断里没有业务。g_tick_ms是全局毫秒计数,任何模块要用超时都拿它做差,不要各自开定时器。g_flag_scan是给主循环的扫描请求,用标志位而不是在中断里直接扫键,是为了避免中断执行时间随按键数量增长。预分频和周期这两个值必须按你实际主频重算,改主频忘了改这里,是所有时序问题的源头。
GPIO 的初始状态也属于这一层。按键脚配上拉输入,未使用的引脚一律配成模拟输入,不要留浮空输入——浮空脚在低功耗模式下会因电平抖动产生额外漏流,这个坑在后面功耗章节还会遇到。
2.2 红外还是 2.4G:先定介质再谈代码
介质选择直接决定协议复杂度和调试手段,两者的差别不是"一个有线一个无线"这么简单。
| 维度 | 红外(NEC 类) | 2.4G(nRF24L01 类) |
|---|---|---|
| 有效距离 | 5~10 米,需大致对准 | 10~50 米,全向 |
| 方向性 | 强,适合定点控制 | 弱,适合移动载体 |
| 单向/双向 | 基本单向 | 可做 ACK 双向 |
| 协议开销 | 极低,一帧 4 字节 | 需处理地址、信道、重发 |
| 抗干扰 | 受强光影响 | 受同频段设备影响 |
| 调试难度 | 低,逻辑分析仪一抓就懂 | 中,需要看寄存器状态 |
| 典型功耗 | 发射瞬时高,平均低 | 待机低,发射中等 |
做家电替换、灯控、风扇这类单向定点场景,红外更省事,一颗定时器出 38kHz 载波就能干活。做小车、云台、需要知道对方在不在线的场景,2.4G 的 ACK 机制值回票价,因为你能在遥控器上做"失联提示",这是红外做不到的。工程里如果两套都留了接口,恭喜,你可以只改应用层的介质选择;如果只写了一路,另一路就得自己补。
2.3 分层:协议层不该知道 GPIO 是哪一根
判断一份遥控器工程能不能改,看它协议层有没有直接调HAL_GPIO_WritePin。如果有,改介质就等于改协议,测试成本翻倍。合格的切法是三层:
- 硬件层(bsp):GPIO、定时器 PWM、SPI、时基,对外只暴露
xxx_init和xxx_send_raw这类函数。 - 协议层(protocol):NEC 编码、2.4G 封包、校验计算,只认"发一段 mark/space"或"发一包字节"这样的抽象。
- 应用层(app):按键映射、长按短按、重发策略、电量提示。
/* 协议层只依赖这个接口,不关心底下是红外还是 2.4G */ typedef struct { void (*carrier_on)(void); /* 打开载波 */ void (*carrier_off)(void); /* 关闭载波 */ void (*delay_us)(uint16_t us); /* 微秒级延时 */ void (*send_packet)(const uint8_t *buf, uint8_t len); } rc_phy_t; /* 红外实现:载波开关就是 PWM 使能/失能 */ static const rc_phy_t phy_ir = { ir_carrier_on, ir_carrier_off, dwt_delay_us, NULL }; /* 2.4G 实现:载波开关是空函数,整包交给 send_packet */ static const rc_phy_t phy_rf = { nop, nop, dwt_delay_us, rf_send_packet };用函数指针表把物理层抽出来,好处是编译期就能切换方案,测试时还能塞一个"回环实现"把发出去的码原样打印到串口,不用接第二个板子。这里的delay_us要求是忙等且可控的微秒延时,不要用HAL_Delay,它的最小粒度是毫秒,粒度不够。carrier_on/off对 2.4G 是空实现,这是刻意的——协议层不该为"某种介质没有载波"而写分支判断。
3. 按键到帧:矩阵扫描、映射表与遥控码组包
这一段是遥控器手感的分水岭。同样一颗薄膜按键,有人做出来轻触即发,有人做出来连击、粘键、松手才响应。差别全在扫描节奏和消抖状态机上,跟芯片型号关系不大。
3.1 10ms 时基配四态机,比 delay 消抖可靠得多
最常见的错误写法是"检测到低电平 → 延时 20ms → 再检测一次"。这种写法在单键时能用,一上矩阵键盘就翻车,因为延时期间其他行被阻塞,快速按键会被漏掉。正确做法是把采样和判定分开:每 10ms 采样一次整个键盘,把结果压进状态机,由状态机决定什么时候算"按下"。
typedef enum { KEY_IDLE, KEY_DEBOUNCE, KEY_HOLD, KEY_RELEASE } key_state_t; /* 每个键一份状态,scan 返回当前电平位图,1 表示按下 */ static void key_fsm_update(uint16_t raw_bitmap) { for (uint8_t i = 0; i < KEY_NUM; i++) { uint8_t raw = (raw_bitmap >> i) & 0x01; switch (key[i].state) { case KEY_IDLE: if (raw) { key[i].state = KEY_DEBOUNCE; key[i].cnt = 0; } break; case KEY_DEBOUNCE: if (raw) { if (++key[i].cnt >= 3) { /* 连续 3 次即 30ms 稳定 */ key[i].state = KEY_HOLD; key[i].press_tick = g_tick_ms; key_event_push(i, KEY_EV_DOWN); /* 只在下降沿发一次事件 */ } } else { key[i].state = KEY_IDLE; /* 抖动,退回 */ } break; case KEY_HOLD: if (!raw) { key[i].state = KEY_RELEASE; key[i].cnt = 0; } break; case KEY_RELEASE: if (!raw) { if (++key[i].cnt >= 3) { key[i].state = KEY_IDLE; key_event_push(i, KEY_EV_UP); } } else { key[i].state = KEY_HOLD; /* 松手抖动,退回按住 */ } break; } } }cnt >= 3对应 30ms 的稳定窗口,这个值是可调的:薄膜键盘取 2~3 就够,机械轴可以取 3~4,弹簧片差的遥控器取到 5。太大会让手感发钝,太小会连击。press_tick用于长按判定,在主循环里拿g_tick_ms - press_tick做差,超过 800ms 触发长按,之后每 100ms 触发一次连发——连发节奏不要放在状态机里,放在应用层,方便不同按键配不同策略。
矩阵扫描本身要注意两点:行线输出、列线输入,每次只拉低一行,读全部列线,四次读完一个 4×4 键盘。行线切换后要留几微秒让电平稳定,具体值看你的上拉电阻和线容,一般 2~5us 足够,用__NOP()凑也行,但不要用会进中断的延时。另外,扫描期间其余行线要配成高阻或输出高,不能一直拉低,否则多键同时按下会形成鬼键。
3.2 映射表:把物理键位和功能码彻底解耦
键位和功能码写死在一起,是后期改需求最痛苦的事。今天"音量+"是 0x12,明天客户要换成 0x45,你得翻遍代码。抽一张常量表出来,改表不改逻辑。
typedef struct { uint8_t key_id; /* 物理键位编号 */ uint8_t func_code; /* 遥控码里的命令字节 */ uint8_t repeat; /* 0=单发 1=长按连发 */ uint16_t long_ms; /* 长按阈值,repeat=1 时有效 */ } key_map_t; static const key_map_t key_table[] = { { 0, 0x45, 0, 0 }, /* 电源键,单发 */ { 1, 0x09, 0, 0 }, /* 静音 */ { 4, 0x40, 1, 800 }, /* 音量+,长按连发 */ { 5, 0x41, 0, 0 }, /* 音量- */ };repeat字段区分单发和连发,是最省事的设计:电源键按一次只发一帧,按住不放也不重复,避免误触发开关机;音量键按住才连发。long_ms给 0 表示这个键根本不参与长按判定,主循环里可以直接跳过计时,省一点开销。表里的func_code要和你接收端的解码表对齐,改之前先把接收端的手册或源码翻出来对一遍,别凭记忆填。
如果工程支持组合键,不要在扫描层做,放到事件层做:收到 DOWN 事件时起一个 50ms 的窗口,窗口内收到第二个键就判组合,窗口结束没等到就按单键处理。这样组合键逻辑不影响扫描节奏,扫描节奏是实时性最关键的部分。
3.3 组帧:地址、命令、序号、校验,一个都不能少
红外 NEC 的帧结构是固定的:9ms 引导、4.5ms 空间、8 位地址、8 位地址反码、8 位命令、8 位命令反码。地址反码和命令反码就是它的校验手段,收端对不上就丢弃。很多自制工程省掉反码,结果在强光干扰下误触发,还以为是接收头坏了。
2.4G 的帧可以自己定,但建议保留四个字段:地址(区分多台设备)、命令、序号(判重和判丢)、校验。
#pragma pack(1) typedef struct { uint8_t addr; /* 设备地址,多遥控器共存时用 */ uint8_t cmd; /* 功能码,来自映射表 */ uint8_t seq; /* 递增序号,收端据此判重 */ uint8_t crc; /* 前三个字节的 CRC8 */ } rc_frame_t; #pragma pack(1) static uint8_t crc8(const uint8_t *p, uint8_t len) { uint8_t crc = 0x00; while (len--) { crc ^= *p++; for (uint8_t i = 0; i < 8; i++) crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x07) : (uint8_t)(crc << 1); } return crc; } void rc_build_frame(rc_frame_t *f, uint8_t addr, uint8_t cmd) { static uint8_t seq = 0; f->addr = addr; f->cmd = cmd; f->seq = seq++; /* 自然回绕,收端用差值判断新旧 */ f->crc = crc8((uint8_t *)f, 3); /* 只算前 3 字节 */ }#pragma pack(1)必须加,否则编译器会按 4 字节对齐补空洞,结构体变成 8 字节,和收端解析的长度对不上。CRC8 用的多项式是 0x07(x^8+x^2+x+1),收发两端必须一致,换成 0x31 也能用,但要同步改。seq用static变量自增,回绕到 255 之后归零,收端判重时要用(new - old) & 0xFF而不是直接比大小,否则回绕那一下就漏包。
帧长控制在 4 字节是有意的:nRF24L01 的最小有效载荷就是 1 字节,4 字节的空中时间极短,撞包概率低。如果你的应用需要携带摇杆模拟量,把帧扩到 8 字节,同时把发送周期从"按键触发"改成"固定 20ms 周期",不然摇杆推快了会丢中间态。
4. 发射链路落地:38kHz 载波、NEC 时序与 2.4G 封包
协议层把帧拼好之后,真正难的是把它按时序推出去。红外对时间的要求是微秒级,2.4G 对时序的要求反而宽松,但对电源和配置极其敏感。这一章两套都过一遍。
4.1 38kHz 载波:ARR 和 PSC 别拍脑袋填
红外接收头内部有带通滤波,只认 38kHz 附近的调制信号,你直接推直流电平它是不认的。所以发射端必须用载波调制,硬件定时器出 PWM 是首选,软件翻转 GPIO 在低功耗场景下既费电又不准。
以 72MHz 主频、目标 38kHz 为例:先分频到 1MHz 计数基准,周期取 26,得到 38.46kHz,误差 1.2%,在接收头 ±2kHz 的容忍范围内。占空比取 1/3 左右,比较值 9。占空比不要设 50%,峰值电流翻倍不说,部分接收头的灵敏度在窄脉冲下更好。
/* TIM1_CH1 输出 38kHz 载波,占空比约 1/3,默认关闭输出 */ void ir_carrier_init(void) { __HAL_RCC_TIM1_CLK_ENABLE(); htim1.Instance = TIM1; htim1.Init.Prescaler = 72 - 1; /* 72MHz / 72 = 1MHz */ htim1.Init.Period = 26 - 1; /* 1MHz / 26 ≈ 38.46kHz */ htim1.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_PWM_Init(&htim1); TIM_OC_InitTypeDef oc = {0}; oc.OCMode = TIM_OCMODE_PWM1; oc.Pulse = 9; /* 9/26 ≈ 34.6% 占空比 */ oc.OCPolarity = TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(&htim1, &oc, TIM_CHANNEL_1); } /* 载波开关,供协议层调用 */ void ir_carrier_on(void) { __HAL_TIM_ENABLE(&htim1); } void ir_carrier_off(void) { __HAL_TIM_DISABLE(&htim1); }Pulse这个参数决定占空比,改它之前先确认Period没被别的模块动过。ir_carrier_off用的是关定时器而不是把占空比设 0,因为关定时器能让输出脚保持确定的低电平,而设 0 在某些型号上会残留一个窄脉冲,接收端可能误判成起始位。引脚复用要配成推挽输出、高速模式,中间串一颗限流电阻,别把 IO 直接怼到三极管基极上。
4.2 NEC 的 mark/space:关中断比调延时更重要
NEC 的时序是:9ms 载波(mark)+ 4.5ms 空间(space)作引导,之后每位是 560us 载波加一段空间,空间 560us 表示 0,1690us 表示 1,最后 560us 载波收尾。这些数字不难,难在"不被打断"。
/* DWT 微秒延时,前提是已使能 DWT->CYCCNT */ static inline void dwt_delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000U); while ((DWT->CYCCNT - start) < ticks) { } } /* 发送一位:先 mark 后 space */ static void nec_send_bit(uint8_t bit) { ir_carrier_on(); dwt_delay_us(560); ir_carrier_off(); dwt_delay_us(bit ? 1690 : 560); } /* 发送完整一帧,期间关中断保护时序 */ void nec_send_frame(uint8_t addr, uint8_t cmd) { uint32_t primask = __get_PRIMASK(); __disable_irq(); /* 9ms 引导期间绝不能被中断打断 */ ir_carrier_on(); dwt_delay_us(9000); ir_carrier_off(); dwt_delay_us(4500); uint8_t buf[4] = { addr, (uint8_t)~addr, cmd, (uint8_t)~cmd }; for (uint8_t i = 0; i < 4; i++) for (uint8_t b = 0; b < 8; b++) nec_send_bit((buf[i] >> b) & 0x01); /* NEC 低位先发 */ ir_carrier_on(); dwt_delay_us(560); ir_carrier_off(); __set_PRIMASK(primask); /* 恢复原有中断状态 */ }关中断的窗口大约 67ms,这是 NEC 一帧的最长耗时。如果你的系统里有 1ms 时基中断,它会被推迟,所以按键扫描的时基不能依赖这一帧期间的准时性——把发送放在主循环里、发送前后各标记一次时间戳,让扫描逻辑自己补偿。__get_PRIMASK和__set_PRIMASK成对使用,不要直接__enable_irq(),否则会把调用者原本关着的中断打开,这是很隐蔽的翻车点。
低位先发是 NEC 的规矩,但有些日系协议是高位先发,替换家电时要先抓一次原遥控器的波形确认,别照抄。另外,dwt_delay_us依赖SystemCoreClock正确,用外部晶振还是内部 RC 会导致 1% 左右的偏差,直接影响 1690us 这一档的判定,接收端容忍度一般在 ±10%,勉强够,但别用内部 RC 做批量产品。
4.3 nRF24L01:SPI 配置对了,稳定性就有了一半
2.4G 这边的坑集中在配置寄存器和电源。模块对电源纹波极其敏感,3.3V 上并一颗 10uF 加一颗 0.1uF 是最低要求,很多"近距离正常、远一点就丢包"的问题都是电源引起的。
/* 精简初始化:1Mbps、0dBm、通道 76、开启自动应答与重发 */ void rf_init(void) { uint8_t cfg; rf_write_reg(REG_CONFIG, 0x0C); /* EN_CRC | PWR_UP,CRC 两字节 */ rf_write_reg(REG_EN_AA, 0x01); /* 管道 0 开启自动应答 */ rf_write_reg(REG_EN_RXADDR, 0x01); rf_write_reg(REG_SETUP_AW, 0x03); /* 地址宽度 5 字节 */ rf_write_reg(REG_SETUP_RETR, 0x1A); /* 重发间隔 500us,重发 10 次 */ rf_write_reg(REG_RF_CH, 76); /* 2.476GHz,避开常见 2.400 */ rf_write_reg(REG_RF_SETUP, 0x06); /* 1Mbps,0dBm */ rf_write_buf(REG_TX_ADDR, tx_addr, 5); rf_write_buf(REG_RX_ADDR_P0, tx_addr, 5); /* 自动应答要求收发地址一致 */ rf_write_reg(REG_CONFIG, 0x0E); /* 切到发送模式 */ rf_delay_ms(5); /* 上电稳定时间,手册要求 1.5ms 以上 */ } /* 发一包并等待结果,超时返回失败 */ uint8_t rf_send_packet(const uint8_t *buf, uint8_t len) { rf_write_buf(W_TX_PAYLOAD, buf, len); rf_write_reg(REG_STATUS, 0x30); /* 清 TX_DS 与 MAX_RT */ CE_HIGH(); rf_delay_us(15); CE_LOW(); /* 至少 10us 才能触发发送 */ for (uint16_t t = 0; t < 500; t++) { /* 最多等 5ms */ uint8_t st = rf_read_reg(REG_STATUS); if (st & 0x20) return 1; /* TX_DS,发送成功 */ if (st & 0x10) return 0; /* MAX_RT,重发耗尽 */ rf_delay_us(10); } return 0; }SETUP_RETR的 0x1A 拆开看是"重发间隔 500us、重发 10 次",总重发窗口约 5ms,配合按键的触发节奏够用。重发次数不是越多越好,次数多了延迟上去了,遥控器会出现"按了半秒才有反应"的粘滞感。RF_CH选 76 是有意的:Wi-Fi 信道 1/6/11 的中心频率会压到 2.412/2.437/2.462,往上挪一点能躲开大部分家用路由器的能量中心。CE拉高至少 10us 再拉低,这是手册里容易忽略的一条,拉高时间不够会出现"发了但没发出去",寄存器状态还是干净的,极难查。
收发地址必须一致且都是 5 字节,因为自动应答机制要求接收端用发送端的地址回 ACK。地址宽度配成 3 字节能省一点空中时间,但撞地址概率上升,多台设备同场时建议保持 5 字节并在地址里带设备编号。
4.4 功耗账:待机电流是从每一个引脚省出来的
遥控器待机电流的及格线是 10uA 以内,做得好能到 2~3uA,做不好直接几百微安,两节电池放三个月就没。差距不在主控型号,在进入低功耗前的那几行配置。
| 项目 | 常见错误值 | 正确做法 |
|---|---|---|
| 未用 GPIO | 浮空输入,漏流数十 uA | 配模拟输入或输出低 |
| 按键上拉 | 内部上拉常开,按住时持续耗流 | 扫描时临时开上拉,扫完关掉 |
| 2.4G 模块 | 常供电,待机 20uA 以上 | 用 MOS 管切电,或进 POWER_DOWN |
| 调试口 | SWD 引脚保持调试模式 | 低功耗前配成普通 IO |
| 指示灯 | 高亮常亮 | PWM 降亮度 + 3 秒自动熄灭 |
进入 STOP 模式的顺序也有讲究:先关外设时钟,再把唤醒源配成按键所在的 EXTI 线,最后清挂起位、发 WF I。顺序反了会出现"刚进去就被自己唤醒",现象是电流表指针跳一下就回来,容易误判成没进去。
5. 避坑排查:五个把板子调哭的瞬间
这一章是我自己在调试台上真撞过的几类问题,现象、原因、解决按顺序写。每条都给出可复现的检查手段,不讲理论。
5.1 现象:红外灯珠明明是亮的,接收端就是不响应
原因通常不在协议,在极性。发射管有两种接法:MCU 输出高、三极管导通、管子接地;或者 MCU 输出低、PNP 导通。如果代码里carrier_on输出的是高电平,而硬件是低有效,那发出去的就是一段"反相"的波形,接收头看到的是持续的空间,直接当无信号丢弃。另一种情况是 PWM 通道配成了互补输出,实际波形和预期差 180 度。
解决:拿手机摄像头对着发射管,按一次键,能看到紫色闪烁说明有载波;再用示波器或逻辑分析仪夹在管子阴极,看 mark 期间是不是有 38kHz 的脉冲串、幅度是不是接近电源电压。如果只有直流电平没有脉冲,说明 PWM 没真正输出,检查定时器的MOE位(高级定时器要额外使能主输出)和通道是否被HAL_TIM_PWM_Start真正启动。这一条我吃过两次,第二次是因为换了引脚忘了改复用映射,波形全在另一个脚上。
5.2 现象:按键偶尔连击,快速连按又丢键
原因:消抖窗口和扫描周期耦合。扫描周期 10ms、消抖判定要连续 3 次,等于 30ms 才确认,快速点按(比如一秒按 8 次)的间隔只有 125ms,理论上够,但如果主循环里还有红外发送这种 67ms 的阻塞操作,扫描就被推迟,采样点漏掉,表现就是丢键。连击则是另一头:消抖要求连续 3 次低电平,但机械键在按下瞬间的抖动如果恰好落在两次采样之间,可能被误判成稳定的按下,松手时又抖一次,就出双触发。
解决:把扫描的采样和发送解耦,发送期间不求时效,但采样时间戳要连续——用g_tick_ms记录每次采样的实际间隔,间隔超过 15ms 就把状态机计数减一,避免误累计。另外消抖窗口给到 4 次,同时把主循环里所有阻塞操作改成状态机推进。做完这两步,手感基本就稳了。
5.3 现象:2.4G 一米内完美,三米外随机丢包
原因八成在电源,两成在配置。电源方面:模块直接吃 MCU 的 3.3V LDO,发射瞬间的浪涌把电压拉到 2.8V 以下,模块内部复位,寄存器回到默认值,通道变回 2.400,地址清零——表现就是"突然不通,断电重启又好了"。配置方面:SETUP_RETR重发次数给太少,或者自动应答开了但收发地址不一致,ACK 永远收不到,每次都在重发耗尽。
解决:模块电源脚就近并 10uF 钽电容 + 0.1uF 瓷片,走线单独从 LDO 拉,不要和 MCU 共用一段细线。抓一次故障时的寄存器值,如果RF_CH和TX_ADDR确实变了,基本可以确定是掉电复位。另外把重发间隔调到 750us、次数 5 次,比 250us×15 次稳,因为间隔太短时接收端还没从上一包恢复。天线净空也要留,模块底下铺地会吸掉一部分辐射功率,实测距离能差一倍。
5.4 现象:电池一装上,稳压器发烫,待机电流 8mA
原因:GPIO 配置冲突。最常见的是一个脚被配成推挽输出高,而外部电路把它拉低,形成持续灌流;其次是两个定时器通道映射到同一个引脚,输出互相打架。还有一种隐蔽情况:进入低功耗前没关调试口,SWD 引脚保持调试模式,内部上拉一直开着。
解决:进低功耗前把所有 GPIO 打一张表,逐个确认方向和电平,未用的全部配模拟输入。用电流表串在电池正极,逐个断开外设(红外管、2.4G 模块、指示灯)看电流变化,谁断下去最多就是谁。指示灯如果用的是共阳接法,注意输出低才是亮,配反了就是常亮,这个错误在样板阶段特别常见。
5.5 现象:逻辑分析仪抓到的码,时序总是差 100us 左右
原因:延时被中断打断。dwt_delay_us是忙等,本身很准,但 SysTick 或定时器中断一来,CPU 跳去执行中断服务,回来继续数周期,实际经过的时间就超了。9ms 的引导被打断一次,接收头可能直接判帧错。
解决:关键时序段关中断,如前文nec_send_frame里的写法;或者干脆用硬件 PWM + DMA 把整帧波形预生成到缓冲区,让定时器自己按表输出,CPU 完全不参与。第二种方案改动量大,但时序稳定性最好,批量产品建议走这条路。临时验证用第一种就够,把中断优先级里最高的那个也考虑进去——关中断关不掉 NMI 和 HardFault,虽然正常跑不会触发,但调试阶段一旦进 HardFault,整帧就废了,抓波形会看到一坨乱码。
6. 验收与进阶:用逻辑分析仪把时序钉死
一套遥控器代码写完,别急着接执行端,先在发射端做自验收。我习惯的顺序是:串口打印帧内容 → 逻辑分析仪抓发射波形 → 两块板对发做回环 → 再上真实负载。这个顺序能把问题定位到具体一层,而不是在"到底是谁的问题"上耗时间。
第一步,在rc_build_frame后面加一个调试口,把 addr、cmd、seq、crc 打出来。波特率 115200,格式自己定,重点是每按一次键能看到一行。这一步能确认映射表和组帧逻辑没问题,如果这行都没打印,问题在按键层。
/* 调试专用,正式固件里用宏关掉 */ #ifdef RC_DEBUG printf("addr=%02X cmd=%02X seq=%02X crc=%02X\r\n", f.addr, f.cmd, f.seq, f.crc); #endif第二步,逻辑分析仪通道 0 接发射管阴极,采样率至少 1MHz,触发方式设成下降沿。抓到的波形应该是:9ms 脉冲串、4.5ms 静默、32 位数据、560us 收尾。逐段量宽度,偏差超过 5% 就回头查dwt_delay_us的时钟基准和关中断逻辑。这一步是黑匣子,波形不会骗人。
第三步,接收端用同一套工程的解码函数做回环,成功率连测 200 次,记下失败次数。2.4G 方案正常应该在 99% 以上,红外在 95% 以上(受环境光影响)。低于这个数就别往下走,先把重发和电源解决掉。
进阶方向有两个。一是把 NEC 换成学习型方案:用一个红外接收头采集原遥控器的波形,存进 Flash,发射时回放。这个方案的好处是不用管协议,坏处是存储开销大,一帧要几百字节,得用压缩或者只存关键段。二是给 2.4G 加双向:接收端回传电量、按键状态,遥控器上做低电量提示和失联告警。双向的代价是遥控器要从纯发送改成发送-接收切换,时序上要留出切换窗口,不能发完立刻切,模块内部状态机需要几个微秒恢复。
最后说个习惯。我以前调红外总喜欢先怀疑接收头坏了,换一个、再换一个,浪费半天。后来强制自己每次先抓发射端波形,波形对了才动接收端,问题定位时间从半天压到十分钟。从那以后我每次上电第一件事就是夹探头,不管有没有问题,先看一眼波形心里才踏实。希望帮到你。
本文还有配套的精品资源,点击获取