Shell 指令碼避坑指南(一)

張晉濤發表於2021-11-23

大家好,我是張晉濤。

提到 Shell 大家想必不會太陌生,我們通常認為 Shell 是我們和系統互動的介面,執行命令返回輸出,比如 bash 、zsh 等。偶爾也會有人把 Shell 和 Terminal(終端)混淆,但這和本文關係不大,暫且略過。

作為一名程式設計師,我們可能天天都會用到 Shell ,偶爾也會把一些命令組織到一起,寫個 Shell 指令碼之類的,以便提升我們的工作效率。

然而在看似簡單的 Shell 指令碼中,可能隱藏著很深的坑。這裡我先給出兩段簡單且相似的 Shell 指令碼,大家不妨來看看這兩段程式碼的輸出是什麼:

#!/bin/bash
set -e -u
i=0
while [ $i -lt 6 ]; do
  echo $i
  ((i++))
done

答案是隻會輸出一個 0 。

#!/bin/bash
set -e -u
let i=0
while [ $i -lt 6 ]; do
  echo $i
  ((i++))
done

答案是沒有任何輸出,直接退出。

如果你能解釋清楚上面兩段程式碼輸出結果的話, 那大概你可以跳過這篇文章後續的內容了。

我先來分解下這段程式碼中涉及到的主要知識點。

變數宣告

變數宣告有很多種辦法, 但是其行為卻各有不同。

我們必須先有個基礎認識: Bash 沒有型別系統,所有變數都是 string 。 基於這個原因,如果是讓變數進行算術運算時,不能像在其他的程式語言中那樣直接寫算術運算子。這會讓 bash 解釋為對 string 的操作,而不是對數字的操作。

直接宣告

(MoeLove)➜  ~ foo=1+1
(MoeLove)➜  ~ echo $foo
1+1

直接宣告最簡單,但正如前面提到的,直接宣告會預設當作 string 進行處理,不能在宣告時進行算術運算。

declare 宣告

(MoeLove)➜  ~ declare foo=1+1
(MoeLove)➜  ~ echo $foo
1+1

除去直接宣告變數外,比較常用的方法是用 declare 來宣告變數,但預設情況下,其宣告的變數都是按 string 處理的,無法進行正常的算術運算。

declare 整數屬性

declare 在宣告變數的時候,可以通過 -i 引數增加整數屬性,當變數被賦值時,將進行算術運算。

(MoeLove)➜  ~ declare -i bar=1+1
(MoeLove)➜  ~ echo $bar
2

但要注意的是,增加整數屬性後,如果將字串賦值給它,則會出現解析失敗的情況,即:將值設定為 0:

(MoeLove)➜  ~ bar=test
(MoeLove)➜  ~ echo $bar
0

let 宣告

另一種辦法,我們可以通過 let 命令進行變數的宣告,這種方式允許在宣告時進行算術運算,同時也支援將其他值賦值給此變數。

(MoeLove)➜  ~ let baz=1+1
(MoeLove)➜  ~ echo $baz
2
(MoeLove)➜  ~ baz=moelove.info
(MoeLove)➜  ~ echo $baz
moelove.info

while 迴圈

while list-1; do list-2; done

Bash 中 while 語法就是這樣,在 while 關鍵字後是一個序列(list),可以是一個或多個表示式/語句,

需要注意的是,當 list-1 返回值為 0 時, list-2 總是會被執行,並且 while 語句最後的返回值是 list-2 最後一次執行的返回值,或者,如果沒執行任何語句的話,則返回 0 。

bash 中的算數計算

這部分的內容大家想必常會用到。我來介紹幾種常用的方法:

算術擴充套件

Bash 中的擴充套件一共有 7 種,算術擴充套件只是其中之一。具體而言就是通過類似 $((expression)) 這樣的形式,來計算表示式的值。例如:

(MoeLove)➜  ~ echo $((3+7))
10
(MoeLove)➜  ~ x=3;y=7
(MoeLove)➜  ~ echo $((x+y))
10

expr 命令

