4、硬件抽象层(HAL):HAL体系结构、板级支持包(BSP)、外设驱动框架

好,咱们今天聊聊硬件抽象层。说白了,HAL 就是 ArduPilot 能跑在几十种飞控板上的秘密武器。

你想想看,一个 Pixhawk 用的是 STM32,一个 CubeOrange 可能用的是 STM32H7,还有个 Matek 用的又是 F4 系列。如果没有 HAL,那代码得写成什么样?每个板子一套驱动,维护起来简直是噩梦。

我个人习惯把 HAL 理解成「中间人」。它夹在飞控应用代码和真实硬件之间。应用代码说「我要发个串口数据」,HAL 就负责找到这个板子的串口寄存器在哪、怎么配置 DMA、怎么处理中断。应用代码根本不用关心底层细节。

核心思想:写一次应用代码,编译时换一个 HAL 库,就能跑在不同硬件上。

4.1 HAL 体系结构

ArduPilot 的 HAL 分三层,我画个图你就明白了。

ArduPilot HAL 三层体系结构 应用层(AP_XXX) AP_GPS、AP_Baro、AP_InertialSensor、AP_Compass ... HAL 抽象层(HAL_XXX) HAL_UART、HAL_I2C、HAL_SPI、HAL_GPIO、HAL_ADC、HAL_PWM 纯虚接口类,定义「做什么」,不关心「怎么做」 BSP 板级支持包(HAL_XXX_ChibiOS/HAL_XXX_Empty) 具体实现:寄存器操作、DMA配置、中断服务、引脚映射 每个板子一套,编译时通过 hwdef.dat 选择 接口不变 实现可变

嗯,这里要注意。应用层代码只跟 HAL 抽象层打交道。抽象层定义了一堆纯虚类,比如 UARTDriverI2CDevice。BSP 层才是真正的干活人。

我记得刚开始看 ArduPilot 源码时,找 hal.uartA->write() 的实现找了半天。后来才明白,hal 这个全局对象是在编译时根据板子类型实例化的。Pixhawk1 用的是 ChibiOS 的 UART 驱动,Linux 板子用的是 Linux 的串口驱动。

小技巧:想快速找到当前板子的 HAL 实现?去 libraries/AP_HAL_ChibiOS 目录下翻。每个外设一个文件,命名很规范。

4.2 板级支持包(BSP)

BSP 是什么?说白了就是「这个板子专属的配置和驱动集合」。

每个飞控板在 ArduPilot 里都有一个 hwdef.dat 文件。这个文件定义了:

  • MCU 型号和时钟配置
  • 每个外设的引脚映射(比如 UART1 的 TX 是 PA9,RX 是 PA10)
  • 外设使能开关(这个板子有没有 I2C?有没有第二个 SPI 总线?)
  • DMA 通道分配
  • 定时器资源分配

我曾经踩过一个坑。给一块新板子移植 ArduPilot 时,我直接复制了 Pixhawk1 的 hwdef.dat,只改了 MCU 型号。结果编译通过了,但 GPS 死活搜不到星。查了两天,发现是 UART 的 DMA 通道跟定时器冲突了。嗯,从那以后我每次改 hwdef.dat 都会仔细核对 DMA 和定时器的资源分配表。

避坑指南:我曾经因为 hwdef.dat 里少写了一行 PA9 UART1_TX,导致串口输出全是乱码。后来养成习惯:每改一个引脚定义,都去查对应 MCU 的数据手册确认 Alternate Function。

BSP 的另一个重要部分是启动代码。每个板子上电后,先执行 board_init(),这里面做了:

  1. 时钟系统初始化(PLL、HSE、HCLK、APB1/APB2 分频)
  2. GPIO 初始化(所有用到的引脚配置成正确的模式)
  3. 外设时钟使能(RCC 寄存器操作)
  4. 中断向量表重定位(有些板子需要)
  5. 堆栈和内存初始化

我个人习惯在 board_init() 里加一个 LED 闪烁函数。这样板子上电后如果 LED 没闪,我就知道初始化卡在哪一步了。调试效率能提高不少。

