💾 Archived View for tozip.chickenkiller.com › 2022-07-16-decoupling.gmi captured on 2023-04-19 at 22:49:10. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2022-07-16)
-=-=-=-=-=-=-
Created 2022-07-16
This post was inspired by reading the following article wrt programming MCUs (microcontrollers):
embeddedartistry: How Our Approach to Abstract Interfaces Has Changed Over the Years
They started out with abstract interfaces for the SPI bus, amongst other interfaces. They then tried to reduce the abstraction. So they would then write something like:
void radio0_spi_transfer(unsigned char* data, uint8_t length) { wiringPiSPIDataRW(SPI_CHANNEL_RADIO_0, data, length); }
This does some wrapping, but has the problem that it is dependent on wiringPi. They then go on to discuss potential improvments.
Arduino libraries are particularly egregious when it comes to coupling. The SPI code is mixed in with the device logic. I'm also lukewarm to the "abstract interfaces" approach. People tend to overdo the C++-isms wrt classes, so you end up with a lot of "onion skins" (you peel back one layer of the onion only to reveal another, with the result that you end up confused and crying. As some wag put it: you can solve every problem in computing by adding a layer of abstraction, except for the problem of having too many layers of abstraction).
Adafruit provide many libraries for MicroPython. I feel it, too, overdoes inheritance. They are good source for making your own libraries in C++, with the caveat that you often have to pick apart the underlying guts of the driver from a lot of convenience features.
I think that this is also a problem with TDD (Test Driven Development). A class (call it A) may need to, say, print something. Perhaps it prints to a window. This is tricky from a test viewpoint, because you almost certainly do not even have the concept of window in the test suite. A posited solution is to introduce an abstracted print class, which is passed to A.
Meh. OK, so the specifics of printing is removed from A to somewhere else, which is good, but then you introduce a layer of indirection. It's hard to argue that you've simplified the problem.
I would argue that a better approach is to see if you can completely decouple notions of what class A does with the notions of printing. Then try to write a small number of functions which couple it together.
If you look at the standard C library, it is remarkable that the libraries have little coupling. That's not 100% accurate, as under the hood print functions might need memory allocations. What I do when writing the equivalent functions for mcus I try to not use any memory allocations at all. If you use mallocs, then you are effectively saying that my buffers might be of unbounded size. Well, you don't have unbounded memory. Figure out what is reasonable, and use that. If you found that assumption too narrow, then increase it. It may be iffy in a general-purpose computing environment, but you are likely to yield much greater simplicity when writing for memory- or time- contrained environments.
One of the latest things I've done in my mcu libraries is to try to write an adapter layer. So instead of writing "wiringPiSPIDatRW", I will use a function from libopencm3. libopencm3 is not entirely consistent between different mcus, so I wirte an adapter layer on top of that.
What happens then is that if I use an mcu that doesn't have libopencm3 support, I will implement an adapter function to whatever function it is, as well as my own adapter layer. I have found that I am able to get a good amount of reuse from my code.
I think, though, that I could go one stage further. That is to say, try to separate out driver-specific code from peripheral code. So a driver for an OLED (a type of display) should have no knowledge of how SPI works. It should be concerned with code that initialises the device, stores stuff like the bitmap, and produces a buffer that the peripheral driver shoves to the physical device. It has the advantage that you can swap out details of how the transfer takes place. For example, it should help you more easily change from a blocking approach to an interrupt or DMA approach.
They'll need to be some knitting-together of the peripheral mechanisms and driver code, of course, and I don't think you can write driver code completely devoid of notions as to how the transfers might take effect. I think it's an approach that helps, however.
Consider one possible limitation. The driver might hold a screen of data of 1K in size. Now, most mcus will be able to transfer that data in a single transaction, but not all. So you'll have to consider if it is necessary to accomodate that limitation in your API.
Something that occurs to me is the possible use of coroutines as a decoupling mechanism. The advantage of this approach is that it allows a coroutine to keep its state whilst at the same time passing values back to the caller. So the caller keeps asking for packets of information until the coroutine is done. It may be that this adds too much complexity into the code. Coroutines were only introduced in C++ version 20, and they look like tricky beasts.
Just something to think about.