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

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

上一课哈希把字节"搅碎"成指纹,用了异或、移位、与或非------这些位运算只是操作单个数值内部的比特 。这一课的 std.binary 管的是另一件事:一个数值在内存/文件里到底占几个字节、这些字节怎么排 。为什么同一张 BMP 图片,Windows 和 Mac 读出来一样?为什么网络协议里写"先传高字节"?答案是同一个词------字节序(Endianness) 。本课把 std.binary 的三个接口(大端、小端、字节反转)讲透,最后动手解析一张 BMP 图片的文件头,看看真实世界的二进制格式长什么样。
本文所有代码、输出与报错文案均在仓颉 SDK 1.2.0 下逐行实测编译运行。


目录(第二篇导航)

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

模块 课次 内容
一、补遗篇 01~06 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理
二、数据库篇 07~10 std.database.sql 入门与 CRUD、预编译与事务、手写 DAO、综合小实战
三、进阶硬核篇 11~14 反射、宏与 deriving、C 互操作 FFI、并发容器与对象池
四、实战项目篇 15~17 HTTP 服务端、多线程日志分析器、简易爬虫
  1. 正则表达式(std.regex)
  2. 时间日期处理(std.time)
  3. 命令行参数解析(std.argopt)
  4. 随机数与概率(std.random)
  5. 加密与哈希(std.crypto)
  6. 二进制数据处理(本文)
  7. 数据库入门:std.database.sql 与 CRUD
  8. 预编译、事务与防注入
  9. 手写 DAO 数据访问层
  10. 数据库综合小实战
  11. 反射:在运行时打量类型
  12. 宏与 deriving:让编译器帮你写代码
  13. 与 C 互操作:FFI
  14. 并发容器与对象池
  15. 实战三:HTTP 服务端
  16. 实战四:多线程日志分析器
  17. 实战五:简易爬虫

一、为什么需要"二进制处理"

1.1 一个数占几个字节

仓颉里 Int32 是 32 位整数------也就是 4 个字节。把 0x12345678 这个数存进文件或发过网络,物理上就是 4 个字节依次排开。但哪 4 个字节、什么顺序?

0x12345678 拆成 4 个字节:0x12、0x34、0x56、0x78。其中 0x12 是最高位字节(MSB,Most Significant Byte),0x78 是最低位字节(LSB)。写进内存时,谁放低地址、谁放高地址?两种主流约定:

字节序 低地址 → 高地址 谁先用
大端序(Big-Endian) 高位在前:12 34 56 78 网络协议(TCP/IP)、Java、PowerPC
小端序(Little-Endian) 低位在前:78 56 34 12 x86/x64 CPU、Windows、ARM(多数模式)

名字来自《格列佛游记》里吃鸡蛋先敲大头还是小头的荒唐争论------没有谁对谁错,只是约定。麻烦在于:不同约定的系统之间传数据,必须先讲清楚用哪种字节序,否则同一段字节读出来是完全不同的数。

1.2 不处理字节序会怎样

假设你用 x86 电脑(小端)把 0x12345678 写进文件,字节是 78 56 34 12。另一个程序(或另一台机器)按大端序读回来,得到的是 0x78563412------同一个文件,两个数。这就是所有跨平台二进制格式(图片、视频、网络包)必须在规范里写死字节序的原因。

std.binary 就是管这件事的:给你一套统一接口,显式指定按大端还是小端读写,把"约定"写进代码而不是靠默契。

1.3 先认人:std.binary 的家底

std.binary 只提供三个接口,各司其职:

接口 作用 关键方法
BigEndianOrder<T> 大端序转换 readBigEndian / writeBigEndian
LittleEndianOrder<T> 小端序转换 readLittleEndian / writeLittleEndian
SwapEndianOrder<T> 翻转字节顺序 swapBytes

