PHP library that wraps the posi extension with global helpers, enums, and data objects. Every helper delegates to a facade class under Microscrap\Bindings\SPI.
This project provides PHP bindings to the Linux spidev character device API, mirroring the public surface of <linux/spi/spidev.h> (the SPI_IOC_* ioctl family used by spi-tools, Python's spidev, and friends).
- Open a spidev device (
/dev/spidev0.0,/dev/spidev1.1, etc.) and configure mode, clock speed, and word size in one call - Half-duplex byte I/O via
spi_read/spi_write(usesposix_read/posix_writeunder the hood) - Full-duplex, chained transfers via
spi_transferand theSPITransferdata object — packsspi_ioc_transferand issuesSPI_IOC_MESSAGE(n) - Per-segment overrides: clock speed, bits-per-word, inter-word delay, CS-change, dual/quad lane counts
- Inspect and mutate every spidev attribute —
mode,max_speed_hz,bits_per_word,lsb_first - Typed
SPIModebitmask covers CPOL/CPHA, CS polarity, 3-wire, loopback, no-CS, dual/quad/octal lanes, ready signal - Automatically prefers a native
spi_transfer(int $fd, …)from a low-level extension when one is loaded, otherwise falls back to theext-posimemory path - Thin global
spi_*helper API — all functions arefunction_exists-guarded
- PHP 8.3+
- Linux kernel with
spidevenabled and a populated/dev/spidev*device - ext-posi ^0.4.0 — install from php-io-extensions/posi; the
posi_mem_*helpers are required forspi_transferunless a nativespi_transfer(int $fd, …)is loaded - microscrap/posix ^0.4.0
Confirm ext-posi is loaded:
php -m | grep posiConfirm the spidev device is visible (and that your user can access it, or run as root):
ls /dev/spidev*On a Raspberry Pi, enable SPI via raspi-config or by adding dtparam=spi=on to /boot/config.txt.
composer require microscrap/spiComposer autoloads src/Helpers/spi-device.php, registering the global spi_* functions.
SPI transactions are driven through global helper functions (spi_open, spi_read, spi_write, spi_transfer, etc.). All helpers delegate to the Device facade and are only defined once (function_exists guard).
Enums live under Microscrap\Bindings\SPI\Enums. The device handle is Microscrap\Bindings\SPI\DataObjects\SPIDevice, and a single transfer segment is described by Microscrap\Bindings\SPI\DataObjects\SPITransfer.
<?php
use Microscrap\Bindings\SPI\DataObjects\SPITransfer;
use Microscrap\Bindings\SPI\Enums\SPIMode;
// Open spidev0.0 at 500 kHz, mode 1 (CPHA=1, CPOL=0), 8 bits/word.
$dev = spi_open('/dev/spidev0.0', SPIMode::CPHA->value, 500_000, 8);
if ($dev === null) {
exit("Failed to open SPI device\n");
}
// Full-duplex read of register 0x07. Many SPI sensors use bit 6 of the first
// byte to flag a read; adjust to match your device's protocol.
$rx = spi_transfer(
$dev,
new SPITransfer(tx: pack('CC', 0x07 | 0x40, 0x00), len: 2),
);
if ($rx === false) {
exit("Transfer failed\n");
}
printf("REG 0x07 = 0x%02X\n", ord($rx[1]));
spi_close($dev);spi_transfer accepts any number of SPITransfer segments. Each segment is sent back-to-back as a single SPI_IOC_MESSAGE transaction; CS is held low between segments unless a segment sets csChange: true.
<?php
use Microscrap\Bindings\SPI\DataObjects\SPITransfer;
$dev = spi_open('/dev/spidev0.0', 0, 1_000_000, 8);
$rx = spi_transfer(
$dev,
new SPITransfer(tx: "\x9F", len: 1), // JEDEC ID command
new SPITransfer(tx: str_repeat("\0", 3), len: 3), // 3 bytes manufacturer + device ID
);
if ($rx === false || strlen($rx) !== 4) {
exit("Transfer failed\n");
}
// Skip the first byte (clocked out during command phase).
printf("JEDEC ID: %s\n", strtoupper(bin2hex(substr($rx, 1))));
spi_close($dev);See examples/as3935-autotune.php for a complete AS3935 antenna-autotune script that combines spi_transfer, GPIO edge events, and kernel hardware timestamps.
| Helper | Facade method | Description |
|---|---|---|
spi_open(string $path, int $mode = 0, int $speed = 500_000, int $bitsPerWord = 8) |
Device::spiOpen |
Open /dev/spidev* and apply mode/speed/word-size; returns SPIDevice|null |
spi_close(SPIDevice $dev) |
Device::spiClose |
Close the file descriptor; returns 0 or -1 |
spi_read(SPIDevice $dev, int $len) |
Device::spiRead |
Half-duplex read of up to $len bytes; returns string|false |
spi_write(SPIDevice $dev, string $data) |
Device::spiWrite |
Half-duplex write of $data; returns bytes written |
spi_transfer(SPIDevice $dev, SPITransfer ...$transfers) |
Device::spiTransfer |
Full-duplex, chained transfer; returns concatenated MISO bytes or false |
spi_open validates each ioctl as it goes and closes the descriptor on any failure, so a non-null return guarantees the device is configured. The mode parameter is the same SPIMode bitmask the kernel uses (SPI_IOC_WR_MODE32).
| Helper | Facade method | Underlying ioctl |
|---|---|---|
spi_get_mode(SPIDevice $dev) |
Device::spiGetMode |
SPI_IOC_RD_MODE32 |
spi_set_mode(SPIDevice $dev, int $mode) |
Device::spiSetMode |
SPI_IOC_WR_MODE32 |
spi_get_speed(SPIDevice $dev) |
Device::spiGetSpeed |
SPI_IOC_RD_MAX_SPEED_HZ |
spi_set_speed(SPIDevice $dev, int $hz) |
Device::spiSetSpeed |
SPI_IOC_WR_MAX_SPEED_HZ |
spi_get_bits_per_word(SPIDevice $dev) |
Device::spiGetBitsPerWord |
SPI_IOC_RD_BITS_PER_WORD |
spi_set_bits_per_word(SPIDevice $dev, int $bits) |
Device::spiSetBitsPerWord |
SPI_IOC_WR_BITS_PER_WORD |
spi_get_lsb_first(SPIDevice $dev) |
Device::spiGetLsbFirst |
SPI_IOC_RD_LSB_FIRST |
spi_set_lsb_first(SPIDevice $dev, bool $lsb) |
Device::spiSetLsbFirst |
SPI_IOC_WR_LSB_FIRST |
All setters return 0 on success and -1 on failure. Getters return the raw integer (or bool for lsb_first), and -1 on ioctl failure for the int-returning ones. Mask spi_get_mode against SPIMode cases to test individual mode bits.
Issues a single SPI_IOC_MESSAGE(n) ioctl bundling n transfer segments. CS is asserted at the start of the first segment and stays asserted across all segments unless an individual segment requests otherwise via csChange. Returns the concatenated MISO bytes from every segment (length equals the sum of each segment's len), or false if the transaction fails.
Two paths are supported transparently:
- Native fast path — if a low-level extension exposes
spi_transfer(int $fd, …)(note theintfirst argument), it is used directly. ext-posifallback — otherwise the call packs eachspi_ioc_transferstruct manually usingposi_mem_alloc/posi_mem_write/posi_mem_readand issuesSPI_IOC_MESSAGE(1)per segment. This is the supported path for plainext-posiinstallations.
If neither path is available, spi_transfer returns false.
final readonly class SPIDevice {
public int $fd; // open file descriptor
public string $path; // device path (/dev/spidev0.0)
public int $mode; // SPIMode bitmask the device was opened with
public int $speed; // max clock speed (Hz) configured at open time
public int $bitsPerWord; // word size configured at open time
}The mode, speed, and bitsPerWord fields record the values passed to spi_open and are not updated when you later call spi_set_mode / spi_set_speed / spi_set_bits_per_word. Use the matching getter for the live kernel value.
final readonly class SPITransfer {
public function __construct(
public string $tx, // bytes clocked out on MOSI (padded/truncated to $len)
public int $len, // total bytes clocked in each direction
public int $speedHz = 0, // override device speed (0 = use device default)
public int $delayUsecs = 0, // microseconds to hold after this segment
public int $bitsPerWord = 0, // override device word size (0 = use device default)
public bool $csChange = false, // de-assert CS after this segment
public int $txNbits = 0, // 1 / 2 / 4 / 8 — dual/quad/octal MOSI
public int $rxNbits = 0, // 1 / 2 / 4 / 8 — dual/quad/octal MISO
public int $wordDelayUsecs = 0, // microseconds between words
) {}
}Notes:
$txis right-padded with NUL bytes if shorter than$len, and truncated if longer.- For read-only segments pass
tx: str_repeat("\0", $len)(or any dummy bytes — the slave ignores them on a pure read). csChangesemantics are inverted from intuition: setting ittruerequests CS de-assert after this segment, which is what you want for "this is the last byte of a logical message" inside a chain.- The dual/quad/octal lane fields require a controller and slave that support SPI multi-IO; leave them at
0for standard single-MOSI/MISO operation.
All enums are int-backed with SCREAMING_SNAKE_CASE cases that map directly to kernel constants.
| Case | Value | Meaning |
|---|---|---|
MODE_0 |
0x00 |
CPOL=0, CPHA=0 (alias of zero) |
CPHA |
0x01 |
Clock phase = 1 (sample on trailing edge) |
CPOL |
0x02 |
Clock polarity = 1 (idle high) |
CS_HIGH |
0x04 |
Chip-select is active-high |
LSB_FIRST |
0x08 |
LSB transmitted first |
THREE_WIRE |
0x10 |
Shared MOSI/MISO line |
LOOP |
0x20 |
Internal MOSI→MISO loopback |
NO_CS |
0x40 |
Disable hardware CS (manage manually) |
READY |
0x80 |
Slave pulls ready signal |
TX_DUAL |
0x100 |
Dual-lane MOSI |
RX_DUAL |
0x200 |
Dual-lane MISO |
TX_QUAD |
0x400 |
Quad-lane MOSI |
RX_QUAD |
0x800 |
Quad-lane MISO |
CS_WORD |
0x1000 |
Toggle CS per word |
TX_OCTAL |
0x2000 |
Octal-lane MOSI |
RX_OCTAL |
0x4000 |
Octal-lane MISO |
THREE_WIRE_HIZ |
0x8000 |
High-Z on inactive cycle of 3-wire |
The classical "SPI mode 0..3" values are simply CPOL/CPHA combinations:
| SPI mode | Bitmask | CPOL | CPHA |
|---|---|---|---|
| 0 | SPIMode::MODE_0->value (0) |
0 | 0 |
| 1 | SPIMode::CPHA->value (1) |
0 | 1 |
| 2 | SPIMode::CPOL->value (2) |
1 | 0 |
| 3 | SPIMode::CPOL->value | SPIMode::CPHA->value (3) |
1 | 1 |
| Case | Value |
|---|---|
SPI_IOC_RD_MODE |
0x80016B01 |
SPI_IOC_WR_MODE |
0x40016B01 |
SPI_IOC_RD_LSB_FIRST |
0x80016B02 |
SPI_IOC_WR_LSB_FIRST |
0x40016B02 |
SPI_IOC_RD_BITS_PER_WORD |
0x80016B03 |
SPI_IOC_WR_BITS_PER_WORD |
0x40016B03 |
SPI_IOC_RD_MAX_SPEED_HZ |
0x80046B04 |
SPI_IOC_WR_MAX_SPEED_HZ |
0x40046B04 |
SPI_IOC_RD_MODE32 |
0x80046B05 |
SPI_IOC_WR_MODE32 |
0x40046B05 |
MESSAGE_1 |
0x40206B00 |
SPIOpCode::messageN(int $n): int computes the variant SPI_IOC_MESSAGE(n) ioctl number for batched transfers:
use Microscrap\Bindings\SPI\Enums\SPIOpCode;
$ioctl = SPIOpCode::messageN(3); // request for 3 chained spi_ioc_transfer structs| Helper | Signature |
|---|---|
spi_open |
(string $path, int $mode = 0, int $speed = 500_000, int $bitsPerWord = 8): ?SPIDevice |
spi_close |
(SPIDevice $dev): int |
spi_read |
(SPIDevice $dev, int $len): string|false |
spi_write |
(SPIDevice $dev, string $data): int |
spi_get_mode |
(SPIDevice $dev): int |
spi_set_mode |
(SPIDevice $dev, int $mode): int |
spi_get_speed |
(SPIDevice $dev): int |
spi_set_speed |
(SPIDevice $dev, int $hz): int |
spi_get_bits_per_word |
(SPIDevice $dev): int |
spi_set_bits_per_word |
(SPIDevice $dev, int $bits): int |
spi_get_lsb_first |
(SPIDevice $dev): bool |
spi_set_lsb_first |
(SPIDevice $dev, bool $lsb): int |
spi_transfer |
(SPIDevice $dev, SPITransfer ...$transfers): string|false |
- Half-duplex vs full-duplex.
spi_readandspi_writeoperate on the kernel'sposix_read/posix_writepath, which is half-duplex — the kernel clocks out dummy bytes on a read and ignores MISO on a write. For real full-duplex transactions (where the bytes you receive correspond byte-for-byte to the bytes you transmit), usespi_transferwith one or moreSPITransfersegments. - CS behavior. A single
spi_transfercall holds CS low across every segment by default. SetcsChange: trueon a segment to release CS after it. UseSPIMode::NO_CSif your driver provides CS via a GPIO instead of the controller. - Word size on Linux. Most spidev drivers only support 8-bit words. Setting
bitsPerWordto anything else may return-1on platforms that don't support it. - Speed. The configured speed is an upper bound. The kernel rounds down to the closest divider the SPI controller can produce; query
spi_get_speedafterwards if exact-rate behavior matters. - Concurrency. Linux serialises access to a spidev character device per file descriptor. If you need overlapping transactions, open the same device twice — but the kernel will still serialise them on the bus.
MIT. See LICENSE.