miniFOC Driver Development

miniFOC 驱动板开发的记录, 涉及 STM32 配置, 所用器件, 以及相关通信协议。

研 0 的时候不知好歹自己做了一块驱动板, 但是没有任何经验也不知道怎么开发, 结果上电就烧。 直到最近看到了 B 站 Up 热心市民翔先生 发的 FOC 开发的系列视频感觉这个项目是有救了。 这也算是圆了自己 STM32 开发的愿望, 之前只学习做过一些 demo, 导师甚至把 MCU 称之为玩具(相对高大上的嵌入式 Linux 而言确实如此)。 但无论如何, 学习的过程中总是会有收获的, 我不认为他们口中简单的东西在没有接触之前都是简单的, 至少我需要这个开发经历去了解这套体系。

  • MCU: STM32F103C8T6, 64K Flash, 20K SRAM
  • Dev Tool: CMake, OpenOCD, STLink-v2, STM32CubeMX
  • Serial Communication: Vofa+
  • PCB Design: Altium Designer 23
  • Editor: VSCode
  • Debugger: Cortex-Debug
  • Third Party Library:
    • Float-point Calculation Optimization: Qfplib-M3
    • OLED Display: u8g2

项目地址 HangX-Ma/miniFOC, 项目将持续更新, 这次挺有信心的!

miniFOC design, HangX-Ma
miniFOC design, HangX-Ma

miniFOC driver board and other components, HangX-Ma
miniFOC driver board and other components, HangX-Ma

1. SC60228 磁编码器

SC60228 是一款非接触式高精度磁编码器芯片, 芯片中心内置了霍尔感应点矩阵, 可用以测量无刷电机的转子角度。 通过 PWM 或 SPI 的方式输出 12 bit 绝对式位置信息, 检测速率可达 20K rpm, 不难得知该款芯片能检测一周最多 4096 个位置。 相较读取 PWM 输出计算角度而言, 使用 SPI 协议读取数据的精度会更高, 代码编写也比较方便且具备通用性。

用这款芯片也是历史遗留问题了, 当时做了这个板子不忍心浪费, 网上有 Simple FOC 提供的 SC60228 SimpleFOC driver 可参考代码部分, 虽然看起来事情不多, 但理解数据手册进行配置和代码编写也对学习很有帮助。

1.1 时序图与 SPI 基本配置

Encoder SPI time diagram, HangX-Ma
Encoder SPI time diagram, HangX-Ma

这款芯片只需要配置 SPI 口在主机接收 (Receive Only Master) 数据即可, 不涉及双向数据传输。 我原来计划使用 DMA 传输 SPI 的数据, 但实际上 DMA 在小数据量以及高速读取的情况下并不合适, 这里的位置信息仅 16 位, 有些多此一举了。

可以看到上图数据手册提供的读取绝对式角度数据的时序表, 根据 SPI 协议 SCLK 空闲时低电平, 另外根据数据手册, 在 SCLK 上升沿之后需要保持 \(t_{DO}\) 时间以使得 MISO 数据有效, 那么只能在下降沿进行数据采样。 不难确定 SPI 协议的 CPOL(Clock Polarity)LowCPHA(Clock Phase)Second Edge

另外, 手册指出 \(t_{CLK}\)(SCLK 周期) 最小需要 100ns, 这里我查了一下 STM32CubeMX 配置中的 Baud Rate 的含义, 如果没理解错就相当于 SPI 中的 SCLK。 对于预设 72MHz 时钟频率的板子, 8 分频 9.0 MBits/s ≈ 111 ns, 另外 \(t_{DO}\)(SCLK 的上升沿到 MISO 数据有效之间的时间)_ 其值最大能达到 50 ns, 那么当前算是极限的速率配置了。 为了保险起见我选择设置 16 分频约 222 ns 周期, 防止数据出现丢失以及未定义的情况。

