第4章:网关硬件抽象层(HAL)设计
硬件抽象层,说白了就是给上层软件穿上一双「万能鞋」。不管底下是STM32还是i.MX,上层应用看到的接口都一样。我在做第一个PROFINET网关时,就因为没做好这层抽象,换了个MCU平台,结果大半个协议栈都得重写……那叫一个惨。
这一章,我们重点讲三块:GPIO模拟数字量、SPI/I2C外设封装、共享内存数据交换。这三块搞定了,你的网关硬件就算「半条腿」站稳了。
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; /* 还没稳定,上层再读一次 */
}
模拟量输入输出,我通常用两种方式:
- 内置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后,调用者只需要知道「读这个寄存器,写那个寄存器」。
我自己的封装套路是这样的:
- 总线层: 提供最基础的读写函数,比如
spi_write_read()、i2c_write() - 设备层: 针对具体外设,封装成
dev_eeprom_read()、dev_rtc_get_time() - 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, ®, 1, buf, 2) != 0)
return -EIO;
raw = (buf[0] << 8) | buf[1];
raw >>= 5; /* 右移5位,去掉低精度位 */
*temp_c = raw * 0.125f;
return 0;
}
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];
}
atomic_t或者__sync_bool_compare_and_swap。
下面这张图,展示了整个HAL层的架构和数据流向:
从图里能看出来,数据流向是双向的:
- 输入方向: 硬件 → 驱动 → HAL封装 → 共享内存 → 协议栈
- 输出方向: 协议栈 → 共享内存 → HAL封装 → 驱动 → 硬件
共享内存就是中间的「数据枢纽」。我建议把共享内存放在独立的内存段里,比如用__attribute__((section(".shared_mem"))),这样调试时能直接看内存内容。
#pragma pack(1)。
4.4 小结一下
这一章的内容,说白了就是三句话:
- GPIO: 封装成统一的DI/DO/AI/AO接口,去抖和量程转换在HAL层做掉
- SPI/I2C: 分层封装,总线层→设备层→HAL层,速度匹配要小心
- 共享内存: 双缓冲+原子操作,让数据在模块间安全、高效地飞
嗯,这些代码和设计模式,我几乎每个项目都会复用。你把它做成一个模板,以后换平台、换外设,改改底层驱动就行,上层代码基本不用动。这就是HAL的价值所在。