简介:基于RK3399平台的WK2xxx系列SPI转串口扩展驱动源码,专为需要扩展多路串口的嵌入式Linux开发者、RK3399方案工程师以及正在评估串口扩展方案的学习者准备。该驱动利用WK2114芯片通过SPI接口扩展出4个串口,可应用在工控板卡、物联网网关、智能终端等对串口数量有较高要求的场景,也适合相关项目初期验证使用。压缩包内共1个文件,为1个C驱动源文件,整体约12KB,代码精简,便于阅读、移植和二次开发,可快速定位驱动注册与数据收发逻辑。目前已有229人学习/下载,适合作为对照实现或排错参考。读者可从中获取WK2114的初始化时序、SPI读写操作、串口回调接口封装等核心代码,同时了解其在RK3399平台上的驱动组织方式,为同类SPI转串口项目提供直接的代码级参考。 做嵌入式Linux的,迟早会遇到一个问题:主控的UART不够用。RK3399这颗SoC在国产板卡里算是明星级产品,接口丰富,但你想同时挂一堆串口设备——PLC、工业仪表、GPS模块、多路RS485传感器——它原生的UART资源立刻见底。这个项目的核心,就是在RK3399上通过SPI总线外扩一颗WK2114芯片,把一路SPI拆出四路标准串口,并且把整套驱动整理成了独立可移植的压缩包。
如果你手上的板子也是RK3399,或者你正在用其他主控想扩展串口数量,这篇博文会把我从硬件连接、驱动框架选型、设备树编写到数据面调通的完整过程,原原本本拆给你看。代码和配置都是能直接抄作业的。
1. 需求从哪来:RK3399平台为什么会缺串口
RK3399原生的UART接口数量并不少,算上调试串口,大概有6路可用的UART控制器。听起来好像够用?真上项目你就知道了。
我遇到的实际场景是个边缘计算网关,需要同时接这几路设备:
- 一路调试串口(内核日志,永远不能挪作他用)
- 一路蓝牙模块(RK3399虽然常配Wi-Fi/BT combo,但BT的HCI协议要占一路UART)
- 一路4G模组(AT指令+PPP拨号,至少要占一路)
- 两路RS485总线(底下各挂七八个传感器节点)
- 一路RS232接老式PLC
数一下,这已经6路出头了,而且有些还带流控、有些波特率还不一样。主控原生资源被瓜分干净,再想加设备就得想别的路子。
扩展串口的三条路线对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| USB转串口(如CH340/FT232) | 即插即用,Linux自带驱动 | 占用USB带宽,线材多,供电和信号质量易出问题 | 临时调试、桌面级设备 |
| I2C转串口(如SC16IS752) | 接线少,占用引脚少 | I2C速率偏低,高波特率下吞吐不足 | 低速、对带宽不敏感的场景 |
| SPI转串口(如WK2114/WK2124) | 速率高,驱动可控,一片扩4路 | 需要写驱动,硬件上多一根片选和中断 | 嵌入式板卡、工业级产品 |
我当时直接在方案对比表里划掉了前两个。USB转串口在工业现场不可靠,接头松一下、供电波动一下就掉线;I2C那点带宽跑个115200都紧张,更别说多路同时收发。SPI转串口是正路:SPI时钟跑个几十MHz轻松,每路UART分配个1Mbps的带宽绰绰有余,而且使用Linux内核标准serial框架的话,用户态看到的就是/dev/ttyWK0~/dev/ttyWK3,和原生串口行为一致,上层代码零改动。
注意:选WK2114的关键原因是它一片带4路UART且支持硬件流控(RTS/CTS)。如果你只需要两路,可以选WK2112;如果只需要单路,看WK2102。整个WK21xx系列的驱动寄存器布局基本统一,本文的思路直接通用。
2. WK2114是怎么工作的:一颗SPI从机拆出四路UART
搞清楚芯片工作机制,是写驱动前最重要的一步。我建议任何拿到新片子的人,都先把数据手册里“功能框图”那一页看三遍,再动手写代码。
2.1 芯片内部结构
WK2114本质上是一个SPI从设备 + 4个UART控制器的桥接芯片。它内部主要模块是:
- SPI从机接口:通过4个引脚(SPI_CLK、SPI_MOSI、SPI_MISO、SPI_CS)与主控通信,支持SPI Mode 0~3,从机模式,片选由主控控制
- 四路UART控制器:每路有独立的发送缓冲、接收FIFO、波特率发生器、流控逻辑
- 中断输出引脚(INT):当任意一路UART的RX FIFO收到数据、或者TX FIFO从满变空时,INT引脚会拉低,通知主控来处理
主机侧访问芯片,本质上只有两种操作:写寄存器和读寄存器。SPI转串口芯片的寄存器布局通常分两类:一类是全局控制寄存器(比如中断状态、复位控制),另一类是每路UART独立的配置寄存器(波特率分频值、数据位格式、FIFO水位、流控开关等)。
2.2 数据收发路径
我要先说清楚一个容易懵的点:这支芯片的收发是“寄存器式”的,不是“内存映射式”的。也就是说,SPI主机要读某路UART收到的一个字节,需要先发一个“读X路UART RX FIFO”的命令字,再等芯片把字节从MISO引脚推回来。每次收发都是一次完整的SPI事务。
这就带来一个设计取舍:**软件在中断里单字节操作,还是利用芯片FIFO做批量搬运?**看芯片的FIFO大小:典型WK系列芯片每路UART的RX/TX FIFO大约16字节(具体按型号不同),如果你每次中断只读一个字节,SPI的往返开销会吃掉大量CPU时间。正确做法是:中断里来一次,就把FIFO能读出来的全部搬走。
2.3 硬件连接细节
硬件上踩过的坑,我单独列一张表给你:
| 连接项 | 建议 | 说明 |
|---|---|---|
| VCC | 3.3V | 看芯片手册,一般3.3V供电,别接到5V上 |
| SPI_CLK | 主控SPI时钟脚 | 频率从低往上调,见下文调试章节 |
| SPI_MOSI/MISO | 主控对应数据脚 | 方向别反了,MOSI对MOSI |
| SPI_CS | 任意GPIO或主控片选 | 我用的GPIO软件片选,更灵活 |
| INT | 任意GPIO中断脚 | 必须接!否则只能轮询 |
| 晶振 | 按手册要求 | 有的型号需要外部晶振,有的是内置RC |
如果INT脚没接到主控,驱动就退化成纯轮询模式,除非你跑在很低波特率下,否则丢数据的概率非常大。这脚一定要接,这是这个驱动的“事件驱动”核心,没有它,驱动写出来就是个残废。
3. 驱动架构选型:在8250框架里做,还是自己写UART驱动
这是整个项目里最值得花时间考虑的决定。Linux内核里支持串口设备的框架有好几层,选择的正确与否,直接影响后续开发量。
3.1 三条技术路线对比
路线一:利用 kernel 的 8250 框架(serial8250 + port ops 钩子)
内核的8250驱动是串口驱动的“老大哥”,它实现了所有标准tty操作(open/read/write/ioctl),然后通过struct uart_port里的几个函数指针(比如start_tx、stop_tx、handle_irq)来对接具体硬件。如果你想复用8250框架,需要把WK2114的“SPI读写某个寄存器”封装成这些回调里的具体实现。
路线二:基于serial_core自己实现uart_ops
serial_core是比8250更底层的串口抽象层。你写一个平台驱动,注册struct uart_driver,实现struct uart_ops里的startup、shutdown、start_tx、stop_tx、set_termios等回调函数。自由度大,但样板代码多。
路线三:完全绕过内核串口框架,写一个 misc/char 设备
自己维护一个字符设备,把SPI读写逻辑全部包在驱动里,用户态通过open/read/write/ioctl访问。优点是代码最短、最快出效果;缺点是你得自己处理termios(波特率、数据位、停止位、流控),自己处理poll/select,自己处理内核并行访问的各种锁。做出来的东西又糙又难维护,我强烈不建议量产项目走这条路。
3.2 我的最终选择:基于 serial_core 自己实现 uart_ops
为什么不用8250框架?因为8250的底层逻辑是针对标准16550 UART的寄存器模型写的,它默认芯片里有一组I/O端口,通过 inb/outb 或 readb/writeb 直接访问。WK2114是SPI寄存器操作,你要把“读一个寄存器”的动作变成“发一笔SPI读事务”,这中间塞进去的适配层,最终会让你改到怀疑人生。
而serial_core则给你留了更大的自由度:波特率怎么算、数据怎么发射、中断怎么处理,全由你的uart_ops决定。虽然样板代码多,但每根线头都在你手里,软硬件联调的时候能精确控制每个环节。
选型逻辑可以一句话总结:如果你用的桥接芯片寄存器布局和16550高度相似,那复用8250省事;如果芯片有自己的寄存器模型和FIFO机制,那就接受serial_core的样板代码,自己控制关键路径。
3.3 设备树节点设计
RK3399的设备树里,SPI主机节点是现成的。我要做的是在SPI总线下挂一颗WK2114子设备。设备树节点这样写:
&spi1 { status = "okay"; pinctrl-names = "default"; pinctrl-0 = <&spi1_clk &spi1_mosi &spi1_miso>; cs-gpios = <&gpio3 17 GPIO_ACTIVE_LOW>; wk2114: spi@0 { compatible = "wk,wk2114"; reg = <0>; spi-max-frequency = <10000000>; interrupt-parent = <&gpio3>; interrupts = <20 IRQ_TYPE_EDGE_FALLING>; wk,uart-clk = <48000000>; wk,default-baud = <115200>; }; };几个字段说明:
cs-gpios:把片选指定为GPIO控制,而不是用SPI控制器自带的硬件片选。这样做的原因是,软件片选能在一次SPI传输前后更灵活地控制电平时序,对芯片上电时序要求苛刻的场景非常有用。spi-max-frequency:一开始不要设太高,我建议从1MHz起步,确认通信正常后再往上调。wk,uart-clk:芯片内部UART模块的基准时钟,波特率分频值是根据它算的,必须与硬件实际晶振/内置时钟一致,否则波特率会偏。
4. 驱动骨架与数据通路:从probe到tty设备
这部分是核心代码层面的拆解。我不会把完整驱动几百行贴上来(太长了),但会把关键路径上的函数设计思路和容易写错的点讲透,你把骨架搭起来后照着填细节即可。
4.1 probe流程:注册一个标准的UART驱动
驱动的入口是一个平台驱动/spi驱动,但最终注册的目标是uart_driver。整体流程是:
spi_driver.probe触发,拿到struct spi_device- 解析设备树里的
wk,*自定义属性 - 向内核注册
struct uart_driver(名字wk_uart,有4个port) - 为这四个port填充
struct uart_port,包括iotype、irq、ops(指向我们自己的uart_ops) - 调用
uart_add_one_port把四个port分别注册进tty层
这一步最重要的一句话是:一个uart_driver对应多个port,每个port对应一个/dev/ttyWKx节点。WK2114有4路UART,就注册4个port,port id 0~3,硬件上对应芯片的UART0~UART3。
4.2 数据线:uart_ops 里的关键回调
serial_core在数据进出的关键节点会调用我们实现的uart_ops,这些就是数据通路的各个“闸门”。
发送路径(主控 -> 外设):
static void wk_uart_start_tx(struct uart_port *port) { struct wk_uart_port *wkport = to_wk_port(port); u8 data; while (!uart_circ_empty(&port->state->xmit)) { data = uart_xchg_buf(port, port->state->xmit.tail, 0); wk_spi_write_byte(wkport, port->line, REG_TX_FIFO, data); uart_xmit_advance(port, 1); // 检查TX FIFO是否满,满了就停 if (wk_spi_read_status(wkport, port->line) & TX_FULL) break; } if (!uart_circ_empty(&port->state->xmit)) mod_timer(&wkport->tx_timer, jiffies + 1); }这段代码的核心逻辑是:从内核的circ_xmit环形缓冲区里取数据,逐个写入芯片的TX FIFO寄存器。写之前要查TX FIFO是否满,满了就停,用定时器延后再来——这样避免卡死中断上下文。
接收路径(外设 -> 主控):
WK2114有INT引脚,所以接收路径的驱动核心在中断处理函数里:
static irqreturn_t wk_uart_irq_handler(int irq, void *dev_id) { struct wk_uart_device *wkdev = dev_id; int i; for (i = 0; i < 4; i++) { if (wk_spi_int_status(wkdev, i) & RX_PENDING) { while (wk_spi_read_status(wkdev, i) & RX_FIFO_NON_EMPTY) { u8 c = wk_spi_read_byte(wkdev, i, REG_RX_FIFO); uart_insert_char(&wkdev->ports[i], 0, 0, c, 0); } uart_write_wakeup(&wkdev->ports[i]); } } return IRQ_HANDLED; }要注意的是:中断里一次性把RX FIFO读空。这比每次中断只读一个字节、然后靠内核tty_flip_buffer缓冲要高效得多。芯片的RX FIFO一般只有16字节,如果不及时读空,下一个字节进来就会覆盖丢数据。
4.3 波特率计算:别让分频器背黑锅
uart_ops里的set_termios回调,负责把用户态设置的波特率(比如9600、115200)转换成芯片的分频值。这个函数如果写错了,表现是串口输出的数据全是乱码,或者对面收到的波特率不对。
WK2114这类芯片,波特率分频公式一般是:
static int wk_uart_set_baud(struct uart_port *port, unsigned int baud) { unsigned int divisor = DIV_ROUND_CLOSEST(port->uartclk, 16 * baud); u8 quot_l, quot_h; if (divisor < 1 || divisor > 0xFFFF) return -EINVAL; quot_l = divisor & 0xFF; quot_h = (divisor >> 8) & 0xFF; wk_spi_write_reg(port, REG_LCR, 0x80); // 使能分频锁存 wk_spi_write_reg(port, REG_DLL, quot_l); wk_spi_write_reg(port, REG_DLM, quot_h); wk_spi_write_reg(port, REG_LCR, 0x03); // 8N1,关闭分频锁存 return 0; }看到熟悉的DLL/DLM就明白了:这套寄存器模型是老16550的经典玩法。所以即便我说“不用8250框架”,芯片本身的寄存器语义和16550相通——这并不矛盾,框架不用,寄存器语义还是可以借鉴的。uartclk我上面设备树里设了48000000(48MHz),不同WK芯片的时钟来源不同,这个值错了,算出来的分频值就全错。调波特率问题的时候,先拿逻辑分析仪抓芯片的TXD脚,看实际波形频率,比什么排查都快。
4.4 默认波特率与初始化
WK2114上电后,UART端口并不会自动配置到你要的波特率。必须在startup回调里做完整的初始化:禁用FIFO中断、清空FIFO、设置8N1默认格式、设置中断使能。
static int wk_uart_startup(struct uart_port *port) { wk_spi_write_reg(port, REG_IER, 0x00); // 先关中断,防止初始化中途乱跳 wk_spi_write_reg(port, REG_FCR, 0x07); // 使能FIFO, 清空 wk_spi_write_reg(port, REG_LCR, 0x03); // 8N1 wk_uart_set_baud(port, 115200); wk_spi_write_reg(port, REG_IER, IER_RX_EN); // 开接收中断 if (port->irq > 0) { request_irq(port->irq, wk_uart_int_handle, ...); } return 0; }startup在open()设备时被调用。每次串口被应用重新打开,都会执行一遍 startup 和 shutdown,所以这段初始化代码必须写干净,不能依赖上一次打开时的残留状态。
5. 实测中的拦路虎:SPI时钟、片选和FIFO溢出
驱动写完之后,真正的硬仗才开始。我在这块板子上调了整整两天,遇到的三个问题,每一个都是典型中的典型。
5.1 调高SPI时钟后,读取寄存器全是0xFF
现象:spi-max-frequency设1MHz时,能正常读写寄存器;调到10MHz之后,读回来的寄存器值全是0xFF。
排查链路:
- 先用逻辑分析仪抓SPI总线,发现MOSI上的数据波形明显畸变,边沿变圆——这就是线缆/布线容性负载太重,高频下信号质量撑不住。
- 检查RK3399 SPI引脚的驱动能力配置,发现是默认的弱驱动档。
- 在设备树pinctrl里把SPI引脚的驱动强度提到最高一档,波形立刻改善。
- 更根本的做法:把SPI时钟降到5MHz,配合提高驱动强度,稳定跑住了。
注意:在pinctrl-0里,除了指定引脚功能(spi1_clk等),还要看SoC的pinconf支持不支持设置驱动强度:
&spi1 { ... pinctrl-0 = <&spi1_clk &spi1_mosi &spi1_miso>; };如果SoC的pinctrl有pinconf-single的 bias/drive-strength 配置节点,需要一并加上。经验值:SPI到转串口芯片的走线长度超过3cm,时钟频率就别超过10MHz,否则就要端接电阻。
5.2 RX丢数据:高波特率下频繁溢出
现象:把外部设备波特率调到921600,配合SPI时钟=10MHz,连续收发100帧,丢了一小半。
这个坑不在芯片的FIFO,而在我的中断处理逻辑。
排查链路:
- 一开始怀疑是
uart_insert_char被调用太慢,被新的中断打断。 - 打印实际中断触发频率,发现收满一帧数据(几十字节),中断会触发两次以上——因为芯片RX FIFO是16字节,帧长超过16时,先触发一次中断,读走前16字节,然后剩余字节又触发一次。
- 问题出在中断触发边沿:我设备树里写的是
IRQ_TYPE_EDGE_FALLING,而芯片在收到16字节、FIFO非空期间,INT脚一直是低电平,只有清空后才会拉高。如果是沿触发,第二次中断进来时,边沿可能已经被错过了。 - 把中断类型改成
IRQ_TYPE_LEVEL_LOW,并且在中断处理里用while循环把FIFO读空再返回。这样中断函数只在FIFO为空后退出,天然不会丢字节。
这是本驱动里最关键的一个经验:芯片有FIFO且FIFO数据不读空INT不还原时,一定要用电平触发,不能用边沿触发;中断里务必读空FIFO。如果SPI读比较慢,还可以考虑把spi-max-frequency调到20MHz来缩短每次SPI读的牙缝时间。
5.3 与应用层波特率不符:乱码的根因
现象:应用层设置115200,观察波形发现实际是115613,偏差约0.36%,虽然没超2%的容忍范围,但个别设备严格要求时就会乱码。
原因:WK2114的基准时钟不是精确的整数分频比。比如基准时钟48MHz,想分频出115200,实际分频值是 48000000/(16*115200)=26.0417,取整26后实际波特率是115384,偏差0.16%。
排查链路:
- 优先把基准时钟选为能被常用波特率整除的值。比如12MHz、14.7456MHz、22.1184MHz这些“串口专用晶振频率”,配合芯片内部时钟源看支不支持。
- 如果芯片允许外接晶体,建议直接换成14.7456MHz之类的高精度频点。
- 在
set_termios里计算完分频值后,回读实际波特率并打印调试信息,这样可以快速发现偏差过大的组合。
很多同学看到乱码第一反应是改代码,其实先拿频率计/示波器量一下TXD脚的实际波特率是最快的。
5.4 大批量数据吞吐验证
驱动稳定后,我用下面这个命令做了长时间压测:
# 板子上把ttyWK0的数据重定向到ttyWK2 socat -v /dev/ttyWK0,raw,echo=0,b115200 /dev/ttyWK2,raw,echo=0,b115200 & # 用串口调试助手往ttyWK0灌数据 dd if=/dev/urandom of=/dev/ttyWK0 bs=1024 count=1000这个方案的逻辑是:板载端把两路串口短接在一起(TXD接RXD),形成自发自收环路,然后通过数据比对校验是否丢字节。生产环境里最常用的还是/dev/ttyWK0接外部串口工具,配合crcmod或者minicom做文件传输校验。
6. 驱动打包与跨平台移植:为什么发这个.rar
最后聊聊这个压缩包本身。很多工程师拿到一份驱动代码,只看.c和.h就完事了,其实一个能交付的驱动包,应该是一个自洽的小工程。这个.rar里,我塞了以下几类东西:
6.1 压缩包内容清单
| 文件/目录 | 作用 |
|---|---|
wk_uart.c/wk_uart.h | 驱动主体,含uart_ops和SPI读写助手 |
Makefile | 编译内核模块用 |
dts/wk2114.dts | RK3399下的设备树节点示例 |
dts/wk2114-pinctrl.dtsi | 引脚复用配置片段,移植时按平台改 |
scripts/load.sh | 自动完成insmod、mknod、权限设置 |
scripts/test_uart.py | 环路测试脚本,用python的serial库做收发校验 |
README.md | 编译步骤、接线图、注意事项 |
这个结构照顾到了三种使用者:只想快速看到效果的人(直接跑load.sh)、要移植到其他平台的人(主要看dts和Makefile)、想从零搞懂原理的人(完整读一遍wk_uart.c)。
6.2 从RK3399移植到其他SoC的注意点
如果你不是RK3399,而是全志V3s、i.MX6ULL或者树莓派,改动主要集中在这几个地方:
- 设备树:SPI主机节点名和引脚复用需要换成目标平台的。比如全志V3s可能叫
spi1,引脚在pinctrl里要定义spi1_pe0之类。这是体力活,看SoC手册即可。 - 中断号:RK3399上
interrupts = <20 IRQ_TYPE_LEVEL_LOW>里的20是GPIO3_20的IRQ编号,换平台要用目标平台的GPIO中断映射方式。 - SPI控制器差异:有些平台用
spi-gpio模拟SPI,有些用硬件控制器。驱动代码里的struct spi_device操作是通用的,只要spi-device能被绑定成功,剩下的逻辑完全不用改。 - 时钟树:
uartclk的值直接填进设备树的wk,uart-clk,需要和目标平台选的WK芯片实际时钟一致。
移植最关键的原则:驱动核心里的
wk_uart_ops一行都不要动,要动的只是设备树和平台相关的SPI配置。
6.3 一个容易被忽略的交付问题:驱动签名和Secure Boot
如果你要部署到开了Secure Boot的板卡上,编译出来的.ko文件必须被签名,否则insmod会直接被拒绝。这时候需要把签名公钥添加到内核密钥环,或者临时关闭Secure Boot。这个坑我提一句,避免你到产线调试时抓瞎。
7. 实测数据与经验心得
调试全部完成后,我留了一组参考参数,算是这次项目的收尾:
| 项目 | 最终值 |
|---|---|
| SPI时钟 | 8MHz |
| 分频基准时钟 | 48MHz(内置) |
| 每路UART波特率 | 最高1Mbps稳定,3.3V电平下实测 |
| RX FIFO处理方式 | 电平触发 + 中断内读空 |
| 驱动体积 | 单模块约12KB |
| 长时间压测 | 4路同时115200收发48小时,0丢字节 |
经验上最值得记住的三条:
- SPI转串口芯片的中断脚一定要接,而且要用电平触发。这是高性能收发的命门。
- 先把SPI通信验证扎实,再上串口业务。写一个简单的spidev用户态程序,用
spidev_test把芯片寄存器读写改成“写一串01、读回来校验”的形式,能最快验证硬件链路有没有坏。 - 驱动里所有SPI读写路径,不要加任何自定义的
msleep。SPI操作本身就是同步阻塞的,sleep只会拖垮吞吐。
这个驱动后续如果想再压榨性能,可以往两个方向深入:一个是把SPI读写改成DMA模式,结合spi_async的异步接口,把中断处理时间压到最低;另一个是给每个port开独立的内核kthread,把FIFO搬运从中断上下文挪出去,很适合放到高实时性场景。不过那是后话了,先把基础版跑通,你手里的板子能多出四路稳定的串口,这波就不亏。
本文还有配套的精品资源,点击获取