Keep it simple: avoid over engineering

A perfect design is an enemy of a good design. Often, designers striving for a perfect design may end-up with no design at all due to schedule and cost overruns. A simple design may not provide best solution to a given problem but it would probably have the best chance of meeting the schedule and cost constraints with acceptable quality. Also, a simple design is easier to implement, maintain and enhance.

It is better to start with a simple design, making reasonable and rational compromises during the design phase. This will save you from making unreasonable compromises in quality when faced with looming product delivery dates.

Here are some of the "reasonable and rational" design simplification techniques:

  1. Use lookup tables for complex decision making
  2. Use fixed size arrays whenever possible
  3. Avoid dynamic memory allocation
  4. Reduce the number of tasks in the system
  5. Avoid multi-threaded design
  6. Optimize the design only for the most frequently executed scenarios
  7. Sometimes searching might be more efficient than hashing
  8. Use state machines to simplify design
  9. Use timestamps to avoid running timers
  10. Use object oriented programming
  11. Avoid design hooks for future enhancements
  12. Avoid variable length and bit packed messages
  13. Reduce message handshakes
  14. Simplify the hardware architecture
  15. Prefer general purpose computing platforms over specialized platforms
  16. Do not use proprietary protocols/operating systems
  17. Prefer buy over build for software
  18. Prefer buy over build for hardware
  19. Prefer designs that lead to more reuse
  20. Avoid a heterogeneous hardware and software environment
  21. Consider hardware upgrades to reduce software effort
  22. Minimize configurable system parameters
  23. The "0 or 1 or n" Rule

Use lookup tables

Many times complex decision making code with cascades of if-then-else statements might be replaced with a simple lookup table. A lookup table would also be easier to understand and modify compared to complex and convoluted if statements.

Consider the following example, where the system has to support three terminal types with each terminal type supporting different features. See the following code to see the simplification brought about by lookup tables:

Checking for terminal service types

Terminal service type check using lookup

Use arrays

When managing multiple entities, the simplest possible data-structure is arrays. Consider a case where a certain processor has to support a maximum of 1000 user terminals. In this case, we recommend using an array definition for 1000 user terminals. This might seem like a waste of memory when the system is actually handling only 100 terminals, but do you really have any use for that memory when the system is running at 10% of its rated capacity. A design where the system just allocates memory as and when user terminals are added, will be far more complicated to implement. A fixed size array implementation will be simpler to implement and it will probably be more efficient.

Avoid dynamic memory allocation

As far as possible, dynamic memory allocation in Realtime systems should be restricted to message communication related operations. Many times dynamic allocation of objects and structures adds needless complexity to the design. Also with dynamic memory allocation, memory leaks can plague the system for a long time. If memory is allocated statically the design remains simple and free of memory leaks. The designer is freed from thing about when to allocate/free memory and can focus on the core design.

Reduce the number of tasks

Try to reduce the number of tasks in a Realtime system design as it reduces inter-task message interactions. It also eliminates task scheduling delays. Reduced message interaction simplify design as inter-task message handling is replaced with local function and method invocations. The direct result of this is reduction in the complexity of the state machines.

Avoid multi threaded design

Scheduling of actions in real-time systems is controlled by state machines. Typically state machines do not call blocking OS primitives while executing a state machine. In such a design, multi-threaded design is of little value. Multi-threaded design complicates the design by introducing synchronization primitives to access resources shared among different threads. Multi-threaded programs are harder to debug than their single-threaded counterparts.

Optimize only frequent scenarios

Though a programmer has to design a feature for every possible failure and glare scenario, their frequency of occurrence in real-time is low. Thus the designers should focus on optimizing the design for the frequent success scenarios. There is no major impact on system performance if the error and glare scenarios are implemented in a simple but non-optimal way.

Consider the call handling in a switching system. Here, failure of a digital trunk might be handled by simply searching through all the call objects for the switching card and initiating call clears. This could have been implemented more efficiently if the digital trunk object maintained the list of all of the active calls on that digital trunk. But this failure scenario would not occur frequently. So, the extra complexity of maintaining a list of calls on a per digital trunk basis is not worth the effort.

Searching vs. hashing

Many times Realtime systems need to maintain hash tables and maps to quickly search for an object. Maps and hash tables provide efficient way to dispatch messages. However when the number of entries in small, a simple search in an array might be more efficient than incurring the fixed overhead of a hashing algorithm.

Use state machines

Proper use of finite state machines in real-time design avoid unnecessary use of large number of flags and cascaded if statements checking for those flags. We strongly recommend using state machines as they simplify the code and also make it easier to understand the feature flow. See the article on hierarchical state machines for details.

Use timestamps

If there is a requirement of keeping track of long time durations, it is preferable to avoid timer management overheads. This could avoided by taking a time-stamp for the timer-start instant. Then, the time-stamp should be subtracted from the current time to achieve the same effect as timer expiry. This technique is illustrated in the code for handling leaky bucket counters.

Handling Leaky Bucket Counters

Use object oriented programming

Design object oriented as it provides the simplest technique as it maps directly from the problem domain to objects. This simplifies the design cycle as the design actually mirrors the problem domain. Due to the same reason, object oriented designs are simpler to understand and maintain for new developers.

Avoid design hooks for future enhancements

It is advisable not to introduce additional complexity in the name of future design hooks. More often than not, these hooks turn out to be a liability than an asset for the future designers as they might be forced to accept the design based on these hooks. Thus avoid adding design hooks for the future, as they will just add additional work that you don't need to do now and will be of little help to future designers.

Avoid variable length and bit packed messages

Avoid using variable length messages as they add complexity to message interface code. Use of variable length fields also forces you to abandon C- structure level definition of messages. This is additional work that you can do without.

