1024奇点头像
【仓颉语言入门 · 第二篇 第 20 课】封面图

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

【仓颉语言入门 · 第二篇 第 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~26HTTP 基础、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. 实战六:简易爬虫

一、从上一课的「示意」说起

第 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 = 10
  • p + 1 是指针算术,步长按 sizeof(Int32) = 4 自动计算,指向下一个元素,等价于 C 的 p + 1(不是 p + 1 字节)。这是 CPointer<T> 和裸字节指针最大的区别——类型信息带在指针上,算术自动按元素步长走
  • p.read() 等价于 C 的 *p
  • LibC.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 管不到。把这一课和上一课踩过的坑归成四条:

  1. 谁分配,谁释放。LibC.malloc 分配的,配对 LibC.free;C 侧 malloc 的,调 C 侧的 destroy/free 函数。绝不能在仓颉侧 LibC.free 一个 C 侧 malloc 的指针(反之亦然)——两边堆不是同一个,跨堆 free 是未定义行为
  2. 不透明句柄必须显式 destroy。第三节的 Point 类,用完必须调 destroy()。仓颉 GC 回收仓颉对象时不会帮你调 C 的 free
  3. 指针算术按元素步长。p + 1 跳过一个 T,不是一字节。心里想着字节偏移就会写错位置
  4. C 返回的指针先判空。isNull() 过一道再 read,否则段错误

七、常见错误与避坑

现象原因处理
链接报 undefined symbol: cj_xxxcsrc/ 里 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() 拿到垃圾值或段错误指针未初始化就读;或对空指针 readmalloc 后先 write;C 返回的指针先 isNull() 判空
数组传给 C 后数据错位指针算术按字节而非元素步长算用 p + i(元素步长),不要手动按字节偏移
链接第三方库报 cannot find -lxxxlink-option 里 -L 路径不对,或库名拼错确认 .lib/.dll.a 真在 -L 目录,库名去掉 lib 前缀和后缀
程序退出后内存持续涨malloc/create 后漏了 free/destroy检查每一对分配/释放是否配对,尤其提前 return 的分支
上一课的 reinterpretCast 编译报 undeclared identifier1.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 编译器加头文件搜索路径

九、课后练习

  1. 指针读写(不需 gcc):用 LibC.malloc<Int64>(count: 5) 分配 5 个 Int64,写入 1, 2, 3, 4, 5,再用 read() 和指针算术求和打印 15,最后 free。
  2. 判空(不需 gcc):写一个函数 safeRead(p: CPointer<Int32>): Option<Int32>,p.isNull() 为真返回 None,否则 Some(p.read())。在 main 里分别用正常指针和分配失败的场景测试——申请一块巨大到必然失败的内存(如 LibC.malloc<Int64>(count: 100000000000000000))会返回空指针,而 count: 0 不会返回空指针(实测 isNull() 为 false),别用这个当失败用例。
  3. 不透明句柄封装(需 gcc):按第三节的模式,C 侧定义一个 cj_rect { int32_t w, h; } 和 create/get_w/get_h/area/destroy 五个函数,仓颉侧包成 Rect 类,main 里创建 Rect(3, 4) 打印面积 12 后 destroy。
  4. 思考题:第四节传数组给 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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--