串口还能传文件?别再只用来打印日志了 🚀
说实话,刚入行做嵌入式的时候,我也以为串口(UART)就是个“高级调试灯”——系统跑飞了?打点
printf("Here!\n")
;变量不对劲?再加一行输出看看。直到某天项目经理甩过来一句话:“我们要通过串口升级固件。”我当场愣住:
这玩意儿连个包头都没有,怎么保证数据不丢、不错、不乱序?
后来才明白,真正的工程能力,不是你会用多复杂的协议栈,而是能在最简陋的条件下,把不可能变成可靠可行。今天我们就来聊聊这个看似“复古”,实则极具实战价值的话题: 如何在只有串口的设备上,实现安全可靠的文件传输 。
这不是理论推演,而是一套已经在STM32、ESP32、国产MCU等平台上验证过的轻量级方案。它不需要RTOS,不依赖TCP/IP,甚至可以在仅有几KB RAM的单片机上跑起来。
为什么串口不适合直接传文件?
先泼一盆冷水:你不能像FTP那样直接往串口里塞一个bin文件就完事。
原因很简单——串口本质上是 裸露的字节流通道 ,它没有:
- 帧边界标识(你怎么知道哪几个字节是一组?)
- 错误检测机制(电磁干扰导致某位翻转怎么办?)
- 流量控制(发太快,接收方缓存溢出咋办?)
- 确认与重传(对方到底收到没?)
换句话说,原生串口就像一条没有红绿灯和交警的城市小路,你想运货过去可以,但得自己组织车队、编号装箱、派人押车、中途检查有没有丢包……
所以,我们必须在串口之上,构建一层“交通规则”——也就是我们所说的 文件分片传输协议 。
💡 小知识:很多人觉得RS-485比UART高级,其实不然。RS-485只是物理层标准(差分信号、支持多点通信),真正决定能不能传文件的,还是上层协议。
我们要解决的核心问题是什么?
假设你现在手头有个需求:给一台部署在现场的工业控制器远程升级固件,但它只有串口可用,且现场环境嘈杂、通信不稳定。
你需要回答以下几个关键问题:
-
大文件怎么发?
—— 总不能一次性读进内存吧?很多MCU只有几KB RAM。 -
怎么防止数据出错?
—— 工厂电机启停时的电磁干扰可能导致比特翻转。 -
如果中间断了,能续传吗?
—— 难道每次失败都要从头再来? -
接收端怎么知道已经收全了?
—— 没有连接状态,怎么判断“结束”?
这些问题归结为四个关键词: 分片、校验、确认、状态管理 。
接下来我们就围绕这四点,一步步搭建一个实用的协议框架。
分片:让大文件“走得了”
想象你要把一辆卡车的货物运过一座窄桥,桥一次只能过一个小推车。那怎么办?拆!
文件也一样。一个几百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 协议 。
基本流程如下:
- 主机发送一片数据 → 进入等待ACK状态
-
设备收到后立即校验:
- 成功 → 回复ACK
- 失败 → 回复NACK -
主机收到
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(¤t_pkt);
uart_send(¤t_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(¤t_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),仅供参考
1247

被折叠的 条评论
为什么被折叠?



