
1. 从一次误改事故说起readonly 锁住的到底是什么Bash 的readonly是我见过的最容易被低估的内建命令之一。说它被低估是因为多数人只在脚本里写过一行readonly TIMEOUT10然后就忘了它还能管数组、还能管函数但也有不少人在生产环境里吃过它的亏——比如把PATH或某个运行时变量顺手设成只读结果后续所有初始化逻辑全部报readonly variable整个会话直接废掉。这篇内容会把readonly对变量、数组、函数三种对象的真实行为讲清楚包括它锁得住什么、锁不住什么以及不同 Bash 版本里那些容易翻车的边界。适合正在写部署脚本、运维脚本、Bash 函数库的读者也适合刚接触 shell 编程、想搞清楚declare和readonly关系的朋友。1.1 事故现场把只读变量当普通变量改先看一个我在真实项目里见过的例子。某天同事跑一个部署脚本报错信息翻来覆去就一句话bash: DEPLOY_VERSION: readonly variable脚本本身逻辑不复杂就是先定义版本号后面有几处地方要根据部署阶段动态更新这个版本号。问题出在脚本开头写了这样一行readonly DEPLOY_VERSION1.4.2后面有一行更新逻辑DEPLOY_VERSION1.4.2-hotfix结果脚本直接中断。当时同事的第一个反应是这个只读到底是谁加的查了半天才发现是自己加的。这里就引出了readonly的核心语义它保护的是变量名和值的绑定关系而不是值的后续不可变性。一旦DEPLOY_VERSION被标记为只读任何形式的重新赋值、unset、甚至用declare再次声明同名变量都会被 Bash 直接拒绝。这个绑定的概念很重要。你可以把变量名想象成一个储物柜上的标签readonly等于给这个柜子贴了一张只出不进的封条。封条管的是这个标签还能不能贴到别的东西上、柜子还能不能整个换掉而不是柜子里的纸片内容从此冻住。对普通字符串变量来说换内容和换绑定几乎是同一件事所以readonly x之后x2会报错但对数组这种复合结构来说事情就没那么简单了——这也是我后面单独讲数组的原因。1.2 三个立刻就能试的最小例子光说不练假把式。在继续之前建议你直接打开终端跑一遍下面三个例子你就能直观感受到readonly拦截了什么、放行了什么。变量重赋值$ foo1 $ readonly foo $ foo2 bash: foo: readonly variable变量被unset$ barhello $ readonly bar $ unset bar bash: unset: bar: cannot unset: readonly variable再次用declare声明同名变量$ bazvalue $ readonly baz $ declare -r baznew bash: declare: baz: readonly variable注意第三种的报错形式用declare -r重新声明一个已经只读的变量不会因为你同样加了-r就放行。只读状态一旦建立当前 shell 会话里就没有回头路。1.3 防呆机制不是安全机制还有一个必须提前建立的认知readonly的作用域仅限于当前 shell。它既不会通过环境变量传递给子进程也不会阻止子 shell 里出现同名变量。$ readonly MY_NAMEparent $ ( MY_NAMEchild; echo $MY_NAME ) child $ echo $MY_NAME parent看到没有子 shell 里完全可以创建一个同名变量并赋值父 shell 的只读变量纹丝不动。这种设计决定了它只能当防呆锁防止同一个脚本里、同一个 shell 会话里有人不小心改掉关键状态它防不住外部进程、防不住子 shell更防不住恶意篡改。如果你需要的是一门心思防止外部进程干扰光靠readonly是做不到的得配合权限、文件锁或者独立用户。2. 只读变量的声明方式、作用域规则与实用场景2.1 三种常见的声明姿势与等价关系在 Bash 里给变量设置只读属性至少有三种写法它们表面差不多实际细节有差别。第一种最直白readonly TIMEOUT10第二种是declare -rdeclare -r TIMEOUT10第三种是先创建再打标TIMEOUT10 readonly TIMEOUTreadonly和declare -r在大部分场景下效果一致但要留意几个差异declare支持的功能位更多比如declare -rx可以同时完成导出和只读readonly本身没有-x参数只能先readonly PATH再export PATH或者反过来。在函数内部declare默认具有局部变量的特性后面细说而readonly的行为在不同 Bash 版本上表现不够统一不建议用来声明函数内的局部变量。declare还可以配合-a、-A、-f等选项组合出只读数组只读关联数组只读函数语法上更统一。如果你写过不少 Bash应该已经发现declare -r其实还能和-a、-A、-f、-x自由组合。这种属性叠加的能力是单纯一个readonly命令给不了的。所以我个人在真实脚本里的默认选择是简单场景用readonly需要组合属性或者出现在函数内时用declare -r。2.2 函数内的局部只读local -r 与 declare -r 的细节在函数体内部你经常会遇到这样的需求某个常量只在这个函数里用不希望污染全局命名空间也不希望函数内部某个分支不小心改了它。这时候最清晰的写法是init_task() { local -r MAX_RETRY3 local -r MIN_INTERVAL1 for ((i0; iMAX_RETRY; i)); do echo retry $i done }local -r意味着两个属性同时生效局部可见 只读。函数执行完MAX_RETRY和MIN_INTERVAL就消失了不会留在全局。如果你已经习惯用declare下面这个写法在 Bash 中也是可用的init_task() { declare -r MAX_RETRY3 # 等价于 local -r MAX_RETRY3 }原因在于 Bash 手册里写得很清楚在函数内部使用declare时如果不加-g它的行为等价于local。如果你确实想让函数内的declare -r作用在全局必须显式写declare -gr。这里我想强调一个很多人踩过的坑不要用readonly x1在函数里试图创建一个只读局部变量。readonly命令对作用域的处理不像local那么直观在部分 Bash 版本中会直接操作全局绑定函数结束之后变量仍然存在容易造成命名污染。函数内统一用local -r或者declare -r这是最稳妥的选择。2.3 应用场景脚本开头把定数固化掉readonly最常见的合理用法是在脚本开头把那些一旦确定就不该再变的值固化下来。最典型的有这么几个#!/usr/bin/env bash set -euo pipefail declare -r SCRIPT_DIR$(cd $(dirname ${BASH_SOURCE[0]}) pwd) declare -r CONFIG_FILE$SCRIPT_DIR/config/settings.yaml declare -r LOG_DIR/var/log/myapp declare -r LOG_LEVEL${LOG_LEVEL:-INFO}为什么要这么做因为脚本越写越长后面可能有几十个函数每个函数都可能带几行对全局变量的操作。如果没有只读保护一个函数里手滑写了个LOG_DIR/tmp/log排查起来非常痛苦。有了只读属性脚本会在错误发生的第一时间尖叫出来而不是等到几百行之后才暴露问题。2.4 一个无法反悔的副作用这是我想单独拎出来说的readonly一旦设置在当前 shell 生命周期里没有官方支持的撤销手段。你试unset不行试declare r也不行常规的反悔路径全部被堵死。唯一的办法是退出当前 shell 重新开一个会话或者在子 shell 里操作。这个特性造成了两个现实影响。第一不要轻易对PATH、PYTHONPATH这类后续很可能被其他配置追加的环境变量使用readonly。尤其是你把readonly PATH写进.bashrc之后下次新开终端很有可能发现一堆工具加载异常因为一些软件在安装时会修改 PATH而 Bash 直接拒绝执行。第二脚本里如果确实要保护 PATH 之类的变量更好的做法是先让所有依赖 PATH 的初始化完成再在某一个明确的节点固化它并且用declare -rx一步到位避免中间状态混乱。3. 只读数组的边界整体重绑被拦元素修改在不同版本上表现不一3.1 声明只读数组的推荐姿势数组加上只读属性语法上比普通变量多了一个-a。强烈推荐用declare -ra因为它的语义最完整declare -ra FRUITS(apple banana cherry) declare -rA METADATA([os]linux [arch]x86_64)第一条是普通索引数组的只读声明第二条是关联数组的只读声明。注意-rA中间的顺序没有严格要求declare -Ar也常见但写成declare -rA更容易阅读。如果数组已经在前面定义过了可以用这种后打标的方式FRUITS(apple banana cherry) readonly -a FRUITS我为什么不推荐你用readonly FRUITS(apple banana cherry)这种写法一次性声明因为在部分 Bash 版本中readonly后面直接跟数组字面量的解析行为并不稳定有些版本能正常工作有些版本会报语法错误或产生诡异的变量值。既然declare -ra在所有主流 Bash 4.x/5.x 上都表现稳定没必要去赌一个老版本行为。3.2 哪些操作被拦截哪些操作看版本数组被标记只读后下面这些操作会被明确拒绝FRUITS(melon) # 整个数组重新赋值报错 unset FRUITS # 删除整个数组报错 FRUITS() # 清空数组并重绑报错报错信息同样是readonly variable。但有一个边界需要特别注意数组元素的就地修改在不同 Bash 版本上的行为不一致。在现代 Bash 5.x 上FRUITS[0]watermelon通常会触发只读检查并报错但在一些老版本 Bash比如 3.2macOS 自带的旧版本或者 4.x 的某些小版本上元素赋值并不会被 readonly 拦截你仍然可以偷偷改掉数组里的某个元素甚至向数组追加元素。# Bash 3.2 上可能出现的行为 $ declare -ra FRUITS(apple banana) $ FRUITS[0]watermelon # 没有报错数组变成 (watermelon banana)这非常反直觉。你在当前环境测得好好的换一台旧机器跑只读数组的内容居然被改了。所以我要反复强调不要把你的核心安全逻辑建立在readonly 数组的元素绝对不可变这个假设上。跨版本部署时你只能可靠地依赖数组整体不能重新绑定、不能被 unset这一层保护。3.3 实在要锁数组内容的替代方案如果业务上确实需要这份数据从头到尾不能变有两个思路比readonly数组更可靠。思路一是把数组内容序列化成字符串用只读字符串保存需要时再拆成数组。这样原始数据是普通变量只读语义在所有版本上都是稳定的。readonly FRUITS_SERIALIZEDapple banana cherry # 需要数组时再拆分 FRUITS() read -r -a FRUITS $FRUITS_SERIALIZED思路二是把数组藏进函数里每次调用函数都返回一份全新副本get_fruits() { local -ra DATA(apple banana cherry) printf %s\n ${DATA[]} } while IFS read -r fruit; do echo fruit: $fruit done (get_fruits)DATA是函数内部的局部只读数组外部脚本拿不到引用也就谈不上改不改。这条思路的本质是不暴露可变状态比单纯依赖 readonly 属性更符合防御性编程的习惯。4. 只读函数函数库防覆盖与初始化顺序的讲究4.1 声明方式先定义再 readonly -f函数也能设只读很多写了多年脚本的人都没用过这个特性。语法很简单run_deploy() { echo deploying... } readonly -f run_deploy或者用declare -rf一步完成declare -rf run_deploy注意-f这个选项不能省因为declare -r默认操作的是变量加了-f才会去处理函数。一个很容易踩的坑是顺序问题。readonly -f只认已经存在的函数定义如果函数还没定义就执行它会直接报错$ readonly -f not_exist_func bash: readonly: not_exist_func: not a function所以在脚本里要先把函数定义好再对它设只读顺序反了就是上面这个报错。4.2 防覆盖的价值函数库场景只读函数最典型的应用场景是函数库。假设你写了一个lib.sh里面提供了一批公共函数别人通过source lib.sh引入。如果使用者不小心写了同名函数覆盖了你的实现整个脚本的行为会变得不可预测。# lib.sh check_env() { echo checking environment... # 真正的环境检查逻辑 } readonly -f check_env当有人在业务脚本里尝试重新定义check_env() { echo a fake implementation } # bash: check_env: readonly functionBash 会直接拒绝。这在团队协作里非常实用公共函数一旦发布就等于承诺了接口语义不允许使用者悄悄替换。4.3 只读函数并不阻止子 shell 覆盖和只读变量一样只读函数的保护范围也只有当前 shell。如果你把函数导出给了子进程子进程里完全可以重新定义同名函数$ export -f check_env $ bash -c check_env() { echo hijacked; }; check_env hijacked原因还是那句话只读属性不会通过环境传递。所以如果你需要保护的是多个子脚本之间的公共入口光靠readonly -f是不够的得在子进程里同样加载一遍保护逻辑或者改用外部文件权限等手段。4.4 函数内部状态与函数只读是两件事还有一点容易混函数被设为只读只限制函数定义能不能被替换不限制函数内部能否修改外部变量。也就是说global_counter0 incr() { global_counter$((global_counter 1)) } readonly -f incrincr函数体里修改global_counter完全合法因为global_counter本身没有只读属性。别搞混了——readonly -f锁的是函数这个名字不是它操作的数据。想保护数据只能给数据加只读。5. 实战组合、内建只读变量清单与版本化踩坑复盘5.1 一个完整的部署脚本示例把前面的知识点串起来写一个小而完整的部署脚本。你会看到只读变量、只读数组、只读函数是怎么协同工作的#!/usr/bin/env bash set -euo pipefail # 全局常量一旦定下就不允许再改 declare -r APP_NAMEops-console declare -r DEPLOY_DIR/srv/${APP_NAME} declare -r LOG_DIR/var/log/${APP_NAME} # 部署前必须存在的命令清单 declare -ra REQUIRED_CMDS(curl jq tar ssh) # 初始化目录 prepare_dirs() { mkdir -p ${DEPLOY_DIR} ${LOG_DIR} } readonly -f prepare_dirs # 检查依赖命令 check_commands() { local cmd for cmd in ${REQUIRED_CMDS[]}; do if ! command -v $cmd /dev/null 21; then echo 缺少必要命令: $cmd 2 return 1 fi done } readonly -f check_commands prepare_dirs check_commands echo 部署前检查通过这个脚本里APP_NAME、DEPLOY_DIR、LOG_DIR是任意函数都不能修改的全局常量REQUIRED_CMDS是不能整体重绑的数组prepare_dirs和check_commands是任何人都不能覆盖的实现。如果后面有人接手代码不小心写了个同名的check_commands函数Bash 会在第一行定义处直接告诉他这个函数只读。5.2 Bash 内建的只读变量与快速检查除了我们自己声明的只读对象Bash 本身也内置了一批只读变量/数组。最常见的包括下面这些变量名类型说明UID标量当前用户 IDEUID标量有效用户 IDPPID标量父进程 PIDBASHPID标量当前 Bash 进程 PIDBASH_VERSION标量Bash 版本字符串BASH_SOURCE数组调用栈对应的源文件BASH_VERSINFO数组Bash 版本号分段信息GROUPS数组当前用户所属组列表PIPESTATUS数组管道中各命令的退出状态SHELLOPTS标量已开启的 shell 选项冒号分隔BASHOPTS标量已开启的 bash 选项这些变量的共同特点是你直接给它们赋值会收到readonly variable的报错。$ UID0 bash: UID: readonly variable想知道当前环境里到底有哪些只读变量运行readonly -p或declare -r的透传输出即可。注意readonly -p的输出格式实际上就是declare -r ...所以两者看到的信息一致。每个机器上实际存在的内建只读变量可能因 Bash 版本不同有细微差异最权威的判断方式永远是readonly -p的输出而不是记忆一份写死的表。5.3 版本化踩坑复盘三个我真实遇到过的场景第一个坑和.bashrc有关。我以前喜欢在.bashrc里定义一堆快捷函数某次给其中一个函数加了readonly -f。当时一切正常但后来想优化这个函数改了.bashrc再source ~/.bashrc直接弹出一行readonly function。原因是.bashrc被重新加载时函数需要重新定义而 Bash 不允许覆盖只读函数。从那以后我就不再在交互式配置里用readonly保护函数了只在业务脚本的局部范围用。第二个坑是把运行时变量错当成常量。我在一个监控脚本里声明了readonly PID_FILE/tmp/agent.pid然后一个清理函数里想更新这个路径结果每次执行清理都报错。后来我才意识到PID_FILE表示的是一个运行时会变化的路径根本不是常量不该给它上只读。这个教训就是只读保护只适合从创建到脚本结束都不会变的值任何可能被后续逻辑重新计算、替换、拼接的值都不应该加 readonly。第三个坑和 Windows 环境、git bash 下的 CRLF 换行符有关。在 Windows 上写脚本如果不小心保存成 CRLF 换行上传到 Linux 服务器执行时常常会出现/bin/bash^M: bad interpreter这类报错。而如果你在这个坏文件里定义函数并试图用readonly -f保护还会遇到一个更隐蔽的诡异现象函数名末尾带了一个看不见的\r你写readonly -f myfunc却提示not a function因为真实函数名是myfunc\r。排查方法很简单用cat -A script.sh看行尾是不是有^M$有的话用dos2unix或sed -i s/\r$//修复。5.4 我现在的使用习惯经验积累到现在我的原则可以浓缩成三条常量用declare -r公共函数用readonly -f都在值真正确定之后马上标记不要拖到脚本末尾。运行时会变化的状态PID、路径、临时文件名、计数器一律不用只读保护它们需要的是逻辑上的谨慎而不是一刀切的锁。数组内容需要看起来不可变时优先用序列化字符串加readonly或者用函数封装快照不要盲目信任版本之间的数组只读行为。readonly这个命令本身不复杂复杂的是它在不同对象、不同版本上的边界。把边界摸清楚它就是你脚本里的安全气囊摸不清楚它也可能是你排查一下午问题的根源。希望这篇内容能帮你把这块拼图补上。