目录

Linux-01 重新认识 Linux:内核、发行版,与自学任何命令的方法

目录

这是 Linux 系列的第一篇。全系列共 40 篇,分两部分:01~17 篇是上手层,把日常操作讲透、命令参数讲全;18~40 篇是深入层,讲每个机制「为什么被设计成这样」。

第一篇不讲具体操作,讲两件比操作更长期有用的事:给自己建立一个坐标系(我在什么系统上、命令从哪来),以及掌握自学任何陌生命令的方法。后者的收益是复利的——学会查 man 之后,剩下 39 篇里出现的几百个参数你都能自己验证。

先看五个问题,能全答对可以跳到第 5 章:

  1. ls 这个程序的文件在哪?敲下 ls 时系统怎么知道去哪找它?
  2. cd/bin/usr/bin 里都找不到。它是什么?
  3. man lsls --help 内容不一样,该看哪个?
  4. man 2 write 里的 2 是什么意思?为什么 write 需要分章节?
  5. 为什么 go version 好用,但 sudo go versioncommand not found

1. Linux 到底是什么

严格来说,Linux 只是一个内核(kernel)——一个约 3000 万行 C 代码的程序,负责管理硬件、调度进程、分配内存、处理系统调用。它自己不能给你一个能用的系统:没有 shell,没有 ls,没有包管理器,甚至没有办法登录。

你平时用的「Linux 系统」实际上是:

         你敲的命令、你的 Go 服务
   +-------------------------------+
   | user space                    |   ls / bash / nginx / your-go-app
   |-------------------------------|
   | libc  (glibc or musl)         |   把系统调用包装成 C 函数
   +-------------------------------+
                |
                |  syscall  系统调用
                v
   +-------------------------------+
   | Linux kernel                  |   进程调度 / 内存管理
   | (the kernel IS Linux)         |   文件系统 / 网络协议栈
   +-------------------------------+
                |
                |  driver   驱动
                v
            physical hardware

所以严谨的说法是 GNU/Linux:内核来自 Linus 的 Linux 项目,而 lsbashgccglibc 这些用户态工具来自 GNU 项目。两者是独立开发、组合使用的。这个区分不是抠字眼,它有实际后果:

  • 同一个内核可以配不同的用户态。Alpine Linux 用 musl 替代 glibc、用 busybox 替代 GNU coreutils,于是 ls 的参数都少了一半——你在 Ubuntu 上背的参数在 Alpine 容器里可能不存在。
  • 同一套用户态可以跑在不同内核版本上。所以「Ubuntu 22.04」和「内核 5.15」是两个独立的版本号。

1.1 先搞清自己在什么系统上

登录一台陌生机器,第一组该敲的命令:

# ① 内核版本(Linux 本身的版本)
uname -r
# 5.15.0-91-generic

# ② 完整内核信息
uname -a
# Linux prod-web-01 5.15.0-91-generic #101-Ubuntu SMP Tue Nov 14 13:30:08 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
# ^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^
# 内核名  主机名       内核版本-发行版补丁号   编译时间                            架构

# ③ 发行版(这才是决定"用 apt 还是 dnf"的东西)
cat /etc/os-release
# PRETTY_NAME="Ubuntu 22.04.3 LTS"
# NAME="Ubuntu"
# VERSION_ID="22.04"
# ID=ubuntu
# ID_LIKE=debian              <- 关键:告诉你它属于哪个家族
# VERSION_CODENAME=jammy

# ④ 架构(决定下载哪个二进制包、Go 交叉编译的 GOARCH)
uname -m
# x86_64          <- 对应 Go 的 GOARCH=amd64
# aarch64         <- 对应 Go 的 GOARCH=arm64(云上 ARM 机型越来越多)

# ⑤ 一条命令看全(systemd 系统上)
hostnamectl
#  Static hostname: prod-web-01
#        Icon name: computer-vm
#          Chassis: vm                          <- 是虚拟机还是物理机
# Operating System: Ubuntu 22.04.3 LTS
#           Kernel: Linux 5.15.0-91-generic
#     Architecture: x86-64

/etc/os-release 是唯一可靠的发行版判断方式(freedesktop 标准,所有现代发行版都有)。老教程里的 lsb_release -a 依赖 lsb-release 包,很多精简镜像里没装;/etc/redhat-release/etc/debian_version 只在特定家族存在。

脚本里判断家族的标准写法:

. /etc/os-release           # 它就是一个 shell 变量赋值文件,可以直接 source
echo "$ID / $ID_LIKE / $VERSION_ID"
# ubuntu / debian / 22.04

case "$ID_LIKE $ID" in
  *debian*) PKG="apt-get install -y" ;;
  *rhel*|*fedora*) PKG="dnf install -y" ;;
  *alpine*) PKG="apk add" ;;
  *) echo "unknown distro"; exit 1 ;;
esac

1.2 内核版本号怎么读

uname -r
# 5.15.0-91-generic
# │ │  │  │  └──────── 发行版自己的风味标记(generic/aws/azure/cloud)
# │ │  │  └─────────── 发行版的补丁号(Ubuntu 打了 91 次补丁)
# │ │  └────────────── 修订号(上游稳定版补丁)
# │ └───────────────── 主版本内的次版本
# └─────────────────── 主版本

有两个认知很重要:

① 发行版的内核是「改过的」。 Ubuntu 的 5.15.0-91 不等于 kernel.org 的 5.15.0,它回合(backport)了大量补丁。所以「内核 5.15 不支持某特性」这种判断要结合发行版实际情况,直接查特性开关更可靠:

# 查内核编译时开启了哪些特性(排查 cgroup v2、eBPF 支持时常用)
grep -E 'CONFIG_CGROUP_BPF|CONFIG_BPF_SYSCALL' /boot/config-$(uname -r)
# CONFIG_BPF_SYSCALL=y
# CONFIG_CGROUP_BPF=y

# 或者从 /proc 看运行时能力
cat /sys/fs/cgroup/cgroup.controllers 2>/dev/null && echo "cgroup v2"

② 内核版本决定了你能用什么系统调用。 这对 Go 服务端有直接影响:io_uring 需要 5.1+、cgroup v2 的完整功能需要 5.2+、epoll_pwait2 需要 5.11+。Go runtime 会在启动时探测并降级,但如果你用了依赖特定 syscall 的库,就得关心这个数字。

2. 发行版为什么分化成这么多个

2.1 两大家族

Debian 系                          RHEL 系(Red Hat 系)
├── Debian(最稳,社区)             ├── RHEL(付费,企业标准)
├── Ubuntu(最流行,Canonical)      ├── CentOS Stream(RHEL 的上游测试版)
│   └── Ubuntu LTS(5 年支持)       ├── Rocky Linux(CentOS 的社区继任者)
└── Linux Mint / Kali / ...         ├── AlmaLinux(同上,另一个继任者)
                                    ├── Fedora(RHEL 的实验田)
包管理:dpkg + apt                   └── Amazon Linux / Oracle Linux
包格式:.deb                        包管理:rpm + dnf(旧版 yum)
                                    包格式:.rpm

其他重要分支
├── Alpine(musl + busybox,容器镜像首选,5MB)
├── Arch(滚动更新,pacman,桌面/极客)
└── SUSE / openSUSE(zypper,欧洲企业市场)

为什么会分化? 核心是一组无法同时满足的取舍:

取舍点 一端 另一端
软件新旧 Debian stable:软件老但极稳,一个大版本撑 5 年 Arch/Fedora:几天就能拿到上游新版,偶尔炸
发布模式 定期发布(有明确的版本与支持周期) 滚动更新(没有版本概念,一直升)
商业支持 RHEL:付费拿 10 年支持与合规认证 Debian:纯社区,没有 SLA
体积 完整发行版:几百 MB,工具齐全 Alpine:5MB,够跑一个进程就行
libc glibc:功能全、兼容好、体积大 musl:小、静态链接友好、部分行为不同

服务器场景不存在「哪个最好」,只有「团队会哪个、公司买了谁的支持」。但有两条实践共识:

  • 生产服务器优先选 LTS 版本(Ubuntu 22.04/24.04 LTS、Rocky/Alma 9),因为你要的是「五年不用大动」。
  • 不要在生产上用滚动更新发行版,也不要用即将 EOL 的版本(EOL 之后没有安全补丁)。
# 查自己的版本还有多久到期(Ubuntu)
ubuntu-support-status 2>/dev/null || cat /etc/os-release | grep VERSION
# RHEL 系看 https://access.redhat.com/support/policy/updates/errata

2.2 CentOS 的故事:为什么很多公司在迁移

