4. 网关硬件选型与设计
各位好,欢迎来到第四章。这一章我们聊聊Linux内核与驱动基础。说实话,很多做嵌入式应用层的朋友一听到“内核驱动”就头大,觉得那是内核开发者才需要碰的东西。但做工业协议网关,你绕不开它——因为你要操作GPIO、SPI、UART,要处理中断,要跟硬件寄存器打交道。说白了,驱动就是连接硬件和操作系统的桥梁。
我个人习惯把这一章的内容分成四个台阶:内核模块编程是入门,字符设备驱动是骨架,Platform总线与设备树是解耦利器,中断处理与下半部机制是性能关键。我们一个一个来。
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——那场面,挺尴尬的。
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);
4.5 本章小结
这一章我们走完了从内核模块到中断处理的完整路径。你可能会觉得内容有点多,但这些都是工控网关开发的基石。我建议你按这个顺序动手实践:
- 写一个hello模块,加载卸载试试
- 实现一个简单的字符设备,用echo/cat测试
- 把硬件资源移到设备树里,用platform驱动读取
- 添加中断处理,用tasklet或workqueue做下半部
每一步都在你的开发板上验证。别怕内核panic——我当年写驱动,一天能panic十几次。关键是学会看dmesg和/proc/kallsyms,学会用kgdb或者加printk调试。嗯,调试的事我们后面章节再细聊。