干了快十年的软件架构设计和开发,被问得最多的一句话是:架构到底是什么?有人说架构是画框图,有人说是技术选型,也有人说是为了满足未来扩展性。我自己的回答一直很直接:软件架构的本质,就是一门对抗复杂度的系统工程。如果你没有在跟复杂度较劲,那画再多框图也只是自娱自乐。
这些年做嵌入式系统、上位机软件,也深度参与过几轮智驾相关的软件架构梳理,越到后面越发现一个规律:项目烂不烂,往往不取决于团队里有没有大牛,而取决于架构能不能把错综复杂的业务逻辑、硬件差异、团队协作压进一个可理解、可改动、可维护的框架里。这个“压进去”的过程,就是在跟复杂度搏斗。
这篇文章我会结合我自己在项目里的实际经验和踩过的坑,把“软件架构对抗复杂度”这件事拆开揉碎来讲。会聊到复杂度的来源、怎么量化复杂度、实战中常用哪些招数来降维打击复杂度,也会分享一些嵌入式分层架构、上位机架构、智驾软件架构里的真实案例,给正在做架构设计或者准备重构的朋友一些参考。
1. 为什么写得越久,系统就越难受?从复杂度的来源说起
很多人习惯把“系统变烂”归咎于“代码写得乱”。但我观察下来,代码乱只是表象,真正的问题在于业务复杂度与技术复杂度在系统里不断叠加,而架构没有为它们画好边界。先搞清楚对手是谁,我们才能谈怎么赢。
1.1 两种复杂度:本质复杂度与偶然复杂度
软件工程里有个经典分类,我觉得特别适合作为架构思考的起点。复杂度分两种:一种是问题本身自带的,叫本质复杂度(Essential Complexity);另一种是你自己“作”出来的,叫偶然复杂度(Accidental Complexity)。
本质复杂度躲不掉。比如你要做一个智驾系统,要处理传感器融合、路径规划、车控指令下发,这些算法和实时性要求本身就很难。再比如你做一个银行交易系统,事务一致性、资金对账这些业务约束本身就是硬骨头。架构能做的,不是消灭本质复杂度,而是把它限制在可控的范围里,让它不要污染整个系统。
偶然复杂度则是人为造成的。比如用错了中间件、为了一个边缘功能引入整套微服务框架、团队之间职责不清导致模块互相渗透。这一块在我看来,才是架构师真正能发挥价值的地方。好的架构不是消灭了所有复杂,而是把不必要的复杂挡在门外。
1.2 Cynefin模型对软件复杂度的启发
前几年我接触到项目管理里的Cynefin模型,发现它对理解软件系统的复杂度也非常有启发。Cynefin把问题域分成简单域、繁杂域、复杂域和混沌域。
- 简单域:因果关系明确,比如“按下一个按钮,灯亮”,直接用最佳实践就能解决。
- 繁杂域:因果关系存在但需要分析,比如“怎么优化一个排序算法”,需要专家分析多种方案,要先感知、再分析、再响应。
- 复杂域:没有标准答案,只有回顾之后才能理解,比如“如何设计一套自动驾驶的决策架构”,需要先探索、再感知、再响应。
- 混沌域:完全失序,先行动、再感知、再响应。
架构设计最大的坑,就是拿应对“繁杂域”的方法去处理“复杂域”的问题。比如需求还没探明白,就开始堆流程、堆框架。我见过太多项目在复杂域强行寻求“最佳实践”,结果整出一套反直觉的大而全框架,反而是给系统主动增加复杂度。架构的本质不是把所有问题都“设计清楚”,而是给不同类型的问题安排不同的容器。该用规则引擎的地方别硬编码,该做事件溯源的地方别拿同步调用硬扛。
1.3 复杂度不会消失,只会被转移
讲了来源,必须讲一个残酷的定律:在真实业务系统里,总复杂度是守恒的,架构能做的只是转移它。你不在代码层面处理并发,就得靠数据库锁来兜底;你不做消息队列削峰,就得靠客户端无限重试来“填坑”。
这个道理,我是被一个血泪教训砸醒的。早期做一个嵌入式网关的上位机配置工具(Qt),当时觉得“功能也不复杂,直接线程里轮询设备状态就行了”,就没有加状态机也没有做统一消息分发。结果后来设备类型从3种涨到15种,通信协议开始分版本,界面逻辑里塞满了if (deviceType == XXX && state == YYY)这种判断。一个配置项改下去,牵出一堆联动逻辑,测试一遍一遍回归,别人改代码按天算,我们按星期算。
当时我就意识到:如果架构不主动去消化复杂度,复杂度就会在代码里野蛮生长,最后整个团队都为它买单。后来我做的第一件事,就是给上位机引入一个轻量分层架构和状态机模型,把设备通信、业务解析、界面展示拆开,复杂度瞬间变得有秩序了。
2. 对抗复杂度的三板斧:分层、模块化与解耦
聊完了理论,我给你我的实操经验。这些年做过嵌入式软件、Qt上位机、也参与过智驾域控软件的架构评审,真正经受过考验的,无非是三板斧:分层、模块化、解耦。
2.1 分层架构:嵌入式与上位机里“乐高式”的秩序感
分层是软件架构里最朴素也最有效的复杂度对抗手段。它的核心思想是单向依赖:上层依赖下层,下层不反向依赖上层,同层之间尽量互不相干。
以嵌入式系统为例,我习惯把代码分成这么几层:
| 层级 | 职责 | 典型内容 |
|---|---|---|
| 应用层 | 业务逻辑 | 任务调度策略、业务状态机 |
| 服务层 | 通用服务 | 通信协议解析、存储管理、日志系统 |
| 驱动抽象层 | 屏蔽硬件差异 | 统一SPI/I2C/UART接口、设备抽象 |
| 芯片相关层 | 寄存器与中断 | MCU外设初始化、启动代码 |
很多刚做嵌入式的朋友觉得:项目小,一两个芯片,直接操作寄存器多痛快,分层反而绕。这就是典型的只看到“当前复杂度”,没看到“演化后的复杂度”。分层不是为了眼前写起来爽,而是为了三个月后、一年后系统还能继续加功能。
拿我用Qt做上位机的经验同样如此。早期版本界面代码、业务代码、通信代码全混在MainWindow里,一个窗口文件几千行。后来重构成了三层:界面层只做控件绑定和展示,业务层处理逻辑和状态,通信层负责收发数据与协议编解码。看起来每次加功能都要“多写几行胶水代码”,但增量维护的难度成倍下降。
2.2 模块化的边界:如何判断切得对不对
有了层,切模块就是下一步。模块化不是“把代码分文件”,而是划分职责边界和依赖方向。
业界常说高内聚低耦合,但“内聚”和“耦合”这两个词太抽象了。我判断一个模块边界是否合理的标准很简单:改一个需求,需要同时修改的源文件分布在多少个模块里?如果总是要跨两三个模块联动改代码,说明边界切得不对。
举一个智驾软件架构的例子。智驾系统往往分为感知、预测、规划、控制等模块。看起来边界清晰,但如果你做的是“规则+AI混合”方案,感知模块输出的不确定性会传导到预测模块,预测又影响规划模块。如果模块间只是定义了接口,但接口内部却传递了“隐式假设”,比如预测模块假设感知模块输出的目标ID永远不变,那模块边界就形同虚设。真正的模块化,要连“隐式假设”也明文化。
我的实操建议是:给每个模块定义一份“合同”——输入是什么、输出是什么、保证什么、不保证什么。这样可以最大程度避免跨模块互相“薅逻辑”,复杂度就不会无谓地蔓延。
2.3 解耦的真正含义:让变化各自独立
解耦这个词,被用烂了。什么都是“解耦”,但其实很多人做的只是“把耦合区域从代码里挪到心里”。真正的解耦,是让不同的变化方向可以各自独立演进。
嵌入式系统最典型的解耦场景是硬件替换。比如你之前用STM32,后来因为成本换成了国产某MCU。如果驱动层抽象得好,应用层代码几乎不用动,只需要重写驱动适配层。如果代码里到处是寄存器操作、到处依赖特定外设库,那一次换芯就是一次“考古式重构”。
Qt上位机里的解耦,则更多体现在界面与逻辑的分离。我用过几种方案,最推荐的是信号槽 + 业务对象注入。界面层只管emit信号,业务层决定怎么响应,通信层决定怎么发送。谁都不直接依赖谁的具体实现,只看接口。
因为你把变化隔离开了,所以每一处的复杂度都是局部化的,改一处不会炸一片,这就是解耦最大的价值。
3. 复杂度分析与量化:别靠感觉,用工具说话
很多人架构设计凭“感觉”,觉得这里该拆、那里该合。但架构是长期演化的,如果复杂度不能被量化,团队讨论架构就是“谁嗓门大听谁的”。所以这些年我比较关注复杂度分析的方法,也习惯用一些指标辅助判断。
3.1 从排序复杂度到架构复杂度:渐近思维的迁移
大学里都学过各种排序算法,什么冒泡O(n²)、快排O(n log n)、堆排O(n log n)。这些分析关注的是“随着输入规模增长,时间开销怎么变”。架构复杂度的分析思路其实一脉相承:随着功能数量、模块数量、团队规模增长,系统的维护成本怎么变?
举个例子。一个单体系统,模块数量从5个涨到20个,接口数量可能从10个涨到80个。如果模块间是网状连接,那么连接数大约是n²量级。这就像冒泡排序——代码还没跑到一半,人先被复杂度耗死了。如果架构师事先做一次“接口收敛”,引入分层和总线式交互,连接数可能变成n量级,也就是线性增长。这相当于把系统的复杂度复杂度从O(n²)降到了O(n)。
所以架构评审时,我喜欢问一个问题:再加一个模块,系统要付出的“连接成本”是常数、线性还是平方级?这个问题比任何代码规范都有杀伤力。
3.2 常用复杂度指标:圈复杂度与模块依赖度
代码层面的复杂度,有一个非常实用的指标叫圈复杂度(Cyclomatic Complexity)。它衡量的是代码中独立路径的数量,值越高说明分支越多、越难测试。一般圈复杂度超过10的函数,就要考虑重构了。
但架构层面,圈复杂度颗粒度太细了,我更关注的是模块依赖度和变更影响面:
| 指标 | 含义 | 预警阈值 |
|---|---|---|
| 扇入数 | 有多少模块依赖该模块 | 过高说明基础模块太“重”,改它要冒天下之大不韪 |
| 扇出数 | 该模块直接依赖多少个其他模块 | 过高说明该模块承担太多职责 |
| 循环依赖数 | 模块间存在循环依赖的组数 | 不为零就要警惕,超过1组就该立刻处理 |
| 平均依赖深度 | 依赖链路的平均长度 | 过深说明分层之间跳级,架构被穿透 |
我每次重构完,习惯用这组数据做前后对比,不光是为了吹牛发报告,更是为了确认“复杂度真的降了”。有一次我给一个旧的上位机软件做分层重构,重构前模块依赖图里有8个环状依赖,重构后降到2个,关键路径平均深度从7层降到4层。这种量化结果,比一句“代码清晰多了”有说服力得多。
3.3 排序最坏复杂度给架构师的启示:警惕最坏情况
学数据结构的时候我们会算算法的最坏复杂度、平均复杂度。遗憾的是,大多数架构设计只考虑了“平均情况”下的复杂度,也就是大家都在正常路径上跑。但软件系统里真正击垮项目的,往往是最坏情况下的复杂度。
什么是架构里的最坏情况?
- 关键中间件突然挂了,系统有没有降级预案?
- 团队来了一个新人,需要多久才能看懂核心链路?
- 核心架构师离职,代码能不能交给其他人接手?
这些听起来不像是“复杂度”问题,但本质上是“人脑理解复杂度的上限”问题。架构如果依赖某个特定的人的隐性知识,那么这个人就是系统的“最坏复杂度节点”。所以我在做架构设计时,会有意识地做“新人测试”:找刚入职的同事,让他只看架构文档,能不能复述出核心流程。如果做不到,说明架构文档的抽象程度不够,或者模块划分仍然不自然。
3.4 XGBoost交通复杂度建模的跨界启示
搜复杂度相关的资料时,看到一个很有意思的研究方向叫“基于XGBoost的空域交通复杂度建模”。出发点是用机器学习模型来预测某片空域的交通复杂度,核心思路是把影响复杂度的因素(航班密度、气象条件、空域结构等)作为特征输入,用回归或分类模型输出复杂度水平。
我为什么提这个?因为在管理大型软件系统时,“复杂度”同样可以被当成一个可预测的因变量。比如短期内提交量、模块变更数量、跨模块变更比例,这些是特征;系统的“失控感”或者Bug率,就是目标变量。如果你有心去积累这些数据,完全可以用类似XGBoost的工具建模,提前预判哪些模块即将腐化。这比事后复盘要有价值得多。
4. 从嵌入式到智驾软件架构:复杂度是如何被驯服的
前面讲的偏方法论,接下来我结合两类具体的软件架构,讲讲复杂度在不同场景下是怎么被驯服的。一类是嵌入式系统分层软件架构,一类是智驾软件架构。这两类系统都处于“硬件资源受限+实时性要求高+业务逻辑复杂”的交汇点,非常有代表性。
4.1 嵌入式分层架构的实战拆解
嵌入式系统往往跑在资源受限的MCU上,可能只有几十KB的RAM、几百KB的Flash。在这种环境里,分层不是“架构洁癖”,而是生存刚需。
我常用的分层模型前面列过,这里展开讲每一层的设计要点。
- 应用层:只处理“业务规则”。比如一个充电桩控制器,“充电流程先握手、再绝缘检测、再泄放”,这是业务逻辑,和用的MCU型号一点关系都没有。
- 服务层:提供与业务无关的通用服务,比如日志、按键扫描、定时器管理、看门狗管理。这里的关键是服务的API要稳定,否则上层业务一直在跟着服务层改,复杂度会迅速上升。
- 驱动抽象层(HAL):是嵌入式架构的“护城河”。HAL层把所有差异化的硬件操作统一封装成标准接口。比如
hal_uart_send(uint8_t *data, uint16_t len),无论底层是STM32的USART还是某国产MCU的LPUART,接口都不变。 - 芯片相关层:放启动代码、寄存器映射、外设中断处理。这一层的代码“越丑越好”,因为越贴近硬件,越难以抽象;不要试图把硬件相关的代码写得“优雅”,只需把它隔离在最底层。
实际项目里,我见过最典型的嵌入式复杂度爆点,是把协议解析写在中断里。比如某外设数据进来,直接在ISR里做状态机跳转,甚至分配内存。短时间看“响应快”,但当数据帧变长、协议版本增多后,ISR里逻辑越来越长,优先级反转、临界区冲突、响应超时问题接踵而至。嵌入式架构的核心不是快,而是可预测。
4.2 Qt上位机软件架构:看似简单,烂起来也很快
Qt上位机在工控、测试、军工领域极其常见。跟嵌入式软件比,它的复杂度看起来不高,很多人想法就是“拖几个控件,绑定一下数据,完事”。但我做过几个长期迭代的上位机之后,我的结论是:上位机的复杂度在“界面状态与数据状态的同步”上,做不好就是一场灾难。
我最推荐的上位机架构是MVVM变体配合信号槽,把“数据模型”、 “界面模型”、 “通信层”拆开。核心思路是:
- 通信层:负责与下位机交互,解析出“业务数据对象”,通过信号发布给业务层。
- 业务层:负责处理业务数据,维护运行状态,触发命令下发。
- 界面层:只负责展示,用户点击按钮就发信号,界面订阅业务层的数据变化。
很多朋友在做Qt上位机时喜欢直接在工作线程里更新UI控件,这样做在Demo阶段没问题,但一旦界面卡死或者数据错乱,排查起来特别痛苦。Qt的UI更新并不是线程安全的,你必须通过信号槽把数据“搬运”回GUI线程。这个搬运的过程不要嫌麻烦,它是上位机架构中防止复杂度失控最重要的一道闸门。
另外上位机里一定要重视日志系统。不是随手qDebug(),而是带时间戳、级别、模块名的结构化日志。因为上位机的复杂问题往往不是跑不出来,而是过程不可见。有了结构化日志,很多“偶现”问题才能有迹可循。
4.3 智驾软件架构:分布式、实时性与数据流复杂度
智驾软件架构是这几年很火的方向,也是“高复杂度”的代名词。感知、融合、预测、规划、控制、功能安全、冗余备份,模块多、数据量大、实时性分层,再加上功能安全与预期功能安全要求,整个系统的复杂度远超普通软件。
智驾软件架构怎么对抗复杂度?我认为有几条核心主线。
- 接口标准化+数据分发中间件:智驾模块之间高频度、低延迟地交换数据,如果不用DDS这类数据分发服务,而是自己定义一套私有通信,模块越多复杂度越高。DDS通过主题(Topic)、服务质量(QoS)策略来解耦生产者和消费者,本质上就是让“谁生产数据”和“谁消费数据”互不感知。
- 确定性调度:智驾系统里既有10ms周期的控制任务,也有50ms周期的感知任务,还有毫秒级的中断。如果调度逻辑不清晰,系统的行为会变得“时好时坏”,这是所有系统工程师的噩梦。
- 状态机与有限状态管理:智驾的主状态(Disengage、Standby、Active)、子状态(跟车、变道、过路口)必须用显式状态机管理,不能用若干个布尔变量组合来隐式表达。我见过用“三个布尔变量组合出8种状态”的开源代码,逻辑之绕,令人头皮发麻。显式状态机不仅能降低实现复杂度,还能让安全分析有明确的锚点。
智驾领域最难的其实不是某个算法,而是怎么让几十个模块在严格时序和严格数据依赖下协同不出错。解决思路靠的还是分布式架构的那套经典智慧:异步解耦、协议先行、状态可见。
5. 实战复盘:一次上位机架构重构,如何把复杂度从“压垮团队”拉到“从容维护”
方法论讲多了容易飘,我拿一个真实的项目复盘来收尾,大家更能看到这些原则落地的样子。
5.1 旧架构的问题:意大利面条式的“伪分层”
两年前我接手了一个Qt上位机项目,是给一种嵌入式采集设备做参数配置与数据监控的。第一次看代码,我一度以为是历史遗留的“教学代码”——所有逻辑都堆在MainWindow里,信号槽直接跨线程乱飞,通信协议解析散落在各个按钮的点击槽函数里。
具体表现:
MainWindow.cpp长达8000多行,一个updateUI()函数里有400多行if else。- 每个设备类型都有自己的一套协议解析分支,新增一个设备类型要改十几个文件。
- 工作线程和GUI线程共享变量,不加锁,靠“运气”维持稳定。
- 想加一个“导出配置”功能,需要同时改动通信、业务、界面三个层面,但谁也没有真正理解完整链路。
这套代码的复杂度,已经超出了团队所有人的记忆容量。每次改需求,就像在一锅粥里找一粒豆子,不看个半天都不敢动手。
5.2 重构目标与拆解:先把分层的边界画出来
我跟团队定的重构目标非常朴素:新需求平均交付时长从两周降到三天;新增一个设备类型的改动能够在一天内完成。
为了达到这个目标,我们花了三个晚上把整个业务链路走了一遍,画出了核心数据流和关键状态,然后按照“三层分离”的思路切分:
- 通讯层:负责串口/TCP的连接、收发、分包、协议解析,对外输出统一结构体。
- 业务层:全局配置管理、设备注册与状态管理、异常处理策略,对外提供业务操作接口。
- 界面层:只负责用户的交互展示,事件一律发给业务层,数据变化通过信号槽订阅。
这里我想特别强调一个容易被忽视的点:重构不能落入“用新技术重写一遍”的陷阱。我们保留Qt原有框架,不换编译链、不换UI库,只在代码组织层面做结构调整。技术栈越稳,重构的变量越少,复杂度越容易控制。
5.3 核心操作:状态机引入与协议驱动的接口设计
重构过程中收获最大的操作,是给设备管理模块引入了一个显式状态机。
旧代码里设备状态是用bool isConnected、bool isConfiguring、bool isError组合表达的,然后每个页面自己去解释这些布尔值。我对接业务层的接口时,改成统一的枚举状态:kDisconnected、kConnecting、kConnected、kConfiguring、kReady、kError。
状态机的好处立刻体现出来:很多非法操作在状态层就被拦截了,不需要业务层到处打补丁。比如用户正在配置参数时,电闸断开,旧代码可能会在配置写了一半时把数据写入Flash,导致配置损坏;有了状态机,“配置中掉线”这一个事件就有了明确的迁移路径和异常处理策略。
协议解析也做了统一重构。原来每种设备一个解析函数,现在用一张“协议描述表”来定义消息ID、字段偏移、数据类型、取值范围。解析逻辑变成了一组表驱动代码,新增设备类型不再需要写解析逻辑,而是加一条表项。这一步把“修改量从十几处”降到了“一处”。
5.4 重构后的量化对比:复杂度降到什么程度了
重构完成后,我们用复杂度指标做了前后对比,这个对比结果让我非常踏实:
| 指标 | 重构前 | 重构后 |
|---|---|---|
| 核心文件行数 | 8000行 | 约500行 |
| 循环依赖数 | 8个 | 0个 |
| 设备类型新增改动文件 | 10+个文件 | 1个配置文件+少量业务注册 |
| 平均状态分支数(核心页面) | 40+ | 8 |
| 新增需求平均交付时间 | 2周 | 2~3天 |
不只是数字好看,团队氛围也变了。原来新人接手要“考古”一个月,现在看一遍分层文档和状态机图,就可以在指导下去改业务代码。架构对抗复杂度的胜利,最直接的体现是团队重新获得了对系统的掌控感,这种感受比任何指标都重要。
6. 常见问题与排查技巧实录:给正在对抗复杂度的你
最后分享一些实战中反复出现的问题,和对应的排查技巧,希望能帮你少走弯路。
6.1 分层了,但代码还是乱?八成是依赖方向被绕过
我见过一种“伪分层”:包名上是view/service/dao,但代码里view直接去操作数据库,service里偷偷改界面。这种代码之所以存在,通常是“图方便”导致的。临时赶进度,界面里直接调了三层之下的函数,当时爽,以后每一次改动都是噩梦。
排查技巧很简单:代码评审时做一次依赖方向检查,用工具画出模块间的依赖图,凡是箭头往上指的(上层依赖下层正常,下层依赖上层是问题)或者跨层依赖的,一律打回。一开始团队会觉得繁琐,坚持两个版本之后,大家写代码之前就会主动想“这条依赖该不该存在”。
6.2 模块拆了,接口也定了,为什么改动还是牵一发动全身?
这种情况大概率是接口粒度过粗。比如通信模块对外只暴露了一个sendRawData(QByteArray),业务层每次都要自己拼报文、解析回包。表面上看通信和业务解耦了,实际上业务层被通信协议绑架了,协议一改,业务层全得改。
解决方案是:接口要按照“业务意图”来设计,而不是按“通信动作”来设计。不要暴露“发一帧数据”这种底层接口,而是暴露“配置设备的IP地址”、“读取设备当前状态”等业务接口。封装得越贴近业务语义,模块之间的耦合就越小。
6.3 状态太多,状态机画成一团麻?先砍状态再画图
很多团队引入状态机,结果画出来的图比原来的if else还难懂。原因通常是状态粒度过细,比如把“正在写寄存器1”、“正在写寄存器2”都定义成独立状态。正确的做法是,状态之间必须有“可感知的业务差异”,中间寄存器的读写过程属于“子流程”,不应该出现在核心状态机里,可以放到服务层内部处理。
所以我的经验是:先列业务事件,再画核心状态迁移,最后才补充异常路径。如果状态超过10个,就需要重新考虑抽象层级了。
6.4 架构评审争论不休,用什么裁判?回到“变化点”
最后再分享一个很实用的方法。每当架构评审各方拍桌子,吵得不可开交的时候,我都会要求大家回到一个问题:未来半年到一年,这个系统最可能的变化点是什么?
- 如果要换硬件,那驱动抽象层的接口设计就是重中之重。
- 如果要加界面功能,那业务层信号与界面层订阅的协议就要稳定。
- 如果协议版本会增加,那协议解析的表驱动设计就必须前置。
复杂度管理的本质,就是为不确定的未来预留确定性的结构。所有架构决策,都应该围绕“变化点”来展开,而不是围绕“当前功能”来堆砌。
我个人的体会是,软件架构确实没有银弹,但它也绝不玄乎。所谓架构功底,就是能一眼看穿哪些复杂度是核心业务躲不掉的,哪些复杂度是设计不当引入的,然后把前者封装好,把后者消灭掉。这条路没有终点,每个项目、每次重构,其实都是在重新界定“简单”与“复杂”的边界。但只要你始终把“对抗复杂度”当成架构的第一性原理,系统的健康状况就一定会往好的方向发展。