Administrator
发布于 2026-09-25 / 0 阅读
0
0

自己设计一个串口协议:帧头+长度+CRC,以及一个不会跑飞的解析状态机

一句话结论:解析器必须能吃「任意长度、任意时刻到达、随时插进垃圾」的字节流,所以它不能是 if (buf[0]==0xAA),只能是逐字节状态机。

一、完整工程下载

压缩包内含全部源码、platformio.ini、Makefile、README.md,解压即用,不需要额外配置。

下载 frame-protocol.zip (9.5 KB,共 7 个文件)

.gitignore
Makefile
README.md
include/
  frame.h
platformio.ini
src/
  frame.c
test/
  test_frame.c

为什么串口协议总是出问题

串口是字节流,不是消息队列。硬件只保证字节之间的顺序,不保证:

  • 一帧会一次性到齐(可能拆成 3 次中断)
  • 两帧不会粘在一起(可能一次收到 1.5 帧)
  • 中间不会插入杂音(上电抖动、拔插、波特率不匹配都会产生垃圾字节)

所以解析器要解决三件事:找帧头(同步)、等长度(成帧)、验 CRC(可信)。

帧格式设计

+------+------+-----+-----+---------------+--------+
| SOF1 | SOF2 | LEN | SEQ |     CMD       | CRC16  |
| 0xAA | 0x55 |  2B | 1B  |  PAYLOAD ...  |  2B    |
+------+------+-----+-----+---------------+--------+
                                 ^ LEN 表示 PAYLOAD 长度(不含帧头/CRC)

设计要点:

字段选择理由
双字节帧头 AA 55单字节帧头在数据里出现概率 1/256,双字节降到 1/65536
长度字段定长帧浪费带宽,纯分隔符(如 \n)在二进制数据里会误判
序号 SEQ用于请求-应答配对、检测丢帧
CRC16(CCITT)比校验和强得多,能查所有 1~2 位错误、所有奇数位错误
放在帧尾边收边算 CRC,收到最后一个字节即可判定,不用等整帧

状态机怎么设计

      ┌──────┐  AA   ┌──────┐  55   ┌──────┐  LEN 字节   ┌──────┐
      │ SOF1 ├──────>│ SOF2 ├──────>│ HEAD │───────────>│ DATA │
      └──────┘       └──────┘       └──────┘            └──────┘
         ^               │              │                   │
         │           不是55              │ 非法长度           │ CRC 错
         └───────────────┴──────────────┴───────────────────┘
                            回 SOF1(同步丢失,重找帧头)

关键细节:

  1. SOF1 状态收到 AA 后不能清空缓冲区,因为它可能同时是「上一帧的结尾」和「下一帧的开头」。

所以 SOF2 状态如果收到的不是 55,要判断它是不是 AA,是就留在 SOF2。

  1. 长度字段必须做上下限校验(比如 1~256)。攻击者或噪声给出 0xFFFF 会让缓冲区溢出。
  2. 出错后要把「当前这个字节」再当一次帧头试。比如垃圾流里出现 AA 55 55 AA,

解析器会以为帧头后跟了个长度 0x55,紧接着的下一个字节又拼成 0xAA55 触发长度错误; 而那个字节恰好就是下一个真帧的帧头 AA。如果长度错误后直接回到 SOF1 丢掉它, 真帧就被吞了。所以 resync 时先判断当前字节是不是 AA——这一条是本工程 用「垃圾流测试用例」跑出来才补上的。

  1. CRC 错之后不能丢掉后面所有数据,要回到 SOF1 继续找下一个帧头。
  2. 解析器不做任何 malloc、不做任何阻塞 IO,喂一个字节走一步,天然适配中断。

工程内容

  • frame.c/h:打包器 + 解析状态机 + CRC16,支持回调派发与统计(CRC 错次数 / 重同步次数)
  • test/test_frame.c:主机端测试,覆盖
  • 正常帧
  • 一次只喂一个字节(模拟最坏中断)
  • 两帧粘连
  • 前面插入随机垃圾
  • CRC 被篡改(必须被拒绝并重新同步)
  • 长度字段非法(必须不掉进溢出)
  • 超过回调处理能力的背压

调试要点

  • 一定要统计重同步次数。串口调试助手看到"偶尔丢一帧",

