☰
51单片机定时器与串口通信:TMOD配置、波特率计算与中断收发实战
2026/11/2 9:21:49 网站建设 项目流程

1. 项目整体设计与思路拆解

「51单片机的定时器与串口通信」这个组合,几乎是我见过的每一块入门开发板上都会出现的经典课题。它看着像两个独立外设的练习,实际上是一条链路上的两个端点:定时器负责在时间轴上切出稳定的刻度,串口负责把刻度上发生的事情送出去,或者把外部的命令收进来。你把这两样东西配齐了,一块裸片就具备了「感知时间」和「与外界对话」的能力,绝大多数小项目——数据采集上报、按键响应、LED 状态机、简单的上位机联调——都能在这套骨架上长出来。这篇内容适合刚把 LED 点亮、正在为「为什么我的串口助手全是乱码」而抓头的人,也适合已经写过几版代码、想把实时性做得更稳一点的人。

真正让新手卡住的,往往不是寄存器本身,而是这几个寄存器之间的耦合关系。串口的波特率要用定时器 1 来产生,定时器 0 又要占着中断做系统节拍,两个定时器共用 TMOD 这一个字节,配置顺序搞错、位掩码写反,就会出现「定时器好好的,串口一开就崩」或者「串口能通,但延时全乱了」的现象。我打算把这条链路从头拆到尾:先把两个外设的底层逻辑讲清楚,再算明白每一条初值的来历,最后落到一份能直接烧进去跑的完整代码上,中间穿插我自己踩过的坑。

1.1 从「滴答声」和「比特流」两个视角看单片机

理解定时器,最简单的模型就是一个只会数数的哑巴。它不认识秒,只知道每过一个机器周期就往上加一,加到溢出就喊一声(置位中断标志),然后从零再来。在标准 8051 内核里,一个机器周期等于晶振周期的 12 倍,11.0592MHz 的晶振算下来机器周期是 1.085μs,这个数字是后面所有计算的地基。你给它装一个初值,本质上是告诉它「你从第几级台阶开始爬,爬到楼顶就叫我」,剩下的工作它自己完成,CPU 该干嘛干嘛去。

理解串口则是另一个视角。异步串行通信没有时钟线,收发双方全靠事先约定好的速率对齐每一位。一帧数据 10 位(1 起始位 + 8 数据位 + 1 停止位),每一位的时间宽度由波特率决定,9600bps 时每一位占 104μs。接收方在检测到起始位下降沿之后,按约定间隔采样,采到第 5、6、7 位的时候取多数作为这一位的结果。整个过程的容错窗口其实很窄,如果双方速率差得多了,采样点会一路漂移,漂到隔壁位上去,你看到的就是一行乱码。

把这两个视角叠起来,就得到了这块芯片的核心时间观:定时器在内部刻出均匀的刻度,串口把这些刻度换算成电平的持续时间。二者共用同一个晶振,所以晶振准不准,直接决定了通信能不能成。

1.2 方案选型上的三个分岔口

动手之前,有三个决策绕不开,选错了后面全是返工。

第一个分岔口是两个定时器怎么分工。8051 一般有两个 16 位定时器(T0、T1),部分型号还有 T2。最常见、也最省心的做法是:T1 专职给串口当波特率发生器,工作在方式 2(8 位自动重装),永远不需要 CPU 干预;T0 留给用户做系统节拍,工作在方式 1(16 位),允许定时时间拉得长一些。这个分工的好处是职责清晰,T1 一旦配好就再也不动,T0 想改成什么周期都随意。反过来,如果让 T1 去做节拍、T0 去产生波特率,虽然技术上可行,但每次 T0 里跑业务代码都会增加心理负担,不如一开始就分开。

第二个分岔口是查询还是中断。查询方式写起来直观:while(!TF0); TF0=0;,代码一看就懂。但它的致命问题是 CPU 被完全绑死,这段时间里按键按了不知道、串口来了数据接不了,整个系统退化成单线程。中断方式稍微绕一点,需要写中断服务函数、要理解现场的进出,但它把「等」这件事交给了硬件。我的建议很明确:只要系统里同时存在两个以上的时间敏感任务,就必须上中断,查询只适合写 demo 时验证寄存器配置对不对。

