A programming environment for Lua for the Raspberry Pi Pico microcontroller.
Version 0.3, April 2021
picolua
is a proof-of-concept Lua programming environment for the Pi
Pico. As well as the Lua run-time, it includes a rudimentary
shell that accepts Linux-like commands, a full-screen editor,
and basic file management facilities. picolua
is designed to be
operated by connecting the Pico's USB to a terminal emulator.
Lua functions have been added for controlling the Pico's GPIO
and other peripherals.
As a result, it is possible to enter and run simple programs that
manipulate connected devices, without the need for any particular
development tools. There is support for general digital input/output,
analog input, PWM output, and I2C.
picolua
has some conceptual similarities with MicroPython on the
Pico. However, picolua
is designed to be completely self-contained,
in that no addition tools (other than a terminal) are required to
create, edit, and test programs. It's possible (to some extent) to
create and test Lua code on a workstation, and then upload it to the
Pico. However, this isn't necessary -- picolua
is capable of
being self-contained.
picolua
maintains a filesystem in the Pico's flash ROM, that can store
multiple files, perhaps organized into directories.
There is a rudimentary command-line shell that provides a few Unix-like commands, for copying, viewing, and deleting files; and, of course, for running Lua code.
Most of the standard Lua features are available, except those that
interact with an operating system. The Lua file handling routines
have been replaced by alternatives specific to the picolua
filesystem.
On startup, you will see the shell prompt $
. I've used the dollar sign
here to distinguish it from the Lua >
prompt, and because the shell
is a little like a Unix shell. There is a basic line editor
for entering shell commands; these commands are similar to Unix shell commands
-- cp
, rm
, mv
, etc. The shell supports wilcard expansion ("globbing"),
with the ? character matching any single character, and * matching any number
of characters. So it's possible, and sometimes useful, to run
commands like cp *.lua /backup
. There is a full list of shell
commands below.
As in Unix shells, any line that starts with a #
is taken to be a
comment. This is only useful in scripts (see below).
There are various ways to run Lua in picolua
.
-
Create the Lua file using the built-in editor, or upload it from a terminal using the YModem protocol (described below). Then run it using the shell command
lua {filename}
. -
Run the Lua file directly from the editor, by hitting the Ctrl+
key combination. -
Start an interactive Lua session by running
lua
at the shell prompt. You can enter Lua code directly at the Lua prompt. From this prompt you can also invoke the editor, usingpico.edit "filename"
. -
Run
lua -e "lua code..."
at the shell prompt. -
If the Lua file is in a directory which is in the search path (see "Search path" below), then you can just type the name at the prompt, without the
.lua
extension. So you can run '/bin/blink.lua' just be enteringblink
. -
Lua files can be grouped into modules, and executed using the
require()
function -- see "Lua modules" below.
picolua
is based on Lua 5.4, and this will probably not change,
given the number of changes that I had to make to it.
On start-up, picolua
enters interactive mode. You can enter expressions,
which are evaluated and displayed, or run a program using dofile
or
require
. At the Lua prompt, you can invoke the (rudimentary)
screen editor using
edit "file.lua"
. One change from the conventional Lua interactive mode
is that if the result is a table, the contents are displayed. This makes
it possible to provide "commands" like ls "/dir"
and df()
-- these return
a table, which gets displayed.
Standard Lua has only one numeric data type (at least, one only one
that is accessible outside the Lua runtime). It can be set at compile
time, but then it's fixed. At present, it is set to double
-- double-precision
floating-point. Many functions in the Pico interface of necessity take
integer arguments. A GPIO pin number, for example, can only be an
integer. Care needs to be take if using the results of calculations
as arguments to these functions. At the time of writing, the Pico
SDK includes double-precision floating-point support more-or-less
as a matter of course, so there's little to be gained by trying to
use lower precision.
The implementation uses one Lua context for the whole runtime.
So any functions defined, or variables set, by executing a Lua file
(e.g, by using dofile "file.lua"
) remain in effect. This is nearly
always the expected behaviour.
As in regular Lua, a file can be loaded using
dofile "file.lua"
and require "file"
. There are subtle differences
between these methods, that are described in the Lua documentation,
and apply here too. Here, though, there's little need to use
require
, because there isn't a file search path.
Like all Lua functions, the Pico functions can be used without brackets
when there is a single string argument. So it's correct to write
edit "test.lua"
as well as edit ("test.lua")
. Although these
functions are defined in a library called pico
, and should probably
be written pico.edit
, pico.ls
, etc., they are also defined in the
global namespace as well, just for convenience.
Since there is no underlying shell, some of the Pico-specific functions provide shell-like functionality. There are functions for editing and copying files, for example, and querying system status.
Sending Ctrl+C should interrupt a running program. The same key is used to abandon a line in the line editor. Ctrl+C is not used to exit the screen editor; in fact, it has no function there -- not even "copy". See the Screen editor section for more information.
picolua
maintains a filesystem in the PICO's flash memory. Filesystem
storage starts a little after the program code, and extends, more-or-less
to the end of the flash memory.
When you flash a .uf2
file on the Pico, only the flash areas specified
in the file are re-written. Consequently, and intentionally,
the picolua
filesystem will survive re-flashing the program itself.
The filesystem supports files and directories, with names up to 255 characters. It has no notion of file permissions, ownership, timestamps, or links. This is to reduce the amount of storage used per file to a minimum.
The line editor responds to cursor movement and backspace (delete on some keyboards) keys. Ctrl+Left and Ctrl+Right move the cursor by whole words. Ctrl+Home and Ctrl+End move the cursor to the start and end of the line respectively
Up-arrow and down-arrow scroll through a history of previous lines. By default, the history is limited to the last four lines. The line editor is nowhere near as sophisticated as that found in most Linux shells -- but it's a lot smaller.
The screen editor is fairly rudimentary, and designed specifically to operate from a serial terminal. Some of the key bindings are not very conventional, because I've tried to avoid key combinations that are taken over by terminal emulators and window managers.
The editor is invoked initially by running edit "filename"
.
Thereafter, further files can be opened by sending Ctrl+O and
entering a filename. If multiple files are open, you can
switch between them using Ctrl+E.
To get a list of key bindings, send Ctrl+@. It would have been nice use Ctrl+H (for "help") but Ctrl+H usually generates a "backspace" keycode.
There are a few oddities that might be worth mentioning.
-
The key combination Ctrl+\ executes the file in the editor as a Lua program.
-
The usual Ctrl+C for "copy" has been replaced by Ctrl-Y, because I wanted to reserve the Ctrl+C combination for an interrupt throughout the environment.
-
If text is selected, the tab key indents the text two spaces. Shift-tab unindents by the same amount. If nothing is selected, the tab key simple enters a tab character.
-
The editor auto-indents using spaces. At present, this behaviour can't be turned off
picolua
does not have well-developed shell script support, because it's
unnecessary -- you can run shell commands, should there be a need to,
from a Lua script.
However, picolua
does have a rudimentary notion of a shell script. A
script if a file containing shell commands, one to each line. The shell
will process the script line by line to the end, or until some command
raises an error.
This facility is mostly intended for initialization.
When you run a Lua program from the shell prompt, you can pass command-line arguments if necessary. This mechanism works in Lua the same in regular Lua.
So we can execute a LUa program like this:
$ lua bin/myprog.lua hello world
or
$ myprog hello world
and in both cases the arguments "hello" and "world" are available in
the Lua program as arg[1]
and arg[2]
. By convention,
arg[0]
is the name of the program.
The picolua
shell has a rudimentary knowledge of search path. At start-up,
the path consists (only) of the directory /bin
. Any Lua file placed
in that directory, with a name ending in .lua
, can be executed at the
shell prompt simply by entering the name, without the extension. So
instead of entering lua /bin/blink.lua
, we can enter blink
.
The same principle applies to shell scripts. A shell script can be
executed by entering only its name, if it is in the /bin
directory,
and has a name endring in .sh
.
picolua
supports Lua modules, as ordinary Lua does. However, the module
search path contains only the /lib' directory. If a Lua program says
require "foo", Lua will search for
/lib/foo.luaand
/lib/foo/init.lua. This allows simple modules to be placed directly in
lib, and more complex ones in their own subdirectories of
\lib`.
When picolua
starts, it executes the shell script /bin/shellrc.sh
.
There really isn't anything useful that can be done in this file, except
perhaps to run a Lua script.
When Lua starts up, it executes /bin/luarc.lua
, before entering
interactive mode.
In both cases, the shell script or Lua program need not ever finish.
If this happens, then picolua
will never enter interactive mode.
This is exactly what would be require, to have picolua
execute a
Lua program when the Pico powers up.
It should still be possible to connect to a terminal, and hit
Ctrl+C to regain control. If, for some reason, it isn't possible, then
the only solution is to erase the Pico flash completely. Re-flashing
picolua
does not erase the rest of the flash -- this is intentional.
You can run Lua code directly from within the screen editor, by hitting
Ctrl+. This works when running the editor from the shell prompt, and
from within an interactive Lua session (e.g., using pico.edit()
).
In an interactive session, the Lua is executed in the global context,
and any functions and variables defined continue to exist, until the
end of the Lua session. If you run the editor from the shell prompt,
the each invocation gets a separate Lua context, and any definitions
are not persistent once the editor is closed.
It's possible to run shell commands directly from Lua, using
the Lua function pico.execute()
(similar to the conventional
os.execute()
). However, there are some file management functions
directly available as custom Lua functions.
adc_pin_init (pin)
Set a specific GPIO pin for use as an analogue input. This only needs to be done if the pin has previously been used for something else. This function does not enable the ADC, for the specified pin or in any more general sense.
adc_select_input (input)
Select which specific analog input adc_get()
will read from. In
practice, the input
argument will be a number from 0..3 to select
pins 16-19. Input channel 4 is the internal temperature sensor.
Note that the input
argument is not a pin number, but a channel
number, even though each channel (except the temperature sensor)
is assigned to a specific pin. Calling this function enables the ADC
subsystem, but it does not set a GPIO pin to be in an appropriate
state for analog input -- use adc_pin_init()
for that.
adc_get()
Read an analog value from the currently-selected channel. The value will be in the range 0..4095.
df
Returns an array containing the total, used, and free space in the persistent storage, in bytes.
edit "file"
Invokes a (very) simple text editor on the file. Please note that this editor limits line length to 200 characters.
gpio_get ()
gpio_get (pin)
Returns HIGH or LOW (0 or 1) according to the state of the input pin.
Formats the persistent storage, erasing any data.
gpio_pull_up (pin)
Enables the built-in pull-up resistor for a particular pin.
gpio_put (pin,value)
Set a GPIO output to HIGH or LOW (or 1 or 0). This method does not
implicitly make the pin and output -- use gpio_set_dir()
.
gpio_set_dir (pin, direction)
Set a GPIO pin to GPIO_IN or GPIO_OUT (or 0 or 1).
gpio_set_function (pin, function)
Set the operating mode of a particular GPIO pin. To set "normal", programmed I/O, use:
gpio_set_function (pin, GPIO_FUNC_SIO)
There are also modes GPIO_FUNC_I2C
, GPIO_FUNC_PWM
, etc., that
must be used to select specific operating modes.
i2c_init (port, baud)
Initialize a specific I2C port. For more information, see the section on I2C below.
i2c_write_read (port, addr, output, input_length)
Perform an I2C read, write, or read/write. For more information, see the section on I2C below.
ls ls "/directory"
Returns an array containing the names of files and directories in the
specified directory. There is no way to tell, from these results alone,
whether the entries represent files or directories.
See the example ll.lua
for an idea how to combine pico.stat()
and
pico.ls()
to implement a function like the Unix ls -l
.
pwm_pin_init (pin)
Sets up a GPIO for hardware PWM operation. This function implicitly sets the pin to PWM mode. The base frequency is the default provided by the underlying C SDK.
pwm_pin_set_level (pin,level)
Sets the PWM duty cycle for a specified pin, in the range 0 (off)
to 65535 (on). The pin must have been prepared by calling
pwm_pin_init()
first.
read "path"
Reads the entire contents of the specified file into a string variable. The file can contain zeros -- many (but not all) the Lua string-handling functions work on files with embedded zeros. No value is returned, but an exception is raised if the file cannot be read.
readline()
Reads a line from the terminal, and assigns it to a string variable. This
is broadly equivalent to the standard Lua io.read
, except that it only
works on the terminal. If the user sends an interrupt (usually
Ctrl+C), the function raises an exception and stops further execution. If
the user sends end-of-message (usually Ctrl+D) it returns nil
. Otherwise
it returns a string containing the line entered.
rm "path"
Deletes a file or an empty directory. There is no return value, whether is succeeds or fails.
sleep_ms (msec)
Sleep for the specified number of milliseconds.
stat "path"
Returns a table containing the size and type of the specified file.
See the example ll.lua
for an idea how to combine pico.stat()
and
pico.ls()
to implement a function like the Unix ls -l
.
write ("path", string)
Writes a string variable to the specified file. No terminating zero is written. An exception is raised if the file cannot be written.
The Pico has two I2C ports, that can be assigned to various pairs of
pins. picolua
refers to these ports as '0' and '1'.
To initialize the I2C subsystem for a specific port, use
pico.i2c_init (port, baud_rate)
Baud rate is typically set to 400 000, although faster operation is possible in good operating conditions.
It's necessary to set the specific pins to I2C operation:
pico.gpio_set_function (pin, GPIO_FUNC_I2C)
Note that, although various pins can be set for I2C operation, there is no choice about which pins correspond to which ports. Pins 16 and 17, for example, can only ever be used with port 0.
I2C requires that the bus lines be pulled up to a positive voltage at one point. If this is not done anywhere else, it can be done in the Pico using
pico.gpio_pull_up (pin)
There is one function, pico.i2c_write_read()
for both reads and writes.
pico.i2c_write_read (port, addr, output_string, input_lenth)
The implementation in a single function makes it easy to perform the common "combined" operation of a write followed without interruption by a read.
If there is no data to write, pass a zero-length string as the output_string
argument. Lua allows binary data to be stored in strings, and it doesn't matter if some of the values are zero, For example, we can do:
output_string = string.char (0xFF, 0x00,...)
The function must be told the number of bytes to read; this also can be
zero. If it is not zero, then i2c_write_read()
returns a string
containing the specified number of bytes. This string can be
unpacked using string.byte()
. Don't forget that Lua indexes strings
starting at 1, not 0, by default.
For an example of I2C operation, see the mpu6050.lua
example in the
source code bundle.
The Pico has a single ADC, which is multiplexed between five different
sources -- pins 16-19, and the internal temperature sensor.
The pico.adc_select_input()
function selects which channel to use,
but be aware that it takes a channel number, not a pin number.
On the other hand, the function adc_pin_init()
, which prepares a
pin for ADC operation, takes an actual pin number. These complication
follows from the fact that one of the ADC channels -- the temperature
sensor -- does not have a pin. So we can't just use pin numbers for
all the ADC functions.
See the file adctest.lua
in the source code bundle, for an example of
using the ADC.
picolua
has rudimentary support for PWM outputs -- enough to control
the brightness of a lamp, for example. Although the Pico's PWN system
is very flexible -- and therefore complex -- picolua
avoids this
complexity by using defaults for all settings. As a result, the only
functions needed are pico.pwm_pin_init()
and pico.pwm_pin_set_level()
.
See the file led_fade.lua
in the source code bundle, for an example of
using hardware PWM.
picolua
support the YModem protocol for sending and receiving files to
and from a
host system. The files can have any contents, but there is a limit
on a single file of 100kB -- this is just to protect the Pico filesystem
from a badly-behaved sender. In addition, files are sent by being
buffered in memory first, so even 100kB might be too much.
picolua
doesn't support the more common Xmodem protocol, because it's
not much use for anything other than ASCII text. Ymodem has the additional
advantage of allowing multiple files to be sent in one batch.
To start a receive operation, use the shell command yrecv
or
yrecv [filename]
. If
a filename is specified, it will take precedence over the filename
provided by the sender. However, it makes no sense to provide a filename
if the sender will be sending multiple files -- they'll just overwrite
one another.
To send, use ysend {filename}
.
When using YModem from a terminal emulator, it's probably best to
start yrecv
or ysend
before starting the transfer in the terminal -- the
terminal might not allow you back to the prompt to run the command
after that. After starting either function, you'll probably see a row of
"C" characters -- that's the YModem start character. You should stop
seeing this as soon as communication starts. The terminal
will probably only allow a limited amount of time for picolua
to
signal it is ready to receive, and picolua
will
only allow twenty seconds for the terminal to start sending.
I've tested the YModem support with Minicom which, on Linux at least,
just invokes the command-line utilities rb
and rz
to do the
actual transfer. YModem is notoriously fussy, and I can't be sure
that any YModem utilities than these will work with picolua
.
The shell prompt is somewhat Linux-like. The line editor supports cursor movement and word-by-word movement using Ctrl+Arrow. It remembers a limited amount of history. However, it's really only designed for basic file management, and running Lua code -- the built-in shell is not intended to be as power as a Linux shell.
Note that there is, as yet, no concept of shell redirection, or even of a working directory: picolua supports directories, but all commands require full pathnames.
There is no shell scripting support, but you can create scripts in Lua that invoke shell commands.
cat {files...}
Dumps the contents of the specified files to the console.
cp [-v] {files...} {file | directory}
Copy the specified files to the specified location. If the target is a directory, then the source files are copied into that directory, and given the same names. If there are multiple source files, the target must be a directory. If there are only two arguments, and the second is not a directory, then the target is overwritten.
if -v
is specified, each filename is printed before it is
copied. The command can't be used to copy complete directory
trees.
df [-k]
Report the amount of free and used storage in byte, unless
-k
is specified, in which case it is in kB.
echo {arguments...}
Print the arguments to the terminal.
edit [filename]
Open the built-in editor. If no filename is given, start with an untitled file.
format [-y]
Format the filesystem. This deletes all data, and creates the
initial '/bin,
/etc', and 'lib/' directories, along with the
"blink.lua" sample script. Unless the -y
switch is given, this
command prompts the user before reformatting the filesystem.
i2cdetect {pin1} {pin2}
Scan the I2C bus for devices. The Pico has two I2C buses, but they can be assigned to a number of different pin pairs. The utility works out the I2C bus from the specified pins. The pins are usually an adjacent pair, e.g., 16 and 17. One of these pins will be the data line and the other the clock, but it doesn't matter which order they are specified in one the command line. Of course, it might matter to whether the device actually works or not.
ls [-l] {paths...}
List the contents of the specified directories, or list the specified
files. If -l
is given, show the type and size of each entry.
mkdir {directories...}
Creates one or more directories. The parent directories must exist.
rm {paths...}
Delete the specified files or directories. Directories can only be deleted if they are empty.
mv {paths...} {file | directory}
Renames or moves files or directories. If there are multiple sources, the last argument must be a directory that already exists.
yrecv [filename]
Receives one or more files using the YModem protocol. See the section on YModem support for more details.
ysend {filename}
Sends a single file using the YModem protocol. See the section on YModem support for more details.
picolua
is implemented in C, and designed to be built using
the documented method for the Pico C SDK. The method depends, for better
or worse, on CMake. Setting up CMake, the SDK, and the ARM compiler
toolchain, is documented in the Raspberry Pi documentation. I'm not a fan
of CMake, but building for the Pico is well-nigh impossible any other way.
If you have set up according to the documentation, you should be able
to build like this:
$ mkdir build_pico
$ cd build_pico
$ cmake ..
$ make
The build process is configured to provide maximal warning logging,
and will throw out thousands of messages, of varying degrees of scariness.
The result should be a file picolua.uf2
, that can be copied to the
Pico in bootloader mode.
For testing purposes, it should be possible to build a version that will work on a Linux workstation, like this:
$ mkdir build_host
$ cd build_host
$ PICO_PLATFORM=host cmake ..
$ make.
This should result in a picolua
executable. The Linux version expects
to see a file at /tmp/picolua.blockdev whose size is at least 128kB.
This will be used to model the persistent storage that the Pico
version uses in flash. The Linux version is designed to model the
Pico version closely, including all its faults and limitations. Of course,
GPIO access and the like will not be available in this build.
picolua
is a thoroughgoing ASCII system. There is no support
for wide or multibyte characters in any part of the implementation.
Neither the screen editor nor the line editor can handle them,
even if the terminal can display them. Lua's support for such things
is itself pretty rudimentary.
Most of the testing has been done using the Minicom terminal
emulator. Whatever terminal is used, it must have the following
characteristics. It may be necessary to adjust the terminal settings,
or the screen window size, or both, as well as the terminal's internal
settings. All terminal emulators I'm aware of allow these settings to be adjusted; where possible, I've tried to make picolua
work with Minicom's defaults.
-
Most importantly, the terminal must have exactly 80 screen columns, and at least 24 screen lines. Typically a Linux shell window has 25 lines by default, but Minicom uses one line for its status, so 24 are available. It won't hurt to have more than 24 lines, but it will cause problems if there are more than 80 columns.
picolua
assumes the terminal is completely dumb, and won't attempt to work out from the terminal what its screen size is -- there is no robust way to do so, anyway. -
Picolua
expects the terminal to wrap lines automatically. This is not the default with Minicom -- useminicom -w
. -
The terminal must support basic ANSI/VT100 cursor movement and screen handling codes.
-
Picolua
potentially uses all the control key combinations, including Ctrl+[symbol]. Minicom, in particular, uses Ctrl+A to enter command mode, and this clashes with the editor's use of this key combination for "select all". In general, it might be necessary to reconfigure the terminal if there are too many clashes. -
The terminal should send ASCII code 8 for "backspace" and 128 for "delete". It should send code 13 (CR) when the "enter" key is pressed.
-
The terminal should do a line-feed, not a carriage return, when it receives ASCII code 10.
If the terminal cannot be configured, it is possible to change most of these settings by editing interface.h
.
The line editor allows lines up to 200 character long, but it does not behave well once the line is longer than the terminal. This is because terminals do not generally allow a backspace character to move the cursor from the start of one line to the end of the previous line.
picolua
runs without an operating system. The only file-handling functions
available to Lua programs are those documented above, which read and write
whole files. In particular, there is no io.write
, even for the terminal. So
there's no straightforward way to have a Lua program print a line of text
without a newline at the end. The workaround is to build the whole line in a
string, and print that. However, Lua's string concatenation functions
are not very memory-efficient, so this technique needs to be used with care.
picolua
supports general, polled GPIO operation for digital I/O, analog
input, I2C read and write, and hardware PWM. It runs on a single core,
leaving the other core idle. There is no support, and probably never
will be, for DMA, interrupts, threading, or multi-core operation.
Lua was developed by, and continues to be maintained by, the Computer Science Department of the Pontifical Catholic University in Rio de Janeiro.
The screen editor was derived from work by Michael Ringgaard.
The YModem implementation is based on work by Fredrik Hederstierna.
The LittleFS filesystem implementation was developed by ARM.