The Shell & Command-Line Mastery
Master Bash and Unix pipelines — the transparent window into the Linux kernel's process, I/O, and signal abstractions that every kernel developer lives inside.
Overview
The shell is the most powerful interface ever built for a general-purpose computer. Before graphical desktops existed, the shell was the computer's face. Even today, decades after the invention of the GUI, professional systems programmers, kernel developers, and sysadmins spend the majority of their working hours in a terminal — not because they are nostalgic, but because the shell gives them something no GUI can: composability. You can wire together ten programs you have never even heard of, in a single line, to solve a problem that nobody anticipated when those programs were written. That is extraordinary.
This module covers Bash — the Bourne-Again SHell — as the canonical interactive shell and scripting language on Linux. You will learn not just the syntax but the underlying Unix model that makes the shell feel inevitable: everything is a file, programs talk through streams of bytes, and small sharp tools compose into complex machinery. By the end you will be able to navigate the filesystem at the speed of thought, write scripts that automate real work, and read and write shell code the way experienced kernel developers do.
A crucial point before we begin: the shell is not just a convenience layer. It is the primary way you interact with the Linux kernel's abstractions — processes, file descriptors, pipes, signals, environment. Understanding the shell at a deep level means understanding the kernel interfaces underneath it. Every pipe you create is a kernel object. Every fork/exec is exposed in shell syntax. The shell is a transparent window into the OS.
We will move from the basic mechanics of how a shell works, through file manipulation, pipelines, text processing, job control, scripting, and finally arrive at the Unix philosophy — the design aesthetic that makes all of this cohere.
Linux kernel development happens almost entirely through the command line. Patches are prepared with git, reviewed via mailing list tools written in Perl and shell, tested with make and shell scripts, and submitted with git send-email. The kernel's own build system is a massive collection of Makefiles and shell scripts. When you need to search thousands of source files for a specific pattern, or extract all function names matching a convention, or pipe the output of one analysis tool into another — the shell is the instrument you reach for.
Beyond tooling, understanding the shell deeply teaches you the POSIX process model in a visceral, hands-on way. When you type ls | grep foo, you are watching fork(), pipe(), dup2(), and exec() happen in front of you. The shell makes the kernel's process and I/O abstractions tangible. Every kernel developer you will ever read about — Linus Torvalds, Greg Kroah-Hartman, Rusty Russell — is a master of the command line. Shell mastery is not optional on this path; it is foundational.
Shell Pipeline: How a Two-Stage Pipe Is Set Up by the Kernel
When you type 'ls | grep foo', the shell calls pipe(), then fork()+exec() twice — once for each program — wiring each program's stdin/stdout to the pipe's read/write ends via dup2(). Both programs run concurrently; the kernel's pipe buffer mediates data flow between them. The shell then waits for both children to exit before returning your prompt.
The concepts
01What a Shell Actually Does: fork, exec, wait
A shell is a program whose job is to read commands from you, run other programs in response to those commands, and report the results. Simple in concept; the mechanics reveal the entire Unix process model.
When you type ls -l, the shell does exactly three things:
- fork() — it calls the
forksystem call, which creates an almost-identical copy of the shell process. This copy is called the child process. At this instant, two processes exist: the parent shell and the child shell. - exec() — the child calls
execve("/bin/ls", ["ls", "-l"], envp). This replaces the child's entire memory image with thelsprogram. The child is nowls, not a shell. The PID (process ID) is unchanged. - wait() — the parent shell calls
wait()to block until the child (nowls) exits. Whenlsfinishes and exits, the kernel wakes up the parent, which then prints a new prompt and waits for your next command.
This fork-exec-wait cycle is the heartbeat of every interactive shell session. It explains several things that surprise beginners:
- Why
cdcannot be an external program: if the shellfork+exec'd acdbinary, that child would change directory and then exit, leaving the parent shell in the original directory.cdmust be a builtin that runs inside the shell process itself. - Why environment variables set in a child do not propagate back to the parent. The child is a copy; changes to copies do not flow back.
- Why
exitin a script terminates the whole script: the script runs in a child shell, andexitcalls_exit()on that child.
You can watch this in real time: open two terminals and run watch -n 0.5 'ps --ppid $$ -o pid,comm' in one. Every command you run in the other terminal will briefly appear as a child of the shell.
/* Minimal shell in ~30 lines of C — see what Bash does under the hood */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
int main(void) {
char line[256];
while (1) {
printf("sh> ");
fflush(stdout);
if (!fgets(line, sizeof line, stdin)) break;
line[strcspn(line, "\n")] = '\0'; /* strip newline */
/* tokenize (no pipes, quotes, etc. — bare minimum) */
char *argv[64]; int argc = 0;
char *tok = strtok(line, " ");
while (tok) { argv[argc++] = tok; tok = strtok(NULL, " "); }
argv[argc] = NULL;
if (argc == 0) continue;
/* built-in: cd */
if (strcmp(argv[0], "cd") == 0) {
if (chdir(argv[1] ? argv[1] : getenv("HOME")) < 0)
perror("cd");
continue;
}
pid_t pid = fork();
if (pid == 0) { /* child */
execvp(argv[0], argv); /* search PATH */
perror(argv[0]); /* only reached on error */
_exit(127);
} else { /* parent */
int status;
waitpid(pid, &status, 0);
}
}
return 0;
}
02File Descriptors, Redirection, and Pipes
Every process inherits three open file descriptors (FDs) from its parent:
- FD 0 — standard input (stdin): where the process reads input
- FD 1 — standard output (stdout): where normal output goes
- FD 2 — standard error (stderr): where error messages go
A file descriptor is just a small integer — an index into a per-process table of open files managed by the kernel. Files, pipes, sockets, devices, and terminals all look the same through this uniform interface. This uniformity is the Unix superpower.
Redirection tells the shell to replace one of those FDs before exec-ing the child. When you write ls -l > files.txt, the shell:
- Opens
files.txtfor writing, getting FD 3. - Calls
dup2(3, 1)— this makes FD 1 point to the same file as FD 3. - Closes FD 3 (now redundant).
execsls -l.
ls never knows the difference — it writes to FD 1 as always. The kernel silently routes those bytes to the file.
Redirection operators:
>— redirect stdout (truncate/create)>>— redirect stdout (append)<— redirect stdin2>— redirect stderr2>&1— redirect stderr to wherever stdout currently points&>or>file 2>&1— redirect both stdout and stderr
Pipes connect two processes in real time. ls | grep foo creates a kernel pipe object (a small in-memory ring buffer with a read end and a write end). The shell sets up ls's FD 1 to write into the pipe's write end, and grep's FD 0 to read from the pipe's read end. Both processes run concurrently — ls writes chunks and grep reads them as they arrive. The kernel wakes and sleeps each process as the pipe buffer fills and drains.
This is live IPC (inter-process communication) mediated by the kernel, happening every time you type a pipeline.
# Everything here uses redirection and pipes
# Write stdout to file; stderr still goes to terminal
ls /nonexistent > out.txt # out.txt is empty; error on terminal
# Capture stderr too
ls /nonexistent > out.txt 2>&1 # IMPORTANT: order matters!
# Wrong order — stderr goes to original stdout, not the file:
ls /nonexistent 2>&1 > out.txt
# Discard all output
some-noisy-command > /dev/null 2>&1
# Pipe: stdout of find → stdin of xargs → stdin of grep
find . -name '*.c' | xargs grep -l 'kmalloc'
# Process substitution: treat command output as a file
diff <(sort file1) <(sort file2)
# Here-document: feed multiline string as stdin
cat <<'EOF'
line one
line two
EOF
03Globbing and Filename Expansion
When you type ls *.c, the shell — not ls — expands *.c into a list of matching filenames before calling ls. This is called globbing or pathname expansion. The program never sees the glob pattern; it sees a pre-expanded list of actual filenames.
Glob patterns:
*— matches any string of characters (but not a leading., and not/)?— matches exactly one character[abc]— matches one character that is a, b, or c[a-z]— matches one character in the range[!abc]or[^abc]— matches any character NOT in the set**— (Bash 4+ withshopt -s globstar) matches any path including/, enabling recursive globbing
Critical insight: if a glob matches nothing, Bash by default leaves the literal pattern in the argument list. This is a famous footgun. rm *.tmp when there are no .tmp files might become rm '*.tmp' — trying to delete a file literally named *.tmp. Use nullglob to make unmatched globs expand to nothing, or failglob to make them an error.
Globbing is purely filesystem-based — the shell reads the directory and matches names. It is different from regular expressions, which operate on arbitrary text. grep '*.c' does NOT grep for C files — * means 'zero or more of the preceding character' in regex. Confusing these two is one of the most common beginner errors.
Brace expansion {a,b,c} is a related but distinct mechanism that does NOT touch the filesystem — it generates combinations lexically: echo {foo,bar}.{c,h} produces foo.c foo.h bar.c bar.h regardless of whether any of those files exist.
# Enable globstar for recursive matching (Bash 4+)
shopt -s globstar nullglob
# Find all .h files anywhere under current directory
for f in **/*.h; do
echo "$f"
done
# Match files like main_v1.c, main_v2.c but not main_vX.c
ls main_v[0-9].c
# Brace expansion — no filesystem access
touch file_{a,b,c}.txt # creates file_a.txt file_b.txt file_c.txt
mkdir -p src/{drivers,fs,mm,net} # create kernel-like directory tree
# Safe glob: if nothing matches, loop body doesn't run
shopt -s nullglob
for f in /tmp/stale_*.lock; do
rm -- "$f"
done
04Variables, Quoting, and Parameter Expansion
Shell variables store strings (the shell has no native numeric type beyond arithmetic expansion). Assignment has no spaces around =:
name=Linus # correct
name = Linus # WRONG — shell tries to run a command named 'name'
To use a variable, prefix with $. Always double-quote variable expansions to prevent word splitting and globbing:
file="my file.txt"
cat $file # BROKEN: shell word-splits into 'cat my file.txt' (two args)
cat "$file" # CORRECT: single argument with the space
This is the most important quoting rule: double-quote every $variable unless you specifically want word-splitting.
Parameter expansion goes far beyond simple substitution. Some invaluable forms:
${var:-default}— usedefaultifvaris unset or empty${var:?error message}— abort with message ifvaris unset${#var}— length of the string${var#prefix}— remove shortest prefix match${var##prefix}— remove longest prefix match (greedy)${var%suffix}— remove shortest suffix${var%%suffix}— remove longest suffix${var/pattern/replacement}— replace first match${var//pattern/replacement}— replace all matches
Arrays (Bash extension):
arr=(alpha beta gamma)
echo "${arr[1]}" # beta
echo "${arr[@]}" # all elements (properly quoted)
echo "${#arr[@]}" # number of elements
Arithmetic expansion: $(( expr )) evaluates integer arithmetic. $((2**10)) gives 1024.
Command substitution: $(command) runs command and inserts its stdout. The older backtick form `command` is equivalent but cannot nest cleanly — prefer $().
#!/usr/bin/env bash
set -euo pipefail # exit on error, undefined vars, pipe failures
# Parameter expansion cookbook
path="/home/karthik/kernel/drivers/usb/core.c"
base="${path##*/}" # core.c (strip longest prefix up to /)
dir="${path%/*}" # /home/karthik/kernel/drivers/usb
noext="${base%.c}" # core (strip .c suffix)
echo "file: $base"
echo "dir: $dir"
echo "stem: $noext"
# Default values
BUILD_DIR="${BUILD_DIR:-/tmp/build}"
# Mandatory variable check
: "${KERNEL_SRC:?KERNEL_SRC must be set}"
# Array iteration — always quote ${arr[@]}
modules=(ext4 btrfs xfs tmpfs)
for mod in "${modules[@]}"; do
echo "modprobe $mod"
done
# Arithmetic
total=$(( 1024 * 1024 * 4 )) # 4 MiB in bytes
echo "4 MiB = $total bytes"
05The Environment: export, PATH, and Process Inheritance
Every process has an environment — a set of key=value string pairs passed to it at birth. In C, main(int argc, char *argv[], char *envp[]) exposes this as a third argument, or you access it via getenv(). The shell lets you view and modify the environment interactively.
export marks a shell variable as part of the environment, so child processes inherit it:
FOO=bar # shell variable, not in environment
export FOO # now in environment — children see it
export BAR=baz # shorthand
Without export, a variable is local to the current shell and invisible to programs you run.
PATH is the most important environment variable. It is a colon-separated list of directories. When you type gcc, the shell searches each directory in PATH from left to right, looking for an executable named gcc. First match wins. This is why which gcc tells you which one you get, and why prepending a directory to PATH (e.g., export PATH="$HOME/bin:$PATH") lets you override system commands.
Important environment variables to know:
HOME— your home directoryUSER/LOGNAME— your usernameSHELL— path to current shellTERM— terminal type (used by ncurses programs)LANG/LC_ALL— locale settings (affect sort order, date formats)PS1— your prompt stringIFS— Internal Field Separator (default: space, tab, newline — controls word splitting)OLDPWD— previous directory (cd -uses this)
env with no arguments prints the full environment. env -i command runs a command with an empty environment — great for reproducibility testing. printenv VAR prints one variable.
A subtlety: when you ssh to a remote machine, only certain variables are forwarded (controlled by SendEnv in ~/.ssh/config). When you sudo, the environment is typically sanitized. Knowing this saves hours of debugging.
# See the full environment
env | sort
# Add ~/bin to PATH permanently (put in ~/.bashrc)
export PATH="$HOME/bin:$PATH"
# Run one command with a modified environment (doesn't affect your shell)
HTTPS_PROXY=proxy.example.com:3128 curl https://kernel.org
# env -i: see exactly what a fresh process gets
env -i PATH=/usr/bin:/bin bash --norc --noprofile -c 'echo $PATH; which ls'
# Inspect what a child actually inherits (requires /proc)
bash -c 'cat /proc/$$/environ' | tr '\0' '\n' | grep PATH
# IFS footgun: changing IFS affects word splitting everywhere
IFS=':' # now 'a:b:c' splits into three words
read -ra parts <<< "one:two:three"
echo "${parts[1]}" # two
06Job Control: Foreground, Background, Signals
The shell manages jobs — one or more processes that form a pipeline. At any time, one job is in the foreground (receiving your keyboard input) and zero or more are in the background.
Key operations:
command &— run command in background; shell prints[1] 12345(job number, PID)Ctrl-Z— suspend the foreground job (sendsSIGTSTP)bg— resume a suspended job in the backgroundfg— bring a background job to the foregroundfg %2— bring job number 2 to the foregroundjobs— list all current jobskill %1— send SIGTERM to job 1wait— wait for all background jobs to finish
Signals are asynchronous notifications sent to processes. The shell translates keyboard shortcuts into signals:
Ctrl-C→SIGINT(interrupt) — default action: terminateCtrl-Z→SIGTSTP(terminal stop) — default action: suspendCtrl-\→SIGQUIT— default action: terminate with core dumpkill -9 PID→SIGKILL— cannot be caught or ignored; kernel kills the processkill -15 PID→SIGTERM— default: terminate (can be caught for cleanup)
In scripts, trap lets you run cleanup code when a signal arrives:
trap 'rm -f /tmp/mylock; exit 1' INT TERM EXIT
Process groups and sessions: every process belongs to a process group. Sending a signal to a process group (negative PID: kill -INT -12345) signals all members. When you log out or close a terminal, SIGHUP is sent to the entire session's foreground process group. nohup command & makes the command ignore SIGHUP; disown %1 removes a job from the shell's job table (so SIGHUP is not forwarded when the shell exits).
screen/tmux solve the SIGHUP problem permanently: they maintain a persistent session that outlives your SSH connection.
#!/usr/bin/env bash
# Demonstrate job control and trap
TMPFILE=$(mktemp)
# Cleanup on exit OR interrupt
cleanup() {
echo "Cleaning up $TMPFILE"
rm -f "$TMPFILE"
}
trap cleanup EXIT INT TERM
echo "Working..." > "$TMPFILE"
# Start a background job
sleep 30 &
BG_PID=$!
echo "Background sleep PID: $BG_PID"
jobs # shows [1]+ Running sleep 30
# Wait for it (or Ctrl-C to trigger trap and cleanup)
wait "$BG_PID"
echo "Done"
07Shell Scripting: Control Flow and Functions
A shell script is simply a text file containing shell commands, with #!/usr/bin/env bash as the first line (the shebang). Make it executable with chmod +x script.sh and run it with ./script.sh.
Always start scripts with:
#!/usr/bin/env bash
set -euo pipefail
-e: exit on any command failure (nonzero exit status)-u: treat unset variables as errors-o pipefail: a pipeline fails if ANY command in it fails (not just the last)
Without these, scripts silently continue after errors. This is how real production bugs hide in shell scripts.
Exit status: every command returns a number 0-255. 0 = success, nonzero = failure. $? holds the last exit status. if, while, &&, || all work on exit status.
Conditionals: [[ ]] (Bash double-bracket) is preferred over [ ] (POSIX test) — it handles spaces in variables safely and supports regex with =~.
Loops:
for i in $(seq 1 10); do ...; done
while IFS= read -r line; do ...; done < file.txt # correct way to read a file
Functions are first-class:
my_func() {
local x="$1" # 'local' is crucial — prevents variable leakage
echo "arg: $x"
return 0 # explicit return status
}
my_func "hello"
Without local, variables set inside functions pollute the global scope — a common, painful bug.
Script arguments: $1, $2, ... are positional parameters. $0 is the script name. $# is the count of arguments. "$@" is all arguments, properly quoted (always use this, not $*). shift consumes $1 and renumbers the rest.
#!/usr/bin/env bash
set -euo pipefail
# --- utility functions ---
die() { echo "ERROR: $*" >&2; exit 1; }
log() { echo "[$(date +%T)] $*"; }
# --- argument parsing ---
usage() { echo "Usage: $0 [-v] <kernel-src-dir>"; exit 1; }
VERBOSE=0
while getopts ':v' opt; do
case $opt in
v) VERBOSE=1 ;;
*) usage ;;
esac
done
shift $(( OPTIND - 1 ))
[[ $# -ge 1 ]] || usage
KERNEL_SRC="$1"
[[ -d "$KERNEL_SRC" ]] || die "Not a directory: $KERNEL_SRC"
# --- main logic ---
count_c_files() {
local dir="$1"
local count
count=$(find "$dir" -name '*.c' | wc -l)
echo "$count"
}
log "Scanning $KERNEL_SRC"
total=$(count_c_files "$KERNEL_SRC")
log "Found $total C source files"
[[ $VERBOSE -eq 1 ]] && find "$KERNEL_SRC" -name '*.c' -print
log "Done"
08Text Processing: grep, sed, awk, find
The Unix tradition is to have programs that do one thing well and communicate through text streams. Four tools form the backbone of text processing in the shell:
grep — search for lines matching a pattern
grep pattern file— print lines matching regex-r— recursive (or userg/agfor speed)-l— print filenames only-n— show line numbers-v— invert match (lines NOT matching)-E— extended regex (alternation|,+,?)-F— fixed string, no regex (much faster)-c— count matching lines--include='*.c'— filter by filename pattern
Kernel developers use git grep inside the kernel tree — it respects .gitignore and is significantly faster.
sed — stream editor: transform text line by line
sed 's/old/new/g' file— global substitution (the workhorse)sed -n '5,10p'— print only lines 5–10sed '/pattern/d'— delete matching linessed -i 's/foo/bar/g' file— edit file in place (add backup:-i.bak)sed 's/^/ /'— indent every line by 4 spaces
awk — line-oriented pattern-action language
Awk treats each line as a record and splits it into fields $1, $2, ..., $NF (last field). NR is the current line number. Awk is a full programming language — it has variables, arrays, arithmetic, and printf.
awk '{print $2}' file— print second fieldawk -F: '{print $1}' /etc/passwd— set field separator to:awk '/pattern/ {sum += $3} END {print sum}'— sum column 3 for matching linesawk 'NR==5'— print line 5
find — search for files by attribute (name, type, size, time, permissions...)
find . -name '*.c'— by namefind . -type f -newer Makefile— files newer than Makefilefind . -name '*.o' -delete— find and deletefind . -name '*.c' -exec grep -l 'kmalloc' {} +'— find C files containing kmalloc-maxdepth 2— don't recurse too deep-perm /u+x— files with execute bit
A key -exec idiom: {} is replaced by the filename; + batches many filenames into one grep call (efficient), while \; runs one invocation per file (sometimes necessary).
# Real-world text processing tasks
# 1. Find all functions that call kmalloc in kernel source
grep -rn 'kmalloc(' --include='*.c' linux/mm/ | head -20
# 2. Extract all #include'd headers from a C file
grep '^#include' main.c | sed 's/#include //; s/[<>"]//g'
# 3. Count syscall implementations in kernel (files named sys_*.c)
find linux/ -name 'sys_*.c' | wc -l
# 4. Print the 3rd column of /proc/meminfo (the unit)
awk '{print $3}' /proc/meminfo | sort -u
# 5. Sum all anonymous mapping sizes from /proc/$$/maps
awk '$5 == 0 {sum += strtonum("0x" $2) - strtonum("0x" substr($1,1,12))}
END {printf "anon: %d MB\n", sum/1024/1024}' /proc/$$/maps
# 6. Rename all .txt files to .md (no external tool needed)
for f in *.txt; do
mv -- "$f" "${f%.txt}.md"
done
# 7. Find C files > 1000 lines (potential complexity hotspots)
find . -name '*.c' -exec awk 'END{if(NR>1000)print NR, FILENAME}' {} \;
09The Unix Philosophy
In 1978, Doug McIlroy articulated what became the Unix Philosophy in the Bell System Technical Journal:
Write programs that do one thing and do it well. Write programs to work together. Write programs to handle text streams, because that is a universal interface.
This is not just a historical footnote — it is the architectural principle that explains why the Unix toolset is still useful 50 years later, and why you can compose tools that were written before you were born to solve problems their authors never imagined.
Do one thing well: wc counts lines, words, and bytes. It does not sort, filter, or format. This constraint is a feature — it makes wc reliable, fast, and auditable. When a tool does one thing, it is easy to understand, easy to test, and easy to combine.
Work together: The universal interface is bytes on a stream. grep does not care if its input came from a file, a network socket, another program, or a keyboard. Everything that can emit bytes on stdout and read bytes from stdin can be composed. This is why you can curl URL | jq '.items[]' | grep active | wc -l — four programs from four different authors, written in different decades, working together seamlessly.
Text streams: Text is the universal interchange format in Unix. It is human-readable, debuggable, and processable by every tool. Compare this to binary formats that require special decoders just to inspect.
The practical consequences:
- A complex task is decomposed into a pipeline of simple stages
- You can insert
teeanywhere in a pipeline to inspect intermediate results without changing the flow - You can replace any stage with a better implementation without changing the others
- Each stage can be independently tested
For kernel development specifically: the kernel's development process — email-based patches, git log --oneline, checkpatch.pl, scripts/get_maintainer.pl — is built entirely on this philosophy. Understanding it is not optional.
# The Unix philosophy in action: a realistic kernel workflow
# Question: which subsystems have the most commits in the last month?
git log --since='1 month ago' --format='%s' |
grep -oP '^[a-z_/]+(?=:)' | # extract 'subsystem:' prefix
sort | # sort for uniq
uniq -c | # count occurrences
sort -rn | # sort numerically, descending
head -10 # top 10
# Each tool does ONE thing:
# git log — enumerate commits
# grep -oP — extract the subsystem label
# sort — sort lines (required by uniq)
# uniq -c — count consecutive duplicates
# sort -rn — sort numerically
# head — show top N
# Insert 'tee /tmp/debug.txt' anywhere to inspect intermediate output
# Replace 'git log' with 'cat saved-log.txt' to work offline
# The pipeline is modular, debuggable, and composable
Key insights
- ✦The shell is a thin, transparent wrapper over the kernel's process and I/O APIs — every pipe, redirect, and background job directly corresponds to a system call (pipe, dup2, fork, wait, kill).
- ✦Always double-quote variable expansions: '$var' without quotes causes word splitting and glob expansion that will silently corrupt your data in edge cases.
- ✦Start every script with 'set -euo pipefail' — the shell's default of continuing silently after errors is a trap that hides bugs in production systems.
- ✦Globbing is done by the shell, not by the program receiving the arguments. Regex patterns in grep/sed/awk are a completely different language from glob patterns.
- ✦The Unix philosophy of small composable tools connected by text streams is not nostalgia — it is a profound architectural principle that makes systems debuggable, extensible, and understandable decades after being written.
- ✦cd must be a shell builtin because a child process cannot change its parent's working directory — this single rule teaches you everything about process isolation.
- ✦SIGKILL (kill -9) cannot be caught or blocked by the target process — the kernel enforces it directly. Every other signal can be caught or ignored, which is why well-written programs always handle SIGTERM for graceful shutdown.
- ✦The 'text stream as universal interface' insight means you can always insert 'tee file.txt' into any pipeline to capture intermediate state without disrupting the flow — the closest thing to a debugger for shell pipelines.
Common pitfalls
- ⚠Forgetting to quote variables: 'rm $file' where file='my file.txt' becomes 'rm my file.txt' — two arguments — and silently does the wrong thing. Always write 'rm "$file"'.
- ⚠Wrong order for stderr+stdout redirect: 'cmd 2>&1 >file' does NOT capture both — you must write 'cmd >file 2>&1'. The redirections are processed left to right.
- ⚠Using 'for line in $(cat file)' to iterate file lines — this word-splits on whitespace, breaks on filenames with spaces, and triggers glob expansion. Use 'while IFS= read -r line; do ...; done < file' instead.
- ⚠Omitting 'local' in shell functions — variables set inside functions without 'local' modify global state and cause mysterious bugs in scripts that call multiple functions.
- ⚠Confusing glob patterns (*, ?, [...]) with regular expressions — '*' means 'any string of characters' in globs but 'zero or more of the preceding character' in regex. These are entirely different syntaxes.
- ⚠Relying on '$?' after a compound command — 'set -e' may have already exited the script before you check '$?'. Capture exit status immediately: 'command; rc=$?'.
- ⚠Assuming environment variables set in a script persist after the script exits — child processes cannot modify their parent's environment. Source the script with '. script.sh' if you need the variables in your current shell.
- ⚠Using 'ls | grep' to find files — 'ls' output is not designed for programmatic parsing (it adds colors, aligns columns, omits details). Use 'find', 'glob patterns', or 'compgen -G' instead.
Practice
- 01hard
Write a minimal shell in C (under 100 lines) that supports: running external commands, the 'cd' and 'exit' builtins, and a single pipe ('cmd1 | cmd2'). Compile with 'gcc -Wall -o mysh mysh.c' and test it by running 'ls | grep .c' inside it.
- 02medium
Write a Bash script 'check_kernel_style.sh' that accepts a directory as an argument, finds all .c and .h files recursively, and reports: (1) files longer than 1000 lines, (2) files containing tabs used for indentation (a common style issue), (3) the top 5 most-included headers across the tree. Use only grep, awk, find, sort, uniq, and head.
- 03medium
Create a script 'watch_proc.sh' that monitors a given PID (passed as $1) every 2 seconds, printing its CPU%, RSS memory (from /proc/PID/status), and open file descriptor count (from /proc/PID/fd). The script should handle the case where the process exits gracefully, printing 'Process NNN exited' and stopping.
- 04easy
Build a 'syslog-summary' pipeline (no script file, just a single pipeline) that reads /var/log/syslog (or journalctl --no-pager output), extracts the service name from each line, counts occurrences, and displays the top 10 noisiest services with their counts — all in one pipeline on the command line.
- 05easy
Implement a 'safe_rm' function in your ~/.bashrc that moves files to ~/.trash/ instead of deleting them, supports multiple arguments and glob expansion, creates ~/.trash/ if it doesn't exist, and warns (but does not fail) if a file doesn't exist. Add a companion 'trash_list' function and a 'trash_empty' function.
- 06hard
Write a script that takes a git repository path as an argument and produces a per-author breakdown: for each author, show total commits, total lines added, total lines deleted, and their most-touched file (the file they appear in most commits). Output in a neat table using printf formatting.
Resources
The canonical, complete reference for every Bash feature. Dense but authoritative — bookmark the Parameter Expansion and Special Parameters sections.
Free online, beautifully written, takes you from first login to advanced scripting. One of the best free technical books ever written.
Exhaustive reference with hundreds of worked examples covering every corner of Bash scripting.
The best resource for understanding Bash pitfalls correctly — especially quoting, word splitting, and the read-while loop. Authoritative on correctness.
The primary reference for system calls and Linux-specific interfaces. Read man 7 signal, man 2 fork, man 2 pipe, man 2 execve alongside this module.
Deep philosophical treatment of the Unix design tradition. Chapter 1 alone is worth an afternoon — it explains WHY the Unix philosophy is correct, not just what it says.
The canonical deep-dive into sed and awk — covers the full awk programming language, not just one-liners.
Run every script you write through ShellCheck. It catches quoting bugs, deprecated syntax, and subtle portability issues with detailed explanations. The fastest way to level up script quality.
Paste any shell command and get a visual breakdown of every flag and component mapped to the relevant man page text. Invaluable when reading unfamiliar one-liners.
The professional sysadmin bible. Chapters on shell scripting, process management, and logging are directly relevant to kernel development workflows.
Checkpoints — you can now…
- ✓You can explain what happens at the OS level (fork, exec, wait, pipe, dup2) when you type 'ls -l | grep foo > results.txt' and press Enter.
- ✓You can write a Bash script that safely handles filenames with spaces, checks for required arguments, uses functions with local variables, and cleans up temporary files on exit using trap.
- ✓You can use grep, sed, awk, and find in combination to answer questions about a large codebase — for example, 'which files call this function and how many times'.
- ✓You can explain the difference between a glob pattern and a regular expression, and correctly use each in the appropriate context without confusing them.
- ✓You can manage jobs: run a command in the background, suspend it, bring it back to the foreground, and kill it by job number — and explain which signals are sent at each step.
- ✓You can set, export, and inspect environment variables, explain what PATH controls, and trace why a command runs from one directory versus another.
- ✓You can identify and fix the five most common shell scripting bugs: unquoted variables, missing 'local', wrong redirect order, parsing 'ls' output, and not using 'set -euo pipefail'.
- ✓You can construct a multi-stage pipeline to answer a real analytical question (e.g., most common error type in a log file, busiest file in a git history) using only standard Unix tools.