第三个分岔口是通信协议要不要自己定义。很多人做实验时就是一问一答,发个字符回个字符,能跑通就算完。可一旦要做真实一点的应用——比如周期性上报一组传感器读数——裸数据流就撑不住了,因为接收方无法判断哪几个字节是一组的开头。这时候必须约定帧头、长度、校验。协议本身不需要多复杂,一两个字节的帧头加一个累加和,就足够把一个按字节收数据的流切成一条条可靠的命令。

2. 核心细节解析与实操要点

2.1 定时器四种工作方式到底该怎么选

TMOD 寄存器的低 4 位管 T0、高 4 位管 T1,每 4 位里低 2 位是方式选择(M1、M0),第 3 位 C/T 选定时还是计数,第 4 位 GATE 选门控。GATE 这个位绝大多数场合写 0 就行,写 1 的话定时器的启停会额外受 INT 引脚电平控制,典型用途是测量外部脉冲宽度,日常做节拍用不上,别被它绕进去。

四种方式里,方式 0 是 13 位定时器,纯粹的历史遗留(为了兼容更早的芯片),初值计算还得拆成高低位错开的 5+8 结构,除了维护老代码,我想不出任何理由选它。方式 1 是 16 位,最大计数值 65536,在 11.0592MHz 下最长能定 71ms 左右,够覆盖绝大多数场景,缺点是溢出后不会自动重装,必须在中断服务函数里手动补初值。方式 2 是 8 位自动重装,把初值预先放在 TH 里,TL 溢出时硬件自动把 TH 的值搬回来,代价是最大只能计 256 个数,11.0592MHz 下最长 277.8μs——这个时间短得没法做节拍,但它的「零维护」特性恰好是波特率发生器梦寐以求的。方式 3 是把 T0 拆成两个 8 位定时器,属于特殊用法,一般项目不碰。

方式位数最大定时(11.0592MHz/12T)自动重装典型用途
方式 013 位约 8.9ms否兼容老代码
方式 116 位约 71.1ms否系统节拍、长延时
方式 28 位约 277.8μs是串口波特率发生器
方式 3双 8 位—否需要三个以上定时器时的补丁

选型逻辑一句话概括:要长时间、要灵活,用方式 1;要省心、要稳定、且不需要精确定时值,用方式 2。串口波特率发生器是方式 2 的教科书用法,因为它的溢出率完全由 TH 决定,写进去一次就永远不变,不会因为 CPU 忙而漂移。

2.2 初值计算:把公式拆成三步走

初值公式背下来不难,难的是每次都得重新推一遍,还容易在单位上翻车。我习惯把它拆成三步,每一步都落在一个具体数字上。

第一步,算机器周期。标准 12T 模式下,机器周期 = 12 / 晶振频率。11.0592MHz 时是 12 / 11059200 = 1.0850694μs,我平时就取 1.085μs。这一步是全流程的基石,如果你手上的芯片被配置成了 1T 模式(现在不少增强型兼容芯片支持软件切换),机器周期会变成 1 / 11059200 = 0.0904μs,同样的初值算出来的定时时间会差 12 倍,这是「明明照着教程抄的却不准」的头号元凶。

第二步,算需要计多少个数。计数值 = 定时时间 / 机器周期。想定 1ms,就是 1000 / 1.085 = 921.6,取整 922。想定 50ms,就是 50000 / 1.085 = 46080。

第三步,用上限减去计数值。方式 1 的上限是 65536,方式 2 的上限是 256。50ms 的初值就是 65536 - 46080 = 19456,转成十六进制是 0x4C00,于是 TH0 = 0x4C、TL0 = 0x00。

把常用的几档一次性算出来贴在工程里,比每次掏计算器靠谱得多:

目标定时计数值初值(十进制)初值(十六进制)THTL
100μs92654440xFFA40xFF0xA4
250μs230653060xFF1A0xFF0x1A
500μs461650750xFE330xFE0x33
1ms922646140xFC660xFC0x66
5ms4608609280xEE000xEE0x00
10ms9216563200xDC000xDC0x00
20ms18432471040xB8000xB80x00
50ms46080194560x4C000x4C0x00

