Chapter 23: Shell Scripting: Functions and Basic Automation

Chapter Objectives

By the end of this chapter, you will be able to:

  • Understand the fundamental role of functions in structuring shell scripts for clarity and reusability.
  • Define and call shell functions, passing arguments and handling positional parameters effectively.
  • Implement functions that return values using both exit statuses for success/failure and standard output for data.
  • Develop practical automation scripts for common embedded tasks on the Raspberry Pi 5, such as system monitoring and hardware interaction.
  • Debug common scripting errors related to function scope, argument handling, and return value interpretation.
  • Apply best practices for creating robust and maintainable shell scripts for embedded Linux environments.

Introduction

In the world of embedded Linux, efficiency is paramount. Developers and system administrators constantly perform sequences of commands to configure hardware, manage services, deploy updates, or monitor system health. Performing these tasks manually is not only time-consuming but also prone to human error. This is where the power of shell scripting, and specifically the use of functions, becomes a critical skill. A well-crafted script can automate a complex, multi-step process, transforming it into a single, reliable command.

This chapter introduces you to the foundational building block of effective shell scripting: the function. Think of a function as a reusable “sub-program” or a named block of code that performs a specific task. By encapsulating logic within functions, you can write scripts that are dramatically easier to read, debug, and maintain. Instead of a long, monolithic sequence of commands, your script becomes a structured, modular program where the main logic calls upon specialized functions to do the heavy lifting. This approach mirrors the software engineering principles of “Don’t Repeat Yourself” (DRY) and separation of concerns, which are just as relevant in scripting as they are in application development.

We will explore how to define these functions, pass data into them as arguments, and get results back out. You will learn that the shell provides two primary mechanisms for a function to communicate its result: a simple numeric exit status to signal success or failure, and the more versatile standard output to pass back complex data. Using the Raspberry Pi 5 as our practical workbench, we will build scripts that move from theory to tangible outcomes, from checking system temperature to controlling GPIO pins. By mastering functions, you are taking a significant step from being a simple command-line user to becoming a proficient embedded Linux automator.

Technical Background

The Philosophy of Shell Functions: Creating Reusable Tools

At its core, the Linux shell is an environment of small, powerful, and specialized tools. Commands like grep, sed, awk, and ls each do one thing and do it well. The Unix philosophy encourages combining these tools through pipes and redirection to accomplish complex tasks. Shell functions are your way of extending this philosophy by creating your own custom tools within a script. When you find yourself writing the same cluster of commands multiple times, it’s a clear signal that those commands are a candidate for being wrapped in a function.

Imagine you are building a house. You have fundamental tools like a hammer, a saw, and a screwdriver. A shell function is akin to building a specialized jig or template. For instance, you might create a jig to repeatedly cut pieces of wood to the exact same length and angle. You build the jig once, and then you can reuse it hundreds of times, ensuring consistency and saving immense effort. Similarly, a function to, say, check the network connectivity of a device can be written once and then called from various points in your script whenever you need to validate the network status before proceeding. This modularity is the cornerstone of scalable and maintainable code.

A script without functions is like a novel with no chapters or paragraphs—just a single, daunting wall of text. It’s difficult to navigate, hard to understand the flow of logic, and nearly impossible to modify without introducing unintended side effects. Functions introduce structure and hierarchy. The main body of your script can provide a high-level overview of the task, delegating the detailed implementation to a series of well-named functions. Someone reading your script can immediately grasp its purpose by reading the main logic (e.g., initialize_hardware, run_diagnostics, log_results), without needing to understand the intricate details of each step right away.

Defining and Calling Functions: Syntax and Structure

In the Bourne Again Shell (Bash), which is the default shell on Raspberry Pi OS and most Linux distributions, there are two common syntaxes for defining a function. The first, and more traditional, form uses the function name followed by parentheses:

Bash
function_name () {
    # Commands to be executed go here
    echo "This is a function."
}

The second, more modern syntax, uses the function keyword. This is often preferred for readability as it explicitly declares the identifier as a function, which can be helpful in complex scripts.

Bash
function function_name {
    # Commands to be executed go here
    echo "This is also a function."
}

Both forms are functionally identical in Bash. The choice between them is largely a matter of style and convention. For consistency and clarity, we will often use the first form in our examples. The curly braces {} are crucial; they define the beginning and end of the function’s code block. It is a common convention to place the opening brace on the same line as the function name and the closing brace on a new line by itself.

