Applying Abstraction concepts in firmware

Started by fabatera 7 months ago4 replieslatest reply 7 months ago262 views

Hi all! 

Could you share your opinion and experience with the application of Abstraction concepts in firmware development using C as the programming language?

Some practical examples would be great.

Cheers!

[ - ]
Reply by bamosDecember 12, 2017

I love abstraction in firmware!

This is coming from someone who programs in C/C++ on a daily basis when not doing hardware development (using ARM Cortex M MCU's) and focuses heavily on code re-use and overall efficiency (but not necessarily program flash footprint).  So...take all of this with a grain of salt. . .everyone does things differently and there is DEFINITELY no one-size-fits-all.

I use a few different layers of encapsulation.  This has served my team pretty well in the past several years.  It has been used from C to C++, bare metal to RTOS based systems and on around half a dozen different MCU's and across 10 fully embedded systems.  This approach is largely inspired by the TinyOS project which used nesC.  The last time I looked at mBed they were doing all of this at a much higher level than what's suggested here.  mBed provides a common API to access underlying hardware (but is C++ only, non-the-less, it should provide a valuable reference for research purposes, and there are plenty of users out there).

Don't let anyone fool you. . .proper abstraction (at the level suggested here) will take longer than hard-coding everything and will be at a minimum slightly less CPU efficient.  However, if you're creating a code-base that is expected to be maintained over a long period of time and expected to support long product life-cycles - I personally feel it's worth-while.


I'm not sure what kind of examples you're looking for, but here's a general discussion:

  • MCU implementation of a generic interface
    • this layer needs to be as efficient as possible
    • almost all MCU manufacturers provide their own drivers for peripherals - you can either wrap these or roll your own (it takes a lot longer to roll your own, but can be well-worth the effort if performance or specific behavior is required, and you'll actually understand what's going on when you're finished).
    • the idea here is to make it easier on yourself when switching between MCU's - you only need to re-implement the lowest level drivers and then all code above it works properly
    • e.g. create serial drivers that provide a common API, ideally regardless of the underlying peripheral (e.g. a "linear" serial API could be provided for SPI and UART, the same goes for a ring buffer approach)
    • create a simple interface for interacting with hardware timers
    • create a simple interface for setting up GPIO and interrupt lines
  • chip specific drivers
    • HPL - hardware presentation layer
      • implement the underlying chip's full base level capabilities (this is the super-set of it's functionality and can/should be stateless - e.g. register level access).  NOTE- this should use as few MCU specific implementations as possible
      • avoid connecting to actual MCU pins here, so the HPL is portable to a different board and even an entirely different MCU later
    • HAL level driver (the simpler the better here)
      • figure out a good general purpose API for this type of chip and implement it - then when moving to a different IC - you can use the same API without touching higher level code
        • Berkeley sockets for a Wifi module
        • simple read functions for ADC's
        • a DAC might have an output function
  • Board specific
    • collect all of your HAL drivers, MCU Implementations and roll them up into a single file and connect them to the actual MCU-specific hardware.  Try and do this in one place (then you can move board specific implementations to other boards easily in the future by simply "re-wiring" the components)
  • agnostic middle-ware - not tied to any MCU or specific IC's
    • uses HAL interfaces for drivers and MCU agnostic drivers only
    • protocol implementations when implemented correctly
    • algorithms (i.e. PID)
    • data structures
    • data handling routines

Here's an example of how an application might look using the above method:

firmwareabstractionblockdiagram_86518.pn


And how an abstracted DAC might look :

dacexample_52382.png

And how it would fit into an overall system.  Note that since all of the yellow blocks below implement a common API, the DAC Driver is "hardware agnostic" from an MCU perspective and the Positioner's perspective.  It implements the iDACInterface for the specific DAC084S085 IC.

firmwarearchabstraction_44198.png

[ - ]
Reply by BVRameshDecember 12, 2017

My two cents about abstraction concepts in firmware:

Think from top-to down, and implement from bottom to up.

Any problem, think from top to down approach, or divide and conquer. For example, lets say we are desiging an automatic oven,

it requires a heater, temperature sensor, timer, door lock, indicator, human interface through keypad and display, buzzer, internal light, safety cutoff.. (the list continues, out of which some are essential and some are fancy.

Now list essentials:

Now we have to put from a top down view that it requires a temperature sensor and heater feedback loop with timer to control current in heat. This is the basic block.

Then you require a human interface device to set the temperature and amount of time. This is done through as set of keys and alphanumeric display. This is another block.

Then you require an indicator to show job in process and job completed. This can be essential like buzzer, or a fancy display. This is another block.

Then from safety point of view, the door lock the runaway shutoff, may be electronic, or as simple as fuse. This is another block.

Now put all the blocks together and work from bottom up:

Choose a micro controller to support all / most of the above.

Put the drivers in place.

Put each block in a thread.

Integrate through a scheduler.

In this way you can achieve to solve any embedded related product.

[ - ]
Reply by lmitchamDecember 12, 2017
Abstraction, to me, is making the 'C' code more readable and therefore maintainable by future programmers, as well as for my benefit as I maintain various assorted projects.  Many micro-controllers allow BIT addressing and one of my first tasks, when starting a new project, is to define my BIT registers.  These registers can indicate a button state, progression through an algorithm, or information about a peripheral such as serial data format.

Reading a button state is easier if you can see MenuState instead of PORT3.PIDR.BIT.B1, and having a BIT addressable flag to indicate when a button interrupt interrupt has fired is easy to set up and does not give up any speed in your interrupt routine.

#define F_ModeBtn        (ButtonState.BIT.F0)    // Mode pressed
#define F_MenuExBtn        (ButtonState.BIT.F1)    // Menu / Exit Menu pressed
#define F_BrtUpBtn        (ButtonState.BIT.F2)    // Brightness / Up arrow pressed
#define F_VolDnBtn        (ButtonState.BIT.F3)    // Volume / Down arrow pressed
#define F_IWthrEnBtn    (ButtonState.BIT.F4)    // Inclement Weather / Enter pressed
#define F_SelfTestBtn    (ButtonState.BIT.F5)    // Self-Test pressed
#define F_Debounce        (ButtonState.BIT.F6)    // Debounce buttons
#define F_Trigger        (ButtonState.BIT.F7)    // Trigger pressed

//ICU IRQ10
void INT_Excep_ICU_IRQ10(void)
{
    F_MenuExBtn = 1;
    F_Debounce = 1;
}

Instead of dealing with an offset number, to indicate a serial packet format, I #define each packet to make it more meaningful to the coder.

#define    pkt_ASCII    (0)
#define    pkt_20        (1)
#define    pkt_21        (2)
#define    pkt_NMEA    (3)
#define    pkt_UDef    (15)

    if(SerFormat == pkt_20)
    {
        BYTEcnt = DE_data_14_pkt();        // Build binary DATA packet
    }
    else if(SerFormat == pkt_21)
    {
        BYTEcnt = DE_data_15_pkt();        // Build binary DATA packet
    }
    else if(SerFormat == pkt_NMEA)
    {
        BYTEcnt = DE_NMEA_pkt(1);        // Build NMEA DATA packet
    }
    
Instead of a BIT mask such as 0x3C, I define the mask so that I can tell, at a glance, what the code is filtering.  The following allows me to indicate an error state in an algorithm by a simple ORing of the error with the AlgoERR (TgtStatus BYTE).

union STATE
{
    unsigned char BYTE;
    
    struct
    {
        unsigned char F012:3;
        unsigned char F3:1;
        unsigned char F4:1;
        unsigned char F5:1;
        unsigned char F6:1;
        unsigned char F7:1;
    } BIT;
};

    // Error Codes
#define AlgoERR            (TgtStatus.BIT.F012)
#define bBadData        (0x01)
#define bGoodPoint        (0x02)
#define bSEFlush        (0x03)
#define bCycSpeed        (0x04)
#define bStdError        (0x05)
#define bEoG            (0x06)
#define bDeltaSpeed        (0x07)

The struct is a great method of abstraction and makes data easy to access when there are many fields associated, as in this example of a NMEA string definition.

//    %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%//
//        NMEA String :
//
//                $DGN,012345,mmddyy,hhmmss,ddmm.mmmm,N,dddmm.mmmm,W,A,A,SSS,RRRR.R,U*XX<CR><LF>
//
//                    LIDAR Serial Number    012345
//                    Date                mmddyy
//                    Time                hhmmss
//                    Latitude            ddmm.mmmm
//                    N/S Indicator        [N]orth or [S]outh
//                    Longitude            dddmm.mmmm
//                    E/W Indicator        [E]ast or [W]est
//                    UTC Position        hhmmss.sss
//                    Status                A = valid, V = invalid
//                    Direction            A = Approaching, R = receding
//                    Integer Speed        SSS
//                    Range                RRRR.R
//                    Units                0 = English, 1 = SI
//                    Checksum            XX (XOR of BYTEs between $ and *)
//
//    %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%struct st_DGN
{
    unsigned char    Month;
    unsigned char    Day;
    unsigned char    Year;
    unsigned char    Hour;
    unsigned char    Minute;
    unsigned char    Second;
    unsigned char    Lat_Deg;
    unsigned char    Lat_Min;
    unsigned char    Lat_Sec_H;
    unsigned char    Lat_Sec_L;
    unsigned char    Lat_NS;
    unsigned char    Long_Deg_H;
    unsigned char    Long_Deg_L;
    unsigned char    Long_Min;
    unsigned char    Long_Sec_H;
    unsigned char    Long_Sec_L;
    unsigned char    Long_EW;
    unsigned char    Speed_H;
    unsigned char    Speed_L;
    unsigned char    Range_H;
    unsigned char    Range_L;
    unsigned char    Units;
};

    if(F_GPS_Valid && ((stGPS_Valid.Status == 0x58) || (stGPS_Valid.Status == 0x41)))    // Copy GPS data    // Diagnostic - GPS
    {
        sprintf(strTemp, "%02X%02X.%04X,", (unsigned char)(stGPS_Valid.Lat_Deg), (unsigned char)(stGPS_Valid.Lat_Min), (unsigned short)(stGPS_Valid.Lat_Dec));
        strcat(strDGN, strTemp);
        sprintf(strTemp, "%c,", (unsigned char)(stGPS_Valid.NS_Ind));
        strcat(strDGN, strTemp);
        sprintf(strTemp, "%03X%02X.%04X,", (unsigned short)(stGPS_Valid.Long_Deg), (unsigned char)(stGPS_Valid.Long_Min), (unsigned short)(stGPS_Valid.Long_Dec));
        strcat(strDGN, strTemp);
        sprintf(strTemp, "%c,", (unsigned char)(stGPS_Valid.EW_Ind));
        strcat(strDGN, strTemp);
        sprintf(strTemp, "%c,", (unsigned char)(stGPS_Valid.Status));
        strcat(strDGN, strTemp);
    }
    else                                    // Fill with comma space holders
    {
        strcat(strDGN, ",,,,,");
    }
    
    Conditional debug statements can be placed in strategic areas of code and controlled by a BIT switch in an include file.  Levels of debug or areas to debug can be enabled by defining a level and then compiling.
    
// %%%%%%%%%%%%%%%%%%%% Serial Output %%%%%%%%%%%%%%%%%%%%%%%%
#define DEBUG_SERIAL_OFF    0
#define DEBUG_SERIAL_SIMPLE    1
#define DEBUG_SERIAL_SCI0    2
#define DEBUG_SERIAL_SCI2    3
#define DEBUG_SERIAL_SCI3    4

/*Set the current level of debug output using one of the #defines above.*/
#define DEBUG_SERIAL_LEVEL     DEBUG_SERIAL_OFF
    
Simply surround DEBUG code like this:

#if DEBUG_SERIAL_LEVEL == DEBUG_SERIAL_SCI3
    TX3_str((unsigned char *)cmdSetSSP, 0);

    while(F_TXdone_3 == 0)
    {
        WDT_Kick();                            // Kick WDT
    }

    Delay_2uS(150);
#endif

If the #if statement is not TRUE, the lines of code between #if and #endif will be grayed out, in the source code, and not compiled.

    The tools are all there and you are only limited by your own creativity.  I have found, over the years, that spending a little more time in the planning stage of a project pays off tenfold when it comes to maintaining and especially debugging. 
[ - ]
Reply by SpiderKennyDecember 12, 2017

I'm not sure what you mean by 'Abstraction concepts' but here are some things I've used, based on my (maybe incorrect) assumption of what you might mean.

1. Abstracting an MCU from the actual hardware implementation.

Example, a linux embedded core in a multi-room digital music system that I designed. During the lifecycle of the product I might change the actual DAC that is used. I don't want to re-build the entire linux image, and there might not be drivers available. So I build the linux image with a common Audio DAC driver, because I won't bother connecting the 'control' signals to the DAC, just the I2S signals. I then use a cheap MCU to act as the Audio DAC driver. On Power up, the cheap MCU sends all the right control signals to configure the MCU. EQ, Volume, PAN etc are all controlled via a standardized UART connection between the linux core and the MCU. I can now replace the Audio DAC in the design at any time, and simply update the cheaper MCU rather than re-building the entire linux image with a driver that might not exist. Thus, abstracting the linux core from the hardware implementation.

2. Using the same code, regardless of the MCU on which it is running.

So for example, being able to write code once and run it on Atmel or PIC, or ARM. The code includes timers, interrupts, UART etc.

To do this type of thing, I generally define a HAL (Hardware Abstraction Level) set of headers, which are hardware agnostic, and implement features such as setting up serial ports, timers etc. These headers then look for certain pre-processor definitions to be #defined and then compile / link different sections of code, depending on which hardware definitions are made. For example, un pseudo-c:

void setTimer1(period){
#ifdef 
    PIC_MICROT0CON = <wahatevr>
    CCP2CON = <whatever>
#else ifdef ATMEL_MICRO
    TCCR0B = <whatever>
    TCNT0 = <whatever>
#else 
    ... <snip>
}