1. 为什么要在AM32上折腾Telemetry
如果你正在看这篇文章,大概率是手里攥着一块刷了AM32固件的电调,想让它把转速、电压、电流、温度这些实时数据吐回飞控,结果发现官方文档语焉不详,社区里的帖子东一句西一句,串口能通但数据全是乱码,或者干脆连不上。这不是你一个人的问题,AM32的Telemetry功能在STM32F103这类资源紧张的MCU上实现起来,确实有几个容易翻车的点。
AM32是一款开源的电调固件,主要跑在STM32F051、STM32F103、STM32G071这类芯片上,支持BLHeli风格的PWM输入、DShot、串口通信等多种协议。Telemetry就是它的遥测回传功能,通过USART把电调的状态数据打包发给飞控,飞控再转发给地面站显示。听起来简单,但实际做的时候,你会遇到协议格式不统一、DMA和中断打架、CRC8校验算不对、串口发送阻塞导致电机控制抖动等一系列问题。
这篇文章面向的是有一定嵌入式基础的开发者,最好你已经在用STM32CubeMX或者CubeIDE做开发,对USART、DMA、中断这些外设有基本概念。如果你刚接触STM32,建议先把串口收发和DMA搬运数据这两件事搞明白再来看。我会从协议解析开始,一步步讲到怎么在AM32的代码框架里把Telemetry报文稳定地发出去,中间踩过的坑、试过的方案、最终为什么选这个方案,都会说清楚。
核心关键词就这几个:AM32、Telemetry、DMA、USART、CRC8。整篇文章围绕它们展开,不跑题。
2. Telemetry协议到底长什么样
2.1 常见电调遥测协议的格式对比
电调遥测没有统一的国际标准,不同厂商、不同固件用的格式不一样。BLHeli32用的是自己的Telemetry格式,KISS也有自己的一套,AM32在早期版本里兼容了BLHeli的部分格式,后来也支持了更通用的串口遥测。你在做开发之前,第一件事是确认你的飞控端期望收到什么格式的数据。
| 协议类型 | 典型帧长 | 校验方式 | 波特率 | 数据内容 |
|---|---|---|---|---|
| BLHeli32 Telemetry | 10字节 | CRC8 | 115200 | 温度、电压、电流、转速、状态 |
| KISS Telemetry | 10字节 | CRC8 | 115200 | 类似BLHeli,字节序不同 |
| 自定义ASCII | 不定长 | 无或简单校验 | 9600-115200 | 可读文本,调试方便 |
| MSP遥测 | 不定长 | 校验和 | 115200 | 多传感器融合数据 |
AM32的Telemetry实现主要参考BLHeli32的格式,因为它的目标用户群体大量使用Betaflight和BLHeliSuite生态。BLHeli32的Telemetry帧结构大致是这样的:帧头、温度、电压、电流、转速、状态标志、CRC8校验。具体字节序和缩放系数在不同版本里有细微差别,你需要对着你用的AM32版本源码里的telemetry.c或者serial_telemetry.c来确认。
2.2 帧结构逐字节拆解
假设我们用的是BLHeli32兼容格式,一帧10个字节,结构如下:
- Byte 0:帧头,通常是0x2E或者0x3F,取决于具体实现
- Byte 1:温度,单位摄氏度,直接取整
- Byte 2-3:电压,小端序,单位0.01V,比如12.6V存为1260
- Byte 4-5:电流,小端序,单位0.01A
- Byte 6-7:转速,小端序,单位是电周期每秒钟,需要根据电机极对数换算成RPM
- Byte 8:状态标志位,bit0表示电机运转,bit1表示方向,等等
- Byte 9:CRC8校验值,对前9个字节计算
这里有个容易搞错的地方:电压和电流的缩放系数。有些版本用0.1V,有些用0.01V,如果你发出去的数据飞控显示电压只有实际值的十分之一,大概率就是缩放系数搞反了。我的建议是先用一个已知电压(比如12.6V的电池)测试,看飞控显示多少,反推缩放系数。
CRC8的计算多项式通常是0x07,初始值0x00,不反转输入输出。但BLHeli32用的是CRC8-DVB-S2,多项式0x07,初始值0x00,结果异或0x00。这个细节在协议文档里经常被忽略,但算错了飞控就直接丢弃整帧数据。
2.3 为什么选USART而不是其他接口
AM32跑在STM32F103上,芯片资源有限。Telemetry需要持续回传数据,对实时性有要求,但又不能占用太多CPU时间。可选方案有几种:
- USART:最常用,硬件简单,两根线TX/RX,波特率115200足够。缺点是如果不用DMA,每个字节都要中断,CPU开销大。
- I2C:速度慢,不适合持续数据流,而且电调上I2C总线容易被电机噪声干扰。
- SPI:速度快,但需要额外的片选线,飞控端也要支持SPI从机模式,兼容性差。
- 单线半双工:BLHeli的Telemetry就是单线,但AM32的硬件设计通常把USART的TX和RX分开,用单线需要额外配置。
综合来看,USART是AM32上最现实的选择。STM32F103的USART1挂在APB2上,最高可以跑到4.5Mbps,115200绰绰有余。关键是怎么把数据高效地搬进USART的发送寄存器,这就引出了DMA。
3. DMA和USART的配合方式
3.1 为什么不用中断发送
如果你用HAL库的HAL_UART_Transmit()函数,它是阻塞发送,每个字节都要等TXE标志置位,发完一帧10个字节大概需要10*(1/115200)*10 ≈ 0.87ms。这0.87ms里CPU什么都干不了,对于电调来说,电机换相控制是微秒级的,阻塞0.87ms可能导致换相延迟,电机发出异响甚至失步。
用中断发送呢?每发一个字节进一次中断,10个字节就是10次中断。STM32F103的NVIC中断响应大概需要12个时钟周期,72MHz主频下约0.17us,10次中断加上中断服务程序的执行时间,总开销也不小。而且如果Telemetry发送频率高,中断会频繁打断电机控制循环。
DMA发送就优雅多了:你只需要把一帧数据准备好,放到一个缓冲区里,然后告诉DMA“从这个地址搬10个字节到USART的DR寄存器”,DMA硬件会自动完成搬运,每搬一个字节不需要CPU干预。搬完之后DMA产生一个传输完成中断,你在中断里准备下一帧数据。CPU只在帧与帧之间介入,中间过程完全解放。
3.2 DMA通道选择和配置要点
STM32F103的DMA1有7个通道,USART1_TX通常映射到DMA1_Channel4,USART1_RX映射到DMA1_Channel5。这个映射关系是固定的,查参考手册的DMA请求映射表就能确认。如果你用的是USART2,TX是DMA1_Channel7,RX是DMA1_Channel6。
配置DMA的时候有几个关键参数:
- 传输方向:外设到存储器(接收)或存储器到外设(发送)
- 数据宽度:外设端和存储器端都设为8位(Byte),因为USART的DR寄存器是8位的
- 循环模式:发送用Normal模式,发完一帧就停;接收可以用Circular模式,配合空闲中断
- 优先级:建议设为Medium或者High,但不要设Very High,避免抢占电机控制的DMA(如果有的话)
- 中断使能:使能传输完成中断(TC),在中断里处理下一帧
在CubeMX里配置的时候,注意DMA的“Mode”要选Normal,不要选Circular,否则DMA会一直循环发送同一个缓冲区,数据会重复。这个坑我踩过,飞控端收到的数据一直是第一帧的内容,查了半天才发现是DMA模式选错了。
3.3 双缓冲和空闲中断的取舍
对于接收方向,DMA加空闲中断(IDLE)是STM32串口接收的经典方案。空闲中断在总线空闲一个字节时间后触发,你可以在中断里计算收到了多少字节,然后处理数据。这个方案的好处是不需要知道对方发多少字节,来多少收多少。
但Telemetry主要是发送方向,接收方向在AM32上通常用于接收飞控的配置命令。如果你只需要发送遥测,接收方向可以简单用中断或者轮询。不过为了完整性,我还是建议把接收也配上DMA加空闲中断,这样飞控发来的参数调整命令不会丢。
双缓冲(Double Buffer)在STM32F103的DMA上支持有限,F4系列有真正的双缓冲模式,F103需要用两个DMA通道或者手动切换缓冲区。对于Telemetry发送来说,单缓冲加传输完成中断就够了,没必要上双缓冲,增加复杂度。
4. CRC8校验的实现细节
4.1 CRC8的多种变体
CRC8不是只有一个标准,不同的多项式、初始值、输入反转、输出反转组合起来有几十种变体。BLHeli32 Telemetry用的是CRC8-DVB-S2,参数如下:
- 多项式:0x07
- 初始值:0x00
- 输入反转:否
- 输出反转:否
- 结果异或:0x00
但有些AM32的版本用的是CRC8-MAXIM,多项式0x31,初始值0x00,结果异或0x00。这两个算出来的校验值完全不同。如果你发现飞控丢弃了所有帧,第一件事就是确认CRC参数。
4.2 查表法还是逐位计算
CRC8的计算有两种实现方式:逐位计算和查表法。逐位计算代码简单,但每个字节要循环8次,10个字节就是80次循环,在72MHz的F103上大概消耗几微秒。查表法需要一个256字节的表,计算时直接查表,速度快,但占用Flash空间。
对于Telemetry这种每秒发送几十到几百帧的场景,逐位计算完全够用。我的建议是先用逐位计算实现,确认功能正常后再考虑优化。下面是一个逐位计算的CRC8实现:
uint8_t crc8_dvb_s2(uint8_t crc, uint8_t data) { crc ^= data; for (uint8_t i = 0; i < 8; i++) { if (crc & 0x80) { crc = (crc << 1) ^ 0x07; } else { crc <<= 1; } } return crc; }调用的时候,初始crc传0x00,然后对前9个字节依次调用,最后得到的crc就是第10个字节。
4.3 校验失败的常见原因
CRC算不对,除了参数搞错,还有几个隐蔽的坑:
- 字节序问题:电压、电流、转速是多字节的,小端序和大端序搞反了,CRC自然不对。确认你的飞控端期望的是小端序还是大端序。
- 数据类型截断:比如电压是uint16_t,你传的时候强转成uint8_t,高位丢了,CRC算出来和飞控端不一致。
- 缓冲区越界:如果你发送的缓冲区比实际数据长,CRC计算包含了未初始化的字节,结果随机。
- CRC计算范围:有些协议CRC只算数据字节,不算帧头;有些算全部。确认你的协议里CRC覆盖哪些字节。
我遇到过一次CRC随机失败的问题,最后发现是DMA发送完成中断里,我在准备下一帧数据时没有等DMA完全停止,新数据覆盖了正在发送的缓冲区。解决办法是在传输完成中断里先关闭DMA,再填充数据,最后重新使能DMA。
5. 在AM32代码框架里落地Telemetry
5.1 AM32的代码结构概览
AM32的源码结构比较清晰,主要文件包括:
main.c:主循环和初始化motor.c:电机换相控制adc.c:电压电流采样serial_telemetry.c:遥测发送eeprom.c:参数存储dshot.c:DShot协议解析
Telemetry相关的代码主要在serial_telemetry.c里,它负责组帧、计算CRC、通过USART发送。你需要关注的是它怎么和主循环配合,怎么在不影响电机控制的前提下把数据发出去。
5.2 发送时机和频率控制
Telemetry不能发得太频繁,否则占用太多CPU和总线时间;也不能太慢,否则地面站显示卡顿。BLHeli32的默认遥测频率大概是每秒30到50帧,具体取决于飞控的请求频率。
在AM32里,Telemetry发送通常由两种方式触发:一种是定时器中断里定时发送,另一种是收到飞控的遥测请求后回复。我建议用定时器触发,比如每20ms发一帧,这样频率稳定,不会因为飞控请求的抖动导致数据忽快忽慢。
定时器可以用TIM3或者TIM4,配置成20ms周期中断,在中断里设置一个标志位,主循环检测到标志位后组帧并启动DMA发送。注意不要在定时器中断里直接组帧和启动DMA,因为组帧需要读ADC值、算CRC,耗时较长,会阻塞其他中断。
5.3 组帧函数的实现
组帧函数的核心任务是把各个传感器的值读出来,按照协议格式填充到发送缓冲区,计算CRC,然后启动DMA。下面是一个简化的实现示例:
#define TELEMETRY_FRAME_LEN 10 uint8_t telemetry_tx_buf[TELEMETRY_FRAME_LEN]; void telemetry_build_frame(void) { uint16_t voltage = adc_get_voltage(); // 单位0.01V uint16_t current = adc_get_current(); // 单位0.01A uint16_t rpm = motor_get_erpm(); // 电周期每秒 uint8_t temp = adc_get_temperature(); // 摄氏度 uint8_t status = motor_get_status(); telemetry_tx_buf[0] = 0x2E; // 帧头 telemetry_tx_buf[1] = temp; telemetry_tx_buf[2] = voltage & 0xFF; telemetry_tx_buf[3] = (voltage >> 8) & 0xFF; telemetry_tx_buf[4] = current & 0xFF; telemetry_tx_buf[5] = (current >> 8) & 0xFF; telemetry_tx_buf[6] = rpm & 0xFF; telemetry_tx_buf[7] = (rpm >> 8) & 0xFF; telemetry_tx_buf[8] = status; uint8_t crc = 0; for (int i = 0; i < 9; i++) { crc = crc8_dvb_s2(crc, telemetry_tx_buf[i]); } telemetry_tx_buf[9] = crc; }这个函数里,adc_get_voltage()这些函数需要你根据AM32的ADC采样代码来实现。注意电压和电流的缩放系数要和飞控端一致,否则显示值不对。
5.4 DMA发送的启动和完成处理
组帧完成后,启动DMA发送:
void telemetry_send(void) { HAL_UART_Transmit_DMA(&huart1, telemetry_tx_buf, TELEMETRY_FRAME_LEN); }HAL库的HAL_UART_Transmit_DMA()会自动配置DMA并启动传输。传输完成后会调用HAL_UART_TxCpltCallback()回调函数,你可以在里面设置一个标志位,表示上一帧发送完成,可以准备下一帧了。
但这里有个问题:如果你在上一帧还没发完的时候就调用HAL_UART_Transmit_DMA(),HAL库会返回HAL_BUSY,新数据发不出去。所以你需要一个状态机来管理发送过程:
- 空闲状态:等待定时器触发
- 组帧状态:填充缓冲区,计算CRC
- 发送状态:DMA正在搬运数据
- 完成状态:DMA传输完成,回到空闲状态
用状态机可以避免发送冲突,也能在发送失败时重试。
6. 实战中遇到的坑和解决方案
6.1 串口DMA发送不能连续发送
这是HAL库的一个经典问题。你调用HAL_UART_Transmit_DMA()发送第一帧,成功;发送第二帧,返回HAL_BUSY。原因是HAL库在传输完成后没有正确清除内部状态,或者你的回调函数里没有重新使能发送。
解决办法有几个:一是用LL库直接操作寄存器,绕过HAL的状态管理;二是在HAL_UART_TxCpltCallback()里手动清除huart->gState;三是用DMA的传输完成中断直接操作,不用HAL的封装。
我最终选的是第三种方案,直接配置DMA和USART寄存器,不经过HAL的UART发送函数。这样代码更精简,也更容易控制发送时机。具体做法是:使能USART1的DMA发送请求(设置CR3寄存器的DMAT位),配置DMA1_Channel4的源地址、目的地址、传输长度,然后使能DMA通道。传输完成后DMA产生TC中断,在中断里清除标志位,准备下一帧。
6.2 电机噪声导致串口误码
电调上电机换相会产生强烈的电磁干扰,串口线如果走线不好,很容易误码。表现是飞控端偶尔收到CRC错误的帧,或者数据跳变。
硬件上的解决办法:串口线尽量短,远离电机线;TX和RX之间加磁珠或者小电容滤波;如果条件允许,用屏蔽线。软件上的解决办法:降低波特率,115200不行就降到57600;增加发送重复次数,同一帧发两次,飞控端取正确的那个;在CRC校验失败时请求重发。
我实测下来,115200波特率下,如果串口线长度小于10cm,并且和电机线保持一定距离,误码率可以接受。如果线长了,降到57600会稳定很多。
6.3 DMA传输完成中断不触发
有时候DMA配置好了,数据也发出去了,但传输完成中断就是不触发。排查思路:
- 确认DMA的TC中断使能位(CCR寄存器的TCIE位)置1了
- 确认NVIC里对应的DMA通道中断使能了
- 确认在中断服务程序里清除了TC标志位(写1到IFCR寄存器)
- 确认DMA没有因为传输错误而停止(检查TE标志位)
还有一个隐蔽的原因:如果你在DMA传输过程中修改了传输长度或者源地址,DMA可能会异常终止。确保在DMA使能之前配置好所有参数,使能之后不要动。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 飞控收不到数据 | TX/RX接反 | 交换TX和RX试试 | 更正接线 |
| 数据全是乱码 | 波特率不匹配 | 确认双方波特率 | 统一为115200 |
| CRC校验失败 | CRC参数错误 | 对比协议文档 | 改用正确的多项式和初始值 |
| 电压显示为0 | ADC通道配置错误 | 检查ADC采样代码 | 更正通道和缩放系数 |
| 发送几帧后停止 | DMA状态未清除 | 检查TC中断 | 在中断里清除标志并重启DMA |
| 电机抖动 | 发送阻塞控制循环 | 测量发送耗时 | 改用DMA发送,降低频率 |
| 数据跳变 | 电磁干扰 | 观察误码率 | 缩短线长,加滤波,降波特率 |
7. 调试工具和验证方法
7.1 用逻辑分析仪抓串口波形
调试串口通信,逻辑分析仪是最直接的工具。把探头接到TX线上,设置波特率115200,触发方式设为下降沿,就能抓到每一帧的波形。你可以直接看到帧头、数据字节、CRC字节,和你的代码对照,确认发送的内容对不对。
我用的是一款几十块钱的USB逻辑分析仪,配合开源的Sigrok/PulseView软件,解码串口协议很方便。抓到的波形可以直接导出成文本,和代码里的缓冲区对比。
7.2 用飞控地面站验证数据
如果你有Betaflight飞控,可以把电调的Telemetry线接到飞控的某个串口上,在Betaflight配置里打开遥测,然后在地面站软件里看电调数据。这是最接近实际使用场景的验证方法。
注意Betaflight对Telemetry的格式有要求,不是所有格式都支持。你需要确认你的AM32固件输出的格式和Betaflight期望的一致。如果不一致,可能需要在飞控端做转换,或者修改AM32的代码。
7.3 用串口助手做单元测试
在把Telemetry接到飞控之前,先用USB转串口模块把电调的TX接到电脑上,用串口助手接收数据。设置好波特率,看能不能收到完整的帧。你可以写一个简单的Python脚本,解析收到的数据,验证CRC和数值范围。
import serial import struct def crc8_dvb_s2(data): crc = 0 for byte in data: crc ^= byte for _ in range(8): if crc & 0x80: crc = (crc << 1) ^ 0x07 else: crc <<= 1 crc &= 0xFF return crc ser = serial.Serial('COM3', 115200, timeout=1) while True: frame = ser.read(10) if len(frame) == 10: if crc8_dvb_s2(frame[:9]) == frame[9]: temp = frame[1] voltage = struct.unpack('<H', frame[2:4])[0] / 100.0 current = struct.unpack('<H', frame[4:6])[0] / 100.0 rpm = struct.unpack('<H', frame[6:8])[0] print(f"温度:{temp}C 电压:{voltage}V 电流:{current}A 转速:{rpm}") else: print("CRC错误")这个脚本可以快速验证你的Telemetry输出是否正确,比接飞控方便得多。
8. 性能优化和进阶玩法
8.1 降低CPU占用
Telemetry发送本身占用CPU不多,但如果你在组帧的时候做了浮点运算或者复杂的换算,就会增加CPU负载。优化方向:
- 用整数运算代替浮点运算,比如电压用mV为单位,避免除以100.0
- 把CRC计算表放在Flash里,用查表法代替逐位计算
- 减少组帧频率,从50Hz降到30Hz,对显示效果影响不大
我实测过,在72MHz的F103上,逐位CRC计算10个字节大概消耗5us,查表法只要1us。如果每秒发50帧,逐位计算占用0.025%的CPU时间,其实可以忽略。但如果你的主循环还有其他耗时操作,优化一下也无妨。
8.2 支持多种遥测格式
如果你想让AM32的Telemetry兼容更多飞控,可以在代码里实现多种格式,通过参数选择。比如BLHeli32格式、KISS格式、自定义ASCII格式。这样同一个固件可以适配不同的飞控,提高通用性。
实现方式是用函数指针或者switch-case,根据配置参数调用不同的组帧函数。注意不同格式的帧长和CRC参数不同,发送缓冲区要足够大。
8.3 遥测数据的扩展
除了基本的电压、电流、转速、温度,你还可以扩展其他数据:
- 电机堵转检测:通过电流突变判断
- 温度保护:超过阈值时在状态位里标记
- 运行时间统计:记录电机累计运行时间
- 错误码:记录过流、过温、失步等错误
这些数据可以放在状态字节或者扩展帧里。但要注意帧长不能超过飞控端的接收缓冲区,否则会丢数据。
9. 一些个人经验
我在AM32上做Telemetry开发,前后折腾了大概两周,大部分时间花在调试CRC和DMA上。最开始用HAL库的阻塞发送,电机一跑起来就抖动,后来换成DMA发送,问题解决。CRC算不对的时候,我用逻辑分析仪抓了波形,和飞控端的解析代码逐字节对比,才发现是多项式搞错了。
还有一次,Telemetry数据在电机低速时正常,高速时全是乱码。查了半天,发现是电机高速时电流大,电源纹波导致MCU的USART时钟不稳定。解决办法是在USART的时钟线上加了一个小电容,问题缓解。
如果你也在做类似的项目,我的建议是:先把协议搞清楚,用串口助手验证数据格式,再接飞控。不要一上来就接飞控调试,那样出了问题你分不清是电调的问题还是飞控的问题。另外,逻辑分析仪真的很有用,几百块钱的投资能省你很多时间。
最后分享一个小技巧:在Telemetry帧里加一个递增的序号字节,飞控端可以据此判断是否丢帧。这个字节不参与CRC计算,或者参与也行,只要双方约定好。这样调试的时候能快速发现是发送端丢帧还是接收端丢帧。