前言
一旦你学会了 ArduPilot 库的基础知识,现在是时候让你了解 ArduPilot 是如何处理线程的。继承自 arduino 的 setup()/loop() 结构可能会让人觉得 ArduPilot 是一个单线程系统,但事实上它并不是。
ArduPilot 中的线程方法取决于它所构建的主板。有些主板(如 APM1 和 APM2)不支持线程,所以用一个简单的定时器和回调来解决。有些主板(PX4 和 Linux)支持具有实时优先级的丰富 Posix 线程模型,这些模型被 ArduPilot 广泛地使用。
在 ArduPilot 中,有一些与线程有关的关键概念,你需要了解:
- 定时器回调;
- HAL专用线程;
- 驱动程序专用线程;
- ardupilot驱动程序与平台驱动程序的对比;
- 平台专用线程和任务;
- AP_Scheduler系统;
- 信号量;
- 无锁数据结构。
1 定时器回调
每个平台都在 AP_HAL 中提供一个 1kHz 的定时器。ArduPilot 中的任何代码都可以注册一个定时器函数,然后以 1kHz 的速度调用。所有注册的定时器函数都是按顺序调用的。这个非常原始的机制被使用,因为它具有极高的可移植性,而且非常有用。你通过调用 hal.scheduler->register_timer_process() 来注册一个定时器回调,像这样:
hal.scheduler->register_timer_process(AP_HAL_MEMBERPROC(&AP_Baro_MS5611::_update));
这个特殊的例子来自 MS5611 气压计驱动程序。AP_HAL_MEMBERPROC() 宏提供了一种将 C++ 成员函数封装为回调参数的方法(将对象上下文与函数指针捆绑起来)。
当一段代码希望某件事情以低于 1kHz 的速度发生时,那么它应该维护自己的 "last_called"变量,如果时间不够就立即返回。你可以使用 hal.scheduler->millis() 和 hal.scheduler->micros() 函数来获取启动后的时间,以毫秒和微秒为单位来支持。
你现在应该去修改一个现有的示例概述(或创建一个新的),并添加一个定时器回调。让定时器增加一个计数器,然后在 loop() 函数中每秒钟打印一次计数器的值。修改你的函数,使它每25毫秒增加一次计数器。
2 HAL专用线程
在支持真实线程的平台上,该平台的 AP_HAL 将创建一些线程来支持基本操作。例如,在 Pixhawk 上,会创建以下 HAL 专用的线程:
UART 线程,用于读取和写入 UARTs(和 USB);
定时器线程,支持上述的 1kHz 定时器功能;
IO 线程,支持对 microSD 卡、EEPROM 和 FRAM 的写入。
查看每个 AP_HAL 实现中的 Scheduler.cpp,看看创建了哪些线程以及每个线程的实时优先级是多少。
如果你有一个 Pixhawk,那么你现在也应该设置一个调试控制台电缆,并将其连接到 nsh 控制台(serial5 端口)。连接波特率为 57600。当你连接好后,尝试使用 "ps"命令,你会得到类似这样的结果:
PID PRI SCHD TYPE NP STATE NAME 0 0 FIFO TASK READY Idle Task() 1 192 FIFO KTHREAD WAITSIG hpwork() 2 50 FIFO KTHREAD WAITSIG lpwork() 3 100 FIFO TASK RUNNING init() 37 180 FIFO TASK WAITSEM AHRS_Test() 38 181 FIFO PTHREAD WAITSEM <pthread>(20005400) 39 60 FIFO PTHREAD READY <pthread>(20005400) 40 59 FIFO PTHREAD WAITSEM <pthread>(20005400) 10 240 FIFO TASK WAITSEM px4io() 13 100 FIFO TASK WAITSEM fmuservo() 30 240 FIFO TASK WAITSEM uavcan()
在这个例子中,你可以看到"AHRS_Test"线程,它正在运行来自 library/AP_AHRS/examples/AHRS_Test 的示例概述。你还可以看到定时器线程(优先级 181)、UART 线程(优先级 60)和 IO 线程(优先级 59)。
此外,你可以看到 px4io、fmuservo、uavcan、lpwork、hpwork 和 idle 任务。稍后会有更多关于这些的内容。
其他 AP_HAL 端口有更多或更少的线程,这取决于需要什么。
线程的一个常见用途是为驱动程序提供一种方法来调度缓慢的任务,而不中断主要的自动驾驶飞行代码。例如,AP_Terrain 库需要能够对 microSD 卡进行文件 IO(以存储和检索地形数据)。它的方法是这样调用函数 hal.scheduler->register_io_process():
hal.scheduler->register_io_process(AP_HAL_MEMBERPROC(&AP_Terrain::io_timer));
设置 AP_Terrain::io_timer 函数,使其定期被调用。这是在主板的 IO 线程内调用的,意味着它的实时优先级很低,适合于存储 IO 任务。重要的是,像这样的慢速 IO 任务不能在定时器线程中调用,因为它们会导致更重要的高速传感器数据的处理出现延迟。
3 驱动程序专用线程
也可以创建驱动程序的专用线程,以特定于一个驱动程序的方式支持异步处理。目前,你只能以与平台相关的方式创建驱动程序的专用线程,因此,仅当你的驱动程序只打算在一种类型的自动驾驶板上运行时,才适用。如果你想让它在多个 AP_HAL 目标上运行,那么你有两种选择:
- 你可以使用 register_io_process() 和 register_timer_process() 调度器调用来使用现有的定时器或 IO 线程;
- 你可以添加一个新的 HAL 接口,为在多个 AP_HAL 目标上创建线程提供一种通用方法(请回馈补丁)。
Linux端口的ToneAlarm线程就是一个驱动程序专用线程的例子。参见AP_HAL_Linux/ToneAlarmDriver.cpp。
4 ArduPilot驱动与平台驱动的对比
你可能会注意到 ArduPilot 中的一些重复的驱动程序。例如,我们在 libraries/AP_InertalSensor/AP_InertialSensor_MPU6000.cpp 中有一个 MPU6000 驱动程序,而在 PX4Firmware/src/drivers/mpu6000 中还有一个 MPU6000 的驱动程序。
这种重复的原因是,PX4 项目已经为 Pixhawk 主板上的硬件提供了一套经过良好测试的驱动程序,而且我们与 PX4 团队在开发和增强这些驱动程序方面保持着良好的合作关系。因此,当我们为 PX4 构建 ArduPilot 时,我们通过编写小型的 "垫片"驱动程序来利用 PX4 的驱动程序,这些驱动程序以标准的ArduPilot库接口呈现给PX4。如果你看一下libraries/AP_InertialSensor/AP_InertialSensor_PX4.cpp,你会看到一个小的 shim 驱动程序,它询问 PX4 这个板子上有哪些 IMU 驱动,并自动将所有这些驱动程序作为 ArduPilot AP_InertialSensor 库的一部分。
因此,如果我们在主板上有一个MPU6000,我们在非Pixhawk/NuttX平台上使用 AP_InertialSensor_MPU6000.cpp驱动程序,而在基于NuttX的平台上使用 AP_InertialSensor_PX4.cpp 驱动程序。
其他 AP_HAL 端口也可能发生相同类型的拆分。例如,我们可以将 Linux 内核驱动程序用于 Linux 主板上的某些传感器。对于其他传感器,我们使用通用的 AP_HAL I2C 和 SPI 接口来使用ArduPilot “in-tree” 驱动程序,该驱动程序可在各种主板上工作。
5 平台专用线程和任务
在一些平台上,启动过程将创建许多基础任务和线程。这些都是非常具体的平台,因此在本教程中,我将集中介绍基于 PX4 主板上使用的任务。
在上面的 "ps"输出中,我们看到一些任务和线程没有被 AP_HAL_PX4 调度器代码启动。具体来说,它们是:
- 空闲任务 - 当没有其他任务需要运行时被调用;
- init - 用于启动系统;
- px4io -处理与 PX4IO 协处理器的通信;
- hpwork - 处理基于PX4驱动程序的线程(主要是 I2C 驱动程序);
- lpwork - 处理基于低优先级工作的线程(例如 IO);
- fmuservo - 处理 FMU 上的辅助 PWM 输出的通讯;
- uavcan - 处理 uavcan 的 CANBUS 协议。
所有这些任务的启动都由 PX4 专用的 rc.APM 脚本控制( rc.APM script)。该脚本在 PX4 启动时运行,并负责检测我们使用的是哪种 PX4 主板,然后为该板加载正确的任务和驱动程序。它是一个 "nsh"脚本,类似于 bourne shell 脚本(尽管 nsh 要原始得多)。
作为一个练习,尝试编辑 rc.APM 脚本并添加一些睡眠和打印命令。然后上传一个新的固件,在主板启动的时候连接到调试控制台。你的打印命令应该在控制台显示出来。
探索 PX4 启动的另一个非常有用的方法是在插槽中没有 microSD 卡的情况下启动。在 rc.APM 之前运行的 rcS 脚本(rcS script)会检测是否插入了 microSD,如果没有,会在 USB 端口上给你一个最基本的 nsh 控制台。
然后你可以自己在 USB 控制台手动运行 rc.APM 的所有步骤来学习它是如何工作的。
在没有 microSD 卡的情况下启动 Pixhawk 并连接到 USB 控制台后,尝试以下练习:
tone_alarm stop uorb start mpu6000 start mpu6000 info mpu6000 test mount -t binfs /dev/null /bin ls /bin perf
试着练习其他的驱动程序。在 /bin 中看看有什么可用的。大多数这些命令的源代码在 PX4Firmware/src/drivers 中。浏览一下 mpu6000 驱动,以了解所涉及的内容。
鉴于我们正在讨论线程和任务的话题,对 PX4Firmware git 树中的线程进行简要描述是值得一提的。如果你在 mpu6000 驱动中查看,你看到如下一行:
hrt_call_every(&_call, 1000, _call_interval, (hrt_callout)&MPU6000::measure_trampoline, this);
这相当于 AP_HAL 中的 hal.scheduler->register_timer_process() 函数,但它是针对 PX4 的,也更灵活。它表示希望PX4的HRT(高分辨率定时器)子系统每1000微秒调用 MPU6000::measure_trampoline 函数。
在操作非常快的驱动程序中,使用 hrt_call_every() 是驱动程序中常规事件的常用方法,比如 SPI 设备驱动。这些操作通常是在禁用中断的情况下运行的,最多只需要几十微秒的时间。
如果你将其与 hmc5883 驱动相比较,你会看到如下所示的行:
work_queue(HPWORK, &_work, (worker_t)&HMC5883::cycle_trampoline, this, 1);
使用一种替代机制来处理常规事件,这适用于较慢的设备,如 I2C 设备。这所做的是将 cycle_trampoline 函数添加到你在上面看到的 hpwork 线程中的一个工作队列中。在 HPWORK 角色中进行的调用应该在启用中断的情况下运行,可能需要几百微秒的时间。对于耗时超过这个时间的任务,应该使用 LPWORK 工作队列,它在低优先级的 lpwork 线程中运行。
6 AP_Scheduler系统
ArduPilot 线程和任务的下一个要了解的方面是 AP_Scheduler 系统。AP_Scheduler 库用于在主飞行器线程中划分时间,同时提供一些简单的机制来控制每个操作(在 AP_Scheduler 中称为 "任务")使用多少时间。
它的工作方式是,每个飞行器实现的 loop() 函数包含一些代码来做这个工作:
- 等待一个新的 IMU 样本的到来;
- 在每个 IMU 样本之间调用一组任务。
它是一个表驱动的调度程序,每种飞行器类型都有一个 AP_Scheduler::Task 表。要了解它是如何工作的,请查看 AP_Scheduler/examples/Scheduler_test.cpp 概述。
如果查看该文件,你将看到一个小表格,其中包含一组3个调度任务。与每个任务相关的是两个数字。该表如下所示:
static const AP_Scheduler::Task scheduler_tasks[] PROGMEM = { { ins_update, 1, 1000 }, { one_hz_print, 50, 1000 }, { five_second_call, 250, 1800 }, };
每个函数名称后面的第一个数字是调用频率,单位由 ins.init() 调用控制。在这个例子中,ins.init() 使用 RATE_50HZ,所以每个调度步骤是 20ms。这意味着每20毫秒进行一次 ins_update() 调用,每50次(即每秒一次)
调用一次 one_hz_print() 函数,每250次(即每5秒一次)调用一次 five_second_call()。(这里的调用频率计算我觉得有问题)
第二个数字是该函数预计花费的最大时间。这是用来避免调用的,除非在这个调度运行中还有足够的时间来运行这个函数。当 scheduler.run() 被调用时,它被传递给运行任务的可用时间总量(以微秒为单位),如果是这个任务最坏情况下的时间,意味着它在时间用完之前无法适应,那么它将不会被调用。
另一个需要仔细观察的点是 ins.wait_for_sample() 调用。这是驱动 ArduPilot 调度的 "节拍器"。它阻止了飞行器主线程的执行,直到有新的 IMU 样本可用。IMU 采样之间的时间是由 ins.init() 调用的参数控制的。
请注意,AP_Scheduler 表中的任务必须具有以下属性:
- 它们不应该阻塞(除了 ins.update() 调用);
- 它们不应该在飞行时调用睡眠函数(一个自动驾驶仪,就像一个真正的飞行员,不应该在飞行时睡眠);
- 它们应该有可预测的最坏情况的时间。
你现在应该去修改 Scheduler_test 的例子,加入你自己的任务来运行。尝试添加做以下工作的任务:
- 读取气压计;
- 读取磁罗盘;
- 读取 GPS;
- 更新 AHRS 并打印横滚/俯仰数据。
查看你在本教程前面使用过的每个库的示例概述,了解如何使用每个传感器库。
7 信号量
当你有多个线程(或定时器回调)时,你需要确保两个执行逻辑线程共享的数据结构以防止损坏的方式更新。在 ArduPilot 中,有3种主要的方式可以做到这一点--信号灯、无锁数据结构和 PX4 ORB。
AP_HAL 信号量只是对特定平台上可用的信号量系统的包装,并提供一个简单的互斥机制。例如,I2C 驱动可以请求 I2C 总线信号,以确保在同一时间内只使用一个 I2C 设备。
查看 libraries/AP_Compass/AP_Compass_HMC5843.cpp 中的 hmc5843 驱动程序,并查找 get_semaphore() 调用。查看所有使用它的地方,看看是否可以弄清为什么需要它。
8 无锁数据结构
ArduPilot 的代码还包含了使用无锁数据结构的例子,以避免对一个信号的请求。这比信号量要高效得多。
ArduPilot 中无锁数据结构的两个例子是
- libraries/AP_InertialSensor/AP_InertialSensor_MPU9250.cpp 中的 _shared_data 结构;
- 在许多地方使用的环形缓冲区。一个很好的例子是 libraries/DataFlash/DataFlash_File.cpp。
查看这两个例子,并向你自己证明它们对于并发访问是安全的。对于 DataFlash_File 来说,查看 _writebuf_head 和 _writebuf_tail 变量的使用。
如果能创建一个通用的环形缓冲器类,可以代替 ArduPilot 中多个地方的独立环形缓冲器实现,那就更好了。如果你想做出贡献,请提出拉取请求!!
9 PX4 ORB
这类机制的另一个例子是 PX4 ORB。ORB(对象请求代理)是一种使用在多线程环境中安全的发布/订阅模型,将数据从系统的一个部分提供给另一个部分的方式(例如,设备驱动程序->飞行器代码)。
ORB 提供了一个很好的机制来声明,将以这种方式共享的结构(全部定义在 PX4Firmware/src/modules/uORB/)。然后,代码可以将数据 "发布"到这些主题之一,而其他代码则可以选择这些主题。
一个例子是发布执行器的值,这样 uavcan 电调就可以在 Pixhawk 上使用。查看 AP_HAL_PX4/RCOutput.cpp 中的 _publish_actuators() 函数。你会看到,它发布了一个 "actuator_direct"主题,其中包含了每个 ESC 所需的速度。uavcan 代码会观察PX4Firmware/src/modules/uavcan/uavcan_main.cpp 中该主题的变化,并将新值输出给 uavcan 电调。
与 PX4 驱动程序进行通信的另外两种常见机制是:
- ioctl calls (see the examples in AP_HAL_PX4/RCOutput.cpp)
- /dev/xxx read/write calls (see _timer_tick in AP_HAL_PX4/RCOutput.cpp)
如果你不确定新代码应该使用哪种机制,请在ArduPilot开发者讨论区(ArduPilot Developers Discord)与 ardupilot 开发团队交流。