Saturday, September 19, 2026

VSCodium on Linux for AVR and safe development

Until recently I have used MPLAB X IDE from Microchip to program AVR microcontrollers on Linux, or CachyOS to be more specific. While it works, most of the time the app got stuck at loading modules and after 2 or 3 restarts it would eventually launch. Searching for an alternative I found VSCodium, which is the open source alternative to VSCode by Microsoft. Microchip also is transitioning MPLAB to VS Code as an extension but I find it to be too intrusive interfering with my other extensions and UI. Since I didn't need the extra features in MPLAB a simple Makefile, avr-gcc, avr-libc and avrdude is enough for me to develop C/C++ microcontroller projects. This setup provides a fast workflow and has the benefit that you can also use VS Codium to program in other languages such as Python and regardless of the language you can also create a safe environment using Podman that can protect your main system from potentially dangerous extensions you might install in VSCode.


Contents


Developing for AVR microcontrollers on VSCodium

This setup shows how to configure a workflow in VSCodium using standard tools like make, avr-gcc, avr-size without depending on vendor apps. Although some commands are specific to CachyOS or Arch such as pacman -S avr-gcc, these can be adapted to your Linux distribution.

VSCodium is a community-driven free and fully open-source distribution of Microsoft's Visual Studio Code. 

Official VS Code binaries include proprietary Microsoft customization, telemetry collection, and tracking. VSCodium compiles directly from the open-source vscode repository with all telemetry completely disabled by default.

VSCodium uses Open VSX Registry (or can be configured for local extensions), providing a completely free, vendor-neutral ecosystem for extension management. 

Folder structure

Create this structure inside your project directory:

my-avr-project/
├── .vscode/
│   └── tasks.json
├── src/
 |    └── main.c
├── .clangd
├── Makefile
├── print_size_avr.py

Pres Ctrl + H to display hidden files (that start with a dot).

Create a reusable Makefile

The Makefile is the most important part. It is used to compile your code, generates .hex files, prints memory utilization, and handles flashing via avrdude. The variables at the top need to be configured according to your setup for example MCU defines microcontroller type, ATmega328PB in this case, F_CPU which is the CPU frequency (16MHz). 

PROGRAMMER and PORT are used to write the code to MCU using avrdude. Usually the port is /dev/ttyUSB0 but I have an udev rule that creates a symbolic link to /dev/ttyUSB_UART since a device assigned to /dev/ttyUSB0 can become /dev/ttyUSB1 if you plug in/out other devices. Using a udev rule, the FTDI programmer will always be mapped to /dev/ttyUSB_UART based on device ID.

EXTERNALDIRS is also important. Here you can include external folders containing C/C++ files.

To exclude a file like src/UART_v2.c from compilation without deleting or moving it, use Makefile's filter-out:

