前言:先让两个监听者收到一次信号

这次做 signal4cj,我先给页面放了两个计数器:监听 A 和监听 B。点一次发送,两边都加一;移除 B 后再发送,只有 A 变化。这样一来,注册、接收、撤销这几件事能直接从画面看出来,也方便检查库的行为有没有真正接到鸿蒙进程。

它对应的上游是 signal-hook。这里的 signal 指 Unix 系统信号,不是聊天软件,也不是普通组件点击事件。演示按钮最终调用 kill(getpid(), SIGUSR1),由系统把信号送回本应用,再经过原生处理器和仓颉分发路径更新计数。按钮本身不修改 A、B 的数值。

CJMP 社区:公共逻辑库与平台接入

工程是 MyApplication45,框架按 CJMP 0.2.2 的 native 逻辑模块组织。公共订阅接口在仓颉 common 包,sigaction 和管道操作留在鸿蒙后端,ArkTS 负责展示。文章沿着一次信号的实际去向往下看,最后再回到设备测试和源码复现。

环境展示

signal-hook 固定上游源码

CPF-RN:基础工具与安装资料

CJMP 开发准备

CJMP 0.2.2 配套说明

项目

本次实际配套

框架

CJMP Tools 0.2.2 / logic-module / native

IDE

DevEco Studio 6.1.1.300

SDK / 编译器

HarmonyOS 6.1.1(24) / 仓颉与 cjpm 1.1.3

模拟器

x86_64,6.1.1.350

真机

MatePad Edge,arm64-v8a,6.1.1.120

环境搭建直接引用 CPF-RN 的基础工具文件,RN 专有依赖不照搬到这个 CJMP 工程。CJMP 0.2.2 文档给出的仓颉配套是 1.1.0-beta.22,本机实际为 1.1.3,构建目录与 C ABI 桥按实际 SDK 调整。目前完成的是 API 24 双端实测,API 26 仍需要换配套后重新验证。

信号处理器里做什么

系统信号可能打断任意线程。当中调用仓颉回调、拼接字符串或刷新 ArkUI,都不适合放进这个特殊执行环境。原生入口因此只做两件事:置 pending 标志,向非阻塞管道写一个唤醒字节。真正的业务回调等 pump 在正常调用路径里处理。

static void on_signal(int sig) {
    int saved=errno, i=index_of(sig);
    if(i>=0 && atomic_load_explicit(&enabled[i],memory_order_relaxed)) {
        atomic_store_explicit(&pending[i],1,memory_order_relaxed);
        unsigned char byte=(unsigned char)sig;
        (void)write(wakefd[1],&byte,1); /* Non-blocking; pending survives EAGAIN. */
    }
    errno=saved;
}

这里保留并恢复 errno,避免信号打断的原操作发现错误码被改了。pending 使用无锁原子整数,编译时要求对应平台的 int 原子始终无锁;处理器没有加互斥锁,也没有分配内存。管道满时,write 可能失败,但 pending 仍然保留,通知不依赖每个唤醒字节都成功写入。

这并不意味着能精确记录每次信号。普通信号本来就可能合并,这一版还主动按类型合并 pending:连续发送二十次,再 pump,一名监听者本次只收到一次通知。要统计每条任务,应该用有序消息队列等机制,不能把这个标志当作可靠计数器。

仓颉回调在 pump 中分发

public func pump(timeoutMillis: Int32): Int64 {
        ensureOpen();let bits=unsafe {sg_poll(timeoutMillis)}
        if(bits<0){throw IllegalStateException("poll failed, errno=${-bits}")}
        var count: Int64=0
        // Snapshot: subscriptions made in a callback are deferred to the next pump.
        let snapshot=listeners.toArray()
        for(slot in snapshot) {
            let bit: Int32=if(slot.signal==user1){1}else{2}
            if(slot.active && (bits & bit)!=0){slot.callback(slot.signal);count++}
        }
        return count
    }

pump 先读取管道并取走 pending,再遍历当前监听者的快照。每个订阅有独立 ID,撤销只把对应项标为 inactive。回调里新增的监听者不会挤进正在处理的这批快照;已经撤销的项也会在调用前重新判断。这些操作都是普通线程路径上的仓颉逻辑。

public func unsubscribe(id: Int64): Bool {
        ensureOpen()
        for(slot in listeners){if(slot.id==id && slot.active){slot.active=false;return true}}
        return false
    }

示例先注册 A、B,两边收到第一次通知后都是 1。点击移除 B,再发送一次,A 变成 2,B 保持 1。日志也只新增 A 的一行。这里同时检查了画面数字和日志,避免只看某个内部返回值就认为撤销成功。

调用规则也要提前说清:全进程只能有一个分发器,普通 API 由拥有线程串行调用;第二个分发器会在获取所有权时被拒绝。回调如果抛异常,会终止当前分发并向调用者传播,该批 pending 已经消费,不会自动重新执行剩余回调。业务侧应在自己的回调边界处理可预期异常。

注册和关闭不能抢占运行时

