NVIDIA's Tegra kernel omits both modules a SIM8262E 5G modem depends on. How we compiled cdc-wdm and qmi_wwan from mainline source and kept the link stable.
NVIDIA's Tegra kernel omits both modules a SIM8262E depends on. This is how we compiled them from mainline source, and what it took to keep the link stable afterwards.
Much of the hardware we deploy runs where there is no Wi-Fi and no wired uplink, which leaves cellular as the only practical backhaul. For one recent build that meant pairing a SIMCom SIM8262E-M2 5G modem with an NVIDIA Jetson Nano Super.
The vendor's installer targets Linux 4.9; the Jetson runs 5.15.148-tegra. More significantly,
NVIDIA's Tegra kernel configuration omits both modules the modem depends on: cdc-wdm, which
provides the control channel, and qmi_wwan, which provides the data path. Neither exists as a
loadable module on the system, so there is nothing for modprobe to find.
Downgrading the kernel to accommodate a peripheral was not an option, so we compiled both drivers from mainline Linux source against the Tegra headers and worked up the stack until the interface carried traffic. What follows is that process, together with the undocumented behaviour we had to account for on the way.
The result:
cdc-wdm and qmi_wwan compiled from mainline 5.15 source against the Tegra kernelThis is how we solved one problem on one specific combination of hardware, kernel, modem firmware and carrier. It is not official documentation or a supported procedure. Building out-of-tree kernel modules and rewriting network configuration can leave a device unreachable, and results will differ on other systems. Anyone who follows it does so at their own risk and is responsible for the outcome. It is provided as is, with no warranty and no liability on our part.
Kernel and firmware versions matter here more than they usually do, so this is the exact configuration everything below was verified against.
5.15.148-tegra, JetPack 5.x, Ubuntu 22.041e0e:9001wwan0/dev/cdc-wdm0/dev/ttyUSB2internetThree things need to be in place before any driver work begins.
DNS comes first. A fresh JetPack image frequently ships with a broken /etc/resolv.conf, which
presents as a total loss of connectivity and is easy to misattribute to the modem later. Point it
back at systemd's stub resolver and confirm name resolution works.
sudo rm -f /etc/resolv.conf
sudo ln -s /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
sudo systemctl restart systemd-resolved
ping -c 2 google.com # verify it works
Then the toolchain and the QMI userspace utilities.
sudo apt-get update
sudo apt-get install -y p7zip-full python3 python3-pip \
libqmi-utils usbutils build-essential
sudo pip3 install pyserial --break-system-packages
Finally, ModemManager. When the modem enumerates, ModemManager claims its serial ports and opens its
own session with the hardware. Every AT command sent afterwards returns device disconnected, which
reads as a hardware fault rather than contention for the port. Stopping the service is not enough,
since other units will restart it. Mask it.
sudo systemctl stop ModemManager
sudo systemctl disable ModemManager
sudo systemctl mask ModemManager
sudo systemctl status ModemManager # should report: masked
The problem is visible in a single directory listing: qmi_wwan.ko is absent from
/lib/modules/$(uname -r)/kernel/drivers/net/usb/, and cdc-wdm.ko is absent from the
corresponding drivers/usb/class path. NVIDIA ships a deliberately lean kernel for Tegra, and
neither module is part of it.
Both drivers are small, self-contained, and have been stable across several releases. Because the
Tegra kernel is itself a 5.15 kernel, the v5.15 tag in the mainline tree is the correct source to
build against and no backporting is required. Start with headers matching the running kernel.
sudo apt-get install -y nvidia-l4t-kernel-headers
ls /lib/modules/$(uname -r)/build/ # sanity check
Then fetch the two source files and add a minimal out-of-tree Makefile. The build needs little more than the location of the kernel build directory.
mkdir -p ~/qmi_build && cd ~/qmi_build
wget https://raw.githubusercontent.com/torvalds/linux/v5.15/drivers/usb/class/cdc-wdm.c
wget https://raw.githubusercontent.com/torvalds/linux/v5.15/drivers/net/usb/qmi_wwan.c
cat > ~/qmi_build/Makefile << 'EOF'
obj-m := cdc-wdm.o qmi_wwan.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
rm -rf *.o *.ko *.mod.c .tmp_versions Module.* modules.order
EOF
The build completes in well under a minute and, with matching headers, produces both objects from unmodified source.
cd ~/qmi_build
make 2>&1 | tail -10
ls *.ko # cdc-wdm.ko qmi_wwan.ko
Install them where modprobe will find them and rebuild the dependency map:
sudo cp ~/qmi_build/cdc-wdm.ko \
/lib/modules/$(uname -r)/kernel/drivers/usb/class/
sudo cp ~/qmi_build/qmi_wwan.ko \
/lib/modules/$(uname -r)/kernel/drivers/net/usb/
sudo depmod -a
Load order matters: USB serial support first, then the Qualcomm RmNet data path, then the control interface and the network device layered on top of it.
sudo modprobe option # USB serial ports (ttyUSB0-4)
sudo modprobe rmnet # Qualcomm RmNet data path
sudo modprobe cdc-wdm # CDC WDM control interface
sudo modprobe qmi_wwan # QMI/WWAN network interface
sleep 40 # QMI needs the time; see below
ls /dev/ttyUSB* # ttyUSB0 through ttyUSB4
ls /dev/cdc-wdm0
ip link show wwan0
sudo qmicli -d /dev/cdc-wdm0 --device-open-qmi --dms-get-ids
The forty-second delay is not arbitrary. USB enumeration completes almost immediately, but the QMI
service behind /dev/cdc-wdm0 takes considerably longer to become responsive, and qmicli does not
retry; it times out. The resulting failure is indistinguishable from a broken driver, and cost us
several unnecessary rebuilds before we identified it as a timing problem.
A working control channel does not mean the modem has attached to a network. That still happens over
AT commands on /dev/ttyUSB2: close any stale session, set the radio access mode, open the data
bearer, and read back the address the carrier assigns.
sudo python3 << 'EOF'
import serial, time, re
p = serial.Serial('/dev/ttyUSB2', 115200, timeout=5)
time.sleep(2)
p.reset_input_buffer()
def cmd(p, c, w):
p.reset_input_buffer()
p.write((c+'\r\n').encode())
time.sleep(w)
r = p.read(p.in_waiting or 512).decode(errors='ignore')
print(f'{c}: {r.strip()}')
return r
cmd(p, 'AT+NETCLOSE', 6) # close any existing session
cmd(p, 'AT+CNMP=2', 4) # automatic network mode, not 5G-only
cmd(p, 'AT+NETOPEN', 15) # open the data bearer
r = cmd(p, 'AT+IPADDR', 6) # read back the assigned address
m = re.search(r'\+IPADDR: ([\d\.]+)', r)
print('IP:', m.group(1) if m else 'NOT FOUND')
p.close()
EOF
A successful run returns four responses and an address in the carrier's private range:
AT+NETCLOSE: +NETCLOSE: 0
AT+CNMP=2: OK
AT+NETOPEN: +NETOPEN: 0
AT+IPADDR: +IPADDR: 10.x.x.x
IP: 10.x.x.x
AT+CNMP=2 is the significant line. The module ships in mode 38, which is 5G only. Where coverage is
intermittent, or where the carrier expects an LTE anchor first, the modem never registers and
reports no error explaining why: signal quality reads normally and attach simply does not occur.
Mode 2 selects automatic access-technology selection, which is the correct setting for most
deployments.
This accounted for more lost time than anything else in the build.
A QMI modem does not present an Ethernet interface. There are no MAC addresses on a cellular bearer
and nothing to resolve by ARP, so the kernel has to be told to pass bare IP packets rather than
frames. Left in its default mode, wwan0 looks entirely healthy. The link comes up, routes install,
transmitted packets are counted, and the receive counter stays at zero indefinitely.
The distinguishing symptom is in ip link: an interface reporting BROADCAST,MULTICAST is
misconfigured, whereas POINTTOPOINT,NOARP is correct. The setting is a single sysfs write, and it
is only accepted while the interface is down.
sudo ip link set wwan0 down
sleep 2
echo Y | sudo tee /sys/class/net/wwan0/qmi/raw_ip
cat /sys/class/net/wwan0/qmi/raw_ip # must read Y
The session itself is handled by qmicli. Two flags in the block below carry most of the weight.
sudo qmicli -d /dev/cdc-wdm0 --device-open-qmi \
--wds-start-network="apn=internet,ip-type=4" \
--client-no-release-cid
sudo qmicli -d /dev/cdc-wdm0 --device-open-qmi \
--wds-set-autoconnect-settings=enabled
sudo qmicli -d /dev/cdc-wdm0 --device-open-qmi \
--wds-get-current-settings | grep -E "address|gateway|DNS"
The first is --device-open-qmi. The obvious alternative, --device-open-auto, lets libqmi
negotiate which protocol the device speaks, and against this modem it consistently times out. Naming
the protocol explicitly removes the negotiation entirely.
The second is --wds-set-autoconnect-settings=enabled, which determines whether the link stays up at
all. Without it the session terminates roughly every four minutes, with nothing logged on either the
modem or the host. We examined antenna placement, USB power management and carrier provisioning
before establishing that the modem was releasing the session because nothing had instructed it to
hold one. With autoconnect enabled it maintains and re-establishes the bearer itself.
With both flags in place, hardware that had been dropping every few minutes has since run for weeks without intervention.
The last manual stage is addressing. The address is configured as a /32, because this is a
point-to-point bearer and there is no subnet. The gateway is not the address returned by AT+IPADDR;
it is a separate address, and --wds-get-current-settings is the only reliable source for it.
sudo ip link set wwan0 up
sleep 2
# <IP> comes from AT+IPADDR, <GW> from --wds-get-current-settings
sudo ip addr add <IP>/32 dev wwan0
sudo ip route add <GW> dev wwan0
sudo ip route add default via <GW> dev wwan0 metric 50
ping -c 3 8.8.8.8
curl --interface wwan0 http://httpbin.org/ip
The curl call is the meaningful test. A response containing a carrier-assigned address confirms
traffic is leaving over wwan0 rather than an existing route.
The sequence above is not something to repeat by hand. Collapsed into a single script, it loads the modules, waits out QMI initialisation, performs the AT exchange, opens the session and configures the interface, reading both the address and the gateway back from the hardware, since both change on every reconnect.
cat > ~/start_modem.sh << 'SCRIPT'
#!/bin/bash
set -e
echo '[1/6] Stopping ModemManager...'
sudo systemctl stop ModemManager 2>/dev/null || true
echo '[2/6] Loading drivers...'
sudo modprobe option
sudo modprobe rmnet
sudo modprobe cdc-wdm || sudo insmod ~/qmi_build/cdc-wdm.ko
sudo modprobe qmi_wwan || sudo insmod ~/qmi_build/qmi_wwan.ko
echo '[3/6] Waiting 40s for QMI to be ready...'
sleep 40
echo '[4/6] Initializing modem...'
sudo python3 -c "
import serial, time, re
p = serial.Serial('/dev/ttyUSB2', 115200, timeout=5)
time.sleep(2);
p.reset_input_buffer()
def cmd(p,c,w):
p.reset_input_buffer()
p.write((c+'\r\n').encode())
time.sleep(w)
return p.read(p.in_waiting or 512).decode(errors='ignore')
cmd(p,'AT+NETCLOSE',6)
cmd(p,'AT+CNMP=2',4)
cmd(p,'AT+NETOPEN',15)
r=cmd(p,'AT+IPADDR',6)
m=re.search(r'\+IPADDR: ([\.\d]+)',r)
print(m.group(1) if m else '')
p.close()
"
echo '[5/6] Starting QMI session...'
sudo ip link set wwan0 down 2>/dev/null || true
sleep 2
echo Y | sudo tee /sys/class/net/wwan0/qmi/raw_ip
sudo qmicli -d /dev/cdc-wdm0 --device-open-qmi \
--wds-start-network='apn=internet,ip-type=4' \
--client-no-release-cid
sudo qmicli -d /dev/cdc-wdm0 --device-open-qmi \
--wds-set-autoconnect-settings=enabled
echo '[6/6] Configuring network...'
IP=$(sudo python3 -c "
import serial,time,re
p=serial.Serial('/dev/ttyUSB2',115200,timeout=5)
time.sleep(2)
p.write(b'AT+IPADDR\r\n')
time.sleep(4)
r=p.read(512).decode(errors='ignore')
m=re.search(r'\+IPADDR: ([\.\d]+)',r)
print(m.group(1) if m else '')
p.close()
")
GW=$(sudo qmicli -d /dev/cdc-wdm0 --device-open-qmi \
--wds-get-current-settings 2>/dev/null | \
grep 'IPv4 gateway' | awk '{print $NF}')
sudo ip link set wwan0 up
sleep 2
sudo ip addr flush dev wwan0
sudo ip addr add $IP/32 dev wwan0
sudo ip route del default 2>/dev/null || true
sudo ip route add $GW dev wwan0 2>/dev/null || true
sudo ip route add default via $GW dev wwan0 metric 50
echo "Done! IP=$IP GW=$GW"
ping -c 3 8.8.8.8
SCRIPT
chmod +x ~/start_modem.sh
A oneshot unit with RemainAfterExit=yes is the right model: the script runs once at boot and the
unit stays active rather than being restarted.
sudo tee /etc/systemd/system/modem-connect.service << 'EOF'
[Unit]
Description=SIM8262E 5G Modem Connection
After=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/home/hoverap/start_modem.sh
StandardOutput=journal
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable modem-connect.service
Cellular links do not fail cleanly. The interface stays up, routes remain installed, and traffic simply stops, so anything monitoring link state reports normal operation throughout. The only reliable test is whether packets reach a known host. That is what the watchdog below checks every ten seconds, rebuilding the session after three consecutive failures.
It does not reload the drivers. An earlier version did, and the effect was to lengthen outages rather
than shorten them: removing and reinserting qmi_wwan triggers a USB reset that takes the modem
offline for close to a minute and occasionally leaves it in a state requiring a power cycle. Closing
the session, opening a new one and reconfiguring the interface is sufficient, and considerably
faster.
cat > ~/modem_watchdog.sh << 'EOF'
#!/bin/bash
reconnect() {
echo "$(date): Reconnecting..."
systemctl stop ModemManager 2>/dev/null
# Close the existing session
python3 -c "
import serial, time
p = serial.Serial('/dev/ttyUSB2', 115200, timeout=5)
time.sleep(2)
p.reset_input_buffer()
p.write(b'AT+NETCLOSE\r\n')
time.sleep(5)
p.read(p.in_waiting or 512)
p.close()
" 2>/dev/null
sleep 3
# Reopen and read back the new address
IP=$(python3 -c "
import serial, time, re
p = serial.Serial('/dev/ttyUSB2', 115200, timeout=5)
time.sleep(2)
p.reset_input_buffer()
def cmd(p,c,w):
p.reset_input_buffer()
p.write((c+'\r\n').encode())
time.sleep(w)
return p.read(p.in_waiting or 512).decode(errors='ignore')
cmd(p,'AT+CNMP=2',4)
cmd(p,'AT+NETOPEN',15)
r=cmd(p,'AT+IPADDR',6)
m=re.search(r'\+IPADDR: ([\.\d]+)',r)
print(m.group(1) if m else '')
p.close()
" 2>/dev/null)
[ -z "$IP" ] && echo "$(date): No IP" && return
# raw_ip must be reasserted while the link is down
ip link set wwan0 down 2>/dev/null
sleep 2
echo Y > /sys/class/net/wwan0/qmi/raw_ip 2>/dev/null
qmicli -d /dev/cdc-wdm0 --device-open-qmi \
--wds-start-network="apn=internet,ip-type=4" \
--client-no-release-cid 2>/dev/null
qmicli -d /dev/cdc-wdm0 --device-open-qmi \
--wds-set-autoconnect-settings=enabled 2>/dev/null
sleep 5
GW=$(qmicli -d /dev/cdc-wdm0 --device-open-qmi \
--wds-get-current-settings 2>/dev/null | \
grep 'IPv4 gateway' | awk '{print $NF}')
ip link set wwan0 up
sleep 2
ip addr flush dev wwan0
ip addr add $IP/32 dev wwan0
ip route del default 2>/dev/null
[ -n "$GW" ] && ip route add $GW dev wwan0 2>/dev/null || true
[ -n "$GW" ] && ip route add default via $GW dev wwan0 metric 50 \
|| ip route add default dev wwan0 metric 50
echo "$(date): Reconnected! IP=$IP GW=$GW"
ping -c 2 8.8.8.8 && echo "$(date): OK!" || echo "$(date): Failed"
}
systemctl stop ModemManager 2>/dev/null
systemctl mask ModemManager 2>/dev/null
echo "$(date): Watchdog started"
FAILS=0
while true; do
if ping -c 2 -W 5 8.8.8.8 -I wwan0 &>/dev/null; then
FAILS=0
else
FAILS=$((FAILS+1))
echo "$(date): Ping failed ($FAILS/3)"
[ $FAILS -ge 3 ] && reconnect && FAILS=0
fi
sleep 10
done
EOF
chmod +x ~/modem_watchdog.sh
sudo tee /etc/systemd/system/modem-watchdog.service << 'EOF'
[Unit]
Description=Modem Watchdog
After=modem-connect.service
[Service]
ExecStart=/home/hoverap/modem_watchdog.sh
Restart=always
User=root
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now modem-watchdog.service
Compiling the modules was the straightforward part. Nearly all of the difficulty came from a small set of undocumented behaviours, each of which presents as a different problem than it is.
ModemManager must be masked rather than stopped. It claims the serial ports and turns every AT
command into a device disconnected error.
raw_ip must be set before bringing wwan0 up. Without it, RX packets remain at zero while
everything else appears healthy.
The QMI startup delay is real: QMI is not ready when the device node appears, and calling
qmicli early produces a timeout that reads as a broken driver.
Autoconnect: --wds-set-autoconnect-settings=enabled is what prevents the session dropping every
four minutes.
The gateway address is not the assigned IP. Read it from --wds-get-current-settings rather than
routing bare default dev wwan0.
Driver reloads never belong in the watchdog. Reinserting the modules resets the modem over USB and extends the outage.
AT+CNMP=2 is required because the module defaults to mode 38, 5G only, and will silently fail
to register on 4G until this is changed.
--device-open-qmi should always be specified. --device-open-auto times out on this hardware.
Serial ports: /dev/ttyUSB2 is the reliable AT port; 0 and 1 answer some commands but not all.
USB autosuspend should be disabled with echo on > /sys/bus/usb/devices/2-2.1/power/control, or
the modem will suspend mid-session.
Most failures during the build reduced to a handful of symptoms, each with a single cause.
The control channel was not ready yet. This is a timing issue rather than a fault.
sleep 40
sudo qmicli -d /dev/cdc-wdm0 --device-open-qmi --dms-get-ids
ModemManager has been restarted by another unit and has taken the port.
sudo systemctl stop ModemManager
sudo systemctl mask ModemManager
sudo pkill -f ModemManager
raw_ip is not set, so the kernel is framing packets the modem discards.
sudo ip link set wwan0 down
sleep 2
echo Y | sudo tee /sys/class/net/wwan0/qmi/raw_ip
sudo ip link set wwan0 up
Autoconnect is disabled. This is the most common reason an otherwise correct configuration fails to stay up.
sudo qmicli -d /dev/cdc-wdm0 --device-open-qmi \
--wds-set-autoconnect-settings=enabled
Check the radio access mode before the antenna; mode 38 is the usual cause.
# AT+CNMP? -> 38 = 5G only (bad), 2 = automatic (good)
# AT+CREG? -> +CREG: 0,1 = registered on home network
# AT+CSQ -> +CSQ: 99,99 = no signal at all; check the antenna
Where the cause is not obvious, this returns firmware revision, registration status, signal quality, radio mode and current address in a single pass.
sudo python3 << 'EOF'
import serial, time
p = serial.Serial('/dev/ttyUSB2', 115200, timeout=5)
time.sleep(2)
p.reset_input_buffer()
for cmd, w in [('ATI',2),('AT+CREG?',2),('AT+CSQ',2),
('AT+CNMP?',2),('AT+IPADDR',3)]:
p.reset_input_buffer()
p.write((cmd+'\r\n').encode())
time.sleep(w)
print(cmd,':', p.read(p.in_waiting or 512).decode(errors='ignore').strip())
p.close()
EOF
The modules themselves were the least difficult part of this work. What took time was the set of
behaviours sitting between a device that enumerates correctly and one that carries traffic reliably:
the QMI initialisation delay, raw_ip, the default radio access mode, and the autoconnect setting.
The resulting configuration comes up at boot, recovers without intervention.
Whether it's a vulnerability assessment or coordinated disclosure, our team can help. Let's talk.