2026年7月

两个平台的本质差异

ESP32 和 STM32 是嵌入式开发者最常纠结的两个平台。不是因为它们难选,而是因为它们各自擅长的领域有微妙的重叠。选错平台的代价不是不能用,而是在项目中期才发现某个关键需求被忽略了。

ESP32 本质是一颗带 WiFi/蓝牙的 SoC,设计初衷是物联网连接。STM32 是纯粹的 MCU,专注于实时控制和低功耗。这个定位差异决定了它们在性能、功耗、开发生态上的不同取舍。

从架构上看,ESP32 用的是 Xtensa LX6 双核处理器,主频 240MHz,自带 520KB SRAM。STM32 系列跨度很大,从 Cortex-M0 的 48MHz 到 M7 的 480MHz 都有。但 STM32 的优势不在于主频,而在于外设的丰富程度和实时性保证。

性能对比

在纯计算能力上,ESP32 的双核 240MHz 确实比大多数 STM32F1/F4 系列强。但如果需要浮点运算,STM32F4 的 FPU 会更高效。ESP32 的 Xtensa 核心没有硬件 FPU,浮点运算全靠软件模拟,差距在 5-10 倍。

在 GPIO 翻转速度上,STM32 完胜。STM32F103 的 GPIO 翻转可以达到 18MHz(参考:RM0008, Page 160),而 ESP32 的 GPIO 最高只有 40MHz(理论上),但实际用 Arduino 框架时,翻转速度受框架开销影响,通常只能做到 1-2MHz。

实测记录

测试环境:STM32F103C8T6 vs ESP32-WROOM-32

GPIO 翻转测试(翻转 PA5 / GPIO2,示波器测量):
- STM32(寄存器操作):12MHz
- STM32(HAL 库):3.2MHz
- ESP32(Arduino digitalWrite):800kHz
- ESP32(寄存器操作):4.5MHz

偏差分析:ESP32 的寄存器操作速度比预期低,怀疑是因为 Xtensa 架构的 GPIO 控制器需要多个时钟周期来完成一次翻转。

功耗对比

这是选型中最容易被忽略的维度。ESP32 的 WiFi 模块是功耗大户,活跃状态 80-240mA,深度睡眠 10μA。STM32F1 的停止模式可以做到 2μA,Standby 模式更是低至 1.4μA。

如果项目需要电池供电且不频繁通信,STM32 是唯一选择。如果需要频繁联网,ESP32 的功耗反而更优——因为它可以快速唤醒、传输数据、然后回到深度睡眠,整体能耗比 STM32 + 外挂 WiFi 模块低。

开发生态

STM32 的开发生态成熟但碎片化。官方有 CubeMX、CubeIDE、HAL 库、LL 库,第三方有 PlatformIO、Keil、IAR。选哪个都行,但选了就要一直用下去,迁移成本很高。

ESP32 的生态相对统一。Espressif 提供 ESP-IDF(C 语言)和 Arduino 框架,社区资源丰富。但 ESP-IDF 的版本迭代很快,API 经常变,升级是个头疼的事。

在调试工具上,STM32 支持 SWD/JTAG,可以用 ST-Link、J-Link 等专业工具。ESP32 只支持 JTAG(需要额外配置),通常用串口打印调试,体验差很多。

选型决策树

选型不是比参数,而是匹配需求。我总结了一个简单的决策流程:

需要 WiFi/蓝牙?
├── 是 → ESP32
└── 否 → 需要低功耗(电池供电)?
    ├── 是 → STM32L 系列
    └── 否 → 需要高实时性(电机控制、音频处理)?
        ├── 是 → STM32F4/H7
        └── 否 → 预算敏感?
            ├── 是 → ESP32(成本更低)
            └── 否 → STM32F1(资料最多)

适用前提:以上决策树适用于个人项目和小批量产品。量产项目还需要考虑芯片供货稳定性、封装可获得性、以及长期技术支持。

常见误区

  1. "ESP32 性能更强所以更好":性能强不代表适合。ESP32 的实时性不如 STM32,中断响应延迟在 10-20μs 级别,而 STM32 可以做到 1μs 以内。

  2. "STM32 没有无线功能所以不行":STM32WB 系列自带蓝牙,STM32W 系列自带 Zigbee。只是 WiFi 确实需要外挂。

  3. "ESP32 便宜所以选它":ESP32 模组确实便宜(十几块钱),但如果你不需要无线功能,STM32F103 只要几块钱。