这里配置的是 SPI1 的, 我后面改成 SPI2 之后由于总线的时钟频率不一样, SPI2 只需要 8 分频。 改这个总线的原因还是因为 SPI2 的 DMA 通道给 USART1 给占了, 想玩玩 OLED 得用 SPI + DMA 的方式速率才能跟得上, 虽然最后可能会因为计算资源问题不上 OLED。

除此之外数据手册中的 \(t_{L}\), \(t_{H}\), \(t_{CS}\) 这几个关键量需要在编程时在特定位置延时。 LL 库仅定义了毫秒级的延时, 一个简单的办法实现 us 级的延时就是通过 __NOP 指令, 这样至少不会浪费定时器资源而且目前也不需要这么高精度的延时(64 个 __NOP 就足够了。 72MHz 主频一个 __NOP 为 1/72 us)。

Encoder SPI1 parameter settings, HangX-Ma
Encoder SPI1 parameter settings, HangX-Ma

后续测试若有问题则可将分频倍数调得更高一些, 400 ns 应该也是能接受的, 手册说明最大转速可测 20K rpm, 小电机能有 5K rpm 也不得了了。

另外 16-bits 的数据中, 除了 MSB 开始的 [D11..D0] 角度数据, 后续有用的还有 ERR, 能够识别磁铁安装位置是否合适, 在装机的时候非常有用。 PARC 用以进行奇偶校验, 该位需要和前 15 位数据保持奇数特性。

另外配置片选引脚的时候需要默认输出为高并且将其配置为上拉模式, 这是根据时序图所示, 芯片是在 CS 脚为低的时候工作的, 这样能保证在必要的时候选中相应的片选脚进行数据传输。

详解SPI中的极性CPOL和相位CPHA - blogernice 博客园
秒懂 奇偶校验码 - 车卡门 知乎
奇偶校验原理及C实现 - ftswsfb CSND
硬件探索——STM32F4通过SPI总线读取GMR(磁编码器) - 123-wqy CSDN
STM32——DMA数据转运 - 柯宝最帅 知乎
stm32利用通用定时器实现函数运行时间精确测量 - biao2488890051 51CTO

1.2 SPI 片选的说明

SPI 片选对 SPI 设备是通用的, 不管是从机还是主机, 都需要配置片选引脚。 我在 CubeMX 中看到 NSS 引脚分为硬件和软件两种, NSS 就是平常所说的片选脚, 对于主机而言片选脚需要拉高的, 这样才能保证主机 SPI 处于工作状态。 如果选择软件配置, 那么在我们选择 STM32 作为主机的时候, LL 库自动帮我们配置了 SSI 引脚作为主机的片选。 而硬件配置则会根据芯片特定的 NSS 引脚决定相应的状态。

而 SPI 主机硬件配置时可以将 Hardware NSS Signal 配置为 Hardware NSS Output Signal, 这样 NSS 脚就能自动输出低电平使能从设备。

STM32 SPI的NSS引脚配置 - 星水天河 CSDN
STM32 SPI 软件NSS和硬件NSS解读 - fanyuandrj CSND

1.3 关于磁编码器的一些建议

  • 一般来说磁编码器就只需要 1 或 2 个滤波电容外加芯片就行, 设计起来很方便, 时间成本允许就自己打样, 还能针对电机安装孔进行定制化设计。
  • SPI 在芯片上的顺序一般都是 CS, SCLK, MISO, MOSI, 顺序搞错接线会很麻烦。
  • 磁编码器似乎不是特别耐高温, 我自己把之间用热风枪吹上的芯片拆下来又装到新板子, 老会出毛病, 如果一直出现返回 error 指不定是编码器坏了。
  • 磁铁需要买径向磁铁, N 极S 极 对向水平分布。
  • 为了安装磁铁买了 AB 胶, 非常管用, 但除此之外还买了 3mm 轴套增加安装接触面积(热胶枪装不上)。

2. Cortex-M3 浮点计算优化

Qfplib: a family of floating-point libraries for ARM Cortex-M cores

