做 Android 系统定制的人,应该都碰到过这类需求:手上的设备跑的是 EMUI8(某厂商基于 Android 8.x 开源系统深度定制的 ROM),希望在设备每次开机时自动执行一段 sh 脚本。可能是要调整内核参数、挂载额外分区、拉一个本地守护进程,也可能是想统一做一次环境初始化。这篇文章我就围绕“EMUI8 开机执行 sh 脚本”这个真实场景,把完整设计思路、底层原理、实操步骤和排错经验一次讲清楚,适合正在做 ROM 定制、系统集成和底层自动化验证的开发者参考。内容会尽量说人话,能直接照着操作,也会把我踩过的坑一并交代。
1. 需求拆解与整体思路
1.1 为什么要在开机阶段执行脚本
开机执行脚本听起来是个很朴素的诉求,但实际能覆盖不少场景。我在实际项目里遇到比较多的是这么几类:一是内核参数调节,比如修改 TCP 拥塞控制算法、IO 调度器、某些功耗相关的节点,这类操作用普通 App 根本碰不到,必须要有高权限且时机要早;二是挂载与目录绑定,比如外置存储分区挂载、把某个目录 bind mount 到指定位置,开机阶段做最合适;三是环境清理和初始化,像删除上次异常退出留下的临时文件、拉起的 socket、锁文件,或者在系统服务启动前准备好配置文件。
为什么不能用普通 App 来做?原因很简单,Android 的应用层启动顺序不可控,而且普通 App 没有 CAP_SYS_ADMIN 这类内核能力,很多系统节点即使给了 root 权限也未必能稳定操作。更直接的一点是,有些设备根本没有屏幕,或者设备不需要进入完整桌面环境,App 方案天然失效。而一个 sh 脚本配合 init 进程来拉起,属于用户空间最早执行的一批逻辑,时机早、权限高、行为直白,出了问题也容易定位。
1.2 主流的几种实现路径对比
围绕“开机跑脚本”这件事,业内常见的做法大致能分成四类,我先把它们放在一起对比,再解释为什么我最终推荐第四种实现路径。
| 实现路径 | 生效阶段 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 修改 ramdisk 中的 init.rc 并添加服务 | 内核启动后、init 解析阶段 | 时机最早、可控性最强、权限高 | 需要解包和重打包 boot 分区,操作稍复杂 | 绝大多数系统级初始化需求 |
| 修改 system 分区内的服务或脚本 | system 挂载完成后 | 不需要动 boot 分区,改动简单 | 依赖签名校验,OTA 和恢复出厂易丢失,时机偏晚 | 应用层配置、弱权限初始化 |
| 用户态 root 管理方案 | init 后期,通过框架动态加载 | 不用解包 boot 分区,增删灵活 | 兼容性受限于 ROM,重启后可能失效,维护成本高 | 快速验证、个人设备临时使用 |
| 外部控制器或上位机触发 | 任意阶段 | 完全不依赖目标系统 | 需要额外硬件配合,部署复杂 | 量产产线、特殊嵌入式场景 |
先解释一下为什么很多人第一反应会想到“改 system 分区”。因为很多早期 Android 版本可以靠 init.d 机制或直接把脚本丢进 system/etc,但到了 Android 8.x 时代,官方早已不默认执行 init.d,厂商又普遍开启 system 分区签名校验,直接改 system 镜像很容易触发启动校验失败。因此,如果你希望脚本长期稳定存在,并且不随恢复出厂被清理掉,ramdisk 这条路径是最稳妥的。
1.3 推荐方案:改造 ramdisk 中的 init 服务
EMUI8 虽然厂商定制内容多,但底层没有脱离 Android 8.x 体系,开机流程依然是 bootloader -> kernel -> init。init 会解析 ramdisk 里的 rc 文件,并按照触发器执行对应动作。把 sh 脚本放进 ramdisk,再通过 rc 文件定义成服务,到了指定阶段由 init 拉起,这个做法最直接、最可靠,也是我做这类需求时的首选。
选择 ramdisk 还因为一个很实际的好处:ramdisk 位于 boot 分区内,和 system 分区相互独立。你恢复出厂设置、双清、Wipe data,ramdisk 里的脚本都不会被清掉,除非重新刷 boot 分区或 OTA 覆盖。这就让脚本的“持久性”有了基本保障。当然,这也意味着你必须在 boot 分区上做操作,所以设备是否允许自定义引导程序、是否能处理引导校验,就成了前置条件。这一点我在后面会单独强调。
2. 核心机制解析与前置准备
2.1 init 进程和 rc 文件的加载顺序
要改好开机脚本,至少得先理解 Android 8.x 的 init 是怎么转起来的。内核态工作结束后,用户空间第一个进程就是 /init,它的任务包括挂载基础文件系统、解析 rc 文件、启动属性服务、拉起核心服务。EMUI8 的 init 和通用 Android 8.x 没有本质区别,只是厂商会在 rc 文件里追加不少自定义逻辑,比如专属硬件初始化、日志服务等。
rc 文件里最核心的是触发器和服务。几个常见触发器按时间顺序大概是 early-init、init、late-init、boot,还有基于属性变化的触发器如 property:sys.boot_completed=1。每个触发器对应一批命令,执行完就往下走。如果你只是简单地把脚本挂在 on boot 后面,很多时候会发现脚本跑得太早,比如 /data 还没挂好、属性服务还没就绪,进而出现写文件失败、目录不存在、mount 不成功这类问题。
服务和触发器是配合使用的。一个 service 可以指定 class,比如 class late_start,然后 init 在 late-init 阶段执行 class_start late_start 时统一启动该 class 下的所有服务。用 class 而不是直接挂在 on boot 里的好处是时机更可控、启动顺序更清晰,也方便你通过 disabled 加手动 start 的组合实现“注册但不立即启动”。这套机制理解透了,后面写配置就不会像无头苍蝇一样乱试。
2.2 脚本编写规范与存放位置
脚本本身写起来不难,难在让它符合 Android 运行环境。第一件事是解释器路径,脚本头必须写#!/system/bin/sh,不要想当然写成#!/bin/sh。Android 上/bin/sh的符号链接在某些版本里存在,但很多定制 ROM 并不保证,直接用/system/bin/sh最稳。第二件事是语法克制,不要用 bash 特有语法,比如数组、${var^^}这类,Android 自带的 sh 是精简版 mksh 或 toybox sh,兼容性有限。第三件事是文件格式,我吃过一次亏:脚本在 Windows 上编辑保存后带 CRLF 换行,结果刷进去后 init 一直报“not found”,看起来每个字符都对,但执行器就是拒绝,最后用 dos2unix 转一下立马正常。
存放位置建议分三步走:先在 ramdisk 根目录放/init.custom.sh,因为 ramdisk 一解包就能看到,不依赖其它分区;验证通过后再根据需求迁移到/vendor/bin或/data/local/tmp。放/vendor/bin需要 vendor 分区已经挂载,放/data/local/tmp则依赖数据分区解密完成,时间更晚,但好处是后续更新脚本不用重打包 boot。这个取舍没有标准答案,完全取决于你的脚本内容对文件系统和时机的依赖程度。
2.3 EMUI8 定制系统环境的关键点
EMUI8 毕竟是厂商深度定制 ROM,boot 分区结构虽然沿用 Android 标准,但具体细节不能想当然。第一个要注意的是分区命名。A/B 设备上 boot 分区对应 boot_a/boot_b,甚至还要看当前活跃 slot;单分区设备则简单一些,直接是 boot。建议先执行ls -l /dev/block/by-name/看清楚当前环境,别把两个 slot 搞混。
第二个关键是启动校验。Android 8 之后的设备普遍启用 Verified Boot,boot 分区和 vbmeta 分区存在强校验关系。如果你只改了 ramdisk 里的文件,刷回去之后很可能直接提示校验失败、卡在开机警告页。常规处理方式是在确认设备允许解锁引导程序的前提下,先完成引导程序解锁,再处理 vbmeta 状态。具体解锁步骤每家厂商公开的说明都不一样,我不在这里展开,只想强调:操作前务必备份原始 boot 分区和 vbmeta 分区,否则一旦校验不通过,你连回退的余地都没有。
3. 实操过程与关键步骤
3.1 准备解锁与提取 boot 分区
正式开始前,先把环境准备好。一台 Linux 机器,安装好cpio、gzip、mkbootimg这类常用工具即可,不需要图形界面。然后把设备进入可调试状态,确认 adb 连接正常。这里我默认你已经处理好了 bootloader 解锁以及 vbmeta 校验的问题,如果没有,请先按厂商公开流程完成操作,不要在未解锁状态下强刷。
提取 boot 分区可以用 adb 配合 dd 完成,命令大概长这样:
adb root adb shell "ls -l /dev/block/by-name/ | grep -E 'boot|vbmeta'" adb shell "dd if=/dev/block/by-name/boot of=/data/local/tmp/boot.img bs=4096" adb pull /data/local/tmp/boot.img ./我在这一步的习惯是顺便把vbmeta.img也备份一份,再把当前 slot、分区布局、kernel cmdline 全部记录到文本里。后续出问题想要恢复,这些信息比网上搜任何资料都有用。提取完 boot.img 后,立刻在电脑上另外复制一份原始文件,命名为boot-stock.img并放到安全目录,避免后续操作把唯一备份弄坏。
3.2 解包 ramdisk 并加入自定义脚本
拿到了 boot.img,接着解包。boot.img 本身由 kernel、ramdisk、dtb 等组成,直接解包可以用现成脚本或开源工具,如果你更愿意手动,思路是先跳过 kernel 头部,取出 ramdisk.cpio.gz,再用 cpio 解开。这里以解包 ramdisk 为例:
mkdir boot cd boot # 假设你已经用工具从 boot.img 中提取出 kernel 和 ramdisk.cpio.gz mkdir ramdisk cd ramdisk gunzip -c ../ramdisk.cpio.gz | cpio -i解包后就会看到init、init.rc、selinux_version、prop等文件,目录结构和 Android 系统根目录很像。接着把脚本放进去:
cp /path/to/init.custom.sh ./init.custom.sh chmod 0755 init.custom.sh这一步有个关键细节:不要试图改动解包出来的其它文件结构,尤其是selinux_version、file_contexts这类文件,它们和打包格式、SELinux 标签绑定得很紧,随意修改很容易造成 init 阶段解析异常。脚本放进去后,可以先在宿主机上做一次轻量语法检查,比如sh -n init.custom.sh,确保没有基础语法错误再继续。
3.3 编写 init 服务配置
脚本放进 ramdisk 后,还需要让 init 知道在什么时候把它拉起来。我习惯新建一个独立的 rc 文件,比如init.custom.rc,然后在init.rc里用import引入,而不是直接在原有 rc 文件末尾追加。这样逻辑清晰,回滚也方便。一个最小可用的配置可以写成这样:
on boot start custom_init service custom_init /init.custom.sh class late_start user root group root system seclabel u:r:init:s0 oneshot disabled解释一下每一行的作用。on boot是触发器,表示 boot 阶段会执行下面的命令;start custom_init表示启动名为 custom_init 的服务。服务本体用service custom_init /init.custom.sh定义,执行路径是 ramdisk 根目录下的脚本。class late_start让服务归入 late_start 类,这样 init 在 late-init 阶段就会处理它;user root和group root system指定运行身份;seclabel u:r:init:s0是给脚本指定 init 域的 SELinux 标签;oneshot表示服务只执行一次,不会常驻;disabled表示初始不启动,等待触发器手动拉起。
如果你希望脚本再晚一些执行,比如必须等系统完全启动完,可以把触发器替换成属性监听:
on property:sys.boot_completed=1 start custom_init这种情况下服务依赖属性触发,脚本执行时机基本在所有开机阶段之后,适合做后置初始化。
3.4 重新打包 boot.img 并刷回
脚本和 rc 文件都改好之后,重新打包 ramdisk 并合成 boot.img。打包 ramdisk 的命令:
cd ramdisk find . | cpio -o -H newc | gzip > ../new-ramdisk.cpio.gz然后合成 boot 镜像。最好的方式是用能够从原 boot.img 自动读取 cmdline、base、pagesize 等参数的工具,而不是手敲 mkbootimg 参数。手敲参数很容易漏掉某个关键字段,导致刷进去无法启动。合成完成后,先确认新 boot.img 大小和原包差别不大,再进入刷写阶段:
adb reboot bootloader fastboot flash boot boot-new.img fastboot reboot如果你使用的是 A/B 设备,注意先fastboot getvar current-slot确认当前 slot,并把镜像刷到对应分区。刷写完成后,如果设备提示校验失败,那就要回到 vbeta 处理流程,确保引导程序处于解锁状态。整个过程中,原版boot-stock.img始终保留,这是你最后的救命稻草。
3.5 首次开机验证
刷回系统后,第一件事不是确认功能,而是确认脚本到底有没有被执行。我强烈建议脚本最开始就加上日志重定向,比如:
#!/system/bin/sh LOG=/data/local/tmp/custom_boot.log exec > "$LOG" 2>&1 echo "=== custom script start $(date) ==="然后在每个关键步骤后面加 echo 输出。重启后进入系统,直接adb shell cat /data/local/tmp/custom_boot.log看日志。如果日志文件存在且内容完整,说明服务已经被成功拉起了;如果文件不存在,再去查 init 日志和 dmesg。不要一上来基于“我觉得它应该执行了”去做下一步,日志先行能帮你节省大量时间。
4. 常见问题与排查技巧实录
4.1 服务没被拉起:先看 init 有没有报错
最典型的现象是脚本没执行,日志文件根本没出现。这时候先去查 init 进程日志:
adb shell "dmesg | grep -i init" adb logcat -b all -d | grep -i init重点看有没有 rc 文件解析错误。rc 文件的语法容错很低,一个词写错、缩进异常、缺了冒号,都可能导致后续内容被忽略。我踩过一次非常无语的坑:在某行命令后面多打了一个空格,init 直接把整段配置判为非法,服务怎么都不启动。后来逐行对比才找到问题,从那以后我改完 rc 文件都会先做一次最小化验证,比如把服务名改成在 on boot 下直接 echo,至少能确认配置能被解析。
另外检查一下服务名和触发器的拼接。disabled的服务不会自己启动,必须由start命令触发;如果你在 on boot 里写了start custom_init,但服务名写成了custom_init_service,那当然拉不起来。这类低级错误在情绪紧张时很容易犯,排查时不妨先冷静扫一遍配置。
4.2 脚本执行太早:等待分区和属性
脚本执行了,但里面的操作失败,这是第二种常见情况。比如脚本里要往/data/local/tmp写日志,但执行时/data还没完成挂载和加密流程,目录都不存在,写日志自然失败。解决办法有两个层面。第一个是在脚本开头加等待循环:
while [ "$(getprop sys.boot_completed)" != "1" ]; do sleep 2 done第二个是在 rc 层面把触发时机往后期挪,使用on property:sys.boot_completed=1代替on boot。两者可以结合,rc 控制启动阶段的粗粒度,脚本内的循环控制细粒度。还有一点要注意:如果你的脚本会写入/data,而设备开启了用户数据加密,那么必须等用户空间解锁完成后才能访问。盲目在早期写入,有时会把未解密的目录结构看得一团糟,甚至影响后续解密流程。
4.3 SELinux 拦截:从 dmesg 找 avc 日志
第三种情况更隐蔽:服务确实启动了,进程也运行了,但脚本里的操作被 SELinux 拦截了一部分,导致功能不完整。此时日志文件和 dmesg 里都会出现avc: denied相关内容,但默认日志等级下可能被过滤,需要你主动去查:
adb shell "dmesg | grep avc"看到avc: denied { execute }这类记录,就说明脚本的执行域和目标文件之间的策略不允许。临时验证的手段是把 SELinux 打成 permissive 模式,比如setenforce 0,然后重新执行一次脚本,确认问题是否真的出在策略上。确认后,不要图省事一直开着 permissive,正确做法是补充 sepolicy 规则,给对应域放行必要的文件访问和执行权限。
这里补充一个细节:如果你在 rc 里给脚本指定了seclabel u:r:init:s0,那么脚本运行在 init 域,权限范围比默认域宽不少,很多简单操作不会触发拦截;可一旦脚本去访问带特殊标签的文件,依然会被拒。所以关键是搞清楚脚本到底需要访问什么,再去匹配对应的 sepolicy 规则。
4.4 刷入后 bootloop:快速恢复与备份习惯
刷完 boot 后设备不断重启,这种情况谁遇到都会慌,但处理流程其实很固定。第一步,设备如果能进 bootloader,直接用 fastboot 把原版boot-stock.img刷回去:
fastboot flash boot boot-stock.img fastboot reboot多数情况下,纯 ramdisk 改动导致的 bootloop 都能靠恢复原版 boot 解决。如果连 bootloader 都进不去,那就需要考虑是不是操作过程中把分区表或 vbmeta 弄坏了,这种时候只能依靠厂商提供的底层恢复工具,操作复杂度会高一个数量级。所以我特别强调,开工前把原版 boot 和 vbmeta 备份到电脑和外部存储各一份,这条习惯能救命。
排查 bootloop 原因时,常见的有三个方向:ramdisk 打包格式错误、脚本本身卡死(比如脚本里出现了阻塞挂载)、SELinux 标签异常导致 init 无法执行后续服务。逐一排除即可。
4.5 问题速查表
我在项目里把常见问题和解决方案整理成了一张速查表,贴出来直接做参考:
| 故障现象 | 可能原因 | 处理建议 |
|---|---|---|
| 脚本完全没有执行 | rc 文件解析异常、服务名不匹配、触发器没命中 | 检查 dmesg 和 init 日志,核对服务名与触发器 |
| 脚本执行但日志写不了 | 执行时机太早,/data 还没挂载 | 在脚本内等待属性,或改用 boot_completed 触发器 |
| 操作被系统拒绝 | SELinux 策略拦截 | 查 dmesg avc 日志,补充 sepolicy 规则 |
| 刷入后无法开机 | ramdisk 打包错误、设备校验失败 | 恢复原版 boot,重新检查打包参数 |
| 脚本执行了但功能不生效 | 依赖的模块/服务还没准备好 | 增加依赖检查,用循环等待或属性触发 |
5. 扩展玩法与个人心得
5.1 触发时机与幂等控制
开机脚本在实际项目中往往不是跑一次就完,各种触发路径可能导致重复执行,比如异常重启、用户手动重启等。我的习惯是在脚本里加一个幂等判断,保证同一轮开机只执行一次完整逻辑:
if [ -f /data/local/tmp/custom_executed ]; then exit 0 fi touch /data/local/tmp/custom_executed要注意的是,这个标记文件依赖 /data 分区,所以脚本必须在数据分区可用之后才执行,不然判断逻辑起不到作用。如果你希望每次开机都重新执行全部逻辑,就不要用幂等文件,改成记录时间戳即可。
5.2 用开机属性做条件分支
脚本里可以借助系统属性来决定执行哪些逻辑。比如某个设备在系统属性中标识为研发模式,那么脚本可以走完整调试流程;如果是普通用户版本,则只执行核心初始化。判断逻辑并不复杂:
if [ "$(getprop ro.boot.mode)" = "dev" ]; then # 开发模式额外初始化 echo "dev mode init" >> "$LOG" fi这类分支非常适合同一个 boot 镜像适配多种场景,避免为不同模式维护多份 ramdisk。它也能让你在联调时临时关闭某些高风险动作,比如在脚本开头判断某个自定义属性是不是1,如果是则提前退出,方便快速回滚逻辑。
5.3 日志记录与自删除技巧
开机脚本如果写得比较随意,日志文件可能会无限增长。建议每次执行时先清理旧日志,或者只保留最近几条。比如:
if [ -f "$LOG" ]; then tail -n 100 "$LOG" > "$LOG.tmp" mv "$LOG.tmp" "$LOG" fi另外,如果你希望在完成一键初始化后不再让脚本继续存在,可以在脚本末尾加一条自删除逻辑,从 ramdisk 里把自身删掉。不过这里要提醒,ramdisk 是只读挂载的,能否删除取决于你的挂载方式;一般更实际的做法是让脚本保留,但靠标记文件阻止重复执行。
5.4 与普通 App 初始化搭配
开机脚本更适合做系统层面的准备,而不是承担所有业务逻辑。很多项目里,脚本负责准备配置文件、设置目录权限、拉起底层服务,然后一个普通 App 在启动后去读取这些产物,继续做上层业务。这种职责划分可以避免把脚本写得过于庞大,也方便后续维护。
我曾经遇到一个项目,脚本里塞了一大段网络探测逻辑,结果 ROM 版本升级后网络栈行为变化,脚本直接卡在等待回包那一步,把后续服务全部拖住了。后来把所有业务判断移到 App 层,脚本只做环境准备,问题立刻消失。这个经验让我明白,脚本层越薄越好,宁可多写几个独立步骤,也不要在 init 阶段搞复杂状态机。
5.5 一点个人体会
我自己做这类需求时,习惯先在模拟器或一台不重要的设备上做最小验证,确认 rc 语法、打包参数、SELinux 标签这些基础要素没问题,再上真机。真机上第一次刷写后,一定先看日志再判断效果。EMUI8 这种定制 ROM 最容易出问题的地方往往是厂商的额外校验和分区差异,遇到问题先看 dmesg、看分区布局,不要盲目重复刷写。备份做得足够完整,你就有足够的试错空间。希望这篇分享能让你少走点弯路,顺利把开机脚本跑起来。