《基于STM32duino的CRSF协议实现:ELRS接收机遥控通道数据解析与发送指南》

本文详细介绍了如何在STM32duino平台上实现CRSF(Crossfire)协议,以解析来自ELRS(ExpressLRS)接收机的遥控通道数据,并演示如何构建和发送CRSF遥测数据包。CRSF协议是一种用于遥控器和飞控之间低延迟、双向通信的协议,广泛应用于FPV无人机等遥控飞行器中。

文档首先概述了CRSF协议的特性和数据包结构,然后提供了一个完整的代码实现,包括串口初始化、CRC校验、数据包解析与构建等核心功能。代码采用模块化设计,易于理解和移植。

本指南不仅展示了如何正确解码16个遥控通道的11位数据,还详细解释了每个函数的作用和实现逻辑。此外,文档还提供了测试验证方法和潜在的应用扩展建议,使读者能够将这一实现应用到实际的飞控开发项目中。


1. CRSF协议简介

CRSF(Crossfire)协议是一种用于遥控器和飞控之间低延迟、双向通信的协议,广泛应用于遥控飞行器(例如FPV无人机)。CRSF协议具备以下特性:

  • 低延迟、高速数据传输:CRSF通常使用高达420,000bps的波特率,确保了遥控器和飞控之间的低延迟。
  • 支持16个通道数据传输:每个通道的数据被压缩成11位,可以传输16个通道的信息。CRSF原始通道值范围为172到1811。
  • 双向通信:可以传输飞行控制器的状态数据(如GPS数据、链路状态)到遥控器,同时也传输遥控通道数据到飞控。
  • 帧校验:使用CRC-8 DVB-S2校验确保数据完整性。

2. 硬件串口和波特率配置

在STM32duino中,通过硬件串口(例如 Serial2)实现与ELRS接收机或目标设备的通信。CRSF协议通常要求串口波特率设置为 420,000bps。串口的其他配置为:8数据位、无校验位、1停止位(8N1),这是标准的串行通信设置。

代码中的initCRSFSerial()函数负责初始化串口通信:

// CRSF协议标准波特率
#define CRSF_BAUDRATE 420000

// 假设使用Serial2作为CRSF通信串口
HardwareSerial &crsfSerial = Serial2;

// 初始化串口,波特率设置为CRSF协议要求的420000
void initCRSFSerial() {
    crsfSerial.begin(CRSF_BAUDRATE);
}

该函数调用了STM32duino的串口库,设置波特率为420,000bps。在STM32中,硬件串口可以根据项目需求选择不同的Serial端口,例如Serial1, Serial2等。


3. CRSF 数据包结构 (以RC Channels Packed为例)

CRSF协议的数据包结构具有以下特征。以最常见的遥控通道数据包(RC Channels Packed, 类型 0x16)为例:

字节偏移 长度 (字节) 内容 说明
0 1 同步字节 (Sync Byte) 固定为 0xC8
1 1 长度 (Length) 类型(1) + 负载(22) + CRC(1) = 24 (十六进制 0x18)
2 1 类型 (Type) 表示数据帧的类型,RC通道数据类型为0x16
3-24 22 负载 (Payload) 实际的16个通道数据,每个通道用11位表示,共176位 (22字节)
25 1 CRC校验 (CRC) CRC-8 DVB-S2校验,校验范围从**类型(Type)字节到负载(Payload)**结束
总计 26

负载 (Payload) 部分说明 (RC Channels Packed)

负载部分存储了16个通道的值,每个通道值使用11位来表示。这些11位数据的原始值范围是 172 到 1811。这些原始值在飞控中通常会被映射到标准的PWM脉宽(如1000µs-2000µs或988µs-2012µs)。这176位数据被紧密打包到22字节中进行传输。

例如:

  • 通道1: 11位(例如 00010101100 对应原始值 172)
  • 通道2: 11位,紧接着通道1

数据的解码与编码需要考虑到每个通道数据是连续的11位,并需要适当的移位和掩码操作。


4. CRC校验

CRSF协议使用 CRC-8 DVB-S2 校验算法,校验值附加在数据包的最后。CRC的作用是确保数据在传输过程中没有被损坏。CRSF协议使用的多项式是 0xD5,初始值为0x00,输入和输出均非反射。

在代码中,calcCRC()函数负责计算校验值:

