1. 为什么我建议初学者先把USART串口通讯玩透
刚学STM32那会儿,最痛苦的事不是看不懂芯片手册,而是程序从烧录到运行全凭感觉。LED能闪就接着往下写,闪不了就改引脚重烧,来回折腾十几分钟,最后发现只是线松了。这种“盲调”阶段的根本症结,是芯片没有一条能向外界反馈状态的通道。USART串口通讯恰恰就是最容易搭起来的反馈通道。如果说单片机是一个正在做手术的病人,串口就是手术台旁边的监护仪,心跳、血压、异常报警都能从这里看到。
按照“嵌入式学习之路-->stm32篇”的学习节奏,GPIO、定时器都碰过之后,下一步切入USART是性价比很高的选择。原因很简单,后面要接触的蓝牙模组、WiFi模组、GPS模块、各类传感器,甚至一些显示屏,底层基本都是走串口。很多模块出厂就只留了TX、RX、GND三根线,想让STM32跟它们对话,绕不开USART。把这一节吃透,后面学I2C、SPI这些总线时会顺很多,因为“电平、时序、协议”这三个概念已经通过串口建立起来了。
USART跟UART经常被混着叫,其实它们只差一个字母。UART是通用异步收发器,USART多出来的“S”代表同步,多了同步时钟线,也兼容异步模式。实际开发中绝大多数情况都按异步模式使用,所以你听到“串口通讯”时,基本指的就是UART那套工作方式。这篇博文不讲空泛的理论,我会从硬件接线说起,讲到初始化配置、轮询收发、中断接收、printf重定向,最后放几个我在实际调试中反复踩过的坑和排查思路。刚跑通点灯的新手能从里面找到可直接抄写的步骤,已经写了很久串口代码的朋友也能在异常排查部分找到一些线索。
2. 动手之前的硬件常识:电平、引脚和共地最容易出错
2.1 TTL电平、RS232电平、USB转串口模块怎么选
很多新手第一次接串口就一脸懵:为什么开发板上明明写着TX、RX,接到电脑上却没反应?问题往往出在电平匹配上。STM32的串口引脚输出的是3.3V TTL电平,而电脑的USB接口和传统RS232串口走的完全是另一套电气标准。直接把单片机的TX接到电脑串口上,轻则通讯失败,重则烧坏引脚。所以中间必须有一个电平转换环节。
常见的做法是买一个USB转TTL模块,模块上一颗转换芯片负责把USB信号和3.3V TTL电平相互转换。模块上同样标有TX、RX、GND、VCC几个引脚,接线时注意TX要接单片机的RX,RX要接单片机的TX。为什么必须交叉?串口通讯的本质就是“你发我收”,双方都要把自己的发送脚接到对方的接收脚上。如果TX对TX、RX对RX,就变成两个人同时冲着对方喊话,却没人竖起耳朵听,数据自然传不通。
如果你的开发板上本身就板载了USB转串口芯片,那就更省事了。直接拿USB线把开发板和电脑连起来,电脑上会出现一个COM口。注意这时候不要再去外接USB转TTL模块,两个转换芯片同时挂在同一条串口线上会产生电平冲突,轻则收发异常,重则损坏芯片。
2.2 引脚映射别想当然,初始化前先查数据手册
STM32的每个串口外设都对应一组或多组引脚映射。以最常见的USART1为例,它的默认引脚是PA9作为TX,PA10作为RX。看到这里你可能会想,那我把杜邦线从PA9、PA10引出去不就行了?逻辑上没错,但前提是你得确认自己用的开发板没有把这组引脚复用成其他功能。有些板子为了兼容别的功能模块,会把USART1的默认引脚引到了其他地方,或者只有通过跳线帽才能把串口信号接到板载转换芯片上。
我见过不少新手接好了线却毫无反应,最后发现是板载的串口跳线帽没插。所以动手前花两分钟翻一下开发板原理图,确认一下板载串口芯片连接的是USART1还是USART2,对应到芯片的哪个引脚。这件事怎么强调都不过分,因为它能避免后面90%的“硬件看起来没问题但就是不工作”的疑难杂症。
2.3 共地问题:三根线就是信号TX、RX和地
总有人问,TTL串口到底接几根线?答案是最少三根:TX、RX、GND。很多入门教程画连接图时,把TX和RX画得又粗又明显,GND却画得很细,让人以为地线可有可无。实际上GND恰恰是串口通讯里最容易翻车的一环。通讯双方必须在同一个参考电位上,彼此的电平判断才有依据。如果两个设备不共地,信号线上的电压差会飘忽不定,接收方看到的电平可能是乱跳的,表现出来就是乱码或者完全收不到数据。
共地的做法很简单:把单片机开发板的GND和USB转TTL模块的GND用杜邦线连起来,或者把两个板子的GND接到同一个电源地。用同一个电源给两个板子供电时,它们通常已经通过电源共地了,但为了保险起见,我仍然建议在串口接线附近再补一条GND连接。尤其是当开发板通过不同电源适配器分别供电时,GND不共导致的故障非常隐蔽,换个串口助手、调波特率都解决不了,最后才发现两根GND根本没接在一起。
3. 初始化最怕顺序错:时钟、GPIO、USART参数的先后关系
3.1 为什么必须先把时钟打开,外设才“醒”过来
STM32跟电脑上写应用程序不一样,它每个外设的时钟都由RCC模块控制。刚上电时,出于降低功耗的考虑,大部分外设时钟默认是关闭的。你可以把每个外设看成一间办公室,时钟就是办公室的电闸。电闸没合上,你打再多次电话、发再多指令,里面的人也无法响应。所以初始化串口的第一步,永远是先使能USART外设的时钟,同时使能它使用到的GPIO引脚的时钟。
以USART1为例,它挂在APB2总线上,使用的PA9、PA10属于GPIOA。初始化代码里对应做的就是同时打开USART1和GPIOA的时钟。这里有一个很容易被忽视的细节:如果直接用寄存器操作而漏了GPIO时钟,GPIO配置寄存器根本没上电,写配置不生效,串口当然不会工作。而如果GPIO时钟开了、USART时钟没开,后面操作USART寄存器时程序会直接进入硬件错误中断,因为你访问了一个没有时钟的外设地址。
3.2 配置GPIO复用功能的两种选择:推挽复用和浮空输入
串口的TX脚和RX脚,GPIO模式配置不一样。TX是发送数据用的,需要由USART外设控制引脚输出电平,所以要配置成复用推挽输出;RX是接收数据用的,由外部设备输入电平给到USART接收移位寄存器,所以通常配置成浮空输入或带上拉的输入模式。有些人图省事把两个脚都配成复用推挽输出,发送倒是正常,接收却可能因为引脚电气状态不对而出现丢字节。
用标准外设库写,这一段的代码大概长这样:
void UART1_Init(void) { GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_StructInit(&gpio); gpio.GPIO_Pin = GPIO_Pin_9; // TX gpio.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽输出 gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_10; // RX gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, &gpio); ... }这段代码看起来简单,但“先配GPIO还是先配USART”是有讲究的。我习惯先把引脚复用功能设好,再配置USART参数,最后使能USART。顺序反过来也不是绝对不能工作,但某些芯片内部在引脚复用尚未建立时就开启了外设输出,可能会导致短暂的异常电平。所以老老实实按顺序来,能少一次排查。
3.3 波特率不是随便填的:从寄存器里算一遍就懂了
波特率是串口通讯中双方约定的“说话语速”,单位是bps,表示每秒传输的bit数。如果速率不一致,就像普通话对话里夹杂了方言,接收方完全不知道你在说什么。最常见的速度是9600和115200,但如果你的外部设备是某个传感器模块,它可能固定工作在19200或38400,这时候初始化代码里的波特率就要跟着改。
波特率不是拿来就填的参数,它由USART外设的时钟频率和一个分频寄存器BRR共同决定。标准的异步模式公式是:
波特率 = 外设时钟 / (16 * USARTDIV)反过来就是:
USARTDIV = 外设时钟 / (16 * 波特率)举一个实际例子。如果串口挂在72MHz的时钟上,目标是115200bps,那么:
USARTDIV = 72000000 / (16 * 115200) = 39.0625BRR寄存器写入时要把USARTDIV拆成整数部分和小数部分:整数部分39,小数部分0.0625换算成4位小数位表示,0.0625 * 16 = 1,最终BRR写入 (39 << 4) | 1,整体数值是625。
USART1->BRR = (39 << 4) | 1; // 等价于写625,产生115200bps新手往往会直接用库函数USART_Init传一个波特率参数进去,省去手动算BRR的麻烦。但这道计算题还是要做一遍,因为很多疑似“波特率不对”的乱码问题,追根溯源是外设时钟频率没弄对。比如系统时钟实际跑在64MHz而不是72MHz,按72MHz算出来的分频结果就会产生误差,导致9600的波特率误差逐渐累积出乱码。
3.4 数据位、停止位、校验位,三个必须统一的约定
双方除了波特率一致,还要约好一帧数据的格式。串口异步传输一帧一般包含起始位、数据位、可选的校验位、停止位。最常见的是“8N1”,也就是8位数据、无校验、1位停止位。STM32的USART参数配置里对应的是8位字长、无校验、1个停止位。如果其中一个设置跟对端不一致,比如对端是7位数据位,这边配了8位,收到的字节就会整体错位。
初始化结构体里的这几项其实都有默认值,标准外设库里的USART_StructInit会把波特率设为9600,8位数据,1位停止位,无校验。但我不建议完全依赖默认值,因为你不知道后面会把代码拷到哪个项目里。每次初始化串口时明确写出波特率、字长、停止位、校验位、收发模式,看起来啰嗦,实际上是对通讯双方契约的显式确认。
4. 轮询收发能跑通,但为什么说它只能垫底
4.1 HAL_MAX_DELAY是个温柔的陷阱
先来看一个用HAL库发送一个字节的代码:
HAL_UART_Transmit(&huart1, (uint8_t*)"OK\r\n", 4, HAL_MAX_DELAY);这个函数在大多数例程里都能正常跑,发送一个字符串毫无压力。但HAL_MAX_DELAY这个参数的意思是“无限等待”,意思是直到发送完成或者出现错误,函数才会返回。如果串口线断开,或者对端设备没上电,发送数据时TXE标志始终得不到满足,程序就卡在发送函数里不出来了。嵌入式开发里最怕的就是这种“静默卡死”,它比报错更让人头疼,因为你还得花时间定位原来卡在哪个函数里。
接收侧的轮询更危险。用HAL_UART_Receive加上HAL_MAX_DELAY等待一个字节时,如果对端一直不发数据,程序会一直堵在接收函数里。放在主循环里还能勉强接受,放在某个事件触发流程里,整个系统都会因为等待一个永远不会来的字节而停止工作。所以轮询模式只适合学习验证,不适合作为产品代码的常态。
4.2 设置超时时间,让“等不到”变成“可处理”
解决无限等待最简单的办法,是给收发函数一个明确的超时。比如用HAL库发送时写一个100ms的超时:
HAL_StatusTypeDef status; status = HAL_UART_Transmit(&huart1, txdata, txlen, 100); if (status != HAL_OK) { // 发送超时或异常,在这里做处理 }接收时也一样。给一个50ms超时,如果超时没收到数据,就返回HAL_TIMEOUT,主程序可以根据返回状态决定是重试还是走错误流程。这样一来,故障就不再是“整个程序死在那里”,而是变成了一个可捕获、可处理的状态,这是从“能跑”走向“可靠”的第一步。
另外还有一个必须强调的坑:不要在中断服务函数里调用阻塞的轮询收发。中断服务程序应该快进快出,你在里面等待一个串口发送完成,整个MCU的中断响应就都被拖住了。很多“单片机好像死机了”的假象,其实就是中断服务里有个轮询收发在死等。如果确实需要在中断里发送数据,应该只把数据放进发送缓冲,把实际发送操作放在中断外或者使用发送中断机制。
5. 中断式接收与环形缓冲区:真正适合实战的数据接收思路
5.1 每个字节都触发一次中断,是接收数据的底座
学习串口接收时,很多教材会让你在主循环里不停轮询RXNE标志:
while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET);这其实就是上面说的轮询接收,问题很直观:如果主循环正在做别的事,或者被某个耗时操作卡住,串口来的数据就只能在硬件接收寄存器里苦等。等主循环回来读时,新的字节可能已经覆盖旧数据了,发生溢出。解决这类丢失问题,最简单的路径是开中断。让芯片在收到字节时即刻通知CPU,CPU放下手头的事,先把数据搬到内存里存起来。
开启USART1接收中断需要两件事,一是使能USART的RXNE中断,二是使能对应中断线上NVIC的通道:
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); NVIC_EnableIRQ(USART1_IRQn);然后在中断服务函数里把收到的字节从数据寄存器读出来:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = (uint8_t)USART_ReceiveData(USART1); rx_buf[rx_head] = data; rx_head = (rx_head + 1) % RX_BUFFER_SIZE; } }注意,从USART_ReceiveData读出数据这个动作本身就会清掉RXNE标志。如果你不读,RXNE一直保持置位,后续的接收就进不来了。这也是很多人“只开了一个中断,后面再也不触发”的原因之一。
5.2 环形缓冲区:头尾指针围成的“数据等待区”
上面的中断服务代码里已经用到了一个数组也叫环形缓冲区。环形缓冲的思路很朴素:用一个固定大小的数组,写入指针head不断往数组后面走,读取指针tail在后面追。走到数组末尾就回绕到开头,形成一个环。
定义部分可以这样写:
#define RX_BUFFER_SIZE 256 volatile uint8_t rx_buf[RX_BUFFER_SIZE]; volatile uint16_t rx_head = 0; volatile uint16_t rx_tail = 0;写入方是中断服务程序:
rx_buf[rx_head] = data; rx_head = (rx_head + 1) % RX_BUFFER_SIZE;读取方是主循环或者业务处理函数:
uint16_t BUF_GetReadableCount(void) { return (rx_head + RX_BUFFER_SIZE - rx_tail) % RX_BUFFER_SIZE; } uint8_t BUF_ReadByte(void) { uint8_t ch = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUFFER_SIZE; return ch; }环形缓冲区天然适合“中断写、主循环读”这种一写一读的场景。为什么不用队列结构?因为嵌入式里动态内存分配要尽量避免,用一个固定数组加两个指针就足够。为什么缓冲区大小设为256?这是一个折中,太小可能在主循环忙的时候溢出,太大又浪费RAM。1024、512、256都是常用值,具体看你一次数据处理能及时到什么程度。如果数据频率高而主循环响应慢,就需要增大缓冲区,否则新数据会把还没被读走的数据覆盖掉。
这里还有一个多任务相关的心得:在中断服务里写rx_head,在主循环里读rx_tail,两个指针各自独立,通常不需要加锁。但是如果读取侧也有别的中断会操作同一个缓冲区,就需要在操作前关闭对应中断,防止指针在更新过程中被中断打断。一个简单的临界区保护可以在读走数据、更新tail前后关闭USART中断,处理完再打开。
5.3 处理不定长数据:用空闲中断判断“一帧结束”
缓冲区解决的是字节存储问题,但实际项目里更常见的是需要对一帧一帧的数据做处理。对端发过来的可能不是固定长度,而是一串以换行结束的字符串,或者是一组以特定帧头帧尾包裹的数据。如果不做帧切割,主循环读到的只是一堆连续字节,根本分不清哪一段属于哪一条命令。
常用的做法是利用串口空闲中断,也就是当串口在一段时间内没有收到新字节时,触发一次中断,表示这一帧数据传输结束了。在标准外设库的初始化里使能空闲中断:
USART_ITConfig(USART1, USART_IT_IDLE, ENABLE);中断服务函数里判断IDLE标志:
if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART1); // 读一次数据以清除IDLE标志 frame_len = BUF_GetReadableCount(); // 当前缓冲区里的数据就是一帧 // 把frame_len交给上层协议处理 }HAL库版本里对应的是HAL_UARTEx_ReceiveToIdle_IT这种接口,它在收到一帧空闲时回调通知,本质是同一个理念。空闲中断的坑在于,它判断的是“总线空闲”,如果对端设备发送时每两个字节中间间隔了较长时间,它可能会被拆成好几帧。所以真正的帧切割还是要结合协议设计,比如在数据帧开头放帧头0xAA、0x55,结尾放校验字节,上层按帧头找起始位置,再按长度字段收完一整帧。空闲中断只是一个辅助分帧的好帮手,不是唯一答案。
6. printf重定向:把串口变成开发期的“仪表盘”
6.1 为什么程序里printf直接就能往串口吐字符
很多嵌入式方案都喜欢在代码里写printf来打印调试信息,但printf本身只是C库里的格式化输出函数,它并不知道该往哪个设备输出。要让printf把输出写到串口,需要做一件叫“重定向”的事,把fputc这个底层字符输出函数改写成从串口发出去。
在标准外设库风格下,最简单的一种实现是:
int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); return ch; }加上这段之后,printf("hello\r\n")内部就会逐字符调用fputc,最终从TX引脚发出去。这个方案的优点是非常直观,性能也够用,缺点同样明显:如果你在中断服务里调用printf,里面这个while等待TXE复位的循环可能会跟主循环里的发送形成竞争,表现为输出乱序或者字符被吞。所以我给printf的使用划了几条边界:主循环里放心用,中断里尽量不用,一定要用时就把待打印信息先缓存,移到主循环里再输出。
6.2 重定向后还要处理半主机模式和浮点打印
在Keil MDK环境下,默认的C库printf可能会链接到“半主机模式”,这种模式会把输出送到调试器而不是串口。于是出现一个很经典的现象:程序编译正常、在线调试时能看到printf内容,拔掉调试器单独跑就什么都没有。这是因为printf的输出目标是调试主机的标准输出,而不是USART外设。
解决思路一般有两条。一条是勾选MicroLIB,这个轻量级C库对串口重定向支持简单,省去配置半主机的问题;另一条是手动在代码里关闭半主机模式。如果你用printf只是为了串口输出,又不想引入别的库,MicroLIB是最省事的路线。但要注意MicroLIB的单精度浮点printf有时输出跟标准C库有细微差别,对日志打印影响不大。
还有一个容易踩的坑:在默认不启用某些浮点库选项时,printf打印浮点变量会输出成空字符串。你在串口助手里看到一条printf("%.2f", value)被输出了一个空或者异常字符串,别急着怀疑串口,先检查编译器/链接器里浮点打印支持是否打开,或者选择MicroLIB来回避这个问题。我印象很深的一次浪费了一小时,最后发现只是浮点库没启用。
6.3 用宏把日志分成等级,输出从“随心所欲”变成“可控制”
直接到处写printf,项目小的时候很爽,项目一大就难受了。调试信息满天飞,根本分不清哪些重要、哪些只是噪音。比较好的做法是定义一套简洁的日志宏,编译时通过一个开关决定哪些等级的信息需要输出。
比如可以这样定义:
#define LOG_LEVEL_DEBUG 0 #define LOG_LEVEL_INFO 1 #define LOG_LEVEL_WARN 2 #define LOG_LEVEL LOG_LEVEL_DEBUG #define LOG(level, fmt, ...) \ do { \ if ((level) >= LOG_LEVEL) { \ printf("[%s] " fmt "\r\n", \ level >= LOG_LEVEL_INFO ? "INFO" : "DBG", \ ##__VA_ARGS__); \ } \ } while (0) #define LOG_INFO(fmt, ...) LOG(LOG_LEVEL_INFO, fmt, ##__VA_ARGS__) #define LOG_DEBUG(fmt, ...) LOG(LOG_LEVEL_DEBUG, fmt, ##__VA_ARGS__)这样当需要保持日志输出精简时,把LOG_LEVEL调到LOG_LEVEL_INFO,开发时再调回DEBUG级。真正的产品代码里甚至可以通过串口命令运行时动态控制日志等级,那就要判断每个等级是否可打印,而不是编译期裁剪了。动态的好处是不用重新编译就能看到详细信息,坏处是日志代码本身会增加if判断开销。对于调试用途,编译期裁剪已经够用。
7. 我常遇到的串口异常现象与完整排查链路
7.1 乱码:先别怀疑芯片,从最廉价的环节开始查
串口收到的数据全都对不上,比如明明发了“hello”,收到的是“0x68 0x00 0x05 ...”,这种乱码几乎是串口调试里遇到频率最高的问题。我排查乱码的固定链路是:波特率、时钟频率、接线、电源干扰。第一步确认双方波特率是否完全一致,包括115200两边都是115200,而不是一端115200另一端9600。第二步确认单片机实际运行的时钟频率跟初始化代码里算波特率时用的频率是否一致,特别是用了外部晶振时,如果晶振没起振而芯片跑在内部HSI上,波特率误差会放大,结果就是高速率下乱码突出,低速率下勉强能通。
第三步检查接线是否正确交叉,TX和RX是否接反,GND是否可靠连接。第四步才是考虑干扰,杜邦线过长、电源纹波过大都可能导致接收采样错误。我建议新手备一个USB转TTL模块,把模块的TX和RX直接短接,做一个自发自收测试。如果短接后接收能看到自己发出去的数据,模块就是好的,问题多半出在单片机的初始化配置上。这个“自环测试”虽然简单,却在排查中能快速缩小问题范围。
7.2 丢第一个字节:一种很典型的“电平还没稳定”现象
另一种常见的现象是发送一串数据,每次都是第一个字节丢了,后面都正常。比如发送“ABCDE”收到“BCDE”。如果你用的波特率跑得很快,第一个字节丢失往往跟引脚状态或者USART使能的时机有关。初始化时GPIO和USART使能的先后顺序如果不对,TX引脚可能先被拉低或者拉高,接收方把这段电平误判成一帧的半截数据,导致真正有效数据进来时同步错位了。
还有一种可能是对端设备上电慢于本机,本机已经初始化好串口开始发送,对端还没准备好,前面的字节就丢了。解决思路也很直接:在双方通讯建立阶段,发送方先延时几百毫秒再发首包;或者接收方用一个固定帧头做同步,一旦发现帧头就认为接下来是有效数据,丢掉之前所有噪声。这比反复调整初始化顺序更实用,因为真实系统中对端设备的上电时间是不可控的。
7.3 偶发性丢帧或数据错位:多半是中断优先级和缓冲区问题
程序在主循环里收串口数据,偶尔会丢一两个字节,很多时候不是硬件问题,而是软件层面中断卡顿导致的。串口接收中断如果被别的中断长期打断,此时新的字节到达,RXNE标志无法及时被响应,硬件接收寄存器又只能暂存一个字节,后面的字节就会覆盖前面的数据,再也找不回来。排查时可以看接收中断和其他高优先级中断的执行时间,如果中断服务里有耗时的循环或者阻塞调用,优先把它们挪到主循环。
缓冲区溢出也经常被忽略。环形缓冲区写满后,新写入的数据会覆盖最旧的数据,看起来像是随机丢字节,其实被丢弃的是缓冲区最前面的旧数据。解决方法是把缓冲区调大,同时确保主循环读取频率足够高。如果数据量很大且收发都有要求,可以升级到DMA结合串口空闲中断,让数据搬运不再依赖CPU一个一个地读,而是硬件自己搬进内存,CPU只在DMA完成或空闲中断时处理一整段数据。关于DMA的细节,等下一篇专门再讲,这里先记住一个原则:当CPU逐个处理字节成为瓶颈时,就该考虑DMA了。
7.4 插拔USB后通讯恢复不了,复位却好了
开发时经常把USB线拔掉重新插上,然后发现串口助手连不上COM口,或者连上了但发送没有响应。手动按一下开发板复位键又恢复正常了。这类问题大多是USB转串口芯片和单片机之间通信状态没对齐。电脑端的USB重新枚举,转换芯片会重新初始化,但单片机这边完全不知情,它还在按照原来的状态等待数据,两边状态就错位了。
处理办法有几个方向。一是先检查USB转串口芯片有没有自动复位单片机的功能,有些板载串口芯片会在USB枚举成功时拉低单片机的复位脚,让两边重新同步。如果没有这个硬件机制,就要养成烧录完成或重新插拔USB后手动复位的习惯。二是在协议层加入握手机制,比如PC端打开串口助手的瞬间先发一个“握手查询”给单片机,单片机收到后返回确认,双方在应用层完成一次重新同步。从工程角度说,第二种做法更可靠,因为它不依赖特定硬件设计。
7.5 一个建议:永远保留一条“看得到内部状态”的串口
串口这个外设,平时看起来只是数据收发,但它最大的价值在于让嵌入式系统从黑盒变成透明盒。我在好几个项目里都有过这种体会:功能问题查不出来的时候,关键信息往往就在某一行被忽略的日志里。你甚至不需要拿串口传业务数据,只需要让它吐调试信息,就已经能解决很多定位难题。所以不管学习还是工作,我都建议把串口当成一个常备仪表,而不是一个用完就关的功能模块。学习路线走到这里,USART的分量不只是让你会配置几个寄存器,而是给你打开了一扇观察芯片内部世界的窗户。