Arduino and Linux are a natural pair. Both are open-source, both have enormous communities, and together they power everything from hobbyist sensor arrays to industrial monitoring systems. But the Linux experience is different from Windows or macOS, and the documentation rarely explains why. You plug in your Uno and nothing shows up in the IDE port list. You select the port and hit upload and get avrdude: ser_open(): can't open device "/dev/ttyACM0": Permission denied. You follow a forum post, add a udev rule, and the upload still fails at the exact moment the board resets.
These problems are not random. Each one has a specific, explainable cause rooted in how Linux handles USB devices, serial permissions, and the bootloader reset cycle. This article works through all of them from first principles, then shows you how to graduate from the GUI and run a complete Arduino workflow from the terminal using arduino-cli — the same engine that Arduino IDE 2.3.8 uses internally.
Why Arduino on Linux Is Different
On Windows, Arduino installers bundle a signed INF driver and the OS handles device registration automatically. On macOS, the required serial drivers are either baked into the OS or supplied as a signed kext. Linux takes a different approach entirely: the kernel includes generic USB-serial drivers that handle most hardware without any user-space installer, and device access is managed through the filesystem permission model and the udev rule system.
This architecture is more transparent and more powerful once you understand it, but it requires you to know a few things that no wizard will tell you. The two questions that trip people up first are: why does the device node have a different name than expected, and why does a regular user account lack permission to write to it.
According to the Arduino CLI official documentation, arduino-cli provides board and library management, sketch compilation, board detection, and uploading — everything required to work with any Arduino-compatible board entirely from the command line.
ttyACM0 vs ttyUSB0: What the Device Name Tells You
When you plug an Arduino into a Linux machine, the kernel looks at the USB device descriptor and decides which driver to bind to it. The name of the resulting device node in /dev tells you exactly which driver won.
/dev/ttyACM0 is created by the cdc_acm kernel module. It appears when the device implements the USB Communications Device Class (CDC) with an Abstract Control Model (ACM) subclass. This is what genuine Arduino boards with an ATmega16U2 or ATmega8U2 USB interface chip do. The Uno R3, the Mega 2560, and the Leonardo all fall into this category when using their factory firmware. The Leonardo is a special case — its main AVR handles USB natively, but the result is still a CDC-ACM device and still appears as /dev/ttyACM0.
/dev/ttyUSB0 is created by generic USB-to-serial bridge drivers. The WCH CH340 and CH341 chips used in the vast majority of inexpensive Arduino clone boards use the ch341 kernel module and show up here. FTDI FT232-based boards use the ftdi_sio module and also land under /dev/ttyUSBx. Clone Nano boards from Chinese manufacturers almost universally use a CH340G, which is why the Nano so often surfaces at /dev/ttyUSB0 rather than /dev/ttyACM0.
Run dmesg | grep tty immediately after plugging in the board. The kernel log will name the driver, the chip, and the assigned device node. For a genuine Uno you will see something like cdc_acm 1-1:1.0: ttyACM0: USB ACM device. For a CH340 clone Nano you will see ch341-uart converter now attached to ttyUSB0.
This distinction matters beyond just knowing which name to type. The cdc_acm driver and the ch341 driver behave differently during the DTR-toggled reset sequence that triggers the bootloader. If the wrong driver is bound or the driver has a bug in the kernel version you are running, uploads will fail regardless of permissions. More on that in the troubleshooting section. If you want to understand how the kernel handles drivers and associated Linux firmware blobs more broadly — including why some hardware needs binary firmware loaded alongside the open-source driver — that is covered in depth in the firmware blobs guide.
One important quirk: on Ubuntu 22.04 and later, the brltty (Braille display accessibility daemon) package claims CH340 and CP210x devices before the ch341 driver can bind to them. This causes the device to disconnect immediately after appearing. The fix depends on your distribution.
On Ubuntu and Debian-based systems, the cleanest fix is to remove the package if you do not use Braille hardware:
# Confirm brltty is the culprit: $ dmesg | grep -E "(ch341|brltty|ttyUSB)" # If you see "usbfs: interface 0 claimed by ch341 while 'brltty' sets config #1", proceed: $ sudo apt purge brltty # Unplug and replug the Arduino. /dev/ttyUSB0 should now persist.
On Arch Linux, the package should not be removed if any other application depends on it. Instead, override the conflicting udev rule without touching the system file (which would be overwritten on package update):
# Copy the system rule file to /etc where your edit will survive package updates: $ sudo cp /usr/lib/udev/rules.d/90-brltty-device.rules /etc/udev/rules.d/90-brltty-device.rules # Open the copy and comment out the CH340 entry (1A86:7523): $ sudo nano /etc/udev/rules.d/90-brltty-device.rules # Find the line: ENV{PRODUCT}=="1a86/7523/*", ENV{BRLTTY_BRAILLE_DRIVER}="bm", GOTO="brltty_usb_run" # Add a # at the start to comment it out, then save. $ sudo udevadm control --reload-rules # Unplug and replug the Arduino.
The Arch Linux wiki documents this behavior clearly for anyone chasing a disappearing /dev/ttyUSB0. The USB vendor ID 1A86 and product ID 7523 identify the WCH CH340 chip used in the vast majority of clone Nano boards. Any rule that matches on those IDs and is processed before the ch341 driver binding will claim the device first.
The Permission Denied Error: What It Actually Means
Serial port device nodes on Linux are owned by root and belong to a group, typically dialout on Debian-derived distributions and uucp on Arch. The default permissions are crw-rw----, meaning root and group members can read and write, while everyone else is locked out. A fresh user account is not in either group. If you want to take a wider look at how Linux group membership and device permissions interact with security posture, the guide to auditing Linux user permissions covers group misconfigurations, SUID/SGID, and sudoers in the same unified framework.
The fastest fix, documented by both the official Arduino support pages and the Arch Linux wiki, is to add your account to the appropriate group:
$ sudo usermod -aG dialout $USER # Log out and back in, or reboot. The group change is not active in your current session. $ groups # verify dialout appears in the output
$ sudo usermod -aG uucp $USER # Some guides also recommend adding yourself to the 'lock' group on Arch. $ sudo usermod -aG lock $USER
Running sudo chmod a+rw /dev/ttyACM0 is a tempting quick fix that many forum posts suggest. It works until the next replug, at which point udev recreates the device node with the original permissions. The group membership approach is permanent. You must log out and log back in (or reboot) for the change to take effect in your current session — opening a new terminal window is not enough.
If you need group membership to take effect in your current shell session without logging out, run newgrp dialout (or newgrp uucp on Arch). This starts a new shell with the updated group token active. The change only applies to that shell and its child processes — other open terminals retain the old group list. For permanent effect across all sessions, a full logout and login is still required.
There is a second, less obvious permission scenario that confuses people who have already solved the group problem: the upload starts successfully, the IDE output shows compilation finishing, avrdude begins communicating — and then the upload fails after the board resets into bootloader mode. This is not a group permission issue. This is a udev rule issue specific to boards that use native USB (like the Leonardo, the Due, the Mbed-based Nano 33 families, and the RP2040-based boards).
root — the kernel created this node owned by root.dialout — group has read+write, but you are not in it yet.--- — no permissions for world. Your user gets nothing.avrdude: ser_open(): can't open device "/dev/ttyACM0": Permission deniedgroups in your terminal shows dialout is absent from your session./dev.dialout, verified by running groups:udev Rules: Why Some Boards Need Them
When a Leonardo-style board resets to enter the bootloader, it disconnects from USB entirely and reconnects as a different device with a different vendor/product ID pair. The /dev/ttyACM0 node disappears and a new one is created, often /dev/ttyACM1. Without a udev rule that grants the new node the correct permissions instantly on creation, avrdude cannot open the port in the brief window before the bootloader times out and the sketch resumes.
There is a related technique worth knowing for situations where the bootloader reset is not triggered automatically. On boards that use native USB (Leonardo, Micro, ATmega32U4-based Pro Micro clones), you can manually force the board into bootloader mode by opening the serial port at 1200 baud and closing it. This is the same signal avrdude sends internally, but doing it explicitly with stty first can help if the automated handshake is failing:
# Open the port at 1200 baud then close it — this pulses the reset into bootloader mode. # The board will disconnect and reconnect as a different USB device (often ttyACM1). $ stty -F /dev/ttyACM0 1200 # Wait ~2 seconds for the board to reconnect in bootloader mode, then upload: $ arduino-cli compile --upload --fqbn arduino:avr:leonardo --port /dev/ttyACM1 ~/sketches/MySketch # If the port number changes unpredictably, use a udev SYMLINK rule to create a stable path.
The Arduino board packages include a shell script called post_install.sh that is supposed to run automatically during board manager installation and write these rules into /etc/udev/rules.d/. When the installation happens in an AppImage sandbox or the script fails silently, the rules are never written.
"missing udev rules can result in failed uploads"
— Arduino Help Center: Fix udev rules on Linux (arduino.cc)
The Help Center article goes on to note that the correct solution is to run the post_install.sh script as root from the board package directory, not to manually chmod the device node. The chmod approach resets on every replug because udev recreates the node with its rule-specified permissions.
To fix this manually, download post_install.sh from the appropriate board package repository on GitHub and run it as root. For Arduino AVR boards (Uno, Mega, Nano) this script typically writes a rule like the following:
# Grant uaccess to all USB-serial and ACM devices when connected ACTION!="remove", SUBSYSTEMS=="usb-serial", TAG+="uaccess" ACTION!="remove", SUBSYSTEMS=="tty", TAG+="uaccess" # Vendor-specific rule for genuine Arduino SA boards (Vendor ID 0x2341) SUBSYSTEMS=="usb", ATTRS{idVendor}=="2341", GROUP="plugdev", MODE="0666"
After writing or editing any file in /etc/udev/rules.d/, reload the rules and trigger them without needing a reboot:
$ sudo udevadm control --reload-rules $ sudo udevadm trigger # Unplug and replug the Arduino after running these commands.
Static Device Names with Persistent Symlinks
If you have multiple Arduinos and need to refer to a specific one reliably — for example in a script that reads sensor data — the dynamic naming (/dev/ttyUSB0, /dev/ttyUSB1) creates an ordering problem that depends on which board was plugged in first. You can solve this by matching on the USB serial number and creating a persistent symlink. Run udevadm info -a -n /dev/ttyUSB0 to find the serial attribute for your board, then write a rule like this:
# Replace the ATTRS{serial} value with the actual serial string from udevadm info SUBSYSTEMS=="usb", KERNEL=="ttyUSB[0-9]*", \ ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", \ ATTRS{serial}=="A700dzaF", \ SYMLINK+="arduino/sensor-board"
After reloading rules and replugging, your board will be accessible at /dev/arduino/sensor-board regardless of what other USB devices are connected or in what order. This technique is also used in identifying which process is holding a port — having a stable device path makes debugging dramatically easier.
lsusb or udevadm info -a -n /dev/ttyACM0 | grep idVendorAfter saving the rule to
/etc/udev/rules.d/ run: sudo udevadm control --reload-rules && sudo udevadm triggerThe ModemManager Problem
There is one more cause of upload failures that is specific to desktop Linux distributions and is widely under-documented: ModemManager. This service probes newly connected serial devices by sending them AT commands to determine whether they are cellular modems. When you plug in an Arduino, ModemManager gets there first, opens the port, sends a burst of AT commands, and holds the device busy for several seconds. If avrdude tries to connect during this window, it gets a device-busy error or, worse, the AT command traffic confuses the bootloader.
As UDOO's documentation on the Arduino Leonardo notes, ModemManager opens any serial port — including the Leonardo's ttyACM0 — to probe for modem responses, which regularly conflicts with the upload mechanism. The source is the UDOO BOLT GEAR Documentation: Getting Started with Arduino Leonardo.
ModemManager ships by default on Ubuntu, Fedora, and many desktop distributions because it is required for mobile broadband support. You have three options:
- Uninstall it if you have no use for mobile broadband:
sudo apt remove modemmanager - Add a udev rule that prevents ModemManager from probing Arduino vendor IDs. The Arduino board package
post_install.shscripts typically do this. - Wait a few seconds after plugging in before uploading. ModemManager gives up after about 10–15 seconds if the device does not respond like a modem.
If you ever see upload failures that happen intermittently and only right after plugging in the board, ModemManager is almost certainly the cause.
Installing Arduino IDE 2.3.8 on Linux
According to the arduino/arduino-ide GitHub repository README, Arduino IDE 2.x is a complete rewrite built on the Theia IDE framework and Electron, with no shared code from the 1.x branch. Compilation and uploading are delegated to an arduino-cli instance running in daemon mode.
The current release as of early 2026 is Arduino IDE 2.3.8, published on February 25, 2026. It bundles Arduino CLI 1.4.1 as its backend and includes updated built-in example sketches at version 1.10.3. The IDE no longer ships as a .deb or .rpm package. The three main installation paths on Linux are an AppImage, a portable ZIP archive, and a Flatpak via Flathub (cc.arduino.IDE2). The Flatpak avoids the FUSE dependency required by the AppImage and integrates cleanly with GNOME Software and KDE Discover.
Arduino IDE 2.3.4 was the final release to support Ubuntu 18.04. Versions 2.3.5 and later require a more recent glibc. If you are on 18.04, you need to either upgrade the OS or use the legacy IDE 1.8.19 or arduino-cli directly.
AppImage Installation
The AppImage is the simplest path. Download it from arduino.cc/en/software, mark it executable, and run it:
$ chmod +x arduino-ide_2.3.8_Linux_64bit.AppImage $ ./arduino-ide_2.3.8_Linux_64bit.AppImage # On Ubuntu 22.04/23.x, if you see "dlopen(): error loading libfuse.so.2", install FUSE: $ sudo apt install libfuse2 # On Ubuntu 24.04+, the package was renamed — use libfuse2t64 instead: $ sudo apt install libfuse2t64
There is a known quirk: the AppImage runs in a sandbox that may prevent the post_install.sh udev scripts from running automatically when you install a board package via the Board Manager. If upload permissions fail on boards that use native USB (Leonardo, Nano 33 BLE, Nano Every, RP2040-based boards), run the post_install.sh script manually as described in the udev section above.
To add the AppImage to your application launcher, create a .desktop file in ~/.local/share/applications/ with an Exec pointing to the full path of the AppImage. Tools like appimaged can automate this for all AppImages in your home directory.
arduino-cli: The Terminal-Native Workflow
Arduino IDE 2 is useful for getting started, but it is a heavyweight application built on the Theia IDE framework and Electron — it carries a significant runtime overhead and does not integrate well into scripted environments, CI pipelines, or text-editor-centric workflows. arduino-cli solves this. It is not a third-party tool: as Arduino's own documentation states, it is "the heart of all official Arduino development software" — including Arduino IDE 2.x and the Arduino Web Editor. The graphical IDE offloads all compilation and upload operations to an arduino-cli instance running in daemon mode — everything the IDE does under the hood, the CLI can do directly from your terminal.
arduino-cli is described in its official documentation as "the heart of all official Arduino development software" — covering board and library management, sketch compilation, board detection, and uploading.
arduino-cli was first introduced in 2018 as a standalone tool for headless Arduino development. When Arduino IDE 2.0 reached stable release in September 2022, the CLI became its compiled backend. This design means the CLI is not merely an alternative to the IDE — it is the authoritative interface to the Arduino toolchain, with the IDE serving as a graphical layer on top of it.
The scale of what arduino-cli indexes is worth understanding before you use it. According to the 2025 Arduino Open Source Report, the Library Manager now hosts 8,754 libraries, with 1,218 added in 2025 alone — a figure that matters practically because arduino-cli lib search queries that index. The same report notes that the library manager system itself turned ten years old in 2025, having been introduced in Arduino IDE 1.6.2. When you run arduino-cli lib install, you are drawing from this curated ecosystem. For production projects, pinning library versions in a sketch.yaml profile rather than relying on lib upgrade blindly is strongly recommended.
Installation
$ curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh # Installs the binary to ./bin by default. To install to ~/bin instead: $ curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | BINDIR=~/bin sh $ export PATH="$HOME/bin:$PATH" $ arduino-cli version # Expected output (April 2026): arduino-cli Version: 1.4.1 Commit: ... OS: linux/amd64 # To pin a specific version: curl -fsSL ... | sh -s -- 1.4.1
Alternatively, it is available as a snap on the Snap Store, and as arduino-cli in the official Arch Linux extra repository (pacman -S arduino-cli) as well as the AUR for users who prefer AUR tooling.
The Snap Store version of arduino-cli runs in a confined sandbox. Out of the box it cannot access USB serial ports. After installing via sudo snap install arduino-cli, you must manually connect the raw-usb plug: sudo snap connect arduino-cli:raw-usb. Without this step, arduino-cli board list will return an empty result even when a board is connected. If you need access only to a specific serial port rather than all USB devices, use sudo snap connect arduino-cli:serial-port instead.
First-Time Setup
# Initialize config and update the board index $ arduino-cli config init $ arduino-cli core update-index # Install the AVR core (covers Uno, Nano, Mega, Leonardo...) $ arduino-cli core install arduino:avr # Plug in your Arduino and discover what's connected $ arduino-cli board list
The board list output will show the port, protocol, board name (if detected), and the Fully Qualified Board Name (FQBN) needed for compile and upload commands. A typical line for an Uno looks like:
Port Protocol Type Board Name FQBN Core /dev/ttyACM0 serial Serial Port (USB) Arduino Uno arduino:avr:uno arduino:avr
Compiling and Uploading a Sketch
# Compile only $ arduino-cli compile --fqbn arduino:avr:uno ~/sketches/Blink # Compile and upload in a single step $ arduino-cli compile --upload --fqbn arduino:avr:uno --port /dev/ttyACM0 ~/sketches/Blink # Monitor the serial output after uploading $ arduino-cli monitor --port /dev/ttyACM0 --config baudrate=9600
A common point of confusion: arduino-cli upload on its own uploads a previously compiled binary. It does not recompile. For a combined operation, use arduino-cli compile --upload. This matches the --upload flag on the compile subcommand, not a separate upload command with automatic compilation.
Managing Libraries
# Search for a library $ arduino-cli lib search DHT # Install a specific library $ arduino-cli lib install "DHT sensor library" # List installed libraries $ arduino-cli lib list # Update all libraries $ arduino-cli lib upgrade
Running arduino-cli lib upgrade blindly in a production workflow will eventually break a build when a library changes its API. Use a sketch.yaml profile to lock the exact version of every dependency. Initialize one with arduino-cli profile init inside your sketch directory, then compile against it: arduino-cli compile --profile <name>. The profile records the FQBN, core version, and all library versions, giving you a fully reproducible build environment that travels with your sketch. The Library Manager now hosts over 8,754 libraries; pinning versions is increasingly important as the ecosystem grows.
VS Code and PlatformIO: The Other Terminal-Friendly Path
For developers who want a richer editing experience without giving up terminal-level control, VS Code with the PlatformIO extension is worth knowing about. PlatformIO handles board management, library dependencies, and uploads independently of the official Arduino toolchain. It supports a much wider range of boards than the official Arduino core system, including ESP32, STM32, and many other families.
PlatformIO stores configuration in a platformio.ini file at the project root, which makes projects easily portable and CI-friendly. Builds can be triggered from the VS Code sidebar, the PlatformIO CLI, or a Makefile. For projects that share sensor libraries with a Python data processing layer, the PlatformIO approach integrates particularly cleanly with the kind of Linux-side tooling described in articles like Python with Linux.
That said, PlatformIO introduces its own complexity around core version management and is less suitable for beginners or for projects that need to stay compatible with the standard Arduino IDE. For pure Arduino-ecosystem work, arduino-cli remains the cleaner choice.
Troubleshooting: Systematic Diagnosis
Most Arduino-on-Linux problems fall into one of five categories. Here is a structured diagnostic sequence that resolves the large majority of cases.
Step 1: Confirm the device is recognized at all
$ dmesg | tail -20 # run this right after plugging in $ lsusb # look for the Arduino or CH340 entry $ ls -l /dev/ttyACM* /dev/ttyUSB*
If dmesg shows no USB activity at all, the issue is hardware: a bad cable (use one that carries data, not just power), a faulty USB port, or a damaged board. If dmesg shows the device connecting and disconnecting immediately, suspect the brltty conflict described earlier.
Step 2: Verify group membership is active in the current session
$ groups # dialout (or uucp on Arch) must appear here $ ls -l /dev/ttyACM0 # Expected: crw-rw---- 1 root dialout 166, 0 ...
Step 3: Check for a process holding the port open
The serial monitor in one IDE instance will block uploads from another. So will screen, minicom, or any other terminal program connected to the same port. As the Arduino Help Center notes, you can find the blocking process using the approach described in How to Find Which Process Is Using a Port on Linux:
$ lsof /dev/ttyACM0 # If the output includes a java process, another Arduino IDE instance has the port. # Kill it by PID or close the Serial Monitor in that IDE window first.
Step 4: Check whether a serial-getty service is bound to the port
$ systemctl status serial-getty@ttyACM0.service # If it is active, disable it: $ sudo systemctl stop serial-getty@ttyACM0.service $ sudo systemctl disable serial-getty@ttyACM0.service
This situation is rare on desktop systems but common on single-board computers like the Raspberry Pi, where a console login is sometimes enabled on the hardware UART. If you are using the Raspberry Pi as a Linux host for Arduino development and communication, make sure the console is not routed through the same serial interface you are trying to use. This intersects with general systemd service management concepts that are worth understanding if you do any server-side work alongside your embedded projects.
Step 5: Test avrdude directly
Bypassing the IDE and testing avrdude directly isolates whether the issue is in the IDE layer or the actual upload chain:
$ avrdude -p m328p -c arduino -P /dev/ttyACM0 -b 115200 -v # A working board responds with its device signature and fuse byte values. # "avrdude: Device signature = 0x1e950f" confirms a good connection to an ATmega328P. # The -v flag prints the device signature, fuse bytes, and flash/EEPROM sizes from the part definition. # To do a full verify pass (read flash back and compare), add: -U flash:v:/path/to/sketch.hex:i
Scripting Arduino Workflows on Linux
Once you have arduino-cli working from the command line, you can script any part of the workflow. A common pattern for multi-board projects is a Makefile or shell script that compiles a shared sketch and flashes it to several boards identified by their persistent udev symlinks:
#!/usr/bin/env bash set -euo pipefail FQBN="arduino:avr:nano:cpu=atmega328old" SKETCH="$HOME/projects/sensor-firmware" BUILD_DIR="$HOME/projects/sensor-firmware/build" BOARDS=("/dev/arduino/sensor-a" "/dev/arduino/sensor-b" "/dev/arduino/sensor-c") # Compile once into BUILD_DIR; upload uses the pre-compiled binary via --input-dir arduino-cli compile --fqbn "$FQBN" --build-path "$BUILD_DIR" "$SKETCH" for port in "${BOARDS[@]}"; do if [[ -e "$port" ]]; then arduino-cli upload --fqbn "$FQBN" --port "$port" --input-dir "$BUILD_DIR" echo "Flashed $port successfully" else echo "Warning: $port not found, skipping" >&2 fi done
This pattern scales well when combined with cron jobs or shell scripts for task automation, for example triggering a flash cycle at a scheduled time for devices in a headless lab environment. It also integrates cleanly into CI pipelines — the compile step in particular (without upload) runs fine on any standard Linux CI runner with no USB hardware at all.
CI/CD Integration with GitHub Actions
The official arduino/setup-arduino-cli GitHub Action installs a pinned version of arduino-cli into the runner environment. From there the full compile workflow runs without modification. This is useful for catching build errors across multiple boards before merging, or for generating per-board firmware binaries as release artifacts.
name: Build Sketches on: push: branches: [main] pull_request: jobs: build: runs-on: ubuntu-latest strategy: matrix: board: - fqbn: arduino:avr:uno core: arduino:avr - fqbn: arduino:avr:nano:cpu=atmega328old core: arduino:avr - fqbn: arduino:renesas_uno:unor4minima core: arduino:renesas_uno steps: - uses: actions/checkout@v4 - name: Install arduino-cli uses: arduino/setup-arduino-cli@v2 with: version: "1.4.1" - name: Update indexes run: | arduino-cli core update-index arduino-cli lib update-index - name: Install core run: arduino-cli core install ${{ matrix.board.core }} - name: Compile sketch run: | arduino-cli compile --fqbn ${{ matrix.board.fqbn }} --warnings all ./firmware/sensor-node
Standard GitHub-hosted runners have no USB access, so the --upload flag is not useful there. The value of CI is catching compilation errors and dependency problems before code reaches a physical device. Hardware-in-the-loop upload testing requires a self-hosted runner connected to the target board, or a service like tmate-based remote access to a lab machine.
Communicating with Arduino from Python on Linux
A very common use case is writing a Linux-side Python script that reads sensor data from an Arduino over serial. The pyserial library handles this cleanly and respects the same device nodes and permissions discussed throughout this article.
import serial import time # Replace /dev/ttyACM0 with your device node or a persistent symlink with serial.Serial('/dev/ttyACM0', 9600, timeout=1) as ser: time.sleep(2) # wait for Arduino auto-reset to complete while True: line = ser.readline().decode('utf-8').strip() if line: print(line)
The time.sleep(2) after opening the port is not optional on boards that auto-reset when DTR is asserted (Uno, Nano, Mega). Opening a serial connection pulses DTR, which triggers the bootloader. The sketch takes about two seconds to start after the bootloader times out. If you send data before the sketch is running, it disappears. This behavior is fully described in the Python with Linux guide and is a common source of confusion in data-logging projects.
What is actually happening when the Arduino resets on serial open
The reset is not triggered by pyserial or by the Arduino IDE directly. It is triggered by the Linux kernel. When any application opens a serial device with a non-zero baud rate, the kernel's tty_port_block_til_ready() function unconditionally calls tty_port_raise_dtr_rts() regardless of what dsrdtr=False says in pyserial. This is documented in the Linux kernel tty layer. The consequence is that setting dsrdtr=False in pyserial does not prevent the initial DTR pulse — it only controls whether pyserial sets DTR again after the port is open. The pulse happens at the kernel level before userspace can intervene.
There are two reliable software-only approaches to prevent the auto-reset without hardware modification:
import os import serial import termios # Method 1: Clear HUPCL before opening so the kernel does not lower DTR on open/close. # Use os.open() with O_NOCTTY and O_NONBLOCK to avoid blocking on the tty open itself. fd = os.open('/dev/ttyACM0', os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) attrs = termios.tcgetattr(fd) attrs[2] = attrs[2] & ~termios.HUPCL # clear HUPCL in c_cflag termios.tcsetattr(fd, termios.TCSAFLUSH, attrs) os.close(fd) # Now open via pyserial — HUPCL is cleared so DTR will not be lowered on open or close. ser = serial.Serial('/dev/ttyACM0', baudrate=9600, timeout=2) # Method 2 (pyserial only — partially effective): # Setting dtr=False before open() tells pyserial not to raise DTR post-open, # but does NOT prevent the kernel's unconditional tty_port_raise_dtr_rts() pulse. # Use Method 1 for a reliable software-only solution. # ser2 = serial.Serial() # ser2.port = '/dev/ttyACM0' # ser2.baudrate = 9600 # ser2.dtr = False # ser2.open()
The HUPCL termios flag controls whether the kernel lowers modem control lines (including DTR) when the serial port is closed, not just when it is opened. If HUPCL is set (the default), closing the port resets the Arduino. This means that in a loop that repeatedly opens and closes the port — for example, a polling script that checks for data and then closes the port to save resources — the board will reset on every cycle. Setting speed to B0 also triggers a hangup and will lower DTR. Both behaviors are documented in termios(3) under the HUPCL and CLOCAL flags.
Why Python serial reads from Arduino sometimes show garbled or doubled output
A non-obvious source of garbled output from CDC-ACM devices on Linux is the tty driver's default ICRNL setting. The c_iflag field in termios has ICRNL enabled by default, which translates incoming carriage-return bytes (\r, 0x0D) to newline bytes (\n, 0x0A). If the Arduino firmware sends \r\n line endings (as is common when using Serial.println()), the Linux tty layer converts the \r to \n, so your Python readline() sees two newline characters and produces an extra blank line on every read.
There is also an interaction with the echo flags. As documented in a detailed kernel-level analysis by Michael Stapelberg, the cdc_acm driver is built on the Linux tty infrastructure whose default settings include echo flags. When the driver receives data from the device before userspace has had a chance to configure termios, the standard tty settings are still in effect — meaning the first bytes received may be echoed back to the device before your application sets raw mode. This is particularly visible when connecting to devices that print a banner immediately on USB enumeration.
The correct fix is to configure the port in raw mode before reading. pyserial does this automatically when you construct a Serial object with explicit settings, but a plain open('/dev/ttyACM0') in Python followed by a read will not, and you will encounter the ICRNL transformation. The complete set of termios flags needed for raw mode on an Arduino serial connection is:
$ stty -F /dev/ttyACM0 9600 cs8 -parenb -cstopb cread clocal -crtscts \ -ignbrk -brkint -icrnl -inlcr -igncr \ -opost -onlcr -isig -icanon -iexten \ -echo -echoe -echok -echoctl -echoke # Key flags: -icrnl disables CR-to-NL translation; -echo disables tty echoing; # -icanon enables non-canonical (raw) mode; -opost disables output post-processing. # Shorthand equivalent: stty -F /dev/ttyACM0 raw 9600 # (raw sets -ignbrk -brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr -icrnl # -ixon -ixoff -opost -isig -icanon -xcase min 1 time 0) # The explicit form above is preferred for documentation clarity.
The ATmega16U2 on the Uno R3 is itself a programmable MCU
This is one of the least-known capabilities of a board that every Arduino user owns. The USB interface chip on the Arduino Uno R3 and Mega 2560 R3 is an ATmega16U2 running open-source firmware based on the LUFA (Lightweight USB Framework for AVRs) library. That firmware is what makes the board enumerate as a CDC-ACM serial device on Linux. It can be replaced.
On Linux, you can reflash the ATmega16U2 with dfu-programmer without any external hardware programmer. The procedure to enter DFU mode on a Uno R3 is to briefly short the two innermost pins of the six-pin ICSP header located next to the USB port. After shorting, the board disconnects and reconnects as lsusb entry 03eb:2fef Atmel Corp. (the Atmel DFU bootloader vendor/product ID) instead of the usual 2341:0043 Arduino SA Uno.
$ sudo apt install dfu-programmer # Debian/Ubuntu # After shorting the ICSP reset pins to enter DFU mode: $ lsusb | grep Atmel # confirms 03eb:2fef is present $ sudo dfu-programmer atmega16u2 erase # --suppress-bootloader-mem is required for the COMBINED hex (which includes bootloader section): $ sudo dfu-programmer atmega16u2 flash --suppress-bootloader-mem Arduino-COMBINED-dfu-usbserial-atmega16u2-Uno-Rev3.hex $ sudo dfu-programmer atmega16u2 reset # The hex file is included with the Arduino AVR platform at: # ~/.arduino15/packages/arduino/hardware/avr/<version>/firmwares/atmegaxxu2/
By loading different LUFA-based firmware into the ATmega16U2, the Uno R3 can enumerate as a USB MIDI controller, a USB HID joystick or keyboard, or an AVRISP MkII programmer — all without modifying the main ATmega328P sketch at all. This works because the 16U2 is entirely independent of the 328P and only communicates with it over a hardware UART. As Arduino's own documentation states, the ATmega16U2's flexibility allows the board to appear as a different USB device entirely. Uploading an Arduino sketch still works afterward, because the standard usb-serial firmware can always be restored via the same DFU process.
Common Questions
Why does my Arduino show up as /dev/ttyACM0 on Linux instead of /dev/ttyUSB0?
Genuine Arduino boards with an ATmega16U2 USB interface chip implement the USB Communications Device Class (CDC/ACM), so Linux assigns them to /dev/ttyACM0 via the cdc_acm kernel module. Clone boards using a WCH CH340 or FTDI chip use a generic USB-to-serial driver and appear as /dev/ttyUSB0. The device name tells you which USB interface chip the board uses — see the ttyACM0 vs ttyUSB0 section above for the full driver path.
How do I fix Arduino permission denied errors on Linux?
The fastest fix is adding your user to the dialout group: sudo usermod -aG dialout $USER. You must log out and back in for the group change to take effect. On Arch Linux, the equivalent group is uucp. If the permission error only occurs during the bootloader reset phase of uploading, your board package may be missing udev rules — run the post_install.sh script included with the board package as root to install them.
Can I use Arduino on Linux without the IDE?
Yes. arduino-cli is the official command-line tool that handles board detection, compilation, library management, and uploading entirely from the terminal. It is also the backend engine that Arduino IDE 2.x uses internally. You can pair it with any editor and build a fully scriptable, CI-friendly workflow without ever opening the graphical IDE. See the arduino-cli section above.
Why does my CH340-based Arduino clone disappear immediately after plugging in on Ubuntu or Arch Linux?
This is caused by brltty (a Braille display daemon) claiming the CH340 device before the ch341 kernel driver can bind to it. On Ubuntu, the fix is sudo apt purge brltty. On Arch Linux, copy /usr/lib/udev/rules.d/90-brltty-device.rules to /etc/udev/rules.d/ and comment out the line matching idVendor 1a86/idProduct 7523, then run sudo udevadm control --reload-rules. Full steps are in the device nodes section.
Can I use the Arduino IDE on a server with no graphical environment?
No, but you do not need to. arduino-cli is fully headless and was explicitly created for this use case. Install it on the server, add the AVR core, and use your editor of choice over SSH. The serial port will work over USB passthrough or via a USB-to-IP server. The Ubuntu server management guide covers the general headless administration patterns that apply here.
Why does the Arduino IDE show no ports even though the device appears in /dev?
The IDE filters the port list by group membership in the current session. If you added yourself to the dialout group after starting the IDE, the IDE process does not have the updated group token. Restart the IDE (or log out and back in, then relaunch) and the port will appear.
Does the Raspberry Pi use the same setup?
Mostly. The Raspberry Pi runs a Debian-derived Linux and uses the same dialout group, the same udev subsystem, and the same arduino-cli workflow. The difference is that the Pi has an onboard UART exposed as /dev/ttyAMA0 (hardware UART) or /dev/ttyS0 (software UART), and on Pi 3 and later, Bluetooth occupies the hardware UART by default. If you connect an Arduino to the Pi via a USB cable rather than directly to the GPIO UART pins, you will get a /dev/ttyACM0 or /dev/ttyUSB0 node just as you would on any other Linux machine, and the same permissions rules apply.
What is the FQBN for my board and where do I find it?
Run arduino-cli board listall to see every board whose core is already installed, along with its FQBN. Important: this command only shows boards for cores you have installed — if the list is empty, run arduino-cli core install arduino:avr first. Common values: arduino:avr:uno for the Uno R3, arduino:avr:nano for the Nano with the standard bootloader, arduino:avr:nano:cpu=atmega328old for the Nano clone with the old bootloader (the variant most inexpensive clone Nanos use, where uploads with the standard bootloader setting stall at the end), and arduino:avr:mega for the Mega 2560. If you do not know which core a board belongs to, run arduino-cli board search <board name> to search the full index regardless of what is installed.
Why does arduino-cli board list show my port but no board name or FQBN?
This is normal when no installed core matches the board's USB vendor and product IDs. arduino-cli board list always shows detected ports regardless of core installation — it just cannot identify the board by name until it can match the USB IDs against a known platform. Install the likely core (arduino-cli core install arduino:avr for AVR boards, arduino:renesas_uno for the Uno R4 family) and replug the board. The FQBN column will populate. If it still shows unknown after installing the correct core, the board's USB IDs may belong to a clone using non-standard IDs — in that case specify the FQBN explicitly on the command line rather than relying on auto-detection.
My Arduino Uno R4 does not appear as a serial port on Linux at all. What is wrong?
The Uno R4 Minima and Uno R4 WiFi use a Renesas RA4M1 microcontroller with native USB. On some Linux systems, particularly those running kernels older than 6.1, the board enters DFU (Device Firmware Upgrade) mode rather than CDC-ACM mode and no /dev/ttyACM* node appears. Confirm what the kernel sees with dmesg | tail -20 after plugging in. If it reports a DFU device instead of a CDC-ACM device, install the arduino:renesas_uno core first (which writes the required udev rules), then press the reset button on the board twice quickly to force it into the DFU bootloader — the onboard LED will pulse to confirm. Run arduino-cli core update-index && arduino-cli core install arduino:renesas_uno and then retry. On kernels 6.1 and later, installing the arduino:renesas_uno core (which writes the required udev rules) and replugging resolves the issue on most systems without the double-reset workaround.
Sources
The technical claims in this article are drawn from the following primary sources, all of which are publicly verifiable:
- Arduino CLI official documentation — docs.arduino.cc. Primary source for all arduino-cli commands, architecture description, and installation methods.
- Fix udev rules on Linux — Arduino Help Center (support.arduino.cc). Source for the udev post_install.sh requirement and the statement that missing udev rules cause failed uploads.
- Fix port access on Linux — Arduino Help Center (support.arduino.cc). Source for the dialout group fix and the newgrp workaround.
- arduino-cli v1.4.1 release — GitHub (arduino/arduino-cli). Release date: January 19, 2026. Current stable release as of April 2026.
- Arduino IDE 2.3.8 release — GitHub (arduino/arduino-ide). Release date: February 25, 2026. Confirms arduino-cli 1.4.1 bundled as backend.
- Arduino IDE 2.x source and README — GitHub (arduino/arduino-ide). Source for the Theia/Electron architecture description and the daemon-mode backend detail.
- Arduino CLI installation documentation — docs.arduino.cc. Source for the official install.sh script and BINDIR environment variable usage.
- 2025 Arduino Open Source Report — Arduino Blog. Source for Library Manager figures (8,754 libraries, 1,218 added in 2025) and the Library Manager 10th anniversary detail.
- Supported versions of Arduino IDE — Arduino Help Center. Source for IDE 2 stable release date (September 2022) and the IDE 1.8.19 end-of-life status.
- Flash the USB-to-serial firmware for UNO and Mega boards — Arduino Help Center. Source for the ATmega16U2 DFU reflash procedure, hex file location, and dfu-programmer commands.
- Updating the ATmega8U2 and 16U2 via DFU — Arduino Documentation. Source for the statement that the 16U2's flexibility allows it to appear as a different USB device type.
- Linux and USB virtual serial devices (CDC ACM) — Michael Stapelberg, 2021. Source for the ICRNL echo-flag interaction with the cdc_acm driver and the DISABLE_ECHO kernel quirk table analysis.
- Linux always toggles DTR and RTS — Quentin Santos, 2025. Source for the tty_port_block_til_ready() / tty_port_raise_dtr_rts() unconditional DTR behavior, and the HUPCL suppression technique in termios.
Putting It Together
Arduino on Linux requires understanding three separate layers: the kernel driver that determines the device node name, the filesystem permission system that controls who can write to that node, and the udev rule mechanism that governs device naming and access at insertion time. Once those three layers are clear, nearly every setup problem has an obvious cause and a deterministic fix.
The practical sequence is: add yourself to dialout, log out and back in, confirm the device node appears after plugging in, and run a compile-and-upload from arduino-cli to verify the toolchain. From there, the entire workflow — editor, build, flash, serial monitoring, library management — can live in the terminal. The graphical IDE becomes optional rather than mandatory, and your Arduino projects become first-class citizens of the same Linux environment you use for everything else.
If you want to go further with the Linux side of embedded development, the kernel-level I/O events article covers how the kernel handles device I/O at the syscall and DMA level — the same infrastructure that drives serial port communication under the hood.