给CMS8S6990串口加上轻量协议:从字节流到可靠帧的完整实现 简介针对中微公司出品的CMS8S6990型号单片机这套示例工程完整演示了串口收发时如何附加简单通信协议特别适合刚接触单片机开发的初学者用来理解数据帧、校验等基础概念。工程中提供了串口、定时器、ADC等模块的源文件和对应头文件同时包含编译中间文件以及可直接烧录的hex文件代码结构清晰便于对照学习初始化流程、中断接收与发送逻辑。整个压缩包共有一百四十四个文件其中以三十六个头文件和三十三个源文件为主要内容还包含目标文件、列表文件等编译产物以及工程配置文件整体体积约两兆字节非常轻量。目前已经有一千五百五十四人学习下载说明该示例对入门阶段的学习者确实有实用价值。通过阅读代码并配合工程中的目录组织读者能快速掌握在串口帧中加入帧头、命令字、校验等简单协议的方法同时了解中微CMS8S6990的定时器、PWM、比较器、ADC等外设驱动写法有效缩短从理论到实践的距离。1. 串口收发加了简单协议为什么CMS8S6990通信突然就“正常”了设备端和上位机对着同一个波特率线也接对了但串口助手收到的数据偶尔多一个字节偶尔少一个字节甚至一条完整指令被拆成两帧。这是做中微CMS8S6990这类8051内核项目最常翻车的场景。问题不在波特率不在接线而在“字节流没有消息边界”。给串口收发加一层简单协议本质就是自己定义“哪几个字节是一家人”帧头、命令字、数据长度、校验和。这层协议一旦加上单片机和PC之间、板卡和传感器之间就能像寄快递一样单号核对、箱子点数、封口验签不对就拒收重发。这篇文章面向的是正在用CMS8S6990做传感器采集、小显示屏、电机控制通信的开发者目标是让你用最少的内存开销把这项能力在本地跑通。2. 从字节流到帧给CMS8S6990设计最小可用串口协议2.1 裸UART的痛点为什么字节流需要“消息边界”CMS8S6990的串口模块本质上只做一件事把并行数据转成串行位流发出去再把收到的位流拼回字节。它不管“这一段字节代表什么含义”。也就是说上位机连续发0x01 0x02 0x03 0x04单片机可能在中途被中断打断先收到0x01 0x02几毫秒后收到0x03 0x04如果你的代码是“收到一个字节就立刻判断是不是完整指令”那这帧就废了。更麻烦的是串口线上空闲时的电平是持续高电平也就是逻辑1。当发送方开始时拉低电平接收方会把它识别成一个起始位然后采集后续8个位。如果发送方和接收方的本地时钟有微小偏差第一个字节还能凑合对齐连续收多个字节时误差会累积到采样点偏移最终某一位被采错。于是出现整帧数据里偶发一个错字节。这类问题用示波器能看到一个字节的某个位宽比标准多了半个位靠软件看就是“玄学性乱码”。解决思路不是去提高采样精度而是让接收方有能力抛弃坏帧、只信任完整帧。这就需要一个轻量级的“包裹格式”。常见做法是帧头2字节 命令1字节 长度1字节 数据负载N字节 校验和1字节。发送方在把数据塞进串口发送寄存器之前先按这个格式打包接收方在中断里只负责把字节塞进环形缓冲区主循环里的解析器按状态机逐字节消费缓冲区拼完一整帧再做校验。这套方案的优点在于中断里不做判断、不做拷贝、不做校验所以中断服务函数一直保持极短。CMS8S6990没有DMA通道的时代串口中断响应速度直接决定最高稳定收包速率把逻辑推到主循环里是低风险做法。2.2 一帧只花五个固定开销帧头、长度与校验的选型设计简单协议时最怕一开始就把格式定得很复杂。像“帧头 类型 源地址 目标地址 序号 长度 数据 CRC32”这种格式适合网络通信不适合8位单片机上的本地串口。CMS8S6990的RAM资源有限常见型号只有几百字节一条命令动不动占几十字节协议头性价比太低。我一般推荐的最小帧结构为字段宽度取值说明帧头11字节0xAA第一帧头帧头21字节0x55第二帧头用于误码保护命令字1字节0x01~0x0F区分“读版本”“写参数”“控制IO”等操作数据长度1字节N数据负载的字节数范围0~63数据负载N字节业务数据按具体场景填充校验和1字节无符号累加除校验和字节外其余字节累加取低8位为什么帧头用0xAA 0x55而不是单字节0xAA因为串口线上长时间空闲时电平为高接收方从空闲状态醒来后第一个收到的合法位流大概率是0xFF之类的全1字节。如果把帧头设成单字节一旦接收方从中间某个字节开始同步就再也找回帧边界。双字节连续匹配的好处是只要前一个字节不是0xAA哪怕是错位同步也会被第二字节0x55拒绝。对于低速传感数据两字节帧头的开销完全可接受。长度字段限制在0~63是因为这类项目的单条指令基本不会超过几十字节。限制长度上限还有一个好处环形缓冲区不用开很大解析器也能对“超长帧”直接做拒绝。校验和用单字节累加而不是CRC16主要考虑到8位单片机上求CRC需要查表或循环移位而累加运算只需要两条指令。单字节累加防不了两个字节同时错位的场景但在一米内TTL电平通信场景下误码率足够低能让代码保持简单可读。2.3 环形缓冲区加有限状态机接收路径的核心接收路径如果用“每收到一个字节就调一次处理函数”一旦处理函数里出现查表、判时间戳这类耗时操作下一个字节可能已经覆盖了接收寄存器。所以必须引入一个中间层中断里只做“读寄存器写环形缓冲区”两件事解析放到主循环里。环形缓冲区实现上有几个细节。第一缓冲区大小必须是2的幂比如64、128、256这样取模可以用 (SIZE-1)代替% SIZE。我常用的是128字节因为使能深度为2的幂后读写指针的计算简单也避免除法指令在8位机上被模拟成多字节运算。第二缓冲区空满判断必须保留一个单元。如果生产和消费指针相等表示空如果写入指针追上读取指针表示满。第三读操作和写操作要保证原子性。解析器则是一个典型的三状态机。状态A为“等待帧头”只关心当前字节是不是0xAA如果是则进状态B状态B为“等待第二帧头”收0x55则进状态C否则回状态A状态C按顺序收完命令、长度、数据和校验每一步都校验长度是否越界。一旦校验和不对整个状态机立刻复位到A丢弃当前帧。这个做法不会误杀下一帧因为下一帧的0xAA会被重新识别。3. 实现串口协议CMS8S6990上的打包、解析与参数取舍3.1 发送端代码打包逻辑与发送超时保护发送端要做的事情很简单把协议帧写进一个发送数组然后一个字节一个字节地往串口发送寄存器里写。下面这段代码是典型的CMS8S6990串口发送封装把“填写帧头、命令、长度、累加校验”集中在一个函数里。/* 发送缓冲区最大帧长 帧头2 命令1 长度1 数据N 校验1 */ #define FRAME_MAX_LEN (2 1 1 64 1) static unsigned char tx_buf[FRAME_MAX_LEN]; /* 发送一帧cmd为命令字data为负载指针len为负载长度 */ unsigned char protocol_send(unsigned char cmd, unsigned char *data, unsigned char len) { unsigned char i; unsigned char sum 0; if (len 64) { return 0; /* 超长帧直接拒绝 */ } tx_buf[0] 0xAA; /* 帧头1 */ tx_buf[1] 0x55; /* 帧头2 */ tx_buf[2] cmd; /* 命令字 */ tx_buf[3] len; /* 数据长度 */ for (i 0; i len; i) { tx_buf[4 i] data[i]; sum data[i]; } /* 校验和把帧头、命令、长度、数据都累加 */ sum sum tx_buf[0] tx_buf[1] tx_buf[2] tx_buf[3]; tx_buf[4 len] sum; /* 发送每个字节等待发送完成标志置位 */ for (i 0; i (4 len 1); i) { while (uart_tx_is_busy()) { /* 超时保护由上层调用者负责 */ /* 空等待 */ } uart_put_char(tx_buf[i]); } return 1; }函数入口处的len 64检查不是摆设。如果你在长度字段里存了一个超过64的数接收方会按这个长度傻等后面的字节导致状态机卡死。发送端先自我约束接收端才能安心解析。uart_tx_is_busy()是一个查询串口发送标志的函数实现时读串口控制寄存器里对应的状态位即可。如果调试过程中发现发送函数卡死在等待标志上优先检查是否忘记开启串口发送。这里还应加一个发送总超时比如用一个延时计数变量超过某个阈值就强制退出循环避免单片机在主循环里被卡住。对CMS8S6990这类8051核最简单的超时办法是开一个1ms周期的定时器标志在等待循环里不断检查这个标志是否超时而不是直接用delay()函数因为延迟函数会阻塞其他任务。3.2 接收端实现中断只喂环形缓冲主循环里解析接收端最怕的就是在中断里做复杂判断。你要做的是串口中断触发后把硬件寄存器里的字节读出来写入环形缓冲区然后退出中断。下面是一个不依赖具体寄存器名的示意实现重点在结构和流程。#define RX_BUF_SIZE 128 static unsigned char rx_buf[RX_BUF_SIZE]; static volatile unsigned char rx_head 0; /* 写入指针 */ static volatile unsigned char rx_tail 0; /* 读出指针 */ /* 串口接收中断服务函数只放字节 */ void uart_isr(void) { unsigned char byte; byte uart_read_char(); /* 读寄存器清中断 */ rx_buf[rx_head] byte; /* 先写数据 */ rx_head (rx_head 1) (RX_BUF_SIZE - 1); /* 2的幂取模为与运算 */ /* 如果写入指针赶上读出指针说明缓冲满了丢弃旧帧 */ if (rx_head rx_tail) { rx_tail (rx_tail 1) (RX_BUF_SIZE - 1); } }主循环里则是一个解析器每次循环从环形缓冲区读一个字节喂给状态机。状态机里需要记录“当前已经收到第几个字节”“这一帧期望的总长度”。这里的实现要点是只要状态错误就放弃整帧并且不额外消耗下一帧的字节。常见做法是直接让状态机回到“等帧头”当前读到的字节作为下一帧的起始字节重新判断。/* 协议解析状态机 */ #define ST_WAIT_AA 0 #define ST_WAIT_55 1 #define ST_WAIT_CMD 2 #define ST_WAIT_LEN 3 #define ST_WAIT_DATA 4 #define ST_WAIT_SUM 5 static unsigned char proto_state ST_WAIT_AA; static unsigned char recv_cmd; static unsigned char recv_len; static unsigned char recv_sum; static unsigned char recv_cnt; static unsigned char recv_payload[64]; void protocol_poll(void) { unsigned char byte; while (rx_head ! rx_tail) { byte rx_buf[rx_tail]; rx_tail (rx_tail 1) (RX_BUF_SIZE - 1); switch (proto_state) { case ST_WAIT_AA: if (byte 0xAA) proto_state ST_WAIT_55; break; case ST_WAIT_55: if (byte 0x55) { proto_state ST_WAIT_CMD; } else { proto_state ST_WAIT_AA; /* 不是0x55回到原始状态 */ } break; case ST_WAIT_CMD: recv_cmd byte; proto_state ST_WAIT_LEN; break; case ST_WAIT_LEN: recv_len byte; recv_cnt 0; recv_sum 0xAA 0x55 recv_cmd recv_len; if (recv_len 64) { proto_state ST_WAIT_AA; /* 超长帧拒绝 */ } else if (recv_len 0) { proto_state ST_WAIT_SUM; } else { proto_state ST_WAIT_DATA; } break; case ST_WAIT_DATA: recv_payload[recv_cnt] byte; recv_sum byte; recv_cnt; if (recv_cnt recv_len) { proto_state ST_WAIT_SUM; } break; case ST_WAIT_SUM: if (byte recv_sum) { /* 校验通过交给业务处理函数 */ protocol_handle_frame(recv_cmd, recv_payload, recv_len); } proto_state ST_WAIT_AA; break; default: proto_state ST_WAIT_AA; break; } } }ST_WAIT_LEN里对长度大于64的处理必须单独列出来因为如果你直接把长度存入数组计数器后面ST_WAIT_DATA会一直等超过64的字节进入最终状态机被拖死。拒绝超长帧后本帧剩余字节会当作噪声被后续状态机过滤不会影响下一帧。recv_sum的初始化把帧头、命令、长度全部加进去是为了和发送端的累加方式保持一致。如果你在发送端把校验范围写成“数据负载累加”接收端也只要把recv_sum初始化成0再逐字节加负载即可。最关键的是发送端和接收端对“累加范围”的理解必须一字不差。3.3 波特率、时钟源和缓冲区大小的取舍CMS8S6990的串口波特率通常由定时器1或定时器2产生具体选择需要看项目的时钟树。常见配置是外接11.0592MHz晶振这样计算出来的波特率误差很小。如果换成24MHz这种频率只能通过分频系数凑一个接近9600的波特率误差可能达到1%甚至更高。对20cm以内TTL通信来说1%误差还能忍但线一长累积位偏移就会让数据出现随机错位。晶体频率与波特率的匹配是很多人忽视的细节。最好用的频率就是11.0592MHz、22.1184MHz这类能被波特率整除的晶振因为它们能直接得到9600、19200、115200的精确分频。如果你的板子上已经是24MHz我建议优先把串口波特率设到4800或9600而不是硬撑115200。8位机应用场景下的体感是9600波特率下帧间隔约1ms对协议处理完全够用115200下每字节约87微秒中断里一个小分支延迟都可能丢字节。缓冲区大小的选择取决于最坏情况下协议处理被其他任务阻塞的时间。CMS8S6990主循环里如果有一段耗时操作比如EEPROM擦写、LCD刷新串口数据仍在不断到达。512字节缓冲区大材小用但64字节又可能不够。通用做法是设成不小于最大帧长的两倍。上文设计里最大帧长为68字节缓冲区设128字节留出了处理抖动余量。如果项目有低功耗要求串口接收就不能一直查询。CMS8S6990通常支持串口中断唤醒但唤醒后需要先等一段时间让时钟稳定。协议层的处理则要在唤醒后重新同步帧头避免把唤醒噪声当成数据。4. 串口协议常见问题排查五个必踩的坑4.1 现象上电或按键复位后接收到的第一帧总是乱码这是最常见的踩坑场景。板子上电瞬间晶振尚未稳定串口模块的波特率实际值和设定值偏差很大接收方此时收到的数据位宽不对解出来的字节自然不对。另外单片机复位瞬间TX引脚可能被拉到低电平上位机侧会把它当成一个起始位解码得到一个0x00。原因与解决复位期间不要发送任何数据。发送端的初始化代码里在串口使能之后加一个10~20ms延时如果板子上有外部传感器或通信芯片往单片机回数据也要等单片机串口模块稳定后再输入。协议本身可以再加一道保护帧头设置成0xAA 0x55后即使复位瞬间收到一个0x00状态机也不会把它误认为帧头。4.2 现象收发小数据量很稳定可一旦把某条调试日志打印打开就开始丢帧这个问题出在串口中断和打印共用串口资源。比如你在主循环里通过同一条串口打印格式化日志打印一个字符串需要几毫秒期间如果来了一个字节中断里仍然可以把它写入环形缓冲区这没问题。但如果你不小心把打印函数放进了串口中断里或者打印前关闭了总中断就会漏收字节。原因与解决严格区分“通信串口”和“日志串口”。CMS8S6990如果不支持第二路UART那调试日志就别走串口改用GPIO翻转配合逻辑分析仪看时序。另一个解法是日志通过同一个串口输出但只在协议空闲时打印打印时用临界区保护住发送过程同时确保中断接收始终开启。血泪经验是千万不要在串口中断函数里调用任何打印类函数哪怕它只是向发送寄存器写一个字节。4.3 现象两块板子短距离通信正常换成长杜邦线或机箱环境后乱码重现长度超过20厘米的杜邦线在9600下还不能说完全不可靠但超过1米后线间电容和地线噪声会明显拉低信号质量。上位机端显示的错误往往是某个字节的校验和不对或者帧头被识别成0x00。原因与解决TTL电平串口不适合长距离传输优先降低波特率到4800以下。多板互联时检查是否所有设备共地很多时候系统各板卡用独立开关电源供电地电位不一致串口线上的信号以地线为基准一旦两边地电位差出1V收发就变得“像抽风一样”。解决办法是把所有板卡GND连接在一起必要时在靠近接收端加一个10kΩ上拉电阻。如果是机箱干扰场景改用RS232电平转换或RS485收发器协议完全不用改只换物理层。4.4 现象协议帧格式一眼看上去没问题但收发双方总是对不上这类问题的典型表现是一方用累加校验另一方用了异或校验一方把长度字段放进校验范围另一方没放或者一方的长度字段表示的是“负载长度”另一方却把它当成“总长度”。协议本身没有标准答案一旦收发双方存在两套理解线上就会持续出现偶发错帧。原因与解决先把协议字段的“定义表”用文档固定下来不要只停留在代码注释里。建议画一张表格列清楚每个字段的偏移、宽度、取值范围和校验算法。另有一种不太明显但常见的差池帧内一个命令字被接收方当成数据长度导致后续状态错乱。因此命令字的取值空间最好限定在0x01~0x0F避免和帧头数值混淆。4.5 现象程序运行中偶发死机或主循环卡死定位后发现停在发送等待标志处如果发送函数里直接while (uart_tx_is_busy())等待发送完成而发送中断被误配置或未开启标志位可能永远不被置位。CMS8S6990这类8051内核的串口发送中断和接收中断常常共用一个中断源如果你只开了接收中断发送完成标志不会被处理查询发送标志可能在部分寄存器配置下永远返回“忙”。原因与解决不要依赖无限等待。给发送循环加一个send_timeout_count变量在1ms定时器中断里自增主循环发送时判断这个值是否超过阈值超时就强制退出并清除发送标志。另一个做法是干脆不用查询标志采用阻塞等待固定延时按波特率估算传输一字节所需时间再留出30%余量。这个方法不够精确但在调试阶段最省事。5. 协议验收技巧回环压测、状态机探针加波特率快照做完以上工作协议能不能正式上线得用一套压测方法验收。我惯用的第一步是“回环压测”把当前板子的TXD和RXD短接写一段测试代码循环发送随机长度和随机内容的帧统计接收端解析成功的帧数。正常情况错误率应该为零。如果偶尔出错就把波特率降低一档再看。这一步能排除掉大部分时序和状态机逻辑问题。第二步是“状态机探针”。CMS8S6990没有内置调试器时建议选一个空闲GPIO在协议解析器的每个状态切换处翻转该引脚用逻辑分析仪去对比实际波形与期望状态序列。比如你发现GPIO波形在ST_WAIT_DATA阶段出现一个异常毛刺就能直接定位是哪个字节触发了状态回退。这样比盲改代码快很多。第三步是“波特率快照”。用一个已知频率的方波信号比如让单片机定时器输出10kHz方波再用逻辑分析仪去量实际周期确认系统时钟是否和设想一致。很多“波特率不对”其实不是波特率寄存器的问题而是时钟源被软件切到了低频内部RC导致所有时间基准偏移。上电后如果没做时钟切换默认时钟可能是低频模式串口跑起来就奇慢。另外一个值得养成的习惯是每次改协议前先写一份“协议变更记录”哪怕只是修改一个校验字节也要标注日期和原因。旧代码和新代码混用时最怕的就是固件库里留着旧版打包函数某条链路还被它驱动。我在这上面吃过亏某个跨平台系统因为沿用旧协议包上位机解析时总是校验失败排查半天才发现是程序里残留了一个旧版发送函数函数入口参数一样但校验范围不同。调试串口协议这类工作看起来很底层但只要把状态机、缓冲区和超时三样东西设计到位稳定性和可移植性都很高。我现在做新项目时都会先画一版协议字段表然后写个模拟脚本验证校验逻辑再动单片机的代码。希望帮到你。本文还有配套的精品资源点击获取