Skip to content

Conversation

@SeppoTakalo
Copy link
Contributor

AT Host Refactoring for pipe based architecture

This PR refactors Serial Modem to convert all UART and AT Host modules
to use Zephyr's modem_pipe API instead of build-time and run-time switches
between different modes.

This simplifies the design as all modules that deal with serial traffic
now use the same API. Modules do not need any knowledge if they are running
from CMUX pipe or directly at UART.
This can be demonstrated by running PPP without CMUX and then PPP with CMUX using the same
build configuration.

This work is still largely in progress, but this PR already offers the overview of the architecture.

Overview

######### WITHOUT CMUX #############

                   uart
 ┌──────────────┐  pipe  ┌──────────┐
 │ UART driver  ├───────►│AT host 1 │
 └──────────────┘        └──────────┘

-------------  ( alternative ) -----------------

                   uart
 ┌──────────────┐  pipe  ┌──────────┐
 │ UART driver  ├───────►│PPP module│
 └──────────────┘        └──────────┘



######### WITH CMUX ################

                   uart             DLC 1
 ┌──────────────┐  pipe  ┌────────┐ pipe   ┌──────────┐
 │ UART driver  ├───────►│CMUX    ├───────►│AT host 1 │
 └──────────────┘        └──┬──┬─┬┘        └──────────┘
                            │  │ │  DLC 2  ┌──────────┐
                            │  │ └────────►│AT host 2 │
                            │  │           └──────────┘
                            │  │    ...
                            │  │           ┌──────────┐
                            │  └──────────►│AT host n │
                            │              └──────────┘
                            │
                            │              ┌──────────┐
                            └─────────────►│PPP module│
                                           └──────────┘
  • Data path is based on Zephyr's modem_pipe architecture.
    • The UART driver provides a pipe with sm_uart_pipe_get()
      • No runtime switching between models. Only the pipe is offered.
    • AT Host attaches to uart pipe during init.
    • CMUX creates pipes for each allocated DLC pipes.
      • This is the maximum number of channels.
      • Statically set, no runtime or build time configuration of number of channels.
    • Depends on CONFIG_MODEM_PIPE=y, but does not depend on CMUX.
    • PPP module attaches to a pipe set by sm_ppp_attach()
      • No runtime difference between CMUX or plain AT mode.
  • Each AT Host have an encapsulated context (struct sm_at_host_ctx):
    • Per-instance state: mode, buffers, timers, work items, event callbacks, and the pipe reference.
    • Instances are tracked
    • While AT command is executing sm_at_host_get_current() or sm_at_host_get_current_pipe()
      provides a way to push data to specific instance.
    • Similarly sm_at_host_get_urc_ctx() or sm_at_host_get_urc_pipe() provides a way
      to push data to a pipe that is for URC messages.
  • Dynamic memory strategy:
    • No static allocations in AT host module.
    • All instances and buffers are allocated from system heap.
    • AT command buffers grow and shrink in runtime.
    • AT command buffer starts at 128B (AT_BUF_MIN_SIZE), expands in 128B steps up to
      8kB (AT_BUF_MAX_SIZE), and compresses back to 128B after command completion.
    • Data-mode ring buffer (CONFIG_SM_DATAMODE_BUF_SIZE) is allocated only when entering data mode.

