☰
STM32寄存器实操手册:串口USART配置全流程与调试避坑指南
2026/10/13 1:03:35 网站建设 项目流程

这一章我们不点灯了,来让芯片开口说话。STM32寄存器白话手册上一章已经带着大家用寄存器操作点亮了LED,把GPIO那点家底摸了个大概。这一章,我打算直接聊串口(USART)。原因很简单:真正调试嵌入式程序的时候,屏幕不是标配,示波器也不一定随时在手边,串口几乎算是最后一道可靠的调试窗口。更关键的是,串口的寄存器配置牵扯到时钟使能、引脚复用、波特率计算、收发状态标志、中断触发,正好是把寄存器思维从“会点灯”推向“会玩外设”的一座桥。

这篇文章默认读者已经跑通上一章的GPIO点灯实验,至少看过一遍参考手册里寄存器的描述方式。如果你还只会复制粘贴HAL库代码,也不要紧,跟着我一步步把寄存器翻出来,看完你会发现,芯片和外设之间其实就是一堆开关和数字的交互。我们将以最常见的STM32F103系列为例,先把串口整个初始化流程掰开揉碎,再说数据收发、中断方式,最后整理我实际调试中踩过的一批坑。

1. 为什么第二章要拿串口开刀

1.1 串口是调试时的最后一个救命稻草

我见过不少初学者,拿到板子第一件事就是跑一个点灯程序,灯亮了就觉得会单片机了。可一旦程序跑飞,或者传感器数据读不到,立刻手足无措。这时候串口的作用就体现出来了:它能让你在几乎没有任何外部设备的情况下,看到芯片内部到底在执行什么。

你在串口助手看到一行Hello,你就知道时钟、GPIO配置、波特率、发送通路全部可能没大问题;你看到乱码,就知道串口参数或时钟配置有问题;你什么都收不到,至少能确认方向是硬件连接还是软件没走到。这种“可观测性”是寄存器操作阶段特别重要的反馈,远比自己对着代码猜要高效。

有人可能会说,用调试器打断点看不也一样吗?但很多实际场景只能焊接好板子、接上USB转串口工具,根本没有多余接口留给调试器。哪怕调试器能连上,看变量也很方便,可你总得先有一个稳定的输出通道,才能验证后续的逻辑。串口就是那个通道。

1.2 寄存器思维:把外设当成一堆开关和数字

上一章我们写GPIO寄存器,可能已经体会到一个道理:芯片内部没什么神秘的,外设寄存器就是一堆物理存在的存储单元,你往里面写1或者写0,硬件引脚就会变高变低。库函数做的事,无非是把“哪些位置1、哪些位置0”这些步骤封装成人话。

但封装也带来一个问题:当程序表现异常时,很多人不知道从哪查起。而寄存器方式,每个操作都能对应到参考手册里的某一位,出问题时你能直接打开调试器的外设寄存器窗口,亲眼看到那个位是不是按你的预期变了。

串口的寄存器配置比GPIO复杂不少,但也没有想象中可怕。它不过是你需要同时操作几个外设:时钟控制模块RCC负责给串口和引脚提供时钟,GPIO负责把对应引脚切到复用功能,USART本身负责波特率、数据格式、收发状态。把这几个模块串起来理解,寄存器就不再是一堆零散的地址,而是一张清晰的联动关系图。

2. 写代码前,先搞清楚三张地图

2.1 数据手册和参考手册怎么配合着看

很多初学者拿到一个芯片的PDF,从头翻到尾,结果越看越困。STM32这类芯片,官方文档一般分成数据手册(Datasheet)和参考手册(Reference Manual),再加上一个编程手册。我们写寄存器代码主要看参考手册里的“通用I/O(GPIO)”“复位和时钟控制(RCC)”“通用同步异步收发器(USART)”这几章。

为什么还要瞄一眼数据手册?因为参考手册告诉你寄存器里每位的作用,但不会告诉你某个引脚具体能不能作为串口发送脚,这得看数据手册里的引脚定义表和复用功能映射表。以STM32F103C8T6为例,USART1的发送引脚一般在PA9,接收引脚一般在PA10。你先在数据手册里确认这个对应关系,然后再去配置GPIO,否则自作聪明把TX配置到别的引脚上,自然怎么调都调不通。

