news 2026/10/23 3:59:08

Linux字符设备驱动fasync异步通知机制与SIGIO信号发送实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux字符设备驱动fasync异步通知机制与SIGIO信号发送实战

做Linux驱动开发时间长了你会慢慢发现,应用层和内核态之间最常用的交互方式其实就三样:read、write、ioctl,说白了就是“你问我答”的同步模式。但有一类场景下这种一问一答的效率非常低,比如网卡收到数据包、串口来了一帧数据、GPIO电平跳变、底层硬件异常报警……这些事件发生的时间完全不可预期,如果应用层靠轮询去查,要么延迟大,要么白白消耗CPU。更合理的做法是:驱动在事件发生的那一刻主动“喊”应用层一声——“嘿,有情况了”,由应用层决定什么时候来取数据。这个“喊一声”的机制在Linux下最轻量、最直接的方式就是信号。

这一讲我们专门聊清楚一件事:字符设备驱动里,如何通过fasync异步通知机制给应用程序发送信号。我假设你已经会写基本的字符设备驱动,熟悉file_operations结构体、open/read/write这些回调的注册流程,所以下面会直接进入主题,把信号发送这条链路上的每个环节拆开讲,包括驱动端怎么写、应用端怎么配合、实测中哪些坑最容易踩。

1. 为什么驱动要主动“推”信号:几类典型场景与机制取舍

1.1 轮询的痛点和异步通知的定位

先从一个真实场景说起。某项目里我写了一个采集卡的驱动,硬件上有8路AD通道,数据由FPGA侧异步往驱动里的DMA缓冲区灌,灌满一帧就触发一次中断。最初应用层用的是最朴素的方案:起一个线程,每隔20ms调用一次read,读取当前累积的数据。结果发现两个问题:一是20ms的轮询间隔在高吞吐场景下CPU占用率窜到30%以上,二是如果数据帧刚好在上一次轮询之后到达,应用层要等下一个20ms才能感知到,延迟不稳定。

后来改成阻塞式read加上内核等待队列,CPU占用问题解决了,但应用层为了同时监控多个设备,不得不给每个设备开一个线程堵在read上,代码结构变得很别扭。

这两种模式之外,Linux其实还提供了第三种选择:异步通知。驱动在事件发生的瞬间向应用进程发送一个SIGIO信号,应用层收到信号后再去read拿数据。设备空闲时应用层可以睡大觉,CPU占用为零,事件到来时又能做到及时响应。这种“设备主动喊人”的模型,正是fasync机制的设计初衷,也是本讲标题里“驱动程序发送信号给应用程序”的标准落地方式。

1.2 信号、等待队列与poll/select:该怎么选

这里需要把三种机制放在一起比一比,免得你以后选型时纠结。

机制通信方向应用层使用方式典型延迟适合场景
等待队列 + 阻塞read内核通知应用可读read线程阻塞在fd上实时,但每设备一线程单设备、单线程的简单采集
poll/select/epoll内核上报可读状态应用层多路复用等待实时,多fd统一管理多设备、多fd集中管理
fasync + SIGIO内核主动推信号注册signal handler实时,单线程可处理多fd事件驱动、需要主动通知的场景

实测下来的结论是:如果只是单设备采集,阻塞read配合等待队列最省事;如果是要同时管理几十个socket或者多个设备fd,epoll更合适;但如果你的应用是事件驱动的架构,比如硬件中断到来时需要立刻改变应用层状态机、或者需要在信号handler里做轻量级处理,fasync+SIGIO是最贴合“推送”语义的方案。本讲的标题既然点明了“发送信号”,那我们就围绕fasync这条完整的链路来展开,后面也会讲到它和阻塞IO混用的方式。

2. 驱动端完整实现:fasync注册、信号触发与数据保护

2.1 异步通知的三个回调函数与数据结构

