NESH Developer Manual
Developer manual

Inside NESH

Everything a programmer needs to understand, build, test and extend NESH: architecture, build pipeline, platform layer, shell core, the BASIC interpreter, the UEFI modules and the project's conventions.

Version 0.2.0 Language C (gnu11), Python 3 tools Size ~23,400 lines of C Target UEFI x86-64 · Linux host build for tests

Contents

Press / to search. The index lists files, functions and topics.

Overview

What NESH is from the inside, the principles that shaped it, and a map of this manual.

What this manual covers

The user manual explains what NESH does. This manual explains how. After reading it you should be able to find your way in every source file, build and test the shell, add commands and BASIC functions, and change the firmware-facing parts without breaking the others. It assumes you know C; UEFI concepts are explained where they are needed, and the glossary summarizes them.

Design principles

1

One file

The whole shell is one PE32+ image, nesh.efi: nothing to install, one signature for Secure Boot.

0

No EDK2 code

No EDK2 libraries or headers. UEFI definitions, libc, printf, compression and the UEFI Shell commands are written from scratch.

Portable core

The shell and the interpreter only talk to a small platform layer (PAL). The same core builds for UEFI and for Linux.

Test on Linux first

The Linux build runs the language and command tests in seconds; QEMU with OVMF firmware tests the UEFI parts.

Compatible, not a copy

UEFI Shell command names, options, variables and the EFI_SHELL_PROTOCOL are honored, so existing tools and habits keep working.

?

Docs are code

The command reference is generated from the help texts, and the manual's examples are run by the test suite.

The project in numbers

AreaDirectoryLinesContent
Runtime librarysrc/lib1,523libc subset, printf, string buffer, UTF-8, UEFI compression
Platform layersrc/pal1,831PAL interface, UEFI and Linux implementations
Shell coresrc/core2,782command execution, console, environment, aliases, line editor, menus
BASICsrc/basic4,019lexer, parser, interpreter, built-in functions
Portable commandssrc/cmd3,336files, text, utilities, editors
UEFI modulessrc/platform/efi10,215shell protocol, variables, boot, drivers, disks, hardware, network
UEFI definitionsinclude724UEFI types and protocols used by NESH
Tools and teststools, tests3,104ELF→PE converter, QEMU runner, doc generators, test scripts

The resulting nesh.efi is about 360 KB. The line counts here and in the source file map are not typed by hand: make docs counts the files and writes them into this page, and make test fails when they no longer match the tree — a new source file without a row in the map fails as well.

Source tree and tools

Repository layout

PathContent
MakefileAll build and test targets.
include/efi.h, include/efi_shell.hUEFI types, services and protocols (written for NESH), EFI_SHELL_PROTOCOL.
src/lib/Runtime: rt.h (the C subset every module includes), libc.c, fmt.c (vsnprintf), util.c (Sbuf, UTF-8, glob, parse_int), eficomp.c (UEFI compression).
src/pal/pal.h (the platform interface), pal_efi.c, pal_host.c, pal_common.c, efi_glue.h (UEFI globals for EFI-only modules).
src/core/shell.c (registry, execution, paths, REPL, nesh_main), con.c (output sinks, keys), env.c, alias.c, lineedit.c, ui.c (menu).
src/basic/lexer.c, parser.c, interp.c, bfuncs.c, basic.h, interp_int.h.
src/cmd/Portable commands: cmd_core.c (help, ver, echo…), cmd_fs.c (files, map…), cmd_text.c, cmd_util.c, cmd_edit.c (edit, hexedit).
src/platform/efi/UEFI-only modules (see UEFI modules).
src/platform/host.cStubs of the platform hooks for the Linux build.
tools/efi.lds, elf2efi.py, run-qemu.sh, gen-docs.py, check-doc-examples.py, mkusb.sh, record-demo.py, setup-dev.sh, backup.sh.
tests/host/*.nsb + .out (Linux tests), efi/*.nsb (QEMU tests), apps/shelltest.c, fixtures/ (EDK2 test binaries).
examples/Example scripts shipped with NESH.
docs/This manual, the user manual, decisions-and-history.md, assets/ (CSS, JS, Mermaid).

Tools needed

ToolUsed for
GCC (x86-64) and GNU ldBoth builds. No cross-compiler is needed: the EFI image is linked as an ELF shared object and converted.
Python 3elf2efi.py and the documentation tools.
QEMU (qemu-system-x86_64) and OVMFUEFI tests. OVMF= selects the firmware image, a single-file OVMF.fd (default: the first of /usr/share/ovmf/OVMF.fd and /usr/share/OVMF/OVMF.fd that exists).
mkfs.fat (dosfstools)The writable test disk of the QEMU runs.

Make targets

TargetDoes
makeBuilds build/nesh.efi and build/nesh-host (Linux build with AddressSanitizer and UBSan).
make testHost tests, documentation consistency check, manual examples.
make qemuBoots NESH in QEMU with a serial console, interactively.
make qemu-testRuns tests/efi/run.nsb inside QEMU and checks the result.
make qemu-nettestNetwork tests (IPv4 and IPv6) inside QEMU with user networking.
make qemu-sbtestSecure Boot tests inside QEMU with test keys (see Secure Boot tests).
make docsRegenerates the command reference of the user manual from the help texts.
make cleanRemoves build/.
Daily loop

Change code → make test (seconds) → make qemu-test when the change touches UEFI code (about a minute) → make qemu-nettest when it touches the network.

Architecture

NESH is a stack of layers. Each layer only calls the layers below it, and everything that depends on the firmware is kept out of the portable core.

Layers

flowchart TB
  subgraph Portable["Portable (UEFI and Linux builds)"]
    CMD["Portable commands<br/>src/cmd"]
    BASIC["NESH BASIC<br/>src/basic"]
    CORE["Shell core<br/>src/core"]
    LIB["Runtime library<br/>src/lib"]
  end
  subgraph EFI["UEFI only"]
    PLAT["UEFI modules<br/>src/platform/efi"]
    PALE["pal_efi.c"]
  end
  subgraph HOST["Linux only"]
    PALH["pal_host.c + host.c"]
  end
  CMD --> CORE
  BASIC --> CORE
  PLAT --> CORE
  CORE --> PAL["pal.h interface"]
  PAL --> PALE
  PAL --> PALH
  CORE --> LIB
  PALE --> FW["UEFI firmware services"]
Layers and dependencies

Start-up sequence

sequenceDiagram
  participant FW as UEFI firmware
  participant EM as efi_main (pal_efi.c)
  participant NM as nesh_main (shell.c)
  participant PC as platform_cmds_init
  FW->>EM: StartImage(nesh.efi)
  EM->>EM: globals gST/gBS/gRT, watchdog off,<br/>TSC calibration, volumes, arguments
  EM->>EM: allocate a 1 MiB stack and switch to it
  EM->>NM: nesh_main(argc, argv)
  NM->>NM: register BASIC functions and portable commands
  NM->>PC: platform_cmds_init()
  PC->>PC: install EFI_SHELL_PROTOCOL,<br/>register UEFI commands and functions
  NM->>NM: cd to the boot volume, env_init, alias_init
  NM->>NM: parse -n / -c / -k / SCRIPT
  NM->>NM: banner, startup.nsb, REPL
  NM-->>EM: exit code
  EM->>EM: efi_exit_hook: uninstall the shell protocol
  EM-->>FW: EFI status
From the firmware to the prompt

Two details matter. First, UEFI only guarantees 128 KiB of stack and the interpreter is recursive, so efi_main allocates 1 MiB of pages and calls nesh_main on that stack (call_on_stack). Second, when NESH exits nothing may keep pointing into its image: efi_exit_hook uninstalls (or restores) the shell protocol.

EFI_STATUS EFIAPI efi_main(EFI_HANDLE image, EFI_SYSTEM_TABLE *st)
{
    gImage = image;
    gST = st;
    gBS = st->BootServices;
    gRT = st->RuntimeServices;
    gBS->SetWatchdogTimer(0, 0, 0, NULL);
    ...
    /* The interpreter is recursive: run on a private 1 MiB stack instead of
     * the firmware stack (UEFI only guarantees 128 KiB). */
    if (gBS->AllocatePages(AllocateAnyPages, EfiLoaderData, STACK_PAGES, &stack) == EFI_SUCCESS) {
        call_on_stack(main_on_stack, (void *)(uintptr_t)(stack + STACK_PAGES * 4096));
        ...
    if (efi_exit_hook)
        efi_exit_hook(); /* nothing may point into this image after it exits */
    return rc ? EFIERR(rc & 0xFF) : EFI_SUCCESS;
}

