23 jan 2023
You need a strategy to write effective programs. Without a strategy you end up with spaghetti code.
Events in the real world are arbitrary and asynchronous. The whole point of pushbutton switches is that people press them whenever they need to, not when the computer wants to. Embedded systems need to respond to the sensors, controls, and actuators attached to them.
Event-loops are a way to coerce linear-one-step-at-a-time stored-program computers, MCUs like Arduino, into handing as many tasks as you can think up, and clearly, succinctly, understandably and maintainably.
I agonized over how to describe this system incrementally, introducing one idea at a time, but it always ended up tedious and plodding. So instead I've laid out some examples that introduce these interrelated ideas at once:
With these you can code pretty much any interactive, real-time interaction you can think of. With these ideas, and the simple framework skeletal code in the examples, you can write real programs. With the addition of one more fundamental idea, Messages, not covered here yet anyway, you will have the basis for a genuine but simple real-time "operating system"; this has been highly developed in my roadster and Flock projects.
Event-loop programming is not new. The event-loop model is what set the first Macintosh apart from it's DOS competitor in the early 80's: where the Mac was (almost) entirely event-driven, DOS was trivially single-threaded: if you popped a floppy disk out when a program needed it, the entire system froze with the prompt "ERROR: R)etry, I)gnore, A)bort?". The Mac mostly handled this with grace; you could back out gracefully and try again, no work lost. Event-loops made the mouse and keyboard work independently.
Here is a more formal approach to teaching about Arduino and event-loop programming at CMU.
As crappy as the Arduino-supplied BLINK program is, it hints at the right
approach: turn LED on, delay, turn LED off, delay... then exit.
Control passes right back in at the top, endlessly; the LED blinks. The problem
that delay()
function. It is a serious disappointment that so much
of the Example code is built around delay()
.
BLINK matters. It's the first thing every newbie runs, and it and those
other simple examples are looked to for basic ideas. And fail you, right from
the start. delay()
is harmful.
DOWNLOAD THIS FILE: HowTo-SRTimer.zip
~/Documents/Arduino/libraries
folder.~/Documents/Arduino
.
In the Arduino IDE, load sketch SRTimer_example_1, connect your Arduino board and upload it. This is the basic BLINK example with some fancy stuff added.
Load SRTimer_example_1 in the IDE, connect your Arduino board, under Tools select the board type (Uno, etc) and the serial port. Compile and upload to your board. Click the little hourglass in the upper right tool bar to open the serial monitor. Select 115200 bits/sec in the pulldown in the bottom right. The IDE will remember this setting.
The statement A includes my library into your program. The library contains
a bunch of code that you can use that you couldn't before. SRResources is
documented here. B directs the
compiler to place an object of type SRLoopTally
right here, and
name it SRL
. C sets up the USB serial port for use in debugging;
every program I write does some variation of this.
D is the setup function for SRLoopTally; by C language convention
most objects that need setting up have a method called "begin". What begin does
depends on the object. For SRLoopTally
it says how often to print
out it's little tally of statistics (5 seconds).
Last, the code within loop()
is the plain old dumb
BLINK
example code, with one addition: at E, the invokation
("calling") the loop statistics tally, SRL.tally()
.
Instantiated and setup, SRL.tally()
runs in every iteration
through loop()
and internal to the object generates statistics on
how much time the code inside loop()
takes to execute, and how
many iterations (how many times loop()
is called) per second. This
will soon become interesting.
The built-in LED should be blinking. Let it blink for 10 seconds or so. The serial monitor will display:
The text loop: 1/s, 1000mS avg, 1001mS max
is the work of the
SRL.tally()
. It is very terse, but states that loop()
ran through, completed, one iteration in one second ("1/s"), that the average
loop execution time was 1000 milliseconds, and the maximum time any one
iteration took was 1001 milliseconds.
Because this delay()
-base blink is so stupid and wasteful, yes,
it's all redundant, one blink cycle took one second. This is not news is it.
The "useful work" of BLINK is contained in the two
digitalWrite()
statements. The two delay()
s
essentially halt the machine -- they block. Blocking is bad. Blocking
code is about the worst thing you can do in a machine that is supposed to be
responsive or reactive to its environment.
This program does exactly what example 1 did, with a big difference... But first let's look at the additions and changes.
First of all, we've added two of the fundamental structures mentioned, a crude timer and a simple state machine. This is still a "blink" program, it does the same thing as before to the LED, but so far all we've done is make it more complicated. This added complexity has great value, and will become simpler and shorter in subsequent examples.
The first structure, marked in red, is a timer. It uses a feature
available in all Arduino compatibles, workalikes, and pretty much every
microcontroller available anywhere: a millisecond "clock".
millis()
is a function that returns the number of milliseconds
that have passed since the controller was reset/powered on. It is strongly
analogous to a clock on the wall, time passes whether you're looking at it or
not; millis()
"runs in the background", it's actually a hardware
feature of the CPU. Read about millis()
elsewhere, here we will
just use it.
millis()
hands us a "time line" that goes in the forward
direction that we can mark with a timer, here called T1
. At
program start T1
contains 0. At program line 27 the code compares
millis()
to T1
, time has certainly passed (it always
does) so by the time it gets to line 27 millis()
is certainly
greater than 0, so the we're inside the if
. The first thing done
is to set the timer for next time, T1
to 500 milliseconds into
the future. millis()
returns "now" o'clock; add 500 to that.
Then it executes the state machine.
Lines 30 through 40 comprise our state machine. The state variable,
ingeniously named state
, directly controls which case
statement is executed. state
was explicitly set to 0 in
setup()
. This wasn't strictly necessary since new global variables
are always set to zero but it tells us humans what the programmer's
intent is. It's a good habit to develop.
We know that the first time through, state
is 0, so the first
case
is executed. This turns the LED on, then sets
state
to 1, then break
makes it exit the
switch
statement. The next thing executed is
SRL.tally()
, as in the previous example, and then
loop()
terminates -- only to be reinvoked immediately.
I will not tediously explain subsequent loop iterations. Note that since we
advance the timer 500 milliseconds, that test against millis()
will be false, and the state machine will not be executed, iterating repeatedly
until somehow, half a second passes.
Which branch will the program take then?
loop()
do, most of the time? This loop entry,
test, tally, exit, how long does that take? How many per second does it
execute? Why has no one ever asked you this before? lol.
It should be obvious, and the serial monitor results will tell you, the CPU
is not doing very much work at all to blink those LEDs (the goal forgotten in
this discussion). loop()
spends most of its time (...) waiting for
the clock on the wall (millis()
) to change, twice a second it
executes a total of four tiny statements.
Instead of one iteration a second, it is now doing over 50,000
iterations/second with an average of 17 microseconds each.
(The maximum loop time of some 800 uS is in fact LoopTally.)
Blinking an LED takes 34 uS every second (17 uS on, 17 uS
off).
What else can you do in all that free time?
The added complexity of example 2, vs. example 1, is the state machine and
timer. Working together these two concepts eliminated blocking.
Blocking means waiting for an event or time or a resource to become
available. Blocking means literally no other thing can happen while it waits
this one thing. If you are boiling eggs on the stove, you can stand there for
13 minutes [I hate soft-boiled eggs] and do nothing else, or you can start the
process, and periodically check the clock while you do something else, like
make coffee.
The state machine, coupled with the timer, eliminates blocking. It "unwinds
the loop", it turns a loop -- repeating over and over the same task -- into a
series of simple decisions and executed statements.
Here's a general rule to identify blocking code: code execution goes
"backwards". This includes Loosely, code execution should start at the top and continue unimpeded to
the closing brace or What makes a loop bad is waiting for some event or resource that is
not ready or available.
Waiting for an attached device or component or resource to become ready may
or may not block, depending on the device. Very often, waiting is reasonable if
the datasheet tells you the wait is a few microseconds or milliseconds. Each
instance requires a bit of research.
Serial communication devices, and keyboards, radios, like switches, may
change at any time or not at all. The Arduino Serial object has the
Some jobs, like writing lots of data to a big LCD display, even though
non-blocking in that they terminate in a known, fixed time, might still take
long enough to make other code sluggish. When you need to write thousands of
bytes to a device (such as simple animation to an LCD) consider a state machine
that writes a few hundred at a time then exits, picking up where it left off in
the next call/iteration.
With not much practice, writing code to not block becomes natural, when you
have easy solutions like the state machine to resolve multi-step decisions.
This series of examples is heading towards a particular end, a structure
that can be expanded to handle a large number of sub-tasks, using a succinct
style of event-loop coding.
This example improves on the last by using the Documentation for A major feature of using something like Note that using SRTimer increased overhead a bit (a few more lines of code
in the common global area above OK now we apply this so-far abstract advantage.
There is no practical way to blink two LEDs at independent rates, at all,
using I am intentionally leading you towards a particular and peculiar
style of coding -- mine. If you are an experienced programmer you have
your own. I'll suggest only that you consider clarity of intent when you write.
If you're a novice this style is as good as any and you'll inevitably modify it
to suit your needs. Take your own work seriously. There is no excuse for ugly
code. Any program you persist at should be readable and critically,
reusable.
Examples 4 and 5 require that you add a second LED, here, to pin 12. A
resistor and LED in series.
Note the output of Note also that I re-styled one of the blinkers in a very compact way.
Instead of testing that the the timer has fired, it tests for it not
having fired, and exits if so. This of course accomplishes the same thing. Why
do it this way?
There is a long history of how code should be structured. While the general
consensus is that there should be only one exit (at the bottom) and the first
blinker does adhere to that, it is recognized that for tests such as this,
straightforward tests that enable all following code, that putting
negative-test cheks at the top adds clarity. This also eliminates a level of
braces and detents in from the left margin. Again this is a short, simple
subroutine, so these details are superfluous. I think it adds clarity to this
and similar subroutines.
Code should be (dangerous words, that) written to maximize clarity. When a
state machine such as an LED-blinker is this simple, this compact style adds
clarity by highlighting the similarities and differences; the symmetry is
clearly visible.
State machines can sometimes be long and complex, and the C/C++
This is fairly representative of timer- and event-driven code execution.
This is the beginning of multi-tasking programming.
Example 5 is no longer about timers, blinking, or state machines; it is
straight-up an example of C++ objects being used to contain and define a
"subtask", containered as a separate program each, and tied together into a
functional program by the main source file. I call these separate objects task
loops.
The two blinkers of example 4 here each have their own tab, and the tab is a
Note that each task loop is structured like an Arduino sketch. It has a
This leaves the main Blocking vs non-blocking
for (; ;)
, while ( )
,
do { } ;
statements. These are all loops and loops
potentially block.
return
statement.
Filling an array with a value non-blocking
Waiting for a switch press or release BLOCKING
Reading a Serial character BLOCKING
Searching an array, table, etc non-blocking
Waiting for a device READY pin BLOCK or non-blocking...
Data copy or events that never end BLOCK or non-blocking...
available
method precisely for this; if it returns
true
then read
will return a character without
blocking.
ILLUSTRATION #3: using SRTimer: SRTimer_example_3
Explanation
SRTimer
object,
with which you can create any number of timers in the abstract. These timers
can each be set, reset, force-triggered, tell you how-long-until. By default
timers automatically restart after they fire, as in this blink example.
SRTimer
also contains one-shot (monostable) and holdoff timers that
run only once until explcitly triggered.
SRTimer
is here.
SRTimer
is clarity of
intent. With it you can write code such as "when this timer fires do this
thing".
Discussion
setup()
) but the code in
loop()
is actually shorter; fewer lines. It doesn't matter much in
programs this small, but as complexity increases, you want code to remain
concise for clarity, and you'll find fixed overhead even when long just isn't
that distracting.
ILLUSTRATION #4: blink two LEDs: SRTimer_example_4
Explanation
delay()
. Here, it is easy, and by example 5, trivial.
Discussion
SRL.tally()
shows that blinking two LEDs is
takes longer than one, not quite twice as long, or 28 microseconds. The number
of iterations/second has dropped from 50,000 to 35,000, accordingly. Maximum is
about the same, because SRLoopTally()
's overhead doesn't change.
switch
statement often problematic, and considered prone to
grammatical errors. It is also very useful at times, and can be extremely
clear. Long if ... then ... else if ...
chains, clearly indented
and labelled, work also. Effort used to style code for readability is worth
it.
ILLUSTRATION #5: objects: SRTimer_example_5
Explanation
.h
file. This means the IDE doesn't automatically include it as
part of your program, you must do it with an explicit #include
statement, as shown here. This lets you "insert code here" where you want it
rather than where the IDE wants it. In this case it matters.
setup()
for doing one-time things and a loop()
meant
to be invoked over and over to accomplish the work.
loop
to serve as an executive, supervisor
program that does no or little work itself; it simply serves to execute each of
the task loops' loop()
code. SRL.tally()
remains in
the main module, and provides overview statistics on your program.