Skip to content

serial: CRSF and SBUS receivers on DMA, handed each frame when the line goes idle - #12132

Open
MrScothh wants to merge 34 commits into
iNavFlight:maintenance-10.xfrom
MrScothh:feature/uart-rx-dma-receivers
Open

MrScothh wants to merge 34 commits into
iNavFlight:maintenance-10.xfrom
MrScothh:feature/uart-rx-dma-receivers

Conversation

@MrScothh

@MrScothh MrScothh commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Builds on #12129 (DMA on every UART, which builds on #12128, #12062, #12033 and #12001) and on #12118 (afatfs_poll out of ITCM on F7), merged in because KAKUTEF7 and KAKUTEF7HDV need the ITCM it frees. Please review those first; once they are merged, only this PR's commits are left here.

Problem

Receivers are the one kind of port the UART DMA work leaves on the byte interrupt: every byte of a CRSF or SBUS frame is one interrupt, 26 per CRSF RC frame, so 26 000 a second with ELRS at 1 kHz. They were left out because their parsers time each byte: CRSF starts a new frame after a gap, SBUS waits for a quiet line before a frame and drops one that pauses for 3 bytes in the middle. A stream that hands the bytes over later would break those times.

Change

A receiver that asks for it with SERIAL_RX_BURSTS (CRSF and SBUS here) gets a receive stream like any other port.

  • The UART's idle-line interrupt (one character of silence after the last byte) hands the new bytes to the receiver's callback in one go, from interrupt context at the UART's priority, as the byte interrupt did.
  • The stream's half and full ring interrupts hand over a burst longer than half the ring before it is overwritten.
  • Each byte is timed back from the end of the burst, one character apart; the parsers read that time with serialRxByteTimeUs() instead of micros(), so their gap checks see what they saw with one interrupt per byte. A pause shorter than a character inside a burst is not seen, but it never mattered to them either.

Half-duplex receivers and the other protocols stay on the byte interrupt. The CRSF port's transmitter (telemetry), and SBUS's when it shares the port with telemetry, get a stream too, like every other port since #12129.

Around it: uartOpen() stops the receive stream before it resets a port, as it already did the transmit one; a port given a new ring restarts its stream in the same atomic block; and an MSP serial passthrough drops the receiver's callback, as the CLI one always did, so the session reads the ring alone, and gives it back when the session ends with +++. The target guide (timer-dma-conflicts.md) says which receivers take a stream.

The costs:

  • A frame reaches the parser one character later than on the byte interrupt: 24 us at CRSF's 420000 baud, 120 us for SBUS (12 bits at 100000).
  • The whole frame is handed over in one interrupt: about 4 400 to 5 500 cycles for a 25 or 26 byte frame on an F722 at 216 MHz, 20 to 25 us, where the byte interrupt took about 650 cycles for each byte. Another UART still on the byte interrupt waits that long at most; at 460800 baud and above (a character every 22 us or less) a continuous stream on such a port could lose a byte. Since serial: DMA on every UART, the stream picked at runtime where the target names none #12129 those ports normally have a stream of their own.

Tests

MAMBAF722_2022B with TX3 wired to RX3: the receiver on UART3, and a bench-only CLI command sending frames out of the receiver's own port at a fixed rate. The same runs on a baseline image with the receive stream refused (byte interrupt). Every frame checked by its CRC.

Frames sent intact, byte interrupt / this PR UART interrupts per frame interrupt cycles per frame first byte after the write, us (avg)
CRSF 150 Hz, 600 600 / 600 26 / 1 16 854 / 6 662 35 / 37
CRSF 500 Hz, 2000 2000 / 2000 26 / 1 16 832 / 6 675 35 / 37
CRSF 1000 Hz, 3000 3000 / 3000 26 / 1 16 840 / 6 695 35 / 37
CRSF, 6 frames back to back, 600 600 / 600 26 / 0.17 16 561 / 5 896 71 / 77
CRSF, 8 frames back to back, 400 400 / 400 26 / 0.13 16 542 / 5 863 86 / 91
SBUS 100 Hz, 500 500 / 500 25 / 1 16 961 / 5 817 121 / 128

