1. 项目概述:从零构建一个可靠的查询式串口驱动
在嵌入式开发中,串口通信几乎是每个项目的“标配”,无论是用于调试打印、固件升级,还是与传感器、模块进行数据交互,它都是最基础、最直接的通信手段。很多朋友在初次接触STM32这类MCU时,面对HAL库、LL库或者各种现成的驱动框架,可能会觉得串口用起来很简单,调用几个API就完事了。但在我看来,如果不从最底层的寄存器操作开始,亲手“拧一遍螺丝”,就很难真正理解串口通信的时序、状态机以及那些隐蔽的坑。今天,我就以STM32F103C8T6这款经典的“蓝桥杯”芯片为例,抛开复杂的库和中断,带你手写一个最纯粹、最可靠的查询方式串口驱动。我们会从引脚复用讲起,一步步算波特率、配寄存器,直到实现数据的收发。这个过程不仅能帮你夯实基础,更能让你在日后调试更复杂的通信协议时,心里更有底。
2. 核心设计思路与方案选型
2.1 为什么选择查询方式?
在项目初期或者对实时性要求不高的简单应用中,查询方式(Polling)有着独特的优势。它的核心逻辑就是程序不断地、主动地去查看串口的状态寄存器,判断是否有新数据到达(接收)或者数据是否已经发送完毕(发送)。
优势在于:
- 代码极其简单直观:没有复杂的中断服务程序(ISR)需要编写和管理,逻辑一目了然,非常适合新手理解串口工作的本质流程。
- 确定性好:程序流程是顺序执行的,没有中断嵌套、抢占带来的时序不确定性。在简单的控制逻辑中,这反而是个优点。
- 资源占用清晰:不涉及中断向量表配置、优先级设置,对系统其他部分的影响最小。
当然,缺点也很明显:
- CPU利用率低:在
while(!RXNE)或while(!TXE)这样的等待循环中,CPU在空转,无法执行其他任务。这在多任务系统中是不可接受的。 - 可能丢失数据:如果CPU在忙于处理其他长耗时任务时,串口接收到了数据,而程序没有及时来查询,数据就可能被新数据覆盖而丢失。
所以,查询方式最适合的应用场景是:作为学习原型、用于简单的固件调试信息输出、或者在系统初始化阶段进行配置通信。一旦你的系统需要同时处理多个事件,中断方式或DMA方式就是必须的。但无论如何,理解查询方式是通往更高级用法不可或缺的第一步。
2.2 硬件连接与时钟树分析
我们的目标是驱动USART1。根据STM32F103的数据手册,USART1的默认引脚是:
- TX (发送): PA9
- RX (接收): PA10
这里有一个至关重要的前提:这两个引脚与普通GPIO是复用的。也就是说,上电后它们默认是普通的输入/输出引脚,而不是串口功能引脚。如果你不进行重映射配置,那么即使寄存器配置得再正确,数据也无法从正确的物理引脚上发出或接收。因此,GPIO的复用功能配置是我们的第一步,也是最容易遗忘的一步。
另一个关键是时钟。USART1挂载在APB2总线上。在标准的72MHz系统时钟配置下,APB2的时钟频率也是72MHz(APB2预分频器通常设为1)。这个USART_CLK(即APB2时钟)是计算波特率分频值的基准。我见过不少初学者直接套用公式时,错误地使用了APB1的时钟(36MHz),导致实际波特率偏差一倍,通信自然失败。
3. 寄存器定义与驱动框架搭建
3.1 手动定义寄存器结构体
虽然标准外设库或HAL库已经提供了定义,但自己写一遍印象更深刻。我们根据参考手册,为USART1的关键寄存器定义对应的结构体和指针。这样做的好处是,代码的可读性极强,直接操作pbUSART1_BRR->DIV_Mantissa比操作USART1->BRR = 0x1A0要直观得多。
通常,我们会创建一个USART.h头文件来存放这些定义。这里以状态寄存器(SR)、数据寄存器(DR)和控制寄存器1(CR1)为例:
// USART.h #ifndef __USART_H #define __USART_H #include <stdint.h> // 假设微控制器头文件已定义了外设基地址 #define USART1_BASE 0x40013800UL // 状态寄存器 (SR) 位定义 typedef struct { uint32_t PE :1; // 奇偶错误 uint32_t FE :1; // 帧错误 uint32_t NF :1; // 噪声错误 uint32_t ORE :1; // 溢出错误 uint32_t IDLE :1; // 空闲线路检测 uint32_t RXNE :1; // 接收数据寄存器非空 (读数据) uint32_t TC :1; // 发送完成 uint32_t TXE :1; // 发送数据寄存器空 (写数据) uint32_t LBD :1; // LIN Break检测标志 uint32_t CTS :1; // CTS标志 uint32_t RESERVED :22; } USART_SR_TypeDef; // 数据寄存器 (DR) 位定义 (实际上是一个9位的寄存器) typedef struct { uint32_t DR :9; // 数据值 uint32_t RESERVED :23; } USART_DR_TypeDef; // 控制寄存器 1 (CR1) 位定义 (部分关键位) typedef struct { uint32_t SBK :1; // 发送断开帧 uint32_t RWU :1; // 接收器唤醒 uint32_t RE :1; // 接收使能 uint32_t TE :1; // 发送使能 uint32_t IDLEIE :1; // 空闲中断使能 uint32_t RXNEIE :1; // RXNE中断使能 uint32_t TCIE :1; // 发送完成中断使能 uint32_t TXEIE :1; // TXE中断使能 uint32_t PEIE :1; // PE中断使能 uint32_t PS :1; // 奇偶选择 uint32_t PCE :1; // 奇偶控制使能 uint32_t WAKE :1; // 唤醒方法 uint32_t M :1; // 字长 uint32_t UE :1; // USART使能 uint32_t RESERVED :18; } USART_CR1_TypeDef; // 波特率寄存器 (BRR) 位定义 typedef struct { uint32_t DIV_Fraction :4; // 小数部分 uint32_t DIV_Mantissa :12; // 整数部分 uint32_t RESERVED :16; } USART_BRR_TypeDef; // 将各寄存器映射到USART1的基地址 #define pbUSART1_SR ((volatile USART_SR_TypeDef*)(USART1_BASE + 0x00)) #define pbUSART1_DR ((volatile USART_DR_TypeDef*)(USART1_BASE + 0x04)) #define pbUSART1_BRR ((volatile USART_BRR_TypeDef*)(USART1_BASE + 0x08)) #define pbUSART1_CR1 ((volatile USART_CR1_TypeDef*)(USART1_BASE + 0x0C)) // 这里还可以继续定义CR2, CR3等寄存器... // 函数声明 void Usart1Init(void); unsigned char Usart1GetChar(void); void Usart1PutChar(unsigned char Value); void Usart1PutString(unsigned char *pString); #endif /* __USART_H */注意:在实际工程中,为了代码的严谨性和可移植性,我们通常会使用
volatile关键字来修饰指向外设寄存器的指针。这是因为寄存器的值可能被硬件异步改变,编译器在优化时不能假设它的值不变。volatile告诉编译器,每次都必须从内存中重新读取这个值,不能做缓存优化。
3.2 GPIO复用功能配置详解
在Usart1Init函数中,配置GPIO是真正的第一步,必须在使能USART时钟和配置USART本身之前完成。以PA9和PA10为例,我们需要查阅STM32F103的GPIO章节,了解如何将其配置为复用推挽输出(TX)和浮空输入/复用功能输入(RX)。
// 假设已有GPIOA相关的寄存器定义,例如: // GPIOA_CRH 寄存器,用于配置PIN8-15 // GPIOA_CRH 的 CNFy[1:0] 和 MODEy[1:0] 位域 // TXD (PA9) 配置为复用推挽输出,最大速度50MHz // MODE9 = 0b11 (输出模式,最大速度50MHz) // CNF9 = 0b10 (复用功能输出模式,推挽) GPIOA_MODE9 = 3; // 即 0b11 GPIOA_CNF9 = 2; // 即 0b10 // RXD (PA10) 配置为浮空输入或复用功能输入 // MODE10 = 0b00 (输入模式) // CNF10 = 0b01 (复用功能输入,但引脚状态由外设决定,通常配置为浮空) // 注意:对于输入,速度模式(MODE)无效。 GPIOA_MODE10 = 0; // 即 0b00 GPIOA_CNF10 = 1; // 即 0b01这里有一个实操心得:对于RX引脚,配置为“复用功能输入”即可,芯片内部会自动将其连接到USART接收器。不需要也不应该将其配置为输出模式。
4. 串口初始化与参数配置实战
4.1 波特率计算:整数与小数分频
波特率发生器是串口的核心。STM32的USART波特率计算公式为:Tx/Rx波特率 = fCK / (16 * USARTDIV)其中,fCK是给USART的时钟频率(对我们用的USART1就是APB2时钟,72MHz),USARTDIV是一个无符号的定点数,存放在波特率寄存器BRR中。
BRR寄存器分为两部分:
DIV_Mantissa(位[15:4]):存储USARTDIV的整数部分。DIV_Fraction(位[3:0]):存储USARTDIV的小数部分 * 16。也就是说,小数部分是用4位二进制表示的16进制小数。
计算示例(波特率9600):
- 计算理论
USARTDIV值:USARTDIV = 72,000,000 / (16 * 9600) = 468.75 - 整数部分
DIV_Mantissa = 468(即 0x1D4) - 小数部分
DIV_Fraction = 0.75 * 16 = 12(即 0xC) - 所以,
BRR = (468 << 4) | 12 = 0x1D4C
在代码中,我们通常用宏和计算来实现:
#define BIT_RATE 9600 #define USART_CLK 72000000UL void Usart1Init(void) { // 计算并设置波特率 uint32_t usartdiv = (USART_CLK + (BIT_RATE / 2)) / BIT_RATE; // 先计算16*USARTDIV,四舍五入 pbUSART1_BRR->DIV_Mantissa = usartdiv / 16; pbUSART1_BRR->DIV_Fraction = usartdiv % 16; // ... 其他配置 }提示:上面代码中
(BIT_RATE / 2)是实现四舍五入的技巧。因为整数除法会截断小数,加上除数的一半再除,可以实现四舍五入的效果,使波特率更精确。
4.2 数据格式与中断的显式关闭
在查询方式下,我们必须确保所有可能产生中断的位都被禁用,否则一旦满足中断条件,程序就会跳转到未定义的中断向量,导致硬件错误(Hard Fault)。
// 使能USART,这是配置的前提 pbUSART1_CR1->UE = 1; // 配置数据格式:8位数据位,无校验,1位停止位(这是CR2的配置,示例代码中在CR2部分) pbUSART1_CR1->M = 0; // 0: 1 Start bit, 8 Data bits, n Stop bit pbUSART1_CR1->PCE = 0; // 禁止奇偶校验 // 在CR2中配置停止位,0b00表示1个停止位 pbUSART1_CR2->STOP = 0; // !!!关键步骤:显式关闭所有中断!!! pbUSART1_CR1->PEIE = 0; // 奇偶错误中断 pbUSART1_CR1->TXEIE = 0; // 发送数据寄存器空中断 pbUSART1_CR1->TCIE = 0; // 发送完成中断 pbUSART1_CR1->RXNEIE = 0; // 接收数据寄存器非空中断 pbUSART1_CR1->IDLEIE = 0; // 空闲线路中断 // 还需要关闭CR2和CR3中的相关中断 pbUSART1_CR2->LBDIE = 0; // LIN Break检测中断 pbUSART1_CR3->CTSIE = 0; // CTS中断 pbUSART1_CR3->EIE = 0; // 错误中断 // 使能发送器和接收器 pbUSART1_CR1->TE = 1; pbUSART1_CR1->RE = 1;注意事项:TE和RE位最好在配置完其他参数并使能UE之后再置1。有些工程师习惯先使能TE/RE再使能UE,这在某些情况下可能导致一个错误的起始位被发送。
5. 查询式收发函数实现与优化
5.1 发送一个字节:等待TXE标志
发送数据的流程是:将数据写入数据寄存器DR,硬件会自动将其加载到发送移位寄存器中,并开始发送。当DR寄存器变空(即数据已转移到移位寄存器)时,状态寄存器SR的TXE位会被硬件置1。查询方式就是不断检查这个位。
void Usart1PutChar(unsigned char Value) { // 等待发送数据寄存器空 while(!pbUSART1_SR->TXE) { // 这里可以加入超时机制,防止因硬件故障导致死循环 } // 将数据写入DR寄存器,写操作会自动清除TXE标志 pbUSART1_DR->DR = Value; }看起来很简单,但这里有两个极易忽视的坑:
- TC标志与TXE标志的区别:
TXE表示数据寄存器已空,可以写入下一个数据。TC表示整个发送移位寄存器也已空,即上一帧数据已完全发出。在连续发送字符串时,我们通常只查询TXE。只有在发送完最后一字节后,如果需要确保数据完全离开引脚(例如在关闭串口或进入低功耗前),才需要查询TC。 - 写DR寄存器会清除TXE标志:这是一个硬件行为。当你执行
pbUSART1_DR->DR = Value;后,TXE位会被自动清零,直到硬件将数据从DR寄存器转移到移位寄存器后,它才会再次被置1。
5.2 接收一个字节:等待RXNE标志
接收流程类似:当硬件从RX引脚接收到一帧完整的数据,并将其从移位寄存器转移到数据寄存器DR后,RXNE位会被置1。查询方式就是等待这个位变高。
unsigned char Usart1GetChar(void) { // 等待接收数据寄存器非空 while(!pbUSART1_SR->RXNE) { // 同样,强烈建议加入超时处理 } // 读取DR寄存器,读操作会自动清除RXNE标志 return (unsigned char)(pbUSART1_DR->DR); }重要提示:读取DR寄存器是清除RXNE标志的唯一正确方法。任何其他操作(如直接写状态寄存器)都可能无法清除它,导致程序一直认为有数据未读,陷入死循环。
5.3 发送字符串函数的实现与陷阱
基于单字节发送函数,我们可以很容易地写出字符串发送函数:
void Usart1PutString(unsigned char *pString) { while(*pString != '\0') { // 判断字符串结束符 Usart1PutChar(*pString); pString++; } }这个函数很直观,但在实际使用中有一个性能上的小瑕疵:它没有利用好TXE标志的“提前量”。当写入一个字节到DR后,硬件需要一定时间(约一个字节的传输时间)去发送它。在这段时间里,CPU在Usart1PutChar函数中等待下一个TXE标志。其实,在发送第一个字节后,我们可以先检查第二个字节是否准备好,然后立即写入,实现一种“紧耦合”的发送,稍微提升效率。但对于初学者,上面的写法清晰可靠,完全够用。
更健壮的写法应该考虑超时和错误处理:
#define USART_TIMEOUT 100000 // 定义一个超时计数值 int Usart1PutChar_Timeout(unsigned char Value) { uint32_t timeout = USART_TIMEOUT; while(!pbUSART1_SR->TXE) { if(--timeout == 0) { return -1; // 发送超时,返回错误 } } pbUSART1_DR->DR = Value; return 0; // 发送成功 } void Usart1PutString_Safe(unsigned char *pString) { while(*pString != '\0') { if(Usart1PutChar_Timeout(*pString) != 0) { // 处理发送错误,例如点亮错误LED,或记录日志 // break; // 可以选择跳出循环 } pString++; } }6. 系统集成与测试程序构建
6.1 主程序逻辑与调试技巧
我们将串口驱动集成到一个简单的测试程序中。这个程序的行为是:上电后发送欢迎信息,然后进入循环,等待接收一个字符,并将该字符回显(发送回去),同时控制一个LED闪烁一次。
// main.c #include "USART.h" #include "gpio.h" // 假设有GPIO驱动,用于控制LED #include "delay.h" // 假设有简单的延时函数 int main(void) { // 系统时钟初始化(需另行实现,确保系统时钟为72MHz) SystemClock_Config(); // GPIO初始化,用于LED LED_GPIO_Init(); // 串口初始化 Usart1Init(); // 发送启动信息 Usart1PutString((unsigned char*)"\r\nSystem start...\r\n"); while(1) { unsigned char received_char; // 等待并接收一个字符 received_char = Usart1GetChar(); // 将接收到的字符回显 Usart1PutChar(received_char); // 也可以换行,使显示更清晰 Usart1PutString((unsigned char*)"\r\n"); // 控制LED闪烁一次,作为视觉反馈 LED_ON(); Delay_ms(100); LED_OFF(); Delay_ms(100); } }调试技巧:
- 使用逻辑分析仪或示波器:这是最直接的方法。抓取PA9(TX)引脚上的波形,测量位时间。对于9600波特率,一个位的时间大约是104us。你可以检查起始位、数据位、停止位是否完整、电平是否正确。
- 使用PC串口助手:将STM32的USART1通过USB转TTL模块连接到电脑。打开串口助手(如Putty、SecureCRT、或者各种嵌入式IDE自带的工具),设置正确的波特率、数据位、停止位、校验位。如果硬件和代码正确,你应该能看到“System start...”信息,并且你从键盘输入的每个字符都会被回显。
- 如果收不到数据:
- 首先检查硬件:TX、RX线是否接反?USB转TTL模块的VCC是否接了3.3V?GND是否共地?
- 检查波特率:计算一下
BRR寄存器的值是否正确。用示波器测量位时间是最准的。 - 检查GPIO配置:这是最常出错的地方。确认PA9和PA10是否被正确配置为复用功能。可以用调试器在初始化后读取GPIOA_CRH寄存器的值来验证。
- 检查时钟:确认
USART_CLK宏定义的值是否是你的APB2实际时钟频率。
6.2 常见问题排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无输出 | 1. GPIO未配置为复用功能。 2. USART时钟未使能(如果未使用库,需手动配置RCC寄存器)。 3. 硬件连接错误(线断了,接反了)。 4. UE位未使能。 | 1. 检查GPIOA_CRH寄存器值。 2. 检查RCC_APB2ENR寄存器的USART1EN位。 3. 用万用表检查连通性,TX/RX是否接反。 4. 单步调试,查看CR1寄存器的UE位。 |
| 输出乱码 | 1.波特率不匹配(最常见)。 2. 数据格式不匹配(如PC端8N1,STM32配置了奇偶校验)。 3. 系统时钟频率与预期不符。 | 1. 用示波器测量位时间,计算实际波特率。 2. 核对双方的数据位、停止位、校验位设置。 3. 检查系统时钟配置,确认HSE是否起振,PLL配置是否正确。 |
| 能发送但不能接收 | 1. RX引脚配置错误(如配置成了输出)。 2. RE位未使能。3. 外部设备发送的信号电平不标准。 | 1. 检查GPIOA_CRH中PA10的配置。 2. 检查CR1寄存器的RE位。 3. 用示波器观察PA10引脚在PC发送时是否有波形。 |
| 第一个字符丢失 | 1. 在初始化完成前,TX引脚处于不稳定状态,可能发送了乱码。 2. 串口助手打开时机问题。 | 1. 确保在配置完所有参数并使能TE之前,TX引脚处于已知状态(可通过GPIO初始化将其设为高电平)。 2. 尝试在发送前加一个短暂延时。 |
程序卡在while(!RXNE) | 1. 根本没有数据发送过来。 2. RXNE标志因其他原因无法置位(如过载错误ORE)。3. 之前读取数据后未正确清除RXNE(但我们的代码是读DR,可以清除)。 | 1. 确认发送端是否工作。 2. 检查SR寄存器的ORE位,如果置1,需要先读SR(清除ORE),再读DR。可以在初始化后读一次SR和DR来清空残留状态。 |
7. 从查询到中断:思维进阶与代码改造
虽然本文聚焦查询方式,但理解它是为了更好地使用中断。这里简要提一下改造思路,作为你下一步学习的指引。
中断方式的优势在于解放CPU。当数据到达或发送寄存器空时,硬件自动产生中断,CPU才去处理,其余时间可以执行其他任务。
改造要点:
- 使能中断:在CR1寄存器中,使能
RXNEIE(接收中断)和/或TXEIE(发送中断)。 - 配置NVIC:在NVIC(嵌套向量中断控制器)中,使能USART1的中断通道,并设置合适的优先级。
- 编写中断服务函数:函数名需与启动文件中的向量表定义一致,例如
void USART1_IRQHandler(void)。 - 在中断函数中判断标志位:进入中断后,首先检查SR寄存器,是
RXNE置位还是TXE置位,然后执行相应的读写操作。 - 清除中断标志:对于
RXNE,读DR寄存器即可清除。对于TXE,写DR寄存器或读取SR寄存器再写DR可以清除。注意TC标志可能需要软件清除。
一个简单的接收中断示例框架:
volatile uint8_t usart_rx_buffer[256]; volatile uint16_t usart_rx_index = 0; void USART1_IRQHandler(void) { if(pbUSART1_SR->RXNE) { // 读取数据 uint8_t data = pbUSART1_DR->DR; // 存入缓冲区 if(usart_rx_index < 256) { usart_rx_buffer[usart_rx_index++] = data; } // 可以在这里判断是否收到特定字符(如回车符)来决定处理一帧数据 } // 还可以处理其他中断源,如TC, IDLE等 }在主循环中,你就可以不再调用Usart1GetChar去死等,而是去检查usart_rx_index,处理缓冲区中的数据。这样,主循环就可以同时处理按键、显示等其他任务了。
手写这个查询式驱动的过程,就像在组装一个精致的机械手表。你能清晰地看到每一个齿轮(寄存器位)是如何咬合,带动指针(数据流)一步步前进的。这份对底层硬件的掌控感,是使用高级库无法完全替代的。当你下次遇到串口通信的疑难杂症时,这份亲手调试的经验会让你更快地定位问题——是时钟不对,还是引脚配错,抑或是状态标志没有正确清除。从查询到中断,再到DMA,每一步的进阶都建立在对这些基础环节的牢固掌握之上。希望这个详细的拆解,能成为你嵌入式路上的一块坚实垫脚石。