串口通信中实现简单文件分片传输协议

AI助手已提取文章相关产品:

串口还能传文件?别再只用来打印日志了 🚀

说实话,刚入行做嵌入式的时候,我也以为串口(UART)就是个“高级调试灯”——系统跑飞了?打点 printf("Here!\n") ;变量不对劲?再加一行输出看看。直到某天项目经理甩过来一句话:“我们要通过串口升级固件。”我当场愣住: 这玩意儿连个包头都没有,怎么保证数据不丢、不错、不乱序?

后来才明白,真正的工程能力,不是你会用多复杂的协议栈,而是能在最简陋的条件下,把不可能变成可靠可行。今天我们就来聊聊这个看似“复古”,实则极具实战价值的话题: 如何在只有串口的设备上,实现安全可靠的文件传输

这不是理论推演,而是一套已经在STM32、ESP32、国产MCU等平台上验证过的轻量级方案。它不需要RTOS,不依赖TCP/IP,甚至可以在仅有几KB RAM的单片机上跑起来。


为什么串口不适合直接传文件?

先泼一盆冷水:你不能像FTP那样直接往串口里塞一个bin文件就完事。

原因很简单——串口本质上是 裸露的字节流通道 ,它没有:

  • 帧边界标识(你怎么知道哪几个字节是一组?)
  • 错误检测机制(电磁干扰导致某位翻转怎么办?)
  • 流量控制(发太快,接收方缓存溢出咋办?)
  • 确认与重传(对方到底收到没?)

换句话说,原生串口就像一条没有红绿灯和交警的城市小路,你想运货过去可以,但得自己组织车队、编号装箱、派人押车、中途检查有没有丢包……

所以,我们必须在串口之上,构建一层“交通规则”——也就是我们所说的 文件分片传输协议

💡 小知识:很多人觉得RS-485比UART高级,其实不然。RS-485只是物理层标准(差分信号、支持多点通信),真正决定能不能传文件的,还是上层协议。


我们要解决的核心问题是什么?

假设你现在手头有个需求:给一台部署在现场的工业控制器远程升级固件,但它只有串口可用,且现场环境嘈杂、通信不稳定。

你需要回答以下几个关键问题:

  1. 大文件怎么发?
    —— 总不能一次性读进内存吧?很多MCU只有几KB RAM。

  2. 怎么防止数据出错?
    —— 工厂电机启停时的电磁干扰可能导致比特翻转。

  3. 如果中间断了,能续传吗?
    —— 难道每次失败都要从头再来?

  4. 接收端怎么知道已经收全了?
    —— 没有连接状态,怎么判断“结束”?

这些问题归结为四个关键词: 分片、校验、确认、状态管理

接下来我们就围绕这四点,一步步搭建一个实用的协议框架。


分片:让大文件“走得了”

想象你要把一辆卡车的货物运过一座窄桥,桥一次只能过一个小推车。那怎么办?拆!

文件也一样。一个几百KB的固件,在资源受限的MCU眼里就是“超载”。解决方案就是切片发送。

如何切?切多大?

常见的做法是设定一个最大分片长度,比如 1024 字节。每一片独立打包,包含以下信息:

typedef struct {
    uint16_t index;        // 当前是第几片(从0开始)
    uint16_t total;        // 一共多少片
    uint16_t len;          // 实际数据长度(最后一片可能不满)
    uint8_t  data[1024];    // 数据体
    uint16_t crc;          // CRC16校验值
} fragment_t;

注意几个细节:

  • index total 是必须的 :不然接收方不知道这是第几块,也不知道还差多少。
  • len 很关键 :最后一片通常不会刚好填满缓冲区,必须明确告知有效长度。
  • crc 放在包尾 :这样接收方收到完整一帧后才能计算并比对。

切片逻辑示例

void send_file(const char* path) {
    FILE* fp = fopen(path, "rb");
    if (!fp) return;

    fseek(fp, 0, SEEK_END);
    long file_size = ftell(fp);
    rewind(fp);

    int frag_size = 1024;
    int total = (file_size + frag_size - 1) / frag_size;  // 向上取整

    fragment_t pkt;
    uint8_t buffer[1024];
    int index = 0;

    while (!feof(fp)) {
        size_t read_len = fread(buffer, 1, frag_size, fp);
        if (read_len == 0) break;

        pkt.index = index++;
        pkt.total = total;
        pkt.len = read_len;
        memcpy(pkt.data, buffer, read_len);
        pkt.crc = crc16(buffer, read_len);  // 只校验data部分!

        uart_send(&pkt, sizeof(pkt));
        delay_ms(5);  // 给接收方喘口气
    }

    fclose(fp);
}

