funwithlinux guide

How to Use Bash Scripting in Embedded Systems Development

Embedded systems—from IoT devices and industrial controllers to smart home appliances—operate under unique constraints: limited memory, processing power, and storage, often with real-time or reliability requirements. Developing for these systems involves repetitive tasks: cross-compiling firmware, flashing devices, monitoring logs, and validating hardware. Manually performing these tasks is error-prone, time-consuming, and scales poorly across teams or production lines. This is where **Bash scripting** shines. Bash (Bourne-Again Shell) is a lightweight, ubiquitous shell available on nearly all Linux-based embedded systems and development hosts. It enables automation of repetitive workflows, integrates seamlessly with build tools (e.g., `make`, `cmake`), and interacts directly with hardware and low-level utilities (e.g., `dd`, `fastboot`, `ssh`). In this guide, we’ll explore how to leverage Bash scripting to streamline embedded systems development. We’ll cover setup, core concepts, practical use cases with code examples, advanced techniques, best practices, and pitfalls to avoid. By the end, you’ll be equipped to write robust Bash scripts that save time, reduce errors, and accelerate your embedded development workflow.

Table of Contents

Why Bash for Embedded Systems?

Bash is not the only scripting language (Python, Perl, and Lua are also popular), but it offers unique advantages for embedded development:

1. Ubiquity

Most embedded Linux distributions (e.g., Yocto, Buildroot, Debian-based systems) include Bash by default. Unlike Python or Ruby, it requires no additional runtime installation, critical for resource-constrained devices.

2. Toolchain Integration

Bash natively interacts with low-level development tools: cross-compilers (e.g., arm-linux-gnueabihf-gcc), debuggers (gdb), flashing utilities (fastboot, dd), and remote access tools (ssh, adb). This makes it ideal for stitching together complex workflows.

3. Lightweight & Fast

Bash scripts have minimal overhead—no compilation, small memory footprint, and fast execution. This is critical for embedded systems where every kilobyte of RAM and CPU cycle counts.

4. Rapid Prototyping

Bash scripts are easy to write and modify, making them perfect for testing ideas (e.g., “Can I automate firmware flashing for 100 devices?”). They can later be optimized or replaced with compiled code if needed.

Setting Up Your Development Environment

Before diving into scripting, ensure your environment is configured for embedded development. Here’s what you’ll need:

1. Host Machine Setup

  • OS: Linux (Ubuntu/Debian recommended) or WSL2 (Windows). Most embedded toolchains and utilities work best on Linux.
  • Bash: Preinstalled on Linux/WSL. Verify with bash --version (aim for Bash 4.0+ for advanced features like arrays).
  • Cross-Compilation Toolchain: Install a toolchain matching your target architecture (e.g., arm-linux-gnueabihf for ARM32, aarch64-linux-gnu for ARM64). Use package managers (apt install gcc-arm-linux-gnueabihf) or download from vendors like Linaro.
  • Remote Access Tools: Install ssh, scp, and screen (for serial console access). For Android-based embedded systems, install adb (Android Debug Bridge).

2. Target Device Access

  • Serial Console: Use a USB-to-serial adapter (e.g., FTDI) to connect to the target’s UART port. Tools like screen or minicom let you interact with the target shell.
  • Network Access: Configure the target with an IP address (via DHCP or static) to enable ssh/scp access.
  • Flashing Interface: Ensure access to the target’s bootloader (e.g., USB for fastboot, JTAG/SWD for low-level programming).

3. Testing Scripts

Always test scripts in a controlled environment first:

  • Use a development board (e.g., Raspberry Pi, BeagleBone) instead of production hardware.
  • Simulate target devices with QEMU if physical hardware is unavailable.

Core Bash Concepts for Embedded Development

Bash scripting relies on a few foundational concepts. Below are the most relevant for embedded workflows, with examples tailored to device development.

Variables & Environment Variables

Variables store data like file paths, toolchain names, or target IPs. Environment variables (e.g., PATH, CC) configure system behavior—critical for cross-compilation.

