How they talk¶
Two computers, no shared memory. To cooperate they must send bytes down a wire. This page is about that wire, the two extra wires that make it usable, and why "just send the data" is harder than it sounds.
Serial, from the bottom¶
The connection is a UART — a universal asynchronous receiver/transmitter. Strip the name and it is this: one wire for each direction, and a byte is sent by wiggling the voltage on it in a pattern both ends have agreed on.
There is no clock wire. That is what "asynchronous" means, and it is the reason both ends must agree in advance on how fast to wiggle. That speed is the baud rate, and ours is 115200 — roughly 115,200 signal changes per second, about 11 KB/s of actual data.
If the two ends disagree about the baud rate, you do not get an error. You get
garbage: the receiver samples at the wrong moments and reconstructs bytes that
were never sent. A screenful of ÿ<ÿ?ÿ almost always means a baud mismatch, not
a broken cable.
On Linux the wire appears as a file:
Reading that file gives you bytes the microcontroller sent. Writing to it sends bytes back. That really is the whole interface.
The two wires nobody documents¶
Here is where this board bites people, and it is the reason hardware.md exists.
Two GPIO pins on the Linux side control the microcontroller. They are on
gpiochip1, and they are documented nowhere official:
| Line | What it does | What you see when it is wrong |
|---|---|---|
| 37 | MCU BOOT0 | Firmware flashes and verifies, but never runs |
| 70 | UART link enable | MCU transmits, /dev/ttyHS1 reads nothing |
BOOT0 decides what the microcontroller runs when it starts. High, and the chip boots a small program burned into it at the factory that waits for someone to send it new firmware. Low, and it runs your program. The pin is only sampled at reset, so it must be set before the chip restarts, not after.
The failure this produces is nasty because everything reports success. Your firmware writes correctly. Verification passes. The chip is simply running a different program — the factory bootloader — which does not blink your LEDs.
The link enable turns on the path between the two chips. Without it the microcontroller happily transmits into a disconnected wire, and Linux reads an eternally empty file. Both sides are working. No error is available anywhere.
unoq-link.service sets both at boot:
This is the single most useful thing on this page. If the board ever looks dead — flashed fine, running nothing; or connected fine, saying nothing — suspect these two pins before anything else.
Bytes are not messages¶
You have a wire that carries bytes. You want to send a value. Those are not the same thing, and the gap is called framing.
Say the microcontroller sends the number 1234. You read the file and get 12.
Is that the whole message? Is more coming? Has 34 been lost, or is it merely
late? The wire cannot tell you — bytes arrive when they arrive, in any grouping.
sequenceDiagram
participant M as MCU
participant W as the wire
participant L as Linux
M->>W: "1234\n"
W-->>L: read() → "12"
Note over L: is that all?<br/>is more coming?<br/>was 34 lost?
W-->>L: read() → "34\n"
Note over L: the newline is the<br/>only thing that said<br/>"that was a message"
Every serial protocol solves this somehow:
- A terminator: send text and agree that a newline ends a message. Simple, human-readable, and what a shell does.
- A length prefix: say how many bytes are coming, then send exactly that many.
- Fixed size: agree every message is exactly 8 bytes.
This project uses the first. The microcontroller runs a shell, the same one you typed at in The microcontroller side, and the "protocol" is: send a command line, read text back until the prompt returns.
/* mcu/app/include/app_proto.h - the format both sides agree on */
#define APP_STATUS_FMT "uptime_ms=%lld flip=%d sweeps=%u"
The MCU prints that; Python parses it. It is not the fastest possible design,
and that is a deliberate trade: you can debug it by hand with tio, which
matters far more here than saving bytes.
Why the Python side is more complicated than you would expect¶
Reading a reply sounds like read() and done. In practice
unoq/mcu.py copes with all of this:
- The shell echoes your command back, so the first thing you read is what you just sent, not the answer.
- Output is wrapped in ANSI escape codes for color, which must be stripped.
- Bytes dribble in. One
read()rarely returns a whole reply; you must keep reading until you see the prompt. - The reply might never come. If the MCU is reset, or held in the wrong boot mode, you wait forever unless you impose a timeout.
That last point is why the project's tests fake the serial port deliberately badly — echoing commands, wrapping output in escape codes, dribbling bytes a few at a time. A fake that answers instantly and perfectly would let broken code pass.
Try it: have the conversation by hand¶
First be the Python library yourself. Stop the demo, which is holding the wire,
and open it in tio, the serial terminal:
Press Enter to get a prompt, then type the command the library sends:
Your numbers will differ. Everything on that screen crossed the wire: the
characters you typed came back as the shell's echo, then the reply line in
exactly the format APP_STATUS_FMT promises, then the prompt that says the
reply is over. Leave tio with Ctrl-t q.
Now let Python do the same thing:
cd ~/two-computers-one-board
./.venv/bin/python -c "
from unoq import MCU
with MCU() as mcu:
print(mcu.status())
"
sudo systemctl start unoq-cpu-bars
It prints the same three fields — uptime_ms, flip and sweeps — as a
Python dictionary. The library sent the line you typed, threw
away the echo and the color codes, read until the prompt came back, and split
what was left on spaces and =.
One after the other, and not side by side in two windows, for a reason: only
one program can hold the wire at a time. The library opens /dev/ttyHS1
exclusively, and a second program that tries is refused with a message saying
the port is taken. That is deliberate. Two readers on one serial line each get
fragments of the other's conversation, which is a far worse failure than a
clean refusal. It is also why the exercise starts by stopping the demo.
The other direction¶
So far Linux asks a question and the MCU answers it. The demo sends data the other way: twice a second, Linux measures how busy each core is and pushes four percentages down the wire. The MCU decides which pixels that means.
Notice that the MCU still answers. Every frame gets an ok bars=4 back, and the
Python side waits for it — and for the prompt after it — before it does anything
else. Is that wait worth having?
Here, yes. At two frames a second there is half a second between frames and the round trip is a small fraction of that, so waiting costs nothing you could see. And the reply buys something: it is the only thing that tells Linux the other end is still there. If the MCU has been reset, or the link has gone down, the wait times out and the program finds out, instead of carrying on sending frames to nobody.
Now change the numbers. At a thousand frames a second the same choice would be wrong: waiting for each reply would cap the frame rate at the round-trip time of the wire, and a dropped frame would not matter, because the next one replaces it a millisecond later. There you would stream the frames and not wait.
Choosing whether a message needs an answer is a real design decision. The right answer depends on how often you send, what a lost message costs, and what the reply tells you that you could not learn otherwise.
Go deeper — Beej's Guide to Network Programming is nominally about sockets and is the friendliest explanation anywhere of the lesson on this page: bytes are not messages. For framing schemes with a proper worst case, read Consistent Overhead Byte Stuffing; for why a checksum you invented is weaker than you think, Ross Williams' Painless Guide to CRC Error Detection. SparkFun's serial tutorial covers the electrical layer this page skims over.
Check yourself¶
/dev/ttyHS1reads nothing at all, and the MCU is definitely running. Which of the two control pins do you suspect, and why?- Your terminal fills with
ÿ?ÿ<. What is wrong, and is it a hardware fault? - The demo waits for the MCU to confirm every frame. What does that wait cost here, and what does it buy? When would waiting be the wrong choice?
Answers — try each one first
- Line 70, the UART link enable. Without it the microcontroller transmits into a disconnected wire and Linux reads an eternally empty file, with no error anywhere. BOOT0 (line 37) wrong looks different: the firmware flashes and verifies but never runs.
- A baud rate mismatch, and no, it is not a hardware fault. The receiver is sampling at the wrong moments and reconstructing bytes that were never sent. Both ends must agree on the speed in advance; ours is 115200.
- It costs almost nothing and it buys knowing the link is alive. At two frames a second the round trip is a small part of the half second between frames, and a reply that never comes is how Linux learns the MCU has gone away. Waiting would be the wrong choice at a high frame rate — a thousand a second, say — where it would cap the rate at the round-trip time of the wire and where a lost frame is replaced almost at once anyway.
Next: From source to a running chip — turning source code into something the chip can run, and getting it there.