注意它的设计思路:不是给你一个 ByteBuffer 类 (那在 std.io 里,第 5 课用过),而是用接口扩展 的方式,直接给所有数值类型装上"读/写字节序列"的能力。Int32、Float64、Bool......统统自带 writeBigEndian。这种"用接口扩展内建类型"的写法本身就值得学。


二、大端序:BigEndianOrder

2.1 接口形状

text 复制代码
public interface BigEndianOrder<T> {
    func writeBigEndian(buffer: Array<Byte>): Int64
    static func readBigEndian(buffer: Array<Byte>): T
}
  • writeBigEndian:把当前值按大端序写进 buffer,返回实际写入的字节数(Int64 写 8 字节,Int16 写 2 字节)。
  • readBigEndian:从 buffer 开头按大端序读出一个 T 值。

已实现的类型:Bool、Int8/16/32/64、UInt8/16/32/64、Float16/32/64 。import std.binary.* 后直接可用。

2.2 写:值 → 字节数组

cangjie 复制代码
import std.binary.*

main(): Int64 {
    let buf = Array<Byte>(8, repeat: 0)   // 分配 8 字节缓冲区(桌面 SDK 用 repeat:)
    let n = 0x1234i16.writeBigEndian(buf)
    println("写入字节数: ${n}")
    println("字节内容: ${buf[..n]}")
    0
}
text 复制代码
写入字节数: 2
字节内容: [18, 52]

0x1234 = 十进制 4660,拆成大端序两个字节就是 0x12(18)、0x34(52)------高位在前,和你脑子里读十六进制的顺序一致。

坑 :官方在线文档(鸿蒙 V5 版)里数组分配写的是 Array<Byte>(8, item: 0),桌面版 SDK 1.2.0 不支持 item: 命名参数 ,实测报错 unknown named argument prefix 'item:'。桌面版统一用 repeat::Array<Byte>(8, repeat: 0)。本文所有代码均用桌面版写法。

2.3 读:字节数组 → 值

cangjie 复制代码
let buf2: Array<Byte> = [0x12, 0x34]
let v = Int16.readBigEndian(buf2)
println("读回的值: ${v}")
text 复制代码
读回的值: 4660

readBigEndian 是静态方法 ,挂在类型上调用(Int16.readBigEndian(...)),不是在实例上。写出来的 [18, 52] 原样读回 4660,往返一致。

2.4 多字节类型:Int64 与 Float64

Int64 占 8 字节,Float64 也占 8 字节,看大端序怎么排:

cangjie 复制代码
let val = 0x0102030405060708i64
let beBuf = Array<Byte>(8, repeat: 0)
val.writeBigEndian(beBuf)
println("Int64 BE: ${beBuf}")

let f = 12.5f64
let fBuf = Array<Byte>(8, repeat: 0)
f.writeBigEndian(fBuf)
println("Float64 BE: ${fBuf}")
println("Float64 读回: ${Float64.readBigEndian(fBuf)}")
text 复制代码
Int64 BE: [1, 2, 3, 4, 5, 6, 7, 8]
Float64 BE: [64, 41, 0, 0, 0, 0, 0, 0]
Float64 读回: 12.500000

Int64 的字节 01 02 03 04 05 06 07 08 一眼就能看出"高位在前"。Float64 的 64 41 00 00 00 00 00 00 是 IEEE 754 双精度浮点的大端编码------人脑算不出来没关系,只要读写用同一种字节序,就能无损还原,这就是接口的价值。

2.5 缓冲区不够会怎样

Int32 需要 4 字节,只给 2 字节的缓冲区,读写都会抛异常(实测原文):

cangjie 复制代码
let small: Array<Byte> = [0x12, 0x34]
Int32.readBigEndian(small)
text 复制代码
Exception in thread "main" java.lang.IllegalArgumentException: Buffer size(2) is too small.

写也是一样的报错:Buffer size(2) is too small.。所以分配缓冲区时,大小要 ≥ 目标类型的字节数(Int32→4,Int64→8,Float32→4......)。嫌算麻烦就直接开 8 字节,用 buf[..n] 切出实际写入的部分。


