1. 嵌入式Linux驱动概述
大家好,我是你们的讲师。从今天开始,我们正式进入《嵌入式Linux驱动从零到部署》的课程。
第一节课,咱们先不急着写代码。先把地基打牢。什么是驱动?它到底在系统里扮演什么角色?开发环境怎么搭?这些搞不清楚,后面写再多代码也是空中楼阁。
我个人习惯,每接手一个新项目,第一件事就是先把整个软件栈的脉络理清楚。驱动也不例外。你想想看,一个嵌入式设备从开机到跑起你的应用程序,中间经历了多少层?驱动就是连接硬件和操作系统的桥梁。
1.1 什么是驱动?
说白了,驱动就是一段软件代码。它的任务只有一个:让操作系统能够控制和管理硬件设备。
你写了一个LED闪烁的程序,直接操作GPIO寄存器。这算驱动吗?严格来说不算。驱动是运行在操作系统内核里的,它遵循内核的框架和接口规范。用户程序不能直接碰硬件寄存器,必须通过驱动这个“代理人”来操作。
驱动的本质: 封装硬件操作细节,向上层提供统一的、标准化的接口。
举个例子。你在开发板上接了一个I2C温度传感器。如果没有驱动,你的应用程序就得自己去搞I2C时序、处理中断、解析数据。有了驱动,你只需要打开一个设备文件,调用read(),温度数据就回来了。这就是驱动的价值——让应用开发变得简单。
我的经验: 我在项目中遇到过很多次,硬件工程师换了一个型号相近的传感器,以为软件不用改。结果因为寄存器地址不同,应用层直接操作硬件的代码全得重写。如果当初写了驱动,只需要改驱动里的硬件映射表,应用层一行代码都不用动。
1.2 驱动的三大分类
Linux内核把驱动分成三大类:字符设备、块设备和网络设备。这个分类不是随便分的,它对应着不同的数据访问模式。
| 类型 | 数据特点 | 典型设备 | 访问接口 | 内核中标识 |
|---|---|---|---|---|
| 字符设备 | 按字节流顺序访问 | 串口、GPIO、I2C、SPI | open/read/write/ioctl | 主设备号 + 次设备号 |
| 块设备 | 按数据块随机访问 | eMMC、SD卡、NAND Flash | mount/文件系统接口 | 主设备号 + 次设备号 |
| 网络设备 | 数据包收发 | 以太网、Wi-Fi、CAN | socket接口 | 网络接口名(eth0等) |
字符设备是最常见的。你写一个LED驱动、按键驱动、传感器驱动,基本都是字符设备。它的特点是数据像水流一样,一个一个字节地处理。没有缓冲区,没有随机访问的概念。
块设备不一样。它处理的是数据块,比如一个扇区512字节。而且支持随机访问——你可以直接跳到第1000个扇区去读数据。块设备上面通常会挂载文件系统,比如ext4、FAT32。你用的U盘、SD卡,驱动层都是块设备。
网络设备比较特殊。它不通过设备节点(/dev/xxx)访问,而是通过socket接口。数据以报文(packet)的形式收发。你写一个网卡驱动,需要实现内核里的net_device_ops结构体。
注意: 这三种驱动在内核里的实现框架完全不同。字符设备用file_operations结构体,块设备用block_device_operations,网络设备用net_device_ops。千万别搞混了。我曾经见过有人把网络设备驱动写成字符设备,结果数据包收上来全变成字节流,根本没法用。
1.3 内核模块基础概念
Linux内核是宏内核,但支持模块化。什么意思呢?就是你可以把一段驱动代码编译成一个.ko文件(内核模块),然后在系统运行时动态加载进内核,不用重新编译整个内核,也不用重启系统。
这太方便了。调试驱动的时候,改一行代码,重新编译模块,insmod加载,测试。如果内核不支持模块,每次改驱动都得重新编译内核、烧写镜像、重启开发板——一天下来干不了几件事。
一个最简单的内核模块长这样:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init my_driver_init(void)
{
printk(KERN_INFO "Hello, driver world!\n");
return 0;
}
static void __exit my_driver_exit(void)
{
printk(KERN_INFO "Goodbye, driver world!\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple example module");
这里有几个关键点:
module_init:指定模块加载时调用的函数。驱动在这里做硬件初始化、注册设备等。module_exit:指定模块卸载时调用的函数。做清理工作,释放资源。MODULE_LICENSE:必须声明许可证。不声明的话,内核会报“tainted kernel”警告。商业项目里要注意这个。
我的习惯: 写模块的时候,我总会在init函数里加一句printk打印模块名和版本号。这样加载的时候看一眼内核日志,就知道模块有没有成功加载。调试阶段特别有用。
1.4 开发环境搭建
环境搭建是新手最容易卡住的地方。我见过太多人花了一周时间,就卡在编译环境上。咱们把步骤拆开,一步步来。
1.4.1 交叉编译链
你的开发电脑是x86架构,但开发板是ARM架构(或者RISC-V、MIPS)。在x86上编译出来的程序,ARM芯片跑不了。所以我们需要交叉编译链——一套在x86上运行、但生成ARM机器码的编译工具。
常用的交叉编译链有:
- arm-linux-gnueabihf-gcc:针对ARM Cortex-A系列,带硬件浮点
- aarch64-linux-gnu-gcc:针对ARM64(AArch64)架构
- arm-none-linux-gnueabi-gcc:不带硬件浮点的ARM版本
安装方式很简单。以Ubuntu为例:
sudo apt-get install gcc-arm-linux-gnueabihf
装完之后,验证一下:
arm-linux-gnueabihf-gcc --version
看到版本信息,说明交叉编译链装好了。
避坑指南: 我曾经在项目里吃过一次大亏。客户给的SDK里自带了一个交叉编译链,版本是gcc 4.8。我图省事,直接从网上下了一个gcc 9.3的。结果编译出来的驱动加载到内核里,内核直接panic。后来查了半天,发现是ABI不兼容。所以记住:交叉编译链的版本必须和内核编译时用的版本一致,最好直接用芯片厂商提供的工具链。
1.4.2 内核源码树
编译内核模块,光有交叉编译链不够。你还需要内核源码树。因为模块代码里引用了内核的头文件和宏定义,这些都在内核源码里。
有两种方式获取内核源码:
- 从芯片厂商获取:比如NXP、TI、Rockchip都会提供适配好的内核源码包。这是最推荐的方式。
- 从kernel.org下载主线内核:然后打上芯片厂商的补丁。
拿到源码后,先配置,再编译:
# 解压源码
tar -xvf linux-5.10.tar.xz
cd linux-5.10
# 配置内核(使用厂商提供的默认配置)
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- xxx_defconfig
# 编译内核(第一次编译时间较长)
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j4
编译完成后,源码目录下会生成一个Module.symvers文件。这个文件记录了内核导出的符号信息。编译外部模块时需要它。
接下来,编译一个外部模块验证环境:
# 进入模块源码目录
cd ~/my_driver
# 编译模块
make -C /path/to/linux-5.10 M=$(pwd) ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- modules
如果一切顺利,你会得到一个.ko文件。把它拷贝到开发板上,用insmod加载,用rmmod卸载。
我的建议: 第一次搭建环境,不要追求完美。先让一个最简单的hello world模块跑起来。哪怕只是打印一行日志,也说明你的工具链、内核源码、编译流程全部打通了。后面再往里面加功能,心里就有底了。
1.5 本章知识体系
下面这张图,把本章的核心知识点串起来了。你可以把它当作一个思维导图,随时回来对照。
这张图把本章内容分成了四个大块:驱动的定义、三大分类、内核模块基础、开发环境搭建。每一块之间都有逻辑关系。你可以在学习后续章节时,随时回到这张图,看看当前知识点在整个体系中的位置。
本章小结: 驱动是连接硬件和操作系统的桥梁。字符设备、块设备、网络设备各有各的框架。内核模块让你可以动态加载驱动,不用重新编译内核。开发环境的核心是交叉编译链和内核源码树,这两样配齐了,后面写代码才有基础。