Tutorials · v0.5.0

From the stack to hot-swapping a live behaviour.

Ten tutorials in two halves. The first six run verbatim on the firmware you just flashed. The last four extend the engine with your own words — which is what the project is actually for.

Every transcript below was executed against the v0.5.0 engine, not written from memory. Output is copied from those runs. Where a tutorial needs hardware the site cannot exercise, that is said plainly.

Part one — runs on the firmware you flashed

Nothing here needs a rebuild. Open the monitor and type.

01

The stack, and why there are no parenthesesruns today

words: + − * / mod · dup drop swap · .s . · abs min max

Forth has no expression syntax. Values go onto a stack; words take their arguments off it and push results back. 2 3 + pushes two numbers, then + removes both and pushes 5. . prints and discards the top; .s shows the whole stack without disturbing it, depth first in angle brackets.

ok> 2 3 + .
5
ok> 10 4 - .
6
ok> 20 4 / .
5
ok> 17 5 mod .
2
ok> 1 2 3 .s
<3> 1 2 3
ok> drop .s
<2> 1 2
ok> swap .s
<2> 2 1
ok> dup .s
<3> 2 1 1
ok> -7 abs .
7
ok> 3 7 max .
7

Read 20 4 / as "push 20, push 4, divide" — the order you would do it by hand, which is why no brackets are needed. The stack survives between lines, so .s is how you check you have not left rubbish behind.

Try it: compute (7 + 3) × (10 − 4) with no parentheses. One answer is 7 3 + 10 4 - *.

02

Defining wordsruns today

words: : ; · ." · cr · words

: starts a definition and ; ends it. The name you define is indistinguishable from a built-in afterwards — this is the whole of Forth's extensibility mechanism.

ok> : square dup * ;
ok> 5 square .
25
ok> 12 square .
144
ok> : cube dup square * ;
ok> 3 cube .
27
ok> : f>c 32 - 10 * 18 / ;
ok> 212 f>c .
100
ok> 98 f>c .
36
ok> : greet ." Hello from Forth" cr ;
ok> greet
Hello from Forth

cube is built from square, which you defined thirty seconds earlier. That layering — many tiny words, each one line — is the Forth style; Thinking Forth is three hundred pages on why.

f>c does (f − 32) × 10 / 18 rather than × 5 / 9 to keep precision in integers. There are no floats; see the quirks below.

Note the space after ." — it is a word, not punctuation, so it needs a delimiter. ."Hello" will not work.

03

Conditionals and loopsruns today

words: if else then · do loop +loop · i j · begin until

Control flow words only work inside a definition. if consumes a flag from the stack; anything non-zero is true, and comparison words push -1 for true and 0 for false.

do takes its bounds as limit index and stops before the limit. i is the current index, j the index of the enclosing loop.

ok> : sign 0 > if ." positive" else ." not positive" then cr ;
ok> 5 sign
positive
ok> -3 sign
not positive
ok> : countup 10 0 do i . loop cr ;
ok> countup
0 1 2 3 4 5 6 7 8 9
ok> : evens 20 0 do i . 2 +loop cr ;
ok> evens
0 2 4 6 8 10 12 14 16 18
ok> : sum-to 0 swap 1 + 1 do i + loop ;
ok> 10 sum-to .
55
ok> : grid 3 0 do 3 0 do i j * . loop cr loop ;
ok> grid
0 0 0
0 1 2
0 2 4
ok> : countdown begin dup . 1 - dup 0= until drop cr ;
ok> 5 countdown
5 4 3 2 1

grid is a multiplication table: the inner loop's i is the column, the outer loop's index reached through j is the row. begin … until is the bottom-tested loop — it repeats until the flag on top is true.

Try it: write : triangle that prints a right triangle of asterisks n rows tall. 42 emit prints one asterisk.

04

Variables, constants and memoryruns today

words: variable constant · ! @ · here allot · c! c@

variable name allocates one cell and makes name push its address. ! stores (value addr !) and @ fetches. A constant pushes its value directly — no address, no store.

