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 内核源码树

编译内核模块,光有交叉编译链不够。你还需要内核源码树。因为模块代码里引用了内核的头文件和宏定义,这些都在内核源码里。

有两种方式获取内核源码:

  1. 从芯片厂商获取:比如NXP、TI、Rockchip都会提供适配好的内核源码包。这是最推荐的方式。
  2. 从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 本章知识体系

下面这张图,把本章的核心知识点串起来了。你可以把它当作一个思维导图,随时回来对照。

嵌入式Linux驱动概述 - 知识体系 Linux驱动 什么是驱动 驱动三大分类 内核模块基础 字符设备 块设备 网络设备 module_init/exit 开发环境搭建 交叉编译链 内核源码树 arm-linux-gnueabihf aarch64-linux-gnu make modules insmod/rmmod

这张图把本章内容分成了四个大块:驱动的定义、三大分类、内核模块基础、开发环境搭建。每一块之间都有逻辑关系。你可以在学习后续章节时,随时回到这张图,看看当前知识点在整个体系中的位置。

本章小结: 驱动是连接硬件和操作系统的桥梁。字符设备、块设备、网络设备各有各的框架。内核模块让你可以动态加载驱动,不用重新编译内核。开发环境的核心是交叉编译链和内核源码树,这两样配齐了,后面写代码才有基础。