Linux-compatible character devices for macOS — starting with /dev/binder,
Android's IPC driver, so Android userspace running under mSL/NABI can talk.
One module of mSL/XNU, a modular macOS Subsystem for Linux.
Status: early. The driver is complete enough to carry real binder traffic — transactions and replies, the object and handle translation between processes, the reference-counting protocol, death notifications,
poll(), and the three Android contexts — and it builds clean for arm64e, x86_64 and universal. It has not yet been run against a loaded kernel: the load needs a password this repository's build cannot supply.out/binder-probeis the test that decides whether it works; see Testing. Descriptor passing (BINDER_TYPE_FD) is deliberately incomplete — see What macOS will not do.
| Piece | What it does | Where |
|---|---|---|
| Syscall translation | Linux system calls onto Darwin's, over Hypervisor.framework |
mSL/NABI |
| Filesystem Hierarchy Standard | Native Linux-style filesystem layout | mSL/FHS |
| procfs | /proc, as a native pseudo-filesystem |
mSL/ProcFS |
| sysfs | /sys, as a native pseudo-filesystem |
mSL/SysFS |
| devfs | /dev driver add-ons for macOS |
this repository |
The devfs pseudo-filesystem, mounted at /dev, is already present in XNU.
What it does not have is Linux-compatible character drivers such as binder.
SIP refuses a second devfs stacked over /dev, so these nodes are published
into XNU's own.
Android's IPC. Not a socket: a transaction names an object, carries a parcel,
and the kernel rewrites every object reference inside that parcel so it means
the same thing to the receiving process — a local object becomes a handle, a
handle becomes a local object if it is going home. That translation, plus
reference counting that tells an object's owner when the last remote user lets
go, is what a /dev/binder is. servicemanager, every AIDL interface and every
HAL call in Android goes through it.
mSL/NABI's porting notes closed the Waydroid question with the observation that
/dev/binder "is a kernel driver with no macOS equivalent and nothing here
could supply one". This repository supplies one.
| Linux | Here |
|---|---|
drivers/android/binder.c |
kext/binder_*.c |
/dev/binder, /dev/hwbinder, /dev/vndbinder |
the same, via devfs_make_node_clone |
binderfs mounted at /dev/binderfs |
/dev/binderfs/binder-control, minting nodes beside itself |
one binder_proc per open() |
one per open, via a cloning device |
mmap() of the driver |
a client-registered arena (BINDER_MSL_SET_ARENA) |
BINDER_TYPE_FD moves a descriptor |
passed to a userspace broker |
/dev/ashmem |
not a device: Darwin shared memory, in the NABI shim |
Three findings shaped everything above. All were verified against the XNU source for the running kernel and the KPI export lists, not inferred:
mmap() of a character device does not exist. bsd/kern/kern_mman.c
answers ENODEV for every VCHR vnode, and cdevsw.d_mmap has no callers
anywhere in the kernel — the BSD page-number contract was dropped. Binder's
receive buffer is normally that mapping, so instead the client allocates the
region itself and registers it, and the driver copies each payload into it in
the receiving thread's own context, which is the one place a copyout to that
address is legal. It costs one extra copy per transaction; everything userspace
observes is unchanged.
No kext can install a descriptor into a process. sys/file.h exposes five
calls, all operating on the caller's own descriptors; falloc, fp_lookup and
fp_drop are exported to nothing. BINDER_TYPE_FD therefore travels with the
sender's descriptor number and the sender's pid (in a field Linux leaves as
padding), for a userspace broker to resolve over SCM_RIGHTS. BINDER_TYPE_FDA
is passed through to the same broker, which uses the sender's pid stamped into
the transaction header to translate the array.
poll() cannot reach a third-party character device. macOS implements
poll() over kqueue, and filt_specattach refuses a knote on a cdev whose
driver has not called cdevsw_setkqueueok() — which is absent from the SDK and
exported only through com.apple.kpi.private, a KPI the sibling modules
deliberately do not link. poll() therefore answers POLLNVAL without ever
consulting the driver. select() works, and so does kqueue when the knote
carries NOTE_LOWAT with a low-water mark of 1, which is the escape hatch in
that same check. This is not academic: Android's looper watches the binder fd
with epoll, mSL/NABI implements epoll over kqueue, so its shim sets
NOTE_LOWAT and gets exactly the readiness this driver reports.
The ioctl direction bits are inverted. BSD's _IOW is IOC_IN; Linux's is
the other bit, and XNU acts on them before the driver is called — a
Linux-numbered _IOW arrives as a buffer of zeroes. Clients send
BINDER_CMD_HOST(cmd), which sets both; the driver matches on
BINDER_CMD_KEY(cmd), which masks them off, and refuses data-carrying commands
that arrive without the input bit rather than acting on zeroes.
Working (built, not yet run against a loaded kernel):
/dev/binder,/dev/hwbinder,/dev/vndbinder— three separate contexts, each with its own context manager, objects and handle numbering/dev/binderfs/binder-controlwithBINDER_CTL_ADD, minting further contextsBINDER_VERSION(protocol 8),BINDER_SET_MAX_THREADS,BINDER_SET_CONTEXT_MGR,BINDER_SET_CONTEXT_MGR_EXT,BINDER_THREAD_EXIT,BINDER_GET_NODE_DEBUG_INFO,BINDER_GET_NODE_INFO_FOR_REF,BINDER_WRITE_READ- the full command streams:
BC_TRANSACTION/BC_REPLYand their scatter-gather forms,BC_FREE_BUFFER, the four reference commands and their_DONEacknowledgements, the looper commands, death notification request and clear;BR_TRANSACTION/BR_REPLY,BR_INCREFS/ACQUIRE/RELEASE/DECREFS,BR_DEAD_BINDER,BR_SPAWN_LOOPER,BR_FAILED_REPLY/BR_DEAD_REPLY - object translation across processes, including
BINDER_TYPE_PTRscatter-gather buffers - asynchronous transactions, one at a time per object, with the rest queued on it
- readiness that agrees with the read loop, through
select()and through kqueue withNOTE_LOWAT(poll()is a platform limitation, above) - teardown on close: objects declared dead, handles dropped, callers waiting on
a reply told
BR_DEAD_REPLY - diagnostic counters under
sysctl devfs
Not implemented:
/dev/ashmem— deliberately, see doc/NABI-INTEGRATION.md- fine-grained locking: the driver holds one mutex, by choice, and says why in
include/fs/devfs/binder_internal.h
mSL-DevFS/
├── doc/NABI-INTEGRATION.md what mSL/NABI must do, and what Waydroid still needs
├── include/fs/devfs/
│ ├── binder.h the Linux binder ABI, shared with userspace and NABI
│ └── binder_internal.h the driver's own state, and why it is shaped that way
├── include/xnu/ vendored XNU private headers
├── kext/
│ ├── devfs.c module start/stop, device major, diagnostic sysctls
│ ├── binder_dev.c the cdevsw, the cloning nodes, the contexts, binderfs
│ ├── binder_proc.c processes, threads, queues, the read and write loops
│ ├── binder_node.c objects, handles, reference counting, death
│ ├── binder_txn.c transactions and object translation
│ └── binder_alloc.c the transaction arena
├── lib/ git submodules (libkext, xnu)
├── out/ build output (gitignored)
└── tools/
├── binder-probe.c the functional test
└── binder-abi-test.c the ABI gate: every constant against its _IOC form
git submodule update --init lib/libkext
make # native arch (arm64e here)
make ARCH=x86_64
make ARCH=universalNever build as root: make install only copies what make already built, so
every artifact stays owned by the invoking user.
Third-party kexts on Apple Silicon need Reduced Security and a reboot before they will load at all. With that done:
sudo make loadmake load clears the kext staging area first — macOS caches third-party kexts
in the Auxiliary Kernel Collection, and a stale staged copy otherwise shadows a
freshly built one, which costs an afternoon the first time it happens.
sudo make unloadThe ABI gate runs as part of every build: tools/binder-abi-test.c recomputes
each ioctl number and command code from Linux's _IOC encoding applied to the
struct the ABI names, and fails the build if a literal disagrees. It exists
because the first draft of the header carried a fabricated 24-byte
binder_version (Linux's is 4 bytes), inverted direction bits on every BC_/
BR_ code, and a constant size field — none of which is visible by inspection.
The functional test needs the kext loaded:
make checkIt walks the protocol the way libbinder does: the device answers and reports
protocol 8; the arena is registered and its bounds enforced; a context manager
registers once and only once, per context; a oneway transaction to ourselves
completes and its payload arrives byte-for-byte inside the registered arena; a
synchronous call is served and replied to across two threads; poll() reports
readable exactly when there is work; and an object passed to a second process
arrives as a handle whose holder is told when its owner exits.
make check skips cleanly when nothing is loaded, so CI stays a compile gate.
The stages are independent and can be run one at a time, which is what you want while the driver is still young enough to hang the machine:
./out/binder-probe --list
./out/binder-probe --stage onewayEvery line is also appended to out/binder-probe.log, flushed and fsynced as
it is written. That matters more than it sounds: a driver bug here stops the
machine rather than the process, taking the terminal with it, and a synced log
survives the reboot. A log whose last line begins .. names the operation that
never returned — the stage alone narrows a hang to a few hundred lines of
kernel code, and that line narrows it to one.
The protocol, and much of the shape of the implementation, is Android's binder
driver — drivers/android/binder.c. Where this driver departs from it, the
reason is written down next to the departure.
MIT. See LICENSE.