自己实现专属打印函数printf
你兴致勃勃地在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” 跳出时——那一刻不是打印成功,而是你让一块冷冰冰的芯片,说出了你让它说的话。
在单片机上,没有什么是自动帮你安排好的,这也是它最迷人的地方。所以我构建,你回应。
更多推荐

所有评论(0)