4. 网关硬件选型与设计

各位好,欢迎来到第四章。这一章我们聊聊Linux内核与驱动基础。说实话,很多做嵌入式应用层的朋友一听到“内核驱动”就头大,觉得那是内核开发者才需要碰的东西。但做工业协议网关,你绕不开它——因为你要操作GPIO、SPI、UART,要处理中断,要跟硬件寄存器打交道。说白了,驱动就是连接硬件和操作系统的桥梁。

我个人习惯把这一章的内容分成四个台阶:内核模块编程是入门,字符设备驱动是骨架,Platform总线与设备树是解耦利器,中断处理与下半部机制是性能关键。我们一个一个来。

本章核心知识图谱:内核模块 → 字符设备驱动 → Platform总线/设备树 → 中断处理(tasklet/workqueue)
第4章 内核与驱动基础 知识结构 Linux内核与驱动基础 内核模块编程 字符设备驱动框架 Platform总线/设备树 中断处理/下半部 module_init / module_exit LICENSE / MODULE_AUTHOR file_operations 结构体 open/read/write/ioctl platform_driver 匹配 设备树 .dts 解析 request_irq / 中断号 tasklet / workqueue 💡 学习路径建议 先写一个hello模块 → 实现字符设备 → 用设备树描述硬件 → 添加中断处理 每一步都在工控网关的实际硬件上验证,不要只做模拟器实验

4.1 内核模块编程——从Hello World到实战

先看一个最简单的内核模块。你可能会说:“这跟应用层的Hello World有什么区别?”区别大了——内核模块没有main函数,它通过module_init和module_exit两个宏来注册入口和出口。

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int __init my_gateway_init(void)
{
    printk(KERN_INFO "My Gateway Module Loaded\n");
    return 0;
}

static void __exit my_gateway_exit(void)
{
    printk(KERN_INFO "My Gateway Module Unloaded\n");
}

