CH592 radio register reference
2026-10-08I've spent some time reverse engineering the radio peripheral of the CH592 microcontroller. The other WCH BLE microcontrollers are broadly similar. My long term goal is better support in the open source ch32fun library. There is some API design work still needed for that. In the meantime, here are my work-in-progress register level docs. These are undoubtedly both incomplete and in places incorrect. However, they are enough to implement the basics of BLE.
If you have questions about the WCH radios, the best place to ask is the ch32fun discord. The reverse engineering effort has been taking place there. Thanks to @beimster, @montemonte, and others there for their help with this.
Radio
The radio hardware is organized into 4 memory mapped peripheral blocks.
| Register Block | Address |
|---|---|
| BB_BASE | 0x4000C100 |
| LL_BASE | 0x4000C200 |
| AES_BASE | 0x4000C300 |
| RFEND_BASE | 0x4000D000 |
The BB and LLE blocks have interrupt requests associated with them.
Interrupt lines: BB_IRQn = 20, LLE_IRQn = 21.
Notation
Register names match docs for the few that WCH has named publicly. Most are my own invention, matching ch32fun names when reasonable.
| Access | Meaning |
|---|---|
| R | Read only |
| R0 | Always reads 0 |
| W | Write only |
| R/W | Read / Write |
| R/W1C | Read / Writing a 1 bit to this place clears it to 0 |
| R/W0C | Read / Writing a 0 bit to this place clears it to 0 |
BB (BB_BASE = 0x4000C100) -- BaseBand controller
CTRL_CFG (BB0) -- BaseBand Controller Config
| Bit | Access | Description |
|---|---|---|
| 5:0 | R/W | BLE channel |
| 6 | R/W | Disable whitening (untested) |
| 7 | R/W | PHY rate (clear -> 1M, set -> 2M) |
| 9:8 | R/W | Radio or BB enable? 2 for ON and 1 for OFF |
| 12:10 | R/W | Unknown |
| 22:13 | R0 | Unused |
| 23 | W | Set when starting TX |
| 25:24 | R0 | Unused |
| 26 | R/W | Unknown |
| 27 | R0 | Unused |
| 30:28 | R/W | Unknown |
| 31 | R0 | Unused |
I think this register controls the RF frequency iff bit 1 of RF11 is cleared.
CRCINIT (BB1) -- Initial CRC register value
| Bits | Access | Description |
|---|---|---|
| 23:0 | R/W | CRC register initialization value |
| 31:24 | R0 | Unused |
ACCESSADDR (BB2) -- Access Address
32-bit access address used for both TX and RX
| Bits | Access | Description |
|---|---|---|
| 31:0 | R/W | Access address |
BB4 -- Unknown
| Bits | Access | Description |
|---|---|---|
| 5:0 | R/W | Carrier frequency tolerance? |
| 7:6 | R0 | Unused |
| 10:8 | R/W | Unknown Gain? |
| 13:11 | R/W | Unknown Gain? |
| 15:14 | R0 | Unused |
| 18:16 | R/W | Unknown |
| 23:19 | R0 | Unused |
| 31:24 | R/W | RX gain? Directly affects BBSTATUS[31:24] |
Increasing [5:0] narrows the acceptable frequency offset. Reception breaks above 0x1c, the blob sets 0x0e.
BB6 -- Unknown
| Bits | Access | Description |
|---|---|---|
| 3:0 | R/W | RX deviation window. Blob sets to 0x2 for 1M |
| 9:4 | R/W | Unknown. Must be 0x13 |
| 31:10 | R0 | Unused |
BB9 -- Unknown
| Bits | Access | Description |
|---|---|---|
| 15 | R/W | Enable continuous RSSI sampling |
CTRL_TX (BB11) -- TX Control
| Bits | Access | Description |
|---|---|---|
| 0 | R/W | Unknown. Must be cleared for TX to start |
| 1 | R/W | |
| 2 | R0 | Unused |
| 3 | R/W | Unknown. Appends ~2 us of unmodulated carrier |
| 4 | R/W | 16 symbol preamble when set (2M PHY untested) |
| 24:5 | R0 | Unused |
| 30:25 | R/W | Transmit power setting |
| 31 | R/W |
RSSI (BB12) -- Signal Stats
Written repeatedly while demodulating a packet. Updates continuously in receive when BB9 bit 15 is set. Between updates it holds the last value.
| Bits | Access | Description |
|---|---|---|
| 14:0 | R | Carrier frequency offset (15 bit signed) |
| 23:15 | R | RSSI (signed) dBm? |
| 31:24 | R? | Unknown |
Uncertain if bit 0 is the LSB of the CFO, but I think it is. The carrier frequency offset scale is relative to the symbol rate: 19.3 Hz per count at 1M and 38.7 Hz per count at 2M.
BBINT_EN (BB13) -- Baseband Interrupt Enable
| Bits | Access | Description |
|---|---|---|
| 15:0 | R/W | Interrupt enable corresponding to BBSTATUS |
| 31:16 | R | Unused |
BBSTATUS (BB14) -- Baseband Status
Bits [28:25] read 1 while the radio is idle.
| Bits | Access | Description |
|---|---|---|
| 0 | R/W1C | Transmission complete |
| 1 | R/W1C | Reception complete |
| 2 | R/W1C | Payload reception started |
| 3 | R/W1C | Unknown. Sets sporadically in RX |
| 4 | R/W1C | Unknown. Used in blob |
| 5 | R/W1C | CRC error |
| 6 | R/W1C | Length field complete. Written ~16 us later |
| 7 | R/W1C | CRC received |
| 8 | R/W1C | Unknown. Sets sporadically in RX |
| 9 | R/W1C | Unknown. Sets sporadically in RX |
| 10 | R/W1C | Unknown. Set at end of RX (trailer written?) |
| 11 | ? | Never observed set |
| 12 | ? | Never observed set |
| 13 | ? | Never observed set |
| 14 | ? | Never observed set |
| 15 | ? | Never observed set |
| 21:16 | R | Packet-processing state |
| 22 | R | Set in transmit, clear in receive or at idle |
| 23 | R | 8th state bit? Never observed set |
| 31:24 | R | AGC? Always 0x1e at idle |
Packet-processing state [23:16] advances through a fixed sequence, 0x00 being idle.
Observed states:
| Direction | Sequence |
|---|---|
| Receive | 0x24 window open, 0x25 access address correlated, 0x26, 0x27, 0x28 payload start, 0x29 payload end, 0x2a |
| Transmit | 0x45 .. 0x4f setup and ramp, 0x50 payload sent, 0x54, 0x56 |
BB15 -- Unknown
Looks like four 5 bit fields?
| Bits | Access | Description |
|---|---|---|
| 4:0 | R/W | Unknown. Blob sets 0x0c |
| 7:5 | R0 | Unused (?) |
| 12:8 | R/W | Unknown. Blob sets 0x02 |
| 15:13 | R0 | Unused (?) |
| 20:16 | R/W | Unknown. Blob sets 0x02 |
| 23:21 | R0 | Unused (?) |
| 28:24 | R/W | Unknown. Blob sets 0x00 |
| 31:29 | R0 | Unused (?) |
BB22, BB24 -- RSSI Sample Counters
Two independent 32-bit up-counters. Both increment at about 29.0 kHz, once for each RSSI sample.
CTE (BB26) -- Constant Tone Extension?
I think this is BB25 on the CH58x.
| Bits | Access | Description |
|---|---|---|
| 0(?) | R/W | param_1 (is this 1 bit or 2?) |
| 1 | R/W | |
| 2 | R/W | Set unconditionally |
| 7:3 | R/W | param_2 |
| ??:8 | R/W | param_3 |
| 18 | R/W | param_4 |
| 22:19 | R/W | ? |
Bits 22:19 are 0x0010_0000 on CH59x and 0x0028_0000 on CH58x
Sets RB_RF_ANT_SW_EN in R16_PIN_ALTERNATE if param_1 != 0:
RF antenna switch control output enable:
1: Switch control output to PA[4]~PA[5], PA[12]~PA[15];
LL (LL_BASE = 0x4000C200) -- Link Layer Engine
CTRL_CMD (LL0) -- Link Layer Engine Command
Write 1 for RX, 2 for TX. 8 is probably STOP, 4 might be SHUT (untested). STOP is intended to exit automatic mode from the ISR. SHUT resets the entire LLE state machine (c.f. CH579DS1).
LL_CFG (LL1) -- Link Layer Engine Configuration
| Bit | Access | Description |
|---|---|---|
| 0 | R/W | Enable auto mode if set |
| 4:1 | R/W | Unknown |
| 7:5 | R0 | Unused |
| 10:8 | R/W | Unknown |
| 15:11 | R0 | Unused |
| 31:16 | R/W | Delay after LL0 command |
[31:16] is a 2 MHz counter, 0.5 us per count. It sets a delay between the LL0 command and the TX/RX in both manual and auto mode. Values above about 256 destabilise transmission?
LLSTATUS (LL2) -- Link Layer Engine Status
Bits [25:0] are latched IRQ flags, cleared by writing 1. Bits [31:26] are read-only live state and clear themselves.
| Bit | Access | Description |
|---|---|---|
| 0 | R/W1C | RX command complete (LL0 = 1 sequence finished, including an auto-mode turnaround TX) |
| 1 | R/W1C | TX command complete (LL0 = 2 sequence finished, including an auto-mode turnaround RX) |
| 2 | R/W1C | Auto-mode RX phase complete: packet received, turnaround pending |
| 3 | R/W1C | Auto-mode TX phase complete: packet sent, turnaround pending |
| 4 | R/W1C | Unknown event. Sets with bit 29 when the second phase of an auto-mode sequence whose first phase is an RX engages. With a TX second phase, at the end of the LL7 interval |
| 5 | R/W1C | Unknown event. Sets when the sequencer re-engages after an RX-initiated sequence completes |
| 6 | R/W1C | Unknown event. Sets when the second phase of an auto-mode TX sequence engages |
| 7 | R/W1C | Never observed set |
| 8 | R/W1C | Unknown event. Sets at the midpoint of the turnaround following an auto-mode RX phase. With a TX second phase, at the end of the LL5 interval, where the LL7 interval begins. Also sets 67 us after a manual-mode RX command completes |
| 9 | R/W1C | Unknown event. Sets 71 us after bit 0 in an auto-mode RX sequence |
| 10 | R/W1C | Post-transmit guard elapsed, 240 us after a TX completes |
| 11 | R/W1C | Never observed set |
| 12 | R/W1C | Unknown event. Sets with bit 8 in auto mode |
| 13 | R/W1C | Unknown event. Sets with bit 9 in auto mode |
| 14 | R/W1C | Unknown event. Sets with bit 10 in auto mode |
| 15 | R/W1C | Never observed set |
| 16 | R/W1C | TMR0 (LL24) expired |
| 17 | R/W1C | TMR1 (LL25) expired |
| 18 | R/W1C | TMR2 (LL26) expired |
| 19 | R/W1C | TMR3 (LL27) expired |
| 20 | R/W1C | TMR4 (LL28) expired |
| 21 | R/W1C | TMR5 (LL29) expired |
| 22 | R/W1C | Unknown event. Sets with bit 0 on a manual-mode RX completion |
| 23 | R/W1C | Unknown event. Sets with bit 1 on a manual-mode TX completion |
| 24 | R/W1C | Unknown event. Set when RX is cancelled? |
| 25 | R/W1C | Never observed set |
| 26 | R | Live state. Set while the radio is engaged (CTRL_MOD bit 3 = 1). Clears with bit 8 at the turnaround midpoint |
| 27 | R | Live state. Set when a command is written to LL0, and with bit 8 at the turnaround midpoint, where bit 26 clears |
| 28 | R | Live state. Set during an auto-mode turnaround |
| 29 | R | Live state. Set during an auto-mode turnaround. Sets with bit 4 when the second phase engages |
| 30 | R | Live state. Set while the radio is engaged (CTRL_MOD bit 3 = 1) |
| 31 | R | Live state. Set when a command is written to LL0 |
LLINT_EN (LL3) -- Link Layer Engine Interrupt Enable
| Bits | Access | Description |
|---|---|---|
| 25:0 | R/W | Interrupt enable corresponding to LLSTATUS |
| 31:26 | R0 | Unused |
Auto Mode Delay Pairs (LL5/LL7 - LL17/LL19)
Four pairs of 32-bit timing registers. In auto mode, the spacing between the two phases is determined by one of these four pairs. Which pair is used seems to match the choice of IRQs in 7:4 and 11:8. The values work out to either 129 us or 125 us. I suspect this is so the RX starts a little early?
| Register | Blob Value | time |
|---|---|---|
| LL5 | 0x8c | 70 us |
| LL7 | 0x76 | 59 us |
| LL9 | 0x8c | 70 us |
| LL11 | 0x6e | 55 us |
| LL13 | 0x8c | 70 us |
| LL15 | 0x6e | 55 us |
| LL17 | 0x8c | 70 us |
| LL19 | 0x76 | 59 us |
The even indexed LL registers in-between (LL6 - LL18) appear unused?
CTRL_MOD (LL20) -- LLE Module Control?
| Mode | Blob value |
|---|---|
| TX | 0x30258 |
| RX | 0x30158 |
| TX (AES) | 0x30278 |
| RX (AES) | 0x30178 |
| STOP | 0x30078 |
| Bits | Access | Description |
|---|---|---|
| 2:0 | R0 | Unused |
| 3 | ? | Unknown, maybe a READY bit? HW changes this |
| 4 | R/W | Unknown, always set to 1 |
| 5 | R/W | Routes packet data through AES module when set |
| 6 | R/W | Unknown, always set to 1 |
| 7 | W | Written before AES config. AES block reset? |
| 10:8 | R/W | Radio mode? 0 stop, 1 RX, 2 TX, 5 tune |
| 15:11 | R0 | Unused |
| 19:16 | R/W | Unknown, always set to 3 |
| 31:20 | R0 | Unused |
LL21 -- Unknown
| Bits | Access | Description |
|---|---|---|
| 15:0 | R/W | Unknown, set to 0x14 in lle.o:LLE_DevInit |
| 25:16 | R0 | Unused |
| 30:26 | R/W | Unknown |
| 31 | R/W | Unknown, set in lle.o:LLE_DevInit |
LLTMRn (LL24-LL29) -- LLE Timers
Six independent 32-bit count down timers. Value decrements every 0.5 us (2 MHz) until it reaches 0. The decrement of the count from 1 to 0 triggers the corresponding LL IRQ (16 - 21).
| Bits | Access | Description |
|---|---|---|
| 31:0 | R/W | Timer count down value |
TXBUF (LL30) -- Transmit data buffer
Pointer to the transmit data, formatted as a PDU including header PDU (plaintext) length is determined from TXBUF[1]. When hardware packet encryption is enabled the on-air packet includes four additional MIC bytes.
Address must be in RAM and word aligned. The value in the register is masked by 0x00007ffc.
| Bits | Access | Description |
|---|---|---|
| 1:0 | R0 | Fixed zero (word aligned) |
| 14:2 | R/W | Buffer RAM address |
| 31:15 | R0 | Implied 0x2000_0000, reads zero |
RXBUF (LL31) -- Receive data buffer
Pointer to the receive buffer. Will be filled with received PDU including header. PDU (plaintext) length is in RXBUF[1]. When hardware packet encryption is enabled the length in the buffer does not include the four additional MIC bytes.
Address must be in RAM and word aligned. The value in the register is masked by 0x00007ffc.
| Bits | Access | Description |
|---|---|---|
| 1:0 | R0 | Fixed zero (word aligned) |
| 14:2 | R/W | Buffer RAM address |
| 31:15 | R0 | Implied 0x2000_0000, reads zero |
Buffer contents
The hardware appends four status bytes to the end of the RX buffer. Here n is the length on-air, which is BUF[1]+4 when AES is enabled.
| Byte | Description |
|---|---|
| 0 | PDU, as received on air |
| 1 | Length, plaintext length when AES is enabled |
| 2..n+1 | Payload data |
| n+2..n+3 | center frequency offset (see RSSI reg BB12) |
| n+4 | Packet RSSI (see RSSI reg BB12) |
| n+5 | Status bits: 4->CRCFAIL, 7->MICFAIL |
When AES CCM is enabled, RXBUF[LENGTH + 9] bit 7 set indicates MIC failure.
AES (AES_BASE = 0x4000C300) -- AES Accelerator
128-bit AES engine with two modes selected by CTRL bit 7: ECB block mode over the register file, and CCM packet mode inline in the radio datapath. Register addresses are the same in both modes; the roles of AES2-AES5, DATA and KEY differ.
Clocked by the BLE clock (R8_SLP_CLK_OFF1.RB_SLP_CLK_BLE). Serviced by BB_IRQn; there is no dedicated AES interrupt line.
CTRL (AES0) -- AES Control
| Bits | Access | Description |
|---|---|---|
| 0 | R/W | ECB mode: start. Self-clearing |
| 1 | R/W | ECB mode: decrypt if set, encrypt if clear |
| 2 | R/W | Unknown |
| 4:3 | R/W | Key length: 0 (or 3) -> 128 bit, 1 -> 192, 2 -> 256 |
| 5 | R/W | CCM mode: nonce direction bit |
| 6 | R/W | CCM mode: arm. Clears when a packet is processed |
| 7 | R/W | Mode select (clear -> ECB, set -> CCM) |
| 31:8 | R0 | Unused |
STAT (AES1) -- AES Status
| Bits | Access | Description |
|---|---|---|
| 0 | R/W | Completion BB_IRQn interrupt enable |
| 1 | R/W0C | Operation complete, cleared by writing 0 |
| 31:2 | R0 | Unused |
When bit 0 is set, AES completion triggers a BB interrupt.
AES2 -- CCM Nonce IV Low
| Bits | Access | Description |
|---|---|---|
| 31:0 | R/W | CCM: low bytes of the 8-byte nonce IV (IVm) |
AES3 -- CCM Nonce IV High
| Bits | Access | Description |
|---|---|---|
| 31:0 | R/W | CCM: high bytes of the 8-byte nonce IV (IVs) |
AES4 -- CCM Packet Counter Low
| Bits | Access | Description |
|---|---|---|
| 31:0 | R/W | CCM mode: packet counter bits [31:0] |
AES5 -- CCM Packet Counter High
| Bits | Access | Description |
|---|---|---|
| 6:0 | R/W | CCM mode: packet counter bits [38:32] |
| 31:7 | R0 | Unused |
DATA (AES6-AES9) -- Data Block / CCM SKD
Four 32-bit words, DATA[0] at offset 0x18. Words are packed little-endian: the byte at DATA[0] bits [7:0] is FIPS-197 block byte 0.
ECB mode: the input block. The result overwrites it in place, and is valid once STAT bit 1 is set.
CCM mode: the 16-byte session key diversifier (SKD). Not modified by the engine.
| Bits | Access | Description |
|---|---|---|
| 31:0 | R/W | Block word |
KEY (AES10-AES17) -- Key / CCM LTK
Four, six, or eight 32-bit words, KEY[0] at offset 0x28. Pack like DATA: the byte at KEY[0] bits [7:0] is FIPS-197 key byte 0. Retained across operations.
ECB mode: the 128-bit / 192-bit / 256-bit key. Length determined by CTRL.
CCM mode: the long term key (LTK). The engine derives the session key internally as AES-128-ECB(KEY, DATA). The derived key is not written back to any register.
| Bits | Access | Description |
|---|---|---|
| 31:0 | R/W | Key word |
AES18 - AES21 -- Scratch
| Bits | Access | Description |
|---|---|---|
| 31:0 | R/W | Unknown |
Updated by AES operations but has no visible impact.
ECB operation
Write KEY, write CTRL with bit 1 selecting the direction, write DATA, then set CTRL bit 0. Then wait for STAT bit 1 to set. DATA read back before STAT bit 1 sets is stale. One block takes approximately 39 core cycles.
CCM operation
Write CTRL with bit 7 set and bit 5 selecting the direction, write AES4, AES5, DATA, KEY, AES2 and AES3, then set CTRL bit 6. The engine then waits for one packet in one direction. LL->CTRL_MOD bit 5 must be set for that packet to be routed through the engine. Completion is indicated by CTRL bit 6 clearing and STAT bit 1 setting. Raises no interrupt of its own, unclear if a BB interrupt is tied to this.
LENGTH is the plaintext length in both directions. The additional authenticated data is the PDU header octet with NESN, SN and MD masked to zero. Data is encrypted/decrypted on the fly. TXBUF is not modified. On reception the plaintext is written to RXBUF whether or not authentication succeeded. A status byte is appended at RXBUF[RXBUF[1] + 9] with bit 7 set on MIC failure.
RFEND (RFEND_BASE = 0x4000D000) -- RF FrontEND
RF11
| Bits | Access | Description |
|---|---|---|
| 1 | R/W | Freq from RF17 if set, BB->CTRL_MOD if clear |
RF17 -- Manual Tune
F_hz = COARSE * 64 MHz + FINE * 244.140625 Hz and 244.140625 Hz = 64 MHz / 2^18
I suspect the demodulator expects offset tuning by data rate, e.g. tune to 2401 MHz to receive 2402 MHz in 1M PHY mode.
| Bits | Access | Description |
|---|---|---|
| 17:0 | R/W | Coarse tune (freq / 64 MHz) |
| 19:18 | R0 | Unused |
| 24:20 | R/W | Fine tune (244.140625 Hz steps) |
| 31:25 | R0 | Unused |
if (tune-by-channel) {
RFEND->RF11 &= ~(1 << 1);
BB->CTRL_CFG = (BB->CTRL_CFG & ~0x7f) | chan & 0x7f;
} else {
// Probably needed for whitening sequence
BB->CTRL_CFG = (BB->CTRL_CFG & ~0x7f) | chan & 0x7f;
if (tune-by-channel) {
if (2M PHY) {
freq = freq - 2000;
}
else {
freq = freq - 1000;
}
}
RFEND->RF11 |= (1 << 1);
RFEND->RF17 = (RFEND->RF17 & 0xfe0fffff) | (freq / 64000 & 0x1f) << 20;
RFEND->RF17 = (RFEND->RF17 & 0xfffc0000) | (freq % 64000 << 10) / 250 & 0x3ffff;
}RF20 -- Receive Settings
POR value 0x00017010. Writable mask 0x1031f71f.
| Bits | Access | Description |
|---|---|---|
| 4:0 | R/W | Receive filter corner trim |
| 7:5 | R0 | Unused |
| 10:8 | R/W | Unknown |
| 11 | R0 | Unused |
| 16:12 | R/W | Unknown. Default is 7 |
| 16:12 | R/W | Ignores 4:0 filter trim when set |
| 19:17 | R0 | Unused |
| 21:20 | R/W | Negate I / Q |
| 27:22 | R0 | Unused |
| 28 | R/W | Unknown |
| 31:29 | R0 | Unused |
Increasing 4:0 lowers the upper edge of the channel filter. Setting 20 / 21 negates I / Q, mirroring the signal. Can't determine which is I vs Q, doesn't really matter.