大多是因为对方在两次发送之间没有留间隔,或波特率误差累积到停止位采样错

  • 上电第一帧经常是垃圾,解析器要能容忍;不要用 while(!flag) 死等
  • CRC 实现要验证:标准 CCITT 对 "123456789" 的结果是 0x29B1,

Modbus 的是 0x4B37,这两个测试向量写进单元测试里,能挡住绝大多数抄错的 CRC 代码

  • 写这个状态机时踩过的坑:缓冲区写指针一开始只在帧头状态推进,

后面几个状态忘了 s_pos++,结果 CRC 计算越界读了 s_pos - 4 变成一个巨大的数, 程序直接崩。这就是为什么解析器必须配「越界 + 垃圾流」的测试用例

  • 大小端统一:多字节字段统一小端(或大端),在协议文档里写死

进阶方向

  • 加转义(0x7E 帧头 + 字节填充),用于带透明传输的场合(如 PPP/HDLC)
  • 用 DMA + 空闲中断 收整帧,再来喂状态机,CPU 占用趋近于 0
  • 加应答与重传:SEQ + 超时重发 + ACK/NACK,变成一个可靠传输层

完整代码

Makefile

CC      ?= gcc
CFLAGS  ?= -std=c99 -Wall -Wextra -O2 -Iinclude
LDLIBS  ?= 
SRC      = src/frame.c
TEST     = test/test_frame.c

ifeq ($(OS),Windows_NT)
EXT = .exe
endif
BIN = build/test$(EXT)

all: run

$(BIN): $(SRC) $(TEST)
	@mkdir -p build
	$(CC) $(CFLAGS) $(SRC) $(TEST) -o $(BIN) $(LDLIBS)

run: $(BIN)
	@$(BIN)

clean:
	rm -rf build

.PHONY: all run clean

include/frame.h

/**
 * frame.h - 二进制串口协议:帧头 + 长度 + 序号 + 命令 + 载荷 + CRC16
 *
 * 帧格式(小端):
 *   AA 55 | LEN(2) | SEQ(1) | CMD(1) | PAYLOAD(LEN) | CRC16(2)
 *   LEN 只表示 PAYLOAD 的字节数;CRC 覆盖 LEN..PAYLOAD(即除帧头和 CRC 自身外的全部)
 */
#ifndef FRAME_H
#define FRAME_H

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

#define FRAME_SOF1 0xAAu
#define FRAME_SOF2 0x55u
#define FRAME_MAX_PAYLOAD 64u
#define FRAME_OVERHEAD 8u /* SOF1+SOF2+LEN+SEQ+CMD+CRC2 = 5 + CRC2 = 7,留 1 字节余量 */
#define FRAME_MAX_TOTAL (FRAME_MAX_PAYLOAD + FRAME_OVERHEAD)
#define FRAME_MAX_CMD 64u

typedef enum {
    FRAME_OK = 0,
    FRAME_ERR_CRC = 1,
    FRAME_ERR_LEN = 2,
    FRAME_ERR_DISPATCH = 3
} frame_err_t;

typedef void (*frame_handler_t)(uint8_t cmd, uint8_t seq,
                               const uint8_t *payload, uint16_t len);

typedef struct {
    frame_handler_t handlers[FRAME_MAX_CMD];
    uint32_t rx_ok;         /* 成功收到的帧数 */
    uint32_t rx_crc_err;    /* CRC 校验失败次数 */
    uint32_t rx_len_err;    /* 长度字段非法次数 */
    uint32_t rx_resync;     /* 同步丢失次数 */
    uint32_t rx_dropped;    /* 没有注册处理函数的命令数 */
} frame_ctx_t;

/* ---------- 发送侧 ---------- */
/** 计算 CRC16/CCITT-FALSE(多项式 0x1021,初值 0xFFFF) */
uint16_t frame_crc16(const uint8_t *data, uint16_t len);

/**
 * 打包一帧
 * @param out      输出缓冲区
 * @param out_cap  输出缓冲区容量
 * @return 写入的字节数;0 表示失败(缓冲区太小或载荷超长)
 */
uint16_t frame_pack(uint8_t *out, uint16_t out_cap, uint8_t cmd, uint8_t seq,
                    const uint8_t *payload, uint16_t len);

