1. USART1串口通信实验:从硬件连接到软件实现的完整工程解析

在嵌入式系统开发中,串口通信是工程师接触最频繁、应用最广泛的外设之一。它不仅是调试信息输出的核心通道,更是设备间数据交互的基础接口。本实验以STM32F103C8T6(常见于“蓝 pill”开发板)为平台,聚焦USART1外设,构建一个完整的PC-单片机双向透传系统:PC端通过USB转串口芯片(CH340)发送任意数据,MCU接收后原样回传,同时驱动LED闪烁指示系统运行状态。该设计不依赖任何高级协议栈,完全基于HAL库底层API实现,具备极强的可移植性与教学价值。整个过程涵盖硬件电路理解、时钟树配置、GPIO复用、寄存器级初始化逻辑、中断服务机制及实际调试技巧,是掌握STM32外设驱动开发的关键实践。

1.1 硬件拓扑与信号路径分析

理解物理连接是软件配置的前提。本实验采用标准USB-TTL通信方案,其信号链路如下:

PC USB端口 → CH340 USB-UART桥接芯片 → STM32F103 USART1引脚

CH340芯片承担USB协议解析与TTL电平转换双重职责。其TXD引脚输出逻辑“1”为3.3V、逻辑“0”为0V的TTL电平信号,直接接入STM32的RX引脚;RXD引脚则接收来自STM32 TX引脚的同规格信号。关键在于引脚映射关系:STM32F103的USART1默认复用在GPIOA的PA9(TX)与PA10(RX)上。这一映射并非固定不变,而是由芯片的数据手册《STM32F103x8/B datasheet》第45页“Alternate function mapping”表格所定义。PA9与PA10在AFIO重映射寄存器未被修改的前提下,其Alternate Function 7(AF7)即对应USART1功能。因此,硬件设计必须确保CH340的TXD连接至PA10,CH340的RXD连接至PA9——若接反,则通信必然失败。此外,CH340的GND必须与STM32的GND可靠共地,这是所有数字通信的基石。对于使用RS232接口的旧式设备,需额外增加MAX3232等电平转换芯片,因其逻辑“1”为-12V、“0”为+12V,与TTL电平不兼容。本实验规避此复杂度,直连CH340,仅需一根USB线即可完成程序下载与串口调试双重任务。

1.2 工程框架重构:模块化代码组织规范

一个健壮的嵌入式项目始于清晰的目录结构。本实验摒弃了将所有代码堆砌于 main.c 的陋习,严格遵循分层设计原则,建立独立的 usart 功能模块。具体操作流程如下:

  1. 工程克隆 :基于已验证无误的“PWM呼吸灯”工程进行复制,命名为 usart_communication 。此举继承了正确的启动文件、链接脚本及基础时钟配置,避免从零搭建引入隐性错误。
  2. 冗余清理 :彻底移除原工程中与PWM相关的所有源文件(如 pwm.c/h )、头文件包含语句及 main() 函数内PWM初始化与控制代码。特别注意,在IDE(如Keil uVision或STM32CubeIDE)中执行“Remove from Project”操作仅从编译列表中剔除,源文件仍保留在磁盘。为节省空间,需手动删除本地文件夹中的 pwm.c/h
  3. 模块创建 :在工程根目录下新建 Drivers/USART/ 子文件夹。在此路径下创建 usart.c (实现文件)与 usart.h (接口头文件)。此路径符合ARM CMSIS标准,便于未来扩展其他外设驱动。
  4. 工程集成 :通过IDE的“Add Files to Group”功能,将 usart.c 添加至 Source Group 。随后,在 Options for Target → C/C++ → Include Paths 中,添加 Drivers/USART/ 路径。此举使预处理器能在 #include "usart.h" 时准确定位头文件,避免编译错误 fatal error: usart.h: No such file or directory
  5. 公共库迁移 :将通用的位带操作( bitband.h/c )、SysTick延时( systick.h/c )等基础工具代码,统一移至 Drivers/Public/ 目录。这不仅提升了代码复用率,更强化了项目结构的可维护性——后续所有基于STM32F103的项目均可直接引用此 Public 库。

