适用对象:第一次学习 Shell、正在使用 Windows 和 Codex 桌面端终端的初学者。
本教程以 Bash 为主,同时说明 PowerShell 中的对应写法。
如果你需要把每条命令拆成“命令名、选项、源参数、目标参数和实际作用”,请配合阅读同目录下的 shell-command-dictionary.md。
- Shell 是什么
- Codex 终端为什么没有
touch - Windows 上选择哪种终端
- 命令的基本结构
- 认识当前目录
- 目录和文件操作
- 查看文件内容
- 输出、重定向和管道
- 搜索文件和文本
- 变量和环境变量
- 引号、通配符和特殊符号
- 编写第一个 Shell 脚本
- 权限、进程和任务
- 常见报错及解决方法
- 完整练习项目
- 命令速查表
Shell 是一个用来和操作系统对话的程序。你输入命令,Shell 负责解析命令并调用系统功能。
常见 Shell 有:
| Shell | 常见平台 | 特点 |
|---|---|---|
| Bash | Linux、macOS、Git Bash、WSL | Linux 教程最常用 |
| PowerShell | Windows | Windows 默认终端,功能强大 |
| Command Prompt(CMD) | Windows | 较老的 Windows 命令行 |
| Zsh | macOS、Linux | Bash 的常见替代品 |
本教程中的命令如果写成这样:
$ pwd开头的 $ 只是提示符,不要把 $ 一起输入。实际输入的是 pwd。
PowerShell 的提示符通常类似这样:
PS C:\Users\Lenovo>你之前看到的错误:
touch : 无法将“touch”项识别为 cmdlet、函数、脚本文件或可运行程序
说明当前终端是 PowerShell。touch 是 Bash/Linux 中常见的命令,不是 PowerShell 的内置命令。
New-Item hello.txt简写:
ni hello.txt如果电脑已经安装 WSL,可以输入:
wsl进入后提示符会变化,此时可以使用 Bash 命令:
touch hello.txt
ls退出 WSL:
exit如果已经安装 Git for Windows,可以在 PowerShell 输入:
& "C:\Program Files\Git\bin\bash.exe"然后就能运行:
touch hello.txt
ls -la如果电脑没有 Git Bash,可以安装 Git for Windows。
- 想学习 Linux/Shell:选择 WSL 或 Git Bash。
- 只做 Windows 文件操作:使用 PowerShell。
- 想运行和服务器完全一致的 Linux 命令:优先使用 WSL。
PowerShell 是 Codex 在 Windows 上常见的默认环境。它的命令示例:
Get-Location
Get-ChildItem
New-Item hello.txt它也支持许多别名,所以以下命令可能可以运行:
pwd
ls
cat file.txt但这些别名并不代表你已经进入 Bash。比如 touch、grep、chmod 仍可能不可用。
Git Bash 提供比较接近 Linux 的 Bash 环境,适合学习本教程。它可以直接使用:
pwd
ls
touch hello.txt
grep hello hello.txtWSL 是 Windows Subsystem for Linux,可以在 Windows 内运行真实的 Linux 发行版。它最适合学习服务器、Linux 和 Bash。
在 PowerShell 中检查 WSL:
wsl --status如果还没有安装,可以用管理员 PowerShell 执行:
wsl --install安装后可能需要重启电脑。
Shell 命令通常由四部分组成:
命令 选项 参数
例如:
ls -la /tmpls:命令名称-la:选项,表示显示详细信息和隐藏文件/tmp:参数,表示查看哪个目录
查看命令帮助:
ls --help查看 Bash 命令手册:
man ls在 Windows PowerShell 中查看帮助:
Get-Help Get-ChildItem按 Ctrl + C 可以中止正在运行的命令。
Shell 中最重要的概念之一是“当前工作目录”。很多相对路径都以它为起点。
pwd示例输出:
/home/user/project
PowerShell:
Get-Locationls
ls -l # 详细信息
ls -a # 包含隐藏文件
ls -la # 两者一起使用PowerShell:
Get-ChildItem
Get-ChildItem -Forcecd folder # 进入 folder
cd .. # 返回上一级
cd . # 当前目录,通常没有实际变化
cd ~ # 回到用户主目录
cd - # 返回上一次所在的目录Windows 路径也可以写成:
cd /c/Users/Lenovo/DocumentsPowerShell 写法:
Set-Location C:\Users\Lenovo\Documents路径中包含空格时,要使用引号:
cd "My Project"mkdir demo
mkdir -p project/src/components-p 表示父目录不存在时一起创建。
PowerShell:
New-Item -ItemType Directory demo
New-Item -ItemType Directory project/src/components -ForceBash:
touch hello.txtPowerShell:
New-Item hello.txtBash:
cp hello.txt copy.txt
cp -r demo demo-backupPowerShell:
Copy-Item hello.txt copy.txt
Copy-Item demo demo-backup -RecurseBash:
mv hello.txt demo/
mv old-name.txt new-name.txtPowerShell:
Move-Item hello.txt demo/
Rename-Item old-name.txt new-name.txtBash:
rm file.txt
rm -r folder
rm -i file.txtPowerShell:
Remove-Item file.txt
Remove-Item folder -Recurse
Remove-Item file.txt -Confirmrm 和 Remove-Item 通常不会进入回收站。删除前先确认路径:
pwd
ls不要在不确定时执行危险命令,特别是 rm -rf。
cat file.txtPowerShell:
Get-Content file.txthead file.txt
head -n 20 file.txt
tail file.txt
tail -n 20 file.txt持续查看日志:
tail -f app.log按 Ctrl + C 停止。
PowerShell 等价命令:
Get-Content app.log -Waitless large-file.txt在 less 中:
Space:向下翻页b:向上翻页/文字:搜索文字q:退出
echo "Hello Shell"PowerShell 也支持:
Write-Output "Hello Shell"echo "first line" > notes.txt如果文件已经存在,原内容会被覆盖。
echo "second line" >> notes.txt这会把内容追加到文件末尾。
管道把前一个命令的输出交给后一个命令:
cat names.txt | sort常见组合:
cat names.txt | sort | uniq
ls -la | less
grep "ERROR" app.log | tail -n 20许多时候可以省略不必要的 cat:
sort names.txt | uniqShell 中:
1:标准输出2:错误输出
只保存正常输出:
command > output.txt同时保存正常输出和错误:
command > all.txt 2>&1从第 8 章开始,Shell 的重点变成“把命令组合起来”。先记住这条主线:
输入数据 -> 命令处理 -> 输出结果 -> 下一个命令继续处理
echo "hello"这条命令把 hello 输出到屏幕。这个输出叫标准输出。
echo "hello" > hello.txt这次屏幕上没有 hello,因为 > 把标准输出改送到 hello.txt。读取文件:
cat hello.txt可以把 > 读成“把左边命令打印的结果写进右边文件”。
echo "第一行" > notes.txt
echo "第二行" > notes.txt第二条命令会覆盖第一条,文件只剩第二行。
echo "第一行" > notes.txt
echo "第二行" >> notes.txt两行都会保留。记忆方法:
-
是重新写,旧内容会消失。
-
是接着写,旧内容会保留。
练习:
echo "Alice" > names.txt
echo "Bob" >> names.txt
echo "Carol" >> names.txt
cat names.txt先准备文件:
printf "orange\napple\nbanana\n" > fruits.txt查看:
cat fruits.txt排序:
cat fruits.txt | sort执行过程是:
- cat 打开 fruits.txt。
- cat 原本要把内容显示在屏幕上。
- | 截住 cat 的输出,交给 sort。
- sort 排序后再把结果显示在屏幕上。
结果:
apple
banana
orange
多个管道就是多个处理步骤:
cat fruits.txt | sort | head -n 2人话翻译:读取文件,排序,只显示前两行。
这两条通常结果相同:
cat app.log | grep "ERROR"
grep "ERROR" app.log第一条是 cat 读文件、grep 读管道;第二条是 grep 自己读文件。能直接把文件交给命令时,第二条更简单。需要多个处理步骤时才使用管道:
grep "ERROR" app.log | sort | uniq -c翻译:
- 只保留 ERROR 行。
- 排序,让相同内容相邻。
- 统计重复行。
命令可能同时产生正常结果和错误提示:
ls existing.txt missing.txt保存正常结果:
ls existing.txt missing.txt > result.txt保存错误:
ls existing.txt missing.txt 2> errors.txt把两者放到同一个文件:
ls existing.txt missing.txt > all.txt 2>&1入门阶段先记住:
-
file:保存正常结果。
- 2> file:保存错误信息。
-
file:追加正常结果。
准备日志:
printf "INFO started\nERROR database down\nINFO retrying\nERROR timeout\n" > app.log完整查看:
cat app.log只显示含 ERROR 的行:
grep "ERROR" app.log显示行号:
grep -n "ERROR" app.log只显示数量:
grep -c "ERROR" app.log忽略大小写:
grep -i "error" app.log显示不含 ERROR 的行:
grep -v "ERROR" app.log在当前目录的所有文件中递归搜索:
grep -rn "ERROR" .这里的 r 是递归,n 是显示行号。
从当前目录开始显示所有路径:
find .查找名字以 .log 结尾的文件:
find . -name "*.log"只找普通文件:
find . -type f -name "*.log"只找目录:
find . -type d -name "data"如果 find 太难,先分成两步:
find . -type f -name "*.log" > log-files.txt
cat log-files.txtname="Alice"
echo "$name"第一行创建变量,第二行读取变量。等号两边不能有空格:
name="Alice"错误:
name = "Alice"读取变量要加美元符号。下面输出的是变量内容:
echo "$name"下面输出的是普通文字 name:
echo "name"把文件名保存到变量:
file="app.log"
grep "ERROR" "$file"把一个命令的输出保存到变量:
error_count="$(grep -c "ERROR" app.log)"
echo "错误数量:$error_count"执行顺序:
- 先运行 grep。
- 得到数字,例如 2。
- 把数字放入 error_count。
- echo 打印这个变量。
$(...) 的意思就是“先执行括号里面的命令”。
file="my notes.txt"
cat "$file"双引号保证整个 my notes.txt 被当成一个文件名。
不加引号时:
cat $fileShell 可能把它拆成 my 和 notes.txt 两个参数。初学阶段遵守这个规则:
使用变量时优先写成 "$变量名"
if [ -f "app.log" ]; then
echo "文件存在"
else
echo "文件不存在"
fi解释:
- if 开始判断。
- [ -f "app.log" ] 检查 app.log 是否是文件。
- then 表示条件为真时执行。
- else 表示条件为假时执行。
- fi 表示 if 结束。
常用测试:
[ -f file ] # 是文件
[ -d folder ] # 是目录
[ -e path ] # 路径存在
[ -z "$text" ] # 字符串为空
[ -n "$text" ] # 字符串非空
[ "$a" = "$b" ] # 字符串相等
[ "$n" -gt 10 ] # 数字大于 10在 Bash 中,方括号不是装饰符号。单方括号实际上是一个叫 test 的命令:
[ -f "app.log" ]它和下面这条命令意思相同:
test -f "app.log"test 不会直接打印 true 或 false,而是通过退出状态表示结果:
- 返回 0:条件为真。
- 返回非 0:条件为假。
所以它通常放进 if:
if [ -f "app.log" ]; then
echo "app.log 是文件"
fi把这条命令拆开:
[ 开始执行 test 命令
-f 检查后面的路径是否为普通文件
"app.log" 被检查的路径
] test 命令的结束标记
; then if 条件结束,开始执行真分支
正确:
[ -f "app.log" ]错误:
[-f "app.log"]Shell 会按空格拆分命令,所以 [ 和 ] 必须是独立参数。
then 也有两种写法:
if [ -f "app.log" ]; then
echo "存在"
fi或者:
if [ -f "app.log" ]
then
echo "存在"
fi如果 then 和条件在同一行,中间需要分号;如果换行,就不需要分号。
[ -e "path" ] # 路径存在
[ -f "file.txt" ] # 是普通文件
[ -d "logs" ] # 是目录
[ -r "file.txt" ] # 可读
[ -w "file.txt" ] # 可写
[ -x "run.sh" ] # 可执行完整例子:
if [ ! -f "config.conf" ]; then
echo "缺少配置文件"
exit 1
fi! 表示取反,所以这句话是“如果 config.conf 不是文件”。
name="Alice"
if [ -n "$name" ]; then
echo "name 非空"
fi
if [ -z "$name" ]; then
echo "name 为空"
fi
if [ "$name" = "Alice" ]; then
echo "名字匹配"
fi常见写法:
- -n:字符串非空。
- -z:字符串为空。
- =:字符串相等。
- !=:字符串不相等。
变量为空时也要保留引号:
name=""
[ -z "$name" ]不推荐:
[ -z $name ]变量为空时,不加引号可能造成参数数量不正确。
数字比较要使用专门的运算符:
count=3
[ "$count" -eq 3 ] # 等于
[ "$count" -ne 3 ] # 不等于
[ "$count" -gt 2 ] # 大于
[ "$count" -ge 3 ] # 大于或等于
[ "$count" -lt 5 ] # 小于
[ "$count" -le 3 ] # 小于或等于例子:
error_count=2
if [ "$error_count" -gt 0 ]; then
echo "发现错误"
else
echo "没有错误"
fi记忆方法:
- eq:equal,等于。
- ne:not equal,不等于。
- gt:greater than,大于。
- lt:less than,小于。
两个条件都为真:
if [ -f "app.log" ] && [ -r "app.log" ]; then
echo "文件存在并且可读"
fi至少一个条件为真:
if [ "$env" = "dev" ] || [ "$env" = "test" ]; then
echo "这是非生产环境"
fi双方括号是 Bash 提供的条件语法:
if [[ -f "app.log" ]]; then
echo "文件存在"
fi它和单方括号用途相近,但更适合 Bash 脚本,因为变量为空时更安全,并支持模式匹配和正则表达式。
字符串比较:
name="Alice"
if [[ "$name" == "Alice" ]]; then
echo "匹配"
fi模式匹配:
file="backup-2026.sql"
if [[ "$file" == backup-*.sql ]]; then
echo "这是 SQL 备份文件"
fi正则表达式:
version="v1.2.3"
if [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "版本号格式正确"
fi| 场景 | 推荐 |
|---|---|
| 兼容普通 sh | [ ] |
| 明确使用 Bash | [[ ]] |
| 简单文件判断 | 两者都可以 |
| Bash 模式匹配 | [[ ]] |
| Bash 正则表达式 | [[ ]] |
| 阅读已有脚本 | 按原脚本写法理解 |
如果脚本第一行是:
#!/bin/sh不要随意把 [ ] 改成 [[ ]],普通 sh 不一定支持双方括号。
如果脚本第一行是:
#!/usr/bin/env bash那么 [[ ]] 通常可以使用。
检查配置:
if [[ -z "$DB_NAME" ]]; then
echo "DB_NAME 未设置" >&2
exit 1
fi检查客户端:
if ! command -v psql >/dev/null 2>&1; then
echo "找不到 psql" >&2
exit 1
fi检查连接:
if psql "$DB_NAME" -c "SELECT 1;" >/dev/null 2>&1; then
echo "数据库连接成功"
else
echo "数据库连接失败" >&2
exit 1
fi看到方括号时,先翻译成人话:
if [[ ! -f "$config_file" ]]; then就是:
如果 config_file 不是一个文件,就执行下面的内容。
看到:
if [ "$count" -gt 0 ]; then就是:
如果 count 大于 0,就执行下面的内容。
for file in *.log; do
echo "正在处理:$file"
done如果目录中有 a.log、b.log、c.log,循环会执行三次。每次执行时,file 的值不同。
对每个日志统计错误:
for file in *.log; do
count="$(grep -c "ERROR" "$file")"
echo "$file: $count"
done翻译:对每个 .log 文件执行相同的两行操作。
run_query() {
local sql="$1"
echo "准备执行:$sql"
}
run_query "SELECT 1;"读函数时只问三件事:
- 接收哪些参数。
- 内部执行什么命令。
- 最后输出什么或返回什么状态。
数据库脚本中的函数可能是:
run_query() {
local sql="$1"
psql "$DB_NAME" -c "$sql"
}人话:接收一条 SQL,交给 psql 执行。
另一个脚本可能这样写:
source "./database-common.sh"
run_query "SELECT 1;"source 会在当前 Shell 中加载文件。文件里的函数加载完成后,后面的脚本就能调用它们。
直接运行:
bash database-common.sh和 source 不一样,直接运行会开启另一个进程。公共函数通常需要 source,而不是单独运行。
看到一条长命令:
psql \
--host "$DB_HOST" \
--port "$DB_PORT" \
--username "$DB_USER" \
--dbname "$DB_NAME" \
--command 'SELECT 1;'不要整体死记,拆成三部分:
- psql:使用 PostgreSQL 客户端。
- --host、--port、--username、--dbname:连接参数。
- --command:要执行的 SQL。
其他常见客户端:
mysql "连接参数" --execute "SELECT 1;"
sqlite3 "$DB_FILE" "SELECT 1;"psql "$DB_NAME" <<'SQL'
SELECT id, name
FROM users
WHERE active = true;
SQL解释:
- psql 等待从标准输入读取 SQL。
- <<'SQL' 表示开始多行输入。
- 中间的几行是 SQL。
- 单独一行的 SQL 表示输入结束。
看到这种结构时,把它整体翻译成“把下面这段 SQL 交给 psql”。
命令成功通常返回 0,失败返回非 0。可以这样判断:
if psql "$DB_NAME" -c 'SELECT 1;' >/dev/null; then
echo "连接成功"
else
echo "连接失败" >&2
exit 1
fi这条也很常见:
cd "$PROJECT_ROOT" || exit 1翻译:进入目录失败就退出,不要在错误目录继续执行。
数据库修改脚本中寻找:
BEGIN;
UPDATE users SET active = true WHERE id = 10;
COMMIT;- BEGIN:开始事务。
- COMMIT:提交修改。
- ROLLBACK:发生问题时撤销修改。
读脚本时重点确认:失败时有没有停止、有没有回滚、是否真的提交了。
tmp_sql="$(mktemp)"
trap 'rm -f "$tmp_sql"' EXIT第一行创建临时文件,第二行表示脚本退出时删除它。看到 trap 时,先问“脚本结束时要清理什么”。
先创建日志:
printf "INFO start\nERROR database down\nINFO retry\nERROR timeout\n" > app.log再统计错误:
count="$(grep -c "ERROR" app.log)"
echo "错误数量:$count"加上判断:
if [ "$count" -gt 0 ]; then
echo "发现错误"
else
echo "没有错误"
fi最后写入报告:
{
echo "日志报告"
echo "错误数量:$count"
} > report.txt
cat report.txt大括号表示把里面多条命令的输出作为一个整体,再由 > 写入文件。
看到任何 database-common.sh 代码,可以按这个模板写中文旁注:
解释器:用什么 Shell?
配置:变量从哪里来?有没有默认值?
入口:脚本是 source 还是直接运行?
函数:每个函数接收什么、执行什么、返回什么?
数据库:使用 psql、mysql 还是 sqlite3?
输入:SQL 来自 -c、-f、重定向还是多行输入?
输出:结果显示在屏幕、文件还是变量?
错误:失败后退出、重试还是继续?
清理:临时文件和连接如何处理?
先能回答这些问题,再研究复杂的 SQL 细节。
不要一次学习全部内容。每一步运行成功后再进行下一步:
echo "ERROR one" > app.log
echo "INFO two" >> app.log
grep "ERROR" app.log
count="$(grep -c "ERROR" app.log)"
echo "$count"
if [ "$count" -gt 0 ]; then echo "有错误"; fi
for file in *.log; do echo "$file"; done如果某一行不懂,就只研究那一行,不要继续复制后面的命令。
grep "hello" file.txt
grep -i "hello" file.txt # 忽略大小写
grep -n "hello" file.txt # 显示行号
grep -v "hello" file.txt # 显示不包含 hello 的行
grep -rn "hello" . # 递归搜索当前目录PowerShell 对应:
Select-String -Pattern "hello" -Path file.txtfind . -name "*.sh"
find . -type f -name "*.txt"
find . -type d -name "data"含义:
.:从当前目录开始-name:按名称匹配-type f:只找文件-type d:只找目录
PowerShell:
Get-ChildItem -Recurse -Filter *.sh
Get-ChildItem -Recurse -File -Filter *.txtwc file.txt # 行数、单词数、字节数
wc -l file.txt # 只统计行数
sort file.txt # 排序
uniq file.txt # 删除相邻重复行
sort file.txt | uniq -c注意:uniq 只处理相邻重复行,所以通常先 sort 再 uniq。
Bash 中等号两边不能有空格:
name="Tom"
age=18读取变量要加 $:
echo "$name"
echo "${name} is ${age} years old"错误写法:
name = "Tom"echo "$HOME"
echo "$PATH"
env临时设置一个环境变量并运行命令:
MODE=development bash script.shPowerShell:
$name = "Tom"
Write-Output $name
$env:Path
Get-ChildItem Env:PATH 是一组目录。你输入命令时,Shell 会在这些目录中寻找可执行程序。
查看 Bash 的命令位置:
which bash
which gitPowerShell:
Get-Command git
Get-Command bash如果看到“command not found”,可能是软件没有安装,或者安装目录没有加入 PATH。
双引号会展开变量:
name="Tom"
echo "Hello, $name"输出:
Hello, Tom
单引号中的内容基本按原样处理:
echo 'Hello, $name'输出:
Hello, $name
ls *.txt # 当前目录中的所有 txt 文件
ls data-?.csv # ? 代表一个字符
ls file[0-9].txt # [0-9] 代表一个数字| 符号 | 含义 |
|---|---|
. |
当前目录 |
.. |
上一级目录 |
~ |
用户主目录 |
/ |
路径分隔符或根目录 |
* |
任意多个字符 |
? |
任意一个字符 |
| ` | ` |
> |
覆盖重定向 |
>> |
追加重定向 |
&& |
前一个命令成功后再运行后一个 |
| ` | |
# |
注释开始 |
示例:
mkdir demo && cd demo
test -f config.txt || echo "config.txt does not exist"新建 hello.sh:
#!/usr/bin/env bash
name="Tom"
echo "Hello, $name!"第一行叫 shebang,告诉系统使用哪个解释器运行脚本。
最简单的方式:
bash hello.shLinux、WSL 或 Git Bash 中也可以这样运行:
chmod +x hello.sh
./hello.shPowerShell 中可以调用 Bash:
bash .\hello.sh脚本 greet.sh:
#!/usr/bin/env bash
name="$1"
if [ -z "$name" ]; then
echo "用法:bash greet.sh 姓名"
exit 1
fi
echo "你好,$name!"运行:
bash greet.sh Alice常用参数:
$0:脚本名称$1:第一个参数$2:第二个参数$#:参数数量$@:全部参数$?:上一条命令的退出状态码
if [ -f "hello.txt" ]; then
echo "文件存在"
else
echo "文件不存在"
fi常用测试:
[ -f file ] # 是否为文件
[ -d folder ] # 是否为目录
[ -z "$text" ] # 字符串是否为空
[ "$a" = "$b" ] # 字符串是否相等
[ "$n" -gt 10 ] # 数字是否大于 10for file in *.txt; do
echo "正在处理:$file"
done数字循环:
for i in {1..5}; do
echo "第 $i 次"
donels -l示例:
-rwxr-xr-- 1 user user 120 hello.sh
其中:
r:read,读取w:write,写入x:execute,执行
chmod +x hello.sh
chmod 644 notes.txt
chmod 755 run.shWindows PowerShell 不使用完全相同的 Linux 权限模型,因此 chmod 主要在 WSL、Linux 或 Git Bash 中使用。
ps
ps aux
topPowerShell:
Get-Process结束当前前台程序:
Ctrl + C
Bash:
long-command &
jobs
fg& 会让命令在后台运行,jobs 查看后台任务,fg 把任务切回前台。
含义:Shell 找不到这个命令。
检查:
which command-namePowerShell:
Get-Command command-name可能原因:
- 命令属于另一种 Shell。
- 软件尚未安装。
- 软件没有加入
PATH。 - 命令拼写错误。
含义:文件或目录不存在,或路径写错。
检查:
pwd
ls -la使用 Tab 键自动补全文件名,可以减少拼写错误。
含义:没有执行或读取权限。
Linux/WSL 中可能需要:
chmod +x script.sh含义:你把目录当成文件操作,或者把文件当成目录操作。
先确认类型:
ls -ld path这不一定是错误。很多成功的命令本来就没有输出,例如:
mkdir demo
touch file.txt用 ls 检查结果:
ls -la下面的练习会创建一个小型日志项目。建议在工作区中执行。
mkdir -p shell-practice/logs shell-practice/scripts
cd shell-practice
touch logs/app.log如果当前是 PowerShell:
New-Item -ItemType Directory shell-practice/logs -Force
New-Item -ItemType Directory shell-practice/scripts -Force
Set-Location shell-practice
New-Item logs/app.logBash:
echo "INFO server started" >> logs/app.log
echo "INFO user logged in" >> logs/app.log
echo "ERROR database unavailable" >> logs/app.log
echo "INFO retry completed" >> logs/app.log
echo "ERROR request timeout" >> logs/app.log查看日志:
cat logs/app.loggrep "ERROR" logs/app.log
grep -c "ERROR" logs/app.log预期结果:错误数量为 2。
{
echo "日志报告"
echo "========"
echo "错误数量:$(grep -c "ERROR" logs/app.log)"
echo "信息数量:$(grep -c "INFO" logs/app.log)"
} > report.txt查看报告:
cat report.txt这里的 $(...) 叫命令替换,表示先运行括号里的命令,再把结果放进文字中。
cp -r logs logs-backup
find . -type f创建 scripts/report.sh:
#!/usr/bin/env bash
log_file="logs/app.log"
if [ ! -f "$log_file" ]; then
echo "找不到日志文件:$log_file"
exit 1
fi
echo "日志报告"
echo "========"
echo "总行数:$(wc -l < "$log_file")"
echo "错误数:$(grep -c "ERROR" "$log_file")"
echo "信息数:$(grep -c "INFO" "$log_file")"运行:
bash scripts/report.sh如果使用 Git Bash 或 WSL,还可以:
chmod +x scripts/report.sh
./scripts/report.shpwd # 当前路径
ls -la # 查看目录
cd folder # 进入目录
cd .. # 返回上一级
cd ~ # 用户主目录mkdir folder # 创建目录
touch file.txt # 创建文件
cp a b # 复制
mv a b # 移动/重命名
rm file.txt # 删除文件
rm -r folder # 删除目录cat file.txt
head file.txt
tail file.txt
grep "text" file.txt
find . -name "*.sh"
wc -l file.txtbash script.sh
chmod +x script.sh
./script.sh
echo "$variable"Ctrl + C 停止当前命令
Ctrl + L 清屏
Tab 自动补全
↑ / ↓ 浏览历史命令
Ctrl + R 搜索历史命令
前面的内容足够入门,但如果你的明确目标是读懂一个名为 database-common.sh 的数据库公共脚本,还需要掌握本节。
当前工作区里没有名为 database-common.sh 的文件,因此本节按照常见的数据库 Bash 公共脚本来准备。它通常负责读取数据库配置、建立连接、执行 SQL、记录日志、处理错误、重试操作和清理临时文件。
拿到陌生脚本时,先快速找出解释器、变量、函数和外部命令:
head -n 20 database-common.sh
grep -nE '^(function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\(\)' database-common.sh
grep -nE 'source|psql|mysql|sqlite3|DATABASE|DB_|PASSWORD|BEGIN|COMMIT|ROLLBACK' database-common.sh
nl -ba database-common.sh | less建议按这个顺序阅读:
- 第一行是什么解释器,是 Bash 还是普通 sh。
- 文件是否使用 source 被其他脚本加载。
- 哪些变量来自环境变量,哪些变量有默认值。
- 对外提供哪些函数,每个函数需要几个参数。
- 调用了什么数据库客户端。
- 命令失败时是否退出、重试或回滚。
- 最后才逐行研究每个函数的细节。
database-common.sh 往往不是让用户直接运行的,而是这样被加载:
source "./database-common.sh"
run_query "SELECT 1;"下面两种写法等价:
source "./database-common.sh"
. "./database-common.sh"source 表示在当前 Shell 中读取并执行文件,所以文件中定义的变量和函数会继续存在。直接运行则会启动新的进程:
bash database-common.sh这也是为什么公共文件通常没有明显的“主程序入口”,而是集中定义函数。
数据库脚本开头经常出现:
set -euo pipefail它由三部分组成:
- set -e:命令失败时尽早退出,避免错误继续传播。
- set -u:使用未定义变量时报错。
- set -o pipefail:管道中任意命令失败时,整条管道算失败。
例如:
set -u
echo "$UNDEFINED_VARIABLE"为了安全地提供默认值,常见写法是:
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"不要把 set -e 理解成“任何情况下都立即退出”。if 条件、while 条件和某些测试命令有特殊规则,所以仍然要阅读显式的 if、|| 和错误处理。
调试时可能看到:
set -x它会打印实际执行的命令。数据库脚本中如果命令行含有密码,不能随意开启 set -x,否则密码可能进入日志。
常见配置写法:
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:?DB_NAME is required}"
DB_USER="${DB_USER:?DB_USER is required}"这些形式必须能看懂:
| 写法 | 含义 |
|---|---|
| ${VAR} | 读取变量 |
| ${VAR:-default} | 为空时使用默认值 |
| ${VAR:=default} | 为空时设置并使用默认值 |
| ${VAR:?message} | 为空时显示错误并退出 |
| ${VAR:+value} | 有值时使用 value |
推荐从环境变量读取密码,而不是写死在文件里:
export DB_PASSWORD='example-password'
bash migrate.sh检查数据库相关环境变量:
env | grep '^DB_'常见日志函数:
log_info() {
printf '[INFO] %s\n' "$*"
}
log_error() {
printf '[ERROR] %s\n' "$*" >&2
}调用函数:
log_info "开始连接数据库"
log_error "连接失败"数据库函数通常把 SQL 作为第一个参数:
run_query() {
local query="$1"
psql "$DB_NAME" -c "$query"
}
run_query 'SELECT 1;'需要记住:
- local:变量只在当前函数内有效。
- $1、$2:函数的第一个、第二个参数。
- $@:所有参数。
- return 0:成功返回。
- return 1:失败返回。
常见数据库客户端如下:
| 数据库 | 客户端 |
|---|---|
| PostgreSQL | psql |
| MySQL/MariaDB | mysql |
| SQLite | sqlite3 |
| Redis | redis-cli |
PostgreSQL 可能写成:
psql \
--host "$DB_HOST" \
--port "$DB_PORT" \
--username "$DB_USER" \
--dbname "$DB_NAME" \
--command 'SELECT 1;'MySQL 可能写成:
mysql \
--host="$DB_HOST" \
--port="$DB_PORT" \
--user="$DB_USER" \
--database="$DB_NAME" \
--execute='SELECT 1;'SQLite 通常只需要数据库文件:
sqlite3 "$DB_FILE" 'SELECT 1;'读长命令时,把它拆成三部分:客户端名称、连接参数、SQL 参数。
行尾反斜杠表示命令还没有结束:
psql \
--host "$DB_HOST" \
--dbname "$DB_NAME" \
--command 'SELECT 1;'它等价于一行命令。反斜杠后面不要再加空格。
数据库脚本也常用 Here Document:
psql "$DB_NAME" <<'SQL'
SELECT id, name
FROM users
WHERE active = true;
SQL最后单独一行的 SQL 是结束标记。写成 <<'SQL' 时,Shell 不会展开其中的 Bash 变量;写成 <<SQL 时,变量可能会被展开。
执行一条 SQL:
psql "$DB_NAME" -c 'SELECT count(*) FROM users;'执行 SQL 文件:
psql "$DB_NAME" -f migrations/001_init.sql通过标准输入执行:
psql "$DB_NAME" < migrations/001_init.sql看到这些写法时,它们本质上都是“把 SQL 交给数据库客户端执行”,区别只是 SQL 来源不同。
每条命令都会返回状态码:0 表示成功,非 0 表示失败。
if psql "$DB_NAME" -c 'SELECT 1;' >/dev/null; then
echo "数据库连接成功"
else
echo "数据库连接失败" >&2
exit 1
fi也可能看到:
cd "$PROJECT_ROOT" || exit 1
psql "$DB_NAME" -c "$query" || return 1含义是:前一个命令失败时,不允许继续执行后面的危险操作。
PostgreSQL 脚本还可能使用:
psql "$DB_NAME" -v ON_ERROR_STOP=1 -f migration.sql它要求 SQL 出错时客户端返回失败状态,而不是继续执行剩余内容。
涉及数据修改时,重点寻找:
BEGIN;
UPDATE users SET active = true WHERE id = 10;
COMMIT;BEGIN 开始事务,COMMIT 提交修改,ROLLBACK 撤销事务中的修改。
阅读脚本时问自己:
- 是否开启事务。
- SQL 失败时是否停止。
- 是否一定执行 COMMIT。
- 出错路径是否执行 ROLLBACK。
不要在不清楚影响范围时直接运行生产数据库迁移脚本。
公共脚本可能先生成临时 SQL,再交给客户端:
tmp_sql="$(mktemp)"
trap 'rm -f "$tmp_sql"' EXIT
cat > "$tmp_sql" <<'SQL'
SELECT 1;
SQL
psql "$DB_NAME" -f "$tmp_sql"trap ... EXIT 表示脚本结束时执行清理,即使中途失败也会尽量删除临时文件。
数据库脚本可能用 case 区分开发、测试和生产:
case "${APP_ENV:-development}" in
development)
DB_HOST="localhost"
DB_NAME="app_dev"
;;
test)
DB_HOST="test-db"
DB_NAME="app_test"
;;
production)
DB_HOST="prod-db"
DB_NAME="app_prod"
;;
*)
echo "未知环境:$APP_ENV" >&2
exit 1
;;
esac把 case 理解成多个 if/elif 分支即可。
不推荐:
psql --dbname $DB_NAME
rm -rf $BACKUP_DIR推荐:
psql --dbname "$DB_NAME"
rm -rf -- "$BACKUP_DIR"未加引号的变量可能因空格被拆成多个参数,也可能触发通配符展开。数据库名、用户、路径和 SQL 变量通常都应该使用双引号。
长参数列表可能使用 Bash 数组:
psql_args=(
--host "$DB_HOST"
--port "$DB_PORT"
--username "$DB_USER"
--dbname "$DB_NAME"
)
psql "${psql_args[@]}" -c 'SELECT 1;'数组可以避免手动拼接命令字符串,也能更好地处理包含空格的参数。
检查整个文件:
shellcheck database-common.sh优先关注这些提示:
- 变量没有加引号。
- 函数内部变量没有使用 local。
- cd 失败后仍继续执行。
- 管道忽略了前面命令的失败。
- 临时文件没有清理。
- 密码可能出现在命令行或调试日志中。
set -euo pipefail
DB_HOST="${DB_HOST:-localhost}"
DB_NAME="${DB_NAME:?DB_NAME is required}"
run_query() {
local query="$1"
psql \
--host "$DB_HOST" \
--dbname "$DB_NAME" \
--command "$query"
}逐段理解:
- 开启严格模式。
- DB_HOST 可被环境变量覆盖,默认是 localhost。
- DB_NAME 必须存在,否则脚本退出。
- 定义 run_query 函数。
- 函数把第一个参数保存为 SQL。
- 调用 psql 连接数据库。
- 使用双引号保护所有变量。
- psql 失败时返回失败状态,上层脚本可以停止。
可能的调用方式:
export DB_NAME="app_dev"
source ./database-common.sh
run_query 'SELECT 1;'打开实际文件后,至少回答这些问题:
- 使用 Bash 还是 sh。
- 是直接运行还是被 source。
- 必须设置哪些环境变量。
- 哪些变量有默认值。
- 哪些变量为空时会退出。
- 提供了哪些函数。
- 每个函数接受几个参数。
- 调用的是 psql、mysql 还是 sqlite3。
- SQL 通过 -c、-f、重定向还是 Here Document 传入。
- 数据库客户端失败时会不会停止。
- 是否有事务、提交和回滚。
- 是否创建临时文件,退出时是否清理。
- 密码是否可能泄露到日志。
- 路径和变量是否正确加引号。
如果这些问题都能回答,你就不只是会背 Shell 命令,而是已经具备用“读懂数据库公共脚本”的能力。
本工作区的 solutions 文件已经包含了很多同类语法,可以按下面顺序阅读:
- solutions/Solution1.sh:练习 cd、mkdir、cp、mv、wc 和失败退出。
- solutions/Solution2.sh:练习命令替换、sort、uniq、循环和 awk。
- solutions/Solution3.sh:练习 find、grep、变量、条件判断和重定向。
- setup.sh:练习 shebang、mkdir -p、Here Document、for 循环和变量展开。
先不要追求一次看懂全部内容。每次只回答一个问题:这个变量从哪里来、这个函数输出什么、这个命令失败时会发生什么。
这一章不是要求一次背完,而是一个长期速查表。遇到陌生代码时,先找对应关键词。
脚本运行:
bash greet.sh Alice Bob脚本中常见的特殊变量:
echo "$0" # 脚本名称
echo "$1" # 第一个参数
echo "$2" # 第二个参数
echo "$#" # 参数数量
echo "$@" # 所有参数
echo "$?" # 上一条命令的退出状态
echo "$$" # 当前 Shell 的进程号把所有参数转发给另一个命令:
some_command "$@"shift 会丢弃当前第一个参数:
while [ "$#" -gt 0 ]; do
echo "当前参数:$1"
shift
donegetopts 用来解析 -f 文件、-n 数量这类选项:
while getopts "f:n:" option; do
case "$option" in
f) file="$OPTARG" ;;
n) count="$OPTARG" ;;
*) echo "用法:$0 -f 文件 -n 数量"; exit 1 ;;
esac
donea=10
b=3
echo $((a + b))
echo $((a - b))
echo $((a * b))
echo $((a / b))
echo $((a % b))增加和减少:
count=0
((count++))
((count += 2))双括号也能直接判断数字:
if (( count > 0 )); then
echo "数量大于零"
ficase "$action" in
start)
echo "启动"
;;
stop)
echo "停止"
;;
restart|reload)
echo "重启或重新加载"
;;
*)
echo "未知操作"
exit 1
;;
esac每个分支用双分号结束,星号分支相当于 else。
while:条件为真时重复:
count=0
while [ "$count" -lt 3 ]; do
echo "$count"
((count++))
doneuntil:条件变真之前一直重复:
until ping -c 1 "$DB_HOST" >/dev/null 2>&1; do
echo "等待数据库主机..."
sleep 2
done循环控制:
for file in *.log; do
[ -f "$file" ] || continue
[ "$file" = "stop.log" ] && break
echo "$file"
done- continue:跳过本次循环。
- break:结束整个循环。
get_count() {
local file="$1"
grep -c "ERROR" "$file"
}
count="$(get_count app.log)"函数通过 echo 或 printf 输出文字,调用者可以用命令替换保存输出。
函数通过 return 返回成功或失败:
require_file() {
if [ ! -f "$1" ]; then
return 1
fi
return 0
}
if require_file app.log; then
echo "文件存在"
fi- return 结束函数。
- exit 结束整个脚本。
*.log # 任意多个字符
file?.txt # 任意一个字符
file[0-9].txt # 一个数字
file[ab].txt # a 或 b遍历通配符结果:
for file in logs/*.log; do
[ -e "$file" ] || continue
echo "$file"
done没有匹配文件时,先用 -e 检查,避免把原始的 logs/*.log 当成文件名。
Bash 数组用于保存多个值:
files=("a.log" "b.log" "file with spaces.log")读取某一个元素:
echo "${files[0]}"
echo "${files[1]}"遍历全部元素:
for file in "${files[@]}"; do
echo "$file"
done读取数组数量:
echo "${#files[@]}"数据库命令的参数也可以放进数组:
args=(--host "$DB_HOST" --dbname "$DB_NAME")
psql "${args[@]}" -c "SELECT 1;"数组比手动拼接字符串更安全,尤其适合参数很多或参数中含空格的情况。
双引号会展开变量:
name="Alice"
echo "Hello, $name"单引号不会展开变量:
echo 'Hello, $name'反斜杠可以转义字符:
echo "他说:\"你好\""包含空格的路径要加引号:
cat "folder with spaces/file.txt"command > file # 覆盖标准输出
command >> file # 追加标准输出
command 2> file # 覆盖错误输出
command 2>> file # 追加错误输出
command > file 2>&1 # 正常输出和错误都写入 file
command < file # 从文件读取标准输入
command <<EOF # Here Document
command <<< "$text" # Here String丢弃所有输出:
command >/dev/null 2>&1/dev/null 可以理解为输出黑洞。
圆括号会开启子 Shell:
(cd /tmp && echo "子 Shell:$PWD")
echo "外层 Shell:$PWD"子 Shell 的目录变化不会影响外层。
大括号可以把多条命令的输出作为整体重定向:
{
echo "第一行"
echo "第二行"
} > output.txt进程替换把命令输出伪装成文件:
diff <(sort old.txt) <(sort new.txt)看到 <(command) 时,理解成“把 command 的输出当作临时输入文件”。
cut:按分隔符取列。
cut -d',' -f1 data.csvawk:按列计算。
awk -F',' 'NR > 1 {sum += $3} END {print sum}' data.csvsed:替换或选取行。
sed 's/old/new/g' file.txt
sed -n '1,10p' file.txttr:转换字符。
echo "hello" | tr 'a-z' 'A-Z'xargs:把输入变成命令参数。
find . -name "*.tmp" -print0 | xargs -0 ls -lecho "$PWD" # 当前目录
echo "$OLDPWD" # 上一次目录
echo "$HOME" # 用户目录
echo "$PATH" # 命令搜索路径
echo "$USER" # 当前用户
echo "$SHELL" # 默认 Shell检查命令是否存在:
command -v git
command -v psql如果出现 command not found,依次检查拼写、软件安装情况和 PATH。
后台运行:
long_command &查看和切换任务:
jobs
fg
bg查看进程:
ps aux
pgrep -af process_name结束进程:
kill PID
kill -TERM PID
kill -KILL PID优先使用 TERM,让程序有机会清理;KILL 只在最后使用。
简单重试:
for attempt in 1 2 3; do
if command; then
break
fi
echo "失败,准备重试"
sleep 2
done限制执行时间:
timeout 30s psql "$DB_NAME" -c "SELECT 1;"退出时清理:
tmp_file="$(mktemp)"
cleanup() {
rm -f "$tmp_file"
}
trap cleanup EXITset -e # 命令失败时退出
set -u # 未定义变量报错
set -o pipefail # 管道失败不会被隐藏
set -x # 打印执行的命令
set +x # 关闭命令打印含密码的数据库命令不要在 set -x 下运行,否则密码可能出现在日志。
遇到新文件时按这个顺序:
- 查看第一行解释器。
- 搜索 set、export、source 和 trap。
- 找出所有函数名称。
- 找出 DB_、DATABASE、PASSWORD 变量。
- 找出 psql、mysql 或 sqlite3。
- 看 SQL 从哪里进入客户端。
- 看命令失败时返回什么。
- 看事务、提交和回滚。
- 看临时文件和清理逻辑。
- 用 ShellCheck 检查不熟悉的写法。
你不需要一次理解每个字符。先给每段写一句中文摘要,再回头确认细节。
建议按照这个顺序练习:
pwd、ls、cdmkdir、touch、cp、mv、rmcat、head、tailgrep、find、wc>、>>、|- 变量、条件、循环
- 编写和运行
.sh脚本
每天用终端完成一个小任务,比只背命令更有效。对于删除、覆盖写入和批量处理命令,先用 pwd、ls -la 检查位置,再执行操作。