看到这里你可能会问: 为什么不等所有片都发完再统一校验?

因为那样一旦出错就得重传整个文件,效率极低。我们追求的是“ 局部容错 ”——哪一片错了就重哪一片。


校验:数据真的完整吗?

你说你发的是 0x5A ,可到了对方手里变成了 0xDA (第6位被干扰翻转),这种事在工业现场太常见了。

所以我们需要一种快速又可靠的检错手段。MD5?SHA?太重了。CRC 才是嵌入式世界的首选。

为什么选 CRC16-CCITT?

  • 计算速度快,适合MCU实时处理
  • 对突发错误敏感(连续多位出错也能检测到)
  • 实现简单,代码不到30行
  • 广泛用于Modbus、PPP、蓝牙等协议,兼容性好

下面是标准实现:

uint16_t crc16(const uint8_t* data, size_t len) {
    uint16_t crc = 0xFFFF;
    for (size_t i = 0; i < len; ++i) {
        crc ^= data[i];
        for (int j = 0; j < 8; ++j) {
            if (crc & 1) {
                crc = (crc >> 1) ^ 0x8408;  // 多项式 0x1021,低位优先
            } else {
                crc >>= 1;
            }
        }
    }
    return crc;
}

📌 重点提醒
- 发送和接收两端必须使用完全相同的参数(初始值、多项式、是否反转输入输出等)
- 校验范围不应包括 crc 字段本身,否则永远对不上
- 如果你发现校验老是失败,先检查字节序和移位方向!


确认机制:你收到了吗?说一声!

光发不管收,等于白忙活。

我们需要一种“握手+确认”的机制,确保每一帧都被正确接收。这就是所谓的 ACK/NACK 协议

基本流程如下:

  1. 主机发送一片数据 → 进入等待ACK状态
  2. 设备收到后立即校验:
    - 成功 → 回复 ACK
    - 失败 → 回复 NACK
  3. 主机收到 ACK → 发下一片;收到 NACK 或超时 → 重发当前片

听起来很简单,但实际落地有几个坑:

❌ 错误做法:阻塞等待ACK
uart_send(&pkt, sz);
while (!received_ack);  // 死循环!万一丢了呢?

这会导致程序卡死。正确的做法是 事件驱动 + 定时器

✅ 推荐模式:非阻塞轮询 + 超时重试
typedef enum {
    IDLE,
    SENDING,
    WAITING_ACK,
    ERROR,
    DONE
} tx_state_t;

tx_state_t state = IDLE;
fragment_t current_pkt;
uint8_t retry_count = 0;
uint32_t last_send_time = 0;

void tx_tick() {
    switch (state) {
        case IDLE:
            if (need_to_send) {
                load_next_packet(&current_pkt);
                uart_send(&current_pkt, sizeof(current_pkt));
                last_send_time = get_tick();
                retry_count = 0;
                state = WAITING_ACK;
            }
            break;

        case WAITING_ACK:
            if (received_ack) {
                if (has_more_fragments()) {
                    state = SENDING;  // 下一轮开始
                } else {
                    state = DONE;
                }
            } else if (get_tick() - last_send_time > 1000) {  // 超时1秒
                if (++retry_count > 3) {
                    state = ERROR;
                    return;
                }
                uart_send(&current_pkt, sizeof(current_pkt));  // 重发
                last_send_time = get_tick();
            }
            break;
    }
}

这个设计亮点在于:

  • 不会阻塞主线程,适合裸机系统或低优先级任务
  • 超时自动重试,最多3次,避免无限重发
  • 状态清晰,易于扩展(比如加入断点续传)

状态机:让复杂流程变得可控

上面提到的状态切换,其实就是典型的 有限状态机(FSM) 应用。

很多人一听“状态机”就觉得高深,其实它的核心思想特别朴素: 把复杂的交互过程拆成一个个“稳定状态”,每个状态下只做一件事,条件满足就跳转。

在我们的文件传输场景中,典型的状态流转是这样的:

[IDLE] 
  │ start request
  ▼
[SEND_START_CMD] → (timeout?) → [RETRY] or [ERROR]
  │
  ▼
[WAIT_START_ACK]
  │ got ACK
  ▼
[SEND_FRAGMENT_0] →→→ [WAIT_FRAG_ACK]
  │                      │
  │                      └─(NACK/timeout)─→ resend
  │                      └─(ACK)──────────→ next fragment
  │
  ▼
[SEND_LAST_FRAGMENT]
  │
  ▼
[SEND_END_CMD] → [WAIT_FINISH_ACK] → [DONE]

你可以把它画成一张图贴在办公室墙上,新人一看就懂。

