资讯详情

51单片机定时器与串口通信:波特率、初值与断帧实战

📅 2026/11/2 13:55:19 | 华诺云谱 👁 阅读
51单片机定时器与串口通信:波特率、初值与断帧实战
上个月有人抱着一块调试板来找我现象很典型串口助手上刷出来的全是乱码偶尔能蹦出半个能认出来的字符。他换了三根杜邦线、两个 USB 转 TTL 模块、两台电脑一点变化都没有。我拿到手第一件事没动线先问了一句——板上焊的晶振是多少他说 12M。代码里 TH1 填的 0xFD那是 11.0592MHz 下算 9600 的初值。把晶振换成 11.0592MHz屏幕立刻干净了。这类问题我在 51 单片机上见过太多次。定时器和串口通信是 8051 内核里最常被一起使用的两个外设也是最容易互相牵连出问题的一对串口的波特率要靠定时器 1 产生系统的时基节拍要靠定时器 0 维持两个中断还挤在同一套优先级体系里抢 CPU。很多人能背出TMOD 0x20、TH1 0xFD但一旦现象和预期不符就完全没有排查方向。这篇东西不打算写成寄存器手册的复述。我想做的是把定时器和串口的应用实例拆开从“为什么这个数是这样算出来的”讲到“跑起来之后哪里会翻车”中间给两段可以直接抄进工程的代码一段是定时器节拍加串口回环自检一段是不定长数据帧的接收。适合刚上手 51 的朋友也适合做了几年但一直靠改别人代码过日子的朋友——尤其是后者很多坑其实都是“知道有这回事”和“不知道”的差别。1. 定时器和串口被绑在一起讲的真实原因1.1 一次乱码事故的完整复盘回到开头那块板子。为什么会乱码因为串口方式 1 的波特率不是凭空来的它必须由一个定时器的溢出率除以一个固定分频数得到。定时器 1 工作在模式 2 时溢出周期由fosc / 12 / (256 - TH1)决定这个数直接被串口拿去当位时钟用。晶振从 11.0592MHz 变成 12MHz分频链路上的每一个数都变了TH1的取值自然全部作废。12MHz 晶振下想凑 9600 波特率需要256 - TH1约等于 2.6这不是整数取 3 得到 10417取 2 得到 15625误差分别是 8.5% 和 62%。UART 一帧 10 位收发两端累计误差超过 5% 左右就会开始采样错位8.5% 已经彻底出局。所以 12MHz 晶振在经典 12T 模式下根本产不出可用的 9600这不是配置问题是算术上就不成立。这件事说明一个事定时器和串口不是两个独立模块是一条链上的上下游。链路上任何一环的参数变了下游全部要重算。后面所有章节的推演都是围绕这条链路展开的。1.2 把寄存器地图摊开先看清谁管谁写 51 的代码脑子里的第一张图应该是寄存器。定时器相关的就两个TMOD管工作模式TCON管启停和溢出标志。串口相关的也是两个SCON管工作方式和收发标志PCON的最高位SMOD管波特率倍频。寄存器关键位作用常见取值含义TMOD高 4 位定时器 1 配置0x20 模式 28 位自动重装TMOD低 4 位定时器 0 配置0x01 模式 116 位定时TCONTR0 / TR1定时器启停置 1 开始计数TCONTF0 / TF1溢出标志中断方式下硬件自动清SCONSM0 / SM1串口工作方式01 方式 18 位 UARTSCONREN接收使能必须置 1 才能收SCONRI / TI收/发完成标志必须软件清零PCONSMOD波特率倍频置 1 则波特率翻倍有个细节新手经常搞混TMOD是高 4 位给定时器 1、低 4 位给定时器 0很多人写反。写反的表现是定时器怎么都不进中断或者串口出来的全是垃圾。我自己的习惯是永远不用TMOD 0x21这种一次性赋值而是两个初始化函数里各用一次和|谁负责哪四位就只动哪四位互相不覆盖。/* 定时器0初始化只动低4位 */ TMOD 0xF0; TMOD | 0x01; /* 串口/定时器1初始化只动高4位 */ TMOD 0x0F; TMOD | 0x20;这样两个初始化函数的调用顺序随便怎么排都不会打架。看起来是小事但在一个要反复加功能的工程里这种“局部修改”的习惯能省掉大量莫名其妙的调试时间。我第一次被人坑就是因为在串口初始化里写了TMOD 0x20把前面配好的定时器 0 直接抹掉了现象是系统节拍彻底不动而串口却好好的排查了半天。1.3 中断向量表谁先响应不是看谁重要8051 有固定的中断向量地址定时器 0 是 0x000B串口是 0x0023。同一个优先级下硬件按向量顺序轮询定时器 0 排在串口前面。也就是说如果两个中断同时挂起定时器 0 先执行。这个机制平时不显眼但在高频串口加高频定时器的场景下会要命。假设定时器 0 每 1ms 中断一次中断服务里干了 300μs 的活串口在这 300μs 里进来的数据就有被延后处理的风险。方式 1 的接收是单缓冲的SBUF只有一个如果上一个字节的RI还没被清掉下一个字节的接收完成会直接覆盖SBUF数据就这么丢了而且丢得无声无息。所以后面讲实例的时候我会把中断服务函数的执行时间当成一条硬约束来对待——能在中断里干的活越少越好能搬到主循环的坚决搬走。这不是洁癖是 51 这种资源极度紧张的平台上必须守的纪律。2. 定时器初值的推演过程2.1 机器周期是所有计数的尺子51 的定时器本质上是个加法计数器每个机器周期加 1。经典 8051 内核12T 模式下一个机器周期等于 12 个时钟周期。所以先算机器周期11.0592MHz 晶振12 / 11059200 1.0850694 μs12MHz 晶振12 / 12000000 1 μs这个数就是尺子的刻度。要定时 T 微秒需要计数的次数N T / 机器周期然后初值 65536 - N模式 116 位。注意这里的 65536 不是随便来的是因为计数器从初值一路加到 0xFFFF 再进位溢出总共能加65536 - 初值次。以 11.0592MHz、50ms 为例N 50000 / 1.0850694 46080 初值 65536 - 46080 19456 0x4C000x4C00拆成TH0 0x4C、TL0 0x00。这个数之所以这么“整”是因为 11.0592MHz 这个奇怪的频率本身就是为串口挑的——它是 9600 的整数倍关系的产物顺带让 50ms 定时也落在了整数上。同样以 12MHz、50ms 为例N 50000 / 1 50000 初值 65536 - 50000 15536 0x3CB0TH0 0x3C、TL0 0xB0。2.2 一组可以直接抄的初值和它背后的取舍把常用值整理成表用的时候直接查。但查之前请务必确认你的晶振和表头一致这是最容易出错的地方。目标定时晶振机器周期计数次数THTL实际时长误差50ms11.0592M1.0851μs460800x4C0x0050.000ms010ms11.0592M1.0851μs92160xDC0x0010.000ms01ms11.0592M1.0851μs9220xFC0x661.0004ms0.04%50ms12M1μs500000x3C0xB050.000ms010ms12M1μs100000xD80xF010.000ms01ms12M1μs10000xFC0x181.000ms0注意 11.0592MHz 下的 1ms921.6 不是整数。取 922 得到 1.0004ms单次误差 0.04%累积 1000 次后是 0.4ms做秒级计时完全可以接受。但如果你的应用要求长时间累计误差很小比如做一个每天误差不超过 1 秒的时钟这个方案就不够需要外挂专门的时钟芯片。工程上搞清楚“够不够用”比追求“绝对精确”重要得多。还有一点模式 1 是软件重装中断响应本身有延迟进中断要压栈、跳转几个机器周期每次重装的起点都会比理论值晚一点点。这个延迟很稳定所以误差是系统性的可以用“重装时补减几个数”的方式修正但大多数应用没必要。要真正消除这个误差换模式 2 的自动重装更直接。2.3 模式 1 和模式 2 的分工不能乱模式 1 是 16 位定时器最大定时 65536 个机器周期11.0592MHz 下约 71ms。超过这个长度必须靠软件计数累加。模式 2 是 8 位自动重装TL0计数、TH0存重装值溢出时硬件自动把TH0的值搬回TL0。最大定时只有 256 个机器周期11.0592MHz 下约 278μs做不了长定时但精度极高、代码极简。所以分工很清楚定时器 0 做系统节拍用模式 1配 1ms 或 10ms软件累加成秒。因为它要的是“够长”。定时器 1 做波特率发生器必须用模式 2因为它要的是“够稳”。模式 2 的自动重装完全由硬件完成不受中断响应时间影响波特率就不会抖。有个常见写法是让定时器 1 也在模式 1 下做波特率靠中断重装。这种代码我见过能跑但波特率会随中断延迟抖动通信距离一长或者干扰一大就开始出误码。不值得省那几行。3. 串口方式 1 的数据是怎么被搬到引脚上的3.1 SBUF 的两个身份读写方向决定它是谁51 的SBUF地址是 0x99但它物理上是两个独立的 8 位寄存器一个发送缓冲、一个接收缓冲。写 SBUF 是往发送寄存器里塞数据并启动发送读 SBUF 是从接收寄存器里取数据。同一个地址靠读写方向区分。这个设计带来一个必须记住的行为写 SBUF 之后数据不会立刻出现在 TXD 引脚上硬件要等下一个机器周期的内部时钟才启动移位。所以如果你在写完 SBUF 后立刻去改与发送相关的东西可能会干扰。稳妥的做法是写完就不管了靠TI标志判断完成。方式 1 的帧格式是 10 位1 个起始位低电平、8 个数据位低位先出、1 个停止位高电平。没有校验位没有地址位。这个格式和绝大多数上位机、模块的默认配置是匹配的所以方式 1 几乎是 51 串口通信的默认选择。发送一个字节的标准写法void UART_SendByte(unsigned char dat) { SBUF dat; while (!TI); /* 等硬件把这一帧移出去 */ TI 0; /* 标志必须软件清 */ }这段代码在裸机小工程里够用但它有个致命弱点while(!TI)是死等。9600 波特率下传一个字节约 1.04ms这 1ms 里 CPU 什么都干不了。如果主循环里还要维护别的时序比如驱动数码管动态扫描、读按键整个系统就会卡顿。所以后面实例里我会改成中断驱动。3.2 波特率误差这条线卡在多少才安全接收方是在每一位的中间时刻采样。一帧 10 位从起始位下降沿开始对齐到停止位结束时累积的定时偏差如果超过半个位宽采样就会落到相邻位上直接读错。理论上限是 ±5%但那是最理想情况。实际工程里要考虑晶振本身的精度普通无源晶振±20ppm到±30ppm问题不大但温漂和负载电容选错会放大、线路容抗导致的边沿变缓、以及对方设备的误差。行业里普遍把总误差控制在 2% 到 3% 以内当作安全线。用这个标准回头看 11.0592MHz 能产出哪些常用波特率目标波特率SMODTH1 / TL1实际值误差240000xF424000480000xFA48000960000xFD960001920010xFD1920005760010xFF576000115200—无解——推导过程方式 1 的波特率 (2^SMOD / 32) × fosc / (12 × (256 - TH1))。令N 256 - TH1则 SMOD0 时波特率 28800 / NSMOD1 时 57600 / N。115200 需要N 0.5或0.25都取不到整数。这就是为什么很多用 11.0592MHz 的项目跑到 115200 就开始丢包——它们其实是在用一个误差很大的近似值硬撑。想上 115200 有两条路一是换更高的晶振频率重新找整数解二是用带独立波特率发生器的增强型型号绕开定时器 1。经典 51 上死磕这个数没意义。顺便说个反直觉的点19200 反而比 9600 更容易出错因为它需要SMOD 1。很多人在 PCON 上栽跟头——PCON除了最高位SMOD还有几个电源控制位空闲模式、掉电模式直接写PCON 0x80虽然能置 SMOD但也把别的位清了。规范写法是PCON | 0x80; /* 只置 SMOD不动其他位 */ PCON 0x7F; /* 只清 SMOD */3.3 RI 和 TI 挤在同一个中断入口的处理顺序串口只有一个中断向量中断号 4发送完成和接收完成共用。所以中断服务函数里必须先判断是谁触发的void UART_ISR(void) interrupt 4 { if (RI) { RI 0; /* 这里取 SBUF 存起来 */ } if (TI) { TI 0; /* 这里标记发送完成 */ } }用两个独立的if而不是if...else if是因为理论上两个标志可能同时挂起比如发送刚完成、接收也刚好完成。虽然实际概率极低但多写一个if不费什么成本。这里有一个容易忽略的细节读 SBUF 会自动清 RI。也就是说RI 0这一句在读取 SBUF 之后其实是冗余的。但反过来如果你先RI 0再读 SBUF逻辑上完全没问题如果只读 SBUF 不清 RI硬件也会清掉。我个人的习惯是显式写RI 0放在读之前一来意图清晰二来防止某些编译器优化把读取动作挪位置。还有一个更隐蔽的坑中断服务函数里不要调用printf不要做浮点运算不要有任何可能耗时超过几百微秒的操作。串口接收是单缓冲的中断服务执行太久下一个字节进来就会覆盖SBUF。这个错误不会报错只会表现为“偶尔丢一两个字节”极难复现极难定位。4. 实例一定时器节拍加串口回环自检4.1 先把资源分配定下来动手写代码之前先把资源分清楚这一步比写代码本身更重要。资源用途配置定时器 0系统 1ms 节拍模式 111.0592MHz初值 0xFC66定时器 1串口波特率发生器模式 29600bpsTH1 0xFD串口数据收发方式 1允许接收开中断P3.0 / P3.1RXD / TXD复用功能无需额外配置选 1ms 而不是 50ms 做节拍是因为后面要做超时断帧需要毫秒级的粒度。1ms 中断在 11.0592MHz 下的实际周期是 1.0004ms一天累积下来误差约 34 秒做秒表肯定不行但做通信超时判断绰绰有余。这里体现的就是“按用途定精度”不是所有定时都要往最准了做。4.2 完整代码与逐段注释#include reg52.h #include stdio.h #define RX_BUF_SIZE 64 #define FRAME_IDLE_MS 5 /* 空闲超过5ms认为一帧结束 */ volatile unsigned int tick_1ms 0; volatile bit flag_1s 0; volatile bit tx_busy 0; volatile bit frame_ready 0; volatile bit rx_overflow 0; volatile unsigned char rx_buf[RX_BUF_SIZE]; volatile unsigned char rx_head 0; volatile unsigned char rx_tail 0; volatile unsigned int idle_ms 0; /* ---------- 定时器01ms 系统节拍 ---------- */ void Timer0_Init(void) { TMOD 0xF0; /* 只清 T0 的四位保住 T1 的配置 */ TMOD | 0x01; /* T0定时模式116位 */ TH0 0xFC; /* 922 个机器周期实测 1.0004ms */ TL0 0x66; ET0 1; TR0 1; } /* ---------- 串口 定时器19600bps ---------- */ void UART_Init(void) { SCON 0x50; /* SM00 SM11 方式1REN1 允许接收 */ TMOD 0x0F; /* 只清 T1 的四位保住 T0 的配置 */ TMOD | 0x20; /* T1定时模式28位自动重装 */ PCON 0x7F; /* SMOD 0 */ TH1 0xFD; /* 256-325311.0592M/12/3/32 9600 */ TL1 0xFD; TR1 1; ES 1; } /* ---------- 单字节发送中断驱动不阻塞 ---------- */ void UART_SendByte(unsigned char dat) { while (tx_busy); /* 上一字节还没走完就等避免覆盖 SBUF */ tx_busy 1; SBUF dat; } void UART_SendString(const char *s) { while (*s) { UART_SendByte(*s); } } /* ---------- 定时器0中断节拍 断帧计时 ---------- */ void Timer0_ISR(void) interrupt 1 { TH0 0xFC; /* 模式1必须软件重装 */ TL0 0x66; if (tick_1ms 1000) { tick_1ms 0; flag_1s 1; } /* 收缓冲非空时空闲计时才开始走 */ if (rx_head ! rx_tail) { if (idle_ms 0xFFFF) { idle_ms; } if (idle_ms FRAME_IDLE_MS) { frame_ready 1; idle_ms 0; } } } /* ---------- 串口中断收字节入环形缓冲 ---------- */ void UART_ISR(void) interrupt 4 { unsigned char dat; unsigned char next; if (RI) { RI 0; dat SBUF; next (rx_head 1) % RX_BUF_SIZE; if (next ! rx_tail) { rx_buf[rx_head] dat; rx_head next; } else { rx_overflow 1; /* 缓冲区满丢弃并置位 */ } idle_ms 0; /* 每收到一个字节超时计时清零 */ } if (TI) { TI 0; tx_busy 0; } } /* ---------- printf 重定向到串口 ---------- */ char putchar(char c) { UART_SendByte((unsigned char)c); return c; } /* ---------- 主函数 ---------- */ void main(void) { unsigned char dat; Timer0_Init(); UART_Init(); EA 1; UART_SendString(boot ok\r\n); while (1) { /* 回环收到什么原样发回去 */ while (rx_tail ! rx_head) { dat rx_buf[rx_tail]; rx_tail (rx_tail 1) % RX_BUF_SIZE; UART_SendByte(dat); } /* 每秒打印一次心跳 */ if (flag_1s) { flag_1s 0; printf(tick\r\n); } /* 一帧接收完成 */ if (frame_ready) { frame_ready 0; /* 这里做整帧解析比如校验、命令匹配 */ } if (rx_overflow) { rx_overflow 0; UART_SendString(overflow\r\n); } } }几个设计上的说明。回环测试用的是“收进来先入环形缓冲主循环再取出来发走”的路径而不是在串口中断里直接回发。原因是中断里发要占用发送缓冲如果主循环也在发两边会抢SBUF。走缓冲虽然多一层但路径清晰不会互相干扰。tx_busy这个标志的用法值得说一句。它是“发送寄存器被占用”的标记写 SBUF 前置 1TI中断里清 0。这样主循环里连续发多个字节时第二个字节会卡在while(tx_busy)上等待不会把第一个字节直接冲掉。如果不加这个标志连续写 SBUF 的结果是只有最后一个字节真正发出去前面的全丢了。4.3 上电之后怎么一步步验证代码烧进去之后别急着连业务设备按这个顺序验每一步都能独立定位问题。第一步只看 TX。上电应该立刻在串口助手看到boot ok之后每秒一行tick。如果什么都没有先查三件事晶振起振没有示波器量一下或者用万用表直流档量晶振脚对地电压正常在 2V 左右、TXD 引脚有没有接对、串口助手的波特率是不是 9600。第二步测回环。在串口助手里敲几个字符应该原样回显。如果 TX 正常但回显没有问题在接收侧重点查REN有没有置 1、RXD 线接对没有。第三步测高负载。在串口助手里粘一大段文本一次性发过去看会不会丢字符。丢的话说明环形缓冲或者中断处理有问题。缓冲区设 64 字节一次发 200 字节必然溢出这时候应该看到overflow提示而不是静默丢数据——能报出溢出说明这套机制是工作的。第四步测断帧。在frame_ready分支里加一句打印然后分两次发送abc和def中间间隔 100ms。应该看到两次断帧而不是一次。这一步验证的是超时计时逻辑。这四步做完通信链路就算彻底打通了后面接任何协议都是在这个基础上加解析。5. 实例二不定长帧的接收靠超时判断5.1 固定长度收数在真实场景里撑不住很多教程收串口数据是这么写的for (i 0; i 8; i) { while (!RI); RI 0; buf[i] SBUF; }教科书上跑得通真实项目里一定翻车。首先它假定每帧固定 8 字节稍微变一下格式就废了其次while(!RI)在主循环里死等一旦对端少发一个字节程序就永远卡死在这一行看门狗不在的话整机就假死了最后如果在等第 5 个字节的时候来了个干扰脉冲整个缓冲区就错位了后面全乱。真实场景的数据帧长度是不固定的命令帧可能 4 字节数据帧可能 20 字节中间还夹着不定长的字符串。判断“这一帧收完了”唯一可靠的依据是时间——两个字节之间的间隔超过某个阈值就认为这一帧结束了。这个思路和工业上常用的 RTU 协议是一致的只是那边用的是 3.5 个字符时间的固定算法我们用自己的毫秒节拍做近似。5.2 环形缓冲区的指针设计环形缓冲的核心就两个指针head是写指针中断里递增tail是读指针主循环里递增。两者相等表示空head 1 tail表示满。用“牺牲一个字节空间”来判断满是标准做法好处是不需要额外的计数器也不用处理读写同时进行的竞态。64 字节的缓冲区实际可用 63 字节这个代价可以接受。指针递增用取模next (rx_head 1) % RX_BUF_SIZE;如果缓冲区大小取 2 的幂64、128、256可以直接用位与next (rx_head 1) (RX_BUF_SIZE - 1);在 51 上%运算会调用库函数一次几十个机器周期只要一个周期。在中断服务这种对时间敏感的地方能省就省。我一开始也是用取模后来在一次高频中断的场景下发现中断服务时间太长改成位与之后缩短了将近三分之一。还有一个必须遵守的规则head只在中断里改tail只在主循环里改。两边都不碰对方的指针就不需要关中断保护。如果反过来主循环也要操作head那就得上EA 0/EA 1的临界区稍不注意就会漏掉某个分支导致数据错乱。5.3 超时阈值定多少怎么和节拍挂钩FRAME_IDLE_MS定 5ms 是个折中。太短了如果发送方是慢速设备比如某些模块一次发一帧要 10ms会被误判成多帧太长了一帧结束后到主循环处理之间会有明显延迟影响响应速度。计算方法是先确认发送方两个字节之间的最大间隔是多少。如果是连续发送间隔就是传输一个字节的时间9600 波特率下约 1.04ms。取 3 到 5 倍作为阈值比较稳所以 5ms 对应 9600 波特率是合适的。如果波特率降到 1200一个字节要 8.3ms那阈值至少得设 30ms。这个阈值最好做成宏定义方便按项目调#define FRAME_IDLE_MS 5还有一个容易忽略的点idle_ms的清零必须在收到字节的那一刻做而不是在环形缓冲的
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