Skip to content

程序架构演进史

liuzikai edited this page Jan 17, 2022 · 4 revisions

Meta-Embedded 自诞生之初以来,经历了数代程序架构的演化,截至目前,已有三代程序架构,这些架构是我们在研发过程中不断探索、优化的成果,随着功能的增加、稳定性与可维护性的需求提高,程序架构也呈现越来越复杂的发展趋势。

程序架构对于组织、维护代码起着至关重要的作用。当然,但在实际开发过程中,也不宜被架构所限定,一切从需求出发,不应盲目追求“高端”的架构而引入不必要的工作量。

在此,将数次架构变更加以记录,并希望能为之后的研发工作有所启发。

第一代架构

第一代程序架构如上图所示,这张图来自 2019 赛季的设计报告。最初的出发点,是模块化,每个模块负责一块功能,提供输入、输出接口,模块间的信息传递完全由“主线程”(并不单指 main(),而是集中在一块的一些代码)完成。其中一个应用场景如下图:

(图中目标电流红色箭头方向有误)

这是云台控制的信息传递图。其中 MPU6500、GimbalInterface、RemoteInterpreter 均与底层交互的模块,接受信息并处理为特定的形式(单位转换、Normalize 等),GimbalInterface 也提供发送控制电流的接口。而 GimbalCalculator 则是一个硬件无关的“计算器”,里面包含双环 PID 控制器。GimbalThread 属于“主线程”,主动周期性调用这几个模块的接口,从几个 Interface 模块中获取信息,调用 GimbalCalculator 的接口传入信息、计算,将反馈信息传回给 GimbalInterface。

原先,GimbalInterface 与底层 CAN 的交互也计划是在“主线程”中完成转发、分发的,但后来还是将接收部分移到了 Interface 中,以减少“主线程“代码量,而目标控制信号的发送还是由“主线程”线程控制的。“主线程”程序框图大致如下图所示:

简而言之,总体思路是各个模块提供接口,模块间交互、周期性调度由“主线程”完成。对于云台(发射机构)、底盘系统来说,框架有 Interface、Calculator、Thread 三个部分组成:

依赖关系如下图所示。Interface 和 Calculator 之间不存在依赖关系,不使用继承。

第一代架构关键词:

  • 模块化:一个模块负责一块功能,提供接口,封装代码
  • 调度集中:大部分模块只处于“待命”状态,周期性调用工作集中于“主线程”(部分实现,后来回传的处理工作还是放入了模块中,没有经主线程分发)

第二代架构

第一代架构基本实现了模块化。但“调度集中”也导致大量信息需要“主线程”转发,“主线程”的代码量变得非常庞大。以云台为例,执行一次计算,两个电机的当前角度、当前角速度、目标角度,计算后将目标电流均需要通过“主线程”转发:

而“主线程”还包含遥控器信号转换为目标角度等逻辑代码,这些代码淹没在这些信息传递代码中,不利于代码的修改维护。

因此,第二代架构主要解决其中一个的问题是:模块间信息传递下放,减少主线程代码量

第二代架构取消了了 Calculator,而新产生一种类型的模块:Control(没有正式命名,控制云台的 Control 模块即命名为 Gimbal)。Control 模块继承对应的 Interface,将原有 Calculator 的代码放入 Control 中,通过继承将信息传递转变为模块内部过程,缩减接口数量。同时,通过继承使得“主线程”只需要和 Control 交互:只需要初始化 Control(Control 重载 Interface 的初始化接口),根据遥控信号生成 Target 传递给 Control,再调用 Control 的接口发送控制电流。

然而,在实际应用的过程中,发现并不是所有的 Feedback 都能通过继承“内部消化”,例如云台反馈角速度依赖陀螺仪,Gimbal 继承 MPU6500 在逻辑上并不是很说的通,因此这个信息的传递依然还是要依赖主线程。

另外,使用继承使得 Gimbal 成为一个涵盖底层到上层运算的巨大的模块,这其实是有违模块精简、专一的精神的。同时,GimbalInterface 不再会被直接调用,存在的唯一目的只剩被 Gimbal 继承。总体来说,这个框架还是没那么完美的。

