Connecting a Raspberry Pi Zero 2 W to an iPad Over USB

, , , , , ,

ipad-macbook-pro-rpi-zero-2-w

A step-by-step guide to turning a single USB cable into a direct network link between your Pi and an iPad — no Wi-Fi, no router, no fuss once it’s set up.


Why do this at all?

Normally you’d SSH or VNC into a Raspberry Pi over Wi-Fi. But Wi-Fi isn’t always available or reliable — maybe you’re on a network that blocks device-to-device traffic, maybe you’re somewhere with no network at all, or maybe you just want a rock-solid, low-latency link for remote coding on the go. The Pi Zero 2 W has a killer feature for this: its USB port can act as a USB gadget, meaning instead of behaving like a normal USB peripheral (a keyboard, a flash drive), it can pretend to be a network adapter plugged straight into your iPad. Your iPad sees “Ethernet,” assigns it an IP address, and suddenly you have a private, wired network between the two devices.

This article walks through the entire process: picking the right gadget mode, editing the two boot files that make it happen, setting up networking so the iPad actually gets an IP, and getting VNC working for full remote desktop access — plus the specific gotchas that trip people up.


Part 1: g_ether vs g_ncm — which gadget mode do you actually want?

Before touching any files, it’s worth understanding what you’re choosing between, because this decision affects whether other devices (like an Android phone) will work later too.

The Pi’s USB gadget framework can present itself as several different device types. For networking, there are two main options:

g_ether (CDC-ECM)g_ncm (CDC-NCM)
What it isAn older, simpler USB networking standard. Sends one Ethernet frame per USB transfer.A newer standard. Bundles multiple frames into a single USB transfer, which is more efficient.
iPad / iPadOS supportYes — well supportedYes — this is actually what Apple’s own Personal Hotspot feature uses internally
macOS supportYesYes
Android support (as a USB host)No — stock Android has no built-in driver for CDC-ECMOften yes — Android commonly ships the cdc_ncm driver, the same one used by off-the-shelf USB-C-to-Ethernet dongles
Windows supportNeeds RNDIS (a different mode, bundled with the same g_ether driver and auto-negotiated)Not supported without extra drivers

The practical takeaway: if you only ever plan to connect to an iPad or a Mac, g_ether is simpler and has been the traditional default for years. But if you also want a chance of this working with an Android phone (like a Pixel) without switching configs back and forth, g_ncm is the better bet, since it’s supported by both iOS/macOS and stock Android in most cases.

This guide uses g_ncm, since it’s the more broadly compatible choice, but every step below notes what changes if you use g_ether instead.

An important wrinkle: g_ncm might not exist as a simple module on your Pi

Older Raspberry Pi OS kernels shipped a monolithic g_ncm.ko kernel module you could load with a single command. Newer kernels (check yours with uname -r) have dropped this in favor of a more modern, flexible system called configfs. Instead of one big pre-built module, the kernel offers small building blocks (libcomposite, usb_f_ncm) that you assemble yourself into a gadget. This guide covers the modern configfs approach, since it’s what current Raspberry Pi OS images actually use — but be aware that if you’re following older tutorials online that just say modules-load=dwc2,g_ncm in cmdline.txt, that shortcut may silently fail on a current OS image, because there’s no g_ncm.ko file to load. You can check this yourself before starting:

modinfo g_ncm
# If this prints "ERROR: Module g_ncm not found," you're on a
# modern kernel and need the configfs method in this guide.

Part 2: Physical setup — plugging in the right port

This step is boring but it’s the single most common point of failure, so don’t skip it.

The Pi Zero 2 W has two micro-USB (or USB-C, depending on your exact board revision) ports on the board:

  • One labeled PWR IN — this is power-only. It has no data lines connected at all. Plugging your iPad cable in here will charge the Pi and nothing else will ever work, no matter how perfect your software config is.
  • One labeled USB — this is the OTG (On-The-Go) capable port. This is the only port that can carry both power and data, and it’s the only one that can run in gadget mode.

Always plug your USB-C-to-USB cable into the port labeled USB, not PWR IN. If your Pi Zero 2 W has no labels visible (some cases obscure them), check the official pinout diagram for your specific board revision — the port closer to the middle of the board is typically the data-capable one.

A second physical gotcha: use a proper data-capable cable. Cheap “charge-only” USB cables have the power wires but not the data wires (D+/D−) connected. If your Pi is receiving power but never enumerates as a device, try a different cable before touching any software.


Part 3: Editing the boot configuration files

Two files control USB gadget mode at boot. On current Raspberry Pi OS, they live in /boot/firmware/; on some older images, directly in /boot/.

config.txt

This file needs one line telling the kernel to activate the dwc2 USB controller driver in “dual-role” mode (capable of being either a host or a device):

