【仓颉语言入门 · 第二篇 第 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 进阶、多线程日志分析器、简易爬虫 |
- 正则表达式(std.regex)
- 时间日期处理(std.time)
- 命令行参数解析(std.argopt)
- 随机数与概率(std.random)
- 加密与哈希(std.crypto)
- 二进制数据处理(std.binary)
- 数据库入门:认识 std.database.sql,连上 MySQL
- 建表:用代码定义表结构
- 查询:SELECT 与结果集遍历
- 插入:INSERT、占位符与自增主键
- 更新与删除:UPDATE / DELETE
- 预编译与防 SQL 注入
- 事务:批量操作的成败一致
- 手写 DAO 数据访问层
- 数据库综合小实战
- 反射:在运行时打量类型
- 宏基础:std.ast 与 quote
- deriving 宏实战
- 与 C 互操作基础:FFI 调用 C 函数(本文)
- FFI 进阶:结构体、指针与编译配置
- 并发容器:并发安全的集合
- 对象池与资源复用
- 实战三:HTTP 服务端基础
- 实战四:HTTP 进阶与 JSON 接口
- 实战五:多线程日志分析器
- 实战六:简易爬虫
一、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:
- 下载 MinGW-w64(或用
winget install mingw等任意方式装一个 gcc) - 确认
gcc --version能在终端里输出版本号 - 把 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 课展开 |
两个特别容易犯的错:
- 用
Int64去对应 C 的int。Windows 上int是 32 位,对应的是Int32。签名错位轻则拿到垃圾值,重则栈损坏直接崩溃。 - 看到
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:
- 谁转换,谁释放。
LibC.mallocCString在 C 堆上malloc了一份新内存,GC 不会替你管,必须配对调用LibC.free,漏了就是内存泄漏。 - CString 只在 unsafe 里有意义。转换和释放都必须在
unsafe上下文中进行,别想着把CString存起来跨作用域用。 - 中文要想清楚编码。仓颉侧是 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 课展开) |
十、课后练习
- 减法函数:在
csrc/mymath.c里加int32_t cj_sub(int32_t a, int32_t b),仓颉侧声明并调用,验证cj_sub(50, 8)输出42。 - 字符串回声:写 C 函数
void cj_echo(const char* s)原样打印,仓颉侧传"你好 FFI",观察 Windows 终端输出,体会 5.2 节第 3 条编码问题(先chcp 65001再跑一次对比)。 - 封装练习:按第七课的方式,把练习 1、2 的两个
foreign func全部收进ffi_mymath.cj,对外暴露safeSub和echo两个安全函数,让main里不出现任何unsafe。 - 思考题:
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

浙公网安备 33010602011771号