libstored
TL;DR
What is it?
A generator for a C++ class (store) with your application’s variables, and a tool set to synchronize updates between processes (including FPGA), and debug it remotely. See also the presentation.
When do I need it?
When you have a distributed application in need of synchronization, and/or you want to be able to inspect and modify internal data of a running application.
Does it work on my platform?
Yes.
Huh? But how you do know my hardware architecture?
I don’t. You have to supply the drivers for your (hardware) communication interfaces, but everything else is ready to use.
Great! How do I use it?
Have a look at the examples.
Table of contents
Introduction
Data is at the core of any application. And data is tricky, especially when it changes. This library helps you managing data in three ways:
Using a simple language, you can define which variables, types, etc., you need. Next, a C++ class is generated, with these variables, but also with iterators, run-time name lookup, synchronization hooks, and more. This is your store. Additionally, a VHDL implementation is generated too, such that the store can also be used on an FPGA.
These stores can be synchronized between different instances via arbitrary communication channels. So, you can build a distributed system over multiple processes or hardware nodes like microcontrollers and FPGAs, via lossy and lossless channels, over TCP, CAN, serial, you name it. When some node writes to its store, this update is distributed through your application.
The store can be accessed via a debugging protocol. Using this protocol, all objects in the store can be read/written or sampled at high frequency for nice plots. It offers streams to have a sort of stdout redirection or debugging output from your application. The protocol is ASCII based, and usable via PuTTY, for example. However, there is GUI and CLI implementation available, which can be extended easily to build your custom (Qt) debugging GUI.
See next sections for details, but the following is worth to mention here:
All code is normal C++, there are no platform-dependent constructs used. Therefore, all platforms are supported: Windows/Linux/Mac/bare metal (newlib), x86/ARM, gcc/clang/MSVC/armcc).
The VHDL implementation is also generic, but only developed for Xilinx (using Vivado).
The store and all other libstored classes are not thread-safe. Using threads is troubling anyway, use fibers instead.
Have a look in the examples directory for further in-depth reading. Refer to the documentation for the C++ API. See also the presentation.
libstored - Store by description
The store is described in a simple grammar. See the examples directory for more explanation. This is just an impression of the syntax:
// Comment
// Grammar: type:size[array]=initializer long name with any character
uint32 some int
int8=42 another int, which is initialized
(uint64) time (s)
{
bool=true b
double[2] numbers
string:16 s
} scope
The generated store (C++ class) has variables that can be accessed like this:
mystore.some_int = 10;
int i = mystore.another_int_which_is_initialized;
mystore.time_s.get();
mystore.scope__b = false;
mystore.scope__numbers_0.set(0.1);
mystore.scope__numbers_1.set(1.1);
mystore.scope__s.set("hello");
The store has a few other interesting properties:
Objects can have a piece of memory as backing (just like a normal variable in a
struct
), but can also have custom callbacks on every get and set. This allows all kinds of side effects, even though the interface of the object is the same.Objects are accessible using a C++ interface, but also via name lookup by string. The generator generates a compact name parser, such that names, types, and sizes can be queried dynamically.
A store is not thread-safe. This seems a limitation, but really, applications without threads are way easier to build and debug.
libstored - Store on a distributed system
Synchronization is tricky to manage. libstored helps you by providing a stored::Synchronizer class that manages connections to other Synchronizers. Between these Synchronizers, one or more stores can be synchronized. The (OSI) Application layer is implemented, and several other (OSI) protocol layers are provided to configure the channels as required. These protocols are generic and also used by the debugger interface. See next section for details.
The store provides you with enough hooks to implement any distributed memory architecture, but that is often way to complicated. The default Synchronizer is simple and efficient, but has the following limitations:
Only instances of the exact same store can be synchronized. This is checked using a SHA-1 hash over the .st file of the store. That is fine if you compile your program at once, but harder to manage if components are not built in the same compile run.
Writes to a variable in a store should only be done by one process. If multiple processes write to the same variable, the outcome of the synchronization is undefined. However, you would have a data race in your application anyway, so this is in practice probably not really a limitation.
See the documentation for more details.
The topology is arbitrary, as long as every store instance has one root, where it gets its initial copy from. You could, for example, construct the following topology:
B--A--C
|
G--D--E--F
|
H
Assume that A is the first node, of all other nodes gets the initial copy from. So, D registers at A, then E gets it from D, F from E, etc. After setup, any node can write to the same store (but not to the same variable in that store). So, updates from H are first pushed to D. The D pushes them to A, E and G, and so on.
Different stores can have different topologies for synchronization, and synchronization may happen at different speed or interval. Everything is possible, and you can define it based on your application’s needs.
The example 8_sync implements an application with two stores, which can be connected arbitrarily using command line arguments. You can play with it to see the synchronization.
The store implementation in VHDL integrates a Synchronizer instance. However, it cannot be used as in intermediate node in the topology as described above; the FPGA has to be a leaf.
libstored - Store for Embedded Debugger
If you have an embedded system, you probably want to debug it on-target. One
of the questions you often have, is what is the value of internal variables of
the program, and how can I change them? Debugging using gdb
is great, but it
pauses the application, which also stops control loops, for example.
Using libstored, you can access and manipulate a running system.
The (OSI-stack) Application layer of this debugging interface is provided by
libstored. Additionally, other layers are available to support lossless and
lossy channels, which fit to common UART and CAN interfaces. You have to
combine, and possibly add, and configure other (usually hardware-specific)
layers of the OSI stack to get the debugging protocol in and out of your
system. Although the protocol fits nicely to ZeroMQ, a TCP stream, or stdio
via terminal, the complexity of integrating this depends on your embedded
device. However, once you implemented this data transport, you can access the
store, and observe and manipulate it using an Embedded Debugger (PC) client.
Moreover, the protocol supports arbitrary streams (like stdout) from the
application to the client, and has high-speed tracing of store variables. These
streams are optionally heatshrink compressed. libstored provides Python
classes for your custom scripts, a CLI and GUI interface.
Your application can have one store with one debugging interface, but also multiple stores with one debugging interface, or one store with multiple debugging interfaces – any combination is possible.
It seems to be a duplicate to have two synchronization protocols, but both have a different purpose. For synchronization, a binary protocol is used, which only synchronizes data, using memory offsets, and some endianness. This is tightly coupled to the exact version and layout of the store. This is all known at compile time, and great for performance, but harder to manage when you start debugging. The debugging protocol is ASCII based, writable by hand, easy to use dynamic lookup of variable names, and has support to easily add custom commands by adding another capability in a subclass of stored::Debugger.
The FPGA does not support the debugging protocol. If you want to debug the FPGA, instantiate the store, which includes a Synchronizer, and use a bridge in C++ that has the same store, a Synchronizer connected to the FPGA, and a Debugger instance. The connect to this C++ bridge.
Example
The host tools to debug your application are written in Python, based on
PySide6. The libstored
wheel file from the python
directory is by
default installed in the venv, located in dist/venv
.
To get a grasp how debugging feels like, try the following.
Build the examples, as discussed above.
If you use Windows, execute
dist\win32\env.cmd
to set your environment properly. In the instructions below, usepython
instead ofpython3
.Run
examples/zmqserver/zmqserver
. This starts an application with a store with all kinds of object, and provides a ZeroMQ server interface for debugging.Activate the Python venv by running
dist\venv\Scripts\activate.bat
on Windows, orsource dist/venv/bin/activate
on Linux or macOS.Run
python3 -m libstored.gui
. This GUI connects to thezmqserver
application via ZeroMQ.The GUI window will pop up and shows the objects of the
zmqserver
example. To add a signal to the plot, first enable the polling checkbox, and then enable the plot checkbox.
The structure of this setup is:
+---------------+ +------------+
| libstored.gui | -----> | matplotlib |
+---------------+ +------------+
|
| ZeroMQ REQ/REP channel
|
+-----------+
| zmqserver |
+-----------+
The Embedded Debugger client connects via ZeroMQ. If your application does not
have it, you must implement it somehow. The examples/terminal/terminal
application could be debugged as follows:
Run
python3 -m libstored.wrapper.stdio build/deploy/bin/terminal
from the activated venv (see above). This starts theterminal
example, and extracts escaped debugger frames fromstdout
, which are forwarded to a ZeroMQ interface.Connect a client, such as
python3 -m libstored.gui
. Instead of usingmatplotlib
, the GUI can also write all auto-refreshed data to a CSV file when the-f log.csv
is passed on the command line. Then, Kst can be used for live viewing the file.
The structure of this setup is:
+---------------+ +---------+ +-----+
| libstored.gui | -----> | log.csv | --------> | Kst |
+---------------+ +---------+ +-----+
|
| ZeroMQ REQ/REP channel
|
+-------------------------+
| libstored.wrapper.stdio | ---------- terminal interface
+-------------------------+
|
| stdin/stdout (mixed terminal interface
| with Embedded Debugger messages)
|
+----------+
| terminal |
+----------+
There are some more ready-to-use clients, and a Python module in the python directory.
Embedded Debugger protocol
Communication with the debugger implementation in the application follows a request-response pattern. A full description of the commands can be found in the documentation. These commands are implemented in the stored::Debugger class and ready to be used in your application.
However, the request/response messages should be wrapped in a OSI-like protocol
stack, which is described in more detail in the documentation too.
This stack depends on your application. A few standard protocol layers are
available, which allow to build a stack for lossless channels (stdio/TCP/some
UART) and lossy channels (some UART/CAN). These stacks are configurable in
having auto retransmit on packet loss, CRC-8/16, segmentation, buffering, MTU
size, ASCII escaping and encapsulation. See also examples/7_protocol
.
To get a grasp about the protocol, I had a short chat with the zmqserver
example using the libstored.cli
. See the transcript below. Lines starting
with >
are requests, entered by me, lines starting with <
are responses
from the application.
In the example below, I used the following commands:
?
: request capabilities of the targetl
: list object in the storei
: return the identification of the targetr
: read an objectw
: write an objectv
: request versionsa
: define an alias
Refer to the documentation for the details about these and other commands.
> ?
< ?rwelamivRWst
> l
< 0110/a blob
201/a bool
2b4/a float
2f8/a double
02f/a string
312/a uint16
334/a uint32
301/a uint8
378/a uint64
234/a ptr32
278/a ptr64
392/an int16
3b4/an int32
381/an int8
3f8/an int64
7b4/compute/an int8 + an int16
734/compute/length of /a string
6f8/compute/circle area (r = /a double)
734/stats/ZMQ messages
734/stats/object writes
778/t (us)
6f8/rand
> i
< zmqserver
> r/a bool
< 0
> w1/a bool
< !
> r/a bool
< 1
> r/s/Z
< 14
> r/s/Z
< 15
> r/rand
< 3d26000000000000
> r/rand
< 3f50250b79ae8000
> r/rand
< 3fa550a89cb27a00
> v
< 2
> ar/rand
< !
> rr
< 3fc69c39e2668200
> rr
< 3fd755a4ab38afc0
> rr
< 3fb7617168255e00
How to build
Run dist/<platform>/bootstrap
(as Administrator under Windows) once to
install all build dependencies. Then run dist/<platform>/build
to build
the project. This does effectively:
mkdir build
cd build
cmake ../../.. -DCMAKE_INSTALL_PREFIX=deploy
cmake --build .
cmake --build . --target install
dist/<platform>/build
takes an optional argument, which allows you to
specify the CMAKE_BUILD_TYPE
. If not specified, Debug is assumed.
By default, all examples are built. For example, notice that sources are
generated under examples/1_hello
, while the example itself is built in the
build
directory. The documentation can be viewed at
sphinx/html/index.html
.
To run all tests, use one of:
cmake --build . --target test
cmake --build . --target RUN_TESTS
Alternatively, use Visual Studio Code. Open the libstored
repository in your
workspace, run the task bootstrap
, one of the config
tasks, and one of
the build
tasks afterwards. Run the test
task to execute all tests.
How to integrate in your build
Building libstored on itself is not too interesting, it is about how it can generate stuff for you. This is how to integrate it in your project:
Add libstored to your source repository, for example as a submodule.
Run
dist/<platform>/bootstrap
in the libstored directory once to install all dependencies.Include libstored in your cmake project, such as:
list(APPEND CMAKE_MODULE_PATH extern/libstored/cmake) include(libstored)
Before including
libstored
, you can specify several options (seecmake/libstored.cmake
), such as enabling ASan or clang-tidy. Especially the library dependencies (ZeroMQ, Zth, Qt, heatshrink) are relevant to consider. For example, to enable ZeroMQ:list(APPEND CMAKE_MODULE_PATH extern/libstored/dist/common) find_package(ZeroMQ REQUIRED) set(LIBSTORED_HAVE_LIBZMQ ON)
For Windows, Linux and macOS, the provided package in the
common
directory tries to find ZeroMQ on your system, or it is built from source. For other targets, you might tweak this approach.Optional: install
dist/common/st.vim
in$HOME/.vim/syntax
to have proper syntax highlighting in vim.Add some store definition file to your project, let’s say
MyStore.st
. Assume you have a targetapp
(which can be any type of cmake target), which is going to useMyStore.st
, generate all required files. This will generate the sources in thelibstored
subdirectory of the current source directory, a library namedapp-libstored
, and set all the dependencies right:add_executable(app main.cpp) libstored_generate(TARGET app STORES MyStore.st)
To override the default configuration, provide a
stored_config.h
file. Add this to the build by either settingLIBSTORED_PREPEND_INCLUDE_DIRECTORIES
to a space-separated list with application-specific include directories, containing the header file, or by setting the include directory using:target_include_directories(app-libstored BEFORE PUBLIC path/to/my/include)
Now, build your
app
. The generated libstored library is automatically built.If you want to use the VHDL store in your Vivado project, create a project for your FPGA, and source the generated file
rtl/vivado.tcl
. This will add all relevant files to your project. Afterwards, just save the project as usually; thertl/vivado.tcl
file is not needed anymore.If you ran the
install
target, ashare/cmake/libstored/libstored.cmake
file is generated. If you include this file in another cmake project, you import all generated libraries as static libraries. See theexamples/installed
example how to do this.
Alternatively, you could install libstored from PyPI instead of the submodule approach. In that case:
Do something like
python3 -m pip install libstored
.Run
python3 -m libstored.cmake
to generate aFindLibstored.cmake
.In your project, call
find_package(Libstored)
, while having the generatedFindLibstored.cmake
in yourCMAKE_MODULE_PATH
.Libstored
acceptsZeroMQ
,Zth
,Heatshrink
,Qt5
,Qt6
asCOMPONENTS
. Setting these enables integration of these libraries with libstored. When possible, they are taken from your host system, or built from source (except Qt). If you want more control over these libraries, you can also use the mechanism described above with manually settingLIBSTORED_HAVE_LIBZMQ
and friends.Continue with step 5 of the submodule-approach above.
Check out the examples of libstored, which are all independent applications with their own generated store.
License
(Most of) this project is licensed under the terms of the Mozilla Public License, v. 2.0, as specified in LICENSE. This project complies to REUSE.