此套流程看似繁琐,实则是工业级嵌入式开发的必备素养。它强制开发者思考代码的边界与职责,为后期引入RTOS、文件系统或多传感器融合奠定坚实基础。

1.3 头文件编写:防御性编程与接口契约

usart.h 是模块对外的唯一契约,其质量直接决定调用方的易用性与安全性。一个专业的头文件应包含以下核心要素:

#ifndef __USART_H
#define __USART_H

#ifdef __cplusplus
extern "C" {
#endif

/* 包含必要的系统头文件 */
#include "stm32f1xx.h"
#include "stdint.h"

/* 函数声明:USART1初始化,波特率作为参数 */
void USART1_Init(uint32_t baudrate);

/* 接收缓冲区声明(供外部访问,用于演示) */
extern uint8_t rx_buffer[64];

/* 发送完成回调声明(可选,为后续扩展预留) */
extern void (*usart_tx_complete_callback)(void);

#ifdef __cplusplus
}
#endif

#endif /* __USART_H */

关键设计点解析:
- 宏卫士(Macro Guard) #ifndef __USART_H ... #endif 是防止头文件被重复包含的标准范式。若多个 .c 文件均 #include "usart.h" ,宏卫士能确保其内容仅被编译一次,避免符号重定义错误。
- C++兼容性 extern "C" 声明块允许该头文件被C++源文件安全包含,防止C++编译器对函数名进行名称修饰(name mangling),确保C函数能被C++代码正确链接。
- 最小依赖原则 :仅包含 stm32f1xx.h (提供寄存器定义与HAL基础)和 stdint.h (提供标准整数类型),避免引入不必要的庞大依赖,提升编译速度与可移植性。
- 清晰接口 USART1_Init() 函数声明明确其功能与参数含义; rx_buffer 声明为 extern ,表明其定义在 usart.c 中,供其他模块(如 main.c )读取接收到的数据; usart_tx_complete_callback 为函数指针,虽本实验未实现,但预留了中断发送完成后的回调接口,体现设计前瞻性。

1.4 USART1初始化:时钟、GPIO与外设的协同配置

USART1的初始化绝非简单调用一个API,而是一系列硬件资源协同工作的精密过程,其步骤严格遵循STM32参考手册《RM0008》第27章时序要求。以下是基于HAL库的完整实现逻辑,每一步均附有原理阐释:

1.4.1 使能相关时钟
// 1. 使能GPIOA时钟 (USART1 TX/RX引脚所在端口)
__HAL_RCC_GPIOA_CLK_ENABLE();

// 2. 使能USART1时钟
__HAL_RCC_USART1_CLK_ENABLE();

原理 :STM32采用门控时钟(Gated Clock)架构。所有外设在未获得时钟前均处于休眠状态,无法响应任何配置或操作。GPIOA是PA9/PA10的物理载体,必须首先上电;USART1本身作为一个独立外设,其寄存器操作与数据收发亦需专属时钟驱动。此两步是所有后续配置的先决条件,遗漏任一都将导致初始化失败或行为不可预测。

1.4.2 配置GPIO引脚为复用推挽输出与浮空输入
GPIO_InitTypeDef GPIO_InitStruct = {0};

// 配置PA9为USART1_TX:复用推挽输出
GPIO_InitStruct.Pin = GPIO_PIN_9;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;      // Alternate Function Push-Pull
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 配置PA10为USART1_RX:浮空输入
GPIO_InitStruct.Pin = GPIO_PIN_10;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;       // Input mode for RX
GPIO_InitStruct.Pull = GPIO_PULLUP;           // 可选:上拉增强抗干扰
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

原理
- GPIO_MODE_AF_PP :TX引脚需主动驱动信号,复用推挽模式允许其输出高/低电平,驱动能力远超普通开漏模式,且无需外部上拉电阻。
- GPIO_MODE_INPUT + GPIO_PULLUP :RX引脚为纯输入,接收外部信号。配置上拉电阻(而非浮空)可有效抑制线路噪声,防止在无信号时因悬空引脚电平漂移导致UART误触发起始位。 GPIO_PULLUP 是针对STM32F1系列的推荐实践,尤其在长线或嘈杂环境中至关重要。

1.4.3 初始化USART1外设参数
USART_HandleTypeDef huart1;

