1. 从一根USB转串口线说起:CH341的日常与内核之旅
如果你玩过树莓派、Arduino,或者调试过路由器、工控板,那你抽屉里肯定躺着一两根USB转串口线。插上电脑,识别出一个新的串口(比如/dev/ttyUSB0),打开个串口调试助手就能和板子“对话”了。这事儿看起来简单得就像给手机插充电线,但你知道吗,从你把那根小小的USB线插进电脑那一刻起,Linux内核里就上演了一场精密的“协同作战”。
这场作战的核心主角之一,就是我们今天要聊的CH341芯片。它是国内非常常见的一款USB转串口芯片,成本低、稳定性不错,很多廉价开发板和数据线都用它。而这场作战的舞台,则是Linux内核中错综复杂的USB驱动框架和TTY(终端)子系统。我们最终能在/dev下看到那个可爱的ttyUSB0设备文件,全靠背后几个“部门”——USB总线驱动、CH341特定的设备驱动、以及一个叫serial的抽象层——的紧密配合。
我自己在早期做嵌入式开发时,就经常被CH341的驱动问题搞得头疼。有时候系统识别不出来,有时候能识别但没权限,还有时候数据传输莫名其妙就断了。后来我下定决心,非得钻进内核源码里看看它到底是怎么工作的。这一看才发现,原来一个看似简单的“转接”功能,内核里却构建了一套非常优雅的分层模型。理解这套模型,不仅能帮你彻底搞定CH341的使用问题,更能让你对Linux设备驱动的设计哲学有更深的认识。今天,我就把自己踩过的坑和梳理明白的链路,用最“人话”的方式分享给你。
简单来说,整个过程就像一家快递公司(Linux内核)处理一个特殊包裹(CH341设备):
- USB端口(仓库入口) 收到一个写着“VID=1A86, PID=7523”(这是CH341的身份证)的包裹。
- USB核心(总调度中心) 根据包裹信息,去找专门处理“1A86:7523”这类包裹的CH341设备驱动(专属分拣员)。
- 专属分拣员(CH341驱动)发现这个包裹需要转换成“串口”这种标准格式,于是把它交给公司内部的Serial转换部门(serial层)。
- Serial部门有一套标准流程,它把包裹最终封装成TTY终端包裹,放在
/dev这个领取窗口,等着用户(你的串口工具)来取。
下面,我们就顺着这个流程,一层层剥开看看。
2. CH341设备驱动:专属“分拣员”的入职与认领
首先,我们得找到那位专属分拣员——CH341的驱动代码。在Linux内核源码里,它通常位于 drivers/usb/serial/ch341.c。我们不看复杂的代码细节,只抓核心逻辑。
2.1 驱动如何声明“我管CH341”
一个设备驱动在内核里“上班”的第一步,就是告诉系统:“我能管理哪些设备”。对于USB设备,这是通过一个叫做 usb_device_id 的结构体数组来实现的,你可以把它理解为驱动的一张“工作证”或“管辖清单”。
static const struct usb_device_id id_table[] = {
{ USB_DEVICE(0x1a86, 0x7523) }, // CH340/CH341最常见的型号
{ USB_DEVICE(0x1a86, 0x5523) }, // CH341的另一个变种
{ USB_DEVICE(0x4348, 0x5523) }, // 某些厂家可能用的不同VID
{ } // 结束标记
};
MODULE_DEVICE_TABLE(usb, id_table);
这里的 USB_DEVICE(VID, PID) 宏是关键。VID是厂商ID(Vendor ID),PID是产品ID(Product ID)。1a86是南京沁恒微电子的VID,7523就是CH341芯片的PID。这个表的意思就是:“我这个驱动,专门负责所有VID是0x1a86且PID是0x7523(或0x5523)的USB设备。”
那么,这个表是怎么和驱动绑定的呢?这就要引出USB转串口驱动的一个核心数据结构——usb_serial_driver。
2.2 usb_serial_driver:连接设备与串口层的桥梁
CH341驱动定义了一个 usb_serial_driver 类型的结构体,我们叫它“串口驱动描述符”。它填满了驱动所需要的各种信息和方法。
static struct usb_serial_driver ch341_device = {
.driver = {
.owner = THIS_MODULE,
.name = "ch341-uart", // 驱动名字
},
.id_table = id_table, // 关键!把上面的“管辖清单”挂进来
.num_ports = 1, // CH341通常只有一个串口
.open = ch341_open, // 打开串口时内核要调的函数
.close = ch341_close, // 关闭时调的函数
.set_termios = ch341_set_termios, // 设置波特率、数据位等参数
.break_ctl = ch341_break_ctl, // 控制Break信号
.tiocmget = ch341_tiocmget, // 获取MODEM状态(如DTR、RTS)
.tiocmset = ch341_tiocmset, // 设置MODEM状态
.read_int_callback = ch341_read_int_callback, // 中断端点回调(部分型号用)
.port_probe = ch341_port_probe, // 端口探测
.port_remove = ch341_port_remove, // 端口移除
.reset_resume = ch341_reset_resume, // 系统重置恢复
};
这个结构体是serial层定义的标准接口。你可以把它理解为Serial部门给所有USB转串口驱动发的“标准化工作手册”。CH341驱动只要按照这个手册,实现好open、set_termios这些函数(也就是CH341芯片硬件操作的具体代码),并把id_table填好,剩下的“入职”、“对接”等杂事,Serial层会帮你搞定。
2.3 驱动的“入职”入口:module_usb_serial_driver
驱动最后,会用一个宏来声明自己的入口和出口。
module_usb_serial_driver(serial_drivers, ch341_device);
这个 module_usb_serial_driver 宏是Serial层提供的“一站式入职助手”。它背后干了什么呢?我帮你展开一下:
- 它定义了一个标准的Linux内核模块的
init和exit函数。 - 在
init函数里,它会去调用usb_serial_register_drivers。 - 这个注册函数,才是真正把我们的
ch341_device(那个“标准化工作手册”)递交给Serial层总部的关键。
所以,当你执行 sudo insmod ch341.ko(或者系统自动加载)时,流程是这样的:内核模块加载 -> 调用 module_usb_serial_driver 定义的初始化函数 -> 调用 usb_serial_register_drivers -> 把CH341驱动信息注册到Serial层。
到这里,CH341这位“专属分拣员”已经带着它的“工作证”(id_table)和“工作手册”(usb_serial_driver),在Serial部门报到了。接下来,就看Serial部门怎么安排它和具体的USB设备“接头”了。
3. USB总线驱动框架:设备与驱动的“相亲大会”
CH341驱动准备好了,但它怎么知道有设备插上了呢?这就要靠Linux内核的USB总线驱动框架。这个框架就像一个大型“相亲大会”的组织者。
3.1 USB核心:大会的组织者
USB核心(USB Core)是内核中的一个子系统,它管理着所有USB主机控制器(就是你主板上的USB口)和USB设备。它的核心职责之一,就是维护两个重要的链表:
- USB设备链表:所有连接到系统上的USB设备,从你的键盘、鼠标到CH341转接线,都会被创建为一个
usb_device结构体,挂到这个链表上。 - USB驱动链表:所有注册到系统中的USB设备驱动,包括我们刚注册的CH341驱动(不过它是以一种特殊方式注册的,后面讲),都会被记录在案。
3.2 匹配(Match)与探测(Probe):看对眼就牵手
当一个CH341设备被插入电脑,硬件上电、枚举(设备告诉主机“我是谁”)完成后,USB核心就为这个新设备创建好了一个usb_device对象,里面包含了它的VID、PID等信息。
紧接着,“相亲大会”的关键环节——匹配(Match)——就开始了。USB核心会遍历USB驱动链表,对每一个驱动,取出它的id_table(就是我们之前写的那个“工作证”),和当前设备的VID/PID进行比对。
对于我们CH341设备,当遍历到CH341驱动时,USB核心发现:
设备(VID=1a86, PID=7523) 匹配上了 驱动.id_table[0] (VID=1a86, PID=7523)。
Bingo!匹配成功!
匹配成功后,内核就会调用该驱动的探测(Probe)函数。对于标准的USB设备驱动,这个Probe函数是驱动自己定义的。但对于我们USB转串口这类设备,情况有点特殊。因为CH341驱动是通过 module_usb_serial_driver 注册的,它的Probe函数并不是直接暴露给USB核心的,而是由Serial层提供的一个统一的Probe函数。
这就引出了下一个核心角色:Serial层。它不仅是“标准化工作手册”的制定者,还是这场“相亲大会”的重要中介。
4. Serial层:承上启下的“协议转换中心”
Serial层,具体指的是Linux内核中的 drivers/usb/serial 目录下的代码。它不是一个可以单独加载的模块,而是USB转串口驱动赖以运行的基础设施和框架。它的核心价值在于抽象和统一。
4.1 为什么要有个Serial层?
想象一下,如果没有Serial层,会是什么样?
- 每家芯片公司(沁恒的CH341、FTDI的FT232、普瑞的PL2303)写的驱动,都要自己实现如何创建
/dev/ttyUSBx设备文件。 - 每个驱动都要自己处理TTY子系统那一套复杂的线路规程、终端设置。
- 用户空间的应用(如
minicom、screen)面对不同芯片的驱动,可能需要不同的操作方式。
这太混乱了!Serial层的出现,就是为了解决这个问题。它定义了一个中间层,对上(TTY子系统)提供统一的串口设备接口,对下(具体芯片驱动)提供统一的驱动编程接口。
具体来说,它做了两件大事:
- 对上,它封装了一个
tty_driver。这是TTY子系统的标准接口。所有USB转串口设备,在用户空间看来,都是一个普通的TTY设备(/dev/ttyUSBx),应用软件完全不用关心底下是CH341还是FTDI。 - 对下,它定义了
usb_serial_driver结构体。这就是前面CH341驱动填的那个“标准化工作手册”。芯片厂商的驱动只需要关心如何操作自己家的硬件(实现open,set_termios等函数),而设备注册、TTY创建、数据缓冲管理等通用脏活累活,全交给Serial层。
4.2 Serial层的“双重注册”机制
这是理解整个链路最精妙的地方。Serial层自己本身,就是一个USB设备驱动!
当你加载任何一个USB转串口驱动(比如ch341.ko)时,usb_serial_register_drivers这个函数内部,实际上发生了“双重注册”:
-
向Serial层注册
usb_serial_driver: 首先,它把CH341驱动提供的ch341_device(那个“工作手册”)添加到一个Serial层内部维护的全局链表里。这样Serial层就知道:“哦,现在系统里多了一个能处理CH341芯片的驱动模块。” -
向USB核心注册一个“通用”的
usb_driver: 更关键的一步来了!Serial层自己定义了一个usb_driver结构体,比如叫usb_serial_driver。这个驱动的id_table是动态生成的——它会遍历刚才那个全局链表,把所有注册进来的具体芯片驱动(CH341、FT232、PL2303...)的id_table全部收集起来,合并成一张大表。然后,Serial层调用
usb_register(&usb_serial_driver),把这个集大成的驱动注册到USB核心。
这意味着什么?意味着真正直接面对USB核心、参与“设备匹配”的,是Serial层提供的那个“通用USB驱动”,而不是具体的CH341驱动。
当CH341设备插入时,USB核心进行匹配:
- 它发现设备的VID/PID匹配上了
usb_serial_driver.id_table这个大表中的某一项(该项来自CH341驱动)。 - 于是USB核心调用
usb_serial_driver的Probe函数(这是Serial层实现的函数)。 - Serial层的Probe函数被调用后,它再根据匹配到的VID/PID,去自己的全局链表里查找,找到对应的
usb_serial_driver(也就是ch341_device)。 - 最后,Serial层才会去调用
ch341_device中我们实现的、针对硬件的具体函数(比如port_probe)。
这个过程就像一个“代理”模式。Serial层是所有USB转串口芯片驱动的总代理。USB核心只和这个总代理打交道。总代理收到业务(设备插入)后,再根据客户类型(VID/PID)分发给对应的专属业务员(CH341驱动)去处理硬件操作。
4.3 Serial层内部的“总线”:usb_serial_bus_type
你可能在代码里还看到一个 usb_serial_bus_type。这又是干什么的?
这是Serial层为了更精细地管理它下面的各种芯片驱动而创建的一个内部总线。当我们调用 usb_serial_register(&ch341_device) 时,除了把驱动加入全局链表,还会调用 driver_register,把 ch341_device.driver 注册到这个 usb_serial_bus_type 总线上。
这个内部总线的主要作用,是管理“端口”(port)。一个USB转串口芯片可能支持多个串口(比如FT2232就有两个)。Serial层会为每个物理串口创建一个 usb_serial_port 对象。这个内部总线就用于将 usb_serial_driver(芯片驱动)和 usb_serial_port(端口对象)关联起来,确保数据收发能正确路由到对应的端口。
5. TTY子系统:最终呈现的“用户界面”
经过Serial层的转换和封装,一个USB设备终于要被呈现给用户空间了。这最后一棒交给TTY子系统。
5.1 创建 tty_driver
在Serial层初始化的时候(通常是在第一个USB转串口驱动加载时),它会创建一个 tty_driver 结构体实例,我们称之为 USB Serial TTY 驱动。
这个 tty_driver 是Linux中所有终端设备的抽象,它定义了如何从设备读取数据、如何向设备写入数据、如何设置设备参数(如波特率)等标准操作。Serial层实现的这个 tty_driver,其内部的这些操作函数(open, close, write, set_termios等),最终都会“转发”给底层具体的 usb_serial_driver(如CH341驱动)去执行硬件操作。
5.2 注册 tty_driver 与 创建设备节点
创建好 tty_driver 后,Serial层会调用 tty_register_driver 将其注册到TTY子系统。TTY子系统会为这个驱动分配一个主设备号(major number)和一系列次设备号(minor number)。
当具体的CH341设备被探测(Probe)成功,并且Serial层为其创建好 usb_serial_port 后,就会调用 tty_port_register_device。这个函数会在 /dev 目录下创建一个设备文件,例如 /dev/ttyUSB0。这个设备文件的主设备号对应我们注册的USB Serial TTY驱动,次设备号则对应这个特定的端口。
至此,整个链路就打通了: 物理CH341设备 -> USB核心枚举 -> 匹配Serial层的通用usb_driver -> Serial层分发任务给CH341驱动 -> Serial层创建并管理usb_serial_port -> TTY子系统绑定端口并创建/dev/ttyUSB0
5.3 用户空间的读写
当你用 echo "hello" > /dev/ttyUSB0 或者用串口工具发送数据时:
- 写操作通过文件系统接口进入内核。
- VFS找到
/dev/ttyUSB0对应的inode,其关联的操作集指向我们注册的USB Serial TTY驱动。 - TTY驱动(Serial层实现)的写函数被调用,它可能经过线路规程(line discipline)处理后,将数据放入一个缓冲区。
- Serial层从缓冲区取出数据,调用底层
usb_serial_driver(CH341驱动)的写函数,该函数将数据打包成USB协议要求的格式,通过USB总线发送给CH341芯片。 - CH341芯片收到数据,通过其UART接口,将数据以串行信号的形式发送出去。
读操作则是完全相反的路径。
6. 实战调试:当CH341不工作时,如何顺藤摸瓜?
理论讲完了,我们来点实在的。当你插上CH341线,发现没有出现/dev/ttyUSB0,或者出现了但无法通信时,该怎么排查?理解上面的层次,你就能有条不紊地排查了。
6.1 第一步:检查USB设备层(最底层)
首先,确认系统是否识别到了USB设备本身。
lsusb
在输出列表里,你应该能找到类似这样的一行:
Bus 001 Device 007: ID 1a86:7523 QinHeng Electronics CH340 serial converter
如果连这一行都没有,那问题出在硬件或最基础的USB连接上(线坏了、供电不足、USB口故障)。
如果能看到,说明设备已被USB核心识别。记下Bus和Device号(这里是001和007)。
进一步查看设备详情:
lsusb -v -s 001:007 | less
这里可以查看设备的详细描述符,确认它是否正确地声明了自己是一个“通信设备”(CDC)或“厂商特定设备”。
6.2 第二步:检查驱动绑定(匹配与探测)
查看内核是否给这个设备加载了正确的驱动模块。
ls -l /sys/bus/usb/devices/1-1.2/driver
# 注意:`1-1.2`是设备在sysfs中的路径,通常与Bus/Device号相关,也可以用`lsusb -t`查看树状结构找到路径。
# 或者更通用一点,根据设备ID查找:
find /sys/bus/usb/devices/ -name \"*1a86:7523*\" | xargs -I {} readlink {}/driver
如果这个driver链接指向的是一个驱动(比如/sys/bus/usb/drivers/ch341),说明匹配和探测成功。如果指向(null)或者指向了usb-storage等其他驱动,说明匹配失败。
匹配失败怎么办?
- 检查驱动是否加载:
lsmod | grep ch341。如果没有,用sudo modprobe ch341加载。 - 检查驱动ID表:确认你的设备VID/PID是否在驱动的
id_table中。有些山寨CH340/CH341芯片可能使用了不同的PID。你可以用modinfo ch341查看驱动支持的ID列表。如果不支持,你可能需要修改驱动源码,添加你的PID,然后重新编译驱动。 - 检查驱动黑名单:看看是不是在
/etc/modprobe.d/下的某个配置文件中把驱动禁用了。
6.3 第三步:检查Serial层与TTY层
如果驱动绑定成功,接下来看Serial层和TTY层是否正常工作。
查看内核日志,这里包含了设备探测和初始化的详细信息:
sudo dmesg | tail -30
# 或者更针对性地看
sudo dmesg | grep -E \"(ch341|ttyUSB|usb.*serial)\"
你期望看到类似这样的成功信息:
[ 1234.567890] usb 1-1.2: new full-speed USB device number 7 using xhci_hcd
[ 1234.789012] usb 1-1.2: New USB device found, idVendor=1a86, idProduct=7523
[ 1234.789015] usb 1-1.2: New USB device strings: Mfr=1, Product=2, SerialNumber=3
[ 1234.789017] usb 1-1.2: Product: USB Serial
[ 1234.789019] usb 1-1.2: Manufacturer: wch.cn
[ 1234.789020] usb 1-1.2: SerialNumber: 12345678
[ 1234.791234] ch341 1-1.2:1.0: ch341-uart converter detected
[ 1234.792345] usb 1-1.2: ch341-uart converter now attached to ttyUSB0
最后一行 attached to ttyUSB0 是关键,它表明Serial层成功创建了TTY设备。
如果这里出错了,日志通常会给出原因,比如“cannot create port”、“probe failed”等。这可能是因为:
- 资源分配失败(如内存不足)。
- 硬件初始化失败(驱动里的
port_probe函数操作硬件寄存器出错)。 - 权限问题:虽然设备节点创建了,但用户没有读写权限。检查
/dev/ttyUSB0的权限(ls -l /dev/ttyUSB0),通常需要将用户加入dialout组(sudo usermod -a -G dialout $USER,然后重新登录)。
6.4 第四步:深入sysfs,观察各层状态
Sysfs是一个宝库,可以清晰地看到我们讲的每一层。
- USB设备层:
/sys/bus/usb/devices/1-1.2/下包含了设备的所有USB描述符信息。 - 驱动绑定层:
/sys/bus/usb/devices/1-1.2/driver链接指向绑定的驱动。/sys/bus/usb/drivers/ch341/下可以看到这个驱动管理的所有设备。 - Serial层:
/sys/bus/usb-serial/devices/目录下会有以ttyUSB0命名的链接,指向具体的端口。/sys/class/tty/ttyUSB0/device这个链接会指向对应的USB设备,清晰地表明了TTY设备与底层USB设备的关联。 - TTY层:
/sys/class/tty/ttyUSB0/下是TTY设备本身的属性,如power/、uevent等。你可以用cat /sys/class/tty/ttyUSB0/dev查看它的主次设备号。
通过顺着 设备 -> driver -> tty 这条线索在sysfs里浏览,你能直观地感受到整个层次结构是如何在内核中组织起来的。这种理解,远比死记硬背命令和配置要深刻得多。
304



被折叠的 条评论
为什么被折叠?



