Linux-05 查看文件内容:less 的正确用法、tail -f 与 -F 的本质区别、缓冲与编码
上一篇讲了文件的元数据,这一篇讲怎么看内容。同样五个问题开头:
cat一个 5GB 的日志会发生什么?为什么该用less?tail -f和tail -F差在哪?为什么日志切割之后tail -f突然就不动了?tail -f app.log | grep ERROR为什么半天不出东西,直接grep却很快?- 一个文件全是乱码,怎么判断它到底是什么编码?
cat了一个二进制文件,终端全乱了、敲什么都是方块,怎么恢复?
第三个问题的答案(缓冲)是本篇最有价值的部分,它能解释一大类「管道里的命令看起来卡住了」的现象。
1. cat 家族:它其实不是「查看」命令
1.1 cat 的真名是 concatenate
cat = catenate(连接),它的设计用途是把多个文件拼接起来输出,「查看单个文件」只是它的副作用:
cat a.txt b.txt > merged.txt # 这才是它的本职工作
cat part-*.csv > all.csv # 拼接分片文件
理解这一点就明白它的局限:cat 会把内容原封不动地全部倒进标准输出,没有分页、没有截断、没有过滤。对 5GB 的日志文件执行 cat:
- 终端疯狂刷屏几分钟(且大部分内容你根本来不及看)
- 终端模拟器要维护回滚缓冲,内存暴涨甚至卡死
- 通过 ssh 时还要把 5GB 全部传过网络
看文件内容永远用 less,cat 只用于拼接、或者确认小文件。
1.2 cat 的参数:-A 是排查利器
| 短 | 长 | 全称 | 作用 |
|---|---|---|---|
-n |
--number |
number | 给所有行编号 |
-b |
--number-nonblank |
number nonblank | 只给非空行编号 |
-s |
--squeeze-blank |
squeeze | 连续多个空行压成一个 |
-E |
--show-ends |
show ends | 行尾显示 $ |
-T |
--show-tabs |
show tabs | tab 显示成 ^I |
-v |
--show-nonprinting |
— | 不可打印字符用 ^X/M-x 表示 |
-A |
--show-all |
show all | = -vET,显示全部不可见字符 |
cat -A 能解决一整类「看起来一样但就是不工作」的诡异问题:
cat -A config.txt
# name=value$ <- $ 是行尾(Unix 换行 LF)
# host=localhost^M$ <- ^M 是 \r,这是 Windows 换行 CRLF!
# ^^^^^^ 程序读到的 host 值是 "localhost\r",连不上是必然的
# path=/data ^I$ <- ^I 是 tab,行尾还有个空格
# ^^^ 尾随空白,很多配置解析器不会 trim
# 典型受害场景
# ① shell 脚本报 "$'\r': command not found" -> CRLF
# ② 配置里的 IP 地址连不上 -> 尾随 \r
# ③ YAML 缩进报错但肉眼看不出 -> 混用 tab 和空格
# ④ 密码/token 认证失败 -> 复制时带了尾随空格或换行
# 修复
dos2unix config.txt # 最直接(需安装 dos2unix)
sed -i 's/\r$//' config.txt # 等价,无需额外安装
sed -i 's/[[:space:]]*$//' config.txt # 去掉所有尾随空白
1.3 UUOC:无谓地使用 cat
# ❌ 多起了一个进程,还多了一次管道拷贝
cat access.log | grep 500
cat access.log | wc -l
# ✅ 这些命令本来就接受文件名参数
grep 500 access.log
wc -l < access.log
# ^ 用重定向也行,且 wc 不会输出文件名
# 什么时候 cat 是合理的?
cat a.log b.log | grep ERROR # ✅ 真的要拼接多个文件
grep ERROR a.log b.log # 也行,但输出会带文件名前缀
这不只是「优雅」问题:多一个进程就多一次 fork+exec 和一次数据拷贝,在循环里跑几万次差异明显(回顾 01 篇 3.2 的 echo 对比实验)。
1.4 同族命令
tac file # cat 反过来拼(倒序输出行)—— 看日志最新的在前很有用
tac app.log | head -20 # 最后 20 行,且倒序显示
rev file # 每行内部字符反转(不是行序)
echo "hello" | rev # olleh
# 实用场景:按后缀排序(把域名反转后排序,能把同后缀的聚在一起)
cut -d: -f1 /etc/passwd | rev | sort | rev | head
nl file # 加行号(比 cat -n 更灵活)
nl -ba -w4 -s': ' file # b=body 编号策略(a=所有行) w=宽度 s=分隔符
2. less:最该练熟的工具
2.1 为什么 less 能瞬间打开 10GB 文件
time less huge.log # 瞬间打开,按 q 退出
# real 0m0.01s
因为 less 只读取当前屏幕需要的那部分(按需 seek + read),不像 cat 或早期的编辑器要把整个文件载入内存。这也是它相对 more 的核心优势——more 只能往下翻,less 可以任意跳转,所以有了那句双关的命名梗:less is more(less 比 more 更强)。
2.2 必须记住的快捷键
| 键 | 作用 | 记法 |
|---|---|---|
/pattern |
向下搜索 | 同 vim |
?pattern |
向上搜索 | |
n / N |
下一个 / 上一个匹配 | next |
G |
跳到文件末尾(看最新日志) | |
g |
跳到文件开头 | |
50% |
跳到文件 50% 处 | |
100g |
跳到第 100 行 | |
空格 / f |
下一页 | forward |
b |
上一页 | back |
d / u |
下半页 / 上半页 | down / up |
F |
跟随模式(等价 tail -f),Ctrl-C 退出跟随 |
Follow |
&pattern |
只显示匹配的行(过滤模式!),& 回车取消 |
|
-i 然后回车 |
运行中切换「忽略大小写」 | |
m + 字母 |
打标记 | mark |
' + 字母 |
跳到标记('' 回到上次位置) |
|
:n / :p |
下一个 / 上一个文件(打开多个文件时) | |
v |
用 $EDITOR 打开当前文件 |
|
= 或 Ctrl-G |
显示当前位置、行号、文件大小 | |
q |
退出 | |
h |
less 自己的帮助 |
两个用得最少但价值最高的:
# ① F 键:把 less 变成 tail -f,但可以随时 Ctrl-C 停下来往回翻
less app.log
# 按 F -> 开始实时跟随
# 看到可疑内容,按 Ctrl-C -> 停止跟随,此时可以自由搜索、往回翻
# 按 F -> 继续跟随
# 这比 tail -f 强太多:tail -f 里你没法往回看
less +F app.log # 打开就直接进入跟随模式
# ② & 过滤:只显示匹配的行,其余隐藏
less app.log
# 输入 &ERROR 回车 -> 只剩含 ERROR 的行
# 输入 &!DEBUG 回车 -> 排除含 DEBUG 的行(! 表示取反)
# 输入 & 回车 -> 取消过滤
# 相当于在 less 内部做 grep,而且能随时改条件、不用退出重跑
2.3 常用启动参数
| 参数 | 全称 | 作用 |
|---|---|---|
-N |
--LINE-NUMBERS |
显示行号 |
-S |
--chop-long-lines |
长行不折行(超宽日志、CSV 必用,用 ←→ 横向滚动) |
-i |
--ignore-case |
搜索忽略大小写(除非搜索词含大写) |
-I |
--IGNORE-CASE |
完全忽略大小写 |
-R |
--RAW-CONTROL-CHARS |
保留 ANSI 颜色(不加的话彩色日志会显示成 ESC[31m 乱码) |
-X |
--no-init |
退出后不清屏(保留看到的内容在终端里) |
-F |
--quit-if-one-screen |
内容不足一屏时直接输出并退出 |
+F |
— | 启动即进入跟随模式 |
+G |
— | 启动即跳到末尾 |
+/pattern |
— | 启动即搜索 |
-n |
--line-numbers |
关闭行号计算(超大文件能更快) |
-m / -M |
— | 显示进度百分比 / 更详细的状态行 |
# 推荐写进 ~/.bashrc 的默认值
export LESS='-R -i -M -S'
# | | | └ 长行不折行
# | | └── 详细状态行(显示行号与百分比)
# | └───── 搜索忽略大小写
# └──────── 保留颜色
# 各种实用组合
less -N +G app.log # 带行号,从末尾开始看
less -S data.csv # 宽表格不折行
journalctl -u nginx | less -R # 保留 journalctl 的颜色
git log -p | less -R # git 默认就是用 less
docker logs -f app 2>&1 | less -R +F # 跟随容器日志且可回翻
2.4 less 也能直接看压缩文件
# lesspipe 让 less 能预处理各种格式(多数发行版默认已配好)
less app.log.gz # 自动解压显示
less archive.tar.gz # 显示包内文件列表
less image.png # 显示图片元信息
# 没配好的话手动开
export LESSOPEN="| /usr/bin/lesspipe %s"
# 或者用专门的命令
zless app.log.gz
3. head / tail:取头尾
3.1 参数
head file # 默认前 10 行
head -n 20 file # 前 20 行(-n = --lines)
head -n -5 file # 【除了最后 5 行】的所有内容(负数语义!)
head -c 100 file # 前 100 字节(-c = --bytes)
head -c 1M bigfile # 前 1MB
head -q a b # quiet:多文件时不打印文件名头
head -v file # verbose:单文件时也打印文件名头
tail file # 默认后 10 行
tail -n 50 file # 后 50 行
tail -n +100 file # 【从第 100 行开始】到结尾(+ 号语义!)
tail -c 1K file # 后 1KB
# 组合技:取第 100~110 行
sed -n '100,110p' file # ✅ 最直接
head -n 110 file | tail -n 11 # 也行
awk 'NR>=100 && NR<=110' file # 也行
# 跳过 CSV 表头
tail -n +2 data.csv | wc -l
3.2 tail -f 与 -F:本质区别
这是开篇第二问,也是日志排查的高频坑。
tail -f 跟的是「打开的文件描述符」,tail -F 跟的是「文件名」。
tail -f app.log # = --follow=descriptor(默认):盯着这个 fd(也就是这个 inode)
tail -F app.log # = --follow=name --retry:盯着这个【路径】
回顾 04 篇讲的:文件名只是目录里指向 inode 的一行。日志轮转(logrotate)时发生的是:
轮转前: app.log ──────> inode A (tail -f 打开了它,持有 fd)
↑ 程序也在往这里写
logrotate 执行 mv app.log app.log.1 并创建新文件:
轮转后: app.log.1 ────> inode A (tail -f 还盯着这个!但已经没人往里写了)
app.log ──────> inode B (新文件,程序 reopen 后写这里)
结果:tail -f 永远不再有新输出,看起来"卡住了"
# 亲手复现
echo "line1" > /tmp/t.log
tail -f /tmp/t.log & # 后台跟随
mv /tmp/t.log /tmp/t.log.1 # 模拟轮转
echo "line2" > /tmp/t.log # 写新文件
sleep 1
# tail -f 什么都没输出 —— 它还盯着老 inode
kill %1
tail -F /tmp/t.log & # 换成 -F
echo "line3" >> /tmp/t.log
# line3 <- 正常输出
# tail: '/tmp/t.log' has been replaced; following new file
# ^^ -F 会检测到文件被替换并自动重新打开
结论:跟随日志一律用 tail -F。 -F 展开是 --follow=name --retry,--retry 让它在文件暂时不存在时也持续重试(服务还没启动、日志还没创建时不会直接退出)。
其他跟随相关参数:
tail -F -n 100 app.log # 先显示最后 100 行,再开始跟随
tail -F app.log error.log # 同时跟随多个文件(会打印 ==> 文件名 <== 分隔)
tail -F --pid=1234 app.log # 进程 1234 结束时自动退出(脚本里很有用)
tail -F -s 5 app.log # 每 5 秒检查一次(默认 1 秒),降低 IO
tail -f app.log --max-unchanged-stats=5 # 多少次无变化后重新检查文件名
更好的选择:如果服务用 systemd 管理,日志走 journald,直接用 journalctl -fu 服务名 就没有轮转问题(journald 自己管理存储)。容器里用 docker logs -f / kubectl logs -f 同理。
4. 缓冲:管道里的命令为什么「卡住」
开篇第三问。这是本篇最值得理解的机制。
4.1 三种缓冲模式
C 标准库(stdio)对输出有三种缓冲策略,程序自己不选,由输出目标自动决定:
| 模式 | 何时刷新 | 什么时候采用 |
|---|---|---|
| 无缓冲 | 立即写 | stderr 永远无缓冲(所以报错总能立刻看到) |
| 行缓冲 | 遇到 \n 就写 |
stdout 连着终端时 |
| 全缓冲 | 缓冲区满(通常 4KB/8KB)才写 | stdout 连着管道或文件时 |
关键就在最后一行:一旦你把输出接进管道,缓冲模式就从「行缓冲」变成了「全缓冲」。
# 直接跑:每来一行就显示(行缓冲)
tail -F app.log
# 接进管道:grep 要等上游攒够 4KB 才能收到数据(全缓冲)
tail -F app.log | grep ERROR
# ^^^^ 看起来"卡住了",其实是在等 tail 的缓冲区填满
# 低频日志下可能几分钟才蹦出一批
注意卡住的是上游而不是 grep——tail 发现自己的 stdout 是管道,就切换成全缓冲,攒够 4KB 才 write() 一次。
4.2 三种解法
# ① 用命令自带的行缓冲选项(最优先)
tail -F app.log | grep --line-buffered ERROR
# ^^^^^^^^^^^^^^^ grep 自己的输出也要行缓冲,否则它输出给下游时同样会攒
tail -F app.log | grep --line-buffered ERROR | awk '{print $1}'
# 每一级都要处理,只要后面还有管道
awk '{print; fflush()}' file # awk 用 fflush()
sed -u 's/a/b/' file # sed 用 -u(--unbuffered)
python3 -u script.py # python 用 -u
jq --unbuffered . # jq 用 --unbuffered
# ② 用 stdbuf 强制改变缓冲策略(对付没有相应选项的程序)
stdbuf -oL -eL mycommand | grep x
# | └ stderr 行缓冲
# └──── stdout 行缓冲(L = line)
stdbuf -o0 mycommand | grep x # 0 = 完全无缓冲(更慢但最实时)
# ⚠️ stdbuf 通过 LD_PRELOAD 注入,只对使用 stdio 的动态链接程序有效,
# 对静态链接的 Go 程序无效(Go 不用 stdio)
# ③ 用伪终端骗过程序(终极手段)
unbuffer mycommand | grep x # 需要 expect 包
script -qfc "mycommand" /dev/null | grep x
# 原理:创建一个伪终端(pty),让程序以为自己连着终端,于是选择行缓冲
4.3 Go 程序的缓冲
Go 不用 C 的 stdio,所以行为不同,但坑是类似的:
// os.Stdout 是【无缓冲】的:每次 Write 都是一次 syscall
fmt.Println("立即输出") // 直接 write(1, ...),管道里也能马上被下游看到
// 但如果你自己套了 bufio(为了性能),就有缓冲了
w := bufio.NewWriter(os.Stdout)
fmt.Fprintln(w, "这行会被缓冲")
// 不 Flush 的话下游收不到
w.Flush() // ✅ 必须显式刷
defer w.Flush() // 或者 defer(注意 panic 时不一定执行)
// 日志库同理:检查你的 logger 是否带缓冲
// zap 的 SugaredLogger 需要 defer logger.Sync()
defer logger.Sync()
一个真实的排查场景:容器里的 Go 服务日志「延迟出现」或「崩溃时最后几行丢失」,八成是自己套了 bufio.Writer 或用了带缓冲的 logger 却没有在退出路径上 Flush。检查方式:
# 看进程实际的 write 系统调用频率
strace -f -e trace=write -p $(pidof myapp) 2>&1 | head -20
# 如果一批日志攒到一起才 write 一次,说明有缓冲
5. 编码、乱码与终端救援
5.1 判断文件的编码
开篇第四问。
file -i mystery.txt
# mystery.txt: text/plain; charset=utf-8
file --mime-encoding mystery.txt
# utf-8
# 常见结果与含义
# charset=us-ascii 纯 ASCII,任何编码都能正确显示
# charset=utf-8 UTF-8
# charset=iso-8859-1 file 分不出来时的兜底猜测,中文文件多半其实是 GBK
# charset=binary 不是文本文件
# ⚠️ file 的编码判断是【启发式】的,短文本、中文文件经常判错。
# 更准的工具:
enca -L zh_CN mystery.txt # 专门做编码检测(需装 enca)
python3 -c "import chardet,sys; print(chardet.detect(open(sys.argv[1],'rb').read()))" f
# {'encoding': 'GB2312', 'confidence': 0.99}
# 最实用的办法:直接试着转,看哪个不报错、内容通顺
iconv -f gbk -t utf-8 mystery.txt | head
iconv -f big5 -t utf-8 mystery.txt | head
5.2 iconv 转换编码
iconv -f gbk -t utf-8 old.txt > new.txt
# ^^^^^ from ^^^^^^ to
# 遇到无法转换的字符时的三种策略
iconv -f gbk -t utf-8 f # 默认:遇到非法字节直接报错中止
iconv -f gbk -t utf-8//IGNORE f # 忽略无法转换的字符(继续处理)
iconv -f gbk -t utf-8//TRANSLIT f # 音译近似字符(如 é -> e)
iconv -l | head # 列出支持的所有编码(有上千种)
iconv -l | grep -i gb # 找中文相关的:GBK GB2312 GB18030
# 批量转换整个目录
find . -name '*.txt' -exec sh -c 'iconv -f gbk -t utf-8 "$1" -o "$1.tmp" && mv "$1.tmp" "$1"' _ {} \;
# ⚠️ 不能原地转换!这会清空文件
iconv -f gbk -t utf-8 f > f # 💀 重定向先把 f 截断成 0 字节了
iconv -f gbk -t utf-8 f -o f # ✅ 用 -o(较新版本支持)
iconv -f gbk -t utf-8 f > f.tmp && mv f.tmp f # ✅ 或者用临时文件
5.3 BOM 与 CRLF:Windows 带来的两个坑
# ① BOM(字节序标记):UTF-8 文件开头的 EF BB BF
head -c 3 script.sh | xxd
# 00000000: efbb bf ...
# ^^^^^^^^ 有 BOM
# 后果:shebang 前面多了 3 个字节,内核识别不出 #!,脚本无法执行
./script.sh
# bash: ./script.sh: cannot execute: required file not found
# 或 -bash: ./script.sh: /bin/bash^M: bad interpreter
# 去掉 BOM
sed -i '1s/^\xEF\xBB\xBF//' script.sh
dos2unix script.sh # dos2unix 也会顺带去 BOM
# ② CRLF:Windows 的换行是 \r\n,Unix 是 \n
file script.sh
# script.sh: Bourne-Again shell script, ASCII text executable, with CRLF line terminators
# ^^^^^^^^^^^^^^^^^^^^^^^^^
./script.sh
# bash: ./script.sh: /bin/bash^M: bad interpreter: No such file or directory
# ^^ 解释器路径变成了 "/bin/bash\r"
# 修复
dos2unix script.sh
sed -i 's/\r$//' script.sh
tr -d '\r' < win.txt > unix.txt
# 反向转换(给 Windows 用)
unix2dos file
sed -i 's/$/\r/' file
# 预防:git 层面统一
git config --global core.autocrlf input # Linux/macOS 推荐:提交时转 LF,检出不转
# 或在仓库根放 .gitattributes
echo '* text=auto eol=lf' > .gitattributes
echo '*.sh text eol=lf' >> .gitattributes
5.4 终端被二进制搞乱了怎么办
开篇第五问。cat 一个二进制文件时,里面的字节会被终端当成控制序列解释——可能切换字符集、改变颜色、甚至关闭回显:
cat /bin/ls # 💀 终端开始输出方块、乱码,之后敲什么都不对
# 恢复方法(按推荐顺序,即使看不见自己输入的字也要照敲)
reset # 完全重置终端(最彻底,会清屏)
tput reset # 同上
stty sane # 恢复终端行设置(回显、换行等),不清屏
# 如果连回车都不响应,先按 Ctrl-J(真正的换行符)再敲命令
# 终极方案:关掉这个终端标签重开;ssh 的话按 Enter ~ . 强制断开
预防:先判断类型再看内容。
file unknown_file # 先看是什么
head -c 100 unknown_file | xxd # 只看前 100 字节的十六进制,绝对安全
less unknown_file # less 会把控制字符转义显示,不会搞乱终端
strings unknown_file | head # 只抽取可打印字符串
顺带解释 grep 的一个提示:
grep "error" /bin/ls
# Binary file /bin/ls matches
# ^^ grep 检测到文件含 NUL 字节,判定为二进制,为了保护终端只报告"匹配了"而不输出内容
grep -a "error" /bin/ls # -a = --text,强制当文本处理并输出内容
grep -a "error" core.1234 | head
6. 看二进制文件
6.1 xxd:十六进制查看
xxd file | head -3
# 00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
# ^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
# 偏移量 十六进制内容(每 2 字节一组) ASCII 显示(不可打印显示为 .)
# ^^^^^^^^^ 7f 45 4c 46 = \x7f ELF,这就是 ELF 可执行文件的魔数
| 参数 | 全称 | 作用 |
|---|---|---|
-l N |
--length |
只看前 N 字节 |
-s N |
--seek |
从偏移 N 开始(支持 +N/-N 相对) |
-c N |
--cols |
每行显示 N 字节(默认 16) |
-g N |
--groupsize |
每 N 字节一组(默认 2;-g 1 更易读) |
-p |
--plain |
纯十六进制,无偏移无 ASCII |
-b |
--bits |
用二进制显示 |
-u |
— | 十六进制用大写 |
-r |
--revert |
反向:把十六进制转回二进制(改二进制文件用) |
-i |
--include |
输出成 C 语言数组 |
xxd -l 16 /bin/ls # 只看文件头,判断类型
xxd -s 1024 -l 64 disk.img # 从 1024 字节处开始看 64 字节
xxd -g 1 -c 8 file # 单字节分组、每行 8 个,最易读
xxd -p file # 纯 hex,方便复制
# 改二进制文件的经典流程(hex 编辑)
xxd file > file.hex # 转成文本
vim file.hex # 编辑(只改中间的 hex 列,别动偏移和 ASCII)
xxd -r file.hex > file.new # 转回二进制
# 判断真实文件类型(比扩展名可靠)
xxd -l 8 unknown | head -1
# 7f45 4c46 -> ELF 可执行文件
# 504b 0304 -> ZIP(也是 docx/xlsx/jar/apk)
# 8950 4e47 -> PNG
# ffd8 ffe0 -> JPEG
# 1f8b -> gzip
# 2321 -> #! 脚本
同类工具:
od -A x -t x1z -v file | head # od = octal dump(更老,参数晦涩)
# ^^^^ 地址用十六进制 ^^^^^^ 单字节 hex + ASCII ^^ 不压缩重复行
hexdump -C file | head # -C = canonical,输出格式与 xxd 类似
6.2 strings:从二进制里捞文本
strings binary | head
# 抽取所有【连续 4 个及以上可打印字符】的序列
| 参数 | 作用 |
|---|---|
-n N |
最小长度改为 N(默认 4) |
-t d|o|x |
显示字符串在文件中的偏移(十进制/八进制/十六进制) |
-e s|b|l |
编码:单字节 / 16 位大端 / 16 位小端(找 UTF-16 字符串) |
-a |
扫描整个文件(默认只扫已初始化的数据段) |
-f |
多文件时显示文件名 |
# 实战场景
strings myapp | grep -i 'password\|secret\|token' # 检查有没有硬编码密钥 ← 安全审计
strings core.1234 | grep -i 'panic\|error' | head # 从 core dump 里找线索
strings -t x binary | grep 'v1.2.3' # 找版本号及其偏移
strings /proc/1234/environ # 看进程环境变量(也可直接 tr)
strings -n 8 unknown.bin | head -50 # 判断未知文件的内容线索
# Go 二进制专用(比 strings 强得多)
go version -m ./myapp
# ./myapp: go1.22.0
# path github.com/me/myapp
# mod github.com/me/myapp (devel)
# dep github.com/gin-gonic/gin v1.9.1 h1:...
# build -ldflags="-s -w"
# build CGO_ENABLED=0
# build vcs.revision=abc123def
# ^^ 完整的依赖清单与构建参数,排查线上二进制版本时极其有用
6.3 ELF 与动态库
file myapp # 静态还是动态链接
ldd myapp # 依赖哪些动态库(静态链接会显示 "not a dynamic executable")
# ⚠️ 不要对不可信的二进制跑 ldd —— 它某些实现会实际运行程序
objdump -p myapp | grep NEEDED # ✅ 更安全的替代
readelf -d myapp | grep NEEDED
nm -D myapp | head # 动态符号表
readelf -h myapp # ELF 头(架构、类型、入口地址)
objdump -d myapp | head -40 # 反汇编
7. 统计、校验与比较
7.1 wc
wc file
# 120 890 7654 file
# ^^^ ^^^ ^^^^ 字节数
# | 单词数
# 行数
| 参数 | 全称 | 作用 |
|---|---|---|
-l |
--lines |
行数(最常用) |
-w |
--words |
单词数 |
-c |
--bytes |
字节数 |
-m |
--chars |
字符数(中文按 UTF-8 会与 -c 不同) |
-L |
--max-line-length |
最长的一行有多少字符 |
wc -l < file # ✅ 用重定向,输出里不带文件名,方便脚本取值
wc -l file # 输出 "120 file",脚本里要再切一次
find . -name '*.go' | wc -l # 数文件个数
wc -lc 中文.txt # 对比字节数与字符数
wc -L file # 查最长行(找异常的超长日志行)
# ⚠️ wc -l 数的是【换行符个数】,最后一行没有换行时会少算一行
printf 'a\nb' | wc -l # 1 (实际有两行内容!)
printf 'a\nb\n' | wc -l # 2
grep -c '' file # ✅ 更准确的行数统计
awk 'END{print NR}' file # ✅ 同样准确
7.2 校验和
md5sum file # 快,但已不安全(可构造碰撞),只用于校验传输完整性
sha256sum file # 推荐
sha1sum / sha512sum / b2sum # 其他算法
cksum file # CRC32(很快,防传输错误够用)
# 校验下载文件
sha256sum -c SHA256SUMS # 用清单文件批量校验
echo "abc123... go1.22.tar.gz" | sha256sum -c -
# go1.22.tar.gz: OK
# 生成清单
sha256sum *.tar.gz > SHA256SUMS
# 比较两个文件是否相同(大文件时比 diff 快)
[[ $(md5sum < a) == $(md5sum < b) ]] && echo same
cmp -s a b && echo same # ✅ 更快:逐字节比,发现不同立即停止
7.3 比较文件
cmp a b # 报告第一处不同的字节位置与行号
# a b differ: byte 15, line 2
cmp -s a b; echo $? # -s 静默,只看退出码(0=相同 1=不同 2=出错)
cmp -l a b # 列出所有不同的字节(八进制)
diff a b # 默认格式(老式)
diff -u a b # ✅ unified 格式,就是 git diff 的样子,最易读
diff -u --color=always a b | less -R
diff -r dir1 dir2 # 递归比较目录
diff -q -r dir1 dir2 # 只报告哪些文件不同,不显示内容
diff -y a b # 并排显示(side by side)
diff -w a b # 忽略空白差异
diff -B a b # 忽略空行
diff -i a b # 忽略大小写
# 生成与应用补丁
diff -u old.c new.c > fix.patch
patch old.c < fix.patch # 应用
patch -R old.c < fix.patch # 撤销
# 更好用的现代工具
git diff --no-index a b # 用 git 的 diff 引擎(有颜色、有词级高亮)
delta / difftastic # 第三方,语法感知的 diff
vimdiff a b # 在 vim 里并排比较(第 06 篇讲)
# comm:比较两个【已排序】文件的行
comm -12 <(sort a) <(sort b) # 只显示两者都有的行(交集)
comm -23 <(sort a) <(sort b) # 只在 a 里有的(差集)
comm -3 <(sort a) <(sort b) # 只显示不同的
# ^^ 数字表示"抑制第几列":1=只在a 2=只在b 3=共有
8. 压缩文件与日志排查组合技
8.1 不解压直接看
zcat app.log.gz # = gunzip -c,输出到 stdout
zless app.log.gz # 用 less 浏览
zgrep ERROR app.log.gz # 直接在压缩文件里搜
zdiff a.gz b.gz # 比较两个压缩文件
zcat *.gz | grep ERROR # 搜所有归档
bzcat / bzless / bzgrep # bzip2
xzcat / xzless / xzgrep # xz
zstdcat / zstdgrep # zstd(现在最快的通用压缩,推荐)
tar -tzvf archive.tar.gz # 只看归档内的文件列表(不解压)
tar -xzOf archive.tar.gz path/to/file # 只把某一个文件解到 stdout
# ^ O = --to-stdout
8.2 日志排查的常用组合
# ① 实时跟随并高亮(不过滤,只是标红)
tail -F app.log | grep --line-buffered --color=always -E 'ERROR|WARN|$'
# ^^^^^^^^^^^^^^ 加 |$ 匹配所有行,
# 效果是"全部显示但关键词标红"
# ② 实时跟随并过滤(注意行缓冲)
tail -F app.log | grep --line-buffered -i 'timeout\|refused'
# ③ 按时间段截取(日志时间戳格式为 2026-08-12 21:30:00)
awk '$0 >= "2026-08-12 21:00" && $0 <= "2026-08-12 22:00"' app.log
sed -n '/2026-08-12 21:00/,/2026-08-12 22:00/p' app.log
# ④ 统计 top N(第 08 篇会细讲 awk)
awk '{print $NF}' access.log | sort | uniq -c | sort -rn | head -10
grep -oP 'status=\K\d+' app.log | sort | uniq -c | sort -rn
# ⑤ 看错误的上下文
grep -n -B5 -A10 'panic' app.log | less -R
# ^^^ 前 5 行 ^^^ 后 10 行
# ⑥ 多文件同时跟随并标注来源
tail -F app.log error.log access.log # 自带 ==> 文件名 <== 分隔
# 更好的工具:multitail、lnav(专业日志浏览器,自动识别格式、支持 SQL 查询)
# ⑦ 大文件里定位到具体位置后从那开始看
grep -n 'panic' huge.log | head -1 # 找到行号,比如 458392
less +458392g huge.log # 直接跳过去
sed -n '458380,458420p' huge.log # 或者直接截取那一段
# ⑧ systemd 服务的等价操作(推荐,没有轮转问题)
journalctl -fu myapp # 跟随
journalctl -u myapp --since '1 hour ago' # 时间范围
journalctl -u myapp -p err # 只看 error 及以上级别
journalctl -u myapp -o json-pretty | less # 结构化输出
journalctl -u myapp --since today | grep -i timeout
9. 知识点扩展
9.1 cat / tac / nl
全称:cat = concatenate(拼接文件并输出);tac = cat 反写(倒序输出行);nl = number lines
参数见 1.2。补充 nl:
| 参数 | 作用 |
|---|---|
-b a|t|n |
编号策略:a=所有行 t=非空行(默认) n=不编号 |
-w N |
编号宽度 |
-s STR |
编号与内容之间的分隔符 |
-v N |
起始编号 |
-i N |
步进 |
9.2 less
全称:less(opposite of more,双关"less is more")
快捷键见 2.2,启动参数见 2.3。补充几个环境变量:
export LESS='-R -i -M -S' # 默认参数
export LESSHISTFILE=- # 不保存搜索历史(默认存 ~/.lesshst)
export PAGER=less # 给其他程序用的默认分页器
export MANPAGER='less -R' # man 专用
export LESSOPEN="| lesspipe %s" # 预处理器(自动解压、看归档)
9.3 head / tail
| head 参数 | 作用 |
|---|---|
-n N |
前 N 行;-n -N 表示"除了最后 N 行" |
-c N |
前 N 字节;支持 K/M/G 后缀 |
-q / -v |
多文件时不显示/强制显示文件名头 |
-z |
行分隔符用 \0 |
| tail 参数 | 全称 | 作用 |
|---|---|---|
-n N |
--lines |
后 N 行;-n +N 表示"从第 N 行开始" |
-c N |
--bytes |
后 N 字节 |
-f |
--follow[=descriptor] |
跟随 fd(轮转后失效) |
-F |
= --follow=name --retry |
跟随文件名(日志跟随应该用这个) |
--retry |
— | 文件不存在时持续重试 |
--pid=PID |
— | 该进程结束时自动退出 |
-s N |
--sleep-interval |
检查间隔秒数(默认 1.0) |
-q / -v |
— | 不显示/显示文件名头 |
9.4 xxd / od / hexdump / strings
见 6.1、6.2 的表格。选择建议:看十六进制用 xxd(参数最直观,还能 -r 反转),抽字符串用 strings,od/hexdump 只在没有 xxd 时用。
9.5 iconv / dos2unix
| 命令 | 作用 |
|---|---|
iconv -f A -t B f |
从编码 A 转到 B |
iconv -l |
列出所有支持的编码 |
//IGNORE 后缀 |
忽略无法转换的字符 |
//TRANSLIT 后缀 |
音译近似字符 |
iconv -o out f |
输出到文件(不能用 > 原地重定向) |
dos2unix f |
CRLF → LF,顺带去 BOM |
unix2dos f |
LF → CRLF |
dos2unix -k f |
保留原文件时间戳 |
dos2unix -n in out |
不原地改,输出到新文件 |
tr -d '\r' < f > f2 |
无需额外安装的替代方案 |
9.6 wc / cmp / diff / comm / 校验和
见第 7 章。速查:
wc -l < f # 行数(脚本用重定向)
grep -c '' f # 更准确的行数(文件末尾无换行时)
cmp -s a b && echo same # 快速判断是否相同
diff -u a b # 可读的差异
diff -qr d1 d2 # 目录差异摘要
sha256sum -c SHA256SUMS # 批量校验
comm -12 <(sort a) <(sort b) # 交集
9.7 缓冲控制
| 方式 | 用法 |
|---|---|
grep --line-buffered |
grep 的行缓冲 |
sed -u |
sed 的无缓冲 |
awk '{...; fflush()}' |
awk 手动刷 |
python3 -u |
Python 无缓冲 |
jq --unbuffered |
jq |
stdbuf -oL cmd |
通用:改 stdout 为行缓冲(仅对用 stdio 的动态链接程序有效) |
stdbuf -o0 cmd |
完全无缓冲 |
unbuffer cmd |
用伪终端骗过程序(需 expect 包) |
script -qfc "cmd" /dev/null |
同上,无需额外安装 |
9.8 终端救援
reset # 完全重置(清屏 + 恢复所有设置)
tput reset # 同上
stty sane # 恢复终端行设置,不清屏
stty -a # 查看当前终端设置
clear # 只清屏(Ctrl-L)
Ctrl-J # 终端不响应回车时,用它代替(真正的 \n)
Enter ~ . # ssh 会话卡死时强制断开(依次按这三个键)
10. 面试题
Q:为什么不能用 cat 看大文件?less 凭什么能瞬间打开 10GB 的文件?
cat 的本职是 concatenate(拼接),它会把内容全部倒进标准输出,没有分页也没有截断。对 5GB 日志执行会导致终端疯狂刷屏、终端模拟器的回滚缓冲吃光内存,ssh 场景下还要把 5GB 全传过网络。
less 只读取当前屏幕需要的那部分(按需 seek + read),文件多大都是常数级的启动开销。它相对 more 的核心优势是可以任意跳转(more 只能向下翻),所以有了「less is more」的命名梗。
日常应该形成的习惯:看内容用 less,cat 只用于拼接或确认小文件;cat f | grep x 这种 UUOC 写法应该改成 grep x f——不只是优雅问题,多一个进程就多一次 fork+exec 和一次数据拷贝。
Q:tail -f 和 tail -F 有什么区别?为什么日志轮转后 tail -f 就不动了?
tail -f 跟的是打开的文件描述符(也就是 inode),tail -F 跟的是文件名(路径)。
logrotate 轮转时做的是 mv app.log app.log.1 然后创建新的 app.log。此时老 inode 被重命名成了 app.log.1,而程序 reopen 后写的是新 inode。tail -f 手里的 fd 仍然指向老 inode——那个文件已经没人写了,所以它永远不再有输出,看起来像「卡住」。
tail -F 展开是 --follow=name --retry:它会周期性检查这个路径对应的 inode 是否变了,变了就重新打开,并提示 has been replaced; following new file;--retry 让它在文件暂时不存在时持续等待而不是退出。
结论:跟随日志一律用 -F。 更好的方案是让服务日志走 journald(journalctl -fu 服务名)或容器运行时(kubectl logs -f),它们自己管理存储,根本不存在轮转问题。
Q:tail -F app.log | grep ERROR 半天不出东西,直接 grep ERROR app.log 却很快,为什么?
缓冲模式变了。 C 标准库对输出有三种缓冲策略,由输出目标自动决定:stderr 永远无缓冲;stdout 连着终端时是行缓冲(遇 \n 就写);stdout 连着管道或文件时是全缓冲(攒满 4KB/8KB 才写)。
所以 tail -F app.log 单独跑是行缓冲、逐行出;一旦接上管道,tail 就切换成全缓冲,要攒够 4KB 才 write() 一次。卡住的其实是上游的 tail,不是 grep。
三种解法:① 用命令自带选项——grep --line-buffered、sed -u、awk '{...; fflush()}'、python3 -u(注意管道链上每一级都要处理);② stdbuf -oL cmd 强制改缓冲,但它靠 LD_PRELOAD 改 stdio,对不使用 stdio 的 Go 程序和静态链接程序无效;③ unbuffer 或 script -qfc 创建伪终端骗过程序。
Go 的 os.Stdout 本身无缓冲,但如果自己套了 bufio.Writer 或用了带缓冲的 logger(如 zap),退出路径上没 Flush/Sync 就会丢日志——「容器崩溃时最后几行日志丢失」多半是这个原因。
Q:怎么判断一个文件是什么编码?转换时要注意什么?
file -i 或 file --mime-encoding 能给出猜测,但它是启发式的,中文文件常被误判成 iso-8859-1。更准的是 enca -L zh_CN 或 Python 的 chardet。最实用的办法是直接试转:iconv -f gbk -t utf-8 f | head 看内容是否通顺。
转换用 iconv -f 源编码 -t 目标编码,三个要点:① 遇到无法转换的字符默认报错中止,可用 //IGNORE 跳过或 //TRANSLIT 音译;② 不能用 > 原地重定向(iconv ... f > f 会先把 f 截断成 0 字节),要用 -o 或临时文件;③ 转换前先确认源编码,猜错会产生二次损坏且不可逆。
Q:脚本报 /bin/bash^M: bad interpreter,或者配置里的 IP 明明对却连不上,可能是什么原因?
CRLF 换行符。Windows 的换行是 \r\n,Unix 是 \n。文件在 Windows 上编辑过之后,shebang 变成了 #!/bin/bash\r,内核拿着 /bin/bash\r 这个路径去找解释器,自然找不到。同理配置里的 host=localhost\r 会让程序拿到带 \r 的值。
诊断用 cat -A(-A = -vET,显示所有不可见字符):行尾的 ^M$ 就是 CRLF,^I 是 tab。file 也会提示 with CRLF line terminators。
修复:dos2unix f 或 sed -i 's/\r$//' f。预防:git config --global core.autocrlf input,或在仓库放 .gitattributes 写 * text=auto eol=lf。
另一个同源的坑是 UTF-8 BOM(文件开头的 EF BB BF):它挡在 #! 前面,导致内核识别不出 shebang。用 head -c 3 f | xxd 检查,sed -i '1s/^\xEF\xBB\xBF//' f 去掉。
Q:cat 了一个二进制文件,终端全乱了敲什么都不对,怎么恢复?
二进制里的字节被终端当作控制序列解释了——可能切换了字符集、改了颜色、甚至关闭了回显。恢复命令(即使看不见自己输入也照敲):reset(完全重置,会清屏)、tput reset、或 stty sane(只恢复行设置,不清屏)。如果连回车都不响应,先按 Ctrl-J(真正的换行符)再敲。ssh 会话彻底卡死时按 Enter ~ . 强制断开。
预防:不确定文件类型时先 file 判断,用 head -c 100 f | xxd 看十六进制,或者直接用 less(它会把控制字符转义显示,不会搞乱终端)。
顺带一提 grep 遇到二进制时只说 Binary file matches 而不输出内容,也是同样的保护逻辑,用 grep -a 可以强制当文本处理。
Q:wc -l 统计行数在什么情况下会算错?
wc -l 数的是换行符的个数,不是「行数」。如果文件最后一行没有以换行结尾,这一行就不会被计入:printf 'a\nb' | wc -l 返回 1,但实际有两行内容。
这在处理程序生成的文件、head -c 截断的文件时很常见。更准确的统计方式是 grep -c '' 或 awk 'END{print NR}'——它们数的是真正的行数。
另外脚本里取行数建议写 wc -l < file 而不是 wc -l file,前者的输出不含文件名,不用再切一次。
Q:怎么从一个编译好的 Go 二进制里看出它用了哪些依赖、怎么构建的?
用 go version -m ./myapp,它会输出 Go 版本、模块路径、完整的依赖清单及版本号、以及构建参数(CGO_ENABLED、-ldflags、vcs.revision 等)。排查「线上跑的到底是哪个 commit、依赖有没有升级」时非常有用。
更通用的手段是 strings myapp | grep ...(抽取所有长度 ≥4 的可打印字符串),常用于安全审计——检查有没有硬编码的密钥、token、内网地址。此外 file myapp 看静态还是动态链接,objdump -p myapp | grep NEEDED 看依赖哪些动态库(比 ldd 安全,ldd 的某些实现会实际执行程序)。
小结
cat是拼接命令不是查看命令,看内容用less;cat -A是排查不可见字符(CRLF、tab、尾随空格)的利器less按需读取所以能秒开大文件;练熟F(跟随并可随时回翻) 和&pattern(内部过滤) 这两个大多数人不知道的功能tail -f跟 fd,tail -F跟文件名 —— 日志轮转后前者失效,跟随日志一律用-F- 接进管道会让上游从行缓冲变成全缓冲,这是「管道里的命令卡住」的通用原因,用
--line-buffered/stdbuf/unbuffer解决;Go 程序要注意bufio和 logger 的Flush - 编码判断靠
file -i+ 实际试转,转换用iconv(不能>原地重定向) - CRLF 和 BOM 是 Windows 带来的两个高频坑,
cat -A一眼看出,dos2unix修复 - 终端被二进制搞乱用
reset或stty sane - 看二进制用
xxd(-r能改回去),捞字符串用strings,Go 二进制用go version -m
下一篇讲 vim 生存与效率:为什么这个「反人类」的编辑器无处不在、模式化编辑的设计意图是什么、以及只要记住十几个键就能高效改配置。
xingliuhua