FOC 迷你方向盘手柄配套博客

一、前言

开源是为了提供更高的社会效率,故而不必所有人都重新发明发现。所以我一直坚持开源项目,而且我的项目都是 全开源,也就是不像部分开源项目一样,只开源硬件并发布固件,不开源源码之类只开源其中一部分,这种项目对于社会的贡献也就仅限于培养焊接工程师。诚然,他们不完全开源的很大一部分原因来自奸商倒卖。但是,从长远来看,如果通过开源将大家都培养成有实践能力有互助意愿的工程师,那么就没有买办奸商的生存空间了,反倒是不开源设下的壁垒让拥有信息差的奸商越来越赚,越来越多。
我认为短期内的过渡方法应该是需要有人成立一个组织专门寻找有潜力的开源产品获取授权量产并帮助原作者维权,挤压盗版的生态位,开源作者不用分心量产与维权,双赢。

回到正题,即便是软硬件全开源依然不能发挥开源最大的潜力,也就不能更好地达到 “提高的社会效率” 的目的。为此,我觉得更应该将开源项目的设计思路以及技术要点分享出来,所以有了这篇博客。

这篇博客配合的开源项目是 力反馈方向盘手柄,不过本文并非事无巨细将手柄设计的所有流程列出,更侧重的是从第一性原理出发带你过一遍 FOC 的实现过程,需要一定的嵌入式与 c 语言基础,不需要多少数学基础,至少得懂三角函数和一点微积分。

二、工作流

因为是第一篇开源项目设计流程博客,所以这里提及一下我的工作流。

1、设计工具

PCB 设计:立创 EDA 专业版

画元件封装会比画板子耗时得多,如果你没有什么别的 EDA 的封装积累,那么还是用立创吧,直接提供画好的封装以及数据手册的快捷入口。

其实在我看来,立创是最有希望实现 AI 画板的,因为他们有自己的硬件开源平台,也就是有训练数据,以及因为有自己的元件商城,所以可以直接从供应商获取数据手册和详细资料。立创现在在搞的 EDA 插件支持也是有利于加速 AI 画板到来的。似乎有点跑题,不过 vibe coding 可以成为新的工作流,那么 vibe layout 也许可以期待一下。

外壳设计:Fusion360

免费好用。

一般来讲,我会先确定好大概的 PCB 尺寸和元件位置,暂时不进行布线,然后开始进行模型设计,如果出现需要元件避让的情况再返回 PCB 进行调改,确定没问题后再画完板子,板厂打板期间可以继续把模型剩下的部分画完。

编辑器:VScode/……

最好是选用一些现代化的,可以接入 ai 的编辑器,不然你切网页问一句再复制过来手动更改,还是很麻烦的。直接植入 agent 功能的 ide 可以看到你的整个工程的文件,对你的项目更了解,也能直接修改你的文件,你只需要负责 review,效率更高。

图表:python

本文中除了标注出处的图表,其余大部分图表与动画都是我让 ai 用 python 绘制的,非常精美。

2、开源管理

原理图/PCB 发布:立创开源平台

大部分硬件开源作者会选择将所有东西一股脑放在立创附件,好处是一个地方能获取所有资源,但是麻烦在于难以二次编辑,因为每次更改都需要重新审核,审核期间是看不到项目的,所以常常会有人给开源作者反馈打不开项目页面了……希望优化

源代码 发布:github/gitee/……

为了方便代码的更新与多人协同设计,需要将其托管到远程 git 仓库,不过有些作者会把 pcb 也发布在 git 平台,显然不是很方便,立创的好处就是直接可以跳转一个页面预览 PCB 和原理图,而不用下载导入。不过如果不是用立创 EDA 画的那就另说了。

还有一些情况是有些作者把所有东西放在某些网盘,并不方便获取以及更新。

模型 发布:Makerworld

如果不是偏创意性的项目,而只是电子产品外壳,倒也没有必要单独上传到专门的模型网站,这里提及 makerworld 是因为某竹打印机的保有量比较大。

文档:Markdown

上面几个平台的文档都可以使用 markdown 语言编写,包括这篇文章也是使用 markdown 编写的。学计算机的同学一般都会接触 git 和 markdown,因为很多开源软件项目都放在 github。学电子的同学不常用 git 和 markdown 主要受 网盘分发 的硬件开源现状影响,故而少有了解到相关方面。

我也常常用 markdown 来写一些笔记,因为一般都会支持 latex,能很方便写一些漂亮的公式:

本利和 ( 复利 ) = ∑ n = 4 2 P ( 1 + i ) n = 100 × ( 1 + 10 % ) 4 + 100 × ( 1 + 10 % ) 3 + 100 × ( 1 + 10 % ) 2 = 400.51 ( 万元 ) \begin{aligned} 本利和(复利)=&\sum _{n=4}^2P(1+i)^n\\ =&100\times(1+10\%)^4 + 100\times(1+10\%)^3 + 100\times(1+10\%)^2\\ =&400.51(万元) \end{aligned} 本利和(复利)===n=42P(1+i)n100×(1+10%)4+100×(1+10%)3+100×(1+10%)2400.51(万元)

反馈组态 X ˙ i X ˙ f X ˙ i ′ \dot X_i \dot X_f \dot X'_i X˙iX˙fX˙i X ˙ o \dot X_o X˙o A ˙ \dot A A˙ F ˙ \dot F F˙ A ˙ f \dot A_f A˙f 功能
电压串联 U ˙ i U ˙ f U ˙ i ′ \dot U_i \dot U_f \dot U'_i U˙iU˙fU˙i U ˙ o \dot U_o U˙o A ˙ u u = U ˙ o U ˙ i ′ \dot A_{uu} = \frac{\dot U_o}{\dot U'_i} A˙uu=U˙iU˙o F ˙ u u = U ˙ f U ˙ o \dot F_{uu} = \frac{\dot U_f}{\dot U_o} F˙uu=U˙oU˙f A ˙ u u f = U ˙ o U ˙ i \dot A_{uuf} = \frac{\dot U_o}{\dot U_i} A˙uuf=U˙iU˙o U ˙ i \dot U_i U˙i 控制 U ˙ o \dot U_o U˙o ,电压放大
电流串联 U ˙ i U ˙ f U ˙ i ′ \dot U_i \dot U_f \dot U'_i U˙iU˙fU˙i I ˙ o \dot I_o I˙o A ˙ i u = I ˙ o U ˙ i ′ \dot A_{iu} = \frac{\dot I_o}{\dot U'_i} A˙iu=U˙iI˙o F ˙ u i = U ˙ f I ˙ o \dot F_{ui} = \frac{\dot U_f}{\dot I_o} F˙ui=I˙oU˙f A ˙ i u f = I ˙ o U ˙ i \dot A_{iuf} = \frac{\dot I_o}{\dot U_i} A˙iuf=U˙iI˙o U ˙ i \dot U_i U˙i 控制 I ˙ o \dot I_o I˙o ,电压转换成电流
电压并联 I ˙ i I ˙ f I ˙ i ′ \dot I_i \dot I_f \dot I'_i I˙iI˙fI˙i U ˙ o \dot U_o U˙o A ˙ u i = U ˙ o I ˙ i ′ \dot A_{ui} = \frac{\dot U_o}{\dot I'_i} A˙ui=I˙iU˙o F ˙ i u = I ˙ f U ˙ o \dot F_{iu} = \frac{\dot I_f}{\dot U_o} F˙iu=U˙oI˙f A ˙ u i f = U ˙ o I ˙ i \dot A_{uif} = \frac{\dot U_o}{\dot I_i} A˙uif=I˙iU˙o I ˙ i \dot I_i I˙i 控制 U ˙ o \dot U_o U˙o ,电流转换成电压
电流并联 I ˙ i I ˙ f I ˙ i ′ \dot I_i \dot I_f \dot I'_i I˙iI˙fI˙i I ˙ o \dot I_o I˙o A ˙ i i = I ˙ o I ˙ i ′ \dot A_{ii} = \frac{\dot I_o}{\dot I'_i} A˙ii=I˙iI˙o F ˙ i i = I ˙ f I ˙ o \dot F_{ii} = \frac{\dot I_f}{\dot I_o} F˙ii=I˙oI˙f A ˙ i i f = I ˙ o I ˙ i \dot A_{iif} = \frac{\dot I_o}{\dot I_i} A˙iif=I˙iI˙o I ˙ i \dot I_i I˙i 控制 I ˙ o \dot I_o I˙o ,电流

三、硬件设计

1、选型

  • 主控芯片:PY32F403
  • 电机型号:2804 三相无刷电机
  • 电机驱动:MS8313
  • 电流采样:ina181a2

主控选择 PY32F403。因为便宜,而且它使用的是 stm32 的 HAL 库,没有额外的学习成本。不过这是普冉目前唯一一款 M4(F) 的单片机,缺点是 flash 要开到 5 等待,虽然说它在手册中宣称其 art 可以实现“相当于零等待”,但是中断之类难以预测的函数,最好还是需要转移到 ram 中运行,提高速度。
主控最高主频 144MHz,项目里开到 128MHz,是为了兼顾 ws2812 的 spi 刷新频率(128M / 32 = 4M)以及 adc 在 128M/8=16M,正好可以达到这款芯片的最高 adc 采样速度(1MSPs),也就是 1us 采样一次。

电机选用的是常用的 2804 三相无刷电机,极对数 7,额定电压为 12V,电流为 1A。

电机驱动使用 MS8313,其实就是 DRV8313 的国产 pin2pin 替代,类似的还有 AT8313 等。

电流采样使用 ina181a2(放大倍率 50 倍) 搭配 20mΩ 采样电阻。基于基尔霍夫电流定律,只需要采集其中两路即可算出第三路

2、电路设计

