第四期:环境变量凭空消失?——Shell 初始化机制与脚本执行的那些坑
本文深入剖析Linux Shell初始化机制,直击嵌入式开发中“环境变量凭空消失”的痛点。通过对比SSH与串口登录差异,揭示Login Shell中“减号”的关键作用,详解/etc/profile加载逻辑及rcS export失效的根本原因。文章涵盖Shebang陷阱、execve传参误区及Wrapper脚本优雅解法,并新增PS1提示符调试技巧。提供大量可复现的实验命令与排查思路,助开发者彻底掌握
无限进步!
第四期:环境变量凭空消失?——Shell 初始化机制与脚本执行的那些坑
📌 系列定位:嵌入式 Linux 系统调试实战系列(共 5 期)
🎯 本期目标:彻底搞懂 Shell 初始化机制,解决环境变量丢失、脚本执行异常等核心问题
⏱️ 阅读时间:约 18 分钟
💻 适用场景:嵌入式开发、系统调试、自动化运维

开篇提问
你是否遇到过这些令人抓狂的问题?
| 问题 | 现象 | 常见误区 |
|---|---|---|
| 1. SSH vs 串口环境变量不一致 | SSH 登录后 PATH 完整,串口登录后 PATH 缺失 |
以为是系统 bug,反复重启无效 |
| 2. rcS 里 export 不生效 | 启动脚本里明明写了 export,Shell 里却是空的 |
怀疑脚本没执行,反复加日志 |
| 3. 脚本没写 Shebang 也能跑 | 有时候能跑,有时候报语法错误 | 以为是权限问题,chmod +x 试遍 |
4. 提示符突然变成 $ |
原本漂亮的 user@host:~$ 变成了简单的 $ |
以为是主题配置问题 |
本期结束后,你将能够:
- ✅ 一眼判断当前 Shell 是 Login 还是 Non-login
- ✅ 快速定位环境变量丢失的根本原因
- ✅ 编写兼容性强的 Shell 脚本
- ✅ 用 Wrapper 脚本优雅解决串口环境问题
- ✅ 自定义和调试 Shell 提示符 PS1
一、Login Shell vs Non-login Shell:一个减号的巨大差别

1.1 核心概念对比
Shell 启动时有一个关键的身份区分,这个区分决定了它会加载哪些配置文件:
| 类型 | 判断方法 | 核心特点 | 典型场景 |
|---|---|---|---|
| Login Shell | echo $0 显示 -sh 或 -bash(有减号) |
自动读取 /etc/profile、~/.profile |
SSH 登录、su -、login 命令 |
| Non-login Shell | echo $0 显示 sh 或 bash(无减号) |
不读取 /etc/profile,只读 ~/.bashrc |
已登录后新开终端、脚本执行 |
| Non-interactive Shell | 执行脚本时自动变为此模式 | 基本不读取任何配置文件 | bash script.sh、cron 任务 |
1.2 那个减号是怎么来的?
答案很直接——是启动 Shell 的程序(sshd、getty 等)在调用 exec 时故意加上去的:
// sshd 源码简化示例(openssh/session.c)
void do_exec_child(void) {
// 构造 argv[0],加上减号前缀
char shell_name[256];
snprintf(shell_name, sizeof(shell_name), "-%s", shell_basename);
// 启动 Shell,argv[0] 变成 "-bash" 或 "-sh"
execl(shell_path, shell_name, NULL);
// ↑ 这里的减号!让 Shell 知道自己是 Login Shell
}
而 Shell 程序内部会检查这个减号:
// bash 源码简化示例(shell.c)
int main(int argc, char *argv[]) {
// 检查 argv[0] 的第一个字符是否是减号
int login_shell = (argv[0] && argv[0][0] == '-');
if (login_shell) {
// Login Shell 的初始化流程
source_file("/etc/profile"); // 系统级环境变量配置
source_file("~/.bash_profile"); // 用户级配置(bash 优先)
source_file("~/.bash_login"); // 用户级配置(备选)
source_file("~/.profile"); // 用户级配置(最后备选)
} else {
// Non-login Shell 的初始化流程
if (interactive) {
source_file("~/.bashrc"); // 只读 bashrc
}
}
// ... 进入命令循环
run_shell_loop();
}
1.3 重点结论
🔑 核心知识点:读取
/etc/profile是 Shell 程序自己实现的逻辑,不是内核机制。内核不关心什么 Login Shell——它只是把
argv[0]原样传给新进程而已。减号是约定俗成的标识,不是强制规范。
1.4 快速验证命令
# 验证 1:查看当前 Shell 类型
echo $0
# 输出示例:-bash (Login) 或 bash (Non-login)
# 验证 2:查看 Shell 进程名(更准确)
ps -p $$ -o comm=
# 输出示例:-bash 或 bash
# 验证 3:检查是否加载了 profile
echo $PROFILE_LOADED # 需先在 /etc/profile 中设置测试变量
# 验证 4:查看 Shell 启动参数
cat /proc/$$/cmdline | tr '\0' ' '
# 输出示例:-bash 或 bash
二、三种入口的初始化差异