注意:这张表对应的是 11.0592MHz 晶振、12T 模式。换晶振、换芯片的时钟模式,整张表全部作废,必须重算。我见过有人换了 12MHz 晶振继续用这张表,结果串口能通、定时器慢了 8%,找了整整一下午。

2.3 串口寄存器组的最小配置清单

串口这块真正需要动的一共就四个寄存器:SCON、SBUF、PCON 里的 SMOD 位、以及前面说的 T1 相关配置。把这几个的职责捋清楚,配置代码就是照单填空。

SCON 是串口控制寄存器,位定义里最常用的几个:SM0/SM1 决定工作方式,方式 1 是 10 位异步收发(1 起始 + 8 数据 + 1 停止),这是绝大多数项目的选择;REN 是接收允许位,写 1 才能收,很多人调试时发现只能发不能收,八成是漏了这一位;TB8/RB8 用于方式 2/3 的第 9 位,做多机通信或校验位时才用;TI 和 RI 是收发中断标志,硬件置位、软件清零,这一点极其关键。

SBUF 是一个物理上两个、逻辑上一个的寄存器。写 SBUF 触发发送,读 SBUF 取走接收数据,编译器靠指令类型自动区分,所以你在代码里看到同名变量干了两种完全不同的活,别慌,这是设计如此。

PCON 里只有最高位 SMOD 跟串口有关,它决定波特率是否加倍。在 11.0592MHz、T1 方式 2 的配置下,SMOD = 0 时 9600bps 对应 TH1 = 0xFD;如果要跑 19200bps,把 SMOD 置 1、TH1 还是 0xFD 就行,不需要重算。这个设计很贴心,但前提是你得记得 PCON 这个寄存器里还躺着别的东西(比如低功耗控制位),改的时候用位操作别整个赋值。

寄存器关键位配置值(9600bps/方式 1)作用
SCONSM0 SM1 = 0 10x50方式 1,允许接收
TMOD高 4 位 = 0x20—T1 方式 2,定时
TH1 / TL10xFD0xFD产生 9600bps
PCONSMOD = 00x00(保留其他位)波特率不加倍
TR111启动 T1
ES / EA1 / 11 / 1开串口中断、开总中断

2.4 波特率误差:11.0592MHz 到底赢在哪

这是我被问得最多的一个问题:为什么开发板上非要焊一颗 11.0592MHz 这种奇怪频率的晶振,用圆整的 12MHz 不好吗?

答案藏在除法里。波特率的计算公式是:波特率 = (2^SMOD / 32) × (T1 溢出率),而 T1 在方式 2 下的溢出率 = fosc / (12 × (256 - TH1))。整理一下,256 - TH1 = fosc / (12 × 32 × 波特率 / 2^SMOD)。这个式子里,256 - TH1 必须是整数,否则就会有误差。

11.0592MHz 这个数字恰好是 9600 × 1152,能整除得干干净净。12MHz 就没这么好运了:算 9600bps 时,256 - TH1 = 12000000 / (384 × 9600) = 3.255,只能取 3,反算出来的实际波特率是 10417,误差 8.5%。

晶振目标波特率256-TH1 理论值实际取值实际波特率误差
11.0592MHz2400121224000
11.0592MHz48006648000
11.0592MHz96003396000
11.0592MHz19200(SMOD=1)33192000
12MHz48006.5165208+8.5%
12MHz96003.25310417+8.5%

8.5% 是什么概念?一帧 10 位数据,接收方在第 6 到第 8 位之间采样判决,累计相位误差超过半个位宽(也就是 5%)就会采错。8.5% 意味着最后几位已经漂到隔壁比特上了,收到的就是彻头彻尾的乱码。所以这 8.5% 不是「精度差一点」,而是「完全不能用」。

提示:一般经验是总波特率误差控制在 2% 以内比较安全,超过 3% 就要打问号。11.0592MHz 在这几档常用波特率上的误差是 0,这就是它存在的全部理由。

