ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux驱动开发:固件加载的声明与实现要点解析

Linux驱动开发:固件加载的声明与实现要点解析 写字符设备驱动时大多数开发者最先接触的都是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_uart0 { 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 必须已经挂载。第三步如果直接打开失败固件加载器会根据请求参数决定是否触发用户空间协同生成一个携带FIRMWAREname等环境变量的 uevent发给 udev 等用户空间程序。第四步用户空间程序找到固件文件后通过 sysfs 属性节点把数据写入内核。这里最典型的是/sys/class/firmware/下的固件节点操作方式是先写入loading属性表示开始再写data属性推送数据最后再写loading表示完成。第五步内核收到完成信号后唤醒正在等待的驱动请求者。第六步驱动拿到fw-data和fw-size完成下载并释放。现代内核已经默认不太依赖用户空间辅助路径绝大多数情况下驱动请求固件都直接在内核路径完成。但理解这条链路仍然很重要因为当你看到驱动初始化卡住、或者日志里出现和 uevent 相关的警告时说明请求已经走到了用户空间那一环而那里恰好没人应答。4.2 固件加载相关内核配置与内置固件内核编译时与固件加载相关的配置项主要集中在Device Drivers - Generic Driver Options下面。常见的几项用表格列出来配置项作用典型值CONFIG_FW_LOADER固件加载器主体开关必须开启一般是 yCONFIG_FW_LOADER_USER_HELPER是否允许用户空间协助加载固件现代内核默认 n已标记过时CONFIG_FIRMWARE_IN_KERNEL允许把固件直接编入内核镜像取决于是否内置固件CONFIG_EXTRA_FIRMWARE指定要内置进内核的固件文件名列表多个文件名用空格分隔CONFIG_EXTRA_FIRMWARE_DIR指定编译时搜索这些固件的目录指向固件所在路径把固件直接编进内核的做法在某些场景下很有用比如产品根本不打算用 initramfs或者设备需要在根文件系统挂载之前就完成初始化这时候把几个关键固件通过CONFIG_EXTRA_FIRMWARE编进内核镜像就不需要依赖外部文件了。代价是内核镜像变大、固件升级必须重编内核。我一般只在量产启动设备上这么干开发阶段还是直接放/lib/firmware更灵活。配置示例CONFIG_FW_LOADERy CONFIG_FIRMWARE_IN_KERNELy CONFIG_EXTRA_FIRMWAREmy_uart_v2.bin my_uart_v1.bin CONFIG_EXTRA_FIRMWARE_DIRfirmware编译时把这些固件放在内核源码树相对的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可能原因排查方向-2ENOENT固件文件不存在ls /lib/firmware/检查文件名、大小写-5EIO读取文件时 I/O 错误检查文件系统是否损坏、文件权限-11EAGAIN暂时拿不到可能正在等待用户空间看 dmesg 有没有 uevent 相关内容-110ETIMEDOUT请求超时检查用户空间协同是否卡住其他正返回值固件内容校验失败驱动自身的校验逻辑报错用 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 打印方便线上定位。这套东西坚持下来固件相关的线上问题基本都能在几分钟内定位到文件、版本还是时序问题。希望这篇能帮你在下一步写驱动时少踩几个坑。
返回列表