CentOS7 に Emacs 26.3 をインストールして ShellCheck を Emacs 内でオンザフライ実行する

CentOS7 で利用可能な emacs パッケージは標準 RPM では 24.3.任意のバージョンをインストールしたいので,ここでは 26.3 をソースからビルドする(emacs で flycheck パッケージを使用するが,flycheck 自体は 24.3 以降で利用はできる).

開発ツールのインストール

$ sudo yum -y groupinstall "Development Tools"
$ sudo yum -y install gnutls-devel ncurses-devel

ソースの入手

$ curl -LO https://ftpmirror.gnu.org/emacs/emacs-26.3.tar.gz

ソースの展開

$ zcat emacs-26.3.tar.gz | tar xf -

make/make install

ここでは Emacs はターミナル内でのみ使用することにし,X-Window 上の GUI は使用しないことにするので --with-x=no を指定する.

$ cd emacs-26.3
$ configure --with-x=no --without-pop
$ make
$ sudo make install

インストール完了

$ which emacs
/usr/local/bin/emacs
$ emacs --version
GNU Emacs 26.3
Copyright (C) 2019 Free Software Foundation, Inc.
GNU Emacs comes with ABSOLUTELY NO WARRANTY.
You may redistribute copies of GNU Emacs
under the terms of the GNU General Public License.
For more information about these matters, see the file named COPYING.

ShellCheck をインストール

$ sudo yum -y install ShellCheck-0.8.0-1.x86_64.rpm

Emacs の設定

emacs 初回起動時にホームディレクトリに .emacs.d が作成される.init.el は手動作成する.