一年前就找到了这个库了, 一直没尝试用过。 在 miniFOC 的项目中我是将所有的外部库都放到了 Driver 这个文件夹下面, 我创建了一个 Qfplib-M3 的文件夹存储浮点计算的库文件, 只需要在 CMakeLists.txt 中添加这个库的路径就能使用了。 经过测试, 使用这个浮点库对计算性能有非常大的提升, 在单步调试的时候, 普通的浮点数除法比利用 qfplib 的浮点数除法有肉眼可见的差距。

3. u8g2 库的使用

u8g2 库的使用参考了两份代码(用的是 SSD1306 驱动芯片), 但实际上这两篇文章都没有解决我使用 LL 库配置 SPI + DMA 进行数据传输的问题。 很遗憾我没解决这个问题, 最后使用了普通的 SPI 传输的办法, 这是因为我在几个论坛上发现很多人用了 SPI + DMA 就无法做到数据的正常传输, 必须等到 SPI + DMA 的数据传输完成后才能进行下一次传输, 而这需要通过 while 循环不断检查是否传输完毕。 既然需要 CPU 的参与, 不如直接用 SPI 还更省心。

  • SPI 发送数据, 需要通过 LL_SPI_IsActiveFlag_TXE(SPI1) 检查当前传输是否完成, 当前字节传输完成后才能开启下一次传输。
  • 除此之外, u8g2 将 CS 引脚拉高之前 (Deselect), 需要确认 SPI 的传输已经结束, 否则会造成数据错误。
uint8_t u8x8_byte_4wire_hw_spi(
    U8X8_UNUSED u8x8_t *u8x8,
    uint8_t msg,
    uint8_t arg_int,
    void *arg_ptr)
{
    switch (msg)
    {
        ...
        case U8X8_MSG_BYTE_SEND: // Use SPI to send 'arg_int' bytes
            for (int i = 0; i < arg_int; i++) {
                LL_SPI_TransmitData8(SPI1, *((uint8_t *)arg_ptr + i));
                while (LL_SPI_IsActiveFlag_TXE(SPI1) == RESET) {
                    __NOP();
                }
            }
            break;
        ...
        case U8X8_MSG_BYTE_END_TRANSFER:  // Software CS is needed. (deselect)
            while (LL_SPI_IsActiveFlag_BSY(SPI1) == SET) {
                __NOP();
            }
            u8x8->gpio_and_delay_cb(u8x8, U8X8_MSG_DELAY_NANO, u8x8->display_info->pre_chip_disable_wait_ns, NULL);
            LL_GPIO_SetOutputPin(OLED_CS_SCK_MOSI_GPIO_PORT, OLED_CS_PIN);
            break;
        ...
    }
}

AagsAags/stm32f103c8t6_u8g2_hw_spi - Github
在STM32上使用U8g2图形库并配合DMA发送显示数据(LL库) - izilzty的小窝

4. OLED 菜单与动画

调完 FOC 之后开始不务正业了, 不过实现一个丝滑的 OLED 菜单确实帅得优雅, 也为之后做一个集成主控芯片以及按钮的 FOC 驱动板打下基础。 我在 B 站也找了一些参考, 目前来说 STM32 稚晖君丝滑菜单 - uYanki Bilibili 的实现是比较符合我目前的工程需求的, 不过他提供的 menu 的库的仓库还包含了一堆其他的东西, 真的是非常庞大。

我第一步想实现的就是简单的菜单动画, 但在查看他编写的代码的时候, 发现这些实现需要基于 easing 这个类。 于是, 我上网找了一圈, 发现这其实是一个补间动画相关类, easings.net 网站提供了基于各种缓动函数的动画样例, 非常直观清晰。 另外, tween.js 是有详细的文档说明的, 可以进行查阅。 为了移植方便, 我在网上找到了几个 C/C++ 版本的 tween.js 的实现, 参照这个 Up 的代码进行相关结构体的学习。