Life of a command line

flowchart TD
  L["line typed at the prompt"] --> B{"interp_is_basic_line?"}
  B -- yes --> I["interp_exec_interactive<br/>(may ask for more lines)"]
  B -- no --> S["shell_exec_line<br/>split words, extract > / >>"]
  S --> R{"redirection?"}
  R -- yes --> P["out_push_file"]
  R -- no --> X
  P --> X["shell_exec_argv"]
  X --> A{"alias?"}
  A -- yes --> X
  A -- no --> V{"fsN: alone?"}
  V -- yes --> CD["shell_chdir"]
  V -- no --> C{"built-in command?"}
  C -- yes --> D["strip -data, set data mode,<br/>call Cmd.fn(argc, argv)"]
  C -- no --> F["shell_find_executable<br/>(path, .nsb, .efi)"]
  F --> K{".nsb?"}
  K -- yes --> BR["basic_run_file"]
  K -- no --> EI["platform_run_image<br/>(efi_start_image)"]
What happens to a line

Scripts take the same route: RUN "cmd" calls shell_exec_line, RUN "cmd", a$ calls shell_exec_argv directly (no word splitting), and RUN$ does the same inside a capture sink.

The build

NESH is built with the ordinary Linux GCC, linked as a position-independent ELF shared object, and converted to a PE32+ EFI application by a small Python tool.

Setting up a machine

The repository is self-contained: the sources, the tests with their binary fixtures, the documentation and the tools that generate it are all committed, and nothing is fetched at build time. A clone plus the packages below is the whole development environment; build/ holds only products and is not committed.

$ git clone https://github.com/nic-fio/NG-EFI_SHELL.git
$ cd NG-EFI_SHELL
$ tools/setup-dev.sh --install
Package (Debian, Ubuntu)Needed for
build-essential, python3The build itself: GCC, GNU ld, make, and the Python tools (elf2efi.py, the documentation checks).
qemu-system-x86, ovmfmake qemu, make qemu-test: NESH running on real firmware inside a virtual machine.
ipxe-qemumake qemu-nettest: the network ROM of the virtual card.
dosfstools (≥ 4.2), mtoolsmake usb: the FAT filesystem of the disk image, and writing files into it without root.
sbsigntool, python3-virt-firmwaremake qemu-sbtest: signing the image and enrolling the test keys into OVMF.
ffmpegtools/record-demo.py, the animation of the README. Optional.

How the project is worked on — the agreements with its author, the decisions that are settled, what to check before a commit and where the project stands — is in CLAUDE.md at the root of the repository. tools/backup.sh writes the whole repository, history and tags included, into a single bundle file that clones back without a network.

What a clone does not carry

Two things live outside the repository and have to be set again on a new machine. The first is the git identity used for the public history, which is kept in .git/config: tools/setup-dev.sh sets it (NESH_GIT_NAME and NESH_GIT_EMAIL override the default). The second is anything stored on GitHub rather than in the tree — releases with their nesh.efi and nesh-usb.img, the Pages site, the Actions settings — all of which are rebuilt by make or by the workflow in .github/workflows/ci.yml anyway.

Pipeline

flowchart LR
  C["*.c"] -- "gcc -ffreestanding -fPIC<br/>-mno-red-zone -fshort-wchar" --> O["*.o"]
  O -- "ld -shared -Bsymbolic<br/>-T tools/efi.lds" --> SO["nesh.so<br/>(ELF, base 0)"]
  SO -- "tools/elf2efi.py" --> EFI["nesh.efi<br/>(PE32+, subsystem 10)"]
  C -- "gcc -DNESH_HOST<br/>-fsanitize=address,undefined" --> H["nesh-host<br/>(Linux)"]
From sources to the two executables

Compiler flags and why

