3. 系统启动流程:从Bootloader到主程序入口,AP_HAL层初始化、调度器启动、任务创建的全过程
飞控上电后,到底发生了什么?
很多人拿到ArduPilot源码,直接跳到main()函数开始读。结果发现一堆宏定义、条件编译、HAL抽象层调用,看得云里雾里。我当年也是这么过来的,折腾了两周才理清脉络。
这一章,咱们就把启动流程从头到尾捋一遍。你跟着我走一遍,以后看任何飞控代码都不会迷路。
3.1 第一行代码:Bootloader在干什么?
飞控芯片上电后,CPU从复位向量指向的地址开始执行。这个地址里放的,通常是Bootloader。
Bootloader的任务很简单:
- 初始化最基本的硬件(时钟、内存、串口)
- 检查是否有固件更新请求(比如通过USB或UART)
- 如果没有更新请求,跳转到应用程序入口
在ArduPilot的Pixhawk系列硬件上,Bootloader用的是PX4官方的那个。它会在启动后等待约1秒,看有没有NOP指令流进来。如果有,就进入固件烧录模式;没有,就跳转到Flash中的应用程序起始地址。
3.2 进入主程序:main()函数入口
Bootloader跳转后,执行的是main()函数。但ArduPilot的main()不在你想象的ArduCopter.cpp里,而是在AP_HAL层中。
以STM32F4/F7平台为例,main()在libraries/AP_HAL_ChibiOS/hwdef/下的启动文件里。它长这样:
int main(void)
{
// 1. 硬件初始化
hal.init(OS_STARTUP);
// 2. 调度器初始化
hal.scheduler->init();
// 3. 创建主任务
hal.scheduler->register_timer_process(AP_HAL::Scheduler::timer_process);
hal.scheduler->register_io_process(AP_HAL::Scheduler::io_process);
// 4. 进入主循环
hal.scheduler->system_initialized();
while (true) {
hal.scheduler->delay(1);
}
}
嗯,这里要注意:你看到的hal是一个全局对象,它在AP_HAL命名空间里定义。不同的硬件平台,hal的实现完全不同。这就是HAL层的意义——上层代码不用关心你用的是STM32还是K210。
3.3 AP_HAL层初始化:把硬件抽象成接口
hal.init()干了什么?
说白了,就是把芯片上的外设全部初始化好,然后包装成统一的接口。以ChibiOS版本为例:
- 时钟系统:配置PLL,让CPU跑在最高频率(比如216MHz)
- GPIO:初始化所有引脚,设置复用功能
- UART/串口:打开调试串口和GPS串口
- I2C/SPI:初始化总线,准备挂载传感器
- 定时器:配置系统滴答定时器,提供1ms心跳
- ADC:初始化模数转换器,用于读取电池电压等
你想想看,如果没有这层抽象,你写一个Copter固件,换块飞控板就得重写一半代码。HAL层就是帮你挡掉这些脏活累活。
hal全局对象,包含scheduler、uart、i2c、spi、analog、gpio等子模块。每个硬件平台只需要实现这些接口,上层代码完全不用改。
3.4 调度器启动:飞控的“心脏”
hal.scheduler->init()启动的是整个飞控的任务调度系统。
ArduPilot的调度器不是实时操作系统(RTOS)那种抢占式调度,而是一个协作式调度器。它维护一个任务列表,每个任务有一个期望执行频率(比如400Hz、50Hz、10Hz)。调度器每次循环,检查哪些任务该执行了,就调用对应的函数。
调度器的核心数据结构:
// 任务结构体
struct AP_Scheduler::Task {
const char *name; // 任务名称
bool (*function)(void); // 任务函数指针
float rate_hz; // 期望执行频率
uint16_t max_time_micros; // 最大执行时间(微秒)
};
调度器的主循环逻辑:
void AP_Scheduler::loop()
{
uint32_t now = micros();
for (uint8_t i = 0; i < _num_tasks; i++) {
if (now - _last_run[i] >= (uint32_t)(1000000.0f / _tasks[i].rate_hz)) {
_tasks[i].function();
_last_run[i] = now;
}
}
}
为什么不用RTOS?我个人觉得,协作式调度器在飞控这种硬实时场景下反而更可控。你想想,如果每个任务都能被随时打断,那姿态控制这种高优先级任务反而可能因为中断延迟而出问题。协作式调度器让每个任务自己控制执行时间,只要你不写死循环,系统就不会卡死。
while(1)等待传感器数据,结果整个飞控卡死,飞机直接翻了个跟头。后来才意识到,协作式调度器里绝对不能有阻塞等待。所有等待都要用状态机或者回调来实现。
3.5 任务创建:飞控的“器官”们开始工作
调度器启动后,接下来就是创建各个核心任务。这些任务在ArduCopter.cpp的setup()函数中注册:
void Copter::setup()
{
// 初始化传感器
init_barometer();
init_compass();
init_gyro();
init_accel();
// 初始化GPS
init_gps();
// 初始化RC接收机
init_rc_in();
// 初始化电机输出
init_rc_out();
// 注册调度任务
scheduler.init(&_tasks[0], ARRAY_SIZE(_tasks));
}
注册的任务列表(以Copter为例):
| 任务名称 | 频率 | 说明 |
|---|---|---|
fast_loop |
400 Hz | 姿态控制、角速度控制 |
medium_loop |
50 Hz | 位置控制、速度控制 |
slow_loop |
10 Hz | 导航、航点管理 |
gcs_update |
50 Hz | 地面站数据发送 |
gcs_rc_loop |
50 Hz | 遥控器信号处理 |
compass_cal |
10 Hz | 罗盘校准 |
barometer_read |
25 Hz | 气压计读取 |
这些任务按照频率分层,高频率的任务做最核心的控制,低频率的任务做辅助功能。这种设计保证了飞控的实时性——400Hz的fast_loop永远不会被50Hz的gcs_update阻塞。
3.6 启动流程全景图
下面这张图把整个启动流程串起来了。你可以对照着看,每一步都对应着代码里的哪个函数。
3.7 启动过程中的几个关键点
最后,我总结几个实际项目中容易踩的坑:
- Bootloader跳转地址不对:如果你自己画了飞控板,一定要确认Bootloader跳转的地址和应用程序的链接地址一致。差一个字节,系统就起不来。
- HAL层初始化顺序:有些外设依赖其他外设(比如I2C传感器依赖I2C总线),初始化顺序错了会导致设备挂不上。我建议严格按照
hal.init()里的顺序来,不要自己乱改。 - 调度器任务超时:每个任务都有
max_time_micros限制,如果某个任务执行时间超过了这个值,调度器会打印警告。我曾经在GPS解析任务里用了printf调试,结果每次打印都超时,导致姿态控制任务被延迟。后来改用hal.console->printf才解决。 - 传感器自检失败:在
setup()阶段,每个传感器都会做自检。如果某个传感器挂了,飞控会进入FAILSAFE模式,不会解锁。这是好事,别为了省事把自检关掉。