从零构建 USB 转串口 Linux 驱动:一次深入内核的实战之旅
你有没有遇到过这样的场景?手头有个老旧的 GPS 模块、PLC 控制器或者单片机开发板,只支持 RS232 串口通信。而你的现代笔记本早已砍掉了 COM 口,只剩下几个 USB 接口。这时候,一个小小的“USB 转 TTL”小板就成了救命稻草。
但你知道吗?当你把那根线插上去,系统自动出现/dev/ttyUSB0的那一刻,背后其实是一场精密的内核协作——Linux 内核正在悄悄加载一个 USB 驱动,把它伪装成一个标准串口设备。这不仅仅是“即插即用”那么简单,它涉及 USB 协议解析、数据包调度、TTY 抽象建模等多重机制的协同工作。
今天,我们就来亲手揭开这个黑盒,带你一步步实现一个真实的USB 转串口驱动,不只是调 API,而是理解每一步背后的逻辑与设计哲学。
为什么需要写内核态驱动?
在开始编码前,先问自己一个问题:能不能不用内核驱动,直接在用户空间读写 USB 设备?
当然可以!像libusb这样的库就允许你在用户态发送控制请求、收发数据。但问题也随之而来:
- 如何让
minicom、screen或 Python 的pyserial库识别它? - 如何支持波特率设置、奇偶校验这些传统串口概念?
- 如何保证高负载下的稳定传输,避免丢包?
答案是:你需要一套统一的抽象层。这就是TTY 子系统存在的意义。只有将 USB 设备注册为 TTY,才能无缝接入整个 Linux 串行生态。
所以,真正的 USB 转串口驱动,必须运行在内核态,作为桥梁连接两个世界:
- 向上对接TTY 子系统
- 向下操作USB 子系统
我们不是在“使用”驱动,而是在“成为”驱动开发者。
第一步:让内核认识你的设备 —— USB 设备匹配机制
当 USB 设备插入主机时,内核会通过描述符获取它的身份信息。其中最关键的就是Vendor ID(VID) 和 Product ID(PID)。
比如常见的 Prolific PL2303 芯片,其 VID=0x067B,PID=0x2303。我们的驱动首先要声明:“我认得这个设备”。
static const struct usb_device_id pl2303_table[] = { { USB_DEVICE(0x067B, 0x2303) }, /* Prolific PL2303 */ { USB_DEVICE(0x0403, 0x6001) }, /* FTDI FT232 (示例扩展) */ { } /* 终止项 */ }; MODULE_DEVICE_TABLE(usb, pl2303_table);接着,定义一个标准的usb_driver结构体:
static struct usb_driver pl2303_driver = { .name = "pl2303_custom", .probe = pl2303_probe, .disconnect = pl2303_disconnect, .id_table = pl2303_table, .supports_autosuspend = 1, };注意.probe和.disconnect回调函数。一旦 VID/PID 匹配成功,内核就会调用pl2303_probe()函数,这是你初始化一切资源的起点。
💡 小贴士:如果你的设备插上后
dmesg | grep usb显示 “unknown device”,第一反应应该是检查是否漏加了对应的 VID/PID 条目。
第二步:探针函数里发生了什么?—— probe 流程拆解
probe()是整个驱动的生命入口。它的任务很明确:确认设备可用性 → 分配资源 → 注册为 TTY 设备。
1. 获取接口和端点信息
USB 设备的功能由“接口”(interface)定义。对于串口桥接芯片,通常使用批量传输端点进行收发。
static int pl2303_probe(struct usb_interface *intf, const struct usb_device_id *id) { struct usb_device *dev = interface_to_usbdev(intf); struct usb_host_interface *iface_desc = intf->cur_altsetting; struct usb_endpoint_descriptor *ep_in, *ep_out; int i; /* 查找输入/输出端点 */ for (i = 0; i < iface_desc->desc.bNumEndpoints; ++i) { struct usb_endpoint_descriptor *ep = &iface_desc->endpoint[i].desc; if (usb_endpoint_is_bulk_in(ep)) ep_in = ep; else if (usb_endpoint_is_bulk_out(ep)) ep_out = ep; } if (!ep_in || !ep_out) return -ENODEV;这里我们遍历当前接口的所有端点,找到用于接收(IN)和发送(OUT)的批量端点地址。后续 URB 传输将基于这些地址构造管道(pipe)。
2. 分配私有结构体与 TTY 端口
Linux 提供了一个通用框架usb-serial来简化这类驱动开发。我们可以继承struct usb_serial_port,并在其中嵌入自己的状态字段。
struct usb_serial *serial; struct usb_serial_port *port; serial = usb_serial_probe(intf, id); // 使用通用探测流程 if (!serial) return -ENOMEM; port = serial->port[0]; port->bulk_in_size = le16_to_cpu(ep_in->wMaxPacketSize); port->bulk_in_endpointAddress = ep_in->bEndpointAddress; port->bulk_out_endpointAddress = ep_out->bEndpointAddress;usb_serial_probe()实际上完成了大部分基础工作:内存分配、设备绑定、TTY 驱动注册准备。我们只需填充关键参数即可。
第三步:打通数据通道 —— URB 异步传输机制详解
URB(USB Request Block)是 Linux 中所有 USB 数据传输的核心载体。你可以把它想象成一封“快递单”:指明目的地(端点)、包裹内容(缓冲区)、送达方式(同步/异步),以及签收后的回调动作。
发送数据:从 write() 到 URB 提交
当用户程序调用write("/dev/ttyUSB0", buf, len),最终会触发驱动中的write()回调函数。我们需要做的是:
- 拷贝用户数据到内核缓冲区;
- 构造 OUT 方向的 URB;
- 提交至 USB 核心层。
static int pl2303_write(struct tty_struct *tty, struct usb_serial_port *port, const unsigned char *buf, int count) { struct urb *urb = port->write_urb; unsigned char *transfer_buffer = urb->transfer_buffer; if (!count || !test_bit(USB_SERIAL_ACTIVE, &port->flags)) return 0; count = min(count, urb->transfer_buffer_length); memcpy(transfer_buffer, buf, count); usb_fill_bulk_urb(urb, port->serial->dev, usb_sndbulkpipe(port->serial->dev, port->bulk_out_endpointAddress), transfer_buffer, count, pl2303_write_bulk_callback, port); return usb_submit_urb(urb, GFP_ATOMIC); }关键点说明:
usb_sndbulkpipe(dev, addr):构造一个指向 OUT 批量端点的数据管道。usb_fill_bulk_urb():初始化 URB,设定缓冲区、长度、回调函数。GFP_ATOMIC:因为在原子上下文中(如中断处理),不能睡眠。
提交成功后,数据并不会立即发出,而是排队等待主机控制器调度。一旦完成,pl2303_write_bulk_callback将被调用。
接收数据:永远在线的 IN URB 循环
接收比发送更复杂,因为数据到来不可预测。解决办法是:始终保持一个挂起的 IN URB,随时准备接住飞来的数据包。
static void start_read(struct usb_serial_port *port) { struct urb *urb = port->read_urb; int result; if (!urb || !port->bulk_in_size) return; usb_fill_bulk_urb(urb, port->serial->dev, usb_rcvbulkpipe(port->serial->dev, port->bulk_in_endpointAddress), port->read_buffer, port->bulk_in_size, pl2303_read_bulk_callback, port); result = usb_submit_urb(urb, GFP_KERNEL); if (result) { dev_err(&port->dev, "Failed to submit read URB: %d\n", result); schedule_delayed_work(&port->work, HZ); // 稍后重试 } }重点来了:在pl2303_read_bulk_callback中,必须立刻重新提交这个 URB,否则下次数据来了没人接!
static void pl2303_read_bulk_callback(struct urb *urb) { struct usb_serial_port *port = urb->context; unsigned char *data = urb->transfer_buffer; int status = urb->status; switch (status) { case 0: /* 成功接收,将数据推送给 TTY 层 */ tty_insert_flip_string(&port->port, data, urb->actual_length); tty_flip_buffer_push(&port->port); break; case -ECONNRESET: case -ENOENT: case -ESHUTDOWN: return; /* 断开或关闭,不再重提 */ default: dev_dbg(&port->dev, "Non-zero read bulk status received: %d\n", status); break; } /* 关键!继续监听下一笔数据 */ start_read(port); }tty_insert_flip_string()是 TTY 子系统的专用接口,用于将接收到的数据暂存到翻转缓冲区(flip buffer),再由内核线程异步刷出到用户空间。
第四步:串口参数配置 —— set_termios 的艺术
串口通信离不开波特率、数据位、校验方式等设置。这些都封装在struct ktermios中,并通过set_termios()回调传递给驱动。
static void pl2303_set_termios(struct tty_struct *tty, struct usb_serial_port *port, const struct ktermios *old_termios) { struct usb_device *dev = port->serial->dev; unsigned int baud_rate; uint8_t config; /* 获取新波特率 */ baud_rate = tty_get_baud_rate(tty); if (!baud_rate) baud_rate = 9600; /* 向设备发送控制命令更新波特率 */ pl2303_set_baud_rate(dev, baud_rate); /* 构建数据格式字节:数据位 + 停止位 + 校验 */ config = 0; switch (C_CSIZE(tty)) { case CS5: config |= 0x00; break; case CS6: config |= 0x01; break; case CS7: config |= 0x02; break; case CS8: config |= 0x03; break; } if (C_STOPB(tty)) config |= 0x04; /* 两位停止位 */ if (C_PARENB(tty)) { config |= 0x08; if (C_PARODD(tty)) config |= 0x10; } pl2303_set_data_config(dev, config); }不同芯片的控制协议各不相同。例如 PL2303 使用特定的SET_LINE_CODING控制传输请求:
static int pl2303_set_baud_rate(struct usb_device *dev, unsigned int baud) { return usb_control_msg(dev, usb_sndctrlpipe(dev, 0), 0x20, /* SET_LINE_REQUEST */ 0x40, /* Vendor-specific request */ baud & 0xff, (baud >> 8) & 0xff, NULL, 0, 100); }⚠️ 注意事项:
- 某些芯片对波特率有严格限制(如仅支持标准值),需查表转换;
- 控制请求的目标端点通常是端点 0(控制端点);
- 请求类型(bmRequestType)和值(bRequest)需查阅芯片手册。
常见坑点与调试秘籍
即使代码逻辑正确,实际调试中仍可能踩坑。以下是几个高频问题及应对策略:
🔴 问题一:插入多次后设备失效
现象:第一次能用,拔掉再插就打不开/dev/ttyUSB0。
原因:disconnect()函数未彻底释放资源,导致引用计数泄漏。
解决方案:
- 确保每个kzalloc()都有对应的kfree();
- 使用kref或completion机制等待异步操作结束后再释放;
- 在disconnect中取消所有 pending URB:usb_kill_anchored_urbs()。
🟡 问题二:接收数据乱码或丢包
现象:偶尔收到错误数据,或连续数据流中丢失部分帧。
排查方向:
1.波特率是否匹配?特别是国产 CH340 系列,晶振偏差可能导致非标准波特率失准。
2.IN URB 是否及时重提?若回调中未立即重启接收,可能错过下一包。
3.缓冲区是否足够大?批量传输最大包长一般为 64 字节(低速)或 512 字节(高速),应据此设置缓冲区。
🟢 调试利器推荐
| 工具 | 用途 |
|---|---|
dmesg | 查看内核打印日志,定位 probe 失败原因 |
lsusb -v | 查看设备完整描述符,确认端点配置 |
udevadm info /dev/ttyUSB0 | 检查设备节点属性 |
usbmon+ Wireshark | 抓取 USB 通信全过程,分析控制请求与数据流 |
开启详细日志也很重要:
#define DEBUG module_param_named(debug, pl2303_debug, bool, S_IRUGO | S_IWUSR);配合dev_dbg()输出关键路径信息,在复杂环境中快速定位瓶颈。
最终整合:模块初始化与退出
最后别忘了注册和注销驱动模块:
static int __init pl2303_init(void) { int retval; retval = usb_register(&pl2303_driver); if (retval) err("usb_register failed: %d", retval); return retval; } static void __exit pl2303_exit(void) { usb_deregister(&pl2303_driver); } module_init(pl2303_init); module_exit(pl2303_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("Custom PL2303 USB-to-Serial Driver");编译依赖也别落下:
obj-m += pl2303_custom.o KDIR := /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean加载测试:
sudo insmod pl2303_custom.ko dmesg | tail ls /dev/ttyUSB*写在最后:驱动开发的本质是什么?
编写 USB 转串口驱动,表面上是在填函数、配端点、发 URB。但深入下去你会发现,这其实是对Linux 内核设备模型的一次系统性实践。
你学会了:
- 如何利用设备树思想匹配硬件;
- 如何借助通用框架(usb-serial)减少重复劳动;
- 如何运用异步回调机制构建高效 I/O;
- 如何通过TTY 抽象对接上层应用生态。
更重要的是,你开始理解:操作系统是如何把千差万别的物理设备,统一成简洁一致的编程接口的。
未来无论是开发 I2C 传感器驱动、自定义 HID 设备,还是研究 USB PD 协议栈,这条路都会为你铺平基础。
如果你也在调试某个奇怪的 USB 转串口模块,或者遇到了无法识别的变种芯片,不妨动手改改设备 ID 表,甚至定制自己的控制协议。毕竟,真正的工程师,不仅要会用工具,更要能创造工具。
欢迎在评论区分享你的驱动实战经历,我们一起破解更多硬件谜题。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考