Chapter 21: Shell Scripting: Control Flow (if, else, case)
Chapter Objectives
By the end of this chapter, you will be able to:
- Understand the concept of exit codes and how they form the basis of conditional logic in shell scripts.
- Implement conditional branching using
if
,elif
, andelse
statements to control script execution. - Perform string, numeric, and file-based comparisons using the
test
,[
, and[[
constructs. - Design and implement multi-way branching logic efficiently using the
case
statement for pattern matching. - Write robust shell scripts for the Raspberry Pi 5 that can make decisions based on system state, user input, or hardware status.
- Debug and troubleshoot common errors related to syntax and logic in conditional shell scripts.
Introduction
In our journey through embedded Linux, we have learned how to execute commands and string them together to perform simple, linear tasks. However, the true power of an embedded system lies in its ability to react to a changing environment. A weather station must respond differently to a sudden drop in temperature, a security camera must take action only when motion is detected, and a factory robot must halt if a sensor reports an anomaly. This ability to make decisions is the hallmark of intelligent systems, and in the world of shell scripting, it is made possible through conditional logic.
This chapter introduces the fundamental building blocks of decision-making in Bash: the if
statement and the case
statement. These constructs allow us to move beyond simple, sequential scripts and create programs that can analyze conditions and alter their execution path accordingly. We will explore how a script can check the status of hardware, validate user input, monitor system resources, and take specific actions based on what it finds. For an embedded developer working with the Raspberry Pi 5, mastering conditional logic is not merely an academic exercise; it is the essential skill that enables the creation of automated, responsive, and reliable applications that bridge the gap between software commands and the physical world. By the end of this chapter, you will be equipped to write scripts that do not just execute commands, but make intelligent choices.
Technical Background
At the heart of every decision a computer makes is a simple question: “Is a particular condition true or false?” In many programming languages, this is handled with Boolean true
and false
values. The shell, however, operates on a different but related principle: the exit code of a command. Every command or program you run in Linux finishes with an integer status code that it reports back to the shell. By convention, an exit code of 0 signifies success, while any non-zero value (from 1 to 255) indicates some form of failure or error. This simple mechanism is the foundation of all conditional logic in shell scripting.
The if
statement doesn’t evaluate a Boolean expression directly; instead, it executes a command and examines its exit code. If the exit code is 0 (success), the condition is considered “true,” and the code block following the if
is executed. If the exit code is non-zero (failure), the condition is “false,” and the block is skipped.
Consider the grep
command, which searches for patterns in text. If grep
finds the pattern, it exits with 0. If it doesn’t, it exits with 1. We can use this behavior directly in an if
statement:
if grep -q "root" /etc/passwd
then
echo "The root user exists in the password file."
fi
Here, grep -q "root" /etc/passwd
is the command being tested. The -q
(quiet) option suppresses grep
‘s normal output; we only care about its exit code. If root
is found, the command succeeds (exit code 0), and the echo
statement runs. If not, it fails (exit code 1), and the then...fi
block is skipped.
%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans'}}}%% graph TD subgraph "Shell Conditional Logic" direction TB A["Start: Execute a Command<br>e.g., <i>grep root /etc/passwd</i>"] D{{"Check Command's<br>Exit Code"}} A --> D D -- "Exit Code is 0 (Success)" --> T[Condition is TRUE<br>Execute 'then' block] D -- "Exit Code is Non-Zero (Failure)" --> F[Condition is FALSE<br>Skip 'then' block] T --> E[End] F --> E[End] end %% Styling style A fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff style D fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff style T fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff style F fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff style E fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff
The test
Command and [
…]
While using any command’s exit code is powerful, we often need to perform more explicit comparisons, such as checking if two numbers are equal or if a file exists. This is the role of the test
command. The test
command evaluates an expression and exits with a status of 0 if the expression is true and 1 if it is false.
For example, to check if a variable holds a specific value, you could write:
test “$USER” = “pi”
This command will exit with 0 if the $USER
variable is equal to “pi”. While perfectly valid, this syntax is not as common as its more popular alias: the single bracket [
. The command [ expression ]
is a synonym for test expression
.
Warning: The spaces around the brackets are mandatory. The
[
is not just syntax; it is a command (a link totest
, in fact), and like any command, it requires spaces to separate it from its arguments. Forgetting a space, as in[$VAR=5]
, is a very common syntax error.
Using this improved syntax, we can write more readable conditions.
Types of Comparisons
The test
command and its bracketed forms support three main categories of comparisons, each with its own set of operators. It is crucial to use the correct operators for the type of data you are evaluating.
1. Numeric Comparisons
When comparing integers, you must use a specific set of operators. These operators do not work for string comparisons.
Attempting to use >
or <
for numeric comparison within single brackets will result in an error, as the shell will interpret them as redirection operators.
2. String Comparisons
For comparing textual data, a different set of operators is used.
Tip: Always enclose your variables in double quotes (e.g.,
"$STR"
) within test conditions. If a variable is empty or contains spaces, omitting the quotes can lead to thetest
command receiving an unexpected number of arguments, causing a syntax error. For example, if$VAR
is empty,[ $VAR = "a" ]
becomes[ = "a" ]
, which is invalid.[ "$VAR" = "a" ]
becomes[ "" = "a" ]
, which is valid and evaluates correctly.
3. File Attribute Checks
A common task in embedded systems is to check the status of files, such as device nodes in /dev
or configuration files in /etc
. The test
command provides a rich set of operators for this purpose.
These file operators are indispensable for writing scripts that safely interact with the filesystem, ensuring a file exists before trying to read it or that a directory is present before writing to it.
Branching with if-elif-else
An if
statement on its own is useful, but its power grows when you add branching. The else
and elif
(short for “else if”) clauses allow you to build a decision tree, executing different blocks of code for different conditions.
The full structure looks like this:
if [ condition1 ]
then
# Code to run if condition1 is true (exit code 0)
elif [ condition2 ]
then
# Code to run if condition1 is false and condition2 is true
else
# Code to run if all preceding conditions are false
fi
This structure allows a script to navigate a series of questions. Imagine a script that checks the Raspberry Pi’s CPU temperature. It first asks, “Is the temperature in the critical range?” If so, it takes immediate action. If not, it proceeds to the elif
to ask, “Is the temperature in the warning range?” If so, it logs a warning. If that’s also not true, the else
block acts as a default, reporting that the temperature is normal. This cascading logic is fundamental to creating responsive and robust systems.
%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans'}}}%% graph TD subgraph "if-elif-else Decision Flow" A[Start] --> C1{{"if (condition1 is true)?"}} C1 -- "Yes" --> P1["Execute Block 1"] --> E[End] C1 -- "No" --> C2{{"elif (condition2 is true)?"}} C2 -- "Yes" --> P2["Execute Block 2"] --> E C2 -- "No" --> P3["<b>else</b><br>Execute Block 3"] --> E end %% Styling style A fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff style C1 fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff style C2 fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff style P1 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff style P2 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff style P3 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff style E fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff
The Modern Alternative: [[
…]]
Bash introduced an enhanced version of the [
command: the double-bracket [[ ... ]]
. While [
is a standard command bound by shell parsing rules, [[
is a keyword with its own special parsing rules, which resolves several quirks and adds new features.
Key advantages of [[ ... ]]
include:
- No Word Splitting or Globbing: Inside
[[
, variables don’t need to be quoted to prevent errors with spaces or empty values. The expression[[ $VAR = "some string" ]]
is safe even if$VAR
is empty or contains spaces. - Regular Expression Matching: It introduces the
=~
operator for matching against extended regular expressions, a powerful tool for string validation. - C-Style Logical Operators: You can use
&&
(AND) and||
(OR) directly inside the brackets, rather than using the-a
and-o
operators oftest
, which can be brittle.
Example using &&
:
# Old style with [
if [ -f "$FILE" -a -r "$FILE" ]; then ...
# New, safer style with [[
if [[ -f "$FILE" && -r "$FILE" ]]; then ...
For new scripts written specifically for Bash, using [[ ... ]]
is generally recommended for its improved safety and extended features. However, if you are writing scripts that must be portable to other shells (like sh
or dash
), you should stick with the POSIX-standard [
.
Multi-Way Branching with the case
Statement
When you have a long chain of if-elif-elif...
statements all checking the same variable against different values, the code can become clumsy and hard to read. The case
statement provides a much cleaner and more efficient solution for this scenario. It compares a single variable or value against a list of patterns and executes the code block associated with the first matching pattern.
The structure is as follows:
case "$VARIABLE" in
pattern1)
# Commands for pattern1
;;
pattern2|pattern3)
# Commands for when variable matches pattern2 OR pattern3
;;
*.txt)
# Commands for any value ending in .txt
;;
*)
# Default commands (matches anything)
;;
esac
The case
statement is a perfect analogy for a switchboard. A single input value ($VARIABLE
) is plugged in, and the case
statement routes it to the correct destination based on a matching pattern. Each block of commands must be terminated with ;;
, which prevents the execution from “falling through” to the next pattern. The *)
pattern is a catch-all, similar to an else
clause, that executes if no other patterns match.
One of the most powerful features of case
is its use of shell wildcards in patterns. The asterisk (*
) matches any sequence of characters, the question mark (?
) matches any single character, and character sets ([abc]
) can be used. This makes it exceptionally good at tasks like parsing command-line arguments, where you might want to handle options like --start
, --stop
, or --status
.
%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans'}}}%% graph TD subgraph "case Statement Switchboard" A[Start: Input Variable<br>e.g., $COMMAND] --> S{{"case $VARIABLE in"}} S --> P1("pattern1)") --> B1["Execute<br>Commands 1"] --> T(";;") S --> P2("pattern2 | pattern3)") --> B2["Execute<br>Commands 2"] --> T2(";;") S --> P3("*.txt)") --> B3["Execute<br>Commands 3"] --> T3(";;") S --> P4("*)<br>(Default)") --> B4["Execute<br>Default Commands"] --> T4(";;") T --> E[esac<br>End] T2 --> E T3 --> E T4 --> E end %% Styling style A fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff style S fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff style P1 fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff style P2 fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff style P3 fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff style P4 fill:#eab308,stroke:#eab308,stroke-width:1px,color:#1f2937 style B1 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff style B2 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff style B3 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff style B4 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff style E fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff
Practical Examples
Let’s apply these concepts to practical scenarios you might encounter while developing on a Raspberry Pi 5. These examples will read system information, control hardware, and manage services.
Example 1: System Health Monitor
A common task for an embedded device is to monitor its own health. This script uses an if-elif-else
structure to check the CPU temperature and report its status. The Raspberry Pi 5 exposes its CPU temperature through a file in the /sys
filesystem.
File Structure and Code
Create a file named health_check.sh
.
#!/bin/bash
# health_check.sh
# A simple script to monitor the CPU temperature of a Raspberry Pi 5.
# The temperature is stored in this file in millidegrees Celsius.
# E.g., a value of 54321 means 54.321°C.
TEMP_FILE="/sys/class/thermal/thermal_zone0/temp"
# Define temperature thresholds in millidegrees Celsius.
# 80°C is a typical throttling threshold. 60°C is a warm/warning level.
CRITICAL_TEMP=80000
WARN_TEMP=60000
# First, check if the temperature file exists and is readable.
if [[ ! -r "$TEMP_FILE" ]]; then
echo "Error: Cannot read temperature file at $TEMP_FILE."
echo "Please ensure you are running on a Raspberry Pi and have permissions."
exit 1
fi
# Read the raw value from the file.
# The `cat` command outputs the content of the file.
# We use command substitution `$(...)` to capture that output into a variable.
current_temp_raw=$(cat "$TEMP_FILE")
# Check if the value read is a valid number.
# We use a regular expression to ensure it contains only digits.
if ! [[ "$current_temp_raw" =~ ^[0-9]+$ ]]; then
echo "Error: Invalid temperature value read: '$current_temp_raw'"
exit 1
fi
# Now, use our conditional logic to evaluate the temperature.
echo "--- System Health Report ---"
echo "Current raw temperature value: $current_temp_raw"
if [[ "$current_temp_raw" -ge "$CRITICAL_TEMP" ]]; then
# This block runs if the temperature is >= 80000.
echo "Status: CRITICAL! CPU temperature is above 80°C."
echo "Action: System may be throttling. Check cooling and system load."
elif [[ "$current_temp_raw" -ge "$WARN_TEMP" ]]; then
# This block runs if the first condition was false, but temp is >= 60000.
echo "Status: WARNING. CPU temperature is above 60°C."
echo "System is running warm. Monitor for further increases."
else
# This block runs if both of the above conditions were false.
echo "Status: OK. CPU temperature is within normal range."
fi
echo "--------------------------"
Build and Execution Steps
- Save the code above into a file named
health_check.sh
. - Make the script executable using the
chmod
command:chmod +x health_check.sh
- Run the script from your terminal:
./health_check.sh
Expected Output
The output will vary based on your Pi’s current temperature.
If the Pi is idle and cool:
--- System Health Report ---
Current raw temperature value: 45123
Status: OK. CPU temperature is within normal range.
--------------------------
If the Pi is under load:
--- System Health Report ---
Current raw temperature value: 62580
Status: WARNING. CPU temperature is above 60°C.
System is running warm. Monitor for further increases.
--------------------------
This example demonstrates file checks (-r
), numeric comparisons (-ge
), and a clear if-elif-else
decision path, providing a practical template for any monitoring task.
Example 2: GPIO LED Controller with case
This example shows how to use a case
statement to parse command-line arguments to control an LED connected to a GPIO pin. It provides a clean interface for turning the LED on, off, or checking its status.
Hardware Integration
You will need:
- 1x LED (any color)
- 1x 330Ω resistor
- Jumper wires
Connect the components as follows:
- Connect the anode (longer leg) of the LED to GPIO 21 (Pin 40) on the Raspberry Pi 5.
- Connect the cathode (shorter leg) of the LED to one end of the 330Ω resistor.
- Connect the other end of the resistor to a Ground (GND) pin (e.g., Pin 39).
Warning: Always use a current-limiting resistor when connecting an LED to a GPIO pin to prevent damage to both the LED and the Raspberry Pi.
File Structure and Code
Create a file named led_control.sh
. We will use the modern gpiod
tools (gpioset
, gpioget
) which are the standard for GPIO control, replacing the older, deprecated /sys/class/gpio
interface.
#!/bin/bash
# led_control.sh
# Controls an LED on GPIO 21 using a case statement for command parsing.
# Raspberry Pi 5 uses gpiochip4 for pins 0-27. GPIO 21 is on this chip.
GPIO_CHIP="gpiochip4"
GPIO_LINE=21 # Using GPIO pin 21
# The first argument to the script is our command ($1).
COMMAND=$1
# Check if a command was provided.
if [[ -z "$COMMAND" ]]; then
echo "Usage: $0 {on|off|status}"
echo " on: Turn the LED on."
echo " off: Turn the LED off."
echo " status: Check the current state of the LED."
exit 1
fi
# Use a case statement to handle the command.
case "$COMMAND" in
on)
echo "Turning LED on (GPIO $GPIO_LINE)..."
# gpioset <chip> <line>=<value>
# 1 means high/on.
gpioset "$GPIO_CHIP" "$GPIO_LINE"=1
echo "Done."
;; # End of this case
off)
echo "Turning LED off (GPIO $GPIO_LINE)..."
# 0 means low/off.
gpioset "$GPIO_CHIP" "$GPIO_LINE"=0
echo "Done."
;; # End of this case
status)
echo "Checking LED status (GPIO $GPIO_LINE)..."
# gpioget <chip> <line>
# It returns 0 or 1.
state=$(gpioget "$GPIO_CHIP" "$GPIO_LINE")
if [[ "$state" -eq 1 ]]; then
echo "LED is currently ON."
else
echo "LED is currently OFF."
fi
;; # End of this case
*) # Catch-all for any other argument
echo "Error: Invalid command '$COMMAND'."
echo "Usage: $0 {on|off|status}"
exit 1
;;
esac
exit 0
Build and Execution Steps
1. Ensure the gpiod
tools are installed. They are standard on Raspberry Pi OS.
sudo apt-get update && sudo apt-get install gpiod
2. Save the code as led_control.sh
and make it executable:
chmod +x led_control.sh
3. Run the script with different commands:
# Turn the LED on
./led_control.sh on
# Check its status
./led_control.sh status
# Turn the LED off
./led_control.sh off
# Test an invalid command
./led_control.sh blink
Expected Output
$ ./led_control.sh on
Turning LED on (GPIO 21)...
Done.
$ ./led_control.sh status
Checking LED status (GPIO 21)...
LED is currently ON.
$ ./led_control.sh off
Turning LED off (GPIO 21)...
Done.
$ ./led_control.sh blink
Error: Invalid command 'blink'.
Usage: ./led_control.sh {on|off|status}
This example elegantly demonstrates how a case
statement can create a clean command-line interface, a common pattern in embedded scripts for testing and diagnostics.
Common Mistakes & Troubleshooting
When writing conditional logic in shell scripts, small syntax mistakes can lead to frustrating errors. Here are some of the most common pitfalls and how to avoid them.
Exercises
- Basic User Check: Write a script named
user_check.sh
. It should check if the current user running the script isroot
. If it is, it should print a warning message: “Warning: Running as root is not recommended.” Otherwise, it should print “Running as a standard user. Good.” (Hint: The current user is stored in the$USER
variable). - File or Directory Identifier: Create a script
type_check.sh
that takes one argument: a path to a file or directory. The script should useif-elif-else
to check if the path exists. If it does, it should then determine if it’s a regular file or a directory and print an appropriate message (“X is a regular file.” or “X is a directory.”). If the path does not exist, it should print an error message. (Hint: Use-e
,-f
, and-d
). - Raspberry Pi Model Identifier: Write a script
pi_model.sh
that usesgrep
and acase
statement to identify the Raspberry Pi model. Read the “Model” line from/proc/cpuinfo
. The script should print a specific message for “Raspberry Pi 5 Model B” and have a default message like “Unknown or older Raspberry Pi model detected” for any other value. (Hint: Usegrep -i "Model" /proc/cpuinfo
and command substitution). - Advanced LED Controller: Extend the
led_control.sh
example. Add a new commandblink
to thecase
statement. This command should turn the LED on, wait for 0.5 seconds (sleep 0.5
), turn it off, wait another 0.5 seconds, and repeat this cycle five times. (Hint: You will need afor
loop inside theblink
case). - Simple Service Manager: Write an interactive script
service_menu.sh
. It should check if thessh
service is running usingsystemctl is-active --quiet ssh
. Based on the exit code, it should present a menu using acase
statement. If the service is active, the menu should offer tostop
orrestart
it. If it’s inactive, the menu should offer tostart
it. The script should then execute the chosen command usingsudo systemctl ...
. Include an option toexit
the script.
Summary
- Conditional Logic is Based on Exit Codes: In shell scripting, a command that exits with a status code of 0 is considered “true” (success), while any non-zero exit code is “false” (failure).
- The
if
Statement Tests Exit Codes: Theif
statement executes a command and, based on its exit code, conditionally runs a block of code. test
,[
, and[[
are for Comparisons: These commands are used to perform numeric, string, and file-based comparisons, forming the core of most conditional expressions.- Quoting Variables is Crucial: Always enclose variables in double quotes (
"$VAR"
) inside conditional expressions to prevent errors from empty values or spaces. if-elif-else
Creates Decision Trees: This structure allows for complex, cascading logic where multiple conditions are tested in sequence.case
Provides Clean Multi-Way Branching: Thecase
statement is a more readable and efficient alternative to longif-elif
chains, especially for parsing user input or matching patterns.[[...]]
is the Modern Standard: For Bash scripts, the[[...]]
construct offers safer parsing and more features (like regex matching) than the traditional[...]
.
Mastering these conditional constructs elevates your scripts from simple command sequences to dynamic programs capable of responding intelligently to the diverse conditions of an embedded environment.
Further Reading
- Bash Reference Manual (GNU): The official documentation for the Bash shell. The section on Conditional Constructs is the definitive source.
- Advanced Bash-Scripting Guide: An in-depth, classic resource covering a vast range of scripting topics, including detailed explanations of tests and comparisons.
- Raspberry Pi Documentation – GPIO: Official documentation on using GPIO on the Raspberry Pi, including information on modern
gpiod
utilities. - Bash
case
statement (GeeksforGeeks): A well-structured tutorial focusing specifically on the syntax and usage of thecase
statement. - Greg’s Wiki – BashFAQ: A highly respected wiki that clarifies many common points of confusion in Bash scripting, including the differences between
[
and[[
.