这里有个小习惯我一直保留:拿到一个新芯片,先把时钟树、GPIO复用表、串口寄存器总览这三部分各复印一份,或者截图存到本地笔记里。写代码前先翻这几张图,比对着网上零散代码瞎试强太多。参考手册动辄几百上千页,没人能全背下来,但关键页的位置你得门清。

2.2 地址映射:外设都挂在哪些总线上

STM32F103的内核之外,外设挂在不同的总线桥上。GPIOA挂在APB2总线,USART1也挂在APB2总线,而USART2和USART3挂在APB1总线。这一区别直接决定了你该去RCC的哪个使能寄存器里翻位。如果翻开RCC_APB1ENR去找USART1的使能位,找破头也找不到。

这里列几个常用地址,不需要全背,但至少有个印象:GPIOA基地址是0x40010800,GPIOB是0x40010C00,USART1基地址是0x40013800,RCC的所有寄存器从0x40021000开始。每个外设内部,偏移地址固定,比如GPIOA的CRH寄存器偏移0x04,USART1的BRR寄存器偏移0x08。用指针操作时,实际上就是“基地址+偏移量”。

我用生活化的方式类比:芯片内部就像一栋大楼,APB2是一条走廊,挂了很多房间,每个房间有门牌号。房间内部有很多开关面板,每个面板有编号。你要做的不是找到整个大楼的结构图,而是先确认房间在几楼(哪条总线),再找到门牌号(基地址),最后拨动面板上的开关(偏移量对应的寄存器位)。一切操作都围绕这三个层次展开。

2.3 直接操作寄存器需要了解位段命名

在C语言中,我们经常看到类似GPIOA->CRH的写法。这不是语法魔法,而是固件库头文件里把一个指向基地址的指针强制定义成了结构体,结构体成员正好对应各个寄存器的偏移地址。展开来看,GPIOA->CRH就是*(volatile uint32_t *)(0x40010800 + 0x04)。

很多人看到volatile关键字不知道怎么回事。简单说,告诉编译器这个地址的内容随时可能被硬件改变,也可能因为写寄存器而产生硬件行为,因此不要把这个变量优化进CPU寄存器里,必须老老实实每次都从内存地址读写。如果你写寄存器操作的变量不加volatile,有些编译器优化后会直接跳过你的操作。

如果不习惯结构体写法,也可以自己定义宏:

#define GPIOA_CRH (*(volatile uint32_t *)0x40010804) #define USART1_BRR (*(volatile uint32_t *)0x40013808)

但我个人建议新手直接用标准库头文件提供的结构体指针,避免自己算错偏移量。核心思路都是一样的:地址别写错,位段别搞混,剩下就是细心问题。

3. 串口初始化的四个步骤,每一步都要清楚为什么

3.1 使能时钟:RCC->APB2ENR的两位

串口是一个外设,而外设要想工作,第一步一定是有人给它提供时钟。很多初学者把这一步当成例行公事,甚至直接抄一句RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);但不知道它到底干了什么。

寄存器的世界不搞玄学。对于USART1和GPIOA,因为它们都在APB2总线上,所以操作的是RCC_APB2ENR寄存器,这个寄存器的bit2是IOPAEN(GPIOA时钟使能),bit14是USART1EN(USART1时钟使能)。代码写成:

RCC->APB2ENR |= (1U << 2) | (1U << 14);

这里注意必须用“读-修改-写”的方式,也就是先读原来的值,再按位或置位,防止把其他外设的使能位清掉。如果你偷懒直接写RCC->APB2ENR = (1U << 2) | (1U << 14);,那之前已经使能的ADC、SPI、TIM1等外设的时钟会全部被关掉,整个程序立马乱套。这个坑我见得太多了。