Once a function is defined, it doesn’t execute immediately. The shell simply reads the definition and stores it in memory. To execute the code inside the function, you must call it by its name, just as you would any other command:

Bash
function_name

When the shell encounters this line, it looks for a command or function with that name, finds the definition it stored earlier, and executes the commands within its body. The script’s execution then jumps into the function, runs its commands sequentially, and upon reaching the closing brace, returns to the line immediately following the function call.

flowchart TD
    subgraph Main Script Execution
        direction LR
        A[Start Script] --> B(Command 1);
        B --> C(Command 2);
        C --> D{Call 'my_function'};
        D --> E(Command 4);
        E --> F[End Script];
    end

    subgraph Function: my_function
        direction TB
        G[Function Start] --> H(Function Command A);
        H --> I(Function Command B);
        I --> J[Return to Caller];
    end

    %% Links
    D -- "Execution Jumps" --> G;
    J -- "Execution Returns" --> E;

    %% Styling
    classDef startNode fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff;
    classDef endNode fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff;
    classDef processNode fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff;
    classDef decisionNode fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff;

    class A,F,G,J startNode;
    class B,C,E,H,I processNode;
    class D decisionNode;

Passing Arguments to Functions: Positional Parameters

Functions would be of limited use if they could only perform a fixed task. Their true power is unlocked when they can operate on data that you provide at the time of the call. This is accomplished by passing arguments to the function. When you call a function, any words you place after its name are passed as arguments.

Inside the function, these arguments are accessible through special variables called positional parameters. These are named $1, $2, $3, and so on, corresponding to the first, second, third, and subsequent arguments passed to the function.

Consider a function designed to greet a user by name:

Bash
greet_user () {
    local user_name="$1"
    echo "Hello, ${user_name}! Welcome to the system."
}

# Now, let's call the function with different arguments
greet_user "Alice"
greet_user "Bob"

When greet_user "Alice" is executed, within the function’s context, $1 holds the string “Alice”. When greet_user "Bob" is called, $1 holds “Bob”. This allows the greet_user function to be a generic tool, adaptable to the specific data it’s given.

Several other special variables are useful when working with arguments:

  • $#: This variable expands to the total number of arguments passed to the function. It’s invaluable for checking if the user has provided the correct number of inputs.
  • $@: This expands to a list of all the arguments, with each argument treated as a separate, quoted string. This is the most common and safest way to pass all of a function’s arguments to another command within the function.
  • $*: This also expands to all arguments, but it treats them as a single string. The distinction is subtle but important when arguments contain spaces. In most cases, $@ is the preferred choice.

Here is a more robust example demonstrating their use:

Bash
print_file_info () {
    if [ "$#" -ne 1 ]; then
        echo "Error: Exactly one argument (a file path) is required."
        return 1 # We'll discuss this 'return' statement next
    fi

    local file_path="$1"
    if [ ! -f "$file_path" ]; then
        echo "Error: File '${file_path}' not found."
        return 1
    fi

    echo "Information for '${file_path}':"
    ls -lh "$file_path"
}

