Preamble
This article was originally written by me in Russian language. I’ve translated it via the LLM and read out by myself. Hope you enjoy!
original post on Habr.com: https://habr.com/ru/articles/1021360/
At work, I encountered an unusual chip for switching high-frequency (RF) signals. One problem — the proprietary MIPI RFFE control interface. This raised the question: "How can I control this without specialized tools?" Let's find the answer together.
The chip in question is the QPC1220Q, which routes signals from 4 inputs to 2 outputs, and it can do this for two lines simultaneously.
Functionally, it looks like this:
The chip operates over a wide range from 617 MHz to 6 GHz with high linearity, meaning it introduces very little distortion into the signal. The compact size (2x2 mm package) and energy efficiency of the device are also worth highlighting. Its maximum current consumption is 60 µA, and in low-power mode it’s 10 µA. Not bad! However, we won't go deeper into the specifications, as that's not my area at all.
The key question for me is how do you actually control this thing?
The answer is via the MIPI RFFE v2.1 interface. Never heard of it? At the time of working on this, neither had I. So let's figure it out.
MIPI RFFE is a two-wire interface that uses a clock signal (SCLK) and a bidirectional data line (SDATA) to control up to 15 devices on a single bus. It is, de facto, a modern and extremely widespread standard for controlling such devices inside mobile phones (and beyond). Many modern smartphones have this interface on board.
Great, a very popular interface in the professional field. So, I probably won't have to come up with anything myself, right?
Unfortunately, no - there is no ready-made solution in the form of a hardware module, library, etc., from the manufacturer or the community. In general, there is very little information available on this interface. So I had to figure out how to emulate this interface, either through other interfaces or through the microcontroller's GPIO.
That means I needed to find the specification for the interface itself. And I really mean search - at night, with dogs and a flashlight, so to speak. Because you simply cannot obtain it easily, no matter how much you want to. The MIPI Alliance, which standardizes and develops this interface, only provides it to member organizations, and it is not openly available on the internet.
Long story short, after a long search on Chinese and other foreign forums, I finally found what I was looking for. So now it's time to get down to business. This article will contain some excerpts from that document, but far from all, since it's about 230 pages long.
If we look at the datasheet for the QPC1220Q chip, for control purposes we need to write bits to the corresponding positions of register 0x0001 — SW_CTRL:
Here we can see a reference to the truth table for this register and certain triggers, which we will come back to later. Here is the table itself, in two parts:


In addition, for communication, we need to know the device's address on the bus. In my case, it is determined by the voltage applied to the USID pin. A high level corresponds to address 0x7, a low level to address 0x6.
Let's return to the triggers. Triggers are a mechanism that allows more complex control algorithms. Depending on the device, certain registers may have triggers and associated "shadow" registers attached to them. How does this work? If a trigger is enabled, when writing to a register, the data is not written immediately but is placed into an intermediate buffer. From the buffer, the data is then written to the actual register only when the trigger itself is accessed (by writing a logic 1).
Associated with the SW_CTRL register are triggers 0, 1, and 2 from register 0x001C - PM_TRIG.
As you can see, this requires a regular write command, unlike with SW_CTRL, which requires a WM (Write Masked) command - that is, a masked write command.
I don't need the triggers for my purposes, in fact, they would get in the way, so I will need to disable them when the device starts up. After that, I can work directly with SW_CTRL without an intermediary.
I prefer to eat the elephant one bite at a time, so let's start with disabling the triggers. To do that, let's finally take a look at the interface specification and see what a register write command looks like:
Here we can notice quite a few nuances that need to be taken into account:
- SSC (Sequence Start Condition) - start sequence
- Register Write Command Frame, consisting of:
- SA (slave address) - the slave device address (our QPC), 4 bits
- Register Write command code - 3 bits, binary 010
- Target register address - 5 bits
- Parity bit P
- Data Frame, consisting of:
- Payload - 8 bits
- Parity bit P
- BPC (Bus Park Cycle) - termination sequence
To understand what we are looking at, we need to dive into the architecture of how the interface commands themselves are structured. And it is quite different from typical general-purpose interfaces. The technical requirements of the interface are also important: the success of the operation depends on them.
SSC (Sequence Start Condition)
SSC - a unique start sequence that can only be clocked by the master on the bus. It is characterized by a high level on the SDATA line for one period of SCLK, followed by a low level for one period as well.
BPC (Bus Park Cycle)
BPC is initiated by the bus master at the end of a data transmission, signaling that the transmission is complete. To do this, the SDATA line is set to a low level, and a short high level is generated on the SCLK line.
Frames
The interface defines three basic types of frames:
- Command Frame (13 bits)
- Data/Address Frame (9 bits)
- No Response Frame (9 bits)
Command Frame
A command frame must consist of 4 bits of slave address, 8 bits of payload (which can consist only of a command code, or a command code and a register address), and one parity bit - 13 bits in total.
Data/Address Frame
A data frame (or address frame) consists of 8 bits of information, ending with a parity bit. If the frame carries an address as its payload, it is called an address frame; if it carries data, it is called a data frame, respectively. Otherwise, they are identical and differ only in their position within the overall transmission.
No Response Frame
All bits of the frame are filled with zeros. This frame represents the standard response to an invalid command. Frankly, it's not entirely clear what the interface designers intended by including it in the specification, since in practice its use is extremely limited.
Parity Bit
Each frame must end with one parity bit. It represents the result of the total number of bits in the transmission that are at a high level. So, if the transmission is 0x63 (0b0110_0011), the parity bit should be "1". If, for example, the transmission is 0x4C (0b0100_1100), then the parity bit should be "0".
Now that we have studied everything necessary, we can start thinking about how we can actually go ahead and do what we want. Namely, emulate a write command to the PM_TRIG register.
We need to clock out:
- Start sequence SSC (4 bits)
- Command frame (13 bits)
- Data frame (9 bits)
- BPC (1 bit)
And at this stage, the most attentive readers may already see some problems. The format of the messages used is quite unique and does not lend itself to emulation via SPI or I2C. With SPI, typically, the transmission is either 8 or 16 bits, and with I2C, it's a fixed 8 bits plus a 7- or 10-bit address. While SSC and BPC can somehow be clocked out by switching the GPIO from interface mode back to GPIO mode, the main transmission itself simply does not fit. That means we will have to use only GPIO.
In this context, it is worth discussing the physical requirements of the interface. Let's start with the speed requirement.
As you can see, there are two operating modes: standard and extended frequency ranges. We will only consider staying within the standard range. Achieving speeds above 1 MHz while emulating on a microcontroller, given that it also has to handle other tasks, will be very difficult. One might think that GPIOs should switch between high and low states at the frequency at which their bus is clocked. But no, that's not how it works in practice, and not all manufacturers will specify the achievable speed. Honestly, what do you expect for next to nothing on an ARM? If you want real speed - take an FPGA. So we pay attention to this first and foremost - not every controller will be able to meet the required range.
Now to the signal timing requirements:
Well, here it's not so scary, although the rise and fall times need to be very fast indeed.
In the process of working, I had to create an implementation for two ARM-core microcontrollers:
- STM32F411CEU6 (Widely known as Black Pill)
- AT32F413KCU7-4
The second is a clone, targeting the budget controller series from STM - not hard to guess from the name. They are quite easy to find on popular marketplaces, have comparable characteristics to STM, and in some respects may even be better. But we won't go deeper into that. We will look at the implementation using the example of the "black pill" - though it works on both. For working with STM, CubeIDE and HAL were used; for AT, CMSIS and their BSP library with drivers.
For precise and timely clocking, we need to use a timer. I used TIM2 on the APB1 bus with a maximum frequency of 50 MHz. That's really all we need.
Experimentally, it was determined that a fairly stable square wave is achieved with a timer interrupt interval of 10 microseconds.
Accordingly, my timer configuration is as follows:
In my implementation, I used several structures:
typedef struct RFFE_config_struct{
TIM_HandleTypeDef *tim; //Pointer to an instance of the timer TIM2
GPIO_TypeDef *GPIO_SCLK_BASE; // Pointer to an instance of the Typedef GPIO, desired SCLK output
GPIO_TypeDef *GPIO_SDATA_BASE; // Pointer to an instance of the Typedef GPIO, desired SDATA output
uint16_t rffe_sdata_pin; // GPIO_pins_define desired SDATA
uint16_t rffe_sclk_pin; // GPIO_pins_define desired SCLK
uint8_t rffe_slave_addr; // Slave address
}RFFE_cnfg_s;
Also structure:
typedef struct RFFE_send_statemachine_struct{
uint8_t curr_send_state; // Tracking the current stage of transmission
uint8_t ssq_first_enter; // Flag for start of SSC transmission
uint8_t ssq_ticks_till_end; // Number of ticks until end of SSC transmission
uint8_t bpc_first_enter; // Flag for start of BPC transmission
uint8_t bpc_ticks; // Number of ticks until end of BPC transmission
uint8_t pkg_bits_to_send; // Number of bits to send
uint8_t pkg_iterator; // Iterator over the data being sent
uint8_t rffe_sdata_direction; // SDATA direction: transmit/receive
}RFFE_send_statemachine_s;
And among the global variables, an array remains for storing the bits to be sent:
#define PKG_DEFAULT_SIZE = 40 //Default single message size
uint8_t rffe_data_package[PKG_DEFAULT_SIZE] = {0};
First, we need to initialize the structures. We do this using a function:
void rffe_init(TIM_HandleTypeDef* tim, GPIO_TypeDef* gpio_sclk_base, GPIO_TypeDef* gpio_sdata_base, uint16_t sdata_pin, uint16_t sclk_pin, uint8_t rffe_slave_addr)
{
rffe_cfg.tim = tim;
rffe_cfg.GPIO_SCLK_BASE = gpio_sclk_base;
rffe_cfg.GPIO_SDATA_BASE = gpio_sdata_base;
rffe_cfg.rffe_sclk_pin = sclk_pin;
rffe_cfg.rffe_sdata_pin = sdata_pin;
rffe_cfg.rffe_slave_addr = rffe_slave_addr;
rffe_stmn_cfg.bpc_first_enter = 0;
rffe_stmn_cfg.bpc_ticks = BPC_DEFAULT_TICKS_AMOUNT;
rffe_stmn_cfg.ssq_first_enter = 0;
rffe_stmn_cfg.ssq_ticks_till_end = 0;
rffe_stmn_cfg.pkg_bits_to_send = 0;
rffe_stmn_cfg.pkg_iterator = 0;
rffe_stmn_cfg.curr_send_state = 0;
rffe_stmn_cfg.rffe_sdata_direction = 0;
}
Inside the function we transfer, in my case:
rffe_init(&htim2, //Pointer to timer instance
GPIOB, //GPIO_TypeDef SDATA
GPIOB, //GPIO_TypeDef SCLK
RFFE_SDATA_Pin, //SDATA pin number
RFFE_SCLK_Pin, //SCLK pin number
0x06 // QPC1220Q slave address
);
I should note that the SCLK and SDATA pins must definitely have pull-down resistors to ground!
Now, let's go step by step through the function for sending a regular register write command. To the function, we pass the register address to write to, as well as the data we want to write.
void rffe_send_write_cmd(uint8_t reg_addr, uint8_t payload)
{
config_output();
rffe_clear_data_package(rffe_data_package);
rffe_set_statemachine(WRITE_TICKS_AMOUNT,
BPC_DEFAULT_TICKS_AMOUNT,
RFFE_SDATA_DIRECTION_OUTPUT
);
create_cmd_frame(rffe_cfg.rffe_slave_addr,
reg_addr,
COMMAND_FRAME_CMD_PAYLOAD_BITS_SIZE,
rffe_data_package,
WRITE_CMD
);
create_data_frame(rffe_data_package,
payload,
DATA_FRAME_START_POS,
DATA_FRAME_END_POS,
DATA_FRAME_PARITY_POS
);
__HAL_TIM_SET_COUNTER(rffe_cfg.tim, 0);
HAL_TIM_Base_Start_IT(rffe_cfg.tim);
}
Configure the SDATA pin as an output:
void config_output()
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = rffe_cfg.rffe_sdata_pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_PULLDOWN;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
HAL_GPIO_Init(rffe_cfg.GPIO_SDATA_BASE, &GPIO_InitStruct);
}
First, clear the buffer array:
void rffe_clear_data_package(uint8_t package[])
{
for(uint8_t i = 0; i < PKG_DEFAULT_SIZE; i++){
package[i] = 0;
}
}
Set the initial state of the structure:
void rffe_set_statemachine(uint8_t pkg_bits_to_send, uint8_t bpc_ticks, uint8_t sdata_direction)
{
rffe_stmn_cfg.bpc_first_enter = 0;
rffe_stmn_cfg.bpc_ticks = bpc_ticks;
rffe_stmn_cfg.ssq_first_enter = 0;
rffe_stmn_cfg.ssq_ticks_till_end = 0;
rffe_stmn_cfg.pkg_bits_to_send = pkg_bits_to_send;
rffe_stmn_cfg.pkg_iterator = 0;
rffe_stmn_cfg.curr_send_state = 0;
rffe_stmn_cfg.rffe_sdata_direction = sdata_direction;
}
Inside the function, predefined values are passed specifically for the write command:
- WRITE_TICKS_AMOUNT, 44
- BPC_DEFAULT_TICKS_AMOUNT, 1
- RFFE_SDATA_DIRECTION_OUTPUT, 0
Where did they come from?
It's simple — based on the number of SCLK line pulses for the Register Write command. For those interested — you can count them yourself :)
Next, we create the command frame:
void create_cmd_frame(uint8_t slave_addr,
uint8_t cmd_frame_payload,
uint8_t cmd_frame_bits_count,
uint8_t cmd_frame[],
uint8_t cmd_mask
)
{
uint8_t tmp = cmd_mask | cmd_frame_payload;
uint8_t parity_counter = 0;
for(int16_t i = 11; i >= 0; i--){
if (cmd_frame_bits_count > 0){
cmd_frame[i] = (tmp & 0x1);
tmp >>= 1;
cmd_frame_bits_count--;
} else {
cmd_frame[i] = (slave_addr & 0x1);
slave_addr >>= 1;
}
if (cmd_frame[i]){
parity_counter++;
}
}
if ((parity_counter % 2) == 0){
cmd_frame[12] = 1;
} else {
cmd_frame[12] = 0;
}
}
The following are passed to the function, respectively:
- COMMAND_FRAME_CMD_PAYLOAD_BITS_SIZE, 8 – the size of the command frame payload
- WRITE_CMD, 0b01000000 – write command code
After that, we just need to create the data frame:
void create_data_frame(uint8_t cmd_frame[],
uint8_t data_frame_payload,
uint8_t start_pos,
uint8_t end_pos,
uint8_t parity_pos
)
{
uint8_t parity_counter = 0;
for (uint8_t i = start_pos; i >= end_pos; i--){
cmd_frame[i] = (data_frame_payload & 0x1);
data_frame_payload >>= 1;
if (cmd_frame[i]){
parity_counter++;
}
}
if ((parity_counter % 2) == 0){
cmd_frame[parity_pos] = 1;
} else {
cmd_frame[parity_pos] = 0;
}
}
The following are passed to the function:
- DATA_FRAME_START_POS, 20
- DATA_FRAME_END_POS, 13
- DATA_FRAME_PARITY_POS, 21
After that, we reset the timer, start it, and enable the interrupt.
Let's look at the interrupt function code and examine it in more detail:
void TIM2_IRQHandler(void)
{
/* USER CODE BEGIN TIM2_IRQn 0 */
if (__HAL_TIM_GET_FLAG(rffe_cfg.tim, TIM_FLAG_UPDATE) != RESET) {
if (__HAL_TIM_GET_ITSTATUS(rffe_cfg.tim, TIM_IT_UPDATE) != RESET) {
__HAL_TIM_CLEAR_FLAG(rffe_cfg.tim, TIM_FLAG_UPDATE);
switch (rffe_stmn_cfg.curr_send_state) {
case 0:
if (!rffe_stmn_cfg.ssq_first_enter){
GPIOB->BSRR = rffe_cfg.rffe_sdata_pin;
rffe_stmn_cfg.ssq_first_enter++;
}else if (rffe_stmn_cfg.ssq_ticks_till_end <= SSQ_DEFAULT_TICKS_AMOUNT){
if (rffe_stmn_cfg.ssq_ticks_till_end == 1){
GPIOB->BSRR = (uint32_t)rffe_cfg.rffe_sdata_pin << 16U;
}
rffe_stmn_cfg.ssq_ticks_till_end++;
} else {
rffe_stmn_cfg.curr_send_state++;
}
break;
case 1:
if (rffe_stmn_cfg.pkg_bits_to_send > 0){
GPIOB->ODR ^= rffe_cfg.rffe_sclk_pin;
if (GPIOB->ODR & rffe_cfg.rffe_sclk_pin){
if (rffe_data_package[rffe_stmn_cfg.pkg_iterator]){
GPIOB->BSRR = rffe_cfg.rffe_sdata_pin;
} else {
GPIOB->BSRR = (uint32_t)rffe_cfg.rffe_sdata_pin << 16U;
}
rffe_stmn_cfg.pkg_iterator++;
}
rffe_stmn_cfg.pkg_bits_to_send--;
} else {
rffe_stmn_cfg.curr_send_state++;
}
break;
case 2:
if (!rffe_stmn_cfg.bpc_first_enter){
GPIOB->BSRR = (uint32_t)rffe_cfg.rffe_sdata_pin << 16U;
rffe_stmn_cfg.bpc_first_enter++;
if (rffe_stmn_cfg.rffe_sdata_direction){
config_input();
}
}
if (rffe_stmn_cfg.bpc_ticks > 0){
rffe_stmn_cfg.bpc_ticks--;
GPIOB->ODR ^= rffe_cfg.rffe_sclk_pin;
} else {
GPIOB->BSRR = (uint32_t)rffe_cfg.rffe_sclk_pin << 16U;
HAL_TIM_Base_Stop_IT(rffe_cfg.tim);
}
break;
default:
break;
}
config_output();
}
}
}
With each timer interrupt, we will enter our handler function. Let's focus on it.
The global regulator of the packet transmission stage inside the function is a switch-case statement. It separates three transmission states:
- 0 – send SSC
- 1 – send main data from rffe_data_package
- 2 – send BPS
send SSC:
//If it's the first entry – set SDATA high
if (!rffe_stmn_cfg.ssq_first_enter){
GPIOB->BSRR = rffe_cfg.rffe_sdata_pin;
rffe_stmn_cfg.ssq_first_enter++;
}else if (rffe_stmn_cfg.ssq_ticks_till_end <= SSQ_DEFAULT_TICKS_AMOUNT){
//On the second interrupt – set low level
//Change the transmission stage
if (rffe_stmn_cfg.ssq_ticks_till_end == 1){
GPIOB->BSRR = (uint32_t)rffe_cfg.rffe_sdata_pin << 16U;
}
rffe_stmn_cfg.ssq_ticks_till_end++;
} else {
rffe_stmn_cfg.curr_send_state++;
}
break;
Sending the main packet:
//While there are bits to send, send them
if (rffe_stmn_cfg.pkg_bits_to_send > 0){
//On each entry, invert the SCLK state
GPIOB->ODR ^= rffe_cfg.rffe_sclk_pin;
//Start clocking on the rising edge of SCLK if (GPIOB->ODR & rffe_cfg.rffe_sclk_pin){
//Depending on the bit, set high or low level
if (rffe_data_package[rffe_stmn_cfg.pkg_iterator]){
GPIOB->BSRR = rffe_cfg.rffe_sdata_pin;
} else {
GPIOB->BSRR = (uint32_t)rffe_cfg.rffe_sdata_pin << 16U;
}
rffe_stmn_cfg.pkg_iterator++;
}
rffe_stmn_cfg.pkg_bits_to_send--;
} else {
//If no bits left to send – move to the next state
rffe_stmn_cfg.curr_send_state++;
}
break;
Send BPS:
//If it's the first entry, pull SDATA low
if (!rffe_stmn_cfg.bpc_first_enter){
GPIOB->BSRR = (uint32_t)rffe_cfg.rffe_sdata_pin << 16U;
rffe_stmn_cfg.bpc_first_enter++;
if (rffe_stmn_cfg.rffe_sdata_direction){
config_input();
}
}
//While possible, clock the SCLK line
//Upon completion, disable the interrupt and stop the timer
if (rffe_stmn_cfg.bpc_ticks > 0){
rffe_stmn_cfg.bpc_ticks--;
GPIOB->ODR ^= rffe_cfg.rffe_sclk_pin;
} else {
GPIOB->BSRR = (uint32_t)rffe_cfg.rffe_sclk_pin << 16U;
HAL_TIM_Base_Stop_IT(rffe_cfg.tim);
}
break;
As we can see, it's all quite simple! Let's look at the characteristics of our signal. I checked them using a Tektronix TDS1012 oscilloscope.
The SCLK signal frequency shows a stable 50 kHz, which falls within the required RFFE range for standard mode with some margin. And that's very good.
However, the rising and falling edges were sluggish and did not meet the required parameters — exceeding them by about 10 times. Yet, strangely enough, this did not affect operation.
For a masked write, one data byte is transmitted first, which serves as the mask. This is followed by a byte of information to which the mask will be applied. If a bit in the mask is 0, the corresponding bit will be written to the target register. If it is 1, the data will be ignored.
Following the same principle as with the regular write command, we implement a function for sending a masked write command, which is needed for writing to SW_CTRL.
The command is structured as follows:
void rffe_send_write_masked_cmd(uint8_t reg_addr, uint8_t mask, uint8_t payload)
{
config_output();
rffe_clear_data_package(rffe_data_package);
rffe_set_statemachine(MASKED_WRITE_TICKS_AMOUNT,
BPC_DEFAULT_TICKS_AMOUNT,
RFFE_SDATA_DIRECTION_OUTPUT
);
create_cmd_frame(rffe_cfg.rffe_slave_addr,
0x0,
COMMAND_FRAME_CMD_PAYLOAD_BITS_SIZE,
rffe_data_package,
MASKED_WRITE_CMD //Command code 0b00011001
);
create_data_frame(rffe_data_package,
reg_addr,
ADDR_FRAME_START_POS, //20
ADDR_FRAME_END_POS, //13
ADDR_FRAME_PARITY_POS //21
);
create_data_frame(rffe_data_package,
mask,
MASK_FRAME_START_POS, //29
MASK_FRAME_END_POS, //22
MASK_FRAME_PARITY_POS //30
);
create_data_frame(rffe_data_package,
payload,
MDATA_FRAME_START_POS, //38
MDATA_FRAME_END_POS, //31
MDATA_FRAME_PARITY_POS //39
);
__HAL_TIM_SET_COUNTER(rffe_cfg.tim, 0);
HAL_TIM_Base_Start_IT(rffe_cfg.tim);
}
For testing whether the QPC1220Q switches correctly, a Rohde & Schwarz ZVL13 vector network analyzer was used. Masked write commands were sent sequentially to toggle the switch.
Example of switching a device using this chip between several frequency ranges:
Thus, it is possible to emulate the operation of other commands of this interface on microcontrollers with comparable characteristics. I wish everyone the best of luck in doing so.





















Top comments (0)