MPCA Mini Project — Embedded Systems Simulation
This project encodes and decodes Morse code. You type plain text (like HELLO), and the system converts it to Morse, blinks a virtual LED on screen, and draws a signal waveform. You can also go the other way — type dots and dashes and get the decoded text back.
What makes it interesting is how it's built. Instead of one simple Python script doing everything, it's split across four layers that each do one job:
- A Python GUI — what you see and interact with
- A C program — handles the Morse alphabet and timing math, written in ARM embedded style
- A Verilog module — simulates how a real hardware chip would generate the signal using clock cycles
- A Python bridge — translates between the C output and the Verilog output so the GUI can display both
All four layers talk to each other by reading and writing plain text files inside a shared/ folder. No networking, no imports across layers — just files.
This project was built by four members, each responsible for one layer:
| File | Who wrote it | What they were asked to do |
|---|---|---|
arm/main.c |
Member 1 | Write ARM-style C firmware for encoding and decoding |
arm/mem_map.h |
Member 1 | Define memory-mapped register addresses and constants |
verilog/morse_core.v |
Member 2 | Build the hardware timing module in Verilog |
verilog/testbench.v |
Member 2 | Write a test harness that simulates SOS and dumps a waveform |
bridge/sim_bridge.py |
Member 3 | Build the translation layer between C, Verilog, and GUI |
gui/morse_gui.py |
Member 4 | Build the Tkinter dashboard with LED animation and waveform |
run_all.bat |
Member 1 | One-click setup and test script for Windows |
gui/morse_gui.py — The dashboard. Run this to start the app.
arm/main.c — C firmware. Compile this to get the encoder/decoder binary.
arm/mem_map.h — Header included by main.c. Defines registers and Morse types.
verilog/morse_core.v — Verilog hardware module. Compiled by Icarus Verilog.
verilog/testbench.v — Verilog test harness. Feeds SOS into morse_core and checks output.
bridge/sim_bridge.py — Translation layer. Called by the GUI and usable on its own.
run_all.bat — Windows batch script. Compiles, runs, and tests everything.
You never write these by hand. They are created when the program runs. This is how each layer leaves "notes" for the next one.
shared/input.txt ← GUI writes the plain text you typed (e.g. "HELLO")
shared/morse_timing.txt ← ARM C writes the pulse sequence (e.g. "HIGH 1", "LOW 3")
shared/morse_wave.vcd ← Verilog writes the waveform dump (standard VCD format)
shared/output.txt ← Bridge writes the final signal list for the GUI (e.g. "HIGH,100")
shared/vcd_verify.txt ← Bridge writes "MATCH" or "MISMATCH" after comparing both layers
shared/morse_in.txt ← GUI writes the dot-dash string when decoding (e.g. "... --- ...")
shared/morse_wave_decoded.txt ← Bridge writes pulse durations for ARM C to decode
shared/decoded.txt ← ARM C writes the decoded plain text result
Say you type SOS and click Encode & Simulate:
- GUI writes
SOStoshared/input.txt - GUI runs
morse_arm.exe --encode(the compiled C program) - ARM C reads
input.txt, looks up each letter in its Morse table, and writes the pulse sequence toshared/morse_timing.txt:HIGH 1 LOW 1 HIGH 1 LOW 1 HIGH 1 LOW 3 HIGH 3 ... - GUI runs
iverilogandvvpto simulate the Verilog hardware — this producesshared/morse_wave.vcd - The bridge reads both
morse_timing.txtandmorse_wave.vcd, converts the timing units to milliseconds, and writesshared/output.txt:HIGH,100 LOW,100 HIGH,100 ... - The bridge also compares the ARM timing against the Verilog waveform and writes
MATCHorMISMATCHtoshared/vcd_verify.txt - GUI reads
output.txtand animates the LED — yellow when HIGH, dark grey when LOW — and draws the waveform
Say you type ... --- ... and click Decode:
- GUI writes
... --- ...toshared/morse_in.txt - Bridge converts dots and dashes to pulse durations and writes
shared/morse_wave_decoded.txt:HIGH 100 LOW 100 HIGH 100 LOW 300 HIGH 300 ... - GUI runs
morse_arm.exe --decode - ARM C reads
morse_wave_decoded.txt, measures each HIGH duration (≤150ms = dot, ≥250ms = dash), builds up letters, and writesSOStoshared/decoded.txt - GUI reads
decoded.txtand displays the result
The C code is written in ARM embedded style. The key trick is a compile-time switch in mem_map.h:
#ifdef ARM_TARGET
// On real hardware: write directly to a memory-mapped address
#define LED_REG (*(volatile uint32_t*)0x40000000)
#else
// On desktop: write to a file instead
fprintf(morse_timing_file, "HIGH 1\n");
#endifWhen you compile with gcc main.c, it runs in desktop simulation mode and writes files. The same code compiled with -DARM_TARGET on a real ARM board would toggle a hardware LED at address 0x40000000.
The Morse lookup table is a simple array of structs covering A–Z and 0–9:
const MorseEntry morse_table[] = {
{'A', ".-"}, {'B', "-..."}, {'S', "..."},
{'0', "-----"}, {'1', ".----"}, // ... all 36 entries
};Command-line flags the binary accepts:
--encode→ readsshared/input.txt, writesshared/morse_timing.txt--decode→ readsshared/morse_wave_decoded.txt, writesshared/decoded.txt--encode-legacyand--decode-legacy→ older direct-write mode, kept for compatibility
This simulates a digital circuit as it would behave on a real chip. It has two modes controlled by the mode input.
Encode mode (mode = 0):
- Receives one symbol at a time via
data_in(0 = dot, 1 = dash, 2 = letter gap, 3 = word gap) - Holds
led_outputHIGH for exactly 100 clock cycles per timing unit - Pulses
done = 1for one clock cycle when the symbol is finished transmitting
Decode mode (mode = 1):
- Monitors
signal_in(a stream of HIGH/LOW pulses) - Counts how many clock cycles
signal_instays HIGH - If the count is ≤ 200 → it's a dot,
dot_outfires - If the count is ≥ 201 → it's a dash,
dash_outfires - If
signal_instays LOW for > 200 cycles →letter_donefires - If
signal_instays LOW for > 401 cycles →word_donefires
The timing thresholds are parameters at the top of the file, easy to read and change.
The testbench (testbench.v) is a self-contained simulation. It:
- Feeds S (dot dot dot) then O (dash dash dash) then S again into encode mode
- Then feeds a full SOS pulse stream into decode mode
- Counts detected dots, dashes, and letters
- Prints
PASSif the counts match what's expected,FAILif not - Saves the full waveform to
shared/morse_wave.vcd
The bridge has three jobs:
1. Convert units. ARM C writes timing in units (1, 3, 7). The GUI needs milliseconds. The bridge multiplies: 1 unit = 100ms.
ARM file: "HIGH 3" → output.txt: "HIGH,300"
ARM file: "LOW 7" → output.txt: "LOW,700"
2. Parse the VCD waveform. VCD files use picoseconds and a symbol-based format that nothing else understands directly. The bridge finds the led_output signal in the VCD, reads its transitions, and converts them to (state, milliseconds) pairs the GUI can use.
3. Verify the match. After both ARM C and Verilog run, the bridge compares their HIGH pulse durations. If they match within 15% tolerance, it writes MATCH to vcd_verify.txt. Otherwise it writes MISMATCH.
The bridge also exposes plain Python functions (encode_morse, decode_morse) used by the GUI as a fallback when GCC or Icarus Verilog are not installed.
Two tabs: Encode and Decode.
Encode tab has:
- A text input box
- An "Encode & Simulate" button that runs the full pipeline
- A circular LED — animates yellow (HIGH) and dark grey (LOW) as each pulse plays
- A Morse text label showing the dot-dash pattern like
... --- ... - A waveform canvas — green bars for HIGH, gaps for LOW, time in ms on the x-axis
- A VCD verify badge — green MATCH or red MISMATCH
Decode tab has:
- A Morse input box (type dots and dashes with spaces between letters)
- A "Decode" button
- A scrollable log that appends each decoded result
All subprocess calls (compiling, running ARM binary, running Verilog) happen in a background thread so the GUI doesn't freeze. Results are passed back to the GUI thread using root.after(). If the binaries aren't found, the GUI automatically uses the Python functions in sim_bridge.py as a fallback.
| Tool | What it's for | How to get it |
|---|---|---|
| Python 3.8+ | GUI and bridge | python.org — standard library only, no pip needed |
| GCC | Compile arm/main.c |
MinGW-w64 on Windows, or already installed on Linux/macOS |
| Icarus Verilog | Simulate the Verilog files | iverilog.icarus.com |
| GTKWave (optional) | View the .vcd waveform file |
gtkwave.sourceforge.io |
Check your tools are working:
python --version
gcc --version
iverilog -vDouble-click run_all.bat. It handles everything: creates folders, compiles the C code, runs a test SOS message through the whole pipeline, and prints the result.
python gui/morse_gui.pycd arm
gcc main.c -o morse_armOn Windows with MinGW:
gcc main.c -o morse_arm.execd verilog
iverilog -o morse_sim testbench.v morse_core.v
vvp morse_simgtkwave shared/morse_wave.vcdIn GTKWave, add clk, led_output, done, dot_out, and dash_out from the signal list to see what the hardware is doing.
python bridge/sim_bridge.py encode "SOS"
python bridge/sim_bridge.py decode "... --- ..."
python bridge/sim_bridge.py testEverything is measured in "units". 1 unit = 100ms = 100 Verilog clock cycles.
| Signal | Duration |
|---|---|
| Dot | HIGH for 1 unit (100ms) |
| Dash | HIGH for 3 units (300ms) |
| Gap between dots/dashes in the same letter | LOW for 1 unit (100ms) |
| Gap between letters | LOW for 3 units (300ms) |
| Gap between words | LOW for 7 units (700ms) |
Example — what SOS looks like in shared/morse_timing.txt:
HIGH 1 ← S: dot 1
LOW 1
HIGH 1 ← S: dot 2
LOW 1
HIGH 1 ← S: dot 3
LOW 3 ← letter gap (S is done)
HIGH 3 ← O: dash 1
LOW 1
HIGH 3 ← O: dash 2
LOW 1
HIGH 3 ← O: dash 3
LOW 3 ← letter gap (O is done)
HIGH 1 ← S: dot 1
LOW 1
HIGH 1 ← S: dot 2
LOW 1
HIGH 1 ← S: dot 3
LOW 7 ← word gap (end of word)