14 KiB
Custom Chips — Examples walkthrough
Velxio ships an in-app gallery of 11 example chips. Each one is a complete, working chip you can study, fork, and modify. They cover every protocol the runtime supports: GPIO, I2C, SPI, UART, attributes, timers.
The C source for all of them lives at:
test/test_custom_chips/sdk/examples/- (Same files copied into the frontend bundle at
frontend/src/components/customChips/examples/for the gallery UI)
Each example is also covered by automated tests in
test/test_custom_chips/test/ — read
those alongside the C source to see the expected behavior under load.
The galleria
| # | Chip | Tier | Protocol | Lines of C |
|---|---|---|---|---|
| 1 | Inverter | Beginner | GPIO + watch | ~25 |
| 2 | XOR Gate | Beginner | GPIO × 2 + watch | ~30 |
| 3 | Pulse Counter | Beginner | GPIO + attributes | ~35 |
| 4 | CD4094 | Intermediate | GPIO + state machine | ~70 |
| 5 | 74HC595 | Intermediate | SPI slave | ~75 |
| 6 | MCP3008 | Intermediate | SPI + analog | ~85 |
| 7 | PCF8574 | Intermediate | I2C slave + GPIO bidir | ~75 |
| 8 | 24C01 EEPROM | Intermediate | I2C slave | ~80 |
| 9 | 24LC256 EEPROM | Advanced | I2C 16-bit addressing | ~90 |
| 10 | DS3231 RTC | Advanced | I2C state + BCD | ~110 |
| 11 | ROT13 UART | Intermediate | UART RX/TX | ~50 |
1. Inverter
OUT is always the inverse of IN.
Why study it: simplest possible chip. Shows the full lifecycle: allocate state, register pins, watch one input, react.
typedef struct { vx_pin in, out; } chip_state_t;
static void on_in_change(void *ud, vx_pin pin, int value) {
chip_state_t *s = ud;
vx_pin_write(s->out, value ? VX_LOW : VX_HIGH);
}
void chip_setup(void) {
chip_state_t *s = malloc(sizeof(chip_state_t));
s->in = vx_pin_register("IN", VX_INPUT);
s->out = vx_pin_register("OUT", VX_OUTPUT);
vx_pin_write(s->out, vx_pin_read(s->in) ? VX_LOW : VX_HIGH);
vx_pin_watch(s->in, VX_EDGE_BOTH, on_in_change, s);
}
chip.json:
{ "name": "Inverter", "pins": ["IN", "OUT", "GND", "VCC"], "attributes": [] }
Tested by: test/chips/01_inverter.test.js (3 tests).
2. XOR Gate
OUT = A xor B. Demonstrates watching multiple inputs.
typedef struct { vx_pin a, b, out; } chip_state_t;
static void update_output(chip_state_t* s) {
int a = vx_pin_read(s->a);
int b = vx_pin_read(s->b);
vx_pin_write(s->out, (a ^ b) ? VX_HIGH : VX_LOW);
}
static void on_input_change(void *ud, vx_pin pin, int value) {
update_output(ud);
}
void chip_setup(void) {
chip_state_t *s = malloc(sizeof(chip_state_t));
s->a = vx_pin_register("A", VX_INPUT);
s->b = vx_pin_register("B", VX_INPUT);
s->out = vx_pin_register("OUT", VX_OUTPUT);
vx_pin_watch(s->a, VX_EDGE_BOTH, on_input_change, s);
vx_pin_watch(s->b, VX_EDGE_BOTH, on_input_change, s);
update_output(s);
}
Pattern: when output depends on multiple inputs, write a single
update_output() and call it from every input's watch.
3. Pulse Counter
Counts rising edges on PULSE. Toggles OVF every N pulses, where N is
user-editable from the UI.
Why study it: shows attributes (vx_attr_register/vx_attr_read) and
how the UI's slider value flows into the chip at runtime.
typedef struct {
vx_pin pulse, ovf, rst;
vx_attr threshold;
uint32_t count;
int ovf_state;
} chip_state_t;
static void on_pulse(void *ud, vx_pin p, int v) {
chip_state_t *s = ud;
s->count++;
uint32_t threshold = (uint32_t)vx_attr_read(s->threshold); // re-read live
if (s->count >= threshold) {
s->count = 0;
s->ovf_state = !s->ovf_state;
vx_pin_write(s->ovf, s->ovf_state);
}
}
static void on_reset(void *ud, vx_pin p, int v) {
chip_state_t *s = ud;
s->count = 0;
s->ovf_state = 0;
vx_pin_write(s->ovf, VX_LOW);
}
void chip_setup(void) {
chip_state_t *s = calloc(1, sizeof(chip_state_t));
s->pulse = vx_pin_register("PULSE", VX_INPUT);
s->ovf = vx_pin_register("OVF", VX_OUTPUT_LOW);
s->rst = vx_pin_register("RST", VX_INPUT_PULLUP);
s->threshold = vx_attr_register("threshold", 4.0);
vx_pin_watch(s->pulse, VX_EDGE_RISING, on_pulse, s);
vx_pin_watch(s->rst, VX_EDGE_FALLING, on_reset, s);
}
chip.json:
{
"pins": ["PULSE", "OVF", "RST", "GND", "VCC"],
"attributes": [
{ "name": "threshold", "type": "int", "default": 4, "min": 1, "max": 1024 }
]
}
The user gets a slider labeled "threshold" with range 1..1024. Move it during simulation → next pulse picks up the new value.
4. CD4094 shift register
8-stage shift-and-store bus register. Real-world chip with three control pins (CLK / DATA / STR), parallel outputs Q1..Q8, and cascade outputs.
Why study it: state machine with multiple input watches, edge masks
(VX_EDGE_RISING for STR, VX_EDGE_BOTH for CLK), and a "no power" guard.
Highlights:
static int has_power(chip_state_t *s) {
return vx_pin_read(s->VDD) && !vx_pin_read(s->VSS);
}
static void on_clk_change(void *ud, vx_pin pin, int value) {
chip_state_t *s = ud;
if (!has_power(s)) {
for (int i = 0; i < 8; i++) vx_pin_write(s->Q[i], VX_LOW);
return;
}
if (vx_pin_read(s->CLK)) {
/* rising edge: shift DATA into bit position s->bit */
if (vx_pin_read(s->DATA)) s->reg |= (1 << s->bit);
else s->reg &= ~(1 << s->bit);
s->bit = s->bit > 0 ? s->bit - 1 : 7;
}
}
static void on_strobe_change(void *ud, vx_pin pin, int value) {
chip_state_t *s = ud;
for (int i = 0; i < 8; i++) {
vx_pin_write(s->Q[i], (s->reg >> i) & 1 ? VX_HIGH : VX_LOW);
}
}
void chip_setup(void) {
chip_state_t *s = calloc(1, sizeof(chip_state_t));
/* register VDD/VSS/CLK/DATA/STR/OE/QS/QSN and Q1..Q8 */
vx_pin_watch(s->CLK, VX_EDGE_BOTH, on_clk_change, s);
vx_pin_watch(s->STR, VX_EDGE_RISING, on_strobe_change, s);
}
Tested by: test/chips/03_cd4094.test.js (4 tests including a power-gate test).
5. 74HC595 SPI shift register
8-bit SIPO shift register driven over SPI.
Why study it: SPI without CS — the chip shifts on every SCK clock
as long as data flows, so it must re-arm vx_spi_start after each byte.
static void on_spi_done(void *ud, uint8_t *buffer, uint32_t count) {
chip_state_t *s = ud;
if (count > 0) s->shift_reg = buffer[0];
vx_spi_start(s->spi, s->spi_buf, 1); // re-arm immediately
}
static void on_rclk(void *ud, vx_pin pin, int value) {
chip_state_t *s = ud;
s->latch_reg = s->shift_reg;
for (int i = 0; i < 8; i++) {
vx_pin_write(s->Q[i], (s->latch_reg >> i) & 1);
}
}
void chip_setup(void) {
chip_state_t *s = calloc(1, sizeof(chip_state_t));
/* register SER/SRCLK/RCLK/SRCLR/OE/QH and Q0..Q7 */
vx_spi_config cfg = {
.sck = s->SRCLK, .mosi = s->SER, .miso = s->QH,
.cs = s->RCLK, .mode = 0, .on_done = on_spi_done, .user_data = s,
};
s->spi = vx_spi_attach(&cfg);
vx_spi_start(s->spi, s->spi_buf, 1); // arm initial transfer
vx_pin_watch(s->RCLK, VX_EDGE_RISING, on_rclk, s);
}
Pattern: chips with a real CS pin should call vx_spi_start /
vx_spi_stop from a CS pin watch instead. See MCP3008 below.
6. MCP3008 SPI ADC
8-channel, 10-bit ADC.
Why study it: SPI with CS-driven transactions, two-phase exchange (the
master sends a command, then clocks more bytes to read the result), and
analog reads via vx_pin_read_analog.
static void on_cs_change(void *ud, vx_pin pin, int value) {
chip_state_t *s = ud;
if (value == VX_LOW) {
s->buf[0] = s->buf[1] = s->buf[2] = 0xff;
vx_spi_start(s->spi, s->buf, 3);
} else {
vx_spi_stop(s->spi);
}
}
static void on_spi_done(void *ud, uint8_t *buffer, uint32_t count) {
chip_state_t *s = ud;
if (count < 3) return;
uint8_t channel = (buffer[1] >> 4) & 0x07;
double voltage = vx_pin_read_analog(s->CH[channel]);
uint16_t result = (voltage / 5.0) * 1023.0 + 0.5;
/* Pre-fill response: byte[0] don't care, byte[1] = upper 2 bits, byte[2] = lower 8 */
s->buf[0] = 0;
s->buf[1] = (result >> 8) & 0x03;
s->buf[2] = result & 0xff;
vx_spi_start(s->spi, s->buf, 3); // arm response phase
}
Pattern: when a chip needs to send a response, fill the buffer and call
vx_spi_start again — the master clocks more bytes which read out the
buffer (and overwrite it with whatever the master happens to send next, which
the chip can ignore).
7. PCF8574 IO expander
8-bit I2C IO expander. Master writes a byte → 8 GPIO pins reflect that byte. Master reads → returns the current state of the 8 pins.
static bool on_write(void *ud, uint8_t byte) {
chip_state_t *s = ud;
s->latched = byte;
for (int i = 0; i < 8; i++) {
vx_pin_write(s->P[i], (byte >> i) & 1);
}
return true;
}
static uint8_t on_read(void *ud) {
chip_state_t *s = ud;
uint8_t v = 0;
for (int i = 0; i < 8; i++) {
if (vx_pin_read(s->P[i])) v |= (1 << i);
}
return v;
}
Address: A0/A1/A2 are read at chip_setup() time and form the low 3
bits of the 7-bit address (base 0x20). Wire them HIGH/LOW differently to
put two chips on the same bus at 0x20 and 0x21.
8. 24C01 EEPROM
128-byte I2C EEPROM with a write pointer that auto-increments.
Why study it: classic I2C protocol with an internal state machine (IDLE → HAS_POINTER) and the standard "first byte after addressing is the register pointer" pattern.
See the full code in API reference → I2C example.
Tested by: test/chips/04_eeprom_24c01.test.js — 4 tests covering basic write/read, auto-increment, wrap at 0x80, and address-pin selection. Also full E2E with a real Arduino sketch using Wire.h in test/e2e/07_chip_eeprom_avr_e2e.test.js.
9. 24LC256 EEPROM
32 KB I2C EEPROM. Same idea as 24C01 but with two-byte addressing.
typedef enum { ST_IDLE, ST_HAS_HIGH, ST_HAS_FULL_ADDRESS } ee_state;
static bool i2c_write(void *ud, uint8_t byte) {
chip_state_t *s = ud;
switch (s->state) {
case ST_IDLE:
s->pointer = (byte & 0x7f) << 8; // address high
s->state = ST_HAS_HIGH;
break;
case ST_HAS_HIGH:
s->pointer = (s->pointer & 0xff00) | byte; // address low
s->state = ST_HAS_FULL_ADDRESS;
break;
case ST_HAS_FULL_ADDRESS:
s->mem[s->pointer & (EEPROM_SIZE - 1)] = byte;
s->pointer = (s->pointer + 1) & (EEPROM_SIZE - 1);
break;
}
return true;
}
The state machine grows naturally as the protocol gets richer. Same pattern extends to 24LC512, 24LC1024, and other big EEPROMs.
10. DS3231 RTC
I2C real-time clock with 19 registers and BCD-encoded values.
Why study it: register pointer + multi-register read auto-increment + encoded values. This is the workhorse pattern for most I2C peripherals (sensors, displays, motor drivers).
static bool on_write(void *ud, uint8_t byte) {
chip_state_t *s = ud;
if (s->state == ST_IDLE) {
s->pointer = byte % RTC_REG_COUNT; // first byte = register pointer
s->state = ST_HAS_POINTER;
} else {
s->regs[s->pointer] = byte; // subsequent bytes = data
s->pointer = (s->pointer + 1) % RTC_REG_COUNT;
}
return true;
}
static uint8_t on_read(void *ud) {
chip_state_t *s = ud;
uint8_t b = s->regs[s->pointer];
s->pointer = (s->pointer + 1) % RTC_REG_COUNT;
return b;
}
Seed values: this chip pre-seeds time to 2026-01-15 12:34:56 (Thursday)
inside chip_setup. A real RTC would tick — extending this with a 1-second
timer is a great exercise.
11. ROT13 UART
UART loopback that ROT13-shifts every received byte.
Why study it: simplest possible UART chip. Establishes the pattern for any UART peripheral (GPS modules, BT modems, anything Serial-based).
static uint8_t rot13(uint8_t v) {
if (v >= 'A' && v <= 'Z') return ((v - 'A' + 13) % 26) + 'A';
if (v >= 'a' && v <= 'z') return ((v - 'a' + 13) % 26) + 'a';
return v;
}
static void on_rx(void *ud, uint8_t byte) {
chip_state_t *s = ud;
uint8_t out = rot13(byte);
vx_uart_write(s->uart, &out, 1);
}
void chip_setup(void) {
chip_state_t *s = malloc(sizeof(chip_state_t));
vx_uart_config cfg = {
.rx = vx_pin_register("RX", VX_INPUT),
.tx = vx_pin_register("TX", VX_INPUT_PULLUP),
.baud_rate = 115200,
.on_rx_byte = on_rx,
.on_tx_done = NULL,
.user_data = s,
};
s->uart = vx_uart_attach(&cfg);
}
When wired to the Arduino's Serial, every Serial.print('A') from the
sketch makes the chip echo 'N' — visible in the Serial Monitor.
How to learn from these
A productive workflow:
- Pick the example closest to what you want to build. EEPROM-like? Start
from 24C01. Logic gate? Start from XOR. Display? See the chip with a
displayfield inchip.json. - Open it in the Custom Chip Designer. Examples tab → click the chip.
- Modify it. Change a constant, add a pin, adjust a callback.
- Compile and place it. Save & Place puts it on the canvas.
- Wire it up. The Arduino sketch in your editor talks to it via the normal Wire/SPI/Serial APIs.
- Run. The chip console (browser dev tools) shows your
vx_log/printfoutput.
If something breaks, see Build & test → Troubleshooting.