This function first checks if the number of arguments ($#) is not equal to one. If it’s not, it prints an error and exits. This is a crucial validation step to prevent errors later on.

The Concept of Scope: local vs. Global Variables

A critical concept in any programming or scripting language is variable scope, which defines where a variable can be accessed. By default, any variable you declare in a shell script is global. This means it is accessible anywhere in the script, both inside and outside of functions.

While this might seem convenient, it can be a significant source of bugs in larger scripts. A function might accidentally modify a global variable that another part of the script depends on, leading to unexpected behavior that is difficult to trace.

To prevent this, you should always declare variables that are only needed inside a function as local. This is done using the local keyword. A local variable is visible only within the function where it is declared.

Let’s illustrate with an example:

Bash
#!/bin/bash

# A global variable
counter=10

update_counter () {
    # A local variable with the same name
    local counter=0
    echo "Counter inside function starts at: $counter"
    counter=$((counter + 1))
    echo "Counter inside function is now: $counter"
}

echo "Counter before function call: $counter"
update_counter
echo "Counter after function call: $counter"

The output of this script would be:

Bash
Counter before function call: 10
Counter inside function starts at: 0
Counter inside function is now: 1
Counter after function call: 10

Notice how the global counter variable remained unchanged. The local counter inside the function created a new, separate variable that only existed for the duration of the function call. Without the local keyword, the function would have modified the global variable, which may not have been the intended behavior.

Tip: As a rule of thumb, always use the local keyword for variables inside your functions unless you have a specific, well-justified reason to modify a global variable. This practice, known as minimizing global state, makes your scripts more predictable and robust.

Returning Values from Functions: Exit Status vs. Standard Output

Once a function has completed its task, we often need a way to know the outcome. Did it succeed? Did it fail? If it was supposed to compute a value, what was that value? Shell functions provide two distinct mechanisms for this communication: the exit status and standard output. Understanding the difference and when to use each is key to writing effective scripts.

1. The Exit Status: Signaling Success or Failure

Every command that runs in Linux finishes with an exit status (also called a return code), which is an integer between 0 and 255. By convention, an exit status of 0 means the command completed successfully. Any non-zero value (1-255) indicates some kind of failure.

Functions behave just like commands in this regard. You can explicitly set the exit status of your function using the return command.

Bash
check_root_user () {
    if [ "$(id -u)" -eq 0 ]; then
        echo "Running as root user. Proceeding."
        return 0 # Success
    else
        echo "Error: This script must be run as root."
        return 1 # Failure
    fi
}

# Call the function and check its exit status
check_root_user

if [ "$?" -eq 0 ]; then
    echo "Root check passed."
else
    echo "Root check failed. Aborting."
    exit 1
fi

In this example, the check_root_user function returns 0 if the user ID is 0 (the root user) and 1 otherwise. After calling the function, we immediately check the special variable $?. This variable always holds the exit status of the most recently executed command (or function). This pattern of calling a function and then immediately checking $? is extremely common for handling operations that can succeed or fail.

It’s important to note that the return command is used to set the exit status. It does not return data in the way a return statement in Python or C does. The value given to return must be a number between 0 and 255.

2. Standard Output: Returning Data

So, if return is only for exit codes, how does a function send back data, like a calculated number, a line of text, or a system measurement? The answer lies in using standard output. The function simply prints (echo) the data it wants to “return.” The calling part of the script then uses command substitution to capture that output into a variable.

Command substitution is performed using the $(...) syntax. The shell executes the command inside the parentheses and replaces the entire $(...) expression with the command’s standard output.

Here’s a function that reads the Raspberry Pi’s CPU temperature and “returns” the value:

Bash
get_cpu_temp () {
    # The 'vcgencmd' is a RPi-specific tool. We read the temp,
    # use 'cut' to extract the value, and 'sed' to remove 'C'.
    local temp=$(vcgencmd measure_temp | cut -d '=' -f 2 | sed "s/'C//")
    echo "$temp"
}

# Capture the output of the function into a variable
current_temp=$(get_cpu_temp)

echo "The current CPU temperature is ${current_temp}°C."

In this case, the get_cpu_temp function doesn’t use the return command at all (so its exit status will be 0 by default, indicating success). Its sole purpose is to echo a value. The line current_temp=$(get_cpu_temp) executes the function, captures whatever it prints to standard output, and assigns it to the current_temp variable.

This is the standard and most powerful way to return arbitrary data from a function. You can return single values, multi-line strings, or even complex data formats like JSON.

graph TD


    subgraph "Method 2: Standard Output (for Data)"
        K[<b>Function:</b><br>get_temp] --> L(Read sensor value);
        L --> M(Format value);
        M --> N(<i>echo 42.5</i><br>Print data to stdout);
        N --> O((End Func));

        P[<b>Caller Code</b>] --> Q("<i>temp=$(get_temp)</i>");
        Q --> R{Use '$temp' variable};
        R --> S(echo Temperature is $temp);
    end
        subgraph "Method 1: Exit Status (for Success/Failure)"
        A[<b>Function:</b><br>check_file] --> B{File exists?};
        B -- Yes --> C(<i>return 0</i><br>Success);
        B -- No --> D(<i>return 1</i><br>Failure);
        C --> E((End Func));
        D --> E;

        F[<b>Caller Code</b>] --> G("check_file /path/to/file");
        G --> H{Check '$?' variable};
        H -- "$? is 0" --> I(Proceed with logic);
        H -- "$? is 1" --> J(Handle error and exit);
    end
    
    %% Styling
    style A fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff
    style F fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff
    style K fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff
    style P fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff

    style C fill:#10b981,stroke:#10b981,stroke-width:1px,color:#ffffff
    style I fill:#10b981,stroke:#10b981,stroke-width:1px,color:#ffffff
    
    style B fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff
    style H fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff
    style R fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff

    style L fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style M fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style N fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style G fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style Q fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style S fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff

    style D fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff
    style J fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff

Combining these two methods allows for very robust error handling. A function can return data via standard output on success, and return a non-zero exit status on failure, allowing the caller to check $? first before attempting to use the (potentially empty) captured output.

Practical Examples

This section translates the theory into hands-on scripts you can run on your Raspberry Pi 5. These examples are designed to be practical tools for common embedded system tasks.

Example 1: System Health Dashboard

A common requirement for an embedded device is to have a quick way to check its vital signs. This script combines several checks into functions and presents a clean report.

Build and Configuration Steps

No special build is required. This is a pure shell script. Simply open a text editor (like nano or vim) on your Raspberry Pi and create a file named health_check.sh.

Bash
nano health_check.sh

Copy the code below into the file. After saving, make the script executable:

Bash
chmod +x health_check.sh

Code Snippet

Bash
#!/bin/bash
#
# health_check.sh - A simple system health dashboard for Raspberry Pi.
#

# --- Function Definitions ---

# Function to get and format the CPU temperature.
# Returns the temperature value via standard output.
get_cpu_temp() {
    local temp_str
    if ! temp_str=$(vcgencmd measure_temp 2>/dev/null); then
        echo "N/A"
        return 1
    fi
    # Extracts the numeric part, e.g., from "temp=52.5'C"
    echo "$temp_str" | cut -d '=' -f 2 | cut -d "'" -f 1
}

# Function to get the current CPU frequency.
# Returns the frequency in MHz via standard output.
get_cpu_freq() {
    local freq_hz
    if ! freq_hz=$(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq 2>/dev/null); then
        echo "N/A"
        return 1
    fi
    # Convert from KHz to MHz
    echo "$((freq_hz / 1000))"
}

# Function to get memory usage information.
# Returns a formatted string "Used / Total MB" via standard output.
get_mem_usage() {
    # 'free -m' gives output in Megabytes.
    # We use 'awk' to grab the relevant numbers from the 'Mem:' line.
    free -m | awk '/^Mem:/ {printf "%s / %s MB", $3, $2}'
}

# Function to get the root filesystem disk usage.
# Returns a formatted string "Used / Size (Percentage)" via standard output.
get_disk_usage() {
    # 'df -h /' gets usage for the root filesystem in human-readable format.
    # 'awk' on the second line prints the relevant columns.
    df -h / | awk 'NR==2 {printf "%s / %s (%s)", $3, $2, $5}'
}

# --- Main Script Logic ---

echo "========================================="
echo "  Raspberry Pi 5 System Health Report"
echo "========================================="
echo

# Call each function, capture its output, and print it.
# The `printf` command is used for neat, aligned formatting.
printf "%-20s: %s °C\n" "CPU Temperature" "$(get_cpu_temp)"
printf "%-20s: %s MHz\n" "CPU Frequency" "$(get_cpu_freq)"
printf "%-20s: %s\n" "Memory Usage" "$(get_mem_usage)"
printf "%-20s: %s\n" "Disk Usage (root)" "$(get_disk_usage)"
echo
echo "========================================="

Expected Output

When you run the script (./health_check.sh), the output will look similar to this, with values reflecting your system’s current state:

Plaintext
=========================================
  Raspberry Pi 5 System Health Report
=========================================

CPU Temperature     : 48.5 °C
CPU Frequency       : 1500 MHz
Memory Usage        : 450 / 4085 MB
Disk Usage (root)   : 12G / 29G (43%)

=========================================

Code Explanation

flowchart TD
    A[Start: ./health_check.sh] --> B{Main Script Logic};

    subgraph "Function Calls & Data Capture"
        B --> C[Call get_cpu_temp];
        C --> D["temp=$(...)<br>Capture output"];
        D --> E[Call get_cpu_freq];
        E --> F["freq=$(...)<br>Capture output"];
        F --> G[Call get_mem_usage];
        G --> H["mem=$(...)<br>Capture output"];
        H --> I[Call get_disk_usage];
        I --> J["disk=$(...)<br>Capture output"];
    end

    J --> K{Format and Print Report};
    
    subgraph "Formatted Output"
        K --> L["printf '...Temp...' $temp"];
        L --> M["printf '...Freq...' $freq"];
        M --> N["printf '...Memory...' $mem"];
        N --> O["printf '...Disk...' $disk"];
    end

    O --> P[End: Display Report];

    %% Sub-details for one function
    subgraph "Inside get_cpu_temp()"
        C -- "Executes" --> C1(vcgencmd measure_temp);
        C1 --> C2(cut -d '=' -f 2);
        C2 --> C3(echo value);
    end
    
    %% Styling
    classDef startNode fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff;
    classDef endNode fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff;
    classDef processNode fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff;
    classDef decisionNode fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff;
    classDef systemNode fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff;

    class A,P startNode;
    class B,K decisionNode;
    class C,D,E,F,G,H,I,J,L,M,N,O processNode;
    class C1,C2,C3 systemNode;
  • Shebang (#!/bin/bash): This first line ensures the script is executed by the Bash interpreter.
  • Functions: The script is organized into four distinct functions: get_cpu_temp, get_cpu_freq, get_mem_usage, and get_disk_usage. Each is responsible for one piece of information.
  • Error Handling: Notice the if ! command; then ... structure in get_cpu_temp and get_cpu_freq. This checks if the command fails (e.g., vcgencmd isn’t installed). If it does, it echoes “N/A” and returns a failure status. 2>/dev/null is used to suppress any error messages from the command itself, keeping our output clean.
  • Data Extraction: The script uses standard Linux tools like cut, sed, and awk to parse the output of system commands and extract only the data we need. This is a very common pattern in shell scripting.
  • Main Logic: The main part of the script is simple and readable. It’s just a series of printf statements. printf provides more control over formatting than echo, and %-20s creates a left-aligned string padded to 20 characters, resulting in a tidy, table-like output.
  • Command Substitution: Each function is called within $(...). This captures the function’s standard output (the value it echoed) so it can be passed as an argument to printf.

Example 2: GPIO Control Function

This example demonstrates how to create functions to interact with the Raspberry Pi’s hardware, specifically the GPIO pins. We will create a script that can turn an LED on and off.

Hardware Integration

  • Components: You will need one LED and one 330Ω resistor.
  • Wiring:
    1. Connect the anode (longer leg) of the LED to GPIO 17 (Physical Pin 11) on the Raspberry Pi 5.
    2. Connect the cathode (shorter leg) of the LED to one end of the 330Ω resistor.
    3. Connect the other end of the resistor to a Ground (GND) pin (e.g., Physical Pin 9).

Warning: Always connect an appropriate current-limiting resistor in series with an LED to avoid damaging both the LED and the Raspberry Pi’s GPIO pin.

Build and Configuration Steps

Modern Raspberry Pi OS uses the gpiod library for GPIO control, which is safer and more robust than the older sysfs method. We will use the command-line tools gpioset and gpioinfo. These should be installed by default.

Create a new script file named toggle_led.sh.

Bash
nano toggle_led.sh
chmod +x toggle_led.sh

Code Snippet

Bash
#!/bin/bash
#
# toggle_led.sh - Controls an LED on a specific GPIO pin.
# Usage: ./toggle_led.sh <on|off>

# --- Configuration ---
# GPIO 17 is on the main GPIO chip, which is 'gpiochip4' on RPi 5.
# You can verify this with `gpioinfo`. The line number is 17.
GPIO_CHIP="gpiochip4"
LED_PIN=17

# --- Function Definitions ---

# Function to set the state of the LED.
# Argument 1: The desired state (0 for OFF, 1 for ON).
set_led_state() {
    local state="$1"

    # Validate the input state
    if [[ "$state" -ne 0 && "$state" -ne 1 ]]; then
        echo "Error: Invalid state '$state'. Must be 0 or 1." >&2
        return 1
    fi

    # gpioset <chip> <pin>=<state>
    if ! gpioset "$GPIO_CHIP" "$LED_PIN"="$state"; then
        echo "Error: Failed to set GPIO pin state." >&2
        echo "Check permissions and if the GPIO chip/pin is correct." >&2
        return 1
    fi

    return 0 # Success
}

# --- Main Script Logic ---

# Check for the correct number of arguments
if [ "$#" -ne 1 ]; then
    echo "Usage: $0 <on|off>"
    exit 1
fi

main_arg=$(echo "$1" | tr '[:upper:]' '[:lower:]') # Convert arg to lowercase

case "$main_arg" in
    on)
        echo "Turning LED ON..."
        set_led_state 1
        ;;
    off)
        echo "Turning LED OFF..."
        set_led_state 0
        ;;
    *)
        echo "Error: Invalid argument. Use 'on' or 'off'."
        exit 1
        ;;
