In-process TriCore assembler/disassembler. C++17 library + Rust and Python
bindings. Built on the GNU assembler's TriCore encoder (md_assemble) and
libopcodes' print_insn_tricore decoder from a pinned
emproof-com/tricore-binutils-gdb
fork, fully compatible with upstream EEESlab/tricore-binutils-gdb.
# Rust, pick your licensing mode (see "License modes" below):
cargo add nyxstone-tricore-gcc # GPL-3.0-or-later, in-process, ~2.8 M ops/s
cargo add nyxstone-tricore-gcc-ipc # MIT, IPC daemon, ~150 k ops/s
# Python
pip install nyxstone-tricore-gccSupported prebuilts: x86_64-linux-gnu, aarch64-linux-gnu. Other hosts
build binutils-tricore from source automatically (see Binutils provisioning
below).
The public API mirrors that of the sibling project
Nyxstone, a different
implementation built on LLVM-MC that covers the architectures LLVM supports.
NyxstoneTricoreGCC is not a fork or downstream of Nyxstone; it's an
independent codebase using GNU binutils to cover TriCore (which LLVM-MC
doesn't). Both projects expose six assemble/disassemble methods with the
same argument order (source / bytes, address, then either labels or
count):
| method | purpose |
|---|---|
assemble |
bytes only, labels resolved inline |
assemble_to_instructions |
per-insn records, labels resolved inline |
assemble_with_relocs |
bytes + relocations (gcc/gas -r equivalent) |
assemble_to_instructions_with_relocs |
per-insn records + relocations |
disassemble |
bytes → text |
disassemble_to_instructions |
bytes → per-insn records |
- No fork/exec in the hot path. All work happens in the calling process (or in a long-lived per-instance daemon for the MIT-licensed Rust binding).
.text-only. Any directive that would switch the active section to anything other than.textmakesassemble()fail.- Stock binutils. Uses an unmodified
emproof-com/tricore-binutils-gdbbuild, no patches to gas, libbfd, or libopcodes. - Byte-equivalent to
tricore-elf-as. PC-relative branches with forward references go through a mini relax +md_apply_fixpass;.align/.orgpadding is sized after relaxation, exactly like gas. - Loud failures, real diagnostics. gas's stderr is captured and embedded
in the returned error (
Error: Opcode/operand mismatch: mov %d0, 0x123456), never printed to the host process. In the plainassemble/assemble_to_instructionspaths, references to labels that are neither defined in the source nor supplied viaLabelDefinitionare an error, not a silent zero displacement; same for.orgmoving backwards, unknown directives, and out-of-range branch displacements. The*_with_relocsvariants are the link-later path: undefined references are not an error there -- each one comes back as a relocation record (zero placeholder bytesR_TRICORE_*entry, gas-rstyle) for the linker to resolve.
The project ships two Rust crates with byte-identical public APIs so you
can pick the licensing mode that fits your situation by changing one line in
Cargo.toml:
| crate | license | mode | per-op | typical use |
|---|---|---|---|---|
nyxstone-tricore-gcc |
GPL-3.0-or-later | in-process (links gas) | ~360 ns | open-source / GPL-compatible projects |
nyxstone-tricore-gcc-ipc |
MIT | IPC to GPL daemon | ~6 µs | closed-source / commercially permissive |
The MIT crate spawns a separate daemon binary (nyxstone-tcd, GPL-3.0+) and
talks to it over a socketpair(2) UNIX socket, one daemon per
NyxstoneTricoreGCC instance, lazy-spawn, exiting when the instance is
dropped or the parent dies (socket EOF; deliberately not PR_SET_PDEATHSIG,
which is thread-scoped). Strict FIFO, 30 s request timeouts, automatic
one-shot daemon respawn if it crashes mid-session. See
bindings/rust-ipc/README.md for the binary
dependency and three daemon-install methods (cargo install, cargo binstall, programmatic bootstrap).
The C++ and Python bindings only ship in GPL form today, they statically link gas in-process for full speed. If you need permissive licensing for C++ or Python too, use the Rust MIT crate via a thin FFI or open an issue.
make Just Works™, it auto-extracts the committed binutils prebuilts
(third_party/binutils-tricore-prebuilt/, ~3 MB across x86_64+aarch64,
nopic+pic) on first run, no network access needed:
# 1. Build the C++ library + tests + examples (~5 s on first run).
make # or: cmake -S . -B build && cmake --build build -j
# 2. Run the test suite (162 + extra API tests).
make test # or: ctest --test-dir build --output-on-failure
# 3. Try an example.
./smoke # round-trip a handful of TriCore insns
./bench 2.0 # throughput benchmark, 2-second windowFor the Rust binding (GPL in-process mode):
cd bindings/rust
NYX_LIB_DIR=.. cargo build --release
NYX_LIB_DIR=.. cargo test --releaseFor the Rust binding (MIT IPC mode):
cd bindings/rust && cargo build --release --bin nyxstone-tcd # build the daemon
cd ../rust-ipc
NYXSTONE_TCD_PATH=../rust/target/release/nyxstone-tcd \
cargo test --releaseFor the Python binding (requires pip install cffi setuptools):
# Python's CFFI extension needs -fPIC binutils objects; use the PIC variant.
NYX_BINUTILS_PIC=1 make
(cd bindings/python && python3 setup.py build_ext --inplace)
(cd bindings/python && PYTHONPATH=. python3 examples/smoke.py)| source | trigger | time |
|---|---|---|
| prebuilt (default, x86_64 / aarch64 Linux) | make extracts third_party/binutils-tricore-prebuilt/$(uname -m)-linux-gnu/{nopic,pic}/lib.tar.xz automatically |
~1 s |
from source (other arches, or make fetch_binutils) |
scripts/fetch_binutils.sh, clone emproof-com fork, configure, build, stage |
~75 s |
Override the binutils tree wholesale with make NYX_BINUTILS=/path/to/tree.
namespace nyxstone {
using Address = uint64_t;
struct RelocationSymbol { std::string name; Address address; };
struct RelocationInfo {
Address offset;
std::optional<int64_t> addend;
RelocationSymbol symbol;
uint32_t relocation_type; // ELF R_TRICORE_*
};
class NyxstoneTricoreGCC {
public:
struct LabelDefinition { std::string name; Address address; };
struct Instruction { Address address; std::string assembly; std::vector<uint8_t> bytes; };
struct AssembleWithRelocsResult {
std::vector<uint8_t> bytes;
std::vector<RelocationInfo> relocations;
};
struct AssembleInstructionsWithRelocsResult {
std::vector<Instruction> instructions;
std::vector<RelocationInfo> relocations;
};
static tl::expected<std::unique_ptr<NyxstoneTricoreGCC>, std::string> create();
tl::expected<std::vector<uint8_t>, std::string> assemble(
const std::string& assembly, Address address,
const std::vector<LabelDefinition>& labels) const;
tl::expected<std::vector<Instruction>, std::string> assemble_to_instructions(
const std::string& assembly, Address address,
const std::vector<LabelDefinition>& labels) const;
tl::expected<AssembleWithRelocsResult, std::string> assemble_with_relocs(
const std::string& assembly, Address address,
const std::vector<LabelDefinition>& labels) const;
tl::expected<AssembleInstructionsWithRelocsResult, std::string>
assemble_to_instructions_with_relocs(
const std::string& assembly, Address address,
const std::vector<LabelDefinition>& labels) const;
tl::expected<std::string, std::string> disassemble(
const std::vector<uint8_t>& bytes, Address address, size_t count) const;
tl::expected<std::vector<Instruction>, std::string> disassemble_to_instructions(
const std::vector<uint8_t>& bytes, Address address, size_t count) const;
};
}tl::expected is the Sy Brand single-header
(include/nyxstone/expected.hpp), no
external dependency.
typedef struct nyxstone_handle nyxstone_handle_t;
typedef struct { const char* name; uint64_t address; } nyxstone_label_def_t;
typedef struct { uint64_t address; char* assembly;
uint8_t* bytes; size_t bytes_len; } nyxstone_instruction_t;
nyxstone_handle_t* nyxstone_create(char** out_err);
void nyxstone_destroy(nyxstone_handle_t*);
int nyxstone_assemble(nyxstone_handle_t*, const char* src, size_t src_len,
uint64_t address,
const nyxstone_label_def_t* labels, size_t labels_len,
uint8_t** out_bytes, size_t* out_len,
char** out_err);
int nyxstone_assemble_to_instructions(/* same args, instr array out */);
int nyxstone_disassemble(nyxstone_handle_t*, const uint8_t* bytes, size_t bytes_len,
uint64_t address, size_t count,
char** out_text, char** out_err);
int nyxstone_disassemble_to_instructions(/* same args, instr array out */);
void nyxstone_free_bytes(uint8_t*);
void nyxstone_free_string(char*);
void nyxstone_free_instructions(nyxstone_instruction_t*, size_t);Both crates expose NyxstoneTricoreGCC with identical method signatures.
Switching is a one-line Cargo.toml change:
// Same user code for both crates, only the use-path differs.
use nyxstone_tricore_gcc::{LabelDefinition, NyxstoneTricoreGCC}; // GPL crate
// or:
use nyxstone_tricore_gcc_ipc::{LabelDefinition, NyxstoneTricoreGCC}; // MIT crate
let nx = NyxstoneTricoreGCC::new()?;
let bytes = nx.assemble("nop; ret", 0, &[])?;
let insns = nx.disassemble_to_instructions(&bytes, 0x1000, 0)?;
let bytes2 = nx.assemble("nop; nop; j ext", 0x1000,
&[LabelDefinition::new("ext", 0x2000)])?;
// gcc/gas -r equivalent, external labels stay as relocs in the byte stream.
let (rel_bytes, relocs) = nx.assemble_with_relocs(
"nop\n j ext\n", 0x1000,
&[LabelDefinition::new("ext", 0x2000)])?;
// relocs[0] = { offset: 0x1002, symbol: { name: "ext", address: 0x2000 },
// relocation_type: 3 /* R_TRICORE_24REL */, addend: Some(0) }
// offset is the absolute address of the reloc site: base 0x1000 + 2-byte nop.The MIT crate additionally exposes three bootstrap helpers for daemon
installation (install_daemon_if_missing, install_daemon, daemon_path)
so you can avoid documenting a separate cargo install step in your own
README:
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Optional zero-config bootstrap (prefers `cargo binstall` if available).
nyxstone_tricore_gcc_ipc::install_daemon_if_missing()?;
let nx = nyxstone_tricore_gcc_ipc::NyxstoneTricoreGCC::new()?;
// ... use nx ...
Ok(())
}See bindings/rust-ipc/README.md for full
details on the IPC architecture, daemon lifecycle, and license boundary.
from nyxstone_tricore_gcc import LabelDefinition, NyxstoneTricoreGCC
nx = NyxstoneTricoreGCC()
bytes_ = nx.assemble("nop; ret", address=0)
for ins in nx.disassemble_to_instructions(bytes_, address=0x1000):
print(f"0x{ins.address:08x} {ins.assembly}")
ext = nx.assemble("nop\n j ext\n",
address=0x1000,
labels=[LabelDefinition("ext", 0x2000)])
# gcc/gas -r equivalent: bytes + relocations.
rel_bytes, relocs = nx.assemble_with_relocs(
"nop\n j ext\n",
address=0x1000,
labels=[LabelDefinition("ext", 0x2000)])
# relocs[0] == RelocationInfo(offset=0x1002, addend=0, # absolute: base + nop
# symbol=RelocationSymbol(name="ext", address=0x2000),
# relocation_type=3) # R_TRICORE_24RELHot-cache throughput measured by examples/bench.cpp (single-threaded,
20-core x86_64 i7-1370P, gcc 14.2.0, -O3). Numbers are ops/second on the
assemble / disassemble_to_instructions path, fully resetting gas's
state and re-running the encoder on each call.
| backend | per-op (1-insn) | ops/s (1-insn) | insns/s (10-insn batch) |
|---|---|---|---|
| C++ in-process | ~360 ns | ~2.78 M | ~4.84 M |
| Rust GPL (in-process) | ~410 ns | ~2.45 M | ~4.70 M |
| Rust MIT (IPC daemon) | ~6 µs | ~167 k | ~1.14 M |
process-spawn (tricore-elf-as baseline) |
~2.6 ms | ~385 | – |
The MIT mode pays ~15× over the GPL in-process path at the 1-insn level for the license-clean process boundary. Batching client-side (e.g., 10 instructions per request) closes most of the gap.
Reproduce locally:
make && ./bench 2.0 # C++
cd bindings/rust && cargo run --release --example bench # GPL crate
cd bindings/rust-ipc && NYXSTONE_TCD_PATH=../rust/target/release/nyxstone-tcd \
cargo run --release --example bench # MIT crateNyxstoneTricoreGCC/
├── include/nyxstone/
│ ├── nyxstone.h Public C++ API
│ └── expected.hpp Sy Brand's tl::expected (vendored, header-only)
├── c_api/nyxstone_c.h Public C ABI
├── src/
│ ├── nyxstone.cpp C++ implementation
│ ├── nyxstone_glue.c The only TU that touches gas internals
│ └── nyxstone_c.cpp C ABI wrapper
├── tests/tests.cpp 162-test matrix + stress + round-trip + API checks
├── examples/{smoke,bench}.cpp Demo and throughput benchmark
├── bindings/
│ ├── rust/ nyxstone-tricore-gcc (GPL) crate + nyxstone-tcd daemon
│ │ └── src/bin/nyxstone-tcd.rs GPL daemon binary served via cargo install
│ ├── rust-ipc/ nyxstone-tricore-gcc-ipc (MIT) IPC client crate
│ └── python/ setuptools + CFFI extension
├── scripts/
│ ├── fetch_binutils.sh Clone + build binutils from source
│ ├── build_prebuilts.sh Refresh all 4 prebuilt tarball variants
│ └── extract_prebuilt.sh Extract host-matching prebuilt into third_party/
├── third_party/
│ ├── binutils-tricore-prebuilt/ Committed prebuilts (~3 MB compressed)
│ └── binutils-tricore/ Extracted-on-demand working tree (gitignored)
├── docs/architecture.md How the library is plumbed together
├── Makefile Simple build
└── CMakeLists.txt Production build with install
tests/tests.cpp ships a 162-test matrix split into twelve groups:
- insn (47): every TriCore format we exercise (SR/SRR/SLR/SSR/SC/SRC/RC/ RR/RLC/B), various register kinds, immediate widths, signedness.
- label (12): forward + backward branches, multi-label lines
(
a: b: nop),.L0/$x/_xnaming variants, label-only sources. - relax (2): local branches shrink to their 2-byte short form in both directions.
- data (36): every data directive Nyxstone supports,
.byte/.half/.hword/.short/.2byte/.word/.int/.long/.4byte/.quad/.8byte/.ascii/.asciz/.string/.skip/.space/.zero/.org/.align/.balign. - mixed (4): instructions + labels + data interleaved.
- edge (9): empty / comments-only / whitespace /
;separators. - forbid (7+3):
.text-only restriction,.data/.bss/.section .foo/.pushsectionmust all reject;.text/.section .text*must accept. - quote (6):
#/;/////* */inside string literals survive the tokenizer (.asciz "a#b"keeps its hash). - comment (6):
/* */block comments, including mid-line, whole-line, multi-line, and unterminated, are stripped like gas's input scrubber. - dirsem (13):
.skip/.space/.alignfill operands,.alignmax-skip,.p2align,.equ/.setconstants and label expressions, numeric local labels (1:/1b/1f, including instance reuse). - alignrelax (4):
.align/.orgpadding is sized after branch relaxation -- a shrinking branch before the directive must not shift the alignment. - error (13): undefined labels, unknown directives/mnemonics,
duplicate labels,
.orgbackwards, non-power-of-2.balign, out-of-range immediates -- all must reject instead of emitting silently wrong bytes.
After the core matrix, post-matrix checks (194 assertions) cover:
- 100× stress per test catches state-reset drift across consecutive
assemble()calls. - Every BYTES test of ≥2 bytes is round-tripped through
disassemble_to_instructions()(128 cases). LabelDefinitionexternal symbols (exact displacement bytes + address invariance), theaddressparameter propagating toInstruction.address, thecountparameter, relocation records, and branch displacement semantics (decoded target == label address).- Resolution-path relocation encodings: every TriCore relocation the
library resolves in place is a faithful transcription of gas's
md_apply_fix, including the non-linear forms a naive mask heuristic silently mis-encoded -- the split B-format 24-bit displacement (24REL/ 24ABS), the carry-adjusted high half (HI/HIADJ,movh hi:), the permuted 16-bit offset (16OFF/LO2), and 18-bit absolute addressing (18ABS,lea/ld.w/st.w). - Relocation offsets are absolute (base + section position) and stay
correct through relaxation,
.align/.orgpadding, and interleaved data; thesymbol.addresshint resolves through every binding. - ISA level: the assembler accepts the v1.6/1.6.2 instruction set the
disassembler decodes (
fret,fcall,cmpswap.w, ...) -- gas's default v1.2 silently rejected them. - Disassembly is re-assemblable: the objdump-style
<0x..>symbolic- address annotation (after amovh.a+lea/ld/stsequence) is stripped, and load/store/lea references to labels resolve to the right address. - The data fast-path (
emit_int_list) is byte-checked against gas'sconsfor every value (overflow, sign, all bases). - An instruction idempotence corpus (one per major format) pins both the disassembler (bytes → text) and assembler/disassembler agreement (text → bytes → text), the property the round-trip fuzzer verified across the full 16-bit space and a 2.1M-case 32-bit sample.
- Error-message quality (gas diagnostics + offending symbol names appear in the returned error; nothing leaks to the host stderr) and 32-bit masking of printed branch targets.
A separate roundtrip_all test (also run by make test) is exhaustive over
the whole v1.6.2 instruction set: it drives all 398 instruction/format
entries from the pinned binutils opcode table (extracted by
scripts/gen_v162_corpus.py into
tests/tricore_v162_insns.inc), synthesizing
operands from each instruction's arg-spec across two parameter variants (low
registers + mid immediates, and high registers + extreme immediates). Every
instruction must assemble in both variants; every non-branch form must
round-trip idempotently (disassemble → assemble → disassemble stable); and
every branch/absolute/symbol operand must resolve both inline (label address
recovered in the disassembly) and as a relocation (assemble_with_relocs
yields the right symbol, absolute offset, and address). PC-relative branches
are validated only via the reference path, since the disassembler prints an
absolute target that gas re-reads as a displacement (upstream convention).
=== TriCore v1.6.2 exhaustive round-trip ===
instruction/format entries: 398
assembled (variant A): 398 (100.0%)
assembled (variant B): 398 (high regs / extreme immediates)
round-trip idempotent: 375 / 375 checked (non-branch)
reference/reloc resolved: 25 / 25
OK
The Rust crates run the same unit tests (verbatim sources, only the
use line differs between nyxstone-tricore-gcc and nyxstone-tricore-gcc-ipc)
plus one doctest each, and produce byte-identical output across both
backends; the IPC crate adds daemon-resilience tests (kill-then-respawn,
protocol frame caps).
$ ./run_tests
... (162 tests in 12 groups) ...
--- 100x stress (each non-MUST_FAIL test 100 iterations) ---
PASS: no drift across 142 non-fail tests * 100 iterations
--- disassembly round-trip ---
128 round-trip pass, 0 fail
--- resolution-path reloc encodings ---
12 resolution-reloc pass, 0 fail
--- relocation offsets ---
7 reloc-offset pass, 0 fail
--- data fast-path vs gas cons ---
88 data-fastpath pass, 0 fail
--- idempotence corpus ---
33 idempotence-corpus pass, 0 fail
--- v1.6 instruction set / annotation strip / load-store label resolution ---
5 v1.6-isa, 4 annotation-strip, 9 load-store-label pass, 0 fail
... (external-label, relocs, branch, error-message, masking checks) ...
Summary: 162 passed, 0 failed, 0 drifts (of 162 tests);
128 disasm round-trips passed, 0 failed;
194 additional API checks passed, 0 failed
Single-threaded only at the gas layer. All gas globals are process-wide; concurrent calls from multiple threads would corrupt state.
- The C++ API does not lock, callers must serialize.
- The Rust GPL binding holds a global
Mutex<()>per call so calls from multiple threads are safe (they're just serialized).NyxstoneTricoreGCCis bothSendandSync. - The Rust MIT binding holds a per-instance
Mutexaround the UDS socket, same FIFO contract, scoped per-handle since each MIT-mode instance has its own daemon process. - The Python binding holds a
threading.Lock()per call.
GPL-3.0-or-later for the C++/C/Python parts and the nyxstone-tricore-gcc
Rust crate. These statically link GNU assembler (gas) object files from
binutils, which are GPL-3.0-or-later; that propagates to the combined
library and to any binary distribution. libbfd and libopcodes
(LGPL-3.0-or-later) are subsumed by the GPL term.
MIT for the nyxstone-tricore-gcc-ipc Rust crate, it ships
zero GPL bytes and only talks to a separate nyxstone-tcd daemon binary
(GPL-3.0+) over an IPC socket. Shipping the daemon alongside a commercial
product follows the same well-trodden pattern as bundling gcc.exe next to
a closed-source IDE.
See LICENSE, LICENSE-MIT, and
bindings/rust-ipc/README.md for the full
story on the license-mode split.