ok> variable counter
ok> 0 counter !
ok> : bump counter @ 1 + counter ! ;
ok> bump bump bump
ok> counter @ .
3
ok> 100 constant limit
ok> limit .
100
ok> variable total
ok> 0 total !
ok> : accumulate 5 0 do i total @ + total ! loop ;
ok> accumulate
ok> total @ .
10
ok> here 4 allot here swap - .
4

That last line is the dictionary heap in miniature: here pushes the current allocation pointer, allot advances it, and the difference is what you reserved. Use c! and c@ for single bytes in a region you allotted.

Do not pass a negative number to allot. The bounds check only catches overflow, so a negative argument moves the allocation pointer outside the heap block and subsequent writes land in memory that is not yours. This is a known bug, not a feature.

05

Interrogating the chipruns today

words: chip-info chip-model chip-cores chip-rev · mac-addr · free-heap mem

These are the FFI words that ship in the box — thin C wrappers over ESP-IDF calls, indistinguishable at the prompt from dup. This is the pattern you will copy in part two.

ok> chip-info
Chip: ESP32-C3 rev 4, 1 core(s)
Features: WiFi BLE
MAC: 12:34:56:78:9a:bc
ESP-IDF: v5.3.1
Free heap: 198432 bytes
ok> chip-cores .
1
ok> : board-report ." cores: " chip-cores . cr ." rev:   " chip-rev . cr ." heap:  " free-heap . cr ;
ok> board-report
cores: 1
rev:   4
heap:  198432
ok> mac-addr .s
<2> 2018915346 48282
ok> 2drop

mac-addr pushes two cells because six bytes will not fit in one 32-bit cell: low four bytes, then high two. Printing it readably is a nice exercise in hex and shifts.

mem prints the fuller report — internal heap, dictionary usage, stack depth and word count. It is the fastest way to answer "am I about to run out?" without attaching a debugger.

06

Number bases, strings and stack surgeryruns today

words: hex decimal · 0x $ % prefixes · s" type · emit · pick

hex and decimal change both how numbers are read and how they are printed. The prefixes are per-literal and override the current base: 0x or $ for hex, % for binary, # for decimal.

ok> 255 hex . decimal
ff
ok> 0xFF .
255
ok> %1010 .
10
ok> $2A .
42
ok> s" hello world" type cr
hello world
ok> 65 emit 66 emit 67 emit cr
ABC
ok> 1 2 3 0 pick .
3
ok> 1 2 3 2 pick .
1

s" pushes an address and a length; type prints that many bytes. pick copies the nth item counting from zero at the top, so 0 pick is dup.

Watch the base carefully: after hex, typing 255 means 0x255. The idiom 255 hex . decimal above converts and immediately restores, which is what you almost always want.

Do not pass a negative number to pick. Like allot, the bounds check is one-sided and a negative index reads past the top of the stack.

Part two — teaching the engine new words

Everything above was a language tutorial. This is the part that makes ESPIDFORTH worth choosing: any ESP-IDF API becomes a Forth word in about five lines of C, and from then on it is interactive.

The pattern never changes. Write a void fn(void), pull arguments with forth_pop(), push results with forth_push(), and register it after forth_init() and before forth_repl():

src/main.c — inside app_main()

forth_init(heap_size);
forth_register_word("my-word", w_my_word);   /* <-- here */
forth_repl(console_getchar, console_putchar);
07

Your first FFI word: ms and usadds C

new words: ms ( n -- ) · us ( -- t )

The stock engine has no way to wait, which makes it hard to pace anything. ms is the smallest useful word you can add and it demonstrates the whole mechanism.

src/main.c

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_timer.h"
#include "forth_core.h"

/* ( n -- )  block this task for n milliseconds */
static void w_ms(void) {
    intptr_t n = forth_pop();
    if (n > 0) vTaskDelay(pdMS_TO_TICKS(n));
}

/* ( -- t )  microseconds since boot */
static void w_us(void) {
    forth_push((intptr_t)esp_timer_get_time());
}

/* register both, after forth_init() */
forth_register_word("ms", w_ms);
forth_register_word("us", w_us);

