Sunday, May 21, 2023

Wrench Embedded Interpreter


1.0 Introduction

"The difficulty is that things almost always start with some guy doing something that at the time looks totally useless" - James Burke

wrench solves the problem of needing an easy-to-understand scripting language that can fit into a very small space but retain full power, flexibility, and speed.

  • Embedable:Fits into less than 30K of Flash, and runs with a ~1k memory footprint
  • Comprehensible:c-like syntax, weakly typed, garbage collected.

  • Fast:Two to three times faster than other interpreters

  • Compact:Bytecode images a small fraction of other interpreters

  • Easy To Integrate:
    • two source files: wrench.cpp and wrench.h
    • architecture neutral, compile anywhere run anywhere else
    • c++98 clean and compliant, nothing fancy.
    • no third-party libs, its all included.
  • Bare-Metal:Wrench can address native c/c++ data and arrays directly from script.

Short Version: I didn't need a whole workshop with all the bells and whistles. I just needed a wrench. So I built one.


2.0 Integration

Wrench [the project] is kept current on GitHub, download it here.

Now the easy part, wrench packages itself into two source files: I have to credit Wren for this idea (at least that's where I saw it), it's brilliant and makes integration a breeze.

add:

/src/wrench.h
/src/wrench.cpp
to your project and compile. That's it, here is a complete

example

#include "wrench.h"
#include &ltstring.h&gt
#include &ltstdio.h&gt

void print( WRState* w, const WRValue* argv, const int argn, WRValue& retVal, void* usr )
{
        char buf[1024];
        for( int i=0; i&ltargn; ++i )
        {
                printf( "%s", argv[i].asString(buf, 1024) );
        }
}

const char* wrenchCode = 
"print( \"Hello World!\\n\" );"
"for( i=0; i&lt10; i++ )        "
"{                            "
"    print( i );              "
"}                            "
"print(\"\\n\");              ";


int main( int argn, char** argv )
{
        WRState* w = wr_newState(); // create the state

        wr_registerFunction( w, "print", print ); // bind a function

        unsigned char* outBytes; // compiled code is alloc'ed
        int outLen;

        int err = wr_compile( wrenchCode, strlen(wrenchCode), &outBytes, &outLen ); // compile it
        if ( err == 0 )
        {
                wr_run( w, outBytes ); // load and run the code!
                delete[] outBytes; // clean up 
        }

        wr_destroyState( w );

        return 0;
}

command line

A command-line utility is included, to compile it under linux just make in the root dir. For windows a visual studio project is included under /win32

For PC-ish stuff this is all you have to do, the full-blown wrench compiler and interpreter are fine as they are, but for an embedded target there are a few slight changes you might want to make:

In src/wrench.h you might want to tweak

build flags

#define WRENCH_WITHOUT_COMPILER

Build wrench without it's compiler. this will minimize the code size at the cost of being able to only load/run bytecode. For embedded projects the command line tool actually compiles to c header .h code for super-easy addition to the source.

#define WRENCH_COMPACT

This causes wrench to compile into the absolutely smallest program size possible at the cost of some interpreter speed (due to the removal of unrolled loops, cache optimizations, and additional 'shared' code with some goto spaghetti)

#define WRENCH_DEFAULT_STACK_SIZE 90

wrench allocates a static stack and does not bounds-check it, this is done for speed an simplicity. The stack is used only for function calls and local data so it need not be large, the default 90 should be more than enough.

Each stack entry consumes 8 bytes, so embedded devices that have very limited ram (like the Uno Mini) should reduce this substantially. The arduino example provided has a stack of 32, which should be plenty to run even a pretty intricate script.

To add functions to extend wrench, as well as calling into it are dead simple and super low overhead. Some examples are provided, but frankly if you actually got this far and are interested, the code in wrench.h is very clear and well commented, and there are quite a few examples.

[TODO]-- actually document this instead of phoning it in :P


3.0 Language Reference

Wrench is designed to be intuitive and c-like, the syntax should be very familiar.

variables

no special declaration is required to introduce a variable:

a = 10;
b = 3.4;
string = "some string";
wrench natively handles 32 bit ints, floats and 8 bit character strings. Variable names follow c-syntax for legality, the must start with a letter or '_' and can contain letters, numbers and more '_' characters.

operators

all of these are supported, with their c-defined precedence:

//binary:
a + b;
a - b;
a / b;
a * b;
a | b; // or
a & b; // and
a ^ b; // xor
a % b; // mod
a &gt&gt b; // right-shift
a &lt&lt b; // left-shift

a += b;
a -= b;
a /= b;
a *= b;
a |= b;
a &= b;
a ^= b;
a %= b;
a &gt&gt= b;
a &lt&lt= b;

// pre and post:
a++;
a--
++a;
--a;

// as well as the c logical operators:
a == b
a != b
a &gt= b
a &lt= b
a || b
a && b

comments

a = 10; // single-line c++ comments are supported
/*
     as well as block-comment style
*/

arrays

arrays can be declared with [] syntax, and can contain any legal type

arrayOne[] = { 0, 1, 2 };
arrayTwo[] = { "zero", 1, 3.55 };

print( arrayOne[1] ); // will print "1"

for

follows the standard c syntax:

for( a=0; a<10; ++a )
{

}

foreach

wrench also supports "foreach" in two flavors, value only and key/value:

somArray[] = {"zero", "one", "two" };
for( v : somArray )
{
   // this loop will run 3 times, with v taking on "zero", "one" and "two"
}

for( k,v : somArray )
{
   // same as above but k will take on the value 0, 1 and 2
}

while

while( condition )
{
}

switch

switch works the same as c, there is an optimized code path for a list of cases (including default) that are between 0 and 254. wrench also supports fall-through.

switch( expression )
{
    case 0:
        break;
    defalt:
        break;
}

do/while

do
{
} while( condition );

break/continue

inside any looping structure (do/while/for) continue and break function as they do in c

if/else

work exactly the same as c:

if( a == true )
{
}
else if ( b == true ) // or whatever
{
}
else
{
}

function

Functions can be called with any number of arguments, extra arguments are ignored, un-specified arguments are set to zero (0)

function f( arg )
{
   if ( arg > 10 )
   {
          return true;
   }
   else
   {
          return false;
   }
}

first = f(20); // first will be 'true' or '1'
second = f(); // second will be 'false' because 'arg' was not
              // specified, so set to 0

If a variable is declared in a function, it will be local unless a global version is encountered first. Global scope can be forced with the '::' operator:

g = 20;

function foo()
{
   g = 30;
   ::n = 40;
}

foo(); // after foo is called the global 'g' will be set to 30,
       // and global variable 'n' will be set to 40

struct

In wrench structs are actually functions that preserve their stack frames.

Another way to put it is structs are "called" so they are their own constructors, and all the variables they declare are preserved:

struct S
{
   member1;
   member2;
};

s = new S(); // s will be a struct with two uninitialized members (member1 and member2) 

// members are dereferenced with '.' notation:
s.member1 = 20;
s.member2 = 50 + s.member1;
// s.member2 is now 70
A more complete example:
struct S(arg1)
{
   member1 = arg1;
   if ( arg1 > 20 )
   {
          member2 = 0;
   }
   else
   {
          member2 = 555;
   }
}

instance = new S(40); // s.member1 will be 40, s.member2 will be 0

Structs can also be initialized when created:

struct S
{
   a;
   b;
}

bill = new S()
{
   a = 20,
   b = "some string",
};

constants some constants that are compiler-defined:

true == 1
false == 0
null == 0

enums

enums are syntactic sugar, when invoked they introduce variables into the namespace with automatic initliazation

enum
{
        n1,
        n2,
        n3
}

// n1 is 0, n2 is 1 and n3 is 2

hash tables

wrench uses hash tables internally so this language feature kind of comes along "for free". Any valid value can be used as a key or value

hashTable = { 1:"one", 2:"two", 3:3, "str":6 };
print( hashTable[1] ); // "one"
print( hashTable[2] ); // "two"
print( hashTable[3] ); // 3
print( hashTable["str"] ); // 6

// and this also works (syntactic sugar for string-keys only)
print( hashTable.str ); // 6

compiler-intrinsics Some compiler directives are included for working with arrays and hash tables:

hashTable = { 1:"one", 2:"two", 3:3, "str":6 };

//  ._count
print( hashTable._count ); // prints 4

// ._exists
hashTable._exists( 2 ); // returns 'true'
hashTable._exists( 20 ); // returns 'false

// ._remove
hashTable._exists( 2 ); // returns 'true'
hashTable._remove( 2 );
hashTable._exists( 2 ); // now false

3.5 Extending Wrench

There are three ways wrench interacts with "native" c/c++ code:

Callbacks

A callback appears inside wrench as an ordinary function, they take arguments and return a value:
retval = myFunction( 25 );
in order to receive the "myFunction(...)" callback the c program needs to register the callback with

void wr_registerFunction( WRState* w, const char* name, WR_C_CALLBACK function, void* usr )

w state to be installed in
name name the function will appear as inside wrench
function pointer to callback function (see below)
usr opaque pointer that will be passed when the function is called (may be null)
void myFunction( WRState* w, const WRValue* argv, const int argn, WRValue& retVal, void* usr )
{
    // do something
}

void main()
{
    WRState* w = wr_newState( 128 );
    wr_registerFunction( w, "myFunction", myFunction, 0 );

    // and then run wrench
}
Every time myFunction() is called from wrench the external c function will be called.

void myFunction( WRState* w, const WRValue* argv, const int argn, WRValue& retVal, void* usr )

WRState* w Pointer to the state that made the call
const WRValue* argv a list of zero or more WRValue that are the arguments the function was called with
const int argn the number of arguments passed in argv
WRValue& retVal this value is returned to the caller (default integer zero)
void* usr value passed when the function was registered
The arguments passed are directly from the wrench stack for speed, because of this their values should never be accessed directly, but with the built-in accessors:

asInt();
asFloat();
asString(...);
c_str();
array(...);

For safety the return value should use the value constructors

void wr_makeInt( WRValue* val, int i );
void wr_makeFloat( WRValue* val, float f );
void wr_makeCharArray( WRValue* val, const unsigned char* data, const int len );

Also since functions in wrench can be called with any number of arguments (including zero) that should be checked for safety, as in:

void openDoor( WRState* w, const WRValue* argv, const int argn, WRValue& retVal, void* usr )
{
    if ( argn != 2 )
    {
        // log an error or something
        return;
    }

    const char* name = argv[0].c_str();
    if ( !name )
    {
        // was not passed a string!
        return;
    }
        
    int door = argv[1].asInt(); 

    OpenDoor( name, door ); // some function to do the work

    wr_makeInt( &retVal, 1 ); // return a '1' indicating success
}

Library Callbacks

The good news is these are basicall the same as regular callbacks. The function signature is a bit different, though, to facilitate very fast calls, minimizing the work wrench has to do.

The assumption here is that if you're writing library calls then you are likely familiar and comfortable with some of the wrench internals and don't mind looking at examples and source code

There are many examples of library calls in std_math.c, std_string.c, and std_io.c.
library functions are registered with a different function, and their names must conform to the "x::y" format for them to be recognized by wrench code:

void wr_registerLibraryFunction( WRState* w, const char* signature, WR_LIB_CALLBACK function );

WRState* w Pointer to the state that made the call
const char* signature "x::y" formatted lib call name
WR_LIB_CALLBACK function c-function to callback

The WR_LIB_CALLBACK looks liks this:

void libFunc( WRValue* stackTop, const int argn, WRContext* c )

Not much to go on, I know, but the idea is to get in and out of a library call as fast as possible, so yeah, you gotta know how to use it.

If any arguments were passed, they are below the stack pointer, examples:

argn = 1:
stackTop[-1].asInt(); // or .asFloat() or whatever

argn = 2:
stackTop[-2].asInt(); // first argument
stackTop[-1].asInt(); // second argument

It might be easier to think about it this way:

WRValue* args = stackTop - argn;

args[0].asInt(); // first
args[1].asInt(); // second
args[2].asInt(); // third
args[3].asInt(); // fourth

The return value is quite a bit easier to explain, it's stackTop itself, so for example returning 5.4:

wr_makeFloat( stackTop, 5.4f );

The WRContext value is provided to save a dereference on the wrench side for a rarely used (but necessary!) parameter; the WRState* value contained inside it if necessary as

c->w


Calls

once wrench code is run for the first time with wr_run() the state is preserved and can be re-entered.
given this simple script:

a = 20;
function wrenchFunction()
{
    ::a += 30;
}

A program that woiuld call "wrenchFunction()" might look like this:

WRState* w = wr_newState();
WRContext* context = wr_run( w, someByteCode ); // 'a' will be 20

wr_callFunction( w, context, "wrenchFunction" ); // 'a' will now be 50

An array of arguments can be passed to the function:

a = 20;
function wrenchFunction( b, c )
{
    ::a += b * c;
}
WRState* w = wr_newState();
WRContext* context = wr_run( w, someByteCode ); // 'a' will be 20

WRValue values[2];
wr_makeInt( &value[0], 2 );
wr_makeInt( &value[1], 3 );
wr_callFunction( w, context, "wrenchFunction", values, 2 ); // 'a' will now be 26

the return value from any call is available with WRValue* wr_returnValueFromLastCall( WRState* w )

function wrenchFunction( b, c )
{
    return b * c;
}
WRState* w = wr_newState();
WRContext* context = wr_run( w, someByteCode ); // must run to establish context

WRValue values[2];
wr_makeInt( &value[0], 4 );
wr_makeInt( &value[1], 5 );
wr_callFunction( w, context, "wrenchFunction", values, 2 );

WRValue* ret = wr_returnValueFromLastCall( w ); // ret->asInt() will contain 20

4.0 Library

library functions are provided as well. These functions are only available if loaded.
math::sin( f );
math::cos( f );
math::tan( f );
math::sinh( f );
math::cosh( f );
math::tanh( f );
math::asin( f );
math::acos( f );
math::atan( f );
math::atan2( x, y );
math::log( f );
math::ln( f );
math::log10( f );
math::exp( f );
math::pow( a, b );
math::fmod( a, b );
math::trunc( f );
math::sqrt( f );
math::ceil( f );
math::floor( f );
math::abs( f );
math::ldexp( a, b );
math::deg2rad( f );
math::rad2deg( f );

str::strlen( str );
str::sprintf( str, fmt, ... );
str::printf( fmt, ... );
str::format( fmt, ... );
str::isspace( char );
str::isdigit( char );
str::isalpha( char );
str::mid( str, start, len ); // return the middle of a string starting
                             // at 'start' for 'len' chars
str::strchr( str, char ); // returns -1 if not found
str::tolower( str );
str::toupper( str );

file::read( name ); // returns an array representing the data
file::write( name, data[] );

io::getline(); // return a line of text input with fgetc(stdin)

5.0 Benchmarks

Memory Benchmarks

The whole point of this interpreter is to run on small embedded systems. Here is a list of compiled image sizes on various processors.

NOTE 1: optimizations are always in progress to shrink the compiled image, I do not keep these numbers up to date but I can assure you they are cielings, the actual value of the latest version will be the same or less.

NOTE 2: compiled with WRENCH_COMPACT and WRENCH_WITHOUT_COMPILER<.code>

Architecture
Compiler
Footprint
Arduino UNO, UNO Mini
Arduino 1.8.19
27696 bytes
Arduino Mega
Arduino 1.8.19
29366 bytes
Arduino Feather/Trinket M0
Arduino 1.8.19
30700 bytes
PC
clang, gcc, msvc
who cares :)