先说驱动端要做的事。要想让驱动具备“发信号”的能力,核心就三个东西:

  • 一个用于保存异步通知队列的指针:通常是struct fasync_struct *async_queue,放在设备的私有结构体里。
  • 一个实现file_operations里.fasync回调的函数:这个函数是应用层调用fcntl(fd, F_SETFL, O_ASYNC)时内核回调进来的,用于注册或注销异步通知。
  • 在事件发生处调用kill_fasync():真正把信号发给应用的地方。

注意file_operations里的.fasync不是必须实现的,如果你的驱动不打算做异步通知,这个回调留空完全没问题。一旦你要做,上面三个组件就必须配合起来,缺一个信号都发不出去。

先看一个典型的实现骨架。假设我们的设备是个简单的数据采集模块,私有结构体长这样:

struct my_dev { struct cdev cdev; struct mutex lock; struct fasync_struct *async_queue; // 异步通知队列 int data_ready; // 数据就绪标志 };

.fasync回调函数的标准写法如下:

static int my_fasync(int fd, struct file *filp, int mode) { struct my_dev *dev = filp->private_data; return fasync_helper(fd, filp, mode, &dev->async_queue); }

fasync_helper是内核帮我们封装好的辅助函数,它会根据mode的值(F_SETFL传入O_ASYNC时是FASYNC_ON,清除时是FASYNC_OFF)自动把当前打开文件的struct file挂入或移出异步通知队列,同时处理好引用计数。你只需要把队列指针的地址传进去,剩下的事情内核全包了。

2.2 kill_fasync触发信号:什么时候调、怎么调

队列注册好之后,接下来就是在合适的时机调用kill_fasync。这个函数原型是:

void kill_fasync(struct fasync_struct **fp, int sig, int band);

参数解释:

  • fp:指向异步通知队列指针的指针,也就是&dev->async_queue。
  • sig:要发送的信号号,驱动里几乎总是填SIGIO。
  • band:读写方向标记,常用的是POLL_IN和POLL_OUT。这个值会被编码到信号附带的信息里,应用层通过siginfo结构可以读出是“可读”还是“可写”。

我们在数据到达的中断处理或者工作队列里调用它:

static void my_irq_handler(struct my_dev *dev) { // 硬件数据已经搬运到缓冲区 mutex_lock(&dev->lock); dev->data_ready = 1; mutex_unlock(&dev->lock); // 唤醒阻塞读的进程(如果应用是阻塞模式) wake_up_interruptible(&dev->wq); // 如果应用注册了异步通知,发SIGIO信号 if (dev->async_queue) kill_fasync(&dev->async_queue, SIGIO, POLL_IN); }

有同学会问:这里既调用了wake_up_interruptible又调用了kill_fasync,是不是重复了?其实不冲突。kill_fasync只对调用过fcntl(fd, F_SETFL, O_ASYNC)的进程发信号,而wake_up_interruptible只唤醒阻塞在等待队列上的进程。一个应用既可以选择阻塞在read上等数据,也可以选择注册异步通知收信号,甚至可以两个都做:平时阻塞在read上,收到信号只是作为一种“提前唤醒”的补充手段。驱动这边两个都调,是给应用层留足选择空间。

2.3 锁与并发:kill_fasync该不该持锁调用

驱动开发里但凡涉及共享数据就绕不开锁,异步通知队列也不例外。dev->async_queue会在.fasync回调里被修改,在kill_fasync里被读取,而这两者可能发生在不同的进程上下文,甚至kill_fasync还可能在中断上下文被调用。所以最简单也最安全的做法是:用设备自带的mutex保护async_queue的注册与撤销操作,在进程上下文调用kill_fasync前也读一下队列是否为空。

实际项目中我见过一种偷懒写法:直接在.fasync回调里不加任何锁,然后某次多个进程同时打开设备节点的时候,队列指针被写乱,信号随机发给缺省进程组,调试起来非常痛苦。所以我的建议是:.fasync回调里的参数检查可以简化,但async_queue指针的读写必须保证原子性。如果你用mutex保护,注意在.release回调里也要清理:

static int my_release(struct inode *inode, struct file *filp) { struct my_dev *dev = filp->private_data; // 进程退出时把fd从异步通知队列中移除,避免悬空指针 if (dev->async_queue) fasync_helper(-1, filp, 0, &dev->async_queue); return 0; }

这里fasync_helper(-1, ...)是标准的注销手段,内核会在队列对应的进程退出时自动调用,但我们自己在.release里显式注销一遍更保险,能避免进程异常退出导致队列里留下失效项。

3. 应用层要做的三件事:注册handler、设置属主、打开异步开关

3.1 一句话概括fcntl三连

驱动端准备就绪后,应用层想要收到SIGIO信号,必须按顺序完成下面三个动作:

signal(SIGIO, my_signal_handler); // 1. 注册信号处理函数 fcntl(fd, F_SETOWN, getpid()); // 2. 告诉内核:信号发给当前进程 fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC); // 3. 开启异步通知开关

顺序不能乱。如果先开O_ASYNC再设F_SETOWN,可能会短时间出现信号不知道该发给谁的情况,虽然实际生产中不一定每次都触发问题,但规范起见一定按上面这个顺序走。

第二步的F_SETOWN是整个机制里最容易忽略的一环。它告诉内核:这个文件描述符上产生的SIGIO信号,指名道姓发给getpid()返回的这个进程。如果漏掉这一步,内核会尝试把信号发给打开fd的进程组(前台进程组),在多进程、多会话的复杂场景下信号经常发错地方,表现就是“驱动明明调了kill_fasync,应用却一个信号都没收到”。

3.2 用sigaction而不是signal

新手喜欢用signal()注册handler,代码短,看着省事。但我建议在生产代码里统一改用sigaction,因为signal()在不同glibc版本和内核上的语义有差异,信号处理期间的掩码行为在不同平台上不一致,容易埋雷。更好的写法是这样:

static void my_sigio_handler(int sig, siginfo_t *si, void *context) { // 从 si->si_band 可以拿到 POLL_IN / POLL_OUT 等方向信息 // 这里只做标记,实际读取数据放到主循环里做 g_flag_data_ready = 1; } int setup_sigio(int fd) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_sigaction = my_sigio_handler; sa.sa_flags = SA_SIGINFO | SA_RESTART; sigemptyset(&sa.sa_mask); if (sigaction(SIGIO, &sa, NULL) < 0) { perror("sigaction"); return -1; } if (fcntl(fd, F_SETOWN, getpid()) < 0) { perror("F_SETOWN"); return -1; } if (fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC) < 0) { perror("F_SETFL O_ASYNC"); return -1; } return 0; }

两个细节这里重点说。

第一,SA_SIGINFO标志让handler能拿到si_band信息,你可以据此判断这次信号是因为数据可读(POLL_IN)还是可写(POLL_OUT)触发的,从而决定是去read还是write。如果用普通signal(),这个信息就丢了。

第二,SA_RESTART标志的意义在于:如果进程正阻塞在某个系统调用上,比如read等待数据,此时一个SIGIO信号打过来,内核在信号处理完返回后,会自动重启被中断的系统调用,而不是返回EINTR错误。没有这个标志,你的read会莫名其妙返回-1且errno等于EINTR,很多应用没处理这个返回值就直接当成硬件错误,排查起来非常绕。

3.3 信号handler里到底能不能读数据

这是另一个高频问题:收到SIGIO信号后,能不能直接在handler里调用read读数据?我的建议是:不要。信号handler是在进程上下文但处于用户态返回路径上执行的,虽然理论上可以调用read,但如果在信号处理期间又来一个SIGIO,两个handler可能嵌套执行,重入问题很难控制。更稳妥的做法是:handler里只做两件事——置一个标志位,或者往一个自写的管道里写一个字节(self-pipe trick);真正读数据的逻辑放到主循环里,看到标志位置位后再去read。这样既能避免重入,又不会因为handler里耗时的IO操作拖慢信号处理流程。

应用主循环大致是:

while (1) { if (g_flag_data_ready) { g_flag_data_ready = 0; n = read(fd, buf, sizeof(buf)); if (n > 0) { // 处理数据 } } // 其他非阻塞任务... }

你也可以把read换成阻塞模式,配合SA_RESTART,收到信号后从read里被唤醒,直接读到数据。哪种方案都行,但一定记得:不要在handler里做复杂业务逻辑,否则后续排查问题时会疯掉。

4. 实测中的典型坑:信号丢失、进程被杀、方向判断失误

4.1 应用层一收信号就退出:SIGIO的默认动作是终止进程

这是初学者最崩溃的坑。明明驱动里调了kill_fasync,结果应用层没进自定义的handler,反而整个进程直接退出了。原因很简单:SIGIO的默认信号动作是终止进程。也就是说,如果应用层没有调用任何注册函数,内核收到SIGIO后直接清掉这个进程。

所以代码检查的第一优先级就是确认sigaction(SIGIO, ...)有没有被执行、执行成功与否。我遇到过一例是因为应用层在fcntl(fd, F_SETFL, ...)之前先调了setup_sigio里的sigaction,逻辑没问题,但另一个线程抢先一步开了O_ASYNC,时序上把信号先放了出来,handler还没来得及注册,进程当场就没了。解决办法很简单:在一开始就把sigaction和fcntl放到同一个初始化函数里按顺序执行,不要拆分到不同线程。

4.2 驱动发了信号但应用老收不到:逐步定位链路

如果你确认应用层注册无误、进程还是收不到信号,就需要按下面这个顺序逐段排查了。

先查驱动读侧。在调用kill_fasync之前加一句printk,看中断或工作队列是否真的触发了。如果连这句都没打印,说明你的事件产生路径有问题,信号压根没发出去。

再查驱动里async_queue是否为空。fasync_helper注册成功之后,这个指针应该非空。可以在.fasync回调里加打印,同时用cat /proc/interrupts这种手段确认应用层执行了fcntl(F_SETFL, O_ASYNC)。如果.fasync回调没被调用,说明应用层的O_ASYNC设置环节没生效。

接着查进程属主。在应用层打印fcntl(fd, F_GETOWN)的返回值,确认它等于你的进程PID。如果你 fork 过子进程,或者fd被多个进程共享,属主可能被覆盖成最后一个设置F_SETOWN的进程,信号就会发到那个进程去。多进程场景下,每个打开同一设备节点的进程都应设置自己的属主,但驱动端的async_queue实际上会把多个进程的struct file都挂进来,kill_fasync会向所有注册进程发信号,这里不会互相覆盖,前提是你的async_queue存在设备私有结构体里,而不是全局变量。

最后再用strace跟一下系统调用。strace -e trace=fcntl,sigaction ./your_app,如果看到F_SETOWN/O_ASYNC设置成功,但SIGIO没进handler,可以用strace -e signal=SIGIO -p PID在另一个终端观察信号是否到达进程。

我实际排过的一个案例,最终定位到的问题是:驱动里的kill_fasync调用被放在一个条件判断里,那个判断条件因为一个寄存器读错而永远为假。这类问题不做链路排查真的很难发现,所以别一上来就怀疑内核API,先从“事件有没有发生”查起。

4.3 POLL_IN和POLL_OUT混用导致业务逻辑错乱

还有一个相对隐蔽的坑,跟kill_fasync的第三个参数有关。如果你的设备既可以读又可以写,比如一个双向通信的串口设备,那么读方向事件用POLL_IN,写方向事件用POLL_OUT,驱动端必须严格区分。但如果你的驱动比较“偷懒”,不管什么事件都传POLL_IN,应用层用SA_SIGINFO解析si_band时就会误判方向,原本该去写数据的逻辑跑去读数据,缓冲区空时read直接返回0,业务上表现为“莫名其妙的空读”。

对比一下正确与错误的方向标记:

事件类型正确的band错误示例后果
数据可读POLL_INPOLL_OUT应用误以为可写,可能写空数据
缓冲可写POLL_OUTPOLL_IN应用误读,read返回0或阻塞
异常/挂断POLL_ERR / POLL_HUPPOLL_IN方向信息丢失,逻辑混乱

如果确实不需要区分方向,应用层就不要依赖si_band,收到信号后统一去read一次,读到什么算什么,反倒简单。

5. 一个可复现的最小Demo:驱动定时发信号到应用层

5.1 驱动代码:用内核定时器模拟事件

讲再多不如一个能跑的Demo。下面这个驱动里,我用一个内核定时器每隔3秒模拟一次“数据到达”事件,然后调用kill_fasync发信号。代码非常精简,只保留了异步通知相关的路径,方便你在自己的环境里快速验证。

#include <linux/module.h> #include <linux/kernel.h> #include <linux/init.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/slab.h> #include <linux/timer.h> #include <linux/uaccess.h> #include <linux/fcntl.h> #include <linux/sched/signal.h> #define DEV_NAME "fasync_demo" struct fasync_demo_dev { struct cdev cdev; struct fasync_struct *async_queue; struct timer_list timer; int data_ready; }; static struct fasync_demo_dev *demo_dev; static int major; static void demo_timer_handler(struct timer_list *t) { struct fasync_demo_dev *dev = from_timer(dev, t, timer); dev->data_ready++; // 模拟数据就绪,发SIGIO给注了册的应用进程 if (dev->async_queue) kill_fasync(&dev->async_queue, SIGIO, POLL_IN); // 重新启动定时器,3秒后再来一次 mod_timer(&dev->timer, jiffies + msecs_to_jiffies(3000)); } static int demo_open(struct inode *inode, struct file *filp) { filp->private_data = demo_dev; return 0; } static int demo_fasync(int fd, struct file *filp, int mode) { struct fasync_demo_dev *dev = filp->private_data; return fasync_helper(fd, filp, mode, &dev->async_queue); } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *pos) { struct fasync_demo_dev *dev = filp->private_data; if (copy_to_user(buf, &dev->data_ready, sizeof(dev->data_ready)) < 0) return -EFAULT; return sizeof(dev->data_ready); } static int demo_release(struct inode *inode, struct file *filp) { struct fasync_demo_dev *dev = filp->private_data; if (dev->async_queue) fasync_helper(-1, filp, 0, &dev->async_queue); return 0; } static struct file_operations fops = { .owner = THIS_MODULE, .open = demo_open, .read = demo_read, .release = demo_release, .fasync = demo_fasync, }; static int __init demo_init(void) { dev_t devno; int ret; ret = alloc_chrdev_region(&devno, 0, 1, DEV_NAME); if (ret < 0) return ret; major = MAJOR(devno); demo_dev = kzalloc(sizeof(*demo_dev), GFP_KERNEL); if (!demo_dev) { unregister_chrdev_region(devno, 1); return -ENOMEM; } cdev_init(&demo_dev->cdev, &fops); demo_dev->cdev.owner = THIS_MODULE; ret = cdev_add(&demo_dev->cdev, devno, 1); if (ret) { kfree(demo_dev); unregister_chrdev_region(devno, 1); return ret; } timer_setup(&demo_dev->timer, demo_timer_handler, 0); mod_timer(&demo_dev->timer, jiffies + msecs_to_jiffies(3000)); pr_info("fasync_demo: major=%d\n", major); return 0; } static void __exit demo_exit(void) { del_timer_sync(&demo_dev->timer); if (demo_dev->async_queue) kill_fasync(&demo_dev->async_queue, SIGIO, POLL_IN); cdev_del(&demo_dev->cdev); kfree(demo_dev); unregister_chrdev_region(MKDEV(major, 0), 1); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL");

这里提两个写驱动的小细节。第一,timer_setup这个API是内核高版本的标准写法,如果你的内核比较旧,可能需要用setup_timer,两代API在回调参数上有差异,编译报错时检查一下内核版本。第二,.exit里也调了一次kill_fasync,是在模块卸载时给还挂着的进程一个收尾信号,实际产品里不一定需要,但多这一行能让Demo在反复insmod/rmmod时行为更干净。

5.2 应用代码:收信号、读计数

应用层代码保持最小化,验证逻辑就是:收到一次SIGIO,从驱动读一次int计数值,打印到屏幕。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <signal.h> #include <sys/types.h> static int fd; static void sigio_handler(int sig, siginfo_t *si, void *context) { int count = 0; (void)sig; (void)context; if (si->si_band & POLL_IN) { read(fd, &count, sizeof(count)); printf("[handler] SIGIO received, current count = %d\n", count); } } int main(void) { struct sigaction sa; fd = open("/dev/fasync_demo", O_RDONLY); if (fd < 0) { perror("open"); return 1; } memset(&sa, 0, sizeof(sa)); sa.sa_sigaction = sigio_handler; sa.sa_flags = SA_SIGINFO | SA_RESTART; sigemptyset(&sa.sa_mask); sigaction(SIGIO, &sa, NULL); fcntl(fd, F_SETOWN, getpid()); fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC); printf("app started, pid=%d, waiting for SIGIO...\n", getpid()); while (1) pause(); return 0; }

应用编译后先别急着跑。手动创建设备节点,或者写一个简单的udev规则自动创建。手动方式:

mknod /dev/fasync_demo c 240 0 # 这里的major号以你的内核实际分配为准

然后insmod fasync_demo.ko,运行编译好的app,终端上每隔3秒就应该打印一行:

[handler] SIGIO received, current count = 1 [handler] SIGIO received, current count = 2 ...

这个Demo把整条信号链路完整跑通之后,你可以再自己改改:把read从handler里挪到主循环,把POLL_IN改成POLL_OUT观察si_band的变化,或者在驱动里故意不加.fasync回调,然后对比一下“收不到信号”时的表现。把这些排列组合都试一遍,你对异步通知机制的理解会比看十遍文档都深。

5.3 扩展思路:把信号机制和epoll结合

最后再聊一个比较进阶的玩法。很多产品的应用层不会只用信号这一种通知方式,而是把信号和epoll结合:主要事件循环跑在epoll_wait上,SIGIO信号通过signalfd变成一个fd,塞进epoll里统一管理。这样就不用在信号handler里做任何实际工作,主循环里一次性处理所有事件源,代码结构非常干净,也不存在重入问题。

具体做法是:用sigprocmask屏蔽SIGIO,然后sigwaitinfo或signalfd读取信号,再把signalfd的fd加入epoll。这套方案在实际工程里我用过几次,稳定性很好,尤其适合那种既要监听硬件事件又要处理网络消息的复杂程序。信号在这里只是一个“事件源头”,真正的调度全交给epoll,算是把本讲的机制发挥出了更大的价值。

信号机制在驱动开发里属于那种“看文档五分钟、调试俩小时”的知识点,最大的难点反而不是API怎么用,而是整条链路涉及多个模块,任何一环断了都会表现为同一个症状:应用收不到信号。所以我建议你把驱动端、应用端的代码分开验证,先确保kill_fasync被调用了,再确认应用层的注册没漏步骤,最后再怀疑方向标记和并发问题。按这个顺序来,基本半小时内就能把问题定位清楚。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 3:58:18

PyCharm 安装与汉化全攻略:Windows、Mac、Linux 三平台避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 3:57:32

工控机抗干扰五层防护体系:从EMC设计到现场落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 3:57:22

Grok Bot 安装配置全攻略:从踩坑到稳定出活的实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 3:57:22

UE5 Lyra交互系统拆解:从GAS动态授予到UI解耦

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 3:56:10

注塑机数据采集器实战指南:从方案选型到落地应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 3:55:31

0.9B OCR小模型生产级部署:精度、成本与性能平衡实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华