15、状态机代码生成:基于表格的代码生成、模板引擎使用、自动化脚本

代码生成这件事,说白了就是「把设计稿变成能跑的代码」。我早期做嵌入式项目时,状态机都是手写的——switch-case 一把梭,改一个状态就要翻半天代码。后来项目规模上来了,状态数超过 20 个,手写真的扛不住。于是我开始琢磨:能不能用表格定义状态机,然后自动生成代码?

这一章,我就把这几年的实践经验拆开来讲。你会看到三种主流方式:基于表格的代码生成、模板引擎的使用、以及自动化脚本的整合。嗯,咱们一个一个来。

15.1 基于表格的代码生成

表格是最直观的状态机描述方式。你想想看,状态、事件、动作、下一个状态——这些信息天然适合放在表格里。我习惯用 CSV 或 Excel 来维护这个表格,然后写个脚本把它转成 C 代码。

先看一个典型的状态转移表:

当前状态 事件 动作 下一个状态
IDLE EVENT_START start_motor() RUNNING
RUNNING EVENT_STOP stop_motor() IDLE
RUNNING EVENT_ERROR log_error() ERROR
ERROR EVENT_RESET clear_error() IDLE

这个表格,就是状态机的「源代码」。我写了一个 Python 脚本,读取这个 CSV,然后生成对应的 C 代码。核心逻辑是这样的:

# 简化版代码生成脚本
import csv

def generate_state_machine(csv_file):
    with open(csv_file, 'r') as f:
        reader = csv.DictReader(f)
        transitions = list(reader)
    
    # 生成状态枚举
    states = set()
    for t in transitions:
        states.add(t['当前状态'])
        states.add(t['下一个状态'])
    
    print('typedef enum {')
    for s in sorted(states):
        print(f'    STATE_{s},')
    print('} state_t;')
    
    # 生成事件枚举
    events = set()
    for t in transitions:
        events.add(t['事件'])
    
    print('\ntypedef enum {')
    for e in sorted(events):
        print(f'    {e},')
    print('} event_t;')
    
    # 生成状态转移表
    print('\nstate_t state_table[][MAX_EVENTS] = {')
    for s in sorted(states):
        print(f'    [STATE_{s}] = {{')
        for e in sorted(events):
            next_state = 'STATE_' + s  # 默认保持当前状态
            for t in transitions:
                if t['当前状态'] == s and t['事件'] == e:
                    next_state = 'STATE_' + t['下一个状态']
                    break
            print(f'        [{e}] = {next_state},')
        print('    },')
    print('};')

这段脚本跑完之后,会生成一个完整的 C 头文件。你只需要在代码里包含它,然后调用状态机引擎就行了。我在一个工业控制项目里用过这个方案,状态表有 40 多行,生成代码只用了 0.1 秒——比手写快太多了。

核心思路:表格是「数据」,代码生成器是「引擎」。数据与逻辑分离,改状态不用改代码,改表格就行。

15.2 模板引擎的使用

直接 print 代码的方式,说实话有点「糙」。代码结构稍微复杂一点,print 就变得难以维护。这时候就该模板引擎上场了。

我个人比较喜欢 Jinja2,它是 Python 生态里最成熟的模板引擎之一。你可以把 C 代码的骨架写成模板,然后把状态机数据填进去。这样代码生成器和模板是分离的,改模板比改 print 语句舒服多了。

看一个 Jinja2 模板的例子:

// state_machine_template.h.j2
#ifndef STATE_MACHINE_H
#define STATE_MACHINE_H

typedef enum {
{% for state in states %}
    STATE_{{ state.name }}{% if not loop.last %},{% endif %}
{% endfor %}
} state_t;

typedef enum {
{% for event in events %}
    {{ event.name }}{% if not loop.last %},{% endif %}
{% endfor %}
} event_t;

extern state_t current_state;

state_t state_transition(state_t current, event_t event);

#endif

对应的 Python 代码就简洁多了:

from jinja2 import Environment, FileSystemLoader
import csv

def generate_with_template(csv_file, template_file, output_file):
    # 读取 CSV 数据
    with open(csv_file, 'r') as f:
        reader = csv.DictReader(f)
        transitions = list(reader)
    
    # 提取状态和事件
    states = [{'name': s} for s in sorted(set(
        [t['当前状态'] for t in transitions] + 
        [t['下一个状态'] for t in transitions]
    ))]
    events = [{'name': e} for e in sorted(set(
        [t['事件'] for t in transitions]
    ))]
    
    # 渲染模板
    env = Environment(loader=FileSystemLoader('.'))
    template = env.get_template(template_file)
    output = template.render(states=states, events=events)
    
    with open(output_file, 'w') as f:
        f.write(output)

