简介:一份基于中微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模式2(8位自动重装)时,重装值的计算公式是:
TH1 = 256 - (晶振频率 / 12 / 32 / 波特率) = 256 - (11059200 / 12 / 32 / 9600) = 256 - 3 = 0xFD这个0xFD就是经典值,很多老工程师闭着眼都在用。如果换晶振或者换波特率,套公式算就行。
2.2 帧结构定义
协议设计我不喜欢搞花活,稳定直观是关键。这次定的帧结构如下表:
| 字段 | 帧头1 | 帧头2 | 命令字 | 数据长度 | 数据域 | 校验和 | 帧尾1 | 帧尾2 |
|---|---|---|---|---|---|---|---|---|
| 长度(字节) | 1 | 1 | 1 | 1 | N | 1 | 1 | 1 |
| 示例 | 0xAA | 0x55 | 0x01 | 0x03 | 0x11 0x22 0x33 | 0x?? | 0x0D | 0x0A |
帧头用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模式,REN=1允许接收 TMOD &= 0x0F; // 不干扰定时器0的配置 TMOD |= 0x20; // 定时器1设为模式2,8位自动重装 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这套代码,我后来在几个项目里复用,每次调整的只是命令字和业务处理函数,框架基本不变。如果你也在做类似设备的串口通信,可以直接把这套协议拿过去改改帧头和命令字,减少自己踩坑的时间。
本文还有配套的精品资源,点击获取