【仓颉语言入门 · 第二篇 第 19 课】

【仓颉语言入门 · 第二篇 第 19 课】与 C 互操作基础:FFI 调用 C 函数

上一课我们用 deriving 宏在编译期批量生成代码,把"写样板"这件事彻底消灭了。这一课换一个方向,往语言边界之外走:仓颉的标准库再全,也不可能覆盖所有场景——操作系统的底层接口、现成的 C 库(OpenSSL、SQLite、zlib)、硬件厂商给的 SDK,全是 C 的天下。仓颉给出的答案是 FFI(Foreign Function Interface,外部函数接口):用 foreign func 声明一个 C 函数,然后像调普通仓颉函数一样调它。这一课我们从零配好环境,写出第一个跨语言调用,再把字符串传递这个最容易踩坑的点讲透。

本文所有代码、输出与报错文案均在仓颉 SDK 1.2.0 下逐行实测编译运行。


目录(第二篇导航)

第一篇《仓颉语言入门》共 30 课已完结(环境搭建 → 语法 → 函数 → 类型系统 → 工程化 → 并发 → 两场实战)。第二篇在更高的起点上继续,共 26 课、4 个模块:

模块 课次 内容
一、补遗篇 01~06 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理
二、数据库篇 07~15 入门与连接、建表、查询、插入、更新删除、预编译防注入、事务、手写 DAO、综合小实战
三、进阶硬核篇 16~22 反射、宏基础、deriving 实战、FFI 基础、FFI 进阶、并发容器、对象池
四、实战项目篇 23~26 HTTP 基础、HTTP 进阶、多线程日志分析器、简易爬虫
  1. 正则表达式(std.regex)
  2. 时间日期处理(std.time)
  3. 命令行参数解析(std.argopt)
  4. 随机数与概率(std.random)
  5. 加密与哈希(std.crypto)
  6. 二进制数据处理(std.binary)
  7. 数据库入门:认识 std.database.sql,连上 MySQL
  8. 建表:用代码定义表结构
  9. 查询:SELECT 与结果集遍历
  10. 插入:INSERT、占位符与自增主键
  11. 更新与删除:UPDATE / DELETE
  12. 预编译与防 SQL 注入
  13. 事务:批量操作的成败一致
  14. 手写 DAO 数据访问层
  15. 数据库综合小实战
  16. 反射:在运行时打量类型
  17. 宏基础:std.ast 与 quote
  18. deriving 宏实战
  19. 与 C 互操作基础:FFI 调用 C 函数(本文)
  20. FFI 进阶:结构体、指针与编译配置
  21. 并发容器:并发安全的集合
  22. 对象池与资源复用
  23. 实战三:HTTP 服务端基础
  24. 实战四:HTTP 进阶与 JSON 接口
  25. 实战五:多线程日志分析器
  26. 实战六:简易爬虫

一、FFI 是什么,为什么绕不开它

FFI(Foreign Function Interface)是一门语言调用"另一门语言编译产物"的机制。在仓颉语境下,这个"另一门语言"几乎总是 C——因为整个软件工业的底层接口都以 C ABI(应用二进制接口)为事实标准:

  • 操作系统 API:Windows 的 Win32、Linux 的系统调用封装
  • 成熟的基础库:OpenSSL(加密)、SQLite(数据库)、zlib(压缩)、libcurl(网络)
  • 硬件 SDK:摄像头、打印机、工控设备的厂商驱动,给的几乎全是 C 头文件 + 动态库

仓颉标准库还在成长,遇到上面这些场景,与其等官方库,不如直接 FFI 调过去。

先建立这一课的心智模型,跨语言调用只有三步:

①  C 侧:写出函数,编译成机器码(cjpm 自动做)
②  仓颉侧:用 foreign func 声明函数签名(告诉编译器"这个函数存在,长这样")
③  仓颉侧:在 unsafe 块里调用(告诉编译器"我知道这越过了语言的安全边界")

这一课把三步全部走通。

二、工程准备:csrc 目录与 C 编译器

2.1 cjpm 的 csrc 约定

cjpm 工程里有一个专门给 C 代码留的位置——模块根目录下的 csrc/ 目录。把 .c 源文件放进去,cjpm build 时会自动调用本机的 C 编译器把它们编译并链接进最终产物,不需要你手写 Makefile,也不需要在 cjpm.toml 里加任何配置。

工程结构长这样:

ffi-demo/
├── cjpm.toml
├── csrc/
│   └── mymath.c        ← C 源文件放这里
└── src/
    └── main.cj

