Decoupling design in a message-passing environment

QNX Neutrino is advertised as a “message-passing” operating system. Understanding the true meaning of that phrase when it comes to designing your system can be a challenge. Sure, you don't need to understand this for a standard “UNIX-like” application, like a web server, or a logging application. But it becomes an issue when you're designing an entire system. A common problem that arises is design decoupling. This problem often shows up when you ask the following questions:

  1. How much work should one process do? Where do I draw the line in terms of functionality?
  2. How do I structure the drivers for my hardware?
  3. How do I create a large system in a modular manner?
  4. Is this design future-proof? Will it work two years from now when my requirements change?

Here are the short answers, in order:

  1. A process must focus on the task at hand; leave everything else to other processes.
  2. Drivers must be structured to present an abstraction of the hardware.
  3. To create a large system, start with several small, well-defined components and glue them together.
  4. If you've done these things, you'll have a system made of reusable components that you can rearrange or reuse in the future, and that can accommodate new building blocks.

In this chapter, we're going to look into these issues, using a reasonably simple situation:

Say you're the software architect for a security company, and you're creating the software design for a security system. The hardware consists of swipe-card readers, door lock actuators, and various sensors (smoke, fire, motion, glass-break, etc.). Your company wants to build products for a range of markets—a small security system that's suitable for a home or a small office, up to gigantic systems that are suitable for large, multi-site customers. They want their systems to be upgradable, so that as a customer's site grows, they can just add more and more devices, without having to throw out the small system to buy a whole new medium or large system (go figure!). Finally, any systems should support any device (the small system might be a super-high security area, and require some high-end input devices).

Your first job is to sketch out a high-level architectural overview of the system, and decide how to structure the software. Working from our goals, the implied requirements are that the system must support various numbers of each device, distributed across a range of physical areas, and it must be future-proof so you can add new types of sensors, output devices, and so on as your requirements change or as new types of hardware become available (e.g., retinal scanners).

The first step is to define the functional breakdown and answer the question, “How much work should one process do?”

If we step back from our security example for a moment, and consider a database program, we'll see some of the same concepts. A database program manages a database—it doesn't worry about the media that the data lives on, nor is it worried about the organization of that media, or the partitions on the hard disk, etc. It certainly does not care about the SCSI or EIDE disk driver hardware. The database uses a set of abstract services supplied by the filesystem—as far as the database is concerned, everything else is opaque—it doesn't need to see further down the abstraction chain. The filesystem uses a set of abstract services from the disk driver. Finally, the disk driver controls the hardware. The obvious advantage of this approach is this: because the higher levels don't know the details of the lower levels, we can substitute the lower levels with different implementations, if we maintain the well-defined abstract interfaces.

Thinking about our security system, we can immediately start at the bottom (the hardware)—we know we have different types of hardware devices, and we know the next level in the hierarchy probably does not want to know the details of the hardware. Our first step is to draw the line at the hardware interfaces. What this means is that we'll create a set of device drivers for the hardware and provide a well-defined API for other software to use.

Figure 1. We've drawn the line between the control applications and the hardware drivers.

We're also going to need some kind of control application. For example, it needs to verify that Mr. Pink actually has access to door number 76 at 06:30 in the morning, and if that's the case, allow him to enter that door. We can already see that the control software will need to:

Figure 2. In the next design phase, we've identified some of the hardware components, and slightly refined the control application layer.

Once we have these two levels defined, we can sort out the interface. Since we've analyzed the requirements at a higher level, we know what “shape” our interface will take on the lower level.

For the swipe-card readers, we're going to need to know:

For the door-lock actuator hardware, we're going to need to open the door, and lock the door (so that we can let Mr. Pink get in, but lock the door after him).

Earlier, we mentioned that this system should scale—that is, it should operate in a small business environment with just a few devices, right up to a large campus environment with hundreds (if not thousands) of devices.

When you analyze a system for scalability, you're looking at the following:

As we'll see, these scalability concerns are very closely tied in with the way that we've chosen to break up our design work. If we put too much functionality into a given process (say we had one process that controls all the door locks), we're limiting what we can do in terms of distributing that process across multiple CPUs. If we put too little functionality into a process (one process per function per door lock), we run into the problem of excessive communications and overhead.

So, keeping these goals in mind, let's look at the design for each of our hardware drivers.