正常串口尝试发送字符串
在开始 printf 重定向之前,我们先按照常规方式配置串口,并尝试直接发送字符串,验证串口基础功能是否正常。这里使用 STM32CubeMX 生成初始化代码,串口选择 USART1,波特率设置为 115200,数据位 8 位、无校验、1 位停止位。
在 main 函数中,我们直接调用 HAL_UART_Transmit 发送一段字符串,代码如下:
uint8_t msg[] = "Hello STM32\r\n"; HAL_UART_Transmit(&huart1, msg, strlen((char*)msg), 1000);编译下载后,打开串口助手,可以看到正常输出 Hello STM32。这说明串口底层驱动已经工作正常,接下来就可以在此基础上实现 printf 重定向。
printf重定向代码
要让 printf 输出到串口,核心思路是重写底层字符输出函数 fputc,把标准输出重定向到 USART1。这样 printf 内部每次输出一个字符时,都会调用我们自定义的 fputc,从而把数据发送到串口。
在 Keil 工程中,需要勾选 Use MicroLIB,否则 printf 会占用大量资源,甚至无法正常工作。同时要包含头文件 stdio.h 和 string.h,否则 strlen 和 printf 无法使用。
重定向的核心代码如下:
#include "stdio.h" #include "string.h" int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }完成上述配置后,就可以在代码中直接使用 printf 输出格式化字符串,例如:
printf("Temperature: %.2f C\r\n", 25.36);记得写头文件,不然strlen和printf无法使用
很多初学者在重定向后编译报错,提示 strlen 或 printf 未定义,原因就是缺少头文件。stdio.h 提供了 printf 和 FILE 类型的声明,string.h 提供了 strlen 的声明。缺少任何一个头文件,编译器都无法识别这些函数。
记得打开魔术棒勾选使用LIB
在 Keil 中,点击魔术棒(Options for Target),切换到 Target 选项卡,勾选 Use MicroLIB。MicroLIB 是面向嵌入式场景的精简 C 库,体积小、占用资源少,并且对 printf 浮点输出支持更友好。如果不勾选,默认使用标准 C 库,链接时可能报错或导致程序体积过大。
Printf尝试发送到串口
完成上述重定向配置后,我们在 main 函数中调用 printf 发送数据,验证是否成功输出到串口。示例代码如下:
printf("System Start OK\r\n"); printf("Counter = %d\r\n", counter); printf("Voltage = %.2f V\r\n", 3.31);编译下载后,打开串口助手,可以看到 printf 输出的内容正常显示在串口助手中。如果输出乱码,请检查串口助手的波特率是否与代码中配置的 115200 一致;如果完全没有输出,请检查 fputc 重定向是否生效,以及是否勾选了 Use MicroLIB。
USB转RTT原理图
阻塞接受
为了在电脑上实时监测代码进度,我们需要让 STM32 能够接收上位机发送的指令。这里采用阻塞模式接收,即程序会一直等待,直到收到数据后才继续往下执行。在开始之前,请确保已经完成了前面的 printf 重定向配置。
调用阻塞模式接收函数
阻塞接收的核心函数是 HAL_UART_Receive,它会一直阻塞等待,直到接收到指定长度的数据或超时。调用方式如下:
uint8_t rx_data = 0; HAL_UART_Receive(&huart1, &rx_data, 1, 0xFFFF);上面代码表示:从 USART1 接收 1 个字节数据存入 rx_data,超时时间设为 0xFFFF(约 26 秒)。在超时时间内如果没有收到数据,函数会返回 HAL_TIMEOUT;收到数据则返回 HAL_OK。
HAL_UART_Receive函数结构和传入参数类型以及所代表的含义(其实用法和发送函数HAL_UART_Transmit差不多)
HAL_UART_Receive 的函数原型如下:
HAL_StatusTypeDef HAL_UART_Receive( UART_HandleTypeDef *huart, // 串口句柄,指向 huart1 uint8_t *pData, // 接收缓冲区指针,存放收到的数据 uint16_t Size, // 要接收的字节数 uint32_t Timeout // 超时时间,单位毫秒 );四个参数的含义分别是:
- huart:串口句柄,传入 &huart1 表示使用 USART1。
- pData:指向接收缓冲区的指针,收到的数据会写入这里。
- Size:期望接收的字节数,例如接收 1 个字节就填 1。
- Timeout:超时时间(毫秒),超过该时间仍未收到数据则返回 HAL_TIMEOUT。
可以看到,它的参数结构和发送函数 HAL_UART_Transmit 非常相似,区别只是数据方向相反:Transmit 是发送缓冲区数据到串口,Receive 是从串口接收数据到缓冲区。
主函数代码
uint8_t rx_data = 0; while (1) { printf("等待接收指令...\r\n"); // 阻塞等待接收 1 个字节 if (HAL_UART_Receive(&huart1, &rx_data, 1, 0xFFFF) == HAL_OK) { printf("收到指令: 0x%02X\r\n", rx_data); // 收到数据后回传确认信息 HAL_UART_Transmit(&huart1, &rx_data, 1, 1000); } }代码结构解释
printf(之前已经写了重定向函数可以使用)——提示已经开始运行“阻塞接收”
Receive——阻塞接收,程序会一直等待直到收到数据
Transmit——确认信息,把收到的数据原样回传给上位机
整个流程是:程序先通过 printf 打印提示信息,然后进入阻塞接收状态等待上位机发送数据;一旦收到数据,就通过 printf 打印收到的内容,并用 Transmit 把数据回传确认,随后进入下一轮循环继续等待。
串口接收模式介绍
前面我们使用了阻塞模式接收串口数据,这种方式实现简单,但程序在等待数据时会一直卡住,无法处理其他任务。为了提升程序的实时性和响应能力,本节介绍更常用的中断接收模式。中断接收的核心思想是:CPU 平时可以专心执行主循环任务,一旦串口收到数据,硬件会自动触发中断,CPU 暂停当前任务去处理接收到的数据,处理完再回到原来的任务继续执行。
此章节会学到的HAL API
本节主要涉及以下 HAL 库函数和回调函数,建议先对照官方手册了解它们的声明位置:
- HAL_UART_Receive_IT:以中断方式接收指定长度的数据,函数立即返回,数据到达后由中断回调处理。
- HAL_UART_RxCpltCallback:接收完成回调函数,当一帧数据接收完成后由 HAL 库自动调用,用户在这里编写数据处理逻辑。
- __HAL_UART_ENABLE_IT:使能串口中断的宏,通常在初始化时调用。
下面结合图片逐步说明如何在 CubeMX 中配置串口中断,并编写对应的回调函数。
上图展示了串口中断接收的整体流程:配置中断、使能接收、等待数据到达、触发中断回调、在回调中处理数据并重新开启下一次接收。
CubeMX中配置打开串口中断
在 CubeMX 中,选中 USART1,在 NVIC Settings 选项卡中勾选 USART1 global interrupt,使能串口全局中断。这样当串口收到数据时,硬件就会触发中断请求,进入中断服务函数。
配置完成后重新生成代码,CubeMX 会自动在 stm32f1xx_it.c 中生成 USART1_IRQHandler 中断服务函数,并在其中调用 HAL_UART_IRQHandler 处理中断。
串口接收中断函数结构
中断服务函数的结构如下,它由硬件触发,负责把中断事件交给 HAL 库处理:
void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); }HAL_UART_IRQHandler 内部会根据中断标志位,自动调用对应的回调函数,例如接收完成时调用 HAL_UART_RxCpltCallback。
优先初始化
在使用中断接收之前,必须在 main 函数中调用 HAL_UART_Receive_IT 启动一次接收,否则即使中断已使能,也不会接收数据。建议在初始化阶段就调用,例如放在 while(1) 之前:
uint8_t rx_data = 0; HAL_UART_Receive_IT(&huart1, &rx_data, 1);注意:HAL_UART_Receive_IT 只接收一次指定长度的数据,接收完成后需要再次调用才能继续接收下一帧,因此通常会在回调函数末尾重新调用它。
找到对应的回调函数
HAL 库提供了多个回调函数,分别对应不同的中断事件。接收完成对应的是 HAL_UART_RxCpltCallback,我们需要在用户代码中重写这个函数。可以在 stm32f1xx_it.c 或单独的用户文件中实现,推荐在 main.c 中实现,方便管理。
上图展示了 HAL 库中回调函数的定义位置和调用关系,可以看到 HAL_UART_IRQHandler 在接收完成后会调用 HAL_UART_RxCpltCallback。
回调函数编写
在回调函数中编写数据处理逻辑,并在末尾重新开启下一次接收,形成连续接收的循环:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 处理收到的数据,例如回传 HAL_UART_Transmit(&huart1, &rx_data, 1, 1000); // 重新开启下一次中断接收 HAL_UART_Receive_IT(&huart1, &rx_data, 1); } }这样,主循环可以专心执行其他任务,串口数据到达时由中断自动处理,大大提高了程序的实时性。
串口控制LED灯
掌握了中断接收后,我们可以做一个综合小实验:通过串口发送指令控制开发板上的 LED 灯。例如发送字符 '1' 点亮 LED,发送字符 '0' 熄灭 LED。在回调函数中根据收到的数据控制 GPIO 即可:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { if (rx_data == '1') { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); // 点亮 } else if (rx_data == '0') { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); // 熄灭 } HAL_UART_Receive_IT(&huart1, &rx_data, 1); } }编译下载后,在串口助手中发送 1 或 0,即可看到 LED 灯随之点亮或熄灭。这个实验把中断接收、GPIO 控制和串口通信结合起来,是理解嵌入式交互的经典入门练习。
其他配置与上方相同,记得在MX上配置好LED的GPIO端口
串口接收不定长中断
前面我们使用 HAL_UART_Receive_IT 接收固定长度的数据,每次只能接收 1 个字节。但在实际项目中,上位机发送的指令长度往往是不固定的,例如发送 "LED_ON" 或 "LED_OFF" 这样的字符串。如果仍然按固定长度接收,就需要预先知道数据长度,使用起来很不方便。本节介绍如何通过中断方式接收不定长数据,并判断一帧数据何时结束。
不定长接收的核心思路
接收不定长数据,通常采用以下两种方式之一:
- 空闲中断(IDLE):当串口在一段时间内没有收到新数据时,硬件触发空闲中断,表示一帧数据接收完毕。这是最常用的方式,适合大多数场景。
- 固定结束符:约定一个特殊字符(如 \r\n 或 \n)作为一帧数据的结束标志,收到结束符就认为一帧数据接收完成。
下面以空闲中断方式为例,说明如何在 HAL 库中实现不定长接收。
函数和回调函数
实现不定长接收,主要涉及以下 HAL 库函数和回调函数:
- HAL_UARTEx_ReceiveToIdle_IT:以中断方式接收数据,直到收到指定长度数据或检测到空闲(IDLE)时停止。这是不定长接收的核心函数。
- HAL_UARTEx_RxEventCallback:接收事件回调函数,当数据接收完成或检测到空闲时由 HAL 库自动调用。用户在这个回调中处理收到的数据。
- __HAL_UART_ENABLE_IT:使能串口中断的宏,用于开启空闲中断等。
下面结合图片逐步说明如何在 CubeMX 中配置,并编写对应的回调函数。
上图展示了 HAL 库中与不定长接收相关的函数声明位置,可以看到 HAL_UARTEx_ReceiveToIdle_IT 和 HAL_UARTEx_RxEventCallback 都定义在 stm32f1xx_hal_uart.h 中。
上图展示了 HAL_UARTEx_ReceiveToIdle_IT 函数的原型和参数说明,它的参数与 HAL_UART_Receive_IT 类似,区别在于它会在检测到空闲时自动停止接收。
上图展示了 HAL_UARTEx_RxEventCallback 回调函数的定义位置,这个回调函数需要我们在用户代码中自行实现。
回调函数位置
与 HAL_UART_RxCpltCallback 类似,HAL_UARTEx_RxEventCallback 也需要在用户代码中重写。推荐在 main.c 中实现,方便统一管理。下面两张图分别展示了回调函数的声明位置和调用关系:
可以看到,HAL_UARTEx_RxEventCallback 在 HAL_UART_IRQHandler 内部被调用,当检测到空闲中断或接收长度达到设定值时触发。
不定长接收代码示例
下面给出一个完整的不定长接收示例。首先在 main 函数中启动接收:
uint8_t rx_buffer[64] = {0}; uint16_t rx_len = 0; // 启动不定长接收,最多接收 64 字节,检测到空闲时停止 HAL_UARTEx_ReceiveToIdle_IT(&huart1, rx_buffer, 64);然后在回调函数中处理收到的数据,并重新开启下一次接收:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { rx_len = Size; // 实际接收到的字节数 // 回传收到的数据,确认接收成功 HAL_UART_Transmit(&huart1, rx_buffer, rx_len, 1000); // 清空缓冲区,重新开启下一次不定长接收 memset(rx_buffer, 0, sizeof(rx_buffer)); HAL_UARTEx_ReceiveToIdle_IT(&huart1, rx_buffer, 64); } }代码说明:
- HAL_UARTEx_ReceiveToIdle_IT:启动不定长接收,第二个参数是接收缓冲区,第三个参数是最大接收长度。当收到数据后出现空闲,或接收长度达到最大值时,接收停止。
- Size 参数:回调函数的第二个参数,表示本次实际接收到的字节数,通过它我们可以知道一帧数据的长度。
- 重新开启接收:与固定长度接收一样,接收完成后必须再次调用 HAL_UARTEx_ReceiveToIdle_IT,才能继续接收下一帧数据。
注意事项
在使用不定长接收时,有几点需要特别注意:
- 空闲中断的触发条件:空闲中断是在串口接收线上出现一段空闲时间(通常是一个字节的传输时间)后触发。如果上位机连续发送数据没有停顿,空闲中断不会触发,此时需要依靠最大长度限制来结束接收。
- 缓冲区大小:接收缓冲区要足够大,避免数据溢出。如果一帧数据可能超过缓冲区大小,需要适当增大缓冲区,或采用环形缓冲区的方式。
- 回调函数中不要做耗时操作:回调函数运行在中断上下文中,应尽量只做数据拷贝和标志位设置,耗时操作(如数据处理、打印等)应放到主循环中执行。
- 不同芯片的差异:部分 STM32 系列(如 F4、H7)对 HAL_UARTEx_ReceiveToIdle_IT 的支持更完善,F1 系列也支持该函数,但需要确认所用 HAL 库版本是否包含此接口。
小结
本节介绍了串口不定长中断接收的实现方法,核心是使用 HAL_UARTEx_ReceiveToIdle_IT 配合 HAL_UARTEx_RxEventCallback 回调函数。相比固定长度接收,不定长接收更符合实际项目需求,可以灵活处理各种长度的指令。建议读者结合前面的 LED 控制实验,尝试用不定长接收实现 "LED_ON" / "LED_OFF" 字符串指令控制,加深理解。
多串口应用
在实际项目中,一块开发板往往不止一个串口,例如 USART1 用于调试打印,USART2 用于与传感器或蓝牙模块通信。本节介绍如何在一个工程中同时使用多个串口,并解决 printf 重定向与多串口之间的冲突问题。
上图展示了多串口应用的整体接线与配置思路:不同外设分别挂接在不同的串口上,通过各自的收发引脚完成数据交互。
注意:一个重定向只能对应一个串口,所以现在封装一个字符打印的函数
printf 重定向的核心是重写 fputc,而 fputc 内部固定调用某一个串口(例如 huart1)。这意味着默认情况下,printf 只能输出到 USART1,无法直接输出到其他串口。如果想让不同串口都能打印,就需要封装一个通用的字符打印函数,把串口句柄作为参数传入。
下面给出一个封装示例,通过一个自定义函数实现向指定串口发送字符串:
// 向指定串口发送字符串 void UART_SendString(UART_HandleTypeDef *huart, const char *str) { HAL_UART_Transmit(huart, (uint8_t *)str, strlen(str), 1000); } // 使用示例:向 USART1 打印 UART_SendString(&huart1, "Hello USART1\r\n"); // 使用示例:向 USART2 打印 UART_SendString(&huart2, "Hello USART2\r\n");这样,无论哪个串口需要输出,都可以通过传入对应的句柄来实现,避免了 printf 只能绑定单一串口的限制。
上图展示了封装函数的实现细节,可以看到通过传入不同的串口句柄,即可灵活控制输出目标。
最终代码
完整的多串口示例代码如下,包含两个串口的初始化和收发逻辑:
// 初始化两个串口(由 CubeMX 生成) // huart1: 调试串口,波特率 115200 // huart2: 通信串口,波特率 9600 int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_USART2_UART_Init(); // 向两个串口分别打印启动信息 UART_SendString(&huart1, "USART1 Ready\r\n"); UART_SendString(&huart2, "USART2 Ready\r\n"); while (1) { // 主循环任务 } }实验现象
编译下载后,打开两个串口助手(分别对应 USART1 和 USART2 的 USB 转串口),可以看到两个串口都正常输出了各自的启动信息,说明多串口配置和封装函数均工作正常。
上图展示了两个串口同时输出的实验现象,验证了多串口应用的可行性。
串口节课大重点:DMA
前面介绍的串口收发方式,无论是阻塞模式还是中断模式,数据搬运都需要 CPU 参与。当数据量较大或通信频率较高时,CPU 会被频繁打断,影响系统整体性能。DMA(Direct Memory Access,直接存储器访问)可以在不占用 CPU 的情况下,在内存和外设之间直接搬运数据,是串口高效通信的关键技术。本节作为串口章节的重点,详细介绍 DMA 的原理、配置和使用方法。
上图展示了 DMA 在串口通信中的整体架构:CPU 只需发起传输请求,之后数据搬运由 DMA 控制器自动完成,传输完成后通过中断通知 CPU。
DMA传输方式与参数
DMA 传输涉及几个关键参数,理解它们对正确配置至关重要:
- 传输方向:内存到外设(发送)、外设到内存(接收)、内存到内存(数据搬移)。
- 数据宽度:字节(8 位)、半字(16 位)、字(32 位),需与外设寄存器宽度匹配。
- 传输模式:普通模式(传输一次后停止)和循环模式(自动循环传输,适合连续数据流)。
- 优先级:当多个 DMA 通道同时请求时,高优先级通道优先获得总线控制权。
上图展示了 DMA 传输方式与参数的配置界面,可以看到方向、模式、优先级等选项。
DMA简介
DMA 是一种无需 CPU 干预即可在外设与内存之间搬运数据的技术。以串口发送为例,传统方式下 CPU 需要逐字节把数据写入发送寄存器;使用 DMA 后,CPU 只需配置好源地址、目的地址和传输长度,DMA 控制器就会自动完成全部数据搬运,传输完成后通过中断通知 CPU。这样 CPU 可以腾出时间处理其他任务,大幅提升系统效率。
上图详细介绍了 DMA 的基本概念和工作原理,重点理解它如何替代 CPU 完成数据搬运。
特性
STM32 的 DMA 控制器具有以下主要特性:
- 支持内存到外设、外设到内存、内存到内存三种传输方向。
- 支持普通模式和循环模式,循环模式适合音频、ADC 采样等连续数据流场景。
- 每个通道支持独立配置优先级,可灵活调度多个 DMA 请求。
- 支持传输完成、半传输、传输错误等多种中断,便于程序及时响应。
上图列出了 DMA 控制器的详细特性,建议对照芯片参考手册逐条理解。
功能框图
DMA 控制器的功能框图展示了其内部结构,包括多个通道、仲裁器和外设请求映射关系。理解功能框图有助于掌握 DMA 的工作流程:外设发出请求后,仲裁器根据优先级选择通道,DMA 控制器按配置完成数据搬运。
上图是 DMA 控制器的功能框图,可以看到通道、仲裁器与外设请求之间的连接关系。
CubeMX初始化
在 CubeMX 中配置串口 DMA 的步骤如下:
- 在 USART1 的 DMA Settings 选项卡中,点击 Add 添加发送(USART1_TX)和接收(USART1_RX)两个 DMA 请求。
- 发送通道选择 Normal 模式,接收通道选择 Circular 模式(便于连续接收)。
- 数据宽度均设为 Byte,优先级按需设置。
- 重新生成代码,CubeMX 会自动生成 DMA 初始化函数 MX_DMA_Init 和串口 DMA 句柄。
上图展示了 CubeMX 中串口 DMA 的配置界面,注意发送和接收通道的模式选择。
函数
配置完成后,主要使用以下 HAL 库函数进行 DMA 收发:
- HAL_UART_Transmit_DMA:以 DMA 方式发送数据,函数立即返回,发送完成后触发回调。
- HAL_UART_Receive_DMA:以 DMA 方式接收数据,配合循环模式可实现连续接收。
- HAL_UART_TxCpltCallback:发送完成回调函数,在发送结束后由 HAL 库自动调用。
- HAL_UART_RxCpltCallback:接收完成回调函数,在接收结束后由 HAL 库自动调用。
下面给出一个使用 DMA 发送和接收的示例:
uint8_t tx_buffer[] = "DMA Test\r\n"; uint8_t rx_buffer[64] = {0}; // 以 DMA 方式发送数据 HAL_UART_Transmit_DMA(&huart1, tx_buffer, strlen((char*)tx_buffer)); // 以 DMA 方式接收数据(循环模式,可连续接收) HAL_UART_Receive_DMA(&huart1, rx_buffer, 64); // 发送完成回调 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 发送完成,可在此处理后续逻辑 } }上图展示了 DMA 相关 HAL 函数的声明位置和参数说明,建议对照官方手册深入学习。
上图展示了 DMA 收发回调函数的定义位置,用户需要在代码中自行实现这些回调。