# Find all files, then exclude specific ones:
C_SRC = $(filter-out src/UART_v2.c src/experimental.c, $(wildcard src/*.c))

Alternatively, change the file extension of the inactive file to .c.disabled or .bak. The wildcard command will automatically skip it.

# --- Microcontroller Settings ---
BUILD ?= release
MCU ?= atmega328pb
F_CPU ?= 16000000UL
PROGRAMMER ?= arduino
PORT ?= /dev/ttyUSB_UART

# --- Toolchain Settings ---
CC = avr-gcc
CXX = avr-g++
OBJCOPY = avr-objcopy
SIZE = avr-size
AVRDUDE = avrdude

# --- Custom External Directories ---
EXTERNALDIRS = /home/vscode/libraries/UART_AVR \
               /home/vscode/libraries/Utils_AVR

# --- Target and Paths ---
TARGET = main

# Local source files inside src/
SRC_C = $(wildcard src/*.c)
SRC_CXX = $(wildcard src/*.cpp)

# External source files
EXT_C = $(foreach dir, $(EXTERNALDIRS), $(wildcard $(dir)/*.c))
EXT_CXX = $(foreach dir, $(EXTERNALDIRS), $(wildcard $(dir)/*.cpp))

# Combine all sources
ALL_C = $(SRC_C) $(EXT_C)
ALL_CXX = $(SRC_CXX) $(EXT_CXX)

# Generate object list in build/
OBJ = $(addprefix build/, $(notdir $(ALL_C:.c=.o))) \
      $(addprefix build/, $(notdir $(ALL_CXX:.cpp=.o)))

# --- Compiler Flags ---
INC_FLAGS = -Isrc $(addprefix -I, $(EXTERNALDIRS))

ifeq ($(BUILD), debug)
    OPT_FLAGS = -Og -g3 -DDEBUG
else
    OPT_FLAGS = -Os -DNDEBUG
endif

COMMON_FLAGS = -mmcu=$(MCU) -DF_CPU=$(F_CPU) $(OPT_FLAGS) -Wall -Wextra $(INC_FLAGS)
CFLAGS = $(COMMON_FLAGS) -std=gnu11 -ffunction-sections -fdata-sections
CXXFLAGS = $(COMMON_FLAGS) -std=c++17 -fno-exceptions -fno-rtti -ffunction-sections -fdata-sections
LDFLAGS = -Wl,--gc-sections -Wl,-Map=build/$(TARGET).map

# --- Rules ---
all: build/$(TARGET).hex print_size

# Helper function to generate dynamic compilation rules for files in different folders
define COMPILE_C
build/$(notdir $(1:.c=.o)): $(1) | build
	$$(CC) $$(CFLAGS) -c $$< -o $$@
endef

define COMPILE_CXX
build/$(notdir $(1:.cpp=.o)): $(1) | build
	$$(CXX) $$(CXXFLAGS) -c $$< -o $$@
endef

# Generate individual compilation targets for all source files
$(foreach src,$(ALL_C),$(eval $(call COMPILE_C,$(src))))
$(foreach src,$(ALL_CXX),$(eval $(call COMPILE_CXX,$(src))))

# Link using C++ compiler
build/$(TARGET).elf: $(OBJ) | build
	$(CXX) $(CFLAGS) $(LDFLAGS) $^ -o $@

build/$(TARGET).hex: build/$(TARGET).elf
	$(OBJCOPY) -O ihex -R .eeprom $< $@

# Remove the b from atmega328pb when running avr-size, 
# fixing the percentage calculation without changing 
# compiler or flasher MCU settings.
# SIZE_MCU = $(patsubst %pb,%p,$(MCU))

# print_size: build/$(TARGET).elf
# 	@echo "=== Memory Usage ==="
# 	@$(SIZE) -C --mcu=$(SIZE_MCU) $< | sed 's/Device: $(SIZE_MCU)/Device: $(MCU)/'

# Custom Python script to print size since avr-size 
# doesn't support all devices such as ATmega328PB and ATtiny402.
print_size: build/$(TARGET).elf
	@python3 print_size_avr.py $< $(MCU)

flash: build/$(TARGET).hex
	$(AVRDUDE) -c $(PROGRAMMER) -p $(MCU) -P $(PORT) -U flash:w:$<:i

# Generate compile_commands.json for clangd
compiledb:
	bear -- make clean all
	
clean:
	rm -rf build

build:
	mkdir -p build

.PHONY: all clean flash print_size

Makefile vs. CMake for AVR embedded programming

Another alternative to Makefile is CMake. Both are good, but they serve different skill levels. GNU Makefile is easier to use and requires bear -- make to generate compile_commands.json. Makefiles are best for bare-metal microcontrollers (AVR/8-bit) while CMake is more useful for complex 32-bit systems (ARM, ESP32, STM32) with many sub-libraries.

VSCode Tasks

The file .vscode/tasks.json is used to create tasks for building, cleaning and flashing the code.

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "Build (Release)",
      "type": "shell",
      "command": "make BUILD=release compiledb",
      "group": {
        "kind": "build",
        "isDefault": true
      },
      "problemMatcher": []
    },
    {
      "label": "Build (Debug)",
      "type": "shell",
      "command": "make BUILD=debug compiledb",
      "group": "build",
      "problemMatcher": []
    },
    {
      "label": "Flash to Microcontroller",
      "type": "shell",
      "command": "make",
      "args": ["flash"],
      "problemMatcher": []
    },
    {
      "label": "Clean Build Directory",
      "type": "shell",
      "command": "make",
      "args": ["clean"],
      "problemMatcher": []
    }
  ]
}

Commands such as build, clean, compiledb, flash, are defined in the Makefile. To build the code you can use the shortcut Ctrl+Shift+B which will run the default task which is "Build (Release)" in this case. The Makefile compiles in RELEASE mode because of the -Os flag (Optimize for Size).

Configure IntelliSense

A C/C++ language server is needed to avoid red squiggly line under missing headers and unresolved types like uint8_t or to enable features such as "Jump to Definition" by holding Ctrl and left-click on any variable, function, or #include path. 

A very popular extension for this purpose is clangd (llvm-vs-code-extensions.vscode-clangd) which is a Language Server Protocol (LSP) server based on LLVM. Unlike Microsoft’s C/C++ extension (ms-vscode.cpptools), clangd is fully open-source and natively supported in VS Codium without relying on Microsoft-proprietary binaries.

clangd needs a compile_commands.json file so it knows the exact target architecture (avr), sysroot include paths, and macros that avr-gcc uses during compilation. GNU Make doesn't create compile commands natively, but you can generate one automatically using bear. clangd does not parse Makefiles directly because Makefiles contain complex shell logic. bear simply intercepts avr-gcc commands during execution and converts them into compile_commands.json which is a standard format that any editor engine can read.

Install bear on CachyOS/Arch:

sudo pacman -S bear

bear will be called by the Makefile using the command:

compiledb: bear -- make clean all 

which will be called after the build command. This creates compile_commands.json in your project root, which clangd immediately reads to fix missing headers and unresolved types like uint8_t.

Because clangd defaults to host architecture (x86_64), it will struggle with standard AVR headers without target context. Create a file named .clangd in your project root directory with the following content:

CompileFlags:
  Add:
    - "-Wno-unknown-warning-option"
  Remove:
    - "-mabi=*"
    - "-fconserve-space"

Hover:
  ShowAKA: Yes

# Enable Doxygen parsing explicitly for hover tooltips
Diagnostics:
  ClangTidy:
    Add: []

After building the project if the errors still show up, open the Command Palette (Ctrl+Shift+P) and run clangd: Restart language server.

Building & viewing memory usage

In the Makefile I have commented out avr-size command and included a custom Python script to print size since avr-size is ancient and unmaintained and doesn't support all devices such as ATmega328PB and ATtiny402. To show memory usage place this Python script inside the root folder:

print_size_avr.py

#!/usr/bin/env python3
import sys
import subprocess
import re

if len(sys.argv) < 3:
    print("Usage: size_check.py <elf_file> <mcu>")
    sys.exit(1)

elf, mcu = sys.argv[1], sys.argv[2]

def get_mcu_limits_from_headers(mcu_name):
    """
    Evaluates FLASHEND, RAMSTART, and RAMEND directly from 
    AVR-LibC header files using the avr-gcc preprocessor.
    """
    try:
        # Ask avr-gcc to dump all preprocessor macros defined for the given MCU
        cmd = ["avr-gcc", f"-mmcu={mcu_name}", "-E", "-dM", "-"]
        proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        stdout, _ = proc.communicate(input=b"#include <avr/io.h>\n")
        macros_text = stdout.decode()

        macros = {}
        for line in macros_text.splitlines():
            parts = line.split()
            if len(parts) >= 3 and parts[0] == "#define":
                macros[parts[1]] = parts[2]

        # Extract FLASH limit
        flash_bytes = None
        if "FLASHEND" in macros:
            # Strip trailing C type suffixes like 'U' or 'UL' (e.g. '0x7FFFU' -> '0x7FFF')
            raw_flashend = macros["FLASHEND"].rstrip('uUlL')
            flashend = int(raw_flashend, 0)
            flash_bytes = flashend + 1
            # print(f"flashend: {hex(flashend)}")
            # print(f"flash_bytes: {flash_bytes}")

        # Extract RAM limit
        ram_bytes = None
        if "RAMSIZE" in macros:
            raw_ramsize = macros["RAMSIZE"].rstrip('uUlL')
            ram_bytes = int(raw_ramsize, 0)
        elif "RAMEND" in macros and "RAMSTART" in macros:
            raw_ramend = macros["RAMEND"].rstrip('uUlL')
            raw_ramstart = macros["RAMSTART"].rstrip('uUlL')
            ramend = int(raw_ramend, 0)
            ramstart = int(raw_ramstart, 0)
            ram_bytes = (ramend - ramstart) + 1
            # print(f"ram_bytes: {ram_bytes}")

        if flash_bytes and ram_bytes:
            return flash_bytes, ram_bytes

    except Exception as e:
        pass

    sys.exit(f"Error: Unable to extract memory limits from avr/io.h for MCU '{mcu_name}'.")

# 1. Query exact hardware boundaries directly from toolchain C headers
flash_max, ram_max = get_mcu_limits_from_headers(mcu)

# 2. Extract section sizes from ELF using avr-objdump
objdump_out = subprocess.check_output(["avr-objdump", "-h", elf]).decode()
sections = {}
for line in objdump_out.splitlines():
    parts = line.split()
    if len(parts) >= 3 and parts[0].isdigit():
        sections[parts[1]] = int(parts[2], 16)

flash_used = sections.get(".text", 0) + sections.get(".data", 0) + sections.get(".bootloader", 0)
ram_used   = sections.get(".data", 0) + sections.get(".bss", 0) + sections.get(".noinit", 0)

# 3. Print accurate usage output
print(f"=== Memory Usage ({mcu}) ===")
print(f"Program: {flash_used:6d} bytes ({flash_used / flash_max * 100:.1f}% Full of {flash_max} bytes)")
print(f"Data:    {ram_used:6d} bytes ({ram_used / ram_max * 100:.1f}% Full of {ram_max} bytes)")

Press Ctrl+Shift+B or run the Build (Debug or Release) task by pressing Ctrl+Shift+P and type "Tasks: Run Task" that will display a list with available tasks. To make the workflow easier you can install an extension such as Tasks (actboy168.tasks) that will place the tasks on the taskbar in the form of buttons.

The output terminal should print something like:

=== Memory Usage (atmega328pb) ===
Program: 6288 bytes (19.2% Full of 32768 bytes)
Data: 712 bytes (34.8% Full of 2048 bytes)


Showing external files in the VS Codium file tree

VS Codium's File Explorer only shows files physically located inside your workspace directory. However, you can add your external library directories to the workspace tree using Multi-Root Workspaces.

This adds external folders directly into your Codium File Explorer sidebar without moving any actual files. In VS Codium, open the top menu and select "File -> Add Folder to Workspace" and select your external library folder. Repeat for any other external directories.

Save your workspace configuration: "File -> Save Workspace As". Your Explorer sidebar will now group your local project and all external library folders under a single file tree view. 

VSCodium Shortcuts

  • Move Line Up: Alt + Up Arrow
  • Move Line Down: Alt + Down Arrow
  • Duplicate/copy selected lines up or down: Ctrl + Shift + Alt + Up Arrow or Ctrl + Shift + Alt + Down Arrow

Isolated development using Dev Containers

Originally built by Microsoft, Dev Containers is the open standard for isolated development and is supported by VS Codium via open-source extensions. I use the Dev Containers OSS (s-h-a-d-o-w.dev-containers-oss) extension which is based on https://github.com/DDorch/codium-devcontainer

Why would you use an isolated container for developing? Different projects has different requirements and installing all of them can clutter your system and often leads to the classic "it builds on my machine, but fails on yours" problem. Dev Containers (Development Containers) solve this by moving your entire build environment inside a lightweight, isolated Docker/Podman container. This has the advantage of reproducible builds. Every contributor or CI/CD pipeline uses the exact same dependencies. New developers can clone the repository, click "Reopen in Container," and have a fully configured build and debug environment running in minutes. Windows, macOS, and Linux users get identical build behavior without tweaking native OS scripts or path configurations.

Another benefit is security. In case an extension that you use is compromised, it can only access the folder paths that you map in the configuration file keeping your browser cookies and personal files secure.

The extension that I use to implement Dev Containers is using Docker but I prefer to use Podman because of daemonless architecture, no background service, runs containers directly as standard child processes of your user. Runs entirely in user space via slirp4netns/pasta. If a container breaks, it only has your user's privileges. --userns=keep-id automatically maps your host UID (e.g., 1000) inside the container. Built files are naturally owned by your user account.

Whereas Docker requires dockerd background daemon, which runs continuously with root privileges. Rootless mode exists, but setup is complex, and default installs run as host root. Frequently creates files owned by root on your host workspace, requiring chown fixes. Manages its own networking bridges and firewall rules, often overwriting host iptables.

If you plan on using Podman install these translator from Podman to Docker. Podman includes an official system package. It creates a docker symlink that translates all docker CLI commands directly to podman. Install podman-docker on CachyOS/Arch:

sudo pacman -S podman-docker

Enable rootless Podman socket access for your user (if not already enabled):

systemctl --user enable --now podman.socket

Folder structure for Dev Containers

Create this structure inside your project directory:

my-avr-project/
├── .devcontainer/
    └── devcontainer.json
    └── Dockerfile

Pres Ctrl + H to display hidden files.

Dev Containers needs a devcontainer.json configuration file placed inside a .devcontainer folder inside your project root:

{
  "name": "Arch Shared Environment",
  "image": "localhost/vspodium-dev-env:latest",
  "remoteUser": "vscode",
  "updateRemoteUserUID": false,
  "customizations": {
    "vscode": {
      "extensions": [
        "llvm-vs-code-extensions.vscode-clangd",
        "cschlosser.doxdocgen",
        "actboy168.tasks"
      ]
    }
  },
  "runArgs": [
    "--userns=keep-id",
    "--security-opt=label=disable",
    "--dns=8.8.8.8",
    "--dns=1.1.1.1", 
    "-v", "/dev/bus/usb:/dev/bus/usb:rw",
    "-v", "/mnt/D_TOSHIBA_S300/Project_Libraries/Utils_AVR:/home/vscode/libraries/Utils_AVR:rw,z",
    "-v", "/mnt/D_TOSHIBA_S300/Project_Libraries/UART_AVR:/home/vscode/libraries/UART_AVR:rw,z"
  ]
}

What is --security-opt=label=disable?

On Linux systems with SELinux enabled (or when using SELinux container policies), Podman automatically assigns a strict security label (e.g., container_t) to every container process. This prevents the container from touching host files or devices, even if file permission bits allow it. Passing --security-opt=label=disable turns off SELinux MAC (Mandatory Access Control) confinement for that specific container. When mounting host directories (like your projects) or raw devices (/dev/ttyUSB0), SELinux blocks access unless you relabel the host paths or pass label=disable. The tradeoff is it tells SELinux to step back and rely strictly on standard Linux file permissions (DAC) and user namespaces for security.

--userns=keep-id

Prevents rootless file permission issues on mounted volumes. 

Mount USB buses (recommended for hot-plugging):

Passing /dev/bus/usb allows tools like avrdude to access USB programmers via libusb without giving access to your hard drives or keyboards.

Mapping external folders

The devcontainer.json include two folders as an example formatted as source:destination where destination is the path inside the isolated container not your host. You can remove, comment out or replace them.

DNS

On my system I had networking issue and manually specifying DNS servers solved the issue. Those are Google DNS and I believe others will work, or you can try commenting them out and see if it works without them on your system.

Extensions

There are few extensions included as an example of how to add per project extensions such as clangd. 

"updateRemoteUserUID": false

If you want all your projects to share a single image without generating separate project-specific -uid image tags or intermediate clutter. Tells VS Codium to boot localhost/vspodium-dev-env:latest as-is, completely skipping the dynamic vsc-<project>-uid image generation step since Dockerfile already sets up the vscode user explicitly with sudo access, so UID remapping is unnecessary.

image

Points to localhost/vspodium-dev-env:latest which was manually generated using a Dockerfile and this command:

podman build -t vspodium-dev-env -f .devcontainer/Dockerfile ./ 

Terminal path must be set to project root folder. 

The Dev Containers extension injects a setup script into base images to prepare helper utilities (git, sudo, curl), but it defaults to assuming a Debian/Ubuntu-based image (apt-get).

When you specify archlinux:latest, the extension still tries to run apt-get update, which fails on Arch-based distributions like Arch or CachyOS.

To bypass the extension's hardcoded apt-get helper setup, define a custom Dockerfile in .devcontainer where you install essential packages using pacman first.

Save the following content as .devcontainer/Dockerfile:

# podman build -t vspodium-dev-env -f .devcontainer/Dockerfile ./

FROM archlinux:latest

# Pre-install essential tools using pacman so Dev Containers doesn't break
RUN pacman -Syu --noconfirm && pacman -S --noconfirm \
    base-devel \
    git \
    curl \
    sudo \
    bash \
    ca-certificates \
    avr-gcc \
    avr-libc \
    avrdude \
    bear \
    && pacman -Scc --noconfirm

# Create a non-root dev user
RUN useradd -m -s /bin/bash vscode && \
    echo 'vscode ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers

Adapt the installed packages per your requirements. 

Once you have an extension that supports the Dev Containers format, VS Codium will recognize your .devcontainer/devcontainer.json file. Open your project folder in VS Codium (File -> Open Folder...), press Ctrl + Shift + P to open the Command Palette, type and select: Dev Containers: Reopen in Container. VS Codium will invoke Podman, build your container image, mount your project workspace, and attach the editor directly inside the container environment. 

No comments:

Post a Comment