// 计算CRC-8 DVB-S2校验值,使用多项式0xD5
uint8_t calcCRC(const uint8_t *data, uint8_t len) {
    uint8_t crc = 0; // 初始值为0
    for (uint8_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x80) {
                crc = (crc << 1) ^ 0xD5;
            } else {
                crc = crc << 1;
            }
        }
    }
    return crc;
}
  • 该函数接收一个数据数组和其长度,使用多项式0xD5来迭代计算校验值。
  • CRC的计算范围是从数据包的“类型(Type)”字节开始,到“负载(Payload)”的最后一个字节结束。

5. 打包和发送CRSF数据包 (以RC Channels Packed为例)

sendCRSFFrame() 函数负责打包16个通道的原始CRSF数据(172-1811范围),并通过串口发送。打包过程涉及到将16个通道的11位数据依次存储到22字节的负载缓冲区中。

// CRSF协议相关常量
#define CRSF_SYNC_BYTE 0xC8
#define CRSF_FRAMETYPE_RC_CHANNELS_PACKED 0x16
#define CRSF_PAYLOAD_SIZE_RC_CHANNELS 22
#define CRSF_MAX_CHANNEL 16

// 假设的遥控通道原始值 (172-1811范围)
uint16_t rcChannelValues[CRSF_MAX_CHANNEL]; // 应填充172-1811范围的值

// 发送CRSF RC Channels Packed帧
void sendCRSFFrame() {
    // 总帧长度: Sync(1) + Length(1) + Type(1) + Payload(22) + CRC(1) = 26字节
    uint8_t crsfData[26];
    int offset = 0;

    // 1. 同步字节
    crsfData[offset++] = CRSF_SYNC_BYTE;

    // 2. 长度字节 (Type + Payload + CRC)
    crsfData[offset++] = 1 + CRSF_PAYLOAD_SIZE_RC_CHANNELS + 1; // 1 + 22 + 1 = 24

    // 3. 类型字节
    crsfData[offset++] = CRSF_FRAMETYPE_RC_CHANNELS_PACKED;

    // 4. 打包16个通道的11位数据到负载区
    uint64_t bitBuffer = 0; // 使用64位缓冲区以容纳足够多的位
    uint8_t bitsInBuffer = 0;
    int payloadOffset = offset; // 记录负载区的起始位置

    for (int i = 0; i < CRSF_MAX_CHANNEL; i++) {
        // 确保通道值在11位范围内 (0-2047), CRSF实际有效范围是172-1811
        uint16_t channel_val = rcChannelValues[i] & 0x07FF;
        bitBuffer |= ((uint64_t)channel_val << bitsInBuffer);
        bitsInBuffer += 11;
        while (bitsInBuffer >= 8) {
            crsfData[payloadOffset++] = bitBuffer & 0xFF;
            bitBuffer >>= 8;
            bitsInBuffer -= 8;
        }
    }
    // 处理剩余的位 (如果有)
    if (bitsInBuffer > 0) {
        crsfData[payloadOffset++] = bitBuffer & 0xFF;
    }
    offset = payloadOffset; // 更新当前偏移到CRC位置

    // 5. 计算CRC校验值
    // CRC计算范围: 从Type字节开始,到Payload结束 (共 1 + 22 = 23字节)
    // crsfData[2] 是Type字节的起始位置
    uint8_t crc = calcCRC(&crsfData[2], 1 + CRSF_PAYLOAD_SIZE_RC_CHANNELS);
    crsfData[offset++] = crc; // 将CRC校验字节加到数据帧末尾

    // 通过硬件串口发送打包后的数据帧
    crsfSerial.write(crsfData, offset); // offset 此时应为 26
}

该函数的工作流程为:

  1. 帧头设置:设置同步字节、计算并设置长度字节、设置类型字节。
  2. 打包16个通道数据:将每个通道的11位原始CRSF值压缩存储到数据帧的22字节负载区中。
  3. 计算CRC校验:调用calcCRC()函数计算从**类型(Type)字节开始到负载(Payload)**结束的CRC值,并将其附加到数据帧末尾。
  4. 发送数据包:使用crsfSerial.write()通过硬件串口将完整数据包发送出去。

6. 主程序流程 (发送示例)

setup()函数初始化硬件串口,loop()函数则定期更新通道数据并调用sendCRSFFrame()发送数据包:

void setup() {
    // 初始化CRSF串口
    initCRSFSerial();

    // 示例:初始化遥控通道值 (172-1811范围)
    for(int i=0; i<CRSF_MAX_CHANNEL; i++) {
        if (i < 4) rcChannelValues[i] = 992; // 中间值 (对应1500us)
        else if (i == 4) rcChannelValues[i] = 172; // 最小值 (对应约1000us)
        else if (i == 5) rcChannelValues[i] = 1811; // 最大值 (对应约2000us)
        else rcChannelValues[i] = 992;
    }
}