这段历史值得知道,因为你接手的老项目很可能还在 CentOS 7 上:

  • CentOS 曾经是「RHEL 的免费克隆」,二进制级兼容,企业大量用它省授权费。
  • 2020 年底 Red Hat 宣布 CentOS 8 提前终止(原定 2029 → 2021 年底),转型为 CentOS Stream(变成 RHEL 的上游滚动测试版,不再是下游稳定克隆)。
  • 社区随即分裂出两个继任者:Rocky Linux(CentOS 原创始人主导)和 AlmaLinux(CloudLinux 支持)。
  • CentOS 7 已于 2024 年 6 月 30 日 EOL,之后不再有安全更新。

所以现在的选择是:

# 如果你在 CentOS 7/8 上,迁移路径通常是
# CentOS 7 -> Rocky/Alma 8 或 9(rpm 生态不变,迁移成本低)
# CentOS 7 -> Ubuntu LTS(生态换家族,成本高但社区资源最多)

# Rocky/Alma 提供了原地转换脚本
# migrate2rocky.sh / almalinux-deploy.sh

2.3 Alpine 与 musl:Go 服务端必须知道的坑

Alpine 因为镜像只有 5MB,成了容器基础镜像的热门选择。但它用 musl libc 而不是 glibc,这会直接影响 Go 服务:

# 现象一:在 Ubuntu 上编译的 Go 二进制,放进 Alpine 容器跑不起来
./myapp
# sh: ./myapp: not found
#     ^^^^^^^^^ 文件明明在!这个报错极具误导性

# 真正的原因:二进制动态链接了 glibc,而 Alpine 里没有 glibc
file myapp
# ELF 64-bit LSB executable, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
#                            ^^^^^^^^^^^^^^^^^^ 需要动态链接器
ldd myapp
# linux-vdso.so.1
# libc.so.6 => not found        <- 找不到 glibc,所以内核加载失败,shell 报 "not found"

# ✅ 解法:关掉 CGO,让 Go 静态编译
CGO_ENABLED=0 go build -o myapp .
file myapp
# ELF 64-bit LSB executable, statically linked        <- 不依赖任何 libc,任何镜像都能跑

为什么 Go 默认会动态链接? 因为 netos/user 两个包在启用 CGO 时会调用 glibc 的 getaddrinfo/getpwnam(为了支持 NSS,也就是 /etc/nsswitch.conf 里配置的 LDAP、mDNS 等名字解析方式)。CGO_ENABLED=0 会让 Go 使用纯 Go 实现的 DNS 解析器,代价是不再支持 NSS 的那些扩展方式。

这也解释了另一个坑:

# 现象二:Alpine 容器里 Go 服务解析内网域名失败,Ubuntu 容器里正常
# 原因:纯 Go resolver 只读 /etc/resolv.conf 和 /etc/hosts,
#      不走 NSS,所以某些依赖 NSS 模块的解析方式会失效。
# 还有一个历史坑:musl 的 DNS 实现不支持 search domain 的某些边界情况,
#      且早期版本对 TCP 回退(超过 512 字节的响应)处理不同。

# 排查时先确认用的是哪个 resolver
go env CGO_ENABLED
# 0        <- 纯 Go resolver

# 也可以在运行时强制指定
GODEBUG=netdns=go   ./myapp      # 强制纯 Go
GODEBUG=netdns=cgo  ./myapp      # 强制走 libc
GODEBUG=netdns=2    ./myapp      # 打印它到底选了哪个(排查神器)

镜像选择的实践建议

基础镜像 大小 适用
scratch 0 纯静态 Go 二进制,最小攻击面;但没有 shell、没有 CA 证书、没有时区数据
gcr.io/distroless/static ~2MB 静态 Go 二进制 + CA 证书 + 时区,生产推荐
alpine ~5MB 需要 shell 排查时;注意 musl 差异
debian:bookworm-slim ~30MB 需要 glibc(用了 CGO、SQLite、某些 DB 驱动)时
# 生产环境的标准两阶段构建
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /app ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /app /app
# 注意:scratch/distroless 里没有 shell,kubectl exec 进不去,
#       排查要靠 ephemeral container(kubectl debug)或日志
USER nonroot:nonroot
ENTRYPOINT ["/app"]

3. 你敲下的「命令」到底是什么

开篇第一、二问:ls 的文件在哪?cd 为什么找不到?

答案是:命令有五种,只有其中一种是磁盘上的文件。

type -a ls
# ls is aliased to `ls --color=auto'          <- ① 别名
# ls is /usr/bin/ls                           <- ④ 外部程序

type -a cd
# cd is a shell builtin                       <- ③ 内建命令,不是文件!

type -a if
# if is a shell keyword                       <- ⑤ 关键字

type -a ll
# ll is aliased to `ls -alF'

type -a time
# time is a shell keyword
# time is /usr/bin/time                       <- 同名的关键字和程序共存,行为不同!

type -a 是本篇最该记住的一个命令。 它告诉你「这个名字到底是什么」,-a(all)表示把所有同名的都列出来。

3.1 五步查找顺序

bash 在你按下回车后,按固定顺序决定执行什么:

你输入:ls -l
   │
   ├─ ① 是别名(alias)吗?          -> 展开成 `ls --color=auto -l` 后重新解析
   │
   ├─ ② 是关键字(keyword)吗?      -> if / for / while / case / function / [[ / time
   │
   ├─ ③ 是函数(function)吗?       -> 执行 shell 函数
   │
   ├─ ④ 是内建命令(builtin)吗?    -> cd / export / echo / pwd / read / source / kill
   │
   └─ ⑤ 去 PATH 里逐个目录找可执行文件
          找到第一个就执行,找不到 -> command not found (退出码 127)

顺序带来的实际后果:

# ① 别名优先级最高,所以别名能"覆盖"真命令
alias ls='ls --color=auto'
ls              # 实际执行的是 ls --color=auto

# 想临时绕过别名,三种方式
\ls             # 反斜杠转义
command ls      # command 跳过别名和函数,但仍走 builtin 和 PATH
/usr/bin/ls     # 直接写绝对路径

# ② 函数能覆盖外部命令 —— 这是排查诡异行为时容易忽略的一层
grep() { echo "被劫持了"; }
grep foo file   # 被劫持了
unset -f grep   # 取消函数定义

# ③ builtin 优先于 PATH,所以 /usr/bin/echo 平时用不到
type -a echo
# echo is a shell builtin
# echo is /usr/bin/echo        <- 存在但轮不到它
builtin echo hi                # 强制用 builtin
/usr/bin/echo hi               # 强制用外部程序(两者对 -e 等参数的处理略有差异)

3.2 cd 为什么必须是 builtin(经典面试题)

这是理解「进程」的第一课,也是「知其所以然」的绝佳例子。

cd 的作用是改变当前工作目录(cwd)。而 cwd 是每个进程各自的属性,存在内核里的进程结构体中:

# 每个进程都有自己的 cwd,可以直接看
ls -l /proc/self/cwd
# lrwxrwxrwx 1 lhx lhx 0 Aug 12 22:00 /proc/self/cwd -> /home/lhx

# 两个 shell 的 cwd 互不影响
ls -l /proc/$$/cwd       # $$ 是当前 shell 的 PID

假设 cd 是一个外部程序 /bin/cd,那么执行 cd /tmp 时会发生:

bash(cwd=/home/lhx)
  │
  ├─ fork() 出一个子进程                    子进程继承 cwd=/home/lhx
  │                                           │
  │                                           ├─ exec /bin/cd /tmp
  │                                           ├─ chdir("/tmp") 成功,cwd=/tmp
  │                                           └─ 进程退出,一切随之消失
  │
  └─ 回到 bash,cwd 还是 /home/lhx      <- 什么都没变!

子进程改的是自己的 cwd,它一退出就没了,对父进程毫无影响。 所以 cd 只有作为 shell 自身的内建命令、直接调用 chdir() 修改自己这个进程的 cwd,才有意义。

同样道理的还有一批命令,它们必须是 builtin:

builtin 为什么不能是外部程序
cd 要改自己进程的 cwd
export / unset 要改自己进程的环境变量
source / . 要在当前 shell 里执行脚本(外部执行就变成子进程了)
exec 要用新程序替换当前进程
exit / logout 要让当前 shell 退出
read 要把读到的值赋给当前 shell 的变量
umask / ulimit 要改自己进程的属性(并被后续子进程继承)
jobs / fg / bg 作业表是 shell 自己维护的数据
alias / history 同上,属于 shell 的内部状态

由此可以推出一个高频问题的答案:为什么执行脚本要用 source script.sh 而不是 ./script.sh

# script.sh 内容:export FOO=bar; cd /tmp

./script.sh           # 起子进程执行 -> 子进程里 FOO 和 cwd 都变了,退出后全没了
echo $FOO; pwd        # 空 / /home/lhx

source script.sh      # 在当前 shell 里执行 -> 真的改了当前 shell
echo $FOO; pwd        # bar / /tmp

所以「配置类脚本用 source,工具类脚本用 ./」。这也是为什么改完 ~/.bashrcsource ~/.bashrc 才生效。

echo 为什么既是 builtin 又有外部程序?因为 builtin 版本省掉了一次 fork+exec(快得多),而外部版本是 POSIX 要求必须存在的。在循环里跑十万次 echo,builtin 和外部程序的耗时能差几十倍:

time for i in {1..10000}; do echo x >/dev/null; done
# real  0m0.09s          <- builtin

time for i in {1..10000}; do /usr/bin/echo x >/dev/null; done
# real  0m5.20s          <- 每次都 fork + exec,慢 50 倍

这条经验推广开就是:shell 脚本慢,通常是因为在循环里调用了外部程序。 能用 builtin 或 shell 参数展开完成的事,就不要起进程(第 17 篇会系统讲)。

3.3 which 为什么不可靠

很多人习惯用 which 查命令位置,但它有三个问题:

which cd
# (什么都没输出,退出码非 0)
#  ^^ which 是【外部程序】,它只扫 PATH,根本不知道 builtin、alias、function 的存在

which ls
# /usr/bin/ls
#  ^^ 也没告诉你其实有个 alias 会先生效

# ✅ 正确的工具
type -a ls           # 显示所有身份(alias/builtin/function/文件),bash 内建
command -v ls        # POSIX 标准,脚本里判断"命令是否存在"用这个
command -V ls        # 更详细的描述(类似 type)

脚本里判断命令是否存在的标准写法:

# ✅ 推荐(POSIX,任何 shell 都支持)
if command -v jq >/dev/null 2>&1; then
    echo "jq 已安装"
fi

# ❌ 不推荐
if which jq >/dev/null; then ...        # which 在某些系统上即使找不到也返回 0
if type jq >/dev/null; then ...         # type 在 dash 等非 bash shell 里行为不一致

顺带区分几个容易混的:

whereis ls           # /usr/bin/ls /usr/share/man/man1/ls.1.gz
#                      ^^ 同时找【程序、源码、man 手册】,按固定目录列表搜索,不看 PATH
whatis ls            # ls (1) - list directory contents        <- 一句话说明它是干什么的
apropos "list dir"   # 按关键词【搜索】所有 man 页面(不知道命令名时用)

3.4 PATH 缓存:改了程序却还在跑旧版本

bash 会把「命令名 → 完整路径」的结果缓存进一张哈希表,避免每次都扫 PATH:

hash                 # 查看缓存表
# hits	command
#    5	/usr/bin/git
#    2	/usr/local/go/bin/go

# 典型故障:把 go 从 /usr/local/go 换到 /opt/go,PATH 也改了,但仍执行旧路径
go version
# bash: /usr/local/go/bin/go: No such file or directory
#       ^^^^^^^^^^^^^^^^^^^^ 缓存里还是旧路径

hash -r              # 清空整张缓存表  ← 记住这个
hash -d go           # 只删 go 这一条
hash -t go           # 查 go 缓存的是哪个路径

「明明装好了新版本,命令却还是旧的」的排查顺序type -a 命令 → 看有没有 alias/function 挡着 → hash -r 清缓存 → echo $PATH 看顺序 → 确认是不是登录 shell 没重新读配置。

4. PATH 与环境变量

4.1 PATH 是一个冒号分隔的搜索列表

echo $PATH
# /home/lhx/.local/bin:/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
#  ^^^^^^^^^^^^^^^^^^^ 从左到右依次查找,【第一个匹配就用】,找到即停

# 更易读的展示
tr ':' '\n' <<< "$PATH"

各目录的分工(这套约定叫 FHS,第 03 篇细讲):

目录 放什么 谁维护
/usr/bin 发行版包管理器装的普通命令(绝大多数命令在这) 包管理器
/usr/sbin 系统管理命令(useraddiptables),历史上只给 root 包管理器
/usr/local/bin 你手动装的软件(编译安装、下载的二进制)
/opt/<软件>/bin 大型第三方软件自带目录 软件自己
~/.local/bin 只给当前用户的私人命令(pip install --user 装到这)
/bin/sbin 现代发行版里是 /usr/bin/usr/sbin软链接(usrmerge 改革)
ls -ld /bin /sbin /lib
# lrwxrwxrwx 1 root root 7 Apr 22 2023 /bin -> usr/bin       <- 就是个软链接
# lrwxrwxrwx 1 root root 8 Apr 22 2023 /sbin -> usr/sbin

顺序很重要/usr/local/bin 排在 /usr/bin 前面,所以你手动装的新版本会覆盖系统自带的旧版本。这是设计意图,不是巧合。

4.2 添加 PATH 的正确姿势

# 临时(只影响当前 shell)
export PATH="$PATH:/usr/local/go/bin"        # 追加到末尾
export PATH="/opt/mytool/bin:$PATH"          # 插到最前(优先级最高)
#             ^^^^^^^^^^^^^^^^ 想覆盖系统同名命令时放前面

# ⚠️ 一定要保留原来的 $PATH,否则整个 shell 就废了
export PATH="/usr/local/go/bin"               # ❌ 现在 ls、cat 全都 command not found
# 救急:用绝对路径修回来
export PATH="/usr/bin:/bin:/usr/sbin:/sbin"

# 永久:写进配置文件(哪个文件见下)
echo 'export PATH="$PATH:/usr/local/go/bin"' >> ~/.bashrc
source ~/.bashrc

永远不要把 .(当前目录)加进 PATH

export PATH=".:$PATH"        # 💀 极危险
# 攻击场景:攻击者在 /tmp 放一个名叫 ls 的恶意脚本,
#          你 cd /tmp 然后敲 ls,执行的就是他的脚本(以你的权限)
# 这也是为什么执行当前目录的程序必须写 ./myapp 而不是 myapp

4.3 为什么 sudo 后 command not found

开篇第五问:

go version
# go version go1.22.0 linux/amd64

sudo go version
# sudo: go: command not found

因为 sudo 出于安全考虑会重置 PATH,用的是 /etc/sudoers 里的 secure_path,而不是你的 PATH:

sudo grep secure_path /etc/sudoers
# Defaults	secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
#                        ^^ 里面没有 /usr/local/go/bin,所以找不到 go

# 设计动机:防止普通用户通过篡改自己的 PATH,让 sudo 执行一个伪造的同名程序提权

# 三种解法
sudo /usr/local/go/bin/go version        # ① 写绝对路径(最推荐)
sudo env "PATH=$PATH" go version         # ② 显式把 PATH 带过去
sudo -E go version                       # ③ 保留全部环境变量(需 sudoers 允许,权限较松)

# ④ 或者把路径加进 secure_path(用 visudo 改,别直接编辑 /etc/sudoers)
sudo visudo
# Defaults	secure_path="...:/usr/local/go/bin"

4.4 环境变量与 shell 变量

FOO=bar                  # shell 变量:只在当前 shell 里可见
export FOO=bar           # 环境变量:会被【子进程继承】
export -n FOO            # 降级成普通 shell 变量(取消导出,但保留值)
unset FOO                # 彻底删除

# 验证继承关系
FOO=local
export BAR=exported
bash -c 'echo "FOO=[$FOO] BAR=[$BAR]"'
# FOO=[] BAR=[exported]        <- 只有 export 过的能传给子进程

# 三个查看命令的区别
env                      # 只列【环境变量】(外部程序,看到的是它自己继承到的那份)
printenv FOO             # 打印单个环境变量(比 echo $FOO 更严格:只认环境变量)
set                      # 列出【所有】shell 变量 + 函数(内建命令,输出巨长)
declare -p FOO           # 看变量的类型与属性(-x 表示已导出)

# 只给一条命令临时设置环境变量(不影响当前 shell)
GOOS=linux GOARCH=arm64 go build ./...
#^^^^^^^^^^^^^^^^^^^^^^ 前置赋值,只对这一条命令生效  ← Go 交叉编译常用

# 看一个【正在运行的进程】的环境变量(排查配置没生效的神器)
sudo tr '\0' '\n' < /proc/1234/environ
#         ^^^^ environ 里各变量用 \0 分隔,要转成换行才能读

常用环境变量:

变量 含义
PATH 命令搜索路径
HOME 当前用户家目录(~ 展开成它)
USER / LOGNAME 用户名
SHELL 登录 shell(不是当前正在跑的 shell
PWD / OLDPWD 当前目录 / 上一个目录(cd - 用它)
LANG / LC_ALL 语言与区域设置(影响排序、时间格式、报错语言)
TERM 终端类型(影响颜色、清屏能力)
TZ 时区(容器里常见的「时间差 8 小时」就是它没设)
EDITOR / VISUAL 默认编辑器(visudogit commit 用它)
PS1 命令提示符的格式
LD_LIBRARY_PATH 动态库搜索路径(谨慎使用,容易引发诡异问题)
http_proxy / https_proxy / no_proxy 代理设置(curlgo mod download 认这个)

Go 相关的几个:

go env                       # 看 Go 的全部环境(GOROOT/GOPATH/GOPROXY/GOCACHE...)
go env GOPATH GOPROXY        # 只看指定项
go env -w GOPROXY=https://goproxy.cn,direct    # 持久化写入(存到 ~/.config/go/env)
#      ^^ 注意:go env -w 写的是 Go 自己的配置文件,不是 shell 环境变量

# GOROOT vs GOPATH(新手最容易混)
# GOROOT = Go 工具链自己的安装目录(/usr/local/go),一般不用手动设
# GOPATH = 你的工作区(默认 ~/go),go install 的二进制放在 $GOPATH/bin
export PATH="$PATH:$(go env GOPATH)/bin"       # 这样 go install 装的工具才能直接用

4.5 改哪个配置文件才生效

这是个高频困惑:改了 ~/.bashrcssh 登录后不生效;或者反过来。 原因是 bash 按「登录 shell / 交互式非登录 shell / 非交互式」读取不同的文件:

① 登录 shell(ssh 登录、控制台登录、su - user、bash -l)
   /etc/profile
     └─ /etc/profile.d/*.sh
   然后按顺序找【第一个存在的】并只读它:
   ~/.bash_profile  ->  ~/.bash_login  ->  ~/.profile

② 交互式非登录 shell(在桌面终端里开新标签、直接敲 bash)
   /etc/bash.bashrc
   ~/.bashrc

③ 非交互式(执行脚本、ssh host 'command')
   两个都不读!只读 $BASH_ENV 指向的文件(通常没设)

所以标准做法是:把真正的配置写在 ~/.bashrc,让 ~/.bash_profile 去 source 它,两条路径都覆盖到:

cat ~/.bash_profile
# if [ -f ~/.bashrc ]; then . ~/.bashrc; fi
#                            ^^^^^^^^^^^ 关键的一行,Ubuntu 的 ~/.profile 默认就有

# 判断当前 shell 是哪种
shopt -q login_shell && echo "登录 shell" || echo "非登录 shell"
echo $-        # 输出里含 i 表示交互式(himBHs 里的 i)

第三种情况有个真实的坑:

# 本地手动跑没问题
go version                        # ok

# 但 CI 里 / ssh 远程执行 / cron 里报 command not found
ssh host 'go version'
# bash: go: command not found
#  ^^ 非交互式 shell 不读 .bashrc,所以你加的 PATH 不生效

# 解法:显式写绝对路径,或在脚本里自己 source
ssh host 'export PATH=$PATH:/usr/local/go/bin; go version'
ssh host 'bash -lc "go version"'    # -l 让它当登录 shell 跑,会读 profile

cron 里同样如此——cron 给的环境极其精简(PATH 通常只有 /usr/bin:/bin),这是「脚本手动跑正常、放进 cron 就失败」的头号原因(第 14 篇细讲)。

5. 自学任何陌生命令的方法

这一章是本篇最有长期价值的部分。目标不是记住命令,而是掌握「遇到不认识的命令/参数时如何在 1 分钟内查清」。

文档有五个层次,从快到详:

① 命令 --help          10 秒,看参数速查              最常用
② man 命令             1 分钟,看完整说明             标准手册
③ info 命令            GNU 工具的完整文档(比 man 详)
④ /usr/share/doc/包名/  官方 README、示例、变更日志
⑤ 源码                 终极答案(man 也会写错/过时)

5.1 –help 与 man 的分工

ls --help | head -20     # 参数清单,速查用
man ls                   # 完整手册:设计意图、环境变量、退出码、示例、相关命令

区别在于:--help 是程序自己打印的字符串(所以一定和实际版本一致),man 是独立的手册文件(可能没装、可能版本略旧)。所以:

  • 查「某个参数怎么写」→ --help(配 grep,见下)
  • 查「这命令到底怎么工作、有哪些坑」→ man
# 在 --help 输出里直接搜参数(最高频的动作)
ls --help | grep -- '-S'
#                ^^ -- 表示后面的 -S 是搜索内容而不是 grep 的选项
#   -S                         sort by file size, largest first

tar --help | grep -A2 'exclude'
#                  ^^^^ 顺带显示后 2 行上下文

# busybox / alpine 环境里没有 man,只能靠 --help
busybox ls --help

5.2 man 的章节数字:为什么 write 要分章节

开篇第四问。man 手册按内容类型分成 9 个章节:

章节 内容 例子
1 用户命令(shell 里能敲的) man 1 lsman 1 write
2 系统调用(内核提供的接口) man 2 writeman 2 openman 2 fork
3 库函数(libc 提供的 C 函数) man 3 printfman 3 malloc
4 设备与特殊文件(/dev 下的东西) man 4 ttyman 4 random
5 配置文件格式 man 5 crontabman 5 fstabman 5 sshd_config
6 游戏 man 6 sl
7 杂项与概念说明 man 7 signalman 7 tcpman 7 capabilities
8 系统管理命令(一般需 root) man 8 mountman 8 iptables
9 内核接口(很少有)

为什么必须分章节? 因为同一个名字在不同层次有完全不同的含义:

man 1 write        # write 命令:给别的登录用户发消息
man 2 write        # write() 系统调用:往 fd 写数据      ← 写 Go/C 时查这个
man 3 printf       # printf() C 库函数:格式化输出
man 1 printf       # printf 命令:shell 里的格式化输出
man 5 crontab      # crontab 文件的格式(五个时间字段怎么写)
man 1 crontab      # crontab 命令(-e -l -r 怎么用)      ← 两个都要看

# 不指定章节时,man 按 1→8 的顺序返回【第一个】找到的
man write          # 得到的是 write(1),不是你想要的系统调用!

# 看某个名字在哪些章节都有
man -f write       # 等价 whatis
# write (1)            - send a message to another user
# write (2)            - write to a file descriptor
# write (1posix)       - write to another user

man -a write       # 依次显示所有章节(看完一个按 q 进下一个)

这三个章节是服务端开发最常用的

  • 章节 2man 2 epoll_waitman 2 fsyncman 2 sendfile —— 想搞清 Go 的 netos 包底层在干什么,就查这里。里面有精确的语义、错误码、边界条件,是比任何博客都权威的资料。
  • 章节 5man 5 sshd_configman 5 systemd.serviceman 5 resolv.conf —— 配置文件的每个字段含义,比搜博客靠谱得多。
  • 章节 7man 7 tcpman 7 signalman 7 epollman 7 namespaces —— 概念性的长文,质量很高,相当于官方教程。
# 例:查 write() 的返回值语义(Go 里 Write 返回 n < len(p) 的原因就在这)
man 2 write
# RETURN VALUE
#   On success, the number of bytes written is returned.  ... it is not an error
#   if this number is smaller than the number of bytes requested
#   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 部分写入不是错误!

# 例:查 signal 的完整列表与默认行为
man 7 signal | grep -A3 SIGTERM

5.3 man 页面的固定结构

每个 man 页都按同样的顺序组织,知道结构就能直接跳:

段落 内容 价值
NAME 名字 + 一句话说明 确认找对了
SYNOPSIS 命令的语法骨架 最重要,见 5.4
DESCRIPTION 详细说明 理解设计意图
OPTIONS 每个参数的解释 查参数就看这
EXIT STATUS 退出码的含义 写脚本时必看
ENVIRONMENT 受哪些环境变量影响 排查「行为怪异」时看这
FILES 相关的配置文件路径 找配置文件在哪
EXAMPLES 示例 有的话优先看(man tarman ssh 有)
SEE ALSO 相关命令与手册 顺藤摸瓜的入口,价值被严重低估
BUGS / CAVEATS 已知问题 遇到诡异行为时看这

5.4 SYNOPSIS 的语法约定(很多人不知道)

SYNOPSIS 用一套固定的排版约定表达语法,读懂它就不用看正文了:

       ls [OPTION]... [FILE]...
          ^^^^^^^^^^^ ^^^^^^^^^
          方括号 = 可选   ... = 可重复出现多次

       cp [OPTION]... SOURCE... DIRECTORY
                      ^^^^^^^^^ 没有方括号 = 必需参数

       tar {-c | -x | -t} [OPTIONS] [FILE...]
           ^^^^^^^^^^^^^^ 花括号 + 竖线 = 必须选其中一个(互斥)

       git commit [-m <msg>]
                     ^^^^^ 尖括号(或斜体)= 占位符,要替换成你自己的值

       kill [-s SIGNAL | -SIGNAL] PID...

四条规则总结:

记法 含义
[ ] 可选,可以不写
... 前面那项可以重复多次
{ a | b } 必须选一个,互斥
粗体 / 原样字符 照着敲
斜体 / <> / 大写 占位符,换成你的值
# 例:读懂 ln 的 SYNOPSIS 就知道它有四种用法
man ln | sed -n '/SYNOPSIS/,/DESCRIPTION/p'
# SYNOPSIS
#      ln [OPTION]... [-T] TARGET LINK_NAME
#      ln [OPTION]... TARGET                  <- 只给目标,在当前目录创建同名链接
#      ln [OPTION]... TARGET... DIRECTORY     <- 多个目标放进一个目录
#      ln [OPTION]... -t DIRECTORY TARGET...  <- 目录在前的写法

5.5 在 man 里高效导航

man 默认用 less 显示,所以 less 的所有快捷键都能用:

man ls
# 然后:
#   /关键词      向下搜索(回车确认)
#   ?关键词      向上搜索
#   n / N       跳到下一个 / 上一个匹配
#   空格 / b     下一页 / 上一页
#   g / G       跳到开头 / 结尾
#   q           退出
#   h           看 less 自己的帮助

# 精确定位某个参数的说明(最高频技巧)
#   在 man 里输入: /^\s*-S
#                  ^^^^^ 行首若干空白后紧跟 -S,能直接跳到该参数的解释
#   查长参数:      /--reflink

# 直接在命令行里抽取想看的段落
man ls | sed -n '/^SYNOPSIS/,/^DESCRIPTION/p'      # 只看 SYNOPSIS
man tar | sed -n '/^EXAMPLES/,/^SEE ALSO/p'        # 只看示例
man ssh | grep -n 'ProxyJump'                      # 找关键词在第几行

# 把 man 转成纯文本存下来慢慢看
man -P cat ls > ls.txt          # -P 指定分页器为 cat(直接输出)
man ls | col -b > ls.txt        # col -b 去掉退格控制符

# 生成 PDF(需要 groff)
man -t ls | ps2pdf - ls.pdf

5.6 不知道命令名怎么办:apropos

这是最被低估的技能——知道想干什么,但不知道用哪个命令

apropos "disk usage"
# du (1)               - estimate file space usage
# df (1)               - report file system disk space usage
#  ^^ 按关键词搜索所有 man 页的 NAME 段

man -k compress        # 等价于 apropos
# gzip (1)             - compress or expand files
# zstd (1)             - zstd, zstdmt, unzstd, zstdcat - Compress or decompress
# xz (1)               - Compress or decompress .xz and .lzma files

apropos -s 2 socket    # 只在章节 2(系统调用)里搜
# socket (2)           - create an endpoint for communication
# socketpair (2)       - create a pair of connected sockets

# ⚠️ apropos 依赖手册索引数据库,新装的软件可能搜不到
sudo mandb             # 重建索引(Debian 系)
sudo makewhatis        # RHEL 系

5.7 man 之外的三个来源

# ① info:GNU 工具的完整文档,通常比 man 详细得多
info coreutils 'ls invocation'      # ls 的完整文档,比 man ls 长
info bash                            # bash 的完整手册(man bash 也有,但 info 有目录导航)
# 导航:n 下一节  p 上一节  u 上一层  Enter 进入链接  q 退出
# GNU 的官方立场是"man 只是简介,info 才是完整文档"

# ② /usr/share/doc/<包名>/:官方 README、示例配置、变更日志
ls /usr/share/doc/openssh-server/
# README.Debian  changelog.Debian.gz  examples/
zcat /usr/share/doc/curl/NEWS.gz | head       # 看版本变更

# ③ tldr:社区维护的「只给例子」速查(需要安装)
sudo apt install tldr && tldr tar
# - Create an archive from files:
#     tar cf target.tar file1 file2
# - Extract an archive in a target directory:
#     tar xf source.tar -C directory
#  ^^ 只有最常用的 5~8 个例子,学新命令时比 man 快得多

# 相关工具:cheat、navi、eg

5.8 实战演示:3 分钟学会一个陌生命令

假设你完全不认识 tar(它的参数风格特别古怪,是个好例子):

# 第 1 步:确认它是什么(10 秒)
type -a tar; whatis tar
# tar is /usr/bin/tar
# tar (1) - an archiving utility

# 第 2 步:看 SYNOPSIS,搞清骨架(20 秒)
man tar | sed -n '/^SYNOPSIS/,/^DESCRIPTION/p'
# tar [OPTION...] [FILE]...
# 看不出太多信息 -> 说明这个命令的参数是"模式 + 修饰"结构,继续看

# 第 3 步:找出核心的"操作模式"(40 秒)
tar --help | grep -A12 'Main operation mode'
#  -A, --catenate, --concatenate   append tar files to an archive
#  -c, --create                    create a new archive          <- 打包
#  -d, --diff, --compare           find differences
#      --delete                    delete from the archive
#  -r, --append                    append files to the end
#  -t, --list                      list the contents             <- 查看
#  -u, --update                    only append newer
#  -x, --extract, --get            extract files                 <- 解包
# 得到关键认知:c/x/t 三个是互斥的"模式",必须选一个

# 第 4 步:找出常用修饰参数(40 秒)
tar --help | grep -E '^\s+-(f|v|z|j|J|C|p)\b'
#  -f, --file=ARCHIVE         use archive file or device ARCHIVE   <- 指定文件名
#  -v, --verbose              verbosely list files processed
#  -z, --gzip                 filter through gzip
#  -j, --bzip2
#  -J, --xz
#  -C, --directory=DIR        change to directory DIR              <- 解压到哪

# 第 5 步:看官方示例(30 秒)
man tar | sed -n '/^EXAMPLES/,/^SEE ALSO/p'
#  tar -cf archive.tar foo bar   # Create archive.tar from files foo and bar.
#  tar -tvf archive.tar          # List all files in archive.tar verbosely.
#  tar -xf archive.tar           # Extract all files from archive.tar.

# 第 6 步:自己验证一遍(40 秒)
mkdir -p /tmp/t/{src,dst} && echo hi > /tmp/t/src/a.txt
tar -czvf /tmp/t/a.tar.gz -C /tmp/t/src .        # 打包
tar -tzvf /tmp/t/a.tar.gz                        # 查看,先确认结构再解
tar -xzvf /tmp/t/a.tar.gz -C /tmp/t/dst          # 解包
ls /tmp/t/dst

这套流程可以套用到任何命令type 确认身份 → SYNOPSIS 看骨架 → --help | grep 找关键参数 → EXAMPLES 抄例子 → /tmp 里自己跑一遍验证。最后一步不能省:文档会过时、会写错,也会因版本而异。

6. 命令行的语法与退出码

6.1 一条命令由什么组成

   ls    -l  --color=auto   /var/log   /tmp
   ^^    ^^  ^^^^^^^^^^^^   ^^^^^^^^^^^^^^^
   命令  短选项  长选项+值      操作数(operand)

   tail  -n 20        app.log
         ^^^^^ 选项 + 它自己的参数

四种写法(要认得,因为不同命令支持的不一样):

tail -n 20 f            # 短选项 + 空格 + 值
tail -n20 f             # 短选项 + 紧贴值(大多数 GNU 命令支持)
tail --lines=20 f       # 长选项 + 等号 + 值(最清晰,脚本里推荐)
tail --lines 20 f       # 长选项 + 空格 + 值

# 短选项可以聚合(前提是它们都不带值)
ls -l -a -h  ==  ls -lah
tar -c -z -v -f a.tgz .  ==  tar -czvf a.tgz .
#                              ^^^^^ 只有最后的 f 带值,所以 f 必须放最后!
tar -cfzv a.tgz .        # ❌ f 后面紧跟的 z 被当成文件名了

# -- 终止选项解析
grep -- '-v' file        # 搜索字符串 "-v",而不是把它当选项
rm -- -rf                # 删除名叫 -rf 的文件
git checkout -- file     # 明确表示 file 是路径而不是分支名

6.2 GNU 与 BSD 风格的差异

macOS 用的是 BSD 版工具,参数与 Linux(GNU)不同。这是「在 Mac 上写的脚本到服务器上跑挂了」的常见原因:

# sed 原地编辑:BSD 的 -i 必须带参数(备份后缀),GNU 的不带
sed -i    's/a/b/' f        # GNU(Linux)✅
sed -i '' 's/a/b/' f        # BSD(macOS)✅
sed -i.bak 's/a/b/' f       # 两边都能用 ✅ 这才是可移植写法

# date 加减时间,语法完全不同
date -d '+1 day' +%F        # GNU
date -v+1d +%F              # BSD

# 其他常见差异
ls --color=auto             # GNU
ls -G                       # BSD
readlink -f path            # GNU(macOS 需要 coreutils 里的 greadlink)
xargs -r                    # GNU 有 --no-run-if-empty,BSD 没有
stat -c '%s' f              # GNU
stat -f '%z' f              # BSD

# 在 macOS 上装 GNU 版(命令带 g 前缀)
brew install coreutils gnu-sed findutils
gsed -i 's/a/b/' f

实践建议:脚本开头声明目标环境,或者只用两边都支持的写法;容器化的项目直接在容器里跑脚本,彻底绕开这个问题。

6.3 退出码:脚本判断成败的唯一依据

每个命令结束时都会返回一个 0~255 的整数,$? 存着最近一条命令的退出码:

ls /tmp >/dev/null; echo $?
# 0            <- 0 = 成功

ls /nonexistent 2>/dev/null; echo $?
# 2            <- 非 0 = 失败(具体数字由程序自己定义)

通用约定(写脚本和用 Go 起子进程时都要知道):

退出码 含义
0 成功。唯一表示成功的值
1 通用错误
2 用法错误(参数写错),很多 GNU 工具用这个
126 文件找到了但不可执行(权限不足,或它是个目录)
127 命令不存在(PATH 里找不到)
128 + N 被信号 N 杀死130 = 128+2 = Ctrl-C(SIGINT),137 = 128+9 = SIGKILL(容器 OOMKilled 就是这个),143 = 128+15 = SIGTERM
255 越界/未定义(exit -1 会变成 255)
# 亲手验证
bash -c 'exit 0'; echo $?              # 0
nosuchcommand 2>/dev/null; echo $?     # 127
/etc/passwd 2>/dev/null; echo $?       # 126(不可执行)
bash -c 'kill -9 $$'; echo $?          # 137(被 SIGKILL)
bash -c 'sleep 10' &  kill -TERM $!; wait; echo $?   # 143

# 特定命令的退出码要查 man 的 EXIT STATUS 段
man grep | sed -n '/^EXIT STATUS/,/^FILES/p'
# 0 if a line is selected, 1 if no lines were selected, 2 if error
#                          ^^ 注意:grep 没找到匹配返回 1,这【不是错误】

最后这条很关键,它是 set -e 脚本的经典翻车点:

set -e                          # 任何命令失败就退出
grep foo file                   # 没匹配到 -> 返回 1 -> 整个脚本直接退出!
# ✅ 正确写法
grep foo file || true           # 显式吞掉非零退出码
if grep -q foo file; then ...; fi   # 或者放进条件判断(条件里的失败不触发 set -e)

管道里要用 PIPESTATUS,因为 $? 只反映最后一个命令:

false | true; echo $?
# 0            <- 只看到 true 的结果,前面的失败被吞了!

false | true; echo "${PIPESTATUS[@]}"
# 1 0          <- 每一段的退出码都在这

set -o pipefail                 # 让管道中任意一段失败都算整体失败
false | true; echo $?
# 1

# 生产脚本的标准三件套(第 17 篇细讲)
set -euo pipefail
#  ^ e=遇错退出  u=用未定义变量报错  o pipefail=管道任意段失败即失败

Go 里起子进程判断退出码:

cmd := exec.Command("git", "status")
err := cmd.Run()
if err != nil {
    var ee *exec.ExitError
    if errors.As(err, &ee) {
        code := ee.ExitCode()          // 命令跑了但返回非 0
        fmt.Printf("退出码 %d\n", code)
        // 被信号杀死时 ExitCode() 返回 -1,要从 ProcessState 里取信号
        if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
            fmt.Printf("被信号 %v 杀死\n", ws.Signal())
        }
    } else {
        // 这类是"根本没跑起来":命令不存在、权限不足
        fmt.Printf("启动失败: %v\n", err)   // exec: "git": executable file not found in $PATH
    }
}

注意 Go 的 exec.Command 不经过 shell,所以:

// ❌ 这样不会工作:管道、通配符、变量展开都是 shell 的功能
exec.Command("ls *.go | wc -l")

// ✅ 需要 shell 特性时显式调用 shell
exec.Command("bash", "-c", "ls *.go | wc -l")
// ⚠️ 但要警惕命令注入:任何用户输入拼进去都是漏洞

// ✅ 不需要 shell 时直接给参数数组(更安全,推荐)
exec.Command("ls", "-l", "/tmp")

7. 让命令行高效起来

这一章的东西不影响「懂不懂」,但每天能省很多时间。

7.1 必须形成肌肉记忆的快捷键

bash 默认用 emacs 风格的行编辑(底层是 readline 库,所有用 readline 的程序都通用:psqlredis-clipython):

快捷键 作用 记法
Ctrl-R 反向搜索历史命令(再按继续往前找) Reverse
Ctrl-A / Ctrl-E 跳到行首 / 行尾 Ahead / End
Ctrl-W 删除光标前一个单词 Word
Ctrl-U 删除光标到行首(清空当前行常用)
Ctrl-K 删除光标到行尾 Kill
Ctrl-Y 粘贴刚删除的内容 Yank
Alt-F / Alt-B 按单词前进 / 后退 Forward / Back
Ctrl-L 清屏(等价 clear,但不清历史)
Ctrl-C 中断当前命令(发 SIGINT) Cancel
Ctrl-D 输入结束(EOF);空行时等于退出 shell
Ctrl-Z 挂起当前程序到后台(fg 恢复,第 13 篇讲)
Alt-. 粘贴上一条命令的最后一个参数(超高频)
Ctrl-X Ctrl-E 把当前命令行丢进编辑器改(写长命令救命) Edit

历史扩展(! 系列):

!!              # 上一条命令
sudo !!         # 用 sudo 重跑上一条(忘加 sudo 时最常用)
!$              # 上一条命令的最后一个参数(等价 Alt-.)
!^              # 上一条命令的第一个参数
!*              # 上一条命令的所有参数
!vim            # 最近一条以 vim 开头的命令,直接执行
!?log?          # 最近一条包含 log 的命令
^old^new        # 把上一条命令里第一个 old 换成 new 后重跑

# 示例
mkdir /tmp/very/deep/path
cd !$                       # cd /tmp/very/deep/path

# ⚠️ !! 会立即执行,危险操作前先确认
!!:p            # 只打印不执行(:p = print)

7.2 历史记录的配置

history            # 看历史
history 20         # 最近 20 条
history -d 105     # 删掉第 105 条(敲错密码进历史时用)
history -c         # 清空当前会话历史

# 写进 ~/.bashrc 的推荐配置
HISTSIZE=100000                    # 内存里存多少条
HISTFILESIZE=200000                # 文件里存多少条
HISTTIMEFORMAT='%F %T '            # 给历史加时间戳 ← 排查"谁什么时候干了什么"必备
HISTCONTROL=ignoredups:erasedups   # 忽略重复
HISTIGNORE='ls:ll:cd:pwd:exit:history'   # 这些不记
shopt -s histappend                # 多个终端的历史【追加】而不是互相覆盖
PROMPT_COMMAND='history -a'        # 每敲一条就写入文件(不等退出)

# ⚠️ 安全提醒:带密码的命令会明文进历史文件
mysql -uroot -pMyPassword          # ❌ 进了 ~/.bash_history
mysql -uroot -p                    # ✅ 交互式输入
export HISTIGNORE='*password*'     # 或者过滤掉

# 命令前加空格可以不记入历史(需要 HISTCONTROL 含 ignorespace)
HISTCONTROL=ignorespace
 curl -H "Authorization: Bearer secret" ...      # 前面有空格,不进历史

7.3 Tab 补全

# 补全不只是补文件名,装了 bash-completion 后能补参数、分支名、包名
sudo apt install bash-completion       # Debian 系
sudo dnf install bash-completion       # RHEL 系

git ch<Tab>                # checkout / cherry-pick
systemctl restart ng<Tab>  # nginx.service
kill -<Tab><Tab>           # 列出所有信号名
docker exec -it <Tab>      # 列出运行中的容器名

# Tab 按两次 = 列出所有候选
ls /usr/<Tab><Tab>

# Go 的补全(cobra 生成的 CLI 一般都支持)
mytool completion bash > /etc/bash_completion.d/mytool

7.4 alias 与 function 的边界

# alias:只做简单的字符串替换,不能接收参数
alias ll='ls -alF'
alias gs='git status'
alias ..='cd ..'
alias grep='grep --color=auto'
alias df='df -hT -x tmpfs'

# ⚠️ alias 不接受参数,需要参数就必须用函数
alias mkcd='mkdir -p $1 && cd $1'       # ❌ 不工作,$1 是空的
mkcd() { mkdir -p "$1" && cd "$1"; }    # ✅ 用函数

# 更实用的几个函数
gitlog() { git log --oneline --graph --decorate -"${1:-10}"; }   # 默认 10 条
extract() {                                                       # 万能解压
    case "$1" in
        *.tar.gz|*.tgz) tar -xzvf "$1" ;;
        *.tar.xz)       tar -xJvf "$1" ;;
        *.zip)          unzip "$1" ;;
        *) echo "不认识的格式: $1"; return 1 ;;
    esac
}

# 查看/取消
alias                # 列出全部 alias
alias ll             # 只看一个
unalias ll
type mkcd            # 看函数定义

# ⚠️ 不要在【脚本】里依赖 alias
# 非交互式 shell 默认不展开 alias,脚本里的 alias 不生效(除非 shopt -s expand_aliases)

一条实践建议:不要给危险命令加「静默改行为」的 alias(比如 alias rm='rm -i')。它会让你养成「反正会问」的错误肌肉记忆,换到一台没有这个 alias 的机器上就会翻车。要么不加,要么加成一个不同的名字alias rmi='rm -I')。

8. 知识点扩展

8.1 uname — unix name

全称uname = unix name(print system information) 作用:从内核读取系统标识信息(本质是 uname(2) 系统调用的封装)。

输出
-a --all 全部信息
-s --kernel-name 内核名(Linux),不带参数时的默认行为
-r --kernel-release 内核版本(5.15.0-91-generic)← 最常用
-v --kernel-version 内核编译信息与时间
-m --machine 硬件架构(x86_64 / aarch64
-p --processor CPU 类型(常显示 unknown)
-i --hardware-platform 硬件平台(常显示 unknown)
-n --nodename 主机名(等价 hostname
-o --operating-system 操作系统(GNU/Linux
uname -r                            # 内核版本
uname -m                            # 架构:x86_64 -> Go 的 amd64;aarch64 -> arm64
uname -sr                           # Linux 5.15.0-91-generic
[[ $(uname -m) == aarch64 ]] && echo "ARM 机器"

uname -m内核的架构。在 ARM Mac 上用 Rosetta 跑 x86 容器时,容器里 uname -m 可能返回宿主内核的架构,判断镜像架构应该用 dpkg --print-architecturego env GOARCH

8.2 type / command / which / whereis / hash

命令 类型 作用 什么时候用
type -a NAME bash 内建 列出名字的所有身份(alias/keyword/function/builtin/文件) 排查「这命令到底是什么」,首选
type -t NAME bash 内建 只输出类型词(alias/file/builtin/function/keyword 脚本里判断类型
type -p NAME bash 内建 只输出可执行文件路径(是文件才输出) 要路径且忽略 alias
command -v NAME POSIX 内建 输出会被执行的东西 脚本里判断命令是否存在,首选
command -V NAME POSIX 内建 详细描述(类似 type)
command NAME POSIX 内建 跳过 alias 和 function 执行 绕过 alias
which NAME 外部程序 只扫 PATH 找可执行文件 不推荐(看不到 builtin/alias)
whereis NAME 外部程序 找程序 + 源码 + man(按固定目录,不看 PATH) 找 man 手册或配置在哪
hash bash 内建 查看/清除命令路径缓存 换了程序位置后 hash -r
builtin NAME bash 内建 强制执行内建版本 被同名函数覆盖时
enable -a bash 内建 列出所有 builtin 想知道有哪些内建命令
type -a python3                # 看清有几个 python3、哪个生效
type -t ls                     # alias
command -v docker >/dev/null || { echo "请先装 docker"; exit 1; }
hash -r                        # 换了 go 的安装位置后必做
enable -a | head               # 所有 builtin 列表
compgen -c | sort -u | wc -l   # 当前能敲的命令总数(含 alias/函数/builtin)
compgen -b                     # 只列 builtin
compgen -A function            # 只列函数

8.3 man / apropos / whatis / info

命令 作用
man CMD 看手册(默认取章节号最小的)
man N CMD 看第 N 章节的手册(man 2 write
man -a CMD 依次看所有章节
man -f CMD / whatis CMD 一句话说明 + 它在哪些章节存在
man -k KEY / apropos KEY 按关键词搜索所有手册(不知道命令名时)
man -k KEY -s N 只在章节 N 里搜
man -w CMD 显示手册文件的路径
man -P cat CMD 用 cat 代替 less 输出(便于重定向)
man -t CMD 输出 PostScript(可转 PDF)
man --path 显示手册搜索路径(等价 manpath
info CMD GNU 完整文档
mandb / makewhatis 重建手册索引(新装软件搜不到时)
man 2 openat                        # 系统调用
man 5 sshd_config                   # 配置文件格式
man 7 epoll                         # 概念长文
man -k 'socket' -s 2,3              # 在系统调用和库函数里搜 socket
man -w bash                         # /usr/share/man/man1/bash.1.gz
MANPAGER='less -R' man ls           # 保留颜色
MANWIDTH=100 man ls                 # 限制宽度(宽屏上更易读)
LANG=C man ls                       # 强制英文手册(中文翻译常常过时)

没有 man 的环境(Alpine、distroless、精简镜像):

apk add man-pages mandoc            # Alpine 上手动装
# 或者退回 --help、busybox 的内置帮助、以及在线手册 man7.org

8.4 env / printenv / export / set

命令 作用
env 列出所有环境变量(外部程序)
env VAR=v CMD 用修改后的环境运行命令
env -i CMD 清空环境运行命令(调试环境依赖问题的利器)
env -u VAR CMD 删掉某个变量后运行
printenv 列出环境变量(内建,可指定单个)
export VAR=v 定义并导出(子进程可见)
export -p 列出所有已导出的变量
export -n VAR 取消导出(保留值)
unset VAR 删除变量
set 列出所有 shell 变量与函数
set -x / set +x 打开 / 关闭命令追踪(调试脚本最有用的开关
declare -p VAR 看变量的属性与值
env | sort | less                       # 通读当前环境
env -i bash --noprofile --norc          # 一个"干净"的 shell,排查配置文件问题
env -u http_proxy curl example.com      # 临时绕过代理
TZ=UTC date                             # 只影响这条命令
set -x; ls /tmp; set +x                 # 看命令展开成了什么
sudo tr '\0' '\n' < /proc/1234/environ  # 看运行中进程的环境(容器排查常用)

8.5 系统信息类

hostnamectl                          # 主机名 + 系统 + 内核 + 架构 + 虚拟化(systemd 系)
hostnamectl set-hostname web-01      # 改主机名(持久化)
cat /etc/os-release                  # 发行版信息(最可靠)
lsb_release -a                       # 同上(需装 lsb-release 包,不推荐依赖)
cat /proc/version                    # 内核版本 + 编译器版本
cat /proc/cpuinfo | grep -c processor    # 逻辑 CPU 数
nproc                                # 同上,更简洁(⚠️ 容器里返回宿主机核数!见下)
free -h                              # 内存
uptime                               # 运行时长 + 平均负载
who / w                              # 谁登录着
id                                   # 当前用户的 uid/gid/组
getconf ARG_MAX                      # 命令行参数的最大长度
getconf PAGE_SIZE                    # 内存页大小(4096)
getconf -a | head -30                # 所有系统配置常量
timedatectl                          # 时间、时区、NTP 同步状态

容器里的一个重要陷阱nprocfree -h 读的是宿主机的信息,不受 cgroup 限制影响:

# 容器限制 1 核,但
nproc                                # 64      <- 看到的是宿主机
# 结果:Go 的 GOMAXPROCS 默认取 nproc = 64,而实际只能用 1 核,
#       导致大量无效的 goroutine 抢占与上下文切换,尾延迟变差

# ✅ 解法:用 automaxprocs 让 GOMAXPROCS 感知 cgroup 限额
# import _ "go.uber.org/automaxprocs"

# 手动查真实限额(cgroup v2)
cat /sys/fs/cgroup/cpu.max           # 100000 100000  -> 1 核
cat /sys/fs/cgroup/memory.max        # 536870912      -> 512MB

这个问题的完整机制在第 36 篇 namespace 与 cgroup里讲。

9. 面试题

Q:Linux 和 GNU/Linux 有什么区别?内核版本和发行版版本是什么关系?

Linux 严格来说只是内核——负责进程调度、内存管理、文件系统、网络协议栈,通过系统调用向上提供接口。它自己不包含任何命令行工具。你日常用的 lsbashgcc 以及 C 标准库 glibc 来自 GNU 项目,两者组合起来才是可用的操作系统,所以严谨叫法是 GNU/Linux。

两个版本号相互独立:uname -r 是内核版本,/etc/os-release 是发行版版本。同一个 Ubuntu 22.04 可以换不同内核,同一个内核 5.15 也能跑在 Ubuntu 和 Debian 上。

这个区分有实际后果:换掉用户态就是另一个系统。Alpine 用 musl 替代 glibc、busybox 替代 GNU coreutils,所以同样的 ls 参数在 Alpine 上可能不存在,在 Ubuntu 上编译的动态链接二进制在 Alpine 里会报 not found

Q:敲下一条命令后,bash 是按什么顺序查找的?为什么 which 不可靠?

顺序是 alias → keyword → function → builtin → PATH 里的可执行文件,找到第一个就执行,全都没有则报 command not found(退出码 127)。

which 不可靠的原因是它本身是个外部程序,只能扫 PATH 找文件,看不见 alias、function、builtin 的存在——which cd 什么都查不到,which ls 也不会告诉你其实有个 alias 会先生效。正确工具是 type -a(列出所有身份,排查用)和 command -v(POSIX 标准,脚本里判断命令是否存在用)。

补充一个高频故障:换了程序位置后仍执行旧路径,因为 bash 有命令路径哈希缓存,hash -r 清掉即可。排查「装了新版本但命令还是旧的」的标准顺序是:type -ahash -recho $PATH → 确认配置文件是否被读取。

Q:cd 为什么必须是 shell 内建命令,不能做成 /bin/cd

因为当前工作目录(cwd)是每个进程各自的属性。如果 cd 是外部程序,shell 执行它时要先 fork() 出子进程再 exec(),子进程调用 chdir() 改的是自己的 cwd,进程一退出就没了,父 shell 的 cwd 纹丝不动。只有作为 builtin、由 shell 进程自己调用 chdir(),才能真正改变这个 shell 的目录。

同理必须是 builtin 的还有:export/unset(改自己的环境变量)、source(要在当前 shell 里执行,否则就成子进程了)、exec(替换当前进程映像)、exitread(要给当前 shell 的变量赋值)、ulimit/umask(改自己的进程属性)、jobs/fg/bg(作业表是 shell 的内部状态)。

由此也能回答「为什么改完 .bashrcsource 而不是 ./」——./ 起子进程,改动随子进程消失。

Q:man 2 write 里的 2 是什么意思?服务端开发最常用哪几个章节?

2手册章节号。分章节是因为同一个名字在不同层次含义完全不同:write(1) 是给其他登录用户发消息的命令,write(2) 是往 fd 写数据的系统调用,printf(1) 是 shell 命令而 printf(3) 是 C 库函数。不写章节号时 man 返回编号最小的那个,所以 man write 给你的是命令而不是系统调用。

服务端最常用三个:章节 2(系统调用)——搞清 Go 的 net/os 包底层行为,如 man 2 write 里明确写着「返回值小于请求字节数不是错误」,这正是 Go 里 Write 可能部分写入的原因;章节 5(配置文件格式)——man 5 sshd_configman 5 systemd.service 比搜博客权威;章节 7(概念)——man 7 tcpman 7 epollman 7 signal 是高质量的官方长文。

配套技巧:man -f NAME(等价 whatis)看它在哪些章节存在,man -a NAME 依次看完,man -k 关键词(等价 apropos)在不知道命令名时按功能搜索。

Q:go version 正常,sudo go version 报 command not found,为什么?

因为 sudo 会重置 PATH,用的是 /etc/sudoers 里的 secure_path(通常只有 /usr/local/bin:/usr/bin:/bin 等标准路径),而不是你当前用户的 PATH。这是安全设计:防止普通用户篡改自己的 PATH,让 sudo 执行一个伪造的同名程序从而提权。

解法按推荐顺序:① 写绝对路径 sudo /usr/local/go/bin/go version;② sudo env "PATH=$PATH" go version;③ sudo -E(保留全部环境变量,权限较松,需 sudoers 允许);④ 用 visudo 把路径加进 secure_path

Q:PATH 里加上 .(当前目录)有什么风险?

会导致当前目录下的程序被优先执行。攻击场景:攻击者在 /tmp 或某个共享目录放一个名叫 ls(或 sudogit)的恶意脚本,你 cd 进去后随手敲 ls,执行的就是他的脚本,且是以你的权限运行。如果 . 排在 PATH 前面,连系统命令都能被劫持。

这也是为什么执行当前目录的程序必须写 ./myapp 而不能直接写 myapp——Unix 用这个显式的 ./ 来强制你确认「我是要跑当前目录这个文件」。同理,root 的 PATH 里绝对不能有 .sudoerssecure_path 也是同一思路的防御。

Q:退出码 0、1、126、127、137 分别代表什么?

0唯一表示成功的值。1 是通用错误,2 常表示用法错误。126 是「文件找到了但不可执行」(权限不足或它是个目录),127 是「命令不存在」(PATH 里找不到)。128+N 表示被信号 N 杀死130=Ctrl-C(SIGINT)、143=SIGTERM、137=SIGKILL,容器里看到 137 基本就是 OOMKilled

两个实战延伸:① grep 没匹配到内容返回 1,这不是错误,但在 set -e 的脚本里会导致整个脚本退出,要写成 grep foo f || true 或放进 if 条件;② 管道中 $? 只反映最后一个命令,false | true$? 是 0,要用 ${PIPESTATUS[@]} 看每一段,或者 set -o pipefail 让任意一段失败都算失败。

Q:在 Ubuntu 上编译的 Go 程序,放进 Alpine 容器报 not found,但文件明明存在。为什么?

因为二进制动态链接了 glibc,而 Alpine 用的是 musl,没有 /lib64/ld-linux-x86-64.so.2 这个动态链接器。内核加载失败后 shell 报的是 not found,指的是找不到解释器,不是找不到你的文件——这个报错极具误导性。用 file myapp 看到 dynamically linkedldd myapp 看到 libc.so.6 => not found 即可确诊。

根因是 Go 在启用 CGO 时,netos/user 包会调用 glibc 的 getaddrinfo/getpwnam 以支持 NSS。解法是 CGO_ENABLED=0 go build 静态编译,代价是 DNS 解析改用纯 Go 实现、不再支持 /etc/nsswitch.conf 里配置的 LDAP/mDNS 等方式。排查 DNS 行为差异时可以用 GODEBUG=netdns=2 打印 Go 实际选用了哪个 resolver。

生产上更推荐 gcr.io/distroless/static 作为基础镜像:只有 2MB,自带 CA 证书和时区数据,攻击面比 Alpine 更小。代价是没有 shell,排查要靠 kubectl debug 的临时容器。

Q:改了 ~/.bashrc,为什么 ssh host 'command' 执行时不生效?

因为 bash 分三种模式读不同的配置文件:登录 shell(ssh 交互登录、su -)读 /etc/profile~/.bash_profile~/.bash_login~/.profile第一个存在的交互式非登录 shell(终端开新标签)读 ~/.bashrc非交互式ssh host 'cmd'、执行脚本、cron)两个都不读

所以标准做法是把配置写在 ~/.bashrc,并让 ~/.bash_profilesource ~/.bashrc,覆盖前两种情况。对于第三种,只能显式写绝对路径,或用 ssh host 'bash -lc "命令"' 强制以登录 shell 运行。

同一个坑在 cron 里更常见——cron 的环境极度精简,PATH 通常只有 /usr/bin:/bin,这是「脚本手动跑正常、放进 cron 就失败」的头号原因。

Q:容器里 nproc 返回 64,但容器只分配了 1 核,会有什么问题?

nprocfree 读的是 /proc宿主机的信息,/proc 没有被 cgroup 命名空间化,所以看不到容器的资源限额。后果是 Go 的 GOMAXPROCS 默认取 nproc=64,runtime 会创建 64 个 P 去调度 goroutine,而 CFS 只给你 1 核的配额——结果是频繁触发限流(throttling)和大量无效的上下文切换,尾延迟显著变差

解法:引入 go.uber.org/automaxprocs(自动读 cgroup 的 cpu.max 设置 GOMAXPROCS),或在部署时显式设 GOMAXPROCS 环境变量。同理内存要设 GOMEMLIMIT,否则 GC 不知道容器的内存上限,容易被 OOMKilled。真实限额在 /sys/fs/cgroup/cpu.max/sys/fs/cgroup/memory.max(cgroup v2)。


小结

这一篇建立的是坐标系,不是操作技巧:

  • Linux 是内核,发行版 = 内核 + 用户态。换掉用户态(musl/busybox)就是另一套行为,这是容器踩坑的根源
  • 登录陌生机器先敲三条:uname -r(内核)、cat /etc/os-release(发行版)、uname -m(架构)
  • 命令有五种,只有一种是文件。type -a 看身份,command -v 判断存在which 不可靠
  • cwd 是进程私有属性,所以 cd 必须是 builtin —— 这条推理能解释一整批 builtin 的存在理由
  • PATH 从左到右查找、sudo 会重置它、. 绝不能进 PATH
  • 自学一个命令的固定流程:type 确认身份 → SYNOPSIS 看骨架 → --help | grep 找参数 → EXAMPLES 抄例子 → /tmp 里亲手验证
  • man 的章节号是分层的:2 系统调用、5 配置格式、7 概念长文,服务端开发最常查这三个
  • 退出码 0 成功、127 命令不存在、126 不可执行、128+N 被信号杀(137 = OOMKilled

下一篇讲 包管理器:apt、dnf、yum、dpkg、rpm 怎样选择版本、解析依赖、验证仓库签名,离线环境怎样准备完整依赖闭包,以及一个软件包怎样从仓库进入系统并影响 systemd。