The cycles count everything at interrupt level for that port, the parser's callback and the bench sender's transmit stream included, which are the same in both images: the difference, about 10 000 cycles per frame, is the byte interrupts. At 216 MHz that is about 47 us per frame, 4.7 % of the CPU with CRSF at 1 kHz. With this PR the stream's half and full ring interrupts add about 0.2 per frame. The last column is the time the parser was given for each frame's first byte, after the sender started writing it: timed back from the end of the burst it lands 2 to 7 us after the byte interrupt's own reading, well within a character (24 us for CRSF, 120 us for SBUS).

  • CRSF bursts of 6 and 8 frames back to back (156 and 208 bytes, past half the 256 byte ring): every frame intact.
  • SBUS with a pause inside the frame, on both images with SBUS: fix SBUS_BYTE_TIME_US being 1000x too long #12110 merged (without it the pause check needs 360 ms, so neither image ever drops a frame for a pause): a pause of about 2 characters splits the frame into two bursts and every frame is kept; from about 3.3 characters up every frame is dropped. Same split, frame for frame, as on the byte interrupt.
  • TBS_LUCID_H7_WING, UART6 in half duplex receiving what it sends. For this bench image two lines in serial_uart_impl.h let a half-duplex receiver take bursts; the stream, the idle line and the timing are the code of this PR. CRSF at 150, 500 and 1000 Hz and in bursts of 6 frames all intact, 1 UART interrupt per frame, 11 590 -> 3 826 interrupt cycles per frame at 480 MHz; SBUS kept and dropped exactly as on the byte interrupt.
  • KAKUTEH7MINI (H743) as shipped, in full duplex, with its ELRS receiver on UART4 and the radio on (about 1.1 kHz), 30 s per image, byte interrupt / this PR: 33 194 / 33 087 frames, none with a bad CRC, 26 / 1 UART interrupts per frame, 13 482 / 3 971 interrupt cycles per frame. Again with everything running together, MSP on UART1, the GPS on UART2 and the four motors spinning in DSHOT300 (props off): 32 689 / 32 693 frames, none bad, the GPS working as usual, 13 676 / 4 037 cycles per frame.
  • MAMBAF722_2022B (F722), its ELRS receiver on UART1, radio on, 30 s per image, byte interrupt / this PR: 32 898 / 33 079 frames, none with a bad CRC, 26 / 1 UART interrupts per frame, 18 048 / 6 824 interrupt cycles per frame, about 52 us a frame given back at 216 MHz. With the four motors spinning in DSHOT300 (props off), this PR: 32 666 frames, none bad, 6 822 cycles per frame.
  • The ELRS runs above had the receiver's telemetry stopped (the counters were read from the CLI). With telemetry on, KAKUTEH7MINI, 50 to 60 s from boot per run: 48 000 to 58 000 frames and none bad, on this PR and on the byte interrupt alike. Some boots start with a run of bad frames: the port opens in the middle of a frame and the CRSF parser, which has no sync byte, reads lengths out of channel bytes until it falls back into step. That comes from the parser, not from this PR: 15 s from boot, 5 boots in 10 had them on the byte interrupt (1 to 574 frames), 2 in 12 with this PR (2 and 180 frames).
  • After the last changes (uartOpen(), the ring swap, MSP passthrough) the Mamba runs again: CRSF at 150 and 1000 Hz, bursts of 6 and 8, SBUS at 100 Hz, every frame intact.
  • Unit tests: 652 of 652 pass. New: the stream pick for receivers taking bursts, and a mirror of the burst timing (character times of CRSF and SBUS, a burst timed back from its end, a burst wrapping round the ring) checked against the source.
  • All 237 targets build. Size against serial: DMA on every UART, the stream picked at runtime where the target names none #12129 (3034909) with asyncfatfs: keep afatfs_poll out of the ITCM scheduler #12118 merged:
Target Flash RAM ITCM
MATEKF405SE +816 B +144 B (80.4 %)
MATEKF722SE +568 B (98.98 %) +120 B +0
FLASHHOBBYF722, the fullest F722 +616 B (99.25 %) +120 B +0
KAKUTEF7, KAKUTEF7HDV +576 / +592 B +104 B -96 B (93.5 %)
MATEKH743 +952 B +160 B +24 B (24.9 %)
BETAFPVF435 +736 B +152 B (91.8 %)

RAM is the two new fields in every serial port's state. Measured before 39bddd3, on #12129 alone KAKUTEF7 and KAKUTEF7HDV ended at exactly 100 % ITCM: none of the new code goes there, but LTO lays scheduler() and the rate controller out differently. That is why #12118 is merged in.

Not tested, testers wanted

  • F4 and AT32: built only, I have no board with a free loop.

If you fly CRSF or SBUS on F4, H7 or AT32, a flight or a bench check with this build (RX rate, link quality, no failsafe) would help; a report of the board and the receiver is enough.