expr 是 coreutils 軟體包提供的一個命令,可對錶達式進行計算,或者比較大小之類的。

(MoeLove)➜  ~ x=3;y=7
(MoeLove)➜  ~ expr $x + $y
10
# 比較大小
(MoeLove)➜  ~ expr 2 \< 3
1
(MoeLove)➜  ~ expr 2 \< 1
0

bc 命令

按定義來說,bc 其實是一種支援任意精度和可互動執行的計算語言。它比上述提到的 expr 要強大的多,尤其是它還支援浮點數運算。例如:

一般浮點數計算

(MoeLove)➜  ~ echo "scale=2;7/3"|bc
2.33
(MoeLove)➜  ~ echo "7/3"|bc
2

注意: scale 需要手動指定,它表示小數點後的位數。預設情況下 scale 的值為 0 。

內建函式

bc 還有一些內建函式,可以方便我們進行一些快速的計算,比如可以利用 sqrt() 快速的計算平方根。

(MoeLove)➜  ~ echo "scale=2;sqrt(9)" |bc
3.00
(MoeLove)➜  ~ echo "scale=2;sqrt(6)" |bc
2.44

指令碼

此外, bc 還支援一種簡單的語法,可以支援宣告變數,編寫迴圈和判斷語句等。例如:我們可以列印20 以內可以被 3 整除的數:

(MoeLove)➜  ~ echo "for(i=1; i<=20; i++) {if (i % 3 == 0) i;}" |bc
3
6
9
12
15
18

bash 的除錯

其實 bash shell 中並沒有內建偵錯程式。很多情況下,都是採用重複執行加列印來進行除錯。但這種方式不夠高效。

這裡介紹一種比較直觀的,也比較方便的用來除錯 shell 程式碼的辦法。以下是一段示例 shell 程式碼。

(MoeLove)➜  ~ cat compare.sh 
#!/bin/bash
read -p "請輸入任意數字: " val
real_val=66
if [ "$val" -gt "$real_val" ]
then
   echo "輸入值大於等於預設值"
else
   echo "輸入值比預設值小"
fi

為其增加執行許可權,或者使用 bash 執行:

(MoeLove)➜  ~ bash compare.sh 
請輸入任意數字: 33
輸入值比預設值小

詳細模式

通過增加 -v 選項,即可開啟詳細模式,用於檢視所執行的命令。當然,我們也可以通過在 shebang 上直接增加 -v 選項, 或者增加 set -v 來開啟此模式

(MoeLove)➜  ~ bash -v compare.sh
which () {  ( alias;
 eval ${which_declare} ) | /usr/bin/which --tty-only --read-alias --read-functions --show-tilde --show-dot "$@"
}
#!/bin/bash
read -p "請輸入任意數字: " val
請輸入任意數字: 33
real_val=66
if [ "$val" -gt "$real_val" ]
then
   echo "輸入值大於等於預設值"
else
   echo "輸入值比預設值小"
fi
輸入值比預設值小

使用 xtrace 模式

我們可以通過增加 -x 引數來進入 xtrace 模式,用於除錯執行階段的變數值。

(MoeLove)➜  ~ bash -x compare.sh
+ read -p '請輸入任意數字: ' val
請輸入任意數字: 33
+ real_val=66
+ '[' 33 -gt 66 ']'
+ echo 輸入值比預設值小
輸入值比預設值小

識別未定義變數

以下示例中,我故意寫錯一個字元。執行指令碼後,你會發現沒有任何報錯,但結果並不是我們預期的。這類可能是手誤居多,所以我們需要檢查是否存在未繫結的變數。

(MoeLove)➜  ~ cat add.sh 
#!/bin/bash
five=5
ten=10
total=$((five+tne))
echo $total
(MoeLove)➜  ~ bash add.sh
5
(MoeLove)➜  ~ bash -u add.sh
add.sh: line 4: tne: unbound variable

增加 -u 選項, 可以檢查變數是否未定義/繫結。

組合使用