三、小端序:LittleEndianOrder

3.1 接口形状

和大端序一模一样,只是方法名换了:

text 复制代码
public interface LittleEndianOrder<T> {
    func writeLittleEndian(buffer: Array<Byte>): Int64
    static func readLittleEndian(buffer: Array<Byte>): T
}

实现的类型也完全相同:Bool、Int8/16/32/64、UInt8/16/32/64、Float16/32/64。

3.2 小端序写入:低位在前

cangjie 复制代码
let buf = Array<Byte>(8, repeat: 0)
let n = 0x12345678i32.writeLittleEndian(buf)
println("Int32 LE 写入字节: ${buf[..n]}")
text 复制代码
Int32 LE 写入字节: [120, 86, 52, 18]

0x12345678 的 4 个字节按小端序排列:0x78(120)、0x56(86)、0x34(52)、0x12(18)------低位在前,正好和大端序反过来。

读回来验证:

cangjie 复制代码
let buf2: Array<Byte> = [0x78, 0x56, 0x34, 0x12]
let v = Int32.readLittleEndian(buf2)
println("读回: ${v}")
text 复制代码
读回: 305419896

305419896 就是 0x12345678。读写配对,顺序一致,值就对。

3.3 大端 vs 小端:同一张图

把同一个 Int64 值分别用大端、小端写出来,对比一目了然:

cangjie 复制代码
let val = 0x0102030405060708i64
let beBuf = Array<Byte>(8, repeat: 0)
val.writeBigEndian(beBuf)
let leBuf = Array<Byte>(8, repeat: 0)
val.writeLittleEndian(leBuf)
println("大端: ${beBuf}")
println("小端: ${leBuf}")
text 复制代码
大端: [1, 2, 3, 4, 5, 6, 7, 8]
小端: [8, 7, 6, 5, 4, 3, 2, 1]

同样 8 个字节,顺序完全相反。这就是"字节序"这三个字的全部含义。


四、翻转字节序:SwapEndianOrder

4.1 接口与用途

text 复制代码
public interface SwapEndianOrder<T> {
    func swapBytes(): T
}

swapBytes() 返回一个字节顺序完全翻转 的新值。实现的类型:Int8/16/32/64、UInt8/16/32/64(浮点和 Bool 没有------翻转浮点字节没有意义)。

它的典型用途:从文件里读出来后才发现字节序不对 ,就地翻转一下。比如某格式文档写的是大端序,你误用了小端序的 readLittleEndian,得到一个乱码数值,swapBytes() 一下就救回来了------等价于换一种读法重算。

4.2 实测

cangjie 复制代码
let val = 0x0102030405060708i64
let swapped = val.swapBytes()
let swapBuf = Array<Byte>(8, repeat: 0)
swapped.writeBigEndian(swapBuf)
println("翻转后大端字节: ${swapBuf}")
text 复制代码
翻转后大端字节: [8, 7, 6, 5, 4, 3, 2, 1]

原值大端是 [1,2,3,4,5,6,7,8],swapBytes() 后再按大端写出就是 [8,7,6,5,4,3,2,1]------正好等于原值的小端写法。换句话说:val.swapBytes() 的大端表示 == val 的小端表示。

UInt 类型也一样:

cangjie 复制代码
let u = 0x1234u16
println("0x1234 翻转后: ${u.swapBytes()}")
text 复制代码
0x1234 翻转后: 13330

0x1234 翻转字节得到 0x3412 = 13330。


五、实战:解析 BMP 图片文件头

光看接口太干,来个真实格式。BMP 是 Windows 原生图片格式,文件头全部用小端序 (因为 Windows 跑在 x86 上)。我们用 LittleEndianOrder 把一个 BMP 文件头拆开看看。

5.1 BMP 文件头结构

BMP 文件头(14 字节)+ DIB 头(40 字节,BITMAPINFOHEADER)的关键字段:

偏移 长度 字段 类型 说明
0 2 signature 字符 "BM" 魔数
2 4 fileSize UInt32 LE 文件总字节数
6 4 reserved --- 保留,通常 0
10 4 dataOffset UInt32 LE 像素数据起始偏移
14 4 dibHeaderSize UInt32 LE DIB 头大小(40)
18 4 width Int32 LE 图像宽度(像素)
22 4 height Int32 LE 图像高度(像素)
26 2 planes UInt16 LE 颜色平面数(1)
28 2 bitsPerPixel UInt16 LE 每像素位数(24=真彩色)

5.2 构造 + 解析

为了不依赖外部图片文件,我们在代码里手动构造一个 54 字节的 BMP 头,再用 LittleEndianOrder 逐字段读出来------写和读都练到:

cangjie 复制代码
import std.binary.*

func hexOf(bytes: Array<Byte>): String {
    let hex = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"]
    var s = ""
    for (b in bytes) {
        s = s + hex[Int64(b >> 4)] + hex[Int64(b & 0xF)]
    }
    return s
}

main(): Int64 {
    // 构造 BMP 文件头(54 字节)
    let bmp = Array<Byte>(54, repeat: 0)
    bmp[0] = 0x42   // 'B'
    bmp[1] = 0x4D   // 'M'
    54u32.writeLittleEndian(bmp[2..6])       // fileSize
    54u32.writeLittleEndian(bmp[10..14])     // dataOffset
    40u32.writeLittleEndian(bmp[14..18])     // dibHeaderSize
    800i32.writeLittleEndian(bmp[18..22])    // width
    600i32.writeLittleEndian(bmp[22..26])    // height
    1u16.writeLittleEndian(bmp[26..28])      // planes
    24u16.writeLittleEndian(bmp[28..30])     // bitsPerPixel

    println("原始头 (前 30 字节): ${hexOf(bmp[..30])}")

    // 解析
    let sig = String.fromUtf8(bmp[0..2])
    let fileSize = UInt32.readLittleEndian(bmp[2..6])
    let dataOffset = UInt32.readLittleEndian(bmp[10..14])
    let dibSize = UInt32.readLittleEndian(bmp[14..18])
    let width = Int32.readLittleEndian(bmp[18..22])
    let height = Int32.readLittleEndian(bmp[22..26])
    let bpp = UInt16.readLittleEndian(bmp[28..30])

    println("签名: ${sig}")
    println("文件大小: ${fileSize} 字节")
    println("像素数据偏移: ${dataOffset}")
    println("DIB 头大小: ${dibSize}")
    println("宽度: ${width} px")
    println("高度: ${height} px")
    println("色深: ${bpp} bpp")
    0
}
text 复制代码
原始头 (前 30 字节): 424d36000000000000003600000028000000200300005802000001001800
签名: BM
文件大小: 54 字节
像素数据偏移: 54
DIB 头大小: 40
宽度: 800 px
高度: 600 px
色深: 24 bpp

几个要点:

  • 切片直接当缓冲区 :bmp[2..6] 是从第 2 字节开始、长度 4 的切片,writeLittleEndian / readLittleEndian 直接吃切片,不用额外拷数组。这是处理"固定偏移格式"最顺手的写法。
  • 魔数用字符串读 :前两字节 0x42 0x4D 是 ASCII 的 BM,用 String.fromUtf8 还原成人话。
  • 宽度 800 的小端字节是 20 03 00 00 :0x00000320 = 800,小端序低位在前 → 20 03 00 00。看输出第三行 ...2003000058020000...,20030000 是宽 800,58020000 是高 600(0x00000258 = 600)。

真实读 .bmp 文件时,把构造那段换成 File.readFrom(path) 读到 bmp 数组即可,解析代码完全不变。


六、常用 API 速查