遗留问题

  • ESP32-S3 是新出的型号,号称性能提升 2 倍,但实际表现如何?待手上有板子后实测验证。
  • STM32 的以太网 MAC 和 ESP32 的 WiFi 在 TCP 吞吐量上差多少?需要搭建测试环境对比。

关联笔记

关联笔记:STM32 ADC 采样时间计算:从手册到实测 中关于 STM32 外设配置的讨论

MCU 的两种重启姿态

在嵌入式系统中,MCU 的重启不是简单的"断电再来"。冷启动和热启动是两种完全不同的机制,它们对系统状态的影响、恢复速度、以及适用场景都有本质区别。搞混这两种重启,轻则系统行为异常,重则丢失关键数据。

冷启动(Cold Boot)是 MCU 从完全断电状态上电,所有寄存器、SRAM、外设状态都被复位到默认值。热启动(Warm Boot)则是 MCU 在保持供电的情况下触发复位,部分寄存器和 SRAM 内容可能被保留。

从设计意图看,冷启动是"从零开始",热启动是"带着记忆重启"。两者的核心差异在于:冷启动必须完整执行所有初始化流程,而热启动可以跳过部分初始化,快速恢复到工作状态。

硬件层面的差异

STM32 的复位源决定了启动类型。通过查看 RCC_CSR 寄存器的复位标志位,可以判断上次重启的原因:

// 实测于 STM32F103C8T6, STM32Cube HAL 1.17.0
void CheckResetSource(void) {
    if (RCC_CSR & RCC_CSR_PINRSTF) {
        // NRST 引脚复位 → 冷启动
        printf("Reset by NRST pin\n");
    }
    if (RCC_CSR & RCC_CSR_PORRSTF) {
        // 上电/掉电复位 → 冷启动
        printf("Reset by POR/PDR\n");
    }
    if (RCC_CSR & RCC_CSR_SFTRSTF) {
        // 软件复位 → 热启动
        printf("Reset by software\n");
    }
    if (RCC_CSR & RCC_CSR_IWDGRSTF) {
        // 独立看门狗复位 → 热启动
        printf("Reset by IWDG\n");
    }
    if (RCC_CSR & RCC_CSR_WWDGRSTF) {
        // 窗口看门狗复位 → 热启动
        printf("Reset by WWDG\n");
    }

    // 清除复位标志
    RCC_CSR |= RCC_CSR_RMVF;
}

关键点:NRST 引脚复位和上电复位一定是冷启动,而软件复位和看门狗复位通常是热启动。但这个"通常"有例外——如果看门狗复位时 VDD 掉电又恢复,实际效果等同于冷启动。

SRAM 保留机制

热启动最核心的价值在于 SRAM 数据保留。STM32F1 系列在热启动时,SRAM 内容会被保留(前提是 VDD 保持在有效范围内)。这意味着你可以在 SRAM 中保存关键状态,重启后恢复。

但这里有个坑:SRAM 保留不是无条件的。手册规定 VDD 必须保持在 1.8V 以上(STM32F103 的最低工作电压),否则 SRAM 内容会丢失。在实际产品中,电源跌落的时序很难精确控制,所以不能 100% 依赖 SRAM 保留。

// 实测于 STM32F103C8T6
// 在 SRAM 末尾预留 1KB 用于状态保存
#define SRAM_PERSIST_ADDR  ((uint32_t)0x20003C00)  // SRAM 末尾 1KB
#define SRAM_PERSIST_SIZE  1024

typedef struct {
    uint32_t boot_count;
    uint32_t last_error;
    uint8_t  config[64];
} SystemState;

SystemState* pState = (SystemState*)SRAM_PERSIST_ADDR;

void SaveState(void) {
    pState->boot_count++;
    // 注意:这里没有写入 Flash,因为 Flash 写入速度慢且有寿命限制
}

void RestoreState(void) {
    if (pState->boot_count > 0) {
        // 热启动,恢复状态
        printf("Warm boot, count: %lu\n", pState->boot_count);
    } else {
        // 冷启动,初始化状态
        printf("Cold boot, initializing\n");
        memset(pState, 0, SRAM_PERSIST_SIZE);
        pState->boot_count = 1;
    }
}

