Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.7 文档即代码——架构文档与详细设计自动生成

凌晨两点的电话

凌晨两点,你被一通紧急电话叫醒。生产线上的ECU突然出现了异常行为:某个CAN报文的数据格式对不上。你翻出三年前编写的《软件详细设计说明书》,对照着检查代码,发现文档里描述的信号偏移量和代码里的宏定义差了整整3个字节。你不知道该信文档,还是该信代码。

同样的事情,建筑行业从不发生。一位建筑师不会在房子盖完之后,再坐到办公室里写一份《建筑物详细描述报告》。为什么?因为建筑图纸本身就是精确的设计描述:施工人员拿着同一套图纸放线、绑钢筋、浇筑混凝土。图纸变了,房子就变了;房子变了,图纸也必须更新。没有“图纸-建筑不一致“这种问题的生存空间。

软件行业偏偏发明了“文档和代码分离“这种反模式,然后用无尽的人力去弥合两者之间的裂缝。本章要回答的核心问题是:如果你的设计文档不能直接编译、不能自动验证、不能在代码变更时同步更新,它就不是文档,它是债务。

核心洞察:“文档和代码分离“是软件行业发明的反模式,建筑从不发生这种不一致。 图纸是施工的输入,房子变了图纸必须更新,没有“图纸-建筑不一致“的生存空间。设计文档若不能编译、不能自动验证、不能在代码变更时同步更新,它就不是文档,是债务。


一、两份真相:文档地狱的真实代价

在汽车电子领域,“文档≠代码“的成本比一般软件行业高出两个数量级。

1.1 ASPICE的双重枷锁

ASPICE v3.1中,工程过程域明确区分了SWE.2(软件架构设计)和SWE.3(软件详细设计)两个过程。标准要求:

  • 架构设计文档描述静态结构和动态行为
  • 详细设计文档细化到每个函数、每个接口
  • 两者之间必须建立双向追溯关系

问题来了:在传统工作流中,这些文档是手工编写和手工维护的Word文档。当架构师画完UML图、写完接口描述后,工程师开始编码。代码在迭代中不断偏离原始设计:为了性能内联了一个函数,为了内存优化合并了两个模块,为了修复Bug增加了一个参数。Word文档还停留在1.3版本。

过了三个月,这些Word文档的价值为负:它会误导下一个人,让人以为代码还按文档描述的方式运行。

1.2 建筑史的镜子:从口述到蓝图

中世纪建造大教堂时,没有现代意义上的“图纸“。工匠靠在现场口耳相授,一边盖一边改。结果呢?科隆大教堂从1248年盖到1880年,跨越632年。不是因为没有决心,是因为没有精确的、可传递的、不依赖特定个体的设计载体。

现代建筑的一切都建立在图纸之上。蓝图、施工图、节点大样,每一张都具有法律效力。没有图纸,监理不能验筋;没有图纸,施工不能进展。图纸就是建筑的“源代码“。

软件的“图纸“是什么?如果你的回答是“Word文档加Visio图“,请重读本节标题:你还在用口耳相授的方式盖大教堂。

核心洞察:手工维护的 Word 文档在代码迭代三个月后价值为负。 ASPICE 强制 SWE.2/SWE.3 双向追溯,可传统工作流里代码不断偏离设计,文档却停在 1.3 版本,最终误导下一个维护者。中世纪大教堂盖了 632 年,因为没有精确、可传递、不依赖个体的设计载体——用 Word 当“图纸“,就是在盖另一座大教堂。


二、代码即文档:从注释提取设计意图

如果你认同“图纸就是设计描述“,那么在软件世界里,源代码加注释就应该能产出架构文档。

2.1 Doxygen:嵌入式C的“图纸生成器“

在Vector AUTOSAR项目里,每个模块的注释都遵循严格的Doxygen格式:

/**
 * @brief   初始化CommM模块
 * @param   u8ChannelId  通信通道ID,范围0~COMM_MAX_CHANNEL_COUNT-1
 * @return  E_OK: 初始化成功
 *          E_NOT_OK: 通道ID无效或模块已初始化
 * @note    此函数必须在EcuM_InitMemory()之后、BswM_Init()之前调用
 * @pre     全局变量g_CommM_State_b必须为COMM_UNINIT
 */
FUNC(Std_ReturnType, COMM_CODE) CommM_Init(uint8 u8ChannelId)

这段注释不是写给Doxygen看的,它是写给两年后凌晨两点紧急排查故障的你看的。但Doxygen把它变成了一套可发布的HTML页面,自带模块依赖图、调用关系图、数据流图。

