A terminal simulation of a multiprogrammed operating system: a round‑robin CPU scheduler with resource‑allocation deadlock avoidance, a fixed‑size memory allocator, and separate I/O and resource‑wait queues. It runs as a live ncurses dashboard, or headless across every core you have.
The program began life in the 1990s as a university project for MS‑DOS /
Turbo‑C, using the BGI graphics library (libregra.h) and a mouse library
(raton.h). It was first ported to Linux as a single file, simso.c, with
the DOS graphics layer swapped for ncurses and the scheduling logic preserved
verbatim.
This version refactors that port into a modular, reentrant, concurrent
program. simso.c is kept, unchanged, as the reference implementation.
build.sh builds the legacy simso.c with Fil-C
build-filc.sh builds the modular src/ tree with Fil-C
Makefile builds the modular src/ tree with the system compiler
src/sim.h sim.c scheduler core — no I/O, no globals, fully reentrant
src/rng.h per-instance PRNG (xorshift64*), so runs replay exactly
src/shared.h shared.c the sim-thread ↔ UI-thread handshake
src/ui.h ui.c ncurses dashboard (renderer + input)
src/bench.h bench.c parallel Monte-Carlo sweep
src/main.c argument parsing and thread orchestration
tests/check.c invariant sweep over seeds and parameters
simso.c the original single-file port, kept for reference
The split is the point of the refactor. In simso.c the scheduler and the
renderer were the same code: round_robin() called imprimir_colas(), which
drew a frame, slept 110 ms and polled the keyboard. Every piece of state was a
global. That had three consequences — the simulation could not run at any
speed other than the animation's, input was only sampled wherever the
scheduler happened to draw, and the process could not hold two simulations at
once.
sim.c now knows nothing about ncurses. Where the original drew a frame, the
engine calls an observer callback and lets the caller decide what that means.
Everything else follows from that one change.
Interactive mode runs two threads:
| thread | role |
|---|---|
| sim | runs sim_run(). At each former imprimir_colas() point it copies its state into a shared snapshot, bumps a sequence number, and parks itself for the configured step delay — or indefinitely while paused. |
| UI (main) | wakes on its own ~60 Hz timer, takes the latest snapshot under the lock, releases the lock, and draws. Input is read every frame. |
The mutex is held for two struct copies and nothing else; all drawing and all sleeping happen outside it. Because input no longer depends on the scheduler reaching a draw call, pause, single‑step, speed control and quit respond immediately even while the engine is parked.
--bench N runs N independent simulations across worker threads. Each run is a
closed system with its own sim_t and its own rng_t, so nothing is shared
except the few microseconds of handing out the next run index.
threads wall (200k runs) runs/s speedup
1 3.509s 57,002 1.0×
2 1.770s 113,012 2.0×
4 1.002s 199,691 3.5×
8 0.590s 338,889 5.9×
16 0.488s 410,016 7.2×
(16 logical cores, 8 physical.)
This is what the refactor bought: the original could not have run two simulations in one process at all. It also makes the simulator useful for something the animated view cannot do — the scheduler is stochastic, so a single run tells you very little, while a few hundred thousand tell you the deadlock rate and how a parameter change actually moves it.
make # release build -> ./simso
make debug # -O0 -g3
make asan # AddressSanitizer + UBSan
make check # invariant sweep (see Testing)
make bench # 500-run parallel sweepRequires ncurses with wide‑character support (libncursesw-dev on
Debian/Ubuntu) and pthreads. The build is warning‑clean under
-Wall -Wextra -Wshadow -Wpointer-arith -Wstrict-prototypes -Wmissing-prototypes.
Fil-C is a memory‑safe C implementation: it enforces
bounds and use‑after‑free checks at runtime and aborts with a filc panic
rather than corrupting memory. Running this simulator under it is a standing
check on the hardening work — the DOS original would almost certainly have
tripped it on the first run.
./build-filc.sh # -O2 -g, keeps Fil-C's panic backtraces useful
./build-filc.sh --debug # -O0 -g3
./build-filc.sh --release # -O2, stripped (no backtraces)
./build-filc.sh -o simso-filc # choose the output name
./build-filc.sh --clean
CC=gcc ./build-filc.sh # same script, ordinary compilermake filc is a shortcut for ./build-filc.sh.
- Finds
filcconPATHor in the usual install directories, and fails with install instructions rather than quietly falling back to an ordinary compiler — a silent fallback hands you a binary you believe is memory‑checked but isn't. - Probes which ncurses the toolchain actually has. The dashboard draws
multi‑byte Unicode block glyphs, and only the wide‑character build
(
ncursesw) measures their column width correctly; a narrow build renders each glyph as several garbled columns. If a pizfix ships only the narrowncurses, the script definesSIMSO_FORCE_ASCIIand the display falls back to ASCII glyphs at build time instead of breaking at run time. - Probes warning flags one at a time, because Fil-C's frontend does not accept every GCC warning switch, and a rejected one would otherwise fail the whole build.
- Rebuilds incrementally, tracking both
.cand.htimestamps, and stamps objects with the compiler and flags they were built with — so switching between--debugand--releaseforces a full rebuild instead of silently relinking the other variant's objects.
Keep -g (the default) while developing — Fil-C's panic backtraces are far
more useful with debug info. To exercise the scheduler hard without tying up
a terminal:
./simso --bench 5000 --allFil-C was not installed on the machine this script was written on, so the
filcc path itself is unproven. Everything else was verified by driving
the identical code path with CC=gcc:
| checked | how |
|---|---|
| compiler discovery | PATH lookup and the install‑directory search |
| missing‑compiler error | run with no filcc present |
| ncurses probe | selects -lncursesw where both exist |
| ASCII fallback | built with SIMSO_FORCE_ASCII, confirmed no Unicode in the rendered frame |
--debug / --release |
218,624 → 47,800 bytes stripped |
--clean, --help, -o |
each exercised |
bad option, missing -o argument |
both exit non‑zero with a message |
| incremental rebuild | .c edit rebuilds one object; .h edit rebuilds all |
| flag invalidation | --release after --debug rebuilds rather than relinking |
What remains untested is whether filcc accepts these exact flags. If it
rejects one, the script reports the file and the compiler's own error rather
than failing opaquely.
build.sh, build-filc.sh and make all write ./simso by default, but
build.sh builds the legacy simso.c while the other two build src/.
Running build.sh after either of the others replaces the modular binary
with the single‑file one. Use build-filc.sh -o <name> if you want both
side by side.
./simso is also still a tracked file in git (the old committed binary), so
every build shows up as a diff. You probably want git rm --cached simso
plus a .gitignore entry for simso, build/, *.o and tests/check.
./simso # prompts for quantum, I/O time, mode
./simso -q 10 -i 5 --all # straight to a run
./simso --seed 4242 --paused # reproducible, start paused
./simso --bench 5000 -j 8 # headless, 8 threads
./simso --bench 5000 --banker # with real Banker's avoidance
./simso --help| key | action |
|---|---|
space |
pause / resume |
n |
single step |
+ / - |
faster / slower |
0 |
uncapped speed |
1 |
slow |
q |
quit |
- Memory — 64 cells coloured by owning process, with occupancy and free‑region count.
- Resources — A/B/C pools as proportional bars, colour‑coded by pressure.
- Processes — every PCB: state, priority, remaining‑execution bar, memory, per‑resource holdings, pending I/O bursts, CPU ticks consumed.
- CPU timeline — a scrolling Gantt strip, one cell per tick, coloured by the process that held the CPU.
- Queues — ready, I/O, resource‑wait and waiting, with the dispatch head marked.
- Events — a scrolling log of admissions, dispatches, preemptions, blocks and exits.
The layout is computed per frame from the terminal size, so resizing works.
At 80×24 the timeline is dropped and the tables shrink; given more room they
come back. Non‑UTF‑8 terminals (or --ascii) fall back to ASCII glyphs, and
256‑colour terminals get twelve distinct process hues where 8‑colour terminals
get six plus bold.
make check sweeps the engine across 3,000 seeds × 5 quanta × 4 I/O times ×
2 termination modes (120,000 runs, a few seconds) and asserts:
- no process is duplicated in a queue, or queued after terminating;
- terminated processes hold no memory and no resources;
available + held == totalfor every resource, and never negative;- memory occupancy stays in range;
all processes finishedreally means every PCB terminated;- no run stalls, and no run needs the lost‑process invariant repair.
A sample of runs is checked at every observer callback, not just at the end. 600,000 runs pass; the same sweep is clean under ASan + UBSan.
This harness is how all of the bugs below were found. None of them are reachable by watching a single animated run — that is exactly why the headless parallel mode earns its place.
The scheduling model is the original's. Structure changed throughout; the
following behaviour changes are deliberate, and each is marked CHANGE:
at its site in src/sim.c.
Lost processes. Two separate paths dropped a runnable process on the
floor, leaving it PS_READY while belonging to no queue — the dispatcher kept
counting it as runnable and kept failing to find it, and the run idled forever
with that process never finishing.
- The admission step ran between
compactar_cola_preparados()and the outcome dispatch, and took the highest free ready slot — precisely the slot the compaction had just freed for a preempted process to return to. When admission won the race, the preempted process was written nowhere. Admission now runs after the outcome dispatch, so the slot is reserved by construction. - When a process arrived from the resource queue it parked the current queue
head with
for (i = 4; i != 0; i--), which stops at 1. If slot 0 was the only free one, the old head was parked nowhere and the next line overwrote it. The scan now covers slot 0.
The ready queue could not refill itself. Admission only ever happened
inside round_robin(). Once the ready queue drained while processes were
still waiting — every resident process having finished or gone to I/O —
nothing could refill it, so round_robin() was never reached again and the
admission step it owned never ran. The idle path now admits too.
A dispatch state that spun without drawing. The dispatch loop tested only
cola_preparados[4]. A ready queue whose head had been vacated, while slots
0–3 still held runnable processes and the resource queue could not be
satisfied, fell through the bottom of the outer loop unchanged and came back
to the identical state — forever, on a frozen screen, since that path drew
nothing. The queue is now rotated so a queued process reaches the head.
Head‑of‑line blocking on resources. recursos() retried only res_q[0],
so a process whose request could never be met blocked every process behind it
indefinitely. The whole queue is now scanned; order is still respected, in
that the earliest satisfiable request wins.
Corrupt rollback on a refused request. evitacion() unwound a partial
grant with for (k = j-1; k >= 0; k--) { asignados[j] -= solicitados[j]; … }
— indexing by j inside a loop over k. It repeatedly adjusted the one
resource that had not been granted, j times, and left the resources that
had been granted applied. Every refusal corrupted alloc[]/max[]. The
rollback is now correct, and verified by the resource‑conservation invariant.
I/O completion dropped the wrong entry. compactar_cola_es() always
removed the head of the I/O queue, whichever slot had actually finished its
service. Correct only while bursts complete in strict arrival order, and
silently wrong otherwise — losing one process and servicing another twice.
Removal is now by index.
Deadlock detection missed the interesting case. error_sistema() asked
"is every queue empty except the resource queue?", which only recognises
deadlock in its most degenerate shape. It cannot see a circular wait that runs
through memory: processes whose I/O has finished but which cannot be swapped
back in for lack of free memory, where that memory is held by processes
blocked on resources held by the first group. Every queue is non‑empty there,
so the original reported no error and idled forever. The engine now enumerates
the ways forward instead of pattern‑matching on empty queues; the old
condition is a strict subset. Roughly 0.02% of runs are genuine deadlocks.
Termination mode 2. The original used 0 both to mean "run until all
done" and as the finite‑mode countdown, so its numero == 0 test fired after
the first quantum and mode 2 exited immediately. The mode is now a separate
flag. (The previous README documented this as a known quirk; it is fixed, and
--all now genuinely runs to completion.)
Unbounded scans. The downward slot searches (while (q[c] != 'z') c--)
ran off the bottom of their arrays when the invariant they assumed did not
hold. All are bounded, and the not‑found case is handled.
rand()→ per‑instance xorshift64*.rand()is process‑global: two engines on two threads would fight over one hidden state and neither run would be reproducible. A(seed, parameters)pair now replays exactly.rand() % rand()→ uniform draws. The original's idiom produced a heavily skewed distribution and could divide by zero (Borland's RNG never hit it by luck; glibc raisesSIGFPE). Selections are now uniform.- The waiting queue is FIFO. The original closed gaps by swapping in entries from the tail, silently reordering the queue, so admission order depended on how many gaps had opened. Compaction is now order‑preserving, which is what the rest of the code already assumed.
- A real Banker's algorithm is available. The original called itself
"Banker's‑style" but only tested
request <= available, which is the admission test, not the avoidance algorithm — it cannot prevent the deadlock it is named after.--bankeradds the actual safety‑sequence check. It is opt‑in; the default remains faithful to the original. - A stall valve and an invariant repair. Both are backstops that should
never fire, and
make checkasserts they don't. They exist because an unattended batch of 200,000 runs cannot be rescued with ctrl‑C.
The three "empty slot" markers ('\0' in the waiting and I/O queues, 'z' in
the ready and resource queues, and the '\0' briefly parked in ready_q[4]
to mean "this slot's process is on the CPU") are not interchangeable, and
the downward scans depend on telling them apart — they walk straight past a
'\0' looking for a 'z'. Normalising them changes which slot those scans
land on, so they are kept exactly, and documented in sim.h.
Original DOS/Turbo‑C project by Eduard G. Castello and Llorenç Llado. Linux/ncurses port, then this modular concurrent refactor, applied to the original source.