以上是幾種比較常見的使用方式,當然,也可以把它進行組合使用。比如上面的變數未定義的問題, 組合使用 -vu 就可以直接看到具體出現問題的程式碼是什麼內容了。

(MoeLove)➜  ~ bash -vu add.sh
which () {  ( alias;
 eval ${which_declare} ) | /usr/bin/which --tty-only --read-alias --read-functions --show-tilde --show-dot "$@"
}
#!/bin/bash
five=5
ten=10
total=$((five+tne))
add.sh: line 4: tne: unbound variable

將除錯資訊輸出到指定檔案

這裡我開啟了一個特定 FD 上的 debug.log 檔案,注意這個 FD 需要與 BASH_XTRACEFD 配置的一致,另外我修改了 PS4 的變數內容,它的預設值是 + 看起來會比較亂,而且沒有有效資訊,我通過設定 PS4='$LINENO: ' 讓它顯示行號。

然後在需要除錯的位置設定 set -x ,在結束的未知設定 set +x ,這樣除錯日誌中就只會記錄我需要除錯部分的日誌了。

(MoeLove)➜  ~ cat compare.sh 
#!/bin/bash
exec 6> debug.log 
PS4='$LINENO: ' 
BASH_XTRACEFD="6" 
read -p "請輸入任意數字: " val
real_val=66
set -x
if [ "$val" -gt "$real_val" ]
then
   echo "輸入值大於等於預設值"
else
   echo "輸入值比預設值小"
fi
set +x

echo "End"
(MoeLove)➜  ~ bash compare.sh 
請輸入任意數字: 88
輸入值大於等於預設值
End
(MoeLove)➜  ~ cat debug.log 
8: '[' 88 -gt 66 ']'
10: echo $'\350\276\223\345\205\245\345\200\274\345\244\247\344\272\216\347\255\211\344\272\216\351\242\204\350\256\276\345\200\274'
14: set +x

這裡介紹了通過 set 設定選項 的方式較簡單,其他的比如使用 trap 加除錯的方式也推薦大家去嘗試下,這裡就不展開了。

回到開始的問題

那我們用剛從介紹的除錯方法來執行下開頭的兩個指令碼,並且進行問題的解答。

第一個

(MoeLove)➜  ~ bash -xv demo1.sh
#!/bin/bash
set -e -u
+ set -e -u
i=0
+ i=0
while [ $i -lt 6 ]; do
  echo $i
  ((i++))
done
+ '[' 0 -lt 6 ']'
+ echo 0
0
+ (( i++ ))

從上述除錯結果可以看到,這個指令碼在輸出 0 然後執行完 ((i++)) 後退出。為什麼呢? 主要是由於在指令碼頂部增加的 set -e 選項。

該選項在遇到首個非 0 值的時候會直接退出。 我們來解釋下:

(MoeLove)➜  ~ i=0
(MoeLove)➜  ~ $((i++))
(MoeLove)➜  ~ echo $?
1

可以看到,執行 ((i++)) 後,返回值其實是 1 ,所以觸發了 set -e 的退出條件,指令碼便退出了。

第二個

(MoeLove)➜  ~ bash -xv demo2.sh
#!/bin/bash
set -e -u
+ set -e -u
let i=0
+ let i=0

第二個和第一個的最主要區別在於變數的賦值上, let i=0 的返回值是 1 ,所以也就會觸發 set -e 的退出條件了。我們嘗試將第二個指令碼修改下,再次執行:

[tao@moelove ~]$ cat demo2-1.sh
#!/bin/bash
set -e -u
let i=1
while [ $i -lt 6 ]; do
  echo $i
  ((i++))
done

[tao@moelove ~]$ bash demo2-1.sh 
1
2
3
4
5

let i=0 修改成 let i=1 即可按預期執行成功。

總結

本篇中,我們主要聊了 bash shell 中的變數宣告,迴圈,數學運算以及 bash shell 的除錯。是否對你有所啟發呢? 歡迎留言進行交流。

  • 注:本文僅討論 Bash Shell

歡迎訂閱我的文章公眾號【MoeLove】

相關文章