RAIL Multiprotocol


As of version 2.1, the RAIL library supports dynamic multiprotocol. In dynamic multiprotocol an application can allocate multiple RAIL instances and configure them independently. Some radio operations are then arbitrated by a radio scheduler internal to RAIL to allow the different instances to coexist. This scheduler attempts to allow as many of these operations to run as possible by moving them around within the bounds allowed by the protocol.

Radio Scheduler

The radio scheduler is critical to multiprotocol operation. Its job is to arbitrate all radio operations, switch radio configurations, and ensure that radio operations run at the time requested. Not every RAIL function is a radio operation considered by the scheduler. Simple state update functions like RAIL_SetTxPower() will directly change the hardware if active or update the state cache if inactive to have that change take affect the next time this protocol is loaded. The following RAIL functions are handled by the scheduler:

To arbitrate the radio operations the scheduler uses four pieces of information: startTime, priority, slipTime, and transactionTime. The startTime comes implicitly from the RAIL API that started the transaction. For example, calling RAIL_StartTx() will use a start time of right now while calling RAIL_StartScheduledTx() takes an explicit start time as a parameter. The other three parameters are passed to all RAIL functions that require the scheduler in the RAIL_SchedulerInfo_t structure. The scheduler attempts to run each task at its desired startTime, but may also choose to run it up until startTime + slipTime. If it cannot run the task within that window it will trigger a RAIL_Config_t::eventsCallback with the RAIL_EVENT_SCHEDULER_STATUS event set once that window has passed.

The scheduler is preemptive and will always make sure that a higher priority task runs over a lower priority task, but it does not guarantee ordering of these tasks. For example, it could choose to run a short lower priority task with a small slipTime before a high priority task with a long slipTime to reduce dropped operations. Once an operation is started by the radio scheduler it will either run to completion and trigger a normal RAIL event callback to indicate it is done or it will be aborted by a higher priority task and ended with a RAIL_EVENT_SCHEDULER_STATUS event. Ultimately, the application is responsible for terminating most operations with a call to RAIL_YieldRadio(). This call cleans up internal state and allows the scheduler to switch to a lower priority operation.

Scheduler Operations

The APIs mentioned above can be grouped into three categories: finite operations, infinite operations, and debug operations.

Finite Operations

Finite operations include the following APIs:

Finite operations make up the majority of the radio scheduler calls. A finite operation has some defined start time and either a defined or estimated end time. Each RAIL handle may only have one of these types of operations queued up at any time. The upper level application code is responsible for waiting until the previous operation has completed before starting a new one like in the single protocol RAIL library. If a new task is issued while a previous task is in progress or if a higher priority task interrupts an operation before the user has yielded it will trigger the RAIL_EVENT_SCHEDULER_STATUS event with an appropriate RAIL_SchedulerStatus_t set and this operation will not be scheduled again.

Once a finite operation has begun the scheduler will use the same priority until a new finite event is provided or a yield occurs. The scheduler will ensure that unnecessary switches do not occur if this next event is in the near future at the same or a higher priority. For more information see Yielding the radio.

Infinite Operations

Infinite operations include the following APIs:

An infinite or background operation is treated by the scheduler as more of a radio state change. It still has a priority associated with it and will be started whenever it can be, but it will never fail. Another difference is that if this event is interrupted by a higher priority task it will be restarted whenever that task finishes where a finite task would be aborted.

Each RAIL handle may have only one infinite task but it may have both an infinite and finite or debug task at the same time. The one interesting aspect of this is that if a finite task starts for this RAIL handle the state will be updated to reflect what the infinite task has requested for that finite task's time period even if the infinite task is lower priority than another task in the system. For example, if you have one handle with a RAIL_StartRx() of priority 5 and another handle with a RAIL_StartRx() priority of 6 and a RAIL_StartTx() priority of 3 the scheduler will choose the second handle's finite transmit task. While running this transmit task though the receiver will be set to on so that as soon as this transmit completes the radio will transition from transmit into receive. It will remain in receive until the finite operation yields at which time the first handle's receive will take precedence.

