Portability in Software Design

Thread Starter

Kittu20

Joined Oct 12, 2022
434
Hey everyone,

I've been diving into the world of software design, and one topic that has caught my attention is portability. I'm trying to understand the concept better and its implications in writing code that can be easily adapted to different platforms or targets.

Specifically, I'm curious about how you handle different versions of a target architecture, such as an 8-bit or 16-bit PIC microcontroller. Would you write separate code for each version, or do you aim for a more generalized approach? Additionally, what about developing portable code that can be used for both PIC microcontrollers and ARM-based platforms?

I look forward to your insights and experiences
 

Ian0

Joined Aug 7, 2020
8,947
There's no such thing as portable software. It's a concept dreamed up by C programmers to promote C over assembler.
The majority of microcontroller software concerns interfacing with peripherals, and peripherals vary from manufacturer to manufacturer. Some manufacturers provide software "drivers" but that just makes matters worse, as they also differ from manufacturer to manufacturer and are much less well documented than the peripherals themselves.
If you move software from one microcontroller to another, even if they both provide GNU C compliers, you will still end up re-writing the vast majority of it.
 

nsaspook

Joined Aug 27, 2009
12,306
I'm talking about C here on 8-bit -> 16-bit controllers.
Code portability is a mixed bag in small embedded controllers as much of the software involves writing very low-level routines (that are the application) that directly manipulate hardware registers and memory with lots of magic number constants. This usually limits portability to specific families of controllers for reasons not directly connected to software design. Portability here is more of abstraction of direct functionality that often distracts from the functional task of making it work and making work right within the limited resources of the chip. In general I like to isolate register level code to a separate file specific to each type of processor that might be used with the code to make things slightly more portable but the main function is to make the code more readable and understandable 6 months from now. It's also possible to inline portability with pre-processor statements with defines and ifdefs to increase possible portability in a family of processors. There are usually IDE related code creators but I find they generated poor or buggy low-level code on small controllers that needs to be patched to be correct for the specific application.

A 8-bit example:
C:
/*
 * simple CAN FD transfers using blocking TX and interrupts for RX
 */
#ifndef CANFD_H
#define    CANFD_H

#ifdef    __cplusplus
extern "C" {
#endif

#include "qconfig.h"
#include "mateQ84.X/mxcmd.h"
#include "modbus_master.h"

#define CAN_DEBUG    // can status on LCD
#define DATA_DEBUG
#define USE_FD    // select classic or FD
#define CANFD_BYTES    64
#define CAN_RX_TRIES    8

#define    EMON_M    0x1    // FM80 host
#define EMON_SL    0x2    // remote display lower data
#define EMON_SU    0x3    // remote display upper data

    typedef struct {
        uint32_t rec_count;
        bool rec_flag;
    } can_rec_count_t;

    extern volatile can_rec_count_t can_rec_count;
    extern CAN_MSG_OBJ msg[2];
    extern volatile uint8_t rxMsgData[2][CANFD_BYTES];

    void Can1FIFO1NotEmptyHandler(void);

    extern char can_buffer[MAX_B_BUF];
    void can_fd_tx(void);
    void can_setup(void);

#ifdef    __cplusplus
}
#endif

#endif    /* CANFD_H */
C:
#include "canfd.h"

CAN_MSG_OBJ msg[2];
volatile uint8_t rxMsgData[2][CANFD_BYTES] = {
    "     no data   ",
    " no_data   ",
};

volatile can_rec_count_t can_rec_count = {
    .rec_count = 0,
    .rec_flag = false,
};

/*
 * process the FIFO data into msg structure
 */
void Can1FIFO1NotEmptyHandler(void)
{
    uint8_t tries = 0;
    static uint8_t half = 0;

    while (true) {
        if (CAN1_ReceiveFrom(FIFO1, &msg[half])) //receive the message
        {
            memcpy((void *) &rxMsgData[half][0], msg[half].data,CANFD_BYTES);
            can_rec_count.rec_count++;
            if (msg[half].msgId == EMON_SL) {
                half = 1;
#ifdef CAN_DEBUG
                MLED_Toggle();
#endif
                break;
            }
            if (msg[half].msgId == EMON_SU) {
                half = 0;
                can_rec_count.rec_flag = true;
#ifdef CAN_DEBUG
                MLED_Toggle();
#endif
                break;
            }
#ifdef CAN_DEBUG
            MLED_Toggle();
#endif
            break;
        }
        if (++tries >= CAN_RX_TRIES) {
            break;
        }
    }
}

