Embedded HandbookApps
All sections › X. Design, networks and IoT
STM32WB55 · level advanced · 15 min read
Open in the interactive handbook

94б. Flipper Zero inside: STM32WB55, FreeRTOS and 433 MHz radio, NFC, RFID and IR in one firmware

Sections 93 and 94а covered one radio each: a LoRa module on SPI and an NFC tag on I²C. Flipper Zero is a pocket device with five such paths: a 300–928 MHz transceiver, an NFC reader, a 125 kHz tag reader, an IR port and an iButton contact. All of this is served by a single STM32WB55, and the device's firmware is open. This is a rare case where you can read from start to finish how a production device on STM32 with an RTOS, external programs and several radios at once is built.

Here: what is connected to what, how the device's OS on top of FreeRTOS is built and how pulse durations turn into the codes of a remote, a tag and a key fob. Three firmware decoders are built on a PC unchanged and run on generated frames with jitter: you can see where each one stops recognizing the signal.

This section is for learning and for protection. Receiving and analysing the air and your own tags is fine. Transmitting other people's codes, copying other people's key fobs and access cards without the owner's permission is not; how to do that is not described here. The firmware itself does not let you transmit outside the allowed bands (94б.4).

Sources: the firmware source — github.com/flipperdevices/flipperzero-firmware (commit 7f0b6e1); below are only short fragments with paths. Microcontroller — the description of the Keil STM32WBxx_DFP pack (core frequencies), the datasheet DS11929 and the reference manual RM0434 from the same pack, the STM32CubeMX database (memory size of STM32WB55RG). The decoders are built with gcc in the image stm-sys:1 with furi stubs, the sizes with arm-none-eabi-gcc in stm-arm:1; directory _verify/flip94b. The firmware as a whole is not built: 10 submodules (FreeRTOS, HAL, the BLE stack and others) were not downloaded for this section, and the fbt build tool downloads its own set of compilers from the network. STM32WB registers — appendix A17. License: GPL-3.0.

Terms used in the documentation

Term Meaning
Sub-GHz radio below 1 GHz: 315, 433, 868 MHz
te, te_short, te_long, te_delta the frame time step, short and long pulse, tolerance
Static, Dynamic (protocol type) fixed code and rolling code
preamble, guard time the lead-in of a frame and the pause between frames
mark, space an IR burst and a pause
Manchester Manchester code: a bit is a transition in the middle of the bit period
record, pubsub a record (a named shared object) and publish/subscribe messaging
FAP, .fap an external program from the memory card
CPU2, coprocessor (copro) the second core, a Cortex-M0+ with the radio stack
region provisioning the table of allowed transmit bands for a country

94б.1 What is inside

The heart of the device is the STM32WB55RG (that is how it is named in .vscode/example/launch.json): VFQFPN68 package, 1 MB Flash, 256 KB RAM (STM32CubeMX database). The WB55 has two cores: a Cortex-M4 at 64 MHz for programs and a Cortex-M0+ at 32 MHz for the radio (description of the STM32WBxx_DFP pack). The 2.4 GHz radio for Bluetooth LE is inside the chip itself.

Everything else is connected externally. Two SPI buses: SPI1 ("bus R" in furi_hal_spi_config.c) goes to the radio chips, SPI2 ("bus D") goes to the display and the memory card:

What Chip How it is connected File
radio 300–928 MHz CC1101 SPI1, CS on PD0, GDO0 output on PA1 → TIM2 lib/drivers/cc1101.c
NFC 13.56 MHz ST25R3916 SPI1, CS on PE4, interrupt on PA2 lib/drivers/st25r3916.c
display 128×64 ST7567 SPI2, CS on PC11 lib/u8g2/u8g2_glue.c
microSD card — SPI2, CS on PC12, FatFS targets/f7/fatfs
RFID 125 kHz — TIM1 carrier on PB13, signal through COMP1 → TIM2 furi_hal_rfid.c
IR port — receive PA0 → TIM2, transmit PB9 furi_hal_infrared.c
iButton — 1-Wire on PB14 lib/one_wire

The pins come from targets/f7/furi_hal/furi_hal_resources.h.

