从 printf 到曲线:把数据送上 VOFA+

上一回让板子学会了收发文本:按一下键,电脑上出现 Hello world;发一句 How are you?,它回一句问候。那时发的是"句子"------一串给人读的字符,用 Uart_SendString() 一个字节一个字节送出去。

句子够用,但不够省事。想发三个数字,得把每个数字转成字符串、拼上逗号、再接上换行,写起来啰嗦,数字一变就要重写。而且更要紧的是:一句给人读的话,曲线区看不懂。

这一篇做两件事:先用 printf() 把"往外发数据"变得省事,再让同一批数据在 VOFA+ 里长成曲线。例子就用前面调 PID 时那四个量------当初只能盯着 OLED 上四行数字来回跳,现在能把它们看成四条线。

让 printf() 从板子里说话

printf() 是标准库里的格式化输出函数,你在电脑上写 C 就用过它。它在单片机里也能用,只是默认不知道"往哪里写"------它把字符交给一个底层函数 fputc(),由这个函数决定送到哪里去。

那么接线就很简单了:我们把这个底层函数换成"通过串口发一个字节" ,printf() 的整条输出路径就自动接到了 USART1 上。

c 复制代码
#include <stdio.h>

int fputc(int ch, FILE *f)
{
    (void)f;
    Uart_SendByte((uint8_t)ch);
    return ch;
}

Uart_SendByte() 是上一回那个轮询发送函数,没变。fputc() 每次被 printf() 叫一次,就送出一个字符;printf() 内部要把数字变成字符、要补空格、要处理格式,这些都不归我们管。

现在可以这样发:

c 复制代码
printf("left=%d, right=%d\r\n", left_actual, right_actual);

%d 会被替换成变量的值,屏幕上出现 left=173, right=486。往后再加变量,只是往格式串里多写一个 %d,不用手写转换。

给人看的日志,画不出曲线

上面那行字,人看着舒服:有名字、有等号、有逗号。可是把它丢给 VOFA+ 的曲线区,一条线都不会出来。

原因在前面认 VOFA+ 界面的时候提过一句:给人看的日志不是给曲线看的格式 。VOFA+ 画图时要按固定规则把一行字节拆成"第几个通道的值",它不认识 left=,也不需要等号------它只想要数字本身。

所以两条路要分开走:

  • 想让人读明白,就用 left=173, right=486 这种带名字的日志;
  • 想让软件画曲线,就只发数字,按协议规定的样子排好。

同一份数据,两种发法各有各的用处。下面先说最常见的那个协议。

FireWater:一行数字就是一组曲线

FireWater 的规则短到一句话就能说完:一行里,用逗号隔开的若干个数字,行尾以 \r\n 结束。

text 复制代码
173,486\r\n

这一行会被解释成两条曲线:第一个数字是通道 0 的值,第二个是通道 1 的值。再多几个数字,就是再多几个通道。发的时候直接用 printf():

c 复制代码
printf("%d,%d\r\n", left_actual, right_actual);

配合上一回的 PID 工程,把速度环那四个量一起发出去,正好四通道:

c 复制代码
printf("%d,%d,%d,%d\r\n",
       left_target, left_actual, right_target, right_actual);

在 VOFA+ 里做的事只有两件:数据源选串口、协议选 FireWater,然后连接。曲线区立刻出现四条线:两条是台阶状的目标,两条是实际值追上去的过程------当初 OLED 上只能看四个数字在跳,现在它们之间的关系变成了形状。

顺带一句:既然发的是文本,终端窗口里也能同时看见这些数字,检查"到底发出来没有"很方便。

不靠浮点,也能发小数

PID 里的量经常是小数:误差可能是 -1.67,增益是 1.20。想把它发出去,我一般先想到 %f------

c 复制代码
printf("%.2f\r\n", error);

这一行在单片机里未必能按你想的干活,原因下面单独讲。先记一个更稳当的办法:把小数放大成整数再发。

误差乘 100,-1.67 就变成 -167;接收那头把它当"百分之一"来读就行。这和前面循迹篇里"误差×100 再显示"是同一招:

c 复制代码
printf("%d,%d\r\n", (int32_t)(error * 100.0f), line_mask);

这样做有三个好处:不依赖浮点格式化(省一段不小的代码)、发送的字节更少、曲线区照样能用;约定好那条线上的 -167 代表 -1.67 就行。

这一篇后面所有跨串口的数字,都按这个思路走:能用整数就别用浮点。 我不是怕麻烦,主要是想绕开一个具体的坑。

那个坑:微库和浮点格式化

printf("%.2f", x) 打不出来的时候,我第一反应也是自己写错了,查到最后才发现问题在库。

