Skip to content

AT Host Refactoring for pipe based architecture#133

Open
SeppoTakalo wants to merge 14 commits intonrfconnect:mainfrom
SeppoTakalo:modem_pipe
Open

AT Host Refactoring for pipe based architecture#133
SeppoTakalo wants to merge 14 commits intonrfconnect:mainfrom
SeppoTakalo:modem_pipe

Conversation

@SeppoTakalo
Copy link
Contributor

@SeppoTakalo SeppoTakalo commented Jan 9, 2026

Jira: SM-126

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.
    • DONE
  • URC handling:
    • First pipe in the list if not necessary the DLC1, or first open DLC. Maybe OK?
    • DONE: URC is send to all pipes.
  • Socket handling:
    • Socket operations from AT commands should be OK already (untested)
  • 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()
  • ATD* to dial up PPP from this pipe DONE: AT+CGDATA
  • AT+CMUX standard command

Future work

Possible ideas for future work:

  • 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.

@SeppoTakalo SeppoTakalo requested review from a team, MarkusLassila and trantanen January 9, 2026 14:25
@SeppoTakalo SeppoTakalo force-pushed the modem_pipe branch 4 times, most recently from 9a46611 to c35d677 Compare February 25, 2026 17:14
@SeppoTakalo SeppoTakalo marked this pull request as ready for review February 26, 2026 09:23
@SeppoTakalo SeppoTakalo force-pushed the modem_pipe branch 2 times, most recently from acc43df to 110fe70 Compare February 26, 2026 22:42
@SeppoTakalo SeppoTakalo changed the title *DRAFT:* AT Host Refactoring for pipe based architecture AT Host Refactoring for pipe based architecture Feb 27, 2026
@SeppoTakalo SeppoTakalo force-pushed the modem_pipe branch 2 times, most recently from 7fe62f8 to 7bb88dd Compare March 2, 2026 11:41

.. note::

|SM| does not have an equivalent to the ``AT+CMUX`` command described in `3GPP TS 27.007`_.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bullets in the note come a bit surprisingly without any intro

PPP is enabled in |SM| by compiling it with the appropriate configuration files, depending on your use case (with or without CMUX).
See the :ref:`sm_config_files` section for more information.

Enter data state +CGDATA
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remember to add all new AT+ AT commands to #XCLAC handling. Own function for them would likely be nice now that the list starts growing.

@SeppoTakalo SeppoTakalo force-pushed the modem_pipe branch 10 times, most recently from 1105136 to f865b46 Compare March 4, 2026 16:57
/* When PPP is stopped from first DLCI, move AT channel there.
* First open DLCI should always be the AT channel.
/*
* This is the legacy behavior of AT#XCMUX=2 that was implemented
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we deprecate the AT#XCMUX and AT#XPPP with this PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No.
I think breaking the AT compatibility is a bit pointless.

We can emulate previous functions, so we don't need to force host to rewrite the existing driver.

if (ctx->owner == owner) {
ctx->owner = SM_URC_OWNER_NONE;
/* Allocate new instance from heap */
ctx = malloc(sizeof(*ctx));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way of getting the available heap? It might make sense to fail in here if the heap is near it's limit after this reservation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there is API for that.

Why would we want to fail it before we have used all of the heap? There are no other users of the heap.

}

void sm_at_host_reset(void)
static void send_urcs(void)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this handle sending for both URC's received from modem and our own? So currently our own stuff would also go to all open AT-channels?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its for the developer to choose.

I would assume that important URCs, like #XPPP are supposed to go to all channels.

But socket specific once use the rsp_send_to()

@SeppoTakalo SeppoTakalo force-pushed the modem_pipe branch 3 times, most recently from 3b23537 to 6820373 Compare March 6, 2026 14:04
@SeppoTakalo SeppoTakalo requested a review from divipillai March 6, 2026 14:12
@SeppoTakalo SeppoTakalo force-pushed the modem_pipe branch 5 times, most recently from 7a94b7d to 2794836 Compare March 10, 2026 08:23
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 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 C library malloc(),realloc() and free()
- First instance cannot be destroyed (has application lifetime)
- Max instances determined by CMUX channel count

When following Kconfig variables are set:
CONFIG_COMMON_LIBC_MALLOC=y
CONFIG_COMMON_LIBC_MALLOC_ARENA_SIZE=-1

It uses all remaining RAM for heap that is used by C library.

Signed-off-by: Seppo Takalo <seppo.takalo@nordicsemi.no>
Move struct async_poll_ctx into AT host context.
Sockets use sm_at_host_get_async_poll_ctx(pipe) API to get the
associated poll context.
Each pipe can have different poll settings.
Socket binds to a pipe where it is created.

The sm_at_host_queue_idle_work() should be used for purposes
where application needs to send URC message to a specific pipe.

For example socket handlers, like #XAPOLL, etc..
These are not send through urc_send() as it should go to only one
channel.

Signed-off-by: Seppo Takalo <seppo.takalo@nordicsemi.no>
Send URC messages to all pipes that are open
and are in AT mode.

Signed-off-by: Seppo Takalo <seppo.takalo@nordicsemi.no>
* Define rsp_send_to() that takes a pipe pointer
* Also add pipe pointer to data_send().
* Refactor socket's to use the pipe they are created on.
* Refactor MQTT to store the pipe pointer as well.

Signed-off-by: Seppo Takalo <seppo.takalo@nordicsemi.no>
When AT#XCMUX and AT#XPPP are used, emulate the previous behavior
with statically assigned channel numbers.

Signed-off-by: Seppo Takalo <seppo.takalo@nordicsemi.no>
Add partial implementation of standard AT+CMUX command.
Only following commands are supported:
AT+CMUX=0
AT+CMUX=0,0
AT+CMUX?
AT+CMUX=?

Baud rate switch is not supported.

Signed-off-by: Seppo Takalo <seppo.takalo@nordicsemi.no>
AT+CGDATA per 3GPP TS 27.007 section 10.1.12.

Supported forms:
  AT+CGDATA           - Start PPP with default CID
  AT+CGDATA="PPP"     - Start PPP with default CID (L2P must be "PPP")
  AT+CGDATA="PPP",<cid> - Start PPP with specified CID
  AT+CGDATA=?         - Report supported L2P values

Signed-off-by: Seppo Takalo <seppo.takalo@nordicsemi.no>
Add AT#XCMUXTRACE command to start CMUX modem traces on a specified
channel.

This is required when using AT+CMUX which does not do hard coded
channel assignments.

Signed-off-by: Seppo Takalo <seppo.takalo@nordicsemi.no>
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.

4 participants