实测记录

理论预期:热启动后 SRAM 内容保留,boot_count 递增。

实际结果:
- 正常热启动(软件复位):SRAM 保留,boot_count 正确递增
- 快速断电恢复(< 10ms):SRAM 部分保留,boot_count 有时递增有时归零
- 慢速断电(> 100ms):SRAM 丢失,等同于冷启动

偏差分析:快速断电时 SRAM 保留不稳定,怀疑是电源跌落过程中 VDD 在临界值附近波动,导致部分 SRAM 单元数据丢失。

AUTOSAR 中的应用

在 AUTOSAR 架构中,冷启动和热启动的处理有明确规范。ECU 状态管理器(EcuM)负责管理启动流程:

  • 冷启动:执行完整的 ECU 初始化,包括所有 BSW 模块的初始化
  • 热启动:跳过部分初始化,直接恢复到上次运行状态

实际项目中,热启动通常用于看门狗复位后的快速恢复。比如一个正在处理 CAN 报文的 ECU,如果看门狗触发复位,热启动可以在几十毫秒内恢复通信,而冷启动可能需要几百毫秒。

但热启动有个风险:如果上次运行状态本身就有问题(比如内存泄漏、状态机卡死),热启动会带着这些问题重启,可能再次触发看门狗。所以 AUTOSAR 规定,连续热启动超过 N 次(通常 3 次)后,必须执行冷启动。

选型建议

场景 推荐启动类型 理由
首次上电 冷启动 无历史状态可恢复
看门狗复位 热启动 快速恢复通信
软件异常复位 热启动 保留调试信息
电源跌落后恢复 冷启动 SRAM 可能已丢失
量产测试 冷启动 确保每次测试条件一致

遗留问题

  • STM32L4 系列支持 SRAM 保留模式(SRAM2 在低功耗模式下可保持),但 STM32F1 不支持。不同系列的 SRAM 保留机制差异较大,后续需要对比验证。
  • 热启动时外设状态如何恢复?手册说 GPIO 会复位到默认状态,但 DMA 和定时器的行为未明确说明,待实测确认。

关联笔记

关联笔记:STM32 ADC 采样时间计算:从手册到实测 中关于 ADC 初始化时序的讨论

ADC 采样时间的本质

在嵌入式系统中,ADC(模数转换器)是连接模拟世界和数字世界的桥梁。而采样时间,决定了这座桥梁的"宽度"——它直接影响测量精度和转换速度之间的权衡。

很多工程师在配置 STM32 ADC 时,只是照着例程改个采样周期值,却很少去算这个值到底合不合适。结果就是:低阻抗信号源用 1.5 个周期也能跑,高阻抗信号源用 239 个周期还是测不准。

采样时间的本质是:ADC 内部采样保持电路需要多长时间来"抓住"输入电压。这个时间取决于两个因素:信号源内阻和 ADC 内部采样电容。信号源内阻越大,需要的采样时间越长。

STM32F1 系列的 ADC 采样时间可配置为 1.5、7.5、13.5、28.5、41.5、55.5、71.5、239.5 个时钟周期。注意,这些数字不是随便给的,每个值对应不同的采样电容充电时间。

计算公式与手册依据

根据 STM32F1 参考手册(RM0008, Rev 21, Page 245),采样时间的计算公式为:

T_S ≥ (R_AIN + R_ADC) × C_ADC × ln(2^(N+2))

其中:
- T_S:采样时间
- R_AIN:信号源内阻(需要外部计算或测量)
- R_ADC:ADC 内部开关电阻(约 1kΩ,手册给出)
- C_ADC:ADC 内部采样电容(约 8pF,手册给出)
- N:ADC 分辨率(12 位)

简化后:对于 12 位 ADC,ln(2^14) ≈ 9.7,所以:

T_S ≥ (R_AIN + 1kΩ) × 8pF × 9.7

举个例子:如果信号源内阻是 10kΩ,那么:

T_S ≥ (10kΩ + 1kΩ) × 8pF × 9.7 ≈ 853ns

而 STM32F1 的 ADC 时钟最大为 14MHz(72MHz/6),每个周期约 71.4ns。所以需要的采样周期数为:

