Understanding Python for embedded systems developers

How to break from the shackles of C/C++ thinking and leverage the full potential of Python

Ruvinda Dhambarage
9 min readJan 14, 2022
No! the other kind of python (Photo by David Clode on Unsplash)

Intro

I get the feeling that a lot of embedded folks have a contemptuous attitude towards Python. That Python is only for beginners and that “real” developers use C. Even when I ask interview candidates about Python, a very common response I get is “I am not an expert but I can Google and write Python”. It is apparent to me that there is not a lot of value being given to properly learn Python in our domain. This is unfortunate because there are a lot of things that Python is better at and I’d argue that Python is a MUST have skill for modern embedded developers.

I think one of the biggest reasons for this is animosity is that Python just does not “ click” for us embedded engineers trained to think in C. There is something very foreign and unfamiliar about idiomatic Python code. A key point that most don’t understand is that if you think in C and write Python, you get sub-optimum performance. In this blog post I will attempt to fix that. I’ll try to convey a mental model for Python so that you can write more Pythonic code and in turn making you a better, more effective embedded systems engineer.

What is Python good for in Embedded Systems?

First let’s begin with a non-exhaustive list of what Python is good at

1. Portability

Python is a great option when portability is important. It’s included by default in most Linux distros and supports both Windows and Mac OS. In terms of hardware architecture support, it is on all the platforms that matter. There are the occasional hiccups related to platform specific native lib bindings in 3rd party packages, but they are pretty rare for us embedded folk that work exclusively on Linux.

2. Scripting

Python is the better alternative to Shell or Perl scripts for platform level automation. This is because it’s easier to write, read and debug; not to mention the awesome standard lib features. It’s also great for build system scripting requirements where you would need to tie in package management and build tools together.

3. Productivity

Python produces smaller more concise code. I’ve seen people quoting numbers in the range of Python being 1/3 the size of comparable C++/Java code. Ultimately, it just means that you can get stuff done faster.

4. Math

Python was invented by a mathematician and you can tell. So it should not be a surprise to find that it is the de facto programming language in the Machine Learning and Data Science fields. I will go into a bit more detail on this topic later in this blog post.

5. Integration testing

Python’s ability to inter-op with other languages helps it be the “glue” language of choice for most integration use cases. For embedded systems, a good use case is automated integration testing (i.e. CI), where we would need to mock out I/O like network sockets or serial buses.

6. Code generation

This is a less obvious use case, but bares mentioning. If you have followed good software design patterns and architecture in your code; the chances are that whenever you need to add a new feature, it would involve a lot of boilerplate code writing. For example, in an observer pattern type implementation, you would need to inherit and implement an observer interface and then register your new observer with the observable object. You can automate this kind of skeleton code generation using something like Jinja. Though originally developed for HTML generation in Django; it can be made to work with for C/C++ codegen as well.

7. Speed? 🤔

Finally we get to the elephant in the room; Python’s biggest deterrent; the fact it uses an interpreter based runtime. There is just no way to get around its higher overhead compared to a native application. In practical terms, the biggest show stopper for Python on embedded systems is the extra memory that is required and NOT the execution speed. This might be surprising to hear for the uninitiated, but in terms of actual execution speed, Python will be very close to native application speed in most compute heavy scenarios. This is possible thanks to Python’s C backend which does the heavy lifting for compute heavy workload. For example, with OpenCV, when you call a method to transform an image; the actual number crunching doesn’t happen in Python bytecode, but in the package’s statically linked C lib implementation. I’ve had to benchmark OpenCV’s Python vs C++ perf on Raspberry Pi a few times in my career and each time Python was able to match the native speed.

The gist regarding performance is that if you have the extra RAM to support Python and if the critical sections of your application are either I/O or compute bound; the chances are that you will not be able to measure a performance difference between a Python app vs a native app.

*see “Updates” at the bottom of the article for additional performance related info

Now let’s switch gears and get a better understanding of Python

Photo by Danil Shostak on Unsplash

The fundamentals that you need to understand

1. The data model

Everything is an Object

Objects are Python’s abstraction for data. All data in a Python program is represented by objects or by relations between objects.

Imagine everything to be heap allocated in the C++ class object sense; even for integer literals. In Python, instead of variables we have “names” that point to objects. Thus assignment to a name is called “named binding”. Names are akin to C pointers in many ways. Going beyond data types, even functions and classes (not only class instances) are also objects.

Object ID, Type and Value

Every object has an identity, a type and a value. An object’s identity never changes once it has been created; you may think of it as the object’s address in memory.

