A Flexible EEPROM Manager in C++
Many embedded controllers contain EEPROM to support field configuration. Designers favor EEPROM because it can be erased and programmed in-circuit, yet it retains its contents when power fails. Like RAM (and unlike Flash ROM), EEPROM can be both erased and programmed byte-by-byte.
We can simply store field configuration variables directly in EEPROM, but adding a bit of record structure yields some advantages:
- corruption of EEPROM contents can be detected and perhaps repaired if we add checksums
- program upgrades can be eased if we can locate variables by name, rather than by numerical address alone.
This article shows how to use C++ to create a simple EEPROM manager with these features:
- supports any byte-erasable EEPROM or EEPROM-like device without modifying the manager
- checksums detect corruption of data in EEPROM
- named records allow program upgrades without maintaining a fixed record ordering
- EEPROM variables are mirrored to RAM for ease of use and speed
- RAM and EEPROM versions of the variables are synchronized automatically.
Included is a completely worked-out example of an EEPROM manager in about 300 lines of code along with some suggestions for extensions. It shows real-world use of several major C++ techniques, such as abstract base classes, operator overloading, and templates.
Design goals
We have one main goal: to create variables that persist over power failure -- in other words, nonvolatile variables. We'll make these nearly identical in use to normal variables, confining the differences to declarations and initialization. The code actually reading and writing the variables will look the same as the code for normal variables.
We'll use C++ templates to build a type-safe client interface. We'll have the compiler ensure (without manual intervention) that only the correct data types are stored in a given variable. We'll make the client interface as simple as possible to discourage copy-paste coding and to avoid obscuring the business logic with lots of housekeeping code.
We'll assign ASCII names to each variable in EEPROM and automatically allocate space in EEPROM for them. We'll thereby allow program upgrades without worrying about how to reallocate EEPROM when adding new variables or when abandoning old ones. (We won't, however, support changing the size or interpretation of an existing variable, but only its contents. To redefine a variable, we can simply create a new variable and abandon the old one, converting the contents as required.)
Finally, we'll support choosing a new EEPROM hardware driver without modifying the storage layout engine, client interface, or client code in any way.
Traditional Approach
The traditional approach to EEPROM storage uses a set of routines like
readEEPROM(unsigned eeaddr, void* memaddr, size_t len); writeEEPROM(unsigned eeaddr, const void* memaddr, size_t len);
that are invoked explicitly when needed. EEPROM addresses are often
#define constants, calculated by hand during manual EEPROM space
allocation.
Some problems with this approach:
- We must remember to back up variables to EEPROM whenever their contents change.
- In each call to
readEEPROM()orwriteEEPROM(), we must manually match the variable address and its size. The compiler cannot check this for us. - We need to maintain the EEPROM allocation table manually.
- To support field upgrades, the allocation table must not change between program versions (except to grow longer).
- Changing EEPROM device types involves either editing the client or driver source, or using preprocessor magic.
We can solve all of these problems in a simple and straightforward way if we use C++ intelligently.
Overview of a C++ solution
We first separate the solution into three layers:
the EEPROM hardware driver, which knows how to write and read blocks of bytes to a particular EEPROM device
the storage layout manager, which allocates EEPROM space, provides named records for our variables, and handles default values for the variable contents
the client interface template, which provides automatic backup of our RAM variables to EEPROM with a type-safe interface and allows us to specify default values for them.
We'll make some simplifying assumptions for the sake of brevity and execution speed:
We'll store the variable contents in binary form as an exact image of the variable in RAM. Storing the data in ASCII has some advantages but adds unnecessary complexity.
We'll assume that we can reduce the location of a variable in the backing store (the EEPROM) to a single unsigned integer, once the variable has been looked up by name. This value will remain unchanged during a program run.
Rather than report errors to the client application (which, as an embedded system, typically can't do much about them anyway), we'll simply fall back to the default values supplied by the application. This error-handling method will work so long as the power-up defaults supplied by the application are reasonable and safe values to use at any time. If not, we can add redundancy to avoid the errors, or a side-channel to report the errors to the application.
We can make these simplifications because not every design must be completely general, especially in small systems. Though it's always possible to imagine a system needing more features, it's often better to wait until they're actually needed and provide them then.
EEPROM drivers
The drivers for various EEPROM devices will be encapsulated in classes with
write() and read() methods, with one class for each device type.
Since we shouldn't constrain our solution to a single device type, we'll
derive all of our EEPROM drivers from a single base class called Eeprom.
The main public methods of this class will be virtual, so they can be
redefined for each new EEPROM device type. The driver for a given device
type will inherit from Eeprom and override the write() and read()
methods.
Since the write() and read() methods in the base class have no
reasonable default implementation, we'll mark them pure virtual by ending
their declarations with "= 0". Having at least one pure virtual function
makes Eeprom an abstract base class. Abstract base classes can't be
instantiated; they can only be used as a base for derivation of other
abstract or concrete classes. In this situation, making Eeprom abstract
matches reality, for without actual write() and read() methods we can`t
talk to an EEPROM device.
By making write() and read() virtual, we ensure that the layout manager
need not know about the specific EEPROM driver, but can instead work
entirely in terms of the base class Eeprom. The actual function run by a
call to, say, Eeprom::read() will be determined at run-time by indirection
through the derived class's virtual table. In this way, the layout manager
will perform a read operation while remaining ignorant of the actual device
type.
The Eeprom class begins like this:
typedef unsigned int EeAddr; // EEPROM address type class Eeprom { public: virtual ~Eeprom() { } virtual bool erase() { return false; } virtual bool write(EeAddr addr, const void* buf, size_t len) = 0; virtual bool read(EeAddr addr, void* buf, size_t len) = 0; // continued below...
Since the class has at least one virtual method, we provide a virtual
destructor. Though not strictly required, this is good practice so that if
someone tries to delete an Eeprom-derived object through a pointer to its
base class, Eeprom, the derived-class destructor (if any) will be run.
We also provide an erase() method to completely erase the device. It's
possible that not all devices will support this feature, so we provide a
default implementation that returns false to indicate the erase was not
done.
Now while the "void*, size_t" description of the memory block to read or
write is completely general, it's also somewhat error-prone or at least
tedious to use. If we happen to know the data type of the object we're
reading or writing, then we already know its size, so there's no need to
specify it explicitly. Though we could write separate methods for each
data type, that would be redundant and would still not cover all the
possible data types.
Instead, we can write templates to cover all cases. These templates can be
members of Eeprom:
template <typename T> bool write(EeAddr addr, const T& t) { return write(addr, &t, sizeof(t)); } template <typename T> bool read(EeAddr addr, T& t) { return read(addr, &t, sizeof(t)); } };
These have no run-time penalty at all since they reduce to calls to the
virtual read() and write() methods at compile time. They do, however,
simplify our use of Eeprom and eliminate any possibility of mismatch
between the object we're passing and its size.
The storage layout manager
The nonvolatile layout manager lives between the EEPROM driver, below, and the nonvolatile variables, above, organizing the storage of the variables in the EEPROM device.
The simplest possible layout manager would allocate EEPROM space sequentially as the nonvolatile variables were created, returning the device-based address of each variable for later use.
This simple approach has some drawbacks, though. First, we have no mechanism to detect errors, such as those that can occur if power fails while writing the device. We can reduce susceptibility to these errors by adding a checksum to each variable's record in EEPROM.
The second problem is more subtle. In a real system, we would have several classes, each creating its own nonvolatile variables. For example:
class SerialPort { // ... // Nonvolatile variable for baud rate, stop bits, etc. // ... }; class CanBusXcvr { // ... // Nonvolatile variable for bit rate, etc. // ... };
and so forth. Objects of these classes would be created during system
initialization, perhaps in main():
int main() { // ... MyEepromDriver eeprom( ... ); // Eeprom-derived device driver NvStore nvStore(eeprom); // storage layout manager SerialPort com0(nvStore); // serial port CanBusXcvr can0(nvStore); // CANBus port // ... };
As each client object is created, it asks the layout manager to allocate space in the EEPROM for its nonvolatile variables. Every time the system starts, this process repeats, with the same EEPROM addresses being assigned each time.
But what if we decide to reverse the order of initializations or add another
serial port between com0 and can0? This would work fine for a brand-new
system with an empty EEPROM, but if we loaded the new code into an existing
system, the old EEPROM contents wouldn't be where the new program expected
them, and chaos would ensue.
We could arrange to wipe the EEPROM on program upgrades, but that would cause loss of any previous configuration. In some applications, losing configuration would be inconvenient, in others, dangerous.
To avoid this problem, we'll give each nonvolatile variable a unique name and put that name in the EEPROM record. At system startup we'll search for records by name; thus, the actual order in EEPROM won't matter.
Using named records solves another problem: how to detect that the system is being powered-up for the first time so we can set default values. With named records, we'll take the absence of a record to indicate its variable was newly defined by the current program version. In this first-time-up case, we'll write default values to EEPROM. In any later startup, we'll instead fetch the values from EEPROM and write them to the RAM variable.
Class overview
The layout manager class looks like this:
class NvStore { Eeprom& eeprom; // EEPROM driver EeAddr base; // start address of first record in EEPROM EeAddr end; // end of available EEPROM static const size_t maxNameLen = 16; // maximum record name length public: NvStore(Eeprom& ee, EeAddr b, EeAddr e) : eeprom(ee), base(b), end(e) { } EeAddr open(const char* name, void* buf, size_t len); void update(EeAddr addr, const void* buf, size_t len); };
NvStore holds a reference to the abstract base class Eeprom, which
allows it to read and write an actual EEPROM device. Since we might share
the device with other uses, NvStore also includes the boundaries base
and end, which delimit the part of the EEPROM to manage.
The open() method will be called at system startup to find a nonvolatile
variable and get its contents or to create and initialize it if necessary.
The update() method will be called to back up the variable to EEPROM
whenever the application code changes the variable`s contents.
Record structure
Each nonvolatile variable will have its own record in EEPROM, with this structure:
size_t length; // length of data[] array in bytes char name[]; // zero-terminated record name string uint8_t hdrsum; // checksum of length and name[] array uint8_t data[]; // record payload uint8_t checksum; // checksum of data[] array
The length of the name and data members are not fixed, and will
potentially be different for each record.
Checksumming and allocation
A record consists of two checksummed fields: the header and the
payload. The NvField class manages these fields, performing
checksumming and assisting with space allocation.
typedef uint8_t NvSum; // ones-complement checksum class NvField { Eeprom& eeprom; // the backing EEPROM device EeAddr addr; // current read/write address in device NvSum sum; // checksum accumulator // continued...
Each NvField holds a reference to the Eeprom device providing its
backing store. To support space allocation in the backing store, its addr
member is advanced as the field is read or written. Finally, NvField
contains a checksum accumulator, also updated as the field is accessed.
We protect each field with an 8-bit ones-complement checksum. This type of checksum incorporates all bits of the data with equal weight, unlike a twos-complement sum which under-emphasizes the high-order bits. To form a ones-complement sum, we first perform a common twos-complement sum then add any carry-out back into the least-significant bit of the sum.
Given a block of field bytes, the note() method incorporates the bytes
into the ones-complement checksum and advances the EEPROM read/write
address:
void note(const void* buf, size_t len) { const uint8_t* p = static_cast<const uint8_t*>(buf); for (size_t i = 0; i < len; i++) { NvSum t = sum; if ((sum += *p++) < t) sum += 1; } addr += len; } // continued...
If sum appears smaller after an addition, we obviously had a carry-out.
We know, however, that a single addition can never generate a carry of more
than one, so that's the most we ever need to add.
The public interface of NvField allows either writing or reading its
subfields in sequence followed by setting or testing the field checksum:
The constructor simply captures the Eeprom reference and the starting
address of the record and initializes the checksum. Both the address and
checksum will be updated as we go along and can be accessed after writing
or reading the entire record:
public: NvField(Eeprom& e, EeAddr a) : eeprom(e), addr(a), sum(0) { }
The write() and read() methods support writing or reading the separate
parts of a field to/from the EEPROM device:
void write(const void* buf, size_t len) { eeprom.write(addr, buf, len); note(buf, len); } bool read(void* buf, size_t len) { bool retval = eeprom.read(addr, buf, len); note(buf, len); return retval; } template <typename T> void write(const T& t) { write(&t, sizeof(t)); } template <typename T> bool read(T& t) { return read(&t, sizeof(t)); }
After each EEPROM access, we update the checksum accumulator and EEPROM
address in preparation for the next access. Template forms of write() and
read() offer type-safe access as shown previously in the EEPROM driver
layer.
The writeSum() method writes the checksum accumulator to the end of the
field in EEPROM. The corresponding testSum() method reads and tests the
checksum in the EEPROM against the checksum accumulator. These methods are
called after a sequence of writes or reads, respectively.
void writeSum() { write(NvSum(~sum)); } bool testSum() { NvSum s; return read(s) && NvSum(~sum) == 0; }
By writing the complement of the checksum accumulator, we ensure that the
checksum of the entire field will be 0xFF. The compiler promotes this
complement to a full-width unsigned integer, so we convert it back to an
NvSum (an 8-bit unsigned value) before writing or testing it.
Finally, the next() method simply returns the EEPROM address, which, after
reading or writing an entire field, is the address of the next field in
EEPROM:
EeAddr next() const { return addr; } };
Initializing a nonvolatile variable
At system startup, we'll arrange to call NvStore::open() for each
nonvolatile variable. This method will search for the record; if found,
will copy its contents to the RAM image. If either we can't find the record
or we can't read the payload successfully, we'll assume this is the first
time up for this variable and we'll create the record and initialize it from
the RAM image. (Doing so assumes the image is initialized with default
values before calling open().)
The open() method is a bit involved, so we'll look at it bit by bit.
The arguments are the name of the record to look up and the address and length of the RAM image:
EeAddr NvStore::open(const char* name, void* buf, size_t len) {
First we'll declare an EEPROM address variable and initialize it to the start of our part of the EEPROM. We'll iterate over all of the records, advancing this address as we parse each record. If we reach the end of our part of the EEPROM, we'll quit unconditionally.
EeAddr addr = base; while (addr < end) {
Each record starts with a header, which we'll parse with an NvField:
NvField hdr(eeprom, addr);
First, get the length of the payload from the header. If the low-level
EEPROM read fails, abort and return zero as a flag. (This flag value will
cause update() to abort early as well; that's what we want since the
EEPROM device isn't working.)
size_t dataLen; if (!hdr.read(dataLen)) return 0;
EEPROM's generally hold all ones after erasure. If the data length is all ones, we can assume there's no record here at all. If the length is not all ones, continue parsing.
if (dataLen != ~0u) {
Read the record name from the EEPROM, character by character, and compare with the desired name. If they don't match, note that fact, but keep fetching name characters from the EEPROM until we find the zero byte that terminates the name.
bool match = true; char c; const char* n = name; for (size_t i = 0; i < maxNameLen && hdr.read(c) && c != '\0'; i++) { if (c != *n++) match = false; }
Recall that as we've been fetching from the record in EEPROM, the NvField
object has been accumulating the checksum of that data. We can now ask the
NvField object to fetch the checksum byte and test it.
if (hdr.testSum()) {
If we matched the name in the loop above, we've found the desired record,
and the address in the NvField points to it. Now we need to read the
payload to test its checksum. We don't want to read it into the RAM
variable yet because if the checksum fails, we'll need the default values
there. We use a temporary NvField to do the reading and checksumming, and
we read the bytes into a dummy variable:
if (match) { NvField pay(eeprom, hdr.next()); uint8_t t; for (size_t i = 0; i < len; i++) pay.read(t);
If the payload checksum is good, copy the payload to the RAM variable. Otherwise, copy the defaults from the RAM variable to the payload in EEPROM. Either way we're done, so return the address of the payload to the client to use later when updating the payload.
We use an anonymous NvField to read the payload into RAM. Since we only
need to run a single method, read(), we don't even need to name the
NvField. If instead we're writing the RAM to the payload, we call
update(), just as the application would.
if (pay.testSum()) NvField(eeprom, hdr.next()).read(buf, len); else update(hdr.next(), buf, len); return hdr.next(); }
If we get to this point, we've scanned the record name, but it didn't match. Advance the address past the payload of this record and continue the search.
else { addr = hdr.next() + dataLen + sizeof(NvSum); continue; } } }
If we arrive here, we either found all ones in the payload length word, or the header checksum failed. In either case, we have to assume there are no records beyond this point in the EEPROM, so break out of the search loop. Create the desired record, and then initialize its payload to the default values from the RAM variable. Test first, however, to ensure the record will fit inside our part of the EEPROM.
break; } size_t recLen = sizeof(size_t) // payload length + min(strlen(name) + 1, maxNameLen) // record name + sizeof(NvSum) // header checksum + len // payload length + sizeof(NvSum); // payload checksum if (addr >= end - recLen) return 0; NvField nhdr(eeprom, addr); nhdr.write(len); nhdr.write(name, min(strlen(name), maxNameLen - 1)); nhdr.write('\0'); nhdr.writeSum(); update(nhdr.next(), buf, len); return nhdr.next(); }
Updating a nonvolatile variable
As the application runs, it may change the value of the RAM image of a
variable. Whenever this happens, we'll arrange to call NvStore::update()
to back up the changes to EEPROM. The application will supply the EEPROM
address of the payload of the nonvolatile record (it receives this address
from open()).
After first checking for a valid address, update() simply creates an
NvField to accumulate the payload checksum, uses it to copy the RAM image
to the payload, and then writes out the new checksum.
void NvStore::update(EeAddr addr, const void* buf, size_t len) { if (addr) { NvField payload(eeprom, addr); payload.write(buf, len); payload.writeSum(); } }
Declaring and using nonvolatile variables
In the application code, our nonvolatile variables should look and work like
regular variables but they should also call NvStore::update() when they`re
written.
We can use C++ operator overloading to "hook" the reading and writing of our
variable. The client code will remain unchanged except for variable
declarations and initialization. As an example, let's make a class that
looks like an int while in use, but backs itself up to EEPROM whenever
it's written to.
class NonVolInt { int value; // RAM image of the nonvolatile variable NvStore& store; // EEPROM storage layout manager EeAddr addr; // EEPROM address of the record payload public: NonVolInt(int v, NvStore& nvs, const char* name) : value(v), store(nvs), addr(store.open(name, &value, sizeof(value))) { } operator const int&() const { return value; } const int& operator = (int v) { value = v; store.update(addr, &value, sizeof(value)); return value; } };
The constructor's v argument provides a default value for the nonvolatile
variable. The constructor records this default value, saves a reference to
the nonvolatile layout manager, and then calls NvStore::open() to either
fetch a new value from EEPROM or to create a new record and initialize it
with the default value. In either case, NvStore::open() returns the
EEPROM address of the variable for later use.
The conversion operator, operator const int&(), simply returns a
reference to the value field of the NonVolInt. It's a const reference,
meaning the caller can't modify value through the reference; it can only
read the value. (The second const is a promise that the operator won't
modify the state of the NonVolInt object. It allows the compiler to
generate better code in some situations.)
The conversion operator comes into play in code like this:
class SerialPort { // ... NonVolInt baudRate; public: int baud() { return baudRate; } // constructor, etc... };
When the compiler sees us trying to read an integer from baudRate, it
looks for a way to convert baudRate to an int. For a function return
value we need only read the variable; thus, the const reference returned
by the conversion operator is good enough. The compiler generates code to
"call" the conversion operator and read the value through it.
Since the conversion operator is defined inline, it's reduced at
compile-time to a simple fetch of value. The operator is still necessary,
however, because the const nature of its returned reference ensures that
client code can't write directly to the value field.
So how do we modify value? The assignment operator, const int&
operator = (int), is invoked whenever we try to assign to a NonVolInt:
class SerialPort { // ... public: baud(int b) { baudRate = b; } // ... };
Here, the assignment operator is called with b as its argument; thus,
baudRate = b effectively becomes baudRate.operator = (b).
In order to preserve the usual assignment semantics, the operator first
updates the value field from its argument, and at the end it returns a
const reference to the value field, so that expressions like myBaud =
baudRate = 9600 will still work. (The assignment to myBaud uses the
result of the expression baudRate = 9600 as its source, but this result is
precisely the reference returned from the assignment operator.)
In between these two operations, our assignment operator can do anything
else it wishes. In the NonVolInt case, it writes the new value out to the
EEPROM so the baud rate setting will persist through power cycles.
Generalizing to nonvolatile variables of any type
We'd like to be able to make any type of variable non-volatile, which we
could do by creating more NonVolXXX classes like NonVolInt. This sort
of copy-paste coding is tedious and error-prone, but we can easily avoid it
with a template. Not only will a template allow a single piece of code to
cover all possible data types, it will also correctly match the variable
type and its length automatically.
The Nv template takes the variable type as an argument, but otherwise
looks remarkably like the single-type class NonVolInt:
template <typename T> class Nv { T t; // RAM image of the nonvolatile variable NvStore& store; // EEPROM storage layout manager EeAddr addr; // EEPROM address of the record payload public: Nv(const T& _t, NvStore& s, const char* name) : t(_t), store(s), addr(store.open(name, &t, sizeof(t))) { } operator const T& () const { return t; } const T& operator = (const T& _t) { t = _t; store.update(addr, &t, sizeof(t)); return t; } };
Note that we've replaced each reference to int in NonVolInt with the
more general T in the Nv template. Now we can use any type for T and
make it nonvolatile. For example:
class SerialPort { // ... Nv<int> baudRate; public: SerialPort(NvStore& nvs) : baudRate(9600, nvs, "baudrate") { /* initialize hardware */ } int baud() const { return baudRate; } void baud(int b) { baudRate = b; } // ... };
The declaration "Nv<int> baudRate" creates an Nv object with int for
the type argument T. When its methods are expanded, sizeof(t) will be
sizeof(int) and so forth.
The SerialPort constructor uses an initial value of 9600, its NvStore
argument, and a record name "baudrate" to initialize its nonvolatile
baudRate member. This initialization calls the constructor of Nv<int>,
which calls NvStore::open(), which either creates a new record with
default values or reads an existing record into the int inside baudRate.
The two baud() accessor methods read and write, respectively, the int
inside baudRate. When writing, the Nv<int> assignment operator also
backs up the new value to EEPROM.
Larger nonvolatile variables
In actual practice, a class like SerialPort would need several nonvolatile
variables rather than just the one shown above. As such, it may make more
sense to store them together in a single record. For the SerialPort
class, we might have:
class SerialPort { struct Config { int baudRate; enum Parity { none, odd, even } parity; int dataBits; int stopBits; Config(int b = 9600, Parity p = none, int d = 8, int s = 1) : baudRate(b), parity(p), dataBits(d), stopBits(s) { } }; Nv<Config> config; // continued below...
Two points are worth noting about this example:
Since only
SerialPortneeds access toConfig, we've made it a nested structure. Doing so reduces namespace pollution and allows us to use less wordy, but still unique, class names.We're in C++, so we may as well provide a constructor, even for a trivial structure like
Config. A constructor allows us to initialize aConfigobject in a single expression and allows us to provide default values easily.
In SerialPort's constructor we need to initialize its config member.
Recall that the Nv template constructor needs a default value for the
nonvolatile variable; this is where Config's constructor comes in handy:
public: SerialPort(NvStore& nvs, int baud) : config(Config(baud), nvs, "serialport") { /* set up hardware */ }
The expression Config(baud) creates an anonymous, temporary Config
object, passing the baud argument to its constructor and leaving the other
arguments to take on their default values. This temporary object exists
just long enough to be copied into the Nv<Config> object, and then the
temporary object is destroyed.
Though this may seem a bit ponderous, the compiler actually just does the
obvious: it initializes the Config object inside config with the
specified values. So long as we define Config's constructor inline, the
"temporary object" will be optimized away.
If we wish to reconfigure the serial port after system startup, we'll need
some support from SerialPort. First, let's provide a method to show the
current settings in the traditional "9600,n,8,1"-type format:
void show(ostream& os) const { Config c(config); // local copy of configuration os << c.baudRate << "," << (c.parity == Config::none ? "n" : c.parity == Config::odd ? "o" : "e") << "," << c.dataBits << "," << c.stopBits; }
Note that we create c, a temporary Config object, because the Config
object inside the nonvolatile variable config is private, preventing
SerialPort::show() from accessing its members directly. The config
object does, however, allow us to extract a const reference to it, which
we use to initialize c. Since show() doesn't actually modify c, the
compiler can optimize it away, in fact reaching directly inside config for
its contents.
Next, to modify the serial port configuration, we'll need to make a local
copy of config's contents, modify that copy, and then store it back into
config. Again, we require the local copy because the Config object
inside config is private.
bool parse(istream& is) { Config c(config); // local copy of configuration char p; // parity character char comma; // dummy for commas if (is >> c.baudRate >> comma >> p >> comma >> c.dataBits >> comma >> c.stopBits) { switch (tolower(p)) { case 'n': c.parity = Config::none; break; case 'o': c.parity = Config::odd; break; case 'e': c.parity = Config::even; break; default: return false; } config = c; return true; } return false; } };
While perhaps a bit rigid, this parser will work well enough for
properly-formed strings. It first makes a temporary copy of config, and
then it attempts to parse the string into its fields. If all fields parse
cleanly, it then assigns the temporary copy back to config and returns
true to indicate success. The write to config, of course, also causes a
write to EEPROM of config's contents. If, on the other hand, one or more
fields fail to parse, it simply skips the write-back to config; thus,
config and its EEPROM backup remain unchanged.
Enhancements
The EEPROM manager in this article easily handles all power failures except those when the EEPROM is actually being written. Consider the following two cases:
If power fails during system startup, one or more records might not be fully written. On the next power-up, however, the checksums on those records should fail, causing re-creation of the records in the usual way.
If power fails during a later call to
update(), the checksum of that record's payload might fail on the next power-up. This will cause a reversion to the first-time-up default values.
In the first case, we can handle any foreseeable corruption during power-up, except if a multi-byte error in a record leaves a corrupted record whose checksum just happens to come out correct. If this is a concern, the 8-bit ones-complement sum could be replaced with a wider sum, or even with a CRC.
In the second case, if the EEPROM writes result from human interaction with the system, we might reasonably expect the operator to notice the power failure during the operation and to take corrective action. If for a particular application this assumption isn't true, we may need to add redundancy, say by duplicating each record as a protection against one of the copies being corrupted.
If do we provide another storage layout, we'll want to make NvStore an
abstract base class and derive all of the different layout managers from it.
Doing so will allow choosing the layout manager to suit the application
while preserving the Eeprom drivers and Nv template unchanged.
Conclusion
This article demonstrates several C++ techniques and how to use them in a real-world system. It uses:
- abstract base classes and virtual methods to allow run-time selection of hardware drivers
- nested classes to reduce namespace pollution
- constructors to simplify client code and ensure proper initialization of objects
- operator overloading to "hook" the reading and writing of variables
- templates to make dangerous interfaces type-safe, to reduce source-code bloat, and to eliminate copy-paste coding.
As used in this article, these techniques incur little or no run-time penalty, but they make the code shorter, clearer, more secure, and more robust under long-term maintenance.
The full source code is available here: eeprom.h, nonvol.h and nonvol.cpp. If you've found this article useful, we'd appreciate hearing from you. Please leave a comment or email the author.