硬件抽象层(HAL)设计:HAL的作用与分层架构

说到HAL,我脑子里第一个蹦出来的词就是「中间人」。说白了,硬件抽象层就是操作系统和硬件之间的翻译官。你想想看,POS机上的硬件五花八门——打印机可能是爱普生的,扫码枪可能是霍尼韦尔的,密码键盘可能是国产的。如果应用层直接操作这些硬件,那代码得写成什么样?

我在做第一代POS机系统时,就吃过这个亏。当时图省事,直接在应用层调用了打印机的寄存器。结果呢?换了批打印机,整个应用层代码重写。从那以后,我坚决要求团队必须上HAL。

HAL的分层架构

我个人习惯把HAL分成三层,这样结构清晰,维护起来也省心:

  • 硬件驱动层(DDL):最底层,直接操作寄存器。比如控制GPIO高低电平、配置UART波特率。这一层跟具体芯片绑定。
  • 硬件抽象层(HAL):中间层,封装驱动接口。比如提供printer_init()printer_write()这样的函数。这一层不关心底层是GPIO模拟还是SPI通信。
  • 硬件服务层(HSL):最上层,提供业务级接口。比如print_receipt(),里面可能调用了多次HAL接口来完成一整套打印流程。

关键点:应用层只能调用HSL的接口,绝对不能直接操作HAL或DDL。这是铁律,谁违反谁重构。

外设驱动接口标准化

接口标准化,说白了就是给每个外设定一套「通用接口」。不管你是哪家的打印机,只要实现了这套接口,上层代码就不用改。嗯,这里要注意,接口设计要够抽象,但又不能太抽象——太抽象了实现起来麻烦,太具体了又失去通用性。

打印机接口标准化

我建议这样设计打印机的标准接口:

// printer_hal.h
typedef struct {
    int (*init)(void);
    int (*write)(const uint8_t *data, uint32_t len);
    int (*read_status)(uint8_t *status);
    int (*reset)(void);
    int (*deinit)(void);
} printer_ops_t;

你看,就五个函数。init和deinit是生命周期管理,write是核心操作,read_status用来查打印机状态,reset是应急用的。我在项目中遇到过,有些打印机初始化特别慢,所以init函数里我习惯加个超时机制。

扫码枪接口标准化

扫码枪比较特殊,它是输入设备。接口设计要考虑到数据到达的异步性:

// scanner_hal.h
typedef struct {
    int (*init)(void);
    int (*start_scan)(void);
    int (*stop_scan)(void);
    int (*get_data)(uint8_t *buf, uint32_t *len);
    void (*register_callback)(void (*cb)(uint8_t *data, uint32_t len));
} scanner_ops_t;

为什么要有回调函数?你想想看,扫码枪扫到条码是随机事件。如果轮询读取,CPU得一直忙着问「扫到了没?扫到了没?」。用回调就优雅多了——扫到了,系统自动通知你。

密码键盘接口标准化

密码键盘涉及安全,接口设计要格外小心。我个人习惯把加密操作也封装进去:

// pinpad_hal.h
typedef struct {
    int (*init)(void);
    int (*start_input)(uint8_t max_len);
    int (*get_pin_block)(uint8_t *pin_block, uint32_t *len);
    int (*verify_pin)(uint8_t *pin_block, uint32_t len);
    int (*deinit)(void);
} pinpad_ops_t;

警告:密码键盘的接口绝对不能暴露明文密码。get_pin_block返回的是加密后的PIN Block,明文密码在键盘内部就处理掉了。这是PCI安全标准的要求,碰都不要碰。

设备树(Device Tree)的配置与使用

设备树这东西,我第一次接触时觉得挺玄乎。说白了,它就是一份描述硬件配置的「说明书」。告诉操作系统:CPU有哪些外设,内存地址是多少,中断号是多少,GPIO怎么分配的。

在POS机系统里,设备树特别有用。因为POS机的硬件配置经常变——今天用A型号的打印机,明天可能换成B型号。有了设备树,改配置就像改配置文件一样简单。

设备树的基本结构

一个典型的设备树节点长这样:

/ {
    model = "POS-Terminal-V2";
    compatible = "vendor,pos-v2";

    serial@10001000 {
        compatible = "ns16550";
        reg = <0x10001000 0x1000>;
        interrupts = <0 33 4>;
        clock-frequency = <1843200>;
    };

    printer {
        compatible = "vendor,thermal-printer";
        gpios = <&gpio0 12 GPIO_ACTIVE_HIGH>;
        baud-rate = <115200>;
    };
};

你看,每个外设都是一个节点。compatible属性告诉系统用哪个驱动,reg属性告诉系统寄存器地址,interrupts属性告诉系统中断号。我在项目中遇到过,有人把reg地址写错了,结果驱动死活初始化不了。排查了两天才发现是设备树配错了。

设备树的配置技巧

这里分享几个我踩过的坑:

  • GPIO复用要小心:同一个GPIO不能同时给两个外设用。我曾经把打印机的控制脚和扫码枪的数据脚配成同一个GPIO,结果两个设备都工作不正常。
  • 中断号别冲突:不同外设的中断号不能重复。如果两个设备共享一个中断,驱动里要做中断共享处理。
  • 时钟频率要匹配:外设的时钟频率必须和实际硬件一致。配高了,通信不稳定;配低了,性能上不去。

小技巧:调试设备树时,可以在内核启动参数里加earlyconignore_loglevel,这样能看到设备树解析的详细日志。我每次移植新硬件,第一件事就是打开这个日志。

设备树在POS机中的典型应用

以我最近做的一个项目为例,设备树里配置了这些外设:

外设 设备树节点 关键属性
热敏打印机 printer@0 gpios, baud-rate, flow-control
扫码枪 scanner@1 uart, baud-rate, parity
密码键盘 pinpad@2 i2c, address, interrupt
NFC读卡器 nfc@3 spi, cs-gpio, irq-gpio

你看,每个外设的配置都清清楚楚。换硬件时,只需要改设备树文件,驱动代码完全不用动。这就是HAL加设备树的威力——硬件变了,软件几乎不用改。

嗯,最后说一句。设备树虽然好用,但也不是万能的。如果硬件改动太大,比如换了CPU架构,那设备树也得跟着大改。不过对于POS机这种相对固定的硬件平台,设备树绝对是提升开发效率的神器。