Same reasoning applies to bit packed messages. Do not define bit pack messages unless mandated by some external protocol that your system supports. Bit packed messages are a constant source of bugs and they lower system performance by wasting valuable CPU cycles in message packing and unpacking.

Reduce message handshakes

When designing message interactions, avoid multiple interactions between two processors. Consider the following example:

  1. Switching card requests a space slot from the Central card
  2. Central card allocates the space slot to the Switching card and replies with a message.
  3. Switching card now requests the Central card to route the call
  4. Central routes the call and replies to the Switching card.

The above interaction could be simplified as:

  1. Switching card requests a space slot and call routing
  2. Central allocates a space slot and routes the call. Central replies back to the Switching card.

As you can see above, the four message handshake has been reduced to two messages only. This reduces one state in the Switching card handling of the a call.

Simplify the hardware architecture

The first step in keeping the software design is keeping the hardware architecture simple. Software system architects should work closely with the hardware team to simplify the hardware architecture of the product. This might involve:

Simplifications in hardware directly translate into simplifications in software architecture. For example, reducing the number of nodes in the system will reduce the number of releases and teams that you need to support. Using off-the-shelf interconnection technologies like Ethernet and TCP/IP will open doors to use of off-the-shelf components like routers and switches.

Prefer general purpose computing platforms over specialized platforms

Use a special purpose computing platform only after you have exhausted all possibilities of using a general purpose platform. For example, if your application requires signal processing capabilities, consider if the performance goals can be met by a general purpose PC platform without using Digital Signal Processors (DSPs). General purpose processors might support specialized instructions that might bring them at par with specialized platforms like DSPs. e.g. the Intel Pentium processors support SSE, SSE2 instructions that can handle really complex signal processing tasks.

There are several advantages of sticking to general purpose platforms:

Do not use proprietary protocols/operating systems

Many embedded systems use home grown protocols and operating systems. This leads to additional cost to maintain the associated software. Use of standard protocols and operating systems lowers cost and improves stability of the product, as standard products have been subjected to rigorous testing by countless systems. Proprietary protocols/operating systems often cost a lot more due to need to train developers.

Prefer buy over build for software

When starting a new software project, consider if you could buy off-the-shelf software modules that can be directly integrated into your system. This saves you time as you can focus on your core application development, without having to worry about standard parts like device drivers, external protocols. When looking for external software modules, consider the following factors:

Keep in mind that most of the time you can buy software at fraction of the cost of building it in house.

Prefer buy over build for hardware

Using a off-the-shelf hardware will save you a lot of time and money. The software team would get the final target hardware on day one of the project. With proprietary hardware, the software team has to invest a lot of development effort in building a test environment on the host machine and finally porting the software when the hardware becomes available.

Prefer designs that lead to more reuse

When developing a hardware and software architecture, prefer designs that will reuse already developed software and hardware modules. The future reusability of the software and modules should also be a factor in choosing new architectures. Avoid "lets start with a clean slate" approach to developing systems. New projects should build over the results of previous project. This lowers cost by reducing complexity of the system being developed.

Avoid a heterogeneous hardware and software environment

When developing a new system, use same or similar hardware platform in most of the modules. This will save you the trouble as you do not need to worry about byte alignment and ordering and other interoperability issues. Using the same operating system will reduce development effort as you can use common utilities on all modules in the system. Use of same operating system will also save you money and development effort as same set of development tools can be used by all developers.

As a corollary to this, consider using the same platform for developers workstations and the final platform. This will allow developers to use their workstations for testing. You would not have to worry about cross development tools and other costs associated with having a different target platform.

Consider hardware upgrades to reduce software effort

Many times you can reduce software complexity by using higher performance hardware platforms. For example:

Often, system designers consider software to be "one time" cost and hardware is considered to be a recurring cost. The designers fail to see that software development and maintenance costs are recurring and will most likely increase with time. On the other hand, hardware recurring costs will go down with time.

Minimize configurable system parameters

Minimize the number of configurable parameters in the system. This will simplify your system configuration and it will save you coding effort involved in adding support for change of these parameters.

In our experience most configurable parameters rarely turn out to be useful after system deployment. But these parameters are a big source of system integration and deployment problems. There are countless horror stories on how wrongly configured parameters brought the system down. There are very few cases where a configurable parameter saved the day.

Parameters should be made configurable only if they meet any of the following criteria:

We have excluded engineering configurable parameters from the above list. All parameters that can be finalized during initial field testing should not be made configurable. Making such parameters configurable serves no purpose. Very rarely you will encounter a problem that can be fixed just by changing a configurable parameter. Even if you encounter such a problem, the chance that the parameter is actually configurable in your system would be remote.

The "0 or 1 or n" Rule

This rule simply states that when designing a system, every feature should be passed through the following sieves:

Try to drop features at Sieve 0. If the features passes through Sieve 0, try to contain the feature in Sieve 1 and implement only one active instance of the feature in the system. If the feature survives Sieve 0 and Sieve 1, implement support for "n" simultaneous instances of the feature in the system.

For example, lets consider the design of a mobile terminal. The marketing folks request that the mobile terminal should support "broadcast sessions" for value added services like TV over a mobile phone. This feature request is first passed through Sieve 0 to determine if this is really needed for this terminal. If the features passes through Sieve 0, consider if the mobile terminal should only one broadcast session. If this meets the requirements, implement a system with just one broadcast session. This will be a lot simpler than supporting multiple simultaneously active broadcasts. If you can't get an agreement on one broadcast, go ahead and implement support for "n" broadcast sessions, even though the marketing folks are prepared to testify under oath that they will never need more than two simultaneous broadcasts.

Related Links