Flipper Zero inside: in the centre the STM32WB55 with a Cortex-M4 core at 64 MHz under FreeRTOS and a Cortex-M0+ core with the BLE stack; on the left, on SPI1, the CC1101 radio for 300–928 MHz and the ST25R3916 NFC reader; on the right, on SPI2, the ST7567 128 by 64 display and the microSD card; below, RFID 125 kHz, the IR port, iButton and Bluetooth LE; 433 MHz reception, IR and RFID are measured by one timer, TIM2, in microseconds

Three of the five paths — 433 MHz, IR and RFID — come down to the same thing: timer TIM2 in capture mode measures pulse durations in microseconds. The prescaler is 64 - 1 everywhere with a 64 MHz clock (CPU_CLOCK_PLL_HZ in furi_hal_clock.c). The protocol decoders see only "level, duration" pairs — which is why you can build and test them on a PC without the device (94б.4–94б.6).


94б.2 Two cores, memory and FreeRTOS

The second core. The Bluetooth stack runs on the Cortex-M0+; its firmware is a binary file from ST. fbt_options.py selects the light stack:

COPRO_STACK_BIN = "stm32wb5x_BLE_Stack_light_fw.bin"

The stack lives in the protected Flash area of the second core. According to RM0434, the SFSA field of the FLASH_SFR register is the number of the first 4-kilobyte page of this area; only CPU2 can write this register, and a write by the M4 core to the protected area is ignored with a bus error. That is exactly how the firmware computes where its own space ends: SFSA × 4096 + the Flash start (furi_hal_flash_get_free_end_address in furi_hal_flash.c).

M4 core memory. The linker script targets/f7/stm32wb55xx_flash.ld takes Flash from 0x08000000 with length 1024K, SRAM1 from 0x20000008 with length 0x2FFF8 (192 KB minus 8 bytes) and 10K each in SRAM2a (0x20030000) and SRAM2b (0x20038000). The script does not touch the rest of SRAM2.

FreeRTOS. The settings are in targets/f7/inc/FreeRTOSConfig.h: preemption enabled, 1000 Hz tick, 32 priority levels, task names up to 32 characters. The heap is all free RAM between __heap_start__ and __heap_end__; the loader also takes the sections of programs from the card out of the heap (aligned_malloc in elf_file.c). How the FreeRTOS kernel is built — section 24.


94б.3 furi: an OS on top of FreeRTOS and programs from the card

Programs do not call FreeRTOS directly — there is the furi layer between them (furi/core). Its parts:

A program is described by an application.fam file. An example from applications/examples/example_thermo (a DS18B20 thermometer on 1-Wire):

App(
    appid="example_thermo",
    name="Example: Thermometer",
    apptype=FlipperAppType.EXTERNAL,
    entry_point="example_thermo_main",
    requires=["gui"],
    stack_size=1 * 1024,
    fap_icon="example_thermo_10px.png",
    fap_category="Examples",
)

EXTERNAL means: the program is built as a separate .fap file and loaded from the memory card. It is an ELF with relocations (targets/f7/application_ext.ld places the code at address 0) and a manifest in the .fapmeta section: API version, board code, name, stack size. The loader (lib/flipper_application) refuses if the program's API is older or newer than the firmware's, or the board is different. What a program may call is listed in targets/f7/api_symbols.csv: API version 88.2, 3537 functions, of which 2451 are public (status +), 1086 are private (-).

The thermometer itself is a typical program: a sensor reading thread, a queue of button events for 8 messages, the gui and power records.


94б.4 433 MHz: CC1101 and the Princeton decoder

Frequencies. The firmware accepts frequencies in three windows — 299999755…348000335, 386999938…464000000 and 778999847…928000000 Hz (furi_hal_subghz_is_frequency_valid). The CC1101 crystal is 26 MHz (CC1101_QUARTZ): the frequency is written to the registers as value · 65536 / 26000000.

Transmit only in your own band. When the frequency changes, the firmware looks it up in the region table (furi_hal_region.c). For the "EU" region (Europe and Russia) there are two bands: 433.05–434.79 and 868.15–868.55 MHz. Outside the table the "receive only" mode is turned on, and transmission refuses:

bool furi_hal_subghz_tx(void) {
    if(furi_hal_subghz.regulation != SubGhzRegulationTxRx) return false;

Reception. The CC1101 demodulator output (GDO0) is wired to TIM2. A falling edge records the duration of the high level, a rising edge that of the low level, and both go to the handler as a "level, duration" pair (furi_hal_subghz_capture_ISR). The capture input filter discards glitches shorter than 16 µs — this is written right in the comment to the TIM2 setup.

How a decoder recognizes a remote. The firmware has 53 protocols (lib/subghz/protocols/protocol_items.c); the receiver (lib/subghz/receiver.c) passes every duration to the decoders of all protocols that passed the filter, and each one decides for itself whether it looks like its frame. The simplest is Princeton, the fixed 24-bit code of cheap remotes. Its constants (lib/subghz/protocols/princeton.c):

static const SubGhzBlockConst subghz_protocol_princeton_const = {
    .te_short = 390,
    .te_long = 1170,
    .te_delta = 300,
    .min_count_bit_for_found = 24,
};

A bit is a "high, low" pair: 1 is long and short, 0 is short and long. The decision for each pair:

            if((DURATION_DIFF(instance->decoder.te_last, subghz_protocol_princeton_const.te_short) <
                subghz_protocol_princeton_const.te_delta) &&
               (DURATION_DIFF(duration, subghz_protocol_princeton_const.te_long) <
                subghz_protocol_princeton_const.te_delta * 3)) {
                subghz_protocol_blocks_add_bit(&instance->decoder, 0);

A short pulse is accepted within ±300 µs, a long one within ±900. The start is found by a pause of 390 · 36 = 14040 µs with a tolerance of 300 · 36; a low level no shorter than 2 · 1170 µs ends the frame. The pause between frames is 30 steps by default. And the main thing: the decoder reports a code only if two frames in a row match.

Experiment. The feed function is cut out of princeton.c unchanged and built on a PC. The frames carry the code 0xA1B2C3, step 390 µs:

Condition Result
4 frames in a row 3 detections: data 0xA1B2C3, serial 0xA1B2C, button 0x3, step 390, pause 30 steps
1 frame 0 detections
2 frames 1 detection
step without jitter recognized from 110 to 680 µs
jitter ±299 µs 1000 of 1000
jitter ±300 µs 983 of 1000
jitter ±350 µs 1 of 1000
jitter ±400 µs 0 of 1000

The serial number and the button are just the upper 20 and the lower 4 bits. The decoder does not know the remote's step in advance: it computes it from the sum of the frame's durations. The tolerances are wide: a short pulse may differ from 390 µs by 77 % (300 µs). Random matches with noise are cut off by the rule of two identical frames.

Rolling code. A fixed code is the same at every press: whoever heard it knows it forever. A rolling code, for example KeeLoq (keeloq.c, 64 bits per frame, type Dynamic), has an encrypted part with a counter (Cnt in the output). The decoder shows the open part — the serial number and the button — and decrypts the encrypted part only with the manufacturer's key from a separate key store. For your own device the conclusion is simple: a fixed code is fine for a doorbell, but not for a lock.


94б.5 IR port: the NEC decoder

The NEC protocol is covered in section 198о: there the step is 562.5 µs, with its own decoder and a 25 % tolerance. Flipper uses rounded times and fixed tolerances (lib/infrared/encoder_decoder/nec/infrared_protocol_nec_i.h):

What Mark Space Tolerance
preamble 9000 µs 4500 µs ±200 µs
bit 1 560 µs 1690 µs ±120 µs
bit 0 560 µs 560 µs ±120 µs
repeat 9000 µs 2250 µs ±200 µs

The transmitter carrier is 38000 Hz, duty cycle 0.33 (INFRARED_COMMON_CARRIER_FREQUENCY, INFRARED_COMMON_DUTY_CYCLE). The common decoder infrared_common_decoder.c is shared by all protocols: it looks for the preamble and slices the frame into bits by a timing table, while the protocol file sets the times and the bit parsing. If the second half of the frame is not the inverse of the address and command, it is treated as NECext with a 16-bit address and command. The firmware has eight protocol families: NEC, Samsung, RC5, RC6, SIRC, Kaseikyo, RCA, Pioneer.

Experiment. The encoder and decoder are built from the firmware files unchanged.

Frame Result
firmware encoder: address 0x04, command 0x08 68 durations; decoder: NEC, 0x04, 0x08
times 562/1688 µs (as in 198о) NEC, 0x04, 0x08
address bytes 0x34 and 0x12 NECext, address 0x1234, command 0xF708
frame and repeat code 1 frame, 1 repeat
marks longer by 119 µs, spaces shorter recognized
marks longer by 120 µs not recognized
jitter of every duration ±119 µs 1000 of 1000
jitter ±120 µs 606 of 1000
jitter ±150 µs 0 of 1000

The edge sits exactly at the tolerance: the comparison is strict, MATCH_TIMING lets through only a difference of less than 120 µs. Section 198о shows how a receiver stretches marks by 100 µs; with Flipper such a receiver still passes, but the margin is 20 µs.


94б.6 RFID 125 kHz and iButton

How a tag is read. TIM1 drives a 125 kHz carrier into the antenna (furi_hal_rfid_tim_read_start(125000, 0.5) in lfrfid_worker_modes.c). The signal from the antenna, through the board's analog circuit (it is not in the sources), comes to the COMP1 comparator: the threshold is half of VREFINT, the hysteresis is large, and the comparator output is routed inside the chip to the capture input of TIM2. As with 433 MHz, from here on there are only durations: the decoders receive first the duration of the burst, then that of the pause. The firmware has 24 tag protocols (lfrfid_protocols.c).

EM4100 is the simplest tag, read-only. A frame is 64 bits: 9 header ones, 10 rows of 4 data bits and a parity bit, 4 column parity bits and a stop 0. The data is 5 bytes. The code is Manchester; a bit lasts 64 carrier periods, a half-bit 32 periods, 256 µs. The decoder windows (protocol_em4100.c):

#define EM_READ_SHORT_TIME_BASE  (256)
#define EM_READ_LONG_TIME_BASE   (512)
#define EM_READ_JITTER_TIME_BASE (100)

A short pulse is strictly between 156 and 356 µs, a long one between 412 and 612 µs. A frame is accepted only if the header, the stop bit, all 10 row parities and 4 column parities match, and the next 9 bits are a header again.

Experiment. protocol_em4100.c and manchester_decoder.c are built unchanged. The firmware encoder for the data 12 34 56 78 9A produced the frame 0xFF8CA64A98F8CA96 and a half-bit of 32 periods; assembling the frame ourselves by the format above gave the same. Then frames at 256 µs per half-bit go into the decoder:

Condition Result
6 frames without jitter 4 correct reads, the first after 191 bits
jitter ±99 µs 4 reads from 6 frames in 1000 of 1000
jitter ±100 µs in 12 of 1000
jitter ±120 µs in 0 of 1000
one data bit inverted not a single read

The first read arrives after almost three frames: the decoder waits for the header of the next frame. One read is not enough for the device: lfrfid_worker_modes.c accepts a tag when, after the first read, as many more matched as the protocol's validate_count requires — for EM4100 that is 3. In total four identical reads — exactly what 6 frames gave.

iButton is a key tablet on 1-Wire. The bus timings are in lib/one_wire/one_wire_host.c: for standard speed, a reset pulse of 480 µs, a presence check 70 µs later, then another 410 µs. The same library also reads the DS18B20 sensor in the example_thermo example from 94б.3; the DS18B20 and its bus are in section 96.

Decoder size. For Cortex-M4 with -Os (arm-none-eabi-gcc):

File Code, bytes
infrared_common_decoder.c + infrared_decoder_nec.c 1066 + 346
feed from princeton.c + decoder.c 376 + 94
protocol_em4100.c (with the encoder) + manchester_decoder.c 1102 + 64

A decoder is hundreds of bytes: NEC together with the common decoder is 1412 bytes, 0.13 % of 1 MB of Flash.


94б.7 Pitfalls


94б.8 Summary

Next: section 95 "How to connect sensors: power, pull-ups, levels, wires".

Open in the interactive handbook