单点接地是教条,回流路径才是真理。
这里将电路分为三个区域,分别为 功率区、数字区以及模拟区。

①、功率区:

功率区由一节锂电池升压 12V 给电机供电。插上 USB 后,会对锂电池进行充电,并且打开一个 PMOS,用于设备的插电自动开机,目前设置 充电电流为 400mA,板上有一个 700mA 的自恢复保险丝以及 TVS 管避免损坏电脑 usb 接口。

②、数字区:

数字区由锂电池经过 LDO 降压至 3.3V 供电。但是就这个项目来说,板子空间比较富裕,各部分回流路径已经处理好了,也就是每条从 mcu 走出去的线都有一条尽可能靠近它的 gnd 回路,所以数字区和功率区没有分割地,因为单点接地有时候反倒可能将部分回流路径扩大。而且虽说是功率区,但是并没有很大的功率,所以也没有另外的隔离需求。

③、模拟区:

模拟区的供电与数字区的供电是来自同一颗 LDO,如果有条件,那么可以单独分配一颗 LDO 或者其他更稳定的参考源。

四、软件选型

使用自研的裸机调度框架:ltx-V3,因为裸机下无需线程上下文切换开销,速度上较之 RTOS 更好。而且此裸机调度框架的脚本组件提供了优雅的异步能力,效果类似于无栈协程,可以设置步骤间非阻塞延时,以及步骤等待事件触发并设置非阻塞等待超时时间。

需要讲明的是,裸机与 rtos 最主要的差异体现在优先级抢占上面,裸机的切换速度已经如刚才所说,是可以比 rtos 更快的,而响应性则是中断提供的,比如按键中断之类都是中断提供的能力,程序设计好的话,裸机是可以提供更快响应性的。裸机的问题是,由于任务间是 谦让 的,所以低优先级任务在执行时,没办法被高优先级的任务打断,只能低优先级任务执行完手头的一点东西后,主动将 cpu 资源释放出去,所以需要一定的程序构建能力,利用好中断,裸机效率不输 rtos。详细可以看我之前写的一篇文章:单片机裸机多任务方案总结

五、FOC 理论

这里并不是标准的 FOC 算法流程,而是一个让你学会从驱动三相电机到让电机实现 FOC 的流程,也并不会进一步对电机进行完整建模。

1、六步换相

首先我们尝试让电机能够转起来。

在这里插入图片描述

图片出处:【CSDN】[科普] 无刷直流电机驱动控制原理图解

网络上的大部分教程(如上图)都是导通某两个桥臂(两两导通)给其中两个线圈通电而悬空另一个来实现六步换相,实践中少有这样做,一是因为这样总会有一个线圈闲着;二是我们使用的 DRV8313 这一驱动的每套半桥都只引出了一个脚,而不是上下两个桥臂分别引出来,所以没办法实现让某一路输出悬空,某一路非 0 则 1(关闭 ENx 除外,为了节约引脚没有直接控制 ENx,而是将其常拉高)。

所以我们使用的是三三导通的形式进行驱动。那么对于输出的编码就不是 A1, B0, C悬空 这种形式了,而是 A0, B0, C1 这种形式。

撇去 000111 这种不输出的情况,依然是六步,这六个向量的方向如下图所示:

在这里插入图片描述

只要按照特定顺序轮换 U1 到 U6,即可完成六步换相,下面是创建了一个 ltx 脚本来实现 100ms 换向一次

// 六步换相表
const uint8_t six_step_list[6][3] = {
    [0] = {0, 0, 1},
    [1] = {0, 1, 1},
    [2] = {0, 1, 0},
    [3] = {1, 1, 0},
    [4] = {1, 0, 0},
    [5] = {1, 0, 1},
};

#define TEST_DUTY_FOR_SIX_STEP  0.1f    // 10% 占空比

// 测试六步换相用脚本
void script_cb_six_step(struct ltx_Script_stu *script){
    // if(ltx_Script_get_triger_type(script) == SC_TRIGER_RESET){ // 外部要求此脚本重置,可在这里做释放资源等操作
    //     return ;
    // }

    ltx_bldc_set_duty_u(motor_wheel, (six_step_list[script->step_now][0] ? TEST_DUTY_FOR_SIX_STEP : 0));
    ltx_bldc_set_duty_v(motor_wheel, (six_step_list[script->step_now][1] ? TEST_DUTY_FOR_SIX_STEP : 0));
    ltx_bldc_set_duty_w(motor_wheel, (six_step_list[script->step_now][2] ? TEST_DUTY_FOR_SIX_STEP : 0));

    // 100ms 后再执行一次本函数实现换相
    ltx_Script_next_step_delay(script, (script->step_now+1)%6, 100);
}

前面图示的电机只有一个对级,而我们实际使用的电机的转子由好几套磁铁配合而成(这里极对数为 7)。由于磁铁极对不止一对,所以旋转时实际旋转的角度(机械角)只有 电角度/极对数,所以说换完 6 个步并不能转完一圈,需要转 6*极对数 步才能转完一圈。

2.1、电压、电流、磁场与力

刚才这个简易六步换相脚本虽然能让电机转起来,但是只能盲目换向,它并不知道换相时机,只能靠运气碰上然后才能带着它旋转(强拖)。我们来梳理一下电压产生力的过程。

①、电压➡电流

因为电感的存在,电压和电流的是存在相位差的,表现为电流滞后于电压,而且因为负载的变化、线圈发热和各种干扰,这个相位差也会变化。
在这里插入图片描述
②、电流➡磁场

电感的磁场向量是正比于电流向量的,而非电压。

③、磁场➡力

在这里插入图片描述
当磁铁方向与磁场方向平行时,磁铁受力平衡,表现为静止,磁场与磁铁相垂直时,此时用来旋转磁铁的力是最大的。

也就是说磁场给转子施加的力可以分解为切向方向(红色)与沿磁铁/直径(蓝色)方向:

在这里插入图片描述

这个切向方向(红色)的力也就是帕克变换追求的 Q轴,因为沿磁铁方向(D 轴方向)的力不做功,所以追求 D 轴上的 i d = 0 i_d = 0 id=0,也就不会产生径向力,而是将力全发挥在了切向。最终帕克变换为的就是构建一个追着磁铁(转子)的旋转磁场,故而需要实时采集转子的角度构建新的坐标系产生新的磁场。

总之,为了确定力的大小和方向,就需要根据转子的位置产生一个特定的磁场向量,而力的向量可以正比于磁场向量,磁场向量又正比于电流向量,所以我们的目标就是产生一个特定的电流。
但是我们又不能直接产生目标电流,只能通过电压产生电流,而电压和电流在不同负载下的相位差不固定,导致在固定的电压下产生的电流会有变化,所以需要实时变更电压来稳住目标电流。
要知道变更多少、怎么变更,那就得找到电压变化对电流/磁场变化的影响能力。

2.2、电感与磁链

  • 电感:电感的电流不能突变,在直流情况下,电感可以看作一条导线。
  • 磁链:描述磁场大小和方向的物理量。

电机的每个绕组都可以看作是一个电阻与电感串联的结果。
在这里插入图片描述

加在电感两边的电压决定了电感的 电流变化率:

U l = L d i d t U_l=L\frac{di}{dt} Ul=Ldtdi
很显然,从公式上看,如果电压保持不变,那么电感的电流似乎会随着时间无限增加。而我们的绕组并非理想电感,它是有等效串联电阻的,故而实际上的电压和电流的关系如下:

U = U r + U l = I ⋅ R + L d I d t U = U_r + U_l = I\cdot R + L\frac{dI}{dt} U=Ur+Ul=IR+LdtdI

随着时间的变化,电流会不断增加,但是因为电阻的存在,分在电阻两端的电压 U r = I ⋅ R U_r=I\cdot R Ur=IR 上升,导致最终电感两端分不到电压,从而电流不会继续上升,也就是电感在直流(稳态)情况下可以看作是短路。
在这里插入图片描述

从图上也可以看出来,电流的斜率 d I / d t dI/dt dI/dt 并不是不变的,而是会逐渐变小直至为 0,电流不会无限增大。

所以特定电压施加在绕组上的最大电流是确定的,也就是 I = U R I=\frac{U}{R} I=RU,而我们的控制情况肯定不是直流,也就是电压需要变化。
但是电压一旦变化,电流可并不会突变,而是会有滞后效应。假如我们的电压从 0V 变到 10V,电阻是 2Ω,因为电感的存在,电流不能突变,也就是需要过一段时间才能将电流变化到 10 V / 2 Ω = 5 A 10V/2Ω = 5A 10V/2Ω=5A

很不幸,我们的磁链 Ψ = L ⋅ I Ψ=L⋅I Ψ=LI,也就是它只与电流线性相关,而施加电压需要过一段时间才能体现到电流上面。电压与磁链也就是如下关系:
U = I ⋅ R + d Ψ d t U = I\cdot R + \frac{d Ψ}{d t} U=IR+dtdΨ

电压的变化与磁链的变化似乎不是线性的?
当我们将时间进行微小细分,因为电感上的电流不能突变,那么这个串联回路上的电流也就不能突变,所以分在电阻两端的电压就是不变的,那么增加电压后电压变化的差值 d U dU dU 在这个微小时间间隔 d t dt dt 内就相当于直接施加在电感上了,也就是可以看作是 Δ U = Δ Ψ Δ t \Delta U = \frac{\Delta Ψ}{\Delta t} ΔU=ΔtΔΨ

