STM32F103与STM32G030串口printf重定向实战
1. 为什么需要printf重定向
在嵌入式开发中,调试是一个绕不开的话题。想象一下,你正在开发一个基于STM32的项目,代码运行在芯片内部,无法像在电脑上那样直接看到打印信息。这时候,如果能够通过串口将调试信息发送到电脑上显示,那该多方便啊!
printf函数就是我们实现这个目标的神器。在桌面编程中,printf默认输出到标准输出(通常是屏幕),但在嵌入式环境中,我们需要让它"改道"到串口。这就是所谓的printf重定向——通过重写fputc函数,让每个字符都通过串口发送出去。
我刚开始接触STM32的时候,最头疼的就是调试问题。点个灯还能用GPIO状态来判断,但遇到复杂的数据处理或者算法逻辑,没有打印信息简直就像盲人摸象。后来学会了printf重定向,调试效率直接翻倍,再也不用一遍遍地烧录程序看现象了。
2. STM32F103的printf重定向实战
2.1 环境准备与基础配置
先来看看STM32F103这款经典的Cortex-M3芯片。我在很多项目中都用过F103,它的外设丰富,性价比高,是入门STM32的首选。
首先需要配置串口。以USART1为例,通常我们会这样初始化:
void USART1_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
USART_InitTypeDef USART_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);
// TX引脚配置
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// RX引脚配置
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
USART_InitStructure.USART_BaudRate = 115200;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_Init(USART1, &USART_InitStructure);
USART_Cmd(USART1, ENABLE);
}
这段代码配置了USART1的工作参数,包括波特率、数据位、停止位等。注意GPIO的模式配置,TX引脚要设置为复用推挽输出,RX引脚为浮空输入。
2.2 重定义fputc函数
接下来是重头戏——重写fputc函数。这个函数是标准库中用于字符输出的底层函数,我们重写它来实现串口输出:
#if 1
#pragma import(__use_no_semihosting)
// 标准库需要的支持函数
struct __FILE {
int handle;
};
FILE __stdout;
// 定义_sys_exit()以避免使用半主机模式
void _sys_exit(int x)
{
x = x;
}
// 重定义fputc函数
int fputc(int ch, FILE *f)
{
while((USART1->SR & 0X40) == 0); // 等待发送缓冲区空
USART1->DR = (uint8_t)ch; // 写入数据寄存器
return ch;
}
#endif
这里有几个关键点需要注意。第一行的#pragma import(__use_no_semihosting)告诉编译器不要使用半主机模式,这是一种调试模式,需要仿真器支持,我们在实际硬件中不需要。
等待发送完成的循环条件(USART1->SR & 0X40) == 0是在检查USART状态寄存器的TXE位(发送数据寄存器空)。当该位为1时,表示可以写入新的数据。
我在实际项目中遇到过一个问题:有时候printf会卡死在这个循环里。后来发现是因为串口时钟没有使能,或者引脚配置错误。所以一定要确保前面的初始化代码正确执行。
2.3 常见问题与调试技巧
新手在做printf重定向时经常会遇到各种问题。我总结了几种常见情况:
第一种是根本看不到任何输出。这时候要先检查硬件连接,TX、RX线是否接反,地线是否共地。然后用示波器或者逻辑分析仪看看TX引脚有没有波形输出。如果没有,可能是初始化代码有问题。
第二种是输出乱码。这通常是波特率不匹配造成的。确保代码中的波特率设置和串口调试工具的设置一致。STM32F103的USART使用APB2时钟,一般是72MHz,计算波特率时要注意分频系数的计算。
第三种是程序运行异常。这可能是因为没有正确处理半主机模式。如果你看到程序莫名其妙地跳转到HardFault,很可能是这个原因。
3. STM32G030的printf重定向实战
3.1 MicroLib的配置与使用
STM32G030是Cortex-M0+内核的芯片,相比F103更加省电,价格也更低。但在printf重定向上有个重要区别:必须使用MicroLib。
MicroLib是ARM专门为嵌入式系统优化的C库,比标准库小很多,适合资源受限的环境。在Keil MDK中,需要手动开启这个选项:
- 打开Options for Target对话框
- 选择Target选项卡
- 在Code Generation区域勾选"Use MicroLIB"
这个步骤很关键,我刚开始用G030的时候就忘了勾选,结果printf怎么都不工作,调试了好久才发现问题。
3.2 重定向代码实现
G030的重定向代码与F103有些不同,主要是寄存器名称和位定义发生了变化:
#include <stdio.h>
int fputc(int ch, FILE *f)
{
while(!(USART1->ISR & (1 << 7))); // 等待TXE位置1
USART1->TDR = ch; // 写入发送数据寄存器
return ch;
}
int fgetc(FILE *f)
{
uint8_t ch;
while(!(USART1->ISR & (1 << 5))); // 等待RXNE位置1
ch = USART1->RDR; // 读取接收数据寄存器
return ch;
}
注意这里使用了ISR(中断状态寄存器)而不是SR,TDR(发送数据寄存器)而不是DR。位定义也变成了移位操作,这是STM32G0系列的新特点。
我还实现了fgetc函数,用于从串口接收数据。这样不仅可以printf输出,还能scanf输入,实现双向通信。
3.3 启动文件修改注意事项
有时候即使代码写对了,编译还是会报错。常见的是关于semihosting的错误。这时候需要修改启动文件:
找到startup_stm32g030xx.s文件,注释掉以下代码:
; IMPORT __use_no_semihosting_swi
; IMPORT __use_no_semihosting
改成:
; IMPORT __use_no_semihosting_swi
; IMPORT __use_no_semihosting
其实就是去掉前面的分号。这个问题困扰过我很久,因为错误信息不太明确,很难直接想到是启动文件的问题。
4. 两款芯片的对比与移植技巧
4.1 硬件差异对比
STM32F103和G030在串口硬件上有不少差异,了解这些差异有助于更好地进行移植:
| 特性 | STM32F103 | STM32G030 |
|---|---|---|
| 内核 | Cortex-M3 | Cortex-M0+ |
| 最大主频 | 72MHz | 64MHz |
| 寄存器命名 | SR/DR | ISR/TDR/RDR |
| 状态位 | TXE(位7)/TC(位6) | TXE(位7)/TC(位6) |
| 是否需要MicroLib | 可选 | 必须 |
| 半主机处理 | 需要 | 需要 |
从表中可以看出,虽然基本功能相同,但寄存器名称和细节实现有差异。G030将数据寄存器分成了TDR(发送)和RDR(接收),而F103只有一个DR寄存器。
4.2 代码移植实战
如果你有一个在F103上正常工作的printf重定向,想要移植到G030上,可以按照以下步骤:
首先修改寄存器访问部分,将USART1->SR改为USART1->ISR,USART1->DR改为USART1->TDR。
然后检查位操作。F103中常用的是位与操作,如USART1->SR & 0X40;而G030中更常用移位操作,如USART1->ISR & (1 << 7)。
最后确保在Keil中勾选了Use MicroLib选项。这个选项在Project -> Options for Target -> Target中。
我在最近的一个项目中就完成了这样的移植。原本担心会很麻烦,实际上只花了不到半小时就调通了。关键是要细心,一步一步检查每个修改点。
4.3 性能优化建议
printf函数虽然方便,但在嵌入式系统中要谨慎使用,因为它会占用不少资源和时间。这里分享几个优化建议:
第一,减少调试输出的频率。可以在调试时使用宏控制输出量:
#define DEBUG 1
#if DEBUG
#define DEBUG_PRINTF(...) printf(__VA_ARGS__)
#else
#define DEBUG_PRINTF(...)
#endif
这样在正式发布时,将DEBUG设为0就可以完全去掉调试输出,不影响性能。
第二,使用简单的字符串输出。有时候不需要完整的printf功能,只需要输出字符串:
void USART_SendString(USART_TypeDef* USARTx, const char *str)
{
while(*str)
{
while(!(USARTx->ISR & USART_ISR_TXE));
USARTx->TDR = *str++;
}
}
这样避免了printf的格式化处理,大大提高了效率。
第三,考虑使用DMA传输。对于大量数据输出,可以使用DMA来减轻CPU负担:
void USART_SendDMA(USART_TypeDef* USARTx, const uint8_t *data, uint16_t len)
{
// 配置DMA源地址、目标地址、数据长度
// 启动DMA传输
// 等待传输完成
}
5. 实际项目中的应用案例
5.1 数据监控系统
我在一个工业数据监控项目中使用了printf重定向。系统需要实时采集多种传感器数据,并通过串口发送到上位机显示。
使用printf重定向后,调试变得非常简单。我可以随时输出各种变量的值,查看系统状态:
printf("温度: %.2f°C, 湿度: %.1f%%\r\n", temperature, humidity);
printf("设备状态: %s\r\n", status_string);
printf("错误代码: 0x%04X\r\n", error_code);
这种调试方式比点灯高级多了,可以输出浮点数、字符串、十六进制数等各种格式的数据。
5.2 通信协议调试
在另一个通信项目中,我需要调试一个自定义的通信协议。使用printf输出通信过程的详细信息:
printf("[RX] 接收数据: ");
for(int i = 0; i < len; i++)
{
printf("%02X ", data[i]);
}
printf("\r\n");
这样可以清楚地看到收发数据的每个字节,很容易发现协议解析中的问题。
5.3 性能测试与优化
printf还可以用于性能测试。比如测试某个函数的执行时间:
uint32_t start_time = GetTickCount();
// 执行需要测试的函数
function_to_test();
uint32_t end_time = GetTickCount();
printf("函数执行时间: %lu ms\r\n", end_time - start_time);
这种方法简单但有效,可以帮助找到性能瓶颈。
记得有一次我发现系统偶尔会卡顿,通过在不同位置添加时间戳输出,最终定位到一个低效的算法。优化后系统流畅多了。
printf重定向虽然是个小技巧,但在实际项目中能发挥大作用。它让嵌入式调试变得更加直观和高效,是现代嵌入式开发中不可或缺的工具。
更多推荐
所有评论(0)