做系统开发的人拿到一套新代码第一个想读的往往是init。它是内核启动后进入用户态的第一个进程所有你看到的服务、后台进程、系统参数几乎都由它统一拉起和守护。鸿蒙系统里这个模块就叫init草草看一遍只会知道它在解析一个配置文件但当源码真正摊开之后你会发现整个系统的启动顺序、服务生命周期、参数回调事件全都在这里编排。这篇文章按我自己阅读源码的路线把init模块从设计到核心实现拆一遍手里有鸿蒙源码、想弄清启动链路的读者可以跟着走只做应用开发的话也可以借这个机会理解系统层启动的大致机制。1. 先看清init模块在整个系统里的位置1.1 init进程到底管哪些事linux上systemd负责并行启动服务、管理socket和cgroup鸿蒙的init看起来也做类似的事情但设计取舍不太一样。先盘一下它的具体职责挂载基础文件系统比如/dev、/proc、/sys、/data设备节点由uevent动态创建读取并解析系统配置文件形成启动任务列表和服务列表按阶段执行启动任务拉起系统关键服务维护服务生命周期进程退出了要能回收并决定是否重启维护系统参数上层通过参数接口读写配置值并支持值变化时触发动作监听设备插拔事件、子进程退出信号以及外部进程发来的参数控制请求。这些任务在init的main函数里体现为一条清晰的执行链先准备基础环境再解析配置最后进入事件循环。后面第三部分会按这条链逐段展开。这里有个容易忽略的点init不是跑一遍配置就退出它要一直活着因为所有服务状态变化、设备热插拔、参数修改最终都会汇总到这个进程上来处理。1.2 为什么选择声明式配置而不是一堆脚本一个很基础但值得细想的问题为什么整个启动编排不用上百行shell脚本而是用一个init.cfg核心原因是脚本的逻辑写死在代码里想改启动顺序就要改代码而声明式配置把启动顺序变成数据不同产品适配时只改配置文件就行。你如果在源码库里搜索init.cfg会发现各个子系统都有自己的配置文件基础系统、分布式通信、硬件驱动各自定义自己需要的服务主cfg通过import把它们“拼”进同一棵配置树。这个组合方式比一个巨型脚本干净得多也方便按需裁剪。声明式的第二层好处是安全可控。配置里写明uid、gid、capabilities、sandbox等字段拉起服务时init可以直接设置好权限属性而脚本里漏掉setuid或者漏设环境变量服务就可能以错误的身份跑起来。鸿蒙生态面向大量设备权限收敛是硬需求所以服务描述里把身份和权限写清楚是必然选择。也正因为如此解析器在加载配置文件时还会做结构合法性校验不合格的节点会直接跳过。1.3 源码目录和功能模块的大致划分不同版本的源码路径会有差异但核心模块基本稳定。init代码大体分为几个部分入口主流程定义main入口做内核cmdline解析、基础目录初始化、进入事件循环配置解析器负责解析json风格cfg处理import合并、语法检查、节点展开服务管理注册、拉起、停止、查询服务维护进程状态机参数管理系统参数读写、持久化、权限检查以及属性变化触发的回调ueventd模块监听内核netlink事件创建设备节点、加载固件、设置权限插件适配层为不同硬件平台保留hook点位开发板厂商可以挂接自定义启动动作。我第一次读的时候陷在细节里后来发现按照“配置文件→服务管理→参数系统→事件循环”四条线去走会高效很多。源码文件很多但主线非常清楚不要一上来就从文件列表的第一行开始看。2. 配置文件里的每个字段背后都有设计2.1 init.cfg整体长什么样init.cfg并不是标准JSON文件而是JSON风格的自描述格式。它支持注释、多行字符串也允许在字符串里嵌入特殊标记。一个最小但完整的配置大概是这样{ import: [ /system/etc/init/*.cfg ], jobs: [ { name: pre-init, cmds: [ mkdir /data 0755 0 0, write /proc/sys/kernel/hotplug /sbin/init ] } ], services: [ { name: sample_service, path: [/system/bin/sample_service], uid: 10000, gid: [10000, 10001], once: 0, important: 1, critical: 0, class: [core] } ] }根上一共有三类主要成员import决定还要加载哪些文件jobs描述启动阶段要做的一连串原子动作services定义常驻或一次性进程。后面逐个解释。2.2 service节点核心字段service是init配置里最值得研究的对象字段很多实际上最常用的是这一批字段含义注意事项name服务唯一标识后续控制服务都靠这个名字不能和已有服务重名后解析的可能被忽略path可执行程序路径加启动参数路径要存在于romfs或已经挂载的分区里uid/gid进程运行身份用数字id别在配置里写字符串用户名once是否只启动一次1表示退出后不自动拉起important是否为关键服务关键服务退出会触发系统级策略甚至重启critical系统不可用时是否panic一般核心系统服务才会配class服务分组拉起时按class批量执行比如core、systemconsole是否允许控制台交互调试服务时可以直接改这个值快速定位还有caps、sandbox、sigterm_timeout、start_on_boot等字段它们都在源码里对应一组结构体成员。我在改一个服务权限时只改了uid忘了配gid结果进程起来后访问不到外部驱动的socket排查半天才发现是gid缺失。所以看字段时最好把权限相关的一组字段整体理解不要孤立地猜含义。2.3 jobs和action配合的触发链cfg里的jobs实际上是“命令序列”。init内部会把jobs和trigger对应起来当某个trigger发生就把对应job里的cmds顺序执行一遍。常见的trigger有几类pre-init、init、post-init这类启动阶段触发on property:xxxyyy这类属性值触发on device:add/remove这类设备事件触发。这里有个容易踩的坑同一个job被多次触发init并不会主动去重。如果你在post-init里写了mkdir /data第二次执行会因为目录已存在而报错但不会致命。真正危险的是重复mount某个分区可能导致资源占用异常。所以配置命令时尽量保持幂等或者先在命令里加if判断。这个逻辑在源码里对应命令执行器每个命令都是独立handler单条失败并不会中止后续命令只是记录错误继续往下走这样启动过程不会因为一个无关紧要的mkdir失败就整体卡死。2.4 import机制和合并策略import是让多个子系统各自维护配置的关键。init启动时会解开import通配符按顺序加载文件再按优先级合并到主配置树中。一个容易被忽略的点是不同文件里可能出现同名service后加载的不一定会直接覆盖而是根据内部合并策略决定是否生效。在排查问题时如果改了配置却不生效先考虑是不是有另一个cfg文件里定义了同名服务把你这个覆盖了。另外import通配符匹配出的文件列表是有排序的不是readdir的随机顺序。这一点对服务启动顺序影响很大因为同类服务之间的先后关系依赖加载顺序。看源码时可以在配置解析器里找到排序函数确认到底是按文件名字典序还是目录优先级。我自己就遇到过两个服务因为配置文件排序变化导致启动顺序反了表现为其中一个要依赖的目录还没创建最后只能靠显式调整cfg文件名来稳定顺序。3. 从内核交棒开始追主线源码3.1 init入口与启动前的环境准备内核把控制权交给init时第一个用户态进程的环境其实非常简陋挂载表是空的设备节点没创建很多目录也不存在。所以init的main函数在解析配置文件之前得先做一系列“脏活”解析内核cmdline确定平台和启动模式挂载基础文件系统创建/dev等目录启动ueventd去监听内核设备事件。这些操作不能等配置解析完再做因为它们本身就是解析配置和拉起服务的基础。很多同事读到这里会问为什么挂载动作不直接放到cfg的pre-init里执行从设计上看早期动作有一部分是固定的、必须最早执行的不适合交给可裁剪的配置层。固定逻辑放在代码里能保证即使cfg被改坏系统也有基本环境可用。这在调试时体会很深把pre-init里的命令改错系统大概率还能走到修复模式如果把基础mount也挪到cfg里可能连异常兜底都来不及做。3.2 cfg解析的关键代码逻辑初始化环境之后核心动作就变成了“读取配置→构建动作列表”。源码里的解析器并不会逐行解释配置而是先把所有import文件读入再做词法分析、结构校验、合并去重最后生成内部数据模型。这里有几点值得记住支持//和#注释键名和字符串按UTF-8处理带BOM会剔除遇到无法解析的语法会跳过该节点而不是让整个init崩溃service的path如果指向/system/bin下不存在的文件解析器只告警不中断。解析完成后init根据trigger把jobs按优先级排序。pre-init始终排在init之前post-init排在它们后面。这些优先级表面看不见实际由内部枚举定义。所以改配置时最好遵守既有阶段不要在自定义阶段上做奇怪安排否则很容易出现“服务起来了但前置目录还没就绪”的诡异现象。3.3 服务拉起与进程守护的实现拉起服务时init会fork一个子进程在子进程里完成uid切换、工作目录设置、环境变量注入然后execv执行目标程序。父进程则保留一个service结构体记录pid、启动时间、退出状态。当子进程退出内核发送SIGCHLD信号init在事件循环里收到后查表判断这个pid对应哪个服务再决定要不要重新拉起。源码里对“要不要重启”有一套状态判断主要看几个条件服务是否once只跑一次退出码是否在预期范围服务是否标记了important。我写服务配置前一定会先想清楚exit策略——有些服务是主动退出成功的比如一次性工具退出后不该重启有些是崩溃退出需要拉起。如果两者的once标错表现就是服务疯狂重启或者崩了完全没人管。常见排查手段是暂时把服务从cfg里摘出去或者给class换一个不存在的分组先让init忽略它静下心看日志。3.4 参数系统与回调机制init里有一套全局参数设计思路和常见属性系统很像但实现上为轻量设备做了很多简化。参数分全局和按前缀分区读写时有权限检查更重要的是cfg里on property:xxx1这类触发其实是参数系统在发现值变化后主动去匹配action列表的。参数底层没有用数据库而是常驻内存的哈希表加持久化文件。运行时的参数大多基于内存通过param set命令写入的才会同步到持久化分区。源码里值得关注的是参数变更的回调链set操作先校验权限再修改内存值然后遍历订阅者列表匹配触发项把对应job的cmds交给命令执行器。所以如果你发现某个属性触发的动作没执行优先查权限和触发条件而不是查动作本身。3.5 事件循环与对外IPCinit进程在加载完配置后不会退出它进入一个epoll驱动的死循环监听几类事件SIGCHLD信号完成子进程回收和服务重启判断uevent套接字完成设备节点创建和固件加载本地域socket处理来自其他进程的参数读写请求。把三类事件揉进同一个loop是init模块设计的核心。好处是单进程少锁竞争坏处是一旦某个handler阻塞整个进程都会卡住。源码里对每个handler都做了耗时记录实际调试时如果发现系统启动异常慢可以看init日志里的handler耗时定位是Uevent处理路径还是Service启动路径把主循环拖住了。这种问题在真实设备上很常见比如某个固件加载超过了启动超时时间系统就会一直停在“等待服务就绪”的状态。4. 实战中遇到的坑和排查手法4.1 服务反复重启日志里一会出现exit一会出现restart这类问题大多数是配置里字段语义没搞对。先看exit code如果进程是正常返回但你把它标成常驻init认为它应该继续活着就会重启如果进程因为缺so崩溃重启前会有加载失败的日志。排查建议从日志里过滤服务名看最后一次退出时的现场而不是盯着重启行为本身。另外critical服务还有额外策略多次崩溃可能会触发系统进入回退模式这种不是普通重启要看系统整体时序才能定位到根因。4.2 cfg解析失败导致启动阶段卡住cfg不是严格JSON但解析器对格式仍有要求不允许尾随逗号字符串必须闭合数组不能混类型。我踩过的最隐蔽的坑是在数组里把字符串和数字混着写解析器没能识别后面所有依赖这个cfg的服务都起不来。排查办法是先用一个小的解析工具单测或者让init以debug参数启动日志等级调到debug在主日志里搜parse fail关键字。还有一个经典问题Windows环境下编辑cfg会留下CRLF个别版本解析器能容忍但会把回车符当成参数的一部分导致path找不到文件。建议提交前统一转成LF再刷到设备。4.3 属性触发不生效属性系统对命名空间有约束不同分区有各自的权限表。如果cfg里写on property:vendor.test1但服务进程没有权限写vendor.test那永远触发不了。另一个常见错误是param set的时机太早属性服务还没完全初始化进程就发起写请求落空。对于这种场景init配置里一般有等待属性的命令条件满足前命令挂起超时才报错。所以写触发逻辑前先确认属性能被谁写、何时写不要只盯着trigger那一行。4.4 设备节点异常说明uevent环节没跟上服务能启动但访问/dev下某个节点找不到通常是uevent没有生成对应节点。先看设备在内核里是否注册成功再看固件是否缺失最后查ueventd的权限配置。有一个容易被忽略的细节某些设备需要服务启动后主动与驱动交互uevent才会创建节点意味着服务启动顺序和驱动加载顺序强关联。宁可在cfg里用trigger等待设备属性出现也不要靠定时sleep去猜时间窗口。4.5 调试init模块的几个有效姿势我调试这套模块的心得排序如下加日志最直接但记得用debug级别日志避免刷屏改服务class让目标服务不进启动序列先把系统带起来再说用参数命令手动验证触发链路绕过服务异常看早期启动日志越早的问题越要在内核日志里查因为init这时候可能还没把日志写入文件系统。这些技巧组合起来即使面对全新的板子和裁剪过的系统也能比较快定位问题。尤其是改class这招能让一个反复崩溃的服务不阻塞整体启动然后你再慢慢分析它为什么崩。5. 读这套源码的个人路线图最后分享一点我个人读代码的顺序。第一次接触init很容易从main函数开始顺着走然后被各类挂载和hook绕晕。后来我改成“先配后码”先把cfg能表达的所有机制看明白再回头看数据结构最后看事件循环。数据结构明白了各handler执行逻辑基本就是遍历、匹配、调用难度会低很多。这条路也适用于其他开源系统的启动模块Linux的init、Android的init核心思路都是“配置驱动加事件循环”只是实现语言和能力边界不同。第二点体会是不要把每个细节都背下来。init模块承载的信息非常密集字段、优先级、触发条件过段时间不看照样记混。源码阅读的价值在于建立“遇到问题时能迅速定位到哪个文件哪段逻辑”的感觉而不是把所有函数名记住。真遇到疑难杂症带着问题读代码比从头刷一遍源码效率高得多。这也是我写这篇内容的原因希望能帮你把进入源码的第一里路铺平一点剩下那些边角细节等你的系统真的出现奇怪问题时你自然会想起来。