| layout | default |
|---|---|
| title | How ZigXLL Works |
Some technical rambling about how this all works.
ZigXLL uses Zig's comptime to automatically generate Excel-compatible wrapper functions. The framework is a Zig package that mushes with user code at build time to produce a complete XLL, ready to run in Excel.
The user never writes any Excel boilerplate. The framework's buildXll() build helper creates a complete XLL by:
- Taking the user's module containing
function_modulestuple - Using the framework's
xll_builder.zigas the root source file - The builder exports all Excel entry points (
xlAutoOpen, etc.) - The builder discovers and registers user functions at compile time
The ExcelFunction() function is a compile-time code generator that takes function metadata and produces a struct containing:
- Excel registration metadata (name, description, type string)
- An
implfunction with the exact signature Excel expects - Type conversion logic between Zig types and XLOPER12
Arity
The switch statement on params.len generates the exact number of parameters needed:
- 0 params:
fn impl() callconv(.c) *xl.XLOPER12 - 1 param:
fn impl(a1: *xl.XLOPER12) callconv(.c) *xl.XLOPER12 - 2 params:
fn impl(a1: *xl.XLOPER12, a2: *xl.XLOPER12) callconv(.c) *xl.XLOPER12
Up to 8 are permitted.
Type conversion
extractArg() and wrapResult() handle conversion:
f64↔xltypeNum(numbers)bool↔xltypeBool(TRUE/FALSE)[]const u8↔xltypeStr(strings with UTF-8 conversion)[][]const f64↔xltypeMulti(2D arrays/ranges of numbers, empty cells → 0.0)*XLOPER12↔ raw passthrough (advanced usage)
Optional parameters: Optional types (?f64, ?bool, etc.) detect xltypeMissing and return null.
All conversions use the XLValue wrapper for safety.
getAllFunctions() uses comptime reflection to find Excel functions in a module. It scans declarations looking for structs with the is_excel_function marker and builds a comptime array of them.
The XLL builder is the root source file for all generated XLLs. It:
- Imports the framework as
"xll_framework" - Imports the user module as
"user_module" - Exports all Excel entry points
- Exposes
user_functionsfor framework discovery
The user never sees or edits this file. It just hooks up the framework to Excel's machinery.
Function discovery
This runs at compile time to build the complete list of functions. It accesses @import("root") which is the xll_builder.zig, which exposes the user's modules.
This takes the zig defined metadata and calls Excel's xlfRegister function for n functions. Again this is mostly code generated at compile time. A dummy trailing empty string is appended to the registration args to prevent Excel from truncating the argument list.
buildXll() is called from user's build.zig and handles all the wiring. This is how user build files stay minimal.
XLValue wraps XLOPER12 with type safety.
Memory Management
XLValue tracks whether it owns memory via m_owns_memory. When Excel calls xlAutoFree12(), the framework deallocates any owned memory. Excel memory ownership rules are strict - time will tell if this is right.
UTF-8 Conversion
Excel uses UTF-16 wide strings. XLValue handles conversion:
fromUtf8String(): UTF-8 → UTF-16 (allocates)as_utf8str(): UTF-16 → UTF-8 (allocates)
- User enters
=ADD(1, 2)in Excel - Excel looks up the registered exported function
add_implin the XLL DLL (dots in names are replaced with underscores bysanitizeExportName()to avoid WindowsGetProcAddressissues) - Excel calls
add_impl(XLOPER12*, XLOPER12*)with C calling convention - The generated impl function:
- Wraps each XLOPER12 in XLValue
- Calls
extractArg()to convert to f64 - Calls user's
add(f64, f64)function - Converts f64 result to XLOPER12 via
wrapResult() - Returns pointer to XLOPER12
- Excel displays the result
- Later, Excel calls
xlAutoFree12()to free the returned memory (triggered by thexlbitDLLFreeflag set on the returned XLOPER12 byheapXloper())
-
User defines function in their module (e.g.,
my_functions.zig):pub const add = ExcelFunction(.{ .name = "add", .func = addImpl, // ... });
-
User lists that module in
main.zig:pub const function_modules = .{ @import("my_functions.zig"), };
-
User calls
buildXll()in theirbuild.zig, passing their module -
Framework uses
xll_builder.zigas the XLL root, which imports user module -
ExcelFunction()runs at compile time, generating wrapper structs with@export() -
getAllFunctions()scans modules and finds all wrappers -
At runtime,
xlAutoOpen()registers each discovered function with Excel
Async functions use Excel's RTD (Real-Time Data) mechanism as a completion notifier — the same pattern used by Excel-DNA. The user writes a normal function with .is_async = true; the framework generates all the RTD and threading machinery at compile time.
All async functions share a single built-in RTD server (zigxll.async) and a shared thread pool (4 workers via std.Thread.Pool). Results are stored in a thread-safe cache keyed by "FuncName|arg1|arg2|...".
The key components:
src/async_infra.zig— Thread pool, topic key building, argument duplication/cleanupsrc/async_handler.zig— Built-in RTD handler implementingonConnect/onDisconnect/onRefreshValuesrc/async_cache.zig— Thread-safeHashMapmapping topic keys to XLOPER12 results with completion flags
When Excel calls an async function:
-
Cache miss (first call)
implbuilds a topic key from function name + serialized args- Checks the cache — miss
- Duplicates arguments (they must outlive the Excel call)
- Spawns a worker on the thread pool
- Sets a pending topic key, then calls
xlfRtdto subscribe the cell ConnectDatain the RTD handler picks up the pending key (this is synchronous —xlfRtdtriggers it before returning)- Returns
#N/Ato the cell via RTD
-
Worker completes
- Stores the result in the cache (marked as completed)
- Calls
UpdateNotify()on the RTD callback — this tells Excel to recalculate
-
Recalc (cache hit)
implchecks cache — hit, completed- Returns the value directly (bypassing RTD entirely)
- Excel drops the RTD subscription since the cell is no longer an RTD formula
-
Subsequent calls with same args
- Instant cache hit — no thread pool, no RTD, just a direct return
If the user function takes *AsyncContext as its last parameter, it can call ctx.yield() to push intermediate values to the cell before the final return. Each yield:
- Stores the intermediate value in the cache (not marked as completed)
- Calls
UpdateNotify()→ Excel recalcs →RefreshDatareturns the intermediate value - Cell updates immediately while computation continues
On the final return, the value is stored as completed. The next recalc returns it directly and the RTD subscription drops.
The framework detects async functions automatically:
ExcelFunction()checks.is_async = trueand generates the cache-check / thread-pool / RTD subscription logic inimplframework_entry.zigscans all registered functions at comptime; if any are async, it auto-registers the built-in async RTD server duringxlAutoOpenxll_builder.ziggenerates combinedDllGetClassObject/DllCanUnloadNowexports that dispatch to both user-defined RTD servers and the async RTD server
The user doesn't declare or configure anything beyond .is_async = true.
The tricky part of the RTD integration is mapping topic keys to topic IDs. Excel assigns topic IDs in ConnectData, but the UDF needs to set the topic key before calling xlfRtd. The solution:
- UDF sets a "pending key" on the handler (a module-level variable)
- UDF calls
xlfRtd, which synchronously triggersConnectData ConnectDatareads the pending key and associates it with the topic ID Excel provides- The pending key is cleared
This works because xlfRtd calls ConnectData synchronously on the same thread before returning, and async functions are always registered as non-thread-safe (so only one runs at a time on Excel's main thread).
Compile time
- Function discovery
- Wrapper generation
- All metadata computed at compile time (no runtime overhead)
Runtime
- Function call: Direct C call, no reflection or indirection
- Type conversion: Minimal overhead (pointer deref and type check)
- Memory allocation: Only for strings and returned values
- Registration: One-time cost at XLL load (although you can safely load the XLL again without restarting Excel)
- Maximum 8 parameters per function (Excel limitation is 255, but framework currently supports 0-8)
- Supported types: f64, bool, []const u8, [][]const f64, *XLOPER12
- Windows x86_64 only, which makes sense as XLLs can only run on Windows
- Requires Zig 0.15.1 or later
Adding new parameter types:
- Update
extractArg()in excel_function.zig - Add conversion logic using XLValue methods
Adding new return types:
- Update
wrapResult()in excel_function.zig - Add conversion logic using XLValue constructors
Supporting more parameters:
Add more cases to the switch statement in ExcelFunction().