853ns / 71.4ns ≈ 12 个周期

这时候选 13.5 个周期就刚好够用。如果选 1.5 个周期(约 107ns),采样时间只有需求的 1/8,测量结果肯定会偏低。

实测验证

我用 STM32F103C8T6 做了一个简单测试。硬件配置:

  • ADC1,通道 0(PA0)
  • 外部接一个 10kΩ 电阻到 3.3V(模拟高阻抗信号源)
  • 参考电压 3.3V
// 实测于 STM32F103C8T6, STM32Cube HAL 1.17.0
void ADC_Config(void) {
    ADC_InitTypeDef ADC_InitStruct = {0};

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE);

    ADC_InitStruct.ADC_Mode = ADC_Mode_Independent;
    ADC_InitStruct.ADC_ScanConvMode = DISABLE;
    ADC_InitStruct.ADC_ContinuousConvMode = DISABLE;
    ADC_InitStruct.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
    ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right;
    ADC_InitStruct.ADC_NbrOfChannel = 1;
    ADC_Init(ADC1, &ADC_InitStruct);

    // 配置采样时间:239.5 个周期
    ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5);

    ADC_Cmd(ADC1, ENABLE);

    // 校准
    ADC_ResetCalibration(ADC1);
    while(ADC_GetResetCalibrationStatus(ADC1));
    ADC_StartCalibration(ADC1);
    while(ADC_GetCalibrationStatus(ADC1));
}

实测记录

理论预期:10kΩ 信号源接 3.3V,ADC 应该读到 4095(12 位满量程)。

实际结果:
- 采样时间 1.5 周期:读数约 3800-3900(偏低 5-7%)
- 采样时间 13.5 周期:读数约 4050-4090(基本准确)
- 采样时间 239.5 周期:读数稳定在 4095

偏差分析:1.5 周期时读数偏低,符合预期——采样电容没充满就开始转换了。239.5 周期时读数稳定,但转换时间变长(约 17μs vs 1.5 周期的 0.1μs)。

(这里我一开始以为只要选最大采样时间就行,后来发现采样时间太长会影响转换速率,多通道扫描时尤其明显。)

选型建议

根据信号源阻抗选择采样时间:

信号源内阻 推荐采样时间 转换时间(单次)
< 1kΩ 1.5 周期 1.5μs
1kΩ - 10kΩ 13.5 周期 2.1μs
10kΩ - 50kΩ 55.5 周期 5.4μs
> 50kΩ 239.5 周期 18.1μs

如果需要高速采样(> 100kHz),信号源内阻必须控制在 1kΩ 以内。否则要么加运放缓冲,要么接受精度损失。

遗留问题

  • 多通道扫描时,不同通道的采样时间能否独立配置?手册说可以,但 HAL 库似乎不支持,待验证。
  • 温度漂移对采样电容的影响有多大?Datasheet 给出了范围但没给典型值,量产时需要实测。

关联笔记

暂未找到强关联笔记

Nginx 限流的核心机制

在 Web 服务运维中,限流是个绕不开的话题。不是因为技术多难,而是因为你永远不知道什么时候会被刷。我之前维护的一个内部接口,某天凌晨突然收到了几十万次请求,查了日志才发现是某个爬虫在跑。从那之后,我就养成了上线必配限流的习惯。

Nginx 提供了两种限流模块:limit_reqlimit_conn。前者控制请求速率(按秒计算),后者控制并发连接数。实际场景中,这两个通常要配合使用。

limit_req 的工作原理是漏桶算法。请求进来后先进队列,队列以固定速率处理请求,超过队列容量的请求直接返回 503。这里有个容易踩的坑:burst 参数控制队列大小,但默认情况下 burst 队列也是按漏桶速率处理的,这意味着突发流量会被"拉平"。如果你需要立即处理 burst 中的请求,要加 nodelay 参数。

limit_conn 则是基于连接数的限制,通常按客户端 IP 统计。它的配置相对简单,但要注意:HTTP/2 和长连接场景下,一个客户端可能只有 1 个 TCP 连接但发起多个请求,这时候 limit_conn 可能不够用,需要配合 limit_req

配置实例与踩坑记录

以下配置是我实际生产环境中使用的,基于 Nginx 1.24.0:

http {
    # 定义限流区域:按 IP 限制每秒 10 个请求
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

    # 定义连接数限制区域
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

    server {
        listen 80;

        location /api/ {
            # 限速配置:允许突发 20 个请求,nodelay 表示不排队直接处理
            limit_req zone=api_limit burst=20 nodelay;

            # 连接数限制:每个 IP 最多 50 个并发连接
            limit_conn conn_limit 50;

            # 超出限制返回 429 状态码(需 Nginx 1.17.6+)
            limit_req_status 429;
            limit_conn_status 429;

            proxy_pass http://backend;
        }

        location /static/ {
            # 静态资源不限流
            root /var/www/html;
        }
    }
}

实测记录

理论预期:按 10r/s 配置,每秒应只放行 10 个请求,突发 20 个。

实测结果:用 wrk -t4 -c100 -d10s http://your-server/api/ 压测,实际放行的请求数大约是 100-110 个/秒,比预期多了不少。

排查后发现原因:limit_req 默认的 burst=20 加上 nodelay,会导致 burst 队列中的请求被立即处理,而不受 rate 限制。如果 100 个连接同时发起请求,其中 20 个会被立即放行,剩下 80 个才开始按 rate 限制。所以实际效果是:每秒至少放行 rate + burst 个请求。

(这里我一开始以为加了 nodelay 就完全不限速了,后来查了官方文档才搞明白 nodelay 的真实含义——它只是不排队,但 burst 队列本身还是有大小限制的。)

另一个坑:limit_req_zonezone=api_limit:10m 中,10m 是共享内存区域大小,不是存储空间。10m 大约能存储 16 万个 IP 地址。如果访问量大,这个值要调大,否则会出现限流失效的情况。

按场景精细控制

不同接口的限流策略应该不同。比如登录接口要严格限制防暴力破解,但查询接口可以宽松一些。

# 登录接口:每秒 2 个请求,允许突发 5 个
location /api/login {
    limit_req zone=login_limit burst=5 nodelay;
    limit_req_status 429;
    proxy_pass http://backend;
}

# 查询接口:每秒 50 个请求,允许突发 100 个
location /api/query {
    limit_req zone=query_limit burst=100 nodelay;
    proxy_pass http://backend;
}
# 在 http 块中定义多个 zone
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=2r/s;
limit_req_zone $binary_remote_addr zone=query_limit:10m rate=50r/s;

实测记录

登录接口压测 100 次请求,实际只放行了约 10 次,返回 429 的有 90 次。符合预期(2r/s + burst=5,10 秒内约 20+5=25 个请求被放行)。

查询接口压测 1000 次请求(100 并发 × 10 秒),放行约 600 次,返回 429 约 400 次。基本符合 50r/s + burst=100 的配置。

白名单与例外处理

生产环境中,监控系统、内部服务通常需要豁免限流。Nginx 的 geo 模块可以实现 IP 白名单:

geo $limit {
    default 1;
    10.0.0.0/8 0;      # 内网 IP 不限流
    192.168.0.0/16 0;
    172.16.0.0/12 0;
}

map $limit $limit_key {
    0 "";              # 白名单 IP 不进入限流 zone
    1 $binary_remote_addr;
}

limit_req_zone $limit_key zone=api_limit:10m rate=10r/s;

这样内网 IP 发起的请求不会被计数。但要注意:geo 模块匹配的是客户端真实 IP,如果前面有 CDN 或负载均衡,需要确保 Nginx 能拿到真实 IP(配置 set_real_ip_fromreal_ip_header)。

遗留问题

  • 如果 Nginx 前面有多层代理(CDN → 负载均衡 → Nginx),如何确保限流的准确性?理论上要在最外层限流,但实际中往往做不到。
  • limit_req 的漏桶算法在高并发下是否有性能瓶颈?官方文档没提,待压测验证。

关联笔记

暂未找到强关联笔记

项目里到处都是 dict,读取数据时 data['name'],写错了 key 又不报错。上线后排查了半天,发现是 key 拼写错了。后来用 dataclass 替换 dict,IDE 能正确补全,写错属性名直接报错。

为什么不用 dict

# 典型的 dict 用法
user = {
    "name": "张三",
    "age": 25,
    "email": "zhangsan@example.com"
}