void can_fd_tx(void)
{
    CAN_MSG_OBJ Transmission; //create the CAN message object
#ifdef USE_FD
    Transmission.field.brs = CAN_BRS_MODE; //Transmit the data bytes at data bit rate
    Transmission.field.dlc = DLC_64; // 64 data bytes
    Transmission.field.formatType = CAN_FD_FORMAT; //CAN FD frames
    Transmission.field.frameType = CAN_FRAME_DATA; //Data frame
    Transmission.field.idType = CAN_FRAME_EXT; //Standard ID
    Transmission.msgId = EMON_SL; //ID of client
#else
    Transmission.field.brs = CAN_NON_BRS_MODE; //Transmit the data bytes at data bit rate
    Transmission.field.dlc = DLC_8; // 8 data bytes
    Transmission.field.formatType = CAN_2_0_FORMAT; // CAN operation mode
    Transmission.field.frameType = CAN_FRAME_DATA; //Data frame
    Transmission.field.idType = CAN_FRAME_STD; //Standard ID
    Transmission.msgId = (EMON_SL); //ID of client
#endif
    Transmission.data = (uint8_t*) can_buffer; //transmit the data from the data bytes
    if (CAN_TX_FIFO_AVAILABLE == (CAN1_TransmitFIFOStatusGet(FIFO2) & CAN_TX_FIFO_AVAILABLE))//ensure that the TXQ has space for a message
    {
        CAN1_Transmit(FIFO2, &Transmission); //transmit frame
    }
    Transmission.msgId = (EMON_SU); //ID of client
    Transmission.data = (uint8_t*) can_buffer + CANFD_BYTES; //transmit the data from the data bytes
    if (CAN_TX_FIFO_AVAILABLE == (CAN1_TransmitFIFOStatusGet(FIFO2) & CAN_TX_FIFO_AVAILABLE))//ensure that the TXQ has space for a message
    {
        CAN1_Transmit(FIFO2, &Transmission); //transmit frame
    }

#ifdef CAN_DEBUG
    if (CAN1_IsRxErrorActive()) {
        MLED_Toggle();
    }
#endif
}

/*
 * complete and correct the MCC CANBUS configuration
 */
void can_setup(void)
{
    /*
     * interrupt handlers, both receive data from the FIFO
     */
    CAN1_SetFIFO1NotEmptyHandler(Can1FIFO1NotEmptyHandler);
    CAN1_SetRxBufferOverFlowInterruptHandler(Can1FIFO1NotEmptyHandler);

    /*
     * don't trust MCC for nothing
     */
    CAN1_OperationModeSet(CAN_CONFIGURATION_MODE);
    C1FIFOCON1Lbits.TFNRFNIE = 1; // not empty FIFO interrupt
    /*
     * enable CAN receiver interrupts, again, to fix one of the many MCC bugs
     */
    C1INTUbits.RXIE = 1; // The stupid MCC sets this back to off when setting the error interrupts
    PIR4bits.CANRXIF = 0; // clear flags and set interrupt controller again, just to be sure
    PIE4bits.CANRXIE = 1;
#ifdef    USE_FD
    CAN1_OperationModeSet(CAN_NORMAL_FD_MODE);
#else
    CAN1_OperationModeSet(CAN_NORMAL_2_0_MODE);
#endif
}
As you can see in the code can_setup function there are very specific register level code modifications in the function that's called in 'main' during the processor and application configuration block.
C:
/*
 * Main application
 */
