Post

Embedded CRSF Driver

Crossfire (CRSF) is a high level communication protocol developed by Team Black Sheep (TBS) for transmitting control and telemetry data between a receiver and Flight Controller (FC). Originally intended for use in TBS devices, the protocol is now implemented in nearly every FC firmware and is used by most off-the-shelf receivers including ExpressLRS and Tracer.

This article serves as an introduction to the CRSF protocol (Skip Ahead) and as documentation for my FreeRTOS Queue based implementation of a CRSF packet encoder/decoder (Skip Ahead).

CRSF Protocol Basics

Crossfire (CRSF) is a high-speed, low latency communication protocol for sending and receiving control and telemetry data between a drone and controller.

The following section will focus on the CRSF packet structure, device addressing, base message types, and how to format a CRSF message for transmission.

CRSF Packet Structure

The basic structure of every CRSF packet is identical, consisting of an address, length, type, payload, and CRC.

1
<address> <length> <type> <payload ... > <CRC>

The address, length, type, and CRC are each one byte (8 bits). The payload can range from $2$ to $62$ bytes to accommodate various message formats. Note that the length field excludes the address. Therefore, length is calculated as:

\[Len(payload) + Len(type) + Len(CRC) = Len(payload)+2\]

Additionally, the CRC is calculated on only the type and payload, excluding the address and length bytes.

Device Addressing

Each CRSF message contains an “address” field, denoting the address of the transmitting/receiving device. The primary address used is 0xC8, corresponding to the flight controller. This address must be used when sending/receiving messages to/from the flight controller. Other possible addresses are shown in the table below:

AddressDevice Description
0x00Broadcast address
0x10USB Device
0x12Bluetooth Module
0x80TBS CORE PNP PRO
0x8AReserved
0xC0PNP PRO digital current sensor
0xC2PNP PRO GPS
0xC4TBS Blackbox
0xC8Flight controller
0xCAReserved
0xCCRace tag
0xEARadio Transmitter
0xEBReserved
0xECCrossfire / UHF receiver
0xEECrossfire transmitter

Message Types

The CRSF protocol contains message types for both control and telemetry data. The following section outlines the base message types used by INAV and Betaflight. Custom message types can be used, but require implementation on the FC side.

NOTE The number next to the each message is its ID in hex.

RC Message (0x16)

The Remote Control (RC) message can be used to transmit stick commands from a device or receiver to the flight controller. The RC message supports up to 16 RC channels. Each channel is 11 bits and the 16 channels are packed into a 22 byte format. The message format is shown below:

1
2
3
4
5
6
7
struct rc_channels_msg{
	unsigned int chnl1 : 11;
	unsigned int chnl2 : 11;
	unsigned int chnl3 : 11;
	...
	unsigned int chnl16 : 11;
};

Many receivers and FC firmware’s represent channels using $\mu$s. CRSF however, uses ticks. Therefore the following functions must be used to convert between the two measurements.

1
2
TICKS_TO_US(x) ((x - 992) * 5 / 8 + 1500) 
US_TO_TICKS(x) ((x - 1500) * 8 / 5 + 992)

The link statistics message contains status information for the link between the receiver and the transmitter. Uplink is the connection between the ground and UAV, downlink is the connection from the UAV to the ground station.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct __attribute__((packed)) {
    // dBm *-1
    uint8_t uplink_RSSI_1;
    // dBm *-1
    uint8_t uplink_RSSI_2;
    // percent
    uint8_t uplink_quality;
    // uplink SNR (db)
    int8_t uplink_SNR;
    // enum ant 1 = 0, 2
    uint8_t diversity_active_antenna;
    // enum Mode (4fps = 0, 50fps, 150Hz)
    uint8_t RF_mode;
    // enum (0mW, 10mW, 25mW, 100mW, 500mW, 1000mW, 2000mW)
    uint8_t tx_power;
    // dBm * -1
    uint8_t downlink_RSSI;
    // percent
    uint8_t downlink_quality;
    // db
    int8_t downlink_SNR;
} _crsf_link_t;

Battery Status Message (0x08)

The battery status message contains exactly the information one would expect. Its format is as follows:

1
2
3
4
5
6
7
8
9
10
typedef struct __attribute__((packed)) {
    // mV * 100
    uint16_t voltage;
    // mA * 100
    uint16_t current;
    // mAh (24 bits)
    unsigned int capacity : 24;
    // percent (0-100]
    uint8_t percent_remaining;
} _crsf_battery_t;

Flight Mode Message (0x21)