NAV-SIG (M9, M10) and NAV-SAT (M8) were requested at every navigation
epoch. They feed only the satellite list in the CLI, yet they are most of
what the receiver sends: NAV-SIG carries 16 bytes for each tracked signal,
several hundred bytes per epoch on a multi-constellation receiver, against
about 100 for the NAV-PVT that navigation runs on.

They are now requested every (gps_ublox_nav_hz + 1) / 2 epochs, which is
about twice a second at any navigation rate.
A receive buffer has to hold what arrives between two runs of the task
that drains it. For the GPS task, 20 ms apart, that is up to 230 bytes at
115200 and 460 at 230400, and a reply to a poll has to fit whole: MON-VER
alone can be 258 bytes. With the 256 bytes every port has, a NEO-F10N at
230400 was never identified, because its MON-VER reply was always cut.

The GPS port now gets a 512 byte buffer of its own, in FASTRAM, where
there is room: CCM on F405, RAM1 on AT32F43x. serialSetRxBuffer() swaps
it in right after the port is opened. Every other port keeps its 256
bytes.

The note that occupied sizes are returned as uint8_t no longer held: the
driver functions and the ring indices are all 32 bit.
The satellite details also feed the OSD's GPS extra stats, not only
gpssats, so the comment now says both. The comments are one line each,
the buffer states the rate it covers (230400; at 460800 and above a
NAV-PVT plus a large NAV-SIG can still overflow it), its size is asserted
to be a power of two because softserial masks with size - 1, and it only
exists where the u-blox provider does, the only one that opens a port.
The note in serial_uart.h about sending a UBLOX SVINFO was stale.
A UART receiving through a DMA stream costs no interrupt per byte, and keeps
the bytes that arrive while interrupts are held off. A target turns it on per
port with UARTx_RX_DMA; a port without one, and a target that names none,
build and behave exactly as before.

The stream fills the port's own ring, whose head is read back from the
transfer counter, so nothing above the driver changes. Ports whose owner
takes each byte through rxCallback (the serial receivers, which frame by
timing) stay on the interrupt even when a stream is named.

F4 and F7 wire each receiver to fixed streams, so a tag naming another is a
build error. H7 and AT32 route through the DMAMUX. On H7 the ring moves to
D2 SRAM, out of the data cache. A stream any timer output is mapped to is
left to the timers, since MSP ports open before the motors do.

On F7, H7 and AT32 the interrupt handler tested the RX flag without the
enable, so a port receiving through DMA could have lost a byte to the
interrupt raised for what it sends; it now leaves the data register alone.

Enabled on TBS_LUCID_H7_WING for UART2, on DMA2 stream 5.
serialSetRxBuffer() only swapped the ring's pointer and size, but a DMA
stream keeps writing to the memory it was started on, so a port receiving
through DMA would have gone on filling its old ring while the reader looked
at the new one. The serial vtable gets an optional setRxBuffer, which the
UARTs implement by swapping the ring and, if a stream is running, starting
it again on the new one.

The GPS ring must then be memory a stream can reach on targets that receive
through DMA: D2 SRAM on H7 rather than DTCM, and on F4 and AT32F43x plain
RAM rather than CCM or RAM1. Everywhere else it stays in FASTRAM, so a
target without UARTx_RX_DMA builds exactly as before.

On the TBS Lucid H7 Wing, GPS on UART2 through DMA2 stream 5, NEO-F10N:
identified with no errors or timeouts at 115200 and after autobaud to
230400, NAV-PVT at the configured 10 Hz. Over ten reboots, 1 error and 2
timeouts at startup, against 1 and 1 over nine with the byte interrupt.
A UART sends one byte per interrupt. A target can now name a stream for a
port's transmitter (UARTx_TX_DMA), as it can for the receiver: the stream
empties the transmit ring and the port takes one interrupt per transfer.
writeBuf() queues a message whole, so MSP and MAVLink send each in one
transfer; what is queued while one runs goes in the next.