void USART1_Init(uint32_t baudrate) {
    huart1.Instance = USART1;
    huart1.Init.BaudRate = baudrate;
    huart1.Init.WordLength = UART_WORDLENGTH_8B;     // 8位数据位
    huart1.Init.StopBits = UART_STOPBITS_1;         // 1位停止位
    huart1.Init.Parity = UART_PARITY_NONE;          // 无校验
    huart1.Init.Mode = UART_MODE_TX_RX;             // 全双工:TX与RX均使能
    huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;    // 无硬件流控
    huart1.Init.OverSampling = UART_OVERSAMPLING_16; // 标准16倍过采样

    if (HAL_UART_Init(&huart1) != HAL_OK) {
        Error_Handler(); // 错误处理函数,通常点亮错误LED或进入死循环
    }
}

参数详解
- BaudRate :波特率是通信双方的“心跳”,必须绝对一致。常见值如9600、115200。HAL库内部根据APB2总线频率(通常为72MHz)与 OverSampling 设置,自动计算并配置 USARTDIV 寄存器值,实现精确分频。
- WordLength :8位数据位是绝大多数协议(如ASCII、Modbus RTU)的标准,与PC端串口助手默认设置匹配。
- StopBits :1位停止位提供最短帧间隔,兼顾效率与可靠性。 UART_STOPBITS_0_5 2 适用于特殊场景,但非本实验需求。
- Parity :无校验( NONE )简化了协议,降低开销。在短距离、高信噪比环境下(如USB-TTL直连),其可靠性已足够。若需增强鲁棒性,可启用偶校验( UART_PARITY_EVEN )。
- Mode UART_MODE_TX_RX 明确声明全双工能力,区别于仅发送( TX_ONLY )或仅接收( RX_ONLY )模式。
- HwFlowCtl :硬件流控(RTS/CTS)用于高速大数据量传输时防止缓冲区溢出。本实验数据量小,故禁用。
- OverSampling :16倍过采样是标准模式,提供最佳的采样精度与抗干扰性。8倍过采样( UART_OVERSAMPLING_8 )可略微提升最高波特率,但牺牲了部分容错能力。

1.4.4 使能USART1外设与接收中断
// 在HAL_UART_Init()成功后,立即使能USART1
__HAL_USART_ENABLE(&huart1);

// 使能USART1接收中断(RXNE: Receive Data Register Not Empty)
__HAL_USART_ENABLE_IT(&huart1, USART_IT_RXNE);

原理 HAL_UART_Init() 函数最终会调用 HAL_UART_MspInit() (由用户实现的底层硬件抽象层),完成寄存器配置,但此时外设仍处于禁用状态。 __HAL_USART_ENABLE() 宏直接操作 USART_CR1 寄存器的 UE 位,将其置1,正式激活USART1。 __HAL_USART_ENABLE_IT() 则设置 USART_CR1 RXNEIE 位,允许当接收数据寄存器(RDR)非空时,向NVIC发出中断请求。这是实现“数据到达即处理”异步模型的核心开关。

1.5 NVIC中断控制器配置:优先级分组与通道使能

STM32的中断管理由嵌套向量中断控制器(NVIC)负责。正确配置是保证串口中断及时响应的关键。本实验采用以下配置:

void USART1_NVIC_Config(void) {
    // 1. 设置中断优先级分组:2位抢占优先级,2位子优先级
    HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2);

    // 2. 配置USART1中断:抢占优先级=3,子优先级=3
    HAL_NVIC_SetPriority(USART1_IRQn, 3, 3);

    // 3. 使能USART1中断通道
    HAL_NVIC_EnableIRQ(USART1_IRQn);
}

深入解析
- 优先级分组(Priority Grouping) :STM32F103支持4位中断优先级,通过 NVIC_PRIORITYGROUP_x 宏将其划分为抢占优先级(Preemption Priority)与子优先级(Subpriority)。 NVIC_PRIORITYGROUP_2 表示高2位为抢占优先级,低2位为子优先级。抢占优先级高的中断可打断正在执行的低抢占优先级中断(即“嵌套”),而相同抢占优先级的中断则按子优先级顺序排队执行。本实验选择 3,3 ,意味着其抢占优先级低于SysTick(通常设为0)和更高优先级的实时任务,但高于大多数外设,确保通信不被过度延迟。
- 中断通道号(IRQn) USART1_IRQn 是CMSIS标准定义的中断号常量,其值为37(见 stm32f1xx.h )。直接使用该符号而非硬编码数字,极大提升了代码可读性与可移植性。
- 使能通道 HAL_NVIC_EnableIRQ() 操作 NVIC_ISER 寄存器,开启USART1中断的“门”,使其能被CPU响应。若此步遗漏,即使接收到了数据,中断服务函数也永远不会被执行。

1.6 中断服务函数(ISR):高效、简洁的数据搬运工

中断服务函数是实时响应的“神经末梢”,其设计必须遵循“快进快出”原则,严禁执行耗时操作。本实验的 USART1_IRQHandler 核心任务仅为:从硬件接收寄存器读取一个字节,并将其暂存至RAM缓冲区。

// 定义全局接收缓冲区
uint8_t rx_buffer[64];
volatile uint16_t rx_head = 0;   // 缓冲区写入索引(由ISR修改)
volatile uint16_t rx_tail = 0;   // 缓冲区读取索引(由主循环修改)

void USART1_IRQHandler(void) {
    uint32_t isrflags = READ_REG(huart1.Instance->SR);
    uint32_t cr1its = READ_REG(huart1.Instance->CR1);

    // 检查是否为RXNE(接收非空中断)且中断使能
    if (((isrflags & USART_SR_RXNE) != RESET) && ((cr1its & USART_CR1_RXNEIE) != RESET)) {
        // 读取RDR寄存器,清除RXNE标志
        uint8_t data = (uint8_t)(huart1.Instance->DR & (uint8_t)0xFF);

        // 将数据存入环形缓冲区
        rx_buffer[rx_head] = data;
        rx_head = (rx_head + 1) % sizeof(rx_buffer);
    }
}

关键要点
- 状态检查 READ_REG() 宏直接读取 SR (状态寄存器)和 CR1 (控制寄存器1),确认中断源确为RXNE且该中断已被使能。这是避免“虚假中断”的必要防护。
- 原子读取 huart1.Instance->DR 读取操作本身会自动清除 RXNE 标志。若不读取,该标志将持续置位,导致中断不断被触发,形成“中断风暴”。
- 环形缓冲区(Circular Buffer) rx_head rx_tail 构成一个简单的环形队列。 rx_head 由ISR更新(生产者), rx_tail 由主循环更新(消费者)。 % sizeof(rx_buffer) 运算实现索引的自动回绕。此设计允许多个字节连续到达而不丢失,是异步通信的基石。
- volatile关键字 rx_head rx_tail 被声明为 volatile ,告知编译器这两个变量可能被中断服务程序意外修改,禁止对其进行优化(如缓存到寄存器),确保主循环每次读取的都是最新值。

1.7 主循环逻辑:数据处理与系统监控

main() 函数是系统的“大脑”,其主要职责是轮询处理接收到的数据并维持系统状态。本实验的主循环实现了数据回传与LED闪烁:

int main(void) {
    HAL_Init();
    SystemClock_Config(); // 配置72MHz系统时钟(HSE+PLL)
    MX_GPIO_Init();       // 初始化LED引脚(如PC13)

    // 初始化USART1及相关NVIC
    USART1_Init(115200);
    USART1_NVIC_Config();

    // 主循环
    while (1) {
        // 1. 处理接收缓冲区
        if (rx_head != rx_tail) {
            uint8_t data = rx_buffer[rx_tail];
            rx_tail = (rx_tail + 1) % sizeof(rx_buffer);

            // 将接收到的字节原样回传
            HAL_UART_Transmit(&huart1, &data, 1, HAL_MAX_DELAY);
        }

        // 2. LED闪烁指示系统运行
        HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
        HAL_Delay(500); // 500ms间隔,产生1Hz闪烁
    }
}

设计哲学
- 非阻塞处理 if (rx_head != rx_tail) 判断缓冲区非空,仅处理一个字节后即退出,避免在 while 循环中长时间占用CPU。这为未来扩展(如添加ADC采样、按键扫描)预留了充足的时间片。
- 同步发送 HAL_UART_Transmit() 在此处以 HAL_MAX_DELAY 阻塞等待发送完成。虽然非最优(理想应为中断或DMA发送),但对于低速、小数据量的调试场景,其简洁性与可靠性无可替代。 HAL_MAX_DELAY 确保了发送必定完成,不会因超时而失败。
- 系统心跳 HAL_GPIO_TogglePin() HAL_Delay() 组合,以恒定频率翻转LED。这不仅是视觉反馈,更是系统健康状况的“脉搏”。若LED停止闪烁,可立即判断为 main() 卡死或 HAL_Delay() 异常,极大加速了故障定位。

1.8 调试与验证:串口助手的正确使用

软件烧录后,需借助PC端串口调试工具验证功能。推荐使用轻量级的“XCOM”或“SSCOM”串口助手,其关键配置项如下:
- 串口号(Port) :在Windows设备管理器中确认CH340对应的COM端口号(如 COM3 )。
- 波特率(Baud Rate) :必须与 USART1_Init(115200) 参数严格一致,此处为 115200
- 数据位(Data Bits) 8
- 停止位(Stop Bits) 1
- 校验位(Parity) None
- 流控(Flow Control) None

验证步骤
1. 打开串口助手,点击“打开串口”。
2. 在发送框内输入任意字符串(如 Hello STM32! ),点击“发送”。
3. 观察接收区是否 原样、完整、无乱码 地显示相同字符串。若出现乱码,首要检查波特率是否匹配;若接收不全,检查环形缓冲区大小或主循环处理速度;若完全无响应,按“硬件连接→时钟使能→GPIO配置→USART初始化→中断使能→NVIC配置”顺序逐级排查。
4. 同时观察开发板上的LED(通常是PC13蓝灯)是否以1秒周期稳定闪烁。若LED不亮,检查 MX_GPIO_Init() 中LED引脚配置及 HAL_GPIO_TogglePin() 调用。

1.9 常见问题深度剖析与实战经验

在无数次的课堂实践中,以下问题反复出现,其根源往往深植于对底层机制的理解偏差:

  • 问题:“发送数据后,PC端只收到第一个字符,后续丢失。”
  • 根因 :主循环中 HAL_UART_Transmit() 是阻塞式发送,而 HAL_Delay(500) 期间,新的数据可能持续涌入RX缓冲区。当 HAL_UART_Transmit() 正在发送一个字节时, rx_head 可能已推进多个位置,导致 rx_tail 追不上 rx_head ,缓冲区溢出。
  • 解决方案 :将发送逻辑改为非阻塞的中断模式,或在发送前增加对缓冲区长度的判断,确保发送速率不低于接收速率。简易修复是增大 HAL_Delay() 时间(如 1000 ),但这牺牲了实时性。

  • 问题:“串口助手能收到数据,但LED不闪烁。”

  • 根因 HAL_Delay() 依赖于 SysTick 定时器。若 SystemClock_Config() 中未正确配置 SysTick 的时钟源(应为AHB/8=9MHz),或 HAL_Init() 未被调用, HAL_Delay() 将无法工作。
  • 解决方案 :在 main() 开头加入 __HAL_DBGMCU_FREEZE_TIMx() 调试语句,或使用逻辑分析仪捕获 SysTick 中断信号,验证其是否规律触发。

  • 问题:“使用不同波特率(如9600)时,通信不稳定。”

  • 根因 :STM32F103的APB2总线频率为72MHz,计算 USARTDIV 时, 9600 波特率对应的分频值可能产生较大误差。HAL库的 HAL_UART_Init() 内部会进行四舍五入,导致实际波特率偏差。
  • 解决方案 :查阅《RM0008》第27.5.2节,手动计算 USARTDIV 值,或在 usart.c 中添加波特率校验打印,确认 huart1.Init.BaudRate 是否被HAL库精确实现。

我在实际项目中曾遇到一个隐蔽Bug:CH340芯片在某些批次中存在上电时序问题,导致其TXD引脚在STM32复位完成前即输出无效电平,被误认为是有效起始位,造成首次通信失败。解决方法是在 main() USART1_Init() 之前,增加一段 HAL_Delay(10) ,给予CH340充分的稳定时间。这个细节,只有在产线上反复“踩坑”后才会铭记于心。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