t 0 t_0 t0 时刻分在电阻和电感两端的电压和:
U 0 = U r 0 + U l 0 U_0 = U_{r_0} + U_{l_0}\\ U0=Ur0+Ul0
电压发生变化,增加 d U dU dU
U 0 + d U = U r 0 + U l 0 + d U U_0 + d U = U_{r_0} + U_{l_0} + dU U0+dU=Ur0+Ul0+dU
因为电流还没来得及改变,所以这个瞬间分在电阻两端的电压 U r = I ⋅ R U_r = I\cdot R Ur=IR 是不变的,也就是 d U dU dU 短时间内全都都加到电感两端,与 U l 0 U_{l_0} Ul0 共同形成了新的电感两端电压 U l 0 ′ U_{l_0}' Ul0,因此可以认为:
U l 0 ′ = U l 0 + d U d U = L d I d t = d Ψ d t U_{l_0}' = U_{l_0} + dU\\ d U = L\frac{dI}{dt} = \frac{d Ψ}{d t} Ul0=Ul0+dUdU=LdtdI=dtdΨ

意味着电压与磁链的关系虽然不是线性的,但是短时间内电压的变化量与磁链的变化量是线性的,要对磁链产生变化,那么就可以直接对电压产生变化,经过 d t dt dt 时间后可以产生一个“线性的”磁链(电流)变化量。

经过这个 d t dt dt 时间后,电流增加,如果此后保持电压不变,那么分给电阻的电压也增加,分给电感的电压则会被蚕食,导致电流变化速度会放缓,如果要维持维持前一刻的电流变化率,此时需要产生额外的 d U dU dU

结果就是,对 dq 两轴的电流的增减都可以通过分别操作 dq 两轴电压增减来在 d t dt dt 时间内产生线性增长。

至此,我们已经有足够的理论基础来让特定电压向量改变电流(磁链)向量,接下来的技术目标就是如何产生特定的电压向量了,也就是将跟随转子旋转的 dq 轴的目标电压转换到输出给三相线的电压。

3、正弦波控制(SPWM)

也可以直接看下一小节 4、空间向量调制(SVPWM)

我们的控制器只能输出高低电平,也就是 1 和 0,一般不能输出 0.5 之类的情况,所以说需要 PWM 的参与来模拟出正弦波,也就是当 1 存在 50% 的时间,0 存在 50% 的时间,那么就等效于输出了 0.5,这也意味着对算力的要求有所提升,因为方波控制只需要换向时切换一次,而正弦波时时刻刻都在切换(不断变换 pwm 占空比来模拟正弦波)。
在这里插入图片描述

基本上所有教程都会在讲 svpwm 的时候提一嘴伏秒平衡,但又不讲为什么,实则更应该在讲 spwm 的时候就先提。尤其是需要前一小节对电压向量作用进行铺垫。
伏秒平衡在这里指的是在一个开关周期内,施加的电压向量对时间的积分等于期望向量对时间的积分,这个伏秒平衡只是保证你在这个领域是可以通过 pwm 进行 等效 操作的。
也就是 保证下图里面红蓝阴影区域是等效的。
在这里插入图片描述
也就是 保证下面公式两端是可以划等号的:

1 V × 0.3 T + 0 V × 0.7 T = 0.3 V × 1 T 1V \times 0.3T + 0V \times 0.7T = 0.3V \times 1T 1V×0.3T+0V×0.7T=0.3V×1T

也就是 保证电压向量是可以时间加权方式进行向量合成的。

使用 SPWM 也可以实现 FOC,但一般更常用 SVPWM 进行 FOC 实践,主要是因为 母线电压利用率不够

SPWM 任意两根线之间最大的电压差只占供电电压的 86.6%:
在这里插入图片描述

图片出处:【Bilibili】无需公式推导,SPWM零序注入可视化演示

4、空间向量调制(SVPWM)

首先硬件会产生一个固定频率(项目里使用的是 20kHz)的 pwm 输出,作为载波,spwm 将正弦波作为基波,软件上动态改变 pwm 的占空比,最终得到的调制波通过低通滤波就相当于产生了一个正弦波:
在这里插入图片描述
spwm 能产生的电压向量覆盖的只有六边形区域内的一个稍小一点的圆:
在这里插入图片描述

而我们的目标则是提高利用率输出更大的扭矩,也就是能产生六边形的内切圆范围内的所有向量:
在这里插入图片描述
当然,你也可以以六边形为范围,利用上剩余的一点边角料,进一步提高部分角度的输出扭矩,但是代价是最大功率输出时会有顿挫感,波形如下:

在这里插入图片描述
在这里插入图片描述
还是这张图,我们拥有六个空间向量,通过两两 pwm 操作,可以合成任意方向的向量。比如 U2 占 50% 的时间,U6 占 50% 的时间,那么就能等效为一个竖直向上的新向量。

而这里的向量看起来虽然有三个变量,但实际上还是二维的,那作为一个二维平面上的向量,为什么会有三个数字?
因为它并不是代表六边形内某个具体的向量,而是三相线的输出电压,例如 U4 的 (1, 0, 0) 代表的就是 (100%, 0%, 0%) 的电压输出占比,而这个电压下,会产生一个 U4 向量。

我如果告诉你 (80%, 50%, 50%)(90%, 60%, 60%) 是代表同一个向量呢?是不是难以接受了?

但是看下面这张动图你就能豁然开朗了:

在这里插入图片描述

ABC 三根线上的电压虽然在变化,但是相与相之间的 电压差 是不变的,对于转子来说,它感受不到任何差别。这也就导致了你可以用不同的驱动波形输出相同的效果,表现为共模分量不产生影响,例如:

在这里插入图片描述


在数学上,仅靠两两 svpwm 操作,你只能合成出落在六边形的边缘上的向量,无法控制其大小,此时零向量就发挥作用了,例如一个周期内 U2(010) 占 20% 的时间,U6(110) 占 30% 的时间,U0 占 50% 的时间,那么就 等效(0.3, 0.5, 0)

0 × 20 % + 1 × 30 % + 0 × 50 % = 0.3 1 × 20 % + 1 × 30 % + 0 × 50 % = 0.5 0 × 20 % + 0 × 30 % + 0 × 50 % = 0 \begin{aligned} 0 × 20\% + 1 × 30\% + 0 × 50\% &= 0.3 \\ 1 × 20\% + 1 × 30\% + 0 × 50\% &= 0.5 \\ 0 × 20\% + 0 × 30\% + 0 × 50\% &= 0 \end{aligned} 0×20%+1×30%+0×50%1×20%+1×30%+0×50%0×20%+0×30%+0×50%=0.3=0.5=0

同时,因为 (1, 1, 1)(0, 0, 0) 是等价的,所以 (0.3, 0.5, 0) 是等效于 (0.45, 0.65, 0.15) 之类无数种分配形式的

0 × 20 % + 1 × 30 % + 0 × 35 % + 1 × 15 % = 0.45 1 × 20 % + 1 × 30 % + 0 × 35 % + 1 × 15 % = 0.65 0 × 20 % + 0 × 30 % + 0 × 35 % + 1 × 15 % = 0.15 \begin{aligned} 0 × 20\% + 1 × 30\% + 0 × 35\% + 1 × 15\% &= 0.45 \\ 1 × 20\% + 1 × 30\% + 0 × 35\% + 1 × 15\% &= 0.65 \\ 0 × 20\% + 0 × 30\% + 0 × 35\% + 1 × 15\% &= 0.15 \end{aligned} 0×20%+1×30%+0×35%+1×15%1×20%+1×30%+0×35%+1×15%0×20%+0×30%+0×35%+1×15%=0.45=0.65=0.15

你可以正向求出新向量的角度和模长:

根据 克拉克变换,将三相占空比 d a , d b , d c d_a, d_b, d_c da,db,dc 投影到 α β \alpha\beta αβ 直角坐标系,从而求出合成向量的模长和角度:

V α = 2 3 ( d a − 1 2 d b − 1 2 d c ) V β = 2 3 ( 3 2 d b − 3 2 d c ) = 1 3 ( d b − d c ) ∣ V ∣ = V α 2 + V β 2 , θ = arctan ⁡ V β V α ( 需根据  V α , V β  的符号确定象限 ) \begin{aligned} V_\alpha &= \frac{2}{3}\left(d_a - \frac{1}{2}d_b - \frac{1}{2}d_c\right) \\[4pt] V_\beta &= \frac{2}{3}\left(\frac{\sqrt{3}}{2}d_b - \frac{\sqrt{3}}{2}d_c\right) = \frac{1}{\sqrt{3}}(d_b - d_c) \end{aligned}\\ |V| = \sqrt{V_\alpha^2 + V_\beta^2}, \qquad \theta = \arctan\frac{V_\beta}{V_\alpha} \quad (\text{需根据 }V_\alpha, V_\beta\text{ 的符号确定象限}) VαVβ=32(da21db21dc)=32(23 db23 dc)=3 1(dbdc)V=Vα2+Vβ2 ,θ=arctanVαVβ(需根据 Vα,Vβ 的符号确定象限)

我们可以轻易地证明刚才提到的 (0.3, 0.5, 0) 是等效于 (0.45, 0.65, 0.15) 的,证明如下(草履虫也能看懂吧我说):

