第4章:网关硬件抽象层(HAL)设计

硬件抽象层,说白了就是给上层软件穿上一双「万能鞋」。不管底下是STM32还是i.MX,上层应用看到的接口都一样。我在做第一个PROFINET网关时,就因为没做好这层抽象,换了个MCU平台,结果大半个协议栈都得重写……那叫一个惨。

这一章,我们重点讲三块:GPIO模拟数字量、SPI/I2C外设封装、共享内存数据交换。这三块搞定了,你的网关硬件就算「半条腿」站稳了。

核心原则: HAL层只做一件事——把硬件差异「吃掉」,向上提供稳定、统一的API。

4.1 GPIO模拟数字量输入输出

工业现场最常见的信号就是数字量(DI/DO)和模拟量(AI/AO)。很多MCU的GPIO可以直接读电平、写电平,但模拟量需要ADC/DAC,或者用PWM+滤波来模拟。

我习惯把GPIO操作封装成三个层级:

层级 说明 典型接口
底层驱动 直接操作寄存器或GPIO库 gpio_set_value()
HAL封装 统一输入输出、中断、去抖 hal_di_read()
应用层 映射到PROFINET数据对象 pn_io_data_t

举个例子,数字量输入的去抖处理。很多新手直接读GPIO,结果现场电机的电磁干扰让信号跳个不停。我曾经在一个变频器项目里,DI信号每秒抖动上百次,差点把PLC搞死机。

/* HAL数字量输入——带硬件去抖 */
int hal_di_read(uint8_t channel, uint8_t *value)
{
    static uint8_t last_state[DI_CH_MAX];
    static uint8_t stable_cnt[DI_CH_MAX];
    uint8_t cur;

    if (channel >= DI_CH_MAX) return -EINVAL;

    cur = gpio_get_value(di_pins[channel]);

    if (cur == last_state[channel]) {
        if (stable_cnt[channel] < DEBOUNCE_THRESHOLD)
            stable_cnt[channel]++;
        if (stable_cnt[channel] == DEBOUNCE_THRESHOLD) {
            *value = cur;
            return 0;
        }
    } else {
        stable_cnt[channel] = 0;
        last_state[channel] = cur;
    }

    return -EAGAIN; /* 还没稳定,上层再读一次 */
}
经验之谈: 去抖阈值一般设3~5ms。太快了滤不掉干扰,太慢了影响实时性。我习惯用定时器每1ms扫描一次,连续读到相同值3次才算有效。

模拟量输入输出,我通常用两种方式:

  • 内置ADC/DAC: 直接读取转换结果,注意参考电压和分辨率
  • 外部ADC芯片(如ADS1115): 通过I2C/SPI读取,HAL层封装成统一的hal_ai_read()

嗯,这里要注意:模拟量的量程转换一定要在HAL层做掉。比如ADC读回来是0~4095,对应0~10V,HAL层直接返回mV值。上层不用关心底层是12位还是16位ADC。

4.2 SPI/I2C外设驱动封装

工业网关里,SPI和I2C用的地方太多了:EEPROM、RTC、温度传感器、IO扩展芯片……每个外设的数据手册都写得像天书,但封装成HAL后,调用者只需要知道「读这个寄存器,写那个寄存器」。

我自己的封装套路是这样的:

  1. 总线层: 提供最基础的读写函数,比如spi_write_read()、i2c_write()
  2. 设备层: 针对具体外设,封装成dev_eeprom_read()、dev_rtc_get_time()
  3. HAL层: 再往上抽象一层,比如hal_nvm_save()、hal_rtc_get()

举个例子,一个I2C温度传感器(比如LM75)的HAL封装:

/* HAL层——温度传感器接口 */
int hal_temp_init(void)
{
    /* 初始化I2C总线 */
    i2c_master_init(I2C_BUS_1, 100000); /* 100kHz */
    return 0;
}

int hal_temp_read(float *temp_c)
{
    uint8_t reg = LM75_TEMP_REG;
    uint8_t buf[2];
    int16_t raw;

    /* 写寄存器地址,读2字节数据 */
    if (i2c_write_read(LM75_ADDR, &reg, 1, buf, 2) != 0)
        return -EIO;

    raw = (buf[0] << 8) | buf[1];
    raw >>= 5; /* 右移5位,去掉低精度位 */
    *temp_c = raw * 0.125f;

    return 0;
}
避坑指南: 我曾经在SPI总线上挂了一个高速ADC和一个慢速EEPROM,结果ADC的时钟频率太高,EEPROM直接罢工。后来加了总线仲裁,每个设备独立片选,时钟频率按最慢的设备设。记住:SPI总线的速度由最慢的从设备决定。

SPI和I2C的选择,我列个对比表:

特性 SPI I2C
速度 最高可达几十MHz 标准100kHz~400kHz
引脚数 4线(MOSI/MISO/SCLK/CS) 2线(SDA/SCL)
多设备 每个设备需独立CS 地址寻址,总线共享
适用场景 高速ADC/DAC、显示、存储 传感器、RTC、EEPROM

我个人习惯:实时性要求高的用SPI,设备多且速度要求不高的用I2C。PROFINET网关里,我通常用SPI连接以太网控制器,用I2C挂一堆传感器和配置存储。

4.3 共享内存数据交换机制

网关内部,数据要在不同模块之间「飞」:PROFINET协议栈、应用逻辑、HAL驱动……如果每个模块都直接调用对方的函数,代码就乱成一锅粥。

共享内存,就是给这些模块一个「公共黑板」。谁想写数据就写上去,谁想读数据就来查。我常用的结构是环形缓冲区(Ring Buffer)加双缓冲(Double Buffer)。

先看一个简单的共享内存结构体:

/* 共享内存——网关数据交换核心 */
typedef struct {
    /* 数字量输入输出 */
    uint8_t di_data[DI_CH_MAX];
    uint8_t do_data[DO_CH_MAX];

    /* 模拟量输入输出(单位:mV 或 mA) */
    int32_t ai_data[AI_CH_MAX];
    int32_t ao_data[AO_CH_MAX];

    /* 状态标志 */
    uint32_t status_flags;

    /* 时间戳 */
    uint64_t timestamp_us;

    /* 读写锁(原子操作) */
    atomic_t lock;
} hal_shared_mem_t;

/* 全局共享内存实例 */
static hal_shared_mem_t *g_shared_mem;

为什么用双缓冲?因为PROFINET协议栈是周期性扫描的,如果一边写一边读,数据可能读到一半。双缓冲的思路是:

  • 前台缓冲区: 供PROFINET协议栈读取
  • 后台缓冲区: 供HAL驱动写入
  • 每次周期切换时,交换前后台指针

代码实现大概这样:

/* 双缓冲交换 */
void hal_shared_mem_swap(void)
{
    /* 原子交换指针 */
    hal_shared_mem_t *tmp = g_shared_mem_front;
    g_shared_mem_front = g_shared_mem_back;
    g_shared_mem_back = tmp;
}

/* HAL写入(写后台缓冲区) */
int hal_shared_mem_write_di(uint8_t ch, uint8_t val)
{
    if (ch >= DI_CH_MAX) return -EINVAL;
    g_shared_mem_back->di_data[ch] = val;
    return 0;
}

/* 协议栈读取(读前台缓冲区) */
uint8_t hal_shared_mem_read_di(uint8_t ch)
{
    if (ch >= DI_CH_MAX) return 0;
    return g_shared_mem_front->di_data[ch];
}
小技巧: 共享内存的锁一定要用原子操作,别用互斥锁。PROFINET的实时周期可能只有1ms,互斥锁一阻塞,整个实时性就崩了。我习惯用atomic_t或者__sync_bool_compare_and_swap。

下面这张图,展示了整个HAL层的架构和数据流向:

网关HAL层架构与数据流向 网关HAL层架构与数据流向 PROFINET协议栈 / 应用逻辑 共享内存(双缓冲) 前台缓冲区 ←→ 后台缓冲区 HAL封装层(统一API) hal_di_read() | hal_do_write() | hal_ai_read() | hal_ao_write() GPIO驱动 SPI驱动 I2C驱动 硬件层(GPIO引脚 / SPI总线 / I2C总线)

从图里能看出来,数据流向是双向的:

  • 输入方向: 硬件 → 驱动 → HAL封装 → 共享内存 → 协议栈
  • 输出方向: 协议栈 → 共享内存 → HAL封装 → 驱动 → 硬件

共享内存就是中间的「数据枢纽」。我建议把共享内存放在独立的内存段里,比如用__attribute__((section(".shared_mem"))),这样调试时能直接看内存内容。

又一个坑: 共享内存的字节对齐问题。如果结构体里有uint8_t和int32_t混着,不同编译器可能填充不同的对齐字节。我吃过一次亏:在ARM和x86上结构体大小不一样,数据交换直接错位。解决方案是手动指定对齐,或者用#pragma pack(1)。

4.4 小结一下

这一章的内容,说白了就是三句话:

  • GPIO: 封装成统一的DI/DO/AI/AO接口,去抖和量程转换在HAL层做掉
  • SPI/I2C: 分层封装,总线层→设备层→HAL层,速度匹配要小心
  • 共享内存: 双缓冲+原子操作,让数据在模块间安全、高效地飞

嗯,这些代码和设计模式,我几乎每个项目都会复用。你把它做成一个模板,以后换平台、换外设,改改底层驱动就行,上层代码基本不用动。这就是HAL的价值所在。