了解了 Login Shell 的机制后,让我们看看为什么不同的登录方式会导致不同的环境变量状态。
2.1 SSH 入口(环境变量完整 ✅)
┌─────────────────────────────────────────────────────────┐
│ SSH 登录流程 │
├─────────────────────────────────────────────────────────┤
│ init (PID 1) │
│ │ │
│ ▼ │
│ sshd (守护进程,监听 22 端口) │
│ │ │
│ ▼ 用户认证通过 │
│ fork() 创建子进程 │
│ │ │
│ ▼ │
│ exec("-bash") ← argv[0] 带减号! │
│ │ │
│ ▼ │
│ bash 检测到减号 → 判定为 Login Shell │
│ │ │
│ ▼ │
│ source /etc/profile → 加载环境变量 ✅ │
│ │ │
│ ▼ │
│ 用户获得完整环境 (PATH, HOME, LD_LIBRARY_PATH...) │
└─────────────────────────────────────────────────────────┘
2.2 串口入口(环境变量缺失 ❌)
┌─────────────────────────────────────────────────────────┐
│ 串口登录流程 │
├─────────────────────────────────────────────────────────┤
│ init (PID 1) │
│ │ │
│ ▼ 解析 /etc/inittab │
│ getty -n -l /bin/sh 115200 ttyS0 │
│ │ │
│ ▼ 用户输入用户名密码 │
│ login 程序验证 │
│ │ │
│ ▼ │
│ exec("/bin/sh") ← argv[0] 无减号! │
│ │ │
│ ▼ │
│ sh 检测无减号 → 判定为 Non-login Shell │
│ │ │
│ ▼ │
│ 不 source /etc/profile → 环境变量缺失 ❌ │
│ │ │
│ ▼ │
│ 用户获得最小环境 (可能只有 PATH=/bin) │
└─────────────────────────────────────────────────────────┘
2.3 HDC Shell 入口(环境变量缺失 ❌)
┌─────────────────────────────────────────────────────────┐
│ HDC Shell 流程 │
├─────────────────────────────────────────────────────────┤
│ init (PID 1) │
│ │ │
│ ▼ │
│ hdcd (HDC 守护进程) │
│ │ │
│ ▼ 客户端连接 │
│ fork() 创建子进程 │
│ │ │
│ ▼ │
│ exec("/bin/sh") ← argv[0] 无减号! │
│ │ │
│ ▼ │
│ sh 检测无减号 → 判定为 Non-login Shell │
│ │ │
│ ▼ │
│ 不 source /etc/profile → 环境变量缺失 ❌ │
└─────────────────────────────────────────────────────────┘
2.4 关键洞察
⚠️ 重要:三者是完全独立的进程分支,互不继承环境变量:
init (PID 1) ├── getty → /bin/sh ← 串口的环境(独立分支) ├── sshd → -bash ← SSH 的环境(独立分支) └── hdcd → /bin/sh ← HDC 的环境(独立分支)每个分支的环境变量从 fork 时刻就确定了,后续无法跨分支共享。
2.5 进程树可视化
# 查看完整进程树
pstree -p | grep -E 'sshd|getty|hdcd|sh'
# 示例输出:
# ├─sshd(1234)─┬─sshd(5678)───bash(5679) # SSH 登录的 Login Shell
# ├─getty(2345)───sh(2346) # 串口的 Non-login Shell
# └─hdcd(3456)─┬─sh(3457) # HDC 的 Non-login Shell
三、配置文件加载顺序