在我自己的工程里, 为了保证在 STM32F103 这类 Cortex-M3 内核的计算效率, 我会对 easing 的主体函数用 Qfplib-m3 库进行优化。

easings.net
tween.js - wiki
tweeny
ctween
C++实现缓动动画效果,使用Tween算法(含详细代码) - Coding14 CSDN tween.js缓动补间动画算法示例 - 李俊杰 脚本之家
Tween动画及缓动函数 - S_clifftop CSDN

5. INA199x1 电流采样配置

这个配置是真的痛苦啊, 前后加起来快有一天了, 主要是对通过 TIM 定时器对 ADC 触发多通道转换这部分的含义不了解。 关于电流采样, 我设想通过 ADC1 完成多通道采样, 并通过定时器定时出发采样, 使用 DMA 对转换结果进行传输。 依据这个思路需要配置 TIM 的 PWM 以及 ADC 的外部触发。

在 STM32CubeMX 对 ADC 的配置中会看到 External Trigger Conversion Source, 这个配置网上说啥的都有, 当时我参照 B 站 Up 在 FOC 的配置中选择的 Timer 2 Capture Compare 2 event 照猫画虎, 配置完后发现根本没办法采到数据。 我发觉这个 Capture Compare 有点像比较器的输出部分, 而 TIM 中配置的则是 PWM Generation No Output, 这显然对不上。

后续参考尝试了很多代码, 最后在 【STM32】 HAL库 STM32CubeMX教程九—ADC 看到了对这几个参数的相关解释, 发现只有 ADC1 仅对 TIM3 有一个 Timer 3 Trigger Out event 的选项。

  • Regular Conversion launched by software: 规则的软件触发, 调用函数触发即可
  • Timer X Capture Compare X event: 外部引脚触发
  • Timer X Trigger Out event: 定时器通道输出触发, 需要设置相应的定时器设置

根据 ADC 中的选项的字面意思, TIM3 中的 Trigger Event Selection 配置为 Update Event, 最后终于能采到数据了。

另外需要注意配置中的几个点:

  • DMA 配置需要在 ADC 配置之前。
  • ADC 的外部触发需要定义为上升沿触发, 这一点可以在数据手册的 ADC 章节看到。 LL 库需要调用 LL_ADC_REG_StartConversionExtTrig 函数。
  • PWM 有 Mode 1 和 Mode 2 两种, 分别表示计数器在达到 ARR 值之有为有效电平, 以及在达到 ARR 后为有效电平。 因而对于上升沿触发的要求, 需要配置 PWM 的极性为 Mode 1 为 Low, Mode 2 为 High。 这样, Mode 1 有效电平为 Low, 而 Mode 2 有效电平为 High, 均能达到上升沿的效果。
  • ADC 校准的代码如下所示, 需要注意结合不同的型号进行差异化配置。 F103 要求在校准之前开启 ADC, 并且需要等待 ADC 的电压稳定。

      //* start ADC1
      LL_ADC_Enable(ADC1);
      // wait until internal voltage reference stable
      delay_nus_72MHz(LL_ADC_DELAY_TEMPSENSOR_STAB_US);
    
      // wait at least 2 ADC cycles after ADC power-on but before calibration
      LL_mDelay(10);
      // wait until ADC calibration done
      LL_ADC_StartCalibration(ADC1);
      while (LL_ADC_IsCalibrationOnGoing(ADC1) != RESET) {}
    

STM32F0使用LL库实现DMA方式AD采集
STM32L476多通道TIM+DMA+ADC采样(LL库)
STM32 定时器触发 ADC 多通道采集,DMA搬运至内存
【STM32】 HAL库 STM32CubeMX教程九—ADC

6. 速度环调试

力矩环在电压控制的情况下就是开环的, 因而测试过后就调力矩环的外环电流环。 电流环遇到一个问题就是给定速度后, 电机转到某个位置就会发生明显的卡顿情况, 视觉上的感受就是这个地方电机遇到了一个非常大的阻力停顿了一下, 这在速度为 10 - 20 rad/s 时非常明显, 整个电机都会因为这个卡顿而发生弹跳。 但一旦速度达到 40 rad/s 及以上之后, 这种卡顿因为速度的提升会转变为一种高频且轻微的抖动(jitter)。

