Mach RPC from first principles, using our own code as the worked example — and what two runtime probes settled about the kernel path.
MIG is a stub generator. You describe an interface in a .defs file;
MIG emits C for both ends — a client stub that turns a function call into a Mach message, and a
server stub that turns that message back into a function call. Everything difficult about MIG
comes from one fact: the two ends must agree perfectly, and nothing at runtime checks that they
do. Normally they agree because they were generated together from the same file. When that stops
being true — as it has for our kernel stubs — MIG stops helping and starts hurting.
Two processes want to call each other's functions. Mach gives you exactly one primitive:
mach_msg(), which sends a blob of bytes to a port. Everything else is your problem:
That is mechanical, tedious, and unforgiving — the classic case for codegen. MIG is that codegen.
It is conceptually identical to rpcgen for ONC RPC, or protoc for gRPC. The
Mach Interface Generator dates from CMU Mach in the 1980s; it long predates XNU and is not
Apple-specific.
You cannot read MIG code without these four ideas.
| Term | What it is |
|---|---|
| Port | A kernel-held message queue. Not a number you can forge — a kernel object. |
| Receive right | Permission to dequeue from a port. Exactly one holder at a time. Owning it means you are the server. |
| Send right | Permission to enqueue. Any number of holders. This is a capability: holding it is the authorisation. |
| Send-once right | A send right good for exactly one message, then it evaporates. Used for reply ports. |
A port name (mach_port_name_t) is a per-task integer index into that task's
port namespace — like a file descriptor. The same kernel port has different names in different tasks.
This is why sending a port to another process is not just copying an integer: the kernel must translate
the name, which is what "descriptors" below are for.
A disposition tells the kernel what to do with a right you are sending:
MACH_MSG_TYPE_COPY_SEND (give them a copy, keep yours),
MOVE_SEND (hand yours over), MAKE_SEND_ONCE (mint a one-shot from a receive right).
These appear packed into msgh_bits.
Every Mach message starts with the same header. From src/libmach/include/mach/message.h:
typedef struct {
mach_msg_bits_t msgh_bits; /* dispositions + COMPLEX flag */
mach_msg_size_t msgh_size; /* total bytes */
mach_port_t msgh_remote_port; /* destination (going out) */
mach_port_t msgh_local_port; /* reply port (going out) */
mach_msg_size_t msgh_voucher_port; /* modern XNU; msgh_reserved on older */
mach_msg_id_t msgh_id; /* WHICH ROUTINE */
} mach_msg_header_t;
Note msgh_id — that single integer is how the server knows which function you meant.
And note the header is 24 bytes in userland but 32 in the kernel, because
mach_port_t is a 4-byte name in userland and an 8-byte pointer in the kernel. The kernel
fixes this up on copyin/copyout (LEGACY_HEADER_SIZE_DELTA == 8). If you ever hand-build a
message, this is the first thing that will bite you.
That header comment in our tree is itself a lesson in MIG version skew:
“
msgh_voucher_port(modern XNU) andmsgh_reserved(older XNU, pre-voucher) occupy the same 32-bit slot. MIG bootstrap_cmds-138 generates stubs that touch->msgh_reserved…”
.defsHere is real code from our tree — src/Libnotify/notify_ipc.defs:
subsystem notify_ipc 1000; <-- name, and the BASE msgh_id
userprefix _new_ipc_base; <-- prefix for generated client functions
serverprefix _; <-- prefix for generated server functions
type notify_name = c_string[*:512] ctype : caddr_t;
skip; <-- burns an id slot; keeps numbering stable
skip;
routine _notify_server_check
(
server : mach_port_t; <-- the destination port
token : int; <-- in-parameter
out check : int; <-- out-parameter (comes back in the reply)
out status : int;
ServerAuditToken audit : audit_token_t
);
Four things worth absorbing:
subsystem name base; — the base msgh_id. Routines are numbered
base + index, counting from zero in declaration order.skip; — reserves an id for a deleted routine. This is why you must never
delete a routine line: it renumbers everything after it and silently breaks every existing
client. skip is a tombstone.out marks reply data. Plain args go in the request.routine vs simpleroutine — routine waits for a
reply (synchronous RPC); simpleroutine is fire-and-forget, generates no reply, and never
blocks.One .defs produces two C files:
fooUser.c — the clientDefines a real C function with your signature. Inside: build a request struct, fill the header,
get a reply port, call mach_msg(SEND|RCV), check the reply, unpack out-params, return
the kern_return_t.
fooServer.c — the serverDefines a demux function. Given a message, look up msgh_id, type-check the request,
unpack args, call your implementation, pack the reply.
The critical property: you write neither. Both come from the same source, so they agree about layout, padding and ids by construction. That guarantee is the entire value proposition. Hold onto that — it is exactly what has failed in our kernel stubs.
msgh_idEach interface gets a numeric range. Ours, from the tree:
notify_ipc 1000 asl_ipc 114
config 20000 wlan 30000
ipconfig 20000 launchd job 400
libdispatch 64 launchd internal 137000
kernel subsystems (from the 2015 stubs):
mach_port 3200-3235 task 3400-3442
mach_host 200- 225 host_priv 400- 426
mach_vm 4800-4820 vm_map 3800-3831
The server dispatches by simple arithmetic — literally
routine[msgh_id - 3200] in our mach_port_server.c. Reply ids are
request id + 100 by convention, so 3217's reply is 3317.
This is where ordering becomes load-bearing. The id is positional. If two parties
disagree about the order of routines in a subsystem, calls silently land on the wrong function with a
mis-shaped message. Our kernel stubs diverge from XNU's ordering in the middle of the
mach_port subsystem — deallocate is 3205 here but 3206 upstream — so about
eleven routines would map wrong if anyone naively used Apple's .defs.
Message data comes in two flavours, and the distinction is not stylistic.
Pure bytes. Integers, strings, structs — data the kernel copies verbatim without understanding it. Layout is just: header, then fields.
Contain things the kernel must interpret and translate:
A complex message sets MACH_MSGH_BITS_COMPLEX in msgh_bits and inserts a
mach_msg_body_t — a 4-byte descriptor count — immediately after the header, followed by
that many descriptors.
SIMPLE: [ header 24 ][ NDR 8 ][ args... ]
COMPLEX: [ header 24 ][ body 4 ][ descriptors... ][ NDR 8 ][ args... ]
^^^^^^^^ this shifts everything after it
The COMPLEX bit changes the byte layout of the whole message. A receiver expecting one shape and handed the other does not see "a slightly different message" — it sees garbage at every offset. That is why MIG type-checks it on arrival, and why disagreeing about it is fatal rather than cosmetic.
CLIENT SERVER
|
| call foo(port, a, b)
| ── MIG client stub ──────────────────────────────────
| 1. reply_port = mig_get_reply_port() (cached per-thread)
| 2. build Request { header, NDR, a, b }
| 3. msgh_remote_port = port (COPY_SEND)
| msgh_local_port = reply_port (MAKE_SEND_ONCE)
| msgh_id = base + index
| 4. mach_msg(SEND|RCV, send_size, rcv_size, reply_port)
| ──────────────── message ────────────────▶
| 5. dequeue, demux on msgh_id
| 6. type-check request
| 7. unpack a, b
| 8. call YOUR function
| 9. pack Reply { RetCode, outs }
| ◀─────────────── reply ─────────────────
| 10. verify msgh_id == request_id + 100
| 11. if size == sizeof(mig_reply_error_t) && RetCode != 0 → return RetCode
| 12. unpack out-params, return KERN_SUCCESS
▼
Step 11 is the escape hatch worth knowing: a server that rejects your message replies with a bare
mig_reply_error_t, and the client recognises it by size. So a malformed request comes
back as a clean error code, not a hang. This is why our broken-client scenario would return
MIG_BAD_ARGUMENTS (-304) deterministically rather than corrupting anything.
The 8-byte NDR_record_t after the header declares endianness, char set and float format,
so a big-endian client could in principle talk to a little-endian server. In practice everyone sets
NDR_record and nobody checks it — our kernel server never validates it.
This is the part that makes "should we use MIG?" a confusing question, because the answer is already yes, extensively.
Seven of them — configd, notifyd, ipconfigd, wland, ASL, launchd, libdispatch. Both halves generated at
build time by the in-tree migcom, from .defs files that live in the repo.
They agree by construction. Nothing to think about.
Apple's libsystem_kernel calls the kernel via MIG — mach_port_*,
task_*, host_* are all MIG clients sending to the task port. Our
libmach instead uses direct syscalls, resolved at runtime through
sysctl mach.syscall.<name>. Fourteen such traps exist today.
The kernel's own source says so, in mach_traps.c:
“task_get_special_port / task_set_special_port — direct syscall wrappers, not Apple-imported (Apple delivers these over MIG to the task port).”
This is a real divergence from Apple. It was long described as a deliberate choice; it is more accurately an absence. Apple does not pick MIG or traps — MIG is the floor and traps are an accelerator layered on top of it, one that falls back to MIG when the trap is missing:
/* xnu/libsyscall/mach/mach_port.c */
rv = _kernelrpc_mach_port_allocate_trap(task, right, name);
if (rv == MACH_SEND_INVALID_DEST) {
rv = _kernelrpc_mach_port_allocate(task, right, name); /* MIG */
}
Routines that return variable-length arrays — mach_port_names,
mach_port_get_set_status — have no trap at Apple at all, because you cannot
return an array through a register interface. Checked against the 130 entries in XNU's
mach_trap_table, 9 of NextBSD's 12 traps match a real Apple trap; the three
that do not (host_set_special_port, task_get_special_port,
task_set_special_port) are exactly the ones the comment above flags.
So the divergence is not that we have traps. It is that we have only traps, and everything
that cannot be expressed as one was stubbed out to return KERN_SUCCESS without doing
anything — about 18 entry points, including the one that keeps on-demand Mach launch dead. See the
mach_port MIG client plan.
Everything above sets up this one situation, which is worth understanding because it is a perfect illustration of what MIG's guarantee is for.
The kernel does have MIG server stubs for mach_port — generated,
compiled, registered, reachable, backed by a complete implementation. They were generated in
2015, by a MIG we no longer have, and checked in as source. There is no
.defs for them in either repo.
Those 2015 stubs demand:
if (!(In0P->Head.msgh_bits & MACH_MSGH_BITS_COMPLEX) ||
(In0P->msgh_body.msgh_descriptor_count != 0) ||
(In0P->Head.msgh_size != sizeof(__Request)))
return MIG_BAD_ARGUMENTS;
Read that carefully: COMPLEX, with zero descriptors. A message flagged as containing kernel-interpreted data, that contains none. Every routine in every kernel subsystem in our tree is generated this way.
Our current migcom decides simple-vs-complex like this:
#define IS_KERN_PROC_DATA(x) (!(x)->itInLine || (x)->itPortType)
MACH_MSG_TYPE_PORT_NAME 15 <-- what mach_port_name_t is
MACH_MSG_TYPE_MOVE_RECEIVE 16 <-- the floor for "counts as a port type"
15 < 16. So a mach_port_name_t argument is not a port type, nothing in the
routine is kernel-processed data, and the request is generated simple — 44 bytes, no
msgh_body, no COMPLEX bit. This was confirmed by building migcom and running it,
not merely by reading.
So: the client we can generate and the server we already have disagree about the fundamental shape of the message. Not about a field — about whether there is a 4-byte body between the header and the payload, and about a flag bit that changes how the kernel interprets everything.
An earlier version of this page concluded from the above that kernel MIG was unusable, and that a syscall trap was therefore the right answer. That conclusion was wrong, and it is worth seeing why, because the error is a common one: a true premise carried one step too far.
The premise — our migcom cannot produce a client matching the frozen server — is
correct. The leap was assuming we are obliged to match the frozen server. We are not. We can
regenerate it too, at which point both ends come from the same compiler and agree by
construction, which is the whole point of MIG.
The second error was deferring a measurement. This page had assumed, from a stale comment in mach_test.c, that a cold kernel round-trip might panic. Two hand-built messages sent on real hardware settled it:
reply msgh_id = 3317 RetCode = 0 outCnt = 10
mps_pset=0 seqno=0 mscount=0 qlimit=5 MSGCOUNT=0 <-- real kernel state
RESULT: kernel MIG WORKS
reply id=3314 size=56 complex=yes desc_count=1
membersCnt=1 ool.size=4 ool.address=0x1000
member[0] = 0x12 <-- the port we inserted
RESULT: OOL round-trip WORKS
The second probe is mach_port_get_set_status — the exact call libmach fakes — and it
returns correctly, out-of-line memory and all. The kernel implementation was complete the whole time.
Userland never asked it anything.
MIG's value is that both ends are generated together and therefore agree. Here, one end was generated
in 2015 by a compiler we no longer have, and no .defs was ever checked in — so
the guarantee was never lost so much as never claimed. It is recoverable: reconstruct
the .defs from the generated stubs, regenerate both ends, and the guarantee comes back.
The wider lesson is about method rather than Mach. The blocking question was “does the kernel receive path work at all?”, it was answerable in an afternoon with a 60-line program that could not damage anything, and it was instead deferred behind a design argument built on a stale code comment. Measure the thing that decides the architecture, first.
foo.defs: pick an unused subsystem base, declare routines in a
fixed order.run_mig invocation in build-userland.sh.fooUser.c into the client, fooServer.c into the server.skip;. Deleting renumbers every
later routine and breaks every deployed client, silently..defs
is missing is a time bomb — this whole case study is that time bomb going off..defs in the repo. It is the source of truth; the C is build output.__Request__foo_t / __Reply__foo_t — the wire structs. Read these to know the
exact layout.__MIG_check__Request__foo_t — the server's validation. Read this to know what it demands._Xfoo — the server stub. Look here for the call into your implementation.__DeclareRcvRpc(3217, "…") — tells you the routine's actual msgh_id.MIG_BAD_ID (-303) — no routine for that msgh_id. Wrong subsystem base, or
ordering skew.MIG_BAD_ARGUMENTS (-304) — the request failed type-check. Almost always size or the
COMPLEX bit.MIG_TYPE_ERROR (-300) — reply didn't match. Often an alignment or count mismatch.XPC is the modern, dynamically-typed layer — dictionaries of values over libdispatch Mach channels, no
codegen. MIG is the older, statically-typed layer. Both ride on mach_msg. Our tree uses both:
XPC for newer services, MIG for the classic daemon interfaces and every kernel subsystem.
Companion to
Restoring MIG as NextBSD’s kernel RPC substrate,
which applies all of this across seven kernel subsystems, and to the
mach_port sub-plan, which is the one
that is reconstructed and gate-verified.