On F4, F7, H7 and AT32. The transmit ring moves to D2 SRAM on H7, as the
receive ring does. The stream check the receiver used now serves both, and
F4/F7 targets naming a stream the transmitter is not wired to fail to
build. The TBS Lucid H7 Wing sends on UART2 through DMA2 stream 7.
taskRunRealtimeCallbacks() calls afatfs_poll() from scheduler(), which is
FAST_CODE, and LTO inlines the file system's state machine into it. On
targets with an SD card that puts 800 to 980 bytes of afatfs code in ITCM,
depending on the target, where KAKUTEF7 and KAKUTEF7HDV are at 99%. The
poll does a little work at most once per scheduler loop, so it runs from
flash like the rest of afatfs.
iNavFlight#12119 turns the F7 D-cache on and makes DMA_RAM an uncached SRAM2
region. A ring left in cached RAM could then hand the CPU a line it
cached before the stream filled it. The rings go in DMA_RAM, as on H7;
on F7 that is plain RAM until iNavFlight#12119, so on its own this changes nothing.

Not seen to fail at 115200: FLYWOOF722PRO with iNavFlight#12119 and the GPS
sending on the interrupt had 0-1 errors per boot with the rings in
cached RAM and in DMA_RAM alike.
On F7 and H7, stopping the stream clears USART_CR3_DMAR and only
uartReconfigure() set it again. After serialSetRxBuffer() the stream ran
but the UART never fed it, and with the byte interrupt off for a DMA port
the port stayed deaf until the next baud, mode or option change. The GPS
got away with it because the u-blox driver changes the baud rate right
after swapping its ring. F4 and AT32 already set the request when the
stream starts.

With a test patch that swaps a larger GPS ring in 10 s after boot,
FLYWOOF722PRO and KAKUTEH7MINI had a timeout on every boot without this
and none with it.
# Conflicts:
#	src/main/drivers/serial_uart_stm32f7xx.c
MSP DisplayPort and the serial gimbal swap their own TX buffer into the port after opening it. On
H7 that buffer is in AXI SRAM, which the CPU caches, so the stream sends what is in RAM rather than
what was just written. Kakute H7 Mini with the GPS port given such a buffer (a test patch): the
u-blox never got its configuration (3 timeouts, no navigation messages); with this, a navigation
message every 120 ms and 0 errors over 3 reboots. On F7 every ring is cached once iNavFlight#12119 turns its
D-cache on: FLYWOOF722PRO with iNavFlight#12119 had 11-14 GPS errors and no fix on every boot, and 0 with this.

The lines the transfer covers are written back first. Without a D-cache, or on the uncached D2
rings, that does nothing.
The stream check refused every stream any timer output is mapped to, so
a target naming a UART stream on F4 or F7, where each UART has one or
two fixed streams, would find most of them taken. Now it refuses a
stream while DSHOT motors that may take it have not started, and the LED
strip's stream when that feature is on. Started motors already own their
streams, so a port opened after them gets the streams of outputs that
ended up as servos or unused, and every stream but the LED strip's when
the motors are not on DSHOT. The choice is made at each boot.
…get names none

On F4, F7, H7 and AT32F43x a UART whose target names no stream now picks
a free one when it opens: on F4 and F7 the streams the reference manual
wires it to, on H7 and AT32 any stream the DMAMUX can route except the
ADC's. It skips the streams any target names for a port, the SD card's,
its own other direction's, and those a timer output will use; a port
opened again takes back a stream it already holds first, since none is
ever given back. Serial receivers keep receiving on the interrupt, and
half-duplex ports get no stream unless the target names one. A target
keeps a port on the interrupt by setting UARTx_RX_DMA or UARTx_TX_DMA to
DMA_NONE.