AC5(也就是 Armcc)的 stdio.h 里,printf 一类的函数有两套声明:

  • 完整版 printf、sprintf、scanf:支持浮点格式;
  • 带下划线的裁剪版 _printf、_sprintf、_scanf:头文件里的注释写得很直白------"等价于 printf,但不支持浮点格式,可用于减小代码体积"。

而 microlib(微库)本身就是一个"体积优先"的库 :官方对它的一句话说明是"为极小的代码体积做了高度优化",同时明确写着它不符合 ISO C 库标准。所以在微库这一档,链接进去的就是那套不支持浮点格式的裁剪版------勾着 Use MicroLIB 时,%f 打不出正常结果。

开关在 Options for Target → Target 页的 Use MicroLIB 上,两个选择各有代价:

选择 浮点格式化 程序体积
勾上 Use MicroLIB 不能用 小
取消勾选(用标准库) 能用 明显变大

真要用 %f,就取消勾选、重新编译;不想动这个开关,就用上一节的定点缩放,%d 在任何配置下都好使。

顺便把话说全:换到 AC6(armclang)以后,这条限制不一样了 。ARM 官方文档里写着,AC6 的微库支持 printf(),不支持的转换只有 %lc、%ls、%a 这三个(宽字符和十六进制浮点),%f 是支持的。不同工具链、不同版本的边界并不相同,遇到浮点打印异常时,先看你手上这套的文档,别急着改代码。

还有一条容易忽略的:微库的浮点运算本身也和标准有偏差------官方列出的是"涉及 NaN、无穷、非规格化数的运算结果不确定""极小但非零的结果会返回 0""IEEE 异常无法被标记""零的符号不确定""只支持默认舍入模式"。日常调参用的都是规规矩矩的普通数值,碰不到这些边角;但如果你在算的东西恰好贴着这些情况,心里要有数。

JustFloat:数据多、要求快的时候

FireWater 是文本,一个数字要占好几个字节:173 是 3 个字符,-1.67 乘 100 后是 4 个。通道多、发得快的时候,这些字符会堆满串口。

JustFloat 走的是另一条路:每个通道的值直接按 4 字节的浮点数发出去 ,一帧的末尾附一个固定的帧尾,告诉软件"这帧到此为止"。帧尾的四个字节是 00 00 80 7F------它是浮点数里"正无穷"在内存里的样子;正常测量值不可能等于正无穷,所以拿它当分隔符不会撞车。(具体格式以 VOFA+ 文档为准。)

c 复制代码
static void JustFloat_Send(const float *values, uint8_t count)
{
    static const uint8_t tail[4] = { 0x00U, 0x00U, 0x80U, 0x7FU };
    uint8_t i;
    uint8_t j;

    for (i = 0U; i < count; i++)
    {
        const uint8_t *p = (const uint8_t *)&values[i];

        for (j = 0U; j < 4U; j++)
        {
            Uart_SendByte(p[j]);
        }
    }

    for (j = 0U; j < 4U; j++)
    {
        Uart_SendByte(tail[j]);
    }
}

(const uint8_t *)&values[i] 这一句是把浮点数"按内存里的样子"当成 4 个字节来看。STM32 是小端机器,浮点数在内存里就是低位字节在前,正好是 VOFA+ 要的顺序。

用它的时候,VOFA+ 的协议下拉框要选 JustFloat。什么时候值得换过去?通道多、发送频繁、或者不希望精度被"乘 100"这种缩放影响的时候。 数据少、只是看个趋势,FireWater 更好用------毕竟它发出来的东西你自己也能读。

让 VOFA+ 反过来改参数

曲线是"看",接下来是"改"。调 PID 最烦的动作是:改一个数,重新编译,重新下载,再跑一遍。几十个来回下来,时间都花在等下载上。

VOFA+ 的控件区可以放滑动条,拖动它的时候,软件会把一条你设定好的文本命令发回串口。板子那边收到的还是上一回那套"一行命令",只是命令里多了个数值:

text 复制代码
P 120
I 12
D 30

按前面定下的规矩,这些数字是放大了 100 倍的整数 :P 120 表示 Kp 取 1.20。板子上的解析也就用整数运算完成,完全不需要浮点格式化:

c 复制代码
static void HandleTuneCommand(const char *cmd)
{
    char     name = cmd[0];
    uint8_t  i = 1U;
    int32_t  value = 0;

    while ((cmd[i] == ' ') || (cmd[i] == '\t'))
    {
        i++;
    }

    while ((cmd[i] >= '0') && (cmd[i] <= '9'))
    {
        value = value * 10 + (int32_t)(cmd[i] - '0');
        i++;
    }

    if (name == 'P')
    {
        left_speed_pid.kp = (float)value / 100.0f;
        right_speed_pid.kp = (float)value / 100.0f;
    }
    else if (name == 'I')
    {
        left_speed_pid.ki = (float)value / 100.0f;
        right_speed_pid.ki = (float)value / 100.0f;
    }
    else if (name == 'D')
    {
        left_speed_pid.kd = (float)value / 100.0f;
        right_speed_pid.kd = (float)value / 100.0f;
    }
    else
    {
        printf("?\r\n");
        return;
    }

    printf("OK %c %d\r\n", name, value);
}

