【仓颉语言入门 · 第二篇 第 20 课】FFI 进阶:结构体、指针与编译配置
上一课我们跨过了语言边界,把数字和字符串传给 C,跑通了第一个
foreign func。但真实 C 库的接口远不止int和char*——SQLite 的sqlite3*是不透明指针、libcurl 的CURL*是结构体句柄、OpenSSL 的EVP_MD_CTX*是上下文对象。这些库的共同特征是:核心数据结构只给你一个指针,字段怎么布局是 C 那边的秘密。这一课就把指针这件事讲透——CPointer<T>怎么分配、读写、做指针算术、判空;结构体怎么用「不透明指针」模式安全映射;最后把cjpm.toml的编译链接配置讲清楚,让你能接上任意第三方.lib/.dll。
本文所有代码、输出与报错文案均在仓颉 SDK 1.2.0 下逐行实测:指针读写、指针算术、判空、系统库调用等不依赖 C 编译器的部分已端到端运行通过;涉及自写 C 源码的结构体与数组示例,也已在本机 gcc 环境下端到端编译链接运行通过(gcc 安装见第 19 课 2.2 节)。
目录(第二篇导航)
第一篇《仓颉语言入门》共 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 接口
- 实战五:多线程日志分析器
- 实战六:简易爬虫
一、从上一课的「示意」说起
第 19 课末尾传数组给 C 时,曾用 handle.pointer 这样的示意写法,并标注「具体指针 API 以本地 SDK 文档为准」。这一课就把这个坑填上——在 SDK 1.2.0 上逐行实测后,正确的指针 API 是下面这一套,和示意写法不完全一样。先把经实测的「指针工具箱」列出来,后面每一节都用它。
CPointer<T> ← 对应 C 的 T*,T 必须是 CType(Int8/Int16/Int32/Int64/Float32/Float64/UInt8.../Unit/CString)
LibC.malloc<T>(count: Int64): CPointer<T> ← 在 C 堆上分配 count 个 T 的内存,返回首元素指针
LibC.free(p: CPointer<T>): Unit ← 释放 malloc 分配的内存
p.read(): T ← 解引用读取 *p
p.write(v: T): Unit ← 解引用写入 *p = v
p + n ← 指针算术,返回指向第 n 个元素的指针(步长按 sizeof(T))
p.isNull(): Bool ← 判断是否空指针
✅ 以上六条 API 全部在 SDK 1.2.0 端到端运行通过(不依赖 gcc,下一节就跑给你看)。
一个关键认知:LibC.malloc<T> 是泛型的,返回值直接就是 CPointer<T>,不需要再做什么「void* 转 T*」的类型转换。1.2.0 上也没有 reinterpretCast 这类转换函数——写了会报 error: undeclared identifier 'reinterpretCast'(实测)。按目标类型直接 malloc<T> 就好,这是更干净也更安全的写法。
二、CPointer:分配、读写、指针算术
2.1 一块 Int32 内存的三连读
用 malloc<Int32> 分配 3 个 Int32 的连续内存,分别写入再读出来。整个过程不需要任何 C 源文件,因为 LibC.malloc / LibC.free 是仓颉标准库 std.core 里就有的:
package ffi20
main(): Int64 {
unsafe {
let p = LibC.malloc<Int32>(count: 3)
p.write(10)
(p + 1).write(20)
(p + 2).write(30)
println("array: ${p.read()} ${(p + 1).read()} ${(p + 2).read()}")
LibC.free(p)
}
return 0
}
编译运行:
> cjpm build
> cjpm run
array: 10 20 30
逐行拆解:
LibC.malloc<Int32>(count: 3)在 C 堆上分配3 * sizeof(Int32) = 12字节,返回指向首元素的CPointer<Int32>。注意count是元素个数不是字节数,泛型参数<Int32>决定了元素大小p.write(10)等价于 C 的*p = 10p + 1是指针算术,步长按sizeof(Int32) = 4自动计算,指向下一个元素,等价于 C 的p + 1(不是p + 1 字节)。这是CPointer<T>和裸字节指针最大的区别——类型信息带在指针上,算术自动按元素步长走p.read()等价于 C 的*pLibC.free(p)释放,和malloc严格配对
2.2 指针算术的步长是元素不是字节
这一点值得单独强调,因为它是 bug 高发区。看 CPointer<Int64> 的例子:
main(): Int64 {
unsafe {
let p = LibC.malloc<Int64>(count: 2)
p.write(100)
(p + 1).write(200)
println("0=${p.read()} 1=${(p + 1).read()}")
LibC.free(p)
}
return 0
}
0=100 1=200
p + 1 跨过了 8 字节(sizeof(Int64))而不是 1 字节。如果你心里想着「字节偏移」去算,就会写错位置。记住:CPointer<T> 的 +n 永远是「跳过 n 个 T」,和 C 里 T* p; p[n] 的语义一致。
2.3 判空:isNull()
C 函数可能返回 NULL(比如 malloc 失败、fopen 打不开文件)。仓颉侧拿到指针后第一件事应该是判空,否则 read() 一个空指针直接段错误:
main(): Int64 {
unsafe {
let p = LibC.malloc<Int32>(count: 1)
if (p.isNull()) {
println("malloc failed")
return 1
}
p.write(42)
println("v = ${p.read()}")
LibC.free(p)
}
return 0
}
v = 42
isNull() 是方法调用(带括号),返回 Bool。任何从 C 侧 foreign func 拿回来的指针,都该先过这一道关。
2.4 用字节缓冲拼一个 C 字符串
把上一课的 CString 和这一课的指针算术合起来——手动往字节缓冲里写字符,再当 CString 用。LibC.mallocCString 是把现成仓颉 String 拷过去的快捷方式;当你需要逐字节构造内容时,就走 malloc<UInt8> + write:
foreign func strlen(s: CPointer<UInt8>): UIntNative
main(): Int64 {
unsafe {
let bytes = LibC.malloc<UInt8>(count: 4)
(bytes + 0).write(UInt8(72)) // 'H'
(bytes + 1).write(UInt8(105)) // 'i'
(bytes + 2).write(UInt8(33)) // '!'
(bytes + 3).write(UInt8(0)) // '\0' 结尾
let len = strlen(bytes)
println("strlen = ${len}")
LibC.free(bytes)
}
return 0
}
strlen = 3
这里把
strlen声明成收CPointer<UInt8>而非CString——两者在 ABI 上同构(都是char*/uint8_t*),foreign func的参数类型只要 ABI 匹配即可,这样字节缓冲就能直接传进去。注意末尾必须自己写\0,C 侧的strlen才不会越界读。
三、结构体映射:不透明指针模式
3.1 为什么不用「字段对齐」映射
上一课预告「结构体怎么映射」时,你可能以为要在仓颉侧把 C 结构体的每个字段都声明出来。SDK 1.2.0 实测下来,foreign struct 语法尚未支持,@C struct 又要求字段全部初始化、和 C 的裸内存语义对不齐。更重要的是,即便字段对齐能做,你也不该做——真实 C 库(SQLite、libcurl、OpenSSL)的结构体定义里塞满了「内部字段,调用方不要碰」的私有成员,版本一升级字段顺序就变。你按字段布局写死,下次升级就崩。
工业界标准做法是不透明指针(opaque pointer):仓颉侧只拿到一个指针,当句柄拿着,所有字段访问都通过 C 函数完成。结构体长什么样,是 C 那边的秘密。
3.2 C 侧:定义结构体 + 一组访问函数
csrc/point.c:
#include <stdint.h>
#include <stdlib.h>
typedef struct {
int32_t x;
int32_t y;
} cj_point;
cj_point* cj_point_create(int32_t x, int32_t y) {
cj_point* p = (cj_point*)malloc(sizeof(cj_point));
p->x = x;
p->y = y;
return p;
}
int32_t cj_point_get_x(const cj_point* p) { return p->x; }
int32_t cj_point_get_y(const cj_point* p) { return p->y; }
int32_t cj_point_sum(const cj_point* p) { return p->x + p->y; }
void cj_point_destroy(cj_point* p) { free(p); }
四个函数各司其职:create 在 C 堆上 malloc 并初始化、get_x/get_y/sum 读字段、destroy 释放。仓颉侧永远不直接碰 cj_point 的内存,只通过这四个函数操作。
3.3 仓颉侧:CPointer 当句柄
Unit 对应 C 的 void,CPointer<Unit> 就是「指向未知类型的指针」——完美的不透明句柄类型:
package ffi20
foreign func cj_point_create(x: Int32, y: Int32): CPointer<Unit>
foreign func cj_point_get_x(p: CPointer<Unit>): Int32
foreign func cj_point_get_y(p: CPointer<Unit>): Int32
foreign func cj_point_sum(p: CPointer<Unit>): Int32
foreign func cj_point_destroy(p: CPointer<Unit>): Unit
main(): Int64 {
unsafe {
let p = cj_point_create(10, 32)
if (p.isNull()) {
println("create failed")
return 1
}
let x = cj_point_get_x(p)
let y = cj_point_get_y(p)
let s = cj_point_sum(p)
println("point = (${x}, ${y}), sum = ${s}")
cj_point_destroy(p)
}
return 0
}
编译运行(需本机 gcc,见第 19 课 2.2 节装 MinGW):
> cjpm build
> cjpm run
point = (10, 32), sum = 42
✅ 本示例已在 SDK 1.2.0 + 本机 gcc 下端到端运行通过,输出与上面一致。仓颉侧代码之外,需本机 gcc 把
csrc/point.c编进产物(CIDE 里点运行会自动完成);本机没装 gcc 时,链接阶段会报undefined symbol: cj_point_create。
3.4 把不透明句柄包成仓颉类
裸 unsafe 散在业务代码里是 code smell。按第 19 课第七节的封装原则,把所有 FFI 调用收进一个类,对外只暴露安全方法:
package ffi20
foreign func cj_point_create(x: Int32, y: Int32): CPointer<Unit>
foreign func cj_point_get_x(p: CPointer<Unit>): Int32
foreign func cj_point_get_y(p: CPointer<Unit>): Int32
foreign func cj_point_sum(p: CPointer<Unit>): Int32
foreign func cj_point_destroy(p: CPointer<Unit>): Unit
public class Point {
let handle: CPointer<Unit>
var freed: Bool = false
public init(x: Int32, y: Int32) {
handle = unsafe { cj_point_create(x, y) }
}
public func x(): Int32 { unsafe { cj_point_get_x(handle) } }
public func y(): Int32 { unsafe { cj_point_get_y(handle) } }
public func sum(): Int32 { unsafe { cj_point_sum(handle) } }
public func destroy() {
if (!freed) {
unsafe { cj_point_destroy(handle) }
freed = true
}
}
}
main(): Int64 {
let pt = Point(10, 32)
println("point = (${pt.x()}, ${pt.y()}), sum = ${pt.sum()}")
pt.destroy()
return 0
}
业务代码里一个 unsafe 都见不到,Point 用起来和普通仓颉类没区别。freed 标记防重复释放(double free 在 C 里是未定义行为)。这就是接 SQLite、libcurl 的标准套路——sqlite3*、CURL* 全用这个模式包。
⚠️ 仓颉的 GC 不会帮你调 C 侧的
free。destroy()必须由调用方显式调用,漏了就是内存泄漏。这和第 22 课要讲的对象池思路一致——跨语言边界的资源,生命周期自己管。
四、传数组给 C:首地址 + 长度
上一课末尾留的「传数组」坑,现在能填严谨了。C 函数要的是「首元素指针 + 元素个数」,仓颉侧用 LibC.malloc<T> 分配一块连续内存,把数组拷过去,再把首指针和长度传给 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;
}
仓颉侧:
package ffi20
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 buf = LibC.malloc<Int32>(count: Int64(scores.size))
for (i in 0..scores.size) {
(buf + Int64(i)).write(scores[i])
}
let r = cj_average(buf, Int32(scores.size))
LibC.free(buf)
r
}
println("average = ${avg}")
return 0
}
average = 82.400000
要点:
malloc<Int32>(count: scores.size)分配匹配长度的连续内存,count要Int64- 用
buf + i逐元素write,把仓颉Array的内容拷到 C 堆 cj_average拿到首指针和长度,按 C 数组语义访问free放在unsafe块返回值之前——这里用let r = ...; LibC.free(buf); r的写法,确保算完就释放,不泄漏。如果先return再free就漏了
这是「仓颉 Array → C 数组」的通用模板。反过来「C 数组 → 仓颉 Array」就是拿到
CPointer<T>和长度后,用(p + i).read()逐个读进Array。
五、cjpm.toml 编译链接配置
5.1 csrc 编译回顾
第 19 课讲过,csrc/ 下的 .c 文件在 CIDE 里点运行/构建时会自动被编译链接——这是 CIDE 的内置能力:它先调本机 gcc 把 csrc/*.c 编成 .obj,再注入 link-option 交给 cjpm 链接。注意裸跑命令行 cjpm build 不会编译 csrc/(实测会直接报 undefined symbol),脱离 CIDE 时要自己 gcc -c 后配 link-option。这一节讲的是接第三方现成库——你拿到一个编译好的 .lib / .dll / .so 和一堆头文件,不想(也没必要)从源码编它。
5.2 link-option:告诉链接器去哪找库
cjpm.toml 里 [package] 段有个 link-option 字段,传给底层链接器(ld.lld)。常见用法:
[package]
cjc-version = "1.2.0"
name = "ffi20"
output-type = "executable"
compile-option = ""
link-option = "-LC:/libs/sqlite -lsqlite3"
-L<目录>把目录加进链接器的库搜索路径-l<库名>链接名为lib<名>.a/<名>.lib/<名>.dll的库(前缀lib和后缀由平台决定)- 多个库空格分隔:
"-L./libs -lsqlite3 -lcurl"
Windows 上链接
.dll的导入库(.dll.a或.lib)时,把.dll.a所在目录用-L给出,再用-l指定库名(去掉lib前缀和.dll.a后缀)。链接产物的运行期还要能找到.dll本身(放 exe 同目录或加PATH)。
5.3 compile-option:给 cjc 传头文件搜索路径
如果第三方库给了头文件,你想在 foreign func 声明里 #include 它(仓颉的 foreign func 不需要头文件,但 csrc/ 里的 C 代码需要),用 compile-option 给 C 编译器加 -I:
[package]
compile-option = "-I C:/libs/sqlite/include"
link-option = "-LC:/libs/sqlite/lib -lsqlite3"
compile-option 传给仓颉编译器 cjc,link-option 传给链接器 ld.lld,两者各管一段。
5.4 一个完整的最小链接配置
假设你要调 SQLite,工程结构:
ffi20/
├── cjpm.toml
├── csrc/
│ └── bridge.c ← 你的胶水代码,#include <sqlite3.h>
└── src/
└── main.cj
cjpm.toml:
[package]
cjc-version = "1.2.0"
name = "ffi20"
output-type = "executable"
compile-option = "-I C:/libs/sqlite/include"
link-option = "-LC:/libs/sqlite/lib -lsqlite3"
csrc/bridge.c 里 #include <sqlite3.h> 就能找到头文件,链接时 -lsqlite3 把库接上。src/main.cj 里用 foreign func 声明你用到的那几个 SQLite 函数(sqlite3_open、sqlite3_exec、sqlite3_close),按不透明指针模式把 sqlite3* 当 CPointer<Unit> 拿着。
这一节讲的是配置语法,不涉及运行——具体库的接入要按该库的文档来,但「
-I给头文件路径、-L给库路径、-l给库名」这三板斧是通用的。
六、内存管理的四条铁律
跨语言边界的内存,GC 管不到。把这一课和上一课踩过的坑归成四条:
- 谁分配,谁释放。
LibC.malloc分配的,配对LibC.free;C 侧malloc的,调 C 侧的destroy/free函数。绝不能在仓颉侧LibC.free一个 C 侧malloc的指针(反之亦然)——两边堆不是同一个,跨堆 free 是未定义行为 - 不透明句柄必须显式 destroy。第三节的
Point类,用完必须调destroy()。仓颉 GC 回收仓颉对象时不会帮你调 C 的free - 指针算术按元素步长。
p + 1跳过一个T,不是一字节。心里想着字节偏移就会写错位置 - C 返回的指针先判空。
isNull()过一道再read,否则段错误
七、常见错误与避坑
| 现象 | 原因 | 处理 |
|---|---|---|
链接报 undefined symbol: cj_xxx | csrc/ 里 C 文件没被编译——本机没装 gcc、函数名两边不一致,或脱离 CIDE 裸跑 cjpm build(cjpm 本身不编 C 源) | gcc --version 验证;对照 C 源码和 foreign func 拼写;在 CIDE 里构建,或手动 gcc -c 后配 link-option |
运行报 Divided by zero 或崩溃在 malloc | 用 malloc<Unit>(count: n) 分配——Unit 大小为 0 | 不透明句柄不要在仓颉侧 malloc,让 C 侧 create 返回 |
read() 拿到垃圾值或段错误 | 指针未初始化就读;或对空指针 read | malloc 后先 write;C 返回的指针先 isNull() 判空 |
| 数组传给 C 后数据错位 | 指针算术按字节而非元素步长算 | 用 p + i(元素步长),不要手动按字节偏移 |
链接第三方库报 cannot find -lxxx | link-option 里 -L 路径不对,或库名拼错 | 确认 .lib/.dll.a 真在 -L 目录,库名去掉 lib 前缀和后缀 |
| 程序退出后内存持续涨 | malloc/create 后漏了 free/destroy | 检查每一对分配/释放是否配对,尤其提前 return 的分支 |
上一课的 reinterpretCast 编译报 undeclared identifier | 1.2.0 上 CPointer 没有 reinterpretCast 方法 | 用 LibC.malloc<T> 直接按目标类型分配,避免类型转换 |
八、常用 API 速查
| 仓颉侧 | 作用 |
|---|---|
LibC.malloc<T>(count: Int64): CPointer<T> | 在 C 堆分配 count 个 T,返回首元素指针 |
LibC.free(p: CPointer<T>): Unit | 释放 malloc 分配的内存 |
p.read(): T | 解引用读 *p |
p.write(v: T): Unit | 解引用写 *p = v |
p + n | 指针算术,指向第 n 个元素(步长 sizeof(T)) |
p.isNull(): Bool | 判空 |
CPointer<Unit> | 不透明句柄,对应 C 的 void* |
LibC.mallocCString(s): CString | 仓颉 String → C 堆 CString(第 19 课) |
foreign func f(p: CPointer<T>): R | 声明一个参数带指针的 C 函数 |
link-option = "-L<dir> -l<lib>" | cjpm.toml 里链接第三方库 |
compile-option = "-I<dir>" | 给 C 编译器加头文件搜索路径 |
九、课后练习
- 指针读写(不需 gcc):用
LibC.malloc<Int64>(count: 5)分配 5 个Int64,写入1, 2, 3, 4, 5,再用read()和指针算术求和打印15,最后free。 - 判空(不需 gcc):写一个函数
safeRead(p: CPointer<Int32>): Option<Int32>,p.isNull()为真返回None,否则Some(p.read())。在main里分别用正常指针和分配失败的场景测试——申请一块巨大到必然失败的内存(如LibC.malloc<Int64>(count: 100000000000000000))会返回空指针,而count: 0不会返回空指针(实测isNull()为false),别用这个当失败用例。 - 不透明句柄封装(需 gcc):按第三节的模式,C 侧定义一个
cj_rect { int32_t w, h; }和create/get_w/get_h/area/destroy五个函数,仓颉侧包成Rect类,main里创建Rect(3, 4)打印面积12后destroy。 - 思考题:第四节传数组给 C 时,为什么必须先
malloc一块 C 堆内存拷过去,而不能直接把仓颉Array的内部指针传给 C?(提示:GC 可能移动仓颉对象;Array 的内存布局不保证连续裸露给 C。)
下节预告
这两课把 FFI 的基础和进阶都走完了——数字、字符串、指针、结构体、链接配置都能玩转,你已经具备接 SQLite、libcurl 这类真实 C 库的能力。下一课换回仓颉自身的安全侧,讲并发容器:多线程同时读写一个 HashMap 会崩,std.collection.concurrent 里的 ConcurrentHashMap 怎么保证线程安全、Mutex 保护的集合和原生并发容器性能差多少。并发篇的硬仗,从这里开始。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写。本文中所有代码(含自写 C 源码的结构体、数组示例)均在 SDK 1.2.0 + 本机 gcc 环境下端到端编译运行通过。如遇 SDK 版本差异导致的 API 出入,以你本地版本为准,欢迎评论区交流。
💬 遇到问题?扫码联系作者
跟着课程练习时,如果在 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
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_41618057/article/details/169565222