而且一旦出了问题,比如卡在 WAIT_ACK ,你马上就知道是通信链路或者ACK响应的问题,而不是一头扎进一堆if-else里找bug。


实际应用中的那些“脏活累活”

纸上谈兵容易,真正在工厂跑起来才知道什么叫“魔鬼在细节”。

下面是我踩过的几个坑,分享给你避雷👇

🛠️ 1. 接收缓冲区太小怎么办?

有些MCU的DMA缓冲只有256字节,而我们一帧就有1024+字节结构体,根本放不下!

解法 :采用 流式解析(Streaming Parser)

不要等整包来了再处理,而是边收边解析头部。例如:

// 伪代码
if (new_byte_arrived) {
    ring_buffer_push(byte);

    // 尝试从ring buffer中提取完整包
    if (peek_uint16(0) == MAGIC_HEADER) {  // 匹配魔数
        uint16_t len = peek_uint16(2);     // 读长度字段
        if (available >= HEADER_SIZE + len + CRC_SIZE) {
            extract_packet();              // 提取完整帧
            process_packet();              // 处理
        }
    }
}

关键是定义一个“魔数”作为帧起始标志,比如 0xA55A ,这样即使数据里也有类似模式,概率也很低。

🛠️ 2. 写Flash时不能再收数据?

Flash擦除/写入期间,CPU常被占用,UART容易丢中断。

解法 :双缓冲机制 + 中断保护

#define BUF_COUNT 2
uint8_t frag_buf[BUF_COUNT][1024];
volatile int active_buf = 0;
volatile int buf_status[BUF_COUNT] = {READY, READY};

// UART中断中
void uart_isr() {
    int buf = active_buf;
    frag_buf[buf][recv_index++] = UDR;

    if (is_packet_complete()) {
        buf_status[buf] = FULL;
        active_buf = 1 - buf;  // 切换到另一个缓冲区
        recv_index = 0;
    }
}

// 主循环中处理FULL的缓冲区(写Flash)
void main_loop() {
    for (int i = 0; i < BUF_COUNT; ++i) {
        if (buf_status[i] == FULL) {
            disable_uart_interrupt();
            write_to_flash(frag_buf[i]);
            enable_uart_interrupt();
            buf_status[i] = READY;
        }
    }
}

这样即使在一个缓冲区写Flash时,另一个还能继续收数据,大大降低丢包率。

🛠️ 3. 传输中途断电了怎么办?

下次上电还得从头传?客户肯定炸锅。

解法 :持久化已接收片索引

在Flash中开辟一小块区域(如最后一个sector),记录哪些片已经成功写入:

uint8_t received_map[256];  // 最多支持256片,每bit表示是否收到

// 收到某片并校验成功后
void mark_received(int index) {
    int byte_idx = index / 8;
    int bit_idx = index % 8;
    received_map[byte_idx] |= (1 << bit_idx);
    save_to_flash(received_map);  // 异步保存
}

// 启动时加载已接收状态
void load_resume_state() {
    read_from_flash(received_map);
}

主机发送前可先询问“哪些片已有”,实现断点续传。


性能优化建议:不只是“能用”

做到上面这些,你的协议已经“能用了”。但如果想更进一步,可以从这几个方面优化:

⚙️ 1. 动态调整分片大小

根据通信质量动态选择分片长度:

  • 信道好 → 大分片(如2048B),提升吞吐
  • 信道差 → 小分片(如256B),降低单次出错概率

可以通过统计NACK率来判断信道质量。

⚙️ 2. 使用滑动窗口,提高吞吐

目前是“发一片 → 等ACK → 再发下一片”,效率很低。改进思路是允许连续发多片(比如3片),形成一个“窗口”。

这就是简化版的 Go-Back-N ARQ 协议。

当然,代价是需要更大的接收缓冲和更复杂的ACK管理。

⚙️ 3. 加密防篡改(可选)

如果是固件升级,必须考虑安全性。可以在头部增加加密字段:

typedef struct {
    uint16_t magic;      // 0xA55A
    uint16_t index;
    uint16_t total;
    uint16_t len;
    uint8_t  iv[16];     // 初始化向量
    uint8_t  data[1024];
    uint16_t crc;
    uint8_t  mac[16];    // 消息认证码
} secured_fragment_t;

配合AES-CBC + HMAC,即可实现防窃听、防篡改。

不过这对低端MCU压力较大,需权衡。


它适合用在哪类项目中?

这套方案不是万能的,但它非常适合以下场景:

资源极度受限的设备
—— 没有网络模块、无RTOS、RAM < 8KB