FlagReason
-ffreestanding -fno-builtinNo hosted C library; GCC must not assume one.
-mno-red-zoneFirmware interrupt handlers run on the current stack and would overwrite the 128-byte red zone.
-fshort-wcharUEFI strings are UCS-2: L"..." must be 16-bit.
-fPIC -fvisibility=hidden + ld -BsymbolicThe image can be loaded at any address; with hidden symbols the only dynamic relocations left are R_X86_64_RELATIVE, which map 1:1 to PE DIR64 fixups.
-mgeneral-regs-onlyNo SSE/x87 code: the firmware does not promise that FPU state is usable, and NESH has no floating point.
-fno-stack-protector -fno-stack-checkNo runtime support for them in firmware.
-fno-asynchronous-unwind-tablesNo unwinding; smaller image.
-fno-tree-loop-distribute-patternsStops GCC from turning the loops of memset/memcpy into calls to themselves.
-fno-strict-aliasingUEFI structures are often reinterpreted through casts.

Warnings: -Wall -Wextra without -Wunused-parameter and -Wmissing-field-initializers. A clean build has no warnings; keep it that way.

The linker script

tools/efi.lds links at base address 0 with page-aligned .text, .rodata, .data (with the GOT) and .bss, and discards notes, comments and unwind tables. Base 0 makes ELF virtual addresses equal to PE relative virtual addresses, which keeps the conversion trivial. Page alignment gives each PE section its own memory protection (code read+execute, data read+write, never both).

elf2efi.py

The converter reads the ELF sections and writes a PE32+ image:

  1. keeps .text, .rodata (as .rdata), .data and .bss, checking page alignment;
  2. for every R_X86_64_RELATIVE relocation writes the addend into the section data and records a DIR64 fixup; any other relocation type is a build error (it means a symbol escaped the hidden visibility);
  3. builds a .reloc section with one block per 4 KiB page;
  4. writes the DOS stub, COFF header (machine 0x8664), optional header (subsystem 10 = EFI application, DllCharacteristics DYNAMIC_BASE | NX_COMPAT) and the section table.

Why not objcopy --target=efi-app-x86_64? It was tried first: it dropped sections and produced corrupted relocations with this layout. The converter is 170 lines, easy to audit, and fails loudly.

The freestanding runtime

The platform layer (PAL)

src/pal/pal.h is the contract between the portable code and the machine. Everything in it has two implementations: pal_efi.c and pal_host.c.

The contract

GroupFunctionsNotes
ErrorsPAL_OK, PAL_ENOENTpal_strerrorNegative codes; efi_to_pal maps EFI_STATUS.
Consolepal_con_write (UTF-8), pal_con_read_key, colors, cursor, sizeKeys arrive as PalKey: Unicode ch or a KEY_* scan code, plus modifiers.
Timepal_ticks_ms, pal_sleep_ms/us, pal_get_time, pal_set_timeEFI: TSC calibrated against Stall at start-up.
Filespal_open/read/write/seek/close, pal_stat, pal_opendir/readdir, pal_mkdir, pal_remove, pal_rename, attributes, size, time, labelPaths are canonical: fsN:\dir\file. Resolution of relative paths, . and .. happens in the core (path_resolve).
Volumespal_volumes_refresh, pal_volume_count, pal_volume(i), pal_boot_volumePalVolume: name, label, device path text, size, free, read-only, removable.
Systempal_reset, pal_exit, pal_platform_name, pal_argc/argv

pal_efi.c

pal_host.c and host.c

The Linux build maps volumes to directories: NESH_FS="dir1;dir2" gives fs0: and fs1: (default: the current directory). The console uses termios raw mode when stdin is a terminal and plain line reading otherwise, which is what the tests use. Other environment variables of the host build:

VariableEffect
NESH_FSDirectories exposed as volumes.
NESH_HISTORYHistory file (no history file when unset).
NESH_SECUREBOOTWhen set, secure_boot_active() returns true, to test the read-only mode.

src/platform/host.c implements the platform hooks with stubs: running .efi files fails, UEFI variables do not exist, only a minimal set of platform commands is registered.

The shell core

src/core turns lines into actions: it keeps the command registry, splits and executes command lines, manages output, the environment, aliases and the prompt.

Commands and the registry

A command is a C function int fn(int argc, char **argv) described by a Cmd entry. Modules keep a static table and register it at start-up with shell_register; the registry is a sorted array of pointers, searched case-insensitively by shell_find_cmd.

typedef int (*CmdFn)(int argc, char **argv);

typedef struct {
    const char *name;
    CmdFn fn;
    const char *usage;   /* "cp [-r] SRC... DST" */
    const char *summary; /* one line */
    const char *help;    /* detailed help, may be NULL */
    int flags;           /* CMD_* */
} Cmd;

#define CMD_KEEP_QUOTES 1 /* arguments keep their double quotes (setvar "text" vs L"text") */
#define CMD_DATA 2        /* supports "-data" (key=value records, see out_data_mode) */

Helpers for commands: cmd_err(cmd, fmt, …) prints cmd: message on the error output and returns RC_FAIL; cmd_perr formats a PAL error for a path; cmd_usage prints the usage line and returns RC_USAGE; getopts(argc, argv, "lr", flags) parses single-letter options that may appear anywhere and returns the index of the first operand.

Splitting and redirection

split_ex in shell.c splits a line at spaces and tabs. Double quotes group words and "" inside quotes is a literal quote; with CMD_KEEP_QUOTES the quotes are kept, because setvar distinguishes "text" from L"text". An unquoted > file or >> file is removed from the words and becomes a file sink around the command. There is no variable expansion and no pipe.

Output sinks

flowchart LR
  W["out_write / out_printf"] --> T{"top of the sink stack"}
  T -- "none" --> C["console (pal_con_write)"]
  T -- "capture" --> B["Sbuf (RUN$)"]
  T -- "file" --> F["PalFile (> file, RUN … TO)"]
  E["err_printf"] --> C
Where output goes

con.c keeps a stack of up to 16 sinks. Normal output goes to the innermost one, so nested RUN$ captures and redirections compose naturally; error messages always go to the console. Colors are only applied when the output is the console (out_is_console). The -data helpers live here too: data_record() starts a record (an empty line before every record but the first), data_field(key, fmt, …) writes one line (newlines in values become spaces), and info_line prints either Label: value or label=value depending on the mode, so a command can describe fields once for both outputs.

