Shell Scripting

Introduction to Shell Scripting

The shell is the command-line interpreter that sits between you and the Linux kernel. When you type ls and press Enter, the shell parses your input, finds the right program, runs it, and delivers the output. Understanding the shell makes you significantly faster as a developer.


What is a Shell?

A shell is a program that reads commands (either typed interactively or from a script file), interprets them, and executes them by talking to the kernel.

text
You type a command

Shell parses it

Shell finds the program (searches $PATH)

Shell forks a child process

Child process executes the program

Output is returned to your terminal

The shell is named "shell" because it is the outer layer (shell) of the OS, surrounding the kernel at the centre.


Bash vs. Zsh vs. sh

ShellFull NameDefault OnNotes
bashBourne Again SHellUbuntu, Debian, Raspberry Pi OSIndustry standard for scripting
zshZ ShellmacOS (since Catalina)More interactive features, Oh My Zsh ecosystem
shPOSIX shMinimal Linux environmentsStrict POSIX compliance, used for portable scripts
fishFriendly Interactive ShellOptional installAutocomplete-first, not POSIX compliant
dashDebian Almquist ShellUbuntu's /bin/shVery fast, but fewer features than bash

Rule: Write scripts with #!/bin/bash unless you specifically need POSIX portability. Bash is the safest choice for embedded toolchain scripts.