esac

# Check the exit status of the function call
if [ "$?" -eq 0 ]; then
    echo "Done."
else
    echo "Operation failed."
fi

Build, Flash, and Boot Procedures

This is a script, so no flashing is needed. You run it directly from the command line.

  • To turn the LED on: ./toggle_led.sh on
  • To turn the LED off: ./toggle_led.sh off

Expected Output

If you run ./toggle_led.sh on, you will see:

ShellScript
Turning LED ON...
Done.

And the LED connected to GPIO 17 will light up. Running ./toggle_led.sh off will turn it off.

Code Explanation

  • Configuration Variables: We define GPIO_CHIP and LED_PIN at the top. This is good practice, making the script easy to adapt for a different pin or device.
  • set_led_state Function: This function encapsulates the logic for controlling the GPIO pin. It takes one argument: the desired state (0 or 1).
  • Input Validation: The function first checks if the provided state is valid. This prevents errors from invalid calls. Note the use of >&2 to redirect error messages to standard error, which is the correct stream for diagnostic output.
  • gpioset Command: The core of the function is the gpioset command. It takes the chip name, the pin number, and the desired state as arguments. We wrap it in an if ! ... block to catch any errors during execution (e.g., permission denied).
  • Main Logic and case Statement: The main part of the script checks the user’s command-line argument (on or off). A case statement is a clean way to handle multiple, distinct string comparisons. It’s more readable than a series of if/elif/else statements.
  • Exit Status Check: After the case statement calls set_led_state, the script checks $? to see if the function succeeded. It then prints a final “Done” or “Operation failed” message, providing clear feedback to the user.

