简介:面向单片机初学者的串口收发练习工程,主控采用中微CMS8S6990,演示在裸机环境下加入简易通信协议后的数据收发与解析流程。压缩包共144个文件,其中36个h头文件、33个c源文件构成代码主体,覆盖定时器初始化、ADC采集、比较器处理、串口收发和中断服务等常用外设模块;另有26个obj、26个lst编译中间文件,配合hex烧录文件和A51启动文件,方便对照源码逐行理解编译、链接和下载过程,整个压缩包约2.05MB。目前已有1554人学习下载,适合初学者作为串口通信入门的参考资料。借助该工程可以掌握自定义帧格式、帧头帧尾识别、长度与校验解析,以及串口中断方式收发的常用写法;还能参考文件组织方式与模块划分思路,快速迁移到自己的单片机小项目中。整体设计简洁,适合作为课外练手与课内实验的补充材料。
1. 给串口加一层简单协议:中微CMS8S6990上被忽略的可靠性问题
很多开发者用中微CMS8S6990这颗8051内核MCU做串口收发时,第一版代码都是“裸奔”的:收到一个字节就往全局数组里塞,主循环里解析。数据少时一切正常,一旦对端是传感器模块、上位机或者另一块板子,线缆一长、干扰一来,数据就错位、粘包、丢字节,整个系统像个黑匣子。标题里的“简单协议”其实就是在原始字节流上补一层帧格式,解决三件事:这一帧从哪里开始、到哪里结束、内容有没有被改。这篇文章按我实际调项目的思路,把协议设计、CMS8S6990的串口初始化、接收状态机和各类坑一次讲透。
2. 协议帧格式怎么定:长度、校验与帧边界的取舍
2.1 帧边界怎么定:帧头帧尾和长度字段的配合
裸串口最大的问题是字节流没有边界。对端连续发来01 02 03,接收方不知道这是三条单字节命令还是一条三字节数据。解决办法就是在数据前后加标记,最常见的是“帧头 + 长度 + 数据 + 校验”的结构。
我在实际项目里常用这样一种最小帧:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2字节 | 固定0xAA 0x55 |
| 长度 | 1字节 | 从命令字到校验和的字节数 |
| 命令字 | 1字节 | 功能号,比如 0x01 读温度 |
| 数据域 | N字节 | 具体负载 |
| 校验和 | 1字节 | 前面所有字节累加和的低8位 |
帧头选0xAA 0x55是经验:0xAA的二进制是10101010,0x55是01010101,波形上一高一低交替,示波器或者逻辑分析仪抓波形时肉眼就能分辨,而且误码后的特征很明显。
要不要再加帧尾?我的结论是“简单协议不加”。既然长度字段已经告诉接收方这一帧该有多少个字节,接收端数够了就把校验和拿出来比对,帧尾只是冗余。加了帧尾反而带来一个问题:接收端必须多等一个字节才能确认帧结束,状态机更复杂。长度字段合法、校验能通过时,帧尾没有收益。
2.2 校验选择:和校验、异或校验与CRC8的取舍
校验是“简单协议”里最容易糊弄过去的部分。很多初学者直接不校验,或者用异或,但这里的取舍会直接影响误码检出率。
| 校验方式 | 实现成本 | 漏检情况 | 适用场景 |
|---|---|---|---|
| 异或校验 | 1行代码 | 字节顺序调换时漏检,偶数个相同位翻转漏检 | 对端固定、数据量极小 |
| 累加和校验 | 1行代码 | 数据位错乱但总和不变时漏检 | 字节数少于32、环境干扰小 |
| CRC8 | 十几行代码或查表 | 漏检率远低于前两者 | 帧长几十字节、环境有电机干扰 |
我的习惯是:帧长在32字节以内、波特率不超过19200时,用累加和;帧长超过32字节或者现场有继电器、电机这类干扰源,直接上CRC8。CRC8实现并不复杂,下面这个就是多项式0x07的逐位算法,不用查表,代码量也不大:
#include <stdint.h> uint8_t crc8_calc(const uint8_t *data, uint16_t len, uint8_t poly) { uint8_t crc = 0x00; for (uint16_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 0x80) { crc = (crc << 1) ^ poly; } else { crc <<= 1; } } } return crc; }这里poly就是生成多项式,常用0x07(CRC-8/ROHC)或0x31(CRC-8/MAXIM),具体用哪个没有严格限制,只要发送端和接收端一致就行。逐位算法慢,但对CMS8S6990这种8051内核来说,一帧几十字节的运算量完全能接受;如果波特率上到115200且帧很长,建议换成查表法。
2.3 帧头出现在数据里怎么办:转义机制的取舍
这是协议设计里最隐蔽的坑。帧头是0xAA 0x55,如果数据域里恰好也出现0xAA 0x55,接收端的有限状态机就会把这个位置当成新帧头,结果一帧被劈成两半。
处理办法有三条路:
- 限制数据范围:业务数据里禁止出现
0xAA或0x55,比如只允许ASCII可见字符。最省事,但通用性差。 - 转义:像二进制传输协议里常见的那样,数据中出现特殊字节时插入转义符。实现要多一个状态,代码量上去了。
- 不做转义,靠校验踢掉坏帧:解析出来的帧校验不过就丢弃,由上层重发机制兜底。
“简单协议”我一般选第3种。理由是:协议简单意味着调试成本低、出错点少,转义机制本身也是bug来源。既然帧头只占0xAA 0x55两个字节,业务数据里恰好连续出现这两个值的概率不高,偶尔出现导致的错帧会被校验挡住,代价只是丢一帧。如果对端业务数据是不可控的二进制流,那就得考虑转义,下面给一个最小转义实现:
#define ESC_CHAR 0x7D #define HEAD1 0xAA #define HEAD2 0x55 // 发送前做转义:遇到帧头字节或转义符,前面加一个 ESC uint8_t *escape_frame(const uint8_t *src, uint16_t len, uint8_t *dst, uint16_t *dst_len) { uint16_t j = 0; for (uint16_t i = 0; i < len; i++) { if (src[i] == ESC_CHAR || src[i] == HEAD1 || src[i] == HEAD2) { dst[j++] = ESC_CHAR; } dst[j++] = src[i]; } *dst_len = j; return dst; }接收端反向处理:读到0x7D时,下一个字节直接作为数据写入缓冲区,不再参与帧头匹配。注意转义符本身如果出现在原始数据里,也要转义,否则接收端会误删一个字节。
3. CMS8S6990串口初始化与发送帧:从寄存器到发送队列
3.1 中微CMS8S6990的串口资源与波特率计算
CMS8S6990是8051内核,串口模块的基本结构沿用了标准8051的SCON、SBUF、PCON这套SFR体系,用定时器1或定时器2产生波特率。和标准8051老芯片相比,这颗芯片的时钟源更灵活,有内部RC振荡器和外部晶振可选,寄存器名称和映射细节各家批次的芯片手册会有差异,下面的代码按标准8051惯例写,实际移植时务必对着数据手册的UART章节核对SFR名称。
波特率公式(定时器1模式2,SMOD=0)是:
波特率 = 系统时钟 / (12 × 32 × (256 - TH1))这里默认是12分频的8051定时器模型。如果你的芯片支持1T模式,要把公式里的12换成分频系数。我算过几组常用配置:
| 目标波特率 | 晶振 | SMOD | TH1值 | 实际波特率 | 误差 |
|---|---|---|---|---|---|
| 9600 | 11.0592MHz | 0 | 0xFD | 9600 | 0% |
| 19200 | 11.0592MHz | 1 | 0xFD | 19200 | 0% |
| 115200 | 11.0592MHz | 需定时器2或1T模式 | — | — | 常规12T出不来 |
注意最后一行:11.0592MHz晶振配12T定时器,无法精确产生115200,误差高达8%,这种情况下串口通信会出现高概率乱码。很多开发者在这上面翻过车,后面避坑章节会专门讲。
3.2 串口初始化与单字节发送:代码与寄存器含义
下面是CMS8S6990串口0的初始化,按标准8051惯例编写。注意我把发送查询标志TI在初始化时置1,这是很多人掉进去的坑——TI默认是0,第一次进入发送函数会直接卡死在等待循环里:
#include <stdint.h> #define FOSC 11059200UL // 11.0592MHz 晶振 void uart0_init(void) { SCON = 0x50; // 模式1:8位数据、可变波特率;REN=1 允许接收 PCON &= 0x7F; // SMOD=0,波特率不倍频 TMOD &= 0x0F; // 只改定时器1,保留定时器0的配置 TMOD |= 0x20; // 定时器1工作模式2:8位自动重载 TH1 = 0xFD; // 9600 @ 11.0592MHz, SMOD=0 TL1 = 0xFD; // 重载值同步 TR1 = 1; // 启动定时器1 TI = 1; // 关键:让首次发送查询通过 ES = 1; // 打开串口中断 EA = 1; // 打开总中断 } void uart0_send_byte(uint8_t ch) { while (!TI); // 等待上一字节发送完成 TI = 0; // 清除发送完成标志 SBUF = ch; // 写入SBUF开始发送 }初始化顺序有讲究:先配置SCON,再配置定时器,最后开中断。如果先开ES再配置波特率,中间有一个窗口期,串口收到噪声字节会立刻触发中断,而这时SBUF和波特率都还没就绪,容易产生假中断。TI置1这行不要删,删了第一个字节发送就卡死,这是8051串口的老毛病。
3.3 有了协议之后,发送函数应该发一帧而不是一个字节
很多人的代码会写成在主循环里依次调用三次uart0_send_byte(0xAA)、uart0_send_byte(0x55)……这样不光代码冗余,还容易出现“发到一半被中断打断、协议帧不完整”的问题。正确做法是封装一个发送帧的函数,一次性把整帧推出去:
// 帧格式: [AA][55][长度][命令][数据...][校验和] // 长度字段 = 命令1字节 + 数据N字节 + 校验1字节 = N + 2 void uart0_send_frame(uint8_t cmd, const uint8_t *data, uint16_t data_len) { uint8_t sum = 0xAA + 0x55; uint16_t i; uart0_send_byte(0xAA); // 帧头1 uart0_send_byte(0x55); // 帧头2 uint8_t len = data_len + 2; // 命令 + 数据 + 校验 sum += len; uart0_send_byte(len); // 长度字段 sum += cmd; uart0_send_byte(cmd); // 命令字 for (i = 0; i < data_len; i++) { sum += data[i]; uart0_send_byte(data[i]); // 数据域 } uart0_send_byte((uint8_t)(sum & 0xFF)); // 累加和低8位 }这里每次发送前都计算累加和,累加和的初始值包含两个帧头字节,这样校验范围覆盖到帧头,能顺带检查帧头有没有被干扰。如果发送频率高、数据量大,每个字节都等TI标志会阻塞主循环,升级做法是建一个发送缓冲区,把待发字节存入数组,由中断里的TI标志驱动逐个发送。数据量不大时阻塞发送更简单可靠,这属于够用就行的方案。
4. 接收侧的处理:中断里的状态机与粘包/半包拆分
4.1 为什么接收必须用状态机:中断里不能停下来等
裸串口的接收通常这样写:主循环里每隔一段时间检查RI标志,有数据就读SBUF。但这个写法在波特率高于9600时就变得不可靠——主循环里一旦有耗时操作,比如驱动OLED屏幕、处理按键消抖,串口硬件收到新字节时RI已经置1,而旧字节还没被读走,新字节会把SBUF覆盖,丢数据就是这么发生的。
正确做法是把接收放进中断,每个字节到达时立刻读走SBUF,丢给一个状态机去解析。状态机的本质是“按当前位置决定这个字节怎么处理”,对应帧格式里的各个字段:
ST_IDLE --0xAA--> ST_HEAD1 ST_HEAD1 --0x55--> ST_LEN ST_LEN --长度--> ST_DATA ST_DATA --收满--> 比对校验和 -> ST_IDLE这种“逐字节推进”的方式和缓冲区扫描法相比,优点是实时性好、不依赖定时轮询,芯片在休眠唤醒后也能靠中断第一时间恢复同步。
4.2 接收状态机实现:从中断入口到校验比对
下面给出接收状态机的完整实现,放在串口中断里调用。注意这里只做状态推进和字节缓存,不做业务解析,保证中断耗时最短:
enum { ST_IDLE = 0, // 等待帧头1 ST_HEAD1, // 已收到0xAA,等待0x55 ST_LEN, // 已收到0x55,等待长度字段 ST_DATA // 接收数据域 + 校验和 }; static volatile uint8_t rx_state = ST_IDLE; static volatile uint8_t rx_len; static volatile uint8_t rx_cnt; static volatile uint8_t rx_sum; static volatile uint8_t rx_buf[64]; static volatile uint8_t rx_frame_ready; // 主循环轮询这个标志 void uart0_rx_parse(uint8_t b) { switch (rx_state) { case ST_IDLE: if (b == 0xAA) { rx_state = ST_HEAD1; } break; case ST_HEAD1: if (b == 0x55) { rx_state = ST_LEN; rx_cnt = 0; rx_sum = 0xAA + 0x55; // 校验和从帧头开始累加 } else if (b == 0xAA) { // 连续两个0xAA,保持等待0x55 } else { rx_state = ST_IDLE; // 帧头不匹配,重新开始 } break; case ST_LEN: if (b < 2) { // 长度至少包含命令和校验 rx_state = ST_IDLE; } else { rx_len = b; rx_sum += b; rx_cnt = 0; rx_state = ST_DATA; } break; case ST_DATA: if (rx_cnt < rx_len - 1) { // 命令字和数据域:缓存并累加校验 rx_buf[rx_cnt++] = b; rx_sum += b; } else { // 最后一个字节是校验和,比对后复位状态机 rx_state = ST_IDLE; if (b == (uint8_t)(rx_sum & 0xFF)) { rx_frame_ready = 1; // 一帧完整且校验通过 } } break; default: rx_state = ST_IDLE; break; } } void uart0_isr(void) { if (RI) { RI = 0; uart0_rx_parse(SBUF); } // TI 的中断分支留给发送缓冲区用,查询发送时不需要 }状态机里几个容易被忽略的细节:ST_HEAD1状态收到重复的0xAA时选择保持等待,这比直接回到ST_IDLE更稳,因为对端如果不是严格按帧发送,偶尔多出一个0xAA不应破坏已同步的帧头;ST_LEN里对长度做了下限校验,长度小于2说明帧格式本身非法,直接丢弃比往后硬解析好;校验和从帧头开始累加,和发送端的计算方式保持一致。
4.3 半包与粘包:超时复位与标志位互斥
状态机最怕“半包”——收到帧头后对端断电或者线缆断开,状态机停在ST_LEN或ST_DATA里,后续任何新数据都会被当成当前帧的剩余部分,整个协议卡死。解决办法是加一个超时机制。用一个软件计数器,主循环里每次检查状态机不在ST_IDLE时累加,超过阈值就强行复位:
static volatile uint16_t rx_timeout_cnt; // 在主循环或定时器中断里周期调用 void uart0_rx_timeout_check(void) { if (rx_state != ST_IDLE) { if (++rx_timeout_cnt > 2000) { // 以1ms周期算,约2秒超时 rx_timeout_cnt = 0; rx_state = ST_IDLE; // 强行复位,等待下一帧 } } else { rx_timeout_cnt = 0; } }超时阈值按波特率算一下:9600波特率下一个字节约1.04ms,一帧几十字节最多几十毫秒,2秒的阈值余量足够,也不会误伤正常的数据间隔。
粘包的问题反而比半包简单。当两帧连续到达时,第一帧解析完状态机回到ST_IDLE,第二个帧头0xAA会立刻开启新一帧的解析,天然支持背靠背数据。真正的坑在主循环取走数据的方式:如果主循环检测到rx_frame_ready后直接读rx_buf,而这时新一帧已经到达并写入同一缓冲区,数据就会被覆盖。稳妥做法是在主循环里关中断拷贝后再清标志:
if (rx_frame_ready) { uint8_t frame_tmp[64]; uint8_t frame_len; EA = 0; // 关串口中断,防止新帧写入 frame_len = rx_cnt - 1; // 去掉校验和,命令+数据 memcpy(frame_tmp, rx_buf, frame_len); rx_frame_ready = 0; EA = 1; process_frame(frame_tmp, frame_len); // 业务解析 }注意EA=0到EA=1之间不能调用耗时函数,SDCC或Keil的库函数有些会隐含使能中断,这里用memcpy拷贝几十字节是安全的。如果帧特别长,可以改成双缓冲:一个缓冲区让中断写,另一个让主循环读,轮换使用,彻底消除复制开销。
5. 串口协议调试避坑:五个真实翻车现场与排查路径
5.1 上电后第一帧的第一个字节神秘消失
现象:板子每次上电,对端发来的第一帧第一个字节永远收不到,后面的所有帧都正常。
原因:很多8051内核芯片上电后串口外设和I/O引脚还没完全稳定,软件里上电后立即初始化串口并开始接收,UART接收移位寄存器此时处于未就绪状态,第一个到达的字节直接丢失。另一个常见原因是初始化顺序错误——代码先把REN=1打开接收,波特率定时器还没启动,此时总线上有噪声也被当成正常接收。
解决:上电后延时50到100ms再执行串口初始化,或者在初始化完成前不要打开REN位。CMS8S6990这类芯片内部RC振荡器起振也需要时间,如果代码里用延时函数,确认延时是基于硬件定时器而不是纯空循环,否则编译器优化可能把延时循环吃掉。
5.2 波特率误差导致高波特率下偶发乱码
现象:9600波特率下通信完全正常,改成115200后每发送几十帧就出现一个字节错误,用串口助手看是“半个字节错位”。
原因:波特率误差超过一定阈值。UART接收端在每个位的中间采样,误差累计到某个位时采样点偏离到位的边缘,就会采到错误电平。12T模式的8051在11.0592MHz下算不出115200的整数分频,误差接近8.5%,稳翻车。
解决:先不要怀疑接线,用示波器量一下TXD引脚上一个数据位的实际宽度:9600波特率应为约104.2us,115200应为约8.68us。如果实测明显偏离,优先换晶振方案或者改用定时器2产生波特率;如果只能用内部RC振荡器,检查芯片手册里有没有时钟校准寄存器(TRIM值),出厂校准值如果不准,写一个校准例程把内部时钟微调到目标频率附近。监控一个波特率误差红线:超过±2%就要警惕,±4%以上基本必出错。
5.3 半包后协议“卡死”:状态机停在半路
现象:通信过程中对端突然重启或者拔线,之后MCU再也无法正确解析任何帧,重启MCU才恢复。
原因:状态机停在ST_LEN或ST_DATA,后续新帧的数据被当成旧帧的剩余内容,长度字段里的计数永远填不满,状态机走不到校验那一步。
解决:按4.3节加超时复位。还有一个隐蔽点:超时计时依赖主循环或者定时器中断,如果MCU在某个中断里执行长耗时任务,超时检查被长期推迟,状态机“假活”。所以超时检查要放在定时器中断里,优先级高于普通业务。我看到过某开发者把超时判断放在一个被串口阻塞的循环里,结果越等越卡,最后只能看门狗复位。
5.4 中断里做耗时操作引起的连续丢帧
现象:波特率越高丢帧越严重,而且每次丢帧都是连续的,不是偶发单个字节。
原因:串口中断里做了太多事——最常见的是调用printf或者sprintf。8051内核处理格式化输出非常慢,一个printf可能要几毫秒,这期间串口硬件已经接收了几十个字节,SBUF被反复覆盖,溢出标志置位,但中断服务函数还在跑,根本顾不上读数据。
解决:中断里只做三件事——读SBUF、清RI标志、把字节喂给状态机。多余的校验、业务解析全部放主循环。如果确实需要发送调试信息,把发送内容先放入缓冲区,中断里只处理发送完成标志,实际发送在缓冲区里排队执行。这个“中断里禁止printf”的原则,基本可以解决80%的串口丢帧问题。
5.5 数据里出现帧头导致的假帧
现象:对端正常发送数据,偶尔一帧校验失败,而且失败帧的位置看起来不规律,用串口助手抓包发现“帧头”出现在数据中间。
原因:协议没做转义,数据域里恰好出现0xAA 0x55,接收端从这个位置当成了新帧头,把原来的帧切碎了。前面介绍过三种处理路径,这里再补充一个判断依据:如果业务数据是固定长度、固定内容的命令码,出现帧头组合的概率极低,不值得为它加转义;如果业务数据是传感器采集的连续字节流,比如图像数据、音频数据,那几乎必然会出现帧头组合,必须上转义。
解决:先看应用场景。数据占比小、对端可控,靠校验踢掉坏帧;不可控的二进制流,用转义。转义实现时注意:转义符本身也要转义,而且发送端转义后长度字段要重新计算,接收端状态机多一个“转义状态”——收到转义符后,下一字节直接进入数据缓存,不能再当帧头。这个状态漏掉,转义方案就是负优化。
6. 验证协议可靠性的最小测试方案与一个进阶技巧
协议写完不等于能上线,先做一个10分钟能完成的自测。最简单的是硬件回环:把TXD和RXD短接,MCU发什么自己收什么。但回环测试测不出噪声干扰,只能验证协议状态机没有语法错误。要测可靠性的最小方案是:让PC端脚本随机生成大量带校验的帧发给MCU,MCU收到后原样回传,脚本比对收发一致性:
import serial import random ser = serial.Serial('COM10', 9600, timeout=0.2) def make_frame(cmd: int, data: bytes) -> bytes: body = bytes([cmd]) + data length = len(body) + 1 checksum = (0xAA + 0x55 + length + sum(body)) & 0xFF return bytes([0xAA, 0x55, length]) + body + bytes([checksum]) for i in range(2000): data = bytes(random.getrandbits(8) for _ in range(random.randint(1, 16))) frame = make_frame(i & 0xFF, data) ser.write(frame) recv = ser.read(len(frame)) if recv != frame: print(f'fail at {i}: {frame.hex()} vs {recv.hex()}') break else: print('all ok')跑2000帧随机数据,全部通过后再说“协议可用”也不迟。跑测试时最好把波特率调到最终应用的波特率,因为波特率误差问题只有在高速下才能暴露。
进阶技巧:给CMS8S6990的串口中断提高优先级。这颗芯片如果同时用了定时器中断和串口中断,默认优先级分配不当的话,定时器中断频繁触发会延迟串口中断的响应,中断响应延迟超过一个位时间就容易在115200下丢字节。做法是把串口中断的优先级位设成高优先级,确保串口中断可以打断其他中断:
// 标准8051惯例:IP寄存器bit4对应串口中断优先级 IP |= 0x10; // 串口中断设为高优先级如果项目里串口已经是最高优先级,那要反过来检查定时器中断服务函数里有没有长循环。我印象最深的一次调试就是:某开发者把按键扫描放进了一个高优先级定时器中断,里面用了阻塞式延时,结果串口在高优先级中断执行期间收进来的字节全部进不了状态机——中断里嵌套中断,看似代码在跑,数据全丢。后来把按键扫描挪到主循环,串口中断独占高优先级,问题立刻消失。希望这个排错思路帮到你,调试串口协议时先量波形、再查中断、最后才怀疑芯片,顺序反了容易被玄学带偏。
本文还有配套的精品资源,点击获取