4.3 外设驱动框架

ArduPilot 的外设驱动框架,说白了就是一套「模板」。每个外设(UART、I2C、SPI、GPIO、ADC、PWM)都有一套固定的接口,BSP 层去实现这些接口。

4.3.1 UART 驱动框架

UART 是最常用的外设。GPS、数传、RC 接收机、OSD,全走串口。

HAL 里 UART 的接口长这样:

class UARTDriver {
public:
    virtual void begin(uint32_t baud) = 0;
    virtual void end() = 0;
    virtual int32_t available() = 0;
    virtual int32_t txspace() = 0;
    virtual ssize_t read(uint8_t *buffer, uint16_t count) = 0;
    virtual ssize_t write(const uint8_t *buffer, uint16_t count) = 0;
    virtual void flush() = 0;
    virtual bool is_initialized() = 0;
    virtual void set_flow_control(enum flow_control flow) = 0;
};

你想想看,GPS 驱动里调用 hal.uartA->read() 时,它根本不知道底层是 STM32 的 USART 还是 Linux 的 ttyS0。它只管拿数据。

我记得有一次调试一个奇怪的串口丢数据问题。GPS 数据每 10 秒丢一帧。查了半天,发现是 DMA 缓冲区溢出。ChibiOS 的 UART 驱动默认 DMA 缓冲区只有 128 字节,而 GPS 一帧数据有 100 多字节,加上中断延迟,偶尔就溢出了。解决办法?在 hwdef.dat 里加大 DMA 缓冲区大小。

关键点:UART 驱动在 ChibiOS 上用的是 DMA + 中断的方式。接收数据时,DMA 自动把数据搬到内存缓冲区,CPU 零开销。发送数据时,也是 DMA 自动从内存缓冲区搬数据到串口发送寄存器。

4.3.2 I2C 驱动框架

I2C 主要用于板载传感器。气压计、磁力计、一些老式的加速度计,都走 I2C 总线。

HAL 的 I2C 接口:

class I2CDevice {
public:
    virtual bool read(uint8_t reg, uint8_t *data, uint8_t len) = 0;
    virtual bool write(uint8_t reg, const uint8_t *data, uint8_t len) = 0;
    virtual bool transfer(const uint8_t *send, uint8_t send_len,
                          uint8_t *recv, uint8_t recv_len) = 0;
    virtual void set_retries(uint8_t retries) = 0;
};

嗯,这里要注意。I2C 总线上可能挂多个设备,每个设备有唯一的地址。ArduPilot 的 I2C 驱动会自动处理总线仲裁、时钟拉伸、ACK/NACK 检测。

我曾经遇到过一个磁力计读数偶尔跳变的问题。示波器一抓,发现 I2C 时钟线上有毛刺。原因是总线电容太大,上拉电阻选小了。换了个 4.7kΩ 的上拉电阻,问题解决。

经验之谈:I2C 总线出问题,90% 是上拉电阻不对。100kHz 模式用 10kΩ,400kHz 模式用 4.7kΩ。别问我怎么知道的,问就是吃过亏。

4.3.3 SPI 驱动框架

SPI 比 I2C 快,主要用于高速传感器。IMU(加速度计+陀螺仪)、一些高速气压计、外部 Flash,都走 SPI。

HAL 的 SPI 接口:

class SPIDevice {
public:
    virtual bool transaction(const uint8_t *tx, uint8_t *rx, uint16_t len) = 0;
    virtual void set_speed(enum bus_speed speed) = 0;
    virtual void set_cs_pin(uint8_t pin) = 0;
    virtual void cs_assert() = 0;
    virtual void cs_release() = 0;
};

SPI 驱动里最核心的是片选(CS)管理。每个 SPI 设备独享一个 CS 引脚。ArduPilot 的 SPI 驱动会自动管理 CS 的拉高拉低,你只管发数据。

我记得调试 ICM-20602 IMU 时,SPI 读数全是 0xFF。查了三天,最后发现是 CS 引脚配置错了。hwdef.dat 里写的是 PB12,但实际板子上 CS 接的是 PC13。改过来就好了。

