Open Source & Research

Building 5G Modem Drivers for the NVIDIA Jetson Nano Super

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.

Author HOVERAP Engineering Team
Published May 2026
Category Embedded Linux / Drivers

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 kernel
  • A stable QMI data session with autoconnect, ending the four-minute drop cycle
  • A systemd unit and watchdog that re-establish the link without reloading drivers

Disclaimer

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

The system

Kernel and firmware versions matter here more than they usually do, so this is the exact configuration everything below was verified against.

  • Board: NVIDIA Jetson Nano Super
  • Kernel: 5.15.148-tegra, JetPack 5.x, Ubuntu 22.04
  • Modem: SIMCom SIM8262E-M2, USB ID 1e0e:9001
  • Network interface: wwan0
  • Control device: /dev/cdc-wdm0
  • AT command port: /dev/ttyUSB2
  • Carrier: Cosmote Greece, APN internet

Groundwork

Three 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 missing modules

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

Loading the modules

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.

Initialising the radio

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.

raw_ip mode

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

Opening the data session

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.

Addressing and routing

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.

Bringing it up at boot

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

Recovering from drops

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

What mattered in practice

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.

Troubleshooting

Most failures during the build reduced to a handful of symptoms, each with a single cause.

qmicli times out

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

Serial port reports "device disconnected"

ModemManager has been restarted by another unit and has taken the port.

sudo systemctl stop ModemManager
sudo systemctl mask ModemManager
sudo pkill -f ModemManager

wwan0 shows RX packets = 0

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

The connection drops every four minutes

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

No signal, or the modem never registers

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

Reading the full state at once

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

In production

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.

References

Security research

Found something in your stack?

Whether it's a vulnerability assessment or coordinated disclosure, our team can help. Let's talk.