在串口、TCP 和蓝牙设备通信中,上位机收到的往往是一段二进制数据。要把这些数据变成温度、设备状态或者寄存器数值,通常要先完成几件基础工作:
- 将调试窗口输入的十六进制字符串转换成字节数组。
- 将收到的字节转换成十六进制文本,方便记录日志。
- 按照协议规定的大小端读取整数。
- 计算校验码,检查数据是否完整、正确。
这些操作单独看都不复杂,但如果散落在各个业务函数中,很容易出现重复代码和边界处理不一致的问题。
本文使用 C 语言封装几个常用函数。代码采用 C99 及以上语法,不依赖第三方库,也不在函数内部申请动态内存,方便用于上位机项目或嵌入式程序。
一、先约定接口的使用方式
C 语言处理缓冲区时,不能只传入一个指针,还要明确它对应的长度或者容量。
例如,字符串转换函数需要知道输出数组能放下多少字节;读取整数时,需要知道输入数据是否足够。
下面几个函数统一使用这些头文件:
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
其中:
uint8_t用于表示一个字节。uint16_t用于表示 16 位无符号整数。size_t用于表示长度、容量和偏移量。- 需要检查输入或容量的函数,通过
bool返回是否成功。
输入、输出缓冲区应分别提供,不要让它们重叠。
二、十六进制字符串转换成字节数组
调试设备时,经常会在输入框中填写这样的指令:
01 03 00 00 00 02
这里的 01 表示一个值为 0x01 的字节,不能直接把字符 '0' 和 '1' 发给设备。
先实现一个辅助函数,把单个十六进制字符转换成数值:
static int hex_value(char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
static bool is_separator(char c)
{
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
}
转换函数如下:
bool hex_to_bytes(const char *text, uint8_t *out,
size_t capacity, size_t *out_len)
{
if (!out_len) return false;
*out_len = 0;
if (!text || !out) return false;
/* 先检查格式和容量,避免出错时只转换了一半。 */
size_t digits = 0;
for (const char *p = text; *p; ++p) {
if (is_separator(*p)) continue;
if (hex_value(*p) < 0) return false;
++digits;
}
if (digits % 2 != 0 || digits / 2 > capacity)
return false;
int high = -1;
for (const char *p = text; *p; ++p) {
if (is_separator(*p)) continue;
int value = hex_value(*p);
if (high < 0) {
high = value;
} else {
out[(*out_len)++] = (uint8_t)((high << 4) | value);
high = -1;
}
}
return true;
}
这里分两次遍历:第一次检查,第二次转换。对于调试窗口输入的短指令,这一点遍历开销通常可以接受,也能让失败时的行为更明确。
调用示例:
uint8_t data[32];
size_t length = 0;
if (hex_to_bytes("AA 55 01 00", data, sizeof(data), &length)) {
printf("转换成功,共 %zu 字节\n", length);
} else {
printf("格式错误或缓冲区容量不足\n");
}
这个版本支持大小写字母,并忽略空格、制表符和换行。输入必须是以 '\0' 结尾的字符串。
为了保持规则简单,暂不支持 0xAA、AA-BB 等形式。需要兼容时,应明确补充分隔规则,不要把所有无法识别的字符都直接跳过,否则输错的指令也可能被当成合法数据发送。
三、字节数组转换成十六进制字符串
收到设备数据后,直接按字符串打印通常没有意义。二进制数据中可能包含 0x00,也可能包含无法显示的字符。
更合适的日志形式是:
AA 55 01 00
可以使用下面的函数:
bool bytes_to_hex(const uint8_t *data, size_t len,
char *out, size_t capacity)
{
static const char hex[] = "0123456789ABCDEF";
if (!out || capacity == 0) return false;
out[0] = '\0';
if ((!data && len != 0) || len > capacity / 3)
return false;
size_t pos = 0;
for (size_t i = 0; i < len; ++i) {
if (i != 0) out[pos++] = ' ';
out[pos++] = hex[data[i] >> 4];
out[pos++] = hex[data[i] & 0x0F];
}
out[pos] = '\0';
return true;
}
当输入包含 n 个字节且 n > 0 时,输出需要:
2n 个十六进制字符 + n - 1 个空格 + 1 个字符串结束符
= 3n 字节
因此,4 个字节对应的输出数组至少需要 12 字节:
uint8_t data[] = {0xAA, 0x55, 0x01, 0x00};
char text[sizeof(data) * 3];
if (bytes_to_hex(data, sizeof(data), text, sizeof(text))) {
printf("收到数据:%s\n", text);
}
容量检查使用 len > capacity / 3,没有直接计算 len * 3,可以避免长度乘法溢出的问题。
四、按大端和小端读取整数
设备协议中的两个字节,可能表示一个 16 位整数。但具体数值是什么,还要看字节序。
例如:
00 02
按照大端读取,结果是 2;按照小端读取,结果则是 512。
- 大端:高位字节在前。
- 小端:低位字节在前。
不要为了省事,直接把字节指针强制转换成 uint16_t *。这种写法会依赖当前机器的字节序,还可能涉及内存对齐和别名访问问题。
显式组合字节更直观:
bool read_u16_be(const uint8_t *data, size_t len,
size_t offset, uint16_t *value)
{
if (!data || !value || offset > len || len - offset < 2)
return false;
*value = (uint16_t)(((uint16_t)data[offset] << 8) |
data[offset + 1]);
return true;
}
bool read_u16_le(const uint8_t *data, size_t len,
size_t offset, uint16_t *value)
{
if (!data || !value || offset > len || len - offset < 2)
return false;
*value = (uint16_t)((uint16_t)data[offset] |
((uint16_t)data[offset + 1] << 8));
return true;
}
这里先判断 offset > len,再计算 len - offset,避免无符号减法下溢;同时也不需要计算可能溢出的 offset + 2。
调用时:
uint8_t data[] = {0x00, 0x02, 0x02, 0x00};
uint16_t first;
uint16_t second;
if (read_u16_be(data, sizeof(data), 0, &first) &&
read_u16_le(data, sizeof(data), 2, &second)) {
printf("%u, %u\n", (unsigned)first, (unsigned)second);
}
输出为:
2, 2
后续如果需要读取 32 位整数,也可以沿用相同方式。先检查剩余长度,再转换为 uint32_t 后移位组合。
五、实现 CRC-16/MODBUS 校验
不少设备协议会在数据末尾附加 CRC 校验码,但协议文档里只写“CRC16”还不够。
CRC 有多种变体,初始值、多项式、位处理方向和最终异或值不同,计算结果也会不同。
这里以 CRC-16/MODBUS 为例:
| 参数 | 取值 |
|---|---|
| 初始值 | 0xFFFF |
| 多项式 | 0x8005,右移实现使用反射形式 0xA001 |
| 位处理方向 | 最低位优先 |
| 最终异或值 | 0x0000 |
| Modbus RTU 中的发送顺序 | CRC 低字节在前,高字节在后 |
下面使用逐位计算方式,代码比较容易核对:
uint16_t crc16_modbus(const uint8_t *data, size_t len)
{
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; ++i) {
crc ^= data[i];
for (unsigned bit = 0; bit < 8; ++bit) {
if (crc & 1)
crc = (uint16_t)((crc >> 1) ^ 0xA001);
else
crc >>= 1;
}
}
return crc;
}
这个函数只负责计算。调用方需要保证:当 len > 0 时,data 指向至少 len 个可读取的字节。
核对算法时,可以使用常见测试字符串:
const uint8_t sample[] = "123456789";
uint16_t crc = crc16_modbus(sample, sizeof(sample) - 1);
printf("CRC:%04X\n", (unsigned)crc);
结果应为:
CRC:4B37
注意这里没有把字符串末尾的 '\0' 算进去。
六、组合使用:构造一条 Modbus RTU 指令
以读取保持寄存器为例,下面这段数据表示:
01 从站地址
03 功能码:读取保持寄存器
00 00 起始寄存器地址
00 02 读取两个寄存器
可以将前面的函数组合起来,完成字符串转换、字段读取、CRC 追加和日志输出:
int main(void)
{
uint8_t frame[32];
size_t len = 0;
/* 预留两个字节,用于追加 CRC。 */
if (!hex_to_bytes("01 03 00 00 00 02",
frame, sizeof(frame) - 2, &len)) {
return 1;
}
uint16_t count;
if (!read_u16_be(frame, len, 4, &count)) {
return 1;
}
uint16_t crc = crc16_modbus(frame, len);
frame[len++] = (uint8_t)(crc & 0xFF);
frame[len++] = (uint8_t)(crc >> 8);
char text[sizeof(frame) * 3];
if (!bytes_to_hex(frame, len, text, sizeof(text))) {
return 1;
}
printf("寄存器数量:%u\n", (unsigned)count);
printf("发送数据:%s\n", text);
return 0;
}
输出结果:
寄存器数量:2
发送数据:01 03 00 00 00 02 C4 0B
这里有一个容易混淆的地方:同一条协议里,不同字段的字节序也可能不同。 Modbus RTU 的寄存器地址、数量等字段高字节在前,而末尾的 CRC 低字节在前,不能统一按一种方式处理。
接收数据时,则需要先确认完整帧的长度,再对 CRC 之前的数据重新计算,并与收到的 CRC 比较。串口接收回调触发一次,不代表已经收到一整帧,组包和拆包仍然需要交给协议解析层处理。
上面的函数可以整理到 binary_utils.h 和 binary_utils.c 中:头文件放接口声明,源文件放具体实现,两个 static 辅助函数保留在源文件内部。
这样,通信模块负责收发,协议模块负责识别完整数据包,工具库负责字节转换和校验。以后更换设备协议时,这些基础函数仍然可以继续使用。