Userspace Kernel
────────────────── ──────────────────────────────────────────
┌─────────────────────────────────┐
write(fd, ipv4_pkt) ──────────► │ copy_from_user() │
│ netif_rx(skb) │
│ │ │
│ ▼ │
│ IP stack / routing │
│ │ │
│ ▼ │
│ camex_net_xmit() │
│ enqueue → rx_queue │
│ wake_up(rx_wait) │
read(fd, buf) ◄────────── │ copy_to_user() │
└─────────────────────────────────┘
poll/select/epoll ◄────────── wait_queue (rx_wait)
/dev/camex ←────────────────────────────────────── net interface "camex"
Two independent kernel objects — a character device (miscdevice) and
a network interface (net_device) — share a single camex_priv
instance. Communication between them flows through the rx_queue
protected by a spinlock.
The upstream /dev/net/tun is a separate module (drivers/net/tun.c)
that must be either loaded or compiled into the kernel. camex registers
its own net_device and miscdevice directly, using only core kernel
subsystems (netdev, misc, skbuff) that are always present and
cannot be unloaded or disabled.
Using misc_register() automatically:
- allocates a minor number dynamically (no conflicts);
- creates
/dev/camexvia udev/devtmpfs without any udev rules; - sets permissions to
0600(root only).
open() additionally requires CAP_NET_ADMIN: the device injects
packets into the network stack and exposes routed traffic, which is a
network-admin operation, just like configuring a tun device.
A daemon that runs as a non-root user with CAP_NET_ADMIN + CAP_NET_RAW
also needs file access to the node, which 0600 does not give it. Grant it
to a dedicated group with a udev rule (the capability check still applies):
# /etc/udev/rules.d/90-camex.rules
KERNEL=="camex", GROUP="camex", MODE="0660"or, without udev, after each insmod: chgrp camex /dev/camex && chmod 0660 /dev/camex.
camex_net_xmit() is called from softirq context (not from process
context), so spin_lock_irqsave is used instead of a mutex to protect
the queue. This prevents deadlocks when called from interrupt context.
Instead of busy-waiting or timers, the standard kernel mechanism is
used: the process sleeps on a wait_queue, and camex_net_xmit()
wakes it up via wake_up_interruptible(). Signals correctly interrupt
the wait (-ERESTARTSYS).
On write(), the first byte of the buffer is checked before
copy_from_user via get_user(). If the IP version is not 4,
-EINVAL is returned without allocating an skb, preventing garbage
or non-IPv4 packets from entering the stack.
g_priv->attached is an atomic_t taken with atomic_cmpxchg(). If
/dev/camex is already open, a subsequent open() immediately returns
-EBUSY, and two concurrent open() calls can never both succeed. On
close() the queue is flushed and the slot released under the same lock
that camex_net_xmit() takes, so no packet is left for the next open().
The interface is declared as having no link-layer address. The kernel
does not attempt to resolve MAC addresses (ARP) and does not prepend
Ethernet headers. Userspace sends and receives bare IPv4 datagrams
starting at the version byte 0x45....
All #if LINUX_VERSION_CODE guards are grouped into a single #define
block at the top of the file. The driver body uses only macro names
(CAMEX_ALLOC_NETDEV, CAMEX_POLL_T, CAMEX_SKB_PUT, etc.) with no
conditional compilation inside functions. This simplifies auditing and
maintenance.
| Range | Status | Adapted API |
|---|---|---|
| 3.10 – 3.16 | ✅ | alloc_netdev(sz, name, setup) — 3 arguments |
| 3.17 – 3.19 | ✅ | alloc_netdev + NET_NAME_PREDICTABLE — 4 arguments |
| 4.0 – 4.9 | ✅ | min_mtu/max_mtu fields absent from net_device |
| 4.10 – 4.15 | ✅ | min_mtu/max_mtu fields introduced |
| 4.16 – 4.19 | ✅ | poll returns __poll_t; EPOLL* constants |
| 4.20+ | ✅ | skb_put() returns void* instead of unsigned char* |
| 5.x | ✅ | no changes required |
| 6.0 – 6.6 LTS | ✅ | no changes required |
Note: Kernels 3.0 – 3.9 are theoretically covered by the same shims but are extremely rare in practice (RHEL 6, early embedded).
# Debian / Ubuntu
sudo apt install linux-headers-$(uname -r) build-essential
# RHEL / CentOS 7
sudo yum install kernel-devel-$(uname -r) gcc make
# Fedora / RHEL 8+
sudo dnf install kernel-devel gcc make
# Alpine Linux
sudo apk add linux-headers build-basemake kmodsudo insmod camex.koVerify:
dmesg | tail -5
# camex: loaded — /dev/camex <-> net:camex (kernel 5.15)
ls -la /dev/camex
# crw------- 1 root root 10, 58 ... /dev/camex
ip link show camex
# camex: <POINTOPOINT,NOARP> mtu 1500 ...# Install the module into the kernel tree
sudo make kmod-install # copies camex.ko and runs depmod -a
# Enable autoload
echo "camex" | sudo tee /etc/modules-load.d/camex.confsudo rmmod camexUnloading is blocked while
/dev/camexis held open by any process (rmmodreturnsEBUSY).
Firmware kernels rarely come with a headers package, and OpenWrt strips
vermagic from its modules and builds without CONFIG_MODVERSIONS: insmod
loads any camex.ko, even one built for another kernel configuration. Such a
module loads and the tunnel comes up, but its offsets into kernel structures
(struct sk_buff, struct net_device) are wrong: every packet is dropped, and
the module can corrupt kernel memory. Build it against the kernel that runs on
the device:
-
Same kernel source and compiler. The same kernel version with the firmware's patches (OpenWrt: the release branch at the commit that set this kernel version,
target/linux/genericandtarget/linux/<target>—files/andpatches-*), built with the same compiler (the OpenWrt SDK toolchain of that release and target). -
Same kernel config. If the device has
/proc/config.gz, use it. If not, start from the SDK or build-system config and turn off what the device lacks: compare its/proc/kallsyms(functions only, unlessCONFIG_KALLSYMS_ALL) and/proc/net. Options that changestruct sock,struct sk_bufforstruct net_devicematter most, for exampleIPV6,XFRM,BRIDGE_NETFILTER,NET_CLS_ACT,NETWORK_SECMARK,NET_RX_BUSY_POLL,NF_CONNTRACK,WIRELESS_EXT. An option turned on byselectgoes off only together with the options that select it. An option with no prompt (WIRELESS_EXT, say) cannot be turned on in.configalone:olddefconfigdrops it again unless something selects it; enable an option that selects it, or give itdefault yin itsKconfig. -
Prepare the tree and build (
<arch>: the kernel's architecture,mipsfor the OpenWrt routers,armfor the HiSilicon cameras;<prefix>: the firmware's toolchain, e.g.mipsel-openwrt-linux-):make -C <kernel-tree> ARCH=<arch> CROSS_COMPILE=<prefix> olddefconfig modules_prepare make kmod KDIR=<kernel-tree> ARCH=<arch> CROSS_COMPILE=<prefix>
If the host tools of an older kernel fail to link with GCC 10 or newer (
dtc: multiple definition ofyylloc), addHOSTCFLAGS=-fcommon. -
Check the layout before loading. Build a module the device already has from the same tree, for example
make -C <kernel-tree> ARCH=<arch> CROSS_COMPILE=<prefix> drivers/net/tun.ko, and compare it with the device's/lib/modules/$(uname -r)/tun.ko: the.textsize (objdump -h) and the offsets in every load and store (objdump -d, e.g.lw v0,452(a0)) must be the same. Instruction order may differ with another build of the same compiler; offsets must not. Without such a module on the device, compare one field in the firmware's kernel image: disassemble a function that reads it (register_netdevicereadsdev->netdev_ops) and compare the offset withoffsetof(struct net_device, netdev_ops)of your tree. -
Symptoms of a mismatch. The handshake and auto-config work, but there is no traffic, and
rx_droppedofcamex(cat /proc/net/dev) grows with every packet from the server: stop the client andrmmod camexat once. Orinsmoditself ends in a kernel oops (NULL pointer dereferenceinregister_netdevice, fromcamex_init): the module wrotenetdev_opswhere the kernel does not read it.Since 3.11.1 the module checks the layout itself before it registers the interface (#134):
alloc_netdev()runs in the kernel and setsnum_tx_queues,real_num_tx_queuesandgso_max_size, which lie after the config-dependent fields; read through the module's offsets they must be 1, 1 and 65536. When they are not,insmodfails withExec format erroranddmesgsayscamex: built for another kernel configuration: struct net_device differs from the running kernel's (...)instead of an oops or a dead interface. It catches a shift before these fields, which is what a config difference usually makes, not every difference: steps 1 to 4 still apply.
Tested example: OpenWrt 15.05.1 ramips/mt7620 (MT7620N, MIPS 24KEc) with a
custom kernel 3.18.92 — linux 3.18.92 with the patches of openwrt/chaos_calmer
at 31cce51cc7 ("kernel: bump to 3.18.92"), the toolchain and .config of the
OpenWrt SDK 15.05.1 ramips/mt7620 with IPV6, XFRM (and INET_AH,
INET_ESP, INET_IPCOMP, INET_XFRM_MODE_*, NET_KEY, XFRM_USER,
XFRM_IPCOMP, XFRM_ALGO, which select it) and BRIDGE_NETFILTER turned off.
With the unchanged SDK config the module loaded but dropped every packet
(#73).
Tested example: HiSilicon hi3516cv500 with the camera's stock kernel 4.9.37
(zenotech firmware). The HiSilicon SDK kernel tree and the OpenIPC kernel of the
same version build without WIRELESS_EXT, the stock kernel has it: its two
pointers in struct net_device (8 bytes on ARM32) put netdev_ops at 312 in
the running kernel and at 304 in the module, and insmod camex.ko oopsed in
register_netdevice; a tun.ko from that tree loaded and passed no traffic.
With default y added to config WIRELESS_EXT in net/wireless/Kconfig, both
work (#133):
sed -i '/^config WIRELESS_EXT$/a\ default y' net/wireless/Kconfig| Operation | Behaviour |
|---|---|
open() |
Needs CAP_NET_ADMIN; one fd at a time, EBUSY otherwise |
read() |
Returns one IPv4 packet. Blocks when queue is empty |
read() + O_NONBLOCK |
Returns EAGAIN when queue is empty |
write() |
Injects one IPv4 packet into the stack. Minimum 20 bytes |
write() non-IPv4 |
Returns EINVAL |
poll() |
POLLIN when a packet is available; POLLOUT always |
close() |
Flushes the queue, clears carrier, releases the slot |
| Code | Cause |
|---|---|
EPERM |
open() without CAP_NET_ADMIN |
EBUSY |
/dev/camex is already open by another process |
ENODEV |
Module is being unloaded |
EMSGSIZE |
read() buffer smaller than packet length (packet dropped) |
EINVAL |
write(): size < 20 or > 65535 bytes, or not IPv4 |
EAGAIN |
read() with O_NONBLOCK, queue is empty |
ENOMEM |
Failed to allocate an skb |
-
IPv4 only. IPv6 is intentionally excluded. A
write()with an IPv6 packet returns-EINVAL. -
Single process at a time. Only one file descriptor may hold
/dev/camexopen. A secondopen()returnsEBUSY. For multi-process schemes, multiplex via a Unix socket in userspace. -
Single device instance. Exactly one
camexinterface and one/dev/camexdevice are created. Supporting multiple instances requires extending the code with ag_priv[]array and name suffixes. -
TUN (Layer 3) only. TAP (Layer 2, Ethernet frames) is not implemented. The interface has no MAC address (
ARPHRD_NONE). ARP does not work. -
No ioctl compatibility with
/dev/net/tun. The commandsTUNSETIFF,TUNGETIFF,TUNSETPERSIST, and other ioctls from the upstream driver are not implemented. Programs that useioctl(fd, TUNSETIFF, ...)will not work with camex without modification. -
MTU is fixed at 1500 bytes. Changing it via
ip link set camex mtu Nis not blocked by the kernel, but camex does not enforce the MTU onwrite().
-
Queue depth is 64 packets. When full, new packets are silently dropped and
tx_droppedis incremented (visible inip -s link). Adjust theQUEUE_LIMITconstant in the source. -
Each queued packet is stored as an
sk_buff. Everysk_buffconsumes ~200 bytes of header plus packet data. At 64 × 1500 bytes the maximum queue memory footprint is approximately 96–110 KB. -
A
read()with a too-small buffer destroys the packet. If the buffer is smaller than the packet length, the packet is lost andEMSGSIZEis returned. Always read with a buffer of at least 1500 bytes (or the interface MTU).
-
Concurrent
read()calls from multiple threads of the same process are not serialised at the fd level — protect access with a mutex in userspace. -
Concurrent
write()calls from multiple threads are safe: each call independently allocates its ownskb.
-
No masquerading/NAT out of the box. To route traffic from
camexto the internet, configureiptables -t nat -A POSTROUTINGmanually. -
netif_rx()vsnetif_receive_skb(). The driver usesnetif_rx()(the older softirq-deferred path). On high-throughput systems,netif_receive_skb()offers lower latency but requires process or softirq context — the substitution is trivial if needed.
| Feature | camex | tun.ko |
|---|---|---|
| External module dependency | No | Requires tun.ko |
| IPv4 | Yes | Yes |
| IPv6 | No (intentional) | Yes |
| TAP (L2) | No | Yes |
| Multiple device instances | No (one) | Yes (dynamic) |
| Multiple open file descriptors | No (one) | Yes |
| ioctl TUNSETIFF / TUNGETIFF | No | Yes |
| Persistent device | No | Yes |
| poll / select / epoll | Yes | Yes |
| O_NONBLOCK | Yes | Yes |
Statistics (ip -s link) |
Yes | Yes |
| Kernel 3.x support | Yes | Yes |
| Code size | ~490 lines | ~3500 lines |
All #if LINUX_VERSION_CODE guards are placed in a dedicated #define
block at the top of camex-k.c. The driver body uses only macro names
(CAMEX_ALLOC_NETDEV, CAMEX_POLL_T, CAMEX_SKB_PUT, etc.) with no
version conditionals inside functions. This keeps the logic readable and
easy to audit.
On insmod:
alloc_netdev+camex_privinitialisationregister_netdev→ interface appears inip linkmisc_register→/dev/camexappears in the filesystem
On rmmod:
misc_deregister→/dev/camexdisappears (newopen()calls blocked)g_priv = NULL→ protects against use-after-free in xmit pathunregister_netdev+free_netdev
The reverse order guarantees that by the time memory is freed, no new accesses can arrive through either path (fd or netdev).
netif_carrier_offon init — the interface showsNO-CARRIERuntil userspace opens/dev/camex.netif_carrier_ononopen()— carrier becomes active.netif_carrier_offonclose()— carrier is cleared; routes through the interface become unreachable.
camex_net_xmit() is called from softirq context where sleeping is
forbidden. Therefore kmalloc for pkt_node uses GFP_ATOMIC. If
memory is unavailable in this context the packet is dropped
(tx_dropped++).