Overview
Module Main
installs an infrastructure to print out to two serial terminals, so that module Out
can be used as usual. Analogously, there are mechanics to read from the terminals with module In
.
Two Terminal Windows
The two UARTs of the RP2040 and the RP2350 are used for two serial text terminals. See module Main
for the pins used. Main
sets up the two terminals using module Terminals
, assigns the two TextIO.Writer
to module Out
, and the two TextIO.Reader
to module In
.
See Set-up for a list of terminal programs, and some of their limitations, in particular regarding input, and how to connect the host computer to the RPs.
Out
Module Out
provides the usual output procedures. Due to the set-up outlined above all code running on core 0 writes to one terminal window, and on core 1 to the other. See the example programs.
Each terminal can be written to by any piece of code using module Texts
(its output procedures take a TextIO.Writer
as parameter), provided they don’t interfere with each other regarding the use of the hardware peripherals.
Library modules that print messages, such as RuntimeErrorsOut
, should use Texts
, not Out
, to retain maximum flexibility. See how module Main
sets this up for RuntimeErrorsOut
.
In
Module In
is symmetrical in its usage concept to Out
: it reads from terminal 0 used by code on core 0, and from terminal 1 on core 1. It currently offers reading strings and integers in decimal format.
However, input is fundamentally different from output. With output, the MCU is in control, with input it’s the user. For each read item, we must check for errors. Traditionally in Oberon, this is done by reading a result
value, exported by In
. But since we really need to check the validity for each input, the procedures in In
use a result parameter as part of the API, which makes this clear(er). Also, a set-up with an exported global (or one per core), and the need to check it in a subsequent extra step, may lead to issues further down the road (think: pre-emptive scheduling, for example).
Writers and Readers
Module TextIO
defines TextIO.Writer
and TextIO.Reader
, respectively, which are basically data structures that hold output and input procedures to and from any device. This abstraction allows to use module Texts
to write to any output device, if a corresponding TextIO.Writer
is provided. Output could be to an LCD-module via SPI. Or read from any device with a suitable TextIO.Reader
.
Modules Out
and In
make use of these features, and use the Writer
and Reader
to/from the UART as set up by module Terminals
.
The Serial Terminals for Input
The input infrastructure outlined above assumes that we use a serial terminal, or terminal program, that
- locally echoes, captures and buffers the input line, allowing line edits;
- sends the whole input line to the UART upon hitting enter.
That is, our program does not “see” the user type their single “raw” characters – including control characters, such as for backspace or ^H for editing –, and cannot, for example, intercept wrong non-numerical letters in an integer value. While this can be done, of course, I am not sure it’s a good use of a microcontroller’s CPU cycles to implement terminal line editing, also considering the many different terminal types and makes. Operating systems have whole subsystems just dedicated to terminal control.
In the same vein, it is assumed that we want to capture a whole user input line in one input buffer. We could read the input line in smaller chunks, but since we don’t know what the user typed, we would need to assemble the chunks into a larger buffer before parsing it. Hence, we can use the larger buffer in the first place.
This whole set-up is intended to take away complexity and processing from the control program.
User Input Handling
Reading user terminal input can be quite challenging, since we simply don’t know what we will get, regarding contents and length of the received data.
Possible user input issues include:
- buffer overflow: the user provides more input than our buffer can capture;
- syntax errors: the user types wrong characters, such as non-numerical for an integer;
- out of limit values: the user inputs a number that is outside the 32 bit 2-complement limits, both positive and negative.
Causing buffer overflow, the user can type too many characters, even when providing valid input. For example, they could type - 002147483648
, which is a valid number. If we tune our input buffer to a size of twelve characters (ten for the integer, one for the sign, and one for the terminating EOL
), the above string will not fit.
Unfortunately, only increasing the buffer size is no panacea, since we can always increase the number of leading zeros. Or add trailing blanks. Simply instructing the user not to do it is maybe not safe enough for a control system. A solution is to handle the overflow gracefully and return a suitable result code. The input procedure can then decide what to do. For a string, it may be OK to continue with a truncated value, but for an integer probably not.
In the case of syntax errors or out of limit values, the current implementation does not even try so salvage part of the input for the integer, and make sense of it. The result code indicates the error, and the returned value is not valid.
Design Considerations
Note: this section is not really relevant anymore with the latest version of the UART string driver for the kernel, which uses interrupts (UARTkstr.mod). It will be removed (or revised) going forward.
Since the user is in control, not our program, we need to detect the input. Unless we use interrupts, we need to poll the UART for the start of the input. As we have a control program that needs to continue running, we cannot simply busy-wait while polling. OK, with the luxury of two processor cores, we could dedicate one core to user interface handling, and do blocking polling, while the control program runs on the other core. For certain situations this is an appropriate design, especially if we have lots of user interactions, and maybe a whole graphical display with menus and all that to handle.
Otherwise, we need to “interweave” the polling into our control program, either by designing the “big main loop” accordingly, or using the kernel. In both cases, there will be a certain elapsed time between the user sending the input, and our detection and handling of it. We must make sure the UART’s receive FIFO does not get overrun in this elapsed time, which puts a limit on the maximum baud-rate for the terminal.
As an example, if we use the time-driven kernel with a 5 ms cycle time, the 32 character FIFO would be overrun with a baud-rate of 115,000, since the terminal can send close to 60 characters in the maximum 5 ms it takes to detect the first character sent. As soon as we have FIFO overrun, the input read is unreliable, since we don’t know which characters sent were rejected by the UART with its full FIFO, and which “made it” due to our program reading the FIFO and thus freeing up slots. We can only say that the FIFO contents is correct at the point we detect the overrun, but not thereafter – see UARTkstr.mod for example.