Linux-01 重新认识 Linux:内核、发行版,与自学任何命令的方法
这是 Linux 系列的第一篇。全系列共 40 篇,分两部分:01~17 篇是上手层,把日常操作讲透、命令参数讲全;18~40 篇是深入层,讲每个机制「为什么被设计成这样」。
第一篇不讲具体操作,讲两件比操作更长期有用的事:给自己建立一个坐标系(我在什么系统上、命令从哪来),以及掌握自学任何陌生命令的方法。后者的收益是复利的——学会查 man 之后,剩下 39 篇里出现的几百个参数你都能自己验证。
先看五个问题,能全答对可以跳到第 5 章:
ls这个程序的文件在哪?敲下ls时系统怎么知道去哪找它?cd在/bin和/usr/bin里都找不到。它是什么?man ls和ls --help内容不一样,该看哪个?man 2 write里的2是什么意思?为什么write需要分章节?- 为什么
go version好用,但sudo go version报command 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 项目,而 ls、bash、gcc、glibc 这些用户态工具来自 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 默认会动态链接? 因为 net 和 os/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,工具类脚本用 ./」。这也是为什么改完 ~/.bashrc 要 source ~/.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 |
系统管理命令(useradd、iptables),历史上只给 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 |
默认编辑器(visudo、git commit 用它) |
PS1 |
命令提示符的格式 |
LD_LIBRARY_PATH |
动态库搜索路径(谨慎使用,容易引发诡异问题) |
http_proxy / https_proxy / no_proxy |
代理设置(curl、go 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 改哪个配置文件才生效
这是个高频困惑:改了 ~/.bashrc,ssh 登录后不生效;或者反过来。 原因是 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 ls、man 1 write |
| 2 | 系统调用(内核提供的接口) | man 2 write、man 2 open、man 2 fork |
| 3 | 库函数(libc 提供的 C 函数) | man 3 printf、man 3 malloc |
| 4 | 设备与特殊文件(/dev 下的东西) |
man 4 tty、man 4 random |
| 5 | 配置文件格式 | man 5 crontab、man 5 fstab、man 5 sshd_config |
| 6 | 游戏 | man 6 sl |
| 7 | 杂项与概念说明 | man 7 signal、man 7 tcp、man 7 capabilities |
| 8 | 系统管理命令(一般需 root) | man 8 mount、man 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 进下一个)
这三个章节是服务端开发最常用的:
- 章节 2:
man 2 epoll_wait、man 2 fsync、man 2 sendfile—— 想搞清 Go 的net、os包底层在干什么,就查这里。里面有精确的语义、错误码、边界条件,是比任何博客都权威的资料。 - 章节 5:
man 5 sshd_config、man 5 systemd.service、man 5 resolv.conf—— 配置文件的每个字段含义,比搜博客靠谱得多。 - 章节 7:
man 7 tcp、man 7 signal、man 7 epoll、man 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 tar、man 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 的程序都通用:psql、redis-cli、python):
| 快捷键 | 作用 | 记法 |
|---|---|---|
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-architecture 或 go 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 同步状态
容器里的一个重要陷阱:nproc、free -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 严格来说只是内核——负责进程调度、内存管理、文件系统、网络协议栈,通过系统调用向上提供接口。它自己不包含任何命令行工具。你日常用的 ls、bash、gcc 以及 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 -a → hash -r → echo $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(替换当前进程映像)、exit、read(要给当前 shell 的变量赋值)、ulimit/umask(改自己的进程属性)、jobs/fg/bg(作业表是 shell 的内部状态)。
由此也能回答「为什么改完 .bashrc 要 source 而不是 ./」——./ 起子进程,改动随子进程消失。
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_config、man 5 systemd.service 比搜博客权威;章节 7(概念)——man 7 tcp、man 7 epoll、man 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(或 sudo、git)的恶意脚本,你 cd 进去后随手敲 ls,执行的就是他的脚本,且是以你的权限运行。如果 . 排在 PATH 前面,连系统命令都能被劫持。
这也是为什么执行当前目录的程序必须写 ./myapp 而不能直接写 myapp——Unix 用这个显式的 ./ 来强制你确认「我是要跑当前目录这个文件」。同理,root 的 PATH 里绝对不能有 .,sudoers 的 secure_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 linked 或 ldd myapp 看到 libc.so.6 => not found 即可确诊。
根因是 Go 在启用 CGO 时,net 和 os/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_profile 去 source ~/.bashrc,覆盖前两种情况。对于第三种,只能显式写绝对路径,或用 ssh host 'bash -lc "命令"' 强制以登录 shell 运行。
同一个坑在 cron 里更常见——cron 的环境极度精简,PATH 通常只有 /usr/bin:/bin,这是「脚本手动跑正常、放进 cron 就失败」的头号原因。
Q:容器里 nproc 返回 64,但容器只分配了 1 核,会有什么问题?
nproc 和 free 读的是 /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。
xingliuhua