另外提一句 115200bps。在 11.0592MHz、12T 模式下算不出整数(256 - TH1 会等于 0.25),跑不了标准 115200。真要高速,要么换晶振、要么用增强型芯片的波特率发生器模块,别硬凑。

3. 实操过程与核心环节实现

3.1 硬件连接与最小系统检查

动手写代码之前,先把硬件确认一遍,这一步省下来的时间比调试代码多得多。最小系统里必须确认的几件事:晶振和两颗负载电容(常见 22pF 或 30pF)焊到位、复位电路有 10μF 电容加 10k 电阻、EA 引脚(老型号)接高电平。晶振起振不良是最隐蔽的故障,表现出来是程序时好时坏、串口偶尔通偶尔不通,用示波器探头碰一下 XTAL2 就能看出来有没有波形。

串口这边,如果是和电脑联调,中间需要一个电平转换电路(经典的是电荷泵方案),TXD 接对方的 RXD、RXD 接对方的 TXD,交叉连接。共地是必须的,我遇到过一位同学折腾了两小时,最后发现是两块板子只连了信号线没连地线,参考电平悬空,收到的全是随机值。

还有一点容易被忽略:单片机的 IO 口在上电复位期间是弱上拉的准双向结构,串口的 TXD 在程序启动前可能处于不确定状态。如果对端设备对起始电平敏感,最好在 TXD 上加一个上拉电阻,让它默认保持高电平(空闲态),避免上电瞬间被误判成一个起始位。

3.2 定时器节拍:1ms 心跳的完整代码

我习惯用 T0 产生 1ms 的固定心跳,把它作为整个系统的时间基准,所有需要定时的任务都基于这个计数做除法,而不是各开各的定时器。下面这段是初始化加中断的实现:

#include <reg52.h> volatile unsigned int g_ms_tick = 0; /* 系统毫秒计数,必须加 volatile */ void Timer0_Init(void) { TMOD &= 0xF0; /* 只清低4位,保住 T1 的配置 */ TMOD |= 0x01; /* T0 方式1,16位定时 */ TH0 = 0xFC; /* 1ms @ 11.0592MHz, 12T */ TL0 = 0x66; ET0 = 1; EA = 1; TR0 = 1; } void Timer0_ISR(void) interrupt 1 { TH0 = 0xFC; /* 先重装,再做别的 */ TL0 = 0x66; g_ms_tick++; }

这段代码有两个细节值得说。第一,TMOD 的赋值用了「先清后或」而不是直接TMOD = 0x01,因为高 4 位还管着 T1,如果串口初始化在前、定时器初始化在后,直接赋值会把 T1 的方式位抹掉,串口立刻失效。这个坑我踩过一次,现象是「加上定时器代码后串口就不工作了」,查了很久才发现是这一行。第二,重装初值放在中断函数的最开头,而不是末尾。

为什么重装必须放最前面?因为中断服务函数本身要花时间,从响应中断到执行第一条指令有延迟,重装初值到再次溢出这段时间会把这部分开销算进去。如果你在中断末尾才重装,那么中断里执行了多少指令,定时器就少跑了多少时间,误差会随着中断频率累积。放在最前面,虽然也不能完全消除误差(中断响应延迟仍在),但至少把可测量的部分固定下来了。对于 1ms 中断,几微秒的偏差影响不大;但如果做的是 100μs 的高频中断,这就必须算清楚。

3.3 串口收发:中断骨架与环形缓冲区

串口的发送和接收共用同一个中断向量(中断号 4),进入中断后必须靠标志位判断到底是收还是发。硬件不会自动清标志,这一点和定时器一样,忘了写RI = 0就会反复进中断,程序直接卡死。下面是最小可用的骨架:

#define RX_BUF_SIZE 64 volatile unsigned char rx_buf[RX_BUF_SIZE]; volatile unsigned char rx_head = 0; volatile unsigned char rx_tail = 0; void UART_Init(void) { TMOD &= 0x0F; /* 只清高4位,保住 T0 的配置 */ TMOD |= 0x20; /* T1 方式2,8位自动重装 */ TH1 = 0xFD; /* 9600bps @ 11.0592MHz, SMOD=0 */ TL1 = 0xFD; SCON = 0x50; /* 方式1,REN=1 允许接收 */ PCON &= 0x7F; /* SMOD = 0 */ ET1 = 0; /* T1 只做波特率,不开中断 */ ES = 1; EA = 1; TR1 = 1; } void UART_ISR(void) interrupt 4 { unsigned char next; if (RI) { RI = 0; /* 先清标志,再取数据 */ next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { /* 缓冲区未满才入队 */ rx_buf[rx_head] = SBUF; rx_head = next; } /* 满了就丢弃,宁可丢数据也不要覆盖未处理的旧数据 */ } if (TI) { TI = 0; /* 发送完成,交给主循环处理下一步 */ } }

环形缓冲区这个结构看着简单,但它是把「中断里只能干极短的事」这条原则落到实处的关键。中断服务函数里绝不做协议解析,只负责把字节搬进队列就退出,所有解析逻辑放到主循环里跑。这样即使主循环某一刻在忙别的任务,串口也不会丢数据(只要队列没溢出)。

出队函数要注意一点:主循环读取rx_head的时候,中断可能正在修改它,所以要么在读取前短暂关中断,要么利用 8 位变量读写的原子性。我一般这么写:

unsigned char uart_get_byte(unsigned char *dat) { if (rx_tail == rx_head) return 0; /* 空 */ *dat = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUF_SIZE; return 1; }

这里rx_tail只有主循环会改,rx_head只有中断会改,两边各管一个变量,天然避开了大部分竞争。唯一要留意的是队列判满用「留一个空位」的方式,实际容量是 63 字节而不是 64,写代码时别把边界算错。

3.4 协议帧设计与状态机解析

裸字节流最大的问题是无法定界。我一般用一条很朴素的帧格式:0xAA 0x55 LEN CMD DATA... SUM。帧头两字节用于同步,LEN 表示 CMD 加 DATA 的总长度,SUM 是从 LEN 到最后一个数据字节的累加和(取低 8 位)。校验用累加和而不是 CRC,是因为在 51 这种资源紧张的平台上,累加和足够应付「偶尔一位翻转」的场景,代码量也小得多。

解析用状态机实现,好处是不占用额外缓冲、来一个字节处理一个字节、内存开销恒定:

typedef enum { S_HEAD1 = 0, S_HEAD2, S_LEN, S_CMD, S_DATA, S_SUM } parse_state_t; static parse_state_t s_state = S_HEAD1; static unsigned char s_len, s_idx, s_sum; static unsigned char s_cmd; static unsigned char s_data[16]; void proto_parse(unsigned char byte) { switch (s_state) { case S_HEAD1: if (byte == 0xAA) s_state = S_HEAD2; break; case S_HEAD2: s_state = (byte == 0x55) ? S_LEN : S_HEAD1; break; case S_LEN: if (byte == 0 || byte > 17) { s_state = S_HEAD1; break; } s_len = byte; s_idx = 0; s_sum = byte; s_state = S_CMD; break; case S_CMD: s_cmd = byte; s_sum += byte; s_state = (s_len > 1) ? S_DATA : S_SUM; break; case S_DATA: s_data[s_idx++] = byte; s_sum += byte; if (s_idx >= s_len - 1) s_state = S_SUM; break; case S_SUM: if (byte == s_sum) { proto_dispatch(s_cmd, s_data, s_len - 1); /* 校验通过才执行 */ } s_state = S_HEAD1; break; default: s_state = S_HEAD1; break; } }

状态机的精髓在于任何异常都能无条件回退到找帧头。长度字段非法、校验不通过、收到一半超时,全部s_state = S_HEAD1重来。这在干扰环境下非常实用,因为串口线上偶尔会冒出一个杂散字节,没有这个回退机制,一个噪声就能让整个解析器永久错位。

注意:proto_dispatch里不要做耗时操作。解析出命令后,更稳妥的做法是把命令挂到一个待处理标志上,让主循环去执行。中断和主循环的职责边界划清楚,系统才不会出现「偶发卡顿」。