用 PI 控制器几乎没办法解决这个问题, 凑巧的是我到 SimpleFOC 的官方文档看了看, 我发现他们的速度环实际上是用 PID 控制器实现的, 其中微分 Kd 仅为 0.001 大小。 微分的作用就是应对控制量的快速变化, 意识到这一点后我认为速度环电机的电角度的快速变化很可能让 PI 控制器没办法以较快的响应速度稳定到需要控制的角度, 因而产生了控制的滞后, 这在电机上的表现就是卡顿与抖动。 尝试加入了微小的微分量后, 电机的速度环果然稳定了下来并且表现优异。

7. 位置环调试

7.1 关于位置环的比例控制

位置环需要对输出到速度环的电机转速进行限制。 另外我发现用 PI 控制器的位置环始终存在着稳态误差, 我在知乎上看到一则解答:

当位置环下还有速度环时,速度和位置是对同一个刚体运动的不同数学描述,在物理上其实是同一个量。它们之间只存在严格的数学关系,并没有实际的物理过程。这就意味着位置是速度的积分这一模型是绝对精确的,因此不会产生稳态误差,自然也就不需要积分环节。

当然除了对位置环仅需要比例控制外, 这则回答还解释了为什么速度环和电流环都需要积分环节去调节其中的非线性环节。 根据这则回答的指导, 我将 PI 控制转为了 P 控制后, 位置环的稳态误差几乎不存在了。 但是, 位置环在误差及其微小的状态下仍驱动电机在工作, 我认为在 12-bits 的位置反馈下精度过大是没有必要的, 因而我对位置环还引入了一个 死区, 限制在误差为 0.05 rad 机械角度以下的情况让位置环的比例控制输出始终为零, 不仅能对电机与电机芯片进行保护, 还能避免电机在某些情况下因为调节精度问题而产生的抖动问题。

// P controller
float PID_angle(float err) {
    float proportional, output;

    // If the 'err' is too small, I don't want the motor to adjust itself.
    if (abs(err) < 0.05f) {
        return 0.0f;
    }

    // u_p  = P *e(k)
    proportional = qfp_fmul(g_ang_ctrl.pid.Kp, err);

    output = proportional;
    output = constrain(output, -g_ang_ctrl.velocity_limit, g_ang_ctrl.velocity_limit);

    return output;
}

7.2 位置环与速度环切换的电机跳动问题

位置环和速度环切换出现电机的跳动, 也就是切换之后电机基于当前的状态值会产生转动速度, 本质上的原因就是前一个环的状态量没有处理妥当。 如下代码所示, 我是这样解决的:

  • 位置传感器仿照电机初始化时的流程, 让当前的从位置传感芯片的 SPI 获取到的 raw_angle_data 赋值给 raw_angle_data_prev, 那么在调用 get_angle 的时候, 返回值会重新归零。
  • 获取转轴速度的时候是基于当前的角度以及与上次获取的角度差的, 因而重新更新 angle_prev 为当前的角度, 那么后续获取 get_velocity 的时候返回值就为零了, 那么电机的状态中的速度也就归零了。
  • 另外我们需要更新当前的机械角度值为 get_shaft_angle, 这是叠加上 Sensor Dir 的机械角度值, 本质上是调用了 get_angle, 此时的值也归零。
  • 最后设定位置环的目标位置 target_angle 为当前的机械角度, 这样就不产生位置误差, 位置环能保持静止。 另外直接设定速度环的 target_speed 为 0, 这样速度环也没有速度量的输出。
