资讯动态

MCU串口通信协议设计:用状态机实现可靠帧收发与校验

发布时间:2026/10/19 8:27:46 来源:尧图企业网站定制
简介这是一份围绕中微CMS8S6990单片机实现的串口收发并带有简单协议处理的完整工程源码包适合刚入门单片机的开发者学习通信协议设计与底层驱动编写。资源包内共144个文件涵盖C源文件、头文件、Keil工程配置文件、编译中间文件、hex烧录文件等工程目录结构基本完整压缩包整体仅2.05MB适合直接导入到开发环境中查阅相关代码。目前已有1554人学习下载内容以实际项目代码为主重点展示如何为串口收发添加协议校验、消息解析和应答逻辑初学者可借此掌握基本的通信协议设计思路。资源价值在于既能看到system.c、uart.c、timer.c等模块化源码也能参考工程配置文件、启动文件及编译生成文件便于初学者对比源码和构建输出快速理解单片机串口通信与简单协议收发的基本套路。对于需要动手验证单片机串口通信、想把协议机制落到代码层面的同学会有直接帮助。1. 从一个实际场景说起串口收发带上协议之后刚调通串口回显时感觉很简单但真正把传感器数据发给上位机对面收到的全是乱码和粘包这就是很多人从点灯转向通信开发的第一道坎。这个项目要解决的正是这个痛点在 CMS8S6990 增强型 8051 上写一套极简但完整的串口收发协议把裸机串口从「能收发字节」升级到「能可靠收发一帧完整命令」。整个工程不依赖操作系统没有 DMA只有串口中断、定时器和状态机逻辑完全透明非常适合初学者照着抄一遍搞清楚协议帧到底怎么组、怎么拆、怎么校验。2. 协议与硬件准备帧格式设计和 CMS8S6990 的串口初始化2.1 为什么裸串口必须加协议层接触过几个入门项目的开发者应该都有体会串口本质是一条字节流芯片端没有任何「消息边界」的概念。你连续发 8 个字节对端只能按顺序收到 8 个字节但它不知道第几个字节算开始、第几个字节算结束。如果不加协议上位机想发一条「查询设备版本」的命令下位机只能约定「收到 0x01 就是查询版本」这在一个命令、一个数据格式的场景下还能凑合一旦有几十种命令、数据长度还不固定立刻会失控。更现实的问题是粘包与错位。假设下位机周期上报温度每帧 6 个字节如果其中某个字节丢失或电平受干扰翻转后续所有字节都会错位而且你根本不知道从哪一位开始重新同步。协议层解决的就是这三件事帧同步知道一帧从哪里开始、长度约束知道一帧到哪里结束、校验知道这一帧数据是否完好。这个项目采用的方案是经典的一帧结构固定帧头 命令字 数据长度 数据域 校验和。2.2 一帧协议的字段定义协议帧设计为总共最多 36 字节数据域上限 32 字节具体布局如下字段长度说明HDR11帧头 1固定 0xAAHDR21帧头 2固定 0x55CMD1命令字由应用层定义LEN1数据域长度范围 0~32DATA0~32数据域内容由命令字决定CHKSUM1校验和低 8 位帧头选 0xAA 和 0x55 是很有讲究的。这两个字节的二进制分别是 10101010 和 01010101波形上能看到清晰的电平交替用逻辑分析仪抓串口波形时一眼就能认出帧起始位置同时 0xAA 在普通数据中出现的概率不高双帧头叠加更是把误同步的概率压到了可以忽略的程度。校验和的计算规则是从 CMD 字节开始把 CMD、LEN、DATA 所有字节累加取累加结果的低 8 位当作 CHKSUM。帧头不参与校验因为帧头只负责同步如果连帧头都错了直接丢弃重新同步即可。也许有人会问为什么不用 CRC16。我的看法是这个场景是 MCU 与 PC 或简单传感器通信波特率 115200、数据量小、误码率低8 位累加和已经能覆盖单字节跳变、丢包这类常见故障。CRC16 会引入一张查找表或移位算法入门阶段先把协议主链路跑通后面想升级校验可以平滑替换这一点在第 6 章二次开发里专门讲。2.3 CMS8S6990 的串口初始化CMS8S6990 是增强型 8051 内核自带多个定时器和两组 UART本工程使用 UART0波特率 115200、8 位数据、无校验、1 位停止位。初始化代码我习惯单独放一个 uart.c不跟业务逻辑混在一起/* uart.c — 基于 CMS8S6990 的 UART0 初始化 */ #include 8051.h void UART0_Init(void) { SCON 0x50; /* 模式18位可变波特率REN1使能接收 */ TMOD 0x0F; /* 清空T1相关位 */ TMOD | 0x20; /* T1工作在方式28位自动重载 */ PCON | 0x80; /* SMOD1波特率加倍 */ TH1 0xE8; /* 复位值按数据手册计算不是固定值 */ TL1 TH1; TR1 1; /* 启动定时器1 */ ES 1; /* 使能串口中断 */ EA 1; /* 开总中断 */ }这里有个容易踩的坑TH1 的重载值不能从网上随便抄。CMS8S6990 跑在 1T 增强模式系统时钟分频系数和传统 12T 的 8051 不一样同一个 115200 波特率对应的 TH1 值很可能不同。最稳妥的做法是翻芯片手册里的波特率表格或者先按手册填一个值用逻辑分析仪抓 TX 脚波形数实际位宽再反推校准。第 4 章会再展开讲这个坑。初始化里还有一个细节SCON 的 0x50 同时完成了两件事一是配置串口工作模式二是把接收使能位 REN 置 1。如果不写 REN串口就只会发不会收初学者第一次跑通发送、卡在接收上时第一个要查的就是这个寄存器值。3. 核心代码拆解发送打包与接收状态机的完整链路3.1 发送端如何把数据打包成一帧发送端的设计分两层底层负责一个字节一个字节地推给 SBUF上层负责把命令和数据组装成协议帧。底层发送函数的实现很直接/* uart.c — 发送单个字节 */ void uart_send_byte(unsigned char dat) { SBUF dat; /* 写入SBUF硬件自动移位发送 */ while (!TI); /* TI1表示当前字节已发送完成 */ TI 0; /* 必须由软件清零 */ }这里的逻辑是把数据写进 SBUF 后串口硬件会在移位时钟的驱动下把 8 个数据位逐个发出去发完置位 TI。程序在 while 里等着直到 TI 变 1 才继续。TI 清零必须放在等待之后如果清零放在写 SBUF 之前第二轮发送时 TI 还残留上一帧的结果会直接跳过等待导致数据错乱。这也是初学者最容易写出「第一帧正常、第二帧全乱」的经典原因之一。上层组帧函数放在 protocol.c 里负责按照第 2 章定义的帧格式把命令字和数据域打包并计算校验和/* protocol.c — 组帧并发送一帧协议数据 */ #define P_HDR1 0xAA #define P_HDR2 0x55 extern void uart_send_byte(unsigned char dat); void protocol_send(unsigned char cmd, unsigned char *buf, unsigned char len) { unsigned char i; unsigned char sum 0; uart_send_byte(P_HDR1); uart_send_byte(P_HDR2); uart_send_byte(cmd); uart_send_byte(len); for (i 0; i len; i) { uart_send_byte(buf[i]); sum buf[i]; /* 数据域累加进校验 */ } sum cmd len; /* CMD与LEN也参与校验 */ uart_send_byte(sum); /* 发送低8位校验和 */ }这段代码有两个设计点值得说。其一校验和的累加顺序是 CMD、LEN、DATA与帧头无关这必须和接收端的状态机严格对齐否则自测时就会发现「自己发的帧自己解不了」。其二unsigned char溢出会自动回绕到低 8 位所以累加过程中不需要额外 0xFF这正是 C 语言里无符号整型的一个讨巧用法。需要注意的是buf指向的数据域不能超过 32 字节如果调用方传了更大的 len帧会突破协议上限接收端会在长度检查那里直接整帧丢弃。3.2 接收端用状态机解析一帧接收端是全工程的核心也可以说是初学者从「会串口收发」进阶到「会写协议解析」的关键一步。思路是把接收过程拆成若干状态串口中断每收到一个字节就喂给状态机走一步状态机根据当前处于什么阶段决定这个字节应该当帧头、命令、长度、数据还是校验来处理。先定义状态枚举和全局缓冲/* protocol.c — 接收状态与缓冲区 */ enum { ST_HDR1 0, /* 等待第一个帧头 */ ST_HDR2, /* 等待第二个帧头 */ ST_CMD, /* 接收命令字 */ ST_LEN, /* 接收长度 */ ST_DATA, /* 接收数据域 */ ST_CHK /* 接收校验和 */ }; volatile unsigned char rx_cmd 0; volatile unsigned char rx_len 0; volatile unsigned char rx_done 0; static unsigned char rx_buf[32]; static unsigned char state ST_HDR1; static unsigned char expect_len 0; static unsigned char rx_idx 0; static unsigned char rx_sum 0;rx_done 是一个「帧到达标志」由中断置 1主循环看到后取数据并清零。注意这里用了 volatile因为它在中断和主循环两个上下文之间共享不加 volatile 有可能被编译器优化成寄存器副本导致主循环永远看不到更新。串口中断函数如下/* uart.c — UART0接收中断中断号4是8051串口的固定位置 */ void UART0_ISR(void) interrupt 4 { unsigned char dat; if (!RI) return; /* RI1时才是接收中断 */ dat SBUF; /* 读数据硬件自动清RI */ RI 0; switch (state) { case ST_HDR1: if (dat 0xAA) state ST_HDR2; break; case ST_HDR2: if (dat 0x55) { state ST_CMD; } else { state ST_HDR1; /* 没等到0x55重新同步 */ } break; case ST_CMD: rx_cmd dat; rx_sum dat; /* 校验从CMD开始累加 */ state ST_LEN; break; case ST_LEN: expect_len dat; rx_sum dat; rx_idx 0; if (expect_len 0) { state ST_CHK; /* 无数据域直达校验 */ } else if (expect_len 32) { state ST_HDR1; /* 长度非法整帧作废 */ } else { state ST_DATA; } break; case ST_DATA: rx_buf[rx_idx] dat; rx_sum dat; if (rx_idx expect_len) { state ST_CHK; } break; case ST_CHK: if (dat rx_sum) { rx_len expect_len; rx_done 1; /* 校验通过通知主循环 */ } state ST_HDR1; /* 无论对错都重新开始 */ break; default: state ST_HDR1; break; } }这段代码的巧妙之处在于帧头匹配只在 ST_HDR1 和 ST_HDR2 两个状态进行一旦进入 ST_DATA数据里的 0xAA 0x55 就不会再干扰同步。这比很多人一开始写的「在主循环里找帧头」的方案要健壮得多因为数据域里的字节不会被误认为是新帧的起点。再细看几个边界。第一个是 LEN 为 0 的情况此时帧里没有数据域收到 LEN 后直接跳到 CHK 状态。第二个是 LEN 超过 32 的情况说明这一帧不是我们的协议帧或者数据被干扰成了异常值直接丢弃比强行接收更安全。第三个是校验失败此时 rx_done 不会被置位主循环不会处理这帧数据等于静默丢弃。3.3 主循环里的帧分发接收状态机把一帧完整数据装进了 rx_buf置 rx_done1。主循环要做的是轮询这个标志然后按命令字分发处理/* main.c — 主循环帧分发 */ void main(void) { System_Clock_Init(); UART0_Init(); EA 1; while (1) { if (rx_done) { process_frame(); } /* 其他周期任务放这里 */ } } /* protocol.c — 命令分发 */ void process_frame(void) { switch (rx_cmd) { case 0x01: /* 查询设备信息 */ handle_get_version(); break; case 0x02: /* 控制LED */ handle_set_led(rx_buf[0]); break; default: break; } rx_done 0; /* 处理完必须清标志否则会重复处理 */ }handle_get_version 和 handle_set_led 内部会调用 protocol_send 组帧回执。比如收到查询版本命令后回复一帧固件版本号上位机就能在串口助手里看到完整的 AA 55 开头的报文。这里有一个工程习惯值得从一开始就养成process_frame 里只做命令分发不写具体业务逻辑具体动作都放到独立的处理函数里。后续每增加一个命令程序结构就多一个 case 和一个处理函数不会把 main.c 变成一团乱麻。4. 常见问题排查三处让串口协议翻车的细节4.1 现象波特率配置看起来没问题但收发的数据全是乱码这是串口开发里出现频率最高的一个问题现象是上位机收到字符完全不可读或者偶尔第一个字节正确、后面的字节全部错位。原因有两层第一CMS8S6990 这类增强型 8051 的系统时钟源可能是内部 RC也可能是外部晶振如果固件里没有显式配置时钟源实际跑起来的主频和代码里假设的主频不一致波特率自然对不上第二1T 内核的波特率计算公式与传统 12T 的 8051 不同直接套用网上 STC 或 AT89S52 的 TH1 计算结果误差会被放大。解决方法是先确认时钟源并显式初始化再用逻辑分析仪抓 TX 脚波形量一个字节的实际位宽。比如设了 115200理论上一位是 8.68 微秒实测如果明显偏大或偏小按比例反推回到 TH1 的重载公式重新计算。这个流程熟练之后基本上五分钟就能定位波特率类问题。4.2 现象连续发两帧数据时接收端偶发一帧丢失或帧头错乱帧头错乱的具体表现是单帧发送一切正常但连续发多帧时第二帧有时收不到有时状态机像是「卡住了」需要重启才能恢复。原因通常是两帧之间没有留间隔接收端刚处理完第一帧的校验字节第二帧的帧头就紧跟着到了此时状态机虽然已经回到 ST_HDR1但主循环还没把 rx_buf 里的数据处理完新帧又覆盖了缓冲。解决方法是两件事一是发送端在两帧之间插入至少 2~5 毫秒延时给接收端留出处理时间二是在接收状态机的 ST_HDR2 分支里当第二个字节不是 0x55 时不要直接丢弃当前字节而应先判断它本身是否是 0xAA如果是就保持在 ST_HDR2 继续等下一个字节。这个小改动能让状态机在半截帧的情况下更快恢复同步属于典型的「状态机边界条件」优化。4.3 现象一旦在中断里做复杂处理串口偶发丢字节有人为了省事把命令处理逻辑直接写进串口中断函数比如在中断里调用 protocol_send 发回执。现象是数据量小的时候没问题一帧里数据多了比如收到 32 字节数据域就偶尔丢一个字节。原因在于 8051 的串口硬件只有一个 SBUF 寄存器接收缓冲区深度只有 1 字节。如果上一个字节还没被取走下一个字节到达就会直接覆盖造成丢数据。中断函数里代码越长关中断时间越久丢字节概率越高。解决方法是严格遵守「中断只做接收和状态转移」的原则把业务处理全部放到主循环的 process_frame 里。以这个工程的中断函数为例从进入中断到返回只执行了一些比较和赋值操作指令周期在微秒级远远小于一个字节的接收时间自然不会丢。记住一个判断标准中断函数里如果出现了函数调用、循环等待或复杂计算就说明设计出了问题。5. 工程搭建到跑通从新建工程到实机验证的完整步骤5.1 工程目录与代码文件怎么摆很多初学者习惯把所有代码塞进一个 main.c协议程序几百行之后改一个宏定义都得翻半天。这里推荐按功能拆文件一个芯片工程拆成四个模块project/ ├── main.c /* 主循环与帧分发 */ ├── uart.c /* 串口初始化与中断 */ ├── uart.h ├── protocol.c /* 协议组帧、解析状态机 */ ├── protocol.h └── system_config.h /* 时钟配置、引脚定义 */uart.h 只对外开放 UART0_Init 和 uart_send_byte 两个函数protocol.h 对外开放 protocol_send、process_frame以及 rx_done、rx_cmd、rx_buf 这组接收结果变量。头文件里不要暴露内部状态机的枚举和静态变量这样下次想改协议结构不影响外部调用方。main.c 里只需要 include 两个头文件剩下的事情都交给协议模块主循环保持干净。5.2 编译烧录与串口通信联调CMS8S6990 可以使用通用的 8051 编译工具链新建工程后把上述文件加入编译生成 hex 文件后烧录进芯片。接线方面芯片的 TX 接 USB 转 TTL 小板的 RX芯片的 RX 接小板的 TX同时两块板子必须共地。很多初学者漏了共地导致串口通信「时好时坏」这是硬件层面最典型的玄学问题。烧录完成后打开串口助手选择对应串口号波特率设置为 115200勾选 HEX 发送然后发送一帧测试命令AA 55 02 01 01 04对照第 2 章的帧格式拆解AA 55 是帧头02 是 CMD控制 LED01 是 LEN01 是数据域点亮04 是 CMD 加 LEN 加数据域的累加和。校验计算过程是 0x02 0x01 0x01 0x04。如果开发板的 LED 应声点亮同时串口助手能收到一条如下格式的回执帧AA 55 02 00 02说明发送、接收、解析、分发、回包整条链路已经打通。回执帧里 CMD 还是 0x02LEN 为 0校验和就是 0x02 0x00 0x02。这一步跑通了整个协议工程的核心目标就达成了。5.3 用逻辑分析仪验证波形时序如果手边有逻辑分析仪可以把它夹在芯片 TX 引脚和地之间抓一帧发送波形。观察点有两个一是起始位和 8 个数据位的时间宽度是否与 115200 波特率匹配这能直接验证 TH1 重载值是否准确二是两帧之间是否能清楚看到整段空闲高电平能直观理解「帧间隔」对接收状态机的影响。这一步虽然不影响跑通但能帮你建立串口时序的手感以后再遇到波特率偏差时会有方向感。6. 二次开发思路让协议能扩展而不是推倒重写6.1 协议层与应用层分离带来的扩展空间工程里 process_frame 只做事分发的设计本质上已经把协议层和应用层解耦了。接下来扩展一个多机通信需求时只需要在帧结构里增加一个地址字段把校验和从 CMD 开始改成从地址开始收端状态机多一个 ST_ADDR 状态应用层完全不需要改动。如果一帧数据超过 32 字节把 LEN 字段从 1 字节扩展为 2 字节同时保留第一个字节的高两位作为扩展标志老设备仍然能按旧格式解析短帧新设备通过标志识别长帧。这样协议演进不需要版本 2.0 重写。6.2 给协议加上超时重传机制另一个值得加的能力是应答与超时重发。目前协议是单向的发送端发出后不确定对端是否收到。可以定义一条新的命令字 0x80 表示应答接收端处理完一帧后原样回传 CMD发送端记录发出的 CMD 和帧序号开启一个 5 毫秒定时器超时未收到应答就重发最多重发 3 次。实现时只需要用到 CMS8S6990 的定时器 0在中断里维护一个发送状态机和重发计数这部分已经超出了入门范围但有了前面的状态机基础理解起来并不难。从那以后我每次做串口通信都会强制走一遍「定义帧格式 → 写状态机 → 回环测试 → 抓波形」这套流程再没有出现过「串口坏了但是找不出原因」这种玄学问题。这套流程同样适合你手头的第一个串口协议工程希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