Example: Define a cross-compiler

# Set cross-compiler prefix (adjust for your architecture)
CROSS_COMPILE="arm-linux-gnueabihf-"
CC="${CROSS_COMPILE}gcc"  # C compiler
OBJCOPY="${CROSS_COMPILE}objcopy"  # Convert binaries to hex/bin

# Print the compiler version to verify setup
echo "Using compiler: $CC"
$CC --version

Conditionals (if Statements)

Check for prerequisites (e.g., “Is the target device connected?”). Use [ ] or [[ ]] for tests (file existence, command success, etc.).

Example: Check if target is reachable via SSH

TARGET_IP="192.168.1.100"

# Test SSH connectivity (quiet mode, exit after 5s)
if ssh -q -o ConnectTimeout=5 pi@$TARGET_IP "exit"; then
  echo "Target device $TARGET_IP is online."
else
  echo "Error: Target $TARGET_IP is unreachable. Exiting."
  exit 1  # Fail the script if target is offline
fi

Loops (for, while)

Automate repetitive tasks: iterate over files, devices, or test cases.

Example: Flash firmware to multiple devices

# List of target serial ports (adjust for your hardware)
PORTS="/dev/ttyUSB0 /dev/ttyUSB1 /dev/ttyUSB2"

FIRMWARE="firmware.bin"

for port in $PORTS; do
  echo "Flashing $FIRMWARE to $port..."
  # Use a flashing tool (e.g., stm32flash for STM32 devices)
  stm32flash -w $FIRMWARE -v $port
  if [ $? -eq 0 ]; then  # Check if last command succeeded
    echo "Successfully flashed $port."
  else
    echo "Failed to flash $port. Skipping."
  fi
done

Functions

Reuse code across scripts (e.g., “log a message with a timestamp” or “validate a binary file”).

Example: Logging function with timestamps

# Log messages with timestamps (critical for debugging)
log() {
  local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
  echo "[$timestamp] $1"
}

# Usage: log "Starting firmware build..."
log "Starting firmware build..."
make  # Run build command
log "Build completed with exit code $?."

Input/Output Redirection

Capture command output to files (e.g., save build logs) or discard errors. Use >, >> (redirect output), or 2> (redirect errors).

Example: Save build logs to a file

BUILD_LOG="build_$(date +%F).log"

# Redirect all output (stdout + stderr) to the log file
make clean all > $BUILD_LOG 2>&1

# Check if build succeeded
if [ $? -eq 0 ]; then
  log "Build successful. Logs saved to $BUILD_LOG."
else
  log "Build failed! Check $BUILD_LOG for details."
  exit 1
fi

Practical Use Cases with Examples

Let’s dive into real-world embedded workflows automated with Bash scripts. Each example includes a script snippet and explanation.

1. Automated Build & Cross-Compilation

Compiling firmware for embedded systems often involves: cleaning old builds, configuring with cmake/make, cross-compiling, and packaging (e.g., .bin, .hex, or .tar.gz).

Script: build_firmware.sh

#!/bin/bash
set -euo pipefail  # Exit on error, unset variable, or pipeline failure (see "Advanced Techniques")

# -------------------------- Configuration ------------------------- #
PROJECT_DIR="/home/user/embedded_project"  # Path to source code
OUTPUT_DIR="${PROJECT_DIR}/build"          # Where to store binaries
CROSS_COMPILE="arm-linux-gnueabihf-"       # Cross-compiler prefix
FIRMWARE_NAME="sensor_node_v1.2.3"         # Final firmware filename
# ------------------------------------------------------------------- #

# Step 1: Validate prerequisites
if [ ! -d "$PROJECT_DIR" ]; then
  echo "Error: Project directory $PROJECT_DIR not found."
  exit 1
fi

# Step 2: Clean old build artifacts
echo "Cleaning old build..."
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"  # Recreate build directory