V α ′ = 2 3 ( ( d a + x ) − 1 2 ( d b + x ) − 1 2 ( d c + x ) ) = 2 3 ( d a + x − 1 2 d b − 1 2 x − 1 2 d c − 1 2 x ) = 2 3 ( d a − 1 2 d b − 1 2 d c + ( x − 1 2 x − 1 2 x ) ) = 2 3 ( d a − 1 2 d b − 1 2 d c ) = V α , V β ′ = 1 3 ( ( d b + x ) − ( d c + x ) ) = 1 3 ( d b − d c ) = V β . \begin{aligned} V_\alpha' &= \frac{2}{3}\left((d_a+x) - \frac{1}{2}(d_b+x) - \frac{1}{2}(d_c+x)\right) \\ &= \frac{2}{3}\left(d_a + x - \frac{1}{2}d_b - \frac{1}{2}x - \frac{1}{2}d_c - \frac{1}{2}x\right) \\ &= \frac{2}{3}\left(d_a - \frac{1}{2}d_b - \frac{1}{2}d_c + (x - \frac{1}{2}x - \frac{1}{2}x)\right) \\ &= \frac{2}{3}\left(d_a - \frac{1}{2}d_b - \frac{1}{2}d_c\right) = V_\alpha, \\[4pt] V_\beta' &= \frac{1}{\sqrt{3}}\left((d_b+x) - (d_c+x)\right) = \frac{1}{\sqrt{3}}(d_b - d_c) = V_\beta. \end{aligned} VαVβ=32((da+x)21(db+x)21(dc+x))=32(da+x21db21x21dc21x)=32(da21db21dc+(x21x21x))=32(da21db21dc)=Vα,=3 1((db+x)(dc+x))=3 1(dbdc)=Vβ.


但是我们的技术要求是反过来的,也就是需要一个特定的向量,反过来求出三相电压占比,但是问题来了,既然 (0.25, 0.5, 0) 是等效于 (0.75, 1, 0.5) 的,那就意味着我们的特定向量在非特殊情况下是有无数解的。怎么办?

定死 (1, 1, 1)(0, 0, 0) 的比例就行了,比如只使用 (0, 0, 0) 就会产生前面那张图里的第一个波形,表现为部分截底,属于一种 DPWM,而将两个零向量各按一半时间平均分配,那么就相当于第二个波形,也就是你常能在各类教程里看到的 SVPWM 波形。

我这里做了一张动图来展示两个零向量在不同占比下产生的 svpwm 波形:
在这里插入图片描述

既然这些波形是 等效 的,那么如何选取呢?大部分教程会说 T0 和 T1 平均分配,不讲为什么,而且由于同时还在讲什么中心对称、七段五段发波顺序,我估计大部分学习者或者二手教程作者都因此处于盲人摸象中,搞混了一些相关概念。这里对相关内容进行一下梳理:

①、

我们在硬件上就是通过配置 pwm 输出占空比来等效特定电压,而且硬件上可以直接配置为中心对齐模式,所以 没有必要按照什么对称序列发波顺序排列向量,落实到硬件上就是直接乘以时间百分比就可以了!

特定发波顺序能 减少开关次数 是因为一个周期内高电平期间 波形连续(硬件产生的波形高电平本身就是连续的),而不是因为中心对称,下图的两个波形是等效的,也都是中心对称的,但是左边的波形同电平部分是连续的,所以能减少开关次数(紫框标注):
在这里插入图片描述

硬件上想产生一个周期内不连续(挖孔)的波形 或者 左右部分偏移(不完全中心对称以及不左右靠边)的波形其实还更麻烦呢……(也许可以频率翻几倍然后每次用 dma 赋值?乱想的,没试过)

有时候为了便于 adc 采样,可能要对波形进行左右偏移或者挖孔,有些硬件可以做到,不在这里讨论范围内

中心对称能降低谐波分量,挖孔另说

②、

中心对称是硬件的活,与 T1 和 T0 的占比无关,都能产生中心对称的波形:
在这里插入图片描述

左右两个波形前面已经证明是等效的了,而且你会发现如果只使用 U0 或 U1 其中之一作为零向量,还能减少开关次数(紫框),使用全 U0 作为零向量就对应波形的平底,全 U1 作为零向量就是平顶,期间一个是保持下管常通,一个是保持上管常通,都能降低开关损耗。

中心对齐能避免左/右对齐情况下存在的同时开关多个管子,也就是避免紫框位置对齐,占空比相同除外。

③、

T0 与 T1 平均分配可以降低谐波分量:
在这里插入图片描述


走到这里,我们就可以直接用 攻读小学学位 时学到的 加权平均法 来分配左右向量和零向量的占比了。

首先根据如下公式从目标向量的角度 θ 和模长 r 投影到左右向量,得到的左右向量长度占比可以直接作为左右向量的时间占比(0~1)(读者自证不难):

T 右 = r ( cos ⁡ θ − sin ⁡ θ 3 ) T_右 = r \left( \cos\theta - \frac{\sin\theta}{\sqrt{3}} \right) T=r(cosθ3 sinθ)
T 左 = 2 r sin ⁡ θ 3 T_左 = \frac{2r \sin\theta}{\sqrt{3}} T=3 2rsinθ

然后时间加权平均:
U a = T 右 × 右 ⃗ [ 0 ] + T 左 × 左 ⃗ [ 0 ] + T z e r o × 零 ⃗ U b = T 右 × 右 ⃗ [ 1 ] + T 左 × 左 ⃗ [ 1 ] + T z e r o × 零 ⃗ U c = T 右 × 右 ⃗ [ 2 ] + T 左 × 左 ⃗ [ 2 ] + T z e r o × 零 ⃗ U_a = T_右 \times \vec{右}[0] + T_左 \times \vec{左}[0] + T_{zero}\times \vec{零}\\ U_b = T_右 \times \vec{右}[1] + T_左 \times \vec{左}[1] + T_{zero}\times \vec{零}\\ U_c = T_右 \times \vec{右}[2] + T_左 \times \vec{左}[2] + T_{zero}\times \vec{零} Ua=T× [0]+T× [0]+Tzero× Ub=T× [1]+T× [1]+Tzero× Uc=T× [2]+T× [2]+Tzero×

其中 T z e r o T_{zero} Tzero 就是可以分配给零向量的时间占比(权重),关系如下:

T z e r o + T 右 + T 左 = 100 % ∴ T z e r o = 1 − T 右 − T 左 T_{zero} + T_右 + T_左 = 100\%\\ \therefore T_{zero} = 1 - T_右 - T_左\\ Tzero+T+T=100%Tzero=1TT

可分配给 U0(000) 和 U1(111) 的时间:
T z e r o = T U 0 + T U 1 T_{zero} = T_{U0} + T_{U1} Tzero=TU0+TU1

所以零向量的时间分配可以展开为:

T z e r o × 零 ⃗ = T U 0 × 0 + T U 1 × 1 = T U 1 T_{zero}\times \vec{零} = T_{U0} \times 0 + T_{U1}\times 1 = T_{U1} Tzero× =TU0×0+TU1×1=TU1

c 代码如下:

// 可以自行设置两个零向量中 U1 的占比 (0~1):
float u1_u0 = 0.5f; // 这里设置为对半开

// 这里我们将六边形内接圆半径作为单位 1
// 需要将输出限制在六边形内接圆范围内(不能超过 1),否则输出会被截顶
// 内接圆半径为六边形边长的 sqrt(3)/2 倍
#define SQRT3DIV2   0.8660254037844386467637231707529f
#define SQRT3       1.7320508075688772935274463415059f

const uint8_t six_vector_list[6][3] = {
    [0] = {1, 0, 0},
    [1] = {1, 1, 0},
    [2] = {0, 1, 0},
    [3] = {0, 1, 1},
    [4] = {0, 0, 1},
    [5] = {1, 0, 1},
};

void test_svpwm(void){
    
    // 目标向量角度
    float angle_now = 0.0f;
    // 目标向量模长
    // float r = 0.5f; // 50%
    float r = 1.0f; // 100%
    // 扇区内的角度
    float theta; // θ = angle_now % 60度
    // 扇区
    uint8_t sector;
    uint8_t sector_next;
    // 最终输出的向量
    float vector_output[3];
    float k1, k2; // 左右向量时间占比系数
    float cos_theta, sin_theta;
    // 左右向量以外的零向量可分配的时间
    float t_zero;
    // 零向量中 U1 可占有的时间占比,U0 不用管,因为是 0,所以数值上相当于没加
    float t_zero_u1;

    while(1){
        theta = myMod(angle_now, 60.0f); // θ = angle_now % 60度
        theta = myAngle2Rad(theta); // 转为弧度

        // 计算扇区
        /*
        if(angle_now < 180.0f){ // [0.0f, 180.0f)
            if(angle_now < 60.0f){
                sector = 0;
            }else if(angle_now < 120.0f){
                sector = 1;
            }else {
                sector = 2;
            }
        }else { // [180.0f, 360.0f)
            if(angle_now < 240.0f){
                sector = 3;
            }else if(angle_now < 300.0f){
                sector = 4;
            }else {
                sector = 5;
            }
        }
        */
        sector = (uint8_t)(angle_now / 60); // 不用条件判断,直接抹小数
        sector_next = (sector + 1)%6;

        // 向量合成
        cos_theta = cosf(theta);
        sin_theta = sinf(theta);

        // 计算左右两个基向量的时间占比
        // k1 = r*(cos_theta - sin_theta/sqrt(3)) * SQRT3DIV2;
        // k2 = 2*r*sin_theta/sqrt(3) * SQRT3DIV2;
        k1 = r/2*(SQRT3 * cos_theta - sin_theta);
        k2 = r*sin_theta;

        // 计算可分配给零向量的时间占比
        t_zero = 1.0f - k1 - k2; // t_zero 不会小于零,读者自证不难

        // 计算 U1 可分配的时间百分比
        t_zero_u1 = u1_u0 * t_zero;

        // 加权平均,t_zero_u1 * 1 = t_zero_u1,t_zero_u0 * 0 = 0
        vector_output[0] = k1 * six_vector_list[sector][0] + k2 * six_vector_list[sector_next][0] + t_zero_u1;
        vector_output[1] = k1 * six_vector_list[sector][1] + k2 * six_vector_list[sector_next][1] + t_zero_u1;
        vector_output[2] = k1 * six_vector_list[sector][2] + k2 * six_vector_list[sector_next][2] + t_zero_u1;

        // 打印最终输出向量
        printf("%f, %f, %f\n", vector_output[0], vector_output[1], vector_output[2]);
        // 每次增加一度
        angle_now += 1;
        if(angle_now >= 360){ // 一个周期后结束程序
            return ;
        }
    }
}