void main(void)
{
    // Initialize the device
    SYSTEM_Initialize();

    // If using interrupts in PIC18 High/Low Priority Mode you need to enable the Global High and Low Interrupts
    // If using interrupts in PIC Mid-Range Compatibility Mode you need to enable the Global Interrupts
    // Use the following macros to:

    // Enable high priority global interrupts
    INTERRUPT_GlobalInterruptHighEnable();

    // Enable low priority global interrupts.
    INTERRUPT_GlobalInterruptLowEnable();

    // Disable high priority global interrupts
    //INTERRUPT_GlobalInterruptHighDisable();

    // Disable low priority global interrupts.
    //INTERRUPT_GlobalInterruptLowDisable();

    TMR4_SetInterruptHandler(FM_io);
    TMR4_StartTimer();
    TMR0_SetInterruptHandler(onesec_io);
    TMR0_StartTimer();
    TMR2_SetInterruptHandler(tensec_io);
    TMR2_StartTimer();

#ifdef MB_MASTER
    init_mb_master_timers(); // pacing, spacing and timeouts
    UART5_SetRxInterruptHandler(my_modbus_rx_32); // install custom serial receive ISR
    StartTimer(TMR_MBTEST, 20);
    void mb_setup(); // serial error handlers
#endif
    StartTimer(TMR_SPIN, SPINNER_SPEED);

    init_display();
   
// code removed
    /*
     * complete and correct the MCC CANBUS configuration
     */
    can_setup();

    while (true) {
        // Add your application code
}
So the bottom like for me is that code isolation is more for debugging and coding structure than actual portability on small controllers.

For 32-bit controllers, the chip resources (MMU, IMU, VM ) allow for much greater hardware abstraction so it's much easier to write application portable code and usually much harder to write low-level portable code.
 

nsaspook

Joined Aug 27, 2009
12,306
What facilitates moving from one processor to another is really good comments and documentation.
But not too many comments. Write good code that speaks for itself. Documentation is the key.
Comments are good, but there is also a danger of over-commenting. NEVER try to explain HOW your code works in a comment: it's much better to write the code so that the working is obvious, and it's a waste of time to explain badly written code. Generally, you want your comments to tell WHAT your code does, not HOW.
https://www.kernel.org/doc/html/v4.10/process/coding-style.html#:~:text=Comments are good, but there,your code does, not HOW.
 

nsaspook

Joined Aug 27, 2009
12,306
It's the quality of the code and the quality of the comments that counts not the quantity.
Like strange MODBUS bit arrangements.
C:
/*
 * reorder 16-bit word bytes for int32_t
 * https://control.com/forums/threads/endianness-for-32-bit-data.48584/
 * https://ctlsys.com/support/common_modbus_protocol_misconceptions/
 * https://iotech.force.com/edgexpert/s/article/Byte-and-Word-Swapping-in-Modbus
 *
 * "Little Endian" slaves or "Big Endian" slaves
 * Byte endianness with Word endianness?
 * Lions and Tigers and Bears!
 */
int32_t mb32_swap(const int32_t value)
{
    uint8_t i;
    union MREG32 dvalue;

    // program it simple and easy to understand way, let the compiler optimize the expressions
    dvalue.value = value;
    i = dvalue.bytes[0];
    dvalue.bytes[0] = dvalue.bytes[1];
    dvalue.bytes[1] = i;
    i = dvalue.bytes[2];
    dvalue.bytes[2] = dvalue.bytes[3];
    dvalue.bytes[3] = i;
    return dvalue.value;
}
 

Ian0

Joined Aug 7, 2020
8,947
Like strange MODBUS bit arrangements.
C:
/*
* reorder 16-bit word bytes for int32_t
* https://control.com/forums/threads/endianness-for-32-bit-data.48584/
* https://ctlsys.com/support/common_modbus_protocol_misconceptions/
* https://iotech.force.com/edgexpert/s/article/Byte-and-Word-Swapping-in-Modbus
*
* "Little Endian" slaves or "Big Endian" slaves
* Byte endianness with Word endianness?
* Lions and Tigers and Bears!
*/
int32_t mb32_swap(const int32_t value)
{
    uint8_t i;
    union MREG32 dvalue;

    // program it simple and easy to understand way, let the compiler optimize the expressions
    dvalue.value = value;
    i = dvalue.bytes[0];
    dvalue.bytes[0] = dvalue.bytes[1];
    dvalue.bytes[1] = i;
    i = dvalue.bytes[2];
    dvalue.bytes[2] = dvalue.bytes[3];
    dvalue.bytes[3] = i;
    return dvalue.value;
}
I was amused about the comment about temporary variables called "tmp" - I work on systems where there are numerous temperatures. . . . .
 

WBahn

Joined Mar 31, 2012
29,512
Hey everyone,

I've been diving into the world of software design, and one topic that has caught my attention is portability. I'm trying to understand the concept better and its implications in writing code that can be easily adapted to different platforms or targets.

Specifically, I'm curious about how you handle different versions of a target architecture, such as an 8-bit or 16-bit PIC microcontroller. Would you write separate code for each version, or do you aim for a more generalized approach? Additionally, what about developing portable code that can be used for both PIC microcontrollers and ARM-based platforms?

I look forward to your insights and experiences
Portability just means the ability to take the same piece of code and run it on different platforms without modification. Complete portability is extremely elusive, especially for code that interacts directly with the hardware. But that's not to say that you can't make significant strides in that direction. The key is to identify those parts of the program that are specific to a particular operating environment and those that are completely generic. You then put the environment-specific code in functions that the rest of your code calls and you put various versions of those function, one set for each environment, in different files or in different places in the same file so that only minimal code change is required to pick which set of functions you want to use.
 

nsaspook

Joined Aug 27, 2009
12,306
A language like C is portable in the sense it can be ported to just about any computer architecture from 8-bit controllers to supercomputers fairly easily and most of the time, produce acceptable code with light optimization. Actual C software code portability across small controllers families and architectures is a nightmare. C has also been used to bootstrap nearly every other modern language that was to come into existence in the last few decades.
 

Ya’akov

Joined Jan 27, 2019
8,536
There's no such thing as portable software. It's a concept dreamed up by C programmers to promote C over assembler.
The majority of microcontroller software concerns interfacing with peripherals, and peripherals vary from manufacturer to manufacturer. Some manufacturers provide software "drivers" but that just makes matters worse, as they also differ from manufacturer to manufacturer and are much less well documented than the peripherals themselves.
If you move software from one microcontroller to another, even if they both provide GNU C compliers, you will still end up re-writing the vast majority of it.
While I agree with the spirit of your post, I would state things a little differently.

If by portability one means as it is used in the pipe dream of Java’s marketing materials, viz.:

Write once, run everywhere.
then your sentiment is quite accurate. The traditional re-write of this slogan is:

Write once, debug everywhere.
But, if instead we consider the goals of software portability and the practical possibilities, it’s a little different. The purpose of portability is code reuse, which is a laudable one in a world with many platforms. But just what does that entail, and how much can we actually gain from it?

If we look at the lowest level, assembler, we see an impossible situation. The opcodes for each target are different. There is no way to write code that will work on processors that don’t share opcodes and register layouts. It’s a non-starter.

But, let’s say there is a processor family, and they share opcodes but have different register layouts. It is a relatively trivial thing to write directives, or pseudo-ops that accommodate the different register sets. This is a clue into the entire nature of portability: abstraction.

Abstraction is both portability’s super power and its nemisis. Abstraction is one or more layers of indirect specification of the function of code. The power of assembler lies in its specificity and concreteness. When writing assembler, we don’t write something about what the code intends to accomplish, only what is it going to do in the very moment the instruction is executed. This is very powerful because in a well written assembler program there are no wasted processor cycles, every step does something useful.

Once we begin to abstract functionality, we lose that ability to make each step optimal. We begin to depend on the idea behind a set of steps. In our instant example of accommodating a different set of registers, this lose may be very small but it could also be significant. For example, our two targets my offer different quantities of registers with the more capable providing an opportunity to increase performance by using a different strategy altogether not realizable in a simple directive which essentially makes the two resulting programs the lowest common denominator versions.

And here is the problem that just grows in magnitude as we increase “portability”. As you mention, C is an example of using a programming language to increase portability by offering more abstraction. The language presents a programmer with abstractions of functions common to programming problems, and it seeks to generate code that is optimized to various targets.

This does work well within limits, and for some types of (relatively trivial but still very useful) programs, this portability is a real benefit. But compared to assembler, except for the most trivial cases, the generated code will not be as efficient or effective. It can’t be, because the program specifies functions, not how to do those functions and their relationships to each other.

So, the C programmer finds the layer of abstraction that C provides to be both a blessing as it reduces the complexity and confusion of the code needed and a curse as it obscures the method to actually do what what the program is designed to do in light of the target plartform.

This is why it is common practice to choose two different languages for development: a higher level (more abstract) one for the bulk of the program, and a lower level (less abstract) one for the time critical and performance critical inner loops that do the heavy lifting.

In practice, this means a C programmer would choose to add assembler to the project while a programmer using a dynamic language like, say, Python, might choose C for those critical sections. Each is a step down in abstraction potentially offering better performance at the cost of reduced portability.

Sometimes, a program distributed for multiple platforms will offer the option to include generic or specific versions of some part at build time. That is, it might have a modular approach to the problem of porting the main code by allowing for, say, assembler pieces if they exist for that platform. In this case, the main code can be seen a portable but the module that targets the specific platform isn’t portable at all.

And here we come to the practical aspect and useful meaning of portability. Portability has value so long as it is used to optimize code reuse rather than attempt to eliminate all non-reusable, that is non-portable, code. It is something that can be applied a little, or a lot, but when you have a situation where the overall system function requires high efficiency and high performance, it is very possible portability will be nowhere to be seen.

To be seen, for sure, but in this view of portability it is almost certainly still present in at least one way: the programmer’s approach to writing code. While some platforms will have unique methods particular to them, most of the things a programmer is going to do with assembler exist in that programmer’s mind a ideas that get massaged into the target code.

And, perhaps, this is the seed of the whole idea. Programmer’s know (in some cases) they use abstractions to think about how to make assembler programs work on various platforms. The idea that this can be extended, to it’s logical extreme is enticing but it is also the typical hubris of software developers to imagine the one principle they identify can be the answer to every problem they encounter. This has proven incorrect over and over but seems to be something each person learns indpendently.

So, in the practical world, what is portability and where is it successful? Here are some things that I would class as portability, ignoring the Javaesque pipe dream of “portability”:

  1. Assembler directives targeting processor specific differences in the same family​
  2. C pragmas targeting platform differences​
  3. The GNU config program​
  4. High level languages themselves, particularly dynamic languages​
  5. Virtual machines that allow programs written in high level languages to run on different programs​
  6. The use of libraries that offer a high level API to platform specific implementations of important functions​
  7. Things like Electron that offer an impressively robust, genuinely cross platform framework (in the end, a better Java than Java*)​
* In terms of portability, Java has other goals, like OO encapsulation which are orthogonal to portability for the most part and a different discussion.

That list is intended to be roughly in order of abstraction. This is all off the cuff, so please excuse discrepancies. The main point of it is this: portability, when rescued from the world of marketing claims, is about reusing code and increasing the usefulness of it.

It can start in your head, with best practices to ensure that when it comes time to port something it’s structured and written in a way that makes this easier. It can include using a combination of high and low level languages based on the needs of the particular parts of the program. It can mean using libraries and frameworks that support necessary things like hardware specific interfaces and the like—often written in assembler themselves.

But it is also always a matter of optimization and there are many moving parts. The system being built must be considered at every important level. The importance of maximizing different aspects needs to be weighted appropriately and trade-offs made.

Performance vs. portability is a constant battle in optimization. Time to completion, maintainability, portability within the versions of a target (old and new ones not yet known), and so many other considerations makes portability just one more possibly attractive attribute of a development environment. Just like all the other things, it is one consideration among the many cogent ones that must be balanced.

And here, we probably agree completely: when portability becomes the goal, you‘ve lost track of what a program is supposed to do.
 

Ian0

Joined Aug 7, 2020
8,947
I changed from NXP LPC processors to Renesas RA4. Both ARM Cortex-M3 and both supplied with GNU C. I took all the subroutines and sorted them into two piles, those that dealt with the peripherals, and those that formed the main core of the program.
When I’d done that, the former greatly exceeded the latter, so I gave up and thought “sod this, I’ll just rewrite the whole thing” and did so with the idea the rewriting it might improve it.
 

MrAl

Joined Jun 17, 2014
10,909
Hello,

This really depends on both WHO is writing the software and WHAT exactly is being targeted.
For example, here is a piece of code that targets both Windows and Linux and because some of the constants are different for those two systems there needs to be a way to detect this in the code and make changes automatically:

Code:
if platform() = LINUX then
    BLUE  = 4
    CYAN =  6
    RED   = 1
    BROWN = 3
    BRIGHT_BLUE = 12
    BRIGHT_CYAN = 14
    BRIGHT_RED = 9
    YELLOW = 11
else
    BLUE  = 1
    CYAN =  3
    RED   = 4
    BROWN = 6
    BRIGHT_BLUE = 9
    BRIGHT_CYAN = 11
    BRIGHT_RED = 12
    YELLOW = 14
end if
Some of the other constants are the same for both systems so only those that are not the same have to be changed if needed. This allows seamless operation on both systems.

In C and C++ we have what are called preprocessor directives. To change targets you might use:
#ifdef
for example. This might be accompanied by #define statements elsewhere in the source files.

In some cases you can query the operating system too as for the version number. With Windows there are constants for this. When you call a function to access the version number, you get a constant that tells your program what version is being run at the time and so you can allow or disallow certain functions and/or replace some functions with other functions depending on the version. You might also have to inform the user that they cant perform some specific task because the current operating system does not support it.
 
Top