Read-only data, such as the images that make up the user interface of your product or files containing static web pages served from an embedded web server
Small pieces of data that are both read and written—for example, user preferences and other long-term state
Large collections of data that are created as your product monitors operations—for example, while gathering data from its sensors
On computers and mobile devices, it’s common to use the file system for most, if not all, data storage needs. However, because of the constraints of embedded systems—code size limitations, highly constrained RAM, and severe performance constraints—the firmware often doesn’t even include a file system.
This chapter explains three different ways to work with stored data on embedded systems: files, preferences, and resources. The final section introduces direct access to flash memory, an advanced technique that provides the greatest flexibility.
When building your product, choose the data storage methods that best match your needs. Before assuming that files are the right choice, consider preferences and resources, which are lighter-weight ways to work with stored data.
Installing the Files and Data Host
You can run all of the examples referred to in this chapter by following the pattern described in Chapter 1: install the host on your device using mcconfig, then install example applications using mcrun.
The host is in the $EXAMPLES/ch5-files/host directory. Navigate to this directory from the command line and install it with mcconfig.
Files
The ESP32 and ESP8266 use SPIFFS (Serial Peripheral Interface Flash File System) for their file system in flash memory. SPIFFS is designed specifically for working with the NOR (NOT OR) flash memory used with many microcontrollers. While SPIFFS is nowhere near as fully featured as the file systems on a computer, it provides all the fundamental features you’ll need.
SPIFFS is a flat file system, meaning that there are no real directories. All files are together in the SPIFFS root directory.
File names are limited to 32 bytes.
There are no file permissions or locks. All files may be read, written, and deleted.
The length of time for a write is unpredictable. It’s often fast, but when the file system needs to consolidate blocks it may block for some time.
This section focuses on accessing files using SPIFFS, which is available without adding any hardware and has a relatively small code size. On the ESP32, these same APIs may also be used to access an SD memory card formatted using the FAT32 file system.
File Classes
The File class performs operations on individual files, including read, write, delete, and rename.
The Iterator class returns the contents of a directory. On a flat file system like SPIFFS, Iterator is only available for the root directory.
The System class provides information about the file system storage, including total amount of storage and free space available.
File Paths
File paths are the strings you use to identify files and directories. The file module uses the slash character (/) to separate the parts of a path, as in /spiffs/data.txt.
Although SPIFFS is a flat file system with no subdirectories, it’s accessed with a root of /spiffs/ instead of / to support embedded devices that have more than one file system—for example, a built-in flash file system and an external SD card.
Because there may be multiple file systems, this root is just a convenient default place for files, not necessarily the sole file system available.
File Operations
This section describes methods that perform operations on a file, including deleting, creating, and opening. There are no methods to read or write the full content of a file, as that would often fail due to memory limitations; later sections introduce techniques for reading and writing.
Determining Whether a File Exists
Deleting a File
The delete method returns true if successful and false otherwise. If the file doesn’t exist, delete returns true rather than throw an error, so there’s no need to surround its invocation with a try/catch block. The method does throw an error if the delete operation fails, but this happens only in rare circumstances, such as when the flash memory is worn out or the file system data structures are corrupt.
Renaming a File
The rename method is only for renaming a file. On file systems that support subdirectories, rename cannot be used to move a file from one directory to another.
Opening a File
The File constructor throws an error if there’s an error in opening a file, such as trying to open a nonexistent file in read mode.
Writing to a File
This section introduces techniques for writing data to a file. You can use the File class to write both text and binary data. A file must be opened in write mode, or write operations will throw an error. To open in write mode, pass true as the second argument to the File constructor.
The file system automatically grows the file when you write data beyond the current size. There’s no support for truncating a file. To reduce a file’s size, create another file and copy the needed data into it from the original file.
Writing Text
Strings are always written as UTF-8 data.
Writing Binary Data
Listing 5-1.
Getting File Size
The length property is read-only. It cannot be set to change the size of the file.
Writing Mixed Types
You might expect the first four bytes to be 01 02 03 04, but remember that instances of TypedArray, which includes Uint32Array, are stored in the host platform’s byte order, and the ESP32 and ESP8266 microcontrollers are both little-endian devices.
Reading from a File
This section introduces techniques for retrieving data from a file. You can use the File class to read both text and binary data. Most files are one or the other—all binary or all text data—though this is not required.
The File class supports reading a file in pieces, which enables you to control the maximum memory used when reading from the file.
Reading Text
The read method always starts reading from the current position. In this case, since the file has just been opened, the current position is 0, the start of the file.
Reading Text in Pieces
You may also use the read method to retrieve parts of a file, to minimize peak memory use. The optional second argument to read indicates the maximum number of bytes to read. This is the number of bytes that are read, with one exception: if reading the requested number of bytes would pass the end of the file, the text from the current position to the end of the file is read.
Listing 5-2.
On a computer, you might memory-map the file to simplify access to the data; however, that approach is not generally available on microcontrollers, as they typically lack an MMU (memory management unit) to perform the mapping. If you want to memory-map read-only data, resources are a good alternative, as explained later in this chapter.
Reading Binary Data
Reading Binary Data in Pieces
Listing 5-3.
Directories
The SPIFFS file system implements only a single directory, the root directory. Other file systems, such as FAT32, support an arbitrary number of subdirectories. In all cases, you use the Iterator class of the file module to list the files and subdirectories contained in a directory.
Iterating over Directories
Listing 5-4.
The next method returns an object with properties that describe the item. The name property is always present. The length property is present only for files and indicates the number of bytes in the file. There’s no separate property to indicate whether the item is a file or directory, as the presence of the length property is sufficient for this purpose.
The iterator instance has a close method, which may be called to free the system resources used by the iterator. This is not usually necessary, however, because the iterator implementation automatically frees any system resources when it reaches the end of the items.
The Iterator class returns one item at a time, rather than a list of all items, to keep memory use to a minimum. The order in which items are returned depends on the underlying file system implementation. In the general case, you cannot assume, for example, that the items are returned in alphabetical order or that directories are returned before files.
Iterating with JavaScript Iterators
Listing 5-5.
Getting File System Information
Preferences
Preferences are another tool for storing data on a microcontroller in your IoT product. They’re much more efficient than files but also much more limited. A file is well suited to storing a lot of information, whereas a preference stores only small pieces of information. Often in your product, you only need to keep track of a handful of user settings, and preferences are all you need for those situations; you may even exclude the file system entirely from your product.
Another advantage of using preferences is their reliability. The implementations of preferences for ESP32 and ESP8266 take steps to ensure that the preference data is not corrupted even if power is lost while the preferences are being updated. That level of reliability is more difficult to achieve in a file system, because the data structures are more complex.
The Preference Class
The JavaScript Preferences API introduced in this chapter is the same between microcontrollers; however, the underlying implementation is different. For example, on the ESP32, preferences are implemented using the NVS library in the ESP32 IDF SDK, whereas on the ESP8266, preferences are implemented by the Moddable SDK because there’s no system-provided equivalent. Since the implementations are different, there also are differences in behavior. The following sections note the differences you need to keep in mind.
Preference Names
Each preference is identified by two values, a domain and a name. These are similar to a simple file system path: the domain is like the directory name, and the name is like the file name. For example, consider a Wi-Fi light, where you want to save the user settings to restore when the power is turned on. You could use a light domain for all the light state preferences, with on, brightness, and color for names. The light might keep statistics data (such as the number of times the light has been turned on) in another domain, such as stats.
The domain and name values of a preference are always strings. Names are limited to 15 bytes on the ESP32 and 31 bytes on the ESP8266.
Preference Data
Preferences are not intended to replace a file system; it’s a common mistake to try to use them that way. Because the size of each individual preference is limited, as is the total storage available for all preferences, they’re far less general than a file system.
Each preference has a data type: a boolean, a 32-bit signed integer, a string, or an ArrayBuffer. Floating-point numeric values are not supported. The string type is often the most convenient to use but is also often the least efficient use of storage space. It you need to combine several values in a single preference, consider using an ArrayBuffer.
When you write a value, the type of the value is established based on the data provided. To change the type, write the value again. When you read a value, the value returned has the same type as the value that was written.
On the ESP32, the preference data space is configurable and is set to 16 KB in the hosts used in this book. On the ESP8266, the space for preference data is 4 KB.
On the ESP32, each preference may be up to 4,000 bytes of data; on the ESP8266, this value is limited to 64 bytes. If you’re writing code that you expect to run on several different microcontroller platforms, you need to design your preference values for the 64-byte data size.
Reading and Writing Preferences
Listing 5-6.
Listing 5-7.
Deleting Preferences
No error is thrown if a preference cannot be found with the specified domain and name. If there’s an error while updating flash memory to remove the preference, delete throws an error.
Don’t Use JSON
Storing preferences in a JSON file requires that your project include a file system—a large body of code that takes up some of the limited space in your flash memory.
A JSON object must be loaded into memory all at once, which means that accessing one preference value requires enough memory to hold all preference values.
Loading the JSON string data from a file and then parsing it to JavaScript objects takes considerably more time than just loading one value from a preference.
File systems are generally less error-resilient to power failures than preferences. Consequently, there’s a higher chance that user settings will be lost.
Security
The preference module provides no guarantees about the security of preference data. The domain, name, and value may be stored “in the clear” without any encryption or obfuscation. As with user data in files, you should take appropriate steps in your product to ensure that user data is adequately protected. Examples of sensitive user data that are commonly stored in IoT products are Wi-Fi passwords and cloud service account identifiers. At a minimum, you should consider applying some form of encryption to these values so that they cannot be read by an attacker scanning the flash memory of the device.
Some hosts do provide encrypted storage for preference data. With additional configuration, this is available on the ESP32, for example.
Resources
Resources are a tool for working with read-only data. They’re the most efficient way to embed large pieces of data in your project. Resources are usually accessed in place in flash memory, where they’re stored, and therefore use no RAM no matter how large the resource data is. The Moddable SDK uses resources for many different purposes, including TLS certificates, images, and audio, but there’s no restriction on the kind of data you can store in a resource.
The $EXAMPLES/ch5-files/resources example hosts a simple web page defined by mydata.dat, which is included as a resource. After you run the example, open a web browser and enter the IP address of your device, and you’ll see a web page that says “Hello, world”.
Adding Resources to a Project
- 1.
You add a file containing the resource data to your project. Often the resource files are placed in a subdirectory such as assets, data, or resources, but you can store them anywhere you like.
- 2.
You add the file to the resources section of your manifest to tell the build tools to copy the file’s data to a resource.
Listing 5-8.
The data file must have a .dat extension. However, the file name in the manifest must not include the extension; the build tools automatically locate your file with the .dat extension. It’s important that you do not include several files with the same name but different extensions (for example, mydata.dat and mydata.bin), as the tools may not find the one you expect first.
This chapter describes resource data that’s copied directly from your input file to the output binary without any changes. The build tools also have the ability to apply transformations to the data, such as converting images to a format optimized for your target microcontroller; Chapter 8 explains how to use resource transformations.
Accessing Resources
Using Resources
The Resource constructor returns the binary data as a HostBuffer. A HostBuffer is similar to an ArrayBuffer but, unlike an ArrayBuffer, the data of a HostBuffer may be read-only and consequently may be located in flash memory.
The advantage of using slice is that it ensures that the full resource is unavailable to untrusted code with access to the values array. In the first of the preceding two examples, values.buffer has access to the entire resource, whereas in the second example it may be used only to access the five values in the Uint16Array.
Accessing Flash Memory Directly
All of the modules described in this chapter for storing and retrieving data—files, preferences, and resources—use the flash memory attached to the controller for data storage. Each approach for working with data in flash memory has its own benefits and limitations. In most cases, one of these approaches is a good fit for your product’s needs; in some situations, a more specialized approach may be more efficient. The flash module gives you direct access to flash storage. Using it well requires more work, but it’s worth the effort in some cases.
This is an advanced topic. Accessing flash memory directly is dangerous. You may crash your device or corrupt your data. You may even damage the flash memory, leaving your device unusable. Proceed with caution!
Flash Hardware Fundamentals
To be able to use the API provided by the flash module, it’s important to understand the fundamentals of the flash hardware.
The flash memory used with the ESP32 and ESP8266 microcontrollers is connected using an SPI (Serial Peripheral Interface) bus. Although reasonably fast to access, it’s still many times slower than accessing data in RAM.
Flash memory is organized into blocks (also called “sectors”). The size of a block varies depending on the flash memory component used. A common value is 4,096 bytes. When you’re reading and writing flash memory, you don’t usually need to be aware of the block size. However, the block size is important when you’re initializing flash memory.
The flash memory uses NOR technology to store data. This has the curious implication that an erased byte of flash memory has all bits set to 1, whereas it’s common to think of erased storage as being set to 0. You might think that you could simply set the freshly erased bytes to all zeros but, as you’ll see, that’s not a good idea with NOR flash memory.
Flash 0. Write 0 => Flash 0.
Flash 0. Write 1 => Flash 0.
Flash 1. Write 0 => Flash 0.
Flash 1. Write 1 => Flash 1.
If a write can only change bits from 1 to 0, how are bits changed from 0 to 1? You use the flash erase method to do that. Unlike read and write, which may access any byte in the flash memory directly, erase is a bulk operation that sets all the bits in a block of flash memory to 1. You erase blocks aligned to the block size boundary, which means bytes 0 to 4,095 or bytes 4,096 to 8,191—not 1 to 4,096 because that’s not aligned to the start of a block, and not bytes 1 to 2 because that’s not a full block.
If you want to change one bit, you can read the entire block into RAM, erase the block, change the bit in RAM, and then write the block back. That works, but it’s slow, because erase is a relatively slow operation—many times slower than read and write. This approach also requires enough RAM to hold a full block, and there’s not always that much memory on a resource-constrained microcontroller. The biggest problem, however, is that flash memory wears out. Each block may be erased only a certain number of times, after which that block no longer stores data reliably; to preserve the device, you need to minimize the number of times you erase each block.
The good news is that the flash memory in your ESP32 or ESP8266 supports thousands, if not tens of thousands, of erase operations. The preference and file module implementations are aware of the limits and characteristics of NOR flash memory and take steps to minimize erases. If you’re accessing flash memory directly in a product that’s intended to be used for years, you need to do the same.
One commonly used strategy is incremental writes. In this approach, the current values are zeroed out and the new values written after the zeros in the block. This enables a single value to be updated many times without an erase. This approach is used by the preference module. The Frequently Updated Integer example later in this section explores the details of incremental writes.
Another common strategy is wear leveling. This approach attempts to erase each flash storage block the same number of times over the lifetime of the product, to ensure that no block (for example, the first block) wears out much sooner than the others due to more frequent access. The SPIFFS file system underlying the file module uses this technique.
Accessing Flash Partitions
Flash memory is divided into segments called partitions. For example, one partition contains your project’s code, another the preference data, and another the storage for the SPIFFS file system. Each partition is identified by a name.
The partitions available to your code vary depending on the microcontroller and the host implementation. The xs partition that contains applications installed with mcrun is always available. The area used for the SPIFFS file system, named storage, is also generally always available; if you’re not using the SPIFFS file system in your project, you can use it for other purposes. Although these partitions are both present, their sizes vary by device.
On the ESP32, the ESP32 IDF from Espressif defines the partitions. The IDF provides a flexible partition mechanism that makes it possible for you to define your own partitions. On the ESP8266, the Moddable SDK defines the partitions, and they cannot easily be reconfigured.
Getting Partition Information
An instance of the Flash class has two read-only properties that provide important information about the partition: blockSize and byteLength .
The blockSize property is important because it tells you both the alignment and the size of erase operations on the partition.
The value of the byteLength property is always an integer multiple of the value of blockSize property, so the number of blocks is always an integer.
Reading from a Flash Partition
This code retrieves the first ten bytes from the partition. It wraps the returned ArrayBuffer in a Uint8Array to trace the byte values to the console.
There are no restrictions on the offset and number of bytes to read, beyond the requirement that they’re within the partition. Specifically, a single call to read may cross a block boundary.
The read call copies the requested data from the partition into a new ArrayBuffer. Consequently, you should read flash memory in small fragments to use as little RAM as practical.
Erasing a Flash Partition
Writing to a Flash Partition
Keep in mind that writing sets only the 0 bits, as explained earlier in the “Flash Hardware Fundamentals” section. Therefore, it may be necessary to perform an erase before calling write.
Mapping a Flash Partition
The map property returns a HostBuffer that may be passed to a typed array or data view constructor to access the data. Memory-mapped partitions are a more convenient way to access data than the read call in some situations. Furthermore, because the data in the partition is not copied to RAM by the map method, RAM use is minimized.
The map method is unavailable on ES8266 due to hardware limitations that allow only memory mapping of the first megabyte of flash memory, the area reserved to store the firmware.
Example: Frequently Updated Integer
This section presents an example of directly accessing flash memory to maintain a 32-bit value more efficiently than is possible using a file or a preference. The example is for the situation where your product needs to update a value frequently in flash storage to ensure that it’s maintained reliably across reboots of the product.
The example uses a single block of flash memory. That’s typically 4,096 bytes, which is 1,024 times bigger than the 32-bit (four-byte) value being stored. The example takes advantage of the additional storage to reduce the number of erase operations, thereby prolonging the lifetime of the flash memory. For convenience, the block used is the first block of the storage partition, which prevents this example from being used with the SPIFFS file system.
The complete Frequently Updated Integer example is available at $EXAMPLES/ch5-files/flash-frequentupdate.
Initializing the Block
Listing 5-9.
Updating the Value
Listing 5-10.
Reading the Value
Listing 5-11.
Benefits and Future Work
This example efficiently stores an integer value in flash memory. The value may be updated 1,023 times before the block needs to be erased. To understand the impact of this, consider a product that updates that value once a minute. That works out to 514 erase operations per year (60 * 24 * 365, which is 525,600 minutes per year, divided by 1,023 updates per erase rounds up to 514). Using a flash chip with support for 10,000 erase operations (a conservative estimate), the product has about a 19.5-year lifetime. If each write operation required an erase, the same product would wear out in only 7 days (60 * 24 * 7 is 10,080 writes per week).
The careful reader has noticed two limitations of this example: if power is lost in the write function after the erase and before the write, the current value will be lost; and the value cannot be set to 0xffffffff because that value is used to identify unused entries in the block. Solutions to these shortcomings are possible and are left as exercises for the reader.
Conclusion
In this chapter, you learned several different ways to store information in an embedded product. Files, preferences, and resources are the three primary ways to store data, and each is optimized for a different use of storage. You can use any combination of these approaches in your product. When designing your product, consider your storage needs to determine which approaches to use to make optimal use of available storage. Some situations are so specialized that none of these standard storage techniques are optimal; to address those cases, this chapter showed how flash memory works so that you can create your own storage methods.