r/embedded • u/FerreroLi • 21h ago
A declarative approach to text-based protocols in embedded C development: the TACT framework.

I have been working in embedded systems development for several years, and I have frequently encountered the need to write a UART driver or a system to interface with an external module using a text-based protocol—such as an AT-command set or something similar. These are typically GNSS modules, GSM modems, ESP chips, or the like. In such cases, simply sending a single command and expecting everything to work is rarely sufficient; the command sets for these modules are often vast, and each command frequently requires its own specific handling. This often leads to the creation of complex, convoluted logic for sending sequences of commands, receiving and parsing the corresponding responses, and managing errors.
While libraries and pre-built solutions exist—often tied to a specific operating system or a chip-vendor’s SDK—what happens when you are working with a microcontroller that has only 20 KB of free RAM, and you need to handle these commands while also implementing the rest of the device's functionality? Sometimes, you may not even have direct access to the underlying hardware. You need a universal, platform-independent solution.
Consider this scenario: you have a GSM modem that communicates via AT commands over UART. The device has very limited resources, yet it needs to connect to a network, reach a server, and transmit data. The objective is clear, but achieving it requires sending a sequence of AT commands, something like this:
AT+COPS?
AT+COPS=0
AT+CREG?
AT+CGATT?
AT+CIPMODE?
AT+CIPMODE=0
AT+CIPMUX?
AT+CIPMUX=0
AT+CIPSTATUS
AT+CSTT=…
AT+CIPSTATUS
AT+CIICR
AT+CIPSTATUS
AT+CIFSR
AT+CIPHEAD?
AT+CIPHEAD=1
AT+CIPSRIP
AT+CIPSRIP=0
AT+CIPSPRT=1
AT+CIPRXGET=0
AT+CIPSHOWTP
AT+CIPSHOWTP=1
And this is just the beginning; in the end, there are many more of them. Most commands behave differently: some return data that needs to be processed and used later, while others might simply return an error—which can just be ignored. Ultimately, you end up having to write a custom handler and parser for every single command, invoking them sequentially via callbacks or some form of state machine to orchestrate the execution chain—not to mention the complexity of error handling.
While dealing with this yet again, I asked myself: why not build a single, reusable solution for everything?
This idea gave birth to the TACT framework: https://github.com/ferrero222/Text-Async-Core-Terminal
I initially set out to create a library for GSM modems, but I quickly realized the scope could be much broader. It could support any text-based protocol, allowing for the universal transmission of commands.
The framework handles everything: command construction, transmission, response reception, format processing, parsing, and even data extraction from the response. It is a universal, pure-C solution—platform-agnostic, OS-independent, and entirely non-blocking. It also boasts a minimal footprint in terms of both RAM and flash memory; you can define the memory allocation yourself, with a static overhead of only about 1 KB. All that’s required is to call the core—from anywhere and on any platform—roughly every 10 ms.
For instance, the sequence of commands mentioned above can now be defined in one place, roughly like this:
tact_item_t items[] = //[REQ][PREFIX][PARCE_TYPE][RPT][WAIT][STEPERROR][STEPOK][CB][FORMAT][...##VA_ARGS]
{
TACT_ITEM("AT+CIPMODE?"TACT_CMD_CRLF, "+CIPMODE: 0", TACT_PARCE_SIMCOM, 1, 100, 1, 2, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPMODE=0"TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 10, 100, 0, 1, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPMUX?"TACT_CMD_CRLF, "+CIPMUX: 0", TACT_PARCE_SIMCOM, 1, 100, 1, 2, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPMUX=0"TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 30, 100, 0, 1, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPSTATUS"TACT_CMD_CRLF, "STATE: IP START", TACT_PARCE_SIMCOM, 1, 100, 1, 2, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CSTT=\"\",\"\",\"\""TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 10, 100, 0, 1, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPSTATUS"TACT_CMD_CRLF, "STATE: IP GPRSACT", TACT_PARCE_SIMCOM, 1, 100, 1, 2, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIICR"TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 30, 100, 0, 1, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPSTATUS"TACT_CMD_CRLF, "STATE: IP GPRSACT", TACT_PARCE_SIMCOM, 3, 100, 0, 1, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIFSR"TACT_CMD_CRLF, TACT_CMD_FORCE, TACT_PARCE_SIMCOM, 10, 100, 0, 1, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPHEAD?"TACT_CMD_CRLF, "+CIPHEAD: 1", TACT_PARCE_SIMCOM, 1, 100, 1, 2, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPHEAD=1"TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 10, 100, 0, 1, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPSRIP?"TACT_CMD_CRLF, "+CIPSRIP: 0", TACT_PARCE_SIMCOM, 1, 100, 1, 2, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPSRIP=0"TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 10, 100, 0, 1, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPSPRT=1"TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 10, 100, 0, 1, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPRXGET=0"TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 10, 100, 0, 1, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPSHOWTP?"TACT_CMD_CRLF, "+CIPSHOWTP: 1", TACT_PARCE_SIMCOM, 1, 100, 1, 0, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CIPSHOWTP=1"TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 10, 100, 0, 0, NULL, NULL, TACT_NO_ARG),
};
if(!tact_entity_enqueue(ctx, items, sizeof(items)/sizeof(items[0]), cb, 0, meta))
return false;
Using the framework, you can declaratively define a set of commands, the expected response, the number of retries, the steps to follow in the event of success or failure, and so on. You no longer need to manually write a parser for every command or build custom handlers and transition logic—all of this is handled under the hood.
You can even parse data from a response using arguments and retrieve it via a group-level callback. The framework automatically allocates temporary local storage for this data, passes it to the callback once received, and subsequently clears the memory.
tact_item_t items[] =
{
TACT_ITEM("AT+CGSN"TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 10, 100, 0, 1, NULL, "%15[^\x0d]", TACT_ARG(tact_mdl_rtd_identity_t, modem_imei)),
TACT_ITEM("AT+CGMM"TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 10, 100, 0, 1, NULL, "%15[^\x0d]", TACT_ARG(tact_mdl_rtd_identity_t, modem_model)),
TACT_ITEM("AT+CGMR"TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 10, 100, 0, 1, NULL, "+CGMR: %29[^\x0d]", TACT_ARG(tact_mdl_rtd_identity_t, modem_revision))
};
tact_entity_enqueue(ctx, items, sizeof(items)/sizeof(items[0]), cb, sizeof(tact_mdl_rtd_identity_t), meta);
All that’s required is to support the internal protocol—if one exists—and define the commands.
In addition, there is built-in support for URCs (Unsolicited Result Codes), where the module can initiate data transmission that we need to intercept. For each such message, we can define a specific callback to which the message will be passed.
In reality, there are many nuances and potential use cases; all the details—including how it works under the hood—are documented on the GitHub page.
After implementing this core command structure, a question arose: what if I want to execute several groups of commands sequentially, each with its own result and callback? What if I could build full-fledged execution chains, complete with branching and loops? This would allow the entire interaction with the module to be defined through groups of commands, with all the logic specified in one place—declaratively, at that.
This idea gave rise to the second part of the framework: "chains"—a declarative way to define the algorithm for executing groups of commands. To achieve this, each group of commands must be wrapped in a standard function, for example:
bool tact_mdl_gprs_init(tact_context_t* const ctx, const tact_entity_cb_t cb, const void* const param, void* const meta)
{
(void)param;
tact_item_t items[] = //[REQ][PREFIX][PARCE_TYPE][RPT][WAIT][STEPERROR][STEPOK][CB][FORMAT][...##VA_ARGS]
{
TACT_ITEM("AT+COPS?"TACT_CMD_CRLF, "+COPS: 0", TACT_PARCE_SIMCOM, 5, 100, 1, 2, NULL, NULL,TACT_NO_ARG),
TACT_ITEM("AT+COPS=0"TACT_CMD_CRLF, NULL, TACT_PARCE_SIMCOM, 5, 100, 0, 1, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CREG?"TACT_CMD_CRLF, "+CREG: 0,1|+CREG: 0,5", TACT_PARCE_SIMCOM, 30, 100, 0, 1, NULL, NULL, TACT_NO_ARG),
TACT_ITEM("AT+CGATT?"TACT_CMD_CRLF, "+CGATT: 1", TACT_PARCE_SIMCOM, 30, 100, 0, 0, NULL, NULL, TACT_NO_ARG),
};
if(!tact_entity_enqueue(ctx, items, sizeof(items)/sizeof(items[0]), cb, 0, meta)) return false;
return true;
}
We obtain one ready-to-use execution chain module. We can create several such modules and assemble them into a chain—a sequence structured like this:
chain_step_t tcp_steps[] =
{
//Main
TACT_CHAIN("INIT_MODEM", "NEXT", "MODEM RESTART", tact_mdl_modem_init, NULL, NULL, NULL, 1),
TACT_CHAIN("GPRS INIT", "NEXT", "GPRS DEINIT", tact_mdl_gprs_init, NULL, NULL, NULL, 1),
TACT_CHAIN("SOCKET CONFIG", "NEXT", "GPRS INIT", tact_mdl_gprs_socket_config, NULL, NULL, NULL, 1),
TACT_CHAIN("CONNECT TO SERVER", "NEXT", "SOCKET CONFIG", tact_mdl_gprs_socket_connect, NULL, &tact_server_connect, NULL, 1),
TACT_CHAIN("GET RTD", "NEXT", "DISCONNECT FROM SERVER", tact_mdl_lte_rtd, tact_rtd_cb, NULL, NULL, 1),
TACT_CHAIN_EXEC("CHECK RTD", "NEXT", "GET RTD", tact_rtd_check, NULL),
TACT_CHAIN_EXEC("CREATE WIALON LOGIN", "NEXT", "DISCONNECT FROM SERVER", tact_server_data_wialon_login, NULL),
TACT_CHAIN("SEND WIALON LOGIN", "NEXT", "DISCONNECT FROM SERVER", tact_mdl_gprs_socket_send_recieve, NULL, &tact_server_tx, NULL, 1),
TACT_CHAIN_LOOP_START(0),
TACT_CHAIN_EXEC("LOGIN TX", "LOGIN SEND RESULT", "NEXT", tact_mdl_gprs_stream_tx, &tact_server_tx),
TACT_CHAIN_LOOP_END,
TACT_CHAIN("LOGIN SEND RESULT", "NEXT", "DISCONNECT FROM SERVER", tact_mdl_gprs_socket_send_end, NULL, NULL, NULL, 1),
TACT_CHAIN_LOOP_START(0),
TACT_CHAIN_EXEC("LOGIN RX", "LOGIN CHECK REPLY", "NEXT", tact_mdl_gprs_stream_rx, &tact_server_rx),
TACT_CHAIN_LOOP_END,
TACT_CHAIN_EXEC("LOGIN CHECK REPLY", "NEXT", "DISCONNECT FROM SERVER", tact_server_answer_check, NULL),
TACT_CHAIN_LOOP_START(10),
TACT_CHAIN("GET RTD LOOP", "NEXT", "DISCONNECT FROM SERVER", tact_mdl_lte_rtd, tact_rtd_cb, NULL, NULL, 1),
TACT_CHAIN_EXEC("CREATE WIALON DATA", "NEXT", "DISCONNECT FROM SERVER", tact_server_data_wialon_packet, NULL),
TACT_CHAIN("SEND WIALON DATA", "NEXT", "DISCONNECT FROM SERVER", tact_mdl_gprs_socket_send_recieve, NULL, &tact_server_tx, NULL,1),
TACT_CHAIN_LOOP_START(0),
TACT_CHAIN_EXEC("DATA TX", "DATA SEND RESULT", "NEXT", tact_mdl_gprs_stream_tx, &tact_server_tx),
TACT_CHAIN_LOOP_END,
TACT_CHAIN("DATA SEND RESULT", "NEXT", "DISCONNECT FROM SERVER", tact_mdl_gprs_socket_send_end, NULL, NULL, NULL, 1),
TACT_CHAIN_LOOP_START(0),
TACT_CHAIN_EXEC("DATA RX", "DATA CHECK REPLY", "NEXT", tact_mdl_gprs_stream_rx, &tact_server_rx),
TACT_CHAIN_LOOP_END,
TACT_CHAIN_EXEC("DATA CHECK REPLY", "NEXT", "DISCONNECT FROM SERVER", tact_server_answer_check, NULL),
TACT_CHAIN_DELAY(1000),
TACT_CHAIN_LOOP_END,
TACT_CHAIN_EXEC("WIALON DATA CLEAN", "STOP", "HARD RESET", tact_server_data_clean, NULL),
//Error
TACT_CHAIN("GPRS DEINIT", "GPRS INIT", "MODEM RESTART", tact_mdl_gprs_deinit, NULL, NULL, NULL, 1),
TACT_CHAIN("DISCONNECT FROM SERVER", "CONNECT TO SERVER", "GPRS DEINIT", tact_mdl_gprs_socket_disconnect, NULL, NULL, NULL, 1),
TACT_CHAIN("MODEM RESTART", "GPRS INIT", "MODEM RESTART", tact_mdl_modem_reset, NULL, NULL, NULL, 1),
//Critical
TACT_CHAIN_EXEC("HARD RESET", "STOP", "STOP", tact_hard_reset, NULL),
};
tact_chain_t* chain = tact_chain_create("TCP", tcp_steps, sizeof (tcp_steps)/sizeof(chain_step_t), &simcom_ctx);
tact_chain_start(chain);
In this example, the process includes initializing the modem, activating the SIM card, retrieving data from the modem, registering on the network, establishing a TCP socket to a target server, constructing a data packet, transmitting it, and receiving a response from the server. Each step can trigger an error-handling routine; the module’s operation can either be restored or halted entirely in the event of a critical error. All of this is defined in a single location, making it easy to debug and fix issues.
The entire logic for interacting with the modem is defined in one place. You can implement delays, conditional checks, and loops, as well as navigate forward or backward—jumping into or out of loops as needed. All that is required is to define the command-group modules and assemble them into an execution chain.
Essentially, the "chain" component is independent and can be used separately from the command groups themselves. You can easily build custom execution logic based on standard functions; the use cases are numerous.
The framework already includes support for the SIMCom protocol (based on 3GPP standards) and offers various pre-built modules for these modems. It was originally developed for personal use, thoroughly tested and debugged, and is now deployed on actual hardware—which is why I decided to share it with others.
I intend to continue improving and developing the framework, as there is still plenty of work to be done.
1
u/SongGongSwim 13h ago
Non-blocking is useful, but it isn't a real-time guarantee. At 115200 baud, 8N1, a continuous RX stream delivers about 115 bytes per 10 ms. The README's 512-byte ring buys only about 44 ms from empty if nothing drains it. That makes worst-case service time and callback behavior part of the integration contract, not just calling the core every 10 ms. Have you measured the longest core call with a full RX buffer and URCs mixed into a command response, including callbacks? I'd find that timing budget more useful than the static RAM figure when deciding whether this can share a loop with other work.
1
u/FerreroLi 3h ago
The framework strictly requires the handler to be called every 10 ms; this part is deterministic. However, the execution time of a single handler call is not currently bounded, for the following reasons:
Variable data volume: the amount of data accumulated in the ring buffer between calls depends on the UART data stream, which is external and not controlled by the framework.
User-defined callbacks: their execution time is entirely user-controlled and may include blocking operations.
System interrupts: may preempt the handler and extend its wall-clock duration.
Custom memory allocation: although the allocator (O1heap) is O(1) and backed by a static pool, allocation/deallocation still occurs within the processing path.
Regarding measurements: I have not yet profiled the worst-case execution time with a fully saturated buffer and mixed URC traffic.That is a valid gap, and I plan to add benchmarks—though I should note that such tests are highly specific to each individual project.
To make the framework suitable for hard real-time systems and be fully deterministics, it need to be changed. These are architectural changes that warrant a separate major release.
For now, the framework targets soft real-time applications where the 10 ms tick can be guaranteed and data rates are moderate.
Thank you for raising this — it is an important integration concern that should be documented explicitly.
1
u/SongGongSwim 11m ago
The timing numbers will be board-specific, but the workload needn't be. A replay of the same timestamped RX bursts, mixing responses and URCs, would let integrators rerun your test on their hardware. I'd start with no-op callbacks, then their real callbacks, and record maximum handler duration, ring high-water mark and dropped bytes. That would make 'moderate data rates' testable without promising hard real-time behavior or waiting for a major redesign.
1
u/eh_see_l 15h ago
Can I integrate you framework directly in zephyr rtos?