在做嵌入式Linux产品时串口不够用几乎是必然会遇到的坎。一块主控板既要接4G模块、GPS、外设调试口又要接传感器和PLC主控原生只有两三个UART根本不够分。我这次做工业网关就碰到了这种情况最终用一颗CH9434把SPI展开成四路独立串口从画原理图、调硬件到Linux驱动移植完整跑通还把坑挨个踩了一遍。这篇东西适合正在做网关、工控板、车载盒子的硬件或嵌入式软件工程师看也适合刚接触SPI设备驱动、想知道“Linux里怎么接一个SPI外设”的同学参考。我尽量把整个流程讲透为什么选CH9434、电路上要注意什么、芯片寄存器怎么访问、Linux驱动怎么写、最后怎么验证性能整个过程会把关键步骤和踩坑点都铺开来说。1. 选型逻辑四路串口扩展为什么最后用了CH94341.1 先盘需求你的平台到底缺几个串口不要一上来就想着扩串口先把需求盘清楚。我这次的项目场景是工业数据采集网关主控是A53双核自带4路UART但其中两路被系统调试和蓝牙占用实际可用的只剩两路。而外设需求是这样的一路接4G模块一路接RS485仪表总线一路接GPS一路接调试用的TTL日志口偶尔还要挂一个条码枪。这么一算至少需要四路而且最好能留一两个富余。这时候就要做一个关键决策是选USB转串口方案还是SPI/UART总线扩串口方案USB转串口比如CH9344确实方便Linux下现成驱动插上就能用但对我们这种工业网关来说USB Hub挂在USB总线上一旦总线异常或者驱动挂了四路串口全军覆没稳定性上不够踏实。SPI转串口则多一根中断线、一个片选驱动完全自己掌控链路短、延迟低出问题也好定位。1.2 串口扩展方案横向对比市面上常见的串口扩展方案我简单拉了个对比表方案接口扩展路数驱动成熟度典型芯片优缺点USB转串口USB1/4/8很好内核自带CH9344、FT4232H即插即用但依赖USB总线稳定工业环境有隐患I2C转串口I2C1/2一般SC16IS740/750接线少但I2C速率低高速串口不够用SPI转串口SPI1/4依赖自研CH9434、WK2124速率高、实时性好需要自己写内核驱动并行总线转串口本地总线4/8依赖自研ST16C554老片子并行总线占用引脚多现在用得少了对我们这次需求USB方案因为稳定性和总线依赖被排除I2C方案又扛不住4G模块的高波特率剩下就是SPI转串口这条路线。CH9434和WK2124都是成熟芯片CH9434是国产芯片手册齐全、开发资料多价格也有优势所以最终选了它。1.3 CH9434到底赢在哪CH9434给我的第一印象是寄存器布局非常“教科书”基本沿用了经典UART的寄存器体系。它对驱动开发者友好网上甚至能找到参考驱动不用完全从零造轮子。芯片自带收发FIFO在中断处理不及时的时候能扛住一阵子数据这个对Linux系统特别重要因为中断线程调度有延迟没有FIFO的芯片很容易在系统忙时丢字节。另外它支持独立的接收触发水位配置可以把FIFO中断的触发点调高比如攒到16字节再断一次这样能显著减少中断次数降低CPU占用。这些细节在后续驱动设计里都派上了用场。2. 硬件设计原理图连线与SPI片选避坑指南2.1 CH9434引脚速览与电源设计CH9434是LQFP封装引脚不算多核心就是SPI四线SCK/MOSI/MISO/CS#、一个中断输出INT#、一个复位RST#外加四组UART的TXD/RXD。这四组串口是TTL电平不能直接怼RS232或RS485后面要加电平转换芯片。电源部分我踩过一个不算坑但容易忽略的细节每一组VCC引脚旁边都要放0.1uF去耦电容布局时电容尽量靠近电源引脚。另外如果主控是3.3V供电芯片的VCC也接3.3V的话UART引脚就是3.3V电平对接5V的外设时必须做电平转换不能侥幸否则长时间运行会损坏引脚。复位引脚我一开始直接接了一个简单的RC复位电路调试中发现不太方便芯片死机或者状态异常时想通过主控GPIO强制复位RC电路做不到。改成由主控的GPIO控制RST#之后驱动里加了一个复位函数出问题可以直接软复位芯片调试效率高了不少。2.2 SPI四线接线模式、方向与硬件片选SPI接线本身就是常规的四根线但方向容易搞混。要记住MOSI的意思是从主机到从机的数据线连CH9434的时候主控的MOSI要接芯片的MOSI/SDI主控的MISO接芯片的MISO/SDO。有些芯片手册里管这两个脚叫SDI/SDO标注更直观别按名字猜看图接线最稳。SPI模式也要先确认好。CH9434支持SPI模式0CPOL0CPHA0这几乎是所有SPI从设备的默认选项。模式不对的表现是能读到数据但全是错的有时候甚至CS拉低后芯片根本没反应。我习惯在写驱动前先用逻辑分析仪抓一次波形确认时钟极性和相位省得后面排错排到怀疑人生。2.3 硬件片选还是软件片选这是SPI设备设计里非常经典的一个问题。硬件片选由SPI控制器自动拉低拉高时序精准CPU不用操心缺点是一旦SoC的SPI控制器对CS释放时机控制得不好设备就可能漏掉最后一拍数据。软件片选就是用普通GPIO来模拟CS信号在SPI传输前手动拉低传输完再拉高灵活性高可以自定义CS建立时间和释放时间。我的建议是如果SPI总线上只挂一个CH9434优先用硬件片选SoC自带的cs-gpios支持通常就够用如果总线上还挂了SPI Flash之类的其他设备就用软件片选并在每次片选切换之间加一点延时防止设备间干扰。两种方式在设备树里的写法不一样后面驱动章节会说到。2.4 串口电平转换与RS232/RS485扩展CH9434出来的四路串口都是TTL电平要对接RS232可以在每一路TXD/RXD上接一颗MAX3232要对接RS485每路接一颗SP3485之类的收发器同时把收发器的方向控制引脚接到主控的GPIO上或者用自动收发方向的芯片。我做项目时四路串口的用途不一样其中一路直接出TTL调试口另外三路出RS485。RS485部分需要注意的一点是每路485的DE/RE引脚方向切换时机很重要发送完成后不能立刻切回接收否则最后一个字节会丢尾巴。后来我在驱动层加了一个发送完成后延时释放方向控制的处理才彻底解决。2.5 先用STM32验证硬件再写Linux驱动直接跳到Linux驱动调试如果硬件有问题排查起来非常痛苦。我习惯先把芯片当纯硬件外设验证一遍用STM32F103配合STM32CubeMX把SPI配置为主模式拉一根GPIO当作CS然后直接读CH9434的某个固定寄存器。这一步不用关心Linux驱动细节逻辑分析仪配合也能抓到SPI时序。CubeMX里配置SPI1主机模式波特率先选低一点比如1Mbps因为STLink调试器供电的测试板上干扰比较多高速容易误码。验证代码就几行拉低CS发一个读寄存器命令读回数据对比芯片手册里的复位值。如果读到的值能对上说明硬件连线、供电、SPI模式全都没有问题接下来才进入Linux驱动移植出问题就知道一定是软件逻辑的问题。3. 芯片内部逻辑寄存器、SPI帧格式与波特率计算3.1 四路UART的寄存器组织方式CH9434内部实际上相当于把四路独立的UART控制器打包在一起每一路的寄存器布局都非常类似经典的16550 UART包含发送保持寄存器THR、接收缓冲寄存器RBR、中断使能寄存器IER、中断标识寄存器IIR、FIFO控制寄存器FCR、线路控制寄存器LCR、线路状态寄存器LSR以及波特率分频寄存器DLL/DLM这些。这个设计对Linux驱动开发者特别友好因为内核里serial驱动的逻辑几乎可以照搬16550的那套只需要把寄存器读写从In/Out操作换成SPI命令帧的收发。移植的工作量一下子就小了很多。需要注意的是芯片手册的寄存器地址是按通道分组的每一路UART的地址段不一样写驱动时如果漏掉通道偏移后两路串口的状态判断就会串线。3.2 SPI读写命令帧格式通过SPI访问寄存器不是简单发一个地址读一个数据它有一套命令帧结构。典型流程是这样的CS拉低后主机先发一个命令字命令字里包含读写标志和通道选择信息紧接着发寄存器地址然后读操作的话主机继续发一个时钟周期读取MISO上的数据写操作的话就在地址后直接发数据字节。每个命令帧的内容细节一定要对照手册。我第一次画驱动框架时想当然地按“地址数据”的结构来收数据结果读回来的值永远是错的。后来看了参考代码才发现漏了命令字这一拍整个SPI传输的长度差了一个字节。这个一定要先手写代码验证再上完整驱动。3.3 波特率分频计算与误差控制波特率生成的原理是固定的经典公式是divisor 时钟源频率 / (16 × 目标波特率)其中divisor取整后分成高字节DLM和低字节DLL写入对应寄存器。CH9434内部有一个时钟源具体是多少MHz要看手册我这边用的是手册推荐的时钟源频率。比如目标波特率是115200计算出来的分频数可能是13那么写进DLL就是13DLM为0。这里有个很深很实际的坑分频数四舍五入之后实际波特率和目标波特率会有偏差。偏差超过2%~3%时对端的UART就可能出现偶发乱码尤其是长时间大流量通信时错位会累积。所以我在写驱动时特意加了一个校验函数把算出来的分频值反算成实际波特率如果偏差太大就在日志里打警告。实测下来常见波特率如9600、115200、460800都在可接受范围内但921600以上就要小心了。3.4 中断机制四路共享一根INT#CH9434只有一个INT#引脚四路UART共用。任何一路有接收数据、发送完成、线路错误都会把INT#拉低主控收到这个中断信号后需要依次读四路的中断标识寄存器看看是谁触发的事件。这是多路共享中断的标准处理方式。中断引脚的电平触发方式非常重要。CH9434的INT#是电平有效低电平表示有事件未处理所以设备树里申请中断时一定要用IRQ_TYPE_LEVEL_LOW不能用边沿触发。用边沿触发的后果是如果两次中断间隔极短电平还没来得及恢复第二次边沿就丢了数据也一起丢了。这个坑我在调试第4路串口的时候踩过后来把触发方式改成电平触发才稳。4. Linux驱动移植从设备树到tty设备4.1 驱动架构选择内核里挂一个SPI外设最直接的做法是用户空间用spidev设备节点通过ioctl收发数据再配合一个GPIO中断线程处理接收。这个方案工作量小但不适合串口场景。原因很简单应用层拿到的不是一个标准的tty设备没有termios配置不能用getty跑登录也不能被busybox的串口工具直接操作。正确的做法是基于内核的serial框架注册一个uart_driver让四路CH9434呈现为四个标准的tty设备。这样应用层看到的设备文件就是/dev/ttyCH9434_0、/dev/ttyCH9434_1之类的可以用stty、picocom、microcom直接操作整个串口子系统的那套功能全都自动获得termios、行规程、flow control什么都不用自己重写。这套架构的核心是uart_driver、uart_port、uart_ops三个结构体。uart_ops定义了串口的操作函数相当于把16550驱动的读写寄存器操作重定向到SPI上。4.2 设备树节点与SPI控制器配置设备树要做两件事第一把SPI控制器使能并配置引脚第二在SPI总线上声明CH9434子节点。下面是我在实际项目里精简后的设备树片段spi1 { status okay; pinctrl-names default; pinctrl-0 spi1_pins_a; cs-gpios gpio1 0 GPIO_ACTIVE_LOW; ch9434: ch94340 { compatible wch,ch9434; reg 0; spi-max-frequency 8000000; interrupt-parent gpio1; interrupts 4 IRQ_TYPE_LEVEL_LOW; /* 如果还有复位引脚可以配reset-gpios */ }; };几个关键点说明一下。compatible字符串必须和驱动里的of_match_table完全一致否则probe不会被调起来。spi-max-frequency我一开始就写了20M实际测试发现稳定性不好最终稳定点是8MHz建议从低频往上调别一上来就冲极限。中断号要和硬件实际连接的GPIO对上触发方式我用的是IRQ_TYPE_LEVEL_LOW对应前面说的电平触发。4.3 驱动核心实现要点驱动的主体结构是platform_driver在probe函数里做三件事分配并初始化四个uart_port、注册uart_driver、把每个port添加到serial core。uart_port里的关键成员要初始化好port.ops指向自己的uart_opsport.line设置成对应的串口号port.dev设置成设备指针。uart_ops里最基本的几个函数是这么分工的static struct uart_ops ch9434_uart_ops { .tx_empty ch9434_tx_empty, .set_mctrl ch9434_set_mctrl, .get_mctrl ch9434_get_mctrl, .stop_tx ch9434_stop_tx, .start_tx ch9434_start_tx, .startup ch9434_startup, .shutdown ch9434_shutdown, .set_termios ch9434_set_termios, .type ch9434_type, .request_port ch9434_request_port, .release_port ch9434_release_port, .config_port ch9434_config_port, };startup函数负责打开串口时初始化复位芯片、清空FIFO、写入FIFO控制寄存器、使能中断最后申请中断线程。shutdown正好反过来串口关闭时关中断、停收发。set_termios是配置波特率和数据格式的地方里面要调用前面说的分频计算把结果写进DLL/DLM寄存器。start_tx是发送路径的核心把要发送的数据从uart控制器拿过来通过SPI依次写入发送FIFO。这里有个小细节如果一次write的数据量超过FIFO深度就要分段写发送完一段后等THR空标志再发下一段否则会溢出丢数据。4.4 中断接收路径整个驱动的命门接收路径做得不好这个驱动基本不能用于生产。设计上有两个绝对不能犯错的地方。第一不能在普通中断上下文里直接调用spi_sync收发数据。SPI传输本质上是同步等待过程在原子上下文里调用会导致内核直接报BUG。解决办法是用request_threaded_irq注册一个中断线程让SPI操作在线程上下文执行或者用workqueue把工作推到进程上下文。我用的是中断线程逻辑相对简单。第二中断处理里要把FIFO一次性读到底不能只读一个字节就退出。我前面说过CH9434的INT#是电平触发只要FIFO里还有数据INT#就一直拉低。如果中断线程只读一字节剩下数据还在退出后马上又被触发一来二去中断风暴就来了CPU占用飙升。中断线程的核心逻辑参考这样static irqreturn_t ch9434_irq_thread(int irq, void *data) { struct ch9434_dev *dev data; int ch; mutex_lock(dev-lock); for (ch 0; ch CH9434_UART_NR; ch) { u8 iir ch9434_read_reg(dev, ch, REG_IIR); if (iir IIR_NO_INT) continue; while (ch9434_read_reg(dev, ch, REG_LSR) LSR_RX_READY) { u8 byte ch9434_read_reg(dev, ch, REG_RBR); tty_insert_flip_char(dev-ports[ch].port, byte, TTY_NORMAL); } tty_flip_buffer_push(dev-ports[ch].port); } mutex_unlock(dev-lock); return IRQ_HANDLED; }每次中断进来先把四路通道都查一遍哪一路的IIR表明有接收数据就把它的FIFO读到空为止。tty_insert_flip_char把收到的字节塞进tty层最后tty_flip_buffer_push统一推送这样应用层的read就能立刻拿到数据。SPI访问加锁也很重要。因为四个串口共用同一个SPI控制器如果在start_tx和中断线程同时访问SPI会产生总线竞争。我在device结构体里放了一个mutex所有SPI读写操作都串行化。实测四路并发读写时这个锁就是整个驱动在SPI层面的唯一瓶颈但它保证了数据不出错。4.5 编译注册与生成/dev/tty节点在驱动里要把uart_driver的name和dev_name设置对比如name设为ch9434_uartdev_name设为ttyCH9434这样注册后生成的设备节点就是/dev/ttyCH9434_0到/dev/ttyCH9434_3。编译方式我直接并入了内核源码树在drivers/tty/serial/目录下加了一个文件和一个Kconfig选项。加载模块或者编进内核后可以先用下面命令确认注册状态cat /proc/tty/drivers | grep ch9434 ls -l /dev/ttyCH9434_*看到ch9434_uart驱动出现在列表里并生成了四个节点说明最核心的注册链路已经通了接下来就是实际通信验证。5. 调试与验证环回、打流与性能评估5.1 环回自测驱动框架搭好后第一件事是环回测试。把CH9434某一路的TXD和RXD用杜邦线短接然后用串口工具往这个tty设备写数据。如果驱动收发逻辑正确写入的内容会从芯片内部发送出去又从自己接收回来应用层read就能读到同一份数据。用最简单的命令验证stty -F /dev/ttyCH9434_0 115200 raw picocom -b 115200 /dev/ttyCH9434_0在picocom里随便敲几个字符能原样显示出来就说明这一路的发送接收通路没有问题。四路都要测一遍尤其是后两路重点验证寄存器地址的通道偏移有没有配错。5.2 多路并发压测要不要用DMA环回通过之后就要做多路并发压测了。我的测试环境是拿一块USB转四串口板卡分别接CH9434的四路然后用脚本在四路同时打数据每路发送几万帧对端统计丢包情况。压测中发现性能瓶颈往往不在波特率而在中断处理速度。每来一个接收FIFO中断中断线程就要读四路状态再循环读FIFO。如果四路同时到达115200满速中断频率已经很高了CPU占用大概在10%左右。这时候要不要上DMA我的结论是暂时不需要。CH9434本身的FIFO已经能扛住短时间突发只要中断线程不长时间被调度延迟就不会丢数据。真到了四路同时跑460800以上的场景再考虑DMA也不迟。如果确实要玩DMA思路是把SPI接收改成DMA循环模式每次传输的buffer足够大配合CH9434的FIFO触发水位可以大幅降低CPU占用。这个属于进阶优化项目初期不推荐一上来就搞。5.3 性能上限与CPU占用评估我实际验证下来CH9434在SPI时钟8MHz时四路串口同时跑115200bps是没有任何压力的CPU占用基本可以忽略。四路同时跑460800bps时如果中断触发水位调得低CPU占用会明显上升大概到20%上下。解决办法就是把FIFO触发水位调高比如让芯片攒到32字节甚至更满才触发一次中断。SPI时钟本身也是瓶颈。8MHz的理论带宽是1MB/s四路460800bps总吞吐大约0.23MB/s加上SPI命令帧的额外开销仍然非常宽裕。真正限制高波特率的是中断频率而不是SPI带宽这个认知能帮你少走很多弯路。6. 常见问题与实战排错6.1 SPI通信不生效这个问题太常见了基本所有SPI外设驱动都会遇到。先别急着怀疑代码按顺序排查先查设备树spi-max-frequency是不是太高降到1MHz试试再查SPI模式是否匹配CH9434用模式0然后用逻辑分析仪抓CS、SCK、MOSI、MISO四根线的波形看CS拉低后SCK是否在正常翻转MISO上有没有返回数据。如果波形里MISO一直是高电平多半是芯片没被唤醒或者方向接反了。6.2 有中断但读不到数据中断一直在触发但应用层read不到数据这种问题通常是中断线配错或者接收路径没打通。检查设备树的interrupts引脚和实际硬件连接是否一致检查中断类型是否用了电平触发。在中断线程里加一句printk看是不是真的进了中断如果进了但没读到数据多半是寄存器命令帧格式里的通道选择字段填错了导致一直读的是第0路。6.3 波特率偏移造成乱码如果低速明明正常高速就乱码优先怀疑波特率分频误差。把目标波特率、分频数、实际波特率三者拉出来算一遍看误差是否超过2%。如果是内部时钟频率选的晶振值不对也会影响整体精度。还有一种可能对手端的串口芯片晶振不准两边都在误差边界时通信质量就会很差。这种情况可以尝试让CH9434朝误差较小的方向靠比如手动调一下分频数而不是直接四舍五入。6.4 长时间跑丢数据长时间压测发现偶发丢包可以从三个方向排查。接收中断线程是否有被更高优先级任务抢占SPI访问锁是否导致中断线程长时间进不去FIFO触发水位是不是太低导致中断频繁但每次读取开销过大我最后把FIFO水位调高、中断改用线程化irq并保证mutex只在极短路径持有问题就消失了。6.5 休眠唤醒后串口失效系统休眠唤醒后CH9434可能因为SPI控制器状态丢失或者芯片供电被切断再恢复里面的寄存器全部变为默认值。解决办法是在驱动的resume函数里重新初始化芯片包括复位、配波特率、清FIFO、重新使能中断。如果内核里只是简单地把uart_port恢复原状不重新走一遍startup流程串口就是“看起来在线实际上死了”的状态。如果让我重新做一次这个项目我一定会在画板子之前就把中断引脚、复位引脚、SPI引脚都引到测试点上并且把CH9434的寄存器读写封装成一个独立模块先在STM32上验证完再写Linux驱动这样整个开发周期至少能缩短三分之一。调试驱动这几个月积累下来的经验是SPI外设驱动的难点不在SPI本身而在它和串口子系统的耦合把中断线程、FIFO水位、SPI并发锁这三件事处理好这个驱动基本就稳了。后面如果有机会我打算再把DMA循环模式和RS485自动方向控制集成进去到时候再写一篇优化记录。