Linux & Bash · Part I · Module 15 · Writing and Running Bash Scripts
From Command Line to Script
Fourteen modules of typing commands, and the step to writing them in a file turns out to involve no new language at all. What it does involve is a permission bit, a path that must be spelled a particular way, and the fact that from now on somebody other than you will run what you wrote.
Foundation16 min readLinuxPart IFundamentalsScripting
Part I · Module 15 · Chapter 15.1 · From Command Line to Script
Everything so far has been typed at a prompt. This module puts it in a file.
1. A Script Is a File of Commands
#!/bin/bash
cd /home/engineer/project/build
make clean
make all
echo "build finished"That is a complete script, and every line is something you could have typed. There is no separate scripting language — the thing you have been using interactively for fourteen modules is the language.
$ chmod +x build.sh
$ ./build.shTwo steps: make it executable, then run it.
2. Making It Runnable
$ printf 'echo "ran"\n' > s.sh
$ ./s.sh
bash: ./s.sh: Permission deniedA file is not executable because it contains commands. It needs the execute bit from Chapter 5.5:
$ chmod +x s.sh
$ ./s.sh
ranThe exit status of that failure is worth recognising:
$ ./noexec.sh 2>/dev/null; echo $?
126126 means "found it, could not run it" — Chapter 11.1's table, and almost always a missing execute bit.
3. Three Ways to Run It
$ ./s.sh # as a program, needs +x
$ bash s.sh # hand the file to an interpreter — no +x needed
$ /tmp/m15v/s.sh # by absolute path, needs +xbash s.sh works without the execute bit, because the file is being read by bash rather than executed by the kernel. The permission that matters there is read, not execute.
That is occasionally useful — running somebody's script without modifying it — and it is not how a script should be used in anger, because it bypasses the shebang (Chapter 15.2) and runs the file in whatever interpreter you named.
Why the ./
$ s.sh
bash: s.sh: command not foundThe file is right there. The shell will not run it.
. is not on PATH, deliberately — Chapter 10.2 showed why: an empty or dot entry means anyone who can write a file in a directory you happen to be standing in can choose what your next command does.
So the shell refuses to look in the current directory, and ./s.sh is you saying explicitly that you mean this one. The ./ is not noise; it is the whole security property.
4. The Shebang, Briefly
#!/bin/bashThe first line names the interpreter. Without it the shell makes a guess, and the guess is not always the one you want:
$ ./noshebang.sh
interpreter: shThat script ran under sh, not bash — so any Bash-specific feature in it would have failed, on a machine where sh is a different shell.
Put #!/bin/bash on the first line of every script. Chapter 15.2 is the full story, including why #!/usr/bin/env bash is often better and what happens when the interpreter is missing.
5. Writing One That Is Worth Keeping
The three-line version:
#!/bin/bash
cd build
makeThe version that will still work next month:
#!/bin/bash
# Build the simulator and report whether it worked.
set -euo pipefail
cd "$(dirname "$0")/build" || { echo "no build directory" >&2; exit 2; }
make clean
make all
echo "build finished"Five additions, each from an earlier module:
- A comment saying what it is for — Chapter 15.5.
set -euo pipefail— Chapter 10.6, so a failing step stops the script instead of the next one running on nothing.- A guarded
cd— Chapter 11.3, becausecd build; makebuilds in the wrong directory when thecdfails. - A path relative to the script, not to where it was run from — Chapter 8.4, and the subject of Chapter 15.3.
- A deliberate message at the end rather than silence.
None of that is new. It is the first chapter where it all has to be used at once.
6. The Script Is a Different Process
$ echo "parent PID=$$ cwd=$(pwd)"
parent PID=72879 cwd=/tmp/m15v
$ ./inherit.sh
PID=72930 PPID=72879
cwd=/tmp/m15v
changed to /tmp
$ pwd
/tmp/m15vDifferent PID, same starting directory, and the cd did not escape.
Chapter 7.1 proved this before scripts were the subject; here it is with the subject in place. A script runs as a child process, so:
- it inherits the environment and working directory;
- it may change them freely;
- nothing it changed comes back.
That is Chapter 15.3, and it is the single most common source of "but it worked when I typed it".
7. From a Command Line You Already Ran
The most reliable way to write a first script is not to write it.
$ history | tail -20The commands are already there. You worked out the pipeline interactively — Chapter 13.9 — and the script is that sequence with the exploration removed.
# worked out interactively:
cd build
make clean
make all
cp simv ../bin/
# saved as a script:
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/build"
make clean
make all
cp simv ../bin/Then improve it in place: add the comment, guard the cd, give it an exit status. A script that started as a command line you actually ran is already known to work, which is a considerably better starting point than one written from imagination.
8. A First Real Script
#!/bin/bash
#
# run_sim.sh — compile and run one simulation, logging everything.
#
# usage: run_sim.sh <test-name>
#
# exit: 0 the test passed
# 1 the test failed
# 2 bad usage
# 3 the simulator is not on PATH
set -uo pipefail
test_name="${1:?usage: $0 <test-name>}"
command -v vcs >/dev/null 2>&1 || { echo "vcs not on PATH" >&2; exit 3; }
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
logdir="$script_dir/runs/$test_name"
mkdir -p "$logdir" || { echo "cannot create $logdir" >&2; exit 2; }
echo "running $test_name, logging to $logdir"
vcs -full64 -sverilog -f "$script_dir/filelist.f" -o "$logdir/simv" \
> "$logdir/compile.log" 2>&1 \
&& "$logdir/simv" +UVM_TESTNAME="$test_name" \
> "$logdir/sim.log" 2>&1
status=${PIPESTATUS[0]}
if grep -q 'TEST PASSED' "$logdir/sim.log" 2>/dev/null; then
echo "PASS"
exit 0
else
echo "FAIL — see $logdir/sim.log" >&2
exit 1
fiThirty lines, and every construct in it has already been taught:
| Line | From |
|---|---|
#!/bin/bash | 15.2 |
set -uo pipefail | 10.6 |
${1:?usage: …} | 8.2 |
command -v … || { …; exit 3; } | 10.5, 11.4 |
$(cd "$(dirname …)" && pwd) | 8.4 |
> file 2>&1 | 12.3 |
&& chaining | 11.3 |
${PIPESTATUS[0]} | 12.8 |
grep -q | 14.1 |
| Documented exit codes | 11.1, 15.6 |
The only genuinely new thing is if, which is Module 16.
Practice
mkdir -p /tmp/first && cd /tmp/first1 — Write the smallest possible script.
printf 'echo "ran"\n' > s.sh
./s.sh
chmod +x s.sh
./s.sh2 — Read the failure status.
printf 'echo hi\n' > noexec.sh
./noexec.sh 2>/dev/null; echo "status: $?"3 — Run it three ways.
./s.sh
bash s.sh
/tmp/first/s.sh
s.shThe last one fails. Say why before reading §3 again.
4 — Watch $0 change.
printf 'echo "\$0 is [$0]"\n' > zero.sh; chmod +x zero.sh
./zero.sh
bash zero.sh
/tmp/first/zero.sh5 — Find out what runs a script with no shebang.
printf 'echo "interpreter: $(ps -o comm= -p $$)"\n' > a.sh; chmod +x a.sh; ./a.sh
printf '#!/bin/bash\necho "interpreter: $(ps -o comm= -p $$)"\n' > b.sh; chmod +x b.sh; ./b.sh6 — Prove the script is a separate process.
cat > p.sh <<'EOF'
#!/bin/bash
echo " script PID=$$ PPID=$PPID cwd=$(pwd)"
cd /tmp
echo " script moved to $(pwd)"
EOF
chmod +x p.sh
echo "shell PID=$$ cwd=$(pwd)"
./p.sh
echo "shell cwd after: $(pwd)"7 — Turn a command line into a script. Pick three commands from your own history, put them in a file with #!/bin/bash and set -euo pipefail, make it executable and run it.
8 — Put one on PATH.
mkdir -p ~/bin
cp s.sh ~/bin/mytest
chmod +x ~/bin/mytest
PATH="$PATH:$HOME/bin" mytest9 — Clean up. cd /tmp && rm -rf /tmp/first ~/bin/mytest
Check Your Understanding
1. What new language does a script use?
2. ./build.sh gives "Permission denied" and exit status 126. What is wrong?
3. Why does bash s.sh work on a file with no execute bit?
4. The script is in the current directory and s.sh says "command not found". Why is that deliberate?
5. A script does export TOOL_HOME=/opt/tool and the variable is unset afterwards. Is that a bug?
6. Why is a command line you already ran a better starting point than writing a script from scratch?
9. Remember
- A script is a file of commands in the language you already use. No new syntax.
- What is new is the file, the interface and the contract — how it is run, what it receives, what it returns.
- It needs the execute bit. Status 126 means found-but-not-runnable; 127 means not found.
bash script.shworks without+xbecause the file is read rather than executed — and it bypasses the shebang../is required because.is not onPATH, and that is a security property rather than an inconvenience.- Put your own scripts in
~/bin, drop the.sh, and append toPATHso they cannot shadow a system command. - Every script gets
#!/bin/bashon line one. Without it the shell guesses, and may picksh. - A script is a child process — it inherits, it may change its copy, and nothing comes back.
- A script that must change your shell has to be sourced, and should not be executable.
- Start from a command line you actually ran. It is already known to work.
The first line of every script in this chapter has been taken on trust. The next chapter is what it does.
Where this fits
Part of the Linux & Bash curriculum.