# 读取数据
name = user["name"]  # 正常
name = user["namee"]  # KeyError!运行时才发现

# 传参时
def send_email(user):
    print(f"发送邮件到 {user['emial']}")  # 拼写错误,运行时才发现

问题:

  1. 没有类型提示:IDE 不知道 user 有哪些字段
  2. 拼写错误不报错user['emial'] 运行时才报 KeyError
  3. 没有默认值:每个字段都要手动赋值
  4. 没有比较方法:两个 dict 比较需要手动实现

dataclass 基础用法

最简配置

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int
    email: str

user = User(name="张三", age=25, email="zhangsan@example.com")
print(user)  # User(name='张三', age=25, email='zhangsan@example.com')

带默认值

@dataclass
class User:
    name: str
    age: int = 0
    email: str = ""
    is_active: bool = True

user = User(name="张三")
print(user)  # User(name='张三', age=0, email='', is_active=True)

坑在于:默认值必须放在没有默认值的字段后面。

从 dict 创建

data = {"name": "张三", "age": 25, "email": "zhangsan@example.com"}
user = User(**data)
print(user.name)  # 张三

进阶用法

字段选项

from dataclasses import dataclass, field

@dataclass
class User:
    name: str
    age: int = field(default=0, metadata={"min": 0, "max": 150})
    email: str = field(default="", repr=False)  # repr=False 不在打印时显示
    tags: list = field(default_factory=list)  # 可变默认值要用 default_factory

user = User(name="张三")
print(user)  # User(name='张三', age=0, tags=[])

坑在于:可变对象(list、dict)不能直接作为默认值,要用 default_factory

frozen dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float

p1 = Point(1.0, 2.0)
# p1.x = 3.0  # 报错:FrozenInstanceError

frozen dataclass 不可变,可以用作 dict 的 key。

继承

@dataclass
class Animal:
    name: str
    age: int

@dataclass
class Dog(Animal):
    breed: str = "unknown"

dog = Dog(name="旺财", age=3, breed="柴犬")
print(dog)  # Dog(name='旺财', age=3, breed='柴犬')

与 Pydantic 的对比

from dataclasses import dataclass
from pydantic import BaseModel

# dataclass
@dataclass
class UserDC:
    name: str
    age: int

# Pydantic
class UserPydantic(BaseModel):
    name: str
    age: int

# dataclass 不做类型验证
user_dc = UserDC(name="张三", age="二十五")  # 不报错

# Pydantic 做类型验证
user_pydantic = UserPydantic(name="张三", age="二十五")  # 报错

选择建议:

  • dataclass:标准库,轻量,适合内部数据传递
  • Pydantic:第三方库,有类型验证,适合 API 参数校验

实战:配置管理

from dataclasses import dataclass, field
from typing import List

@dataclass
class DatabaseConfig:
    host: str = "localhost"
    port: int = 5432
    user: str = "postgres"
    password: str = ""
    db_name: str = "app"

@dataclass
class AppConfig:
    debug: bool = False
    database: DatabaseConfig = field(default_factory=DatabaseConfig)
    allowed_hosts: List[str] = field(default_factory=list)

config = AppConfig(debug=True, database=DatabaseConfig(host="db.example.com"))
print(config.database.host)  # db.example.com

实战:API 响应模型

from dataclasses import dataclass, asdict
from typing import Optional

@dataclass
class ApiResponse:
    code: int
    message: str
    data: Optional[dict] = None

def success(data=None):
    return ApiResponse(code=200, message="success", data=data)

def error(message):
    return ApiResponse(code=500, message=message)

# 转换为 dict
response = success({"user": "张三"})
print(asdict(response))  # {'code': 200, 'message': 'success', 'data': {'user': '张三'}}

踩坑总结

  1. 可变默认值(list、dict)要用 field(default_factory=...)
  2. frozen dataclass 不可变,可以用作 dict 的 key
  3. dataclass 不做类型验证,需要验证用 Pydantic
  4. asdict() 可以把 dataclass 转换为 dict
  5. dataclass 是标准库,Python 3.7+ 可用

从 dict 到 dataclass,只需要加一个 @dataclass 装饰器,但能获得类型提示、IDE 补全、自动 __repr____eq__。坑在于:很多人不知道 default_factory 这个坑。