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.
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
| Shell | Full Name | Default On | Notes |
|---|---|---|---|
bash | Bourne Again SHell | Ubuntu, Debian, Raspberry Pi OS | Industry standard for scripting |
zsh | Z Shell | macOS (since Catalina) | More interactive features, Oh My Zsh ecosystem |
sh | POSIX sh | Minimal Linux environments | Strict POSIX compliance, used for portable scripts |
fish | Friendly Interactive Shell | Optional install | Autocomplete-first, not POSIX compliant |
dash | Debian Almquist Shell | Ubuntu's /bin/sh | Very 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 #!).
#!/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:
#!/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
chmod +x hello.sh # Grant execute permission
./hello.sh # Run (the ./ tells the shell: "look in current directory")
Output:
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
#!/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:
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
readonly MAX_RETRIES=5
MAX_RETRIES=10 # This will fail with an error
Unsetting Variables
MY_VAR="hello"
unset MY_VAR
echo $MY_VAR # Empty — variable no longer exists
Special Variables
These are set automatically by Bash:
| Variable | Meaning |
|---|---|
$0 | Name 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 |
#!/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
#!/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
#!/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 [ ]):
| Operator | Meaning |
|---|---|
-eq | Equal |
-ne | Not equal |
-gt | Greater than |
-lt | Less than |
-ge | Greater than or equal |
-le | Less than or equal |
String comparison:
| Operator | Meaning |
|---|---|
= or == | Equal |
!= | Not equal |
-z "$var" | True if string is empty |
-n "$var" | True if string is not empty |
File tests:
| Operator | Meaning |
|---|---|
-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 |
#!/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:
# 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
#!/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
#!/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
# Opposite of while — runs UNTIL the condition becomes true
COUNT=0
until [ $COUNT -ge 5 ]; do
echo "Count: $COUNT"
COUNT=$((COUNT + 1))
done
Functions
#!/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.
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.
#!/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
# 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
#!/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"

