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:
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.
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:
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:
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:
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:
#!/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:
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.
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:
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
.
nano health_check.sh
Copy the code below into the file. After saving, make the script executable:
chmod +x health_check.sh
Code Snippet
#!/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:
=========================================
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
, andget_disk_usage
. Each is responsible for one piece of information. - Error Handling: Notice the
if ! command; then ...
structure inget_cpu_temp
andget_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
, andawk
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 thanecho
, 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 itecho
ed) so it can be passed as an argument toprintf
.
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:
- Connect the anode (longer leg) of the LED to GPIO 17 (Physical Pin 11) 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., 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
.
nano toggle_led.sh
chmod +x toggle_led.sh
Code Snippet
#!/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:
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
andLED_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 thegpioset
command. It takes the chip name, the pin number, and the desired state as arguments. We wrap it in anif ! ...
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
oroff
). Acase
statement is a clean way to handle multiple, distinct string comparisons. It’s more readable than a series ofif/elif/else
statements. - Exit Status Check: After the
case
statement callsset_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.
Exercises
These exercises are designed to be completed on your Raspberry Pi 5. They build upon each other in complexity.
- 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.
- The function should be named
- Verification:
./exercise1.sh "Gandalf" 2019
should print a greeting../exercise1.sh "Frodo"
should print a usage error.
- 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
becomesmyfile.txt.2025-07-08_22-05-00
). Use thedate
command for formatting. - The function should return 0 on success and a non-zero value on any failure.
- The function,
- 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.
- 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 likeup 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]
.
- The function
- Verification: The script’s output should be a clean sentence containing the uptime information.
- 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”.
- Use the
- 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.
- Objective: Enhance the GPIO example to be interactive, using a
- 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.
- The function
- 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() { ... }
andfunction 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 substitutionvar=$(my_func)
.
- Exit Status (
- 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
- Bash Guide for Beginners: An excellent, detailed guide covering all aspects of Bash scripting, including a solid chapter on functions.
- GNU Bash Manual (Official Documentation): The definitive reference for the Bash shell. The section on Shell Functions is authoritative and comprehensive.
- Raspberry Pi Documentation – Using GPIO: The official documentation on GPIO interaction, including the modern
gpiod
tools. - Google Shell Style Guide: Provides professional best practices and conventions for writing maintainable shell scripts, widely respected in the industry.
- Advanced Bash-Scripting Guide: A more in-depth resource that explores advanced topics, including subtleties of I/O redirection and subshells.
man
pages: On your Raspberry Pi, the built-in manual pages are an invaluable resource. Tryman bash
,man gpioset
, andman free
.- Greg’s Wiki – BashFAQ: A curated list of frequently asked questions about Bash scripting, excellent for understanding tricky concepts and common errors.