为什么USART1在APB2而USART2在APB1?这由芯片的时钟树设计决定。一般而言,APB2的最大时钟是72MHz,APB1是36MHz,如果哪天你配置USART2却去APB2ENR里找使能位,大概率把寄存器地址写错。所以碰到外设不工作时,第一反应应该是查总线图,看这个外设挂在哪个总线上,而不是傻傻地换波特率。

3.2 配置引脚复用:PA9做TX,PA10做RX

GPIO的CRH寄存器每个引脚占了4个位段:低两位是MODE(速度选择),高两位是CNF(模式选择)。PA9从第4位开始,PA10从第8位开始。要让USART1的TX功能映射到PA9,不能只是简单把它变成通用推挽输出,而是要配置成“复用推挽输出”,也就是把这个引脚的控制权从GPIO模块交到USART外设手上。

具体配置值为:PA9需要CNF=10(复用功能输出)、MODE=11(输出模式,50MHz),合起来4位就是1011,也就是0xB。PA10作为RX输入,需要CNF=01(浮空输入)、MODE=00(输入模式),合起来是0100,也就是0x4。代码可以这样:

// 先清除原来的配置 GPIOA->CRH &= ~(0xFU << 4); GPIOA->CRH |= (0xBU << 4); // PA9 复用推挽输出 50MHz GPIOA->CRH &= ~(0xFU << 8); GPIOA->CRH |= (0x4U << 8); // PA10 浮空输入

顺序也很重要:先清位段再赋值。如果不清,原来的配置和新配置按位或在一起,可能出现CNF和MODE变成奇怪组合的后果。这种“先清零再写”的思路,几乎适用于所有寄存器位段操作。你可以把它当成肌肉记忆。

有些教程会把PA10配置成上拉输入,因为上拉电阻能保证空闲时电平稳定。在板子已经焊接好、外部没有上下拉电阻的情况下,上拉输入通常更保险。如果配置成上拉输入,CNF要写成10,并且把GPIOA的ODR对应位置1,这个细节容易漏。对于USB转串口模块的连接,浮空输入一般也能工作;想更稳一点,就配置成上拉。我习惯在芯片悬空测试时用上拉,正常连接外部设备时用浮空,两种都要会改。

3.3 波特率计算:BRR寄存器怎么填才不会乱码

波特率是串口通信最容易翻车的环节。USART1挂载在72MHz的APB2上,串口波特率寄存器USART_BRR并不是简单的“频率除以波特率”那么直接。它是一个12位整数加4位小数的定点数,计算公式是:

USARTDIV = 外设时钟 / (16 × 波特率)

其中USARTDIV的整数部分放入BRR的位[15:4],小数部分乘以16后取整,放入BRR的位[3:0]。

举个例子,目标波特率115200,72MHz时钟:

USARTDIV = 72000000 / (16 × 115200) = 39.0625

整数部分39,十六进制是0x27。小数部分0.0625乘16得到整数1。所以BRR值应该是:

BRR = (39 << 4) | 1 = 0x271

再看一个常用的9600:

USARTDIV = 72000000 / (16 × 9600) = 468.75

整数部分468,即0x1D4。小数部分0.75乘16得到12,即0xC。所以BRR = (0x1D4 << 4) | 0xC = 0x1D4C。

这里的教训是:不是直接把除法的结果当成整数写进去,而是要拆成整数和小数两部分。我见过有人写USART1->BRR = 72000000 / 9600;,结果输出全是乱码。因为BRR不是简单的分频系数寄存器,而是定点数格式。

下表是几种常见波特率在72MHz时钟下的BRR值,可以对照自查:

波特率USARTDIVBRR寄存器值
4800937.50x3A98
9600468.750x1D4C
19200234.3750xEA6
38400117.18750x753
11520039.06250x271
23040019.531250x131

注意19200、38400这些波特率的小数部分不是绝对精确,实际通信时会有微小误差。只要误差在允许范围内,问题不大。但如果你接的是需要长时间连续传输、对时序敏感的设备,误差会累积,这时可以考虑换用更合适的晶振频率,或者使用HSE时钟通过PLL倍频到正确频率后再计算。