void loop() {
    // 在此处可以动态更新各个通道的原始CRSF数据 (172-1811),例如:
    // rcChannelValues[0] = some_sensor_reading_mapped_to_crsf_range;

    // 每10ms发送一次CRSF数据包 (发送频率根据实际需求调整)
    sendCRSFFrame();
    delay(10);
}

setup()函数确保在系统启动时配置串口。loop()函数可以根据实际的遥控输入或飞控状态来更新各个通道的原始CRSF值 (172-1811)。然后,定期调用sendCRSFFrame()函数,发送更新后的CRSF数据包。


7. 模块化与移植 (发送示例)

由于代码模块化设计,函数如initCRSFSerial()calcCRC()sendCRSFFrame()可以很方便地移植到其他项目中。你只需修改串口配置和通道数据的来源,就可以将此代码嵌入到你的飞控开发环境中。

  • 串口选择:根据硬件情况,可以将crsfSerial修改为任意可用的硬件串口(例如Serial1,Serial2)。

8. 其他CRSF数据包类型简介

在 CRSF 协议中,除了遥控通道数据包(RC Channels Packed)外,还有多种其他类型的数据包,它们用于传递设备状态、传感器数据以及控制命令等。下面简要介绍这些不同的数据包类型、它们的内容及使用场景。

(注:以下单位和偏移量已根据CRSF协议规范进行修正)