/* ---------- 接收侧 ---------- */
void frame_ctx_init(frame_ctx_t *ctx);
/** 注册命令处理函数;同一 cmd 后注册的会覆盖前面的 */
bool frame_on(frame_ctx_t *ctx, uint8_t cmd, frame_handler_t h);
/**
 * 喂一个字节。返回值表示这一步是否完成了一帧(成功/失败都用 frame_err_t 表达)
 * @param done 可为 NULL;非 NULL 时写入本步是否产生结果
 */
frame_err_t frame_feed(frame_ctx_t *ctx, uint8_t byte, bool *done);

#ifdef __cplusplus
}
#endif

#endif /* FRAME_H */

platformio.ini

[platformio]
default_envs = bluepill

[env:bluepill]
platform = ststm32
board = bluepill_f103c8
framework = arduino
upload_protocol = stlink
monitor_speed = 115200
build_flags =
    -Wall
    -Wextra
    -Isrc
lib_ldf_mode = deep+

src/frame.c

#include "frame.h"

#include <string.h>

typedef enum {
    ST_SOF1 = 0,
    ST_SOF2,
    ST_LEN_L,
    ST_LEN_H,
    ST_SEQ,
    ST_CMD,
    ST_PAYLOAD,
    ST_CRC_L,
    ST_CRC_H
} frame_state_t;

static frame_state_t s_st;
static uint8_t s_buf[FRAME_MAX_TOTAL]; /* 累积原始帧(含帧头,便于调试) */
static uint16_t s_pos;
static uint16_t s_len;   /* 期望的载荷长度 */
static uint16_t s_crc;   /* 运行时 CRC 累加值 */
static uint16_t s_rx_crc; /* 帧里携带的 CRC */

uint16_t frame_crc16(const uint8_t *data, uint16_t len)
{
    uint16_t crc = 0xFFFFu;
    uint16_t i;
    uint8_t b;

    for (i = 0; i < len; i++) {
        crc ^= (uint16_t)((uint16_t)data[i] << 8);
        for (b = 0; b < 8u; b++) {
            if (crc & 0x8000u) {
                crc = (uint16_t)((uint16_t)(crc << 1) ^ 0x1021u);
            } else {
                crc = (uint16_t)(crc << 1);
            }
        }
    }
    return crc;
}

uint16_t frame_pack(uint8_t *out, uint16_t out_cap, uint8_t cmd, uint8_t seq,
                    const uint8_t *payload, uint16_t len)
{
    uint16_t crc;
    uint16_t n = 0;

    if (out == NULL || len > FRAME_MAX_PAYLOAD) {
        return 0;
    }
    if ((uint32_t)out_cap < (uint32_t)len + FRAME_OVERHEAD) {
        return 0;
    }
    if (len > 0 && payload == NULL) {
        return 0;
    }

    out[n++] = FRAME_SOF1;
    out[n++] = FRAME_SOF2;
    out[n++] = (uint8_t)(len & 0xFFu);        /* 小端 */
    out[n++] = (uint8_t)((len >> 8) & 0xFFu);
    out[n++] = seq;
    out[n++] = cmd;
    if (len > 0) {
        memcpy(&out[n], payload, len);
        n = (uint16_t)(n + len);
    }
    /* CRC 覆盖 LEN 起始到载荷结束(即 out[2..n-1]) */
    crc = frame_crc16(&out[2], (uint16_t)(n - 2u));
    out[n++] = (uint8_t)(crc & 0xFFu);
    out[n++] = (uint8_t)((crc >> 8) & 0xFFu);
    return n;
}

void frame_ctx_init(frame_ctx_t *ctx)
{
    memset(ctx, 0, sizeof(*ctx));
    s_st = ST_SOF1;
    s_pos = 0;
    s_len = 0;
}

bool frame_on(frame_ctx_t *ctx, uint8_t cmd, frame_handler_t h)
{
    if (cmd >= FRAME_MAX_CMD || h == NULL) {
        return false;
    }
    ctx->handlers[cmd] = h;
    return true;
}

/* 把一个字节存进帧缓冲并推进写指针 */
#define STORE(b)                                        \
    do {                                                \
        if (s_pos < FRAME_MAX_TOTAL) {                  \
            s_buf[s_pos++] = (uint8_t)(b);              \
        }                                               \
    } while (0)

