☰
鸿蒙init模块源码解析:从配置文件到系统启动与服务守护
2026/10/20 16:47:59 网站建设 项目流程

做系统开发的人拿到一套新代码,第一个想读的往往是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、system
console是否允许控制台交互调试服务时可以直接改这个值快速定位

还有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:xxx=yyy这类属性值触发;
  • 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:xxx=1这类触发,其实是参数系统在发现值变化后主动去匹配action列表的。

参数底层没有用数据库,而是常驻内存的哈希表加持久化文件。运行时的参数大多基于内存,通过param set命令写入的才会同步到持久化分区。源码里值得关注的是参数变更的回调链:set操作先校验权限,再修改内存值,然后遍历订阅者列表,匹配触发项,把对应job的cmds交给命令执行器。所以如果你发现某个属性触发的动作没执行,优先查权限和触发条件,而不是查动作本身。

3.5 事件循环与对外IPC

init进程在加载完配置后不会退出,它进入一个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.test=1,但服务进程没有权限写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模块承载的信息非常密集,字段、优先级、触发条件过段时间不看照样记混。源码阅读的价值在于建立“遇到问题时能迅速定位到哪个文件哪段逻辑”的感觉,而不是把所有函数名记住。真遇到疑难杂症,带着问题读代码比从头刷一遍源码效率高得多。这也是我写这篇内容的原因,希望能帮你把进入源码的第一里路铺平一点,剩下那些边角细节,等你的系统真的出现奇怪问题时,你自然会想起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询