# Enables the dwc2 USB controller in OTG mode, which is required
# for any USB gadget functionality (network, serial, storage, etc.)
# Without this line, the Pi's USB port only ever behaves as a host,
# never as a device — gadget mode simply won't exist.
dtoverlay=dwc2

Add this line anywhere in config.txt (typically people add it near the bottom). Check it’s not already present under a different comment before duplicating it.

cmdline.txt

This file is trickier — it must remain a single line with no line breaks, and every parameter is space-separated. You’re adding one parameter to the existing line, not replacing the whole file.

Your existing line will look something like this (yours may vary slightly):

console=serial0,115200 console=tty1 root=PARTUUID=xxxxxxxx-xx rootfstype=ext4 fsck.repair=yes rootwait

Add modules-load=dwc2 at the end (just dwc2not dwc2,g_ncm, since we’re not relying on a monolithic gadget module):

console=serial0,115200 console=tty1 root=PARTUUID=xxxxxxxx-xx rootfstype=ext4 fsck.repair=yes rootwait modules-load=dwc2

Why not add g_ncm here too, like older guides say? Because on modern kernels there’s no g_ncm.ko module to load this way — we’re going to build the NCM gadget manually in Part 4 using configfs, which gives more control anyway (and lets you support both g_ether-style and g_ncm-style gadgets from the same script if you ever want to).

Save both files and reboot before continuing:

sudo reboot

Part 4: Building the USB gadget with configfs

This is the heart of the setup. Instead of one command loading a pre-built gadget, we assemble the gadget’s identity and behavior piece by piece, then tell the kernel to activate it. We’ll do this with a shell script that runs automatically at every boot.

The gadget script

Create the script file:

sudo nano /usr/local/bin/usb-ncm-gadget.sh

Paste in the following, fully commented line by line:

#!/bin/bash

# Load the "libcomposite" kernel module. This is the framework that lets
# us build a custom USB gadget out of smaller pieces (functions), rather
# than relying on one fixed, pre-built gadget module.
modprobe libcomposite

# Mount "configfs" — a special in-memory filesystem the kernel exposes
# specifically for configuring gadgets. Once mounted, we can create our
# gadget by simply creating directories and writing files into them;
# the kernel watches these paths and reacts to changes.
# The "2>/dev/null" silences an error if it's already mounted (e.g. on
# script re-runs), since a "mount" that's already active isn't a problem.
mount -t configfs none /sys/kernel/config 2>/dev/null

# Create the top-level directory for our gadget. Its name ("ncm_gadget")
# is arbitrary — you could call it anything — but it becomes part of the
# path for every setting below it.
mkdir -p /sys/kernel/config/usb_gadget/ncm_gadget

# Move into that directory so every command below can use short,
# relative paths instead of repeating the full path each time.
cd /sys/kernel/config/usb_gadget/ncm_gadget

# idVendor and idProduct identify the gadget to the host operating system,
# the same way every USB device reports a vendor/product ID pair.
# 0x1d6b is the official Linux Foundation vendor ID, and 0x0104 is a
# generic "multifunction composite gadget" product ID — using these
# well-known values means the iPad/Mac/PC's OS already has generic
# drivers ready to recognize this kind of device.
echo 0x1d6b > idVendor
echo 0x0104 > idProduct

# Create a directory for the gadget's human-readable descriptive strings,
# using the standard USB language code 0x409 (U.S. English).
mkdir -p strings/0x409

# These three lines set the text that will show up if you inspect the
# device's properties on the host (e.g. "About This Mac" or a USB
# device list). Cosmetic only — doesn't affect function.
echo "1234567890" > strings/0x409/serialnumber
echo "Raspberry Pi" > strings/0x409/manufacturer
echo "Pi NCM Gadget" > strings/0x409/product

# Create a USB "configuration" — every USB device advertises one or more
# configurations, each bundling together the functions (network, serial,
# storage, etc.) it wants to expose. We only need one, numbered "c.1".
mkdir -p configs/c.1/strings/0x409
echo "NCM config" > configs/c.1/strings/0x409/configuration

# Declares the maximum power (in 2mA units) this gadget will request
# from the host over USB. 250 = 500mA, a safe standard USB budget.
echo 250 > configs/c.1/MaxPower

# This is where the actual networking happens. We create an "ncm"
# function named "usb0" — "usb0" becomes the network interface name
# you'll see in Linux tools like "ip a" once the gadget activates.
mkdir -p functions/ncm.usb0

# Every virtual Ethernet link needs two MAC addresses — one representing
# the "host" side (the iPad/Mac) and one representing the "device" side
# (the Pi itself). These can be any locally-administered MAC addresses;
# the specific values below are just examples — generate your own random
# ones if you prefer, but make sure the two addresses are different from
# each other.
echo "82:a5:dd:ef:f3:1d" > functions/ncm.usb0/host_addr
echo "4a:ff:72:cd:4f:a3" > functions/ncm.usb0/dev_addr

