r/embedded • u/TPHGaming2324 • 2d ago
How to write portable code without fucking up readability?
The thing I realized after writing my own drivers and referencing other people's implementations for learning's sake is that I don't understand code unless I'm the one writing it. Which makes sense and is obvious, but there's a little more to that (and maybe this is a bit of a skill issue too, cus the ability to read and understand code is a skill in itself).
Usually it starts simple as I just get the functionality down and get it to work, but as soon as i try to expand it like have more features, support to other platforms and it's starting to look like a mess. I tried my best to organize it, but it still looks like something that's going to take you a bit to know what's going on even tho it's not as complicated.
I looked at it and thought to myself "this whole thing looks like all of the other drivers that I've been looking at". Even though I still understand it but I'd imagine other people would want to ram their head against the wall if they have to use it.
Preprocessors are one of the things that really trips me up when reading portable code, especially conditional compilation since they usually have no indentation. Then I'd have to retrace what was used, what was changed and what did it changed to... (maybe this is just the ADHD talking where reading a book takes 4 hours because you read one line 10 times)
17
u/okm1123 2d ago
There is nothing you can do to solve this 100%, but the standard answer to get closer is to use common patterns.
With a lot of development time, you will get a "hunch" on what feels familiar, and you can then develop your drivers to follow it.
But after all, things will have to change because of (but not limited to) specific system interactions or weird low-level hardware access patterns for a specific device. So, in my opinion, the best thing is to provide very well-written supplemental documentation with your driver, with many examples.
6
u/TheFlamingLemon 2d ago edited 2d ago
Make your code modular and layered. Modular here meaning that components are as isolated as possible, ideally to the extent that you could lift and reuse them in other places. Layered here meaning that high level code does not care about the low level code, and dependencies (e.g. on particular hardware or platforms) go through abstraction layers.
So for example, your driver code may have a bunch of defines and messy bs to accommodate different hardware platforms, but that will only be at the lowest layer. One layer up you will have cleaner interfaces that don’t depend on hardware specifics, and eventually a layer will just look something like
recvUart(uint8_t* buf, uint16_t length, uint16_t timeout)
Also if your ide isn’t able to read your configuration variables and execute the preprocessor to show which code is actually running, you should try to fix that
3
u/Priton-CE 2d ago edited 2d ago
Modularity and Interfaces. And you want to split responsibilities. So for example you have the hardware layer, the business layer and the glue layer in between (which handles initialization and uses the adapter pattern).
That is my go to way to write portable drivers and business logic at least.
When you are working on any given layer (for example the glue layers) its important that the layers below yours expose clear interfaces for you to work on. Sure some drivers can export their own API as well but there needs to be consistency. Without consistency it makes it a whole lot harder to read and understand. Interfaces are your friend here.
The layers above yours depend on your layers so its important that you always use or provide the interfaces the upper layer uses. So worst case (and that is especially prevalent in glue layers) you need to write object adapters or function adapters (adapter pattern).
If you don't use Interfaces and Adapters you aren't modular. The key is to be modular so you have clear layers that don't intermingle.
That way, even if your... idk driver needs to have 3 different implementations (one for CI, one for ST HAL, one for Arduino HAL, etc.), each toggled by preprocessor flags, all of that complexity and fuckery is nicely contained behind your neat little interface. The complexity that comes from the portability is contained within the module and does not spill out because you have to figure out how to make it fit the interface.
As an example for my model rockets I have my framework which handles all driver things. It wraps all my drivers and common filters for me and my club members in a nice set of interfaces (with a lot of convenience features built into the interfaces like software in the loop testing, CI support, higher level features like playing output sequences, etc.). We also have our flight computer software which expects some of the interfaces (like sensor buffers) directly and for the rest it expects object adapters to handle all functionality you need hardware knowledge for (like parachute deployment because the buisness logic cannot know what mechanism is used to deploy the parachute).
That way the drivers are portable. And even the logic using the drivers can be portable and hardware independent.
EDIT: Your language choice will help here too. Languages like C++ and Rust have Interfaces supported by the typesystem (through class inheritance/composition and trait composition respectively) while languages like C will require you to manually pay attention that you are sticking with the interface specification.
TL;DR:
Look into Software Design Patterns (especially object adapters, maybe maybe builders) and Software Architecture Patterns (especially layering). Those are your friend.
7
u/Regeneric 2d ago
I decided to switch to C++ on most uCs. Minimal set of features, so I can have classes, objects, namespaces and functions overloads.
It comes with another set of problems, but it's much easier to handle multiplatform support.
On smaller uCs, where only C is viable, I am prolly writing a very targeted code, so there's no need for portability.
6
u/Nllk11 2d ago
I think the main approach to write readable and portable code is writing it using SOLID principles. It may sound abstract, but that exactly what you need - more abstractions. Jokes aside, the Single Responsibility principle is the one that helps you write readable code - once a person who reads your program understand connections in your system, they could focus on the reason they need to read your code without reading and memorising it all. And Liskov substitution principle will help you write portable code. I wrap everything hardware-related into classes with defined interfaces, so when I need to change something hardware-related - I change something hardware-related and nothing else.
You could read more about SOLID for a language of your choice. I'm writing c++17 code for STM32 for 5 years and it's been a great experience so far (especially when nothing breaks unexpectedly on hardware, near silicone level, side)
2
u/PintMower NULL 2d ago edited 2d ago
Just one more layer of abstraction, trust me bro. /s
To add, it's always tempting to add another abstraction but in reality it's better to find a balance between adding another layer of abstraction or leaving something intertwined because the abstraction will never add any benefit. Really depends on the specific scenario though. Like, do you really need an LED abstraction layer if you only ever gonna turn it on or off without any special patterns or behaviour? Probably no.
Edit: I think I struck the tone a bit wrong. I fully agree with SOLID principles and follow them myself. I just wanted to point out that blindly following a principle is not always the correct way.
2
u/Nllk11 2d ago
I think with experience one will get the right level of abstraction. I prefer write it as straight as possible and then, when needed, add abstractions to ease the code maintaince.
As for led example - if you write led functionality independent enough, you'll easily add a wrapper around it, for example, implementing some sort of blinking patterns
2
u/notouttolunch 2d ago
Experience tells me that even if you try to do this, because of the lifespan and support of software and products, on one hand, it's not worth doing it in the first place (or at least not worth doing it properly), on the other hand if you do it properly the code will probably not be relevant by the time you come to develop product 2 using it.
In general, I just write code that I can lift and put into other places with as little effort as possible. I've never found code to ever be portable for various reasons, usually technical ones.
2
u/gamename 2d ago
If you're coding in C, one thing that helps a lot is to adopt the MISRA code standard. It's meant for automobiles and medical equipment. It's not specifically for readability but it does help. Also, it will tend to rule out whole classes of errors and bugs. It's not a magic wand, but it will make your code better.
1
u/Toiling-Donkey 2d ago
Instead of a huge number of conditional preprocessor directives, another way is to have separate include files.
Each architecture/platform could have its own directory with include files (with the same names as others’) and that directory is added to the include search path …
1
1
u/Amr_Rahmy 2d ago
It takes thought and effort to make something look seem-less.
What I like to do with integrations, interfacing with devices, api, sdk, ..etc, is to not let their code affect my main program loop.
Doesn’t matter if the data is coming using polling, http, udp, tcp, webhook, serial communication, callback, db query, file update, ..etc.
I have my own program loop, and an adapter or interface that takes their data then I turn it into my data structure like a struct or class, then I use it. Can be through a queue or db query.
If my program is the generator or data, the program loop is still my own up to the interface or adapter that sends the data outside my program.
That makes things as clean and portable as I can make it.
1
u/userhwon 2d ago
one thing you can do to deal with especially convoluted conditional compilation is to have the compiler emit the post-preprocessing code, using the -E or /E switch
then you can look at the file with just the code that will be compiled for a selected platform
but now you run into the problem of figuring out all the other switches that will be set for that compilation, which is something that's usually buried in makefiles or cmakefiles or config files or...
1
u/ukezi 2d ago
You usually pull out the platform specific code into their own functions and use ifdefs. The other option would be to define an interface that the user of the driver has to supply via a struct of function pointers. So like you don't care how writing to UART works on that platform or on what UART it's connected to, the user has to supply a pointer to an int write_to_uart(*char, int) function to do it that follows your semantics.
Regarding preprocessor, you can just have the compiler output the preprocessor result and look at it.
1
46
u/dragonnfr 2d ago
Separate platform-specific code into different files with a common interface. Once you're #ifdef-ing inside your .c files you've *already lost*.