软件设计中最折磨人的,不是找不到方案,而是方案不知道该停在哪个复杂度上。我见过把一个状态机“简化”成三个全局布尔变量的源码,也见过一个字符串格式化函数被套上四层抽象工厂的项目——前者死于过度简化,后者崩在过度复杂化。这两条路我都在真实项目里走过头,摔过跟头,也花过很长时间琢磨一个问题:为什么大家明明知道“适度最好”,却还是无可避免地滑向其中一个极端。
最近“嵌入式软件设计基础”的热度又上来了,恰好这个领域是两极化翻车事故最高发的地方。硬件资源有限时,人会下意识砍设计;硬件一升级、需求一变多,又会报复性地堆设计。这篇文章不打算给什么万能答案,而是把我这些年观察到、踩过的两类极端好好拆一遍:它们各自长什么样、背后是什么动机在驱动、怎么在项目里提前识别,以及我目前认为比较可行的平衡做法。
1. 过度简化:像一个没有排水管的高架桥
1.1 全局布尔状态机是怎么把项目拖垮的
我接手过一块老产品的维护,它的核心逻辑是一个“精简”到极致的按键状态机。刚看到代码时我心里还感叹了一句:写得真短,没有多余的东西。
c复制/* v0,当时的“精简设计” */
static int state = 0;
void on_button(void)
{
state = (state + 1) % 3;
if (state == 0) {
led_green();
} else if (state == 1) {
led_yellow();
} else {
led_red();
}
}
三个状态循环切换,三行逻辑,确实很干净。但第二个月需求就变了:新增“长按两秒直接切换常亮模式”“离开待机界面超过十分钟自动回到状态0”“每种状态下按键短按和长按行为不同”。我开始往这段代码里打补丁,先是加了 prev_state 保存上次状态,然后加了 sustain_ms 计时,接着用 enabled_flag 控制某些状态下的响应,最后连 fault_code 都塞进这个全局状态里了。
到第五个版本时,这个“精简设计”已经变成了一个拥有十几个全局变量、到处互相写值的泥潭。最讽刺的是,最初那个 state 变量已经没有人能准确说出它的全部取值了。
这段经历让我想明白一件事:过度简化省掉的不只是设计,而是省掉了“让代码能承载变化”的那个结构。变化不会因为你没做准备就不来,它只会以更丑陋的方式钻进你的系统里。你省掉的结构,最后都会以更多不可读的变量、更多隐式耦合、更多“不知道改了哪里会炸”的形式补回来。
1.2 简化过头的几个典型症状
真正危险的过度简化,不是那种“写得太随便”的代码,而是那种看起来“很漂亮、很直接、几乎不需要注释”的代码。它的问题藏在暗处。
第一个典型症状是全局状态满天飞。不同模块之间靠几个全局数组或者公共变量通信,表面上看“这样传参多方便”,实际上所有模块的边界都被凿穿了。我见过最夸张的例子是一个通信协议栈,所有消息都往同一个缓冲区里写,靠 current_sender 和 last_message_type 来区分是谁的消息。任何一个小函数都可能覆盖缓冲区里的数据,排查两个模块互相踩数据的bug,一查就是好几天。
第二个症状是错误处理被省略成“不会出错”。很多简化过头的代码里,函数返回值要么没人检查,要么全部返回 -1 表示“反正挂了”。网络收发、外部存储读写、协议解析,这些天生就可能失败的环节被当成绝不会失败来处理。等产品在现场真的遇到异常输入,表现就是随机死机或者数据错乱,查都查不到。
第三个症状是业务规则被揉进了“看似通用”的代码里。比如在字符串处理函数里偷偷判断 if (len == 4) { special_case(); },在循环里用魔法数字 3 表示“三种状态”。这些代码在写的当下“零成本”,但每一处都是一个时间炸弹。
我给这种做法的总结是:它就像一座高架桥没设计排水系统。通车头几个月阳光明媚,怎么跑都没事;等雨季一来,路面积水、桥体开裂、全线瘫痪,返工成本远超当初那根排水管的价格。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 过度复杂化:给电钻配了一套航空母舰控制台
2.1 一个计算器项目是怎么长出四层抽象的
过度复杂化我在年轻的时候也干过,而且干得理直气壮。有一次需要实现一个简单的设备配置解析模块,输入是固定格式的几行文本,输出是几个结构体。需求明明白白写在那里,但我当时满脑子都是“可扩展性”,于是做出了这样的设计:
c复制struct config_parser_ctx {
struct event_queue *eq;
struct statemachine *fsm;
struct behavior_provider *provider;
struct di_container *container;
};
struct config_result {
struct config_data *data;
struct config_validation_rule **rules;
struct config_observer **observers;
};
一个几十行的解析逻辑,外面包了“事件队列 + 状态机 + 策略提供者 + 依赖注入容器”。解析一行文本要先从队列取事件,丢给状态机,状态机调用策略提供者,策略提供者再通过依赖注入容器拿到真正的解析函数。每个人都承认这段代码“很正规”,但没有任何人能一口气讲清楚一次解析到底走了哪些路径。
后来一次真实需求改动,我需要调整其中一个解析分支的格式。改完之后,状态机的 entry_action、观察者的回调逻辑、验证规则的触发顺序全部要跟着调,花了整整两天,改出来的代码还不敢保证没有副作用。
这些多余的设计最大的成本,是它让“简单的事情”变难了。更麻烦的是,它还会让整个团队形成一种“这系统太庞大,我不敢动”的集体心理,所有人都在上面加层、打补丁,没人敢把它拆掉重来。
2.2 复杂化的三个伪装
复杂化很少以本来面目出现,它几乎总是穿着三件外衣。
第一件外衣叫“可扩展性”。这是最常用的借口。为“未来可能会有”的功能预留接口,问题在于,“未来可能”这四个字几乎可以为任何设计辩护。我见过有人为了“未来支持多个厂商”给一个只有一家厂商供货的传感器写了抽象工厂;也见过为了“未来可能分布式部署”给一个单机工具软件引入消息队列。这些设计几乎永远等不到预期的未来——因为真正的未来是变幻莫测的,预埋的扩展点往往对不上真实的演变方向。
第二件外衣叫“可维护性”。很多开发者把“抽象层次多”等同于“好维护”,实际上恰恰相反。每一层抽象都意味着一重映射,每重映射都需要读者在脑中做一次跳转。当层数多到靠架构图才能定位问题时,这个系统的可维护性已经远低于一个“直接但笨拙”的实现。
第三件外衣是“设计感”。必须承认,一个画满工厂、策略、观察者类图的方案,在评审会上就是比一个“就用一个结构体数组存配置”的方案显得更有分量。那些看起来像“架构师水平”的图,很多时候不是在服务项目,而是在证明设计者的专业身份。
我后来给自己立了一个判断标准:如果能用一句话说清“这段代码在业务上解决什么问题”,那它还算得上简洁;如果必须用架构术语才能解释,那基本可以确定复杂度已经失控了。给一个只需要拧螺丝的电钻配上航空母舰的控制台,并不是“专业”,而是“没有对准需求”。
3. 为什么会从一端滑向另一端:极端背后的真实驱动
3.1 两种极端其实是同一种误判
很多人把过度简化和过度复杂化看作两个相反的方向,一个做太少,一个做太多。但我观察越久越觉得,它们共享同一个病根:对“变化方式”的预判错了。
过度简化的人预判“变化不会来”。所以他们不设计数据结构,不做边界定义,不考虑错误处理,把一切寄托在“这个需求就到此为止”的幻觉上。等到需求真的变了,原来的设计既没有扩展点,也没有容错面,只能被补丁堆成一座危楼。
过度复杂化的人预判“变化会全方面涌来”。为了承接想象中的全部变化,他们把每个点都设计成可扩展的,但真实的变化通常只会集中在少数几个地方。那些预埋的扩展机制,变成了每天都要背着走的沉重行李。
这两种预判的灾难性是一样的:一个把所有变化都当成意外,一个把不存在的变化全部预先实现。而现实永远走中间路线——变化一定会来,但一定以你预料不到的方式出现,只发生在少数几个位置,且不会均匀分布。
想明白这件事之后,我对“极端设计”的评判就不再是“做多了还是做少了”,而是“有没有把精力对准真实变化可能发生的方向”。
3.2 过度简化的驱动:短期激励与“它只是个小项目”
我复盘自己做过的所有简化过头的东西,发现动机几乎都是短期压力。项目排期短、老板要demo、自己也急于看到功能跑通,于是“最短路径优先”成了唯一原则。那个状态机的第一个版本确实一天就跑通了,但后面连续三周都在补它捅出来的篓子。短期节省的时间,是按十倍利息还回去的。
另一个驱动是“它只是个小项目”心态。我见过无数个“小项目”最后变成生产系统跑了五六年。在运行一个系统的前几年,用“写作业”的心态来设计它,相当于把一座真实桥梁当成沙盘模型来施工。问题是,“小项目”和“大项目”之间的边界在开始写代码那天根本看不出来,只有后来维护时才知道自己当初的判断多离谱。
还有一个相对隐蔽的原因:对“隐性知识”的忽视。简化过头的人往往觉得“代码这么直白,不需要文档、不需要分层、不需要注释”,但系统真正的复杂度在于它运行一段时间之后积累起来的人与人之间的约定——哪些变量只能在这里读、哪个函数必须在哪个阶段调用。这些约定没有人记录下来,全部变成代码里的“不言自明”。等唯一懂这套约定的人离职,整个模块就成了黑洞。
3.3 过度复杂化的驱动:未来焦虑与“结构叙事”
过度复杂化最核心的驱动,我总结为“未来焦虑”。不敢说“这块业务很简单,不值得做抽象”,因为这句话在评审会上听起来像“敷衍”;反而是“我做了全套的抽象,已经很完备地考虑了扩展”更有安全感。这种焦虑让设计者没有勇气按下减法键。
还有一种驱动叫“结构叙事”。人天生喜欢把自己做的事讲成一个好听的故事——分层、解耦、模式化,这类词汇天然带着“专业感”。但设计的第一目的永远不是让代码在结构上显得漂亮,而是让真实需求的实现与演进过程尽量顺畅。当架构图比业务代码更能说服人的时候,这个项目已经陷入“叙事驱动”的陷阱了。
最后是团队环境的问题。代码评审机制下,挑“结构”的刺比挑“复杂度”的刺容易得多。你可以很具体地说“你应该把这部分抽成接口”,但你很难在评审时说“这层抽象根本没必要”。前者听起来专业,后者听起来伤人。在这种氛围里,复杂度总是只增不减,因为没有人愿意做那个“让设计变少”的提议者。
4. 识别两种病:不用靠感觉,看改动成本就行
4.1 用一张自查表给模块做体检
谁也无法保证自己的设计永远不偏,但这两个极端的症状其实非常明显,完全可以提前识别。我做了一个小检查表,每季度或者接手新模块时过一遍:
| 检查项 | 过度简化的信号 | 过度复杂化的信号 |
|---|---|---|
| 加一个新需求 | 改一个点,好几个无关模块跟着动 | 加一个点,先要改框架再写业务 |
| 定位一个bug | 翻遍全局变量也找不到谁写的 | 要连跳十几次抽象层才能到实际逻辑 |
| 代码审查 | 争议集中在“这会不会出事我们赌一把” | 争议集中在“这个模式用得规不规范” |
| 新人上手 | 看着简单但改一行就崩,心智负担极大 | 概念太多,光弄清调用链就要一周 |
| 删除代码 | 删一小段立刻引发连锁反应 | 删一层抽象要检查一堆依赖关系,没人敢下手 |
| 测试策略 | 基本靠“改完跑一遍主流程” | 靠mock和桩函数堆覆盖率,真实逻辑反而悬空 |
这两列症状我都在自己接手过的代码里见过。它们带来的结果其实是一样的:改动成本远超预期,模块越来越脆,团队越来越不敢碰。
4.2 拿最近的十个需求量一量
靠感觉判断容易出错。我自己比较推荐一个更量化的方法:记录连续十个真实需求的“改动触达面”。具体做法很简单:需求上线时,统计三个数据——本次提交改了哪些文件、哪些文件与需求描述毫无关系却必须改、以及修改后是否出现“改A却必须动B”这种被迫联动。
连续积累十几个需求之后,数据会告诉你真相。如果每次改动都发散到五六个无关文件,且存在大量“改了一个字段,另一个模块行为变了”的现象,那基本可以判定过度简化已经让模块边界彻底消失了——因为所有东西都被隐含地耦合在一起;如果每次需求都先花一天时间调整抽象层,再花两小时实现业务,那过度复杂化就是主因——因为设计没有对准真实流量的方向。
这个方法比“代码审查时大家互相辩论”客观得多。代码结构的优劣,最终会以“改动成本”的形式反映出来,而改动成本是可以被记录的。
4.3 反向诊断:从“这次改动动了哪里”看设计病灶
有时候你不需要完整的数据积累,一个单次需求就能暴露出问题。我常用的做法是观察一次中小型需求改完后的心理感受。
改完之后觉得很舒爽、删掉了一些冗余、改动集中在需求相关的区域——这是好设计。
改完之后觉得“这次总算对付过去了”,但心里清楚动过的好几个地方自己都没把握,总觉得会漏掉什么——这是过度简化,因为系统的真实边界在暗中,你根本没看清。
改完之后觉得自己在“框架的缝隙里小心翼翼地塞代码”,虽然框架很完整,但业务的表达很憋屈——这是过度复杂化,因为系统的抽象层已经比业务还重了。
我建议每个开发者把这两种感受记住,它们比任何架构评审意见都更接近真相。代码是给人服务的,当改动代码成为一场冒险,无论冒险的原因是“边界不明”还是“层数太多”,设计都已经失败了。
5. 嵌入式的两极化翻车现场:从“写死一切”到“抽象一切”
5.1 固件世界里没有“先上线再重构”的自由
“嵌入式软件设计基础”最近总能被搜到,我猜是因为越来越多开发者在进入物联网、边缘设备领域后发现,这个领域里软件设计的两极化问题被资源约束放大了无数倍。
嵌入式系统有一个天然特点:代码跑在别人的设备里,出了问题不能像服务器那样热修复。固件OTA有各种风险,有些医疗、工控设备甚至根本不支持远程升级。这意味着你在设计阶段做的每一个简化决定、每一个复杂化决定,都会在长时间内被锁定。
另一个特点是资源硬约束。Flash有限、RAM有限、CPU算力也有限。过度简化会浪费排查时间,但过度复杂化的代价是实打实的:一个本来只需要500行逻辑的按键控制程序,如果硬要套上事件驱动框架、抽象状态机、消息队列、内存池,Flash直接吃满,还谈不上任何性能余量。
这两个特点交叉,导致嵌入式成了软件设计极端症状最容易被观测到的地方。
5.2 两个极端在一颗芯片上的集体发作
我见过很多崩溃在两种极端里的固件代码,这里讲两个最典型的。
过度简化的代表性场景:中断里放了一个 while 循环。这种代码的潜台词是“我就等一毫秒,等外设准备好就行”。单片机的资源的确够小,很多开发者天真地认为“等一下就回来,不会有问题”。但中断函数里 while 在执行期间,所有优先级更低的代码都被冻结,看门狗甚至可能因此超时复位。更隐蔽的是,它不一定会立刻出问题,而是“偶尔”出问题——当外设恰好慢了一点,或者正好有另一个中断被卡住的时候,整个系统就莫名复位一次。排查这种问题,逻辑分析仪都未必第一时间找到原因。
过度复杂化的代表性场景:需求只是“按下按钮切换LED颜色”,却给单片机设计了一个完整的状态机框架。每个状态是独立对象,事件通过队列异步派发,状态切换必须有 guard 和 action,还要做一层抽象让不同LED驱动都能适配。代码看着“规范”,但真实问题是:Led颜色就三种,按钮就一个,这样一套框架让本来就紧张的Flash雪上加霜,更别说后续维护的人要花多少时间在层层嵌套的调用里找到那三行真正改变寄存器的代码。
顺便说一句,这两个极端在同一个项目里轮番发作也很常见。先是被“过度简化”坑了,就发誓以后要把设计做完整,结果矫枉过正,一个简单模块也上了重型框架。接着发现重型框架维护不动,又推倒重来去裸写——就这样在两端之间反复横跳,每一次都是全量返工。
5.3 嵌入式里的平衡线:死板的归硬件,灵活的归业务
我在嵌入式项目里摸索出一套比较实用的分层原则,其实很简单:与时间、硬件强相关的东西要“死板”,与业务相关的东西要“灵活”。
晶体振荡器配置、中断优先级分配、外设寄存器操作、时序要求,这些贴着硬件底层的代码必须在设计阶段定死,尽量静态、确定、可预测。它们通常不会频繁变化,但一旦出问题影响是全系统的。这一层要薄、要稳、注释清晰,不建议做过多抽象——寄存器就是寄存器,没有人需要五个工厂去创建。
业务决策层则相反。模式切换逻辑、按键交互规则、告警判断条件、用户配置参数——这些东西才是真正容易变化的。它们应当被显式地表达为状态和转移关系,不要写在某个没人注意的全局变量里。一个简单的按键切换LED,完全可以用最直接的轮询加标志位实现,只需要遵守一条纪律:中断里只置位标志位,主循环里根据标志位做处理。这已经是在“硬件响应”和“业务决策”之间画出了一条清晰边界。
这个边界就是嵌入式软件设计基础中我认为最重要的一课:硬件层的“死板”和业务层的“灵活”必须分开放,中间保留一个窄窄的、明确的接口。这样硬件变了,你只动底层那一小块;业务变了,你只动上层逻辑——两个极端都可以避开。
6. 平衡不是绘图纸上的折中,而是一套可执行的取舍机制
6.1 复杂度预算:给抽象发门票
聊完识别问题,我想聊聊我目前认为真正有效的平衡做法。核心思路很简单:把“复杂度”当成项目里的预算,花一块就要有一块的收益,而不是放任它自动增长。
我在团队里推过“门票机制”。任何新的设计模式、新的抽象层次、新的框架引入,都必须回答三个问题。第一,它现在解决哪个真实存在的问题?第二,如果今天不引入,三个月内会不会付出明显更高的代价?第三,这个抽象能否在半小时内被一个新成员理解?三个问题里只要有任何一个答不上来,就不允许引入。目标是不让“未来可能”这个词成为自行批准报销的借口。
这个机制的核心思想是:复杂度的“入场券”必须用真实存在的需求来购买,而不是用想象中的需求来预支。
6.2 记账式重构:让重构跟着成本走,不跟着情绪走
我另一个常用的策略是“记账式重构”。以前有个错误习惯:碰到代码写得别扭,就忍不住启动一场大规模重构,结果常常是动的地方越多、出的问题越多,最后还耽误了业务交付。
现在的做法是:先在当前架构下记录每一次真实改动的成本。比如这个模块这个月被改了几次、每次花了多久、有多少次改动是因为“不相关的耦合”造成的。当一个模块连续两个月在记录里都是成本大户,它才真的值得重构。如果一段代码虽然看起来不够“高级”,但这个月根本没人碰它,那就容它先保持原样——先用数据证明痛,再动手治理。
这个方法听起来不够激情,但胜在准确。重构的时机不该由写代码的人“今天心情好不好”来决定,而应由维护成本数据来驱动。
6.3 好的设计有一个让人上瘾的信号:删代码不需要犹豫
我最后想分享一个非常主观、但我觉得特别好用的判断标准:好设计会让人删代码时感到愉快。
这里说的“删代码”不是指重构时的大面积重写,而是日常改动中“删得掉”的程度。好的模块,当你实现一个新需求时,旧代码往往会被自然地删掉几行——因为原来的结构刚好承载不了这个变化,而新的结构可以更简洁地表达;差的模块则相反,每加一个功能都要小心翼翼地把新代码塞进旧结构的缝隙里,最后代码行数越来越多,各种开关和分支像藤蔓一样缠在一起。
我在写完一个功能后有个习惯:回看diff,把那些“加了之后发现没用”的部分主动清掉。删的时候如果有一种“这块本来就不该存在”的释然感,那说明设计方向对了。如果我发现怎么都删不动——每删一行就有另一行姿势怪异的地方站出来抗议,那就说明之前的设计走到了极端。
最后再分享一个小技巧
前阵子我把这个“记账式重构”用脚本量化了一下:通过Git历史统计每个模块过去半年的改动频次、平均每次提交改动的文件数,以及和需求描述无关的文件出现比例。用数据筛选出真正的“痛点模块”后,我发现它们无一例外都是当初被某个极端设计坑过的模块——要么全局变量满天飞,要么抽象层厚到没人敢碰。这个结果是意料之中的,当初凭感觉反复叮嘱“这块代码要小心”的模块,和后来数据指出的痛点高度重合。所以我现在带新人或者审视一个老系统时,第一件事不再是大谈原则,而是把Git统计拉出来,让数据告诉我是哪一个极端在拖后腿。
软件设计这件事,说到底不是一场“做得多还是做得少”的比拼,而是对真实变化方向的感知力。那些能把系统维持在工作状态的工程师,未必是用过最高级抽象的人,但一定是清楚知道自己写的每一行代码在当下解决了什么、在未来可能挡住什么的人。写代码时多问一句“这个复杂度花得值不值”,比背下任何架构原则都有用。