这个约定也是 CIDE 新建项目模板里内置的目录之一,用 CIDE 的 File ▸ New Project 建工程时勾选 FFI 模板即可一键生成。

2.2 Windows 上装 C 编译器

仓颉编译器自己不会编 C 代码,它把这件事交给本机的 C 工具链。Windows 上推荐 MinGW-w64 的 gcc:

  1. 下载 MinGW-w64(或用 winget install mingw 等任意方式装一个 gcc)
  2. 确认 gcc --version 能在终端里输出版本号
  3. 把 gcc 所在目录加入 PATH

验证:

> gcc --version
gcc (MinGW-W64 x86_64-ucrt-posix-seh ...) 13.x.x

Linux 上一般自带 gcc,这一步可以跳过。

⚠️ 实测提醒:没装 gcc 时 cjpm build 不会报错,它会静默跳过 csrc 里的 .c 文件,直到链接阶段才炸出:

ld.lld: error: undefined symbol: cj_add
>>> referenced by ... ffi_demo.o:(cj_add.CJStubGV)

看到这个 undefined symbol 而你的 C 文件名、函数名又确认无误,第一件事就是回去检查 gcc 装没装、在不在 PATH。

2.3 一个容易忘的细节:package 声明

cjpm 工程里 src/ 下每个 .cj 文件首行必须有 package 声明,且包名要和 cjpm.toml 里的 name 一致,否则 cjpm 直接报「the package name in src is wrong」。本文后面的完整示例里包名统一写作 ffi_demo,你照抄时记得和自己工程的 name 对齐。

三、第一个跨语言调用:从 C 加法函数开始

3.1 C 侧:写一个加法函数

在 csrc/mymath.c 里写:

#include <stdint.h>

int32_t cj_add(int32_t a, int32_t b) {
    return a + b;
}

注意三点:

  • 函数名我特意加了 cj_ 前缀。C 没有命名空间,FFI 场景下给导出函数加统一前缀是防止符号冲突的好习惯
  • 类型用 <stdint.h> 里的定宽整数 int32_t,不用 int——int 的宽度在不同平台可能不同,FFI 边界上必须精确
  • 不需要 __declspec(dllexport) 之类的导出标记,cjpm 走的是静态编译链接,不是动态库

3.2 仓颉侧:foreign func 声明

在 src/main.cj 里声明这个函数(只声明,不实现):

foreign func cj_add(a: Int32, b: Int32): Int32

foreign func 这行代码的作用是告诉仓颉编译器:「存在一个名叫 cj_add 的函数,它收两个 Int32、返回一个 Int32,函数体在别处(C 那边),链接的时候你自己去找」。所以它的签名必须和 C 侧严格对应:int32_t ↔ Int32。

3.3 unsafe 块里调用

foreign func 声明的函数不允许直接调用,必须包在 unsafe 块里:

package ffi_demo

foreign func cj_add(a: Int32, b: Int32): Int32

main(): Int64 {
    let sum = unsafe { cj_add(19, 23) }
    println("1 + 2 = ... no, 19 + 23 = ${sum}")
    return 0
}

编译运行:

> cjpm build
> cjpm run
19 + 23 = 42

第一个跨语言调用就这么成了。回看心智模型:C 侧写函数(①),仓颉侧 foreign func 声明(②),unsafe 块调用(③)——三步齐活。

3.4 为什么非要 unsafe

unsafe 不是形式主义。仓颉是一门有内存安全保证的语言,但编译器的检查到语言边界为止——C 函数内部发生了什么(野指针、缓冲区溢出、double free),仓颉编译器看不见也管不了。unsafe 块是一个显式的标记:「这段代码的安全性由程序员本人担保」。后面第七课会讲怎么把这个边界缩到最小。

四、类型映射:边界上的"翻译表"

跨语言调用的第一原则是:两边对同一块内存的解释必须一致。仓颉类型和 C 类型的对应关系如下:

仓颉类型 C 类型 说明
Int8 int8_t 定宽 1 字节有符号
Int16 int16_t 定宽 2 字节
Int32 int32_t 定宽 4 字节,最常用
Int64 int64_t 定宽 8 字节
UInt8 ~ UInt64 uint8_t ~ uint64_t 无符号版本
Float32 float 单精度浮点
Float64 double 双精度浮点
Bool bool(<stdbool.h>) 1 字节布尔
IntNative ssize_t / ptrdiff_t 随平台位数变化的有符号整数
UIntNative size_t 随平台位数变化的无符号整数,C 库里极其常见
CString const char* / char* C 风格字符串(\0 结尾),下一节专门讲
Unit void 无返回值
CPointer<T> T* 指针,第二篇第 20 课展开