关键点在于:注释和代码在同一个文件里。工程师改了函数签名,如果忘了改@param注释,Doxygen生成的文档里参数对不上。CI流水线可以配置为Doxygen的WARNING转ERROR,让“文档不一致“直接阻断构建。

这就是建筑的逻辑:图纸错了,施工就得停下来。你的CI流水线也应该停下来。

2.2 头文件即接口契约

在汽车嵌入式开发中,.h文件天然就是“模块间接口定义文档“:

/**
 * @brief   CanTp模块对外接口定义
 *
 * 本文件定义了CanTp(CAN传输层协议)的所有外部接口。
 * 上层模块(PduR)通过此接口调度CAN报文段的发送和接收。
 * 下层模块(CanIf)通过此接口回调确认发送结果。
 *
 * @startuml{CanTp_Interface.svg}
 * interface CanTp {
 *   + Std_ReturnType CanTp_Transmit(PduIdType, PduInfoType*)
 *   + void CanTp_MainFunction()
 *   + void CanTp_RxIndication(PduIdType, PduInfoType*)
 *   + void CanTp_TxConfirmation(PduIdType)
 * }
 * @enduml
 */

PlantUML的@startuml指令嵌入在头文件注释中。CI流水线运行PlantUML渲染引擎,自动生成CanTp_Interface.svg。这张图永远和代码描述同一个接口,因为它是从代码生成的。

你再也不需要手动对齐Word文档里的接口图和代码里的函数声明了。再也不需要了。

2.3 Sphinx+RST:把设计文档放在代码仓库里

如果说Doxygen解决的是“API参考手册“的问题,Sphinx解决的是“设计决策说明“的问题。

在项目根目录的docs/文件夹下,存储着reStructuredText格式的设计文档:

Swc_OemComm模块设计
====================

.. doxygenfile:: Swc_OemComm.h
   :sections: briefdescription detaileddescription

模块职责
--------
Swc_OemComm负责将CanIf接收到的车辆状态信号转换成ComM可以理解的通信请求
状态。本模块不包含业务逻辑,它本质是一个**信号路由层**。

设计决策
--------
为什么要用轮询而非中断触发?
- 中断优先级在ZynqMP上由XScuGic管理
- ComM的状态机要求在每次MainFunction中同步处理
- 采用轮询避免了中断嵌套导致的任务延迟抖动(见`vMeas-003`的抖动测量数据)

接口约定
--------
本模块仅对ComM暴露初始化接口。所有接收端的绑定通过RTE静态配置完成。

这份文档同时包含了:

  1. Doxygen自动提取的源代码结构(静态分析,100%准确)
  2. 工程师手工撰写的设计决策(为什么这样做、为什么不那样做)

两者都存放在同一个Git仓库里。当代码变更引发Merge Request时,如果代码改动影响了文档描述的设计决策,评审者可以直接在MR里标注:“文档第42行描述的是轮询模式,你的PR改成了中断模式但文档未更新”。这和代码评审的体验完全一致,因为文档就是代码的一部分。

核心洞察:注释和代码在同一个文件里,“文档不一致“才能被 CI 阻断。 Doxygen 把注释变成可发布文档,@param 忘了改,WARNING 转 ERROR 直接阻断构建——图纸错了施工就得停。头文件是天然接口契约,PlantUML 图从代码生成永远同步;Sphinx 把设计决策放进 Git 仓库,纳入评审与版本控制,MR 里可以直接标注“文档第 42 行与你的 PR 矛盾”。


三、从代码生成设计图:让架构可视但不脱离源码

3.1 PlantUML + 源码注解 = 活着的序列图

考虑一个CAN唤醒流程。传统做法:在EA里画一张时序图,导出PNG,粘贴到Word里,在评审会上投影,大家对着它讨论。

现代做法:

/**
 * @startuml{CanWakeup_Sequence.svg}
 * participant CanIf
 * participant EcuM
 * participant ComM
 * participant CanSm
 *
 * CanIf -> EcuM : EcuM_CheckWakeup(can_channel)
 * activate EcuM
 * EcuM -> ComM : ComM_EcuM_WakeUpIndication(Network)
 * activate ComM
 * ComM -> CanSm : CanSm_StartWakeup(Channel)
 * deactivate ComM
 * @enduml
 */
FUNC(void, CANIF_CODE) CanIf_RxIndication_wakeup(PduIdType id, ...)