The Shebang (#!)

The very first line of any script file tells the operating system which interpreter to use. This line is called the shebang (from #!).

bash
#!/bin/bash         # Use Bash
#!/bin/sh           # Use POSIX sh (more portable)
#!/usr/bin/env python3  # Use Python 3 (found via PATH)

Without a shebang, the OS will try to execute the file with the user's current shell, which may behave differently on different machines.


Your First Script

Create a file named hello.sh:

bash
#!/bin/bash

# This is a comment — the shell ignores it
echo "Hello from Bash!"
echo "Current date: $(date)"
echo "You are logged in as: $USER"
echo "Your home directory is: $HOME"

Make it Executable and Run

bash
chmod +x hello.sh      # Grant execute permission
./hello.sh             # Run (the ./ tells the shell: "look in current directory")

Output:

text
Hello from Bash!
Current date: Fri May  9 19:15:00 IST 2026
You are logged in as: rajath
Your home directory is: /home/rajath

Variables

Declaring and Using Variables

bash
#!/bin/bash

# No spaces around = sign — this is a strict rule in Bash
NAME="ESP32"
FIRMWARE_VERSION="5.1.2"
BOARD_COUNT=4

echo "Target: $NAME"
echo "Firmware: $FIRMWARE_VERSION"
echo "Boards: $BOARD_COUNT"

# Curly braces prevent ambiguity when the variable is adjacent to text
echo "Flashing ${NAME}_firmware_v${FIRMWARE_VERSION}.bin"

Command Substitution

Capture the output of a command into a variable:

bash
TODAY=$(date +%Y-%m-%d)
KERNEL=$(uname -r)
FREE_SPACE=$(df -h ~ | awk 'NR==2 {print $4}')

echo "Today: $TODAY"
echo "Kernel: $KERNEL"
echo "Free space in home: $FREE_SPACE"

Readonly Variables

bash
readonly MAX_RETRIES=5
MAX_RETRIES=10     # This will fail with an error

Unsetting Variables

bash
MY_VAR="hello"
unset MY_VAR
echo $MY_VAR       # Empty — variable no longer exists

Special Variables

These are set automatically by Bash:

VariableMeaning
$0Name of the script itself
$1 $2 ...Positional arguments (passed on the command line)
$@All arguments as separate strings
$#Number of arguments passed
$?Exit code of the last command (0 = success, non-zero = error)
$$PID of the current script
$!PID of the last background process
bash
#!/bin/bash
echo "Script name: $0"
echo "First arg:   $1"
echo "All args:    $@"
echo "Arg count:   $#"

Run as: ./script.sh alpha beta gamma


User Input with read

bash
#!/bin/bash

echo -n "Enter project name: "
read PROJECT_NAME

echo "Creating project: $PROJECT_NAME"
mkdir -p "$PROJECT_NAME"

# Read with a prompt and timeout
read -t 10 -p "Enter your name (10s timeout): " USER_NAME

# Read a password without echoing to screen
read -s -p "Enter password: " PASSWORD
echo ""    # Move to next line after silent input

Conditionals

if / elif / else

bash
#!/bin/bash

TEMPERATURE=38

if [ "$TEMPERATURE" -gt 37 ]; then
    echo "ALERT: High temperature detected: ${TEMPERATURE}°C"
elif [ "$TEMPERATURE" -gt 35 ]; then
    echo "WARNING: Elevated temperature: ${TEMPERATURE}°C"
else
    echo "OK: Temperature is normal: ${TEMPERATURE}°C"
fi

Comparison Operators

Numeric comparison (inside [ ]):

OperatorMeaning
-eqEqual
-neNot equal
-gtGreater than
-ltLess than
-geGreater than or equal
-leLess than or equal

String comparison:

OperatorMeaning
= or ==Equal
!=Not equal
-z "$var"True if string is empty
-n "$var"True if string is not empty

File tests:

OperatorMeaning
-f "$file"True if file exists and is a regular file
-d "$dir"True if directory exists
-e "$path"True if path exists (any type)
-r "$file"True if file is readable
-x "$file"True if file is executable
-s "$file"True if file is non-empty
bash
#!/bin/bash

FILE="firmware.bin"

if [ -f "$FILE" ]; then
    SIZE=$(stat -c%s "$FILE")
    echo "Found $FILE — size: $SIZE bytes"
else
    echo "ERROR: $FILE not found. Run: idf.py build"
    exit 1   # Exit with error code
fi

# Check if a directory exists before creating
DIR="build"
if [ ! -d "$DIR" ]; then
    mkdir -p "$DIR"
    echo "Created $DIR"
fi

[[ ]] — Extended Test (Bash Only)

Double brackets are more powerful and safer:

bash
# Pattern matching with ==
if [[ "$FILENAME" == *.bin ]]; then
    echo "Binary file detected"
fi

# Regex matching with =~
if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    echo "Valid semver: $VERSION"
fi

# Compound conditions without quoting issues
if [[ -f "$FILE" && -r "$FILE" ]]; then
    echo "File exists and is readable"
fi

Loops

for Loop

bash
#!/bin/bash

# Loop over a list
for CHIP in esp32 esp32s2 esp32s3 esp32c3 esp32h2; do
    echo "Building firmware for $CHIP..."
    # idf.py set-target $CHIP && idf.py build
done

# Loop over a range
for i in {1..10}; do
    echo "Iteration: $i"
done

# C-style for loop
for ((i=0; i<5; i++)); do
    echo "Step: $i"
done

# Loop over files matching a pattern
for FILE in ./build/*.bin; do
    echo "Found binary: $FILE"
    cp "$FILE" /media/sdcard/
done

# Loop over command output
for PORT in $(ls /dev/ttyUSB*); do
    echo "Serial device found: $PORT"
done

while Loop

bash
#!/bin/bash

# Retry loop — keep trying until success or max retries
MAX=5
COUNT=0

while [ $COUNT -lt $MAX ]; do
    if ping -c 1 google.com &>/dev/null; then
        echo "Network is up!"
        break
    fi
    COUNT=$((COUNT + 1))
    echo "Attempt $COUNT failed. Retrying in 3 seconds..."
    sleep 3
done

if [ $COUNT -eq $MAX ]; then
    echo "ERROR: Network unavailable after $MAX attempts."
    exit 1
fi

# Read a file line by line
while IFS= read -r LINE; do
    echo "Processing: $LINE"
done < input.txt

until Loop

bash
# Opposite of while — runs UNTIL the condition becomes true
COUNT=0
until [ $COUNT -ge 5 ]; do
    echo "Count: $COUNT"
    COUNT=$((COUNT + 1))
done

Functions

bash
#!/bin/bash

# Define a function
build_firmware() {
    local TARGET=$1         # local = function-scoped variable
    local OUTPUT_DIR=$2

    echo "Building for $TARGET..."
    mkdir -p "$OUTPUT_DIR"

    if idf.py set-target "$TARGET" && idf.py build; then
        cp build/firmware.bin "$OUTPUT_DIR/${TARGET}_firmware.bin"
        echo "Success: ${TARGET}_firmware.bin"
        return 0            # Return success exit code
    else
        echo "ERROR: Build failed for $TARGET"
        return 1            # Return failure exit code
    fi
}

# Call the function
build_firmware "esp32" "./dist"
build_firmware "esp32s3" "./dist"

# Check return value
if build_firmware "esp32c3" "./dist"; then
    echo "All builds complete."
else
    echo "One or more builds failed."
fi

Exit Codes

Every command returns an exit code when it finishes. 0 means success. Any non-zero value means failure.

bash
ls /nonexistent
echo $?    # 2 (error)

ls /home
echo $?    # 0 (success)

# Use exit codes for error handling
if ! git pull; then
    echo "Git pull failed — aborting"
    exit 1
fi

set -e — Exit on Error

Add set -e at the top of any production script. The script will immediately stop if any command fails instead of continuing with broken state.

bash
#!/bin/bash
set -e            # Exit immediately on error
set -u            # Treat unset variables as errors
set -o pipefail   # Catch errors in pipes

echo "Updating package list..."
sudo apt update

echo "Installing tools..."
sudo apt install -y cmake ninja-build

echo "All done!"

Pipes and Redirection

bash
# Pipe: send output of one command as input to next
ls -la | grep ".c"                       # Filter ls output
cat /var/log/syslog | grep "USB"         # Filter log
ps aux | sort -k3 -rn | head -10         # Top 10 CPU-consuming processes

# Redirect stdout to a file (overwrite)
echo "Build started at $(date)" > build.log

# Append to a file
echo "Step 1 complete" >> build.log

# Redirect both stdout and stderr to a file
make 2>&1 | tee build.log                # Also display on screen

# Redirect stderr to /dev/null (suppress error output)
find / -name "*.bin" 2>/dev/null

# Here document — feed multi-line input to a command
cat << EOF > config.ini
[settings]
baud_rate=115200
port=/dev/ttyUSB0
EOF

Practical Script: Automated Project Setup

bash
#!/bin/bash
set -e
set -o pipefail

PROJECT_NAME="${1:-my_esp_project}"
CHIP_TARGET="${2:-esp32}"

echo "============================================"
echo "ESP32 Project Setup Script"
echo "Project : $PROJECT_NAME"
echo "Target  : $CHIP_TARGET"
echo "============================================"

# Check prerequisites
for CMD in git python3 idf.py; do
    if ! command -v "$CMD" &>/dev/null; then
        echo "ERROR: '$CMD' is not installed or not in PATH."
        exit 1
    fi
done

# Create project structure
mkdir -p "$PROJECT_NAME/main"
mkdir -p "$PROJECT_NAME/components"

cat << 'EOF' > "$PROJECT_NAME/main/main.c"
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

void app_main(void) {
    while (1) {
        printf("Hello from %s!\n", CONFIG_IDF_TARGET);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}
EOF

cat << EOF > "$PROJECT_NAME/CMakeLists.txt"
cmake_minimum_required(VERSION 3.16)
include(\$ENV{IDF_PATH}/tools/cmake/project.cmake)
project($PROJECT_NAME)
EOF

echo ""
echo "Project created at: ./$PROJECT_NAME"
echo "Next steps:"
echo "  cd $PROJECT_NAME"
echo "  idf.py set-target $CHIP_TARGET"
echo "  idf.py build"
Previous
Package Managers (apt)