3.5 printf 重定向与在线调试

调试阶段最爽的事情莫过于能直接printf出变量值。Keil C51 里重定向的方法是重写putchar:

#include <stdio.h> char putchar(char c) { while (!TI); /* 等上一个字节发完 */ TI = 0; SBUF = c; return c; }

写完这个函数,printf("tick=%u\r\n", g_ms_tick);就能直接在串口助手里看到输出了。

但我得提醒一句:这个putchar是阻塞式的,每次发送都会死等。在 9600bps 下,一个字符要 1.04ms,打印一行 20 个字符就是 20 多毫秒,这段时间 CPU 什么都干不了。所以它只适合调试阶段用,正式版本里必须去掉,或者改造成「丢进发送队列 + 中断后台发」的非阻塞版本。

还有个容易忽略的问题:printf在 51 上很占空间,打开浮点支持后代码体积会暴涨,某些型号的 Flash 直接撑爆。我的做法是调试时只打印整数,需要看小数就自己放大一百倍当整数打,看的时候心里除以一百,简单粗暴但有效。

另外,putchar依赖 TI 标志,如果在主循环里printf、同时串口中断里也在操作 TI,两者会打架。要么调试期间干脆不开串口接收中断,要么就老老实实实现一个发送环形队列,把两边的写操作统一起来。

3.6 时间片调度:把 delay 从主循环里赶出去

系统有了 1ms 心跳之后,最该做的事情就是把所有delay_ms()干掉。delay的本质是空转 CPU,它在的时候,串口数据只能靠中断收着,主循环里任何响应都会延迟。

改成时间片轮询的写法非常统一:

static unsigned int last_100ms = 0; void main(void) { unsigned char ch; Timer0_Init(); UART_Init(); while (1) { /* 串口数据处理,非阻塞 */ while (uart_get_byte(&ch)) { proto_parse(ch); } /* 100ms 周期任务 */ if ((unsigned int)(g_ms_tick - last_100ms) >= 100) { last_100ms += 100; task_report(); /* 上报数据 */ } /* 其他任务同理,各自维护自己的 last 时间戳 */ task_led(); } }

这里(unsigned int)(g_ms_tick - last_100ms)的写法是有讲究的。g_ms_tick是 16 位无符号数,大约 65.5 秒就会回绕一次。如果写成if (g_ms_tick >= last + 100),回绕的瞬间会失效一次;而用减法再强转无符号,回绕时结果依然是正确的差值,这是处理定时器溢出的标准套路,值得记住。

三个任务的执行周期分别是「有数据就来」「100ms 一次」「主循环空闲就跑」,互不阻塞,任何一个任务慢了都不会拖垮其他两个。这就是时间片轮询相对delay的最大价值——它把「等待」从 CPU 身上彻底剥离了。

4. 常见问题与排查技巧实录

4.1 通信类故障速查表

串口出问题时,接收端表现为「全无」「乱码」「时通时不通」三种,对应原因完全不同,按表排查效率最高:

现象可能原因排查手段
完全收不到数据TXD/RXD 未交叉连接;未共地;REN 位为 0;TR1 未启动用示波器看 TXD 有无波形;逐位核对 SCON
收到的全是 0x00 或 0xFF波特率严重不匹配;晶振未起振或频率不符核对晶振型号与 TH1 取值;示波器测 XTAL2
数据大部分正确、偶发错位波特率误差偏大;中断被长时间屏蔽重算误差;检查是否有长临界区
上电后能通、跑一会儿就卡死中断里忘了清 RI/TI;接收缓冲溢出未处理检查中断服务函数的标志清零
只能发不能收SCON 的 REN 未置 1;接收中断使能漏了读回 SCON 确认
只能收不能发SBUF 写入后未等 TI;TI 初值被误置 1发送前手动清 TI