The flight mode message contains a null terminated string indicating the current flight mode of the FC. This field is FC firmware dependent. For Betaflight, the possible strings are covered under the Betaflight CRSF Documentation.

The message format is as follows:

1
2
3
struct crsf_fcmode {
    char mode[];
};

Attitude Message (0x1E)

The attitude message contains the current pitch, yaw, and roll values from the flight controllers IMU. All angles are represented in integer format, in $\frac{\text{radians}}{10,000}$.

1
2
3
4
5
struct crsf_attitude_t{
    int16_t pitch;
    int16_t roll;
    int16_t yaw;
};

GPS Message (0x02)

Finally, the GPS message contains the current GPS information, if the drone has one. The GPS message format is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct crsf_gps {
    // degrees / 10_000_000
    int32_t lattitude;
    // degrees / 10_000_000
    int32_t longitude;
    // km/h / 100
    uint16_t groundspeed;
    // degree / 100
    uint16_t heading;
    // meter - 1000m offset
    uint16_t altitude;
    uint8_t sat_count;
};

Message Formatting

All of the C structures shown above conform to standard CRSF message types. Thus, they can be directly used as payloads within the CRSF message format.

To format a CRSF message for transmission, first append the address (0xC8 for the Flight Controller) and the length bits to the front of the message. Again, note that the message length excludes the address byte. Finally, calculate the 8-bit CRC using the $x^7+x^6+x^4+x^2+x^0$ hash and append it as the last byte in the message.

FreeRTOS Interface Implementation

The following section serves as documentation for my FreeRTOS Queue-based implementation of a CRSF interface. This interface is designed to completely abstract the CRSF interface, providing only read and write functions that return unpacked messages in standardized formats. Furthermore, this implementation is designed to completely abstract the CRSF layer from any hardware constraints, relying only on FreeRTOS stream buffers.

The implementation is designed around the following features:

  • Only using static memory defined within the CRSF_t structure
  • Only reliant on FreeRTOS interfaces
  • Independent from any hardware interfaces
  • Minimal message buffering for low-latency and high throughput

Initialization

The CRSF protocol itself does not require any initialization. However, the implementation sets up an internal FreeRTOS task, used to parse the CRSF packets as bytes are delivered through the input stream buffer.

To initialize the CRSF_t structure, setup the queues, and create the internal task, the following function can be called:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
StreamBufferHandle_t crsf_init(CRSF_t *pHndl, StreamBufferHandle_t pTx_hndl) {
    if (!pHndl)
        return NULL;
    if (!pTx_hndl)
        return NULL;

    // Setup Write handle
    pHndl->tx.semphr_hndl =
        xSemaphoreCreateMutexStatic(&pHndl->tx.static_semphr);
    pHndl->tx.pBuf_hndl = pTx_hndl;

    // Setup Read Handle
    pHndl->rx.hndl = xStreamBufferCreateStatic(
        configMINIMAL_STACK_SIZE, 1, pHndl->rx.buf, &pHndl->rx.static_stream);

    // Setup the CRSF rx task
    pHndl->tsk.hndl = xTaskCreateStatic(vCRSF_Hndl_tsk,
                                        "CRSF",
                                        CRSF_STACK_SIZE,
                                        (void *) pHndl,
                                        2,
                                        pHndl->tsk.stack,
                                        &pHndl->tsk.static_tsk);
    if (!pHndl->tsk.hndl) {
        pHndl->state = eCRSFTskCreateFail;
        return NULL;
    }

    return pHndl->rx.hndl;
}

This function requires two arguments. First is the CRSF_t handle, which should be created statically at initialization time by whatever task “owns” the CRSF interface. This handle contains all information required by the interface, including the FreeRTOS task and stack as well as internal data structures and semaphores.

The second argument required by the initialization function is a FreeRTOS stream buffer handle. This handle is used to transmit messages, and must be initialized externally. External initialization of this buffer allows its size and trigger values to be configured by whichever interface will be dealing with outbound packets.

Finally, the initialization function returns its incoming buffer in the form of another FreeRTOS stream buffer handle. This buffer is used by the interface to receive and parse incoming packets.

Reading CRSF Data

The CRSF interface automatically receives and and parses packets from its input buffer as the arrive. This is done using the internal task that was setup by the initialization function. Once parsed, incoming packets are stored within the CRSF_t structure such that they can be read by an outside task. Note that after parsing, packets are not buffered, thus, only the most recent packet of each type is stored. This ensures:

  1. Only the most recent command/information can be read by an outside task.
  2. Minimal memory overhead is needed to store the parsed packets.
  3. Outside tasks do not need to block waiting for CRSF data

The following read functions are available:

1
2
3
4
eCRSFError crsf_read_rc(CRSF_t *pHndl, crsf_rc_t *pChannels);
eCRSFError crsf_read_battery(CRSF_t *pHndl, crsf_battery_t *pBattery);
eCRSFError crsf_read_attitude(CRSF_t *pHndl, crsf_attitude_t *pAttitude);
eCRSFError crsf_read_mode(CRSF_t *pHndl, crsf_fcmode_t *pMode);

All read functions are non-blocking and thread safe. Given that these functions return from the internal data structure, they can be called multiple times to return the same data. Data is returned through the second parameter, and is transferred via copy.

Writing CRSF Packets

Writing to the CRSF interface is a blocking operation. For a task to write out a CRSF packet, it must wait to hold the transmit semaphore.

The following write functions are available:

1
2
3
4
eCRSFError crsf_write_rc(CRSF_t *pHndl, const crsf_rc_t *pChannels);
eCRSFError crsf_write_battery(CRSF_t *pHndl, const crsf_battery_t *pBattery);
eCRSFError crsf_write_attitude(CRSF_t *pHndl, const crsf_attitude_t *pAttitude);
eCRSFError crsf_write_mode(CRSF_t *pHndl, const crsf_fcmode_t *pMode);

Each write function first copies the data from the normalized format to the CRSF format, ensuring that all data is properly formatted with the correct unit conversions. The write function also ensures that minimum and maximum value constraints are not violated by clamping input data values.

After applying the data transformation, the following internal function is called to write the packet into the output buffer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
eCRSFError _crsf_send_packet(CRSF_t *pHndl, _crsf_msg_t *msg, enum eCRSFMsgId id, uint8_t len) {
    if (!pHndl)
        return eCRSFNULL;
    if (!msg)
        return eCRSFNULL;

    if(len >= CRSF_DATA_MAXLEN){
        return eCRSFPktOverLen;
    }

    // Addr = CRSF Addr FC
    msg->addr = CRSF_ADDR;
    msg->length = len + 2; // type + payload + crc
    msg->type = (uint8_t) id;

    // CRC includes type and payload
    uint8_t crc = _crsf_crc8(&msg->type, len + 1);
    msg->pyld[len] = crc;

    if(xSemaphoreTake(pHndl->tx.semphr_hndl, 10) != pdTRUE){
        xStreamBufferSend(pHndl->tx.pBuf_hndl, msg, msg->length + 1, 10);
    }

    return eCRSFOK;
}

This function assumes that the length argument is the length of the data packet, allowing it to be called as shown below:

1
2
3
4
struct _crsf_rc_t rc_msg = {0};
CRSF_t crsf_interface;
...
_crsf_send_packet(&crsf_interface, &rc_msg, eCRSFMsgRC, sizeof(struct _crsf_rc_t));

Note that the CRSF interface uses two types of packet formats. Packet structures prefixed with _ are used internally, and correspond to the exact formats defined within the CRSF protocol. Structures without this prefix are designed to be used externally, are not “packed,” and contain normalized values that are easier to work with. For example, the CRSF protocol defines battery voltage as a uint16 in $mV *100$. However, it is much easier to work with a standard measurement like $V$, represented as a floating point.

Example Usage

The following code defines example usage for initializing the CRSF interface to operate over a Serial (UART) port.

1
2
3
4
5
6
7
8
9
10
11
 // Create the write handle
 StreamBufferHandle_t tx_hndl = serial_create_write_buffer(pSerial, configMINIMAL_STACK_SIZE, 1, pHndl->tx_buf, &pHndl->tx_streamBuf);

StreamBufferHandle_t rx_hndl = crsf_init(&pHndl->crsf, tx_hndl);

if (!rx_hndl) {
    printf("CRSF INI Fail\n");
}

// Attach the read handle to the Serial interface
serial_attach(pHndl->pSerial, rx_hndl);

The above code first utilizes the Serial interface to create and initialize a new Serial write buffer. Note that the FreeRTOS stream buffer memory is stored within pHndl, which is the handle of a test task containing both the serial driver and the CRSF interface. While the Serial driver memory is owned by the Serial driver layer, and the receive buffer memory is owned by the CRSF layer, the transmit buffer memory must be owned by the encapsulating task. In this case, that is the test task.

Verification

In this test, the program was required to echo decoded CRSF packets onto another Serial interface.

Echo Mode Echo Batt

The CRSF interface was also tested decoding RC packets and forwarding them to a desktop computer over USB for use with the Liftoff Flight Simulator as part of Project Scout. As further testing is done, this page will be updated.

This post is licensed under CC BY 4.0 by the author.