Rebuild once. From then on you never rebuild again to change how they are used:

ok> : elapsed us swap - . ." us" cr ;
ok> us 50 ms elapsed
54266 us
ok> : work 1000 0 do i drop loop ;
ok> us work elapsed
17 us

You now have a benchmark harness. us … elapsed wraps any word and tells you what it costs — a 1000-iteration empty loop is 17 µs, so the interpreter overhead is roughly 17 ns per word.

Cell width matters here. esp_timer_get_time() returns a 64-bit microsecond count, but a Forth cell is 32 bits on these parts. The cast truncates, so us wraps roughly every 71 minutes. Fine for measuring a word; not a clock.

08

GPIO — blinking an LED from the promptadds C

new words: pin-output ( pin -- ) · pin-set ( level pin -- )

Two words are enough for most digital output work. Note the argument order: the pin is pushed last so it sits on top, which reads naturally as 1 LED pin-set.

src/main.c

#include "driver/gpio.h"

/* ( pin -- )  configure as a push-pull output */
static void w_pin_output(void) {
    gpio_num_t pin = (gpio_num_t)forth_pop();
    gpio_reset_pin(pin);
    gpio_set_direction(pin, GPIO_MODE_OUTPUT);
}

/* ( level pin -- )  drive it high or low */
static void w_pin_set(void) {
    gpio_num_t pin = (gpio_num_t)forth_pop();
    uint32_t level = (uint32_t)forth_pop();
    gpio_set_level(pin, level);
}

forth_register_word("pin-output", w_pin_output);
forth_register_word("pin-set",    w_pin_set);

src/CMakeLists.txt already lists driver in REQUIRES, so nothing else changes. Flash once, then build the behaviour interactively:

ok> 2 constant LED
ok> LED pin-output
ok> : on 1 LED pin-set ;
ok> : off 0 LED pin-set ;
ok> on            \ the LED is now lit
ok> off
ok> : blink on 200 ms off 200 ms ;
ok> : blink-n 0 do blink loop ;
ok> 10 blink-n
ok> : blink on 50 ms off 50 ms ;   \ twice as fast, no reflash
ok> 10 blink-n

That last pair of lines is the point of the whole project. blink-n was never redefined — it calls blink by dictionary lookup, so redefining blink changed the behaviour of a word that already existed, on a board you never touched.

Pin 2 is the onboard LED on many devkits; check your board. The C above compiles against ESP-IDF 5.x; the Forth was verified against the engine with the GPIO calls stubbed, since this site has no board attached.

09

Reading a sensor with the ADCadds C

new words: adc-read ( channel -- raw )

An FFI word that returns a value. The oneshot driver wants a unit handle created once, so keep it in a static and configure the channel lazily on each read — slower, but it keeps the Forth side to a single argument.

src/main.c

#include "esp_adc/adc_oneshot.h"

static adc_oneshot_unit_handle_t s_adc;

static void adc_setup(void) {
    adc_oneshot_unit_init_cfg_t unit = { .unit_id = ADC_UNIT_1 };
    adc_oneshot_new_unit(&unit, &s_adc);
}

/* ( channel -- raw )  12-bit sample from ADC1 */
static void w_adc_read(void) {
    adc_channel_t ch = (adc_channel_t)forth_pop();
    adc_oneshot_chan_cfg_t cfg = {
        .atten    = ADC_ATTEN_DB_12,
        .bitwidth = ADC_BITWIDTH_DEFAULT,
    };
    adc_oneshot_config_channel(s_adc, ch, &cfg);
    int raw = 0;
    adc_oneshot_read(s_adc, ch, &raw);
    forth_push((intptr_t)raw);
}

adc_setup();
forth_register_word("adc-read", w_adc_read);

Add esp_adc to REQUIRES in src/CMakeLists.txt, then flash. Now the interesting part — building a measurement rig without ever rebuilding:

ok> 0 constant SENSOR
ok> SENSOR adc-read .
2047
ok> : sample SENSOR adc-read ;
ok> : watch 0 do sample . 100 ms loop cr ;
ok> 10 watch
2041 2044 2039 2050 2047 2043 2046 2041 2045 2048
ok> variable hi
ok> 0 hi !
ok> : peak sample dup hi @ max hi ! ;
ok> : track 0 do peak drop 20 ms loop hi @ . ;
ok> 100 track
2051

Decide the sample rate, the window, and what statistic you care about — all at the prompt, while the sensor is connected and the phenomenon is happening. That loop is normally an edit-build-flash cycle each time you change your mind.

ADC values above are representative of a floating input; yours depend on what you wire up. ADC_ATTEN_DB_12 is the ESP-IDF 5.3 spelling — ADC_ATTEN_DB_11 is the deprecated alias for the same thing.

10

Hot-swapping a behaviour, with rollbackadds C

API: forth_save · forth_restore · forth_eval_rollback · forth_word_exists

This is the thesis. The dictionary is append-only and lookup runs backwards, so redefining a word shadows the old one rather than mutating it. That means the engine's entire state is three fill pointers — and rolling back is just truncating them.

forth_eval_rollback() uses that to apply remotely-delivered code safely: it takes a savepoint, evaluates line by line, and if any line fails it reverts everything and tells you which line broke.

Applying a bundle from C

const char *bundle =
    ": helper 2 * ;\n"
    ": role-tick helper . ;\n";

char failing[128];
int rc = forth_eval_rollback(bundle, strlen(bundle), failing, sizeof failing);

if (rc == 0) {
    /* every line took effect — safe to persist the bundle verbatim */
} else if (rc == 1) {
    ESP_LOGW(TAG, "bundle rejected at: %s", failing);
    /* dictionary is already back where it was */
}

Here is an actual run. A good bundle redefines role-tick; a bad one is rejected whole, leaving the previous definition intact:

// starting point
ok> : role-tick ." v1" ;
ok> role-tick
v1

// apply a GOOD bundle: helper + a new role-tick
apply GOOD bundle -> rc=0
ok> 21 role-tick
42
forth_word_exists("helper") = 1

// apply a BAD bundle: a valid line, then a broken one
? compile: nonexistent-word
apply BAD bundle  -> rc=1
failing line = ": role-tick nonexistent-word ;"
forth_word_exists("broken") = 0      <-- the good line was rolled back too
ok> 21 role-tick
42                                   <-- still the previous version

Note that broken — defined by the bundle's first line, which succeeded — is gone. The rollback is all-or-nothing across the bundle, which is what makes it safe to send code to a device you cannot physically reach.

Know what the savepoint does not cover. It restores the dictionary, the code area and the heap pointer. It does not restore the contents of variables that existed before the savepoint, the data stack, or the number base. A bundle whose last successful act was 99 config ! leaves that store in place after a rollback.

Known quirks

Things that will confuse you, found by running the engine rather than reading about it. All are true of v0.5.0.

A failed definition executes the rest of the line

When compilation aborts on an unknown word, the interpreter drops out of compile mode and keeps reading the same line in interpret mode — so the remainder of your definition runs immediately and can leave values on the stack.

ok> : fact dup 1 > if dup 1 - fact * else drop 1 then ;
? compile: fact
? else
? then
? ;
ok> .s
<1> 1          \ left over from the aborted line

Check .s after any error cascade.

Words cannot call themselves

A definition is not added to the dictionary until ;, so a recursive reference fails to compile — that is what the example above is really showing. Write loops instead, or define a forward stub first.

No floating point

Cells are integers. 3.14 is not a number and will be reported as an unknown word. Scale to fixed point: work in thousandths and divide when you print.

cr emits a line feed only

Not CR+LF. Terminals that do not translate will stair-step multi-line output. 13 emit 10 emit gives you a true new line if yours does not.

Limits worth knowing

512 dictionary entries, 4096 cells of compiled code, 256 stack cells, 256 characters per input line, 64 characters per word name. The compiled-code area is not bounds checked in v0.5.0 — a very large set of definitions can run past it, so keep an eye on mem.