有一次我遇到一个特别隐蔽的情况:串口助手显示的内容永远差一个字符,比如发ABCD收到BCD。查了很久才发现是初始化时 TI 被上一次残留的状态置了 1,第一次写 SBUF 之前while(!TI)直接通过,第一个字节还没真正发出去就被下一个覆盖了。解决办法很简单,初始化结束时手动TI = 0;清一次,或者在putchar里改成先写 SBUF 再等 TI。

4.2 定时器类故障速查表

定时器的问题通常表现为「不准」和「丢中断」,两者的根因差别很大:

现象可能原因排查手段
定时时间整体偏差约 12 倍芯片工作在 1T 模式而按 12T 算初值查芯片手册的时钟分频配置
定时时间偏慢 1%~2%中断里重装初值的位置不对,开销被计入把重装挪到函数第一行
定时时间忽长忽短有其他中断抢占;中断服务函数内有关中断操作检查中断优先级设置
定时完全不动TR0 未置 1;TMOD 被后续代码覆盖单步看 TMOD 的最终值
长时间运行后计数异常计数变量位宽不够,溢出检查unsigned int是否够用
定时器一开,串口就废TMOD 赋值时用=覆盖了 T1 的方式位改成先清后或的位操作

「TMOD 被覆盖」这条我要重点说一下,因为它太常见了。很多人写初始化是这样的:

void Init(void) { TMOD = 0x20; /* 配 T1 做波特率 */ UART_Init(); TMOD = 0x01; /* 配 T0 做定时器 —— 灾难 */ Timer0_Init(); }

第二行赋值把高 4 位清零了,T1 从方式 2 变成方式 0,波特率瞬间跑飞。正确写法就是我前面代码里的TMOD &= 0xF0; TMOD |= 0x01;,每次只动自己那 4 位。这个习惯一旦养成,后面加多少外设都不会互相踩。

4.3 几条不写在手册里的经验

第一条:中断服务函数里能少写一行就少写一行。我见过有人在串口中断里直接调printf回显,结果因为printf内部也在等 TI,而此刻 TI 正是触发中断的那个标志,形成死锁,整个系统卡死。记住一个原则:中断里只做「搬数据」和「置标志」,所有需要判断、循环、等待的事情,全部挪到主循环。

第二条:共享变量不加 volatile,编译器会教你做人。g_ms_tick这种在中断里改、在主循环里读的变量,如果不加volatile,编译器优化时可能把它缓存在寄存器里,主循环里读到的永远是旧值。这个 bug 极其难查,因为它只在特定优化等级下出现,换台电脑编译可能就好了,让人以为是硬件问题。

第三条:波特率能低不高。新手总想一步到位上 115200,但在 51 平台上,9600bps 已经足够用了,而且容错窗口大得多。等系统跑稳了,再考虑提速。实际上很多工业现场的设备仍然跑 9600,稳定比快重要。

第四条:先把发送调通,再调接收。发送是被动的,你可以用串口助手当接收端,很容易判断单片机有没有发对;接收是主动的,两端都有嫌疑,排查成本翻倍。我一般是先写一个「上电每隔一秒发一次 0x55」的最小程序,确认链路和波特率都对,再往上叠逻辑。这个顺序看起来笨,但能省掉大量来回猜测。

5. 一点个人体会

这套定时器加串口的组合,我前后在不同的板子上实现过不下十次,每次重写都会发现一些之前没注意的细节。最开始我也觉得寄存器配置就是抄手册,抄完能跑就完事;后来做的东西稍微复杂一点,才发现真正的功夫全在「边界」上——中断和主循环的边界、缓冲区满和空的边界、计数器回绕的边界、误差可接受和不可接受的边界。这些边界在教科书里往往只有一句话,但在实际项目里,它们就是系统稳不稳的分水岭。

如果让我给刚上手的人一句建议,那就是:先把 1ms 心跳和 9600bps 这两件事做到「闭着眼睛也能写对」,再往上加任何功能都会轻松很多。因为往后的所有需求,无论是采集、控制还是通信,本质上都是在这两个基础节拍上做加减法。基础扎实了,你看别人的代码会突然变得通透——你能一眼看出他为什么在这里关中断,为什么那里要用环形缓冲,为什么初值要这么算。这种通透感,比跑通一个 demo 有价值得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询