半夜两点客户甩来kernel panic截图,一台i.MX6ULL工业平板在压力测试中崩溃。从应用层的read调用查到内核evdev驱动的竞态条件,最后发现坑在input事件处理路径里。
一个ioctl引发的血案——嵌入式Linux应用开发避坑手记
有次半夜两点,客户突然甩过来一张截图,说设备跑了八个小时之后触摸屏完全没反应了。我看了一眼log,kernel panic。当时后背一凉——这玩意儿第二天早上要拿去展会演示。
搞嵌入式Linux的应该都懂,应用层出问题还好说,大不了重启进程。内核崩了,那就真的"死给你看"了。
先说一下背景。我们当时做的是一款基于i.MX6ULL的工业平板,跑Linux 4.19,QT5.12做界面,通过SPI接了一个触摸控制器。驱动芯片是现成的,原厂给了源码,我们只是改了一下设备树里的引脚配置。测试了三天,功能一切正常,于是交付小批量给客户试产。
结果客户那边跑了不到一宿就挂了。
刚开始死活复现不了。我们自己在实验室连续跑了两天都没出问题。后来看了客户的运行日志,发现一个规律:每次崩溃之前都伴随着大量触控操作——快速滑动、多点触摸交替进行。频率大概是一分钟内操作几十次。
这就有点意思了。
我一开始怀疑是SPI驱动的问题,毕竟i.MX系列的SPI控制器有过一些已知的errata。但仔细翻了数据手册,发现我们的操作频率远低于触发条件。而且如果真是硬件Bug,应该更早暴露,不至于跑八个小时才出问题。
那就只能从应用层往下查了。
我们的触摸事件处理,用的是input子系统。应用层通过open("/dev/input/eventX", O_RDONLY)拿到fd,然后在一个while循环里反复调用read。这是最常规的做法,没什么花活。但问题就出在这个"常规做法"上。
看代码:
while (1) {
struct input_event ev;
int ret = read(fd, &ev, sizeof(ev));
if (ret < 0) {
perror("read error");
break;
}
// 处理触摸事件...
handle_touch(&ev);
}
一眼看过去没啥毛病对吧?我也是这么想的。但仔细一想——如果用户连续快速触摸,事件队列里的数据积压,read是会一直有数据返回的。那么while循环里handle_touch如果处理得慢,会不会堵?
查了一下handle_touch的实现,里面居然有个同步的socket通信,去向一个远程服务查询当前界面状态。网络如果抖一下,这个调用可能阻塞几十毫秒甚至更久。问题是input事件不等人啊——事件缓冲区满了之后,内核的evdev驱动会触发一个reset操作,把整个input设备的状态清空,紧接着释放缓冲区。
释放缓冲区的时候如果应用层正在read,或者在read返回后内核还没来得及把数据完全copy到用户空间,就可能会触发一个野指针访问。
我之前一直以为input子系统的evdev很稳,毕竟Linux内核的输入框架从2.6时代就在用了,经历了这么多年打磨,应该不会有这种低级问题。但看了内核源码(drivers/input/evdev.c)才发现,evdev_events和evdev_read之间的竞态条件其实一直存在,只是触发条件比较苛刻。
修复方式倒不复杂。
方案一是把handle_touch里的阻塞操作去掉,改为异步处理。我们重构了那部分逻辑,把socket通信丢到独立线程里,handle_touch只负责把事件塞到一个环形缓冲区就返回。这样read就能以最快速度把事件消费掉,不会积压。
方案二是用epoll而非阻塞read,这样可以在事件到达和其他fd之间做多路复用,万一某个事件处理卡了,不会影响整体的读循环。
int epfd = epoll_create1(0);
struct epoll_event ev, events[8];
ev.events = EPOLLIN;
ev.data.fd = input_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, input_fd, &ev);
while (1) {
int nfds = epoll_wait(epfd, events, 8, 500);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == input_fd) {
struct input_event ev_buf[8];
int n = read(input_fd, ev_buf, sizeof(ev_buf));
for (int j = 0; j < n / sizeof(struct input_event); j++) {
async_enqueue(&ev_buf[j]);
}
}
}
}
两个方案最后我们都上了。改了之后让客户再跑压力测试,连续跑了三天三夜都没出问题。说起来也惭愧,一个如此常规的read调用,居然能踩到内核的暗坑。
事后复盘,我更倾向于认为这不是evdev的"Bug",而是我们应用的读模式踩到了一个不太常见的路径。毕竟input子系统主要面向桌面环境,桌面环境下触摸事件的频率远没有工业平板这么高,而且桌面应用一般也不会在handle_touch里做同步网络调用。
所以吃一堑长一智吧。
最后说几个结论性的东西建议你记住:(1)别在input事件处理路径里做任何阻塞操作,这玩意儿比你想的脆弱;(2)能用epoll就别用阻塞read,多路复用在嵌入式里不是摆设;(3)如果你用的芯片比较老或者内核版本比较旧,多翻翻drivers/input/evdev.c,说不定你的下一个Bug就在那等着你。
那台i.MX6ULL的板子现在还在我们实验室跑着,显示器上挂着压力测试的log。到今天已经是第47天了,没崩过。不过谁知道呢,也许第48天它就又挂了——嵌入式嘛,永远别觉得稳了。