C 语言实现一个实用的二进制数据工具库:十六进制转换、大小端与 CRC 校验

787 阅读8分钟

在串口、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' 结尾的字符串。

为了保持规则简单,暂不支持 0xAAAA-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.hbinary_utils.c 中:头文件放接口声明,源文件放具体实现,两个 static 辅助函数保留在源文件内部。

这样,通信模块负责收发,协议模块负责识别完整数据包,工具库负责字节转换和校验。以后更换设备协议时,这些基础函数仍然可以继续使用。