如果你使用全 U0,那么还能省去算零向量的步骤,虽然节约不了多少时间,但是能减少开关损耗。


最后提一个很多人会产生疑惑的地方,也就是 为什么马鞍波电压会产生正弦波电流?

在这里插入图片描述

图片出处:【Bilibili】无需公式推导,SPWM零序注入可视化演示

这里的马鞍电压波形是三相 对地 电压。但是电机中心连接点 O 的电压也是跟着上下移动的,所以绕组 AO、BO、CO 的电压差波形变化就还是旋转向量所投影出来的正弦波,既然加在绕组两端的电压是正弦波,那么产生的电流自然就也是正弦波。
我们不是为了使用马鞍波而产生马鞍波,而是为了在旋转向量的时候对电压上下限进行避让而整体挪动三相电压,利用了前面证明的共模分量不敏感特性,只是这种避让恰好让三相对地电压形成了非正弦波形例如马鞍波而已。不同的 svpwm 算法产生的避让动作是不一样的,马鞍波只是其中一种。

5.1、SVPWM 收尾

4、空间向量调制(SVPWM) 里似乎已经解决了目标电压向量到三相电压的过程,但并不代表就能直接变化模长和相位来调整当前电流向量。第 4 小节的算法只是为了更直观呈现电压向量减小理解负担,所以使用了模长和角度作为参数实现了算法,那套算法只适合强拖旋转,并不利于解耦。
在这里插入图片描述

例如上图,你需要对当前的紫色向量进行变化,那么就需要执行两次线性操作,也就是需要将当前向量投影到沿目标向量方向(Q轴)和垂直目标向量方向(D轴)执行两次类 4 小节最后的算法而后进行叠加。
这样会比较消耗算力。实际上只需要将目标 qd 电流通过一次反投影(反帕克变化)回静止直角坐标系(αβ 轴)即可。
如此就能直接操作 αβ 轴的电压来影响 qd 轴的电压进而影响电流,这个操作电压影响电流的过程就交给了 pi 控制器,分别给 αβ 轴分配一个 pi 控制器就能自动完成这一切,最后将 pi 控制器给出的 αβ 轴电压传入 svpwm 生成算法给出三相电压就行了,svpwm 算法从 αβ 轴到三相电压的过程也就是个反投影,然后按照第 4 小节最后的补充说明的避让思想对电压整体进行上下移动即可,算法如下:

void svpwm(float V_alpha, float V_beta, float V_outputABC[]){
    // 这里没有对输入进行检查

    // 将 Vα,Vβ 转换为三相占空比(标幺化,范围0-1)
    // 就是投影而已,专有名词反克拉克变化
    float u = V_alpha;
    float v = 0.5f * (-V_alpha + SQRT3 * V_beta);
    float w = 0.5f * (-V_alpha - SQRT3 * V_beta);
    
    // 求最大值和最小值
    float umin = fminf(u, fminf(v, w));
    float umax = fmaxf(u, fmaxf(v, w));
    
    // 计算避让所用共模量
    float offset = 0.5f * (umin + umax);
    
    // 得到占空比(标幺化到0~1)
    V_outputABC[0] = u - offset + 0.5f;
    V_outputABC[1] = v - offset + 0.5f;
    V_outputABC[2] = w - offset + 0.5f;
    
    // 限幅
    if(V_outputABC[0] < 0) V_outputABC[0] = 0; if(V_outputABC[0] > 1) V_outputABC[0] = 1;
    if(V_outputABC[1] < 0) V_outputABC[1] = 0; if(V_outputABC[1] > 1) V_outputABC[1] = 1;
    if(V_outputABC[2] < 0) V_outputABC[2] = 0; if(V_outputABC[2] > 1) V_outputABC[2] = 1;
}

5.2、PID 引入

2.1、电压、电流、磁场与力 这一小节,我们分析出,一般情况下,要让推动转子旋转的力作用最大化,就要让这个力完全发挥在切向方向上,那么我们就要时刻根据转子的位置调整磁场的方向,尽可能保持 90 度夹角(没有 D 轴分量),为了控制力的大小,同样也要调整磁场的大小。那么问题就来到如何使磁场发生变化。

2.2、电感与磁链 这一小节,我们得到了一个结论,就是 d Ψ = d U ⋅ d t d Ψ = dU\cdot dt dΨ=dUdt,要产生磁链变化,那么就要产生电压变化,想让磁链往某个方向增减大小,也可以对该方向上的电压大小进行操作。svpwm 方法则是提供了一种产生一个特定电压向量的方法。

接下来要解决的是如何产生一个合适的 d U dU dU 的问题。虽然我们证明 d U dU dU 在短时间 d t dt dt 可以看作与 d Ψ dΨ dΨ 是线性的,但是而且因为负载变化,线圈发热等等原因,电阻也会变化,我们可能不能在 d t dt dt 时间后产生预计的磁链变化,也就是还会有额外的未建模偏差。
我们要追踪的就是 q 轴和 d 轴两个方向上的电流的偏差,对这两个方向上的电压进行操作产生 d U dU dU,逼近目标电流,这个任务就可以交给两个 pid 控制器,让它们去动态调整。

  • P:比例。现在
  • I:积分。过去
  • D:微分。未来

P 就是根据现在到目标的差值按比例发力。
但是在当前值与目标值差距较小时,P 的作用就也会被缩小,所以当前值可能会停在目标值附近,称之为静差/稳态误差。
I 就是对过往到现在的差值进行积累,哪怕它很小,经过积累,也会聚沙成塔,推一把来消除静差。
D 就是预判未来,通过微分计算走向的斜率,势头过猛时往回拉一把,避免发力过猛导致冲过头。

5.3、PID 落实

①、P 调节

当我们的当前电流大小与目标电流大小差距越大,就应该以更大程度输出电压向量,产生更大的 d U dU dU,在 d t dt dt 时间后,电流大小变化量也就更大。

②、I 调节

当我们的电流大小越靠近目标电流大小,那么 p 调节发力就会越来越小,可能会产生一个稳态误差。
i 调节的工作就是不断对误差进行积分,误差积累越久产生的“推力”就越大。

③、D 调节

在 foc 实践中,d 调节并不常用。因为两根轴的电压和电流的关系都是一阶控制系统,在 d t dt dt 时间内可以看作是线性相关的,除非你将 kp 系数调得过大,否则天然就不容易产生过冲震荡,这时候再来个 d 调节往后拉一把反而有点太保守了,而且还容易受到高频噪声的影响。

在这里插入图片描述

只通过 i 控制,也就是积分,也可以输出目标电流,但是从上面这张动图可以看出,电流的暂态时间是与电压差无关的,不过电压差可以调节电流改变的斜率,故而 p 调节可以在差距较大时输出较大的电压差,实现电流的快速响应。

6、总结

所以,FOC 的目的就如它的名字,磁场定向控制。它的目的是磁场,手段是电压,技巧是投影,执行靠 PID。

另外,力的作用是相互的,在不带负载的情况下,可能不能输出目标电流大小,也就是你用再快的速度挥拳头,打在棉花上,你的拳头也受不到多大的力。表现在电机上,就是电流升不上去,转子还不断加速甚至产生抖动。
想要丰富功能,比如位置、速度和力矩等控制就靠串级 PID。

六、FOC 实践

如果不关心封装驱动,那么可以直接跳到 3、硬件 I2C busy bug

1、平台无关驱动封装

使用宏定义不方便创建多实例,直接将 hal 库什么的写死在驱动里面也不方便移植到 stm32 以外的设备,所以我们需要有一些稍微现代一点的封装编写方法。

以本项目中使用的 mt6701 磁编码器为例,将 i2c 读取抽象为寄存器读取,让用户提供自己平台的读取 i2c 的回调即可实现平台无关的驱动封装:

struct mt6701_stu {
    uint8_t addr;
    uint8_t data_buffer[2];

    // 下面这些是用户自定义平台回调,而且不同实例可以使用不同的读取回调
    // 这里抽象为寄存器读取,而非 i2c 读取,方便兼容 spi
    void (*read_reg)(struct mt6701_stu *mt, uint8_t reg_addr, uint8_t *reg_buffer, uint8_t reg_num);
    void (*read_reg_dma)(struct mt6701_stu *mt, uint8_t reg_addr, uint8_t *reg_buffer, uint8_t reg_num);
};

// 阻塞读取
void mt6701_read(struct mt6701_stu *mt){
    // 调用用户创建的自定义回调
    mt->read_reg(mt, 0x03, mt->data_buffer, 2);
}

// 非阻塞读取
void mt6701_read_dma(struct mt6701_stu *mt){
    // 调用用户创建的自定义回调
    mt->read_reg_dma(mt, 0x03, mt->data_buffer, 2);
}

上面这些是磁编码库提供的内容,用户读取时只需要调用通用的 api 即可,移植时只用修改自定义回调里面的内容,以下是使用样例:

// 创建磁编码器对象
struct mt6701_stu mag_encoder_wheel = {
    .addr = MT6701_DEFAULT_ADDR,
    // 设置自定义回调
    .read_reg = wheel_mag_e_read_reg,
    .read_reg_dma = wheel_mag_e_read_reg_dma,
};
// mt6701 用户平台自定义回调,移植时只需要根据平台修改不同的回调内容,而不需要修改库的任何内容
void wheel_mag_e_read_reg(struct mt6701_stu *mt, uint8_t reg_addr, uint8_t *reg_buffer, uint8_t reg_num){
    HAL_I2C_Mem_Read(&hi2c1_handler, mt->addr, reg_addr, 1, reg_buffer, reg_num, 1000);
}
void wheel_mag_e_read_reg_dma(struct mt6701_stu *mt, uint8_t reg_addr, uint8_t *reg_buffer, uint8_t reg_num){
    HAL_I2C_Mem_Read_DMA(&hi2c1_handler, mt->addr, reg_addr, 1, reg_buffer, reg_num);
    // 这里仅作演示,虽然这个函数名字带有 dma,但是它的发送地址部分是阻塞的,所以如果需要真正的非阻塞,那么要分为发收两段 dma
}