module_init(my_gateway_init);
module_exit(my_gateway_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Industrial Gateway Core Module");

嗯,这里要注意几个点。第一,printk不是printf,它输出到内核日志,你可以用dmesg查看。第二,__init和__exit这两个宏,说白了就是告诉内核:这个函数只在初始化或卸载时用,用完了可以回收内存。我在项目中见过有人把这两个宏写反了,结果模块卸载时内核直接panic——那场面,挺尴尬的。

编译小技巧:写一个Makefile,用obj-m += my_module.o,然后make -C /lib/modules/$(uname -r)/build M=$(PWD) modules。我个人习惯把这一行写成脚本,省得每次敲。

4.2 字符设备驱动框架——工控网关的IO基础

工业网关最常干的事是什么?读写寄存器、控制GPIO、收发串口数据。这些操作在Linux里都抽象成“字符设备”。你想想看,一个设备文件/dev/gateway_ctrl,应用程序打开它,用read/write/ioctl来操作硬件——这就是字符设备驱动的核心。

驱动需要实现一个file_operations结构体,把open、release、read、write、unlocked_ioctl这些函数指针填进去。我直接给一个精简框架:

static int gateway_open(struct inode *inode, struct file *filp)
{
    // 分配硬件资源,比如GPIO、寄存器基址
    return 0;
}

static ssize_t gateway_read(struct file *filp, char __user *buf,
                            size_t count, loff_t *f_pos)
{
    // 从硬件寄存器读取数据,用copy_to_user传给用户空间
    return count;
}

static ssize_t gateway_write(struct file *filp, const char __user *buf,
                             size_t count, loff_t *f_pos)
{
    // 从用户空间接收数据,写入硬件寄存器
    return count;
}

static long gateway_ioctl(struct file *filp, unsigned int cmd,
                          unsigned long arg)
{
    // 处理控制命令,比如设置波特率、复位设备
    return 0;
}

static int gateway_release(struct inode *inode, struct file *filp)
{
    // 释放资源
    return 0;
}

static struct file_operations gateway_fops = {
    .owner   = THIS_MODULE,
    .open    = gateway_open,
    .read    = gateway_read,
    .write   = gateway_write,
    .unlocked_ioctl = gateway_ioctl,
    .release = gateway_release,
};

注册字符设备时,我建议用alloc_chrdev_region动态分配主设备号,别用静态的——静态分配容易冲突。我曾经在一个项目里,两个驱动用了同一个主设备号,结果加载顺序一变,设备就乱套了。从那以后,我再也不用静态分配了。

避坑指南:用户空间传下来的指针不能在内核里直接解引用!必须用copy_from_user和copy_to_user。我曾经见过新手直接memcpy,结果内核直接oops,系统挂掉。

4.3 Platform总线与设备树——让驱动与硬件解耦

早期的Linux驱动,硬件地址、中断号都硬编码在代码里。换一个板子就得改驱动,太痛苦了。Platform总线加上设备树,就是为了解决这个问题。说白了,驱动只关心“怎么操作硬件”,而“硬件在哪里”由设备树描述。

设备树文件(.dts)里描述硬件资源:

/ {
    gateway {
        compatible = "mycompany,industrial-gateway";
        reg = <0x01c20800 0x400>;
        interrupts = <0 29 4>;
        status = "okay";
    };
};

驱动这边,用of_match_table来匹配:

static const struct of_device_id gateway_of_match[] = {
    { .compatible = "mycompany,industrial-gateway" },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, gateway_of_match);

static int gateway_probe(struct platform_device *pdev)
{
    struct resource *res;
    void __iomem *base;
    int irq;

    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    base = devm_ioremap_resource(&pdev->dev, res);

    irq = platform_get_irq(pdev, 0);

    // 初始化硬件...
    return 0;
}

static struct platform_driver gateway_driver = {
    .probe  = gateway_probe,
    .remove = gateway_remove,
    .driver = {
        .name = "industrial-gateway",
        .of_match_table = gateway_of_match,
    },
};
module_platform_driver(gateway_driver);

你想想看,换一个硬件平台,只需要改设备树文件,驱动代码完全不用动。这就是解耦的魅力。我在做一款多平台网关时,同一套驱动跑了三个不同芯片的板子,全靠设备树来适配。

传统方式 设备树方式
硬件地址硬编码在驱动里 硬件地址在.dts中描述
换板子必须改驱动代码 换板子只改.dts,驱动不变
中断号写死在头文件里 中断号通过设备树传递
多平台维护成本高 一套驱动适配多平台

4.4 中断处理与下半部——别让中断把系统拖垮

工业网关要处理各种实时事件:串口收到数据、GPIO电平变化、定时器超时。这些都需要中断。但中断处理函数有个铁律:越快越好。为什么?因为中断上下文里不能睡眠,不能调用可能阻塞的函数,而且中断是关掉的,处理太久会影响系统实时性。

所以Linux把中断处理分成两部分:

  • 上半部(top half):在中断上下文执行,只做最紧急的事,比如读取硬件状态、清除中断标志。
  • 下半部(bottom half):在更宽松的上下文执行,做耗时的处理,比如数据解析、协议栈处理。

下半部的实现方式有几种,我重点讲tasklet和workqueue。

4.4.1 tasklet——轻量级下半部

tasklet在软中断上下文执行,不能睡眠,但比中断上下文宽松。适合处理耗时短、不需要睡眠的任务。

void my_tasklet_handler(unsigned long data)
{
    // 处理数据,比如把收到的字节放入缓冲区
}

DECLARE_TASKLET(my_tasklet, my_tasklet_handler, 0);

static irqreturn_t gateway_irq_handler(int irq, void *dev_id)
{
    // 上半部:读取硬件状态,清除中断标志
    // 然后调度tasklet
    tasklet_schedule(&my_tasklet);
    return IRQ_HANDLED;
}

4.4.2 workqueue——可睡眠的下半部

如果下半部需要做更复杂的操作,比如分配内存、操作互斥锁、甚至访问文件系统,那就得用workqueue。它在内核线程上下文执行,可以睡眠。

struct work_struct my_work;

void my_work_handler(struct work_struct *work)
{
    // 可以睡眠,可以做复杂操作
    // 比如解析Modbus协议帧
}

static irqreturn_t gateway_irq_handler(int irq, void *dev_id)
{
    // 上半部:快速处理硬件
    schedule_work(&my_work);
    return IRQ_HANDLED;
}

// 初始化时:
INIT_WORK(&my_work, my_work_handler);
选择建议:我个人习惯——如果下半部只需要做几百微秒的事,用tasklet;如果需要操作信号量、分配大块内存、或者调用可能阻塞的函数,用workqueue。记住一个原则:能在tasklet里做完就别用workqueue,因为workqueue的调度延迟更大。

4.5 本章小结

这一章我们走完了从内核模块到中断处理的完整路径。你可能会觉得内容有点多,但这些都是工控网关开发的基石。我建议你按这个顺序动手实践:

  1. 写一个hello模块,加载卸载试试
  2. 实现一个简单的字符设备,用echo/cat测试
  3. 把硬件资源移到设备树里,用platform驱动读取
  4. 添加中断处理,用tasklet或workqueue做下半部

每一步都在你的开发板上验证。别怕内核panic——我当年写驱动,一天能panic十几次。关键是学会看dmesg和/proc/kallsyms,学会用kgdb或者加printk调试。嗯,调试的事我们后面章节再细聊。