# Link the "ncm.usb0" function we just built into the configuration
# we created earlier ("c.1"). Until this symlink exists, the function
# is fully defined but not actually part of any active configuration —
# so the gadget would present as a USB device with nothing usable on it.
ln -s functions/ncm.usb0 configs/c.1/

# Give the kernel/udev a brief moment to settle any pending device
# events before we do the final activation step. The "|| true" ensures
# the script keeps going even if this particular command times out.
udevadm settle -t 5 || true

# This is the final activation step. "UDC" stands for USB Device
# Controller — the actual physical hardware block that handles USB
# signaling. Writing its name into this file tells the kernel "start
# presenting this gadget on this specific piece of hardware," which is
# what makes usb0 actually appear and the whole thing come alive.
# "/sys/class/udc/" normally contains exactly one entry on a Pi Zero —
# we grab whatever that name is rather than hardcoding it, since it can
# vary slightly between board revisions.
ls /sys/class/udc > UDC

Save and exit (Ctrl+O, Enter, Ctrl+X in nano), then make it executable:

# Grants this file "execute" permission, so it can be run as a program
# rather than just read as a text file.
sudo chmod +x /usr/local/bin/usb-ncm-gadget.sh

Running the script automatically at boot

We need this script to run every time the Pi boots, before networking fully starts up. A systemd service handles this cleanly:

sudo nano /etc/systemd/system/usb-ncm-gadget.service
[Unit]
# A human-readable description, shown in tools like "systemctl status"
Description=USB NCM Gadget

# Ensures this runs after the kernel's basic network subsystem is ready,
# but before the main network stack finishes configuring — so the
# usb0 interface exists in time for NetworkManager to notice it.
After=network-pre.target
Before=network.target
Wants=network-pre.target

[Service]
# "oneshot" means: run once, then exit — this isn't a long-running
# background process, it just sets things up and finishes.
Type=oneshot
ExecStart=/usr/local/bin/usb-ncm-gadget.sh

# Tells systemd to still consider this service "active" after the
# script exits, rather than immediately marking it as stopped —
# appropriate for a one-time setup task like this.
RemainAfterExit=yes

[Install]
# Ensures this service is included in the early system-initialization
# boot target, so it runs automatically without you calling it by hand.
WantedBy=sysinit.target

Save and exit, then enable it:

# Registers the service to start automatically on every future boot.
sudo systemctl enable usb-ncm-gadget.service

# Reboot now so everything (cmdline.txt, config.txt, and the new
# service) takes effect together.
sudo reboot

After rebooting, confirm the interface exists:

ip link show usb0

You should see a usb0 entry. If it says “Device does not exist,” see the troubleshooting section at the end.


Part 5: Making NetworkManager actually manage and configure usb0

Having the usb0 interface exist isn’t enough — modern Raspberry Pi OS uses NetworkManager for networking, and it needs to be told to (a) actually manage this interface, and (b) hand out an IP address on it.

Step 1: Tell NetworkManager to manage usb0

By default, Raspberry Pi OS keeps USB gadget interfaces unmanaged, since they’re historically used for serial console access rather than networking. Override that:

sudo nano /etc/NetworkManager/conf.d/10-usb0.conf
[device-usb0]
# This tells NetworkManager: "for the device with interface name usb0,
# take full ownership and management" (managed=1), overriding whatever
# default policy would otherwise leave it alone.
match-device=interface-name:usb0
managed=1

Step 2: Create a connection profile that acts as a mini DHCP server

The iPad needs to receive an IP address automatically, the same way it would from a home router. We’ll make the Pi act as that “router” on this one link, using NetworkManager’s built-in shared-connection mode:

sudo nmcli connection add type ethernet ifname usb0 con-name usb0-share ipv4.method shared ipv4.addresses 10.55.0.1/24

What each part does:

  • type ethernet — treat this like a standard wired connection (the gadget presents itself as Ethernet to Linux too).
  • ifname usb0 — attach this profile specifically to the usb0 interface.
  • con-name usb0-share — a friendly name for this connection profile, used in later commands.
  • ipv4.method shared — the key setting. This tells NetworkManager to assign itself the static address below, and automatically run its own internal DHCP server + NAT on this interface, so any device plugged in (the iPad) gets an address automatically without any extra setup.
  • ipv4.addresses 10.55.0.1/24 — the Pi’s own address on this link, with a /24 subnet mask giving room for addresses 10.55.0.2 through 10.55.0.254 to be handed out.

Also disable IPv6 on this profile. Without this, NetworkManager will repeatedly try (and fail) to auto-configure a full IPv6 address over this point-to-point link, time out after about 30 seconds, mark the whole connection as failed, and restart the cycle indefinitely:

# "disabled" stops NetworkManager from attempting IPv6 autoconfiguration
# on this interface at all, which isn't needed for a private USB link.
sudo nmcli connection modify usb0-share ipv6.method disabled

Make sure this connection activates automatically every boot:

# Ensures NetworkManager brings this connection up automatically
# whenever the usb0 interface appears, without manual intervention.
sudo nmcli connection modify usb0-share connection.autoconnect yes

Bring it up now to test immediately (no reboot required):

sudo nmcli connection up usb0-share

Plug your iPad in via USB (into the correct OTG port from Part 2!). Within a few seconds, check on the iPad: Settings → look for a new “Ethernet” or similarly named network entry — it should show an IP address in the 10.55.0.x range, confirming DHCP worked.


Part 6: Setting up remote access (SSH and VNC)

SSH

If you enabled SSH earlier (via raspi-config or by placing an empty ssh file on the boot partition), it should already work over this new link with no extra config:

ssh username@10.55.0.1

VNC — and why the default macOS/iOS approach sometimes fails

This is where a subtle but important distinction matters. Raspberry Pi OS’s default remote-desktop server has changed over time:

  • Older Raspberry Pi OS Desktop images use RealVNC (vncserver-x11-serviced), built for the older X11 display server.
  • Newer Raspberry Pi OS Desktop images have moved to Wayland as the default display compositor, and ship a different VNC server called wayvnc to go with it. wayvnc is Wayland-native, whereas RealVNC’s classic service expects X11 — so on a Wayland-based image, RealVNC’s service can sit there disabled/inactive while wayvnc is what’s actually listening on port 5900.

Check which one is actually active on your system:

# Checks if the classic RealVNC (X11) service is running
sudo systemctl status vncserver-x11-serviced

# Lists exactly which process is listening on VNC's default port (5900),
# regardless of which server it turns out to be
sudo ss -tlnp | grep 5900

Why does Apple’s built-in Screen Sharing app sometimes fail to connect even when the server is clearly running? Apple’s Screen Sharing client (and some other VNC clients) can be picky about the exact VNC protocol version and security-type negotiation that a given server offers. wayvnc, being a newer and less universally-tested server, occasionally doesn’t negotiate cleanly with Apple’s built-in client — even though the TCP connection itself is perfectly fine (which is why ping and raw port checks succeed, but the actual screen-sharing handshake fails).

This is why TigerVNC (or RealVNC Viewer, used purely as a client) tends to work where Apple’s built-in client doesn’t — third-party VNC client apps generally implement a broader, more forgiving range of the VNC protocol specification, including whatever variant wayvnc speaks, whereas Apple’s client is written narrowly around what RealVNC’s own server expects.

Fix, if you hit this: install a dedicated VNC client rather than relying on macOS’s built-in Screen Sharing:

# On a Mac, via Homebrew:
brew install --cask tigervnc-viewer

Then connect that client directly to 10.55.0.1, port 5900. This should succeed even in cases where Apple’s built-in client throws a generic “Unable to communicate” error.


Troubleshooting checklist

SymptomLikely causeWhat to check
usb0 doesn’t appear in ip link at allGadget script failed, or configfs/libcomposite not loadedsystemctl status usb-ncm-gadget.service, journalctl -u usb-ncm-gadget.service -b
iPad shows “Ethernet” but gets no IP addressNetworkManager isn’t running DHCP on usb0Confirm ipv4.method shared is set and the connection is active: nmcli connection show --active
usb0 shown as unmanaged in nmcli device statusNetworkManager config isn’t overriding the default unmanaged policyConfirm /etc/NetworkManager/conf.d/10-usb0.conf exists and is correctly formatted
Connection repeatedly activates then fails every ~30 secondsIPv6 autoconfiguration timing outsudo nmcli connection modify usb0-share ipv6.method disabled
Everything looks fine in software but no carrier/link detectedWrong physical USB port, or bad cableConfirm you’re using the USB (not PWR IN) port, and try a different data-capable cable
ping and SSH work, but VNC fails to connectWrong VNC server assumed, or client/server protocol mismatchCheck which server is actually listening (ss -tlnp | grep 5900); try a dedicated client like TigerVNC instead of Apple’s built-in Screen Sharing

Summary

Getting a Pi Zero 2 W talking to an iPad over a single USB cable comes down to four layers, each of which can fail independently: the physical port and cable, the kernel’s gadget driver (built via configfs on modern systems), NetworkManager’s handling of the resulting interface, and finally whatever remote-access server you’re using on top. Understanding which layer you’re troubleshooting — rather than guessing across all of them at once — is the fastest way to get from “nothing works” to a stable, wired connection you can rely on for real work.