// 创建第二个实例
struct mt6701_stu mag_encoder_2 = {
    .addr = MT6701_DEFAULT_ADDR,
    // 设置自定义回调
    .read_reg = mag_e_read_reg2,
    .read_reg_dma = mag_e_read_reg_dma2,
};
// 自定义回调
// ...

void main(void){
    // 用户使用时只需要调用通用 api
    mt6701_read(&mag_encoder_wheel); // 阻塞读取方向盘编码器
    mt6701_read_dma(&mag_encoder_wheel); // dma 读取读取方向盘编码器

    mt6701_read_dma(&mag_encoder_2); // dma 读取读取另一个编码器
}

这里并不是一个健全的 mt6701 库,因为它还有一些写 eeprom 配置或者按压功能等等相关的内容,但是我的项目里用不到,只写了读取角度,至少它的可移植性和多态能力已经够用了。

2、电机驱动封装

这一小节只是顺上一小节的势提及如何使电机驱动开销更小。如果您对更高效的驱动封装暂时不感兴趣,那么可以跳转到下一小节: 3、硬件 I2C busy bug

你当然能够参照前面的 mt6701 的库函数实现一个能提供多实例能力+平台无关的电机驱动。也就是一套代码能够驱动多个电机而且便于移植,例如如下的样例:

struct motor_stu {
    uint8_t motor_id;

    // 各相电流
    float current_u;
    float current_v;
    float current_w;

    // 以下全是需要用户根据自己平台实现的回调函数

    // 设置各相 pwm 占空比的用户回调
    void (*set_duty_u)(struct motor_stu *motor, float duty);
    void (*set_duty_v)(struct motor_stu *motor, float duty);
    void (*set_duty_w)(struct motor_stu *motor, float duty);

    // 将各相 adc 数值转换为电流值的用户回调
    void (*trans_current_u)(struct motor_stu *motor, uint32_t adc_val);
    void (*trans_current_v)(struct motor_stu *motor, uint32_t adc_val);
    void (*trans_current_w)(struct motor_stu *motor, uint32_t adc_val);
};

// 这是驱动对外暴露的 api,专门用于设置某一电机的 u 路的 pwm 占空比
void motor_set_duty_u(struct motor_stu *motor, float duty){
    motor->set_duty_u(motor, duty); // 调用用户配置的自定义回调
}
// ……

// ======== 以上是驱动库的内容,以下是用户的自定义内容 ========

// 用户自定义的设置电机 1 的 u 路 pwm 占空比回调函数
void motor1_set_duty_u(struct motor_stu *motor, float duty){
    // 在回调中,用户根据自己所用的平台写入特定操作
    // 将占空比乘以重载值得到比较值,赋值给寄存器完成占空比设置
    TIM1->CCR1 = (uint32_t)(duty*1234);

    // 在用户自定义回调里面,用户可以做任何事情,比如打印
    // printf("set u duty to: %f\n", duty);
}

// 用户创建的电机对象
struct motor_stu motor_1 = {
    .id = 1,

    .set_duty_u = motor1_set_duty_u, // 配置为自己设置的自定义回调
    // ...剩余的自定义回调
}

int main(void){

    // 使用时,只要调用通用的 api,即可完成对某一电机的某一路的 pwm 占空比设置
    motor_set_duty_u(&motor1, 0.5);

    motor_set_duty_w(&motor2, 0.71);

    return 0;
}

如此,便是一般的面向对象的驱动设计,可以提供多实例能力,而且是平台无关好移植的,用户能清晰知道要做哪些自己平台的特定操作来实现功能。

但是,你知道的,回调函数会慢于宏定义,需要对函数指针解引用然后跳转,上面的程序每次设置 duty 时需要跳转两次,第一次是调用通用 api(motor_set_duty_u),这个是可以预测的,缓存更好命中,对速度影响较小,而且可以改写为内联函数。但是里面的调用回调就不好说了,尤其是对于这里选型使用的 5 等待 flash 的主控,那会慢很多了,而且自定义回调函数还不能使用内联函数。你当然能将部分函数加载到 ram 中运行,但是总不能啥函数都一股脑加进去吧,对象一多那就很麻烦了,而且还挤占 ram 空间。

下面是一个常见的用宏定义来封装一些操作的样例:

// 设置电机 1 的 u 路的 pwm 占空比的宏
#define MOTOR1_SET_PWM_U(duty)  do{\
                                    TIM1->CCR1 = (uint32_t)(duty*1234);\
                                    printf("set u duty to: %f\n", duty);\
                                }while(0)

// 将 adc 采集到的数值转换为电流
#define MOTOR1_TRANS_CURRENT_U(adc_value) (adc_value*0.123)

// 存储电机信息的结构体
struct motor_stu {
    uint8_t motor_id;

    // 各相电流
    float current_u;
    float current_v;
    float current_w;
    // 其他数据
    // ...
};

// 创建一个电机对象
struct motor_stu motor1 = {.id = 1};

int main(void){
    // 设置电机 u 路 pwm 占空比为 50%
    MOTOR1_SET_PWM_U(0.5);
    // 转换电机 u 路电流
    motor1.current_u = MOTOR1_TRANS_CURRENT_U(adc_buffer[0]);

    return 0;
}

但是它并不能形成一个单独的驱动,因为里面的所有东西都是你通过宏定义创建的,你对外也只能暴露一个 struct motor_stu,其他用户移植的时候根本不知道要创建哪些需要根据不同平台自定义的操作。

所以有没有能将 宏定义/内联函数的高效性回调的多态性 结合起来的方式呢?有的兄弟有的。

当然是使用宏魔法来实现,例如下述例程:

struct ltx_bldc_stu {
    uint8_t id;

    float current_u;
    float current_v;
    float current_w;
};

// ================== 配置 设置 pwm 占空比 用户内联回调宏 ==================
#define ltx_bldc_config_duty_u_cb(motor, code)\
    ltx_inline void motor##_set_duty_u(float duty) code
// ...

// ================== 设置占空比 api ==================
#define ltx_bldc_set_duty_u(motor, duty)\
    motor##_set_duty_u(duty)
// ...

// ======== 以上是驱动库的内容,以下是用户的自定义内容 ========

// 创建一个内联函数作为 motor1 的设置 u 路 pwm 占空比回调:
ltx_bldc_config_duty_u_cb(motor1, {
    TIM1->CCR1 = (uint32_t)(duty*1234); // 直接操作寄存器,响应更快
    printf("set motor1 u duty to %d", duty); // 打印
})
// 创建一个内联函数作为 motor2 的设置 w 路 pwm 占空比回调:
ltx_bldc_config_duty_w_cb(motor2, {
    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_3, duty*5678); // 调用其他库
})

// 创建两个电机对象
struct ltx_bldc_stu motor1 = {.id = 1};
struct ltx_bldc_stu motor2 = {.id = 2};

// 使用样例:
int main(void){

    // 使用时,只要调用通用的 api,即可完成对某一电机的某一路的 pwm 占空比设置
    ltx_bldc_set_duty_u(motor1, 0.5); // 设置电机 1 的 u 路 pwm 占空比为 50%
    ltx_bldc_set_duty_w(motor2, 0.72); // 设置电机 2 的 w 路 pwm 占空比为 72%

    return 0;
}

这样使用起来就和回调方式几乎相同(但是不支持运行时更换回调),而且还能利用上宏定义的高效性。这里只是因为电机需要快速响应,相关代码也是需要跑在中断里的,所以才不得不使用宏魔法,对于一般的驱动设计,例如显示屏什么的,当然还是回调函数更标准好用,也方便编辑器跳转代码。

如果你使用 cpp 或者 rust,那么他们会提供一些零成本抽象的语言特性便于你实现类似要求,这里仅作提及不展开。

3.1、硬件 I2C busy bug

stm32 的硬件 i2c bug 久仰大名,gd32 紧随其后,py32 不落下风,at32 听说也有,受不了了。还有 stc 也别想跑,当年买示波器就是因为 stc 的硬件 i2c 调不通,后面了解到有 bug。。

为了省引脚,自然是没办法用 spi 来伺候一个磁编码器了,而软件 i2c 实时性完全路边一条。
虽然 mt6701 可以输出 pwm 根据占空比来分辨角度,但是还是得用 i2c 写一下配置 eeprom,要么花三个脚,要么写好再装上去。

板子都打好了,只能硬着头皮用了。

下面总结一下我遇到的 i2c 出错情况和处理办法:

情况①:i2c 中断没有及时响应

具体现象就是一般不会出现问题,可以正常读写,但是一旦出些优先级更高的中断或者其他地方关了一下中断导致稍微推迟了 i2c 中断的执行,那么 i2c 就会出现 busy。

据说是 ip 设计为了兼容多主机,也就是有时候会认为有其他主机在操作总线,所以主从双方都在等对方下一步的动作,说是也有可能是 i2c 时钟延拓导致的,但是实测关闭时钟延拓也会有问题,那么锅应该还是多主机的兼容设计,谁知道为什么不加个开关
所以说异步通信虽然虽然不好位定时,但是受到干扰无非是数据出错,可以存在超时机制;而同步通信因为时序受时钟线控制无法实现超时机制,在受到干扰后反倒容易导致通信链路宕掉