Common Mistakes & Troubleshooting

Even with simple functions, several common pitfalls can trip up new scripters. Being aware of them can save hours of debugging.

Mistake / Issue Symptom(s) Troubleshooting / Solution
Forgetting local A function unexpectedly changes a variable’s value outside the function. Calling a function causes strange side effects in other parts of the script. Always declare variables inside functions with the local keyword. This isolates the variable’s scope to the function, preventing conflicts with global variables.
Confusing return and echo Trying to get a string or float from a function results in an error or an incorrect integer value (0-255). Script reports “numeric argument required”. Use return ONLY for exit statuses (0 for success, 1-255 for failure). To send data back, use echo in the function and capture it with var=$(my_function).
Incorrect Argument Quoting A filename with spaces like “My Test File.txt” is treated as three separate arguments by your function, causing “file not found” or other errors. Always enclose variables in double quotes when passing them as arguments: my_func “$filename”. Inside the function, use “$@” to refer to all arguments correctly.
Checking $? Too Late An error check if [ “$?” -ne 0 ] always seems to pass, even when the function fails. The $? variable holds the exit status of the *most recent* command. Check it on the very next line after your function call, or save it immediately: my_func; local status=$?.
Unexpected Subshells A variable that is changed inside a while loop after a pipe (|) resets to its original value after the loop finishes. Pipelines create subshells. The while loop runs in a separate context. To avoid this, use process substitution: while …; do …; done < <(my_func).

