写字符设备驱动时,大多数开发者最先接触的都是open、read、write、ioctl这一套,甚至很多教程在讲完这些之后就直奔中断和内核线程。可只要你手上的外设开始带固件,比如 WiFi 模组、USB 转串口芯片、网卡、FPGA 加速卡甚至是某些传感器,就绕不开 firmware 这个词。这篇文章是 Linux 驱动基础系列的第三篇,专门拆解固件的“声明”和“加载”两个动作:硬件明明已经通电,为什么驱动还要往设备里写一段二进制数据?驱动代码里到底用什么方式告诉内核“我需要某个 bin 文件”?声明和加载的顺序错了、位置错了,会带来什么后果?
适合正在写或准备接手内核驱动的人,尤其是 dmesg 里看到Direct firmware load for xxx failed with status -2会一头雾水的朋友。后面我会用一个虚构的my_uart驱动贯穿全文,把一个带固件外设从声明到加载成功的完整路径走一遍。先把话放在前面:声明和加载是两件事,前者是告诉依赖、后者是建立数据通路,两者分开想清楚,很多问题就自然不是问题了。
1. 固件是什么:加载之前先搞清楚加载的对象
1.1 驱动和固件是两个 CPU 上的两段代码
平时我们说的“驱动”,指的是运行在主机 CPU 上、跑在 Linux 内核态的那段代码,它负责控制系统总线、处理中断、向用户空间提供接口。而“固件”是运行在外设内部微控制器、DSP 或者专用处理单元上的一段程序,相当于设备自己的“操作系统”。以一块常见的 WiFi 模组为例,主 CPU 上的驱动负责通过 SDIO 或者 USB 接口把数据包递过去,但模组内部那个小处理器到底怎么处理射频信号、怎么管理协议栈,完全由固件决定。
这两个程序的关系,可以类比成一部手机和一个智能音箱:手机上的 App 相当于驱动,音箱出厂时内部烧录的那套系统相当于固件。没有烧录系统,音箱的喇叭、按钮、指示灯都在,但它什么也干不了;反过来说,App 写错了可以随时重新装,设备本身不需要拆开。把固件存放在主机侧、由驱动负责加载,就是给这个“随时重装系统”留了一扇门。
1.2 为什么很多外设不把固件存到自己肚子里
早期硬件喜欢把固件烧死在设备自带的 flash 里,成本高不说,以后想修一个 bug 就得召回硬件或者让用户自己刷芯片。现在大量外设的设计思路是:设备内部只放一段非常小的 bootloader,真正的功能固件存放在主机的根文件系统里,每次上电由内核驱动读出来,再通过总线协议灌进设备。硬件上的存储成本被省掉,固件升级也变成了一次普通文件替换。
这套机制带来几个非常实际的好处:固件可以和驱动一起发布,版本匹配关系看得见摸得着;产线上可以针对不同批次硬件使用不同固件文件,不需要改代码;出了问题时收集主机上的固件文件和日志,比拆设备读 flash 容易太多。代价也很直白,内核驱动必须明确告诉系统“我依赖哪个固件文件”,并且保证在上电后合适的时间点完成加载,这就是后面要说的声明与加载。
1.3 内核固件框架与 /lib/firmware 的约定
Linux 内核从很早开始就专门维护了一个 firmware loader 子系统,编译开关是CONFIG_FW_LOADER,源码在drivers/base/firmware_loader/。它的职责是统一管理从驱动发起固件请求、到最终拿到固件数据这一整条通路,包括文件路径检查、用户空间协同、缓存和超时控制。驱动作者基本不需要自己写读文件的逻辑,只需要调用内核提供的 API 把自己需要的固件名传进去。
固件文件的默认存放目录是/lib/firmware/,部分发行版会用/usr/lib/firmware/,通常两者是指向同一位置的符号链接。在内核源码树下编译时,厂商还会提供一个linux-firmware仓库,里面收集了各种主流硬件设备的固件;对于自研设备,直接把产品固件拷进/lib/firmware即可。文件名理论上可以随便起,但实际工程里强烈建议带上厂商号和板级标识,后面我会解释为什么。
2. 固件的“声明”:让内核工具链知道你依赖什么
2.1 MODULE_FIRMWARE:从一个宏开始的依赖声明
在内核模块里声明固件依赖,最标准的手段是MODULE_FIRMWARE()宏。它的作用不是去加载文件,而是把固件文件名写进模块的 modinfo 段,让内核模块加载器、depmod、initramfs 生成工具都能看到这个模块“需要哪些固件”。说白了,它是一份依赖清单。
一个带固件的驱动一般会在文件头部写下这样的声明:
#include <linux/module.h> #include <linux/firmware.h> #define DRV_NAME "my_uart" MODULE_LICENSE("GPL"); MODULE_AUTHOR("Kernel Coder"); MODULE_DESCRIPTION("Firmware loading example for my_uart"); MODULE_FIRMWARE("my_uart_v2.bin");注意这里列出的是文件名,不是路径。MODULE_FIRMWARE()可以写多行,如果驱动兼容多个固件版本,就都写进去,比如:
MODULE_FIRMWARE("my_uart_v1.bin"); MODULE_FIRMWARE("my_uart_v2.bin");很多新手驱动一开始不写这个宏,驱动也能跑起来,因为真正决定能不能加载到数据的是后面要讲的request_firmware()。但一旦你的驱动需要被打进 initramfs,或者需要被udev识别固件依赖,不写声明的驱动就会成为“启动早期设备无法工作”的隐形坑。声明这个动作,本质上是在跟整个内核工具链对话。
2.2 声明之后发生了什么:modinfo、modules.firmware 与 initramfs
把模块编译出来后,第一时间可以用modinfo查看声明是否生效:
modinfo my_uart.ko输出里会多出几行:
firmware: my_uart_v1.bin firmware: my_uart_v2.bin这只是声明对用户可见的最表层。真正重要的是depmod:当系统跑一遍depmod -a之后,内核模块目录下会生成一个modules.firmware文件,里面按模块列出了所有固件依赖。类似 dracut、update-initramfs 这类 initramfs 工具,在制作启动镜像时就会扫描这个文件,把列出的固件自动拷贝进去。
所以如果你写了一个带固件的网卡驱动,并且希望根文件系统挂载之前网卡就能工作,这个宏必须写。否则开机早期内核直接加载驱动时,固件目录可能还在 rootfs 里没起来,驱动拿着文件名在/lib/firmware底下什么也找不到。反过来说,只要声明写对了,initramfs 会把固件塞进启动镜像,early boot 阶段就能把设备拉起来。这个依赖关系,就是“声明”的核心价值。
2.3 固件名不写死在源码里的做法
固件文件名不一定非要硬编码在 C 代码里。实际项目中,尤其是同一块主控板衍生出多个产品型号时,我更推荐把固件名放到设备树或者模块参数里。比如在驱动中通过设备树属性读取固件名:
static int my_uart_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; const char *fw_name = NULL; const struct firmware *fw; int ret; ret = of_property_read_string(dev->of_node, "firmware-name", &fw_name); if (ret) { dev_err(dev, "missing firmware-name property\n"); return -EINVAL; } ret = request_firmware(&fw, fw_name, dev); if (ret) return ret; /* ... 下载固件到设备 ... */ release_firmware(fw); return 0; }设备树里对应节点:
my_uart@0 { compatible = "vendor,my-uart"; firmware-name = "my_uart_v2.bin"; };这样同一份驱动可以适配多个产品,不同设备树用不同固件,驱动代码不用频繁改。要注意的是,用这种方式动态获取固件名时,MODULE_FIRMWARE()仍然要写,因为 initramfs 打包工具是静态扫描模块信息的,它看不到设备树里写的是什么。可以声明多个可能用到的固件名,实际加载哪一个由运行时设备树决定。
3. 固件的“加载”:三种请求 API 与适用场景
3.1 request_firmware:同步加载的直白用法
加载固件最直接的 API 是request_firmware(),它会在当前进程上下文里阻塞等待,直到固件数据就绪或者超时失败。一个典型用法:
static int my_uart_load_firmware(struct device *dev) { const struct firmware *fw = NULL; int ret; ret = request_firmware(&fw, "my_uart_v2.bin", dev); if (ret) { dev_err(dev, "failed to load firmware, ret=%d\n", ret); return ret; } dev_info(dev, "firmware loaded, size=%zu bytes\n", fw->size); /* 把固件数据下发给硬件 */ my_uart_download_fw(dev, fw->data, fw->size); release_firmware(fw); return 0; }函数签名里第三个参数dev很重要,它不光用于打印日志,还用于关联设备的生命周期,固件加载器会把它挂在对应的设备对象上。fw->data指向内核分配的缓冲区,fw->size是文件大小,用完之后必须调用release_firmware()释放,否则每次加载固件都会泄漏一块内存。
这种同步方式适合对启动时间不敏感、并且确认根文件系统已经挂载的场景。如果硬件本身带 flash、固件只在驱动 probe 时刷一次,同步方式完全够用。但如果设备一插上就要在几毫秒内完成初始化,或者系统处于非常早期的启动阶段,同步阻塞就会变成灾难。另一个很容易踩的坑是:在probe里同步调用request_firmware()时,如果用户空间还没就绪,内核会去等待一个可能没人处理的事件,最终表现为设备初始化被死死卡住。
3.2 request_firmware_nowait:异步加载的正确姿势
对启动时序敏感的设备,应该用request_firmware_nowait()。它会注册一个回调,然后立即返回,固件真正加载完成后内核在 worker 线程里调用你的回调函数。这样probe可以立刻返回,设备注册不会被阻塞。
static void my_uart_fw_cb(const struct firmware *fw, void *context) { struct my_uart_dev *pdata = context; if (!fw) { dev_err(&pdata->dev, "async firmware load failed\n"); return; } /* 固件到了,再继续初始化硬件 */ if (!my_uart_check_fw(fw->data, fw->size)) my_uart_download_fw(&pdata->dev, fw->data, fw->size); release_firmware(fw); } static int my_uart_probe(struct platform_device *pdev) { struct my_uart_dev *pdata; int ret; pdata = kzalloc(sizeof(*pdata), GFP_KERNEL); if (!pdata) return -ENOMEM; platform_set_drvdata(pdev, pdata); ret = request_firmware_nowait(THIS_MODULE, true, "my_uart_v2.bin", &pdev->dev, GFP_KERNEL, pdata, my_uart_fw_cb); if (ret) { dev_err(&pdev->dev, "failed to request firmware: %d\n", ret); kfree(pdata); return ret; } return 0; }这里的第二个参数true表示:如果内核在/lib/firmware下直接找不到文件,允许触发用户空间协同机制去帮驱动找固件。如果你确信固件一定在/lib/firmware,可以传false,内核找不到就直接调回调并传一个空指针。注意回调里的context就是你传入的pdata,它的生命周期必须由你自己保证,这个问题在后面的坑里我会专门说。
3.3 request_firmware_direct:只需要内核自己找文件时
还有一个不那么常用但非常有用的接口:request_firmware_direct()。它和request_firmware()最大的区别是:只在内核能直接访问的文件系统路径里寻找固件,匹配失败就直接返回错误,不会触发任何用户空间协助流程。这个接口适合两类场景:一类是固件属于“可选能力”,找不到时可以退回到低功能模式继续工作;另一类是启动非常早期,用户空间根本没有起来,触发事件也无人处理,不如快速失败然后自己决定怎么处理。
static int my_uart_load_fw_optional(struct device *dev) { const struct firmware *fw = NULL; int ret; ret = request_firmware_direct(&fw, "my_uart_optional.bin", dev); if (ret == -ENOENT) { dev_info(dev, "optional firmware not found, run in basic mode\n"); return 0; } if (ret) return ret; my_uart_download_fw(dev, fw->data, fw->size); release_firmware(fw); return 0; }从名字也能看出来,它多了一个 direct 后缀,语义是“直接、快速、不折腾”。调试驱动时也能利用它快速判断固件文件是否真的存在于内核实模式可见的文件系统里:如果request_firmware_direct()都返回-ENOENT,那基本可以确定文件没放对位置,和用户空间服务无关。
3.4 到手之后干什么:校验、下载与 release
固件加载成功不代表可以闭着眼睛往设备里灌。固件文件本质就是一段二进制,内核只负责把文件内容原样拿给你,它不校验内容对不对。最稳妥的做法是在固件文件头部自定义一个固定结构,比如魔数和版本号,加载之后先校验再下载。
#define MY_FW_MAGIC 0x4D594657 /* "MYFW" */ struct my_fw_header { __le32 magic; __le32 version; __le32 payload_size; }; static int my_uart_check_fw(const u8 *data, size_t size) { const struct my_fw_header *hdr; if (size < sizeof(*hdr)) return -EINVAL; hdr = (const struct my_fw_header *)data; if (le32_to_cpu(hdr->magic) != MY_FW_MAGIC) return -EINVAL; dev_info(NULL, "firmware version %u, payload %u bytes\n", le32_to_cpu(hdr->version), le32_to_cpu(hdr->payload_size)); return 0; }为什么必须做这层校验?因为一台机器上可能有多个驱动共用一个/lib/firmware目录,很容易出现文件被覆盖、串用、或者下载工具只写了一半导致文件截断的情况。没有校验时,驱动会把垃圾数据通过总线写进硬件,轻则设备功能怪异,重则让外设进入无法恢复的状态。校验不通过时宁可让设备初始化失败,因为失败可以定位,假成功才是最难排查的。最后记得release_firmware(fw),这个函数不仅释放缓冲区,还会维护固件的缓存引用计数,漏掉一次调用,长时间反复插拔设备就会出现内核内存占用持续增长。
4. 一次固件请求背后的完整链路与内核配置
4.1 从 request_firmware 到文件落地的全流程
把固件加载的完整链路摊开看,整个过程可以分为六步:驱动发起请求、内核路径查找、用户空间协同、数据搬运、唤醒等待者、驱动使用并释放。
第一步,驱动调用request_firmware()系列接口,固件加载器根据传入的name拼出内核默认搜索路径,通常是/lib/firmware/<name>。第二步,内核尝试直接打开这个文件,如果成功,整个流程根本不会打扰用户空间。这里文件系统必须是内核当前能访问到的,也就是说 rootfs 必须已经挂载。第三步,如果直接打开失败,固件加载器会根据请求参数决定是否触发用户空间协同:生成一个携带FIRMWARE=<name>等环境变量的 uevent,发给 udev 等用户空间程序。第四步,用户空间程序找到固件文件后,通过 sysfs 属性节点把数据写入内核。这里最典型的是/sys/class/firmware/下的固件节点,操作方式是先写入loading属性表示开始,再写data属性推送数据,最后再写loading表示完成。第五步,内核收到完成信号后唤醒正在等待的驱动请求者。第六步,驱动拿到fw->data和fw->size,完成下载并释放。
现代内核已经默认不太依赖用户空间辅助路径,绝大多数情况下驱动请求固件都直接在内核路径完成。但理解这条链路仍然很重要,因为当你看到驱动初始化卡住、或者日志里出现和 uevent 相关的警告时,说明请求已经走到了用户空间那一环,而那里恰好没人应答。
4.2 固件加载相关内核配置与内置固件
内核编译时与固件加载相关的配置项主要集中在Device Drivers -> Generic Driver Options下面。常见的几项用表格列出来:
| 配置项 | 作用 | 典型值 |
|---|---|---|
CONFIG_FW_LOADER | 固件加载器主体开关 | 必须开启,一般是 y |
CONFIG_FW_LOADER_USER_HELPER | 是否允许用户空间协助加载固件 | 现代内核默认 n,已标记过时 |
CONFIG_FIRMWARE_IN_KERNEL | 允许把固件直接编入内核镜像 | 取决于是否内置固件 |
CONFIG_EXTRA_FIRMWARE | 指定要内置进内核的固件文件名列表 | 多个文件名用空格分隔 |
CONFIG_EXTRA_FIRMWARE_DIR | 指定编译时搜索这些固件的目录 | 指向固件所在路径 |
把固件直接编进内核的做法在某些场景下很有用:比如产品根本不打算用 initramfs,或者设备需要在根文件系统挂载之前就完成初始化,这时候把几个关键固件通过CONFIG_EXTRA_FIRMWARE编进内核镜像,就不需要依赖外部文件了。代价是内核镜像变大、固件升级必须重编内核。我一般只在量产启动设备上这么干,开发阶段还是直接放/lib/firmware更灵活。
配置示例:
CONFIG_FW_LOADER=y CONFIG_FIRMWARE_IN_KERNEL=y CONFIG_EXTRA_FIRMWARE="my_uart_v2.bin my_uart_v1.bin" CONFIG_EXTRA_FIRMWARE_DIR="firmware"编译时把这些固件放在内核源码树相对的firmware/目录下。注意CONFIG_EXTRA_FIRMWARE里的名字不能带路径,只能写文件名,目录统一由EXTRA_FIRMWARE_DIR指定。
4.3 固件命名与多版本管理的实战经验
固件文件命名看起来是小事,实际是事故高发区。我见过一个项目里两三个驱动模块都把自己的固件叫fw.bin,结果某次升级时一个模块的固件覆盖了另一个模块的,整条产线产品全部异常,排查了两天才发现是文件重名。现在的规矩是:固件名必须带上厂商前缀和硬件代号,比如ti_am335x_my_uart_v2.bin。如果不是产品确定的最终固件,再加一个构建号或日期后缀,比如my_uart_v2_build20241201.bin。
多版本共存时,声明和实际加载的文件要一一对应。MODULE_FIRMWARE()可以声明多个版本,但request_firmware()一次只请求一个。如果固件因业务需求要切换版本,千万不要手工去改驱动源码里的字符串,用设备树属性或者模块参数动态传入更安全。模块参数的写法也很简单:
static char *fw_name = "my_uart_v2.bin"; module_param(fw_name, charp, 0444); MODULE_PARM_DESC(fw_name, "firmware file name");这样升级固件时只需要改启动参数或设备树,驱动代码不用动。发布固件时附带一份清单,写明固件对应的驱动版本、硬件版本和校验值,能省去大量线上沟通成本。
5. 实战中最容易踩的坑与排查手册
5.1 dmesg 错误码速查:从 -2 到 -110
固件加载失败时,内核日志会打印类似Direct firmware load for my_uart_v2.bin failed with error -2的信息。这里的负数就是标准 errno,我把最常见的几个整理成一张速查表:
| 错误码 | errno | 可能原因 | 排查方向 |
|---|---|---|---|
| -2 | ENOENT | 固件文件不存在 | ls /lib/firmware/检查文件名、大小写 |
| -5 | EIO | 读取文件时 I/O 错误 | 检查文件系统是否损坏、文件权限 |
| -11 | EAGAIN | 暂时拿不到,可能正在等待用户空间 | 看 dmesg 有没有 uevent 相关内容 |
| -110 | ETIMEDOUT | 请求超时 | 检查用户空间协同是否卡住 |
| 其他正返回值 | 固件内容校验失败 | 驱动自身的校验逻辑报错 | 用 hexdump 检查文件头部 |
定位不被内核日志显示的固件名有个小技巧:如果模块已经加载,直接看/sys/module/my_uart/parameters/下的模块参数;如果模块还没加载,可以用strings my_uart.ko | grep bin把固件名翻出来。再配合sha256sum比对发布包,基本能把“文件不存在”和“文件不对”两个大类问题区分开。
5.2 异步回调的 context 生命周期问题
request_firmware_nowait()的坑主要在回调执行时机上。probe返回后,回调可能还在 worker 队列里等着执行,如果此时设备被移除、驱动remove里把context指向的结构体释放了,回调就会访问野指针,导致内核崩溃。
我处理这个问题的标准做法是:在私有数据结构里加一个加载状态和一把锁,remove里先标记“设备正在移除”,再用flush_work或者同步等待方式确认没有回调在跑,最后才释放内存。简化原型:
struct my_uart_dev { struct device *dev; struct mutex lock; bool fw_loaded; bool removed; /* ... */ }; static void my_uart_fw_cb(const struct firmware *fw, void *context) { struct my_uart_dev *pdata = context; mutex_lock(&pdata->lock); if (pdata->removed) { mutex_unlock(&pdata->lock); goto out; } /* ... */ pdata->fw_loaded = true; mutex_unlock(&pdata->lock); out: release_firmware(fw); } static void my_uart_remove(struct platform_device *pdev) { struct my_uart_dev *pdata = platform_get_drvdata(pdev); mutex_lock(&pdata->lock); pdata->removed = true; mutex_unlock(&pdata->lock); /* 等待可能正在执行的回调完成 */ flush_work(&pdata->fw_work); /* 再安全释放 pdata */ }这里不要偷懒把flags和交流用读写不加锁就完事。内核里异步回调和驱动移除是两条不同的执行流,竞争条件必须正视。
5.3 文件明明存在,为什么还是加载失败
这是最让人抓狂的一种现象:ls /lib/firmware/my_uart_v2.bin明明有文件,驱动却一直报-ENOENT。我总结过几个隐蔽原因。第一,驱动是树外模块,加载它的是旧版内核,旧内核可能没有开启CONFIG_FW_LOADER,直接读取路径的行为不一样,固件加载器干脆没生效。第二,文件确实在/lib/firmware,但驱动跑在 initramfs 阶段,ramdisk 里的/lib/firmware是旧的,缺少这个新文件,这种情况要去更新 initramfs 而不是系统根目录。第三,内核有固件缓存,某些路径下固件加载器会把固件缓存进内存,第一次加载失败后缓存了失败结果,后续请求会快速失败,需要重启或者触发重新加载。第四,文件名大小写不对,Linux 文件名是大小写敏感的,而一些从 Windows 拷贝文件过来的人最容易在这里栽跟头。
另一个容易被忽略的点是文件权限。虽然一般固件文件权限是 644 就够,但如果目录权限有问题,或者固件文件被安全模块标记为不可读,内核的kernel_read_file路径也会失败。排查时不要只ls文件名,还要确认cat /lib/firmware/my_uart_v2.bin > /dev/null能正常读。最后一个建议:遇到固件加载问题,先把 dmesg 完整抓下来,带上时间戳,配合udevadm monitor观察 uevent,大多数问题不用猜,看事件流就够了。
6. 一点个人经验
最后说点我自己实际踩出来的经验。最初接触固件加载时,我也以为“声明”就是把文件拷进/lib/firmware,后来发现真正麻烦的不是 API 本身,而是启动时序:probe 里同步请求会把整条初始化线卡住,拔插设备时异步回调又容易踩空,固件文件被其他模块覆盖这种低级错误更是让人心态爆炸。现在我做带固件驱动的固定套路是:能异步加载就异步加载,不要赌根文件系统一定及时挂载;固件文件名一定带厂商前缀,绝不使用通用名字;每个固件头部放魔数和版本号,加载后先校验再下载;所有请求路径都留 dmesg 打印,方便线上定位。这套东西坚持下来,固件相关的线上问题基本都能在几分钟内定位到文件、版本还是时序问题。希望这篇能帮你在下一步写驱动时少踩几个坑。