同时,第二代架构还需要解决的另一个主要问题:错误控制。在赛场上,电机掉线之类的意外情况是有可能发生的,尽管我们应该提前做好充分准备,但控制方面做好相应的处理也是必要的。在这方面主要有以下改进:

  • 反馈处理模块引入 last_update_time 记录上一次接收到反馈的时间。如果一段时间内没有接收到反馈,可判断模块离线。
  • 引入状态管理模块 StateHandler。最初的想法,是希望有一个集中管理意外情况的地方。可能出现的错误,包括云台电机离线、底盘电机离线、卡弹、遥控器离线、工程车升降系统失衡等,均能在这里统一管理,包括停止动作、写入日志、蜂鸣器提示,甚至软重启开发板等操作均在此实现。但是,如果将在各个模块中在发生错误时调用 StateHandler,则该模块被许多模块所依赖,需要处在较为底层的位置,并需要“主线程”主动来索取错误状态以决定要进行什么操作,而蜂鸣器提示、写入日志等又要求 StateHandler 依赖 Shell、Buzzer 等模块,所以这个模块的定位一直处于一个比较模糊的状态...在第三代步兵架构中这个模块已经被取消了,在其短暂存在的时间中,只实现了一些模块离线的错误状态“转发”(给“主线程”)
  • 引入启动自检。事实证明启动自检在开发过程中及其有用,有些 Bug 研究了半天最后发现是电机线掉了...第二代架构中启动自检的结果显示也是通过 StateHandler 实现的。

总体而言,对于云台、底盘等系统,大致架构如下:

依赖关系如下:

第二代架构关键词:

  • 信息传递下放,精简主线程
  • 错误控制

第三代程序架构

第二代程序架构尚不完美,趁着引入云台坐标系、扭腰模式、点射等功能,程序架构也进行了全面重构,形成了第三代程序架构。

首先,随着更高级功能的引入,模块间信息交互变得更加复杂了。以云台坐标系功能为例,我们希望实现能实现始终以云台指向为正方向进行底盘移动。为了实现这一目标,底盘运算需要得知云台和底盘的角度差(即云台 Yaw 角度,来自 GimbalInterface)来进行速度分解。如果使用二代架构的继承实现,Chassis 只能获取 ChassisInterface 的回传信息。同时,继承也导致了模块膨胀、权责不清等问题,我们希望在这一代架构中一并解决这些问题。

同时,实践发现,“调度集中”实际上没有太大的必要,如果模块分工明确,没有冲突,将调度下放也未尝不可(解释一下“调度”,前面提到了大部分模块是处于“待命”的状态,提供了接口,但没有自主运行能力,即使是处理回传的模块也只是将回传处理好存放起来,等待调用。在第一和第二代架构中,充当这个调度角色的是“主线程”,在这一代架构中将有所不同)。不仅如此,由于 PID 运算放置于“主线程”中,“主线程”的运算周期必须严格和 PID 周期一致,导致其他的操作也必须按这个周期执行,缺少灵活性。

因此,第三代架构主要解决以下问题:

  • 进一步明确各模块定位,允许模块间数据灵活传递
  • 调度下放,将 PID 运算与控制逻辑分离
  • 强化错误控制