CPU Benchmarks run on Xeon E5-2640

particles: testing foreach() speed as well as struct member access speed

primes: testing function calls/recursion computation

Language
Source
Bytecode Size/% wrench
Time/ % wrench
wrench
primes.w
113
10s
wrench [compact]
primes.w
113
14s 40% slower
lua
primes.lua
422 237% larger
18.2s 82% slower
squirrel
primes.nut
1782 1476% larger
27s 170% slower

recursive fibonacci geerator: testing function call overhead and recursion

Language
Source
Bytecode Size/% wrench
Time/ % wrench
wrench
fibo.w
74
22s
wrench [compact]
fibo.w
74
25s
lua
fibo.lua
288 289% larger
51.5s 131% slower
squirrel
fibo.nut
1001 1252% larger
84s 281% slower

exponent call: testing library call overhead

Language
Source
Bytecode Size/% wrench
Time/ % wrench
wrench
exp.w
87
19s
wrench [compact]
exp.w
87
24.8s 30.5% slower
lua
exp.lua
288 231% larger
40.8s 131% slower
squirrel
exp.nut
1144 1214% larger
71s 273% slower

6.0 FAQ

... why? Aren't there enough interpreters out there? Surely one of them would have worked? Probably, but I couldn't find one! I tried squirrel, wren, tiny-c, pawn, even lua and a few others I can't think of. Most of them would compile and run for my embedded system (SAMD21 CortexM0) but they all blew chow when I actually tried to run scripts.

The problem? RAM.

They all needed a pile of it, hundreds of k in some cases. My chip has 32k total and I needed most of it for shift-buffer space!

wrench was motivated by a need for lightning-fast user-programmable scripts in a tight space

So use FORTH? Wrench is also motivated by the need for scripts that are approachable by novice-to-intermediate programmers. Asking them to become familiar with FORTH (or any of the many other expressive minimalistic langauges I've encountered) would sink the project.

You say wrench is fast, LuaJITs is faster? Yes, I know. If a JIT language solves your problem then of course use one! I am a big fan of c# personally. wrench is for when that's not an option.







from Hacker News https://ift.tt/8EprGVZ

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.