# Step 3: Cross-compile with CMake
echo "Configuring build for cross-compilation..."
cd "$OUTPUT_DIR"
cmake -DCMAKE_C_COMPILER="${CROSS_COMPILE}gcc" "$PROJECT_DIR"

# Step 4: Build the firmware (use -j4 for parallel build)
echo "Compiling firmware..."
make -j4

# Step 5: Package the binary (convert ELF to binary and zip)
echo "Packaging firmware..."
"${CROSS_COMPILE}objcopy" -O binary "${OUTPUT_DIR}/sensor_node.elf" "${OUTPUT_DIR}/${FIRMWARE_NAME}.bin"
zip "${OUTPUT_DIR}/${FIRMWARE_NAME}.zip" "${OUTPUT_DIR}/${FIRMWARE_NAME}.bin"

echo "Build complete! Firmware: ${OUTPUT_DIR}/${FIRMWARE_NAME}.zip"

How to use:

  1. Save as build_firmware.sh and make executable: chmod +x build_firmware.sh.
  2. Adjust PROJECT_DIR, CROSS_COMPILE, and FIRMWARE_NAME for your project.
  3. Run: ./build_firmware.sh.

2. Flashing Firmware to Target Devices

After building, flash the firmware to the target. Tools like dd, fastboot, or vendor-specific utilities (e.g., stm32flash) are commonly used.

Script: flash_firmware.sh

#!/bin/bash
set -euo pipefail

# -------------------------- Configuration ------------------------- #
FIRMWARE_PATH="/home/user/embedded_project/build/sensor_node_v1.2.3.bin"
TARGET_IP="192.168.1.100"  # Target IP (for network flashing)
FLASH_PARTITION="/dev/mmcblk0p2"  # Target partition (verify with 'lsblk' on target)
# ------------------------------------------------------------------- #

# Validate firmware exists
if [ ! -f "$FIRMWARE_PATH" ]; then
  echo "Error: Firmware file $FIRMWARE_PATH not found."
  exit 1
fi

# Step 1: Transfer firmware to target (via SCP)
echo "Transferring firmware to target..."
scp "$FIRMWARE_PATH" pi@$TARGET_IP:/tmp/

# Step 2: Flash firmware to the target partition (run on target via SSH)
echo "Flashing firmware to $FLASH_PARTITION..."
ssh pi@$TARGET_IP "sudo dd if=/tmp/$(basename $FIRMWARE_PATH) of=$FLASH_PARTITION bs=4M status=progress"

# Step 3: Cleanup and reboot
echo "Cleaning up and rebooting target..."
ssh pi@$TARGET_IP "rm /tmp/$(basename $FIRMWARE_PATH) && sudo reboot"

echo "Firmware flashed successfully! Target rebooting..."

Notes:

  • Replace dd with your target’s flashing tool (e.g., fastboot flash system firmware.bin for Android-based devices).
  • For serial-based flashing (e.g., UART), use stm32flash -w $FIRMWARE_PATH /dev/ttyUSB0 instead of scp/ssh.

3. System Monitoring & Log Collection

Embedded systems often lack GUIs, so scripts can monitor CPU/memory usage, collect logs, or trigger alerts.

Script: monitor_target.sh

#!/bin/bash
set -euo pipefail

TARGET_IP="192.168.1.100"
LOG_DIR="./target_logs"
DURATION=300  # Monitor for 5 minutes (300s)
INTERVAL=5    # Check every 5 seconds

# Create log directory
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_DIR}/monitor_$(date +%F_%H%M%S).log"

echo "Monitoring target $TARGET_IP for $DURATION seconds. Logging to $LOG_FILE..."

# Header for log file
echo "Timestamp, CPU%, MemUsage(MB), DiskUsage(%)" > "$LOG_FILE"

