目录

Linux-05 查看文件内容:less 的正确用法、tail -f 与 -F 的本质区别、缓冲与编码

上一篇讲了文件的元数据,这一篇讲怎么看内容。同样五个问题开头:

  1. cat 一个 5GB 的日志会发生什么?为什么该用 less
  2. tail -ftail -F 差在哪?为什么日志切割之后 tail -f 突然就不动了?
  3. tail -f app.log | grep ERROR 为什么半天不出东西,直接 grep 却很快?
  4. 一个文件全是乱码,怎么判断它到底是什么编码?
  5. 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 全部传过网络

看文件内容永远用 lesscat 只用于拼接、或者确认小文件。

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 反转),抽字符串用 stringsod/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」的命名梗。

日常应该形成的习惯:看内容用 lesscat 只用于拼接或确认小文件cat f | grep x 这种 UUOC 写法应该改成 grep x f——不只是优雅问题,多一个进程就多一次 fork+exec 和一次数据拷贝。

Q:tail -ftail -F 有什么区别?为什么日志轮转后 tail -f 就不动了?

tail -f 跟的是打开的文件描述符(也就是 inode),tail -F 跟的是文件名(路径)。

logrotate 轮转时做的是 mv app.log app.log.1 然后创建新的 app.log。此时老 inode 被重命名成了 app.log.1,而程序 reopen 后写的是新 inodetail -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-bufferedsed -uawk '{...; fflush()}'python3 -u(注意管道链上每一级都要处理);② stdbuf -oL cmd 强制改缓冲,但它靠 LD_PRELOAD 改 stdio,对不使用 stdio 的 Go 程序和静态链接程序无效;③ unbufferscript -qfc 创建伪终端骗过程序。

Go 的 os.Stdout 本身无缓冲,但如果自己套了 bufio.Writer 或用了带缓冲的 logger(如 zap),退出路径上没 Flush/Sync 就会丢日志——「容器崩溃时最后几行日志丢失」多半是这个原因。

Q:怎么判断一个文件是什么编码?转换时要注意什么?

file -ifile --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 fsed -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-ldflagsvcs.revision 等)。排查「线上跑的到底是哪个 commit、依赖有没有升级」时非常有用。

更通用的手段是 strings myapp | grep ...(抽取所有长度 ≥4 的可打印字符串),常用于安全审计——检查有没有硬编码的密钥、token、内网地址。此外 file myapp 看静态还是动态链接,objdump -p myapp | grep NEEDED 看依赖哪些动态库(比 ldd 安全,ldd 的某些实现会实际执行程序)。


小结

  • cat 是拼接命令不是查看命令,看内容用 lesscat -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 修复
  • 终端被二进制搞乱用 resetstty sane
  • 看二进制用 xxd-r 能改回去),捞字符串用 strings,Go 二进制用 go version -m

下一篇讲 vim 生存与效率:为什么这个「反人类」的编辑器无处不在、模式化编辑的设计意图是什么、以及只要记住十几个键就能高效改配置。