UEFI applications started by NESH write to the firmware console directly. When the output is not the console, efi_start_image swaps gST->ConOut for a proxy that forwards text to the current sink; see Running images.

Paths

Inside NESH every path is canonical before it reaches the PAL: fsN:\dir\file, backslashes, no . or ... path_resolve does the conversion: it resolves map aliases (usb:), relative paths against the current directory, / separators, and returns NULL if the volume does not exist. Wildcards are expanded by path_glob (one directory level, sorted, case-insensitive match); commands decide whether to accept them.

Environment variables and aliases

env.c keeps variables in memory; non-volatile ones are also written through the platform hook platform_env_store, which stores them as UEFI variables with the EDK2 shell environment GUID, exactly like the UEFI Shell (so both shells share them). A few names are computed on read (cwd, lasterror, versions). The overlay (env_overlay_push/pop) temporarily hides or replaces variables without touching the firmware; it implements the environment parameter of EFI_SHELL_PROTOCOL.Execute. Aliases (alias.c) work the same way with the shell alias GUID.

Prompt, line editor, history and Ctrl-C

The BASIC interpreter

NESH BASIC is implemented as a classic pipeline: a lexer produces tokens, a recursive-descent parser builds an abstract syntax tree, and a tree-walking interpreter executes it.

flowchart LR
  SRC["source text"] --> LEX["lexer.c<br/>tokens"]
  LEX --> PAR["parser.c<br/>Program: Block + Procs"]
  PAR --> INT["interp.c<br/>exec_block / eval"]
  INT --> BF["built-ins<br/>bfuncs.c, efi_var.c, efi_boot.c"]
  INT --> SH["shell core<br/>RUN / RUN$"]
From text to execution

Lexer

