【仓颉语言入门 · 第二篇 第 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 服务端、多线程日志分析器、简易爬虫 |
- 正则表达式(std.regex)
- 时间日期处理(std.time)
- 命令行参数解析(std.argopt)
- 随机数与概率(std.random)
- 加密与哈希(std.crypto)
- 二进制数据处理(本文)
- 数据库入门:std.database.sql 与 CRUD
- 预编译、事务与防注入
- 手写 DAO 数据访问层
- 数据库综合小实战
- 反射:在运行时打量类型
- 宏与 deriving:让编译器帮你写代码
- 与 C 互操作:FFI
- 并发容器与对象池
- 实战三:HTTP 服务端
- 实战四:多线程日志分析器
- 实战五:简易爬虫
一、为什么需要"二进制处理"
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