end_time=$((SECONDS + DURATION))
while [ $SECONDS -lt $end_time ]; do
  timestamp=$(date "+%Y-%m-%d %H:%M:%S")
  
  # Get CPU usage (1-minute average), memory usage, and disk usage (root partition)
  stats=$(ssh pi@$TARGET_IP " \
    top -bn1 | awk '/^%Cpu/ {cpu=\$2}; /Mem:/ {mem=\$3/1024}; /\/$/ {disk=\$5}; END {print cpu\",\"mem\",\"disk}' \
  ")
  
  # Append to log
  echo "$timestamp, $stats" >> "$LOG_FILE"
  
  # Print to console (optional)
  echo "[$timestamp] CPU: $(echo $stats | cut -d',' -f1)% | Mem: $(echo $stats | cut -d',' -f2)MB | Disk: $(echo $stats | cut -d',' -f3)%"
  
  sleep $INTERVAL
done

echo "Monitoring complete. Logs saved to $LOG_FILE."

Output: A CSV log with timestamps, CPU usage, memory usage, and disk usage—easily parsed in Excel or Python for analysis.

4. Device Provisioning

Configure new devices with network settings, hostnames, or preinstalled packages.

Script: provision_device.sh

#!/bin/bash
set -euo pipefail

# -------------------------- Configuration ------------------------- #
TARGET_IP="192.168.1.101"  # New device IP (assigned via DHCP)
NEW_HOSTNAME="sensor-node-001"
NEW_IP="192.168.1.201"    # Static IP to assign
GATEWAY="192.168.1.1"
DNS="8.8.8.8 8.8.4.4"
# ------------------------------------------------------------------- #

echo "Provisioning device at $TARGET_IP..."

# Step 1: Set hostname
ssh pi@$TARGET_IP "sudo hostnamectl set-hostname $NEW_HOSTNAME"

# Step 2: Configure static IP (Debian/Ubuntu example; adjust for your OS)
ssh pi@$TARGET_IP "sudo tee /etc/netplan/01-netcfg.yaml > /dev/null <<EOF
network:
  version: 2
  renderer: networkd
  ethernets:
    eth0:
      addresses: [$NEW_IP/24]
      gateway4: $GATEWAY
      nameservers:
        addresses: [$DNS]
EOF
sudo netplan apply"

# Step 3: Install required packages (e.g., Python, Mosquitto for MQTT)
ssh pi@$TARGET_IP "sudo apt update && sudo apt install -y python3 mosquitto"

echo "Provisioning complete! New IP: $NEW_HOSTNAME ($NEW_IP)"

5. Regression Testing Automation

Validate that new firmware doesn’t break existing functionality. Run test suites on the target and collect results.

Script: run_tests.sh

#!/bin/bash
set -euo pipefail

TARGET_IP="192.168.1.100"
TEST_DIR="/home/pi/tests"  # Test suite directory on target
TEST_RESULTS="./test_results_$(date +%F).txt"

echo "Running regression tests on $TARGET_IP..."

# Step 1: Ensure test suite is up-to-date (sync local tests to target)
echo "Syncing test suite to target..."
rsync -avz ./tests/ pi@$TARGET_IP:$TEST_DIR

# Step 2: Run tests on target and capture output
echo "Executing tests..."
ssh pi@$TARGET_IP "cd $TEST_DIR && ./run_tests.sh" > "$TEST_RESULTS" 2>&1

# Step 3: Check for test failures (assumes tests exit with 0 on success)
if grep -q "FAIL" "$TEST_RESULTS"; then
  echo "❌ Tests failed! See $TEST_RESULTS for details."
  exit 1
else
  echo "✅ All tests passed! Results saved to $TEST_RESULTS."
fi

Advanced Bash Techniques for Embedded Workflows

To build robust scripts, master these advanced techniques:

Error Handling with set -euo pipefail

Add set -euo pipefail at the start of scripts to:

  • e: Exit immediately if any command fails.
  • u: Treat unset variables as errors (avoids bugs from typos like $TARGE_IP instead of $TARGET_IP).
  • o pipefail: Fail if any command in a pipeline fails (e.g., cmd1 | cmd2 fails if cmd1 fails).