API 说明
val.writeBigEndian(buf) 把 val 按大端序写入 buf,返回写入字节数
Type.readBigEndian(buf) 从 buf 按大端序读出一个 Type 值(静态方法)
val.writeLittleEndian(buf) 按小端序写入,返回写入字节数
Type.readLittleEndian(buf) 按小端序读出(静态方法)
val.swapBytes() 返回字节顺序翻转后的新值(仅整数类型)
Array<Byte>(N, repeat: 0) 分配 N 字节零填充缓冲区(桌面 SDK 用 repeat:)

一张表分清三个接口:

接口 方向 实现的类型
BigEndianOrder<T> 高位在前 Bool, Int8/16/32/64, UInt8/16/32/64, Float16/32/64
LittleEndianOrder<T> 低位在前 同上
SwapEndianOrder<T> 翻转 Int8/16/32/64, UInt8/16/32/64(无浮点、无 Bool)

七、常见问题 FAQ

Q1:writeBigEndian 返回的 Int64 是干嘛的?为什么不直接返回字节数组? 返回的是实际写入的字节数 。接口设计成"调用方管缓冲区"------你自己开数组、自己决定大小,方法只负责往里写并告诉你写了几个字节。好处是可以复用同一个大缓冲区连续写多个字段(像 BMP 头那样),不用反复分配。要取实际写入的部分,用 buf[..n] 切片。

Q2:readBigEndian 为什么是静态方法(Int32.readBigEndian(...))而不是实例方法? 因为读之前你手里还没有 Int32 实例------读的目的就是造出一个实例 。所以它必须挂在类型上作为静态方法。写则相反:你已经有一个值了,在值上调 writeBigEndian。一读一写,一个静态一个实例,对称得很。

Q3:为什么 swapBytes() 没有 Float64/Float32 版本? 翻转浮点数的字节在数学上没有意义------IEEE 754 浮点的符号位、指数、尾数是按位划分的,翻转字节会把它们全打乱,得到的是一个几乎随机的浮点数,没有应用场景。所以 SwapEndianOrder 只给整数类型实现。

Q4:缓冲区太小的报错文案是什么?怎么避免? 实测报错:Buffer size(N) is too small.(N 是你给的实际大小)。避免方法:分配缓冲区时大小 ≥ 类型字节数(Int8/UInt8→1,Int16/UInt16→2,Int32/UInt32/Float32→4,Int64/UInt64/Float64→8)。图省事就统一开 8 字节。

Q5:官方文档里 Array<Byte>(8, item: 0) 为什么我这里编译不过? 官方在线文档是鸿蒙(OpenHarmony)版 SDK 的写法,桌面版 SDK 1.2.0 的数组构造命名参数是 repeat: 不是 item:。写 Array<Byte>(8, item: 0) 实测报 unknown named argument prefix 'item:'。桌面版统一用 Array<Byte>(8, repeat: 0)。

Q6:std.binary 有没有 Base64、十六进制编码之类的功能? 1.2.0 的 std.binary 只做字节序转换 (大端/小端/翻转),没有 Base64、Hex 编码。第 5 课的 toHex 是我们手写的,十六进制编码目前需要自己实现或找第三方库。Base64 同理。


八、课后练习

规矩不变:前 4 题必做,第 5 题选做。每题只考本课 1~2 个知识点,题面给全函数签名和 API 提示,先看懂题再动笔。

第 1 题(必做)大端读整数

用 Int32.readBigEndian 从字节数组 [0x00, 0x00, 0x01, 0xF4] 读出一个 Int32 值并打印。

提示:直接 Int32.readBigEndian(buf);0x000001F4 = 500。

cangjie 复制代码
import std.binary.*

main(): Int64 {
    let buf: Array<Byte> = [0x00, 0x00, 0x01, 0xF4]
    // 补全:读出并打印
    0
}

期望输出:

text 复制代码
500

第 2 题(必做)小端写 Int16

把 0x0100i16 按小端序写入 2 字节缓冲区,打印写入的字节内容。