a、堵
对于电机项目,pwm 以 20kHz 运行,那么自然是要每秒钟产生 20k 次中断去采样电流并转换,那么一旦开启这么高频率的中断就很容易影响 i2c 的时序,所以 i2c 的中断优先级是务必要开到最高的。
b、疏
但是还有个问题,就是其他地方如果关中断期间正好碰上了 i2c 中断,那么也会导致 i2c 时序受到影响,进而判断为 busy,所以还是要有从 busy 恢复出来的手段。

void SysTick_Handler(void)
{
    HAL_IncTick();

#if 1
    // 受不了了,画蛇添足的 i2c 多主机兼容 ip 设计你还我 cpu 时间来
    if(flag_i2c_wdg){ // i2c 看门狗开启
        if(flag_i2c_wdg == 1){ // i2c 未更新
            // 修复 i2c
            // 强制生成停止位
            SET_BIT(I2C1->CR1, I2C_CR1_STOP);
            __HAL_UNLOCK(&hi2c1_handler);
            hi2c1_handler.State = HAL_I2C_STATE_READY;
            // 重新发起 i2c 读取
            HAL_I2C_Master_Transmit_DMA(&hi2c1_handler, MT6701_DEFAULT_ADDR, &mag_reg_addr, 1);
        }
        // 重置计数器
        flag_i2c_wdg = 1;
    }
#endif

    ltx_Sys_tick_tack();
}

没招了,只能在收发的时候增加计数值,然后每毫秒在 systick 中断里面检查是否停止了收发,如果停止了,就判定为 busy,那么强制生成一个停止信号来让外设结束等待。或者也可以用 wwdg 来实现,总之就这样吧。

i2c 优先级不够或者关中断期间正好推迟了 i2c 中断导致的 busy:
在这里插入图片描述在这里插入图片描述在这里插入图片描述

情况②:受到外界干扰

设备地址 0x06,寄存器地址 0x03
在这里插入图片描述在这里插入图片描述在这里插入图片描述

受到干扰情况下产生停止位是不能修复 i2c 的,因为已经停止了。

实测发现,明明通信停止了,却不能产生 dma 完成中断,直接发起下次通信也会出问题。
猜测 dma 和 i2c 的硬件状态机可能发生了错位,此时 dma 还在收发状态,发起下次通信是不能正常运行的,说明这时候不是 i2c busy,而是 dma busy,那么必须要终止上次的 dma 传输才能开启下次收发。
查看源码发现 hal 库的终止 i2c 收发已经包含了产生停止信号和关开 dma 等等相关的内容,所以直接调用 hal 库会简单一点:

if(flag_i2c_wdg){ // i2c 看门狗开启
    if(flag_i2c_wdg == 1){ // i2c 未更新
        // 修复 i2c
        // 强制停止
        HAL_I2C_Master_Abort_IT(&hi2c1_handler, MT6701_DEFAULT_ADDR);
        // 重新发起 i2c 读取
        HAL_I2C_Master_Transmit_DMA(&hi2c1_handler, MT6701_DEFAULT_ADDR, &mag_reg_addr, 1);
    }
    // 重置计数器
    flag_i2c_wdg = 1;
}

情况③:SDA 被从机拉低

只碰到过一次,没用示波器抓到。具体现象就是 SCL 高电平,SDA 低电平,单片机发送会阻塞在 hal 库内部的等待 busy 位清除,无法停止传输,重启单片机和发送停止位等等操作都没效果,说明 SDA 是被从机拉低的,所以主机想释放 SDA 产生停止信号无法成功。
可能是从机死机,也可能是受到干扰导致通信出错,主机认为通信完毕不主动产生 SCL 下降沿,但从机认为通信还没结束,也就不能在 SCL 高电平期间切换 SDA 电平状态,导致卡死,主机想生成停止位也不行。
可以参考其他一些博客中的软件操作 gpio 发送九个 SCL 时钟,让从机结束通信。

示波器小技巧

如何让示波器捕捉上图的 i2c 空闲呢?几个小时才会偶尔产生一次问题,而且你的手速也不足以支持你在出问题的时候瞬间按下暂停。
在示波器的触发设置里,可以设置超时触发,开启单次(single)触发,当边沿变化超过一定时间没有产生,那么示波器就会触发暂停,嗯。

3.2、硬件 SPI sck bug

所以我还是放弃了 I2C。
虽然 busy bug 缓解了,但是因为数据频率只有 5kHz,有点跟不上电流环,第二板 pcb 改用了 spi 接口。引脚 - -

非常倒霉,硬件 spi 也有 bug,也就是有概率会出现无法完成 dma 接收的情况,也和 i2c 一样,当接收中断被微小推迟,可能会导致时序受到影响无法完成接收。此时 sck 会不断发送时钟,而且就是进不了接收完成中断,即便使用 HAL_SPI_Abort 和 HAL_DMA_Abort 终止接收重新发起 dma 接收,也会返回 dma busy

堵不住,也疏不通,所以不用 dma 了。为了不阻塞 cpu,这里直接将 spi 配置成 16bit 模式,一次中断接收即可完成两个字节的数据接收,也不需要 dma 的参与了,有个小问题就是 mt6701 在 spi 模式下是会返回三个字节的,最后一个字节是 crc 校验值,所以只能舍弃了

甚至可以看到中断接收模式下要求接收 16bit 它还是忍不住多发送了 16bit 时钟,就这样吧
在这里插入图片描述

4、电流采样

在硬件上,项目里选择的是低侧两电阻采样,基于 kcl,流入等于流出,换算出另一相电流。
因为是低侧电阻,所以需要在下管导通的时候进行采样才能采集到有效的数据,极端情况下,可能某个管子占空比较高,导致低电平时间不足以采样,那么可能需要三电阻进行交替参与。不过一般不会有这么极端就是了,只要你电压不拉满,至少方向盘而言没有必要开那么高的输出。

为了让 adc 能够在下管导通的时候进行采样,那么就需要让产生 pwm 的定时器和 adc 能够进行某种配合。
在这里插入图片描述

可以配置某一通道信号更新事件作为 adc 触发,但是如果选择 ch123 其一的话,会有如下问题:

  1. 总会有输出为 0% 或者 100% 占空比的情况,那么就没有边沿,就不会产生更新事件,导致采样偶尔缺失。
  2. 这款芯片只支持上升沿触发,例如选择 ch1,产生上升沿后 ch1 必然是高电平,那就一定采集不到被选择的通道的电流
  3. 占空比时刻在变化,例如选择 ch1,那么当 ch1 占空比低于 ch2 的时候,不但 ch1 采集不到,ch2 也采集不到,因为他们下管都是关断的

所以可以选择 ch4 专门用来给 adc 产生更新事件,不用输出到某一引脚,直接固定 ch4 为最大比较值减一,大概率其他通道此时都在低电平:
在这里插入图片描述

图上标的是下降沿,但这款芯片只支持上升沿,所以会推迟一点点,导致最高可用占空比会微微减小

5、调试接口

引脚资源十分甚至九分紧张,大部分引脚都用来做按键了,甚至按键都不太够用。所以 V0.1 版本的板子没有引出串口进行调试。使用的是 segger rtt 进行调试信息输入输出。
它使用的是烧录调试用的 swd 接口,也就是不会造成额外的引脚消耗。本质上这个 segger rtt 就是通过创建一个 buffer 在 ram 里面划分一块内存,对这块内存读写来收发数据,上位机通过 swd 接口也可以操作这块内存。所以说理论上它的速度非常快,只需要读写内存,不需要串口之类的外设收发,而且 buffer 还能保存一定量的数据。
虽然是 segger 出品的,但是它并不绑定 jlink,在本项目中使用的就是 daplink,通过 openocd 使用 tcp 进行转发,所有支持 tcp 的串口助手都能进行调试,就不必一定要用 segger 提供的 rttviewer 了,比如可以使用擅长波形显示的串口助手例如 vofa+ 等等。

具体可以参考这篇博客:Clion中OpenOCD结合SEGGER RTT高速输出调试信息

在一般项目中直接按照这篇博客的配置就够了,但是在电机项目里是必须要修改配置的,否则会明显丢数据。实测 20kHz 的三相电流打印每固定隔一小段就丢两千个数据,波形会呈现出阶梯状跳跃,修改 buffer 大小和 daplink 速度都没有变化。

这是因为 openocd 的 rtt 功能默认是按照 10Hz 左右的频率去更新数据的,更新间隔要来到大约 100ms,实测稳定丢失数据,所以需要修改 cfg 文件来增加如下内容提高更新频率:

# 更新间隔默认 100ms 左右,可以将更新间隔设置为 10ms 或者更小
rtt polling_interval 10

改 1ms 也照样丢数据。

虽然说 rtt 不用外设收发,但是实际上对系统响应性影响会比 串口+dma 模式大得多,串口可以设置多个 buffer,写 buffer 前只需要关中断操作 buffer 标志位,屏蔽中断时间很短,填入 buffer 内存可以不用关中断,此时 dma 正在操作另一个 buffer。
而 rtt 填入 buffer 是会关闭中断的,否则填入期间调试器读取到的内存是会有问题的。如果想实现多 buffer 以及 dma 收发等操作的话,例如修改 rtt 源码而后分配一个 dma 来实现 mem2mem 操作恐怕也不行,dma 发送期间同样不能开中断,不然也会被调试器打断,那么 dma mem2mem 和 cpu 直接搬运相比也没有任何好处…