SIGUSR1、SIGUSR2 可以成为这一版的注册目标,但仍需检查进程当时的处理器。已有自定义处理器时返回 EBUSY,而不是强行替换。上游具备旧处理器链式调用能力,这一版没有把那套完整注册表移植进来,因此采用明确拒绝的策略,避免覆盖仓颉运行时或其他库。两端实测对象是 SIGUSR1,SIGUSR2 没有单独扩展验证结论。

int32_t sg_restore(int32_t sig) {
    int i=index_of(sig);if(i<0)return EINVAL;
    if(!atomic_load(&enabled[i]))return 0;
    /* Do not overwrite a handler installed by another component meanwhile. */
    struct sigaction current;if(sigaction(sig,NULL,&current)<0)return errno;
    if(current.sa_handler!=on_signal)return EBUSY;
    atomic_store(&enabled[i],0);
    if(sigaction(sig,&previous[i],NULL)<0){int e=errno;atomic_store(&enabled[i],1);return e;}
    atomic_store(&pending[i],0);return 0;
}

close 会确认当前处理器仍然是本库安装的入口,再恢复原处理器。若另一个组件已经改过它,会报告冲突,不能覆盖别人的新状态。全局管道 FD 保留到进程退出,避免处理器正在写时 FD 被关闭、甚至被其他文件复用;反复开关不会不断创建新管道。

停止之后,页面显示已停止。此时再点发送,接口直接拒绝,不会给已经恢复默认处理器的应用发 SIGUSR1。另一个按钮尝试注册 SIGKILL,用来验证不支持的目标会在安装前被拒绝;演示没有真的向应用发送 SIGKILL。

实际改动都在这些文件里

harmony/cjmp/signal4cj/
  project.conf
  logic-module/cjpm.toml
  logic-module/common/signals.cj
  logic-module/hos/native/signal_core.c
  logic-module/hos/native/napi_bridge.c
  logic-module/hos/demo.cj
  logic-module/hos/test_suite.cj
  hos/signal4cj/
  scripts/build-logic.ps1
example/harmony/MyApplication45/

文件

改动目的

common/signals.cj

提供仓颉订阅、撤销、快照分发及 Resource 关闭

native/signal_core.c

接入鸿蒙 sigaction、self-pipe、真实信号和所有权检查

cjpm.toml / build-logic.ps1

构建公共包,链接原生后端,收集两个 ABI 的依赖

napi_bridge.c / demo.cj

普通调用路径的 C ABI 接入,复制结果并释放返回字符串

hos/signal4cj

HAR 门面与类型声明,提前编译外部 common 源码

example/harmony/MyApplication45

实际依赖 HAR 的应用;显示计数、状态与设备报告

harmony 保存的是实际适配源码,example 是独立依赖它的示例。原生后端先编译,再由 cjpm 链接进公共包;HAR 的构建钩子提前处理位于模块外的 common 目录,避免增量构建把修改漏掉。平台依赖按 ELF 信息收集,x86_64 与 arm64 文件分别打包。页面通过 signal4cj 门面调用,未再实现另一套信号模拟器。

运行效果:模拟器与 MatePad Edge

两端按同一顺序操作:发送、移除 B、再次发送、连续发送、停止、尝试发送、重新注册、拒绝不支持信号、执行自检。下面的 GIF 来自实际运行帧,包含状态变化和当前设备的测试结果。自动化通过设备接口点击应用,不切换 Windows 前台窗口。

自检在设备里的仓颉测试包执行,包含三十轮真实 SIGUSR1 生命周期。每轮检查两个监听者、撤销幂等性、合并通知、空轮询、非法目标拒绝、关闭幂等性和关闭后调用,共三百三十项检查。模拟器与真机都返回 PASS,测试没有通过 Python 写入一个预设的成功报告。

PASS | 330 checks | 30 real-signal lifecycle rounds

截图复核还查出一个显示层问题:设备深色主题会让没有显式字体颜色的正文在白色卡片上变得难读。最终给状态文字设置颜色,并重建、重跑、重录。读数验证能检查计算和调用,真实画面的复核则能发现这种用户实际看不到内容的问题。

总结与复现

这次接入的核心,是把异步系统入口和正常业务执行分开,并给注册、撤销、关闭留出可验证的边界。完整的 Rust 注册表、实时信号队列、siginfo、异步运行时适配器以及退出策略都不在本版范围,Android/iOS 也没有运行验证。README 对这些边界和回调异常行为有单独说明。

下载源码包后打开 example/harmony/MyApplication45,按 README 配置对应 SDK 与运行依赖,再生成自己的签名并运行。公共 CJMP 工程可在 harmony/cjmp/signal4cj 下用官方命令构建 HAR。逐文件理由在 PORTING.md,两端包哈希、读数和源码一致性记录在 docs/verification。

cjmp build har --platform ohos-arm64

项目开源地址:signal4cj(AtomGit)。

加入 CJMP 社区,讨论公共逻辑库与鸿蒙接入

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