提示:writeLittleEndian 返回写入字节数;0x0100 小端序是 00 01(低位 0x00 在前)。

cangjie 复制代码
import std.binary.*

main(): Int64 {
    let buf = Array<Byte>(2, repeat: 0)
    // 补全:写入并打印 buf 内容
    0
}

期望输出:

text 复制代码
[0, 1]

第 3 题(必做)翻转字节序

对 0x12345678u32 调用 swapBytes(),打印结果的十进制值。

提示:0x12345678 翻转后是 0x78563412;直接 println(val.swapBytes())。

cangjie 复制代码
import std.binary.*

main(): Int64 {
    let v = 0x12345678u32
    // 补全:打印翻转后的值
    0
}

期望输出:

text 复制代码
2018915346

第 4 题(必做)大端读 Float32

字节数组 [0x41, 0x48, 0x00, 0x00] 是 12.5f32 的大端编码。用 Float32.readBigEndian 读出来并打印。

提示:Float32 也是 BigEndianOrder 的实现类型,调用方式和整数一样。

cangjie 复制代码
import std.binary.*

main(): Int64 {
    let buf: Array<Byte> = [0x41, 0x48, 0x00, 0x00]
    // 补全:读出 Float32 并打印
    0
}

期望输出:

text 复制代码
12.500000

第 5 题(选做)小端读 BMP 宽度

给定一个 22 字节的 BMP 头片段,用小端序读出其中的宽度字段(偏移 18,长度 4,Int32)。

提示:宽度在偏移 18 处,用切片 buf[18..22] 读;Int32.readLittleEndian。

cangjie 复制代码
import std.binary.*

main(): Int64 {
    // 一个 BMP 头片段:宽 1024(0x00000400),高 768(0x00000300)
    let buf: Array<Byte> = [
        0x42, 0x4D, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00,
        0x00, 0x00, 0x00, 0x04, 0x00, 0x00
    ]
    // 补全:读出宽度并打印
    0
}

期望输出:

text 复制代码
1024

下节预告

字节序解决了"数值怎么变成字节"的问题,但真实世界的数据格式远不止整数------数据库里一行行的结构化记录,怎么存、怎么查、怎么改?下一课进入数据库篇 ,从 std.database.sql 开始,把"数据持久化"从文件升级到真正的数据库。


系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。


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

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

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


📥 工具下载

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

  • GitCode 仓库 / 安装包下载 :gitcode.com/wp_upala/ci...
  • 打开页面后进入 发行版(Releases) ,两种包任选其一:
    • 安装版 :下载 CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用;
    • 免安装版(Portable) :下载 CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
  • 仓颉 SDK 请前往仓颉编程语言官网下载:cangjie-lang.cn
相关推荐
罗丹丹Luna4 小时前
移动端显示“已提交”以后:SAP 与低代码集成该怎样暴露真实状态
后端·架构
码事漫谈4 小时前
因为太想讨好你,所以AI会骗你
后端
吴凡wudianmei4 小时前
每调一次模型都花钱?如何用工程手段把调用压到最少?
前端·后端·架构
花间相见4 小时前
【计算基础|MySQL01】【上手】安装连接、数据类型与建库建表
数据库·后端
花间相见4 小时前
【计算基础|MySQL02】【上手】SQL 增删改查
数据库·后端
姜鱼问生5 小时前
密码明明一样,为什么 GPG 说“错的“?
后端·算法·架构
.Bryce.5 小时前
在智能体中使用A2A工具:让大模型自己决定“该找谁帮忙“
android·java·服务器·redis·后端·agent·a2a
xiaoqiMikko5 小时前
fastjson 1.2.84 的补丁关掉了一个缓存:addAccept 包前缀白名单下,反序列化慢了 13~28 倍
java·后端·性能优化
崎岖Qiu5 小时前
MCP 的架构、协议分层与通信过程
后端·ai·架构·agent·mcp