这样做的好处很明显:模板文件就是 C 代码的样子,你一眼就能看出生成结果长什么样。我曾经在一个团队里推广这个方案,新同事上手特别快——他们不需要懂 Python,只需要会改模板和 CSV 就行。

小技巧:模板里可以加注释,标注「这段是自动生成的,不要手动修改」。我吃过这个亏——有人改了生成的文件,下次重新生成时改动就丢了。

15.3 自动化脚本

有了表格和模板,下一步就是把整个流程串起来。我习惯写一个 Makefile 或者 shell 脚本,一键完成「读取表格 → 生成代码 → 编译验证」的全流程。

一个典型的自动化脚本长这样:

#!/bin/bash
# generate_state_machine.sh

CSV_FILE="state_machine.csv"
TEMPLATE_DIR="./templates"
OUTPUT_DIR="./generated"

echo "=== 状态机代码生成器 ==="

# 1. 检查输入文件
if [ ! -f "$CSV_FILE" ]; then
    echo "错误: 找不到 $CSV_FILE"
    exit 1
fi

# 2. 创建输出目录
mkdir -p $OUTPUT_DIR

# 3. 生成头文件
echo "生成状态机头文件..."
python3 generate.py \
    --csv $CSV_FILE \
    --template $TEMPLATE_DIR/header.h.j2 \
    --output $OUTPUT_DIR/state_machine.h

# 4. 生成实现文件
echo "生成状态机实现文件..."
python3 generate.py \
    --csv $CSV_FILE \
    --template $TEMPLATE_DIR/impl.c.j2 \
    --output $OUTPUT_DIR/state_machine.c

# 5. 编译验证
echo "编译验证..."
gcc -Wall -Wextra -o test_state_machine \
    $OUTPUT_DIR/state_machine.c \
    main.c \
    -I$OUTPUT_DIR

if [ $? -eq 0 ]; then
    echo "编译成功!"
else
    echo "编译失败,请检查模板或表格数据。"
    exit 1
fi

echo "=== 完成 ==="

这个脚本我用了好几年,每次改完状态表,跑一遍脚本就能拿到可编译的代码。如果编译失败,脚本会直接报错,不会让你带着错误的代码去调试。

注意:自动化脚本一定要做「输入校验」。我曾经遇到过 CSV 里多了一个空行,导致生成的状态枚举少了一个逗号,编译报错找了半天。后来我在脚本里加了数据校验步骤,提前检查表格格式。

15.4 整体流程与架构

把上面三部分串起来,整个代码生成的架构就清晰了。我画了一张图,帮你理解数据是怎么流动的:

状态机代码生成架构图 CSV / Excel 状态转移表(数据) Jinja2 模板 C 代码骨架(模板) 代码生成器 Python 脚本 生成的 C 代码 state_machine.h / .c 编译验证 gcc -Wall -Wextra 自动化脚本 (generate_state_machine.sh) 失败 → 修改表格或模板 ✅ 生成成功 → 可直接用于嵌入式项目

从这张图你能看到,整个流程是闭环的。数据从 CSV 流入,经过模板渲染,生成 C 代码,然后编译验证。如果编译失败,就回去改表格或模板,再跑一遍。这个循环跑顺了,状态机的维护成本会大幅降低。

15.5 避坑指南

最后分享几个我踩过的坑:

  • 表格格式要严格:我曾经在 CSV 里用了中文逗号,结果脚本解析出错。建议在脚本里加一个格式校验函数,提前发现这类问题。
  • 模板不要写死路径:用相对路径 + 配置文件的方式,方便不同项目复用同一套模板。
  • 生成的文件要加版本号:我习惯在生成的文件头部加一行注释,记录生成时间和表格的 MD5 值。这样出了问题能快速定位是哪个版本生成的。
  • 不要手动修改生成的文件:这是铁律。如果非要改,就去改模板或表格,然后重新生成。手动修改的结果就是「下次生成时你的改动就丢了」。

好了,这一章的内容就到这里。代码生成这件事,说白了就是「把重复劳动交给机器」。你花一天时间搭好生成框架,后面每次改状态表只需要几秒钟——这笔账怎么算都划算。


公众号:蓝海资料掘金营,微信deep3321