在 Ubuntu 的终端里翻一份几千行的构建日志整屏白字刷下来想定位那行 ERROR 只能靠眼睛逐行扒拉——这种体验做过线上排查的人都懂。其实终端早就能输出颜色、粗体、下划线、反显这些样式只是很多人把它当成某个工具自带的花哨功能没意识到背后就是几个约定好的控制字符自己随手就能拼出来。这篇内容聊的就是在 Ubuntu 终端里给字体上样式这件事从 ANSI 控制序列的编号规则到tput这种不写死转义符的做法再到把整套东西封装成能在自己脚本里复用的函数最后把几种最容易翻车的场景逐个拆开。刚接触 shell 脚本的人能照着抄写过几年运维脚本的人也能在踩坑那几节里找到点共鸣。1. 终端里的字体样式不是排版而是控制序列1.1 一行日志刷屏引出的问题先说清楚底层发生了什么。终端模拟器GNOME Terminal、Konsole、Alacritty、tmux 里跑的那个窗口本质上是一个字符渲染器它接收到的是一串字节流逐个判断这是可打印字符就画出来这是控制字符就执行对应动作。颜色、粗体、下划线这些视觉属性全部归属于一类叫SGRSelect Graphic Rendition选择图形再现的控制序列格式固定为ESC [ 参数 m其中ESC是 ASCII 码 27八进制写作\033十六进制是\x1b。方括号[是 CSIControl Sequence Introducer的一部分末尾的m表示这是一条设置显示属性的指令。整串东西不会显示在屏幕上终端解析完就丢掉只把效果留在后续输出的字符上。所以当你敲下这样一行printf \033[31m这一段是红色\033[0m\n终端实际收到的字节是1B 5B 33 31 6D 这一段是红色 1B 5B 30 6D 换行。它先看到ESC[31m把当前前景色切成红色然后画后面那六个汉字再看到ESC[0m把所有属性清零最后换行。关键点在于这个状态是粘的一旦设置就不会自动复位会一直影响后面输出的所有字符直到你显式重置。这也是为什么漏写\033[0m会让后面整屏输出都变色——不是终端坏了是你没关灯。1.2 SGR 参数速查颜色、粗细、下划线的编号规则SGR 的参数用分号分隔可以一次写多个比如\033[1;4;31m就是粗体 下划线 红色。参数分成两大类属性类和颜色类。属性类常用的就这几个参数效果撤销参数0重置所有属性—1粗体 / 高亮222暗淡faint223斜体234下划线245慢速闪烁257反显前景背景互换278隐藏前景色等于背景色289删除线2921双下划线2453上划线55颜色类分三段。30–37 是八种基本前景色40–47 是对应背景色90–97 和 100–107 是这八种颜色的亮色版本。顺序固定为黑、红、绿、黄、蓝、品红、青、白从 0 数到 7。比如31是红32是绿33是黄34是蓝36是青换成41就是红底91是亮红前景。# 前景色 31-37背景色 41-47 for c in 0 1 2 3 4 5 6 7; do printf \033[3%dm 前景 %d \033[0m \033[4%dm 背景 %d \033[0m\n $c $c $c $c done这段循环跑一遍八种颜色加八种背景一次看全顺手还能验证你的终端配色方案到底是哪个主题——很多人第一次跑完才发现自己终端的黄其实偏橙蓝其实偏紫因为终端读取的是配色方案里的调色板不是某个固定的 RGB 值。提示\033[1;31m和\033[91m在很多终端上看起来完全一样原因是一部分终端把粗体实现成了提亮前景色。这不是标准行为换一个终端就可能出现差异需要精确控制颜色时优先用 90–97 那组亮色编号而不是靠1去提亮。1.3 echo -e 与 printf 的选择网上能搜到的第一段示例基本都是echo -e \033[31m红色\033[0m。它确实能用但只在你当前这个 bash 里能用。问题出在echo这个命令本身POSIX 标准对echo的行为定义得非常宽松允不允许解释反斜杠转义取决于实现。bash 内建的echo默认不解释转义要加-e而 Ubuntu 上很多脚本用/bin/sh指向 dash执行dash 的echo默认就解释转义你再加一个-e它会把这俩字符当成普通文本原样打出来。同一个脚本bash xxx.sh跑得好好的sh xxx.sh就变成-e开头的一堆乱码原因就在这儿。printf就没这个歧义。它是 POSIX 明确定义的工具格式串里的\033三位八进制一定会被解释成 ESC 字符printf \033[31m%s\033[0m\n 这一行是红色注意第二个细节把变量放进printf的参数位而不是格式串里。如果写成printf \033[31m$MSG\033[0m\n当$MSG里含有%或者\时轻则输出错乱重则被当成格式符导致参数错位。养成格式串写死、变量走%s的习惯能省掉一大堆莫名其妙的 bug。真要拼接printf %s%s%s\n $RED $MSG $RESET也是干净的写法。2. 六个最常用的输出场景与对应写法2.1 单行高亮从能显示到不串色最基础也最容易出错的一步是把开始着色和结束着色配成一对。我见过太多脚本写成这样printf \033[31mERROR: 连接失败\n少了一个\033[0m后果是这一行之后的所有输出全是红的直到脚本结束或者某个碰巧带重置序列的工具比如ls --color跑过一次才恢复。排查这种问题时人会非常困惑明明是同一个终端为什么单独跑某个命令就正常跑整个脚本就全红正确的做法是把颜色和文本分开处理而不是把转义序列和内容混在一个字符串里RED\033[31m RESET\033[0m printf %sERROR: 连接失败%s\n $RED $RESET注意这里RED变量里存的是字面量反斜杠 031printf的%s不会解释它所以输出的是字面反斜杠而不是 ESC 字符。这个写法是错的。正确的做法有两种。第一种是用 bash 的 ANSI-C 引用$...让变量里直接存 ESC 字节RED$\033[31m RESET$\033[0m printf %sERROR: 连接失败%s\n $RED $RESET第二种更通用用printf先把序列烤进变量RED$(printf \033[31m) RESET$(printf \033[0m)这两种写法都能让变量里存的是真实的 ESC 字节后面用%s输出就是安全的。第一种更简洁但$...是 bash/ksh/zsh 的扩展dash 不认第二种是纯 POSIXsh下也能跑。如果你的脚本要兼顾多种 shell用第二种。2.2 组合样式叠加时的顺序问题多个属性可以一次给用分号连起来顺序不影响结果printf \033[1;4;33m粗体下划线黄色\033[0m\n printf \033[33;4;1m效果完全一样\033[0m\n但需要撤销某一个属性时就不能用0了0是全部清零。比如你想让一段文本保持粗体但取消下划线得用24printf \033[1;4m粗体加下划线\033[24m只剩粗体\033[0m\n这里有个实现差异要留心部分终端对21双下划线和22取消粗体的处理并不一致有的把21当成取消粗体有的干脆不支持双下划线。所以跨终端场景下21能不用就不用。另一个实际会遇到的问题是属性叠加的语义。\033[1;2m粗体 暗淡在理论上应该互相抵消实际表现取决于终端有的按后者覆盖前者有的渲染成灰色粗体。如果你的配色要精确到某个特定观感用单一属性加明确的颜色编号别指望组合能产生稳定的效果。2.3 256 色与真彩色什么时候值得上八色加亮色一共十六种够做语义区分错误红、警告黄、成功绿、信息青但不够做视觉设计。需要更细的色阶时上 256 色扩展# 38;5;N 设置前景48;5;N 设置背景N 取 0-255 printf \033[38;5;196m正红\033[0m\n printf \033[38;5;208m橙色\033[0m\n printf \033[48;5;236m深灰底\033[0m\n256 色分三段0–7 是基本八色8–15 是亮色16–231 是一个 6×6×6 的 RGB 立方体每通道取 0、95、135、175、215、255 六个值232–255 是 24 级灰阶。写渐变脚本或者给不同项目分配不同色调时这套编号非常好用。再往上就是真彩色直接指定 RGBprintf \033[38;2;255;135;0m橙色\033[0m\n printf \033[48;2;40;44;52m#282C34 背景\033[0m\n真彩色的问题是兼容性取决于终端。GNOME Terminal、Konsole、Alacritty、Kitty、iTerm 这类现代终端都支持但一些老的终端模拟器、部分 IDE 内置终端、以及通过 SSH 连到某些精简环境时可能只回声未知序列或者干脆当普通字符打出来。判断依据是$COLORTERM环境变量case ${COLORTERM:-} in truecolor|24bit) echo 支持真彩色 ;; *) echo 老老实实用 256 色 ;; esac另一个判断手段是tput colors返回 8、16、256 或 16777216。这个数字是从 terminfo 数据库里查出来的比猜环境变量更可靠一些但 terminfo 也未必准确——它的更新滞后于终端实际能力经常出现终端其实支持真彩色但 terminfo 里只写了 256的情况。实用主义做法日志和 CLI 工具用 8/16 色保平安只在明确知道自己在哪跑的交互式脚本里用真彩色。2.4 提示符 PS1 里的着色与 [ ] 包裹给命令行提示符上色是另一个高频需求但这里有个独有的坑bash 需要知道哪些序列不占屏幕宽度否则光标定位会算错。你在提示符里写了\[\033[32m\]这里的\[和\]不是给终端看的是给 bash 的 readline 看的意思是这中间的字符不会占据任何显示列。如果你直接写\033[32m而不包裹bash 会把这 5 个字节当成 5 个可见字符算进提示符长度结果是你敲一个字光标往前跳好几位按退格删掉的是屏幕上的前一个字符而不是缓冲区里的字符翻历史命令时整行都会错位。一份可用的配置长这样塞进~/.bashrc# 颜色定义用 \[ \] 包裹告诉 readline 这些不算宽度 C_RESET\[\033[0m\] C_USER\[\033[1;32m\] C_PATH\[\033[1;34m\] C_GIT\[\033[0;35m\] PS1${C_USER}\u\h${C_RESET}:${C_PATH}\w${C_RESET}\$ 注意$前面的反斜杠——在双引号里\$会被展开成$而我们要的是 bash 在每次渲染提示符时才展开的那个转义\$普通用户显示$root 显示#。同理\u、\h、\w这些也是一样的道理在双引号里它们不是 bash 的转义序列所以能原样保留下来交给提示符引擎去展开。改完~/.bashrc之后记得source ~/.bashrc或者在当前会话里直接PS1...临时试一下。临时试的好处是出问题重开一个终端就恢复了不会把自己锁在一个光标乱跳的 shell 里。3. 用 tput 拿终端自己的能力表3.1 tput 与硬编码转义符的差别在哪前面所有例子都是把\033[...m硬编码在脚本里。这在 Ubuntu 的常见终端上没问题但换到某些环境比如用TERMdumb跑的一个受限会话就会失效——终端根本不认这些序列屏幕上会直接打出^[[31m这种字样。tput的作用是问终端自己能干什么。它读取$TERM指向的 terminfo 条目从中查出当前终端对应的能力字符串然后输出。比如tput bold # 输出当前终端的粗体序列 tput smul # 输出下划线开始序列 tput rmul # 输出下划线结束序列 tput sgr0 # 输出重置所有属性序列注意tput输出的是一串字节不是文本。用tput bold | xxd看一眼你会发现它在 xterm 下输出的是1B 5B 31 6D也就是\033[1m在TERMdumb下则什么都不输出。这就是tput的价值不支持的时候它安静地返回空你的脚本不会往屏幕上吐乱码。代价是多一次子进程调用并且依赖 terminfo 数据库存在Ubuntu 上由ncurses-base提供一般都有极精简的容器镜像里可能被裁掉。3.2 setaf/setab 的编号从哪来tput设置颜色用setaf前景a 是 ANSI 的意思和setab背景tput setaf 1 # 红 tput setab 4 # 蓝底 tput sgr0 # 重置这里的编号和前面 ANSI 的 30–37 不是一回事。tput setaf 1在 256 色终端下会输出\033[38;5;1m在 8 色终端下会输出\033[31m。也就是说tput帮你做了当前终端支持多少色的判断你只需要给一个语义编号。这也意味着同一个数字在不同终端上可能映射到不同的 RGB 值——tput setaf 1在绝大多数配色方案里是红的但如果用户装了某个魔改配色方案它也可能变成别的颜色。这不是tput的问题是终端配色方案本来就由用户控制。想让颜色和终端主题解耦只能上 256 色或真彩色。用tput写一段完整的着色代码大概是这个形态if command -v tput /dev/null 21 [ -n ${TERM:-} ] [ $TERM ! dumb ]; then RED$(tput setaf 1) GREEN$(tput setaf 2) YELLOW$(tput setaf 3) BOLD$(tput bold) RESET$(tput sgr0) else RED; GREEN; YELLOW; BOLD; RESET fi printf %s%s%s 构建成功\n $BOLD $GREEN $RESET注意最后那行的%s%s%s——当RESET是空串时printf输出的是纯文本脚本在任何环境下都不会崩。3.3 判断终端到底支不支持颜色除了tput的空输出策略还有几个更直接的判断维度写脚本时建议组合使用。第一个是stdout 是不是终端。用[ -t 1 ]判断if [ -t 1 ]; then echo 输出到终端可以上色 else echo 输出到文件或管道最好别上色 fi这一条比什么都重要。你的脚本输出被 build.log重定向进文件时颜色转义序列会原样写进日志文件用cat看就是一堆^[[32m之类的乱码用grep匹配时也会被干扰。加上-t 1判断重定向时自动关闭颜色日志文件干干净净。第二个是TERM是不是 dumb。TERMdumb是个明确信号表示这个终端只有最基本的能力这时任何样式序列都该关掉。第三个是NO_COLOR环境变量。业界的约定是只要NO_COLOR存在且非空任何程序都应该关闭彩色输出。这是给用户留的强制开关用户不喜欢彩色输出时export NO_COLOR1就全局生效了if [ -n ${NO_COLOR:-} ]; then USE_COLOR0 fi把这三条串起来就是一个足够稳的判断函数supports_color() { [ -n ${NO_COLOR:-} ] return 1 [ ${TERM:-dumb} dumb ] return 1 [ -t 1 ] || return 1 return 0 }4. 封装一套自己的着色工具函数4.1 函数签名的设计取舍每次写printf %s%s%s\n $RED $MSG $RESET太啰嗦封装成函数是必然的。函数签名怎么设计有几种常见思路各有取舍。第一种是颜色名在前color red 文本。简洁但只能表达单一颜色组合样式粗体 红就表达不了。第二种是参数全是变量名让调用方自己拼printf %s%s\n $BOLD$RED $MSG。灵活但调用方仍然要处理 reset。我实际用得最多的是第三种把样式和文本分开函数内部保证 reset。# 用法c 样式序列 文本 c() { printf %s%s%s $1 $2 ${RESET} }调用时c $BOLD$RED ERROR函数保证每次输出后都收尾不会污染后面。缺点是不带换行需要自己补\n。要不要在函数里加换行取决于你的使用场景如果着色的是行内片段比如一行里只有状态码是红的就不能带换行如果是整行输出带换行更省事。我一般做两个函数c不带换行、cln带换行。还有一种更面向语义的封装用状态名而不是颜色名log_error() { printf %s[ERROR]%s %s\n ${BOLD}${RED} ${RESET} $*; } log_warn() { printf %s[WARN]%s %s\n ${BOLD}${YELLOW} ${RESET} $*; } log_info() { printf %s[INFO]%s %s\n ${BOLD}${CYAN} ${RESET} $*; } log_ok() { printf %s[ OK ]%s %s\n ${BOLD}${GREEN} ${RESET} $*; }这种封装的好处是将来想换配色方案只改函数里的颜色变量所有调用点自动生效。而且函数名本身就是文档读代码的人一眼知道这行日志是什么级别。4.2 自动降级管道、重定向与日志文件封装函数时必须把第 3.3 节那套判断嵌进去否则函数只能解决写得啰嗦的问题解决不了输出到文件全是乱码的问题。标准做法是在脚本开头做一次性的初始化把颜色变量声明成要么是有值的序列要么是空串#!/usr/bin/env bash set -uo pipefail if [ -t 1 ] [ ${TERM:-dumb} ! dumb ] [ -z ${NO_COLOR:-} ]; then ESC$\033 RESET${ESC}[0m BOLD${ESC}[1m RED${ESC}[31m GREEN${ESC}[32m YELLOW${ESC}[33m BLUE${ESC}[34m CYAN${ESC}[36m else RESET; BOLD; RED; GREEN; YELLOW; BLUE; CYAN fi这里用了一个ESC变量存单字节的 ESC其余序列都由它拼出来。好处是可读性高也方便日后想换成tput方案时只改初始化块。有个细节要注意[ -t 1 ]判断的是stdout。如果你的错误信息是往 stderr 输出的这是规范做法而 stdout 被重定向了、stderr 还在终端那么用-t 1判断会导致 stderr 也不上色。更精确的做法是分别判断if [ -t 2 ]; then ERR_RED${ESC}[31m fi实践中大多数情况下 stdout 和 stderr 的重定向状态是一致的所以简单起见用一个判断也够。但如果你的脚本是正常输出进文件、错误输出到屏幕的形态就该分开判断。4.3 一个可直接用的分级日志脚本把前面的东西串起来给一个能直接落地的完整示例。这段可以单独存成log.sh在别的脚本里source进来#!/usr/bin/env bash # log.sh —— 轻量分级日志着色 if [ -t 1 ] [ ${TERM:-dumb} ! dumb ] [ -z ${NO_COLOR:-} ]; then _R$\033[0m; _B$\033[1m _RED$\033[31m; _GRN$\033[32m _YEL$\033[33m; _CYN$\033[36m; _DIM$\033[2m else _R; _B; _RED; _GRN; _YEL; _CYN; _DIM fi _ts() { date %H:%M:%S; } log_debug() { printf %s%s [DBG] %s%s\n $_DIM $(_ts) $* $_R; } log_info() { printf %s [INF] %s%s\n $(_ts) $* $_R; } log_ok() { printf %s%s [ OK] %s%s\n $_B$_GRN $(_ts) $* $_R; } log_warn() { printf %s%s [WRN] %s%s\n $_B$_YEL $(_ts) $* $_R; } log_err() { printf %s%s [ERR] %s%s\n $_B$_RED $(_ts) $* $_R 2; }几个设计点解释一下。log_err重定向到2符合错误走 stderr的规范同时它的颜色判断用的是-t 1——这是简化处理严格来说应该单独判断-t 2。_ts抽成函数是因为调用date有开销将来想换成脚本启动时记一次基准时间、之后算相对秒数只改这一个函数。%s%s ... %s的参数顺序里重置序列永远在最后无论中间输出什么内容都不会串色。实际用起来是这样source ./log.sh log_info 开始处理 128 个文件 log_warn 跳过 3 个空文件 log_err 第 47 行解析失败 log_ok 全部完成耗时 12s重定向到文件时 run.log颜色自动关闭文件内容是纯文本grep、awk、日志采集器都能正常处理。5. 排查实录颜色失效与串色的四种典型情况5.1 分页器和管道把序列当成了普通字符第一种情况脚本在终端里跑得好好的一接| less就变成满屏^[[32m。原因很简单less默认不解释 ANSI 转义序列它把 ESC 当成普通控制字符用^[的形式显示出来。解决办法是加-R参数./build.sh | less -R-R是原样传递 ANSI 颜色序列-r更激进一些会处理更多原始控制字符。一般用-R就够了-r在某些情况下会把屏幕搞乱。more就没这个选项了它根本不支持颜色。遇到用more的环境要么换less要么接受纯文本。管道里还有一层坑cmd | grep xxx | less -R中间过了一次grep。grep本身在输出到管道时会关闭自己的着色除非加--coloralways但上游传过来的转义序列它是原样透传的所以最终到less这里颜色还在加-R就能看到。真正会被吞掉的是列宽计算——less在算折行位置时会把转义序列的字节也计入宽度导致长行折行位置错乱。这个问题没有完美解法只能缩短单行长度或者按S键关闭折行此时需要左右滚动。awk、cut、sed这些按列处理的工具更麻烦它们不认识转义序列awk {print $2}可能把\033[32m里的一部分当成字段。所以给管道下游喂数据时先关颜色。这也是 4.2 节那个[ -t 1 ]判断的真正意义——它保证了管道下游拿到的是纯数据。5.2 中文对齐错位用printf %-20s做表格对齐时中文会明显歪掉。原因是printf的宽度按字节数算一个 UTF-8 汉字占 3 个字节但屏幕上只占 2 列宽。所以printf %-10s 中文会补 4 个空格10 - 6 字节实际显示宽度是 2×2 4 8 列比预期的 10 列少 2 列。更麻烦的是一旦给中文加上了颜色printf的宽度计算还会把转义序列的字节算进去——\033[31m是 5 个字节加上 6 个字节的汉字一共 11 字节超过 10 就直接不补空格了。处理思路有两条。简单粗暴的一条是别用printf的宽度做中英混排对齐改成手工补空格配合一个显示宽度计算函数disp_width() { local s$1 w0 i c for (( i0; i${#s}; i )); do c${s:i:1} if [[ $c ~ [一-龥] ]] || [[ $c ~ [【】“”] ]]; then (( w 2 )) else (( w 1 )) fi done echo $w }这个函数用字符级遍历${#s}在 bash 里对 UTF-8 是按字符数算的对 CJK 范围按 2 列计其余按 1 列计。然后写一个pad函数按目标宽度补齐。另一条思路是放弃对齐改用两行式的输出格式或者把中英混排的表格交给column -t处理。column用的是系统的宽度计算对中文的处理比printf好一些但也不是完美的——它依赖 locale 设置LANG没设成 UTF-8 时一样会错。所以在脚本开头做一次 locale 检查是个好习惯case ${LANG:-}${LC_ALL:-} in *UTF-8*|*utf8*) : ;; *) export LANGC.UTF-8 ;; esac5.3 嵌套调用导致的半截颜色第三种情况比较隐蔽函数 A 里开了颜色但没关调用函数 B 的时候 B 又开了一种颜色返回之后 A 的收尾 reset 把 B 设置的东西一起清了导致后面一段本该是红色的文本变成了默认色。本质原因是颜色状态是全局的、扁平的没有栈的概念。函数 A 设置红函数 B 设置蓝B 结束时执行 reset此时不仅蓝色没了红色也没了——reset 是全部清零不是恢复到调用前的状态。解决办法有两个。第一个是每个函数自己负责收尾谁开的颜色谁关不依赖调用方print_ok() { printf %s%s%s\n $_B$_GRN $* $_R }这样不管嵌套多少层每个函数输出完都回到默认状态下一个函数重新设置。代价是丢失了外层的颜色上下文——如果外层整体是一段黄色区块内层函数输出完就会把黄色也清掉。第二个办法是用保存-恢复的模式把当前的 SGR 状态存下来再恢复。麻烦在于 ANSI 没有查询当前属性的标准序列有些终端支持\033[s保存光标位置但那是光标不是属性。实际可行的做法是约定一个外层样式变量函数内部在收尾时恢复成它OUTER_STYLE${OUTER_STYLE:-} print_in_box() { local saved$OUTER_STYLE OUTER_STYLE$_B$_YEL printf %s边框%s\n $OUTER_STYLE ${saved:-$_R} ... }这套东西实现起来啰嗦收益有限我的建议是别在同一个输出段落里嵌套多层着色。如果确实需要把整个段落拼成一个字符串一次输出中间不穿插函数调用。可读性比精确的颜色嵌套重要得多。5.4 文件里出现的 ^[[31m第四种情况把带颜色的输出重定向进了文件用cat一看全是^[[32m之类的乱码。先解释这个^[是什么它是 ESC 字符0x1B在cat -v下的可读表示形式。cat遇到不可打印字符时会用^加上对应的大写字母来表示控制字符0x1B 是 ASCII 表里第 27 个对应字母表第 27 个是[所以显示成^[。它不是三个字符是一个字节的显示形式。清理这些序列最直接的是sedsed -r s/\x1B\[[0-9;]*[mK]//g run.log clean.log这个正则匹配ESC[加上数字分号和m或K。m是 SGR样式K是清除行内容\033[K常用于擦掉本行剩余部分。如果你的日志里还可能包含光标移动序列\033[A、\033[2J等需要把字符类扩展到[0-9;]*[A-Za-z]但这会误伤一些正常内容谨慎使用。换个角度看根本的解决方式是不让颜色进文件。除了前面说的[ -t 1 ]判断还有一个技巧是把彩色输出和纯文本输出分开交互时走 stdout 带颜色重定向时自动落到一个格式化函数输出纯文本。这需要一个if [ -t 1 ]的分支代码量增加不多但日志质量提升明显。还有个反向需求有些工具默认关闭颜色你想强制开。比如ls需要--coloralwaysgrep需要--coloralwaysdiff需要--coloralways。这些工具的默认行为都是检测到重定向就关颜色和我们的脚本逻辑一致——只是有时候你想把带颜色的输出存进文件用less -R回放查看这时候就需要强制打开。6. 让配色真正服务于阅读的几个落地点6.1 构建输出按严重程度分级颜色最大的价值不是好看而是降低扫读成本。一份几百行的构建输出如果错误统一是红色、警告统一是黄色、成功统一是绿色找问题的时间能从几分钟压缩到几秒钟。反过来如果每行都五颜六色那等于没有颜色。我在写构建脚本时会遵循一个很简单的规则默认输出不上色只有三类信息上色——错误红、警告黄、最终结论绿或红。中间的进度信息全部用默认色最多加个暗淡\033[2m表示这是次要信息。step() { printf %s %s%s\n $_DIM $* $_R; } step 编译模块 A make -C moduleA || { log_err 模块 A 编译失败; exit 1; } step 编译模块 B make -C moduleB || { log_err 模块 B 编译失败; exit 1; } log_ok 构建完成这样一份输出里正常流程是灰白相间出问题时突然跳出一行红色视觉上非常抓人。比所有行都带颜色但重要性相同高明得多。6.2 表格输出的列着色列表类输出比如列出所有服务状态适合按列着色而不是按行。比如service status这类命令的输出服务名一列、状态一列状态列用颜色区分服务名列保持默认printf %-20s %s\n 服务名 状态 while IFS| read -r name state; do case $state in running) sc$_B$_GRN ;; stopped) sc$_B$_RED ;; unknown) sc$_B$_YEL ;; *) sc ;; esac printf %-20s %s%s%s\n $name $sc $state $_R done (some_status_source)注意这里%-20s对齐的是服务名服务名如果全是 ASCII 就没问题如果含中文就回到 5.2 节那个宽度问题需要手工补齐。状态列是行尾不需要对齐所以直接输出就行。按列着色的另一个细节用%s输出颜色变量而不是拼接。因为printf的格式串里如果混入了 ESC 字节某些实现尤其是 busybox 的 printf处理起来会出问题走参数位最稳。6.3 与其他工具的配色体系保持一致最后一点容易被忽略但很影响体验自己脚本的配色最好和系统里其他工具保持一致。ls --color、grep --color、git status这些工具都有自己的默认配色大体上遵循同一套语义绿色 正常/新增红色 错误/删除黄色 警告/修改青色 链接/信息蓝色 目录。如果你在自己的脚本里把成功配成蓝色、错误配成品红用户每次切换上下文都要重新适应一遍认知负担反而变重了。具体做法是把前面 4.2 节那份初始化块当成项目级色板所有脚本共用一份别各写各的。有条件的话把它抽成一个单独的文件source进来改一处全局生效。还有一个小技巧如果用户自己设置过LS_COLORS你可以从里面提取颜色来用让脚本和ls完全同色。不过LS_COLORS的格式相当复杂di01;34:ln01;36:...解析起来成本不低一般项目里没人这么做。比较务实的做法是——只用那 8 个基础语义色因为它们是所有配色方案的公共子集在任何终端主题下都能被正确区分。想玩得花一点的时候确保有个降级路径就行。我在实际使用中发现给终端输出上色这件事最容易出问题的从来不是颜色写错了而是该关的时候没关。脚本在终端里跑得好好的一进 CI、一重定向、一接管道就全是乱码最后排查半天发现是少了一个-t 1判断。所以我的建议是写第一个着色函数的时候先把关闭逻辑写好再补颜色。