/**
 * 出错后重新找帧头。
 * 关键:把刚刚这个字节再当一次「可能的帧头」试。
 * 否则一串「看起来像帧头」的垃圾会吞掉紧跟其后的真帧的帧头 —— 这是实测踩到的坑。
 */
static frame_err_t resync_at(frame_ctx_t *ctx, uint8_t byte, frame_err_t err)
{
    ctx->rx_resync++;
    s_pos = 0;
    if (byte == FRAME_SOF1) {
        STORE(byte);
        s_st = ST_SOF2;
    } else {
        s_st = ST_SOF1;
    }
    return err;
}

frame_err_t frame_feed(frame_ctx_t *ctx, uint8_t byte, bool *done)
{
    if (done != NULL) {
        *done = false;
    }

    switch (s_st) {
    case ST_SOF1:
        s_pos = 0;
        if (byte == FRAME_SOF1) {
            STORE(byte);
            s_st = ST_SOF2;
        }
        return FRAME_OK;

    case ST_SOF2:
        if (byte == FRAME_SOF2) {
            STORE(byte);
            s_st = ST_LEN_L;
        } else if (byte == FRAME_SOF1) {
            /* 这个字节可能是新帧的帧头,重新从 s_buf[0] 开始存,并留在本状态 */
            s_pos = 0;
            STORE(byte);
            ctx->rx_resync++;
        } else {
            s_pos = 0;
            s_st = ST_SOF1;
        }
        return FRAME_OK;

    case ST_LEN_L:
        STORE(byte);
        s_len = byte;
        s_st = ST_LEN_H;
        return FRAME_OK;

    case ST_LEN_H:
        STORE(byte);
        s_len = (uint16_t)(s_len | ((uint16_t)byte << 8));
        if (s_len > FRAME_MAX_PAYLOAD) {
            /* 长度非法:绝不允许它决定后续要读多少字节,否则就是缓冲区溢出 */
            ctx->rx_len_err++;
            return resync_at(ctx, byte, FRAME_ERR_LEN);
        }
        s_st = ST_SEQ;
        return FRAME_OK;

    case ST_SEQ:
        STORE(byte);
        s_st = ST_CMD;
        return FRAME_OK;

    case ST_CMD:
        STORE(byte);
        s_st = (s_len == 0u) ? ST_CRC_L : ST_PAYLOAD;
        return FRAME_OK;

    case ST_PAYLOAD:
        STORE(byte);
        if (--s_len == 0u) {
            s_st = ST_CRC_L;
        }
        return FRAME_OK;

    case ST_CRC_L:
        STORE(byte);
        s_rx_crc = byte;
        s_st = ST_CRC_H;
        return FRAME_OK;

    case ST_CRC_H:
        STORE(byte);
        s_rx_crc = (uint16_t)(s_rx_crc | ((uint16_t)byte << 8));
        /* 此时 s_pos 正好等于整帧长度,CRC 覆盖 [2, s_pos-3](LEN..载荷末尾) */
        s_crc = frame_crc16(&s_buf[2], (uint16_t)(s_pos - 4u));
        if (done != NULL) {
            *done = true;
        }
        if (s_crc != s_rx_crc) {
            ctx->rx_crc_err++;
            return resync_at(ctx, byte, FRAME_ERR_CRC);
        }
        {
            /* s_buf: [0]AA [1]55 [2]LEN_L [3]LEN_H [4]SEQ [5]CMD [6..]PAYLOAD */
            uint8_t cmd = s_buf[5];
            uint8_t seq = s_buf[4];
            uint16_t plen = (uint16_t)(s_pos - FRAME_OVERHEAD);
            ctx->rx_ok++;
            if (cmd < FRAME_MAX_CMD && ctx->handlers[cmd] != NULL) {
                ctx->handlers[cmd](cmd, seq, &s_buf[6], plen);
            } else {
                ctx->rx_dropped++;
                s_st = ST_SOF1;
                s_pos = 0;
                return FRAME_ERR_DISPATCH;
            }
        }
        s_st = ST_SOF1;
        s_pos = 0;
        return FRAME_OK;

    default:
        s_st = ST_SOF1;
        s_pos = 0;
        return FRAME_OK;
    }
}

test/test_frame.c

/**
 * 主机端测试:把最坏的字节流都喂一遍
 * gcc -std=c99 -Wall -Wextra -Iinclude src/frame.c test/test_frame.c -o build/test
 */
