MIG explained reference

Mach RPC from first principles, using our own code as the worked example — and what two runtime probes settled about the kernel path.

The one-paragraph version

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.

1. The problem MIG solves

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.

2. The vocabulary: ports, rights, dispositions

You cannot read MIG code without these four ideas.

TermWhat it is
PortA kernel-held message queue. Not a number you can forge — a kernel object.
Receive rightPermission to dequeue from a port. Exactly one holder at a time. Owning it means you are the server.
Send rightPermission to enqueue. Any number of holders. This is a capability: holding it is the authorisation.
Send-once rightA 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.

3. The message on the wire

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) and msgh_reserved (older XNU, pre-voucher) occupy the same 32-bit slot. MIG bootstrap_cmds-138 generates stubs that touch ->msgh_reserved…”

4. The interface language: .defs

Here 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:

5. What MIG generates

One .defs produces two C files:

fooUser.c — the client

Defines 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 server

Defines 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.

6. Subsystems and msgh_id

Each 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.

7. Simple vs complex — the concept that bit us

Message data comes in two flavours, and the distinction is not stylistic.

Simple messages

Pure bytes. Integers, strings, structs — data the kernel copies verbatim without understanding it. Layout is just: header, then fields.

Complex messages

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.

8. The round trip, step by step

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.

NDR — the byte-order record

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.

9. How NextBSD uses MIG — and where it doesn't

This is the part that makes "should we use MIG?" a confusing question, because the answer is already yes, extensively.

Daemon interfaces: MIG, and it works

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.

Kernel interfaces: not MIG at all

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.

10. Case study: the frozen server, and what we measured

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.

What that does not imply

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.

The lesson

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.

11. Practical notes

Adding a MIG interface (daemon-to-daemon — the normal case)

  1. Write foo.defs: pick an unused subsystem base, declare routines in a fixed order.
  2. Add a run_mig invocation in build-userland.sh.
  3. Compile the generated fooUser.c into the client, fooServer.c into the server.
  4. Server: implement the functions the server stub calls, and run its demux from your message loop.

Rules that will save you

Reading generated code

Debugging

Where MIG sits relative to XPC

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.