第三代架构对模块做了大量重构,自底向上,分为:

  • Hardware 硬件底层:主要是 ChibiOS 的代码,程序的基础。这一部分由 ChibiOS 提供。
  • Interface (IF) 接口层:负责 自主 接受、处理、存储回传信息,负责发送控制信号(目标电流、电压等)。Interface 将上层模块与 ChibiOS 底层 API 分离,追求稳定,硬件配置确定后,Interface 便基本定型了,其中不应出现与逻辑相关的代码,运算仅限于底层信息解码、编码,坐标系、单位、量度变换等。
  • Scheduler (SKD) 调度层:负责 调度 资源,实现目标(Target)。以底盘调度器 ChassisSKD 为例,它接收并存储三个目标量:以云台为坐标系的水平速度 vx、前后速度 vy、底盘与云台的差角 theta。SKD 不关心目标值是怎么来的,但正如其名字,SKD 主动调度所需要的资源,“全力”实现这一目标,例如 ChassisSKD 需要综合底盘回传、云台回传,执行双环 PID 运算,这些代码都封装在 SKD 中。 SKD 包含独立的线程,用于 PID 运算,因此启动后就具备自主运行能力,只要将目标传入该模块,机器人就会一直向这个目标靠拢。SKD 的引入实现了调度下放。
  • Logic (LG) 逻辑层:Scheduler 层负责 Target 的实现,但并不关心这个 Target 是怎么来的,建立在 Scheduler 层以上的 Logic 层就是为了 产生 Target。还是以底盘控制为例,正常模式下,用户控制底盘的前进、左右和旋转,而在扭腰模式下,旋转角度是一个周期性自动变化的值,无论是哪种模式,传递给 Scheduler 的 Target 总是只有 vx、vy、delta(底盘与云台相对角度,以云台为坐标)三个量,尽可能实现模块独立,而扭腰模式负责切换角度的线程便可完全封装在 ChassisLG 中。再以拨弹电机控制为例,GimbalSKD 负责拨弹电机的 PID 控制,接受拨弹电机目标角度,调度资源以实现这一目标。它并不关心旋转角度与子弹数的对应关系,从计划发射的子弹数到角度的转换,完全在 ShootLG 中完成。而发生卡弹时,短时间的反转亦可通过将目标角度设为当前角度加某一负值来实现,因检测卡弹、反转的需要而引入的线程,可以完全封装在 ShootLG 中。
  • User & Inspector:每一台机器人都有自己的 User 和 Inspector。User 负责控制信号来源(遥控或键盘、视觉开发版等)到 Logic 层的传递,包含操作手控制逻辑(例如按下哪个键执行什么操作)。Inspector 是一全自动化线程,负责周期性监测异常(例如,电机超过一定时间没有回传,可认为离线),检测结果由 User 读取并使用。

为了明确依赖关系的考虑,我们规定,在 IF、SKD、LG 三个层次中,上层只能调用紧邻下层的接口,因此当 LG 需要 IF 层的数据时(例如 ShootLG 需要比较当前转速与目标电流判断是否发生卡弹),需要 SKD 层提供 Wrapper。同时,SKD 提供部件坐标抽象,通过 init 时传入的参数设置。以云台为例,无论云台电机的安装方向如何、是否使用同步带,通过修改 init 参数,从 SKD Wrapper 得到的云台角度、角速度等,均以整个云台的逆时针、枪口向上旋转为 Yaw、Pitch 的正方向。使用 SKD 接口的 LG 无需考虑电机安装方向。Inspector 由于需要涉及较多底层数据,不受本条限制,可直接读取全部三层的数据。

至于关于某一运算应该放在哪个模块中,并没有一个严格的划分,例如,子弹计数从 IF 层移到 SKD 层也是合理的。设计的总体目标是保持各个模块职权清晰、代码量均衡,以及在可预见范围内提供拓展维护的便捷性。以下标准作为参考:

  • Interface 层次封装电机、遥控、蜂鸣器、气缸(GPIO)等零件,与上层交互直接信息(回传角度、回传角速度、目标电流或电压、遥控状态、按键状态等)
  • Scheduler 封装地盘、云台、发射机构、机械臂等部件,与上层交互物理量信息(云台角度、云台前进速度、云台旋转速度、机械臂开合)
  • Logic 定位没有 IF 和 SKD 那么清晰,主要封装了“模式”和一些自动化。
  • User 封装用户操作,负责从控制信号来源(遥控或键盘、视觉开发版等)到 Logic 层的传递。Inspector 负责周期性检测异常,检测结果由 User 读取并使用。

第三代架构关键词:

  • Interface、Scheduler、Logic、User & Inspector 层次划分,而非按部件划分
Clone this wiki locally