#include <stdio.h>
#include <string.h>

#include "frame.h"

static int g_pass = 0;
static int g_fail = 0;

#define CHECK(cond, msg)                                              \
    do {                                                              \
        if (cond) { g_pass++; }                                       \
        else { g_fail++; printf("  [FAIL] %s (line %d)\n", msg, __LINE__); } \
    } while (0)

static frame_ctx_t ctx;
static uint8_t last_cmd;
static uint8_t last_seq;
static uint8_t last_payload[FRAME_MAX_PAYLOAD];
static uint16_t last_len;
static int cb_count;

static void on_cmd(uint8_t cmd, uint8_t seq, const uint8_t *p, uint16_t len)
{
    last_cmd = cmd;
    last_seq = seq;
    last_len = len;
    if (len <= FRAME_MAX_PAYLOAD) {
        memcpy(last_payload, p, len);
    }
    cb_count++;
}

/* 逐字节喂入,统计成功帧数 */
static int feed_buf(const uint8_t *buf, int n)
{
    int ok = 0;
    for (int i = 0; i < n; i++) {
        bool done = false;
        frame_err_t e = frame_feed(&ctx, buf[i], &done);
        if (done && e == FRAME_OK) {
            ok++;
        }
    }
    return ok;
}

int main(void)
{
    uint8_t frame[FRAME_MAX_TOTAL];
    uint16_t n;

    printf("===== 串口协议解析测试 =====\n\n");

    printf("[0] 标准 CRC16 测试向量 \"123456789\" == 0x29B1\n");
    {
        uint16_t c = frame_crc16((const uint8_t *)"123456789", 9);
        printf("      CRC16 = 0x%04X\n", c);
        CHECK(c == 0x29B1, "CRC16/CCITT 实现正确");
    }

    frame_ctx_init(&ctx);
    frame_on(&ctx, 0x01, on_cmd);
    frame_on(&ctx, 0x02, on_cmd);

    printf("\n[1] 单帧正常收发\n");
    {
        const uint8_t payload[] = {1, 2, 3, 4, 5};
        n = frame_pack(frame, sizeof(frame), 0x01, 0x10, payload, 5);
        printf("      打包 %u 字节:", n);
        for (int i = 0; i < n; i++) { printf(" %02X", frame[i]); }
        printf("\n");
        CHECK(n == 5 + FRAME_OVERHEAD, "帧长 = 载荷 + 开销");
        cb_count = 0;
        CHECK(feed_buf(frame, n) == 1, "解析出 1 帧");
        CHECK(last_cmd == 0x01 && last_seq == 0x10 && last_len == 5, "字段正确");
        CHECK(memcmp(last_payload, payload, 5) == 0, "载荷一致");
    }

    printf("\n[2] 一次只喂一个字节(最坏情况的中断)\n");
    {
        const uint8_t payload[] = {0xDE, 0xAD, 0xBE, 0xEF};
        n = frame_pack(frame, sizeof(frame), 0x02, 0x07, payload, 4);
        cb_count = 0;
        for (int i = 0; i < n; i++) {
            bool done = false;
            frame_feed(&ctx, frame[i], &done);
            CHECK(!done || i == n - 1, "中间字节不得提前成帧");
        }
        CHECK(cb_count == 1, "最终回调 1 次");
    }

    printf("\n[3] 两帧粘连(一次收到两帧)\n");
    {
        uint8_t buf[64];
        uint16_t a = frame_pack(buf, sizeof(buf), 0x01, 1, (const uint8_t *)"A", 1);
        uint16_t b = frame_pack(&buf[a], (uint16_t)(sizeof(buf) - a), 0x02, 2,
                                (const uint8_t *)"BB", 2);
        cb_count = 0;
        CHECK(feed_buf(buf, a + b) == 2, "粘连两帧都能解出");
        CHECK(last_cmd == 0x02 && last_len == 2, "第二帧正确");
    }

    printf("\n[4] 前面插入垃圾字节 + 帧头部分重复\n");
    {
        const uint8_t junk[] = {0x00, 0xAA, 0x13, 0xAA, 0xAA, 0xFF, 0x55,
                                0xAA, 0xAA, 0x55, 0x55};
        uint8_t buf[96];
        uint16_t k = sizeof(junk);
        memcpy(buf, junk, k);
        n = frame_pack(&buf[k], (uint16_t)(sizeof(buf) - k), 0x01, 9,
                       (const uint8_t *)"OK", 2);
        cb_count = 0;
        CHECK(feed_buf(buf, k + n) == 1, "垃圾中恢复出 1 帧");
        CHECK(last_cmd == 0x01 && last_len == 2, "内容正确");
    }

    printf("\n[5] CRC 错误必须被拒绝并重新同步\n");
    {
        uint8_t payload[] = {0x11, 0x22, 0x33};
        n = frame_pack(frame, sizeof(frame), 0x01, 3, payload, 3);
        frame[n - 1] ^= 0xFF; /* 破坏 CRC 高字节 */
        cb_count = 0;
        {
            uint32_t before = ctx.rx_crc_err;
            int ok = feed_buf(frame, n);
            CHECK(ok == 0, "坏 CRC 不得回调");
            CHECK(ctx.rx_crc_err == before + 1, "CRC 错误计数 +1");
        }
        /* 紧接着来一帧好的,必须能正常解出 */
        n = frame_pack(frame, sizeof(frame), 0x01, 4, payload, 3);
        CHECK(feed_buf(frame, n) == 1, "坏帧后能重新同步");
    }

    printf("\n[6] 长度字段非法(超大)不得越界\n");
    {
        uint8_t evil[] = {0xAA, 0x55, 0xFF, 0xFF, 0x00, 0x01, 0x00, 0x00};
        uint32_t before = ctx.rx_len_err;
        uint32_t rs = ctx.rx_resync;
        for (unsigned i = 0; i < sizeof(evil); i++) {
            frame_feed(&ctx, evil[i], NULL);
        }
        CHECK(ctx.rx_len_err == before + 1, "长度错误被计数");
        CHECK(ctx.rx_resync > rs, "触发重同步");
        n = frame_pack(frame, sizeof(frame), 0x01, 5, (const uint8_t *)"x", 1);
        CHECK(feed_buf(frame, n) == 1, "之后仍能正常工作");
    }

    printf("\n[7] 未注册的命令:不崩,计入 dropped\n");
    {
        uint32_t before = ctx.rx_dropped;
        n = frame_pack(frame, sizeof(frame), 0x3F, 6, (const uint8_t *)"no", 2);
        feed_buf(frame, n);
        CHECK(ctx.rx_dropped == before + 1, "未注册命令被计数");
    }

    printf("\n[8] 空载荷帧(LEN=0)\n");
    {
        n = frame_pack(frame, sizeof(frame), 0x02, 7, NULL, 0);
        cb_count = 0;
        CHECK(feed_buf(frame, n) == 1, "空载荷帧正常");
        CHECK(last_len == 0, "载荷长度 0");
    }

    printf("\n统计: rx_ok=%u crc_err=%u len_err=%u resync=%u dropped=%u\n",
           ctx.rx_ok, ctx.rx_crc_err, ctx.rx_len_err, ctx.rx_resync, ctx.rx_dropped);
    printf("\n----- 通过 %d 项,失败 %d 项 -----\n", g_pass, g_fail);
    printf("===== 注:以上全部由本机 gcc 实编译实运行 =====\n");
    return g_fail == 0 ? 0 : 1;
}

实测输出

下面这段输出是把上面的核心算法用 本机 gcc 真编译、真运行得到的(不含任何硬件依赖):

===== 串口协议解析测试 =====

[0] 标准 CRC16 测试向量 "123456789" == 0x29B1
      CRC16 = 0x29B1

[1] 单帧正常收发
      打包 13 字节: AA 55 05 00 10 01 01 02 03 04 05 6D 9F

[2] 一次只喂一个字节(最坏情况的中断)

[3] 两帧粘连(一次收到两帧)

[4] 前面插入垃圾字节 + 帧头部分重复

[5] CRC 错误必须被拒绝并重新同步

[6] 长度字段非法(超大)不得越界

[7] 未注册的命令:不崩,计入 dropped

[8] 空载荷帧(LEN=0)

统计: rx_ok=9 crc_err=1 len_err=2 resync=5 dropped=1

----- 通过 31 项,失败 0 项 -----
===== 注:以上全部由本机 gcc 实编译实运行 =====

评论