Tested on F722 (Flywoo F722 Pro, also with the D-cache of iNavFlight#12119) and
H743 (Kakute H7 Mini, TBS Lucid H7 Wing) with GPS, MSP and DSHOT motors.
F4 and AT32 are untested on hardware.
The H7's ITCM is 64 KB and about a quarter used, so the poll can stay
inlined in the scheduler there, as on maintenance-10.x. F7 builds are
identical to the previous commit; H7, F4 and AT32 builds are identical
to maintenance-10.x.
…ne goes idle

Receivers were the one kind of port left on the byte interrupt, one
interrupt per byte, because CRSF and SBUS time every byte. A receiver
that asks for it with SERIAL_RX_BURSTS now gets a receive stream: the
idle line interrupt, and the stream's half and full ring interrupts for
longer bursts, hand the new bytes to its callback in one go at the
UART's priority. Each byte is timed back from the end of the burst one
character apart, and CRSF and SBUS read that time with
serialRxByteTimeUs(), so their gap checks see what they saw before.
Half-duplex receivers and the other protocols stay on the byte
interrupt.

uartOpen() now stops the receive stream as well before it resets a
port, a new ring restarts the stream in the same atomic block, and an
MSP passthrough drops the receiver's callback, as the CLI one does.
…art-rx-dma-receivers

KAKUTEF7 and KAKUTEF7HDV need the ITCM it frees: without it this branch
leaves them at exactly 100 %.
On F7 DMA_RAM is plain RAM until iNavFlight#12119 turns the D-cache on and makes
it an uncached SRAM2 section, so until then each ring only duplicated
the port's own, 256 B per UART with a receive stream. They are compiled
only where build_config.h says DMA_RAM is uncached (DMA_RAM_UNCACHED,
set together with the D-cache), so either PR can go in first.
# Conflicts:
#	src/main/drivers/serial_uart_stm32f7xx.c
@qodo-code-review

Copy link
Copy Markdown
Contributor

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Receive CRSF and SBUS frames through UART DMA bursts

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Deliver CRSF and SBUS bytes on UART idle or DMA ring interrupts, preserving parser gap timing.
• Add automatic UART RX/TX DMA allocation with interrupt fallback and timer-output stream
 protection.
• Increase GPS receive capacity, reduce satellite-detail traffic, and protect buffer swaps and
 passthrough sessions.
Diagram

graph TD
  U["UART Port"] --> P{"DMA eligible?"} -->|yes| R["DMA RX Ring"] --> I["Idle or half IRQ"] --> T["Byte timestamps"] --> C["CRSF / SBUS"]
  P -->|no| B["Byte IRQ"] --> C
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Keep receiver byte interrupts
  • ➕ Retains measured per-byte arrival times and avoids burst-delivery latency.
  • ➖ Preserves high interrupt rates for fast CRSF links.
2. Poll DMA receive positions
  • ➕ Avoids idle-line and half/full-ring delivery interrupts.
  • ➖ Adds scheduler-dependent latency and makes parser gap timing and overwrite protection harder.

Recommendation: Keep the opt-in burst path with byte-interrupt fallback: it addresses receiver interrupt load without requiring parser rewrites, while reconstructed character times retain the gap checks. Review DMA ownership, cache accessibility, and worst-case interrupt latency closely; the fallback limits exposure where a stream is unavailable.

Files changed (26) +2801 / -43

Enhancement (17) +1841 / -43
pwm_mapping.cReserve streams needed by timer outputs +23/-0

Reserve streams needed by timer outputs

• Tracks motor initialization and identifies streams that upcoming DSHOT or enabled LED outputs may claim, preventing UART allocation from preempting them.

src/main/drivers/pwm_mapping.c

pwm_mapping.hExpose timer DMA reservation check +2/-0

Expose timer DMA reservation check

• Declares the reservation query used by UART DMA stream selection.

src/main/drivers/pwm_mapping.h

serial.cProvide receiver byte times and RX buffer replacement +32/-0

Provide receiver byte times and RX buffer replacement

• Returns reconstructed timestamps for burst callbacks and ISR time otherwise. Adds RX buffer replacement, delegating to drivers when needed and atomically resetting ordinary rings.

src/main/drivers/serial.c

serial.hExtend the serial contract for burst reception +12/-0

Extend the serial contract for burst reception

• Adds the receiver burst option, per-port byte timing, an optional RX buffer replacement hook, and public timing and buffer APIs.

src/main/drivers/serial.h

serial_uart.cIntegrate DMA into the non-HAL UART lifecycle +51/-9

Integrate DMA into the non-HAL UART lifecycle

• Stops streams before reopening a port, starts RX/TX DMA when available, and reads the DMA-derived RX head. Buffered writes can be queued as one DMA transfer.

src/main/drivers/serial_uart.c

serial_uart.hTrack UART DMA streams and active TX transfer +10/-4

Track UART DMA streams and active TX transfer

• Extends UART port state with RX/TX DMA descriptors and the in-flight transmit count; clarifies standard ring-size comments.

src/main/drivers/serial_uart.h

serial_uart_at32f43x.cImplement AT32 UART RX and TX DMA +272/-2

Implement AT32 UART RX and TX DMA

• Adds DMAMUX-backed circular reception, idle and ring-boundary delivery, transmit transfers, stop/start handling, and interrupt-path guards.

src/main/drivers/serial_uart_at32f43x.c

serial_uart_hal.cPreserve HAL UART DMA operation across reconfiguration +65/-9

Preserve HAL UART DMA operation across reconfiguration

• Restores DMA requests and receiver idle interrupts after HAL reinitialization, stops streams before reopening, and adds DMA-aware ring reads and buffered writes.

src/main/drivers/serial_uart_hal.c

serial_uart_hal_at32f43x.cWire AT32 HAL UART operations to DMA +50/-9

Wire AT32 HAL UART operations to DMA

• Starts or falls back from RX/TX DMA on open, stops old streams before reset, and adds DMA-aware reads and buffered writes.

src/main/drivers/serial_uart_hal_at32f43x.c

serial_uart_impl.hCentralize UART DMA selection and burst timing +361/-0

Centralize UART DMA selection and burst timing

• Defines MCU stream candidates, ownership and conflict checks, and byte-interrupt fallback. Derives RX ring position from DMA, reconstructs callback byte times, and restarts DMA atomically when replacing a ring.

src/main/drivers/serial_uart_impl.h

serial_uart_stm32f4xx.cImplement STM32F4 UART DMA streams +259/-0

Implement STM32F4 UART DMA streams

• Adds fixed-channel RX/TX stream setup, circular receive and idle/half/full delivery, transmit completion handling, and DMA-aware UART interrupts.

src/main/drivers/serial_uart_stm32f4xx.c

serial_uart_stm32f7xx.cImplement STM32F7 UART DMA streams +335/-2

Implement STM32F7 UART DMA streams

• Adds RX/TX stream setup and interrupt handling, burst delivery, transmit cache cleaning, and uncached receive buffers where required.

src/main/drivers/serial_uart_stm32f7xx.c

serial_uart_stm32h7xx.cImplement STM32H7 UART DMA streams +350/-2

Implement STM32H7 UART DMA streams

• Adds DMAMUX-backed RX/TX streams, burst delivery, DMA-aware interrupt handling, and D2 SRAM rings for cache-safe transfers.

src/main/drivers/serial_uart_stm32h7xx.c

asyncfatfs.cKeep filesystem polling out of F7 ITCM +5/-0

Keep filesystem polling out of F7 ITCM

• Prevents F7 builds from inlining afatfs_poll into fast-code callers, freeing scarce ITCM capacity.

src/main/io/asyncfatfs/asyncfatfs.c

gps_ublox.cReduce u-blox satellite-detail message frequency +8/-2

Reduce u-blox satellite-detail message frequency

• Configures NAV-SIG or NAV-SAT at approximately twice per second rather than every navigation epoch, reducing serial traffic not needed for navigation.

src/main/io/gps_ublox.c

crsf.cOpt CRSF into timestamped DMA bursts +2/-2

Opt CRSF into timestamped DMA bursts

• Requests burst reception and uses the serial-provided byte time for frame-gap detection; half-duplex ports remain ineligible for automatic bursts.

src/main/rx/crsf.c

sbus.cOpt SBUS into timestamped DMA bursts +4/-2

Opt SBUS into timestamped DMA bursts

• Requests burst reception and stores the opened port so SBUS gap and pause checks can use each delivered byte's reconstructed time.

src/main/rx/sbus.c

Bug fix (2) +23 / -0
fc_msp.cRelease receiver callback for MSP passthrough +3/-0

Release receiver callback for MSP passthrough

• Clears the selected port's receiver callback so a passthrough session reads incoming bytes from the ring without the parser consuming them.

src/main/fc/fc_msp.c

gps.cGive u-blox GPS a larger receive ring +20/-0

Give u-blox GPS a larger receive ring

• Installs a 512-byte GPS RX buffer after opening the port to accommodate larger responses and traffic between task runs. Selects DMA-accessible memory on platforms using UART RX DMA.

src/main/io/gps.c

Tests (3) +756 / -0
gps_heartbeat_unittest.ccStub the new GPS buffer API +1/-0

Stub the new GPS buffer API

• Adds a serialSetRxBuffer test stub so existing GPS heartbeat tests link against the updated GPS initialization.

src/test/unit/gps_heartbeat_unittest.cc

gps_null_port_unittest.ccStub buffer replacement in GPS null-port tests +7/-0

Stub buffer replacement in GPS null-port tests

• Adds the serialSetRxBuffer stub required by the updated GPS code.

src/test/unit/gps_null_port_unittest.cc

uart_dma_stream_unittest.ccTest DMA allocation and reconstructed burst timing +748/-0

Test DMA allocation and reconstructed burst timing

• Adds host-side mirrors covering timer reservations, UART stream selection, burst eligibility, ring wrapping, and CRSF/SBUS character times. Source-sync assertions detect divergence between the mirrors and embedded implementations.

src/test/unit/uart_dma_stream_unittest.cc

Documentation (1) +112 / -0
timer-dma-conflicts.mdDocument UART DMA selection and conflicts +112/-0

Document UART DMA selection and conflicts

• Explains automatic and named RX/TX streams, interrupt fallback, MCU stream mappings, timer and peripheral conflicts, and DMA-accessible buffer placement.

docs/development/targets/timer-dma-conflicts.md

Other (3) +69 / -0
dma.hDefine automatic DMA selection tag +2/-0

Define automatic DMA selection tag

• Adds a sentinel distinct from explicit DMA tags and DMA_NONE for runtime UART stream selection.

src/main/drivers/dma.h

target.hAssign UART2 RX and TX DMA streams +3/-0

Assign UART2 RX and TX DMA streams

• Names separate UART2 streams outside the documented timer-output and ADC allocations for this board.

src/main/target/TBS_LUCID_H7_WING/target.h

common_post.hEnable automatic UART DMA defaults +64/-0

Enable automatic UART DMA defaults

• Defaults enabled UART RX/TX directions to runtime stream selection on F4, F7, H7, and AT32F43x unless a target supplies a tag; enables the corresponding driver features.

src/main/target/common_post.h

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Closed ports keep DMA streams occupied ✓ Resolved
Description
uartRxDmaStop() disables a stream and clears rxDma without clearing the descriptor’s serial
ownership. When a UART is reopened without that stream, its former descriptor remains unavailable to
another UART’s automatic selection; closeSerialPort() also leaves the stream running until a
reopen.
Code

src/main/drivers/serial_uart_stm32h7xx.c[R384-388]

+        LL_DMA_DisableStream(s->rxDma->dma, stream);
+        while (LL_DMA_IsEnabledStream(s->rxDma->dma, stream));
+        DMA_CLEAR_FLAG(s->rxDma, UART_RX_DMA_FLAGS);
+        s->rxDma = NULL;
+        s->port.rxBursts = false;
Evidence
DMA starts claim descriptors as serial-owned, but stopping them only clears the port pointer. The
automatic picker rejects descriptors owned by another UART, and existing feature shutdown paths call
closeSerialPort without stopping DMA.

src/main/drivers/serial_uart_stm32h7xx.c[376-389]
src/main/drivers/serial_uart_stm32h7xx.c[400-438]
src/main/drivers/serial_uart_impl.h[90-97]
src/main/drivers/serial_uart_impl.h[258-270]
src/main/io/serial.c[437-453]
src/main/mavlink/mavlink_ports.c[48-56]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Stopping or closing a UART does not release its DMA ownership, preventing other ports from selecting the unused stream.
## Fix Focus Areas
- src/main/drivers/serial_uart_stm32h7xx.c[376-389]
- src/main/drivers/serial_uart_stm32f4xx.c[259-270]
- src/main/drivers/serial_uart_stm32f7xx.c[352-365]
- src/main/drivers/serial_uart_at32f43x.c[408-419]
- src/main/io/serial.c[437-453]
## Recommended Fix
Provide a DMA ownership-release operation and use it when each UART stream stops; stop UART DMA when its serial port closes. Apply the same lifecycle handling to transmit streams on all four platforms.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Receiver stays dead after an MSP passthrough ends ✓ Resolved
Description
mspSerialPassthroughFn() sets passthroughPort->rxCallback to NULL before calling
serialPassthrough() and does not restore it afterward. When a session targeting a CRSF/SBUS
receiver ends with +++, execution returns to normal MSP processing, but subsequent receiver bytes
no longer reach the parser and the receiver remains in failsafe until reboot.
Code

src/main/fc/fc_msp.c[R226-227]

+        // As in the CLI: a receiver's callback would take the bytes the session is passing on
+        passthroughPort->rxCallback = NULL;
Evidence
serialPassthrough() returns when serialPassthroughTransfer() detects the escape sequence at
serial.c line 668, but the MSP wrapper neither restores the callback nor closes the port. With
rxCallback still NULL, uartRxDmaDeliver returns early and the byte-interrupt path sends bytes to
the receive ring instead of the receiver parser. Unlike the CLI passthrough path, which tells the
user to power cycle to exit, the MSP path returns without a reboot to restore the callback.

src/main/fc/fc_msp.c[222-237]
src/main/io/serial.c[640-674]
src/main/fc/cli.c[1058-1068]
src/main/fc/fc_msp.c[223-237]
src/main/io/serial.c[660-674]
src/main/drivers/serial_uart_impl.h[311-327]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
MSP serial passthrough clears the destination port's receiver callback without restoring it. After a session ends with `+++`, a receiver on that port no longer receives frames.
## Fix Focus Areas
- src/main/fc/fc_msp.c[222-237]
## Recommended Fix
Save `passthroughPort->rxCallback` locally before setting it to NULL, then restore it after `serialPassthrough()` returns inside an `ATOMIC_BLOCK` at the serial priority. Account for bytes accumulated in the receive ring during the session. Alternatively, reboot the flight controller when the session ends, as the CLI passthrough path expects.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

3. Docs say CRSF/SBUS never use receive DMA ✓ Resolved
Description
The new 'Which ports use it' paragraph says serial receivers, CRSF and SBUS included, always stay on
the byte interrupt, even when the target names a stream. This PR gives CRSF and SBUS a receive
stream through SERIAL_RX_BURSTS, with or without a named stream. Target maintainers reading the
doc will plan DMA streams thinking those receiver ports never take one, while in fact they can take
a stream that another port or the SD card was expected to get.
Code

docs/development/targets/timer-dma-conflicts.md[R170-173]

+**Which ports use it.** Those whose owner reads through the ring: GPS, MSP, telemetry and
+the like. Serial receivers (CRSF, SBUS, IBUS, GHST, SRXL2, ...) take each byte through a
+callback as it lands, because they find their frames by timing, so their port stays on the
+interrupt even when the target names a stream for it. Half-duplex ports (IBUS telemetry and
Evidence
uartDmaPick and uartRxDmaRefused refuse only callbacks that do not take bursts
(uartRxTakesBursts). CRSF and SBUS now pass SERIAL_RX_BURSTS, so in full duplex they get a
stream that is picked at runtime or named by the target. That contradicts the doc text.

src/main/drivers/serial_uart_impl.h[244-271]
src/main/rx/crsf.c[335-339]
src/main/rx/sbus.c[256-264]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The docs say every serial receiver stays on the byte interrupt. CRSF and SBUS in full duplex now receive through DMA.
## Fix Focus Areas
- docs/development/targets/timer-dma-conflicts.md[170-175]
## Recommended Fix
Rewrite the paragraph to say that CRSF and SBUS in full duplex receive through a stream and get each frame on the idle line. Other receivers (IBUS, GHST, SRXL2, ...) and half-duplex receivers stay on the byte interrupt.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can keep summaries lean with Findings visible per group, which tucks the rest behind a View link

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/main/drivers/serial_uart_stm32h7xx.c
Comment thread src/main/fc/fc_msp.c Outdated
Comment thread docs/development/targets/timer-dma-conflicts.md Outdated
… docs on receivers with DMA

A session ended with +++ left the receiver without its callback, so it
stayed in failsafe until a reboot. The target guide still said every
serial receiver stays on the interrupt; CRSF and SBUS in full duplex now
take a stream.
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Test firmware build ready — commit db3ce12

Download firmware for PR #12132

251 targets built. Find your board's .hex file by name on that page (e.g. MATEKF405SE.hex). Files are individually downloadable — no GitHub login required.

Development build for testing only. Use Full Chip Erase when flashing.

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

RAM / Flash usage vs. base commit e87050f — commit db3ce12

Using the nearest available size baseline — the PR's exact base commit has no stored baseline yet.

Target Flash Δ RAM Δ
MATEKF405 +2616 B (+0.36%) CCM: ±0 B (±0.00%)
RAM: +704 B (+0.68%)
MATEKF722 +2044 B (+0.43%) ITCM_RAM: -888 B (-7.08%)
RAM: +684 B (+0.79%)
TCM: +4 B (+0.02%)
MATEKF765 +3336 B (+0.44%) DTCM_RAM: ±0 B (±0.00%)
SRAM1: +756 B (+0.60%)
MATEKH743 ⚠️ +3400 B (+0.43%) D2_RAM: +4096 B (+125.49%)
DTCM_RAM: ±0 B (±0.00%)
ITCM_RAM: +24 B (+0.15%)
RAM: +256 B (+0.18%)

See RAM/flash optimization guide for techniques to reduce usage.

A stopped stream was kept by its UART, so a port closed at runtime held streams
another port could have used. Every stop now frees its stream and closing a port
stops its streams; the pick no longer needs to look for a port's own streams first.
Since streams are given back only the F4/F7 candidate tables read it, and CI builds with warnings as errors.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant