Skip to content
VLSI Mentor

Linux & Bash · Part I · Module 18 · Functions and Script Decomposition

Arguments, and Why a Function Has Its Own $1

Those empty parentheses in a function definition are not a parameter list, so how does data get in? The same way it gets into any command: as words after the name. A function reads $1, $2 and $@ exactly as a script does — and because both use the same names, the one thing worth being precise about is which set you are looking at.

Intermediate19 min readLinuxPart IBashFunctionsPositional ParametersScripting

Part I · Module 18 · Chapter 18.2 · Arguments, and Why a Function Has Its Own $1

Chapter 15.4 taught $1, $@, $# and shift for scripts. A function uses the identical set, which is convenient and is also the only thing about function arguments that ever confuses anyone.

1. A Call Swaps the Parameters Out

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/usr/bin/env bash
# s.sh
show() { echo "  in fn:  \$0=$0  \$1=$1  \$#=$#  \$@=$@"; }

echo "script: \$0=$0  \$1=$1  \$#=$#"
show alpha beta gamma
echo "after:  \$1=$1  \$#=$#"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./s.sh one "two three" four
script: $0=./s.sh  $1=one  $#=3
  in fn:  $0=./s.sh  $1=alpha  $#=3  $@=alpha beta gamma
after:  $1=one  $#=3

Three lines, three things established.

Inside the function, $1 is the function's first argument. alpha, not one. The call replaced the positional parameters for its duration.

After the call, the caller's are back. $1 is one again. Nothing was destroyed; the function's set was pushed on top and popped off.

And $# happened to be 3 both times — a coincidence of this example worth pointing out, since the script took three arguments and the call passed three. They are entirely separate counts.

What a call replaces, and what it does not

What a function does and does not get its own copy of. The numbered parameters, the count and the whole argument list are replaced for the duration of the call and restored afterwards, so a function reads its own arguments rather than the script's. The script name is not replaced, so it still names the script, and the function's own name lives in a separate variable.A call replaces theargumentsfor the duration of that callonlyThe function reads itsownthe numbered ones, the count,the whole listAnd the caller getsthem backunchanged, the moment the callreturnsBut the script name isnot replacedit still names the script, notthe functionThe function name iselsewherein a variable of its own, keptas a stackSo a usage messageneeds careor it tells the reader thewrong name12
Calling a function swaps in a fresh set of positional parameters — the numbered ones, the count and the whole list — and swaps the caller's back the moment the call returns. The script name is the exception: it is not part of that set and keeps naming the script, which is why a usage message written inside a function needs more care than it looks like it does.

2. The One That Is Not Replaced

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cat >> s.sh <<'EOF'
whoami_fn() { echo "  \$0=$0  FUNCNAME=$FUNCNAME"; }
whoami_fn
EOF
$ ./s.sh one two three
...
  $0=./s.sh  FUNCNAME=whoami_fn

$0 inside a function is still the script. It is not the function's name and never becomes it. The function's own name lives in $FUNCNAME, which exists only inside a function.