这张时序图从函数注释中生成。这意味着:

  • 图描述的是“这段代码在做什么“而不是“设计师设想了什么“
  • 代码重构时注释跟着动,图也跟着更新
  • 评审时可以对照代码和时序图一起看,因为它们在同一个屏幕的同一个文件里

3.2 模块依赖图的自动维护

大型AUTOSAR项目动辄上百个模块。模块间依赖关系一旦画成静态图,下个Sprint就会过时。

解决之道是让构建系统自己画。在CMake/Makefile构建过程中添加一个步骤:

# 提取所有.o文件的未定义符号(即本模块对外的依赖),生成GraphViz依赖图
# 教学简化:真实项目会先nm列全部符号、再交叉引用定义与引用关系
module-deps:
	for obj in $(OBJ_DIR)/*.o; do \
		nm -u $$obj | sed "s/^/$$(basename $$obj): /"; \
	done | sort -u > module_deps.txt
	dot -Tpng module_deps.txt -o module_deps.png

每一次构建都产出一张最新的模块依赖图。如果发现依赖图中出现了不该有的循环(比如BswM引用了SwcOemComm),CI流水线告警:这不只是文档自动更新,这是架构合规性的自动化检查。

核心洞察:让架构图从源码生成,它才可能是“活的“。 时序图从函数注释中生成,代码重构时图跟着更新;模块依赖图由构建系统每次构建时提取符号自动画出,出现循环依赖时 CI 直接告警——这已经是架构合规性的自动化检查,而不只是文档更新。图的真正价值不在美观,而在“从哪儿生成的“:它和代码有物理连接。


四、建筑隐喻:永远新鲜的蓝图

想象两栋楼:

甲楼:设计图纸锁在档案室的铁柜里。施工时工人凭经验干,项目经理口头指挥。竣工验收后,设计师花三个月补了一套“竣工图“,但谁也不知道图上的梁号和实际的梁号是不是一根梁。

乙楼:设计图纸是BIM模型的一部分。任何一个构件改了,BIM模型实时更新。监理用一个平板打开模型,对着现场钢筋拍照比对:“第3轴线的箍筋间距和模型不一致,请整改”。

你的ECU软件是哪栋楼?如果你的架构文档是一份Word文件,放在SharePoint的某个文件夹里,只有上次过ASPICE审核时被打开过,你是甲栋楼的工头。如果你的Doxygen+PlantUML+Sphinx从CI流水线里自动生成,一份PDF随每一次Release产出,你是乙栋楼的设计师。

“文档即代码“不只是一个口号。它的核心工程逻辑是:

文档的唯一权威来源必须是代码。任何从代码以外推导出的“设计描述“,其准确性的维持成本趋近于无限大。

核心洞察:文档的唯一权威来源必须是代码,否则其准确性维持成本趋近于无穷。 甲楼的竣工图是设计师三个月后补的,没人知道梁号对不对;乙楼的 BIM 模型实时更新,监理现场对图整改。你的 ECU 是哪栋楼?如果 Doxygen+PlantUML+Sphinx 从 CI 流水线自动生成、随 Release 产出,图纸才永远新鲜。


本篇小结

  • 文档即代码的工程逻辑:从“代码和文档两张皮“这个汽车电子行业的典型痛点出发,用建筑行业“图纸即施工依据“的隐喻,论证了“文档即代码“的工程逻辑。
  • Doxygen + 头文件注释:让API参考手册从源码自动生成,CI流水线检查注释完整性。
  • Sphinx + RST设计文档:将设计决策说明放入代码仓库,纳入版本控制和评审流程。
  • PlantUML嵌入式绘图:时序图和接口图从函数注释中生成,与代码保持物理同步。
  • 构建系统自动依赖分析:模块依赖图随每次构建更新,架构违规自动告警。
  • 用代码承载设计信息:汽车嵌入式软件的文档工程,应该在整个开发周期中,用代码本身承载和产出所有设计信息:从接口约定到设计决策,从时序逻辑到模块依赖。
  • 文档不同步的软件是债务:蓝图不更新的楼是危楼,文档不同步的软件是债务。

【下集预告】:如果你接受了“文档即代码“的理念,那么下一个问题自然浮现:代码仓库里哪些东西应该被版本控制、哪些应该被分离出去?当你把标定参数从代码中抽离、单独存储、在线修改时,你实际上在做一件建筑世界里天天发生的事:把“结构“和“配置“分开。地震来了不要紧,房子结构能扛住;室温高了调一下,空调设定改一个数字。你的ECU,应该怎么分?