Best Practices for Accessing Memory Buffers from Native Code with the XS JavaScript Engine
JavaScript applications for embedded systems frequently work with memory buffers to perform a wide variety of tasks. Scripts gain tremendous power by being able to operate directly on the native data of the classes they use. The XS JavaScript engine at the core of the Moddable SDK contains extensive support for working with memory buffers. These are just some of the places the Moddable SDK uses memory buffers to exchange data with JavaScript code:
- File data
- Network data
- Over-the-Air updates
- Compressed image data
- Uncompressed pixels
- Flash memory access
- Audio samples
- Sensor sample data
- Cryptographic primitives
- TLS
- Preferences
JavaScript represents blocks of memory using buffer objects. Working with memory buffers is fundamental in many programming languages, and these buffer objects are how JavaScript integrates this low-level capability into its APIs. Using memory buffers efficiently is often key to achieving optimal performance.
Unfortunately, it is remarkably easy to make mistakes when writing code to access a buffer and those mistakes can lead to stability bugs and security vulnerabilities. Fortunately, JavaScript is intended to be a safe language, one that prevents scripts from performing operations that would cause crashes or security breaches. As a result, its buffer objects are carefully designed to avoid creating vulnerabilities.
While JavaScript guarantees operations on memory buffers by scripts are safe, it cannot prevent unsafe operations on memory buffers passed to native functions implemented in C and C++. Because these languages are not memory safe, there is always a risk of mistakes.
Every major JavaScript engine has had security vulnerabilities related to their use of memory buffers. The problems occur when the native code that implements JavaScript functions reads from and writes to memory buffers. To make memory buffers secure, it is important to address how native code accesses memory buffers to minimize the opportunities for mistakes.
Before getting into the details of how XS now does that now, it is helpful to review the different kinds of buffers in the JavaScript language and some details of how the XS JavaScript engine implements them.
All About Buffers
JavaScript has a handful of objects for working with memory buffers. XS augments those with capabilities that are essential to embedded systems.
Memory Buffers
JavaScript has two fundamental object types to hold memory buffers.
ArrayBuffer
- This is the most commonly used type of memory buffer, the JavaScript equivalent of calloc
in C.
SharedArrayBuffer
- This is a memory buffer that is intended to be accessed by two or more different JavaScript virtual machines. Synchronized simultaneous access to the buffer is provided by the Atomics
. If a buffer only needs to be accessed from a single virtual machine, it should be an ArrayBuffer
.
The XS JavaScript engine in the Moddable SDK adds another fundamental buffer type.
HostBuffer
- A memory buffer that is managed by native code in the runtime, rather than by the JavaScript engine. There is no way for JavaScript code to create a HostBuffer
itself: native objects create HostBuffers
and provide them to scripts.
Buffer Views
The memory contained in these three fundamental buffer types cannot be accessed directly in JavaScript. Instead, they must be wrapped in a view. Views allow scripts to access the bytes of a buffer as various kinds of numbers.
TypedArray
- These are arrays of numbers. There are many different TypedArrays
- Uint8Array
for 8-bit unsigned integers, Int16Array
for 16-bit signed integers, and more.
DataView
- These are blocks of data that are accessed by APIs that read or write values at a specified offset in the buffer. A DataView
provides greater flexibility than a TypedArray
but is generally less convenient to work with.
XS Buffer Implementation Details
The XS implementation of buffers adds two more points that need to be considered:
- Read-only buffers. The JavaScript language assumes all memory buffers can be read from and written to. In the real world, this isn't always the case. For example, the Moddable SDK provides accessed to memory-mapped data stored in flash memory. This data is read-only, and attempts to write to it using normal memory access generate a hardware exception. Therefore, XS allows a buffer to be marked as read-only. Native code that accesses buffers must respect the read-only state of a buffer. (There are proposals under consideration that could bring read-only buffers to the JavaScript standard in the future.)
- Relocatable buffers. In order to make optimal use of the memory on resource constrained microcontrollers, many of which lack an MMU, XS stores
ArrayBuffers
in relocatable memory blocks. SharedArrayBuffers
and HostBuffers
are non-relocatable. This is invisible to scripts but requires native code to take care with how long it retains a pointer to a memory buffer, as the pointer may be invalidated when the garbage collector compacts memory.
These three types of memory buffers, combined with two types of views, means that native code that accesses the bytes of a memory buffer has nine different cases to support. Any of these combinations might be read-only, and some buffers are relocatable.
That's a lot of details to remember when implementing a native function that reads or writes a buffer. In fact, we got it wrong in the Moddable SDK, in one way or another, just about every time. Getting it wrong can be more than annoyance: it can create security vulnerabilities.
Developer Expectations About Buffers
Developers working in JavaScript expect to be able to pass any kind of memory buffer to an API that works with buffers. This is more-or-less how most APIs in the Web Platform work. It is also how Ecma-419, the ECMAScript Embedded Systems API Specification, defines its APIs. This behavior is convenient for JavaScript developers but makes implementing the native code correctly more complex. The next section introduces APIs recently added to the Moddable SDK to allow native code to safely provide the behavior developers expect with a minimum of effort.
Accessing Bytes in a Buffer
XS has always included APIs for native code to access buffers. These are part of the XS in C API used to bridge between native C code and JavaScript. Unfortunately, the internal data structures of XS were insufficient to implement every case safely. XS internals have been updated (notably with the new Buffer Info slot type) to address this. New XS in C APIs have been added to make it safe and easy for native functions to work with any kind of buffer.
The Easy Way
The good news is that XS now has two APIs to access the bytes of a buffer that take care of all the details regardless of the kind of buffers and views used.
The new xsmcGetBufferReadable
API obtains the address and size of a buffer that will be read from. The xsmcGetBufferWritable
has exactly the same arguments, but is used to obtain the address and size of a buffer that will written to (and may be read from as well). If a read-only buffer is passed to xsmcGetBufferWritable
it throws an exception. Here's an example that copies memory from one buffer to another.
void xsMemCopy(xsMachine *the)
{
void *src, *dst;
xsUnsignedValue srcSize, dstSize;
xsmcGetBufferReadable(xsArg(0), &src, &srcSize);
xsmcGetBufferWritable(xsArg(1), &dst, &dstSize);
if (srcSize > dstSize)
srcSize = dstSize;
memcpy(dst, src, srcSize);
}
When a view is passed to xsmcGetBufferReadable
or xsmcGetBufferWritable
, the byteOffset
and byteLength
are applied to the returned pointer and size, so the xsMemCopy
doesn't need to take those into account itself.
The implementations of xsmcGetBufferReadable
and xsmcGetBufferWritable
are guaranteed not to perform an allocation or execute a script. That's important because it means that xsMemCopy
can be certain that the pointer returned in src
will remain valid after calling xsmcGetBufferWritable
to get the dst
pointer.
Checking for Non-relocatable Buffers
Most of the time native code can be written so that it works independently of whether a buffer is relocatable. However, there are situations where native code can only work with one of these, typically a non-relocatable block. One example of this is a buffer that is written to by an interrupt handler. If the block can relocate, it might be moving when the interrupt handler runs and there would be no way to safely write the data. The return value of xsmcGetBufferReadable
and xsmcGetBufferWritable
indicates whether the block is relocatable or not. Here's an example:
void *gInterruptBuffer;
xsUnsignedValue gInterruptBufferSize;
void xsSetInterruptBuffer(xsMachine *the)
{
if (xsBufferRelocatable == xsmcGetBufferWritable(xsArg(0), & gInterruptBuffer, &gInterruptBufferSize))
xsUnknownError("non-relocatable blocks only");
}
The constant xsBufferNonrelocatable
is also available.
The Other Ways
There's seldom a reason to use functions other than xsmcGetBufferReadable
and xsmcGetBufferWritable
to access the bytes of a buffer. We have already updated most of the Moddable SDK to use these functions, and will finish that migration soon. These functions are often faster than the code they replaced because they are implemented by the XS engine itself. In many cases dozens of lines of code were replaced with a single call.
There are some situations where the buffer must be of a particular type, so it is reasonable to use a more specific function. Note that these functions do not check for read-only buffers. That needs to be done separately.
ArrayBuffer
For ArrayBuffers
use xsmcToArrayBuffer
to get the data pointer and xsmcGetArrayBufferLength
to get the size.
void *data = xsmcToArrayBuffer(xsArg(0));
void *dataSize = xsmcGetArrayBufferLength(xsArg(0));
Note that these functions only accept ArrayBuffers
, and not views that use an ArrayBuffer
for their storage.
HostBuffer
For HostBuffers
, use xsmccGetHostData
to retrieve the data pointer and xsmcGetHostBufferLength
to get the size:
void *data = xsmcGetHostData(xsArg(0));
void *dataSize = xsmcGetHostBufferLength(xsArg(0));
Note that xsmcGetHostData
does not check that the object is a HostBuffer
. It may be a HostObject
instead. To safely use the result of xsmcGetHostData
as a data buffer, be sure to also call xsmcGetHostBufferLength
which throws if the argument is not a HostBuffer
.
SharedArrayBuffer
The bytes of a SharedArrayBuffer
are accessed in the same way as a HostBuffer
.
TypedArray
and DataView
Accessing views directly is too messy to be done safely. Instead use xsmcGetBufferReadable
and xsmcGetBufferWritable
.
Creating a Buffer
Because there are so many different types of memory buffers and views, there are many different ways to create them. This section shows the most common. For the most part the APIs are straightforward to use. HostBuffers
are more complicated because they also give the most flexibility.
ArrayBuffer
Creating an ArrayBuffer
is easy. The following code allocates an ArrayBuffer
of 32 bytes.
xsmcSetArrayBuffer(xsResult, NULL, 32);
XS requires that the buffer it allocates be fully initialized to avoid making uninitialized memory accessible to scripts. In this example, the bytes are automatically initialized to 0, consistent with the behavior of the JavaScript ArrayBuffer
constructor. If the ArrayBuffer
is being created to store data that already exists, the initialization to 0 can be skipped by having xsmcSetArrayBuffer
copy the data instead. Just pass a pointer to the memory buffer to use to initialize the new ArrayBuffer
.
xsmcSetArrayBuffer(xsResult, dataToCopy, 32);
TypedArray
and DataView
Views are created from native code by first allocating the buffer, for example an ArrayBuffer
, and then passing that buffer to the TypedArray
constructor. The following example returns a 12 element Uint16Array
.
xsmcVars(1);
xsmcSetArrayBuffer(xsVar(0), NULL, 24);
xsResult = xsNew1(xsGlobals, xsID("Uint16Array"), xsVar(0));
Alternatively, the TypedArray
constructor can allocate the buffer and then xsmcGetBufferWritable
can be used to access the allocated buffer.
void *buffer;
xsUnsignedValue bufferSize;
xsmcVars(1);
xsmcSetInteger(xsVar(0), 12);
xsResult = xsNew1(xsGlobals, xsID("Uint16Array"), xsVar(0));
xsmcGetBufferWritable(xsResult, &buffer, &bufferSize);
HostBuffer
HostBuffers
are extremely flexible, so there are many different ways to use them. There are three parts of a HostBuffer
:
- Data pointer
- Data size
- Data destructor
The data destructor is a function that is called to release the resources used by the HostBuffer
when it is collected by the garbage collector. If the data pointer of a HostBuffer
is allocated using malloc
then the destructor functions calls free
. On the other hand, if the data pointer of a HostBuffer
points to a block of memory in non-volatile flash memory, there is nothing at all for the destructor function to do, and it can be an empty function or a NULL pointer. The following examples show both of these scenarios.
HostBuffer
Allocated by malloc
The following example wraps a HostBuffer
instance around a block of memory allocated by malloc
. When the HostBuffer
is collected by the garbage collector, free
is called to release the memory. The destructor function is passed as the sole argument to xsNewHostObject
.
xsUnsignedValue dataSize = 32;
void *data = malloc(dataSize);
xsmcVars(1);
xsResult = xsNewHostObject(free);
xsmcSetHostBuffer(xsThis, data, dataSize);
xsmcSetInteger(xsVar(0), dataSize);
xsDefine(xsResult, xsID_byteLength, xsVar(0), xsDontDelete | xsDontSet);
The call to xsDefine
is strictly optional, but almost always necessary as it makes the length of the buffer visible to scripts. Here xsDefine
makes byteLength
read-only so that it cannot be unintentionally changed. Note that the true length of the buffer, the value passed to xsmcSetHostBuffer
, is stored in an internal slot of the HostBuffer
, so it remains correct regardless of the value of the byteLength
property.
HostBuffer Referencing Static Data
The following example wraps a HostBuffer
around a block of static data. Since there is no need to free the memory when the buffer is collected, the destructor argument to xsNewHostObject
is NULL
.
static const uint8_t gData[] = {0, 1, 2, 3, 4, 6, 7};
xsmcVars(1);
xsResult = xsNewHostObject(NULL);
xsmcSetHostBuffer(xsThis, gData, sizeof(gData));
xsmcSetInteger(xsVar(0), sizeof(gData));
xsDefine(xsResult, xsID_byteLength, xsVar(0), xsDefault);
Conclusion
The benefits of applying the xsmcGetBufferReadable
and xsmcGetBufferWritable
APIs across the Moddable SDK have been significant:
- Less code – The number of lines of coded needed to access the bytes of a buffer has been reduced in nearly all cases.
- More consistent – All buffers and views are consistently accepted by APIs across the Moddable SDK, simplifying the developer experience.
- More secure – These changes eliminated several vulnerabilities that had allowed read/write access to arbitrary memory.
- Faster - The implementations of
xsmcGetBufferReadable
and xsmcGetBufferWritable
are part of XS and so directly access internal structures, which is faster than the code they replace.
- Future Proof - Because the new APIs are more general, they can be easily updated to support additional types of buffers and views in the future without requiring changes to the calling code.