This implementation is intended to be used to get the radio to sit in receive for a very long time on one of the RAIL handles. It is generally only advisable to do this on one radio configuration at a time and for this to be the lowest priority task. For well defined receive events like looking for an ACK scheduled receive windows or state transitions will generally work better.

There is a known bug with turning on an infinite task during a finite task. For now, you must always configure the infinite task first to ensure it takes effect.
If using state transitions that can lead from receive to idle the user must tell the scheduler that this happened with a call to RAIL_Idle(). This restriction will hopefully be removed in future versions.

Debug operations

Debug operations include the following APIs:

Debug operations are somewhat special because they don't allow the user to pass a RAIL_SchedulerInfo_t structure to them. Instead, they implicitly provide a start time of now and use the highest possible priority. This means that they will have the highest priority and interrupt most other protocol operations to switch into the test mode. Nothing can preempt them once started because the scheduler does not allow tasks of equal priority to preempt. These operations can be stooped by calling RAIL_StopTxStream(), RAIL_YieldRadio(), or RAIL_Idle().

The modal nature of these debug operations is intentional to prevent multiple tone or stream operations from happening concurrently. It is expected that the user will manage these test modes from a higher level to prevent the different protocols from using tone or stream at the same time.

Yielding the radio

Yielding is used by finite radio scheduler operations. Once an operation is started, that operation will run until explicitly yielded by the upper layer or interrupted by a higher priority task. A yield is forced by a call to RAIL_YieldRadio() or RAIL_Idle(). It is important that the application yield the radio as soon as it's done to allow lower priority tasks to run.

Yielding is not handled by RAIL since terminating an operation can be application specific. While RAIL may know that an individual transmit or receive operation is completed there are sometimes other reasons to prevent a switch or to run a follow on operation. For example, if you are using ACKing you may want to wait for the RAIL_EVENT_TXACK_PACKET_SENT after a receive packet instead of yielding right after the receive. This also allows you to chain together many protocol specific operations without the scheduler constantly context switching. If you do schedule a follow on operation that is far in the future the scheduler will also be smart enough to treat this as a yield and slot in other lower priority operations.

This does mean that you must carefully look through the Events in RAIL and subscribe to any that can impact your state machine. Specifically, things like transmit or receive success conditions, transmit and receive error conditions, and scheduler events (RAIL_EVENT_SCHEDULER_STATUS, RAIL_EVENT_CONFIG_UNSCHEDULED, and RAIL_EVENT_CONFIG_SCHEDULED) are important to keep track of. These would likely be needed in any upper layer to properly update the internal state machine as well so they're likely already in the implementation.

Building a Multiprotocol Application

The RAIL API is the same whether the targeted application is using the multiprotocol version of RAIL or the normal one. This was done intentionally to make switching between the two simple. From a build perspective, this means that the only thing that needs to happen is to switch the RAIL library binary linked into the application. For multiprotocol applications the file name is librail_multiprotocol_CHIP_COMPILER_release.a while for other applications the file name is librail_CHIP_COMPILER_release.a.

Optionally, non multiprotocol applications can save memory by not allocating the RAIL_Config_t::scheduler state structure and passing NULL for all RAIL_SchedulerInfo_t pointers in APIs.


Below is a simple example showing how to initialize two RAIL configurations and have one sit in infinite receive while the other one periodically transmits a packet at a higher priority.

