9.2 KiB
The framework is meant to be used without dynamic allocation. Currently the only supported RTOS is implemented in C, which adds additional constraints.
Static allocation and rust
As far as rust goes, static allocation and multithreading with a C RTOS is not possible generally. No allocation means that almost all data will be located on the stack. References to data on the stack are per design unsafe in the most wide sense of the word. First, because they are on the stack which will be cleared after a function defining data returns. Secondly, rust specifies data to be ignorant of their location in memory, that is data can be moved in memory without any possibility of hooks which could update foreign references.
In a multithreaded software, references to data need to be passed to enable communication between objects, to be able to execute a task (which requires passing a reference to the task's data (which contains information on other tasks/data) to the RTOS), to send messages (passing the reference to the Queue, however encapsulated, is a reference to the queue which needs to be located somewhere) or to acess shared data (mutexes, same as with queues). All of these communication techniques are essential for this framework, so solutions need to be provided to be able to write safe code.
While statically allocating all (shared) data, using global 'static
variables putting them into static memory instead of the stack, is generally possible and might be a possible solution to static allocation, it is not consistent with either the object oriented style of this framework, nor with general rust coding style (which discourages static data).
The framework's approach
Task Executor
Central element in running multithreaded is the task executor. It is borrowing references to all data to be used during runtime. This way, the references are guaranteed to be valid for the lifetime of the executor. This can be seen as 'pinning' the objects on the stack to fixed adresses. By then coupling the availability of tasks to the executor (dropping the executor stops all tasks), the references can be guaranteed to be valid for the whole time tasks are available.
The step where references to other objects are stored in structs is called initialization. This is (enforced by the compiler via layout of the corresponding functions) the only time where access to other objects is granted and references can be stored.
The initialization is performed by the task executor which controls that only pinned objects are given access to other pinned objects. Pinned objects are given an API to access other pinned objects (provided by an ObjectManager
) as well as a token. The token can be passed to functions which will return references to objects to be stored by the calling object. This way, these references can only be created during the initialization.
For an production software, as soon as all tasks are started, the initial task is stopped/deleted. As such, there is no way (and no intention) for the multithreading to ever stop. In that case, the references shared during initialization will be valid for the whole runtime of the program.
As there might be use cases, for example in test cases, where multithreading is to be stopped, additional safeguards are implemented to ensure that the references shared during initialization are invalidated or their use is restricted. As running outside of the multithreaded environment is not meant for production, failing without corruption, ie panicking, is an acceptable way out. This is implemented by using a global semaphore indicating if the thread executor is alive. If that is not the case, all access to shared ressources will result in a panic. This adds an additional overhead to all access to shared data, namely checking the semaphore.
Shared references
To be able to implement aforementioned safeguards, access to references is guarded by the framework.
The only time where references to other objects can be acquired is the initialization step performed by the task executor. As the task executor borrows all objects mutably, no references (mutable or not) to other objects, can be stored within any object.
Access to the other objects is granted via an object manager implementation, which will provide other objects as a nonmutable reference. Again, this reference can not be stored, only queried, without violating the borrow checker.
The reference obtained by the object manager is typed as dyn SystemObjectIF
, so the ways to obtain references is governed by this trait and its super traits. These traits only offer threadsafe APIs. Additionally, these APIs require a token which will be passed by the task executor so they can only be used during initialization.
Those references are either protected by a mutex or implemented using queues, which are the two primitives used to implement thread safety. As such, all access to shared references must use either the mutex or the queue API which is protected by an additional semaphore as described above.
In some cases, smart pointers are used. These store raw pointers to other objects offering a threadsafe API. As dereferencing such a pointer is only allowed when objects are pinned, the dereferencing is protected by the additional semaphore as well.
RTOS Metadata
RTOS metadata is the data the RTOS needs to work on its provided primitives such as tasks, queues and mutexes. Those come in two variants, statically sized, which here are called descriptors, and dynamic data. Descriptors have a size known at compile time which is the same for all instances of the primitive. Dynamic data is for instance the backend of a queue or the stack of a task. These do generally differ in size for the different instances. Keeping with the general theme of object orientation, the dynamic information is encapsulated within the structs abstracting the RTOS primitives.
Descriptors
Two options exist for storing the descriptors. Either they are stored in a preallocated static C array, or they are stored in memory allocated as part of the corresponding struct.
The first option has the advantage that in C, the size of the descriptors is known at compile time (it is determined by the RTOS configuration which is implemented in C macros). Its disadvantage is that the size of the array needs to be adapted to the actual number ob instances used by the RTOS, which might not be trivially determined except for running the software.
The second option does not have this disadvantage, as the memory is provided by the user of the API. The disadvantage here is that the size of the data needs to be encoded manually for the selected configuration of the RTOS, which again can only be verified during runtime, when rust and C interact.
Both solutions lead to a detection of the configuration error (too few descriptors preallocated, too little memory allocated) only during runtime. As the configuration of the RTOS is expected to be more stable than the number of primitive instances, the second option is implemented.
Dynamic data
Dynamic data is allocated on the stack as part of the encapsulating struct. Having dynamic data preallocated is not trivial as the actual size is determined by the instantiation of the corresponding struct, which is done at runtime.
Passing to the RTOS
The metadata should only passed to the RTOS when the references are fixed, that is when the task executor is constructed. To make sure that no uninitialized primitives are used, they are created in an invalid state within new()
of the encapsulating struct. Using an uninitialized struct does fail mostly silently, as this could be happen during runtime, when no panic is allowed.
As the structs are used to facilitate inter task communication, the initialization is hidden in the call which copies the contained reference out to the using object. That way, structs which are used by other tasks are guaranteed to be initialized, and uninitialized structs can only be used locally, so they do not need to be protected by the primitives at all.
The handling of the failure is delegated to the structs using the primitives, as the 'correct' way to fail is dependent on the usage:
- Uninitialized
MessageQueue
s will return empty fromread()
calls - Uninitialized
Mutex
es [TBCoded] will behave nominally, but not lock any actual mutex - Uninitialized
OwnedDataset
s will behave nominally, but not lock any mutex (which is uninitialized) - Uninitialized
ReferencedDataset
s will return default data onread()
and do nothing oncommit()
Smart pointers
Datasets are smart pointers to T, with a read/commit API. Both a mutex and the threading semaphore protect each call. Clones (ReferencedDataset
) are created invalid and can only made valid during init using the init token.
Can be compared to the mutex in rust std with a slightly different API.
Stores are a statically preallocated slotted allocation scheme: StoreAccessor is a smart pointer to the Store, offering same API as Store. Internally, it dereferences a raw pointer in each call, protected by the threading semaphore. Store in turn protects a shared backend with a mutex. Acessors can only be obtained during init. Together, StoreAccessor and Store are an thread safe allocator to the backend. StoreSlots are smart pointers to memory allocated in a store backend. Can be created/allocated during runtime.