That asymmetry is not arbitrary — $0 is the name the process was invoked as, and calling a function does not start a process. But it has a practical edge, because ${0##*/} is exactly how Chapter 15.5 taught you to write a usage message:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# in a function, this is RIGHT — it names the script, which is what a user ran
usage() { echo "usage: ${0##*/} <dir>" >&2; }

# and this is a bug waiting to happen
check() {
  if [ $# -ne 2 ]; then
    echo "usage: ${0##*/} <a> <b>" >&2      # names the SCRIPT's usage
    return 2
  fi
}

The first is correct: a usage message is about the command the user typed, and $0 is that. The second is wrong in a subtler way — it is reporting a function's arity problem using the script's name, so a user who passes the right arguments to the script sees a usage line that does not match what they did. For an internal arity error, name the function:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
check() {
  if [ $# -ne 2 ]; then
    printf '%s: %s: expected 2 arguments, got %d\n' "${0##*/}" "$FUNCNAME" "$#" >&2
    return 2
  fi
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ check one
triage.sh: check: expected 2 arguments, got 1

Script name for the user, function name for whoever is debugging. Both come from the right place.

3. Forwarding: "$@" and Nothing Else

A wrapper function has to pass its arguments onward, and Chapter 15.4's rule applies with no change:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
fwd() { echo "  fwd got $# args:"; printf '    [%s]\n' "$@"; }
fwd "$@"        # quoted
fwd $@          # bare
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./s.sh one "two three" four
--- forwarding "$@":
  fwd got 3 args:
    [one]
    [two three]
    [four]
--- forwarding bare $@ (broken):
  fwd got 4 args:
    [one]
    [two]
    [three]
    [four]

Three arguments became four. Bare $@ is subject to word-splitting, so two three arrived as two separate arguments and the receiving function has no way to know it was ever one.

"$@" is the only correct way to forward an argument list. Not $*, not "$*", not $@:

FormWhat the callee receives
"$@"each argument as a separate word, boundaries intact
$@the arguments re-split on whitespace
"$*"one argument, all of them joined by a space
$*the joined string, then re-split

The one legitimate use for "$*" is when you genuinely want a single string — which is exactly what a logging helper wants:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ note could not read run.log
triage.sh: could not read run.log

"$*" joins the words into one message. With "$@" the printf format would be reused per argument and you would get four separate lines. That is Chapter 15.4's distinction, and a logging function is the place where the usually-wrong one is right.

4. shift Is Local to the Call

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sh2() { shift 2; echo "  in fn after shift 2: \$#=$# \$1=$1"; }
sh2 a b c d
echo "  caller still: \$#=$# \$1=$1"
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  in fn after shift 2: $#=2 $1=c
  caller still: $#=3 $1=one

shift inside a function shifts the function's parameters. The caller's are untouched, because they were never the ones in play. So the option-parsing loop from Chapter 16.6 works identically inside a function, which is how a wrapper takes its own flags:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
run_test() {
  local verbose=0
  while [ $# -gt 0 ]; do
    case $1 in
      -v) verbose=1; shift ;;
      --) shift; break ;;
      -*) printf '%s: %s: unknown option: %s\n' "${0##*/}" "$FUNCNAME" "$1" >&2; return 2 ;;
      *)  break ;;
    esac
  done
  [ $# -ge 1 ] || { printf '%s: %s: need a test name\n' "${0##*/}" "$FUNCNAME" >&2; return 2; }
  ...
}

local is Chapter 18.5; everything else there you already have.

5. A Function Sees Only Its Own

Nesting makes the stack behaviour concrete:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ outer() { echo "  outer \$1=$1"; inner X; echo "  outer \$1 still=$1"; }
$ inner() { echo "    inner \$1=$1"; }
$ outer OUTERARG
  outer $1=OUTERARG
    inner $1=X
  outer $1 still=OUTERARG

inner cannot see OUTERARG and outer is unaffected by inner's call. Positional parameters are the one thing in Bash that is properly scoped per call — which is worth noting now because Chapter 18.5 shows that ordinary variables emphatically are not.

If a callee needs the caller's arguments, pass them:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
outer() { inner "$@"; }

And recursion works, because each call gets its own set:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ fact() { if [ "$1" -le 1 ]; then echo 1; else echo $(( $1 * $(fact $(( $1 - 1 )) ) )); fi; }
$ fact 5
120

That is a demonstration rather than a recommendation — each level forks a subshell for the command substitution, so it is slow and the recursion depth is bounded by FUNCNEST and your stack. For real arithmetic use a loop. The point is that $1 is per-call, and nothing else in this module will be.

6. Validate Arity, Because Nothing Else Will

A function called with too few arguments does not fail — it reads an empty string:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ greet() { echo "hello $1"; }
$ greet
hello

set -u does notice — this is worth measuring rather than assuming, because it is often stated the other way round:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ bash -c 'set -u; greet() { echo "hello $1"; }; greet'
bash: $1: unbound variable

So set -u converts the silent empty string into an abort. That is an improvement and it is still not an arity check, for four reasons:

  • It names a line, not a problem. In a script the message is g.sh: line 3: $1: unbound variable — no function name, no statement of what was expected.
  • It fires only where you read the parameter, so a function that reads $1 on line 40 passes forty lines of setup first.
  • It says nothing about too many arguments. derive_tb a b c is silently fine.
  • And ${1:-} disables it. Defensive scripts are full of ${1:-} precisely to survive set -u — at which point you are back to an empty string with no complaint at all.

The guard is explicit and belongs at the top of any function that takes arguments:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
derive_tb() {
  if [ $# -ne 1 ]; then
    printf '%s: %s: expected 1 argument, got %d\n' "${0##*/}" "$FUNCNAME" "$#" >&2
    return 2
  fi
  printf '%s_tb.sv\n' "${1%.sv}"
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ derive_tb top.sv
top_tb.sv
$ derive_tb
triage.sh: derive_tb: expected 1 argument, got 0
$ echo $?
2

For a two-line helper used once, this is overkill. For anything called from several places, or from a library other scripts source, it is the difference between an error that names itself and a mysterious empty string three functions away.

7. A Complete Script

A wrapper whose functions each take arguments, validate them, and forward correctly:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/usr/bin/env bash
# run-one.sh — run one test at one seed, with per-call options
# usage: run-one.sh [-v] <test> [seed]
#
# exit: 0 passed · 1 failed · 2 unusable arguments

set -o nounset

note()  { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
fatal() { note "$*"; exit 2; }

# arity error that names the FUNCTION, for whoever is debugging
arity() {
  printf '%s: %s: expected %s, got %d\n' "${0##*/}" "${FUNCNAME[1]}" "$1" "$2" >&2
}

# derive_tb <source.sv> -> prints the testbench name
derive_tb() {
  [ $# -eq 1 ] || { arity "1 argument" "$#"; return 2; }
  printf '%s_tb.sv\n' "${1%.sv}"
}

# log_name <test> <seed> -> prints the log file name
log_name() {
  [ $# -eq 2 ] || { arity "2 arguments" "$#"; return 2; }
  printf '%s_s%s.log\n' "$1" "$2"
}

# simulate <test> <seed> <logfile> — stands in for the simulator
simulate() {
  [ $# -eq 3 ] || { arity "3 arguments" "$#"; return 2; }
  if [ "$1" = known_bad ]; then
    printf 'UVM_ERROR : mismatch in %s seed %s\n' "$1" "$2" > "$3"
  else
    printf 'TEST PASSED (%s seed %s)\n' "$1" "$2" > "$3"
  fi
}

# verdict <logfile> -> 0 passed, 1 not
verdict() {
  [ $# -eq 1 ] || { arity "1 argument" "$#"; return 2; }
  grep -q 'TEST PASSED' "$1"
}

# ---- argument handling ---------------------------------------------------

verbose=0
while [ $# -gt 0 ]; do
  case $1 in
    -v|--verbose) verbose=1; shift ;;
    -h|--help)    echo "usage: ${0##*/} [-v] <test> [seed]"; exit 0 ;;
    --)           shift; break ;;
    -*)           fatal "unknown option: $1" ;;
    *)            break ;;
  esac
done

[ $# -ge 1 ] || fatal "need a test name"
[ $# -le 2 ] || fatal "too many arguments"

test=$1
seed=${2:-1}

# ---- the work ------------------------------------------------------------

log=$(log_name "$test" "$seed") || exit 2
tb=$(derive_tb "${test}.sv")    || exit 2

if [ "$verbose" -eq 1 ]; then
  note "test=$test seed=$seed tb=$tb log=$log"
fi

simulate "$test" "$seed" "$log" || exit 2

if verdict "$log"; then
  printf 'pass  %s seed %s\n' "$test" "$seed"
  exit 0
fi

printf 'FAIL  %s seed %s (see %s)\n' "$test" "$seed" "$log"
exit 1
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./run-one.sh smoke
pass  smoke seed 1
$ echo $?
0

$ ./run-one.sh -v smoke 7
run-one.sh: test=smoke seed=7 tb=smoke_tb.sv log=smoke_s7.log
pass  smoke seed 7
$ echo $?
0

$ ./run-one.sh known_bad 3
FAIL  known_bad seed 3 (see known_bad_s3.log)
$ echo $?
1

$ ./run-one.sh --bogus smoke
run-one.sh: unknown option: --bogus
$ echo $?
2

$ ./run-one.sh
run-one.sh: need a test name
$ echo $?
2

$ ./run-one.sh a b c
run-one.sh: too many arguments
$ echo $?
2

Four decisions from this chapter:

arity uses ${FUNCNAME[1]}, not $FUNCNAME. Inside arity, $FUNCNAME is arity — useless. ${FUNCNAME[1]} is whoever called it, which is the function that actually got the wrong number of arguments. That is the callout in §2 put to work, and it is why a shared error helper needs the stack rather than the scalar.

Every function validates $# on its first line. Five one-line guards. Without them, log_name smoke with a missing seed silently produces smoke_s.log and the failure appears much later as a missing file.

The option loop uses shift at the top level, and would work identically inside a function because §4 showed shift is per-call.

note uses "$*" and printf '%s\n' "$@" is not used anywhere for a message. fatal "unknown option: $1" passes three words and gets one line, which is only true because note joins with "$*".

Practice

  1. Write the Section 1 script yourself and run it with three arguments, one of which contains a space. Confirm $1 and $# inside and outside, and say why $# being equal both times is a coincidence.
  2. Print $0 and $FUNCNAME inside a function. Then write a usage message that names the script and an arity message that names the function, and explain which is right for which reader.
  3. Build a three-level call chain and print ${FUNCNAME[*]} at the bottom. Identify what main is.
  4. Write fwd and call it with "$@", $@, "$*" and $* from a script invoked with one "two three" four. Record the argument count for each and fill in the §3 table from your own output.
  5. Write a note helper with "$*", then change it to "$@" and call it with four words. Explain the difference in output in terms of printf reusing its format.
  6. shift 2 inside a function, then print $# and $1 both inside and in the caller. Then move the option loop from run-one.sh into a function and confirm it still works.
  7. Call greet with no arguments where greet() { echo "hello $1"; }. Add set -u and call it again, noting the exact message and the script's exit status. Then give greet three arguments under set -u and say what it reports. List the four reasons set -u is not an arity check.
  8. Compare local name=${1:?need a name} with an explicit [ $# -eq 1 ] || return 2. Call each with no arguments from a script and note what happens to the script.
  9. In run-one.sh, change arity to use $FUNCNAME instead of ${FUNCNAME[1]} and trigger an arity error. What does the message now say, and why is it useless?
  10. Write outer() { inner "$@"; } and show that inner sees the caller's arguments. Then drop the quotes and break it with an argument containing a space.

Check Your Understanding

  1. What happens to the caller's $1 while a function runs?
  2. Which positional parameter is not replaced by a call, and what is it instead?
  3. Where does a function's own name live?
  4. Why is fwd $@ wrong?
  5. When is "$*" the right choice?
  6. Does shift inside a function affect the caller?
  7. greet is called with no arguments. What is $1, and what does set -u do about it?

8. Remember

  • A call swaps in its own $1, $2, $# and $@, and swaps the caller's back on return.
  • $0 is not replaced. It still names the script; the function's name is $FUNCNAME.
  • Script name for the user, $FUNCNAME for the debugger. A usage message wants ${0##*/}; an arity error wants both.
  • FUNCNAME is a stack. ${FUNCNAME[1]} is the caller, which is what a shared error helper needs.
  • "$@" is the only correct way to forward arguments. Bare $@ re-splits; "$*" collapses to one.
  • "$*" is right for a log message, where one string is the point.
  • shift is per-call and does not touch the caller, so an option loop works unchanged inside a function.
  • Positional parameters are properly per-call — the one thing in Bash that is, which Chapter 18.5 will contrast sharply.
  • Validate $# on the first line of any function with arguments. set -u turns a missing argument into an abrupt unbound variable abort rather than an empty string, but it names a line instead of the problem, ignores extra arguments, and is disabled by ${1:-}.
  • ${1:?msg} exits the script, where return 2 lets the caller decide. Choose deliberately.

Chapter 18.3 is that choice. return and exit look interchangeable in a small script and are not — one hands a number back, the other ends everything, and the difference only shows up once the script is big enough for it to hurt.

Where this fits

Part of the Linux & Bash curriculum.