Exercises

These exercises are designed to be completed on your Raspberry Pi 5. They build upon each other in complexity.

  1. Greeting Function with Validation:
    • Objective: Write a script with a function that takes a name and an age as arguments and prints a greeting.
    • Requirements:
      • The function should be named generate_greeting.
      • It must check that exactly two arguments are provided. If not, it should print a usage error and return an exit status of 1.
      • The script’s main logic should call the function and print the returned greeting. If the function fails, it should print an error message.
    • Verification:
      • ./exercise1.sh "Gandalf" 2019 should print a greeting.
      • ./exercise1.sh "Frodo" should print a usage error.
  2. File Backup Function:
    • Objective: Create a script with a function that backs up a given file to a specific directory.
    • Requirements:
      • The function, backup_file, should take one argument: the path to a file.
      • It should check if the source file exists and is a regular file.
      • It should create a backup directory ~/backups if it doesn’t already exist.
      • It should copy the file into ~/backups, appending the current date and time to the filename (e.g., myfile.txt becomes myfile.txt.2025-07-08_22-05-00). Use the date command for formatting.
      • The function should return 0 on success and a non-zero value on any failure.
    • Verification: Create a test file (echo "test" > mytest.txt) and run your script on it. Check that a correctly named backup file appears in the ~/backups directory.
  3. System Uptime Parser:
    • Objective: Write a function that returns the system uptime in a human-readable format like “X days, Y hours, Z minutes”.
    • Requirements:
      • The function get_formatted_uptime should take no arguments.
      • It should use the uptime -p command (which outputs something like up 2 hours, 15 minutes).
      • It must parse this string to remove the leading “up “.
      • The function should “return” the formatted string via standard output.
      • The main script should call this function and print: System has been active for: [formatted uptime].
    • Verification: The script’s output should be a clean sentence containing the uptime information.
  4. Interactive GPIO Control Script:
    • Objective: Enhance the GPIO example to be interactive, using a select loop.
    • Requirements:
      • Use the set_led_state function from the chapter example.
      • The main logic should use a select loop to present the user with three options: “Turn LED On”, “Turn LED Off”, and “Quit”.
      • The script should loop, showing the menu again after each action, until the user selects “Quit”.
    • Verification: Running the script should display a numbered menu. Selecting an option should perform the correct action on the LED and then re-display the menu.
  5. Recursive Directory Size Calculator:
    • Objective: Write a script with a recursive function to calculate the total size of all files in a directory and its subdirectories.
    • Requirements:
      • The function calculate_dir_size should take a directory path as an argument.
      • It should loop through all items in the directory. If an item is a file, it adds its size to a running total. If it’s a directory, it calls itself (recursion) with the new directory path and adds the result to the total.
      • Use stat -c%s "$file" to get the size of a file in bytes.
      • The function should echo the final total size in bytes.
    • Verification: Run the script on a directory with a few files and subdirectories. Compare its output with the output of du -sb <directory>. They should be very close. (This is a challenging exercise that tests your understanding of recursion, scope, and arithmetic in shell scripts).