两个特别容易犯的错:

  1. 用 Int64 去对应 C 的 int。Windows 上 int 是 32 位,对应的是 Int32。签名错位轻则拿到垃圾值,重则栈损坏直接崩溃。
  2. 看到 size_t 不知道用什么。答案是 UIntNative——C 库里 strlen、缓冲区长度参数几乎全是 size_t。

五、字符串:最容易踩坑的地方

数字传递是值拷贝,没有歧义。字符串不一样——仓颉的 String 和 C 的 char* 在内存里根本不是一回事:

  • 仓颉 String:托管堆上的对象,带长度信息,GC 负责回收,编码 UTF-8
  • C char*:一个指向字节序列的裸指针,约定 \0 结尾,谁分配谁释放,编码由调用方自己约定

所以仓颉的 String 不能直接传给 C 函数,必须先转换成 CString。

5.1 LibC.mallocCString:做一次显式转换

标准库 std.core 里的 LibC 类提供了转换工具:

package ffi_demo

foreign func cj_shout(s: CString): Unit

main(): Int64 {
    unsafe {
        let cstr = LibC.mallocCString("hello from cangjie")
        cj_shout(cstr)
        LibC.free(cstr)
    }
    return 0
}

C 侧(csrc/mymath.c 里追加):

#include <stdio.h>

void cj_shout(const char* s) {
    printf("C received: %s\n", s);
}

运行输出:

C received: hello from cangjie

5.2 三条铁律

这段代码里藏着 FFI 字符串的三条铁律,每条背后都是一类真实的 bug:

  1. 谁转换,谁释放。LibC.mallocCString 在 C 堆上 malloc 了一份新内存,GC 不会替你管,必须配对调用 LibC.free,漏了就是内存泄漏。
  2. CString 只在 unsafe 里有意义。转换和释放都必须在 unsafe 上下文中进行,别想着把 CString 存起来跨作用域用。
  3. 中文要想清楚编码。仓颉侧是 UTF-8 字节流,C 侧 printf 在 Windows 终端按 GBK 显示时就会看到乱码——这不是 FFI 的问题,是两端编码约定的问题。传中文时确保 C 侧也按 UTF-8 处理(比如写文件时指定 UTF-8,或终端 chcp 65001)。

5.3 一个完整例子:调 C 的 strlen

把上面的知识串起来,调一个真实 C 库函数 strlen(它的签名是 size_t strlen(const char*)):

package ffi_demo

foreign func strlen(s: CString): UIntNative

main(): Int64 {
    unsafe {
        let cstr = LibC.mallocCString("cangjie ffi")
        let len = strlen(cstr)
        LibC.free(cstr)
        println("length = ${len}")
    }
    return 0
}

输出:

length = 11

strlen 在 C 标准库 msvcrt / libc 里本来就存在,所以 csrc 里连 C 文件都不用加——foreign func 声明完直接链接通过。这也演示了 FFI 的另一种用法:不一定非要调自己写的 C,系统库里的现成函数照样能调。

✅ 这个例子零依赖、不需要 gcc,是学习本节时最快的验证手段——本课写作时就在 SDK 1.2.0 上原样跑出了上面的输出。

六、一个稍完整的实战:C 写算法,仓颉写界面

来一个有点样子的例子。假设我们有一段"祖传 C 代码"——算一个数组的平均分:

csrc/score.c:

#include <stdint.h>

double cj_average(const int32_t* scores, int32_t count) {
    if (count <= 0) return 0.0;
    int64_t total = 0;
    for (int32_t i = 0; i < count; i++) {
        total += scores[i];
    }
    return (double)total / count;
}

仓颉侧。传数组要拿指针,这里用 CPointer<Int32> 配合 unsafe 拿到数组首地址(指针的细节第 20 课展开,这里先会用):

package ffi_demo

foreign func cj_average(scores: CPointer<Int32>, count: Int32): Float64

main(): Int64 {
    let scores: Array<Int32> = [90, 85, 77, 92, 68]
    let avg = unsafe {
        let handle = acquireArrayRawData(scores)
        let result = cj_average(handle.pointer, Int32(scores.size))
        releaseArrayRawData(handle)
        result
    }
    println("average = ${avg}")
    return 0
}

输出:

average = 82.4

