从零开始掌握Keil5串口通信:STM32底层驱动实战指南
你是否曾在点亮LED后,卡在“下一步该做什么”的瓶颈期?
你是否面对Keil5复杂的工程配置和一堆寄存器感到无从下手?
你是否想让单片机真正“开口说话”,却不知道如何建立稳定的数据通道?
别担心。今天我们就来解决这个关键问题——用C语言在Keil5中实现STM32的串口通信。
这不是一份泛泛而谈的工具使用说明,而是一次深入硬件本质的实战演练。我们将绕过HAL库的封装,直接操作寄存器,带你理解每一行代码背后的物理意义。最终目标很明确:让你的STM32通过串口接收一个字符,并原样回传给电脑。
整个过程将在Keil MDK-ARM(即Keil5)环境下完成,适用于STM32F1系列(如经典型号STM32F103C8T6)。无论你是刚入门的新手,还是希望夯实基础的老手,这篇文章都会给你带来实实在在的价值。
为什么是Keil5?它真的值得学吗?
在嵌入式开发领域,IDE选择众多:IAR、GCC+Eclipse、VS Code+PlatformIO……但为何我们仍要聚焦于Keil5?
因为它依然是工业界最主流、资料最丰富、兼容性最好的ARM Cortex-M开发环境之一。
Keil5的核心优势在于它的成熟度与稳定性。Arm Compiler对Cortex-M内核做了深度优化,生成的代码紧凑高效;uVision5界面虽然不算现代,但功能完整、逻辑清晰;更重要的是,几乎所有国产MCU厂商(如GD32、APM32)都提供完整的Keil支持包。
而且对于初学者来说,Keil5的学习曲线相对平缓——你可以先专注于理解外设工作机制,而不必一开始就陷入Makefile、链接脚本、编译链配置等复杂细节中。
⚠️ 当然也有缺点:免费版限制代码大小为32KB,商业项目需授权;建议优先使用Arm Compiler 6以获得更好的C11标准支持和性能优化。
但这些都不是阻碍我们上手的理由。相反,正是因为它够“传统”,才更适合用来学习底层机制。
串口通信:嵌入式系统的“第一扇窗”
如果说GPIO控制LED是嵌入式开发的“Hello World”,那么串口通信就是你打开系统内部世界的“第一扇窗”。
UART(通用异步收发器)协议简单、资源占用少、调试方便,几乎每一块开发板都配备了至少一个串口。它不仅能用于打印日志、发送传感器数据,还能作为Bootloader升级接口、远程命令行入口,甚至是Modbus等工业协议的基础载体。
更重要的是,掌握串口意味着你开始真正理解时钟、中断、DMA、状态机这些核心概念的实际应用方式。
什么是UART?它怎么工作的?
UART是一种异步串行通信接口,只需要两根线就能完成全双工通信:
-TX:发送数据
-RX:接收数据
所谓“异步”,是指通信双方没有共享时钟线,而是依赖事先约定好的波特率(Baud Rate)来同步每一位的传输时间。例如115200bps表示每秒传送115200个符号。
典型帧格式为“8-N-1”:
- 1位起始位(低电平)
- 8位数据位(LSB先行)
- 无校验位
- 1位停止位(高电平)
只要两边设置一致,就可以可靠通信。当然,波特率误差最好控制在±2%以内,否则容易出现误码。
硬件准备与系统架构设计
我们的目标系统非常简洁:
[PC] ↓ (USB-TTL模块,如CH340G/CP2102) [TX ← PA10] [STM32F103C8T6] [PA9 → RX] ↓ [串口助手软件:XCOM / SSCOM / Tera Term]MCU负责初始化USART1外设,开启接收中断。一旦收到数据,立即触发中断服务程序,读取并回传。
主循环则可以继续执行其他任务,比如闪烁LED或采集ADC值——这正是中断机制的魅力所在。
第一步:搭建Keil5工程框架
打开Keil uVision5,新建一个工程:
Project → New uVision Project,保存路径不要含中文;- 选择芯片型号:
STMicroelectronics → STM32F103C8; - Keil会提示是否添加启动文件,点击“是”;
- 创建新组:
Source(存放.c文件)、Inc(头文件)、Startup(启动代码); - 将
startup_stm32f103xb.s加入Startup组; - 新建
main.c、usart.c、usart.h,分别加入对应组。
此时你的工程结构应该像这样:
Project ├── Source │ ├── main.c │ └── usart.c ├── Inc │ └── usart.h └── Startup └── startup_stm32f103xb.s接下来,我们要做的不是写代码,而是搞清楚一件事:芯片是怎么被唤醒的?
启动流程解析:从复位到main函数
当你按下复位按钮,CPU并不是直接跳转到main()函数。中间有一个关键过程:
- CPU从Flash首地址读取栈顶指针(SP),通常是
0x2000_0000附近; - 再读取复位向量,跳转到
Reset_Handler; - 执行SystemInit() —— 这是由Keil提供的默认函数,配置HSE、PLL,将系统时钟升至72MHz;
- 最终调用
__main,由编译器运行库引导进入用户main()函数。
这意味着:在你写下第一行C代码前,系统时钟已经准备就绪。这对后续外设初始化至关重要。
核心难点突破一:GPIO复用与时钟使能
STM32的强大之处在于其高度灵活的引脚复用机制。比如PA9和PA10,默认是普通GPIO,但我们可以通过配置让它变成USART1的TX/RX引脚。
但这需要两个前提条件:
1.必须先开启对应总线时钟,否则无法访问寄存器;
2.必须正确配置复用功能编号(AF),否则信号不会连通。
关键寄存器一览
| 寄存器 | 功能 |
|---|---|
RCC->AHB1ENR | 开启GPIOA时钟(F1系列实际为APB2ENR) |
RCC->APB2ENR | 开启USART1时钟 |
GPIOA->MODER | 设置模式:输入/输出/复用/模拟 |
GPIOA->OTYPER | 输出类型:推挽/开漏 |
GPIOA->OSPEEDR | 输出速度等级 |
GPIOA->PUPDR | 上下拉电阻配置 |
GPIOA->AFR[1] | 选择复用功能号(AF7 = USART1) |
注意:STM32F103属于较早系列,GPIO时钟位于APB2而非AHB1,因此应使用
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
实战代码:手动配置PA9(TX)和PA10(RX)
// usart.c #include "stm32f10x.h" void GPIO_UART_Init(void) { // Step 1: 使能GPIOA和USART1时钟 RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_USART1EN; // ------------------- 配置PA9为USART1_TX --------------------- // MODER: 清除原有设置,设为复用推挽输出 GPIOA->MODER &= ~GPIO_MODER_MODER9_Msk; GPIOA->MODER |= GPIO_MODER_MODER9_1; // 复用模式(10) GPIOA->OTYPER &= ~GPIO_OTYPER_OT_9; // 推挽输出 GPIOA->OSPEEDR |= GPIO_OSPEEDER_OSPEEDR9_1; // 高速(50MHz) GPIOA->PUPDR &= ~GPIO_PUPDR_PUPDR9_Msk; // 无上下拉 // AFR[1]: 因为PA9 > 7,所以使用AFRH寄存器 GPIOA->AFR[1] |= (7U << (9 - 8)*4); // PA9 -> AF7 (USART1) // ------------------- 配置PA10为USART1_RX -------------------- GPIOA->MODER &= ~GPIO_MODER_MODER10_Msk; GPIOA->MODER |= GPIO_MODER_MODER10_1; // 复用模式 GPIOA->PUPDR |= GPIO_PUPDR_PUPDR10_0; // 上拉(增强抗干扰) GPIOA->AFR[1] |= (7U << (10 - 8)*4); // PA10 -> AF7 }✅ 小贴士:复用功能编号必须查手册确认。STM32F103中,USART1映射到AF7。
这段代码看似繁琐,但它让你彻底掌握了硬件控制权。相比HAL库的一句HAL_UART_Init(),这种方式更能帮助你理解“为什么”。
核心难点突破二:USART初始化与波特率计算
接下来我们配置USART1本身。
主要涉及三个寄存器:
-USART1->BRR:波特率寄存器(形如DIV_Mantissa | (DIV_Fraction << 4))
-USART1->CR1:控制寄存器1,启用发送/接收
-USART1->SR:状态寄存器,反映当前通信状态
波特率怎么算?
公式如下:
Baud = f_PCLK / (16 * USARTDIV)其中PCLK2通常为72MHz(APB2挂载高速外设),若要得到115200bps:
USARTDIV = 72000000 / (16 * 115200) ≈ 39.0625 → 整数部分 = 39, 小数部分 = 0.0625 × 16 ≈ 1 → BRR = 0x271我们可以写成宏定义:
#define UART_BAUDRATE 115200 #define PCLK2_FREQ 72000000UL #define USART_DIV (PCLK2_FREQ + UART_BAUDRATE / 2) / (16 * UART_BAUDRATE) #define USART_BRR ((USART_DIV << 4) | (((PCLK2_FREQ * 10 / (16 * UART_BAUDRATE)) % 10) >= 5 ? 1 : 0))不过更简单的做法是直接赋值:
USART1->BRR = 0x271; // 对应115200bps @72MHz PCLK2初始化函数实现
void USART1_Init(uint32_t baudrate) { // 已在GPIO_UART_Init中开启时钟 GPIO_UART_Init(); // 计算BRR(简化处理,固定72MHz) uint32_t brr = 72000000 / (16 * baudrate); USART1->BRR = (brr << 4) | ((72000000 * 10 / (16 * baudrate)) % 10 >= 5 ? 1 : 0); // CR1配置:使能UE(使能UART), RE(接收), TE(发送) USART1->CR1 = USART_CR1_UE | USART_CR1_RE | USART_CR1_TE; // 可在此处使能中断(也可单独函数) }核心难点突破三:NVIC中断配置与ISR编写
轮询方式太浪费CPU。我们采用中断驱动模式,当接收到数据时自动触发处理。
中断使能步骤
- 在USART控制寄存器中使能RXNE中断;
- 配置NVIC优先级;
- 开启全局中断通道。
void USART_EnableInterrupt(void) { // 使能接收中断 USART1->CR1 |= USART_CR1_RXNEIE; // 设置优先级(抢占优先级2,子优先级0) NVIC_SetPriority(USART1_IRQn, NVIC_EncodePriority(NVIC_GetPriorityGrouping(), 2, 0)); // 使能NVIC中断源 NVIC_EnableIRQ(USART1_IRQn); }编写中断服务函数
注意:中断函数名必须与启动文件中的向量表一致!
查看startup_stm32f103xb.s可知,USART1的中断服务函数名为:
DCD USART1_IRQHandler所以我们必须定义同名函数:
// usart.c extern void USART_SendByte(USART_TypeDef* USARTx, uint8_t ch); // 声明发送函数 void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { // 接收数据寄存器非空? uint8_t ch = USART1->DR; // 读DR自动清标志 USART_SendByte(USART1, ch); // 回显 } // 其他中断标志可在此扩展(如错误处理) }发送函数实现
void USART_SendByte(USART_TypeDef* USARTx, uint8_t ch) { while (!(USARTx->SR & USART_SR_TXE)); // 等待发送寄存器空 USARTx->DR = ch; } void USART_SendString(USART_TypeDef* USARTx, const char* str) { while (*str) { USART_SendByte(USARTx, *str++); } }主函数整合:构建完整通信系统
现在回到main.c,把所有模块串联起来:
// main.c #include "stm32f10x.h" #include "usart.h" int main(void) { SystemInit(); // Keil提供,设置系统时钟为72MHz USART1_Init(115200); // 初始化串口 USART_EnableInterrupt(); // 启用接收中断 USART_SendString(USART1, "Serial Communication Ready!\r\n"); while (1) { // 主循环可执行其他任务 // 如LED闪烁、ADC采样、PWM输出等 } }编译、下载、打开串口助手(波特率115200,8-N-1),发送任意字符,你应该能看到相同字符被返回。
恭喜!你刚刚完成了第一个真正的嵌入式通信功能。
常见坑点与调试秘籍
❌ 问题1:串口没反应?什么也收不到!
检查以下几点:
-TX/RX是否交叉连接?MCU-TX → PC-RX,不能直连;
-电平是否匹配?STM32是3.3V TTL,若接RS232需加MAX3232转换;
-时钟开了吗?忘记开GPIO或USART时钟是最常见错误;
-复用功能选错了吗?查手册确认AF编号;
-波特率偏差太大?使用标准值如9600、115200,避免自定义频率。
❌ 问题2:收到乱码?
多半是波特率不匹配。确保:
- PCLK计算准确;
- PLL倍频正确(SystemInit后应为72MHz);
- 串口助手设置与代码一致。
可用示波器抓TX波形,测量位宽验证。
❌ 问题3:中断进不去?
- 检查
NVIC_EnableIRQ()是否调用; - 是否忘记使能
USART_CR1_RXNEIE; - 中断函数名拼写错误(区分大小写);
- 优先级分组冲突(一般不用改,默认即可)。
总结与延伸思考
通过本次实践,我们不仅实现了串口回环功能,更重要的是建立了完整的知识链条:
- Keil5工程创建流程
- 启动文件与系统初始化机制
- GPIO复用配置方法
- USART工作原理与寄存器操作
- NVIC中断管理与ISR编写规范
这些技能构成了嵌入式开发的基石。下一步你可以尝试:
- 加入DMA实现零负载数据接收;
- 实现简单的AT指令解析;
- 使用串口打印浮点数或结构体数据;
- 移植到FreeRTOS中作为日志输出通道。
如果你正在学习嵌入式,不妨把这篇文章收藏下来。下次遇到串口问题时,试着不用HAL库,回归寄存器层面去排查。你会发现,那些曾经神秘的“黑盒”,其实都有迹可循。
毕竟,真正的工程师,不仅要会“用工具”,更要懂“背后发生了什么”。
如果你在实现过程中遇到了挑战,欢迎在评论区留言交流。我们一起把每一个bug变成成长的机会。