Parameter Parsing with getopts

Make scripts flexible by accepting arguments (e.g., ./flash_firmware.sh --firmware latest.bin --target 192.168.1.100).

Example: Add flags to a flashing script

#!/bin/bash
set -euo pipefail

# Default values
FIRMWARE="firmware.bin"
TARGET_IP="192.168.1.100"

# Parse arguments with getopts
while getopts "f:t:h" opt; do
  case $opt in
    f) FIRMWARE="$OPTARG" ;;  # -f: Firmware path
    t) TARGET_IP="$OPTARG" ;; # -t: Target IP
    h) echo "Usage: $0 -f <firmware> -t <target-ip>"; exit 0 ;;
    \?) echo "Invalid option: -$OPTARG"; exit 1 ;;
  esac
done

echo "Flashing $FIRMWARE to $TARGET_IP..."
# Rest of the flashing logic...

Usage: ./flash_firmware.sh -f custom_firmware.bin -t 192.168.1.101

Remote Command Execution with ssh

Run complex commands on the target via a single ssh call. Use here-documents (<<EOF) for multi-line commands.

Example: Collect system info from target

ssh pi@$TARGET_IP "bash -s" <<EOF
  echo "System Info for \$(hostname):"
  uname -a
  uptime
  free -h
  df -h
EOF

Best Practices for Embedded Bash Scripts

Embedded systems demand efficiency and reliability. Follow these practices to write maintainable, robust scripts:

1. Keep Scripts Small & Focused

Avoid monolithic scripts. Split workflows into modular scripts (e.g., build.sh, flash.sh, test.sh) for reusability.

2. Validate All Inputs

Check for:

  • Existence of files/firmware ([ -f "$FIRMWARE" ]).
  • Target device connectivity (ping -c1 $TARGET_IP).
  • Sufficient permissions (e.g., sudo for flashing).

3. Log Everything

Embed timestamps and save output to log files (e.g., log "Build started"). This is critical for debugging failed builds or bricked devices.

4. Minimize Target Resource Usage

  • Avoid running heavy commands on the target (e.g., top in a loop). Use lightweight tools like ps or vmstat instead.
  • Clean up temporary files on the target after transfers/scans.

5. Version Control Scripts

Treat scripts like source code: track them in Git. This ensures reproducibility and enables rollbacks if a script breaks hardware.

6. Test for Edge Cases

  • What if the network drops during flashing?
  • What if the target’s filesystem is read-only?
  • Add trap 'cleanup' EXIT to run cleanup logic (e.g., stop processes, unmount drives) if the script fails.

Common Pitfalls to Avoid

1. Hardcoding Paths/IPs

Never hardcode values like TARGET_IP="192.168.1.100". Use variables or command-line arguments for flexibility.

2. Ignoring Exit Codes

Scripts often fail silently if commands like scp or make fail. Use set -e or explicit checks (if ! command; then exit 1; fi).

3. Assuming Target Tools Exist

Embedded systems may lack utilities like rsync or curl. Verify tool availability on the target first:

if ! ssh pi@$TARGET_IP "command -v rsync"; then
  echo "Error: rsync not found on target. Install it first."
  exit 1
fi

4. Overlooking Quoting

Unquoted variables with spaces (e.g., FIRMWARE="my firmware.bin") break commands. Always quote variables: "$FIRMWARE".

5. Using Bash-Specific Features on sh

Some embedded systems use sh (not Bash). Avoid features like arrays or [[ ]] if targeting sh; use POSIX-compliant syntax instead.

Conclusion

Bash scripting is a cornerstone of efficient embedded systems development. By automating builds, flashing, monitoring, and testing, you reduce errors, save time, and scale workflows across teams and production lines.

Start small: write a script to automate your most repetitive task (e.g., cross-compiling firmware). Gradually expand to more complex workflows like provisioning or regression testing. With practice, Bash will become an indispensable tool in your embedded development toolkit.

References