还有一个容易忽略的点:如果你把APB2时钟改成了其他值,比如主频被PLL倍频到64MHz,上面所有BRR值都要重新算。不要死记硬背,要会自己算。手边常备一张公式纸条,比记一堆仿真值更靠谱。

3.4 控制寄存器设置:UE、TE、RE以及其他位

USART_CR1是串口的“总开关和控制面板”。其中bit13是USART使能位UE,bit3是发送使能TE,bit2是接收使能RE。如果UE不置1,后面的任何配置都不会生效。TE和RE很好理解:发送使能和接收使能。

在STM32F1中,USART_CR1还需注意M位定义了字长,默认0表示8位数据位,OK不要乱改。PCE、PS控制校验方式和Whether校验位,一般关闭。CR2里通常使用默认的8位数据位,CR3通常全零。一个比较稳妥的初始化方式,是先把CR1、CR2、CR3整体清零,再按需要置位,避免上一个程序留下的配置干扰。

使能操作的代码很简单:

USART1->CR1 |= (1U << 13) | (1U << 3) | (1U << 2);

也就是UE | TE | RE。如果你用的是标准外设库头文件,可以写USART_CR1_UE、USART_CR1_TE、USART_CR1_RE这些宏,但本质就是数字常量。这些宏定义的好处是看着代码能知道用在哪一位。

需要提醒的是,使能之后不要立刻认为串口就能用了。你还需要先配置好GPIO和BRR,否则即使CR1里的UE置1,引脚上也不会有正确波形。

3.5 一个可以直接抄的初始化函数

综合上面几步,一个典型的寄存器初始化函数可以这样写。这段代码以STM32F103标准外设库的头文件为基础,但完全使用寄存器操作:

void USART1_Init(void) { // 1. 打开GPIOA和USART1的时钟 RCC->APB2ENR |= (1U << 2) | (1U << 14); // 2. 配置PA9为USART1_TX,复用推挽输出50MHz GPIOA->CRH &= ~(0xFU << 4); GPIOA->CRH |= (0xBU << 4); // 3. 配置PA10为USART1_RX,浮空输入 GPIOA->CRH &= ~(0xFU << 8); GPIOA->CRH |= (0x4U << 8); // 4. 波特率115200,72MHz时钟 USART1->BRR = (39U << 4) | 1U; // 5. 使能USART1、发送、接收 USART1->CR1 |= (1U << 13) | (1U << 3) | (1U << 2); }

如果这段代码运行后串口助手看不到输出,不要急着改波特率,先用调试器看看USART1->CR1的UE、TE位是不是置1了,USART1->BRR是不是0x271。这几个寄存器一眼就能看出问题,比盲猜高效得多。

4. 发送和接收:数据进出的核心逻辑

4.1 数据寄存器TDR/RDR的历史包袱

USART_DR寄存器可能是新手最迷惑的地方:同一个地址,写它表示发送数据,读它表示接收数据。这是因为芯片内部收发各有独立的移位寄存器,而CPU侧访问的数据寄存器共用一个地址窗口。发送时你往DR写入的数据会进入发送数据寄存器TDR,接收时读DR读到的是接收数据寄存器RDR的内容。

真正决定能不能收发,还得看状态寄存器USART_SR。SR里的bit7是TXE(发送数据寄存器空),bit6是TC(发送完成),bit5是RXNE(接收数据非空)。这些标志位是硬件置位或清零的,我们软件要做的就是判断它们,决定下一步操作。

为什么这里强调“状态寄存器”?因为串口是异步的,CPU不知道数据什么时候发送完、什么收件到达,只能通过状态标志来“握手”。如果你不管状态寄存器,直接往DR里写一串数据,前面的字节还没发完,后面的字节已经把位置占了,数据就会乱掉。用生活类比:串口就像一个服务员,你不停往窗口递菜,窗口还没把上一份菜端走,下一份又塞进来,后厨肯定乱套。TXE标志就是窗口空闲的信号。

4.2 轮询方式发送字节和字符串

发送一个字节的代码,最核心的就两三行:

void USART1_SendByte(uint8_t ch) { while ((USART1->SR & (1U << 7)) == 0); // 等待TXE为1 USART1->DR = ch; }

先判断TXE,等它变成1,说明发送数据寄存器空了,可以写入新数据。写入DR后,硬件自动把TXE清0,数据被送到移位寄存器,开始逐位发送。等移位寄存器把整帧数据发完,TXE会重新置1,同时TC也会变成1。

有些人会问,那我发送完最后一个字节之后,要不要等TC?要分情况。如果你发送完立即进入低功耗模式、关闭串口时钟、切换引脚模式,那最好等TC置1再执行后续操作。因为TXE置1只代表数据从“数据寄存器”搬到了“移位寄存器”,此时移位寄存器可能还在发最后几个位。物理线路上的数据还没完全送出去,硬件一停就会截断帧尾。

发送字符串就是循环调用发送字节:

void USART1_SendString(const char *str) { while (*str != '\0') { USART1_SendByte((uint8_t)*str++); } }

这里没有加任何分隔符,你需要在主程序调用时自行决定是否在字符串后面加\r\n。实际调试中我总会让日志带换行,否则串口助手里几行数据叠在一起,根本读不出顺序。

4.3 轮询方式接收字节和处理过载

接收最简单的轮询方式是等待RXNE变为1:

uint8_t USART1_ReceiveByte(void) { while ((USART1->SR & (1U << 5)) == 0); // 等待RXNE return (uint8_t)(USART1->DR); }

读取DR之后,RXNE标志会被硬件自动清0。这在F1系列上是这样,但有些芯片需要先读SR再读DR才清标志,写代码前一定要确认参考手册里的说明,不要拿旧经验硬套。

接收模式最典型的一个坑是过载错误(ORE)。当RXNE还是1,也就是上次接收的数据还没被读走,新的数据又到了,硬件就没地方放新数据,只能把新数据丢掉,同时置起ORE错误标志。如果程序处理不过来,错误后如果不处理,后续数据可能一直错乱。

轮询方式下,我一般会写一个带超时的版本,避免程序死锁在一个等待循环里:

uint8_t USART1_ReceiveByteTimeout(uint8_t *data, uint32_t timeout) { while ((USART1->SR & (1U << 5)) == 0) { if (--timeout == 0) { return 0; // 超时失败 } } *data = (uint8_t)(USART1->DR); return 1; }

实际用起来,这种带超时的接收函数能避免一个极其磨人的问题:外界根本没发数据,程序却卡在接收死循环里,整个系统看起来像崩溃了。好习惯是给所有可能阻塞的等待加上超时或看门狗配合。

4.4 中断方式:把接收放入后台

如果主程序一直用轮询等待串口接收,CPU就被一个外设绑架了,没法干别的活。实际项目中更常见的是中断方式:串口头收到一个字节,硬件触发USART1中断,CPU暂时放下手头事情,在中断服务函数里把字节存进缓冲区,然后回去继续执行主循环。

在寄存器层面开接收中断,只需要在USART_CR1里把RXNEIE置1:

USART1->CR1 |= (1U << 5); // RXNEIE: 接收缓冲区非空中断使能

此外还要使能NVIC中USART1的中断通道。用标准外设库头文件的话,可以直接操作NVIC的寄存器:

NVIC->ISER[1] = (1U << (USART1_IRQn & 0x1F));

USART1_IRQn的值是37,因为Cortex-M3用两个32位寄存器组管理中断,37号中断落在ISER[1]的第5位,所以上面这么写是合理的。如果你在用HAL库或者标准库的NVIC_Init函数,效果也一样,只是封装程度不同。

中断服务函数的基本模板:

volatile uint8_t rx_buffer[64]; volatile uint8_t rx_len = 0; void USART1_IRQHandler(void) { if (USART1->SR & (1U << 5)) // 确认RXNE { if (rx_len < sizeof(rx_buffer)) { rx_buffer[rx_len++] = (uint8_t)(USART1->DR); } else { // 缓冲区满了,需要处理,比如重新计数或丢弃 rx_len = 0; } } }

中断服务函数里有一个陷阱:判断标志时一定要读SR再读DR,有些错误标志比如ORE需要读SR、读DR之后才会清除。如果只读完DR就返回,下个中断可能不会正常到来,主循环里看上去就像串口“卡死”了。我曾在一次多字节连续接收的项目里被这个坑折磨了一晚,最后查资料才发现是没处理ORE。

还有一点,中断函数里不要做耗时的操作,比如解析命令、打印另一条串口数据。能用标志位通知主循环处理,就用标志位;一定要在中断里处理的,也要控制在几微秒内完成。中断占用时间太长,串口高速接收时很容易触发过载错误。

5. 实际调试中的坑与排查清单

5.1 现象1:TX引脚完全发不出数据

如果你用示波器或者逻辑分析仪量PA9引脚,没有任何信号变化,排查顺序应该是:先看RCC_APB2ENR里IOPAEN和USART1EN是否都为1;再看GPIOA->CRH的PA9位段是不是0xB;再看USART1->CR1的UE和TE是否为1;最后确认BRR里的波特率值是否为当前时钟下正确的数值。

在调试器里把鼠标悬停在寄存器结构体上,很多IDE可以直接展开所有寄存器,这个信息比串口助手可靠得多。如果RCC寄存器里IOPAEN是0,写什么都白搭;如果CRH的PA9配置成了0x4或0x0,那就是模式没有切到复用功能,引脚可能被当作普通输出或输入,自然不会有串口波形。

有些板子会把PA9或PA10接到板载的其他外设,比如USB转串口、音频、SD卡等,和你的实验电路产生冲突。这时别只盯着软件,把原理图翻出来看看有没有其他芯片在占用。遇到问题先怀疑硬件连接,再怀疑寄存器配置,顺序非常管用。

5.2 现象2:串口助手显示乱码

乱码的第一嫌疑永远是波特率不一致。电脑端设置的波特率要和你芯片实际算出来的一致。你代码里写115200,电脑串口助手却选9600,那出来的字符必然天女散花。确认两边波特率一致后,如果还是乱码,就要考虑时钟频率是否真的如你所料。

很多STM32板载的是8MHz晶振,通过PLL倍频到72MHz。但有些板子用了12MHz晶振,或者焊接位置放的是其他频率,照搬网上代码时BRR按72MHz算,实际时钟却不对,波特率自然偏差很大。遇到乱码,先用示波器量一下TX引脚空闲电平下的翻转周期,或者用一个已知正常的串口工具回发数据,再对比接收结果。

一个很实用的自测方法是回环测试:把PA9和PA10直接用杜邦线短接,然后程序里发送一个字节,再轮询接收,看看能不能收到自己发的内容。如果收到的是相同字节,说明USART的收发通路基本没问题,这时候再排查外部接线和电脑端设置;如果收不到,问题大概率在芯片内部配置,和外设无关。

5.3 现象3:寄存器写不进,或者写了被“弹回来”

写寄存器像石沉大海,最常见的原因是你操作的外设时钟没开。一个没有时钟的外设,内部寄存器可能根本无法写入,或者写入无效。所以遇到寄存器读出来一直是0,先别怀疑编译器,回头确认对应RCC的EN位。

第二个常见原因是编译优化。如果你定义了一个变量,在代码里被写入后从未被读取,编译器可能把整个写入优化掉。而寄存器操作必须用volatile修饰,硬件寄存器地址本身就是volatile的,但如果你自己定义了临时缓存区或标志位,也要加volatile。调试时如果发现寄存器值没变,先检查变量的性质,再看代码逻辑。

第三个原因非常隐蔽:你清除位段时把不该清的位置清了,然后寄存器整体值和你预期不符。比如给CRH的PA9赋值时,如果你只写GPIOA->CRH |= 0xB << 4;而没有先清掉原来的位,那PA9位段可能变成原有值和0xB叠加后的结果。这在某个引脚反复切换功能的时候特别容易遇到。所以我在所有位段操作前都强制加一行&= ~(...)。

5.4 现象4:开了中断后主循环卡死

大部分中断卡死都和中断标志没有清干净有关。USART的RXNE中断触发后,如果不在服务函数里读DR,RXNE一直保持1,中断会反复触发,主循环根本没机会执行。把读DR操作加上,或者暂时关中断排查,问题很快就能定位。

还有一部分是因为没有处理错误标志。当高速连续接收时ORE置位,如果程序里没有读SR和DR来清除ORE,后续中断可能一直被打断,看起来像卡死。解决方式是在中断函数开头先把错误标志读一遍:

if (USART1->SR & (1U << 0)) // PE,可忽略处理 if (USART1->SR & (1U << 3)) // FE,帧错误 if (USART1->SR & (1U << 4)) // ORE,过载错误 { // 先读SR,再读DR,清除错误标志 volatile uint32_t tmp = USART1->SR; tmp = USART1->DR; }

这类问题需要有点耐心,因为错误标志不一定每次都复现,和发送端节奏、接收端CPU繁忙程度都有关系。我的经验是:先把接收频率降下来,确认基本流程没问题后,再逐步提高速度,看在哪一步开始出错。

5.5 调试工具:让效率翻倍的习惯

写寄存器代码,我最常用的不是printf,而是调试器里的外设寄存器窗口。调试时把RCC、GPIOA、USART1三个寄存器组都展开,盯着它们的变化,很多问题一眼就能发现。哪个位没置位,哪个值不对,全都在上面摆着。

如果硬件允许,逻辑分析仪也是串口调试神器。它不贵,采样率够看115200完全没问题。把PA9飞线到逻辑分析仪的一路通道上,可预算到任意一个波特率,直接看波形。空下来后,把发送的数据和接收的数据对照,能快速确认是发送端问题还是接收端问题。相比在串口助手上看乱码猜原因,波形图直观得多。

6. 串口之后,还能怎么延伸

6.1 把串口改造成调试日志系统

有了串口能收发,第一件值得做的事就是重定向printf,让它从串口输出。在MDK等环境下,可以重写fputc:

int fputc(int ch, FILE *f) { USART1_SendByte((uint8_t)ch); return ch; }

加上这个函数后,代码里就能愉快地用printf打印调试信息。但要注意,printf的完整实现会占用不少代码空间,浮点打印更是重量级。如果你的芯片Flash紧张,建议自己封装一个简易的格式化输出,只支持整数和字符串。实际调试时,我往往会先接一个小版本日志模块,用来打印状态、寄存器值、错误码,等代码稳定后再逐步精简日志。

6.2 用串口做简单指令解析

串口通信不只是调试工具,还可以作为用户交互通道。比如定义一个简单协议:帧头、命令字、数据长度、数据区、校验和。收到完整一帧后解析,再通过串口返回状态。这个思路可以直接用在实时控制、传感器读取、上位机联调等场景。

做指令解析时,接收缓冲区管理尤其重要。你可以用一个环形缓冲区配合中断接收,主循环定期从缓冲区取数据解析。不推荐在主循环里用while死等接收字节,那样系统其他任务就没法执行了。数据量不大时,一个简单的固定数组加写指针和读指针也能胜任,关键是代码结构要清晰。

6.3 为下一章做铺垫

串口掌握之后,你会发现外设寄存器配置的套路开始趋同:先开时钟,再配置引脚功能,然后设置外设本身参数,最后使能。这套思维可以直接迁移到SPI、I2C、定时器、DMA。如果你正打算继续写寄存器代码,我建议下一章可以啃定时器,或者串口加DMA的组合。

DMA配合串口接收是很有意思的方向,它能绷开CPU,让串口数据自动存进内存。想明白DMA缓冲区和串口中断之间的配合,整个系统的数据吞吐能力会上升一个量级。但先把当前这章的串口收发练扎实,否则后面叠加DMA只会让你在调试时晕头转向。

最后分享一个我个人的小习惯:每次写完一段寄存器配置,我都会在调试器里手动把所有相关寄存器复位,然后单步执行初始化代码,看着每个寄存器一步步变成预期值。这个过程能帮我记住配置顺序,也能在出问题时快速定位到具体步骤。你也不要嫌繁琐,寄存器操作的底气,就是建立在这些“亲眼所见”上面的。

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

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

立即咨询