
简介一份基于中微CMS8S6990单片机的串口收发演示工程核心是在基础串口收发功能上增加一套简单的通信协议解决裸串口传输出错、无法分包的问题适合刚接触单片机通信的初学者学习参考。工程源码包含uart.c、timer.c、adc.c、epwm.c、acmp.c、isr.c等模块从底层寄存器配置到中断处理均有涉及可帮助理解波特率设置、定时器辅助超时判断、中断接收与状态机解析等关键思路。压缩包内共有144个文件以C源码与对应头文件为主同时包含编译链接产生的obj、lst、hex、map等工程文件以及Keil工程配置和备份整体约2.05MB目录结构清晰便于对照学习与二次修改。目前已有1550人学习下载适合希望快速上手串口协议设计的单片机入门者通过这套工程能直观掌握数据帧封装、校验与解析的完整流程。 搞单片机这些年我发现在串口收发这件事上翻车最多的地方往往不是芯片本身而是通信双方“各说各话”。就拿我最近用中微CMS8S6990做的一个小设备来说刚开始就是最普通的串口收发——上位机发一帧单片机回一帧看着简单结果一接到现场就各种出幺蛾子明明发了指令单片机偶尔收不全偶尔数据又对上了下一包又错位。折腾了两天最后老老实实把裸收发改成了带简单协议的串口收发世界瞬间清净了。这期就聊聊我在CMS8S6990上加这套协议的全过程包括帧结构设计、发送组帧、接收状态机以及调试中踩过的坑很适合正在做单片机通信、传感器采集或简单上位机交互的朋友参考。1. 为什么串口收发非要加个协议1.1 裸串口的翻车现场很多人一开始都觉得串口收发嘛一个发一个收SBUF寄存器一写一读就完事了。这个想法在实验室里确实没问题但一旦设备连起来、跑起来问题就接踵而至。最常见的翻车场景是这样的上位机给单片机发一段数据比如01 03 00 00 00 0A单片机端用中断接收每进来一个字节就塞到数组里。第一次发数据单片机收到了完整的6个字节一切正常。第二次、第三次数据多了或者上位机发送间隔不稳定单片机这边就开始出现各种诡异的现场数组里突然多出几个字节、数据错位、上一帧的尾巴混进下一帧的头。如果这时候边上还有电机、继电器这类干扰源丢字节、乱码就更频繁了。再有一种情况是“粘包”。上位机连续发两条指令中间间隔只有几毫秒单片机还在处理第一条指令第二条数据已经进中断了结果两帧数据在接收缓冲区里混在一起程序根本分不清哪是哪。裸收发在这种场景下基本是无解的。还有一种现场错误很隐蔽数据对不上但是看起来又“差不多”。比如上位机下发一组配置参数某个字节因为干扰发生了翻转单片机照单全收应用层拿到的参数就是错的设备行为变得不可控。这种错误用裸收发是发现不了的因为程序根本没有能力判断“这一帧数据是不是有问题”。其实这些问题的本质都一样串口本身只是管道它保证的是字节流的传输不保证字节之间的“关系”。谁和谁是一组的、哪里是一帧的起点、哪里是终点、这一帧有没有传错这些都得通信双方提前约定好——这个约定就是协议。1.2 一个简单协议至少要解决三件事在我接手这个项目之后我对协议的要求其实很简单不搞复杂但下面三件事必须解决。第一是帧同步。接收方必须能从连续的字节流里准确找到一帧的起点和终点即使前面有半个字节错位也能自动重新“对齐”。这通常靠帧头、帧尾实现比如帧头用AA 55帧尾用0D 0A。第二是帧长识别。一帧里到底有多少字节接收方必须清楚。可以在帧头后面加一个长度字段也可以在协议里固定每帧长度。固定长度实现简单但灵活性差长度字段多一个字节换来的是随便发多长的数据都行。通信内容经常变的场景强烈建议加长度字段。第三是差错检测。帧在传输过程中被干扰、丢字节后接收方要能发现“这帧坏了”然后把它丢掉而不是当成有效数据去处理。简单做法是累加和校验要求高就上CRC。校验一加上面说的“数据看起来差不多但实际是错的”的情况就能挡掉一大部分。这三件事做完串口通信基本就能在真实环境里稳定工作了。至于应答、重传、超时这些那是“可靠通信”的高级话题做简单设备指令交互时可以先不碰等确实需要再扩展。2. 基于CMS8S6990的协议帧设计2.1 芯片串口与时钟准备这次用的中微CMS8S6990是增强型8051内核的单片机串口外设的用法和标准8051非常接近SCON、SBUF、TI、RI这些寄存器都在写代码的手感很熟悉。开发环境就是Keil C51工程建好之后在target选项里选对应型号即可。如果你用的是其他型号的8位机这套代码移植时也只需要核对一下寄存器映射和中断号。这里想单独强调一下时钟。8051内核串口的波特率是由定时器1或定时器2产生的波特率准不准直接决定了通信稳不稳。我这次用了外部11.0592MHz晶振因为11.0592MHz这个频率有一个好处——它能被 12 × 32 × 9600 整除计算出的定时器重装值是整数。你要是用12MHz晶振跑9600波特率误差就会影响长帧通信的稳定性。波特率9600bps、定时器1模式28位自动重装时重装值的计算公式是TH1 256 - (晶振频率 / 12 / 32 / 波特率) 256 - (11059200 / 12 / 32 / 9600) 256 - 3 0xFD这个0xFD就是经典值很多老工程师闭着眼都在用。如果换晶振或者换波特率套公式算就行。2.2 帧结构定义协议设计我不喜欢搞花活稳定直观是关键。这次定的帧结构如下表字段帧头1帧头2命令字数据长度数据域校验和帧尾1帧尾2长度(字节)1111N111示例0xAA0x550x010x030x11 0x22 0x330x??0x0D0x0A帧头用AA 55两个字节目的是尽量降低数据域里出现相同序列导致的误同步概率。如果数据域里正好出现AA 55且后面的内容又与帧结构吻合理论上确实会造成误判但实际概率极低。真要对可靠性要求苛刻的场合可以把帧头换成AA 55 5A A5这种4字节序列代价是多两个字节的开销。命令字用于区分这帧是干什么的比如0x01读设备状态、0x02写配置参数这个可以根据业务自行定义。数据长度是数据域的字节数上限要预先设定好我这次定为48字节按缓存区容量来的。帧尾用0D 0A也就是\r\n方便调试助手直接查看也能起到二次定位边界的作用。这个结构加起来是 4 N 个字节不含帧头帧尾的情况下是 2 N但实际按上面的表算就是固定开销4字节 N。简单、紧凑足够覆盖大多数指令交互和状态上报。2.3 校验方式为什么用累加和校验这块我直接选了累加和而不是CRC。原因很现实累加和实现就几行代码占一个字节对于9600波特率下的指令交互场景它的检错能力已经能覆盖绝大多数干扰造成的错误。累加和的计算范围要明确从命令字开始一直累加到数据域的最后一个字节帧头和帧尾不参与计算。也就是校验和 (命令字 数据长度 数据域所有字节) 的低8位。发送端算好填进校验字段接收端用同样的算法算一遍比对不一致就说明这帧坏了直接丢弃。我知道有人会说CRC更可靠这话没错。CRC16的检错能力确实比累加和高一个量级但代价是代码量和计算时间。在CMS8S6990这类8位机上数据量不大、频率不高时累加和完全够用。如果哪天你的通信链路穿过工业现场、距离又远、干扰又强再升级CRC也不迟。设计协议时把校验字段放在固定位置将来从累加和换CRC接收端的解析框架可以完全不动。3. 协议落地发送组帧与接收状态机3.1 串口初始化协议设计好了接下来就是代码。先看串口初始化CMS8S6990这类增强型8051的寄存器跟标准8051基本一致直接看代码void UART_Init(void) { SCON 0x50; // 8位UART模式REN1允许接收 TMOD 0x0F; // 不干扰定时器0的配置 TMOD | 0x20; // 定时器1设为模式28位自动重装 TH1 0xFD; // 9600bps 11.0592MHz TL1 0xFD; TR1 1; // 启动波特率发生器 ES 1; // 开串口中断 EA 1; // 开总中断 rx_state ST_IDLE; rx_index 0; }这里有个细节容易踩坑TMOD寄存器只改了高4位低4位是给定时器0用的如果直接TMOD 0x20会把你之前配好的定时器0模式清掉导致别的功能莫名其妙失效。我习惯用 0x0F再| 0x20只操作自己负责的位。3.2 发送端组帧实现发送端的代码我是照着帧结构一步一步填的核心代码如下void UART_SendByte(uint8_t dat) { SBUF dat; while (!TI); // 等待发送完成TI由硬件置1 TI 0; // 软件清零 } void UART_SendFrame(uint8_t cmd, uint8_t *data, uint8_t len) { uint8_t i; uint8_t sum 0; UART_SendByte(0xAA); // 帧头 UART_SendByte(0x55); UART_SendByte(cmd); // 命令字 sum cmd; UART_SendByte(len); // 数据长度 sum len; for (i 0; i len; i) // 数据域 { UART_SendByte(data[i]); sum data[i]; } UART_SendByte(sum); // 校验和 UART_SendByte(0x0D); // 帧尾 UART_SendByte(0x0A); }这个实现是阻塞式的while (!TI)会占用CPU直到当前字节发完。9600波特率下一个字节大约1ms一帧十几个字节就是十几毫秒。对于多数指令交互场景完全够用。如果后续要频繁发大数据帧再改成中断发送也不迟这里不展开。3.3 接收端状态机解析接收端是整个协议的核心。我没有用“每次进中断就判断帧头”那种方式而是用了状态机每个中断进来先读当前字节再根据当前状态决定下一步做什么。消息缓冲区和状态变量如下#define ST_IDLE 0 // 等待帧头 #define ST_HEAD1 1 // 已收到0xAA等待0x55 #define ST_HEAD2 2 // 已收到0x55准备接收命令字 #define ST_CMD 3 // 已收命令字准备接收长度 #define ST_DATA 4 // 正在接收数据域 #define ST_CHECK 5 // 数据域接收完毕等待校验和 #define ST_TAIL1 6 // 已收校验和等待帧尾0x0D #define ST_TAIL2 7 // 已收0x0D等待0x0A uint8_t rx_state; uint8_t rx_buf[64]; // 存储 cmd len data uint8_t rx_index; // 当前填充位置 uint8_t rx_data_len; // 期望收到的数据长度 uint8_t rx_sum; // 收到的校验和中断服务函数里完整走一遍状态机void UART_ISR(void) interrupt 4 { uint8_t dat, calc, i; if (RI) { RI 0; dat SBUF; switch (rx_state) { case ST_IDLE: if (dat 0xAA) { rx_state ST_HEAD1; } break; case ST_HEAD1: if (dat 0x55) { rx_state ST_HEAD2; rx_index 0; } else if (dat ! 0xAA) { rx_state ST_IDLE; // 重新等待 } // 如果又收到0xAA保持ST_HEAD1继续等待 break; case ST_HEAD2: rx_buf[rx_index] dat; // 存命令字 rx_state ST_CMD; break; case ST_CMD: rx_buf[rx_index] dat; // 存长度 rx_data_len dat; if (rx_data_len 48) // 长度非法 { rx_state ST_IDLE; rx_index 0; } else if (rx_data_len 0) { rx_state ST_CHECK; // 无数据域直接等校验和 } else { rx_state ST_DATA; } break; case ST_DATA: rx_buf[rx_index] dat; if (rx_index 2 rx_data_len) // cmd len data 都收齐 { rx_state ST_CHECK; } break; case ST_CHECK: rx_sum dat; rx_state ST_TAIL1; break; case ST_TAIL1: if (dat 0x0D) { rx_state ST_TAIL2; } else // 帧尾异常 { rx_state ST_IDLE; rx_index 0; } break; case ST_TAIL2: if (dat 0x0A) { // 整帧收齐开始校验 calc 0; for (i 0; i 2 rx_data_len; i) { calc rx_buf[i]; } if (calc rx_sum) { ProcessFrame(rx_buf[0], rx_buf[2], rx_data_len); } } rx_state ST_IDLE; rx_index 0; break; default: rx_state ST_IDLE; rx_index 0; break; } } if (TI) { TI 0; } }这个状态机的好处是它对粘包天然免疫。假设上位机一口气发来两帧状态机收完第一帧的帧尾后立刻回到ST_IDLE第二帧的AA正好被下一个状态机周期吞进去不会产生错乱。如果某一帧中间被干扰、丢字节状态机会因为帧尾不对而丢弃整帧然后从下一个字节开始重新同步。这种设计比“用定时器超时判断一帧结束”的方式稳健得多也不需要额外开定时器。3.4 中断里解析还是主循环解析上面的实现是在串口中断里直接完成协议解析的。这样做对CMS8S6990这种场景完全够用。但要注意一个度如果一帧数据很长解析循环会占用中断比较久可能影响其他中断的实时性。我这边数据域限定48字节中断里做累加很快实测没什么问题。如果你手头的数据量更大或者系统里还有对时间敏感的中断建议把“收字节”和“解帧”拆开串口中断里只负责把原始字节塞进环形缓冲区主循环里再逐个取出、跑状态机、解析帧。这样中断处理时间极短解析也不会被其他中断打断。代价是代码量多一点。至于拆不拆判断标准很简单——主循环忙不忙、中断多不多。4. 调试实录常见问题与排查心得4.1 收不到数据先别怀疑协议协议写完上电测试最常见的现象是什么都不通。这时候我的排查顺序永远是先确认单片机到底有没有进入串口中断再谈协议。第一步用示波器或者USB转串口直接看单片机的TXD引脚有没有波形如果完全没波形说明数据根本没发出来检查SCON配置、波特率重装值、TR1有没有置1。第二步把串口中断服务函数里的协议解析全部注释掉只留一个LED翻转的测试代码看串口助手发一个字节时LED亮不亮。亮说明中断链路OK问题在协议解析不亮检查ES、EA、RI标志甚至查一下中断向量号是不是写的interrupt 4。这个问题看起来基础但真的会救你一命。另外还要确认共地。串口通信是看电平差的两个设备电源不共地接收端看到的波形就是飘的轻则乱码重则完全没反应。特别是调试板和目标板各用各的USB供电时这个问题经常出现。4.2 偶发丢帧和粘包排除接线和配置后偶发丢帧多半出在缓冲区管理上。我有一次测试时发现上位机用串口助手的“定时发送”功能间隔50ms发一次单片机偶尔丢一帧。查了半天问题出在中断里处理数据花了太长时间下一次中断来的时候SBUF里的新数据覆盖了旧数据RI标志没来得及被正确处理。后来我把接收解析改成只存原始字节、主循环里再解析问题就消失了。粘包的情况也遇到过。上位机连着发两帧间隔只有5ms如果接收方用“接收完固定字节数就处理”的逻辑就可能在第一帧还没处理完时被第二帧的数据打乱。状态机方案在这点上不容易踩坑因为我只在0D 0A全部到位后才认为一帧完整。如果你在协议解析时发现经常“多出几个字节”十有八九就是两帧粘在一起了检查状态机是不是在帧尾之后正确回到了IDLE状态。还有一个小坑想提醒一下调试助手的“发送新行”功能如果不小心勾选了“追加回车换行”数据末尾会自动多出0D 0A。如果你的协议帧尾也是0D 0A而且接收方状态机没那么严格很可能把追加的0D 0A当成下一帧的帧尾导致下一帧解析错乱。所以调试时要么关掉“追加新行”要么明确知道自己在发什么。4.3 多串口场景怎么扩展这套协议以前有个朋友拿AT32F403A做过一版程序8个串口同时收发问我要怎么处理。其实思路是一样的把状态机相关的所有变量装进一个结构体每个串口定义一个独立实例。典型做法是定义协议上下文结构体比如ProtocolCtx里面放state、buf、index、len、sum然后把ProcessFrame改成结构体指针的形式。每个串口的中断里调用同一个状态机解析函数传入各自的上下文指针。这样8个串口看似同时工作实际上底层是共用一套解析逻辑的只是每个串口的缓冲区彼此隔离。这种“实例化”的思路在CMS8S6990只有一个串口时体会不到价值但一旦项目升级到多串口芯片或者将来要加MODBUS、自定义私有协议一套代码里同时跑多种协议解析用结构体隔离状态是必须的。建议从现在起就把协议解析封装成函数别把所有变量都甩成全局量。最后再分享一个我在实际调试中的小习惯协议调试阶段我会在串口助手和单片机之间串联一个逻辑分析仪或者带时间戳的串口监控工具把RS232/TTL波形抓下来。这样做不是为了量电平而是为了看两帧之间的时间间隔以及确认帧头、帧尾到底有没有被裁剪或篡改。很多裸眼看串口助手显示“都对”的问题实际上在时间轴上是有异常的。协议这件事看着是给数据加了几个字节实际上是把通信双方从“猜”变成了“立规矩”。CMS8S6990这套代码我后来在几个项目里复用每次调整的只是命令字和业务处理函数框架基本不变。如果你也在做类似设备的串口通信可以直接把这套协议拿过去改改帧头和命令字减少自己踩坑的时间。本文还有配套的精品资源点击获取