void encoder_reset(void) {
    rotation_turns_angles = 0.0f;
    raw_angle_data_prev = (float)read_raw_angle();
    LL_mDelay(5);
    angle_prev = get_angle();
    LL_mDelay(5);
    g_foc.state_.shaft_speed = get_velocity(); // must be zero
    LL_mDelay(5);
    g_foc.state_.shaft_angle = get_shaft_angle();
    // Set current shaft angle as the target angle.
    // So the motor can stop after motion mode being switched.
    g_ang_ctrl.target_angle = g_foc.state_.shaft_angle;
    g_vel_ctrl.target_speed = 0.0f;
}

另外需要注意, Sensor Dir 对机械方向的判断需要保留, 2023-07-30 晚上拆了一下设备重新装上之后, 突然转不起来了。 第二天早上 checkout 到之前的版本, 发现引入位置反馈都转不起来, 发现是 CW 和 CCW 没有设置对, 导致机械角度的方向和电角度方向不一致, 因而电机无法正常旋转。

为什么伺服驱动器位置环只有比例控制? - 全力的一度狐的回答 知乎

8. 电流环调试

电流环调试感觉非常困难, 目前没有彻底解决问题。 最开始发现电流环给出的反馈波形非常奇怪, Q 和 D 两轴的波形竟然都是带截止的。 后来一路查到 ADC 转换出来的值, 发现是 A 相的电流采样芯片的放大倍数以及中心点都发生了严重偏移, 更换芯片后两路采样都重新回到了 1.65 V 的参考电压在以正弦波波形在波动。

当时实际调试的时候, 设定 \(^{Q}K_{p}\) 和 \(^{D}K_{p}\) 的值为 0.6 的时候, 转矩设定为 0.6 以上电机才能够平稳旋转, 而这也直接导致套上速度环和位置环后, 在误差较小时电机的运行是不平稳的, 会产生抖动。 我原来想通过 Ki 弥补稳态误差, 但这个给定值非常难找, 经常导致电流环出现不收敛而发生电机转速疯狂飙升的情况。 目前暂时没有解决电流环的调试问题。

9. STM32 LL 库配置踩坑

在网上搜了一番发现 STM32 用 LL 库开发的效率和配置寄存器差不多, HAL 库的封装太多层了, 虽然移植性比较好但效率显著低于 LL 库。 但是, LL 库资源少而且有坑, 不过对于爱折腾的人以及从 51 那套开始习惯读寄存器的人而言, 倒也不是太大的问题。

9.1 Debug

CubeMX 中的 SYS 需要配置 Serial Wire 才能用 stlink-v2 进行调试。 但是我实际测试用 vscode 的 cortex-debug 插件, 开启这个设置会导致调试偏移到很奇怪的地方, 反倒是不用开也能调试。

9.2 TIM1 配置 PWM 输出

LL 库通过 STM32CubeMX 生成的 TIM1 基础配置存在一定的问题。 见 Core/Src/tim.c 中对 Prescaler 以及 Autoreload 的配置, 会把 36 - 1 配置成 36 - LL_TIM_IC_FILTER_FDIV1_N2 怀疑这个与 CubeMX 不支持配置中运算有关系。

// Core/Src/tim.c
void MX_TIM1_Init(void) {
    ...
    TIM_InitStruct.Prescaler = 36 - 1;
    TIM_InitStruct.Autoreload = 100 - 1;
    ...
}

另外, 要使能 TIM1 的输出, LL_TIM_CC_EnableChannelLL_TIM_EnableCounterLL_TIM_EnableAllOutputs 这几条函数必须逐个使用, 具体含义可以参考 ST - Description of STM32F1 HAL and low-layer drivers

关于STM32CubeMX使用LL库设置PWM输出

9.3 SPI

需要增加 LL_SPI_Enable 函数使能相关的 SPIx 设备。

另外发现一个问题, SPI 的收发需要共用一个函数(纯发送不用, 我在 OLED 中测试过), 这是因为 SPI 从机没有自己的 SCLK 时钟, 这就需要主机提供。 而双工 SPI 的接收和发送是用两套缓存空间的, 因而可以通过主机向从机发送相应的字节提供 SCLK 时钟, 才能进行同步进行数据接收。 因而, 磁编码器的 SPI 接收函数是这样的。