lex_next produces one Token at a time: numbers (decimal, 0x, 0b, &H, &B, &O, with _ separators; overflow is an error), strings ("" escape, no newline inside), identifiers (a trailing $ is part of the name), keywords (matched case-insensitively from a table), punctuation and newlines. Comments (', REM) and the line continuation _ are skipped here, so the parser never sees them. A UTF-8 BOM and a #! first line are ignored. Errors are sticky: after T_ERROR the lexer stops.

Parser and AST

The parser keeps one token of look-ahead. Expressions follow this precedence (lowest first): OR/XORANDNOT → comparisons → SHL/SHR+ -* / \ MOD → unary sign → ^ (right-associative) → primary. Depth is limited (64 levels) to protect the stack.

Statements are parsed by parse_block, which loops until it meets a terminator (NEXT, WEND, LOOP, ELSE, ELSEIF, CASE, END IF, END SUB…) and returns which one it found; the caller checks that it is the expected one and reports mismatches with the line where the block was opened. A label is an identifier followed by : alone on its line; labels are recorded per block with the index of the next statement. SUB/FUNCTION definitions are only allowed at the top level and are collected in Program.procs.

When the parse fails because the input ended inside an open block, Program.incomplete is set: the REPL uses it to ask for more lines instead of reporting an error.

Node kindUsed for
E_NUM, E_STR, E_VARLiterals and variables (names are lower-cased by the parser).
E_CALLname(args): array element, user FUNCTION or built-in — decided at run time.
E_UNOP, E_BINOPOperators (op is the token type).
S_ASSIGN, S_PRINT (seps), S_INPUTAssignment and I/O.
S_IF (ifs[]), S_FOR, S_WHILE, S_DO (flags), S_SELECT (cases[])Control flow.
S_EXIT, S_CONTINUE, S_GOTO, S_RETURN, S_ENDJumps.
S_CALL, S_DIM, S_LOCAL, S_RUN, S_CLS, S_COLOR, S_LOCATE, S_SLEEP, S_PAUSEOther statements.

Values, strings and variables

A Value is either a 64-bit integer or a pointer to a reference-counted Str (length + bytes, always NUL-terminated). Copying a string value only increments the count (v_copy); v_free releases it. Arithmetic wraps around (it is done on unsigned integers to avoid undefined behavior); division by zero is a run-time error.

Variables live in hash tables (VarTable, FNV-1a hash). There is one global table and, during a procedure call, a Frame with the local table; lookup checks the current frame's locals, then the globals — never the caller's locals. A variable is created on first assignment; reading an unknown variable gives 0 or "". Arrays are the same Var with is_array set and a vector of values.

Execution and control flow

exec_block runs statements in order and returns an Exec code that tells the enclosing constructs what happened:

ExecProduced byHandled by
X_OKnormal completion
X_EXIT_FOR, X_EXIT_WHILE, X_EXIT_DOEXIT FOR/WHILE/DOthe matching loop
X_CONT_FOR, X_CONT_WHILE, X_CONT_DOCONTINUE …the matching loop
X_RETURNRETURN, EXIT SUB/FUNCTIONcall_proc
X_GOTOGOTO (label name in in->goto_label)the first enclosing block that has the label
X_ENDEND, EXIT n, exit commandseverybody unwinds to the top
X_ERRORrun-time error (rt_err)finish_exec prints it

This explains the GOTO rule: a block that does not contain the label returns X_GOTO to its parent, so a jump can leave loops but never enter them. rt_err records the first error with its line and returns false; every evaluation function propagates false upward, and finish_exec prints file:line: error: message.

Procedures: call_proc evaluates the arguments in the caller's context, type-checks them against the parameter names, creates a Frame, runs the body and converts X_RETURN into the return value. The call depth is limited to 200.

RUN, RUN$ and exit

basic_run_command implements both: with one argument it calls shell_exec_line, with several it builds an argv and calls shell_exec_argv; RUN$ pushes a capture sink first and strips the final newlines. The exit code goes to in->err (the ERR function). After the command it checks two flags set by the exit command:

Interactive mode

The REPL keeps one long-lived interpreter, so variables and SUBs defined at the prompt persist; programs that define procedures are kept alive (in->progs) because the procedures point into their AST. Scripts, instead, each get a fresh interpreter from basic_run_file. interp_is_basic_line decides whether a prompt line is BASIC: it is if it starts with a statement keyword (but a lone exit is the shell command), assigns (x =, a(1) =) or calls a SUB defined earlier.

Built-in functions

Built-ins are C functions registered in tables of BFunc:

/* Built-in functions. Argument type letters: 's' string, 'n' number,
 * '?' any, 'a' array (passed unevaluated, see interp_array_arg).
 * The last letter repeats for extra arguments. */
typedef bool (*BFn)(Interp *in, Node *call, Value *args, int nargs, Value *out);
typedef struct {
    const char *name; /* lower case, with '$' for string results */
    int minargs, maxargs;
    const char *types;
    BFn fn;
} BFunc;

call_builtin checks the number of arguments, evaluates and type-checks them according to types, calls the function and frees the arguments. A function returns false only for a run-time error (after rt_err); functions that can fail softly (files, variables) set in->err instead, so scripts test ERR. The result type must match the name: a name ending in $ returns a string. Array arguments ('a') are not evaluated; the function gets the variable with interp_array_arg. Portable functions are in bfuncs.c (basic_core_funcs_init); UEFI ones are registered by efi_var.c and efi_boot.c.

UEFI modules

Everything that needs the firmware lives in src/platform/efi. The modules register their commands and BASIC functions from platform_cmds_init.

FileContent
efi_platform.cStarting images, output capture proxy, Secure Boot state, ver, reset, memmap, sysinfo, platform_cmds_init.
efi_shellproto.cEFI_SHELL_PROTOCOL 2.2 and the shell file handles; Ctrl-C key notification; device names.
efi_var.cUEFI variables: var, dmpstore, setvar, storage of environment variables and aliases, BASIC variable functions.
efi_boot.cLoad options, bootmgr (plans, backups), bcfg, BASIC boot functions.
efi_drivers.cHandle numbering, drivers, devices, devtree, dh, openinfo, connect…, load, unload, drvdiag, drvcfg.
efi_disk.cBlock devices (blkN), map's block part, dblk, timezone, getmtc, block and memory back-ends of hexedit.
efi_hw.cdmem, mm, pci, smbiosview, acpiview, mode, sermode, loadpcirom, gop, cpuid.
efi_net.c, efi_net6.c, efi_net.hIPv4 and IPv6: ifconfig, ifconfig6, ping, ping6, tftp, http.
efi_guids.cNames of well-known protocol GUIDs (for dh and the shell protocol).

Handle numbers

UEFI handles are opaque pointers. efi_drivers.c keeps a list of every handle ever seen (hdb); a handle's number is its position in that list, printed in hex. The list only grows, so a number keeps its meaning for the whole session, even after other handles disappear. efi_handle_index and efi_parse_handle convert in both directions.

Running images

efi_start_image runs applications (load and loadpcirom start drivers with LoadImage/StartImage directly, without shell parameters):

  1. Read the file and call LoadImage with its device path (the firmware checks the Secure Boot signature here).
  2. Put the command line (UCS-2) into LoadedImage.LoadOptions, as UEFI loaders expect.
  3. Install EFI_SHELL_PARAMETERS_PROTOCOL on the image handle: Argc/Argv and the three standard file handles, as ShellLib programs expect.
  4. If output is being captured or redirected, replace gST->ConOut with the proxy (and fix the system table CRC); the proxy's OutputString writes into the current sink.
  5. StartImage; then restore everything, print the exit data if any, and map the status to an exit code.

EFI_SHELL_PROTOCOL

Programs built with the EDK2 ShellLib locate EFI_SHELL_PROTOCOL and call it for files, environment, current directory, mappings and to run commands. NESH implements all of version 2.2 (efi_shellproto.c).

tests/apps/shelltest.c exercises every function of the protocol and is run by make qemu-test.

UEFI variables and boot entries

efi_var_read / efi_var_write wrap GetVariable/SetVariable with UTF-8 names. GUID names (global, security, shell…) are resolved by guid_parse. efi_boot.c parses EFI_LOAD_OPTION structures (attributes, description, device path list, optional data) and never writes a variable directly: changes are collected in a plan.

Plan p = { .dry = a->n, .yes = a->y, .nobackup = a->B };
plan_add(&p, "BootOrder", data, size, xstrdup("new boot order ..."));
int rc = plan_apply(&p); /* show; stop if dry run; confirm; back up; write */
plan_free(&p);

This one function gives every bootmgr subcommand the same dry run, confirmation and backup behavior.

Network

flowchart BT
  SNP["SimpleNetwork (NIC driver)"] --> MNP["MNP"]
  MNP --> IP4["IP4 / IP4Config2"]
  MNP --> IP6["IP6 / IP6Config"]
  IP4 --> UDP4["UDP4"] --> MT4["MTFTP4 → tftp"]
  IP4 --> TCP["TCP"]
  IP6 --> UDP6["UDP6"] --> MT6["MTFTP6 → tftp"]
  IP6 --> TCP
  TCP --> HTTP["HTTP → http"]
  IP4 --> PING4["ICMP → ping"]
  IP6 --> PING6["ICMPv6 → ping6"]
Firmware network protocols used by NESH

NESH uses the firmware's protocols through their service binding: CreateChild gives a private instance, which is configured, used and destroyed. Configuration commands talk to IP4Config2 and IP6Config. Ping builds ICMP echo packets itself and sends them through a raw IP instance; for ICMPv6 NESH also computes the checksum over the IPv6 pseudo-header. net6_prepare picks the IPv6 source address that can reach the destination (link-local for fe80::, otherwise a global one) and waits up to 10 s for router autoconfiguration. tftp uses MTFTP4 or MTFTP6, which have the same function table and token, so only the configuration differs; http selects the IPv6 access point when the URL host is in brackets.

UEFI compression

src/lib/eficomp.c implements the EFI 1.1 compression format used by option ROMs and firmware volumes, both directions: LZ77 with an 8 KiB window, blocks with three canonical Huffman trees (NT = 19 for code lengths, NC = 510 for literals and match lengths, NP = 14 for position classes), bits written most significant first. The output is verified against the firmware's own decompressor in the QEMU tests.

Secure Boot guard

secure_boot_active() reads the SecureBoot variable. Low-level writes call hw_write_allowed("cmd") first, which prints the standard message and returns false while Secure Boot is active: today mm writes and hexedit -m saves. A new command that writes memory, I/O ports or PCI registers must use it.

Extending NESH

Two complete walkthroughs: a new command and a new BASIC function. The code below is compiled and tested as shown.

Tutorial: adding a command

We add sum, which prints the size and a 16-bit checksum (the BSD sum algorithm) of files, with a -q option and -data support. It only uses the PAL through the core, so it is portable: it goes in src/cmd and works in both builds.

1. Write the command in a new file src/cmd/cmd_sum.c:

/* sum: size and 16-bit checksum of files (tutorial example of the developer manual). */
#include "../core/shell.h"

static int cmd_sum(int argc, char **argv)
{
    bool f[1];
    int first = getopts(argc, argv, "q", f); /* -q: only the checksum */
    if (first < 0)
        return RC_USAGE;
    if (first == argc)
        return cmd_usage("sum");
    int rc = RC_OK;
    for (int i = first; i < argc; i++) {
        char *path = path_resolve(argv[i]);
        char *data;
        size_t len;
        int e = path ? file_read_all(path, &data, &len) : PAL_ENOENT;
        if (e) {
            rc = cmd_perr("sum", argv[i], e);
            free(path);
            continue;
        }
        unsigned s = 0;
        for (size_t k = 0; k < len; k++) {
            s = (s >> 1) | ((s & 1) << 15); /* BSD sum: rotate right, then add */
            s = (s + (uint8_t)data[k]) & 0xFFFF;
        }
        if (out_data_mode()) {
            data_record();
            data_field("path", "%s", path);
            data_field("size", "%zu", len);
            data_field("sum", "%04X", s);
        } else if (f[0]) {
            out_printf("%04X\n", s);
        } else {
            out_printf("%04X %8zu  %s\n", s, len, path);
        }
        free(data);
        free(path);
    }
    return rc;
}

static const Cmd sum_cmds[] = {
    { "sum", cmd_sum, "sum [-q] FILE...", "Show the size and a 16-bit checksum of files",
      "  -q        print only the checksum\n"
      "  sum *.efi              one line per file: checksum, size, path\n"
      "With -data: path, size, sum.\n", CMD_DATA },
};

void cmds_sum_init(void)
{
    shell_register(sum_cmds, ARRAY_SIZE(sum_cmds));
}

Points to notice:

2. Register it. Declare the init function in src/core/shell.h next to the others (void cmds_sum_init(void);), call it from nesh_main in src/core/shell.c after cmds_edit_init(), and add src/cmd/cmd_sum.c to COMMON_SRC in the Makefile. (A UEFI-only command goes in src/platform/efi instead: add its table to one of the modules or register it from platform_cmds_init.)

3. Give it a chapter in the reference: add "sum" to a category of CATEGORIES in tools/gen-docs.py (for example “Text and data”) and run make docs. make test fails until this is done, so a command can never be left out of the manual.

4. Test it. Add a host test, e.g. tests/host/sum.nsb, and generate its expected output:

WRITEFILE "a.txt", "hello" + CHR$(10)
RUN "sum a.txt"
RUN "sum -q a.txt"
PRINT FIELD$(RUN$("sum -data a.txt"), "size")
RUN "sum missing.txt"
PRINT "ERR="; ERR
$ make build/nesh-host
$ UPDATE=1 tests/run-host-tests.sh build/nesh-host   # writes tests/host/sum.out
$ cat tests/host/sum.out                              # check it by hand once
$ make test

The expected output is 9073 6 fs0:\a.txt, then 9073, then 6, then the error message and ERR=1 (0x9073 = 36979, the same value the Unix sum command prints). If the command uses firmware services, also add checks to tests/efi/run.nsb and run make qemu-test.

Tutorial: adding a BASIC function

We add REVERSE$(s$), which reverses a string character by character (not byte by byte: UTF-8 characters must stay intact). Portable functions go in src/basic/bfuncs.c:

/* REVERSE$(s$): the characters of s$ in reverse order. */
FN(f_reverse)
{
    UNUSED;
    const Str *s = a[0].s;
    Sbuf b;
    sb_init(&b);
    for (size_t i = s->len; i > 0;) {
        size_t start = i - 1;
        while (start > 0 && ((uint8_t)s->s[start] & 0xC0) == 0x80) /* UTF-8 continuation byte */
            start--;
        sb_add(&b, s->s + start, i - start);
        i = start;
    }
    *out = v_str(str_take_sb(&b));
    return true;
}

and one line in the core_funcs table (name in lower case with the $, one argument, type letter s):

    { "reverse$", 1, 1, "s", f_reverse },

That is all: the parser needs no change, because any name(args) is resolved at run time. PRINT REVERSE$("NESH è bello") prints olleb è HSEN. Then document the function in the function reference of the user manual (a <dt id="fn-…"> entry): make test fails for a built-in function without an entry.

Rules for built-ins
  • Return false only after rt_err (a real error that stops the script). For expected failures (a missing file) set in->err and return a neutral value.
  • Never keep pointers to args: they are freed after the call. Build the result with v_str/v_int.
  • UEFI-dependent functions go in the UEFI module that owns the topic and are registered with basic_register_funcs from its init function.

Testing

Three levels of tests, all automatic: host tests for the language and portable commands, documentation checks, and QEMU tests for everything that needs firmware.

flowchart LR
  subgraph T1["make test (seconds)"]
    H["tests/host/*.nsb<br/>vs *.out"]
    G["gen-docs.py --check"]
    X["check-doc-examples.py"]
  end
  subgraph T2["make qemu-test (~1 min)"]
    Q["tests/efi/run.nsb<br/>in OVMF"]
    SA["shelltest.efi<br/>EDK2 apps"]
  end
  subgraph T3["make qemu-nettest"]
    N["tests/efi/net.nsb<br/>IPv4 + IPv6"]
  end
Test levels

Host tests

tests/run-host-tests.sh copies each tests/host/NAME.nsb into an empty temporary directory, runs it with NESH_FS=. and the arguments arg1 "arg two", and compares stdout+stderr and the exit code with NAME.out. UPDATE=1 rewrites the .out files — review the diff before committing. The host build uses AddressSanitizer and UBSan, so memory errors and undefined behavior make tests fail loudly.

QEMU tests

tools/run-qemu.sh --test builds a virtual FAT disk (build/esp, read-only, becomes fs0:) with nesh.efi as \EFI\BOOT\BOOTX64.EFI, the tests, the examples and the fixtures, plus a writable FAT image (build/work.img, fs1:). OVMF boots NESH, which runs startup.nsbtests\run.nsb and shuts the machine down. The serial output is saved in build/qemu-test.log. The run passes only if the log contains the end marker, the line failures:0 and no line starting with FAIL.

Inside the tests, a small SUB does the bookkeeping:

fails = 0
SUB check(name$, ok)
  IF ok THEN
    PRINT "PASS "; name$
  ELSE
    PRINT "FAIL "; name$
    fails = fails + 1
  END IF
END SUB
...
check "map lists fs0", INSTR(RUN$("map"), "fs0") > 0

--nettest adds a virtio network card with QEMU user networking (DHCP on 10.0.2.0/24, router advertisements for fec0::/64, a TFTP server) and an HTTP server on the host, and loads the firmware network drivers from tests/fixtures/netdrv.

Continuous integration and releases

.github/workflows/ci.yml runs on GitHub Actions for every push to main and every pull request: it installs QEMU, OVMF, iPXE's option ROMs and dosfstools on Ubuntu, enables KVM, and runs make, make test, make qemu-test and make qemu-nettest; the QEMU logs and nesh.efi are kept as artifacts.

Releases are made by pushing a version tag:

$ git tag -a v0.2.0 -m "NESH 0.2.0"
$ git push origin v0.2.0

The same workflow builds and tests the tagged source, then publishes a GitHub Release with nesh.efi and SHA256SUMS. Update NESH_VERSION in src/core/shell.h (and the version shown in the manuals) before tagging.

Secure Boot tests

make qemu-sbtest checks the Secure Boot behavior end to end. It creates test keys (PK, KEK, db) with openssl, enrolls them into a copy of the OVMF variable store with virt-fw-vars, signs nesh.efi with sbsign and boots the OVMF build that enforces Secure Boot (OVMF_CODE_4M.secboot.fd; OVMF_SECBOOT= and OVMF_VARS= override the paths). Two runs:

  1. the signed shell runs tests/efi/secureboot.nsb, which checks that NESH reports Secure Boot as active, that reading hardware still works, that low-level writes are refused, that clock and serial settings are still allowed, that unsigned applications and drivers are refused with a clear message, that ordinary UEFI variables can still be written and that authenticated ones (the keys) cannot;
  2. the unsigned image must not start at all (the firmware answers Access Denied).

The keys are generated once in build/secureboot/. Without sbsign, virt-fw-vars or a Secure Boot OVMF build the target prints why it is skipped and succeeds.

Fixtures

PathWhat and why
tests/fixtures/edk2/*.efiEDK2 UEFI Shell applications (edit, pci, ping, tftp, usb): run under NESH to prove ShellLib compatibility. Test data only; tests/fixtures/README.md gives origin and license of every fixture.
tests/fixtures/netdrv/*.efiNetwork drivers extracted from the OVMF image (Debian's OVMF only loads them for network boot).
efi-virtio.rom (not in the repository)A compressed PCI option ROM for loadpcirom, copied from the QEMU installation at test time (QEMU_ROM=, default /usr/share/qemu/efi-virtio.rom); the test is skipped without it.
tests/apps/shelltest.cBuilt by the Makefile with the NESH toolchain; checks every EFI_SHELL_PROTOCOL function.

The documentation system

Conventions

Code style

Help text rules

Recording decisions

Design decisions — what was chosen, the alternatives and why — are recorded in docs/decisions-and-history.md. Read it before changing a behavior that looks arbitrary: there is usually a reason.

Debugging

SymptomLikely cause and cure
elf2efi: unsupported relocation typeA symbol is not hidden or a construct needs the GOT. Check for global data without static referenced across files through non-PIC paths; rebuild with the Makefile flags.
Random crashes only on UEFIStack overflow (deep recursion beyond 1 MiB), a missing -mno-red-zone, or a UEFI structure with the wrong layout (check field types against the specification: UINTN is 64-bit, BOOLEAN 8-bit, CHAR16 strings).
Works on the host, fails in QEMUFirmware differences: file systems are case-insensitive, Read may return fewer bytes, strings are UCS-2, events need the right TPL. Reproduce with make qemu and add a check to tests/efi/run.nsb.
A ShellLib application misbehavesCompare with the EDK2 UEFI Shell running the same program (the OVMF image has one: EFI Internal Shell); check memory ownership rules of the protocol function it uses.
An image is refused with "access denied"With Secure Boot active the firmware refuses unsigned images; LoadImage may return EFI_ACCESS_DENIED instead of EFI_SECURITY_VIOLATION (efi_blocked_by_secure_boot handles both).
Memory errors in portable codeRun the failing script with build/nesh-host: AddressSanitizer reports the exact line.

make qemu gives an interactive NESH on the serial console of your terminal; Ctrl+A X leaves QEMU. Debug output can be printed with err_printf, which always reaches the console even while output is captured.

Source file map

FileLinesPurpose
include/efi.h569Minimal UEFI definitions (UEFI Specification 2.10), written for NESH
include/efi_shell.h155UEFI Shell 2.2 protocols (from the UEFI Shell Specification), plus the few extra UEFI protocols the shell implementation needs
src/basic/basic.h187NESH BASIC: lexer, parser (AST) and tree-walking interpreter
src/basic/bfuncs.c816Core built-in functions of NESH BASIC (portable)
src/basic/interp.c1432NESH BASIC interpreter: values and variables, expression evaluation, statement execution (tree walking), procedures, RUN/RUN$ and the public API
src/basic/interp_int.h85Interpreter internals shared by interp.c and the built-in function modules
src/basic/lexer.c295NESH BASIC lexer: turns source text into tokens (numbers, strings, names, keywords, punctuation); skips comments and " _" line continuations
src/basic/parser.c1204NESH BASIC parser: recursive descent from tokens to the AST (Program: main block + procedures); detects incomplete input for the interactive prompt
src/cmd/cmd_core.c374Shell and session commands: help, ver, cls, exit, history, echo, pause, sleep, which
src/cmd/cmd_edit.c1009Full-screen editors: edit (text) and hexedit (files, disk blocks, memory)
src/cmd/cmd_fs.c1372File and volume commands
src/cmd/cmd_text.c390Text and data commands, date/time
src/cmd/cmd_util.c191Utilities: stall, parse, eficompress, efidecompress
src/core/alias.c183Command aliases: permanent (stored in UEFI variables like the UEFI Shell) and temporary
src/core/con.c358Console services: stack of output sinks (console, capture, file), -data record helpers, colors, keyboard with type-ahead buffer and Ctrl-C detection
src/core/con.h50Console services used by the whole shell: output sinks (console, capture buffer, file), colors, key input with type-ahead buffer, Ctrl-C handling
src/core/env.c269Shell environment variables (separate from BASIC variables)
src/core/lineedit.c644Line editor: cursor movement, history (persistent), Ctrl-R search, Tab completion
src/core/shell.c1034Shell core: command registry, option parsing, canonical paths and wildcards, file helpers, command-line splitting and execution, the prompt (REPL) and nesh_main, the portable entry point
src/core/shell.h135Shell core: command registry, execution, paths, file helpers
src/core/ui.c109Interactive selection menu (MENU function)
src/lib/eficomp.c547UEFI compression format (EFI 1.1 / "version 1"): LZ77 with an 8 KiB window and canonical Huffman codes, as decoded by EFI_DECOMPRESS_PROTOCOL
src/lib/eficomp.h12UEFI (EFI 1.1) compression format
src/lib/fmt.c192vsnprintf for the EFI build: %d %i %u %x %X %o %c %s %p %%, flags '-' '0' '+' ' ', width/precision (also '*'), length hh h l ll z j t
src/lib/libc.c320Freestanding C library subset for the EFI build
src/lib/rt.h115NESH runtime: standard C subset + shared helpers
src/lib/util.c337Shared helpers: allocation wrappers, string buffer, UTF-8, globbing
src/pal/efi_glue.h38Access to UEFI services for EFI-only modules
src/pal/pal.h156Platform abstraction layer: everything the portable core needs from the machine
src/pal/pal_common.c25Parts of the platform layer shared by the UEFI and Linux builds: error names
src/pal/pal_efi.c1019Platform layer for UEFI
src/pal/pal_host.c593Platform layer for Linux: used for fast development and automated tests
src/platform/efi/efi_boot.c1572Boot manager: Boot#### / BootOrder / BootNext / Timeout
src/platform/efi/efi_cmds.h48Shared declarations of the EFI-only command modules
src/platform/efi/efi_disk.c449Block devices and disk-level commands: blkN: names, dblk, timezone, getmtc
src/platform/efi/efi_drivers.c1374Drivers and devices: drivers, devices, devtree, dh, openinfo, connect, disconnect, reconnect, load, unload, drvdiag, drvcfg
src/platform/efi/efi_guids.c136Names of well-known GUIDs (protocols, tables, variable vendors), plus the names registered at run time through the shell protocol
src/platform/efi/efi_hw.c1601Memory and hardware: dmem, mm, pci, smbiosview, acpiview, mode, sermode, loadpcirom, gop, cpuid
src/platform/efi/efi_net.c1025Network commands on top of the firmware IPv4 stack: ifconfig, ping, tftp, http
src/platform/efi/efi_net.h39Shared by the IPv4 (efi_net.c) and IPv6 (efi_net6.c) network commands
src/platform/efi/efi_net6.c791IPv6 network commands on top of the firmware IPv6 stack: ifconfig6, ping6
src/platform/efi/efi_platform.c595EFI platform services: running images, Secure Boot state, system commands
src/platform/efi/efi_shellproto.c1317EFI_SHELL_PROTOCOL 2.2, implemented by NESH so that applications written for the UEFI Shell (ShellLib, ShellCEntryLib...) run under NESH
src/platform/efi/efi_var.c1268UEFI variables: the "var" command and the BASIC functions VAR$, VAREXISTS
src/platform/host.c122Platform-specific commands for the Linux test build: firmware features are not available, so only a minimal set is provided
tests/apps/shelltest.c293Test application for NESH's EFI_SHELL_PROTOCOL implementation
tests/run-host-tests.sh25Runs tests/host/*.nsb with the host build and compares the output with the .out files
tools/check-doc-examples.py69Runs the NESH BASIC examples of the user manual with the host build and compares their output with the output printed in the manual
tools/efi.lds26Linker script for NESH: ELF image at base 0, page-aligned sections
tools/elf2efi.py172Convert an x86_64 ELF shared object (linked with tools/efi.lds) into a PE32+ EFI application with native base relocations
tools/gen-docs.py302Generates the command reference of the user manual from the command tables in the C sources, and the line counts of this manual from the files themselves, so that neither can disagree with the code
tools/mkusb.sh102Builds build/nesh-usb.img, a bootable disk image (GPT + EFI system partition) with NESH, the examples, the licence (LICENSE.txt and NOTICE.txt) and a README on it
tools/record-demo.py188Records the animation of the README: drives a QEMU session through the monitor (sendkey/screendump) and turns the frames into a GIF
tools/backup.sh32Packs the repository into one git bundle, with every branch, tag and commit, for a restore without a network
tools/setup-dev.sh89Checks (or installs) the packages a clone needs and sets the git identity of the working copy, which is not cloned
tools/run-qemu.sh158Boots nesh.efi in QEMU/OVMF: interactive, --test and --nettest modes
tools/make-previews.py636Builds docs-preview/: the ten design proposals the look of the manuals was chosen from

Glossary

Boot services
Firmware functions available until the OS takes over (gBS): memory, events, protocols, images.
Device path
A chain of nodes describing where a device or file is, e.g. PciRoot(0x0)/Pci(0x2,0x0)/HD(1,…).
Driver binding
The protocol through which a driver is connected to controllers.
ESP
EFI System Partition: the FAT partition with boot loaders.
GUID
128-bit identifier of protocols and variable namespaces.
Handle
An object in the firmware database carrying protocols.
Load option
The content of a Boot####/Driver#### variable.
OVMF
The UEFI firmware for virtual machines built from EDK2; used by the tests.
PE32+
The executable format of UEFI images (the 64-bit Windows format).
Protocol
A table of functions (and data) identified by a GUID and installed on a handle.
Runtime services
Firmware functions still available to the OS (gRT): variables, time, reset.
Service binding
A protocol that creates private child instances of a network protocol.
ShellLib
The EDK2 library that UEFI Shell applications use; it relies on EFI_SHELL_PROTOCOL.
TPL
Task priority level: UEFI's simple interrupt-priority mechanism for events.
UCS-2
16-bit character encoding of UEFI strings (CHAR16).

Index