需要远程维护但无Wi-Fi/4G的场合
—— 比如通过串口转4G模块进行远程升级

工业控制系统的参数下发
—— 批量配置PLC、HMI等设备

Bootloader阶段的固件更新
—— 系统尚未启动操作系统,只能靠串口“救砖”

❌ 不适合的场景:

  • 高速大量数据传输(如视频流)
  • 实时性要求极高(如运动控制指令)
  • 已具备以太网/Wi-Fi且带协议栈

调试技巧:让你少熬三个通宵 🔧

最后分享几个实用调试经验,都是拿头发换来的教训:

📟 1. 用串口助手模拟设备行为

推荐工具: XCOM、SSCOM、Tera Term

你可以手动构造十六进制数据包,测试主机侧的解析逻辑是否健壮。

比如故意发一个CRC错误的包,看是否会正确返回NACK。

📊 2. 抓包分析:用逻辑分析仪看真相

当你怀疑“我已经发了,为啥没反应?”时,请拿出 Saleae Logic Analyzer

它可以同时监听 TX/RX 两根线,直观看到双方通信全过程,甚至能导出CSV做自动化分析。

你会发现很多“玄学问题”其实是时序不对、ACK延迟太久等问题。

🧩 3. 在协议中加入“心跳命令”

除了数据帧,定期发送一个 PING 命令:

[CMD=0x01][LEN=0][CRC=xxxx]

对方回复 PONG 。这能帮你判断链路是否存活,避免盲目重传。


写到最后:技术的价值在于解决问题

很多人总觉得,搞嵌入式就要追新:Rust写驱动、Zephyr跑应用、MQTT上云……没错,这些都很酷。

但真正考验工程师功力的,往往是在 没有选择的条件下创造可能性

当你面对一块老式的8051单片机,只有9600bps的串口,还要完成20KB固件的远程升级时,你会感谢今天读过的这篇文章。

因为我们讨论的不是一个“协议”,而是一种思维方式:
如何在有限资源下,构建可靠的数据通道?

答案就是:分而治之(分片)、层层校验(CRC)、反馈控制(ACK)、状态明确(FSM)。

这些原则不仅适用于串口,也适用于SPI、I2C、自定义无线协议……甚至分布式系统设计。

所以别再小看串口了。
它也许老旧,但从不廉价。
它是嵌入式世界的“脊梁骨”,撑起了无数沉默运行的工业设备。

而你要做的,就是让它变得更聪明一点。💡

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

您可能感兴趣的与本文相关内容

在自媒体领域,内容生产效率与作品专业水准日益成为从业者的核心关切。近期推出的Coze工作流集成方案,为内容生产者构建了一套系统化、模块化的创作支持体系。该方案通过预先设计的流程模块,贯穿选题构思、素材整理、文本撰写、视觉编排及渠道分发的完整周期,显著增强了自媒体工作的规范性与产出速率。 经过多轮实践验证,这些标准化流程不仅精简了操作步骤,减少了机械性任务的比重,还借助统一的操作框架有效控制了人为失误。由此,创作者得以将主要资源集中于内容创新与深度拓展,而非消耗于日常执行事务。具体而言,在选题环节,系统依据实时舆情数据与受众偏好模型生成热点建议,辅助快速定位创作方向;在编辑阶段,则提供多套经过验证的版式方案与视觉组件,保障内容呈现兼具美学价值与阅读流畅性。 分发推广模块同样经过周密设计,整合了跨平台传播策略与效果监测工具,涵盖社交网络运营、搜索排序优化、定向推送等多重手段,旨在帮助内容突破单一渠道局限,实现更广泛的受众触达。 该集成方案在提供成熟模板的同时,保留了充分的定制空间,允许用户根据自身创作特性与阶段目标调整流程细节。这种“框架统一、细节可变”的设计哲学,兼顾了行业通用标准与个体工作习惯,提升了工具在不同应用场景中的适应性。 从行业视角观察,此方案的问世恰逢其时,回应了自媒体专业化进程中对于流程优化工具的迫切需求。其价值不仅体现在即时的效率提升,更在于构建了一个可持续迭代的创作支持生态。通过持续吸纳用户反馈与行业趋势,系统将不断演进,助力从业者保持与行业发展同步,实现创作质量与运营效能的双重进阶。 总体而言,这一工作流集成方案的引入,标志着自媒体创作方法向系统化、精细化方向的重要转变。它在提升作业效率的同时,通过结构化的工作方法强化了内容产出的专业度与可持续性,为从业者的职业化发展提供了坚实的方法论基础。 资源来源于网络分享,仅用于学习交流使用,请勿用于商业,如有侵权请联系我删除!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值