如果你用的是 bash,完整的配置文件加载顺序是:
3.1 Login Shell 启动时
┌─────────────────────────────────────────────────────────┐
│ Login Shell 配置文件加载顺序 │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. /etc/profile ← 系统级配置,所有用户生效 │
│ │ │
│ ▼ 通常会 source 以下目录 │
│ /etc/profile.d/*.sh ← 模块化配置(推荐) │
│ │ │
│ ▼ │
│ 2. ~/.bash_profile ← 用户级(如果存在,bash 优先)│
│ │ │
│ ▼ 如果不存在,尝试 │
│ 3. ~/.bash_login ← 用户级(如果存在) │
│ │ │
│ ▼ 如果不存在,尝试 │
│ 4. ~/.profile ← 用户级(POSIX 兼容) │
│ │
│ ⚠️ 注意:2、3、4 只执行第一个存在的文件! │
└─────────────────────────────────────────────────────────┘
3.2 Interactive Non-login Shell 启动时
┌─────────────────────────────────────────────────────────┐
│ Interactive Non-login Shell 配置文件加载 │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. ~/.bashrc ← 仅此一个 │
│ │
│ 💡 最佳实践:在 ~/.bash_profile 中添加: │
│ if [ -f ~/.bashrc ]; then │
│ . ~/.bashrc │
│ fi │
│ │
└─────────────────────────────────────────────────────────┘
3.3 Non-interactive Shell(脚本执行)
┌─────────────────────────────────────────────────────────┐
│ Non-interactive Shell 行为 │
├─────────────────────────────────────────────────────────┤
│ │
│ 默认行为:不读取任何配置文件 │
│ │
│ 例外:如果设置了 $BASH_ENV 环境变量 │
│ → 会 source $BASH_ENV 指向的文件 │
│ │
│ 示例: │
│ export BASH_ENV=~/.bash_env │
│ bash script.sh # 会自动 source ~/.bash_env │
│ │
└─────────────────────────────────────────────────────────┘
3.4 嵌入式系统特殊情况
在嵌入式系统中(如 BusyBox、OpenHarmony),通常配置更简单:
| 系统类型 | 主要配置文件 | 说明 |
|---|---|---|
| BusyBox | /etc/profile |
通常只有这一个 |
| OpenHarmony | /etc/profile + /etc/profile.d/ |
支持模块化 |
| Android | init.rc + adb 特殊处理 |
不依赖传统 profile |
3.5 配置文件加载调试技巧
# 技巧 1:在 profile 中加日志
echo "DEBUG: /etc/profile loaded at $(date)" >> /tmp/profile_debug.log
# 技巧 2:追踪文件读取(需要 strace)
strace -e open,access bash -l 2>&1 | grep -E 'profile|bashrc'
# 技巧 3:查看已加载的函数和变量
declare -f | head -20 # 查看已定义的函数
declare -p | head -20 # 查看已定义的变量
四、为什么 rcS 里 export 不生效?

很多人的第一反应是:把环境变量放到启动脚本 /etc/init.d/rcS 里。
4.1 错误示范
#!/bin/sh
# /etc/init.d/rcS 的内容
export MY_VAR="hello"
export LD_LIBRARY_PATH="/usr/local/lib:$LD_LIBRARY_PATH"
export PATH="/usr/local/bin:$PATH"
# ... 其他启动命令 ...
/etc/init.d/S50network start
/etc/init.d/S60dbus start
# 脚本执行完毕,进程退出
exit 0
然后满怀期待地打开 Shell:
$ echo $MY_VAR
$ # 空的!为什么?!
4.2 根本原因分析
原因:rcS 是一个独立的进程。export 只在它自己内部生效。当 rcS 执行完退出后,它的进程和环境变量一起消失了。后续启动的 Shell 是新进程,不会继承已经退出的 rcS 的环境变量。
┌─────────────────────────────────────────────────────────┐
│ 进程生命周期示意 │
├─────────────────────────────────────────────────────────┤
│ │
│ rcS 进程 (PID 100) │
│ │ │
│ ├─ export MY_VAR="hello" ← 写入 rcS 进程的环境空间 │
│ ├─ export PATH="..." ← 写入 rcS 进程的环境空间 │
│ │ │
│ ▼ rcS 执行完毕 │
│ exit 0 │
│ │ │
│ ▼ 进程销毁 │
│ 所有环境变量随进程一起消失 ❌ │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ 你的 Shell (PID 200) ← 新进程,与 rcS 无继承关系 │
│ │ │
│ ▼ │
│ echo $MY_VAR → 空! │
│ │
└─────────────────────────────────────────────────────────┘
4.3 环境变量继承规则
┌─────────────────────────────────────────────────────────┐
│ 环境变量继承规则(Linux) │
├─────────────────────────────────────────────────────────┤
│ │
│ ✅ 可以继承:父进程 → 子进程(fork 时复制) │
│ │
│ ❌ 不能继承: │
│ • 子进程 → 父进程(反向不行) │
│ • 兄弟进程之间(独立分支) │
│ • 已退出进程 → 新进程(进程已销毁) │
│ │
│ 💡 记忆口诀:环境变量只能"向下传",不能"向上传" │
│ │
└─────────────────────────────────────────────────────────┘
4.4 正确做法
方案一:放入 /etc/profile(推荐)
# /etc/profile
export MY_VAR="hello"
export LD_LIBRARY_PATH="/usr/local/lib:$LD_LIBRARY_PATH"
export PATH="/usr/local/bin:$PATH"
这样每个 Login Shell 启动时都会自动 source 它。
方案二:放入 /etc/profile.d/ 目录(更优雅)
# /etc/profile.d/myvars.sh
export MY_VAR="hello"
export LD_LIBRARY_PATH="/usr/local/lib:$LD_LIBRARY_PATH"
# 记得加执行权限
chmod +x /etc/profile.d/myvars.sh
方案三:针对 Non-login Shell 的补充
# 在 ~/.bashrc 中也添加(如果 Non-login Shell 也需要)
if [ -d /etc/profile.d ]; then
for i in /etc/profile.d/*.sh; do
[ -r "$i" ] && . "$i"
done
fi
4.5 验证方法
# 验证 1:新开 SSH 连接后检查
ssh user@device
echo $MY_VAR # 应该显示 "hello"
# 验证 2:检查 profile 是否被 source
grep -r "MY_VAR" /etc/profile /etc/profile.d/
# 验证 3:模拟 Login Shell 测试
bash -l -c 'echo $MY_VAR' # 应该显示 "hello"
bash -c 'echo $MY_VAR' # 可能为空(Non-login)
五、execve 与环境变量传递的陷阱

环境变量的传递还有一个更深层的坑——execve 系统调用。
5.1 execve 系统调用详解
#include <unistd.h>
int execve(const char *pathname, char *const argv[], char *const envp[]);
// ↑ ↑
// 程序路径 环境变量数组
当父进程用 execve 创建子进程时,第三个参数 envp 决定了子进程拥有哪些环境变量:
5.2 正确 vs 危险用法
// ✅ 正确:子进程继承父进程的所有环境变量
extern char **environ; // 全局变量,包含当前进程所有环境变量
execve("/bin/sh", argv, environ);
// ❌ 危险:子进程没有任何环境变量!
execve("/bin/sh", argv, NULL);
// ⚠️ 注意:传 NULL 和传空数组不一样
execve("/bin/sh", argv, (char *[]){NULL}); // 有空环境但无变量
5.3 环境变量丢失的传播链
如果某个守护进程在启动子进程时不小心传了 NULL,那么整条进程链下去的所有子进程都将丢失环境变量:
┌─────────────────────────────────────────────────────────┐
│ 环境变量丢失传播链 │
├─────────────────────────────────────────────────────────┤
│ │
│ init (PID 1) │
│ │ 环境变量:PATH, HOME, LD_PRELOAD... (完整) │
│ │ │
│ ▼ execve(..., NULL) ← 环境变量在这里被"截断" │
│ │
│ 中间进程 (PID 100) │
│ │ 环境变量:无 ❌ │
│ │ │
│ ▼ execve("/bin/sh", argv, environ) │
│ │
│ 你的 Shell (PID 200) │
│ │ 环境变量:无 ❌ │
│ │ 没有 PATH,没有 HOME,什么都没有 │
│ │ │
│ ▼ │
│ 执行任何命令都失败:command not found │
│ │
└─────────────────────────────────────────────────────────┘
5.4 真实案例分析
案例:某嵌入式设备串口登录后 ls 命令找不到
# 现象
$ ls
-sh: ls: not found
# 排查
$ echo $PATH
# 空!
$ cat /proc/$$/environ | tr '\0' '\n'
# 只有少数几个变量,没有 PATH
# 根因
# 某守护进程启动子进程时用了 execve(..., NULL)
# 导致后续所有子进程都没有环境变量
5.5 调试方法
# 方法 1:查看任意进程的环境变量
cat /proc/<pid>/environ | tr '\0' '\n'
# 方法 2:用 strings 查看(更友好)
strings /proc/<pid>/environ
# 方法 3:查看特定变量是否存在
strings /proc/<pid>/environ | grep PATH
# 方法 4:对比正常和异常进程的环境
strings /proc/1/environ | sort > /tmp/init_env.txt
strings /proc/$$/environ | sort > /tmp/shell_env.txt
diff /tmp/init_env.txt /tmp/shell_env.txt
# 方法 5:使用 printenv 命令
printenv # 打印所有环境变量
printenv PATH # 打印特定变量
5.6 编程最佳实践
// ✅ 推荐:始终传递 environ
#include <unistd.h>
extern char **environ;
void spawn_shell(char *const argv[]) {
execve("/bin/sh", argv, environ); // 保留所有环境变量
}
// ✅ 推荐:需要修改环境时,复制后修改
void spawn_with_custom_env(char *const argv[]) {
// 复制当前环境
char **new_env = copy_environ(environ);
// 添加/修改特定变量
add_to_environ(&new_env, "MY_VAR", "hello");
// 执行
execve("/bin/sh", argv, new_env);
// 清理(execve 成功后不会执行到这里)
free_environ(new_env);
}
// ❌ 避免:除非明确知道不需要环境变量
void spawn_minimal(char *const argv[]) {
char *minimal_env[] = {
"PATH=/bin:/usr/bin",
"HOME=/root",
NULL
};
execve("/bin/sh", argv, minimal_env); // 仅当确实需要最小环境时
}
六、Wrapper 脚本:优雅的解决方案

对于串口或 HDC Shell 环境变量缺失的问题,有一个简洁的解决方案——Wrapper 脚本:
6.1 完整实现
#!/bin/sh
# /usr/bin/login_shell_wrapper.sh
# 用途:为 Non-login Shell 加载环境变量
# 1. 在当前进程中加载环境变量
# 注意:必须用 source/. 而不是直接执行
if [ -f /etc/profile ]; then
. /etc/profile
fi
# 2. 加载 profile.d 中的模块化配置
if [ -d /etc/profile.d ]; then
for script in /etc/profile.d/*.sh; do
if [ -r "$script" ]; then
. "$script"
fi
done
fi
# 3. 用 exec 替换当前进程为真正的 Shell
# exec 会保留当前进程的环境变量,替换为新的程序
exec /bin/sh "$@"
6.2 三行代码,三个关键动作
| 行 | 代码 | 作用 | 为什么重要 |
|---|---|---|---|
| 1 | #!/bin/sh |
指定解释器 | 确保脚本用正确的 Shell 执行 |
| 2 | . /etc/profile |
在当前进程中加载环境变量 | 不是创建子进程,变量会保留 |
| 3 | exec /bin/sh |
用 Shell 替换当前进程 | 保留环境变量,PID 不变,无多余进程 |
6.3 配置修改
修改 getty 配置(/etc/inittab):
# 修改前
ttyS0::respawn:/sbin/getty -n -l /bin/sh 115200 ttyS0
# 修改后
ttyS0::respawn:/sbin/getty -n -l /usr/bin/login_shell_wrapper.sh 115200 ttyS0
# 使配置生效
init q # 或 reboot
修改 HDC 配置(具体路径因系统而异):
# 查找 hdcd 配置文件
find /etc -name "*hdc*" -o -name "*hdcd*"
# 修改启动参数(示例)
# 将 /bin/sh 改为 /usr/bin/login_shell_wrapper.sh
6.4 进阶:带日志的 Wrapper
#!/bin/sh
# /usr/bin/login_shell_wrapper.sh(带调试版本)
# 记录启动日志
echo "[$(date)] Wrapper started, PID=$$" >> /tmp/wrapper.log
# 加载环境变量
if [ -f /etc/profile ]; then
. /etc/profile
echo "[$(date)] /etc/profile loaded" >> /tmp/wrapper.log
fi
# 验证关键变量
echo "[$(date)] PATH=$PATH" >> /tmp/wrapper.log
# 启动 Shell
exec /bin/sh "$@"
6.5 注意事项
| 问题 | 解决方案 |
|---|---|
| Wrapper 脚本本身需要 PATH | 使用绝对路径调用命令(如 /bin/echo) |
| 循环 source 导致死循环 | 检查 profile 中是否又调用了 wrapper |
| 权限问题 | chmod +x /usr/bin/login_shell_wrapper.sh |
| SELinux 限制 | 检查并设置正确的安全上下文 |
七、Shell、sh、bash、dash 傻傻分不清?

“Shell” 是一个泛指概念,指命令行解释器。而 sh、bash、dash 是具体的实现:
7.1 Shell 家族谱系
"shell" = 命令行解释器(泛称)
│
├── sh (POSIX shell 标准)
│ ├── bash (Bourne Again Shell) ← 功能最丰富,最常用
│ ├── dash (Debian Almquist Shell) ← 轻量快速
│ ├── ash (BusyBox 内置) ← 嵌入式常用
│ └── ksh (Korn Shell) ← 企业级
│
└── 其他 shell
├── zsh (功能强大,oh-my-zsh)
├── fish (友好交互)
├── csh/tcsh (C 语法风格)
└── PowerShell (Windows)
7.2 关键区别对比
| 特性 | bash | dash | ash (BusyBox) |
|---|---|---|---|
| 大小 | ~1MB | ~100KB | ~50KB |
| 启动速度 | 较慢 | 极快 | 极快 |
| 功能 | 丰富(数组、关联数组等) | 基础(POSIX 兼容) | 基础(精简) |
| 默认系统 | RHEL/CentOS/Fedora | Ubuntu/Debian | 嵌入式系统 |
/bin/sh 指向 |
bash | dash | ash |
7.3 检查你的系统
# 检查 /bin/sh 指向
ls -l /bin/sh
# 输出示例:
# /bin/sh -> bash (Ubuntu 桌面版)
# /bin/sh -> dash (Ubuntu 服务器版)
# /bin/sh -> /bin/ash (嵌入式 BusyBox 系统)
# 检查 bash 版本
bash --version
# 检查 dash
dpkg -l | grep dash # Debian/Ubuntu
rpm -qa | grep dash # RHEL/CentOS
# 检查 BusyBox
busybox --list | grep sh
7.4 兼容性测试
# 测试脚本兼容性
# 创建 test.sh
cat > /tmp/test.sh << 'EOF'
#!/bin/sh
array=(1 2 3)
echo ${array[0]}
EOF
# 用不同 Shell 执行
bash /tmp/test.sh # ✅ 输出:1
dash /tmp/test.sh # ❌ 报错:Syntax error: "(" unexpected
sh /tmp/test.sh # 取决于 sh 指向谁
八、Shebang 的重要性:不写会怎样?

脚本第一行的 #!/bin/sh 叫做 Shebang(也有人叫 hashbang)。
8.1 Shebang 工作原理
┌─────────────────────────────────────────────────────────┐
│ Shebang 执行流程 │
├─────────────────────────────────────────────────────────┤
│ │
│ 用户执行:./script.sh │
│ │ │
│ ▼ 内核读取文件前两个字节 │
│ 检查是否为 "#!" (0x23 0x21) │
│ │ │
│ ├─ 是 → 读取 Shebang 行,提取解释器路径 │
│ │ │ │
│ │ ▼ │
│ │ execve(解释器,script.sh, env) │
│ │ │
│ └─ 否 → 返回 ENOEXEC 错误 │
│ │ │
│ ▼ │
│ 当前 Shell 捕获错误 │
│ │ │
│ ▼ │
│ 用当前 Shell 解释执行 │
│ │
└─────────────────────────────────────────────────────────┘
8.2 有 Shebang vs 无 Shebang
有 Shebang:
#!/bin/bash
array=(1 2 3) # bash 数组语法
echo ${array[0]}
执行时,内核读取第一行,用 /bin/bash 来解释脚本。
没有 Shebang:
array=(1 2 3)
echo ${array[0]}
执行时:
- 内核找不到
#!行,返回ENOEXEC错误 - 当前 Shell 捕获错误,用自己来解释脚本
- 如果当前 Shell 是
dash(Ubuntu 的/bin/sh),它不认识 bash 的数组语法:./test.sh: 1: Syntax error: "(" unexpected
8.3 Shebang 格式详解
#!/bin/sh # 绝对路径(推荐)
#!/bin/bash # 绝对路径
#!/usr/bin/env bash # 在 PATH 中搜索 bash(更通用)
#!/usr/bin/env -S bash # 支持多个参数(较新系统)
# ⚠️ 注意:
# • Shebang 必须是文件的第一行
# • #! 后面不能有空格
# • 路径必须是绝对路径
# • 整行不能超过 128 字符(某些系统限制)
8.4 最佳实践
| 场景 | 推荐 Shebang | 理由 |
|---|---|---|
| 通用脚本 | #!/bin/sh |
POSIX 兼容,所有系统都有 |
| 需要 bash 特性 | #!/bin/bash |
明确依赖 bash |
| 跨系统脚本 | #!/usr/bin/env bash |
在 PATH 中搜索,更灵活 |
| Python 脚本 | #!/usr/bin/env python3 |
避免硬编码路径 |
| 嵌入式系统 | #!/bin/sh |
BusyBox ash 兼容 |
8.5 常见错误
# ❌ 错误 1:#! 前有空格
#!/bin/sh
# ❌ 错误 2:#! 后有空格
#! /bin/sh
# ❌ 错误 3:使用相对路径
#!./bin/sh
# ❌ 错误 4:Shebang 不在第一行
# 注释
#!/bin/sh
# ❌ 错误 5:Windows 换行符
#!/bin/sh\r # CRLF 会导致 "bad interpreter" 错误
8.6 调试 Shebang 问题
# 检查文件是否有正确的 Shebang
head -1 script.sh
# 检查换行符格式
file script.sh
# 输出应包含 "ASCII text",不应有 "CRLF"
# 转换换行符(如果有 CRLF 问题)
dos2unix script.sh
# 检查执行权限
ls -l script.sh
chmod +x script.sh
# 追踪执行过程
strace -e execve ./script.sh 2>&1 | head -20
九、exec 命令:替换而非创建

exec 在 Shell 中是一个被低估的命令。它的核心作用是替换当前进程:
9.1 普通执行 vs exec 执行
┌─────────────────────────────────────────────────────────┐
│ 普通执行 sh(创建子进程) │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌────────────┐ │
│ │ 当前 Shell │ ──→ │ 新 Shell │ (子进程) │
│ │ PID 100 │ │ PID 101 │ │
│ └────────────┘ └────────────┘ │
│ │ │ │
│ │ 等待子进程退出 │ │
│ │ ←─────────────────┘ │
│ │ │
│ ▼ │
│ 当前 Shell 继续运行 │
│ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ exec sh(替换当前进程) │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌────────────┐ │
│ │ 当前 Shell │ ──→ │ 新 Shell │ (替换!) │
│ │ PID 100 │ │ PID 100 │ ← 同一个 PID │
│ └────────────┘ └────────────┘ │
│ │
│ 当前 Shell 不复存在,完全被新进程替代 │
│ │
└─────────────────────────────────────────────────────────┘
9.2 实验验证
# 实验 1:普通执行
$ echo $$
100
$ sh
$ echo $$
101 # PID 变了,是子进程
$ exit
$ echo $$
100 # 回到原 Shell
# 实验 2:exec 执行
$ echo $$
100
$ exec sh
$ echo $$
100 # PID 不变,是同一个进程
$ exit
# 直接退出到登录界面(原 Shell 被替换了)
9.3 exec 的常见用途
| 用途 | 命令示例 | 说明 |
|---|---|---|
| 替换进程 | exec /bin/sh |
Wrapper 脚本中使用 |
| 重定向文件描述符 | exec > /tmp/log.txt |
后续所有输出重定向 |
| 关闭文件描述符 | exec 3>&- |
关闭 fd 3 |
| 在脚本末尾 | exec python app.py |
脚本执行完后替换为 Python 进程 |
9.4 Wrapper 脚本中 exec 的精髓
#!/bin/sh
# /usr/bin/login_shell_wrapper.sh
# 1. 在当前进程中加载环境变量
. /etc/profile
# 2. 用 exec 替换当前进程为真正的 Shell
exec /bin/sh "$@"
为什么用 exec?
| 不用 exec | 用 exec |
|---|---|
| 会多出一个 wrapper 进程 | 无多余进程 |
| 环境变量在子进程中,可能有继承问题 | 环境变量直接传递给新 Shell |
| PID 变化,调试复杂 | PID 不变,进程树清晰 |
| 需要等待子进程退出 | 直接替换,效率更高 |
十、PS1 提示符:Shell 身份的可视化标识

💡 为什么要把 PS1 单独拿出来讲?
PS1 是最直观反映 Shell 初始化状态的"信号灯"。提示符异常往往意味着 profile 未正确加载,是调试环境变量问题的第一线索。
10.1 PS1 是什么?
PS1(Prompt String 1)是 Shell 的主提示符环境变量,决定了你看到的命令行提示样式:
# 常见 PS1 样式
user@hostname:~$ # Ubuntu/Debian 默认
[root@localhost ~]# # CentOS/RHEL 默认
$ # 最小化提示符(profile 未加载)
> # 续行提示符(PS2)
10.2 PS1 的加载时机
PS1 的加载与 Shell 类型直接相关:
┌─────────────────────────────────────────────────────────┐
│ PS1 加载流程 │
├─────────────────────────────────────────────────────────┤
│ │
│ Login Shell 启动: │
│ 1. /etc/profile ← 通常在这里设置 PS1 │
│ 2. ~/.bash_profile ← 用户可覆盖 PS1 │
│ 3. ~/.bashrc ← 如果 profile 中 source 了 │
│ │
│ Non-login Shell 启动: │
│ 1. ~/.bashrc ← 唯一设置 PS1 的地方 │
│ │
│ Non-interactive Shell: │
│ • 不设置 PS1(不需要提示符) │
│ │
└─────────────────────────────────────────────────────────┘
10.3 PS1 常用转义序列
| 转义符 | 含义 | 示例输出 |
|---|---|---|
\u |
当前用户名 | root |
\h |
主机名(短) | localhost |
\H |
主机名(全) | localhost.localdomain |
\w |
当前工作目录(完整) | /home/user |
\W |
当前工作目录(basename) | user |
\$ |
提示符(root 显示#,普通用户显示$) | $ 或 # |
\d |
日期 | Mon Feb 16 |
\t |
时间(24 小时) | 23:44:00 |
\T |
时间(12 小时) | 11:44:00 |
\@ |
时间(12 小时,am/pm) | 11:44 pm |
\n |
换行 | (换行) |
\\ |
反斜杠 | \ |
\! |
命令历史编号 | 42 |
\# |
当前命令编号 | 5 |
$$ $$ |
非打印字符包围(用于颜色) | (颜色控制) |
10.4 经典 PS1 配置示例
# 示例 1:Ubuntu 风格(彩色)
export PS1='$$\e[32m$$\u@\h$$\e[0m$$:$$\e[34m$$\w$$\e[0m$$\$ '
# 效果:user@host:/home/user$ (用户名绿色,路径蓝色)
# 示例 2:CentOS 风格
export PS1='[\u@\h \W]\$ '
# 效果:[root@localhost ~]#
# 示例 3:带时间戳(调试用)
export PS1='[\t] \u@\h:\w\$ '
# 效果:[23:44:00] root@localhost:/root#
# 示例 4:带 Git 分支(需要 bash 和 git-prompt)
export PS1='\u@\h:\w$(__git_ps1 " (%s)")\$ '
# 效果:user@host:~/project (main)$
# 示例 5:极简风格
export PS1='$ '
# 效果:$
10.5 PS1 颜色代码速查
# 前景色(文字颜色)
\e[30m # 黑色 \e[31m # 红色 \e[32m # 绿色
\e[33m # 黄色 \e[34m # 蓝色 \e[35m # 紫色
\e[36m # 青色 \e[37m # 白色
# 背景色
\e[40m # 黑色 \e[41m # 红色 \e[42m # 绿色
\e[43m # 黄色 \e[44m # 蓝色 \e[45m # 紫色
\e[46m # 青色 \e[47m # 白色
# 样式
\e[0m # 重置 \e[1m # 加粗 \e[4m # 下划线
# 使用示例(注意要用 $$ $$ 包围非打印字符)
export PS1='$$\e[31;1m$$\u@\h$$\e[0m$$:\w\$ '
10.6 PS1 丢失的常见原因
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
提示符变成简单的 $ |
profile 未加载 | 检查是否为 Login Shell |
| 提示符完全消失 | PS1 被设为空 | echo $PS1 检查,重新设置 |
| 颜色不显示 | 缺少 $$ $$ 包围 |
用非打印字符包围颜色代码 |
| 提示符乱码 | 终端编码不匹配 | 检查 LANG/LC_ALL 设置 |
| 不同终端提示符不同 | 各终端加载不同配置 | 统一在 /etc/profile 设置 |
10.7 PS1 调试方法
# 调试 1:查看当前 PS1 值
echo $PS1
# 输出:\u@\h:\w\$
# 调试 2:查看 PS1 是否被设置
declare -p PS1
# 输出:declare -- PS1="\u@\h:\w\$ "
# 调试 3:测试 PS1 渲染效果
echo -e "\u@\h:\w\$ "
# 注意:echo 不会真正解析 PS1 转义,需要 Shell 解析
# 调试 4:临时修改 PS1 测试
PS1='TEST> '
# 如果立即生效,说明 Shell 正常
# 调试 5:追踪 PS1 设置位置
grep -r "PS1=" /etc/profile /etc/profile.d/ ~/.bashrc ~/.bash_profile 2>/dev/null
# 调试 6:模拟 Login Shell 检查 PS1
bash -l -c 'echo $PS1'
bash -c 'echo $PS1'
# 对比两者输出是否一致
10.8 嵌入式系统 PS1 特殊处理
在嵌入式系统中,PS1 配置需要更谨慎:
# /etc/profile 中的推荐配置(BusyBox 兼容)
if [ -z "$PS1" ]; then
PS1='\u@\h:\w\$ '
fi
export PS1
# 说明:
# • 检查 PS1 是否为空,避免重复设置
# • 使用简单转义序列,确保 ash/busybox 兼容
# • 避免使用 $$ $$(某些嵌入式 Shell 不支持)
10.9 PS1 与 Shell 类型的关联验证
# 验证实验:PS1 与 Login Shell 的关系
# 步骤 1:SSH 登录(Login Shell)
ssh user@device
echo $PS1
# 通常显示完整提示符,如:\u@\h:\w\$
# 步骤 2:在当前 Shell 中启动新 bash(Non-login)
bash
echo $PS1
# 可能显示不同提示符,取决于 ~/.bashrc
# 步骤 3:启动 sh(可能是 dash/ash)
sh
echo $PS1
# 可能显示简单提示符:$
# 步骤 4:对比 /proc 中的 cmdline
cat /proc/$$/cmdline | tr '\0' ' '
# Login Shell 会显示 -bash,Non-login 显示 bash
10.10 PS1 最佳实践
| 场景 | 推荐配置 | 位置 |
|---|---|---|
| 系统统一提示符 | 在 /etc/profile 设置 |
所有 Login Shell |
| 用户个性化 | 在 ~/.bashrc 覆盖 |
交互式 Shell |
| 嵌入式系统 | 简单配置,避免颜色 | /etc/profile |
| 调试环境 | 带时间戳/命令编号 | ~/.bashrc |
| 生产环境 | 简洁稳定,少用动态内容 | /etc/profile |
# 推荐的 /etc/profile PS1 配置(嵌入式友好)
if [ -z "$PS1" ]; then
if [ "$(id -u)" -eq 0 ]; then
PS1='# '
else
PS1='$ '
fi
fi
export PS1
十一、实验验证

11.1 实验 1:判断当前 Shell 类型
# 方法 1:查看 $0
echo $0
# 输出:-sh 或 -bash → Login Shell
# 输出:sh 或 bash → Non-login Shell
# 方法 2:查看进程名
ps -p $$ -o comm=
# 输出示例:-bash 或 bash
# 方法 3:查看 cmdline
cat /proc/$$/cmdline | tr '\0' ' '
11.2 实验 2:验证 /etc/profile 是否被加载
# 步骤 1:在 /etc/profile 末尾加一行
echo 'export PROFILE_LOADED="yes"' >> /etc/profile
# 步骤 2:新开一个 SSH 连接(Login Shell)
ssh user@device
echo $PROFILE_LOADED
# 如果显示 "yes",说明 profile 被加载了 ✅
# 步骤 3:在已登录的终端中执行 bash(Non-login Shell)
bash
echo $PROFILE_LOADED
# 可能为空,因为 Non-login Shell 不读 profile ❌
11.3 实验 3:source vs 直接执行
# 创建测试脚本
cat > /tmp/test.sh << 'EOF'
export TEST_VAR="hello"
echo "Inside script: TEST_VAR=$TEST_VAR"
EOF
chmod +x /tmp/test.sh
# 方式 1:直接执行(新进程,变量不会传给父 Shell)
/tmp/test.sh
echo "After direct exec: TEST_VAR=$TEST_VAR"
# 输出:空!
# 方式 2:source(在当前进程中执行,变量生效)
source /tmp/test.sh
echo "After source: TEST_VAR=$TEST_VAR"
# 输出:hello ✅
# 方式 3:. 命令(source 的简写)
. /tmp/test.sh
echo "After dot: TEST_VAR=$TEST_VAR"
# 输出:hello ✅
11.4 实验 4:查看某进程的环境变量
# 查看当前 Shell 的环境变量
strings /proc/$$/environ | head -20
# 查看 init 进程的环境变量
strings /proc/1/environ | head -20
# 对比两个进程的环境变量
diff <(strings /proc/1/environ | sort) <(strings /proc/$$/environ | sort)
# 查找特定变量
strings /proc/$$/environ | grep -E 'PATH|HOME|LD_'
11.5 实验 5:模拟环境变量丢失
# 创建一个会丢失环境变量的测试程序(需要 C 编译)
cat > /tmp/test_exec.c << 'EOF'
#include <unistd.h>
int main() {
char *argv[] = {"/bin/sh", NULL};
execve("/bin/sh", argv, NULL); // 故意传 NULL
return 1;
}
EOF
gcc -o /tmp/test_exec /tmp/test_exec.c
# 执行测试
/tmp/test_exec
echo $PATH # 应该是空的!
11.6 实验 6:PS1 调试实验
# 实验 6.1:查看当前 PS1
echo $PS1
# 实验 6.2:临时修改 PS1
PS1='[TEST] \u@\h:\w\$ '
# 实验 6.3:验证 PS1 是否随 Shell 类型变化
bash -l -c 'echo "Login PS1: $PS1"'
bash -c 'echo "Non-login PS1: $PS1"'
# 实验 6.4:在 profile 中设置 PS1 测试
echo 'export PS1="[PROFILE] \$ "' >> /etc/profile
# 重新登录 SSH 验证
回顾与解答
问题一:为什么 SSH 环境变量正常,串口或 HDC Shell 没有?
| 登录方式 | argv[0] | Shell 类型 | 是否读 profile | 环境变量 |
|---|---|---|---|---|
| SSH | -bash |
Login Shell | ✅ | 完整 |
| 串口 | sh |
Non-login Shell | ❌ | 缺失 |
| HDC | sh |
Non-login Shell | ❌ | 缺失 |
根本原因:sshd 启动 Shell 时加了减号(-sh),getty 和 hdcd 默认不加(sh),导致 Login Shell 机制未触发。
解决方案:使用 Wrapper 脚本,在启动 Shell 前手动 source /etc/profile。
问题二:rcS 里 export 为什么不生效?
rcS 进程 (PID 100) → export MY_VAR="hello"
→ 进程退出,MY_VAR 随之消失
你的 Shell (PID 200) → echo $MY_VAR → 空!
(和 rcS 是独立的进程,没有继承关系)
根本原因:export 只在当前进程有效,进程退出后环境变量随之消失。
解决方案:把环境变量放到 /etc/profile 或 /etc/profile.d/ 中,让每个 Login Shell 启动时自己加载。
问题三:脚本没写 #!/bin/sh 会怎样?
| 情况 | 行为 | 结果 |
|---|---|---|
| 有 Shebang | 内核用指定解释器执行 | 按预期执行 |
| 无 Shebang | 当前 Shell 解释执行 | 可能语法错误 |
| 脚本用 bash 语法,当前是 dash | dash 不识别 bash 语法 | Syntax error |
解决方案:始终添加 Shebang,根据需求选择 #!/bin/sh 或 #!/bin/bash。
问题四:为什么提示符突然变成简单的 $?
| 现象 | 原因 | 验证方法 |
|---|---|---|
PS1 变成 $ |
profile 未加载 | echo $0 检查是否为 Login Shell |
| PS1 完全消失 | PS1 被清空 | echo "$PS1" 检查值 |
| 颜色不显示 | 缺少 $$ $$ |
检查 PS1 配置格式 |
解决方案:在 /etc/profile 中统一设置 PS1,确保 Login Shell 加载。
附录:快速参考卡片
环境变量调试命令速查
# 查看当前 Shell 类型
echo $0
# 查看所有环境变量
env
printenv
# 查看特定变量
echo $PATH
printenv PATH
# 查看进程环境变量
strings /proc/<PID>/environ
# 追踪 profile 加载
bash -x -l 2>&1 | grep profile
# 测试 Login Shell
bash -l -c 'echo $PATH'
PS1 配置速查
# 查看当前 PS1
echo $PS1
# 常用 PS1 模板
PS1='\u@\h:\w\$ ' # 标准风格
PS1='[\t] \u@\h:\w\$ ' # 带时间
PS1='$$\e[32m$$\u@\h$$\e[0m$$:\w\$ ' # 带颜色
# PS1 调试
grep -r "PS1=" /etc/profile /etc/profile.d/ ~/.bashrc
配置文件位置速查
| 文件 | 作用 | 加载时机 |
|---|---|---|
/etc/profile |
系统级环境变量 | Login Shell |
/etc/profile.d/*.sh |
模块化配置 | Login Shell |
~/.bash_profile |
用户级配置 | Login Shell (bash) |
~/.profile |
用户级配置 | Login Shell (备选) |
~/.bashrc |
交互式 Non-login Shell | 每次新开终端 |
常见错误及解决方案
| 错误 | 可能原因 | 解决方案 |
|---|---|---|
command not found |
PATH 丢失 | 检查 /etc/profile,使用 Wrapper |
Syntax error: "(" unexpected |
dash 执行 bash 脚本 | 添加 #!/bin/bash Shebang |
bad interpreter: No such file |
Shebang 路径错误或 CRLF | 检查路径,运行 dos2unix |
| 环境变量时有时无 | Login vs Non-login 混用 | 统一在 /etc/profile 配置 |
提示符变成 $ |
profile 未加载 | 检查 Shell 类型,设置 PS1 |
下期预告(最终期)
🎬 第五期:嵌入式调试实战——后台服务与串口终端的爱恨情仇
掌握了以上所有概念后,让我们把知识用到实战中。下一期我们将分析两个真实的嵌入式调试案例:
-
案例一:后台服务如何"偷偷"破坏了串口终端?
- 现象:串口登录后系统卡顿,输入无响应
- 根因:某服务持续向 ttyS0 写日志
- 解决:重定向日志 + 进程隔离
-
案例二:串口被日志打爆时系统会怎样?
- 现象:系统逐渐变慢,最终无响应
- 根因:串口缓冲区满,内核阻塞
- 解决:日志级别控制 + 异步写入
-
系统性排查方法论
- 从现象到根因的完整排查链路
- 常用调试工具组合使用
- 预防性设计建议
更多推荐
所有评论(0)