#include <stdint.h>
#include <stdbool.h>
#include "rail.h"
#include "rail_config.h" // Generated by radio calculator
static RAIL_Handle_t gRailHandle1 = NULL;
static RAIL_Handle_t gRailHandle2 = NULL;
// Each RAIL handle must have its own non-const config structure since state
// is stored in these variables
// Create structure 1
static RAILSched_Config_t railSchedState1;
static RAIL_Config_t railCfg1 = { // Must never be const
.eventsCallback = &radioEventHandler,
.protocol = NULL, // For BLE, pointer to a RAIL_BLE_State_t
.scheduler = &railSchedState1,
// Create structure 2
static RAILSched_Config_t railSchedState2;
static RAIL_Config_t railCfg2 = { // Must never be const
.generic = &radioEventHandler,
.protocol = NULL, // For BLE, pointer to a RAIL_BLE_State_t
.scheduler = &railSchedState2,
// Boolean to track when a packet has been sent
static bool packetSendComplete = true;
// Create transmit FIFOs for each PHY
#define TX_FIFO_SIZE 128
static uint8_t txFifo1[TX_FIFO_SIZE];
static uint8_t txFifo2[TX_FIFO_SIZE];
static void radioConfigChangeHandler(RAIL_Handle_t railHandle,
bool isSubgig = (entry->baseFrequency < 1000000000UL);
// ... handle radio configuration change, e.g. select the desired PA possibly
// using isSubgig to handle multiple configurations
RAIL_ConfigTxPower(railHandle, &railTxPowerConfig);
// We must reapply the Tx power after changing the PA above
RAIL_SetTxPowerDbm(railHandle, txPower);
static void radioEventHandler(RAIL_Handle_t railHandle,
RAIL_Events_t events)
// Note that we could use two different callbacks above, but since we didn't
// we can split handling in this one callback based on the handle
if (railHandle == gRailHandle1) {
// Handle events for protocol 1
// NOTE: Since we're in infinite receive we do not have to yield the radio
// here as we do below. If this were changed we would need to yield here.
} else if(railHandle == gRailHandle2) {
// Handle any packet completion event (success or failure) and set us up
// to send another packet
packetSendComplete = true;
// Initialize the two PHY configurations for the radio
void radioInitialize(void)
// Create each RAIL handle with their own configuration structures
gRailHandle1 = RAIL_Init(&railCfg1, NULL);
gRailHandle2 = RAIL_Init(&railCfg2, NULL);
// Setup the transmit FIFOs
RAIL_SetTxFifo(gRailHandle1, txFifo1, 0, TX_FIFO_SIZE);
RAIL_SetTxFifo(gRailHandle2, txFifo2, 0, TX_FIFO_SIZE);
// Configure radio according to the generated radio settings
RAIL_ConfigChannels(gRailHandle1, channelConfigs[0], &radioConfigChangedHandler);
RAIL_ConfigChannels(gRailHandle2, channelConfigs[1], &radioConfigChangedHandler);
// Configure the most useful callbacks plus catch a few errors
int main(void)
RAIL_SchedulerInfo_t schedulerInfo;
uint32_t sendTime;
// Initialize the radio and create the two RAIL handles
// Only set priority because transactionTime is meaningless for infinite
// operations and slipTime has a reasonable default for relative operations.
schedulerInfo = (RAIL_SchedulerInfo_t){ .priority = 200 };
RAIL_StartRx(gRailHandle1, 0, &schedulerInfo);
// Start the first send 2 seconds after getting setup
sendTime = RAIL_GetTime() + 2000000;
// Issue a transmit periodically on the second RAIL handle
while (true) {
if (packetSendComplete) {
RAIL_ScheduleTxConfig_t scheduledTxConfig = { .when = sendTime,
uint8_t packetData[] = { 0, 1, 2, 3, 4, 5, 6, 7, 7, 8, 10 };
// This assumes the Tx time is around 10ms but should be tweaked based on
// the specific PHY configuration
schedulerInfo = (RAIL_SchedulerInfo_t){ .priority = 100,
.slipTime = 50000,
.transactionTime = 10000 };
// Load the transmit buffer with something to send
RAIL_WriteTxFifo(gRailHandle2, packetData, sizeof(packetData), true);
// Transmit this packet at the specified time or up to 50 ms late
res = RAIL_StartScheduledTx(gRailHandle2,
if (res == RAIL_STATUS_NO_ERROR) {
packetSendComplete = false;
sendTime += 2000000; // Add 2 seconds to the previous time
} else {
// In the current configuration this should never happen
return 0;