8.1. GPS 数据包(Frame Type: 0x02

描述:传递GPS模块的位置信息。

数据包格式 (部分字段)

字段 大小 内容
Latitude 4字节 纬度,单位:度 * 1e7
Longitude 4字节 经度,单位:度 * 1e7
Ground Speed 2字节 地速,单位:0.1 km/h
GPS Heading 2字节 航向,单位:0.01 度 (centi-degrees)
Altitude 2字节 海拔高度,单位:米,通常有1000米偏移量
Satellites in use 1字节 当前使用的卫星数量

使用场景:将飞行器的定位信息传送给遥控器或地面站。


8.2. 电池状态数据包(Frame Type: 0x08

描述:传递飞行器电池的电压、电流、容量以及电量剩余情况。

数据包格式 (部分字段)

字段 大小 内容
Voltage 2字节 电压,单位:0.1V (deci-volts)
Current 2字节 电流,单位:0.1A (deci-amps)
Capacity 3字节 已消耗或剩余容量,单位:毫安时(mAh)
Battery Remaining 1字节 电池剩余电量,百分比表示 (0-100%)

使用场景:提供电池的实时状态,确保操作员能够及时了解电量并进行合理的电池管理。


8.3. 链路统计数据包(Frame Type: 0x14

描述:提供当前遥控器与飞控之间的通信链路的质量信息。

数据包格式 (部分字段)

字段 大小 内容
Uplink RSSI Ant. 1 1字节 上行RSSI信号强度(天线1),单位为-dBm
Uplink RSSI Ant. 2 1字节 上行RSSI信号强度(天线2),单位为-dBm
Uplink Link Quality (LQ) 1字节 上行链路质量,单位为百分比 (0-100%)
Uplink SNR 1字节 上行信噪比,单位为dB
RF Mode 1字节 当前射频模式 (e.g., 50Hz, 150Hz)
Downlink RSSI 1字节 下行RSSI信号强度,单位为-dBm
Downlink Link Quality (LQ) 1字节 下行链路质量,单位为百分比 (0-100%)
Downlink SNR 1字节 下行信噪比,单位为dB

使用场景:监控飞控与遥控器之间的通信质量。


8.4. 姿态数据包(Frame Type: 0x1E

描述:传递飞行器的航向、俯仰角和滚转角。

数据包格式

字段 大小 内容
Pitch 2字节 俯仰角,单位:弧度 * 10000 (0.1 millirad)
Roll 2字节 滚转角,单位:弧度 * 10000 (0.1 millirad)
Yaw 2字节 航向角,单位:弧度 * 10000 (0.1 millirad)

使用场景:用于在地面站或遥控器显示飞行器的实时姿态信息。


8.5. 飞行模式文本数据包(Frame Type: 0x21

描述:传递当前飞行器的飞行模式文本。

数据包格式

字段 大小 内容
Flight Mode 可变长度 飞行模式名称,空终止的ASCII字符串

使用场景:在遥控器或地面站显示当前飞行模式。


(其他不常用的或特定用途的数据包类型在此省略,可参考CRSF协议规范)


9. ELRS接收机遥控通道数据解析示例代码

以下是如何在STM32duino平台上接收并解析来自ELRS接收机的CRSF遥控通道数据的示例。

#include <HardwareSerial.h>

// 定义CRSF协议相关常量 (与发送部分共用)
#define CRSF_SYNC_BYTE 0xC8
#define CRSF_MAX_CHANNEL 16
#define CRSF_FRAMETYPE_RC_CHANNELS_PACKED 0x16
#define CRSF_MIN_FRAME_SIZE 4 // Sync + Length + Type + CRC (最短帧,如ping)
#define CRSF_MAX_FRAME_SIZE 64 // CRSF帧最大长度限制 (包括扩展帧)

// 假设使用Serial2作为CRSF通信串口
HardwareSerial &crsfSerial = Serial2;

// 用于存储解码后的16个通道原始CRSF值 (172-1811)
uint16_t rcChannels[CRSF_MAX_CHANNEL];

// 转换CRSF原始通道值 (172-1811) 到 PWM值 (例如 988-2012us)
uint16_t convertCrsfToPwm(uint16_t crsf_val) {
    // 线性映射: (val - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
    // CRSF: 172 (min) -> 992 (mid) -> 1811 (max)
    // PWM:  988 (min) -> 1500 (mid) -> 2012 (max)
    if (crsf_val <= 172) return 988;
    if (crsf_val >= 1811) return 2012;
    return (uint16_t)(988.0f + (crsf_val - 172.0f) * (2012.0f - 988.0f) / (1811.0f - 172.0f));
}


// CRC校验函数 (与发送部分共用)
uint8_t calcCRC(const uint8_t *data, uint8_t len) {
    uint8_t crc = 0;
    for (uint8_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x80) crc = (crc << 1) ^ 0xD5;
            else crc = crc << 1;
        }
    }
    return crc;
}

// 处理接收到的CRSF RC Channels Packed 数据包
void processCRSFRcChannelsPacket(const uint8_t *payload) {
    uint64_t bitBuffer = 0;
    uint8_t bitsInBuffer = 0;
    int byteIndex = 0;

    for (int i = 0; i < CRSF_MAX_CHANNEL; i++) {
        while (bitsInBuffer < 11) {
            // 从payload中读取字节
            bitBuffer |= ((uint64_t)payload[byteIndex++] << bitsInBuffer);
            bitsInBuffer += 8;
        }
        rcChannels[i] = bitBuffer & 0x07FF; // 取低11位作为通道值
        bitBuffer >>= 11;
        bitsInBuffer -= 11;
    }

    // 打印解析出的各个通道原始值和转换后的PWM值
    // Serial.println("CRSF Channels Received:");
    // for (int i = 0; i < CRSF_MAX_CHANNEL; i++) {
    //     Serial.print("CH");
    //     Serial.print(i + 1);
    //     Serial.print(": ");
    //     Serial.print(rcChannels[i]); // 原始CRSF值 (172-1811)
    //     Serial.print(" (PWM: ");
    //     Serial.print(convertCrsfToPwm(rcChannels[i]));
    //     Serial.println("us)");
    // }
}

// 从串口接收并解析CRSF数据包
void receiveCRSF() {
    static uint8_t rxBuffer[CRSF_MAX_FRAME_SIZE];
    static uint8_t rxPos = 0;
    static uint8_t expectedLength = 0;

    while (crsfSerial.available()) {
        uint8_t byteReceived = crsfSerial.read();

        if (rxPos == 0) { // 等待同步字节
            if (byteReceived == CRSF_SYNC_BYTE) {
                rxBuffer[rxPos++] = byteReceived;
            }
        } else if (rxPos == 1) { // 接收长度字节
            if (byteReceived >= (CRSF_MIN_FRAME_SIZE - 2) && byteReceived <= (CRSF_MAX_FRAME_SIZE - 2) ) { // 长度字节范围校验 (Type+Payload+CRC)
                rxBuffer[rxPos++] = byteReceived;
                expectedLength = byteReceived + 2; // 完整帧长度 = Length_Field + SyncByte + LengthByte
            } else { // 无效的长度字节,重置
                rxPos = 0;
            }
        } else { // 接收剩余数据 (Type, Payload, CRC)
            rxBuffer[rxPos++] = byteReceived;
            if (rxPos == expectedLength) { // 接收到完整帧
                // CRC校验范围: 从Type字节(rxBuffer[2])到Payload结束
                // CRC本身位于帧尾 rxBuffer[expectedLength-1]
                // 校验的数据长度 = expectedLength - 3 (排除Sync, Length, CRC本身)
                // 或者等于 Length_Field - 1 (排除CRC本身)
                uint8_t calculatedCRC = calcCRC(&rxBuffer[2], expectedLength - 3);
                if (calculatedCRC == rxBuffer[expectedLength - 1]) {
                    // CRC校验成功,根据类型处理数据包
                    uint8_t frameType = rxBuffer[2];
                    const uint8_t *payload = &rxBuffer[3]; // 负载数据从第3个字节开始
                    // uint8_t payloadLength = expectedLength - 4; // Sync, Length, Type, CRC

                    if (frameType == CRSF_FRAMETYPE_RC_CHANNELS_PACKED) {
                        processCRSFRcChannelsPacket(payload);
                    } else {
                        // 在此处理其他类型的CRSF帧
                        // Serial.print("Received CRSF Frame Type: 0x");
                        // Serial.println(frameType, HEX);
                    }
                } else {
                    // Serial.println("CRC Error!");
                }
                rxPos = 0; // 重置缓冲区,准备接收下一帧
            } else if (rxPos >= CRSF_MAX_FRAME_SIZE) { // 缓冲区溢出,通常不应发生
                rxPos = 0;
            }
        }
    }
}

void setup() {
    // 初始化调试串口
    Serial.begin(115200); // 用于打印调试信息
    while(!Serial); // 等待串口连接 (某些板子需要)
    Serial.println("CRSF Parser Initializing...");

    // 初始化CRSF串口
    initCRSFSerial();
    Serial.println("CRSF Serial Initialized at 420000bps.");
}

void loop() {
    receiveCRSF(); // 循环接收并解析CRSF数据包

    // 可以在这里使用解析到的 rcChannels[] 数据
    // 例如,每秒打印一次通道1的值
    // static unsigned long lastPrintTime = 0;
    // if (millis() - lastPrintTime > 1000) {
    //     lastPrintTime = millis();
    //     Serial.print("CH1 (Raw): ");
    //     Serial.print(rcChannels[0]);
    //     Serial.print(" (PWM): ");
    //     Serial.println(convertCrsfToPwm(rcChannels[0]));
    // }
}

10. 代码说明 (解析示例)

10.1. initCRSFSerial()calcCRC()

这两个函数与发送部分的实现相同。

10.2. convertCrsfToPwm()

一个辅助函数,用于将CRSF的原始11位通道值(172-1811)线性映射到标准的PWM脉宽值(例如988-2012µs)。这对于在飞控中直接使用或调试非常有用。

10.3. processCRSFRcChannelsPacket()

这是解析CRSF遥控通道数据包负载的核心函数。它接收指向负载数据区的指针,并从中提取16个11位的通道值,存储到全局数组 rcChannels[] 中。

10.4. receiveCRSF()

该函数是CRSF协议的接收状态机:

  1. 等待同步字节 (CRSF_SYNC_BYTE):如果接收到的第一个字节不是同步字节,则忽略。
  2. 接收长度字节:第二个字节是长度字段,它决定了期望接收的完整帧的长度。对长度字节进行范围校验。
  3. 接收剩余数据:继续接收类型、负载和CRC字节,直到达到期望的帧长度。
  4. CRC校验:当接收到完整帧后,计算从**类型(Type)字节到负载(Payload)**结束的CRC值,并与接收到的CRC字节进行比较。
  5. 处理数据包:如果CRC校验通过,则根据帧类型调用相应的处理函数(例如 processCRSFRcChannelsPacket())。
  6. 重置状态:处理完一帧或发生错误后,重置接收状态,准备接收下一帧。
10.5. setup()loop()

setup() 初始化调试串口和CRSF通信串口。loop() 持续调用 receiveCRSF() 来检查和处理传入的CRSF数据。


11. 测试与验证 (解析示例)

将ELRS接收机的CRSF输出连接到STM32配置的crsfSerial(例如TX引脚连接到STM32的RX2)。上传代码后,打开Arduino IDE的串口监视器(设置为115200波特率),你应该能看到解析出的遥控通道数据。操作遥控器的摇杆和开关,观察串口监视器上对应通道值的变化,以验证解析是否正确。


12. 结论

本文档提供了一个基于STM32duino的CRSF协议发送与接收的模块化实现。通过理解CRSF协议的帧结构、CRC校验机制以及通道数据的打包/解包方式,开发者可以在自己的飞控项目中有效地集成ELRS或其他CRSF兼容设备。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