static uint16_t spi2_transmit_rw(uint16_t outdata) {
    // wait until the SPI Tx buffer to be empty
    while (LL_SPI_IsActiveFlag_TXE(SPI2) == RESET) {}
    LL_SPI_TransmitData16(SPI2, outdata);
    // wait for 16 bits data receiving complete
    while (LL_SPI_IsActiveFlag_RXNE(SPI2) == RESET) {}
    return LL_SPI_ReceiveData16(SPI2);
}

可算是懂了为什么磁编码器的数据手册里面 MOSI 是全 0 的数据了。 不过忘记买磁铁了, 调了半个晚上以为电机轴承那边是有磁性的, 结果一直读到 error 的数据, 后来问了好兄弟才知道那块地方是钢, 他推荐我实现 无感 FOC, 外贴磁铁会影响电机的磁场可能会有问题。

STM32 SPI发送与接收用一个函数实现的问题 - VX13260562029 CSDN

9.4 DMA

DMA 的配置需要在任何主配置, 如 USART, SPI 这些配置之前完成, 当时 USART 接收有问题, 看网上资料把 DMA 的配置放到了 USART 配置之前就解决问题了。

9.4.1 USART DMA

USART 的 DMA 开启有几个 LL 库的函数需要注意:

// Enable DMA Mode for reception
LL_USART_EnableDMAReq_RX(USARTx_INSTANCE);
// Enable DMA Mode for transmission
LL_USART_EnableDMAReq_TX(USARTx_INSTANCE);

除此之外, 对于配置了 NORMAL 类型的 DMA 传输, 需要在下一次 DMA 传输开启之前重新配置 传输的内存地址 以及 传输的数据大小。 值得注意的是, 在配置之前需要 失能 相关的 DMA 通道, 另外 传输的数据大小 和该传输方向定义的数据宽度相关。 也就是说, 如果此时我定义了 DMA 从外设到内存的传输数据宽度为 Byte, 那么这个传输的数据大小若设置为 4 则表示传输 4 个字节。 若定义的数据宽度为 HalfWord, 则传输的数据大小为 4 表示传输 4 个半字 (2 个字节)。

9.5 重定向 printf

还得是知乎大佬 STM32用gcc编译printf重定向到串口 - gyx鑫 知乎 这篇文章, 除了如下更改还需要在串口初始化的时候调用 setvbuf(stdout, NULL, _IONBF, 0)

#include <stdio.h>
int __io_putchar(int ch) {
    while (LL_USART_IsActiveFlag_TXE(USARTx_INSTANCE) != SET) {}
    LL_USART_TransmitData8(USARTx_INSTANCE, ch);

    return ch;
}

int _write(int fd, char *ptr, int len) {
    (void)fd; // avoid unused warning
    for (int i = 0; i < len; i++) {
        __io_putchar(*(ptr + i));
    }
    return len;
}

9.6 printf 浮点输出

但是这种办法还不能输出 float 浮点数, 这是因为 STM32 默认是关闭浮点输出的, STM32 社区论坛有一条帖子说明, 通过如下链接指令打开 GCC 中的浮点打印的编译。 但是, 我自己测试之后还是无法输出 float 类型, 即便将优化等级调整为 -O0

add_link_options(LINKER:-u _printf_float)

尝试了很多办法, Vofa+ 上位机程序应该对接收进行了解析所以才能输出 float 类型数据, 直接通过串口一个个发 4 bytes 的 float 还得自己去转换太麻烦了, 干脆就转成整数相关类型输出算了。

2023-08-04 UPDATE: 这个编译选项还是需要打开的, 我后来在 OLED 中使用 sprintf 做浮点数转义的时候, 如果不开启这个编译选项是无法成功将 float 类型转为字符串类型的。

printf and float - ST Community
how to use float in printf? - ST Community
snprintf() prints garbage floats with newlib nano - StackOverflow\