尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SC8F073串口通信实战:从波特率计算到稳定收发框架

SC8F073串口通信实战:从波特率计算到稳定收发框架 上周帮客户排查一块家电主控板故障现象很奇怪板子偶尔能收到上位机的指令偶尔收不到一旦收到连续两帧数据后面必定丢包。我在现场蹲了半个下午最后定位到问题出在串口接收中断里做解析、缓冲区又开得太小数据一进来就溢出。这种问题在SC8F073这类8位MCU上特别典型——串口本身不复杂但要把收发做成一套稳定、可复用的框架里面有不少细节值得掰开揉碎讲清楚。这篇文章我打算围绕SC8F073的串口通信从硬件设计、寄存器配置、波特率计算、收发框架搭建到调试工具使用完整走一遍我自己在实际项目里的做法。整套思路不仅适用于SC8F073放到STM8、新唐、中微其他8位机上也通用。适合正在做嵌入式开发、尤其是刚接触8位MCU串口通信的工程师参考也适合被串口偶尔丢数据上电第一帧解析失败这类问题折磨过的朋友对照排查。1. 项目概述SC8F073串口通信能做什么1.1 SC8F073这颗芯片的定位SC8F073是中微半导体推出的一款基于增强型8051内核的8位MCU主频可以跑到16MHz左右片上集成了Flash、EEPROM、多路定时器、ADC、PWM和UART等常用外设。在这类芯片上做串口通信核心诉求不是能不能通而是稳不稳定、占不占用主循环资源、容不容易出错。我之所以专门为它写一套收发框架是因为实际项目里用过太多裸写串口收发的方式主循环里死等接收标志、中断里直接做协议解析、全局变量满天飞。这些做法在功能演示时都能跑但一旦协议复杂、数据量上来、或者现场有干扰问题就会集中爆发。SC8F073的资源本来就有限代码写得不够干净后面加功能时改起来非常痛苦。1.2 为什么单独为它搭一套收发框架串口收发框架听起来是个很基础的东西但基础的东西恰恰最能体现一个嵌入式工程师的功底。一套稳定的框架至少要解决四个问题接收数据不能丢尤其是中断进来时主循环可能在忙别的事收发不能互相干扰发数据的时候不能被接收中断打断导致帧错乱上层业务解析和底层字节收发要解耦这样改协议不用动底层异常情况下能自恢复比如帧错误、溢出、半包超时我在SC8F073上最终采用的是中断接收 环形缓冲区 状态机解析的架构发送则采用查询方式配合超时保护。这套方案在多个项目里验证过稳定性很高而且代码量不大非常适合资源紧张的8位MCU。2. 硬件设计与初始化串口通信的底层地基2.1 引脚分配与硬件注意事项SC8F073的UART引脚是TXD和RXD硬件上接法倒是简单但有几个细节我吃过亏必须拿出来说。第一TXD和RXD要交叉连接。板子的TXD接对端设备的RXD板子的RXD接对端设备的TXD。这个看起来简单实际项目里我见过至少三次接反的案例现象是完全没有数据。遇到这种情况先不要怀疑软件用万用表量一下两边电平或者直接把TXD和RXD对调试试。第二共地问题。串口通信是异步串行通信收发双方必须共地否则参考电平不一致轻则误码重则完全不通。尤其是用USB转串口模块调试的时候模块的地必须和板子的地连在一起。我见过有人只接了TXD、RXD两根线结果数据乱码一直怀疑是波特率不对折腾了半天才发现是地没共。第三电平匹配。SC8F073是3.3V/5V兼容的IO但如果你对接的是RS232电平的设备比如老式工控机串口必须加MAX232之类的电平转换芯片。直接对接轻则通信失败重则烧IO口。如果对接的是RS485总线还需要加收发器芯片和方向控制逻辑。第四上拉电阻。SC8F073的串口引脚建议外部加上拉电阻尤其是RXD引脚。原因是UART空闲状态是高电平如果RXD悬空或者对端设备未上电引脚电平不确定可能频繁触发接收中断导致MCU一直进中断看起来就像死机。加一个10kΩ上拉电阻到VCC能让空闲状态稳定在高电平。第五去耦和ESD保护。如果产品要过EMC测试串口引脚上建议加TVS管和串联电阻。TVS管选双向的击穿电压比工作电压高一点即可串联电阻选22Ω到33Ω放在MCU引脚和对端接口之间既能限流又能抑制过冲。2.2 波特率参数计算不能拍脑袋定波特率是串口通信最核心的参数收发双方的波特率误差必须在允许范围内否则数据传输就会出错。SC8F073的UART波特率由定时器1或定时器2产生我习惯用定时器1工作在方式28位自动重载来产生波特率。这里把计算过程展开讲一遍因为网上很多文章直接给公式没解释为什么这么算新手照着做心里没底。定时器1方式2的波特率公式是波特率 (2^SMOD / 32) × (定时器1溢出率)其中定时器1溢出率 系统时钟 / (12 × (256 - TH1))SMOD是PCON寄存器最高位为0时波特率减半为1时乘2。综合起来波特率 (2^SMOD × 系统时钟) / (32 × 12 × (256 - TH1))以系统时钟16MHz、SMOD1、目标波特率9600为例TH1 256 - (2^SMOD × 系统时钟) / (32 × 12 × 波特率) 256 - (2 × 16000000) / (32 × 12 × 9600) 256 - 32000000 / 3686400 256 - 8.68 ≈ 247.32取整后TH1只能设置成整数比如247或248这时候实际波特率会偏离9600误差需要计算一下。如果TH1247则(256-247)9实际波特率 (2×16000000)/(32×12×9) ≈ 3518.5误差太大不可用如果TH1248则(256-248)8实际波特率 (2×16000000)/(32×12×8) ≈ 10416.7误差约8.5%不可用看到问题了吧16MHz系统时钟下9600波特率根本算不干净。这就是为什么很多8位MCU的串口例程推荐用11.0592MHz晶振——这个频率专门为串口波特率设计的能精确产生9600、19200、115200等常用波特率。SC8F073如果使用外部晶振选11.0592MHz最省心。如果只能用内部时钟建议换波特率比如115200TH1 256 - (2 × 16000000) / (32 × 12 × 115200) 256 - 32000000 / 44236800 ≈ 256 - 0.72 ≈ 255.28这里256-TH1约等于255实际波特率 32000000/(32×12×255) ≈ 326.8这个值也不对。问题在于公式里的12是标准8051的机器周期SC8F073虽然兼容8051指令集但机器周期数可能不同而且部分芯片有独立的波特率发生器或者内置RC振荡器校准功能。所以在我做的SC8F073项目里如果不外接晶振我会先查数据手册确认内部RC频率是多少然后找一个能整除的频率组合。如果实在凑不齐就在允许范围内加大误差容忍度同时用逻辑分析仪实测波形以实测值反推实际波特率再修正初始化参数。任何时候初始化波特率和实际波特率不一致都是数据乱码的头号原因。2.3 UART初始化关键寄存器配置SC8F073的UART寄存器和标准8051基本一致核心是SCON串口控制寄存器、SBUF串口数据缓冲寄存器、PCON电源控制寄存器以及定时器相关寄存器。我贴一段我实际用的初始化代码加了详细注释。// 串口初始化函数 // 使用定时器1方式2产生波特率8位UART模式允许接收 void UART_Init(uint8_t baud_rate) { // SCON: SM00, SM11 - 方式110位UART起始位8位数据停止位 // REN1 - 允许接收 SCON 0x50; // PCON: SMOD1 - 波特率倍频 PCON 0x80; // 定时器1配置为方式28位自动重载 TMOD 0x0F; // 高4位清零不影响定时器0 TMOD | 0x20; // 定时器1方式2 // 根据计算得到的重载值设置TH1和TL1 TH1 baud_rate; TL1 baud_rate; // 启动定时器1 TR1 1; // 清标志位 RI 0; TI 0; // 使能串口中断总中断在main中打开 ES 1; }关于SMOD位我要多说一句。SMOD1时波特率翻倍但副作用是功耗略高。对于绝大多数场景SMOD1让波特率误差更小所以我一般保持SMOD1。如果你的系统对功耗要求极高可以试着SMOD0但在做波特率计算时要记得把公式里的2^SMOD改成1。SCON寄存器还有个细节SM0和SM1的组合决定工作方式方式1SM00SM11是8位UART、可变波特率这是最常用的。如果你需要9位数据比如多机通信时用第9位做地址/数据标志就用方式2或方式3。SC8F073单机通信用方式1足够了。3. 收发框架的实现从查询模式到中断加缓冲区3.1 为什么不用主循环轮询RI很多朋友刚开始写串口程序时习惯于在主循环里不停查询接收标志位while (1) { if (RI) { RI 0; process_byte(SBUF); } // 干别的事 }这种方式代码最简单但不是稳定框架该有的形态。核心问题是接收是异步事件发送方不会等你的主循环转一圈才发数据。如果主循环里有耗时操作比如处理按键、驱动显示屏、跑控制算法RI置位后可能要几十毫秒甚至更久才被响应而这期间UART硬件接收寄存器SBUF只能存一字节数据下一字节一到旧数据就被覆盖丢数据就这么来的。中断接收的意义就在于无论主循环在干什么只要串口收到一个字节CPU立刻暂停当前工作去把数据挪走。挪到哪不是直接扔给某个业务变量而是先放进缓冲区。这样一来接收一个字节的处理时间被压缩到几个时钟周期几乎不可能丢数据。3.2 环形缓冲区给数据一个稳当的落脚点环形缓冲区又叫Ring Buffer是我在串口框架里最依赖的数据结构。它的本质是一块固定大小的内存用头尾两个索引管理写数据往尾部写读数据往头部读绕一圈回来还能继续用。为什么用环形缓冲区而不是简单数组因为串口收发是典型的生产者-消费者模型中断是生产者往缓冲区塞数据主循环是消费者从缓冲区取数据。两者速度不匹配缓冲区就是中间蓄水池。用环形缓冲区只要消费者消费得够快生产者永远不会被阻塞也不会覆盖未处理的数据。我实现的环形缓冲区长这样#define RX_BUF_SIZE 128 typedef struct { uint8_t data[RX_BUF_SIZE]; volatile uint8_t head; // 写入位置 volatile uint8_t tail; // 读取位置 } ring_buffer_t; ring_buffer_t rx_buf; // 初始化缓冲区 void ring_buffer_init(ring_buffer_t *buf) { buf-head 0; buf-tail 0; } // 判断缓冲区是否为空 uint8_t ring_buffer_is_empty(ring_buffer_t *buf) { return (buf-head buf-tail); } // 判断缓冲区是否已满 uint8_t ring_buffer_is_full(ring_buffer_t *buf) { return (((buf-head 1) % RX_BUF_SIZE) buf-tail); } // 写入一个字节 uint8_t ring_buffer_write(ring_buffer_t *buf, uint8_t byte) { if (ring_buffer_is_full(buf)) { return 0; // 溢出写入失败 } buf-data[buf-head] byte; buf-head (buf-head 1) % RX_BUF_SIZE; return 1; } // 读取一个字节 uint8_t ring_buffer_read(ring_buffer_t *buf, uint8_t *byte) { if (ring_buffer_is_empty(buf)) { return 0; // 无数据可读 } *byte buf-data[buf-tail]; buf-tail (buf-tail 1) % RX_BUF_SIZE; return 1; }缓冲区大小为什么取128这不是随便定的。SC8F073的RAM空间有限128字节做收发缓冲区已经是很合理的折中——既能承载完整的协议帧比如一帧50字节又不至于占用过多资源。如果你的协议帧特别长可以适当加大但要注意RAM余量。这里必须提醒新手一个非常隐蔽的坑头尾索引一定要加volatile修饰。因为head和tail在中断里被写入、在主循环里被读取编译器如果不加volatile可能会优化成一次性从内存读出之后就只用寄存器里的副本导致主循环永远看不到中断里更新的值。这个bug非常难查表现是中断明明进了一直收不到数据或者偶尔收到数据但始终少一部分。3.3 帧协议设计解决粘包和半包缓冲区解决了字节层面不丢数据的问题但上层业务不能只用单个字节。实际项目中串口传输的往往是成帧的数据上位机发来一帧指令比如帧头、命令字、数据长度、数据区、校验码。MCU要能从连续字节流里准确切分出每一帧并且处理帧边界错乱的情况。这就是协议解析的活。我在SC8F073项目上用的协议格式长这样帧头2字节固定为0xAA 0x55用来同步命令字1字节表示指令类型数据长度1字节表示后面数据区的字节数数据区n字节校验码1字节采用累加和校验接收端在缓冲区取字节时用状态机判断当前处于协议解析的哪个阶段typedef enum { FRAME_STATE_HEADER1, // 等待帧头第1字节 FRAME_STATE_HEADER2, // 等待帧头第2字节 FRAME_STATE_CMD, // 等待命令字 FRAME_STATE_LENGTH, // 等待数据长度 FRAME_STATE_DATA, // 等待数据区 FRAME_STATE_CHECK // 等待校验码 } frame_state_t;状态机的好处是不管字节流从哪里开始都能自我同步。比如一上来就收到0x55不是期望的帧头0xAA状态机直接忽略继续等下一个字节。哪怕中间数据错乱丢掉一个字节下一帧帧头也能重新对齐。这在现场干扰强的环境下非常实用。数据帧解析完还要做校验。我用的累加和校验很简单帧头后的命令字、数据长度、数据区所有字节求和低8位作为校验码。接收端同样计算一遍和收到的校验码比对不一致就丢弃整帧。如果你想更可靠一点可以改用CRC8SC8F073的CPU算CRC8几百字节的负载完全没问题。3.4 完整收发框架示例代码有了中断、环形缓冲区和状态机组合起来就是一套可复用的SC8F073串口收发框架。我把核心代码完整贴出来关键地方都有注释。// 串口中断服务函数 void UART_ISR(void) interrupt 4 { uint8_t byte; if (RI) { RI 0; byte SBUF; ring_buffer_write(rx_buf, byte); } if (TI) { TI 0; tx_busy 0; // 标记发送完成 } } // 发送一字节查询方式带超时 uint8_t UART_SendByte(uint8_t byte) { uint16_t timeout 0; while (tx_busy timeout 10000) { timeout; } tx_busy 1; SBUF byte; return 1; } // 发送一帧数据 void UART_SendFrame(uint8_t cmd, uint8_t *data, uint8_t len) { uint8_t sum 0; uint8_t i; UART_SendByte(0xAA); UART_SendByte(0x55); UART_SendByte(cmd); UART_SendByte(len); sum cmd len; for (i 0; i len; i) { UART_SendByte(data[i]); sum data[i]; } UART_SendByte(sum); } // 协议解析状态机在主循环中调用 void UART_ProcessFrame(void) { static frame_state_t state FRAME_STATE_HEADER1; static uint8_t frame_cmd; static uint8_t frame_len; static uint8_t frame_data_idx; static uint8_t frame_data[64]; static uint8_t frame_sum; uint8_t byte; while (ring_buffer_read(rx_buf, byte)) { switch (state) { case FRAME_STATE_HEADER1: if (byte 0xAA) state FRAME_STATE_HEADER2; break; case FRAME_STATE_HEADER2: if (byte 0x55) { state FRAME_STATE_CMD; } else if (byte ! 0xAA) { state FRAME_STATE_HEADER1; // 重新同步 } break; case FRAME_STATE_CMD: frame_cmd byte; frame_sum byte; // 累加和从命令字开始算 state FRAME_STATE_LENGTH; break; case FRAME_STATE_LENGTH: frame_len byte; frame_sum byte; frame_data_idx 0; if (frame_len sizeof(frame_data)) { // 长度非法重新同步 state FRAME_STATE_HEADER1; } else if (frame_len 0) { state FRAME_STATE_DATA; } else { state FRAME_STATE_CHECK; } break; case FRAME_STATE_DATA: frame_data[frame_data_idx] byte; frame_sum byte; if (frame_data_idx frame_len) { state FRAME_STATE_CHECK; } break; case FRAME_STATE_CHECK: if (frame_sum byte) { // 校验通过交给业务层处理 HandleFrame(frame_cmd, frame_data, frame_len); } state FRAME_STATE_HEADER1; break; } } }注意看状态机的两个关键设计。第一个是非法长度的处理如果收到的数据长度超过缓冲数组大小说明这帧数据已经不可信很可能是干扰导致直接回到帧头等待状态不做任何分配内存之类的危险操作。第二个是校验失败的处理整帧直接丢弃状态机回到帧头不影响后续帧的解析。这种设计应对半包数据也很自然。比如上位机发了前半帧突然被更高优先级任务打断后半帧迟到了100ms状态机依然停在DATA状态等剩余字节不会误判为新帧。只要缓存区足够大、主循环调用ProcessFrame足够频繁半包数据最终会凑齐。主循环的调用方式很简单void main(void) { UART_Init(baud_rate_value); ring_buffer_init(rx_buf); EA 1; // 开总中断 while (1) { UART_ProcessFrame(); // 其他业务代码 } }很多项目只需要在主循环里加一行ProcessFrame调用串口接收就再也不操心了。4. 调试工具与常见问题排查实录4.1 我的调试三板斧逻辑分析仪、串口助手、示波器写串口程序最头疼的是出了问题不知道是硬件问题还是软件问题。我调试串口的工具组合很固定三个工具各司其职。逻辑分析仪是我最推荐的排查工具。现在几十块钱的USB逻辑分析仪就能做到8通道、24MHz采样率分析9600、115200波特率的串口绰绰有余。用法很简单把逻辑分析仪的通道接到板子的TXD脚电脑上的客户端软件会自动解码UART协议直接显示收到的字节内容和波特率误差还能把波形导出来看每个位的宽度。我第一次用逻辑分析仪排查波特率问题时直接在软件里看到TXD引脚输出的每个位宽度比标准9600宽了将近5%这才意识到问题不在软件逻辑而在时钟频率不准确。如果没有逻辑分析仪这种问题靠肉眼几乎没法定位。串口助手用于验证收发。调试阶段我用串口助手模拟上位机向板子发送测试帧。串口助手有一些实用功能容易被忽略比如定时发送模式可以按固定周期发数据用来测试长时间通信稳定性发送新行选项会在数据尾部自动追加\r\n有时候协议里需要。示波器用于看电平质量。如果波形上升沿有明显的振铃或过冲说明线路阻抗不匹配或者驱动能力不足可能需要调整串联电阻值。示波器还能用来量TXD和RXD空闲电平如果空闲电平不是高电平而是低电平或者浮空通信必然有问题。4.2 高频问题速查表整理一份我见过的高频问题排查清单覆盖从硬件到软件、从现象到原因直接给结论和解决方案。问题现象可能原因排查思路与解决方案完全收不到数据TXD/RXD接反对调两根线或用逻辑分析仪看对端TXD有无波形完全收不到数据未共地检查GND是否连接不要偷懒只接信号线完全收不到数据波特率差距过大用逻辑分析仪解码实际波特率和代码设置对比数据乱码波特率误差超过2%重新计算TH1值误差太大时换晶振频率或换波特率数据乱码时钟源选择错误检查配置的是内部RC还是外部晶振校准内部RC偶发丢字节中断响应不及时改用中断接收加环形缓冲区不要在中断里做耗时解析偶发丢字节缓冲区溢出加大接收缓冲区或检查消费速度是否跟不上偶发丢字节干扰导致帧错误用示波器看波形必要时降低波特率、加屏蔽、加滤波偶发死机或卡死RXD引脚浮空触发中断RXD引脚加10kΩ上拉电阻上电后第一帧总是错上位机指令在上电瞬间发送板子上电后先延时一段时间再开接收中断或上位机捕获复位事件收发同时进行时出错半双工误用确认是走查询发送并等待发送完成不能一边改SBUF一边读SBUF补充一个非常容易踩的坑中断里打印调试信息。新手喜欢用串口打印日志来排查问题直接在中断服务函数里调用发送函数。这在低速场景下看着没问题但中断嵌套逻辑稍复杂就会出幺蛾子。我的原则是中断里只做数据搬移绝不打印调试信息调试信息放主循环或者单独的任务里发。4.3 避坑心得与经验总结代码写到最后分享几个我在实际项目中沉淀下来的经验这些都是文档里查不到的。第一协议设计要留长度上限校验。我在状态机里对frame_len做了检查超过数组大小就丢弃。这个检查不是锦上添花而是安全底线。现场电磁环境差的时候帧头之后的数据完全可能被干扰成一个超大长度值如果没有上限校验解析函数会一直等一个根本不会到达的数据区导致后续所有正常帧都无法解析。第二发送和接收的缓冲区要分开。有些项目为了省内存把接收缓冲区和发送缓冲区共用一块内存。省下来几十字节代价是逻辑复杂度上升而且一旦涉及DMA或者中断很容易踩到数据冲突。SC8F073的内存虽然不多但几十字节的缓冲区还是花得起的该花的不要省。第三硬件复位后注意串口引脚状态。SC8F073上电瞬间IO引脚可能是高阻态。如果对端设备在这个窗口期发数据MCU可能漏掉。对策是初始化代码里先把UART引脚配置好、置成确定的电平再打开UART模块最后开中断。顺序不能反否则上电后的第一帧数据大概率接不住。第四也是最重要的一条串口通信是异步的协议解析必须用状态机不要用阻塞式等待。什么叫阻塞式等待很多新手拿到一帧数据后在ProcessFrame里用delay等待剩余字节到齐。一旦数据没及时到整个主循环就卡死——这等于把偶尔丢数据升级成了整个系统崩溃。在任何情况下都要保证主循环能一直跑哪怕数据没到其他功能也不能停。5. 从能通到稳定的最后一公里串口通信这件事放在单片机的技术栈里算是最基础的外设驱动之一。但基础不等于简单尤其是到了工业现场干扰、电压波动、时序竞争、多任务并发这些因素叠加起来一个看似简单的串口收发可能隐藏着无数个坑。我在SC8F073上搭建的这套中断接收 环形缓冲区 状态机解析框架本质上是把一个异步的、随时可能发生的事件变成主循环可以同步、按顺序处理的队列。这个思路贯穿了几乎所有可靠嵌入式系统的设计。在实际项目中这套框架后续还可以扩展比如在CRC校验强度不够的时候换成CRC16在数据量大的时候把发送也改成中断加缓冲区在多任务环境下可以把缓冲区改成互斥访问以保护数据一致性。但无论怎么扩展底层的生产者-消费者模型不会变环形缓冲区加状态机的核心也不会变。我个人一直觉得做嵌入式开发最值钱的不是会用多少外设、跑多复杂的算法而是能不能把基础模块做扎实、做出复用性。这套串口框架我用了好几年每次换芯片平台改改寄存器配置就能直接搬过去省下来的时间非常可观。如果你正在为串口通信的稳定性头疼不妨按这篇文章的思路把代码重写一遍我相信你能感受到从能通到稳定之间的巨大差别。
返回列表