避坑指南:SPI 的时钟极性(CPOL)和时钟相位(CPHA)必须跟传感器数据手册一致。搞反了,读出来的数据全是错的。我建议你写驱动前,先拿逻辑分析仪抓一下波形,确认时序对不对。

4.3.4 GPIO 驱动框架

GPIO 是最简单的,但也是最容易出错的。LED、蜂鸣器、继电器、一些使能引脚,都走 GPIO。

HAL 的 GPIO 接口:

class DigitalSource {
public:
    virtual void set_mode(uint8_t mode) = 0;
    virtual void write(uint8_t value) = 0;
    virtual uint8_t read() = 0;
    virtual void toggle() = 0;
};

嗯,这里有个细节。ArduPilot 的 GPIO 驱动支持中断回调。你可以给某个引脚注册一个中断处理函数,当引脚电平变化时自动触发。RC 接收机的 PPM 信号解码就用到了这个功能。

4.3.5 ADC 驱动框架

ADC 用于测量模拟量。电池电压、电流、RSSI、一些模拟传感器,都走 ADC。

HAL 的 ADC 接口:

class ADCSource {
public:
    virtual float read_average() = 0;
    virtual float read_latest() = 0;
    virtual void set_pin(uint8_t pin) = 0;
    virtual uint8_t pin() = 0;
};

ADC 驱动里有个重要的概念:采样平均。因为 ADC 读数有噪声,直接拿来用会跳变。ArduPilot 默认对每个通道采样 8 次取平均。你可以在参数里调整采样次数。

我曾经遇到电池电压读数不准的问题。实际电压 12.6V,飞控读出来 12.1V。查了分压电阻,发现是 ADC 参考电压不准。STM32 的内部参考电压有 ±5% 的误差。解决办法?用外部基准电压源,或者在代码里做软件校准。

核心要点:ADC 的精度取决于参考电压的稳定性。如果要求高精度,别用内部 VREF,用外部基准。

4.3.6 PWM 驱动框架

PWM 是飞控的最终输出。电调、舵机、云台,全走 PWM。

HAL 的 PWM 接口:

class PWMSource {
public:
    virtual void set_freq(uint32_t freq) = 0;
    virtual void set_duty(uint16_t duty_us) = 0;
    virtual void enable() = 0;
    virtual void disable() = 0;
};

PWM 驱动在 ArduPilot 里用的是硬件定时器。每个 PWM 通道对应一个定时器的比较输出通道。定时器频率决定了 PWM 的刷新率,比较值决定了脉宽。

嗯,这里要注意。不同的电调对 PWM 频率要求不同。普通 PWM 电调用 50Hz(周期 20ms),OneShot 电调用 125Hz 到 250Hz,DSHOT 电调走的已经不是 PWM 了,是数字协议。

我记得第一次用 DSHOT 电调时,发现飞控输出的是普通 PWM,电调不转。后来才知道 DSHOT 需要专门的驱动,不能用普通的 PWM 输出。ArduPilot 的 HAL 里专门有一套 DSHOT 驱动,走的是定时器的 DMA 输出。

经验之谈:PWM 输出频率越高,电机响应越快,但电调发热也越大。我个人习惯:普通四轴用 400Hz,穿越机用 1000Hz 以上。具体看电调支持的最高频率。

4.4 总结一下

HAL 体系结构说白了就是三层:应用层只管调用,抽象层定义接口,BSP 层负责实现。每个外设驱动都有一套固定的接口模板,BSP 层去填具体实现。

你如果以后要给新板子移植 ArduPilot,核心工作就是写 BSP。把 hwdef.dat 配好,把每个外设的驱动实现好,剩下的应用代码基本不用动。

我个人觉得,理解 HAL 最好的方法就是读代码。打开 libraries/AP_HAL 目录,看看每个外设的接口定义。再打开 libraries/AP_HAL_ChibiOS,看看具体实现。对照着看,很快就能明白。

嗯,今天就聊到这。HAL 这块内容不少,但理解了框架,后面学具体模块就轻松多了。


公众号:蓝海资料掘金营,微信deep3321