Summary

This chapter provided a comprehensive introduction to using functions as a core organizing principle in shell scripting for embedded Linux systems.

  • Structure and Reusability: Functions allow you to break down complex scripts into logical, reusable, and maintainable blocks of code, following the “Don’t Repeat Yourself” (DRY) principle.
  • Syntax: We covered the two common syntaxes for defining functions in Bash: my_func() { ... } and function my_func { ... }.
  • Argument Passing: Data is passed into functions as arguments, which are accessed inside the function using positional parameters ($1, $2, etc.), $# (argument count), and $@ (all arguments).
  • Variable Scope: The local keyword is essential for creating variables that exist only within a function, preventing unintended modification of global variables and making scripts more robust.
  • Returning Values: Functions communicate results back to the caller in two ways:
    • Exit Status (return): An integer from 0-255 used to signal success (0) or failure (non-zero). Checked with the $? variable.
    • Standard Output (echo): Used to send back any kind of data (strings, numbers), which is captured by the caller using command substitution var=$(my_func).
  • Practical Application: We built real-world scripts for the Raspberry Pi 5, demonstrating how to create a system health dashboard and control hardware GPIO pins, complete with error checking and best practices.

Mastering these concepts transforms scripting from a simple sequence of commands into a form of structured programming, enabling you to build powerful and reliable automation tools for any embedded Linux project.

Further Reading

  1. Bash Guide for Beginners: An excellent, detailed guide covering all aspects of Bash scripting, including a solid chapter on functions.
  2. GNU Bash Manual (Official Documentation): The definitive reference for the Bash shell. The section on Shell Functions is authoritative and comprehensive.
  3. Raspberry Pi Documentation – Using GPIO: The official documentation on GPIO interaction, including the modern gpiod tools.
  4. Google Shell Style Guide: Provides professional best practices and conventions for writing maintainable shell scripts, widely respected in the industry.
  5. Advanced Bash-Scripting Guide: A more in-depth resource that explores advanced topics, including subtleties of I/O redirection and subshells.
  6. man pages: On your Raspberry Pi, the built-in manual pages are an invaluable resource. Try man bash, man gpioset, and man free.
  7. Greg’s Wiki – BashFAQ: A curated list of frequently asked questions about Bash scripting, excellent for understanding tricky concepts and common errors.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top