$ emacs # 起動して何もせず終了する
$ cat <<EOF > ~/.emacs.d/init.el
(require 'package)

(add-to-list 'package-archives
             '("MELPA Stable" . "https://stable.melpa.org/packages/") t)
(package-initialize)
EOF

このあと emacs をまた起動し,M-x package-install[RET] し,Install package:flycheck[RET] を入力する.これで flycheck が ~/.emacs.d/ にダウンロードされ,init.el に必要な設定が追記される.

最後に emacs をまた終了し,以下のように sh-mode で flycheck が自動起動するよう設定を .emacs.d/init.el に追記する.

echo "(add-hook 'sh-mode-hook 'flycheck-mode)" >> ~/.emacs.d/init.el

これで次回 emacs を起動してシェルスクリプトを作成または編集するときには,バッファ内コンテンツに対して ShellCheck が稼働して,引っかかった部分を太字や下線でハイライトして見せてくれるようになる.(https://www.flycheck.org/en/latest/languages.html にある通り,デフォルトで sh-shellcheck が入っているので,flycheck に ShellCheck 用の設定を追加で施す必要はない)

CentOS7 での ShellCheck ビルドメモ

CentOS7 の場合 EPEL に ShellCheck は存在するけれども,バージョンが 0.3.8 と古い(※ pkgs.org によれば,RHEL9 ならば最新の 0.8.0 の RPM が EPEL にあるようだ)ので,ソースからビルドする.

ShellCheck のソースは, https://github.com/koalaman/shellcheck から入手できる.ShellCheck のコンパイルには Haskell/Cabal も必要なので,まずそれをインストールする.なお,README.md によると 2GB RAM でコンパイルできるとあるが,6GB ないと成功しなかった(swap 容量の設定は変えず,単純に VM の RAM 容量を増やして解決した).

Cabal のインストール

$ sudo yum -y install gcc gmp gmp-devel make ncurses xz perl zlib-devel
$ curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
$ cabal install --constraint="lukko -ofd-locking" cabal-install

ShellCheck のソース入手+コンパイル

$ sudo yum -y install git
$ git clone https://github.com/koalaman/shellcheck.git
$ cd shellcheck/
$ cabal install

以上で ShellCheck がコンパイルされて,~/.cabal/bin/shellcheck にインストールされる.--version オプションをつけて実行するとこんな具合だ.

$ shellcheck --version
ShellCheck - shell script analysis tool
version: 0.8.0
license: GNU General Public License, version 3
website: https://www.shellcheck.net
[snakai@task2140 shellcheck]$ shellcheck --help
Usage: shellcheck [OPTIONS...] FILES...
  -a                  --check-sourced            Include warnings from sourced files
  -C[WHEN]            --color[=WHEN]             Use color (auto, always, never)
  -i CODE1,CODE2..    --include=CODE1,CODE2..    Consider only given types of warnings
  -e CODE1,CODE2..    --exclude=CODE1,CODE2..    Exclude types of warnings
  -f FORMAT           --format=FORMAT            Output format (checkstyle, diff, gcc, json, json1, quiet, tty)
                      --list-optional            List checks disabled by default
                      --norc                     Don't look for .shellcheckrc files
  -o check1,check2..  --enable=check1,check2..   List of optional checks to enable (or 'all')
  -P SOURCEPATHS      --source-path=SOURCEPATHS  Specify path when looking for sourced files ("SCRIPTDIR" for script's dir)
  -s SHELLNAME        --shell=SHELLNAME          Specify dialect (sh, bash, dash, ksh)
  -S SEVERITY         --severity=SEVERITY        Minimum severity of errors to consider (error, warning, info, style)
  -V                  --version                  Print version information
  -W NUM              --wiki-link-count=NUM      The number of wiki links to show, when applicable
  -x                  --external-sources         Allow 'source' outside of FILES
                      --help                     Show this usage summary and exit

man の生成

man ページを pandoc で作成することができる.pandoc は以下で導入可能.

$ curl -LO https://github.com/jgm/pandoc/releases/download/2.19.2/pandoc-2.19.2-linux-amd64.tar.gz
$ zcat pandoc-2.19.2-linux-amd64.tar.gz | tar xvf -
$ sudo cp pandoc-2.19.2/bin/* /usr/local/bin
$ sudo cp pandoc-2.19.2/share/man/man1/pandoc* /usr/local/share/man/man1
$ which pandoc
/usr/local/bin/pandoc

pandoc が使えるようになったら,以下で ShellCheck の man ページが生成される.(/usr/local/share/man/man1 へのインストールも行っている)

$ cd shellcheck/
$ pandoc -s -f markdown-smart -t man shellcheck.1.md -o shellcheck.1
$ sudo cp shellcheck.1 /usr/local/share/man/man1
$ man shellcheck

rpm 作成

rpm 作成用パッケージを導入する.

$ yum -y install rpmdevtools
$ rpmdev-setuptree

SPEC ファイルを作成する.

$ cat <<EOF > ~/rpmbuild/SPECS/ShellCheck.spec
Name: ShellCheck
Version: 0.8.0
Release: 1
Summary: ShellCheck - A shell script static analysis tool

License: GPLv3+
URL: https://www.shellcheck.net/
Source0: %{name}-%{version}.tar.gz

%description
ShellCheck is a GPLv3 tool that gives warnings and suggestions for bash/sh shell scripts.
The goals of ShellCheck are

* To point out and clarify typical beginner's syntax issues that cause a shell to give cryptic error messages.

* To point out and clarify typical intermediate level semantic problems that cause a shell to behave strangely and counter-intuitively.

* To point out subtle caveats, corner cases and pitfalls that may cause an advanced user's otherwise working script to fail under future circumstances.

%prep

%setup

%build

%define  debug_package %{nil}

%install
mkdir -p $RPM_BUILD_ROOT/usr/local/bin
mkdir -p $RPM_BUILD_ROOT/usr/local/man/man1
install -m 755 shellcheck $RPM_BUILD_ROOT/usr/local/bin
install -m 644 shellcheck.1 $RPM_BUILD_ROOT/usr/local/man/man1

%files
/usr/local/bin/shellcheck
/usr/local/man/man1/shellcheck.1

%changelog
* <DOW> <Mon> <Day> <Year> <Packager Name> <<Packager Email>>
- Initial release
EOF

バイナリ tar.gz の準備と rpmbuild 実行.

$ mkdir ShellCheck-0.8.0
$ cp ~/.cabal/bin/shellcheck/shellcheck ShellCheck-0.8.0/
$ cp shellcheck/shellcheck.1 ShellCheck-0.8.0/
$ tar czf ShellCheck-0.8.0.tar.gz ShellCheck-0.8.0
$ cp ShellCheck-0.8.0.tar.gz ~/rpmbuild/SOURCES/
$ cd ~
$ rpmbuild -bb ~/rpmbuild/SPECS/ShellCheck.spec

以上で,未署名 RPM ~/rpmbuild/RPMS/x86_64/ShellCheck-0.8.0-1.x86_64.rpm が作成される.必要に応じて GPG 署名する.

while と PIPESTATUS

# while read -r _r; do true; done < /etc/passwd
# echo $?
0
# while read -r _r; do true; done < /etc/passwd
# echo ${PIPESTATUS[@]}
1
# while read -r _r; do true; done < /etc/passwd1
-bash: /etc/passwd1: そのようなファイルやディレクトリはありません
# echo $?
1
# while read -r _r; do true; done < /etc/passwd1
-bash: /etc/passwd1: そのようなファイルやディレクトリはありません
# echo ${PIPESTATUS[@]}
0

フラットファイル + シェルスクリプト「で」トランザクション

ファイルシステム「の」トランザクションではなく,データリポジトリにフラットファイルを使用して,ビジネスアプリケーションをシェルスクリプトで組むときにどうやって「トランザクション」を実現するかという話.

トランザクションは「データ(のセット)を(業務上)整合性のとれた一貫性のある状態に保ちながら更新すること」と言って良いと思う.これを実現するために,「コミット」や「ロールバック」の手段を整備することが必要になる.

SQL の場合,こんな感じにできるらしい.

BEGIN;

UPDATE accounts SET balance = balance + 10000 WHERE name = 'Jiro';

UPDATE accounts SET balance = balance - 10000 WHERE name = 'Taro';

COMMIT;

二つ目の UPDATE 文で,balance に「0または自然数」制約みたいなものが付いていて,なおかつ Taro の口座に 10,000円入っていなかった場合は,二つ目の UPDATE は失敗する.このとき DBMS は勝手に BEGIN の時の状態にデータを戻してくれる – Jiro の口座への 10,000円追加を無かったことにしてくれる(ロールバック).

これに加えて上記の SQL コードには陽に現れていないが,DBMS ではこのトランザクションが開始されると他のプログラムから Taro や Jiro の口座データが更新することはできない仕組みになっている(排他制御).同等のことを,フラットファイル + シェルスクリプトでどれだけシステマティックに実現できるかがテーマである.

排他制御をどう実現するか

ファイルアクセスの排他制御というと「ファイルロック」だが,UNIX では「アドバイザリロック」を使用するのが一般的だ.「マンダトリ(強制)ロック」がサポートされる OS もあるようだ(この辺のことはこちらを参照)が,ここではアドバイザリロックを前提にする.アドバイザリロックというのはあくまで「アドバイザリ」なので,カーネルがプログラムのファイルアクセスを禁止しているわけではない.従って,排他が問題になるプログラム群は,共通の排他制御のルールに従う(簡単に言うと,ファイルアクセスの際にいちいちロックの有無を確認→ロックする→アクセスする→ロックを解除する)ことが要求される.

アドバイザリロックを実現するにも何通りかのやり方がある.字面上いちばん名が通っているのが flock(2) である.ただ flock(2) は C プログラマ向けに用意された API であり,シェルスクリプトから利用するにはなんらかのパッケージを介在させる必要がある.Linux の場合は flock(1) コマンドが用意されているが,他の UNIX OS では一般に利用できない.また flock(1) も flock(2) も NFS に対応していないという仕様上の制限もある.

そういったことから,歴史的に「ロックファイル」の方式がよく使われる.これは,一つのファイルシステム上にパスが同じファイルは複数存在できないという仕様を利用しており,「ファイルAをロックする」ということを「(ファイルAに対応した)ロックファイルaを作成する」ということに読み替えてアドバイザリロックを擬似的に実現するものである.「ロックファイルaの作成を試みたが,できなかった」ということは「ファイルAは他のプログラムで使用されている」ということなので,プログラムはファイルAへのアクセスをいったん諦めて,時間をおいて再挑戦するなり,エラー終了するなりという挙動をするようプログラマがロジックを組む責任がある.

ところで「ファイルロック」を実現するのに「ロックファイル」を使用する,というのが用語上混乱をきたすことがあるかもしれない(自分がそうだったので).「セマフォファイル」という呼称にするとその心配がなくなるかもしれないが,本来セマフォは共有資源にアクセスできるプログラムの数を制限するためのものなので,排他制御の文脈で使うと(共有資源同時アクセス数 1 とすれば排他制御にもなるが)それはそれで混乱するかもしれない.

コミットをどう実現するか

フラットファイル + シェルスクリプトにおいて,データの更新方法はいくつかある.ここではフラットファイルがレコード(行)xフィールド(列)の二次元形式を取っていると想定すると,

レコード追記型更新

new_record="$(<処理>)"
printf "%s\n" "$new_record">> <フラットファイル>

非追記型(ランダム更新 – レコード挿入,削除,フィールド値更新)

<処理> < <フラットファイル> > <一時ファイル>
mv <一時ファイル> <フラットファイル>

こんなところだろう.ビジネスアプリケーションにおいては,レコード追記型更新で全てを済ませられる場合は少ないだろうから,ここは非追記型を想定する.非追記型はレコード追記型にも使用することができる.また,非追記型の更新は「更新試行」→「確定」というトランザクションのコミットの動きそのものである.あとは,トランザクションに関係する複数ファイルを「全て mv で更新する・しない」,言い方を変えると,複数ファイルのうち一部だけ mv で更新されてしまわないようにする制御を加えれば,トランザクションのコミットは実現可能ということになる.

なお mv による更新の魅力は,一時ファイルと本番ファイルが同一ファイルシステムに格納されている場合,それらを置き換えるにあたって I/O が発生しないことである(ファイルシステムメタデータの更新のため微量の I/O は発生するが,データを複製するための I/O は発生しない).この場合,一時ファイルによるフラットファイルの置き換えは一瞬で済ませることができる.

ロールバックをどう実現するか

コミットの考察で,非追記型更新を前提にすると述べた.この場合,ロールバックとは何かというと「mv で一時ファイルをフラットファイルに置き換えないままプログラムを終了すること」で良かろう.ただし単一ファイルの更新ならそれで良いのだが,複数ファイルの更新を行うトランザクションでは少々他の考慮も必要である.

2つのファイルAとBを更新するトランザクションがあったとする.AとBにロックを施して排他を宣言し,ファイルAとファイルBをそれぞれ更新していったん一時ファイルAと一時ファイルBを作成までできた(ここまででエラーが発生した場合は,一時ファイルAもBもない,や,一時ファイルAだけ生成されているという状況だから,そのままプログラムを終了すればファイルAとBの内容は整合性がとれたままである).

問題はこの後で,次は

  • 一時ファイルAをファイルA に mv
  • 一時ファイルBをファイルB に mv

するわけだが,前者が成功して後者が失敗した場合は,ファイルAを更新前の状態に戻さなければならない.ここが工夫のしどころとなる.

ファイルAの更新前の状態を保存するためにまず思いつくのは,トランザクション開始時に cp でバックアップを取っておくことである.

cp ファイルA ファイルA.org

そうして,一時ファイルBをファイルBに mv する操作が失敗したら,

mv ファイルA.org ファイルA

これでロールバックができた,ということになる(この操作が失敗したらどうするかというのは考慮事項だが,それはアプリケーションの要件によって変わってくるのでここでは議論しない.ロールバックが失敗したという通知をさせ,またはログを吐かせ,いったんシステムを停止する – ユーザーからのアクセスを遮断して管理者だけが操作可能にする – というのが定石だとは思う)

問題は,cp によるバックアップでデータ I/O が発生してしまうため,トランザクション開始時のオーバーヘッドが大きいということである.ファイルが数GBあった場合,トランザクション開始操作に数10秒を要することも想定される(RAM を大量に搭載していてファイルシステムキャッシュが潤沢に使えている場合は一瞬で完了する可能性はある – が毎回それは期待できない).

これを解決するのがハードリンクである.大学で共用計算機の SunOS を利用する際に「リンク」は基本「シンボリックリンク」を使え -「ハードリンク」は使ってはならぬと教え込まれたので,その理由もよく考えないまま(今から考えると,複数のファイルシステムにホームディレクトリが収容されていたからとかそういうことなんでは,と思うが)ハードリンクには馴染みのないままこれまで過ごしてきたが,cp の代替手段としてこのハードリンクが使える.

ln ファイルA ファイルA.org

これで「ファイルA」のデータは「ファイルA.org」という名前でも参照できるようになった.内部ではファイルシステム上で同じデータに違うラベルが追加された,ということなので,データ I/O は発生しない.この操作をおこなった後,「一時ファイルA」を「ファイルA」に mv して更新すると,「ファイルA」を指すと更新されたデータが得られるが,「ファイルA.org」を指すと更新前のデータが得られる.

従って,この後「一時ファイルB」を「ファイルB」に mv して更新する操作が失敗した場合,mv ファイルA.org ファイルA でファイルAを元の内容に戻すことが可能である.

定式(決め事)化

トランザクションでのデータ更新,コミット,ロールバックをどう実現するかの骨子は以上である.あとは以上の事柄をプログラマにできるだけ簡単になおかつ確実に実行してもらうための仕組み(決め事)作りである.

現在のところ,以下のような決め事とし,それをサポートする関数 INIT(), BEGIN(), COMMIT(), ROLLBACK(), END() を実装している(関数やファイル名の字面は実際とは異なる)

  • INIT() でトランザクションIDを設定して,本トランザクションで一時ファイル等を置く一時ディレクトリを作成
  • files-to-protect(トランザクション中,他のプログラムからのアクセスを遮断しなければいけないファイル群)を定義
  • files-to-update (トランザクション中更新するファイルと,更新一時ファイル名)を定義
  • BEGIN() で排他ファイルロックとハードリンク作成によるバックアップを実施
  • トランザクション処理はサブシェル内に記述し,エラーが発生すれば non-zero exit させる
  • non-zero exit (異常終了)した場合は ROLLBACK() する(ただし,この場合の ROLLBACK() は実質ほぼ何もしない – バックアップハードリンクを消すだけである)
  • zero exit (正常終了)した場合は COMMIT() するが.ここで失敗すると ROLLBACK() する
  • END() で排他ファイルロックを解除,バックアップハードリンク消去,トランザクション用一時ディレクトリを削除する
# トランザクションの初期化
INIT || exit 1

# このトランザクション中,排他アクセスが必要なファイルをリスト
cat <<EOF > files-to-protect || exit 1
ledger-Taro
ledger-Jiro
EOF

# このトランザクション中,更新するファイルとその一時ファイル名をリスト
cat <<EOF > files-to-update || exit 1
ledger-Taro    ledger-Taro.new
ledger-Jiro    ledger-Jiro.new
EOF

# トランザクション開始
BEGIN || exit 1

dt="$(date "+%Y/%m/%d %H:%M")" || exit 1
name=フリコミ
amount=10000

# トランザクション処理(事前に名前を決めておいた一時ファイルに更新データを出力する)
(
  # Taro の通帳更新
  printf "%s\t%s\t%d\n" "$dt" "$name" "-$amount" > /tmp/transaction || exit 1
  cat ledger-Taro /tmp/transaction > ledger-Taro.new || exit 1

  # Jiro の通帳更新
  printf "%s\t%s\t%d\n" "$dt" "$name" "+$amount" > /tmp/transaction || exit 1
  cat ledger-Jiro /tmp/transaction > ledger-Jiro.new || exit 1
)
es=$?

if [ "$es" -ne 0 ] ; then
  # トランザクション処理が失敗した場合 ROLLBACK() が一時ファイルを消去する
  ROLLBACK || exit 1
else
  # トランザクション処理が成功した場合 COMMIT() が一時ファイルを本番ファイルに mv する
  COMMIT || {
    # コミットに失敗した場合 ROLLBACK() がバックアップハードリンクから本番ファイルをリストアする
    ROLLBACK || exit 1
  }
fi

# トランザクション終了(END() がファイルロック解除,バックアップハードリンク消去を行う)
END || exit 1

要注意 grep

コマンドライン上で気軽に使うには問題ないが,シェルスクリプト内で使用する時は要注意.(考えてみれば,シェルスクリプト内で grep を使用することがほとんどなくなってきた…)

典型的な問題

$ cat DATA
LINE1
LINE2
LINE3

DATA というファイルの内容に LINE2 という内容が含まれていたらある「処理」を行うという動作をするプログラムは,以下のように書くことはできる.

grep -q LINE2 DATA && <処理>

が,DATA が以下のような内容になっても,「処理」が走ってしまう.(これはほとんどの場合,そうなっては欲しくないだろう)

$ cat DATA
LINE1
LINE8
LINE16
LINE24
LINE32

データによってプログラムの挙動が意図しないものになってしまう典型となるので,grep は便利だがここでは使うべきではない.

POSIX シェルの PID の話

PPID がサブシェルの中でも一緒というのは…なんとかならんものか.ジョブコントロールについて結構気にしている割には,プロセス番号をうまく知る術がないというのはちょっといただけないと,思うのだが….BASH の場合は BASHPID というのを用意してくれているというのに.

なお jobs ユーティリティは,あくまでバックグラウンド実行したプロセスを表示するもので…ただ,バックグラウンド実行した以外のものを気にしないといけないかというと,それは…ないのか?フォアグラウンド実行中のものは制御せずとも(制御主体なので).

set -e の罠

プログラム作成にあたっては,ある箇所のエラーにより後続の処理が有害な挙動を起こしたりしないよう注意が必要だし,そもそもエラーが発生したらそれを確実に捕捉するようにすることが重要だ.シェルスクリプトでは set -e というエラー捕捉に便利な仕組みが利用できるが,無邪気に使うと痛い目に遭いそうだ.

#!/bin/sh

set -e

FUNC () {
    false
}

FUNC

exit 0

# bottom of file

たとえばこれは,必ず失敗する false コマンドだけが入った関数 FUNC() を単に呼び出しているが,実行すると以下の通り.

$ ./func0.sh
$ echo $?
1

この結果は素直に受け容れられる.では次はどうか?

#!/bin/sh

set -e

FUNC () {
    false
}

FUNC && printf "FUNC() succeeded.\n"

exit 0

# bottom of file

実行すると以下の通り.

$ ./func1.sh
$ echo $?
0

FUNC() が失敗して printf が実行されないのは期待通りだが,この行の実行が失敗 → 「set -e のはたらきで即 exit 1」とはなっていないことがわかる.これは,POSIX ドキュメントこのあたりを読めば,「ああそういうこと」となるのだけども,直観的にそういう挙動を想像できるかというと,そうでもない.「このあたり」を抜粋しておく.

2. The -e setting shall be ignored when executing the compound list following the whileuntilif, or elif reserved word, a pipeline beginning with the ! reserved word, or any command of an AND-OR list other than the last.

このあたり より

この他に,パイプライン中のコマンドの失敗では set -e 無効,while なんたらのあとのリストでは set -e 無効,サブシェルではない複文({} のことか)内のコマンドでは set -e 無効,が書かれている.

“$@” の謎

シェルスクリプトの引数や,スクリプト内関数の引数へのアクセスについて.

$@ が引数へのアクセスを提供する.これは Positional Parameters と呼ばれ「配列のようなもの」と考えられる.

#!/bin/sh

printf "argc=%d\n" "$#"

i=1
while [ $# -gt 0 ]
do
    printf "argv[%d]=\"%s\"\n" "$i" "$1"
    shift
    i=$((i+1))
done

# bottom of file

たとえば,上のスクリプトに適当な引数を付けて実行すると,以下のような出力となる.

$ ./args0.sh "This is a pen." "How are you doing?"
argc=2
argv[1]="This is a pen."
argv[2]="How are you doing?"

shift を用いて引数(配列)の位置をずらしながら $1 にアクセスすることで全引数にアクセスしている.

#!/bin/sh

FUNC () {

    printf "argc=%d\n" "$#"
    
    i=1
    while [ $# -gt 0 ]
    do
        printf "argv[%d]=\"%s\"\n" "$i" "$1"
        shift
        i=$((i+1))
    done
    
}

FUNC $@

# bottom of file

これを関数に渡すときは注意が必要で,$@ をクオートしない(17行目)と以下のように,スペースでバラバラに区切られた引数として関数に渡ってしまう.

$ ./func0.sh "This is a pen." "How are you doing?"
argc=8
argv[1]="This"
argv[2]="is"
argv[3]="a"
argv[4]="pen."
argv[5]="How"
argv[6]="are"
argv[7]="you"
argv[8]="doing?"

これを防ぐために,関数に渡すときは "$@" とクオートするのがベストプラクティスだ.

#!/bin/sh

FUNC () {

    printf "argc=%d\n" "$#"
    
    i=1
    while [ $# -gt 0 ]
    do
        printf "argv[%d]=\"%s\"\n" "$i" "$1"
        shift
        i=$((i+1))
    done
    
}

FUNC "$@"

# bottom of file

とすると,以下のように一般に期待される結果となる.

$ ./func1.sh "This is a pen." "How are you doing?"
argc=2
argv[1]="This is a pen."
argv[2]="How are you doing?"

でも,何も背景を理解しないままプログラムの字面から期待してしまうのは,argc=1argv[1]=""This is a pen." "How are you doing?"" あたりなのだが…,この辺のことは,POSIX の仕様2.5.2 Special Parameters あたりを解読すれば理解できるのだろうが,正直ちょっと意味不明だ.

ついでに,もう一つの Positional Parameter である $* と対比しておくと,

#!/bin/sh

FUNC () {

    printf "argc=%d\n" "$#"
    
    i=1
    while [ $# -gt 0 ]
    do
        printf "argv[%d]=\"%s\"\n" "$i" "$1"
        shift
        i=$((i+1))
    done
    
}

FUNC "$*"

# bottom of file

は,以下の様に動く.

$ ./func2.sh "This is a pen." "How are you doing?"
argc=1
argv[1]="This is a pen. How are you doing?"