New APIs

  • int sm_at_host_set_pipe(struct sm_at_host_ctx *ctx, struct modem_pipe *pipe)
  • struct modem_pipe *sm_at_host_get_pipe(struct sm_at_host_ctx *ctx)
  • void sm_at_host_attach(struct modem_pipe *pipe)
  • void sm_at_host_release(struct sm_at_host_ctx *ctx)
  • struct sm_at_host_ctx *sm_at_host_get_ctx_from(struct modem_pipe *pipe)
  • struct sm_at_host_ctx *sm_at_host_get_urc_ctx(void)
  • struct sm_at_host_ctx *sm_at_host_get_current(void)
  • struct modem_pipe *sm_at_host_get_current_pipe(void
  • struct modem_pipe *sm_at_host_get_urc_pipe(void

Typical use cases are as follows:

/*
 * When starting CMUX:
 */

/* Switch AT host to CMUX DLCI pipe */
struct sm_at_host_ctx *ctx = sm_at_host_get_current();
int err = sm_at_host_set_pipe(ctx, cmux.dlcis[cmux.at_channel].pipe);
/*
 * When stopping CMUX:
 */

/* Causes all DLCIs to close (including AT channels)*/
modem_cmux_release(&cmux.instance);

/* Return AT host to UART pipe */
/* Note: we are not executing AT command, so there is no "current" context,
 * therefore switch the URC context.
 */
sm_at_host_set_pipe(sm_at_host_get_urc_ctx(), cmux.uart_pipe);
/*
 * Switch current AT channel to PPP mode
 */

struct sm_at_host_ctx *ctx = sm_at_host_get_current();
struct modem_pipe *pipe = sm_at_host_get_pipe(ctx);

/* Release pipe from AT context and destroy it */
sm_at_host_release(ctx);

/* Give pipe to PPP */
sm_ppp_attach(ppp_pipe);

/*
 * Switch another pipe from AT mode to PPP
 */
struct modem_pipe *ppp_pipe = ...;
sm_at_host_release(sm_at_host_get_ctx_from(ppp_pipe));
sm_ppp_attach(ppp_pipe);

Context lifetime

AT context is attached to a pipe using sm_at_host_attach() or existing context is switched to a
new pipe using sm_at_host_set_pipe().

AT context is created when pipe opens.

AT context is destroyed when pipe closes.

AT host does not open or close pipes.

Concurrency and modem_pipe Integration

  • To ensure safety, all sm_at_host.h APIs are only safe to call from within Serial Modem work queue.
  • modem_pipe callbacks are executing within a spinlock.
    • Spinlock means IRQ disabled, should not block or yield
      • Cannot use k_mutex or any other kernel syncronization primitive that might block
    • needs coordination for race-conditions
    • Cannot modify the pipe it is called for, spinlocks are not recursive
    • May be called from different context:
      • UART ISR
      • System work queue (CMUX pipes)
      • Serial Modem work queue (some sm_uart_handler callbacks)
  • Pipe pointer is atomic_ptr_t:
    • Atomically tracks close and open events.
    • When pipe closed, ptr is set NULL in callback
    • Prevents races when callbacks close/open while work is executing from the queue.
  • Two different pipe callbacks (at_pipe_event_handler()/null_pipe_handler()):
    • When AT host is freed, pipe callback is set to null_pipe_handler() which allows us to
      re-open the AT context if the pipe is re-opened.
    • For MODEM_PIPE_EVENT_RECEIVE_READY, AT Host verifies the event pipe matches ctx->pipe before scheduling RX work.
  • RX work (at_pipe_rx_work_fn()):
    • Reads bytes from the pipe in a loop while the pipe remains the same instance; exits immediately if ctx was destroyed or the pipe changed.
  • Close/open event handling (sm_at_host_work_fn()):
    • OPENED: re-attach the context if still bound; otherwise create a new context for a newly opened pipe.
    • CLOSED: mark the context as detached
  • Instance lifecycle:
    • First instance cannot be destroyed; on close it clears its pipe so no transmissions occur.
    • Additional instances are destroyed when their pipe closes.

Modem Pipe Handling: Corner Cases and Races

This section outlines observed and mitigated race scenarios around modem_pipe events and callbacks.

  • Receive while re-attaching:

    • Scenario: MODEM_PIPE_EVENT_RECEIVE_READY is raised while ctx->pipe is transitioning to a new pipe (e.g., CMUX channel switch).
    • Mitigation: In the callback and RX work, the code checks that atomic_ptr_get(ctx->pipe) == pipe before acting. Mismatched events are ignored.
  • Stale CLOSE after re-open:

    • Scenario: MODEM_PIPE_EVENT_CLOSED was queued, but before the work item executes, the context has been re-attached to a new pipe.
    • Mitigation: In sm_at_host_work_fn(), the CLOSED handler uses atomic_ptr_cas() to confirm the context still refers to the closing pipe. If not, the event is ignored to avoid destroying the wrong context.
  • Detach gap before new owner attaches:

    • Scenario: A pipe closes or context detaches, and another module hasn't attached yet; residual data may arrive.
    • No mitigation. The null_pipe_handler() ignores all other that OPENED events. In case of buffer full, the UART might stall.
  • Context destruction races:

    • Scenario: Work items enqueued for a context that was destroyed.
    • Mitigation: sm_at_ctx_check() validates the context against instance_list in all work handlers and skips work if destroyed.
  • ISR/spinlock constraints:

    • Scenario: Attempting to transmit from ISR or within pipe callback locking.
    • Mitigation: All sends happen from work-queue context (sm_at_send_internal() warns on ISR), and pipe mutations are deferred to sm_at_host_work_fn() via message queue.
  • Data-mode termination while pipe breaks:

    • Scenario: Pipe switches or breaks mid-data-mode.
    • Mitigation: SM_NULL_MODE/null_handler() recognizes the terminator and calls exit_datamode(), reporting dropped bytes; raw_send_scheduled uses timers to flush incomplete quit sequences safely.
  • Atomic pointer sentinel during close:

    • Scenario: Avoid double-destroy or accidental re-use when handling CLOSE.
    • Mitigation: A sentinel (void *)0xdeadbeef is used transiently with atomic_ptr_cas() to mark a pipe as processed in the CLOSED path before sm_at_host_destroy().

TODO

Remaining work:

  • uart driver and CMUX should use system work queue, or another work queue.
    • SM work queue should be allowed to block for a short while
    • Implement blocking pipe send inside AT host, so that all other modules can rely on messages going to at least CMUX or UART buffers.
  • URC handling:
    • First pipe in the list if not necessary the DLC1, or first open DLC. Maybe OK?
    • AT command for switching the URC mode while CMUX is running (to first pipe, to all pipes)
  • Socket handling:
    • Socket operations from AT commands should be OK already (untested)
    • non-blocking and polling operations need work. There cannot rely on "current" context, as the
      pipe might change between waiting. Need to store the context pointer.
      • If pointer is stored, we need an event to notify users that context is destoyed.
  • rsp_send() and similar, are not used consistently
    • Need manual work to find when the rsp_send() is mean to send as a response for AT command
      or if it was mean to be send as URC message. Example sm_ppp.c:send_status_notification()
  • Other modules, like nRFCloud, should define HEAP_MEM_POOL_ADD_SIZE_
  • ATD* to dial up PPP from this pipe
  • AT+CMUX standard command
  • Testing
    • Sockets are absolutely untested.
      • Actually.. everything is untested
    • Only tests that are executed are manual tests with PPP. Both external host and Linux should work.

Future work

Possible ideas for future work:

  • Dynamically or build time set number of CMUX channels.
  • AT command for URC modes
  • AT+CMUX to allow changing baud rate..

Testing the PPP module with Linux

As the PPP module now directly uses pipe interface, it can attach the UART or CMUX channel
on runtime.

Build the app using:

west build -b nrf9151/nrf9151/ns -- -DEXTRA_CONF_FILE="overlay-ppp.conf;overlay-cmux.conf"

When testing the PPP with CMUX, use the scripts/sm_start_ppp.sh

When testing the PPP without CMUX, create following file to /etc/chatscripts/nrf91

ABORT ERROR

TIMEOUT 5

'' AT OK-AT-OK

AT+CFUN=4 OK

AT
TIMEOUT 60
OK

AT+CFUN=1 OK

AT#XPPP=1 '#XPPP: 1'

Then start the PPP directly on UART with:

sudo pppd noauth /dev/ttyACM0 115200 local debug noipdefault connect "/usr/sbin/chat -v -f /etc/chatscripts/nrf91" nodetach

Stopping the PPP drops the channel back to AT command mode.

Refactor modules to use SYS_INIT() instead of separate init functions
that are called from sm_at_init().

When the only work is to initialize k_work handlers, use static
macros for initializing.

Remove all uninitialization functions which don't seem to do anything
usefull that would be visible from outside of the device.
The uninit is only used just before powering off, so there is no
point of cleaning the memory.

Signed-off-by: Seppo Takalo <[email protected]>
Instead of returning from main() and wasting the allocated stack space,
run the Serial Modem's work queue from it.

Previous main() is now ns_main() and runs from SYS_INIT() using
order APPLICATION+100, so it should be last of the init functions.

Other modules should use SYS_INIT(..,APPLICATION, 0).

Signed-off-by: Seppo Takalo <[email protected]>
Zephyr's AT shell commands refer to DTS alias "modem"

Signed-off-by: Seppo Takalo <[email protected]>
Allow AT commands to be send for each of the nRF9151DK buttons.
Total of 4 different AT commands that can be set using Kconfig.

Intention is the help testing specific error cases that are
otherwise hard to produce.

Signed-off-by: Seppo Takalo <[email protected]>
TODO.

Signed-off-by: Seppo Takalo <[email protected]>
Refactored the UART handler to initialize the modem_pipe infrastructure
at startup rather than only when switching to CMUX mode. This creates a
more consistent architecture where the pipe abstraction is always present.

Refactored AT Host module to encapsule global variables into
one struct that has a pipe reference it is attached to.

Introduce dynamic memory allocation for AT host instances to enable
multi-instance support required by CMUX multiplexing.

Key changes:
- Add instance_list (sys_slist_t) for managing all AT host instances
- Add current_ctx tracking. Current context is the one that is executing the AT command.
  AT commands can only be executed from Serial Modem work queue context, so this pointer should be
  valid while executing from the same queue. Invalid, if called elsewhere.
- Destroy unused AT instances, but not the last one
- Allow re-attaching AT instance to a pipe
- CMUX module is not handling instances anymore. Each module
  should attach to a given pipe and when releasing, release AT instance, but keep monitoring the
  pipe for PIPE_OPEN event.
- Move all buffers into system heap.
- Only allocate buffers on need.
- Increase AT command parser buffer in 128B steps, until max 8 kB. Release extra, once cmd executed.
- Don't allocate data ringbuffer, until datamode requested
- Parse pipe input one character at a time, no dual buffering

New APIs:
- sm_at_host_create(): Allocate and initialize new AT host instance
- sm_at_host_destroy(): Clean up and free AT host instance
- sm_at_host_get_*(): Get current AT instance, pipe or URC instances
- sm_at_host_set_pipe(): Switch given AT host to a new pipe

Memory allocation:
- Use k_malloc()/k_free() for heap allocation
- First instance cannot be destroyed (has application lifetime)
- Max instances determined by CMUX channel count
- Heap size controlled by HEAP_MEM_POOL_ADD_SIZE_SM_AT, which should be tuned to a need.

Signed-off-by: Seppo Takalo <[email protected]>
@SeppoTakalo SeppoTakalo requested review from a team, MarkusLassila and trantanen January 9, 2026 14:25
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