你兴致勃勃地在STM32上写了人生第一行printf(”Hello World“),下载程序,打开串口助手——然后,什么都没有。
你检查了波特率、接线、串口配置,全都正确。程序在跑,但串口很安静。
为什么?
因为在电脑上,printf默认把数据送到控制台;但在单片机上,根本没有这个控制台。printf把字符串拼好了,却不知道该往哪儿送。就像是对着空气喊话,没人听见,更没人回应。
在单片机上,没有什么是自动帮你安排好的。

#一、为什么单片机不能用 printf?
简而言之,标准 C 库的 printf 是写给**有操作系统**的电脑看的,它不知道在裸机上该把数据往哪儿送。
要让它正常工作,需要拆解成两个原因:
##1.缺少“输出设备” 
在 PC 上,printf 的默认输出是 stdout(标准输出)。操作系统会将这个输出通道自动连接到控制台上。而裸机上只有你写的驱动和应用程序,没有什么操作系统。
##2. printf 不认识单片机的外设
printf 函数内部最终会调用一个叫 fputc 的底层函数,它负责发送单个字符
但它根本不知道 STM32 的 USART(串口)寄存器怎么配置,不知道如何操作 HAL_UART_Transmit。
所以,这里便引出我们要讨论的解决办法,也就是常说的“重定向”。

# 二、重定向
我们要做的,就是使用一个通道,当printf把打包好的数据送过来时,准确地把它们递到UART上。
## 通过newlib 重写 -weak

int _write(int file, char *ptr, int len) {
    HAL_UART_Transmit(&huart1, (uint8_t*)ptr, len, HAL_MAX_DELAY);
    return len;
}

## 对int fputc(int ch, FILE *f)函数改写

include ”stdio.h“
int fputc(int ch, FILE *f)
{
    HAL_UART_Transmit(&huart1, (uint8_t*)&ch, 1, 100);
    return ch;
}

###从前要发送一段文字,需要自己处理格式

char buffer[50];
sprintf(buffer, ”温度:%d 湿度:%d\n“, temp, hum);
HAL_UART_Transmit(&huart1, (uint8_t*)buffer, strlen(buffer), 1000);

###重定向后——

printf(”温度:%d 湿度:%d\n“, temp, hum);
printf(”字符:%c 字符串:%s 十六进制:%x\n“, ’A‘, ”hello“, 0x1A);


现在就已经基本实现日常调试的需求。但这样重定向的printf()只能实现定向到一个串口,字节阻塞式的输出,比如无法充分利用DMA。所以,我们必须自己重写或者实现类似printf()函数才可以。
 

#三、继续走进printf
我们深入printf的原理,看看它是怎么把“csdn %d”和数字“123”拼成“csdn 123”的。当我们发现va_list处理可变参数和vsnprintf进行格式化的秘密,就能自己写一个my_printf函数,它在内存里把字符串拼好,然后一次性交给DMA去发送,效率大大提高。

##1.printf(”buffer[%d],[%x]“, d, x) 是怎么把 d 和 x 的值填进 %d 和 %x 位置的?

在内存里,d 和 x 的值就紧跟在格式字符串的地址后面,**参数在内存中是连续存储的**。所以函数内部只要拿到第一个参数的地址,就能通过偏移找到后面的参数。

所以我们现在要取这些参数,通过以下工具

va_list args;          // 1. 定义指针
va_start(args, count); // 2. 指向第一个可变参数
total += va_arg(args, int); // 3. 取出一个int,指针自动后移
va_end(args);          // 4. 收尾

##2.格式化字符串

vsnprintf 就是把参数填进 %d 和 %x 的函数,可以格式化字符串。是连接“格式化字符串+可变参数”与“目标字符串缓冲区”的桥梁

· 输入:格式字符串 ”buffer[%d],[%x]“ + 参数列表 args(指向 d 和 x)
· 输出:格式化后的字符串 ”buffer[100],[0x200]“ 放到 sprint_buf 里

int vsnprintf (char * sbuf, size_t n, const char * format, va_list arg );
参数sbuf:用于保存格式化字符串结果的字符数组
参数n:限定最多打印到缓冲区sbuf的字符的个数为n-1个,因为vsnprintf还要在结果的末尾追加\0。如果格式化字符串长度大于n-1,则多出的部分被丢弃。如果格式化字符串长度小于等于n-1,则可以格式化的字符串完整打印到缓冲区sbuf。一般这里传递的值就是sbuf缓冲区的长度。
参数format:需要格式化的字符数组
参数arg:可变长度参数列表

n = vsnprintf(char * sbuf, size_t n, const char * format, va_list arg);

##3. 总结

整个过程就三步:

1 拿到可变参数的指针 va_list + va_start
2 把参数填进格式字符串 vsnprintf
3 收尾 va_end

int printf(const char *fmt, ...)
{
    va_list args;
    int n;
    va_start(args, fmt);              // 1. 指向可变参数
    n = vsnprintf(sprint_buf, sizeof(sprint_buf), fmt, args); // 2. 格式化
    va_end(args);                      // 3. 收尾
    return n;
}

#四、myprintf

基于上述原理,我们可以自己写一个函数,在格式化完成后,调用HAL_UART_Transmit_DMA将整个缓冲区一次性发出,从而提升效率。

#include <stdarg.h>
#include <stdio.h>

int my_printf(UART_HandleTypeDef *huart,const char *fmt, ...)
{
    int n = 0;
    char buf_str[200 + 1];        //保存格式化后字符串数组
    va_list args;                 //创建可变形参列表变量
    va_start(args, fmt);       //初始化可变形参列表变量,并将传入的若干个可变形参头指针赋给args
    n = vsnprintf(buf_str, sizeof(buf_str), fmt, args);//格式化字符串,并返回格式化的字符个数
    va_end(args);                 //释放可变形参列表变量
    //下面可以换成任何输出打印的函数
    HAL_UART_Transmit_DMA(huart, (uint8_t *)buf_str, n);
}


#五、结尾时刻

printf 的成就感,不在于它本身多复杂,而在于它证明了:你写的软件,真的能让硬件活过来。

当你重写fputc、打通 UART,然后在串口助手看到第一行 “Hello World” 跳出时——那一刻不是打印成功,而是你让一块冷冰冰的芯片,说出了你让它说的话。

在单片机上,没有什么是自动帮你安排好的,这也是它最迷人的地方。所以我构建,你回应。

Logo

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

更多推荐