pyRTOS is a real-time operating system (RTOS), written in Python. The primary goal of pyRTOS is to provide a pure Python RTOS that will work in CircuitPython. The secondary goal is to provide an educational tool for advanced CircuitPython users who want to learn to use an RTOS. pyRTOS should also work in MicroPython, and it can be used in standard Python as well.
pyRTOS was modeled after FreeRTOS, with some critical differences. The biggest difference is that it uses a voluntary task preemption model, where FreeRTOS generally enforces preemption through timer interrupts. This means there is a greater onus on the user to ensure that all tasks are well behaved. pyRTOS also uses different naming conventions, and tasks have built in message passing.
To the best of my knowledge, aside from voluntary preemption, the task scheduling is identical to that found in FreeRTOS. Tasks are assigned numerical priorities, the lower the number the higher the priority, and the highest priority ready task is given CPU time, where ties favor the currently running task. Alternative scheduling algorithms may be added in the future.
Basic Usage - Tasks - Notifications - Messages - Error Handling
pyRTOS API - Main API - Mutual Exclusion & Synchronization
Templates & Examples - Task Template - Message Handling Example Template - Timeout & Delay Examples - Messages Passing Examples - Notification Examples - Message Queue Exmaples - Mutex Examples - Service Routine Examples - Communication Setup Examples
pyRTOS separates functionality into tasks. A task is similar to a thread in a desktop operating system, except that in pyRTOS tasks cannot be migrated to other processors or cores. This is due to limitations with CircuitPython. In theory, though, it should be possible to write a scheduler with thread migration, for MicroPython, which does support hardware multithreading.
A simple pyRTOS program will define some task functions, wrap them in Task objects, and then register them with the OS using the add_task() API function. Once all tasks are added, the start() function is used to start the RTOS.
Once started, the RTOS will schedule time for tasks, giving tasks CPU time based on a priority scheduling algorithm. When the tasks are well behaved, designed to work together, and given the right priorities, the operating system will orchestrate them so they work together to accomplish whatever goal the program was designed for.
See sample.py for an example task and usage.
A pyRTOS task is composed of a Task object combined with a function containing the task code. A task function takes a single argument, a reference to the Task object containing it. Task functions are Python generators. Any code before the first yield is setup code. Anything returned by this yield will be ignored. The main task loop should follow this yield. This is the code that will be executed when the scheduler gives the task CPU time.
The main task loop is typically an infinite loop. If the task needs to terminate, a return call should be used, and any teardown that is necessary should be done directly before returning. Typically though, tasks never return.
Preemption in pyRTOS is completely voluntary. This means that all tasks must periodically yield control back to the OS, or no other task will get CPU time, messages cannot be passed between tasks, and other administrative duties of the OS will never get done. Yields have two functions in pyRTOS. One is merely to pass control back to the OS. This allows the OS to reevaluate task priorities and pass control to a higher priority ready task, and it allows the OS to take care of administration like message passing, lock handling, and such. Yields should be fairly frequent but not so frequent that the program spends more time in the OS than in tasks. For small tasks, once per main loop may be sufficient. For larger tasks, yields should be placed between significant subsections. If a task has a section of timing dependent code though, do not place yields in places where they could interrupt timing critical processes. There is no guarantee a yield will return within the required time.
Yields are also used to make certain blocking API calls. The most common will likely be delays. Higher priority processes need to be especially well behaved, because even frequent yields will not give lower priority processes CPU time. The default scheduler always gives the highest priority ready task the CPU time. The only way lower priority tasks ever get time, is if higher priority tasks block when they do not need the CPU time. Typically this means blocking delays, which are accomplished in pyRTOS by yielding with a timeout generator. When the timeout generator expires, the task will become ready again, but until then, lower priority tasks will be allowed to have CPU time. Tasks can also block when waiting for messages or mutual exclusion locks. In the future, more forgiving non-real-time schedulers may be available.
There are also some places tasks should always yield. Whenever a message is passed, it is placed on a local queue. Messages in the local task outgoing queue are delivered when that task yields. Other places where yielding is necessary for an action to resolve will be noted with the documentation on those actions.
Notifications are a lightweight message passing mechanic native to tasks. When a task is created, a number of notifications can be specified. These notifications can be used by other tasks or by Service Routines to communicate with the task.
Notifications have a state and a value. The state is an 8-bit (signed) value used to communicate the state of the notification. The meaning of the state is user defined, but the default values for the notification functions assume 0 means the notification is not currently active and 1 means it is active. Notifications also have a 32 bit value (also signed), which can be used as a counter or to communicate a small amount of data. A series of functions are provided to send, read, and otherwise interact with notifications.
A notification wait is provided as a Task Block Condition, allowing a task to wait for a notification to be set to a specific state. This blocking wait can even be used on other tasks, to wait for a notification to be set to a particular value, for example, a task may want to send a notification, but only once that notification is inactive for the target task, and thus it might block to wait for that notification state to be set to 0, before it sends.
Notifications are designed for lightweight message passing, both when full messages are not necessary and for Service Routines to communicate with tasks in a very fast and lightweight manner. To communicate via notification, it is necessary to have a reference to the task you want to communicate with.
Message passing mechanics are built directly into tasks in pyRTOS, in the form of mailboxes. By default tasks are lightweight, without mailboxes, but a constructor argument can be used to give a task has its own incoming mailbox. Messages are delivered when the currently running task yields. This message passing system is fairly simple. Each message has a single sender and a single recipient. Messages also have a type, which can be pyRTOS.QUIT or a user defined type (see sample.py). User defined types start with integer values of 128 and higher. Types below 128 are reserved for future use by the pyRTOS API. Messages can also contain a message, but this is not required. If the type field is sufficient to convey the necessary information, it is better to leave the message field empty, to save memory. The message field can contain anything, including objects and lists. If you need to pass arguments into a new task that has a mailbox, one way to do this is to call deliver() on the newly created task object, with a list or tuple of arguments. This will add the arguments to the task's mailbox, allowing it to access the arguments during initialization.
Checking messages is a critical part of any task that may receive messages. Unchecked mailboxes can accumulate so many messages that your system runs out of memory. If your task may receive messages, it is important to check the mailbox every loop. Also be careful not to send low priority tasks too many messages without periodically blocking all higher priority tasks, so they can have time to process their messages. If a task that is receiving messages never gets CPU time, that is another way to run out of memory.
Messages can be addressed with a reference to the target task object or with the name of the object. Names can be any sort of comparable data, but numbers are the most efficient, while strings are the most readable. Object reference addressing must target an object that actually exists, otherwise the OS will crash. Also note that keeping references of terminated tasks will prevent those tasks from being garbage collected, creating a potential memory leak. Object references are the fastest message addressing method, and they may provide some benefits when debugging, but its up to the user to understand and avoid the associated hazards. Name addressing is much safer, however messages addressed to names that are not among the existing tasks will silently fail to be delivered, making certain bugs harder to find. In addition, because name addresses require finding the associated object, name addressed messages will consume significantly more CPU time to deliver.
sample.py has several examples of message passing.
The error handling philosophy of pyRTOS is: Write good code. The OS operates on the assumption that the user will write good code that does not cause issues for the OS. If this assumption is broken, the OS will crash when it comes across the broken elements, and it probably will not give you very meaningful error messages. For example, attempting to send a notification to a Task that does not have notifications will cause a crash, with a message about the Task object having no notifications attribute (which is actually somewhat meaningful, in this particular case...).
pyRTOS is designed to be used with CircuitPython, on devices that may have very limited resources. Adding OS level error handling would require significantly more code, using more flash and RAM space, as well as requiring more processing. This is unacceptable. As such, we will not be adding OS error handling code to gracefully handle OS exceptions caused by incorrect use of the OS. We will also not add special OS exceptions to throw when errors occur, nor will we add preemptive error detection. These are all expensive, requiring significantly more code and processing time. This means that errors that occur within the OS may not produce high quality error messages. Users are encouraged to write good code, so that errors in the OS do not occur, and barring that, users can add error handling in their own code (but note that we do not condone writing poor code and then covering up the errors with error handling). Please do not file issues for crashes caused by failures to use the APIs provided correctly. Instead, fix your own code.
That said, if there is a bug in the OS itself, please do file an issue. Users should not have to work around bugs in pyRTOS. We apply the same standard, "Write good code" to ourselves, and if we have failed to do that, please let us know, so we can fix it. If you are having a crash, and you are not sure where the error is occurring, please do your best to check your own code first, and if you cannot find the bug in your own code, feel free to file an issue. We will do our best to track down the issue, as we have time (at the time of writing, this is a one man operation, and I am not getting paid for this, so it will likely not be immediate). Do not be offended if we find the error in your code and inform you of that. If the error is on our end, we will do our best to fix it in a timely manner (but again, one man team working for free, so no promises; this is open source, so if it is urgent, please consider fixing it yourself).
Similarly, if you find it difficult to correctly use the APIs, because the documentation is lacking or poorly written, please do file an issue, and we will try to improve it. Our philosophy of "Write good code" also applies to our documentation.
If this sounds harsh, we sincerely apologize. We understand that this is not ideal. Unfortunately, sacrifices must be made when working on systems with extremely limited resources. Limited flash means our code has to be very small. Limited RAM means we are limited in what we can keep track of. Limited processing power means we have to weigh the value of every command we issue. The purpose of an OS is to facilitate the tasks the user deems important, and the more resources the OS uses, the fewer resources are available for the user's tasks. Given such limited resources, keeping the OS as small and streamlined as possible takes precedence over error handling and debugging convenience. If your application needs the error handling, an
$ claude mcp add pyRTOS \
-- python -m otcore.mcp_server <graph>