Once created, the ID of an object is never changed. It’s a unique identifier for it, and it’s used behind the scenes by Python (via names) to retrieve the object when we want to use it.

An object’s type determines the operations that the object supports (e.g., “does it have a length?”) and also defines the possible values for objects of that type.

The type too doesn’t change and defines that values it can be associated with.

Code snippet to visualize how everything is an object on the heap.

Mutable vs Immutable values

Objects whose value can change are said to be mutable; objects whose value is unchangeable once they are created are called immutable.

  • Immutable types: numbers, strings, tuples
  • Mutable types: lists, maps, sets, user defined class objects

To drive this home, consider the following code:

>>> x = 42
>>> id(x)
9790304
>>> x = x*2
>>> id(x)
9791648

since 42 is an Integer object that is immutable; a new objected is created to store the value of x*2.

2. Execution model

Names, namespaces and scope

Names refer to objects. Names are introduced by name binding operations.

e.g x = 42 and import would be examples of “name binding operations”. from ... import * would bind all names defined in the imported module.

If a name is bound in a block, it is a local variable of that block.. If a name is bound at the module level, it is a global variable

Names can have local or global scopes and Python is able to (at runtime) dynamically map names with objects. In general name resolution will be intuitive for C devs; just keep the immutability factor in mind. On a similar vain, passing arguments to a function in Python is analogous to passed by pointer in C.

This behavior should make sense to you now

Names can be shadowed within inner scopes and behaves as shown below:

x = 0
def outer():
x = 1
def inner():
x = 2
print("inner:", x)

inner()
print("outer:", x)

outer()
print("global:", x)

# inner: 2
# outer: 1
# global: 0

You can circumvent this shadowing behavior with the nonlonaland global keywords.

3. Raw loops are bad

For things like checking whether an object is in a container, following the C style of using a for-loop and checking equality for each element is slower than using a declarative statement like if object in container: . This is because the Python for-loop would have to run in the interpreter, while the other more declarative option can run on the C back-end which would execute a lot faster.

C style vs Python style

So, in Python you should always treat raw loops as a code smell. I think the mindset change required to write more declarative code like this is the biggest stumbling point for us C folks.

Leveraging the power of Python

Now that you have a sense of how things work behind the scene. Consider the following non-exhaustive list of language features that unlock Python’s power:

1. Returning multiple values and structured bindings

You can easily return and access multiple heterogeneous objects.

def multiple():
operation = "Sum"
total = 5+10
return operation, total;

operation, total = multiple()

print(operation, total)
#Output = Sum 15

2. Large values and Generators

Python is great at handling large values. Use can just run n = 2 ** 1024to get the value of 2 to the power of 1024 in Python. Now try that in C!

Generators are another language features that allows us to work with very large datasets without running out of memory. Let’s take an example to understand this. Say you need to calculate all the permutations of elements in a list and check whether any one permutation meets a certain criteria. In C, you would have to first allocate the correct amount of memory and then exhaustively generate an array of all the permutations. Then you would have to iterate over each element and break when the condition we are testing for matches. With generators the key difference is that we don’t have to pre-compute all the permutations. We can generate each permutation on the fly as you iterate over. Pretty powerful stuff! For more info check: https://realpython.com/introduction-to-python-generators/

3. Use Virtual environments

Python has a built-in dependency/package manager that makes setting up environments super simple via the pip commandline tool. But for the love of god; use the virtual environments feature when you install packages! I repeat; do not install packages at the system/user level! I have lost count of the number of times people have come to me with broken Ubuntu installs because they sudo pip some random thing from StackOverflow in order to fix some some random package dependency😒. Linux distros rely on Python for many different tasks and operations, and if you fiddle with the system installation of Python, you risk compromising the integrity of the whole system.

Bonus fact: you can kinda have virtual environments for C++ apps too via the Conan package manager: https://docs.conan.io/en/latest/mastering/virtualenv.html

4. Decorators

This syntactic sugar is a great way easily modify the runtime behavior of exiting code while abiding with the Open-Close SOLID principle. For more info: https://realpython.com/primer-on-python-decorators/

5. Type checking

You can optionally add “type hints” to enforce type checking via packages like MyPy and IDEs. This also does a good job of documenting your code too. For more info: https://bernat.tech/posts/the-state-of-type-hints-in-python/

References:

Updates: Thanks to Joshua Ryan for the following additional notes on Python performance!

  • While application launch speeds can be painfully slow with Python
  • Python’s concurrency model doesn’t allow you to take full advantage of a multi-threaded processor due to the Python Global Interpreter Lock (GIL)
  • While not directly applicable to embedded systems; Python’s garbage collector can hinder low-latency critical workloads

--

--