left_speed_pid、right_speed_pid 就是前面速度环那两套 PID,成员名以你自己结构体里的写法为准;左右两个一起改,别只改一边------不然两个轮子会用不同的增益跑,车会开始画弧。

cmd[0] 取第一个字符决定改哪个参数,后面的数字一位一位累加成整数,再除以 100 还原成小数。板子收到之后回一句 OK P 120,电脑上就能确认这次改动生效了。

到了这一步,调参的循环变成:滑动条拖一下 → 板子立刻改参数 → 曲线当场变化。 还是那套"改一个数、看一次变化"的思路,只是中间省掉了编译和下载。

一条链子,两头都要对齐

把这一篇的东西串起来看,数据从变量走到曲线,中间要过四道关:

text 复制代码
变量 -> printf 格式化 -> fputc 重定向 -> Uart_SendByte -> USART1 -> CH340 -> VOFA+

任何一道关出错,现象都不太一样,正好可以分开排查:

  • 终端窗口里什么都看不到 :先确认 fputc() 是不是真的被链接进去了(函数名、返回类型、#include <stdio.h>),再确认串口参数和接线------那是上一回查过的东西。
  • 终端里能看到数字,曲线区却是空的:协议选错了,或者发的是带名字的日志而不是纯数字。回去看那一行到底长什么样。
  • 浮点数字打出来是奇怪的东西:微库那条边界,见前面那一节。
  • 曲线一卡一卡 :发送太频繁,主循环被发送占住了。这里解决不了它------Uart_SendByte() 里那段"等寄存器空"的循环还老老实实站在那里。眼下能做的只有把发送的节奏放慢;想让发送本身也不占人,是另一件要动手的事。

最后橘猫说

printf() 之所以能在单片机里说话,是因为它把"往哪写"交给了底层的 fputc();我们把这个函数改成串口发送,整条输出路径就接到了 USART1 上。但给人看的日志和给曲线看的格式是两回事 :FireWater 要的是一行用逗号隔开的纯数字、行尾 \r\n,173,486\r\n 就是两条曲线,left=173, right=486 则什么也画不出来。小数不一定要用 %f 发,乘 100 变成整数、在接收那头当百分之一读,既省字节又绕开了微库的限制------AC5 的 stdio.h 里,带下划线的裁剪版 printf 白纸黑字写着"不支持浮点格式",而微库正是走体积优先的那一档;要 %f 就取消勾选 Use MicroLIB,代价是体积变大(换到 AC6 以后这条限制不一样,以手上的文档为准)。数据更多更快的时候再考虑 JustFloat:每通道 4 字节浮点,帧尾 00 00 80 7F。最后,滑动条能把命令发回板子,P 120 这样的整数命令让参数在线就能改------一条链路打通以后,看和调才算真正连成了一件事。

最后蜘蛛说

不是,我搞半天,你说板子不能远程蓝牙?

相关推荐
意法半导体STM326 小时前
【官方原创】LAT6048 如何扩大MP2的A35核和M33核共享内存
stm32·单片机·嵌入式硬件·内存映射·stm32mp2
en.en..6 小时前
STM32(F429)启动模式与两种烧录方式
stm32·单片机·嵌入式硬件
单片机仿真设计7 小时前
基于 51 单片机的数字温度计(DS18B20+LCD1602+Proteus 仿真,支持阈值可调声光报警)
单片机·嵌入式硬件·proteus·课设
芝士爱沙拉7 小时前
原生内置agent智能开发能力的MCU IDE-MounRiver Studio
ide·单片机·嵌入式硬件
路上有只喵7 小时前
认识元器件 · 单片机(MCU/MPU/SOC) · 实战篇4
单片机·嵌入式硬件
小向是个Der7 小时前
嵌入式进阶——编译与链接
汇编·单片机·学习·编译原理·交叉编译
【 STM32开发 】8 小时前
【CubeMX+STM32】SD卡 文件系统读写 FatFs+SDIO+DMA (F103版本)
stm32·单片机·嵌入式硬件·1024程序员节
动词ing9 小时前
【51单片机】独立按键控制LED灯亮灭
单片机·嵌入式硬件·51单片机
二十七日秋9 小时前
算法刷题笔记「Day 1|四道 OJ 入门题里的深坑」
c语言·汇编·数据结构·c++·笔记·python·算法