无限进步!

第四期:环境变量凭空消失?——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 显示 shbash(无减号) 不读取 /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/profileShell 程序自己实现的逻辑,不是内核机制。

内核不关心什么 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” 是一个泛指概念,指命令行解释器。而 shbashdash 是具体的实现:

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]}

执行时:

  1. 内核找不到 #! 行,返回 ENOEXEC 错误
  2. 当前 Shell 捕获错误,用自己来解释脚本
  3. 如果当前 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

下期预告(最终期)

🎬 第五期:嵌入式调试实战——后台服务与串口终端的爱恨情仇

掌握了以上所有概念后,让我们把知识用到实战中。下一期我们将分析两个真实的嵌入式调试案例:

  1. 案例一:后台服务如何"偷偷"破坏了串口终端?

    • 现象:串口登录后系统卡顿,输入无响应
    • 根因:某服务持续向 ttyS0 写日志
    • 解决:重定向日志 + 进程隔离
  2. 案例二:串口被日志打爆时系统会怎样?

    • 现象:系统逐渐变慢,最终无响应
    • 根因:串口缓冲区满,内核阻塞
    • 解决:日志级别控制 + 异步写入
  3. 系统性排查方法论

    • 从现象到根因的完整排查链路
    • 常用调试工具组合使用
    • 预防性设计建议
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