所以,既然 rtt 的 printf 在写内存的时候是要关中断的,那么打印频率高的话会影响系统正常运行,例如导致 adc 中断回调运行间隔不稳定,以及硬件 i2c 对时序很敏感,所以读出来的数据也可能产生跳变,进而导致 pi 控制器的 p 项输出很大的电机抖动,这不是调大缓冲区或减小更新间隔就能解决的,总之对于高频打印很难用就是了,如果不是为了扣成本,那么最好还是用串口什么的吧。为了精准调试,不得不重新绘制了 V0.2 版本的板子,加入了一个 4M 串口,程序里可以通过宏定义切换 rtt 或者串口输入输出。

6、调试命令

我在 ltx 里面实现了一套简单的命令处理组件。不是 shell 那种终端,更像是 mc 里面的指令。
指令匹配后,即可调用创建的指令处理回调。功能较为简单,不做过多介绍。连接设备后,使用 /help 命令即可列出所有预设的命令,最好还是直接看源码的中文注释吧。

7、调试输出

在软件选型时,选用了 ltx 裸机调度器,是从它作为裸机切换开销低所以不选用 rtos 以及架构上设计获取就绪任务为弹出队列头的高效性所以不选用其他裸机调度器出发的。

它还有一张王牌,就是发布订阅机制。ltx 里面的事件并不依附于任务存在,而是作为一个话题组件提供事件发布订阅机制。一个事件(话题)的发布,可以有 0 到无数个订阅者,这些订阅者都可以创建一个自定义回调,一旦发布事件,那么这些回调都会被调用。
我们可以在数据产生后发布一个事件,那么将数据处理和数据打印分为两个订阅者,如果需要打印,那么就开启打印回调对数据产生事件的订阅,不需要了就取消订阅。故而不需要每个任务定期去 poll 或者每个任务自己去轮询标志位,只有数据更新的时候才会发布事件执行相应回调。

在调试命令里面,有一个 /print 命令,可以用它来列出所有被配置的更新打印数据,并且可以直接用此命令开关某些数据的更新后打印。另外,还有一个 /param 命令可以读写某些参数。

8、对齐电角度与机械角度

电角度与机械角度的零度并不一定是对齐的:
在这里插入图片描述

绿色为机械角度,红色为电角度,电角度转过七圈,电机旋转一圈。

所以需要软件上增加偏移来进行对齐,偏移多少那就是开机后初始化算法根据实际情况采样来决定了。
项目里是通过强拖旋转电机分别停在七个极对取平均值来决定机械角度偏移值的。

软件上就是写一个状态机来实现较准步骤的运行逻辑,对这部分感兴趣可以直接看项目源码。

依然是 ltx 的广告环节。
如果需要在裸机里面实现一个能做到非阻塞延时的状态机,一般会怎么做呢,当然是插入一个 case,每次进入这个 case,都判断一下时间到没到,没到则出让 cpu 下次再判断一下,到了则进入下个 case。
我之前写的一篇博客对于裸机的非阻塞延时有相应的讲解:单片机裸机多任务方案个人总结

太不优雅了,还是看看远处的 ltx 脚本组件吧:

void script_cb_test(struct ltx_Script_stu *script){
    switch(script->step){
        case 0:
            printf("这里是 step0\n");

            // 100ms 后进入 step 1,填入 0ms 则表示短暂出让 cpu 后立即进入新 step
            ltx_Script_next_step_delay(script, 1, 100);

            break;

        case 1:
            printf("这里是 step1\n");

            // 等待磁编码器更新事件触发后进入 step 2,或者超时 200ms 后也会进入
            ltx_Script_next_step_topic(script, 2, 200, &mag_encoder_update);

            break;

        case 2:
            printf("这里是 step2\n");

            if(ltx_Script_get_triger_type(script) == SC_TRIGER_TIMEOUT){
                // 因为超时而触发
                printf("等待磁编码器更新超时\n");
            }else { // 因为事件发布而触发
                printf("成功获取磁编码器数据\n");
            }

            // 继续等待新数据产生
            ltx_Script_next_step_topic(script, 2, 200, &mag_encoder_update);

            break;
    }
}

好处是不需要状态机自己轮询时间,到了时间调度器就会触发状态机的运行,这样外部就能知道你到底是不是空闲状态,便于休眠操作。以及还能更优雅地实现事件驱动能力,等待事件用起来和 rtos 等待信号量并设置超时时间非常相似。

9、pid 层次

5.1、SVPWM 收尾 小节,了解到了需要两个 pid 控制器分别操作两个电压量。
这两个 pid 控制器是跑在 adc 采样完成中断里面的,adc 一旦采集完,便直接在中断里面运行。用来生成目标电流向量。
而目标电流向量是更上层的 pid 控制器下发的任务,因为这个目标电流只解决了 FOC 最基本的磁场定向控制,结果上也就是只负责提供力产生加速度。

但是速度控制位置控制什么的,是更上层的要求,我要达到某个位置,就需要一层 pid 控制器告知需要多大的速度,需要到达某个速度,就需要一层 pid 控制器告知需要多大加速度,这个加速度就是提供给最底层 pid 控制器需要产生的q轴电流。

本项目不会涉及速度环位置环,因为力反馈方向盘只需要处理游戏下发的力矩数据就行了,,吗?

还是要的,首先是方向盘的限位,为了实现自定义圈数,就需要软件上产生限位力矩,方向盘转到一定程度后,就产生一个较大的力矩抵抗,勉强算是位置控制吧,但是算法上没考虑太多东西,只是超过位置后根据超出程度产生一个 k 乘以最大电流。
然后就是方向盘的限速,因为电机比较小,转动惯量和阻力也比较小,游戏下发的回中力矩容易导致松手后方向盘来回震荡,我的策略是在超速后对游戏发来的力反馈数据按比例进行减幅。效果也不是很好就是了,最好还是手搭在方向盘上吧

七、手柄连接游戏实现流程

方案选型:

  1. 上位机作为游戏和手柄的中介
  2. 插入一个 simhub 在游戏和上位机中间
  3. 上位机通过 udp 读取游戏广播力矩数据再通过 usb cdc 下发给手柄,游戏通过 usb hid 读取手柄按键
  4. 游戏直接通过 hid 下发力矩数据并读取手柄按键信息

手柄通信的选型也是走了一点弯路,本来以为只有商业方向盘才有资格与游戏通过 usb 直接通信,后来询问 ai 得知可以使用 simhub 获取游戏数据,但是发现 simhub 只有 10hz 的通信频率,哪怕充钱也只有 60hz,还是算了吧,ai 又说大部分游戏会通过 udp 发送数据,那这么一来还得每个游戏做适配。最后最后才得知可以直接用 usb hid 获取游戏下发的力矩数据,没有中间商了这下。而且也可以设计一个上位机做手柄自定义功能。

1、usb hid

usb 就是邮局,它只负责交换信件,不关心里面的内容,属于硬件标准,而 hid 就是写在信纸上的特定格式的内容,属于软件/驱动标准,要符合 hid 的力反馈相关标准才能与游戏建立正常通信,不过中间还有一个 windows 做中间商就是了。

你需要在 hid 的报告描述符里面描述好你有多少个按键,多少轴,以及是否支持力反馈,支持哪些力反馈数据,游戏才能通过系统提供的 api 了解到是否应该给你的设备下发力反馈数据。

问题就出现在 windows 上,我修改 hid 的报告描述符几天都没有效果,抓包也抓不到 get report,查资料得知 windows 不会发送 get report:Windows,Linux,Mac系统中,USB HID枚举过程的异同
实则被这篇文章误导了,就算不发 get report,至少要发 out 吧,不然电脑怎么传输数据给我的单片机执行?

折腾几天后,发现是 windows 的拟人操作,如果不修改 vid/pid,即便你修改了 hid 报告描述,那么 windows 也会认为这是一个已经存在过的设备,那么就不重新识别一些内容了,所以只有新设备也就是你电脑没读取过的 vid/pid 才会发起 get report,读取过这一次后,后面重新拔插再也不会读取了,byd

下面这篇博客也是不论怎么修改报告描述符都没有效果,最后通过修改 vid/pid 实现了力反馈,实际上是一个巧合,并不是特定 vid/pid 才能识别,而是你电脑没连接过的新设备也就是新的 vid/pid 才会发起重新识别:
【博客园】STM32配置HID设备时主机识别不到力反馈的解决办法
如果你和我一样恰巧先看到了这篇文章修改为他提到的特定 vid/pid 后再修改报告描述符,那么一定是实现不了力反馈数据下发的,因为你可能不会再怀疑 vid/pid 的问题,而且此时你修改报告描述符的输入部分比如按键和轴是有效果的,那么你一定会聚焦问题在报告描述符的输出部分上面。

2、力反馈实现

在 hid 报告描述符中,需要描述你的设备支持多少种效果力,比如恒力、弹簧力、方波力等等,以及你的设备能支持多少个效果力同时执行,规范里面的最多是 40 个,也可在报告描述符中修改,如果你的设备性能不够,开不了那么多线程,那么就可以改少。

比如游戏可能会将效果块 1 设置为弹簧力模拟方向盘回中,效果块 2 设置为 5hz 的方波来提供引擎震动,效果块 3 设置为 50hz 的方波模拟路面颠簸,等等等等。那么就需要对效果池和效果线程进行管理,不过就 欧卡2 和 神力科莎 测试下来,似乎只会提供一个恒力,也就是游戏自己算好了合成力矩给你用,欧卡2 的设置力反馈页面的一些周期震动效果条是灰色的,不知道是 hid 的哪里没配置对,所以暂时没有管除了常量力以外的其他效果力,直接使用游戏下发的流式数据。

八、模型设计

建模苦手,没什么好讲的,忘记给 pcb 留螺丝孔导致模型螺丝孔只能画在侧面。。提一下参数化建模。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
将一些常用数值设置为参数,可以当成变量使用,不用记住具体值,而且直接修改参数即可修改所有引用此参数的值

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