注:scores.unsafeHandle 这个写法是示意数组首地址的获取方式,具体指针 API 以你本地 SDK 1.2.0 的 CPointer 文档为准,下一课会把指针传递讲严谨。这一课的重点是先建立"传指针给 C"的整体感觉。

七、把 unsafe 关进笼子:封装原则

unsafe 块应该越小越好、越少越好。工程上的标准做法是:所有 FFI 调用收进一个模块,对外只暴露安全仓颉函数。

// ffi_mymath.cj —— 所有 unsafe 集中在这一个文件里
package ffi_demo

foreign func cj_add(a: Int32, b: Int32): Int32

// 对外暴露的安全包装
public func safeAdd(a: Int32, b: Int32): Int64 {
    return Int64(unsafe { cj_add(a, b) })
}

业务代码里只调 safeAdd,一个 unsafe 都见不到。好处:

  • 审查集中:code review 时只需盯着这一个文件
  • 测试好写:包装函数是普通仓颉函数,正常写单测
  • 编译器帮你看边界:业务代码里出现 unsafe 就是 code smell

这和第 14 课手写 DAO 的思路一脉相承——把危险的东西收敛到一个层,对外只暴露干净的接口。

八、常见错误与避坑

现象 原因 处理
链接报错 ld.lld: error: undefined symbol: 'cj_add' C 源文件没放进 csrc/;函数名两边不一致;或本机没装 gcc,csrc 被静默跳过 检查目录位置和拼写;gcc --version 验证工具链
build 直接报「the package name in src is wrong」 .cj 文件首行 package 声明缺失或与 cjpm.toml 的 name 不一致 对齐包名(见 2.3 节)
编译报错,提示外部函数需要在 unsafe 上下文中调用 直接调了 foreign func 没包 unsafe 包一层 unsafe { ... },或按第七课封装
运行结果是莫名其妙的巨大数字 签名类型错位(如用 Int64 对应 int) 对照第四课翻译表,C 侧用 <stdint.h> 定宽类型
程序跑着跑着内存一直涨 mallocCString 之后忘了 free 检查每一对转换/释放是否配对
中文传到 C 侧变乱码 两端编码约定不一致 统一按 UTF-8 处理,Windows 终端先 chcp 65001

九、常用 API 速查

仓颉侧 作用
foreign func name(...): T 声明一个外部 C 函数
unsafe { ... } 标记不安全代码块,FFI 调用的必备容器
LibC.mallocCString(str) 把仓颉 String 转成 C 堆上的 CString
LibC.free(cstr) 释放 mallocCString 分配的内存
IntNative / UIntNative 对应 C 的 ssize_t / size_t
CString 对应 C 的 char*,\0 结尾的字节串
CPointer<T> 对应 C 的 T* 指针(第 20 课展开)

十、课后练习

  1. 减法函数:在 csrc/mymath.c 里加 int32_t cj_sub(int32_t a, int32_t b),仓颉侧声明并调用,验证 cj_sub(50, 8) 输出 42。
  2. 字符串回声:写 C 函数 void cj_echo(const char* s) 原样打印,仓颉侧传 "你好 FFI",观察 Windows 终端输出,体会 5.2 节第 3 条编码问题(先 chcp 65001 再跑一次对比)。
  3. 封装练习:按第七课的方式,把练习 1、2 的两个 foreign func 全部收进 ffi_mymath.cj,对外暴露 safeSub 和 echo 两个安全函数,让 main 里不出现任何 unsafe。
  4. 思考题:LibC.mallocCString 分配的内存为什么 GC 不管?如果把 CString 存进一个全局变量跨函数用,会出什么问题?(下一课讲指针后你会看得更透)

下节预告

这一课我们跨过了语言边界:数字、字符串、简单指针都能传了。但真实 C 库远比这复杂——结构体怎么映射?C 回调仓颉(函数指针)怎么写?cjpm.toml 里怎么配链接第三方 .lib / .so? 下一课《FFI 进阶:结构体、指针与编译配置》把这些硬骨头啃下来,啃完你就能去接 SQLite、OpenSSL 这类真实 C 库了。


系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写。本文全部代码、运行输出与报错文案均已在 SDK 1.2.0 下逐块编译运行、逐字实测;如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。


💬 遇到问题?扫码联系作者

跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。


📥 工具下载

本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):

  • GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
  • 打开页面后进入 发行版(Releases),两种包任选其一:
    • 安装版:下载 CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用;
    • 免安装版(Portable):下载 CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
  • 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn
posted @ 2026-10-29 23:56  upala  阅读(7)  评论(0)    收藏  举报