1. 项目概述:从零构建一个可靠的查询式串口驱动

在嵌入式开发中,串口通信几乎是每个项目的“标配”,无论是用于调试打印、固件升级,还是与传感器、模块进行数据交互,它都是最基础、最直接的通信手段。很多朋友在初次接触STM32这类MCU时,面对HAL库、LL库或者各种现成的驱动框架,可能会觉得串口用起来很简单,调用几个API就完事了。但在我看来,如果不从最底层的寄存器操作开始,亲手“拧一遍螺丝”,就很难真正理解串口通信的时序、状态机以及那些隐蔽的坑。今天,我就以STM32F103C8T6这款经典的“蓝桥杯”芯片为例,抛开复杂的库和中断,带你手写一个最纯粹、最可靠的 查询方式 串口驱动。我们会从引脚复用讲起,一步步算波特率、配寄存器,直到实现数据的收发。这个过程不仅能帮你夯实基础,更能让你在日后调试更复杂的通信协议时,心里更有底。

2. 核心设计思路与方案选型

2.1 为什么选择查询方式?

在项目初期或者对实时性要求不高的简单应用中,查询方式(Polling)有着独特的优势。它的核心逻辑就是程序不断地、主动地去查看串口的状态寄存器,判断是否有新数据到达(接收)或者数据是否已经发送完毕(发送)。

优势在于:

  1. 代码极其简单直观 :没有复杂的中断服务程序(ISR)需要编写和管理,逻辑一目了然,非常适合新手理解串口工作的本质流程。
  2. 确定性好 :程序流程是顺序执行的,没有中断嵌套、抢占带来的时序不确定性。在简单的控制逻辑中,这反而是个优点。
  3. 资源占用清晰 :不涉及中断向量表配置、优先级设置,对系统其他部分的影响最小。

当然,缺点也很明显:

  1. CPU利用率低 :在 while(!RXNE) while(!TXE) 这样的等待循环中,CPU在空转,无法执行其他任务。这在多任务系统中是不可接受的。
  2. 可能丢失数据 :如果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):

  1. 计算理论 USARTDIV 值: USARTDIV = 72,000,000 / (16 * 9600) = 468.75
  2. 整数部分 DIV_Mantissa = 468 (即 0x1D4)
  3. 小数部分 DIV_Fraction = 0.75 * 16 = 12 (即 0xC)
  4. 所以, 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;
}

看起来很简单,但这里有 两个极易忽视的坑

  1. TC标志与TXE标志的区别 TXE 表示数据寄存器已空,可以写入下一个数据。 TC 表示整个发送移位寄存器也已空,即上一帧数据已完全发出。在连续发送字符串时,我们通常只查询 TXE 。只有在发送完最后一字节后,如果需要确保数据完全离开引脚(例如在关闭串口或进入低功耗前),才需要查询 TC
  2. 写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);
    }
}

调试技巧

  1. 使用逻辑分析仪或示波器 :这是最直接的方法。抓取PA9(TX)引脚上的波形,测量位时间。对于9600波特率,一个位的时间大约是104us。你可以检查起始位、数据位、停止位是否完整、电平是否正确。
  2. 使用PC串口助手 :将STM32的USART1通过USB转TTL模块连接到电脑。打开串口助手(如Putty、SecureCRT、或者各种嵌入式IDE自带的工具),设置正确的波特率、数据位、停止位、校验位。如果硬件和代码正确,你应该能看到“System start...”信息,并且你从键盘输入的每个字符都会被回显。
  3. 如果收不到数据
    • 首先检查硬件 :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才去处理,其余时间可以执行其他任务。

改造要点:

  1. 使能中断 :在CR1寄存器中,使能 RXNEIE (接收中断)和/或 TXEIE (发送中断)。
  2. 配置NVIC :在NVIC(嵌套向量中断控制器)中,使能USART1的中断通道,并设置合适的优先级。
  3. 编写中断服务函数 :函数名需与启动文件中的向量表定义一致,例如 void USART1_IRQHandler(void)
  4. 在中断函数中判断标志位 :进入中断后,首先检查SR寄存器,是 RXNE 置位还是 TXE 置位,然后执行相应的读写操作。
  5. 清除中断标志 :对于 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,每一步的进阶都建立在对这些基础环节的牢固掌握之上。希望这个详细的拆解,能成为你嵌入式路上的一块坚实垫脚石。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